Skip to content

Commit 5824f89

Browse files
Contentrainclaude
andcommitted
feat(api): add CLI integration endpoints for Studio CLI
Add token refresh endpoint, localhost OAuth redirect support, CLI token return mode in verify, paginated activity feed, and CLI-friendly usage format. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c49dd55 commit 5824f89

9 files changed

Lines changed: 250 additions & 10 deletions

File tree

server/api/auth/login.post.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,67 @@
1+
/**
2+
* POST /api/auth/login
3+
*
4+
* Initiates an OAuth login flow. Returns a redirect URL the client should open.
5+
*
6+
* Two modes:
7+
* 1. Web (default): reads { provider, redirectTo } from request body.
8+
* Stores CSRF state in encrypted cookie, uses siteUrl as redirect base.
9+
* 2. CLI: reads provider, redirect_uri, state from query params.
10+
* redirect_uri must be a localhost callback (port 9876-9899).
11+
* State is caller-supplied (CLI manages its own CSRF).
12+
*/
13+
14+
/** Validates that a redirect URI is an allowed CLI localhost callback. */
15+
function isAllowedCliRedirectUri(uri: string): boolean {
16+
try {
17+
const parsed = new URL(uri)
18+
if (parsed.protocol !== 'http:') return false
19+
if (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') return false
20+
const port = Number(parsed.port)
21+
if (port < 9876 || port > 9899) return false
22+
if (parsed.pathname !== '/callback') return false
23+
return true
24+
}
25+
catch {
26+
return false
27+
}
28+
}
29+
130
export default defineEventHandler(async (event) => {
231
// Rate limit: 10 login requests per minute per IP
332
const ip = getClientIp(event)
433
const rateCheck = await checkRateLimit(`auth-login:${ip}`, 10, 60_000)
534
if (!rateCheck.allowed)
635
throw createError({ statusCode: 429, message: errorMessage('auth.rate_limited') })
736

8-
const body = await readBody<{ provider: 'github' | 'google', redirectTo?: string }>(event)
37+
const query = getQuery(event) as { provider?: string, redirect_uri?: string, state?: string }
38+
const isCli = !!(query.provider && query.redirect_uri)
39+
40+
let provider: string
41+
let redirectTo: string
42+
43+
if (isCli) {
44+
// ── CLI flow ──
45+
provider = query.provider!
946

10-
if (!body.provider || !['github', 'google'].includes(body.provider)) {
47+
if (!isAllowedCliRedirectUri(query.redirect_uri!)) {
48+
throw createError({
49+
statusCode: 400,
50+
message: 'redirect_uri must be http://127.0.0.1:{9876-9899}/callback',
51+
})
52+
}
53+
54+
// CLI supplies the full redirect URI — pass it directly to the provider
55+
redirectTo = query.redirect_uri!
56+
}
57+
else {
58+
// ── Web flow (existing) ──
59+
const body = await readBody<{ provider: string, redirectTo?: string }>(event)
60+
provider = body.provider
61+
redirectTo = body.redirectTo || '/auth/callback'
62+
}
63+
64+
if (!provider || !['github', 'google'].includes(provider)) {
1165
throw createError({
1266
statusCode: 400,
1367
message: errorMessage('auth.invalid_provider'),
@@ -16,11 +70,16 @@ export default defineEventHandler(async (event) => {
1670

1771
const authProvider = useAuthProvider()
1872
const result = await authProvider.getOAuthRedirectUrl(
19-
body.provider,
20-
body.redirectTo || '/auth/callback',
73+
provider as 'github' | 'google',
74+
redirectTo,
2175
)
2276

23-
// Store provider-generated state in encrypted cookie for validation on callback
77+
if (isCli) {
78+
// CLI manages its own state — don't set cookie
79+
return { url: result.url, state: query.state || result.state }
80+
}
81+
82+
// Web: store provider-generated state in encrypted cookie for validation on callback
2483
if (result.state) {
2584
await setAuthState(event, result.state)
2685
}

server/api/auth/refresh.post.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* POST /api/auth/refresh
3+
*
4+
* Exchanges a refresh token for a new access + refresh token pair.
5+
* Primary consumer: CLI tools that manage tokens outside the browser session.
6+
*
7+
* Web clients don't call this directly — the auth middleware auto-refreshes
8+
* tokens stored in the encrypted httpOnly cookie.
9+
*/
10+
export default defineEventHandler(async (event) => {
11+
// Rate limit: 10 refresh requests per minute per IP
12+
const ip = getClientIp(event)
13+
const rateCheck = await checkRateLimit(`auth-refresh:${ip}`, 10, 60_000)
14+
if (!rateCheck.allowed)
15+
throw createError({ statusCode: 429, message: errorMessage('auth.rate_limited') })
16+
17+
const body = await readBody<{ refreshToken?: string }>(event)
18+
19+
if (!body.refreshToken) {
20+
throw createError({
21+
statusCode: 400,
22+
message: 'refreshToken is required',
23+
})
24+
}
25+
26+
const authProvider = useAuthProvider()
27+
const newTokens = await authProvider.refreshSession(body.refreshToken)
28+
29+
if (!newTokens) {
30+
throw createError({
31+
statusCode: 401,
32+
message: errorMessage('auth.session_expired'),
33+
})
34+
}
35+
36+
return {
37+
accessToken: newTokens.accessToken,
38+
refreshToken: newTokens.refreshToken,
39+
expiresAt: newTokens.expiresAt,
40+
}
41+
})

server/api/auth/verify.post.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
/**
2+
* POST /api/auth/verify
3+
*
4+
* Exchanges an OAuth code (or magic link tokens) for an authenticated session.
5+
*
6+
* Two modes:
7+
* 1. Web (default): stores tokens in encrypted httpOnly cookie, returns { user }.
8+
* 2. CLI (source: 'cli'): returns { user, tokens } — no cookie is set.
9+
* The CLI stores tokens locally and uses /api/auth/refresh when they expire.
10+
*/
111
export default defineEventHandler(async (event) => {
212
// Rate limit
313
const ip = getHeader(event, 'x-forwarded-for') ?? 'unknown'
@@ -10,12 +20,15 @@ export default defineEventHandler(async (event) => {
1020
accessToken?: string
1121
refreshToken?: string
1222
state?: string
23+
source?: 'cli'
1324
}>(event)
1425

26+
const isCli = body.source === 'cli'
27+
1528
// OAuth state CSRF protection
16-
// Code flow (OAuth): state is REQUIRED and validated
29+
// Code flow (OAuth): state is REQUIRED and validated (web only — CLI manages its own state)
1730
// Token flow (magic link): state is optional (no redirect to hijack)
18-
if (body.code) {
31+
if (body.code && !isCli) {
1932
if (!body.state) {
2033
throw createError({ statusCode: 403, message: errorMessage('auth.invalid_state') })
2134
}
@@ -38,7 +51,19 @@ export default defineEventHandler(async (event) => {
3851
throw createError({ statusCode: 400, message: errorMessage('auth.code_or_token_required') })
3952
}
4053

41-
// Store tokens in encrypted httpOnly cookie — never exposed to client
54+
if (isCli) {
55+
// CLI: return tokens directly — no cookie
56+
return {
57+
user: session.user,
58+
tokens: {
59+
accessToken: session.tokens.accessToken,
60+
refreshToken: session.tokens.refreshToken,
61+
expiresAt: session.tokens.expiresAt,
62+
},
63+
}
64+
}
65+
66+
// Web: store tokens in encrypted httpOnly cookie — never exposed to client
4267
await setServerSession(event, {
4368
userId: session.user.id,
4469
accessToken: session.tokens.accessToken,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* GET /api/workspaces/:workspaceId/projects/:projectId/activity
3+
*
4+
* Returns a paginated activity feed from the audit log.
5+
* Currently workspace-scoped (audit_logs table has workspace_id).
6+
*
7+
* Query params:
8+
* - page (default: 1)
9+
* - limit (default: 20, max: 100)
10+
* - action (optional filter, e.g. "delete_project")
11+
* - sort (default: "newest", or "oldest")
12+
*/
13+
export default defineEventHandler(async (event) => {
14+
const session = requireAuth(event)
15+
const workspaceId = getRouterParam(event, 'workspaceId')
16+
const projectId = getRouterParam(event, 'projectId')
17+
18+
if (!workspaceId || !projectId)
19+
throw createError({ statusCode: 400, message: errorMessage('validation.project_id_required') })
20+
21+
const db = useDatabaseProvider()
22+
23+
// Verify workspace access (owner/admin/member)
24+
await db.requireWorkspaceRole(session.accessToken, session.user.id, workspaceId, ['owner', 'admin', 'member'])
25+
26+
// Verify project belongs to workspace
27+
const project = await db.getProjectForWorkspace(session.accessToken, workspaceId, projectId)
28+
if (!project)
29+
throw createError({ statusCode: 404, message: errorMessage('project.not_found') })
30+
31+
const query = getQuery(event) as {
32+
page?: string
33+
limit?: string
34+
action?: string
35+
sort?: string
36+
}
37+
38+
const page = query.page ? Number(query.page) : 1
39+
const limit = query.limit ? Math.min(Number(query.limit), 100) : 20
40+
41+
const result = await db.listAuditLogs(workspaceId, {
42+
page,
43+
limit,
44+
action: query.action,
45+
sort: (query.sort as 'newest' | 'oldest') ?? 'newest',
46+
})
47+
48+
return {
49+
data: result.data.map(log => ({
50+
id: log.id,
51+
action: log.action,
52+
actor: log.actor_id,
53+
entity: log.table_name,
54+
recordId: log.record_id,
55+
origin: log.origin,
56+
createdAt: log.created_at,
57+
})),
58+
total: result.total,
59+
page,
60+
limit,
61+
}
62+
})

server/api/workspaces/[workspaceId]/usage.get.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,24 @@ export default defineEventHandler(async (event) => {
115115
return sum + (projectedOverage * c.overageUnitPrice)
116116
}, 0)
117117

118+
// CLI-compatible flat format: ?format=simple
119+
const query = getQuery(event) as { format?: string }
120+
if (query.format === 'simple') {
121+
const keyMap: Record<string, string> = {
122+
ai_messages: 'aiMessages',
123+
form_submissions: 'formSubmissions',
124+
cdn_bandwidth: 'cdnBandwidthGb',
125+
media_storage: 'mediaStorageGb',
126+
api_messages: 'apiMessages',
127+
}
128+
const simple: Record<string, { current: number, limit: number, percentage: number }> = {}
129+
for (const c of categories) {
130+
const key = keyMap[c.key] ?? c.key
131+
simple[key] = { current: c.current, limit: c.limit, percentage: c.percentage }
132+
}
133+
return simple
134+
}
135+
118136
return {
119137
billingPeriod,
120138
categories,

server/middleware/01.auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const PUBLIC_PATHS = [
1111
'/api/auth/callback',
1212
'/api/auth/magic-link',
1313
'/api/auth/verify',
14+
'/api/auth/refresh',
1415
'/api/health',
1516
'/api/webhooks/',
1617
'/api/cdn/',

server/providers/database.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,4 +437,11 @@ export interface DatabaseProvider {
437437
userAgent?: string | null
438438
origin?: 'app' | 'cascade'
439439
}) => Promise<void>
440+
441+
listAuditLogs: (workspaceId: string, options?: {
442+
page?: number
443+
limit?: number
444+
action?: string
445+
sort?: 'newest' | 'oldest'
446+
}) => Promise<{ data: DatabaseRow[], total: number }>
440447
}

server/providers/supabase-auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function createSupabaseAuthProvider(): AuthProvider {
4444
const { data, error } = await admin.auth.signInWithOAuth({
4545
provider,
4646
options: {
47-
redirectTo: `${config.public.siteUrl}${redirectTo}`,
47+
redirectTo: redirectTo.startsWith('http') ? redirectTo : `${config.public.siteUrl}${redirectTo}`,
4848
skipBrowserRedirect: true,
4949
// Let Supabase handle default scopes per provider
5050
},

server/providers/supabase-db/audit.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import type { DatabaseProvider } from '../database'
88
import { getAdmin } from './helpers'
99

10-
type AuditMethods = Pick<DatabaseProvider, 'createAuditLog'>
10+
type AuditMethods = Pick<DatabaseProvider, 'createAuditLog' | 'listAuditLogs'>
1111

1212
export function auditMethods(): AuditMethods {
1313
return {
@@ -33,5 +33,32 @@ export function auditMethods(): AuditMethods {
3333
console.error('[audit] Failed to write audit log:', error.message)
3434
}
3535
},
36+
37+
async listAuditLogs(workspaceId, options) {
38+
const page = options?.page ?? 1
39+
const limit = Math.min(options?.limit ?? 20, 100)
40+
const offset = (page - 1) * limit
41+
42+
let query = getAdmin()
43+
.from('audit_logs')
44+
.select('id, workspace_id, actor_id, action, table_name, record_id, origin, created_at', { count: 'exact' })
45+
.eq('workspace_id', workspaceId)
46+
47+
if (options?.action) {
48+
query = query.eq('action', options.action)
49+
}
50+
51+
query = options?.sort === 'oldest'
52+
? query.order('created_at', { ascending: true })
53+
: query.order('created_at', { ascending: false })
54+
55+
const { data, count, error } = await query.range(offset, offset + limit - 1)
56+
57+
if (error) {
58+
throw createError({ statusCode: 500, message: `Failed to list audit logs: ${error.message}` })
59+
}
60+
61+
return { data: (data ?? []) as Record<string, unknown>[], total: count ?? 0 }
62+
},
3663
}
3764
}

0 commit comments

Comments
 (0)