api-keys.ts131 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { requireAuth } from '../middleware/session.js'; |
| 5 | import type { AppEnv } from '../types/app-env.js'; |
| 6 | import { audit, hashIp } from '../services/audit.js'; |
| 7 | import { |
| 8 | createApiKey, |
| 9 | listApiKeysForProject, |
| 10 | renameApiKey, |
| 11 | revokeApiKey, |
| 12 | } from '../services/api-keys.js'; |
| 13 | import { assertProjectRole } from '../services/access.js'; |
| 14 | |
| 15 | // Per-key role scoping: human users can issue a key at the same role as the |
| 16 | // caller or lower (viewer/developer/admin). 'owner' is never assignable. |
| 17 | const createKeySchema = z.object({ |
| 18 | name: z.string().min(1).max(80), |
| 19 | role: z.enum(['viewer', 'developer', 'admin']).optional(), |
| 20 | expiresInDays: z.number().int().positive().max(365).optional(), |
| 21 | }); |
| 22 | |
| 23 | export const apiKeysRouter = new Hono<AppEnv>(); |
| 24 | |
| 25 | apiKeysRouter.use('/v1/projects/:id/api-keys', requireAuth()); |
| 26 | apiKeysRouter.use('/v1/projects/:id/api-keys/*', requireAuth()); |
| 27 | |
| 28 | apiKeysRouter.get('/v1/projects/:id/api-keys', async (c) => { |
| 29 | const user = c.get('user')!; |
| 30 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 31 | const keys = await listApiKeysForProject(project.id); |
| 32 | return c.json({ keys }); |
| 33 | }); |
| 34 | |
| 35 | apiKeysRouter.post('/v1/projects/:id/api-keys', async (c) => { |
| 36 | const user = c.get('user')!; |
| 37 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 38 | const body = await c.req.json().catch(() => null); |
| 39 | const parsed = createKeySchema.safeParse(body); |
| 40 | if (!parsed.success) { |
| 41 | return c.json( |
| 42 | { |
| 43 | code: 'validation_failed', |
| 44 | message: 'invalid request body', |
| 45 | issues: parsed.error.issues, |
| 46 | }, |
| 47 | 400, |
| 48 | ); |
| 49 | } |
| 50 | const expiresAt = parsed.data.expiresInDays |
| 51 | ? new Date(Date.now() + parsed.data.expiresInDays * 24 * 60 * 60 * 1000) |
| 52 | : undefined; |
| 53 | const { record, plaintext } = await createApiKey({ |
| 54 | projectId: project.id, |
| 55 | createdBy: user.id, |
| 56 | name: parsed.data.name, |
| 57 | role: parsed.data.role, |
| 58 | expiresAt, |
| 59 | }); |
| 60 | await audit({ |
| 61 | actorId: user.id, |
| 62 | projectId: project.id, |
| 63 | action: 'api_key.create', |
| 64 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 65 | userAgent: c.req.header('user-agent') ?? null, |
| 66 | metadata: { keyId: record.id, name: record.name, role: record.role }, |
| 67 | }); |
| 68 | |
| 69 | return c.json( |
| 70 | { |
| 71 | key: { |
| 72 | id: record.id, |
| 73 | name: record.name, |
| 74 | suffix: record.suffix, |
| 75 | role: record.role, |
| 76 | createdAt: record.createdAt, |
| 77 | expiresAt: record.expiresAt, |
| 78 | }, |
| 79 | // Plaintext is returned ONCE. Per CLAUDE.md §5.4 we never log or |
| 80 | // store this; the caller must save it immediately. |
| 81 | plaintext, |
| 82 | }, |
| 83 | 201, |
| 84 | ); |
| 85 | }); |
| 86 | |
| 87 | const renameKeySchema = z.object({ |
| 88 | name: z.string().min(1).max(80), |
| 89 | }); |
| 90 | |
| 91 | apiKeysRouter.patch('/v1/projects/:id/api-keys/:keyId', async (c) => { |
| 92 | const user = c.get('user')!; |
| 93 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 94 | const keyId = c.req.param('keyId'); |
| 95 | |
| 96 | const body = await c.req.json().catch(() => null); |
| 97 | const parsed = renameKeySchema.safeParse(body); |
| 98 | if (!parsed.success) { |
| 99 | return c.json( |
| 100 | { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues }, |
| 101 | 400, |
| 102 | ); |
| 103 | } |
| 104 | |
| 105 | await renameApiKey(project.id, keyId, parsed.data.name); |
| 106 | await audit({ |
| 107 | actorId: user.id, |
| 108 | projectId: project.id, |
| 109 | action: 'api_key.rename', |
| 110 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 111 | userAgent: c.req.header('user-agent') ?? null, |
| 112 | metadata: { keyId, name: parsed.data.name }, |
| 113 | }); |
| 114 | return c.json({ keyId, name: parsed.data.name }); |
| 115 | }); |
| 116 | |
| 117 | apiKeysRouter.delete('/v1/projects/:id/api-keys/:keyId', async (c) => { |
| 118 | const user = c.get('user')!; |
| 119 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 120 | const keyId = c.req.param('keyId'); |
| 121 | await revokeApiKey(project.id, keyId); |
| 122 | await audit({ |
| 123 | actorId: user.id, |
| 124 | projectId: project.id, |
| 125 | action: 'api_key.revoke', |
| 126 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 127 | userAgent: c.req.header('user-agent') ?? null, |
| 128 | metadata: { keyId }, |
| 129 | }); |
| 130 | return c.json({ revoked: keyId }); |
| 131 | }); |