import "reflect-metadata"; import { NestFactory } from "@nestjs/core"; import { RequestMethod } from "@nestjs/common"; import type { NestExpressApplication } from "@nestjs/platform-express"; import { v4 as uuid } from "uuid"; import { AppModule } from "./app.module.js"; import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter.js"; import { loadEnv } from "./config/env.js"; import { logger } from "./common/logger.js"; import { WEBHOOK_BODY_MAX_BYTES } from "@factory/shared"; import { initOtel, shutdownOtel, m, renderPrometheus, otelContext, otelTrace } from "@factory/observability"; /** * API bootstrap. * * Order matters: * 1. raw express middleware on the http adapter (request id, security * headers) — runs BEFORE Nest's router; * 2. global exception filter (canonical {error:{...}} envelope); * 3. (removed TASK-0100) the dead global class-validator `ValidationPipe` * — request validation is Zod-based per controller (`ZodValidate`); * 4. CORS from CORS_ORIGINS; * 5. global prefix `api/v1`. */ async function bootstrap(): Promise { const env = loadEnv(); // TASK-0135: OTel FIRST — before any request can be served, so the lazy // metric instruments (packages/observability metrics.ts) bind to the SDK // meter provider when they are first touched (the no-op-binding contract). // Disabled (no OTEL_EXPORTER_OTLP_ENDPOINT) → a free no-op. // The OTEL_* keys come straight from process.env (the k8s configmap sets // both; they are deliberately NOT in config/env.ts's zod schema — reading // process.env here keeps env.ts untouched). OTEL_ENABLED gates: the // endpoint alone does not mean "export" (staging default is off). const otelEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; await initOtel({ serviceName: "factory-api", environment: env.NODE_ENV, ...(process.env.OTEL_ENABLED === "true" && otelEndpoint ? { endpoint: otelEndpoint } : {}) }); const app = await NestFactory.create(AppModule, { logger: false, // we log ourselves (structured JSON) cors: false, // Phase 11 (TASK-0110): capture the EXACT request bytes as `req.rawBody` // (Nest's `rawBody` option = a body-parser `verify` hook). The // incoming-webhook endpoint verifies its HMAC-SHA256 signature over the // raw body; the JSON-parsed `req.body` is re-serialized and would NOT // match the signature. rawBody: true, // …and disable Nest's auto-mounted parsers: body-parser's DEFAULT json // limit is 100 KB, which would wrongly 413 a valid 100–256 KB webhook // body (spec: WEBHOOK_BODY_MAX_BYTES = 256 KB). We re-mount the parsers // ourselves below with the webhook ceiling as the global JSON limit (a // deliberate DoS cap — the incoming webhook is the only endpoint that // ever needs > 100 KB). bodyParser: false }); // T-165: the CSV import needs a 2 MB body (10k-row file) — above the 256 KB // global JSON ceiling. This route-scoped parser buffers ONLY // `POST /api/v1/databases/:id/import/csv` (413 past the cap) and sets // req._body so the global parser below skips it. It must be mounted BEFORE // the useBodyParser calls: Nest's parsers attach at init() onto the same // express stack, and express runs middleware in registration order. const { csvImportBodyParser } = await import("./common/csv-import-body.js"); (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use(csvImportBodyParser); // T-167: whiteboard canvas saves buffer up to 2 MB (same pattern — a // 2,000-object canvas exceeds the global 256 KB JSON ceiling). const { whiteboardBodyParser } = await import("./common/whiteboard-body.js"); (app.getHttpAdapter().getInstance() as unknown as { use: (m: unknown) => void }).use(whiteboardBodyParser); app.useBodyParser("json", { limit: WEBHOOK_BODY_MAX_BYTES }); // TASK-0132: SCIM 2.0 IdPs send `Content-Type: application/scim+json` // (RFC 7644) — body-parser's default json type matches ONLY // `application/json`, so mount a second json parser for the `+json` // suffix type (a second `json` parser with a custom `type` only parses // its own content type; the default one above is untouched). app.useBodyParser("json", { type: "application/scim+json", limit: WEBHOOK_BODY_MAX_BYTES }); app.useBodyParser("urlencoded", { extended: true }); const http = app.getHttpAdapter().getInstance(); // 1a. request id: x-request-id in → generated uuid if absent; echoed out. http.use((req: any, res: any, next: any) => { req.id = (req.headers["x-request-id"] as string | undefined) || uuid(); res.setHeader("x-request-id", req.id); next(); }); // 1b. security headers (no helmet dependency). http.use((req: any, res: any, next: any) => { res.setHeader("x-content-type-options", "nosniff"); res.setHeader("x-frame-options", "DENY"); res.setHeader("referrer-policy", "no-referrer"); // TASK-0127: /api/v1/docs is the one API surface that serves HTML — it // needs scripts (the vendored swagger-ui bundle + the inlined spec) and // styles from itself. Everything else stays 'none'. res.setHeader( "content-security-policy", req.path.startsWith("/api/v1/docs") ? "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" : "default-src 'none'" ); res.setHeader("permissions-policy", "geolocation=(), microphone=(), camera=()"); if (env.NODE_ENV === "production") { res.setHeader("strict-transport-security", "max-age=31536000; includeSubDomains"); } next(); }); // 1c. versioning + deprecation (TASK-0127): `Deprecation: true` + // `Sunset: ` on the routes listed in `common/deprecation.ts`. const { deprecationMiddleware } = await import("./common/deprecation.js"); http.use(deprecationMiddleware); // 1d. TASK-0135: the per-request SERVER span (the spec's "spans per route"). // Created under the request id (1a) so `requestId` is available; the span // is made ACTIVE via the AsyncLocalStorage context manager so every // auto-instrumented span inside the route (Prisma, ioredis, outgoing // HTTP to OpenSearch/MinIO) parents under it. Ended (and the http metrics // recorded) on `res` finish/close. // Cardinality contract: the `route` metric label is the ROUTE PATTERN // (`req.route.path`, e.g. `/api/v1/pages/:id`), never a concrete id. // Unmatched requests (404s, no route) aggregate under a single bounded // `` value so a typo storm cannot explode label cardinality. // When OTel is disabled the global tracer is no-op → the whole thing is // a free no-op (dev/test contract). // Skips: /metrics (our own scrape — instrumenting it would emit a span // per Prometheus pull) and CORS preflight OPTIONS (204 noise). http.use((req: any, res: any, next: any) => { const path: string = req.path; if (path === "/metrics" || req.method === "OPTIONS") return next(); const startedAt = Date.now(); const span = otelTrace.getTracer("factory").startSpan("api.request", { attributes: { requestId: req.id, method: req.method, "http.request.method": req.method } }); const headerWs = req.headers["x-factory-workspace"] as string | undefined; if (headerWs) span.setAttribute("workspaceId", headerWs); let finished = false; const finish = (): void => { if (finished) return; finished = true; const routePattern = req.route && typeof req.route.path === "string" && req.route.path ? req.route.path : null; span.setAttribute("route", routePattern ?? path); span.setAttribute("http.response.status_code", res.statusCode); // workspaceId may only resolve at auth (after the guard runs) — capture // it at finish so the auth-attached value wins over the early header read. const authWs = (req.auth as { workspaceId?: string } | undefined)?.workspaceId; if (authWs) span.setAttribute("workspaceId", authWs); m.http.record( { route: routePattern ?? "", method: req.method, status: String(res.statusCode) }, (Date.now() - startedAt) / 1000 ); span.end(); }; res.on("finish", finish); res.on("close", finish); otelContext.with(otelTrace.setSpan(otelContext.active(), span), () => next()); }); // 1e. TASK-0135: Prometheus text on the API's OWN port (the scrape config // targets :4000/metrics). renderPrometheus() returns "" when OTel is // disabled → 204 (nothing to scrape). Registered on the raw adapter // BEFORE the global prefix so the path is literally `/metrics`. http.get("/metrics", async (_req: any, res: any) => { const text = await renderPrometheus(); if (!text) { res.status(204).end(); return; } res.setHeader("content-type", "text/plain; version=0.0.4; charset=utf-8"); res.status(200).send(text); }); // 2. canonical error envelope for everything. app.useGlobalFilters(new AllExceptionsFilter()); // 3. NO global class-validator `ValidationPipe` (TASK-0100 boot-blocker fix, // recorded per AGENTS rule 29): this repo validates request bodies with // the Zod-based `ZodValidate` pipe on each controller (the live contract — // see the `internal.endpoints.test.ts` note "class-validator, which this // repo doesn't install"). Instantiating a class-validator `ValidationPipe` // makes Nest's PackageLoader `process.exit(1)` at boot (a swallowed error // under `logger:false`) the moment the optional `class-validator` peer is // absent — which it always is, by design. The dead fallback is removed; // no dependency is added. // 4. strict CORS. app.enableCors({ origin: env.CORS_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean), credentials: true, methods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"], allowedHeaders: ["authorization", "content-type", "x-request-id", "x-factory-workspace", "if-match", "idempotency-key"] }); // 5. base path. The internal service surface (TASK-0092 slice 2) is // EXCLUDED from the prefix: canonical paths are `/internal/blocks/*` // (service-token auth, never on the public `api/v1` route table). // NOTE: the exclude match runs against the UNPREFIXED route path // (NestJS `RoutePathFactory`), hence `internal/:path*`, not // `api/v1/internal/*`. // TASK-0131: the SSO PROTOCOL surface is excluded the same way — // `/saml/*` (SP metadata + SP-initiated start + ACS form-POST) and // `/sso/*` (OIDC authorize/callback + the anonymous slug start // dispatcher) are BROWSER BINDINGS (302 redirects + HTML form posts), // not the JSON v1 API. Their auth is the assertion/ID-token signature // (auth.guard whitelists the prefixes; no user JWT). The SSO ADMIN // routes (`/api/v1/orgs/:orgId/sso/*`) are NOT excluded — they stay on // the v1 table (OpenAPI contract surface). // TASK-0132: the SCIM PROTOCOL surface (`/scim/v2/*`) is a third-party // IdP binding (bearer-token auth via `ScimBearerGuard`), also outside // the v1 prefix. The SCIM ADMIN routes // (`/api/v1/orgs/:orgId/scim/*`) stay on the v1 table. app.setGlobalPrefix("api/v1", { exclude: [ { path: "internal/:path*", method: RequestMethod.ALL }, { path: "saml/:path*", method: RequestMethod.ALL }, { path: "sso/:path*", method: RequestMethod.ALL }, { path: "scim/:path*", method: RequestMethod.ALL } ] }); app.enableShutdownHooks(); // TASK-0135: flush pending spans/metrics on the k8s termination signal // (terminationGracePeriodSeconds: 30 — plenty for a batch export). for (const signal of ["SIGTERM", "SIGINT"] as const) { process.on(signal, () => { void shutdownOtel().catch(() => undefined).finally(() => process.exit(0)); }); } await app.listen(env.PORT, "0.0.0.0"); logger.info(`api listening on :${env.PORT} (health :${env.PORT}/api/v1/health)`); } bootstrap().catch((err) => { logger.error(`bootstrap failed: ${(err as Error).message}`, { stack: (err as Error).stack }); process.exit(1); });