project-env.ts136 lines · main
1import { Hono, type Context } from 'hono';
2import { z } from 'zod';
3
4import { projectRateLimit, rateLimit } from '../middleware/rate-limit.js';
5import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js';
6import type { ProjectAppEnv as AppEnv } from '../types/app-env.js';
7import { audit, hashIp } from '../services/audit.js';
8import {
9 deleteEnvVar,
10 deleteEnvVarByKey,
11 getPlainEnvForProject,
12 listEnvForProject,
13 upsertEnvVar,
14} from '../services/project-env.js';
15
16const putSchema = z.object({
17 key: z.string().min(1).max(64),
18 value: z.string().max(32_768),
19});
20
21function ipHash(c: Context<AppEnv>): string | null {
22 const fwd = c.req.raw.headers.get('x-forwarded-for');
23 const ip = fwd ? fwd.split(',')[0]!.trim() : null;
24 return hashIp(ip);
25}
26
27export const projectEnvRouter = new Hono<AppEnv>();
28
29// Env values (encrypted-at-rest, plaintext on read) are admin-tier — they
30// hold customer secrets and DSNs. API keys carry projectRole='admin' and
31// pass these gates by design (a project-scoped key already has equivalent
32// authority).
33projectEnvRouter.use('/v1/projects/:id/env', requireProjectAuth(), requireProjectRole('admin'));
34projectEnvRouter.use('/v1/projects/:id/env/*', requireProjectAuth(), requireProjectRole('admin'));
35
36projectEnvRouter.get('/v1/projects/:id/env', async (c) => {
37 const vars = await listEnvForProject(c.req.param('id'));
38 return c.json({ env: vars });
39});
40
41projectEnvRouter.put('/v1/projects/:id/env', projectRateLimit('mutate'), async (c) => {
42 const projectId = c.req.param('id');
43 const user = c.get('user');
44 const apiKeyId = c.get('apiKeyId');
45 const body = await c.req.json().catch(() => null);
46 const parsed = putSchema.safeParse(body);
47 if (!parsed.success) {
48 return c.json(
49 { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues },
50 400,
51 );
52 }
53 await upsertEnvVar({
54 projectId,
55 key: parsed.data.key,
56 value: parsed.data.value,
57 createdBy: user?.id ?? null,
58 });
59 await audit({
60 actorId: user?.id ?? null,
61 projectId,
62 action: 'env.upsert',
63 ipHash: ipHash(c),
64 userAgent: c.req.header('user-agent') ?? null,
65 // why: key is useful for audit review; value is never logged per CLAUDE.md §5.1.
66 metadata: { key: parsed.data.key, via: apiKeyId ? 'api_key' : 'session' },
67 });
68 return c.json({ key: parsed.data.key });
69});
70
71projectEnvRouter.delete('/v1/projects/:id/env/:envVarId', projectRateLimit('mutate'), async (c) => {
72 const projectId = c.req.param('id');
73 const envVarId = c.req.param('envVarId');
74 // Guard against the by-key path shadowing this one — by-key is mounted
75 // explicitly below on a distinct `/env/by-key/:key` path.
76 if (envVarId === 'by-key' || envVarId === 'plaintext') {
77 return c.json({ code: 'not_found', message: 'route not found' }, 404);
78 }
79 const user = c.get('user');
80 const apiKeyId = c.get('apiKeyId');
81 await deleteEnvVar(projectId, envVarId);
82 await audit({
83 actorId: user?.id ?? null,
84 projectId,
85 action: 'env.delete',
86 ipHash: ipHash(c),
87 userAgent: c.req.header('user-agent') ?? null,
88 metadata: { envVarId, via: apiKeyId ? 'api_key' : 'session' },
89 });
90 return c.json({ deleted: envVarId });
91});
92
93projectEnvRouter.delete('/v1/projects/:id/env/by-key/:key', projectRateLimit('mutate'), async (c) => {
94 const projectId = c.req.param('id');
95 const key = c.req.param('key');
96 const user = c.get('user');
97 const apiKeyId = c.get('apiKeyId');
98 const deleted = await deleteEnvVarByKey(projectId, key);
99 await audit({
100 actorId: user?.id ?? null,
101 projectId,
102 action: 'env.delete',
103 ipHash: ipHash(c),
104 userAgent: c.req.header('user-agent') ?? null,
105 metadata: { key, via: apiKeyId ? 'api_key' : 'session' },
106 });
107 return c.json({ deleted: deleted.id, key });
108});
109
110// why: plaintext reads are the single high-signal audit target for this
111// resource. 10/min per project is generous for humans, restrictive enough
112// to slow a leaked-credential scrape.
113projectEnvRouter.get(
114 '/v1/projects/:id/env/plaintext',
115 rateLimit({
116 scope: 'env-pull',
117 limit: 10,
118 windowMs: 60_000,
119 key: (c) => c.req.param('id') ?? null,
120 }),
121 async (c) => {
122 const projectId = c.req.param('id');
123 const user = c.get('user');
124 const apiKeyId = c.get('apiKeyId');
125 const plain = await getPlainEnvForProject(projectId);
126 await audit({
127 actorId: user?.id ?? null,
128 projectId,
129 action: 'env.pull',
130 ipHash: ipHash(c),
131 userAgent: c.req.header('user-agent') ?? null,
132 metadata: { count: Object.keys(plain).length, via: apiKeyId ? 'api_key' : 'session' },
133 });
134 return c.json({ env: plain });
135 },
136);