Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(aih): adds posts api and oauth device flow #367

Merged
merged 13 commits into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"cSpell.words": ["coursebuilder"]
"cSpell.words": ["coursebuilder"],
"vitest.disableWorkspaceWarning": true
}
184 changes: 184 additions & 0 deletions apps/ai-hero/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,187 @@ $ pnpm db:push
3. Navigate to `https://local.drizzle.studio`
2. Via Drizzle Studio (other flows are possible if you'd prefer, but this is the one we're documenting) find your new `user` record and change the `role` to `admin`. Make sure to save/apply the change.
3. When you visit `/tips`, you'll see the form for creating a new Tip.

## API Documentation

AI-Hero exposes several REST APIs for external integrations. All endpoints require proper authentication using OAuth 2.0 device flow.

### Authentication

The platform implements OAuth 2.0 device flow (RFC 8628) for secure authentication. Here's a working Node.js example using `openid-client`:

```typescript
import * as client from 'openid-client'

const ISSUER = 'https://www.aihero.dev/oauth'

async function registerDevice() {
try {
const config = await client.discovery(new URL(ISSUER), 'ai-hero')
const deviceResponse = await client.initiateDeviceAuthorization(config, {})

console.log('deviceResponse', deviceResponse)

const timeout = setTimeout(() => {
throw new Error('Device authorization timed out')
}, deviceResponse.expires_in * 1000)

try {
const tokenSet = await client.pollDeviceAuthorizationGrant(
config,
deviceResponse,
)
clearTimeout(timeout)

if (!tokenSet) {
console.log('AUTH_REJECTED, no token set')
return
}

const protectedResourceResponse = await client.fetchProtectedResource(
config,
tokenSet.access_token,
new URL(`${ISSUER}/userinfo`),
'GET',
)
const userinfo = await protectedResourceResponse.json()

console.dir({ tokenSet, userinfo }, { depth: null })
console.log('AUTH_RESOLVED')
} catch (error) {
clearTimeout(timeout)
throw error
}
} catch (error) {
console.log('error', error)
console.log('AUTH_REJECTED')
}
}

await registerDevice()
```

The flow includes:
1. Discovery of OAuth endpoints
2. Device authorization initiation
3. Polling for user approval
4. Token exchange
5. Protected resource access

Available endpoints:

```
POST /oauth/device/code
- Generates device verification codes
- Returns: { user_code, device_code, verification_uri, expires_in }

POST /oauth/token
- Exchanges device code for access token
- Supports device flow grant type
- Returns: { access_token, token_type, expires_in }

GET /oauth/userinfo
- Returns authenticated user information
- Requires valid access token
```

### Content Management

#### Posts API

```
GET /api/posts
- Lists all accessible posts
- Supports pagination and filtering
- Returns: { posts: Post[], totalCount, pageCount }

POST /api/posts
- Creates new post
- Body: { title, content, status, ... }
- Returns: Created Post

GET /api/posts/:id
- Retrieves specific post
- Returns: Post with version history

PUT /api/posts/:id
- Updates existing post
- Body: { title, content, status, ... }
- Returns: Updated Post

DELETE /api/posts/:id
- Deletes post
- Requires appropriate permissions
```

#### Video Resources

```
GET /api/videos/:videoResourceId
- Retrieves video resource metadata
- Returns: { id, title, status, transcription, ... }

PUT /api/videos/:videoResourceId
- Updates video resource metadata
- Body: { title, description, ... }
- Returns: Updated VideoResource

DELETE /api/videos/:videoResourceId
- Deletes video resource
- Cascades through related resources
```

### File Upload System

The platform uses direct-to-S3 uploads for efficient file handling:

```
POST /api/uploads/signed-url
- Generates pre-signed S3 URL for direct upload
- Body: { filename, contentType, ... }
- Returns: { uploadUrl, key }

POST /api/uploads/new
- Initializes new upload
- Body: { filename, size, ... }
- Returns: { id, status, uploadUrl }
```

### Best Practices

1. **Rate Limiting**: Implement appropriate backoff strategies
2. **Error Handling**: All endpoints return standard HTTP status codes
3. **Authentication**: Always include Bearer token in Authorization header
4. **Versioning**: API versions are embedded in the URL path
5. **Content Types**: All requests should use application/json

### Example Integration

```typescript
const AI_HERO_BASE = 'https://ai-hero.example.com'

// Initialize device flow
const deviceCode = await fetch(`${AI_HERO_BASE}/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})

