1import "reflect-metadata"; 2import { NestFactory } from "@nestjs/core"; 3import { RequestMethod } from "@nestjs/common"; 4import type { NestExpressApplication } from "@nestjs/platform-express"; 5import { v4 as uuid } from "uuid"; 6import { AppModule } from "./app.module.js"; 7import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter.js"; 8import { loadEnv } from "./config/env.js"; 9import { logger } from "./common/logger.js"; 10import { WEBHOOK_BODY_MAX_BYTES } from "@factory/shared"; 11import { 12 initOtel, 13 shutdownOtel, 14 m, 15 renderPrometheus, 16 otelContext, 17 otelTrace 18} from "@factory/observability"; 19 20/** 21 * API bootstrap. 22 * 23 * Order matters: 24 * 1. raw express middleware on the http adapter (request id, security 25 * headers) — runs BEFORE Nest's router; 26 * 2. global exception filter (canonical {error:{...}} envelope); 27 * 3. (removed TASK-0100) the dead global class-validator `ValidationPipe` 28 * — request validation is Zod-based per controller (`ZodValidate`); 29 * 4. CORS from CORS_ORIGINS; 30 * 5. global prefix `api/v1`. 31 */ 32async function bootstrap(): Promise<void> { 33 const env = loadEnv(); 34 // TASK-0135: OTel FIRST — before any request can be served, so the lazy 35 // metric instruments (packages/observability metrics.ts) bind to the SDK 36 // meter provider when they are first touched (the no-op-binding contract). 37 // Disabled (no OTEL_EXPORTER_OTLP_ENDPOINT) → a free no-op. 38 // The OTEL_* keys come straight from process.env (the k8s configmap sets 39 // both; they are deliberately NOT in config/env.ts's zod schema — reading 40 // process.env here keeps env.ts untouched). OTEL_ENABLED gates: the 41 // endpoint alone does not mean "export" (staging default is off). 42 const otelEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; 43 await initOtel({ 44 serviceName: "factory-api", 45 environment: env.NODE_ENV, 46 ...(process.env.OTEL_ENABLED === "true" && otelEndpoint ? { endpoint: otelEndpoint } : {}) 47 }); 48 const app = await NestFactory.create<NestExpressApplication>(AppModule, { 49 logger: false, // we log ourselves (structured JSON) 50 cors: false, 51 // Phase 11 (TASK-0110): capture the EXACT request bytes as `req.rawBody` 52 // (Nest's `rawBody` option = a body-parser `verify` hook). The 53 // incoming-webhook endpoint verifies its HMAC-SHA256 signature over the 54 // raw body; the JSON-parsed `req.body` is re-serialized and would NOT 55 // match the signature. 56 rawBody: true, 57 // …and disable Nest's auto-mounted parsers: body-parser's DEFAULT json 58 // limit is 100 KB, which would wrongly 413 a valid 100–256 KB webhook 59 // body (spec: WEBHOOK_BODY_MAX_BYTES = 256 KB). We re-mount the parsers 60 // ourselves below with the webhook ceiling as the global JSON limit (a 61 // deliberate DoS cap — the incoming webhook is the only endpoint that 62 // ever needs > 100 KB). 63 bodyParser: false 64 }); 65 // T-165: the CSV import needs a 2 MB body (10k-row file) — above the 256 KB 66 // global JSON ceiling. This route-scoped parser buffers ONLY 67 // `POST /api/v1/databases/:id/import/csv` (413 past the cap) and sets 68 // req._body so the global parser below skips it. It must be mounted BEFORE 69 // the useBodyParser calls: Nest's parsers attach at init() onto the same 70 // express stack, and express runs middleware in registration order. 71 const { csvImportBodyParser } = await import("./common/csv-import-body.js"); 72 (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use(csvImportBodyParser); 73 // T-167: whiteboard canvas saves buffer up to 2 MB (same pattern — a 74 // 2,000-object canvas exceeds the global 256 KB JSON ceiling). 75 const { whiteboardBodyParser } = await import("./common/whiteboard-body.js"); 76 (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use(whiteboardBodyParser); 77 app.useBodyParser("json", { limit: WEBHOOK_BODY_MAX_BYTES }); 78 // TASK-0132: SCIM 2.0 IdPs send `Content-Type: application/scim+json` 79 // (RFC 7644) — body-parser's default json type matches ONLY 80 // `application/json`, so mount a second json parser for the `+json` 81 // suffix type (a second `json` parser with a custom `type` only parses 82 // its own content type; the default one above is untouched). 83 app.useBodyParser("json", { type: "application/scim+json", limit: WEBHOOK_BODY_MAX_BYTES }); 84 app.useBodyParser("urlencoded", { extended: true }); 85 86 const http = app.getHttpAdapter().getInstance(); 87 88 // 1a. request id: x-request-id in → generated uuid if absent; echoed out. 89 http.use((req: any, res: any, next: any) => { 90 req.id = (req.headers["x-request-id"] as string | undefined) || uuid(); 91 res.setHeader("x-request-id", req.id); 92 next(); 93 }); 94 95 // 1b. security headers (no helmet dependency). 96 http.use((req: any, res: any, next: any) => { 97 res.setHeader("x-content-type-options", "nosniff"); 98 res.setHeader("x-frame-options", "DENY"); 99 res.setHeader("referrer-policy", "no-referrer"); 100 // TASK-0127: /api/v1/docs is the one API surface that serves HTML — it 101 // needs scripts (the vendored swagger-ui bundle + the inlined spec) and 102 // styles from itself. Everything else stays 'none'. 103 res.setHeader( 104 "content-security-policy", 105 req.path.startsWith("/api/v1/docs") 106 ? "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" 107 : "default-src 'none'" 108 ); 109 res.setHeader("permissions-policy", "geolocation=(), microphone=(), camera=()"); 110 if (env.NODE_ENV === "production") { 111 res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains"); 112 } 113 next(); 114 }); 115 116 // 1c. versioning + deprecation (TASK-0127): `Deprecation: true` + 117 // `Sunset: <date>` on the routes listed in `common/deprecation.ts`. 118 const { deprecationMiddleware } = await import("./common/deprecation.js"); 119 http.use(deprecationMiddleware); 120 121 // 1d. TASK-0135: the per-request SERVER span (the spec's "spans per route"). 122 // Created under the request id (1a) so `requestId` is available; the span 123 // is made ACTIVE via the AsyncLocalStorage context manager so every 124 // auto-instrumented span inside the route (Prisma, ioredis, outgoing 125 // HTTP to OpenSearch/MinIO) parents under it. Ended (and the http metrics 126 // recorded) on `res` finish/close. 127 // Cardinality contract: the `route` metric label is the ROUTE PATTERN 128 // (`req.route.path`, e.g. `/api/v1/pages/:id`), never a concrete id. 129 // Unmatched requests (404s, no route) aggregate under a single bounded 130 // `<unmatched>` value so a typo storm cannot explode label cardinality. 131 // When OTel is disabled the global tracer is no-op → the whole thing is 132 // a free no-op (dev/test contract). 133 // Skips: /metrics (our own scrape — instrumenting it would emit a span 134 // per Prometheus pull) and CORS preflight OPTIONS (204 noise). 135 http.use((req: any, res: any, next: any) => { 136 const path: string = req.path; 137 if (path === "/metrics" || req.method === "OPTIONS") return next(); 138 const startedAt = Date.now(); 139 const span = otelTrace.getTracer("factory").startSpan("api.request", { 140 attributes: { 141 requestId: req.id, 142 method: req.method, 143 "http.request.method": req.method 144 } 145 }); 146 const headerWs = req.headers["x-factory-workspace"] as string | undefined; 147 if (headerWs) span.setAttribute("workspaceId", headerWs); 148 let finished = false; 149 const finish = (): void => { 150 if (finished) return; 151 finished = true; 152 const routePattern = 153 req.route && typeof req.route.path === "string" && req.route.path 154 ? req.route.path 155 : null; 156 span.setAttribute("route", routePattern ?? path); 157 span.setAttribute("http.response.status_code", res.statusCode); 158 // workspaceId may only resolve at auth (after the guard runs) — capture 159 // it at finish so the auth-attached value wins over the early header read. 160 const authWs = (req.auth as { workspaceId?: string } | undefined)?.workspaceId; 161 if (authWs) span.setAttribute("workspaceId", authWs); 162 m.http.record( 163 { route: routePattern ?? "<unmatched>", method: req.method, status: String(res.statusCode) }, 164 (Date.now() - startedAt) / 1000 165 ); 166 span.end(); 167 }; 168 res.on("finish", finish); 169 res.on("close", finish); 170 otelContext.with(otelTrace.setSpan(otelContext.active(), span), () => next()); 171 }); 172 173 // 1e. TASK-0135: Prometheus text on the API's OWN port (the scrape config 174 // targets :4000/metrics). renderPrometheus() returns "" when OTel is 175 // disabled → 204 (nothing to scrape). Registered on the raw adapter 176 // BEFORE the global prefix so the path is literally `/metrics`. 177 http.get("/metrics", async (_req: any, res: any) => { 178 const text = await renderPrometheus(); 179 if (!text) { 180 res.status(204).end(); 181 return; 182 } 183 res.setHeader("content-type", "text/plain; version=0.0.4; charset=utf-8"); 184 res.status(200).send(text); 185 }); 186 187 // 2. canonical error envelope for everything. 188 app.useGlobalFilters(new AllExceptionsFilter()); 189 190 // 3. NO global class-validator `ValidationPipe` (TASK-0100 boot-blocker fix, 191 // recorded per AGENTS rule 29): this repo validates request bodies with 192 // the Zod-based `ZodValidate` pipe on each controller (the live contract — 193 // see the `internal.endpoints.test.ts` note "class-validator, which this 194 // repo doesn't install"). Instantiating a class-validator `ValidationPipe` 195 // makes Nest's PackageLoader `process.exit(1)` at boot (a swallowed error 196 // under `logger:false`) the moment the optional `class-validator` peer is 197 // absent — which it always is, by design. The dead fallback is removed; 198 // no dependency is added. 199 200 // 4. strict CORS. 201 app.enableCors({ 202 origin: env.CORS_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean), 203 credentials: true, 204 methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], 205 allowedHeaders: ["authorization", "content-type", "x-request-id", "x-factory-workspace", "if-match", "idempotency-key"] 206 }); 207 208 // 5. base path. The internal service surface (TASK-0092 slice 2) is 209 // EXCLUDED from the prefix: canonical paths are `/internal/blocks/*` 210 // (service-token auth, never on the public `api/v1` route table). 211 // NOTE: the exclude match runs against the UNPREFIXED route path 212 // (NestJS `RoutePathFactory`), hence `internal/:path*`, not 213 // `api/v1/internal/*`. 214 // TASK-0131: the SSO PROTOCOL surface is excluded the same way — 215 // `/saml/*` (SP metadata + SP-initiated start + ACS form-POST) and 216 // `/sso/*` (OIDC authorize/callback + the anonymous slug start 217 // dispatcher) are BROWSER BINDINGS (302 redirects + HTML form posts), 218 // not the JSON v1 API. Their auth is the assertion/ID-token signature 219 // (auth.guard whitelists the prefixes; no user JWT). The SSO ADMIN 220 // routes (`/api/v1/orgs/:orgId/sso/*`) are NOT excluded — they stay on 221 // the v1 table (OpenAPI contract surface). 222 // TASK-0132: the SCIM PROTOCOL surface (`/scim/v2/*`) is a third-party 223 // IdP binding (bearer-token auth via `ScimBearerGuard`), also outside 224 // the v1 prefix. The SCIM ADMIN routes 225 // (`/api/v1/orgs/:orgId/scim/*`) stay on the v1 table. 226 app.setGlobalPrefix("api/v1", { 227 exclude: [ 228 { path: "internal/:path*", method: RequestMethod.ALL }, 229 { path: "saml/:path*", method: RequestMethod.ALL }, 230 { path: "sso/:path*", method: RequestMethod.ALL }, 231 { path: "scim/:path*", method: RequestMethod.ALL } 232 ] 233 }); 234 app.enableShutdownHooks(); 235 236 // TASK-0135: flush pending spans/metrics on the k8s termination signal 237 // (terminationGracePeriodSeconds: 30 — plenty for a batch export). 238 for (const signal of ["SIGTERM", "SIGINT"] as const) { 239 process.on(signal, () => { 240 void shutdownOtel().catch(() => undefined).finally(() => process.exit(0)); 241 }); 242 } 243 244 await app.listen(env.PORT, "0.0.0.0"); 245 logger.info(`api listening on :${env.PORT} (health :${env.PORT}/api/v1/health)`); 246} 247 248bootstrap().catch((err) => { 249 logger.error(`bootstrap failed: ${(err as Error).message}`, { stack: (err as Error).stack }); 250 process.exit(1); 251}); 252