deployments.ts373 lines · main
1import { Hono, type Context } from 'hono';
2import { z } from 'zod';
3
4import { schemaSnapshotSchema } from '@briven/schema';
5
6import { projectRateLimit } from '../middleware/rate-limit.js';
7import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js';
8import type { ProjectAppEnv as AppEnv } from '../types/app-env.js';
9import {
10 cancelPendingDeployment,
11 createDeployment,
12 getCurrentDeployment,
13 getCurrentSchema,
14 getDeployment,
15 listDeploymentsForProject,
16 transitionDeployment,
17} from '../services/deployments.js';
18import { audit, hashIp } from '../services/audit.js';
19import { applySchema, type SchemaDef } from '../services/schema-apply.js';
20import { assertFunctionCountAllowed, getProjectTier } from '../services/tiers.js';
21import { log } from '../lib/logger.js';
22
23const MAX_LIMIT = 100;
24
25// Per-file source cap to keep a single deploy under ~25 MB even if every
26// function maxes out. The full bundle column is then bounded by
27// MAX_FUNCTION_FILES * MAX_FUNCTION_SOURCE_BYTES.
28const MAX_FUNCTION_SOURCE_BYTES = 256 * 1024;
29const MAX_FUNCTION_FILES = 100;
30
31const createSchema = z.object({
32 schemaDiffSummary: z.record(z.string(), z.unknown()).optional(),
33 // Strict allowlist on every identifier, sqlType, default, and onDelete —
34 // the snapshot is interpolated into raw DDL by services/schema-apply.ts on
35 // the shared data-plane superuser connection. See fix/security-sqli-schema-apply.
36 schemaSnapshot: schemaSnapshotSchema.optional(),
37 functionCount: z.number().int().nonnegative().max(10_000).optional(),
38 functionNames: z.array(z.string().max(128)).max(10_000).optional(),
39 bundle: z
40 .record(z.string().max(256), z.string().max(MAX_FUNCTION_SOURCE_BYTES))
41 .refine((b) => Object.keys(b).length <= MAX_FUNCTION_FILES, {
42 message: `bundle exceeds ${MAX_FUNCTION_FILES} files`,
43 })
44 .optional(),
45});
46
47const listQuerySchema = z.object({
48 limit: z.coerce.number().int().positive().max(MAX_LIMIT).default(50),
49});
50
51function ipHash(c: Context<AppEnv>): string | null {
52 const fwd = c.req.raw.headers.get('x-forwarded-for');
53 const ip = fwd ? fwd.split(',')[0]!.trim() : null;
54 return hashIp(ip);
55}
56
57export const deploymentsRouter = new Hono<AppEnv>();
58
59deploymentsRouter.use('/v1/projects/:id/deployments', requireProjectAuth());
60deploymentsRouter.use('/v1/projects/:id/deployments/*', requireProjectAuth());
61deploymentsRouter.use('/v1/projects/:id/deployments/latest', requireProjectAuth());
62deploymentsRouter.use('/v1/projects/:id/info', requireProjectAuth());
63deploymentsRouter.use('/v1/projects/:id/schema/current', requireProjectAuth());
64
65deploymentsRouter.get('/v1/projects/:id/schema/current', async (c) => {
66 const current = await getCurrentSchema(c.req.param('id'));
67 return c.json(current);
68});
69
70deploymentsRouter.get('/v1/projects/:id/info', async (c) => {
71 const user = c.get('user');
72 const apiKeyId = c.get('apiKeyId');
73 // Intentionally omit user.email per CLAUDE.md §5.1 — the /v1/me endpoint
74 // is the only place a user's own email surfaces, and only to themselves.
75 return c.json({
76 projectId: c.req.param('id'),
77 authenticatedVia: apiKeyId ? 'api_key' : 'session',
78 apiKeyId,
79 userId: user?.id ?? null,
80 });
81});
82
83deploymentsRouter.get('/v1/projects/:id/deployments', async (c) => {
84 const parsed = listQuerySchema.safeParse({
85 limit: c.req.query('limit'),
86 });
87 if (!parsed.success) {
88 return c.json(
89 { code: 'validation_failed', message: 'invalid query', issues: parsed.error.issues },
90 400,
91 );
92 }
93 const rows = await listDeploymentsForProject(c.req.param('id'), parsed.data.limit);
94 return c.json({ deployments: rows });
95});
96
97deploymentsRouter.post(
98 '/v1/projects/:id/deployments',
99 // why: tier-aware burst cap on the deploy path. Free tier = 5/min;
100 // a developer iterating locally never hits that, but it stops a
101 // leaked key from spamming schema-apply (which runs DDL inside a
102 // transaction on the shared data plane). Suspension gating happens
103 // once at app level (apps/api/src/index.ts).
104 projectRateLimit('deploy'),
105 requireProjectRole('developer'),
106 async (c) => {
107 const body = await c.req.json().catch(() => ({}));
108 const parsed = createSchema.safeParse(body);
109 if (!parsed.success) {
110 return c.json(
111 { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues },
112 400,
113 );
114 }
115
116 const projectId = c.req.param('id');
117 const user = c.get('user');
118 const apiKeyId = c.get('apiKeyId');
119
120 if (parsed.data.functionCount != null) {
121 const tier = await getProjectTier(projectId);
122 assertFunctionCountAllowed(parsed.data.functionCount, tier ?? 'free');
123 }
124
125 const deployment = await createDeployment({
126 projectId,
127 triggeredBy: user?.id ?? null,
128 apiKeyId,
129 schemaDiffSummary: parsed.data.schemaDiffSummary,
130 schemaSnapshot: parsed.data.schemaSnapshot,
131 functionCount: parsed.data.functionCount,
132 functionNames: parsed.data.functionNames,
133 bundle: parsed.data.bundle,
134 });
135
136 await audit({
137 actorId: user?.id ?? null,
138 projectId,
139 action: 'deployment.create',
140 ipHash: ipHash(c),
141 userAgent: c.req.header('user-agent') ?? null,
142 metadata: { deploymentId: deployment.id, via: apiKeyId ? 'api_key' : 'session' },
143 });
144
145 // Apply the schema in-band. Phase 1 has one shared cluster + a single
146 // worker — the api process is the worker. Phase 2 moves this onto a
147 // queue so deploys don't block the request.
148 if (parsed.data.schemaSnapshot) {
149 try {
150 await transitionDeployment({
151 projectId,
152 deploymentId: deployment.id,
153 status: 'running',
154 });
155 const prev = await getCurrentSchema(projectId);
156 await applySchema(
157 projectId,
158 deployment.id,
159 parsed.data.schemaSnapshot as unknown as SchemaDef,
160 (prev.snapshot as unknown as SchemaDef | null) ?? null,
161 );
162 await transitionDeployment({
163 projectId,
164 deploymentId: deployment.id,
165 status: 'succeeded',
166 });
167 } catch (err) {
168 log.error('schema_apply_failed', {
169 projectId,
170 deploymentId: deployment.id,
171 message: err instanceof Error ? err.message : String(err),
172 });
173 await transitionDeployment({
174 projectId,
175 deploymentId: deployment.id,
176 status: 'failed',
177 errorCode: 'schema_apply_failed',
178 errorMessage: err instanceof Error ? err.message : String(err),
179 });
180 }
181 }
182
183 const updated = await getDeployment(projectId, deployment.id);
184 return c.json({ deployment: updated }, 201);
185 },
186);
187
188deploymentsRouter.get('/v1/projects/:id/deployments/:deploymentId', async (c) => {
189 const deployment = await getDeployment(c.req.param('id'), c.req.param('deploymentId'));
190 return c.json({ deployment });
191});
192
193const patchSchema = z.object({
194 changedFunctions: z
195 .record(z.string().max(256), z.string().max(MAX_FUNCTION_SOURCE_BYTES))
196 .optional(),
197 removedFunctions: z.array(z.string().max(256)).optional(),
198 schemaSnapshot: schemaSnapshotSchema.optional(),
199 schemaDiffSummary: z.record(z.string(), z.unknown()).optional(),
200 confirmDestructive: z.boolean().optional(),
201});
202
203deploymentsRouter.patch(
204 '/v1/projects/:id/deployments/latest',
205 requireProjectRole('developer'),
206 async (c) => {
207 const projectId = c.req.param('id');
208 const body = await c.req.json().catch(() => ({}));
209 const parsed = patchSchema.safeParse(body);
210 if (!parsed.success) {
211 return c.json(
212 { code: 'validation_failed', message: 'invalid request body', issues: parsed.error.issues },
213 400,
214 );
215 }
216
217 const current = await getCurrentDeployment(projectId);
218 const latestBundle = (current?.bundle as Record<string, string> | null) ?? {};
219
220 const mergedBundle: Record<string, string> = { ...latestBundle };
221 if (parsed.data.changedFunctions) {
222 for (const [k, v] of Object.entries(parsed.data.changedFunctions)) mergedBundle[k] = v;
223 }
224 if (parsed.data.removedFunctions) {
225 for (const k of parsed.data.removedFunctions) delete mergedBundle[k];
226 }
227 if (Object.keys(mergedBundle).length > MAX_FUNCTION_FILES) {
228 return c.json(
229 { code: 'bundle_too_large', message: `bundle exceeds ${MAX_FUNCTION_FILES} files` },
230 400,
231 );
232 }
233
234 // Schema snapshot: if the client didn't send one, inherit the previous;
235 // destructive diffs are enforced when a new snapshot is supplied.
236 const inheritedSnapshot =
237 parsed.data.schemaSnapshot ??
238 (current?.schemaSnapshot as Record<string, unknown> | null) ??
239 undefined;
240
241 if (parsed.data.schemaSnapshot && !parsed.data.confirmDestructive) {
242 const prevSnap = (current?.schemaSnapshot as Record<string, unknown> | null) ?? null;
243 if (isDestructiveDiff(prevSnap, parsed.data.schemaSnapshot)) {
244 return c.json(
245 {
246 code: 'destructive_requires_confirmation',
247 message:
248 'schema diff drops tables or columns; re-send with confirmDestructive:true or run `briven deploy --confirm-destructive`',
249 },
250 400,
251 );
252 }
253 }
254
255 const user = c.get('user');
256 const apiKeyId = c.get('apiKeyId');
257 const functionNames = Object.keys(mergedBundle).sort();
258
259 if (functionNames.length > 0) {
260 const tier = await getProjectTier(projectId);
261 assertFunctionCountAllowed(functionNames.length, tier ?? 'free');
262 }
263
264 const deployment = await createDeployment({
265 projectId,
266 triggeredBy: user?.id ?? null,
267 apiKeyId,
268 schemaDiffSummary: parsed.data.schemaDiffSummary,
269 schemaSnapshot: inheritedSnapshot,
270 functionCount: functionNames.length,
271 functionNames,
272 bundle: mergedBundle,
273 });
274
275 await audit({
276 actorId: user?.id ?? null,
277 projectId,
278 action: 'deployment.patch',
279 ipHash: ipHash(c),
280 userAgent: c.req.header('user-agent') ?? null,
281 metadata: {
282 deploymentId: deployment.id,
283 via: apiKeyId ? 'api_key' : 'session',
284 changedCount: Object.keys(parsed.data.changedFunctions ?? {}).length,
285 removedCount: parsed.data.removedFunctions?.length ?? 0,
286 },
287 });
288
289 // Apply schema in-band (same path as POST /deployments). Harmless no-op
290 // when the client didn't send a new snapshot.
291 if (parsed.data.schemaSnapshot) {
292 try {
293 await transitionDeployment({
294 projectId,
295 deploymentId: deployment.id,
296 status: 'running',
297 });
298 const prev = await getCurrentSchema(projectId);
299 await applySchema(
300 projectId,
301 deployment.id,
302 parsed.data.schemaSnapshot as unknown as SchemaDef,
303 (prev.snapshot as unknown as SchemaDef | null) ?? null,
304 );
305 await transitionDeployment({
306 projectId,
307 deploymentId: deployment.id,
308 status: 'succeeded',
309 });
310 } catch (err) {
311 log.error('schema_apply_failed', {
312 projectId,
313 deploymentId: deployment.id,
314 message: err instanceof Error ? err.message : String(err),
315 });
316 await transitionDeployment({
317 projectId,
318 deploymentId: deployment.id,
319 status: 'failed',
320 errorCode: 'schema_apply_failed',
321 errorMessage: err instanceof Error ? err.message : String(err),
322 });
323 }
324 } else {
325 // Function-only patches: no schema work, deployment is immediately
326 // serve-able. Mark succeeded so getCurrentDeployment picks it up.
327 await transitionDeployment({
328 projectId,
329 deploymentId: deployment.id,
330 status: 'succeeded',
331 });
332 }
333
334 const updated = await getDeployment(projectId, deployment.id);
335 return c.json({ deployment: updated }, 201);
336 },
337);
338
339function isDestructiveDiff(
340 prev: Record<string, unknown> | null,
341 next: Record<string, unknown>,
342): boolean {
343 if (!prev) return false;
344 const prevTables = (prev.tables as Record<string, Record<string, unknown>> | undefined) ?? {};
345 const nextTables = (next.tables as Record<string, Record<string, unknown>> | undefined) ?? {};
346 for (const tName of Object.keys(prevTables)) {
347 if (!(tName in nextTables)) return true; // dropped table
348 const prevCols = (prevTables[tName]!.columns as Record<string, unknown> | undefined) ?? {};
349 const nextCols = (nextTables[tName]!.columns as Record<string, unknown> | undefined) ?? {};
350 for (const cName of Object.keys(prevCols)) {
351 if (!(cName in nextCols)) return true; // dropped column
352 }
353 }
354 return false;
355}
356
357deploymentsRouter.post('/v1/projects/:id/deployments/:deploymentId/cancel', async (c) => {
358 const projectId = c.req.param('id');
359 const deploymentId = c.req.param('deploymentId');
360 const deployment = await cancelPendingDeployment(projectId, deploymentId);
361 const user = c.get('user');
362
363 await audit({
364 actorId: user?.id ?? null,
365 projectId,
366 action: 'deployment.cancel',
367 ipHash: ipHash(c),
368 userAgent: c.req.header('user-agent') ?? null,
369 metadata: { deploymentId },
370 });
371
372 return c.json({ deployment });
373});