deployments.ts373 lines · main
| 1 | import { Hono, type Context } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { schemaSnapshotSchema } from '@briven/schema'; |
| 5 | |
| 6 | import { projectRateLimit } from '../middleware/rate-limit.js'; |
| 7 | import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js'; |
| 8 | import type { ProjectAppEnv as AppEnv } from '../types/app-env.js'; |
| 9 | import { |
| 10 | cancelPendingDeployment, |
| 11 | createDeployment, |
| 12 | getCurrentDeployment, |
| 13 | getCurrentSchema, |
| 14 | getDeployment, |
| 15 | listDeploymentsForProject, |
| 16 | transitionDeployment, |
| 17 | } from '../services/deployments.js'; |
| 18 | import { audit, hashIp } from '../services/audit.js'; |
| 19 | import { applySchema, type SchemaDef } from '../services/schema-apply.js'; |
| 20 | import { assertFunctionCountAllowed, getProjectTier } from '../services/tiers.js'; |
| 21 | import { log } from '../lib/logger.js'; |
| 22 | |
| 23 | const 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. |
| 28 | const MAX_FUNCTION_SOURCE_BYTES = 256 * 1024; |
| 29 | const MAX_FUNCTION_FILES = 100; |
| 30 | |
| 31 | const 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 | |
| 47 | const listQuerySchema = z.object({ |
| 48 | limit: z.coerce.number().int().positive().max(MAX_LIMIT).default(50), |
| 49 | }); |
| 50 | |
| 51 | function 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 | |
| 57 | export const deploymentsRouter = new Hono<AppEnv>(); |
| 58 | |
| 59 | deploymentsRouter.use('/v1/projects/:id/deployments', requireProjectAuth()); |
| 60 | deploymentsRouter.use('/v1/projects/:id/deployments/*', requireProjectAuth()); |
| 61 | deploymentsRouter.use('/v1/projects/:id/deployments/latest', requireProjectAuth()); |
| 62 | deploymentsRouter.use('/v1/projects/:id/info', requireProjectAuth()); |
| 63 | deploymentsRouter.use('/v1/projects/:id/schema/current', requireProjectAuth()); |
| 64 | |
| 65 | deploymentsRouter.get('/v1/projects/:id/schema/current', async (c) => { |
| 66 | const current = await getCurrentSchema(c.req.param('id')); |
| 67 | return c.json(current); |
| 68 | }); |
| 69 | |
| 70 | deploymentsRouter.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 | |
| 83 | deploymentsRouter.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 | |
| 97 | deploymentsRouter.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 | |
| 188 | deploymentsRouter.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 | |
| 193 | const 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 | |
| 203 | deploymentsRouter.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 | |
| 339 | function 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 | |
| 357 | deploymentsRouter.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 | }); |