api-keys.ts131 lines · main
1import { Hono } from 'hono';
2import { z } from 'zod';
3
4import { requireAuth } from '../middleware/session.js';
5import type { AppEnv } from '../types/app-env.js';
6import { audit, hashIp } from '../services/audit.js';
7import {
8 createApiKey,
9 listApiKeysForProject,
10 renameApiKey,
11 revokeApiKey,
12} from '../services/api-keys.js';
13import { 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.
17const 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
23export const apiKeysRouter = new Hono<AppEnv>();
24
25apiKeysRouter.use('/v1/projects/:id/api-keys', requireAuth());
26apiKeysRouter.use('/v1/projects/:id/api-keys/*', requireAuth());
27
28apiKeysRouter.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
35apiKeysRouter.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
87const renameKeySchema = z.object({
88 name: z.string().min(1).max(80),
89});
90
91apiKeysRouter.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
117apiKeysRouter.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});