#!/usr/bin/env node /** * OpenAPI 3.1 spec generator (TASK-0127). * * Scans `apps/api/src/modules/` (controller base path + method decorators) * and emits `docs/api/openapi.yaml` — the CANONICAL * contract for `/api/v1`. The bidirectional contract test * (`tests/integration/openapi.contract.test.ts`) then verifies: * * spec paths ⊆ live routes (every documented path exists, same method) * live public routes ⊆ spec (no undocumented public v1 route) * * The scan is intentionally the SAME source the router is built from, so a * controller change regenerates the spec; the contract test fails on a * controller change WITHOUT a regeneration (the CI gate). * * Run: `node scripts/generate-openapi.mjs` (from the repo root). */ import { createRequire } from "node:module"; import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs"; import { join } from "node:path"; const require = createRequire(join(process.cwd(), "apps", "api", "package.json")); const { stringify } = require("yaml"); const ROOT = process.cwd(); const API_SRC = join(ROOT, "apps", "api", "src"); /** Public v1 surface (no `Authorization` required — mirrors AuthGuard's whitelist). */ const PUBLIC_PATHS = new Set([ "/health", "/health/ready", "/health/deep", "/auth/register", "/auth/login", "/auth/refresh", "/auth/reset-password", "/auth/reset-password/confirm", "/auth/sso/options", "/share/:slug", "/public/forms/:slug", "/public/forms/:slug/submit", "/public/forms/:slug/files", "/public/forms/:slug/files/:fileId/complete", "/debug/events", "/webhooks/incoming/:webhookId", "/docs", "/docs/openapi.yaml", "/docs/assets/:file" ]); /** Self-scoped authenticated routes (no 403/404 data-scope responses). */ const SELF_SCOPED = new Set([ "/auth/me", "/users/me", "/users/me/profile", "/sessions", "/sessions/:id", "/notifications", "/notifications/:id/read", "/notifications/unread-count", "/notifications/read-all", "/notifications/preferences", "/search/recent", "/orgs", "/orgs/:id", "/orgs/:id/members/invites" ]); /** POSTs that create a new resource → 201 (everything else 200). */ const CREATE_POSTS = new Set([ "/workspaces", "/workspaces/:workspaceId/pages", "/workspaces/:workspaceId/api-keys", "/workspaces/:workspaceId/webhooks", "/workspaces/:workspaceId/teams", "/workspaces/:workspaceId/automations", "/workspaces/:workspaceId/integrations", "/orgs", "/shares", "/comments", "/pages/:pageId/blocks", "/blocks/batch", "/databases/:id/properties", "/databases/:id/views", "/databases/:id/records", "/databases/:id/relations", "/databases/:id/formulas", "/databases/:databaseId/forms", "/forms/:formId/questions", "/workspaces/:id/members/invites", "/auth/register", "/files/uploads", "/pages/:id/versions", "/pages/:id/whiteboard/convert", "/workspaces/:workspaceId/ai/conversations" ]); const METHOD_MAP = { Get: "get", Post: "post", Put: "put", Patch: "patch", Delete: "delete" }; /** Collect controller files. */ function* walk(dir) { for (const entry of readdirSync(dir)) { const full = join(dir, entry); const st = statSync(full); if (st.isDirectory()) yield* walk(full); else if (entry.endsWith(".controller.ts")) yield full; } } function pascal(s) { return s .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase()) .replace(/^(.)/, (_, c) => c.toUpperCase()); } /** * Yield each `@Controller("base")` class block in a file with its route * decorators. A file may declare MULTIPLE controller classes (e.g. * `oauth.controller.ts` has the public `oauth` surface AND the workspace * `workspaces/:workspaceId/oauth/apps` sub-resource) — pairing every route * in the file with the FIRST base (the old behavior) silently re-homed the * later classes' routes under the wrong prefix. Each route therefore * belongs to the nearest PRECEDING `@Controller` within its class body. */ function* controllerBlocks(txt) { const re = /@Controller\(\s*(?:"([^"]*)"|([A-Za-z0-9_\/-]+))?\s*\)/g; const heads = []; let m; while ((m = re.exec(txt))) { heads.push({ base: (m[1] ?? m[2] ?? "").replace(/^\/+|\/+$/g, ""), start: m.index }); } for (let i = 0; i < heads.length; i++) { const end = i + 1 < heads.length ? heads[i + 1].start : txt.length; yield { base: heads[i].base, body: txt.slice(heads[i].start, end) }; } } // ---- collect routes ----------------------------------------------------------- /** path → { method: { summary, tag, public, creates } } */ const table = new Map(); /** Controllers mounted OUTSIDE the `api/v1` prefix (excluded in * main.ts) — not part of the v1 contract surface: `internal` (service * token), `saml` (SAML SP: metadata + start + ACS, browser binding), * `sso` (OIDC RP: authorize/callback + the anonymous start dispatcher — * browser redirects, not JSON API). TASK-0131. `scim/v2` — the SCIM 2.0 * protocol surface (third-party IdP binding, bearer-token auth; its own * contract lives in docs/api/scim.md). TASK-0132. */ const NON_V1_BASES = new Set(["internal", "saml", "sso", "scim/v2"]); for (const file of walk(join(API_SRC, "modules"))) { const txt = readFileSync(file, "utf8"); for (const { base, body } of controllerBlocks(txt)) { // `internal` has SUB-BASES (e.g. `internal/workspaces` in the test // seams) — keep the prefix skip for it; the others are exact bases. if (base.startsWith("internal") || NON_V1_BASES.has(base)) continue; // not on the public v1 route table for (const mm of body.matchAll(/@(Get|Post|Put|Patch|Delete)\(\s*(?:"([^"]*)")?\s*\)/g)) { const verb = METHOD_MAP[mm[1]]; const arg = (mm[2] ?? "").replace(/^\/+|\/+$/g, ""); let full = `/${[base, arg].filter(Boolean).join("/")}`.replace(/\/{2,}/g, "/"); full = full.replace(/\/+$/, ""); if (full === "") full = "/"; const entry = table.get(full) ?? {}; entry[verb] = { file }; table.set(full, entry); } } } // ---- tag + summary heuristics ------------------------------------------------- const TAG_LABEL = { workspaces: "Workspaces", pages: "Pages", blocks: "Blocks", databases: "Databases", records: "Records", comments: "Comments", files: "Files", search: "Search", notifications: "Notifications", sessions: "Sessions", orgs: "Orgs", users: "Users", shares: "Sharing", share: "Sharing", teams: "Teams", "api-keys": "API Keys", webhooks: "Webhooks", automations: "Automations", integrations: "Integrations", forms: "Forms", public: "Public Forms", auth: "Auth", health: "Health", docs: "API Docs", ai: "AI", oauth: "OAuth2" }; function tagFor(path) { const segs = path.split("/").filter(Boolean); if (segs[0] === "workspaces" && segs[1] === ":workspaceId") { const sub = segs[2] ?? "workspaces"; return TAG_LABEL[sub] ?? pascal(sub); } if (segs[0] === "workspaces") return "Workspaces"; if (segs[0] === "public") return "Public Forms"; if (segs[0] === "share") return "Sharing"; if (segs[0] === "ai") { // Personal AI conversations (user-identity-only surface). if (segs[1] === "conversations") return "AI Conversations"; return "AI"; } return TAG_LABEL[segs[0]] ?? pascal(segs[0]); } const SUMMARY_OVERRIDES = { "get /auth/sso/options": "SSO discovery for the login page (anonymous, minimal-info: org slugs with SSO enabled)", "post /orgs/{orgId}/sso": "Configure the org's SSO provider (SAML or OIDC; secrets are reveal-once, encrypted at rest)", "get /orgs/{orgId}/sso": "Get the org's SSO provider (secrets masked to configured-flags)", "patch /orgs/{orgId}/sso": "Update the SSO provider (config + certificate/secret rotation)", "delete /orgs/{orgId}/sso": "Remove the SSO provider (and its encrypted secrets)", "patch /orgs/{orgId}/sso/enforce": "Toggle SSO enforce mode (mirror-writes Organization.policy.ssoEnforced)", "post /orgs/{orgId}/sso/test": "Test the SSO connection (SAML metadata / OIDC discovery + JWKS probe)", "post /orgs/{orgId}/scim": "Enable SCIM provisioning (generates a scim_… bearer token — reveal-once)", "get /orgs/{orgId}/scim": "Get the org's SCIM config (masked — no raw token)", "patch /orgs/{orgId}/scim": "Update SCIM config / rotate token / disable (disable invalidates immediately)", "post /orgs/{orgId}/scim/test": "Test the SCIM token (browser pastes it back — the server never stores the raw)", "post /auth/register": "Register a new account", "post /auth/login": "Log in (email + password)", "post /auth/refresh": "Refresh the session tokens", "post /auth/logout": "Log out the current session", "post /auth/reset-password": "Request a password reset", "post /auth/reset-password/confirm": "Confirm a password reset", "post /webhooks/incoming/:webhookId": "Receive an incoming automation webhook payload", "get /search": "Search (POST query)", "post /search": "Run a workspace search", "get /users": "List users (workspace context)", "delete /sessions": "End all other sessions", "get /notifications/unread-count": "Get the unread notification count", "post /notifications/read-all": "Mark all notifications read", "get /orgs": "List the caller's organizations", "post /orgs": "Create an organization", "post /share/:slug": "Open a public share link (unauthenticated)", "get /public/forms/:slug": "Open a public form (unauthenticated)", "post /public/forms/:slug/submit": "Submit a public form (unauthenticated)", "post /files/:id/rollback": "Roll a file back to a previous version", "post /files/:id/restore": "Restore a file from the trash", "post /pages/:id/restore": "Restore a page from the trash", "delete /pages/:id/permanent": "Permanently delete a page", "post /pages/:id/templates": "Save a page as a template", "get /blocks/batch": "Batch block operation", "post /blocks/batch": "Batch block operation", "get /workspaces/:workspaceId/templates": "List workspace templates", "post /workspaces/:workspaceId/api-keys": "Create an API key", "get /workspaces/:workspaceId/api-keys": "List the workspace's API keys", "patch /workspaces/:workspaceId/api-keys/:id": "Update an API key (scope / rate limit / expiry / name)", "delete /workspaces/:workspaceId/api-keys/:id": "Revoke an API key", "post /workspaces/:workspaceId/ai/ping": "AI availability ping", "get /workspaces/:workspaceId/ai/conversations": "List AI conversations (user-identity only)", "post /workspaces/:workspaceId/ai/conversations": "Create an AI conversation (user-identity only)", "get /ai/conversations/:id": "Get an AI conversation (user-identity only)", "patch /ai/conversations/:id": "Update an AI conversation (user-identity only)", "delete /ai/conversations/:id": "Delete an AI conversation (user-identity only)", "get /ai/conversations/:id/messages": "List conversation messages (user-identity only)", "post /workspaces/:workspaceId/ai/chat": "AI chat (user-identity only)", "post /workspaces/:workspaceId/ai/conversations/:conversationId/messages": "Send a message in a conversation (user-identity only)" }; function summaryFor(method, path, tag) { const override = SUMMARY_OVERRIDES[`${method} ${path}`]; if (override) return override; if (path === "/users/me" && method === "get") return "Get the current user"; if (path === "/auth/me") return "Get the authenticated user"; if (path === "/health" || path.startsWith("/health")) return "Liveness/readiness probe"; if (path === "/docs") return "Interactive API documentation (swagger-ui)"; if (path === "/docs/openapi.yaml") return "Download the OpenAPI 3.1 spec (YAML)"; const segs = path.split("/").filter(Boolean); const params = segs.filter((s) => s.startsWith(":")); const isList = method === "get" && !params.length; const verb = isList ? "List" : { get: "Get", post: "Create", put: "Replace", patch: "Update", delete: "Delete" }[method]; const target = params.length ? ` by ${params.map((p) => p.slice(1)).join(" + ")}` : ""; return `${verb} ${tag.toLowerCase()}${target}`; } // ---- build the document ------------------------------------------------------- const usedOpIds = new Set(); function opId(method, path) { const segs = path.split("/").filter(Boolean); const parts = segs.map((s) => (s.startsWith(":") ? "By" + pascal(s.slice(1)) : pascal(s))); let id = method[0].toUpperCase() + method.slice(1) + parts.join(""); if (id === method[0].toUpperCase() + method.slice(1)) id += "Root"; let unique = id; let n = 2; while (usedOpIds.has(unique)) unique = `${id}${n++}`; usedOpIds.add(unique); return unique; } const errorResponse = (code, message) => ({ $ref: `#/components/responses/Err${code}`, description: message }); const paths = {}; const tags = new Map(); /** OpenAPI 3.1 path-template form: `:param` (Express) → `{param}`. */ const toSpecPath = (p) => p.replace(/:([a-zA-Z0-9_]+)/g, "{$1}"); for (const [path, methods] of [...table.entries()].sort()) { const tag = tagFor(path); tags.set(tag, { name: tag, description: `${tag} endpoints (v1).` }); const obj = {}; for (const [method, meta] of Object.entries(methods)) { const isPublic = PUBLIC_PATHS.has(path); const selfScoped = SELF_SCOPED.has(path); const mutating = method !== "get"; const create = method === "post" && CREATE_POSTS.has(path); const op = { operationId: opId(method, path), tags: [tag], summary: summaryFor(method, path, tag), security: isPublic ? [] : [{ bearerAuth: [] }], parameters: [], responses: {} }; // Path parameters (exact names — the contract test matches on these). for (const m of path.matchAll(/:([a-zA-Z]+)/g)) { op.parameters.push({ name: m[1], in: "path", required: true, schema: { type: "string" } }); } // The workspace binding header (workspace-scoped data routes). if (/^\/workspaces\/:workspaceId\//.test(path) && !isPublic) { op.parameters.push({ $ref: "#/components/parameters/FactoryWorkspace" }); } // The idempotency key (all mutating calls). if (mutating) op.parameters.push({ $ref: "#/components/parameters/IdempotencyKey" }); // Success. const success = create ? "201" : "200"; op.responses[success] = { description: create ? "The created resource." : "OK.", content: { "application/json": { schema: { type: "object" } } } }; // Errors (docs/api/authentication.md §9 canonical codes). if (!isPublic) op.responses["401"] = errorResponse(401, "UNAUTHORIZED — missing/invalid credentials."); if (!isPublic && !selfScoped) { op.responses["403"] = errorResponse(403, "FORBIDDEN — insufficient permission or API-key scope (SCOPE_DENIED)."); op.responses["404"] = errorResponse(404, "NOT_FOUND — resource does not exist for this caller."); } if (mutating) { op.responses["400"] = errorResponse(400, "VALIDATION_ERROR — request validation failed."); if (create || method === "patch" || method === "put") op.responses["409"] = errorResponse(409, "CONFLICT — duplicate or state conflict."); } op.responses["429"] = errorResponse(429, "RATE_LIMITED — rate limit exceeded (see X-RateLimit-* headers)."); op.responses["500"] = errorResponse(500, "INTERNAL_ERROR — unexpected server error."); obj[method] = op; } // The spec path uses the OpenAPI `{param}` template form (the live // Express route uses `:param` — the contract test normalizes both). paths[toSpecPath(path)] = obj; } const doc = { openapi: "3.1.0", info: { title: "AI Software Factory — Public API v1", version: "1.0.0", description: [ "The public REST API of AI Software Factory. Base URL: `/api/v1`.", "", "## Versioning", "`/api/v1` is the STABLE surface. Breaking changes ship only in a new major (`/api/v2`, side-by-side). A deprecated route carries `Deprecation: true` + `Sunset: ` headers and `deprecated: true` here. See `docs/api/versioning.md`.", "", "## Authentication", "Send `Authorization: Bearer ` where `` is either a user JWT (web sessions) or an API key (`fk_live_…`). API keys act as their WORKSPACE (docs §7) and carry scopes `READ` / `WRITE` / `ADMIN`; a scope-insufficient call returns `403 FORBIDDEN` with `error.details[0].reason = \"api_key_scope_write\"` (public alias **SCOPE_DENIED**), a cross-workspace call returns `403 … \"workspace_mismatch\"`. The `:workspaceId` path segment is the authoritative workspace selector; the optional `X-Factory-Workspace` header disambiguates multi-workspace users and MUST equal the key's own workspace when present.", "", "## Rate limits", "Every response carries `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`. Per API key: 600 req/min by default, or the key's own `rateLimit` if set (docs §11).", "", "## Idempotency", "Mutating requests may carry an `Idempotency-Key` header; 2xx responses are memoized for 24 h and replays carry `Idempotency-Replayed: true` (docs §9).", "", "## Errors", "Every non-2xx response is `{ \"error\": { \"code\", \"message\", \"details\"?, \"requestId\" } }` (`details[].reason` is the machine-readable reason). See `docs/api/authentication.md` §9 for the canonical code table." ].join("\n") }, servers: [{ url: "/api/v1", description: "Public API v1 (same-origin). Use the absolute URL in deployments." }], tags: [...tags.values()], paths, components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT or API key (fk_…)", description: "User session JWT, or an `fk_` API key (the key acts as its workspace; scopes READ/WRITE/ADMIN)." } }, parameters: { FactoryWorkspace: { name: "X-Factory-Workspace", in: "header", required: false, description: "Optional workspace context. For API keys: if present, MUST equal the key's own workspace (else 403 workspace_mismatch). For users: if the JWT covers several workspaces, selects the active one (must be a membership). The path's `:workspaceId` segment is the authoritative selector on workspace-scoped routes.", schema: { type: "string" } }, IdempotencyKey: { name: "Idempotency-Key", in: "header", required: false, description: "Client-chosen unique key (≤ 128 chars). A repeated key within 24 h replays the stored 2xx response (header `Idempotency-Replayed: true`) instead of re-executing.", schema: { type: "string", maxLength: 128 } } }, schemas: { ErrorDetail: { type: "object", properties: { reason: { type: "string", description: "Machine-readable reason (e.g. api_key_scope_write, workspace_mismatch, requires_user_credentials)." }, field: { type: "string", description: "Validation: the offending field." }, issue: { type: "string", description: "Validation: the issue (required, already_exists, …)." } } }, ErrorResponse: { type: "object", required: ["error"], properties: { error: { type: "object", required: ["code", "message", "requestId"], properties: { code: { type: "string", description: "Canonical error code (VALIDATION_ERROR, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, UNPROCESSABLE_ENTITY, PAYLOAD_TOO_LARGE, RATE_LIMITED, INTERNAL_ERROR, …)." }, message: { type: "string" }, details: { type: "array", items: { $ref: "#/components/schemas/ErrorDetail" } }, requestId: { type: "string" } } } } } }, responses: { Err400: { description: "Validation failed.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, Err401: { description: "Unauthenticated.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, Err403: { description: "Forbidden (permission / API-key scope / workspace mismatch).", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, Err404: { description: "Not found.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, Err409: { description: "Conflict.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, Err422: { description: "Unprocessable (domain validation).", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, Err429: { description: "Rate limited.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }, Err500: { description: "Internal error.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } } } } }; const outPath = join(ROOT, "docs", "api", "openapi.yaml"); writeFileSync(outPath, stringify(doc, { lineWidth: 120 })); const nPaths = Object.keys(paths).length; const nOps = Object.values(paths).reduce((n, m) => n + Object.keys(m).length, 0); console.log(`wrote ${outPath}: ${nPaths} paths, ${nOps} operations`);