1#!/usr/bin/env node
2
19import { createRequire } from "node:module";
20import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
21import { join } from "node:path";
22
23const require = createRequire(join(process.cwd(), "apps", "api", "package.json"));
24const { stringify } = require("yaml");
25
26const ROOT = process.cwd();
27const API_SRC = join(ROOT, "apps", "api", "src");
28
29
30const PUBLIC_PATHS = new Set([
31 "/health",
32 "/health/ready",
33 "/health/deep",
34 "/auth/register",
35 "/auth/login",
36 "/auth/refresh",
37 "/auth/reset-password",
38 "/auth/reset-password/confirm",
39 "/auth/sso/options",
40 "/share/:slug",
41 "/public/forms/:slug",
42 "/public/forms/:slug/submit",
43 "/public/forms/:slug/files",
44 "/public/forms/:slug/files/:fileId/complete",
45 "/debug/events",
46 "/webhooks/incoming/:webhookId",
47 "/docs",
48 "/docs/openapi.yaml",
49 "/docs/assets/:file"
50]);
51
52
53const SELF_SCOPED = new Set([
54 "/auth/me",
55 "/users/me",
56 "/users/me/profile",
57 "/sessions",
58 "/sessions/:id",
59 "/notifications",
60 "/notifications/:id/read",
61 "/notifications/unread-count",
62 "/notifications/read-all",
63 "/notifications/preferences",
64 "/search/recent",
65 "/orgs",
66 "/orgs/:id",
67 "/orgs/:id/members/invites"
68]);
69
70
71const CREATE_POSTS = new Set([
72 "/workspaces",
73 "/workspaces/:workspaceId/pages",
74 "/workspaces/:workspaceId/api-keys",
75 "/workspaces/:workspaceId/webhooks",
76 "/workspaces/:workspaceId/teams",
77 "/workspaces/:workspaceId/automations",
78 "/workspaces/:workspaceId/integrations",
79 "/orgs",
80 "/shares",
81 "/comments",
82 "/pages/:pageId/blocks",
83 "/blocks/batch",
84 "/databases/:id/properties",
85 "/databases/:id/views",
86 "/databases/:id/records",
87 "/databases/:id/relations",
88 "/databases/:id/formulas",
89 "/databases/:databaseId/forms",
90 "/forms/:formId/questions",
91 "/workspaces/:id/members/invites",
92 "/auth/register",
93 "/files/uploads",
94 "/pages/:id/versions",
95 "/pages/:id/whiteboard/convert",
96 "/workspaces/:workspaceId/ai/conversations"
97]);
98
99const METHOD_MAP = { Get: "get", Post: "post", Put: "put", Patch: "patch", Delete: "delete" };
100
101
102function* walk(dir) {
103 for (const entry of readdirSync(dir)) {
104 const full = join(dir, entry);
105 const st = statSync(full);
106 if (st.isDirectory()) yield* walk(full);
107 else if (entry.endsWith(".controller.ts")) yield full;
108 }
109}
110
111function pascal(s) {
112 return s
113 .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
114 .replace(/^(.)/, (_, c) => c.toUpperCase());
115}
116
117
126function* controllerBlocks(txt) {
127 const re = /@Controller\(\s*(?:"([^"]*)"|([A-Za-z0-9_\/-]+))?\s*\)/g;
128 const heads = [];
129 let m;
130 while ((m = re.exec(txt))) {
131 heads.push({ base: (m[1] ?? m[2] ?? "").replace(/^\/+|\/+$/g, ""), start: m.index });
132 }
133 for (let i = 0; i < heads.length; i++) {
134 const end = i + 1 < heads.length ? heads[i + 1].start : txt.length;
135 yield { base: heads[i].base, body: txt.slice(heads[i].start, end) };
136 }
137}
138
139
140
141
142const table = new Map();
143
144
151const NON_V1_BASES = new Set(["internal", "saml", "sso", "scim/v2"]);
152
153for (const file of walk(join(API_SRC, "modules"))) {
154 const txt = readFileSync(file, "utf8");
155 for (const { base, body } of controllerBlocks(txt)) {
156
157
158 if (base.startsWith("internal") || NON_V1_BASES.has(base)) continue;
159 for (const mm of body.matchAll(/@(Get|Post|Put|Patch|Delete)\(\s*(?:"([^"]*)")?\s*\)/g)) {
160 const verb = METHOD_MAP[mm[1]];
161 const arg = (mm[2] ?? "").replace(/^\/+|\/+$/g, "");
162 let full = `/${[base, arg].filter(Boolean).join("/")}`.replace(/\/{2,}/g, "/");
163 full = full.replace(/\/+$/, "");
164 if (full === "") full = "/";
165
166 const entry = table.get(full) ?? {};
167 entry[verb] = { file };
168 table.set(full, entry);
169 }
170 }
171}
172
173
174
175const TAG_LABEL = {
176 workspaces: "Workspaces",
177 pages: "Pages",
178 blocks: "Blocks",
179 databases: "Databases",
180 records: "Records",
181 comments: "Comments",
182 files: "Files",
183 search: "Search",
184 notifications: "Notifications",
185 sessions: "Sessions",
186 orgs: "Orgs",
187 users: "Users",
188 shares: "Sharing",
189 share: "Sharing",
190 teams: "Teams",
191 "api-keys": "API Keys",
192 webhooks: "Webhooks",
193 automations: "Automations",
194 integrations: "Integrations",
195 forms: "Forms",
196 public: "Public Forms",
197 auth: "Auth",
198 health: "Health",
199 docs: "API Docs",
200 ai: "AI",
201 oauth: "OAuth2"
202};
203
204function tagFor(path) {
205 const segs = path.split("/").filter(Boolean);
206 if (segs[0] === "workspaces" && segs[1] === ":workspaceId") {
207 const sub = segs[2] ?? "workspaces";
208 return TAG_LABEL[sub] ?? pascal(sub);
209 }
210 if (segs[0] === "workspaces") return "Workspaces";
211 if (segs[0] === "public") return "Public Forms";
212 if (segs[0] === "share") return "Sharing";
213 if (segs[0] === "ai") {
214
215 if (segs[1] === "conversations") return "AI Conversations";
216 return "AI";
217 }
218 return TAG_LABEL[segs[0]] ?? pascal(segs[0]);
219}
220
221const SUMMARY_OVERRIDES = {
222 "get /auth/sso/options": "SSO discovery for the login page (anonymous, minimal-info: org slugs with SSO enabled)",
223 "post /orgs/{orgId}/sso": "Configure the org's SSO provider (SAML or OIDC; secrets are reveal-once, encrypted at rest)",
224 "get /orgs/{orgId}/sso": "Get the org's SSO provider (secrets masked to configured-flags)",
225 "patch /orgs/{orgId}/sso": "Update the SSO provider (config + certificate/secret rotation)",
226 "delete /orgs/{orgId}/sso": "Remove the SSO provider (and its encrypted secrets)",
227 "patch /orgs/{orgId}/sso/enforce": "Toggle SSO enforce mode (mirror-writes Organization.policy.ssoEnforced)",
228 "post /orgs/{orgId}/sso/test": "Test the SSO connection (SAML metadata / OIDC discovery + JWKS probe)",
229 "post /orgs/{orgId}/scim": "Enable SCIM provisioning (generates a scim_… bearer token — reveal-once)",
230 "get /orgs/{orgId}/scim": "Get the org's SCIM config (masked — no raw token)",
231 "patch /orgs/{orgId}/scim": "Update SCIM config / rotate token / disable (disable invalidates immediately)",
232 "post /orgs/{orgId}/scim/test": "Test the SCIM token (browser pastes it back — the server never stores the raw)",
233 "post /auth/register": "Register a new account",
234 "post /auth/login": "Log in (email + password)",
235 "post /auth/refresh": "Refresh the session tokens",
236 "post /auth/logout": "Log out the current session",
237 "post /auth/reset-password": "Request a password reset",
238 "post /auth/reset-password/confirm": "Confirm a password reset",
239 "post /webhooks/incoming/:webhookId": "Receive an incoming automation webhook payload",
240 "get /search": "Search (POST query)",
241 "post /search": "Run a workspace search",
242 "get /users": "List users (workspace context)",
243 "delete /sessions": "End all other sessions",
244 "get /notifications/unread-count": "Get the unread notification count",
245 "post /notifications/read-all": "Mark all notifications read",
246 "get /orgs": "List the caller's organizations",
247 "post /orgs": "Create an organization",
248 "post /share/:slug": "Open a public share link (unauthenticated)",
249 "get /public/forms/:slug": "Open a public form (unauthenticated)",
250 "post /public/forms/:slug/submit": "Submit a public form (unauthenticated)",
251 "post /files/:id/rollback": "Roll a file back to a previous version",
252 "post /files/:id/restore": "Restore a file from the trash",
253 "post /pages/:id/restore": "Restore a page from the trash",
254 "delete /pages/:id/permanent": "Permanently delete a page",
255 "post /pages/:id/templates": "Save a page as a template",
256 "get /blocks/batch": "Batch block operation",
257 "post /blocks/batch": "Batch block operation",
258 "get /workspaces/:workspaceId/templates": "List workspace templates",
259 "post /workspaces/:workspaceId/api-keys": "Create an API key",
260 "get /workspaces/:workspaceId/api-keys": "List the workspace's API keys",
261 "patch /workspaces/:workspaceId/api-keys/:id": "Update an API key (scope / rate limit / expiry / name)",
262 "delete /workspaces/:workspaceId/api-keys/:id": "Revoke an API key",
263 "post /workspaces/:workspaceId/ai/ping": "AI availability ping",
264 "get /workspaces/:workspaceId/ai/conversations": "List AI conversations (user-identity only)",
265 "post /workspaces/:workspaceId/ai/conversations": "Create an AI conversation (user-identity only)",
266 "get /ai/conversations/:id": "Get an AI conversation (user-identity only)",
267 "patch /ai/conversations/:id": "Update an AI conversation (user-identity only)",
268 "delete /ai/conversations/:id": "Delete an AI conversation (user-identity only)",
269 "get /ai/conversations/:id/messages": "List conversation messages (user-identity only)",
270 "post /workspaces/:workspaceId/ai/chat": "AI chat (user-identity only)",
271 "post /workspaces/:workspaceId/ai/conversations/:conversationId/messages": "Send a message in a conversation (user-identity only)"
272};
273
274function summaryFor(method, path, tag) {
275 const override = SUMMARY_OVERRIDES[`${method} ${path}`];
276 if (override) return override;
277 if (path === "/users/me" && method === "get") return "Get the current user";
278 if (path === "/auth/me") return "Get the authenticated user";
279 if (path === "/health" || path.startsWith("/health")) return "Liveness/readiness probe";
280 if (path === "/docs") return "Interactive API documentation (swagger-ui)";
281 if (path === "/docs/openapi.yaml") return "Download the OpenAPI 3.1 spec (YAML)";
282
283 const segs = path.split("/").filter(Boolean);
284 const params = segs.filter((s) => s.startsWith(":"));
285 const isList = method === "get" && !params.length;
286 const verb = isList ? "List" : { get: "Get", post: "Create", put: "Replace", patch: "Update", delete: "Delete" }[method];
287 const target = params.length ? ` by ${params.map((p) => p.slice(1)).join(" + ")}` : "";
288 return `${verb} ${tag.toLowerCase()}${target}`;
289}
290
291
292
293const usedOpIds = new Set();
294function opId(method, path) {
295 const segs = path.split("/").filter(Boolean);
296 const parts = segs.map((s) => (s.startsWith(":") ? "By" + pascal(s.slice(1)) : pascal(s)));
297 let id = method[0].toUpperCase() + method.slice(1) + parts.join("");
298 if (id === method[0].toUpperCase() + method.slice(1)) id += "Root";
299 let unique = id;
300 let n = 2;
301 while (usedOpIds.has(unique)) unique = `${id}${n++}`;
302 usedOpIds.add(unique);
303 return unique;
304}
305
306const errorResponse = (code, message) => ({
307 $ref: `#/components/responses/Err${code}`,
308 description: message
309});
310
311const paths = {};
312const tags = new Map();
313
314
315const toSpecPath = (p) => p.replace(/:([a-zA-Z0-9_]+)/g, "{$1}");
316
317for (const [path, methods] of [...table.entries()].sort()) {
318 const tag = tagFor(path);
319 tags.set(tag, { name: tag, description: `${tag} endpoints (v1).` });
320 const obj = {};
321 for (const [method, meta] of Object.entries(methods)) {
322 const isPublic = PUBLIC_PATHS.has(path);
323 const selfScoped = SELF_SCOPED.has(path);
324 const mutating = method !== "get";
325 const create = method === "post" && CREATE_POSTS.has(path);
326 const op = {
327 operationId: opId(method, path),
328 tags: [tag],
329 summary: summaryFor(method, path, tag),
330 security: isPublic ? [] : [{ bearerAuth: [] }],
331 parameters: [],
332 responses: {}
333 };
334
335
336 for (const m of path.matchAll(/:([a-zA-Z]+)/g)) {
337 op.parameters.push({
338 name: m[1],
339 in: "path",
340 required: true,
341 schema: { type: "string" }
342 });
343 }
344
345 if (/^\/workspaces\/:workspaceId\
346 op.parameters.push({ $ref: "#/components/parameters/FactoryWorkspace" });
347 }
348
349 if (mutating) op.parameters.push({ $ref: "#/components/parameters/IdempotencyKey" });
350
351
352 const success = create ? "201" : "200";
353 op.responses[success] = {
354 description: create ? "The created resource." : "OK.",
355 content: { "application/json": { schema: { type: "object" } } }
356 };
357
358
359 if (!isPublic) op.responses["401"] = errorResponse(401, "UNAUTHORIZED — missing/invalid credentials.");
360 if (!isPublic && !selfScoped) {
361 op.responses["403"] = errorResponse(403, "FORBIDDEN — insufficient permission or API-key scope (SCOPE_DENIED).");
362 op.responses["404"] = errorResponse(404, "NOT_FOUND — resource does not exist for this caller.");
363 }
364 if (mutating) {
365 op.responses["400"] = errorResponse(400, "VALIDATION_ERROR — request validation failed.");
366 if (create || method === "patch" || method === "put") op.responses["409"] = errorResponse(409, "CONFLICT — duplicate or state conflict.");
367 }
368 op.responses["429"] = errorResponse(429, "RATE_LIMITED — rate limit exceeded (see X-RateLimit-* headers).");
369 op.responses["500"] = errorResponse(500, "INTERNAL_ERROR — unexpected server error.");
370
371 obj[method] = op;
372 }
373
374
375 paths[toSpecPath(path)] = obj;
376}
377
378const doc = {
379 openapi: "3.1.0",
380 info: {
381 title: "AI Software Factory — Public API v1",
382 version: "1.0.0",
383 description: [
384 "The public REST API of AI Software Factory. Base URL: `/api/v1`.",
385 "",
386 "## Versioning",
387 "`/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: <RFC 1123 date>` headers and `deprecated: true` here. See `docs/api/versioning.md`.",
388 "",
389 "## Authentication",
390 "Send `Authorization: Bearer <token>` where `<token>` 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.",
391 "",
392 "## Rate limits",
393 "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).",
394 "",
395 "## Idempotency",
396 "Mutating requests may carry an `Idempotency-Key` header; 2xx responses are memoized for 24 h and replays carry `Idempotency-Replayed: true` (docs §9).",
397 "",
398 "## Errors",
399 "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."
400 ].join("\n")
401 },
402 servers: [{ url: "/api/v1", description: "Public API v1 (same-origin). Use the absolute URL in deployments." }],
403 tags: [...tags.values()],
404 paths,
405 components: {
406 securitySchemes: {
407 bearerAuth: {
408 type: "http",
409 scheme: "bearer",
410 bearerFormat: "JWT or API key (fk_…)",
411 description: "User session JWT, or an `fk_` API key (the key acts as its workspace; scopes READ/WRITE/ADMIN)."
412 }
413 },
414 parameters: {
415 FactoryWorkspace: {
416 name: "X-Factory-Workspace",
417 in: "header",
418 required: false,
419 description:
420 "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.",
421 schema: { type: "string" }
422 },
423 IdempotencyKey: {
424 name: "Idempotency-Key",
425 in: "header",
426 required: false,
427 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.",
428 schema: { type: "string", maxLength: 128 }
429 }
430 },
431 schemas: {
432 ErrorDetail: {
433 type: "object",
434 properties: {
435 reason: { type: "string", description: "Machine-readable reason (e.g. api_key_scope_write, workspace_mismatch, requires_user_credentials)." },
436 field: { type: "string", description: "Validation: the offending field." },
437 issue: { type: "string", description: "Validation: the issue (required, already_exists, …)." }
438 }
439 },
440 ErrorResponse: {
441 type: "object",
442 required: ["error"],
443 properties: {
444 error: {
445 type: "object",
446 required: ["code", "message", "requestId"],
447 properties: {
448 code: {
449 type: "string",
450 description: "Canonical error code (VALIDATION_ERROR, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, UNPROCESSABLE_ENTITY, PAYLOAD_TOO_LARGE, RATE_LIMITED, INTERNAL_ERROR, …)."
451 },
452 message: { type: "string" },
453 details: { type: "array", items: { $ref: "#/components/schemas/ErrorDetail" } },
454 requestId: { type: "string" }
455 }
456 }
457 }
458 }
459 },
460 responses: {
461 Err400: { description: "Validation failed.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
462 Err401: { description: "Unauthenticated.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
463 Err403: { description: "Forbidden (permission / API-key scope / workspace mismatch).", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
464 Err404: { description: "Not found.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
465 Err409: { description: "Conflict.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
466 Err422: { description: "Unprocessable (domain validation).", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
467 Err429: { description: "Rate limited.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
468 Err500: { description: "Internal error.", content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
469 }
470 }
471};
472
473const outPath = join(ROOT, "docs", "api", "openapi.yaml");
474writeFileSync(outPath, stringify(doc, { lineWidth: 120 }));
475const nPaths = Object.keys(paths).length;
476const nOps = Object.values(paths).reduce((n, m) => n + Object.keys(m).length, 0);
477console.log(`wrote ${outPath}: ${nPaths} paths, ${nOps} operations`);
478