// Exchange for token
const token = await fetch(`${AI_HERO_BASE}/oauth/token`, {
method: 'POST',
body: JSON.stringify({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: deviceCode.device_code
})
})

// Make authenticated requests
const posts = await fetch(`${AI_HERO_BASE}/api/posts`, {
headers: {
'Authorization': `Bearer ${token.access_token}`,
'Content-Type': 'application/json'
}
})
```

For detailed integration examples and SDKs, contact the AI-Hero team.
2 changes: 2 additions & 0 deletions apps/ai-hero/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"framer-motion": "^12.0.0-alpha.1",
"groq": "^3.18.1",
"holy-loader": "^2.2.10",
"human-readable-ids": "^1.0.4",
"i": "^0.3.7",
"import-in-the-middle": "^1.11.2",
"inngest": "^3.22.5",
Expand Down Expand Up @@ -158,6 +159,7 @@
"react-stately": "^3.33.0",
"react-use": "^17.5.0",
"react-wrap-balancer": "^0.2.4",
"reading-time": "^1.5.0",
"rehype-raw": "^7.0.0",
"rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.0",
Expand Down
1 change: 1 addition & 0 deletions apps/ai-hero/src/@types/human-readable-ids.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module 'human-readable-ids'
24 changes: 1 addition & 23 deletions apps/ai-hero/src/ability/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,29 +61,7 @@ export type AppAbility = MongoAbility<[Actions, Subjects]>
export const createAppAbility = createMongoAbility as CreateAbility<AppAbility>

type GetAbilityOptions = {
user?: {
id: string
role?: string
roles: {
id: string
name: string
description: string | null
active: boolean
createdAt: Date | null
updatedAt: Date | null
deletedAt: Date | null
}[]
organizationRoles?: {
organizationId: string | null
id: string
name: string
description: string | null
active: boolean
createdAt: Date | null
updatedAt: Date | null
deletedAt: Date | null
}[]
}
user?: User
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export function EditProductForm({ product }: { product: Product }) {
image: {
url: product.fields.image?.url ?? '',
},
visibility: product.fields.visibility || 'unlisted',
visibility: product.fields.visibility || 'public',
state: product.fields.state || 'draft',
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
export default async function AlreadySubscribedPage() {
const { session, ability } = await getServerAuthSession()

if (!session) {
return redirect('/')
}

const { user } = session

const { hasActiveSubscription } = await getSubscriptionStatus(user?.id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function EditLessonForm({
fields: {
title: lesson.fields?.title || '',
body: lesson.fields?.body || '',
visibility: lesson.fields?.visibility || 'unlisted',
visibility: lesson.fields?.visibility || 'public',
state: lesson.fields?.state || 'draft',
description: lesson.fields?.description || '',
github: lesson.fields?.github || '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function EditEventForm({ event }: { event: Event }) {
endsAt: new Date(event.fields.endsAt).toISOString(),
}),
title: event.fields.title || '',
visibility: event.fields.visibility || 'unlisted',
visibility: event.fields.visibility || 'public',
image: event.fields.image || '',
description: event.fields.description ?? '',
slug: event.fields.slug ?? '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useRouter } from 'next/navigation'
import { PostUploader } from '@/app/(content)/posts/_components/post-uploader'
import { NewResourceWithVideoForm } from '@/components/resources-crud/new-resource-with-video-form'
import { createPost } from '@/lib/posts-query'
import { getVideoResource } from '@/lib/video-resource-query'
import { FilePlus2 } from 'lucide-react'
Expand All @@ -15,7 +16,6 @@ import {
DialogHeader,
DialogTrigger,
} from '@coursebuilder/ui'
import { NewResourceWithVideoForm } from '@coursebuilder/ui/resources-crud/new-resource-with-video-form'

export function CreatePost() {
const router = useRouter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function EditPostForm({
title: post.fields?.title,
body: post.fields?.body,
slug: post.fields?.slug,
visibility: post.fields?.visibility || 'unlisted',
visibility: post.fields?.visibility || 'public',
state: post.fields?.state || 'draft',
description: post.fields?.description ?? '',
github: post.fields?.github ?? '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function EditTutorialForm({ tutorial }: { tutorial: ContentResource }) {
.default('draft'),
visibility: z
.enum(['public', 'private', 'unlisted'])
.default('unlisted'),
.default('public'),
body: z.string().nullable().optional(),
}),
}),
Expand Down
Loading
Loading