theokit 0.72.1 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/{actions-virtual-module-OHENNMQG.js → actions-virtual-module-J2HYO7QZ.js} +4 -4
- package/dist/{actions-virtual-module-EAKYEMGF.js → actions-virtual-module-JV7URROJ.js} +7 -7
- package/dist/adapters/agent-mount.js +3 -3
- package/dist/adapters/security-headers.d.ts +27 -11
- package/dist/adapters/security-headers.js +9 -4
- package/dist/adapters/security-headers.js.map +1 -1
- package/dist/{agent-VJTZ4DQ7.js → agent-GU6JIEFJ.js} +3 -3
- package/dist/{app-typed-client-ILLQYTXP.js → app-typed-client-EOA3A2AR.js} +5 -5
- package/dist/{app-typed-client-ILLQYTXP.js.map → app-typed-client-EOA3A2AR.js.map} +1 -1
- package/dist/{app-typed-client-LHH77QDB.js → app-typed-client-XD4DZBV7.js} +8 -8
- package/dist/app-typed-client-XD4DZBV7.js.map +1 -0
- package/dist/{aws-lambda-7AZNJGWU.js → aws-lambda-ZYSPTD3O.js} +61 -27
- package/dist/aws-lambda-ZYSPTD3O.js.map +1 -0
- package/dist/{build-23QF5FNQ.js → build-6FVZKORI.js} +81 -74
- package/dist/build-6FVZKORI.js.map +1 -0
- package/dist/{bun-NKVHOFSJ.js → bun-I7YM3RDB.js} +82 -21
- package/dist/bun-I7YM3RDB.js.map +1 -0
- package/dist/{chunk-WBB3MEEN.js → chunk-2NG5T77O.js} +47 -12
- package/dist/{chunk-WBB3MEEN.js.map → chunk-2NG5T77O.js.map} +1 -1
- package/dist/{chunk-NO6HA4FG.js → chunk-4WP7A7RX.js} +41 -35
- package/dist/chunk-4WP7A7RX.js.map +1 -0
- package/dist/chunk-4X43VTST.js +31 -0
- package/dist/chunk-4X43VTST.js.map +1 -0
- package/dist/{chunk-FHQP23Z5.js → chunk-6R5F5YF2.js} +17 -14
- package/dist/chunk-6R5F5YF2.js.map +1 -0
- package/dist/chunk-72RSCKMW.js +53 -0
- package/dist/chunk-72RSCKMW.js.map +1 -0
- package/dist/{chunk-V5SSTT3C.js → chunk-C43AHOHI.js} +1 -1
- package/dist/chunk-C43AHOHI.js.map +1 -0
- package/dist/{chunk-C5CAL3DX.js → chunk-EQJF2EH5.js} +8 -7
- package/dist/chunk-EQJF2EH5.js.map +1 -0
- package/dist/{chunk-QD5X56I3.js → chunk-FNJVSFEC.js} +1 -1
- package/dist/chunk-FNJVSFEC.js.map +1 -0
- package/dist/{chunk-6U67SO5U.js → chunk-HK57MPSZ.js} +2 -2
- package/dist/{chunk-R7G2B2WH.js → chunk-HKF6YNY4.js} +17 -14
- package/dist/{chunk-R7G2B2WH.js.map → chunk-HKF6YNY4.js.map} +1 -1
- package/dist/chunk-IJQQNHL7.js +34 -0
- package/dist/chunk-IJQQNHL7.js.map +1 -0
- package/dist/{chunk-RE2WE7CD.js → chunk-LOOOB46A.js} +2 -2
- package/dist/{chunk-PEL3MJLM.js → chunk-MX63AWEK.js} +1 -1
- package/dist/{chunk-PEL3MJLM.js.map → chunk-MX63AWEK.js.map} +1 -1
- package/dist/chunk-NPQ5A3RU.js +53 -0
- package/dist/chunk-NPQ5A3RU.js.map +1 -0
- package/dist/{chunk-DKX7LF5Q.js → chunk-ODYLKWRT.js} +3 -3
- package/dist/{chunk-L6UHMWXU.js → chunk-QAS5SKZP.js} +3 -3
- package/dist/chunk-QAS5SKZP.js.map +1 -0
- package/dist/{chunk-4EQEJ4PH.js → chunk-RFZ263XP.js} +68 -23
- package/dist/chunk-RFZ263XP.js.map +1 -0
- package/dist/{chunk-XLSJWKSP.js → chunk-S5QN3O22.js} +66 -21
- package/dist/{chunk-XLSJWKSP.js.map → chunk-S5QN3O22.js.map} +1 -1
- package/dist/{chunk-E2RC367T.js → chunk-SHIXTMQE.js} +49 -14
- package/dist/{chunk-E2RC367T.js.map → chunk-SHIXTMQE.js.map} +1 -1
- package/dist/{chunk-G7U7FQVC.js → chunk-UFBBFP75.js} +13 -3
- package/dist/chunk-UFBBFP75.js.map +1 -0
- package/dist/{chunk-4CIHLTSI.js → chunk-V7ISZGNS.js} +45 -39
- package/dist/chunk-V7ISZGNS.js.map +1 -0
- package/dist/{chunk-RCFD5W5V.js → chunk-WR5B2XNT.js} +6 -6
- package/dist/chunk-WR5B2XNT.js.map +1 -0
- package/dist/{chunk-IB5VIXLR.js → chunk-XLFDYTWO.js} +2 -2
- package/dist/cli/index.js +8 -8
- package/dist/client/index.d.ts +18 -1
- package/dist/client/index.js +15 -1
- package/dist/client/index.js.map +1 -1
- package/dist/{cloudflare-4M4XVNUH.js → cloudflare-AMH4TJNL.js} +104 -82
- package/dist/cloudflare-AMH4TJNL.js.map +1 -0
- package/dist/{config-support-SB5NDQZB.js → config-support-MOZYHRWG.js} +13 -2
- package/dist/{config-support-SB5NDQZB.js.map → config-support-MOZYHRWG.js.map} +1 -1
- package/dist/{deno-deploy-IVACZ62D.js → deno-deploy-6H354VRA.js} +17 -7
- package/dist/deno-deploy-6H354VRA.js.map +1 -0
- package/dist/{dev-JDJWMRLH.js → dev-HJUD6X3R.js} +9 -9
- package/dist/{dev-emit-7KZFUA7M.js → dev-emit-T3X6RR7M.js} +5 -5
- package/dist/{dev-emit-7KZFUA7M.js.map → dev-emit-T3X6RR7M.js.map} +1 -1
- package/dist/{dev-emit-JPJJFBXL.js → dev-emit-ZK5GCHXN.js} +6 -6
- package/dist/dev-emit-ZK5GCHXN.js.map +1 -0
- package/dist/index.js +5 -5
- package/dist/{internal-api-VDYOXOT7.js → internal-api-GKPJYL3G.js} +7 -7
- package/dist/{internal-api-6XNLCNI5.js → internal-api-ZUBUYGCQ.js} +4 -4
- package/dist/{mcp-WJNCTM7M.js → mcp-MVKDWZEQ.js} +3 -3
- package/dist/{netlify-3HEWV375.js → netlify-ALPCTQFO.js} +158 -48
- package/dist/netlify-ALPCTQFO.js.map +1 -0
- package/dist/{node-QUGTW6XV.js → node-4HQO6KKZ.js} +2 -2
- package/dist/{observability-bootstrap-FP6LMMJE.js → observability-bootstrap-DOWRMADT.js} +2 -2
- package/dist/{openapi-Q53YOBIU.js → openapi-C4UX2SFV.js} +5 -5
- package/dist/openapi-C4UX2SFV.js.map +1 -0
- package/dist/{preview-BARW75PN.js → preview-5HUID27O.js} +3 -3
- package/dist/registry-OMLCP4AS.js +34 -0
- package/dist/registry-OMLCP4AS.js.map +1 -0
- package/dist/{routes-FDF4SLOB.js → routes-3PTNISSO.js} +2 -2
- package/dist/server/index.js +2 -2
- package/dist/server/scan/index.d.ts +38 -2
- package/dist/server/scan/index.js +2 -2
- package/dist/{server-boundary-AO6EELRF.js → server-boundary-QCJNECJP.js} +7 -7
- package/dist/{server-boundary-E5GFVPLB.js → server-boundary-RTRPHX5I.js} +4 -4
- package/dist/{start-4JTYXQQJ.js → start-EREUCSN6.js} +10 -10
- package/dist/{static-NQPLPAB6.js → static-7LP7LPGN.js} +2 -2
- package/dist/{vercel-5PMYEEKF.js → vercel-VLR52RXT.js} +173 -73
- package/dist/vercel-VLR52RXT.js.map +1 -0
- package/dist/vite-plugin/index.js +5 -5
- package/dist/{vite-plugin-3ON45MQE.js → vite-plugin-JVGI6QUO.js} +9 -9
- package/package.json +4 -4
- package/dist/app-typed-client-LHH77QDB.js.map +0 -1
- package/dist/aws-lambda-7AZNJGWU.js.map +0 -1
- package/dist/build-23QF5FNQ.js.map +0 -1
- package/dist/bun-NKVHOFSJ.js.map +0 -1
- package/dist/chunk-4CIHLTSI.js.map +0 -1
- package/dist/chunk-4EQEJ4PH.js.map +0 -1
- package/dist/chunk-C5CAL3DX.js.map +0 -1
- package/dist/chunk-FHQP23Z5.js.map +0 -1
- package/dist/chunk-G7U7FQVC.js.map +0 -1
- package/dist/chunk-L6UHMWXU.js.map +0 -1
- package/dist/chunk-NO6HA4FG.js.map +0 -1
- package/dist/chunk-QD5X56I3.js.map +0 -1
- package/dist/chunk-RCFD5W5V.js.map +0 -1
- package/dist/chunk-V5SSTT3C.js.map +0 -1
- package/dist/cloudflare-4M4XVNUH.js.map +0 -1
- package/dist/deno-deploy-IVACZ62D.js.map +0 -1
- package/dist/dev-emit-JPJJFBXL.js.map +0 -1
- package/dist/netlify-3HEWV375.js.map +0 -1
- package/dist/openapi-Q53YOBIU.js.map +0 -1
- package/dist/registry-SDBODRU6.js +0 -23
- package/dist/registry-SDBODRU6.js.map +0 -1
- package/dist/vercel-5PMYEEKF.js.map +0 -1
- /package/dist/{actions-virtual-module-OHENNMQG.js.map → actions-virtual-module-J2HYO7QZ.js.map} +0 -0
- /package/dist/{actions-virtual-module-EAKYEMGF.js.map → actions-virtual-module-JV7URROJ.js.map} +0 -0
- /package/dist/{agent-VJTZ4DQ7.js.map → agent-GU6JIEFJ.js.map} +0 -0
- /package/dist/{chunk-6U67SO5U.js.map → chunk-HK57MPSZ.js.map} +0 -0
- /package/dist/{chunk-RE2WE7CD.js.map → chunk-LOOOB46A.js.map} +0 -0
- /package/dist/{chunk-DKX7LF5Q.js.map → chunk-ODYLKWRT.js.map} +0 -0
- /package/dist/{chunk-IB5VIXLR.js.map → chunk-XLFDYTWO.js.map} +0 -0
- /package/dist/{dev-JDJWMRLH.js.map → dev-HJUD6X3R.js.map} +0 -0
- /package/dist/{internal-api-VDYOXOT7.js.map → internal-api-GKPJYL3G.js.map} +0 -0
- /package/dist/{internal-api-6XNLCNI5.js.map → internal-api-ZUBUYGCQ.js.map} +0 -0
- /package/dist/{mcp-WJNCTM7M.js.map → mcp-MVKDWZEQ.js.map} +0 -0
- /package/dist/{node-QUGTW6XV.js.map → node-4HQO6KKZ.js.map} +0 -0
- /package/dist/{observability-bootstrap-FP6LMMJE.js.map → observability-bootstrap-DOWRMADT.js.map} +0 -0
- /package/dist/{preview-BARW75PN.js.map → preview-5HUID27O.js.map} +0 -0
- /package/dist/{routes-FDF4SLOB.js.map → routes-3PTNISSO.js.map} +0 -0
- /package/dist/{server-boundary-AO6EELRF.js.map → server-boundary-QCJNECJP.js.map} +0 -0
- /package/dist/{server-boundary-E5GFVPLB.js.map → server-boundary-RTRPHX5I.js.map} +0 -0
- /package/dist/{start-4JTYXQQJ.js.map → start-EREUCSN6.js.map} +0 -0
- /package/dist/{static-NQPLPAB6.js.map → static-7LP7LPGN.js.map} +0 -0
- /package/dist/{vite-plugin-3ON45MQE.js.map → vite-plugin-JVGI6QUO.js.map} +0 -0
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server/scan/action-scan.ts","../src/server/scan/match.ts","../src/core/contracts/http-methods.ts","../src/server/scan/scan.ts","../src/server/scan/detect-http-methods.ts","../src/server/scan/detect-route-policy.ts","../src/server/scan/ws-scan.ts"],"sourcesContent":["/* eslint-disable security/detect-non-literal-fs-filename --\n * Build-time scanner: walks `serverDir/actions/` derived from cwd.\n * No HTTP input ever reaches these fs calls.\n */\nimport { existsSync, readFileSync, statSync } from 'node:fs'\nimport { extname, join, relative } from 'node:path'\n\nimport { walkSourceFiles } from '../_internal/scan-walker.js'\n\nexport interface ActionNode {\n filePath: string\n actionPath: string\n}\n\n/**\n * Directory under `<serverDir>/actions/` reserved for ISOMORPHIC zod schemas.\n *\n * The one place in the whole server tree whose files are meant to reach the browser: the\n * `@theo/actions` virtual module emits `import { schema } from '<abs path>'` into the CLIENT\n * facade for each file here, so `<TheoForm>` can drive `zodResolver` with the same schema the\n * server validates against.\n *\n * Named once, and exported, because two rules now depend on knowing it. The scanner skips it (a\n * schema is not an executable action), and the server-only import boundary must NOT refuse it\n * (`vite-plugin/server-boundary.ts`). Restating the literal in the boundary would let the two\n * drift, and the drift would be invisible: renaming the convention here would silently turn the\n * exception into a refusal, breaking a shipped feature at build time in consumer projects only.\n */\nexport const ACTION_SCHEMAS_DIR = 'schemas'\n\n/**\n * Enriched manifest entry per plan g3-server-actions-and-useaction v1.2\n * § Phase 1 / T1.4 + ADR D4. Consumed by virtual module `@theo/actions`\n * (T3.1) and G4 devtools \"Actions\" tab (T5.1).\n */\nexport interface ActionManifestEntry {\n name: string\n filePath: string\n urlPath: string\n accept: 'form' | 'json'\n hasInput: boolean\n /**\n * P#4 plugin-forms shared-schema convention (per plan p4-plugin-forms v1.1 T1.1).\n * When present, points to an isomorphic schema file at\n * `<serverDir>/actions/schemas/<basename>.ts` exporting `export const schema = z.object(...)`.\n * Virtual module emits `import {schema} from '<schemaFilePath>'` + attaches as\n * `actions.X.__zodSchema` so client-side <TheoForm> can drive zodResolver.\n * Undefined when convention not followed (graceful degrade — TheoForm still\n * works via explicit `schema={...}` prop escape hatch).\n */\n schemaFilePath?: string\n}\n\n/**\n * EC-2: structured error for scan-time defects (name collision, reserved\n * identifier, etc.). Throw at scan time to fail loud — silent shadowing is\n * a security/correctness footgun.\n */\nexport class ActionScanError extends Error {\n readonly code: 'NAME_COLLISION' | 'RESERVED_NAME'\n readonly conflictingPaths: readonly string[]\n\n constructor(\n code: 'NAME_COLLISION' | 'RESERVED_NAME',\n message: string,\n conflictingPaths: readonly string[],\n ) {\n super(message)\n // T4.1 fix: identify class by name so serverErrorToEnvelope boundary\n // translator can route via class-name lookup (without this, runtime\n // `err.name` defaults to 'Error' and the meta.name diagnostic is wrong).\n this.name = 'ActionScanError'\n this.code = code\n this.conflictingPaths = conflictingPaths\n }\n}\n\nconst ACTION_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])\nconst TEST_FILE_RE = /\\.(test|spec)\\.(ts|tsx|js|jsx)$/\nconst RESERVED_NAMES = new Set(['index', 'constructor', '__proto__', 'prototype', 'hasOwnProperty'])\n\n/**\n * Backward-compatible: original simple-shape scanner used by existing\n * consumers. Preserved verbatim; new consumers should call\n * `scanServerActionsEnriched`.\n */\nexport function scanServerActions(serverDir: string): ActionNode[] {\n const actionsDir = join(serverDir, 'actions')\n if (!existsSync(actionsDir) || !statSync(actionsDir).isDirectory()) {\n return []\n }\n\n const results: ActionNode[] = []\n walkSourceFiles(actionsDir, { extensions: ACTION_EXTENSIONS }, (absPath) => {\n if (TEST_FILE_RE.test(absPath)) return\n let rel = relative(actionsDir, absPath)\n rel = rel.replace(/\\\\/g, '/')\n rel = rel.slice(0, -extname(rel).length)\n results.push({\n filePath: absPath,\n actionPath: rel,\n })\n })\n return results\n}\n\n/**\n * Enriched scan: light AST detection of `accept: 'form'|'json'` + `input:`\n * presence via regex-after-comment-stripping (EC-9). Throws ActionScanError\n * on file/dir name collision (EC-2) or reserved JS identifier names.\n *\n * Output `ActionManifestEntry[]` is sorted by `name` for deterministic\n * `.theokit/actions-manifest.json` emission.\n */\nexport function scanServerActionsEnriched(serverDir: string): ActionManifestEntry[] {\n const actionsDir = join(serverDir, 'actions')\n if (!existsSync(actionsDir) || !statSync(actionsDir).isDirectory()) {\n return []\n }\n\n const seenNames = new Set<string>()\n const entries: ActionManifestEntry[] = []\n\n // Collect first; collision-check after the full walk.\n walkSourceFiles(actionsDir, { extensions: ACTION_EXTENSIONS }, (absPath) => {\n if (TEST_FILE_RE.test(absPath)) return\n const rel = relative(actionsDir, absPath).replace(/\\\\/g, '/')\n // P#4 plugin-forms — `schemas/` subdir holds isomorphic zod schemas\n // for the shared-schema convention (T1.1). NOT executable actions; skip.\n if (rel.startsWith(`${ACTION_SCHEMAS_DIR}/`)) return\n const name = rel.slice(0, -extname(rel).length)\n\n const basename = name.includes('/') ? (name.split('/').pop() ?? name) : name\n if (RESERVED_NAMES.has(basename)) {\n throw new ActionScanError(\n 'RESERVED_NAME',\n `Reserved JS identifier \"${basename}\" cannot be an action name (${absPath})`,\n [absPath],\n )\n }\n // File-level collision (one file appearing twice) is impossible via the\n // walker; per-export collision check happens inside the export loop below.\n const source = readFileSync(absPath, 'utf8')\n const stripped = stripComments(source)\n const accept = /\\baccept\\s*:\\s*['\"]form['\"]/.test(stripped) ? 'form' : 'json'\n const hasInput = /\\binput\\s*:\\s*z\\./.test(stripped) || /\\binput\\s*:\\s*\\w+\\(/.test(stripped)\n\n // T7.1 wire fix — extract exports so the urlPath includes the second\n // segment required by action-middleware (`/api/__actions/<file>/<export>`).\n // Each named action export becomes its own manifest entry.\n // Proxy key on the EXPORT name (so consumers write `actions.saveMemory(input)`).\n // URL keeps the runtime 2-segment shape `/api/__actions/<file>/<export>`\n // expected by action-middleware. Cross-file export collisions become a\n // scan error to surface the ambiguity early.\n const exportNames = extractActionExportNames(stripped)\n // P#4 plugin-forms shared-schema convention: check for\n // `<actionsDir>/schemas/<basename>.ts` (or .tsx/.js/.jsx).\n // Skip when actions live in subdirs (`schemas/` is flat by convention).\n const schemaFilePath = name.includes('/') ? undefined : detectSchemaFile(actionsDir, basename)\n for (const exportName of exportNames) {\n const proxyKey = exportName === 'default' ? name : exportName\n if (seenNames.has(proxyKey)) {\n throw new ActionScanError(\n 'NAME_COLLISION',\n `Duplicate action proxy key \"${proxyKey}\" (two files export the same name)`,\n [absPath],\n )\n }\n seenNames.add(proxyKey)\n const entry: ActionManifestEntry = {\n name: proxyKey,\n filePath: absPath,\n urlPath: `/api/__actions/${name}/${exportName}`,\n accept,\n hasInput,\n }\n if (schemaFilePath !== undefined) {\n entry.schemaFilePath = schemaFilePath\n }\n entries.push(entry)\n }\n })\n\n // EC-2: detect file vs dir collisions (e.g., foo.ts AND foo/bar.ts).\n // After walk completes, any name that has children prefixed `name/` triggers collision.\n for (const entry of entries) {\n const childPrefix = `${entry.name}/`\n const conflictingChild = entries.find((other) => other.name.startsWith(childPrefix))\n if (conflictingChild) {\n throw new ActionScanError(\n 'NAME_COLLISION',\n `Action \"${entry.name}\" conflicts with directory of same name containing \"${conflictingChild.name}\"`,\n [entry.filePath, conflictingChild.filePath],\n )\n }\n }\n\n entries.sort((a, b) => {\n if (a.name < b.name) return -1\n if (a.name > b.name) return 1\n return 0\n })\n return entries\n}\n\n/**\n * Strip JavaScript line + block comments from source. Simple state machine\n * (does not parse strings — false positives if a comment marker appears\n * inside a string literal, but that's an acceptable trade-off for v1 vs\n * full AST parse).\n */\n/**\n * Extract action export names via regex over comment-stripped source.\n * Matches `export const <name> = defineAction(...)`, `export default\n * defineAction(...)`, and `export function <name>(...)` forms. Returns\n * `['default']` when no named action exports are found (best-effort fallback\n * for default-export shapes the regex misses).\n */\nfunction extractActionExportNames(stripped: string): string[] {\n const names = new Set<string>()\n const namedRe = /\\bexport\\s+(?:const|let|var|function\\*?)\\s+([a-zA-Z_$][\\w$]*)\\s*[=(]/g\n let m: RegExpExecArray | null\n while ((m = namedRe.exec(stripped)) !== null) {\n const name = m[1]\n if (typeof name === 'string' && name.length > 0) names.add(name)\n }\n if (/\\bexport\\s+default\\s+defineAction\\b/.test(stripped)) {\n names.add('default')\n }\n if (names.size === 0) names.add('default')\n return [...names]\n}\n\n/**\n * P#4 plugin-forms shared-schema convention helper (per plan p4-plugin-forms v1.1 T1.1).\n * Returns the resolved path to `<actionsDir>/schemas/<basename>.<ext>` if it exists.\n * Tries `.ts`, `.tsx`, `.js`, `.jsx` in that order. Returns undefined when no match.\n */\nfunction detectSchemaFile(actionsDir: string, basename: string): string | undefined {\n const schemasDir = join(actionsDir, ACTION_SCHEMAS_DIR)\n if (!existsSync(schemasDir)) return undefined\n for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {\n const candidate = join(schemasDir, `${basename}${ext}`)\n if (existsSync(candidate)) return candidate\n }\n return undefined\n}\n\nfunction stripComments(source: string): string {\n let out = ''\n let i = 0\n while (i < source.length) {\n const ch = source[i]\n const next = source[i + 1]\n if (ch === '/' && next === '/') {\n // Line comment: skip until newline\n while (i < source.length && source[i] !== '\\n') i++\n continue\n }\n if (ch === '/' && next === '*') {\n // Block comment: skip until */\n i += 2\n while (i < source.length - 1 && !(source[i] === '*' && source[i + 1] === '/')) i++\n i += 2\n continue\n }\n out += ch\n i++\n }\n return out\n}\n","export interface ServerRouteNode {\n filePath: string\n routePath: string\n paramNames: string[]\n pattern: RegExp\n /** HTTP methods (uppercase) the route file exports. Optional for backward\n * compatibility with manifests generated before G1. Empty array means the\n * file has no HTTP exports (util-only); undefined means \"not detected\". */\n methods?: string[]\n /**\n * The subset of `methods` whose policy is the literal `'public'` (ADR 0001).\n *\n * `undefined` means \"not detected\" — a manifest built before the field existed — and is NOT the\n * same as `[]`, which means \"detected, and nothing is public\". `public-exposure-gate.ts` depends\n * on that distinction: reading absence as safety is the failure it exists to prevent.\n */\n publicMethods?: string[]\n}\n\nexport function compilePattern(routePath: string): {\n pattern: RegExp\n paramNames: string[]\n} {\n const paramNames: string[] = []\n // Single pass: handle both catch-all (:...name) and regular (:name) params\n const regexStr = routePath.replace(/:(?:\\.\\.\\.)?([^/]+)/g, (match: string, name: string) => {\n paramNames.push(name)\n // Catch-all matches across slashes, regular matches single segment\n return match.startsWith(':...') ? '(.+)' : '([^/]+)'\n })\n // `regexStr` is derived from a developer-authored route path (build-time\n // input, not HTTP-controlled). The `security/detect-non-literal-regexp`\n // rule cannot see this constraint — disable narrowly.\n // eslint-disable-next-line security/detect-non-literal-regexp -- route pattern from build-time scan, never HTTP input\n return { pattern: new RegExp(`^${regexStr}$`), paramNames }\n}\n\nexport function matchRoute(\n url: string,\n routes: ServerRouteNode[],\n): { route: ServerRouteNode; params: Record<string, string> } | null {\n // Strip query string and trailing slash\n let path = url.split('?')[0]\n if (path.length > 1 && path.endsWith('/')) {\n path = path.slice(0, -1)\n }\n\n for (const route of routes) {\n const match = route.pattern.exec(path)\n if (match) {\n const params: Record<string, string> = {}\n route.paramNames.forEach((name, i) => {\n params[name] = match[i + 1]\n })\n return { route, params }\n }\n }\n return null\n}\n","/**\n * Canonical HTTP method set used by the typed client codegen + Proxy runtime.\n * Kept here (under `server/scan/`) because the SCAN is the first consumer —\n * `vite-plugin/app-typed-client.ts` (Phase 2) and `client/app-client.ts` (Phase 3)\n * import from here to ensure a single source of truth.\n *\n * Lowercase variants are derived via `.toLowerCase()` at call sites; we do not\n * export them separately to avoid drift between the two lists.\n */\n\nexport const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const\n\nexport type HttpMethod = (typeof HTTP_METHODS)[number]\n\nexport const HTTP_METHOD_LOWERCASE = HTTP_METHODS.map((m) => m.toLowerCase()) as readonly string[]\n","/* eslint-disable security/detect-non-literal-fs-filename --\n * Build-time scanner: walks `serverDir/routes/` derived from cwd.\n * No HTTP input ever reaches these fs calls.\n */\nimport { existsSync, readFileSync, statSync } from 'node:fs'\nimport { basename, extname, join, relative } from 'node:path'\n\nimport type { HttpMethod } from '../../core/contracts/http-methods.js'\nimport { walkSourceFiles } from '../_internal/scan-walker.js'\n\nimport { detectExportedHttpMethods } from './detect-http-methods.js'\nimport { detectRoutePolicyKinds, type RoutePolicyKind } from './detect-route-policy.js'\nimport {\n MissingRoutePolicyError,\n RedundantApiSegmentError,\n RouterConventionError,\n} from './errors.js'\nimport { createFileStampCache } from './file-stamp-cache.js'\nimport { compilePattern, type ServerRouteNode } from './match.js'\n\nconst ROUTE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])\n\n// EC-4: Co-located unit/spec tests must be silently skipped by the scanner,\n// BEFORE the dotted-basename check fires. Matches `*.test.ts|tsx|js|jsx`\n// and `*.spec.ts|tsx|js|jsx`.\nconst TEST_OR_SPEC_RE = /\\.(test|spec)\\.[jt]sx?$/\n\nfunction isTestOrSpecFile(filePath: string): boolean {\n return TEST_OR_SPEC_RE.test(basename(filePath))\n}\n\nfunction hasDotOutsideBrackets(segment: string): boolean {\n let depth = 0\n for (const ch of segment) {\n if (ch === '[') depth++\n else if (ch === ']') depth--\n else if (ch === '.' && depth === 0) return true\n }\n return false\n}\n\nfunction splitDottedSegmentOutsideBrackets(segment: string): string[] {\n const parts: string[] = []\n let current = ''\n let depth = 0\n for (const ch of segment) {\n if (ch === '[') {\n depth++\n current += ch\n } else if (ch === ']') {\n depth--\n current += ch\n } else if (ch === '.' && depth === 0) {\n if (current) parts.push(current)\n current = ''\n } else {\n current += ch\n }\n }\n if (current) parts.push(current)\n return parts\n}\n\nfunction buildDirectoryNestedSuggestion(filePath: string, routesDir: string): string {\n const rel = relative(routesDir, filePath).replace(/\\\\/g, '/')\n const ext = extname(rel)\n const withoutExt = rel.slice(0, -ext.length)\n const segments = withoutExt.split('/').flatMap(splitDottedSegmentOutsideBrackets)\n return `routes/${segments.join('/')}${ext}`\n}\n\n/**\n * `routes/api/…` doubles the prefix `fileToRoutePath` adds unconditionally.\n *\n * Checked on the FIRST segment only. A file merely named `api-keys.ts` or `apiary.ts` is the\n * author's to choose and resolves fine; what doubles is the DIRECTORY, because it prefixes every\n * file beneath it — and carries the same redundant segment into the generated typed client.\n */\nfunction assertNoRedundantApiSegment(filePath: string, routesDir: string): void {\n const rel = relative(routesDir, filePath).replace(/\\\\/g, '/')\n const [first, ...rest] = rel.split('/')\n if (first !== 'api' || rest.length === 0) return\n\n const withoutExt = rel.slice(0, -extname(rel).length)\n throw new RedundantApiSegmentError({\n file: filePath,\n doubledRoutePath: `/api/${withoutExt}`,\n doubledClientChain: `client.${withoutExt.split('/').join('.')}`,\n suggestion: `routes/${rest.join('/')}`,\n })\n}\n\nfunction assertNoDottedSegment(filePath: string, routesDir: string): void {\n const rel = relative(routesDir, filePath).replace(/\\\\/g, '/')\n const ext = extname(rel)\n const withoutExt = rel.slice(0, -ext.length)\n const segments = withoutExt.split('/')\n for (const seg of segments) {\n if (hasDotOutsideBrackets(seg)) {\n throw new RouterConventionError({\n file: filePath,\n suggestion: buildDirectoryNestedSuggestion(filePath, routesDir),\n })\n }\n }\n}\n\n/**\n * ADR 0001, Decision point 5 — a route declares its policy explicitly, `public`\n * included, and absence stops meaning open.\n *\n * The refusal lives here, next to the dotted-basename refusal, because this is\n * the one place every entry point passes through: `theo build`, `theo start`,\n * `theo dev`, `theo routes` and each deployment adapter all reach routes by\n * calling `scanServerRoutes`. A gate wired into the build command instead would\n * have to be remembered by six adapters that generate their own entry file, and\n * a gate you can forget to call is a gate that reports on the routes somebody\n * remembered.\n *\n * The blast radius stops at the file system. A `RouteConfig` built in memory and\n * handed to `executeWebRequest` or `callProcedure` never passed a scanner, so it\n * never reaches this function — the runtime still treats an undeclared policy as\n * \"not declared\", exactly as `evaluateRoutePolicy` documents. Absence is refused\n * where an application DECLARES its routes, not where a caller passes one.\n */\nfunction assertEveryMethodDeclaresPolicy(\n filePath: string,\n routePath: string,\n { methods, declaredPolicy }: RouteSourceFacts,\n): void {\n if (methods.length === 0) return\n const missing = methods.filter((method) => !declaredPolicy.has(method))\n if (missing.length === 0) return\n throw new MissingRoutePolicyError({ file: filePath, routePath, methods: missing })\n}\n\n/** What the two AST passes over a route file answer, together. */\ninterface RouteSourceFacts {\n methods: HttpMethod[]\n /** The subset of `methods` that declared a `policy`. Empty when there are no methods. */\n declaredPolicy: Set<HttpMethod>\n /** What each declaring method declared. Keys are exactly `declaredPolicy`. */\n policyKinds: Map<HttpMethod, RoutePolicyKind>\n}\n\n/**\n * Per-file facts, recomputed only when the file changes (usetheokit/theokit#417).\n *\n * `theokit dev` calls `scanServerRoutes` on EVERY request, and this function parsed each route\n * file with the TypeScript AST TWICE per call — once for the exported methods, once for the policy\n * gate, sharing the source string but not the parse. `agent-scan.ts` had solved exactly this for\n * agent files and wrote down why; routes, which an application has far more of, had neither the\n * cache nor the reasoning.\n *\n * The refusal is deliberately NOT cached — only the facts are. A scan that found a missing policy\n * once must go on refusing on every later scan, so `assertEveryMethodDeclaresPolicy` re-derives the\n * set difference (cheap, no parse) from the cached facts each time.\n */\nconst routeFactsCache = createFileStampCache<RouteSourceFacts>()\n\n/** Test seam — the module-level cache would otherwise outlive a fixture directory. */\nexport function _resetRouteScanCacheForTests(): void {\n routeFactsCache.clear()\n}\n\nfunction routeSourceFacts(absPath: string): RouteSourceFacts {\n return routeFactsCache.get(absPath, () => {\n const source = readFileSync(absPath, 'utf-8')\n const methods = detectExportedHttpMethods(absPath, source)\n // Kept lazy exactly as the early return in the assert made it: a file exporting no HTTP method\n // has nothing for the policy gate to check, and parsing it a second time to learn that was the\n // cost this cache exists to remove.\n const policyKinds =\n methods.length === 0\n ? new Map<HttpMethod, RoutePolicyKind>()\n : detectRoutePolicyKinds(absPath, source)\n // One parse, one source of truth: the gate's set is a projection of the map, so the build gate\n // (\"did it declare?\") and the exposure gate (\"what did it declare?\") cannot drift apart.\n return { methods, declaredPolicy: new Set(policyKinds.keys()), policyKinds }\n })\n}\n\nfunction fileToRoutePath(filePath: string, routesDir: string): string {\n let rel = relative(routesDir, filePath)\n // Strip extension\n const ext = extname(rel)\n rel = rel.slice(0, -ext.length)\n // Normalize separators\n rel = rel.replace(/\\\\/g, '/')\n // Strip index suffix\n if (rel.endsWith('/index')) {\n rel = rel.slice(0, -6)\n } else if (rel === 'index') {\n rel = ''\n }\n // Replace [...param] with :...param (catch-all, before regular params).\n // Replace [param] with :param. Inputs are file paths bounded by the\n // OS filename limit; the bracket capture is bounded by `]`.\n rel = rel.replace(/\\[\\.\\.\\.([^\\]]+)\\]/g, ':...$1')\n // eslint-disable-next-line sonarjs/slow-regex -- bounded by `]`; input is a single filename\n rel = rel.replace(/\\[([^\\]]+)\\]/g, ':$1')\n return `/api/${rel}`\n}\n\nexport function scanServerRoutes(serverDir: string): ServerRouteNode[] {\n const routesDir = join(serverDir, 'routes')\n if (!existsSync(routesDir) || !statSync(routesDir).isDirectory()) {\n return []\n }\n\n const results: ServerRouteNode[] = []\n walkSourceFiles(routesDir, { extensions: ROUTE_EXTENSIONS }, (absPath) => {\n // EC-4: skip co-located test/spec files BEFORE the dotted-basename check\n if (isTestOrSpecFile(absPath)) return\n\n // G6 T1.1: reject dotted basenames (legacy convention that produced wrong\n // paramNames due to greedy `:(?:\\.\\.\\.)?([^/]+)` regex in compilePattern).\n assertNoRedundantApiSegment(absPath, routesDir)\n assertNoDottedSegment(absPath, routesDir)\n\n const routePath = fileToRoutePath(absPath, routesDir)\n const { pattern, paramNames } = compilePattern(routePath)\n const facts = routeSourceFacts(absPath)\n const { methods } = facts\n assertEveryMethodDeclaresPolicy(absPath, routePath, facts)\n results.push({\n filePath: absPath,\n routePath,\n paramNames,\n pattern,\n methods,\n // Always set, even when empty — `public-exposure-gate.ts` reads the FIELD's presence as\n // \"this was measured\" and its absence as \"this predates the check\". A scanned route was\n // measured by definition, so it never reports the second.\n publicMethods: [...facts.policyKinds]\n .filter(([, kind]) => kind === 'public')\n .map(([method]) => method),\n })\n })\n\n // T1.4 / EC-2 — refuse to scan if a user route collides with the reserved\n // batch endpoint path. User must rename or disable batching.\n const conflicting = results.find((r) => r.routePath === '/api/__theo_batch__')\n if (conflicting) {\n throw new Error(\n `Server route ${conflicting.filePath} resolves to '/api/__theo_batch__' which is reserved for the batch endpoint. Rename the route or disable batching in theo.config.ts.`,\n )\n }\n\n results.sort((a, b) => compareRouteSpecificity(a.routePath, b.routePath))\n\n return results\n}\n\n/** How constrained one path segment is. Lower is more specific. */\nconst SEGMENT_STATIC = 0\nconst SEGMENT_DYNAMIC = 1\nconst SEGMENT_CATCH_ALL = 2\n\nfunction segmentSpecificity(segment: string): number {\n if (segment.startsWith(':...')) return SEGMENT_CATCH_ALL\n if (segment.startsWith(':')) return SEGMENT_DYNAMIC\n return SEGMENT_STATIC\n}\n\n/**\n * Order two route paths by specificity, most specific first.\n *\n * `matchRoute` returns on the first pattern that matches, so this order IS the\n * precedence contract — not a presentation detail of the manifest.\n *\n * Segments are compared position by position, and the first position where they\n * disagree decides: a literal beats a parameter, and a parameter beats a\n * catch-all. That is the rule the URL itself expresses, and it is the rule a\n * whole-path comparison cannot express, because a whole-path comparison reads\n * characters across segment boundaries. Comparing `/api/:resource/settings`\n * with `/api/users/:id` that way put the generic route first — `:` precedes `u`\n * in every collation — so `/api/users/settings` reached the generic handler and\n * an authorization check placed on the specific route was bypassed\n * (usetheokit/theokit#348).\n *\n * Only the segments the two paths have in common are ranked. Past that point\n * one path is a strict prefix of the other, and two such routes cannot match\n * the same URL — so there is nothing to decide, only a stable order to pick.\n *\n * The final fallback compares by code unit rather than with `localeCompare`.\n * Collation is locale-dependent, so the same route table would order\n * differently under a different `LANG` — the cross-machine divergence\n * usetheokit/theokit#346 removed from the sibling scanner. The tiebreak only\n * has to be total and stable; it does not have to be alphabetical for a human.\n */\nexport function compareRouteSpecificity(a: string, b: string): number {\n const aSegments = a.split('/')\n const bSegments = b.split('/')\n const shared = Math.min(aSegments.length, bSegments.length)\n\n for (let i = 0; i < shared; i++) {\n const difference = segmentSpecificity(aSegments[i]) - segmentSpecificity(bSegments[i])\n if (difference !== 0) return difference\n }\n\n if (a < b) return -1\n if (a > b) return 1\n return 0\n}\n","/**\n * Detect which HTTP-method named exports a route file declares.\n *\n * Uses the TypeScript compiler API (not regex) per G1 edge-case review EC-4:\n * regex over file content emits false positives for `// export const GET = ...`\n * in comments and `` `export const GET = ...` `` in template literals. AST\n * walking avoids both classes of bug.\n *\n * `typescript` ships as CommonJS with internal dynamic `require('fs')`.\n * When loaded via ESM `import`, the dynamic requires fail at module-bootstrap.\n * We use `createRequire(import.meta.url)` to keep the package on its native\n * CJS path. The type-only namespace import gives us the AST helpers shape.\n *\n * Returns the set of HTTP methods (uppercase) the file exports. Empty array\n * means the file has no HTTP exports (the route file is util-only).\n */\n\nimport { readFileSync } from 'node:fs'\nimport { createRequire } from 'node:module'\n\nimport type * as TS from 'typescript'\n\nimport { HTTP_METHODS, type HttpMethod } from '../../core/contracts/http-methods.js'\nimport { compareByCodeUnit } from '../_internal/compare-by-code-unit.js'\n\nconst require_ = createRequire(import.meta.url)\n\nconst ts = require_('typescript') as typeof TS\n\nconst HTTP_METHOD_NAMES = new Set<string>(HTTP_METHODS)\n\nfunction hasExportModifier(modifiers: readonly TS.Modifier[] | undefined): boolean {\n if (!modifiers) return false\n for (const m of modifiers) {\n if (m.kind === ts.SyntaxKind.ExportKeyword) return true\n }\n return false\n}\n\n// An AST visitor over the export forms (const / function / named export) is inherently branchy;\n// the complexity is in the grammar it walks, not in tangled logic. Splitting it would scatter one\n// cohesive dispatch across helpers for no readability gain.\n// eslint-disable-next-line complexity -- AST visitor, see above\nfunction collectFromStatement(stmt: TS.Statement, found: Set<HttpMethod>): void {\n // `export const GET = ...` / `export function GET ...` / `export async function GET ...`\n if (ts.isVariableStatement(stmt) && hasExportModifier(ts.getModifiers(stmt))) {\n for (const decl of stmt.declarationList.declarations) {\n if (ts.isIdentifier(decl.name) && HTTP_METHOD_NAMES.has(decl.name.text)) {\n found.add(decl.name.text as HttpMethod)\n }\n }\n return\n }\n\n if (\n (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) &&\n hasExportModifier(ts.getModifiers(stmt))\n ) {\n if (stmt.name && HTTP_METHOD_NAMES.has(stmt.name.text)) {\n found.add(stmt.name.text as HttpMethod)\n }\n return\n }\n\n // `export { GET }` / `export { handler as GET } from './shared'` (EC-5)\n if (ts.isExportDeclaration(stmt) && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {\n for (const spec of stmt.exportClause.elements) {\n // spec.name is the exported (re-)name; spec.propertyName is the original (when renamed)\n if (HTTP_METHOD_NAMES.has(spec.name.text)) {\n found.add(spec.name.text as HttpMethod)\n }\n }\n }\n}\n\nexport function detectExportedHttpMethods(filePath: string, content?: string): HttpMethod[] {\n // `filePath` is a route module the framework itself discovered by globbing the project — never\n // user input at runtime. Reading it by a computed path is the whole job of a source scanner.\n // eslint-disable-next-line security/detect-non-literal-fs-filename -- framework-controlled path\n const src = content ?? readFileSync(filePath, 'utf-8')\n const sourceFile = ts.createSourceFile(\n filePath,\n src,\n ts.ScriptTarget.Latest,\n /* setParentNodes */ false,\n ts.ScriptKind.TS,\n )\n const found = new Set<HttpMethod>()\n for (const stmt of sourceFile.statements) {\n collectFromStatement(stmt, found)\n }\n // HTTP method names are ASCII, so no collation disagrees about them today.\n // Ordered by code unit anyway, so that \"build output orders by code unit\"\n // stays a rule a reader can check by grep rather than a rule with exceptions\n // each needing its own argument (usetheokit/theokit#351).\n return [...found].sort(compareByCodeUnit)\n}\n","/**\n * Detect which HTTP-method exports of a route file declare an access policy.\n *\n * The companion of `detect-http-methods.ts`, and deliberately the same shape:\n * the TypeScript AST rather than a regex, because `// policy: 'public'` in a\n * comment and `` `policy` `` in a doc string are both things a route file\n * legitimately contains, and neither declares anything.\n *\n * ADR 0001 decides that a route says who may call it, `'public'` included, and\n * that absence stops meaning open. This is the half of that decision the\n * scanner can answer: which exports said something. `scanServerRoutes` turns\n * the silence into a build error.\n *\n * ## What counts as a declaration\n *\n * Two forms, because the framework ships two ways to write a route:\n *\n * ```ts\n * export const GET = route().policy('public').handler(fn).build() // builder\n * export const GET = defineRoute({ policy: fn, handler: fn }) // object\n * ```\n *\n * Only the config position is inspected — the top level of an object literal\n * passed to a call, and `.policy(...)` links of a call chain. The handler's\n * BODY is never walked, so a handler that happens to return `{ policy: ... }`\n * does not satisfy the gate. Reading deeper would trade a false build error for\n * a false pass, and a false pass here is a route nobody protected that the gate\n * reported as protected.\n *\n * ## What it deliberately cannot see\n *\n * `export { GET } from './shared'` re-exports across a module boundary, and\n * `export const GET = makeAdminRoute(fn)` hides the config inside a helper.\n * Both come back \"not declared\", which fails the build. That direction is the\n * chosen one: the cost is an explicit declaration on a route that already had\n * one somewhere else, and the alternative cost is a route reported as declared\n * because the scanner guessed.\n */\nimport { createRequire } from 'node:module'\n\nimport type * as TS from 'typescript'\n\nimport { HTTP_METHODS, type HttpMethod } from '../../core/contracts/http-methods.js'\n\nconst require_ = createRequire(import.meta.url)\n\nconst ts = require_('typescript') as typeof TS\n\nconst HTTP_METHOD_NAMES = new Set<string>(HTTP_METHODS)\n\nconst POLICY_KEY = 'policy'\n\nfunction hasExportModifier(modifiers: readonly TS.Modifier[] | undefined): boolean {\n if (!modifiers) return false\n for (const m of modifiers) {\n if (m.kind === ts.SyntaxKind.ExportKeyword) return true\n }\n return false\n}\n\n/** Strip the wrappers that carry no runtime meaning (`(x)`, `x as T`, `x satisfies T`, `x!`). */\nfunction unwrap(expression: TS.Expression): TS.Expression {\n let current = expression\n for (;;) {\n if (\n ts.isParenthesizedExpression(current) ||\n ts.isAsExpression(current) ||\n ts.isSatisfiesExpression(current) ||\n ts.isNonNullExpression(current)\n ) {\n current = current.expression\n continue\n }\n return current\n }\n}\n\nfunction propertyNameIsPolicy(name: TS.PropertyName | undefined): boolean {\n if (name === undefined) return false\n if (ts.isIdentifier(name)) return name.text === POLICY_KEY\n if (ts.isStringLiteral(name)) return name.text === POLICY_KEY\n return false\n}\n\n/**\n * What a route said about who may call it.\n *\n * `'public'` is the string literal ADR 0001 gives a meaning to; everything else is a function this\n * pass cannot evaluate, and is reported `'guarded'`. That asymmetry is deliberate — see\n * `policyKindOfArgument`.\n */\nexport type RoutePolicyKind = 'public' | 'guarded'\n\n/**\n * The kind an argument in the `policy` position declares.\n *\n * Only the bare literal `'public'` is read as open. An identifier, a property access, a call, a\n * template — anything this pass cannot evaluate — comes back `'guarded'`.\n *\n * The two mistakes are not symmetric. Labelling an open route `'guarded'` costs a gate that fails\n * to fire on a route somebody left open on purpose. Labelling a guarded route `'public'` would put\n * a protected route on a list of exposures and, worse, train a reader to disbelieve the list. Only\n * the literal is legible, so only the literal is believed.\n */\nfunction policyKindOfArgument(expression: TS.Expression): RoutePolicyKind {\n const arg = unwrap(expression)\n return ts.isStringLiteral(arg) && arg.text === 'public' ? 'public' : 'guarded'\n}\n\n/** A `policy` key at the TOP level of a config object literal. Never deeper. */\nfunction objectPolicyKind(literal: TS.ObjectLiteralExpression): RoutePolicyKind | undefined {\n for (const property of literal.properties) {\n if (ts.isSpreadAssignment(property)) continue\n if (!propertyNameIsPolicy(property.name)) continue\n // `{ policy: x }` carries an initializer; `{ policy }` shorthand does not, and a shorthand\n // reference is exactly the unreadable case that takes the safe label.\n return ts.isPropertyAssignment(property)\n ? policyKindOfArgument(property.initializer)\n : 'guarded'\n }\n return undefined\n}\n\n/**\n * Does this initializer declare a policy?\n *\n * Walks the call chain leftwards (`.build()` -> `.handler()` -> `.policy()`) and\n * checks the arguments each call receives, which is where the object form puts\n * its config. Nothing else is visited.\n */\nfunction policyKind(expression: TS.Expression): RoutePolicyKind | undefined {\n const expr = unwrap(expression)\n\n if (ts.isObjectLiteralExpression(expr)) return objectPolicyKind(expr)\n\n if (!ts.isCallExpression(expr)) return undefined\n\n const callee = unwrap(expr.expression)\n\n if (ts.isPropertyAccessExpression(callee) && callee.name.text === POLICY_KEY) {\n // `.policy()` with no argument declares the key and says nothing readable.\n return expr.arguments.length === 0 ? 'guarded' : policyKindOfArgument(expr.arguments[0])\n }\n\n for (const argument of expr.arguments) {\n const arg = unwrap(argument)\n if (!ts.isObjectLiteralExpression(arg)) continue\n const kind = objectPolicyKind(arg)\n if (kind !== undefined) return kind\n }\n\n // Keep walking the chain: `route().policy(p).handler(h).build()` reaches\n // `.policy` only by stepping left through `.build` and `.handler`.\n if (ts.isPropertyAccessExpression(callee)) return policyKind(callee.expression)\n\n return undefined\n}\n\n/** Every top-level `const x = <expr>` in the file, so `export { x as GET }` can be resolved. */\nfunction collectLocalInitializers(sourceFile: TS.SourceFile): Map<string, TS.Expression> {\n const locals = new Map<string, TS.Expression>()\n for (const stmt of sourceFile.statements) {\n if (!ts.isVariableStatement(stmt)) continue\n for (const decl of stmt.declarationList.declarations) {\n if (ts.isIdentifier(decl.name) && decl.initializer !== undefined) {\n locals.set(decl.name.text, decl.initializer)\n }\n }\n }\n return locals\n}\n\n/** `export const GET = ...` */\nfunction collectFromVariableStatement(\n stmt: TS.VariableStatement,\n declared: Map<HttpMethod, RoutePolicyKind>,\n): void {\n if (!hasExportModifier(ts.getModifiers(stmt))) return\n for (const decl of stmt.declarationList.declarations) {\n if (!ts.isIdentifier(decl.name) || !HTTP_METHOD_NAMES.has(decl.name.text)) continue\n if (decl.initializer === undefined) continue\n const kind = policyKind(decl.initializer)\n if (kind !== undefined) declared.set(decl.name.text as HttpMethod, kind)\n }\n}\n\n/**\n * `export { GET }` / `export { handler as GET }` — resolvable only when the\n * declaration is in this file. A re-export with a module specifier is not.\n */\nfunction collectFromExportDeclaration(\n stmt: TS.ExportDeclaration,\n locals: Map<string, TS.Expression>,\n declared: Map<HttpMethod, RoutePolicyKind>,\n): void {\n if (stmt.moduleSpecifier !== undefined) return\n if (!stmt.exportClause || !ts.isNamedExports(stmt.exportClause)) return\n for (const spec of stmt.exportClause.elements) {\n if (!HTTP_METHOD_NAMES.has(spec.name.text)) continue\n const local = locals.get((spec.propertyName ?? spec.name).text)\n if (local === undefined) continue\n const kind = policyKind(local)\n if (kind !== undefined) declared.set(spec.name.text as HttpMethod, kind)\n }\n}\n\nfunction collectFromStatement(\n stmt: TS.Statement,\n locals: Map<string, TS.Expression>,\n declared: Map<HttpMethod, RoutePolicyKind>,\n): void {\n if (ts.isVariableStatement(stmt)) {\n collectFromVariableStatement(stmt, declared)\n return\n }\n if (ts.isExportDeclaration(stmt)) {\n collectFromExportDeclaration(stmt, locals, declared)\n }\n // `export function GET() {}` and `export class GET {}` reach neither branch,\n // on purpose: a bare function has no config object to carry a policy.\n}\n\n/**\n * What each HTTP-method export of this file declared — `'public'` or `'guarded'`.\n *\n * The KEYS answer the question ADR 0001 asks at build time: which methods declared a policy at all.\n * The VALUES answer the half it left open until now: whether the declaration protects anything.\n * One map, so the build gate and the exposure gate cannot disagree about which methods declared.\n *\n * `content` is passed in rather than read here so the scanner reads each route file once and hands\n * the same source to both detectors.\n */\nexport function detectRoutePolicyKinds(\n filePath: string,\n content: string,\n): Map<HttpMethod, RoutePolicyKind> {\n const sourceFile = ts.createSourceFile(\n filePath,\n content,\n ts.ScriptTarget.Latest,\n /* setParentNodes */ false,\n ts.ScriptKind.TS,\n )\n const locals = collectLocalInitializers(sourceFile)\n const declared = new Map<HttpMethod, RoutePolicyKind>()\n for (const stmt of sourceFile.statements) {\n collectFromStatement(stmt, locals, declared)\n }\n return declared\n}\n","/* eslint-disable security/detect-non-literal-fs-filename --\n * Build-time scanner: walks `serverDir/ws/` derived from cwd.\n * No HTTP input ever reaches these fs calls.\n */\nimport { existsSync, statSync } from 'node:fs'\nimport { extname, join, relative } from 'node:path'\n\nimport { walkSourceFiles } from '../_internal/scan-walker.js'\n\nconst WS_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx'])\n\nexport interface WebSocketRouteNode {\n filePath: string\n wsPath: string\n}\n\nexport function scanWebSocketRoutes(serverDir: string): WebSocketRouteNode[] {\n const wsDir = join(serverDir, 'ws')\n if (!existsSync(wsDir) || !statSync(wsDir).isDirectory()) {\n return []\n }\n\n const results: WebSocketRouteNode[] = []\n walkSourceFiles(wsDir, { extensions: WS_EXTENSIONS }, (absPath) => {\n let rel = relative(wsDir, absPath)\n rel = rel.replace(/\\\\/g, '/')\n rel = rel.slice(0, -extname(rel).length)\n if (rel.endsWith('/index')) rel = rel.slice(0, -6)\n else if (rel === 'index') rel = ''\n results.push({\n filePath: absPath,\n wsPath: `/ws/${rel}`,\n })\n })\n return results\n}\n"],"mappings":"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","names":["basename","existsSync","readFileSync","statSync","extname","join","relative","readFileSync","readFileSync","createRequire","require_","createRequire","ts","HTTP_METHOD_NAMES","hasExportModifier","collectFromStatement","relative","extname","readFileSync","relative","extname","join","existsSync","statSync","existsSync","statSync","extname","join","relative","join","existsSync","statSync","relative","extname"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core/contracts/client-safe-error.ts","../src/server/http/send-response.ts","../src/server/http/node-request.ts","../src/server/http/controller-dispatch.ts","../src/server/security/csrf-warn-dispatch.ts","../src/server/observability/audit-log.ts","../src/server/security/csrf.ts"],"sourcesContent":["import type { TheoErrorEnvelope } from './error-envelope.js'\n\n/**\n * What an error is allowed to tell the caller.\n *\n * Most error codes describe something the caller did and can fix, so their message is the useful\n * part of the response. An *internal* failure is the opposite: its message describes the server —\n * a connection string, an upstream host, a stack of internal names — and the caller can act on\n * none of it. In production it is redacted; in development it is exactly what makes the framework\n * debuggable, so it stays.\n *\n * This lives in one place because it was previously stated in two and missing from a third. The\n * Node runner redacted, the Web error builder redacted, and an exception escaping a Web handler\n * took a hand-built path that did neither — same route, same failure, more disclosure depending\n * on which transport served it. That is the \"one contract, three transports\" rule in\n * `rules/three-target-parity.md` being broken by duplication rather than by design.\n */\n\n/** Both spellings the codebase uses for \"this is our fault, and the detail is ours too\". */\nconst INTERNAL_CODES: ReadonlySet<string> = new Set(['INTERNAL_ERROR', 'INTERNAL_SERVER_ERROR'])\n\nconst GENERIC_INTERNAL_MESSAGE = 'Internal server error'\n\nfunction redacts(code: string): boolean {\n return INTERNAL_CODES.has(code) && process.env.NODE_ENV === 'production'\n}\n\n/** The message this code may carry to the caller. */\nexport function clientSafeErrorMessage(code: string, message: string): string {\n return redacts(code) ? GENERIC_INTERNAL_MESSAGE : message\n}\n\n/**\n * The envelope this code may carry to the caller.\n *\n * When it redacts, `cause`, `meta` and `ext` go with the message rather than being filtered\n * field by field: they exist to describe the failure, and the whole point is that this failure is\n * not describable to the caller. Keeping the code is what lets a client branch on it.\n */\nexport function clientSafeErrorEnvelope(envelope: TheoErrorEnvelope): TheoErrorEnvelope {\n if (!redacts(envelope.code)) return envelope\n return { code: envelope.code, message: GENERIC_INTERNAL_MESSAGE }\n}\n","import type { ServerResponse } from 'node:http'\n\nimport { clientSafeErrorMessage } from '../../core/contracts/client-safe-error.js'\nimport type { TheoTransformer } from '../transformer.js'\n\n/**\n * Canonical HTTP response helpers (T5.1 extraction).\n *\n * Moved out of execute.ts so request-pipeline stages (execute-stages.ts,\n * handle-request-error.ts, etc.) can depend on these helpers without\n * creating a cycle through execute.ts.\n *\n * Public surface re-exported from execute.ts for backward compat — every\n * existing caller of `sendError` / `sendJson` continues to work via the\n * `theokit/server` barrel.\n */\n\nexport function sendJson(\n res: ServerResponse,\n data: unknown,\n status = 200,\n transformer?: TheoTransformer,\n): void {\n // T1.2 — transformer-aware serialization. Default (no transformer) uses\n // JSON.stringify direct for backward compat.\n const body = transformer ? transformer.serialize(data) : JSON.stringify(data)\n res.writeHead(status, {\n 'Content-Type': 'application/json',\n 'Content-Length': Buffer.byteLength(body),\n })\n res.end(body)\n}\n\n/** Render anything that would end the log line as a visible escape, so one call logs one line. */\nfunction oneLine(value: string): string {\n return value.replace(/[\\r\\n]/g, '\\\\n')\n}\n\nexport interface SendErrorOptions {\n custom404Html?: string\n custom500Html?: string\n}\n\n/**\n * Canonical error response.\n *\n * T6.3 (PV-17): the positional 7-param signature is preserved for backward\n * compat. New call sites should use the options-bag form:\n *\n * sendError(res, { code, message, status, issues?, requestId?, options? })\n *\n * Both shapes resolve to the same implementation.\n */\nexport interface SendErrorInput {\n code: string\n message: string\n status: number\n issues?: unknown[]\n requestId?: string\n options?: SendErrorOptions\n}\n\nexport function sendError(res: ServerResponse, input: SendErrorInput): void\n/* eslint-disable-next-line max-params -- T6.3: positional overload preserved\n for backward compat (callers across cli/server still use positional). The\n options-bag overload above is the recommended path. */\nexport function sendError(\n res: ServerResponse,\n code: string,\n message: string,\n status: number,\n issues?: unknown[],\n requestId?: string,\n options?: SendErrorOptions,\n): void\n/* eslint-disable-next-line max-params -- delegates to two surface overloads above; the parameter\n count mirrors the back-compat contract, not internal complexity. The `complexity` half of this\n suppression went away when the redaction rule stopped being restated inline. */\nexport function sendError(\n res: ServerResponse,\n codeOrInput: string | SendErrorInput,\n message?: string,\n status?: number,\n issues?: unknown[],\n requestId?: string,\n options?: SendErrorOptions,\n): void {\n let code: string\n if (typeof codeOrInput === 'string') {\n code = codeOrInput\n message = message ?? ''\n status = status ?? 500\n } else {\n code = codeOrInput.code\n message = codeOrInput.message\n status = codeOrInput.status\n issues = codeOrInput.issues\n requestId = codeOrInput.requestId\n options = codeOrInput.options\n }\n const errorMessage = clientSafeErrorMessage(code, message)\n\n if (code === 'INTERNAL_ERROR') {\n // One log entry per call, whatever the message contains. An exception message can be built\n // from request data and can therefore carry a newline; unescaped, that lets a caller append\n // whatever lines it likes to the log — including a plausible entry attributed to something\n // else (CodeQL `js/log-injection`).\n console.error(`[${oneLine(requestId ?? 'no-id')}] ${oneLine(message)}`)\n }\n\n if (status === 404 && options?.custom404Html) {\n const body = options.custom404Html\n res.writeHead(404, {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Content-Length': Buffer.byteLength(body),\n })\n res.end(body)\n return\n }\n if (status === 500 && options?.custom500Html) {\n const body = options.custom500Html\n res.writeHead(500, {\n 'Content-Type': 'text/html; charset=utf-8',\n 'Content-Length': Buffer.byteLength(body),\n })\n res.end(body)\n return\n }\n\n sendJson(\n res,\n {\n error: {\n code,\n message: errorMessage,\n ...(requestId ? { requestId } : {}),\n ...(issues ? { issues } : {}),\n },\n },\n status,\n )\n}\n\n/**\n * T5a.2 Phase G slice 4/N — Web-Standards response helpers.\n *\n * Mirror of `sendJson` + `sendError` for the Web `Request`/`Response`\n * shape. Returns a native `Response` directly instead of mutating a\n * `ServerResponse`.\n *\n * v1.0 § Phase G.\n *\n * **Difference vs IncomingMessage path:**\n * - No `Content-Length` set explicitly — the runtime computes it from\n * the body when needed. CF Workers / Bun / Deno all do this; setting\n * it manually risks conflict if the body is a stream rather than a\n * fixed string.\n * - Custom 404/500 HTML options preserved (same opts shape).\n * - `requestId` flows into the response body's error envelope AND\n * surfaces as `x-request-id` header (parity with handleWebRequestError\n * Phase G slice 3/N).\n */\nexport function buildJsonResponse(\n data: unknown,\n status = 200,\n transformer?: TheoTransformer,\n): Response {\n const body = transformer ? transformer.serialize(data) : JSON.stringify(data)\n return new Response(body, {\n status,\n headers: { 'content-type': 'application/json' },\n })\n}\n\nexport function buildErrorResponse(input: SendErrorInput): Response {\n const { code, message, status, issues, requestId, options } = input\n const errorMessage = clientSafeErrorMessage(code, message)\n\n if (code === 'INTERNAL_ERROR') {\n // One log entry per call, whatever the message contains. An exception message can be built\n // from request data and can therefore carry a newline; unescaped, that lets a caller append\n // whatever lines it likes to the log — including a plausible entry attributed to something\n // else (CodeQL `js/log-injection`).\n console.error(`[${oneLine(requestId ?? 'no-id')}] ${oneLine(message)}`)\n }\n\n const headers: Record<string, string> = {}\n if (requestId !== undefined) headers['x-request-id'] = requestId\n\n if (status === 404 && options?.custom404Html !== undefined) {\n headers['content-type'] = 'text/html; charset=utf-8'\n return new Response(options.custom404Html, { status: 404, headers })\n }\n if (status === 500 && options?.custom500Html !== undefined) {\n headers['content-type'] = 'text/html; charset=utf-8'\n return new Response(options.custom500Html, { status: 500, headers })\n }\n\n headers['content-type'] = 'application/json'\n const body = JSON.stringify({\n error: {\n code,\n message: errorMessage,\n ...(requestId ? { requestId } : {}),\n ...(issues ? { issues } : {}),\n },\n })\n return new Response(body, { status, headers })\n}\n","/**\n * Pure Node `IncomingMessage` → Web `Request` converters.\n *\n * Kept free of any dependency on `web-handler.js` / `execute*.js` so the\n * executor (`execute.ts`) can build the handler-facing Web `Request` without\n * pulling the Web dispatch pipeline into its import graph (ADR-0028 R3a — the\n * Node adapter is the ONLY place IncomingMessage ↔ Request conversion lives;\n * these are the primitive converters it and the executor share).\n */\nimport type { IncomingMessage } from 'node:http'\nimport { Readable } from 'node:stream'\n\n/** Pick the first usable string from Node's `string | string[] | undefined` headers. */\nfunction pickHeaderString(value: string | string[] | undefined): string | undefined {\n if (typeof value === 'string') return value\n if (Array.isArray(value)) {\n for (const v of value) if (typeof v === 'string' && v.length > 0) return v\n }\n return undefined\n}\n\n/** Web Request requires an absolute URL; synthesize one from the Host header. */\nfunction synthesizeAbsoluteUrl(req: IncomingMessage): string {\n const host = pickHeaderString(req.headers.host) ?? 'localhost'\n return `http://${host}${req.url ?? '/'}`\n}\n\n/**\n * Collapse Node's `string | string[]` headers into a Web `Headers`. Repeated\n * headers are comma-joined (not `.append`ed) because `Headers.append` creates\n * multi-value entries that behave differently on `.get()` (EC-1).\n */\nfunction nodeHeadersToWeb(req: IncomingMessage): Headers {\n const headers = new Headers()\n for (const [key, value] of Object.entries(req.headers)) {\n if (value === undefined) continue\n if (Array.isArray(value)) {\n headers.set(key, value.join(', '))\n } else {\n headers.set(key, value)\n }\n }\n return headers\n}\n\n/**\n * Build a Web `Request` from a Node `IncomingMessage`, body included. The Web\n * Request spec requires an absolute URL; we synthesize one from the Host header\n * (fallback `localhost` for test doubles).\n *\n * For methods with a body (POST/PUT/PATCH/DELETE), the Node Readable stream is\n * wrapped as a Web ReadableStream via `Readable.toWeb()` so downstream consumers\n * can call `request.json()` / `request.formData()` / `request.text()` natively.\n */\nexport function incomingMessageToWebRequest(req: IncomingMessage): Request {\n const url = synthesizeAbsoluteUrl(req)\n const headers = nodeHeadersToWeb(req)\n\n const method = (req.method ?? 'GET').toUpperCase()\n const hasBody = method !== 'GET' && method !== 'HEAD'\n\n if (!hasBody) {\n return new Request(url, { method, headers })\n }\n\n // Drain Node's Readable into a Web ReadableStream. `Readable.toWeb` is\n // available in Node 18+ (theokit's engines.node floor is 22+, so safe).\n const webStream = Readable.toWeb(req) as ReadableStream\n return new Request(url, {\n method,\n headers,\n body: webStream,\n // EC-2: Node 18+ requires `duplex: 'half'` when body is a stream.\n // The `RequestInit` type omits it (Web spec gap); cast accordingly.\n ...({ duplex: 'half' } as { duplex: 'half' }),\n })\n}\n\n/**\n * A Node request that has NOT been converted yet — method now, body only if someone claims it.\n *\n * theokit#400. `incomingMessageToWebRequest` drains the Node stream, and a stream drains once. A\n * dispatcher that converts in order to decide whether it owns a path has already spent the body on\n * every path it does not own: the next branch attaches to a readable that has already ended, waits\n * for an `'end'` that cannot fire twice, and the request hangs with no status at all.\n *\n * The fix is an ordering one, so the type encodes the ordering: a router reads `method` (free) and\n * calls `toRequest()` only after it has decided the request is its own. Passing the source instead\n * of a `Request` is what makes \"did you convert before deciding?\" answerable by reading a signature.\n *\n * `toRequest()` memoizes, because a second conversion of the same `IncomingMessage` yields a\n * Request whose body is an empty closed stream — a silent truncation, which is worse than the hang\n * it would replace.\n */\nexport interface WebRequestSource {\n /** Uppercase HTTP method. Available without touching the body. */\n readonly method: string\n /** Convert on demand. Idempotent: repeated calls return the same `Request`. */\n toRequest: () => Request\n}\n\n/** Wrap `req` as a {@link WebRequestSource} — the conversion is deferred and memoized. */\nexport function createWebRequestSource(req: IncomingMessage): WebRequestSource {\n let converted: Request | undefined\n return {\n method: (req.method ?? 'GET').toUpperCase(),\n toRequest: () => (converted ??= incomingMessageToWebRequest(req)),\n }\n}\n\n/**\n * Build the Web `Request` handed to a route handler as `ctx.request` in the\n * Node server path (dev + `theokit start`). Method + absolute URL + headers\n * only — NO body.\n *\n * Why no body: the Node executor parses the request body BEFORE the handler\n * runs and exposes the parsed value as `ctx.body` (the typed, documented body\n * API). By the time the handler is called the Node stream is already drained,\n * so re-wrapping it would yield an empty/closed stream. Handlers read the body\n * via `ctx.body`; `ctx.request` is for the Web-standard header/cookie/URL/method\n * surface (e.g. `createSessionManagerWeb.getSession(ctx.request)`).\n *\n * Per ADR-0028 R3a, handlers see a Web `Request` in every runtime — this closes\n * the gap where the Node path leaked the raw `IncomingMessage` (whose `.headers`\n * is a plain object, so `.headers.get(...)` threw for Web-standard consumers).\n */\nexport function incomingMessageToHandlerRequest(req: IncomingMessage): Request {\n return new Request(synthesizeAbsoluteUrl(req), {\n method: (req.method ?? 'GET').toUpperCase(),\n headers: nodeHeadersToWeb(req),\n })\n}\n","/* eslint-disable security/detect-non-literal-fs-filename --\n * Controller files are walked from the developer's `serverDir/controllers`\n * (a build-time config path), never from HTTP input. No injection vector.\n */\nimport { readdirSync, type Dirent } from 'node:fs'\nimport type { IncomingMessage, ServerResponse } from 'node:http'\nimport { join } from 'node:path'\n\nimport {\n CONTROLLER_PREFIX,\n createDecoratorHandler,\n getMeta,\n isControllerClass,\n Reflector,\n type ServeAgent,\n} from '@theokit/http'\n\nimport type { PluginContext } from '../plugin-types.js'\nimport type { PluginRunner } from '../plugins/plugin-runner.js'\nimport { dispatchCsrfWarn } from '../security/csrf-warn-dispatch.js'\nimport { enforceCsrf, type DisallowedConfig } from '../security/csrf.js'\n\nimport { incomingMessageToWebRequest } from './node-request.js'\nimport { sendError } from './send-response.js'\n\n/** A decorator controller constructor (`@Controller` class). */\ntype ControllerClass = new (...args: never[]) => object\n\n/** Loads a controller module by absolute path. In dev this is Vite's `ssrLoadModule`\n * (the Task 1.1 swc transform has already compiled the parameter decorators); tests\n * inject `@theokit/http`'s `loadControllerWithSwc`. */\nexport type ControllerModuleLoader = (absPath: string) => Promise<Record<string, unknown>>\n\n/** A built controller route table exposed as a pure Web-Standard handler. */\ninterface ControllerDispatcher {\n /** `null` = no controller route matched — the host owns the miss (404 / fall-through). */\n dispatch(request: Request): Promise<Response | null>\n /** Non-executing route probe — true when a controller route owns `method` + `pathname`. */\n matches(method: string, pathname: string): boolean\n /** True when the controller owning `pathname` declared `theokit:csrf-exempt`. */\n isCsrfExempt(pathname: string): boolean\n}\n\n// State-mutating methods get CSRF, mirroring the file-route pipeline (execute.ts).\nconst CSRF_PROTECTED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])\n\n/**\n * The plugin lifecycle a controller route runs, or a do-nothing stand-in when there is none.\n *\n * A null object rather than an optional runner threaded through four call sites: with `undefined`\n * every stage reads `if (runner && ctx)` and the dispatcher's branch count carries a question that\n * was already answered once. Here the question is answered when the object is built, and the\n * stages below say what they do instead of re-deciding whether to do it.\n *\n * `shortCircuited` from the inert one is always `false`, which is the truth: no hook answered\n * because there were no hooks.\n */\ninterface ControllerLifecycle {\n onRequest(): Promise<boolean>\n preHandler(): Promise<boolean>\n onError(error: unknown): Promise<void>\n onResponse(): Promise<void>\n}\n\nconst INERT_LIFECYCLE: ControllerLifecycle = {\n onRequest: () => Promise.resolve(false),\n preHandler: () => Promise.resolve(false),\n onError: () => Promise.resolve(),\n onResponse: () => Promise.resolve(),\n}\n\n/**\n * Build the lifecycle for a request a controller OWNS (usetheokit/theokit#607).\n *\n * @param runner - absent for an app that declares no plugins.\n * @param owned - `dispatcher.matches(...)`, the non-executing probe. False means this dispatcher\n * is about to decline the path, and a hook fired here would run on a request the host is going\n * to serve some other way — which is how a hook ends up firing twice for one request.\n */\nfunction controllerLifecycle(\n runner: PluginRunner | undefined,\n owned: boolean,\n ctx: PluginContext,\n): ControllerLifecycle {\n if (runner === undefined || !owned) return INERT_LIFECYCLE\n return {\n async onRequest() {\n runner.applyDecorations(ctx.ctx)\n return (await runner.runOnRequest(ctx)).shortCircuited\n },\n async preHandler() {\n return (await runner.runPreHandler(ctx)).shortCircuited\n },\n async onError(error: unknown) {\n await runner.runOnError(ctx, error)\n },\n async onResponse() {\n await runner.runOnResponse(ctx)\n },\n }\n}\n\n/**\n * A controller declaring that it authenticates by other means, so the CSRF gate has nothing to add.\n *\n * The case this exists for is a webhook. Stripe, GitHub and every other sender authenticate with an\n * HMAC over the request body — stronger than a header, and entirely unrelated to one. None of them\n * will ever send `X-Theo-Action`, so without this a webhook endpoint answers 403 to every real\n * delivery and the only escape is `csrf: 'warn'` for the whole application (theokit#535).\n *\n * DELIBERATELY separate from `theokit:public`. They answer different questions — \"may an\n * unauthenticated caller reach this?\" and \"does this route authenticate by other means?\" — and a\n * route can want the first without the second. Conflating them would lift the gate off every public\n * route in the ecosystem as a side effect of a webhook fix.\n *\n * Declared on the CONTROLLER, not the method: the granularity a webhook needs, and it avoids a\n * second path matcher alongside `handle.matches` that could drift from it.\n */\nconst CSRF_EXEMPT_METADATA = 'theokit:csrf-exempt'\n\n/**\n * Segment-wise, so `api/hooks` never matches `api/hooks-admin`.\n *\n * Split-and-filter rather than a trimming regex: `/^\\/+|\\/+$/` is quadratic on a pathname of\n * repeated slashes, and a pathname is attacker-supplied (sonarjs/slow-regex). Splitting is linear\n * and `filter(Boolean)` drops the empty segments the leading and trailing slashes produce, which is\n * all the trim was for.\n */\nfunction segments(value: string): string[] {\n return value.split('/').filter(Boolean)\n}\n\nfunction pathOwnedByPrefix(pathname: string, prefix: string): boolean {\n const want = segments(prefix)\n const got = segments(pathname)\n if (want.length === 0 || got.length < want.length) return false\n return want.every((segment, i) => got[i] === segment)\n}\n\n/** Recursively collect `*.controller.ts` files under `dir` (absolute paths). */\n/**\n * Every `*.controller.ts` under `dir`, recursively. Exported for theokit#123: the build emitter\n * must find exactly the same set the dev dispatcher does, and two independent walks would be two\n * definitions of \"a controller\" that drift.\n */\nexport function findControllerFiles(dir: string): string[] {\n const found: string[] = []\n const walk = (current: string): void => {\n let entries: Dirent[]\n try {\n entries = readdirSync(current, { withFileTypes: true })\n } catch {\n return // dir doesn't exist — no controllers\n }\n for (const entry of entries) {\n const full = join(current, entry.name)\n if (entry.isDirectory()) walk(full)\n // theokit#123 — `.mjs` alongside `.ts`. Dev walks the SOURCE tree; production walks the\n // COMPILED tree under `dist/controllers`, where the same files exist as `*.controller.mjs`.\n // One walk for both keeps a single definition of \"a controller file\"; two would drift, and a\n // file that counts in dev and not in production is exactly the dev/prod split this fixes.\n else if (entry.name.endsWith('.controller.ts') || entry.name.endsWith('.controller.mjs'))\n found.push(full)\n }\n }\n walk(dir)\n return found\n}\n\n/** A discovered controller: its source file + the loaded `@Controller` class. */\ninterface ControllerModule {\n filePath: string\n cls: ControllerClass\n /**\n * The module's full export namespace — theokit#124.\n *\n * Kept alongside the class because it is the ONLY place a `@Body(schema)` regains a name. The\n * schema on `WalkResult.bodySchema` is a runtime `z.ZodType` with no source identifier, but it is\n * the very object this module exported, so matching it back by reference identity recovers the\n * exported name the typed-client codegen needs to write `z.infer<typeof ...>`.\n *\n * Unused by the dispatch path, which needs only the class.\n */\n exports: Readonly<Record<string, unknown>>\n}\n\n/**\n * Load every `@Controller` class under `controllersDir` via the injected loader,\n * keeping each class paired with its source file (needed by the typed-client\n * codegen to emit `import type { X } from '<file>'`). Non-controller exports are\n * ignored (`isControllerClass` — reused from @theokit/http).\n */\nexport async function scanControllerModules(\n controllersDir: string,\n loadModule: ControllerModuleLoader,\n): Promise<ControllerModule[]> {\n const files = findControllerFiles(controllersDir)\n const modules: ControllerModule[] = []\n for (const filePath of files) {\n const mod = await loadModule(filePath)\n for (const exported of Object.values(mod)) {\n if (typeof exported === 'function' && isControllerClass(exported)) {\n modules.push({ filePath, cls: exported as ControllerClass, exports: mod })\n }\n }\n }\n return modules\n}\n\n/** Load every `@Controller` class under `controllersDir` (classes only). */\nasync function scanControllers(\n controllersDir: string,\n loadModule: ControllerModuleLoader,\n): Promise<ControllerClass[]> {\n const modules = await scanControllerModules(controllersDir, loadModule)\n return modules.map((m) => m.cls)\n}\n\n/**\n * Scan `controllersDir` and build a Web-Standard dispatcher over the decorator\n * controllers found. Returns `null` when the directory has no controllers, so\n * the host can skip the controller path entirely (zero cost for routes-only apps).\n *\n * Dispatch REUSES @theokit/http's `createDecoratorHandler` (match + `@Param`\n * binding + `@Body` validation + Response building) — never re-implemented (ADR-1).\n */\nexport async function createControllerDispatcher(opts: {\n controllersDir: string\n loadModule: ControllerModuleLoader\n /** M47 — serves `@Expose`-bound agent routes (theo supplies a `mountAgent`-backed impl). */\n serveAgent?: ServeAgent\n}): Promise<ControllerDispatcher | null> {\n const classes = await scanControllers(opts.controllersDir, opts.loadModule)\n if (classes.length === 0) return null\n // No `undeclaredRoutes` pass-through, deliberately (usetheokit/theokit#576). The handler defaults\n // to `'deny'`, and a theokit app cannot ship an undeclared controller anyway — `theokit build`\n // refuses one (#514). What this closes is `theokit dev`, which never runs that gate: an undeclared\n // route now answers 403 there, at the first request, instead of being served until the build\n // catches it. An escape here would only let an app defer a failure it cannot ship past.\n const handle = createDecoratorHandler({ controllers: classes, serveAgent: opts.serveAgent })\n const reflector = new Reflector()\n\n // Read once at construction: the metadata cannot change between requests, and re-walking every\n // class per request would put a reflection pass on the hot path for a value that never moves.\n const exemptPrefixes = classes\n // `Reflector`, not `Reflect.getMetadata`: the global is only typed where `reflect-metadata`\n // has been imported, and this module does not import it — the dts build fails on it (TS2339).\n // `@SetMetadata` writes through the same store, so the reader is the framework's own.\n .filter((cls) => reflector.getByKey<boolean>(CSRF_EXEMPT_METADATA, cls) === true)\n .map((cls) => {\n const meta = getMeta<{ prefix?: string }>(CONTROLLER_PREFIX, cls)\n return meta?.prefix ?? ''\n })\n .filter((prefix) => prefix !== '')\n\n return {\n dispatch: (request) => handle(request),\n matches: (method, pathname) => handle.matches(method, pathname),\n isCsrfExempt: (pathname) => exemptPrefixes.some((p) => pathOwnedByPrefix(pathname, p)),\n }\n}\n\n/**\n * Write a controller's Web `Response` to a Node response, byte for byte.\n *\n * The body used to be read with `await response.text()`, under a comment asserting that controllers\n * never stream. The comment was true of this path and hid the real problem: `text()` decodes as\n * UTF-8, so every byte >= 0x80 became `U+FFFD`. A payload of all 256 byte values arrived as 512\n * bytes; a 55 296-byte MPEG from `@theokit/plugin-voice` arrived as 76 790 bytes beginning\n * `ef bf bd`. Status 200, correct content-type, plausible length — invisible until someone opens\n * the file. File routes were never affected: `executeRoute` pumps the stream, so this was a silent\n * divergence between two paths meant to be at parity.\n *\n * `arrayBuffer()` fixes it without changing anything else. This path stays BUFFERED, exactly as\n * before — a controller returning a streamed body still has it collected here, so a plugin that\n * promises progressive delivery does not get it through a controller. That is a real limitation and\n * a separate change: making it stream alters when bytes reach the client, which is behaviour beyond\n * the corruption this repairs.\n */\nasync function writeControllerResponse(res: ServerResponse, response: Response): Promise<void> {\n const headersBag: Record<string, string> = {}\n for (const [k, v] of response.headers) {\n if (k.toLowerCase() !== 'set-cookie') headersBag[k] = v\n }\n const setCookies = response.headers.getSetCookie()\n if (setCookies.length > 0) res.setHeader('Set-Cookie', setCookies)\n res.writeHead(response.status, headersBag)\n // `Buffer.from(ArrayBuffer)` views the bytes as they are. Any string in between is a decode, and\n // a decode of arbitrary bytes is a loss.\n const body = response.body ? Buffer.from(await response.arrayBuffer()) : undefined\n res.end(body !== undefined && body.length > 0 ? body : undefined)\n}\n\n/**\n * The `api-middleware` fall-through in one call: scan `controllersDir`, build the\n * dispatcher, and serve the request. Builds a body-ful Web `Request` (`@Body`\n * needs the body; the raw stream is undrained at a route miss) and enforces CSRF\n * with the SAME gate file routes use (parity). Returns `true` when a controller\n * handled it (or CSRF blocked it), `false` when there are no controllers OR none\n * matched (the host continues to its own 404). Built per-miss so controller edits\n * reflect via HMR.\n */\nexport async function dispatchControllerRequest(args: {\n controllersDir: string\n loadModule: ControllerModuleLoader\n req: IncomingMessage\n res: ServerResponse\n csrfMode: 'off' | 'warn' | 'strict'\n disallowed?: DisallowedConfig\n requestId: string\n /** M47 — serves `@Expose`-bound agent routes (mountAgent-backed); omit for routes-only apps. */\n serveAgent?: ServeAgent\n /**\n * usetheokit/theokit#607 — the plugin lifecycle a controller route runs.\n *\n * This parameter did not exist, so neither caller could pass one, so a `@Controller` route ran\n * NO hook in either surface: `theokit start` gave it nothing at all, and `theokit dev` gave it\n * only the `onRequest` its middleware happened to fire before matching. An adopter's identity\n * plugin was therefore dead while the boot log reported it registered, and a rate limiter written\n * as a `preHandler` enforced nothing while reading exactly like protection.\n *\n * Omit it for an app with no plugins — the path then costs one `undefined` check.\n */\n pluginRunner?: PluginRunner\n}): Promise<boolean> {\n const { req, res, csrfMode, disallowed, requestId, pluginRunner } = args\n const dispatcher = await createControllerDispatcher({\n controllersDir: args.controllersDir,\n loadModule: args.loadModule,\n serveAgent: args.serveAgent,\n })\n if (!dispatcher) return false\n\n const method = (req.method ?? 'GET').toUpperCase()\n const webRequest = incomingMessageToWebRequest(req)\n const pathname = new URL(webRequest.url).pathname\n\n /**\n * Whether a controller route owns this path, decided WITHOUT executing anything.\n *\n * The same non-executing probe the CSRF gate below already used, hoisted because the plugin\n * lifecycle needs the identical answer. Every hook is gated on it: this function runs for every\n * unmatched `/api/*` url, so firing a hook before knowing the path is ours would run the\n * lifecycle on requests this dispatcher is about to decline — and the host runs its own for\n * those (`api-middleware.ts`, the \"nobody owns it\" arm).\n */\n const owned = dispatcher.matches(method, pathname)\n const lifecycle = controllerLifecycle(pluginRunner, owned, {\n request: webRequest,\n response: res,\n ctx: {},\n requestId,\n })\n\n // #607 — onRequest BEFORE the CSRF gate, mirroring `executeRoute`: a hook that establishes\n // identity must have run before anything decides whether to refuse the caller.\n if (await lifecycle.onRequest()) return true\n\n // CSRF parity: enforce ONLY when a protected-method controller route actually\n // owns this path (probe with the non-executing matcher — never double-dispatch,\n // which would run the handler + its side effects). An unrouted path falls\n // through to the host's own 404, not a 403.\n if (CSRF_PROTECTED_METHODS.has(method) && owned && !dispatcher.isCsrfExempt(pathname)) {\n const decision = enforceCsrf(\n req,\n csrfMode,\n { warn: dispatchCsrfWarn, path: req.url },\n disallowed,\n )\n if (!decision.allow) {\n sendError(\n res,\n 'CSRF_INVALID',\n decision.reason ?? 'CSRF check failed',\n 403,\n undefined,\n requestId,\n )\n return true\n }\n }\n\n // #607 — preHandler after the CSRF gate and immediately before the handler, the position it\n // holds in `execute.ts` for a file route. A hook that answers here stops the pipeline.\n if (await lifecycle.preHandler()) return true\n\n let response: Response | null\n try {\n response = await dispatcher.dispatch(webRequest)\n } catch (err) {\n // Fail loud: the hook observes the failure and the error keeps rising to the host, which owns\n // the 500 envelope. Swallowing it here would give plugins a view the caller does not have.\n await lifecycle.onError(err)\n throw err\n }\n if (response === null) return false\n await writeControllerResponse(res, response)\n\n // After the response is written, as `executeRoute` and `serveThroughPluginLifecycle` both do.\n await lifecycle.onResponse()\n return true\n}\n","/**\n * Canonical CSRF warn dispatcher (T3.3 of architecture-review-remediation-plan).\n *\n * Consolidates the duplicated `warn: (payload) => { warnOnce(...) }` closure\n * that previously appeared in both `http/execute.ts` and\n * `http/action-execute.ts`. Resolves PV-10 (DRY).\n *\n * `warnOnce` dedupes by `event:method:path` so a request loop with 1000 POSTs\n * doesn't flood logs with identical warnings. Apps grep for `event\":\"csrf.warn\"`\n * (stable event shape — see [[enforcement-cutover.md]]).\n */\nimport { warnOnce } from '../observability/logger.js'\n\ninterface CsrfWarnPayload {\n event: string\n method: string\n path?: string\n reason: string\n code?: string\n docsUrl?: string\n warnOnce?: boolean\n}\n\n/**\n * Build the warn callback that `enforceCsrf` invokes for soft-mode warnings.\n * Returned function is suitable for the `warn` field of `enforceCsrf`'s options.\n */\nexport function dispatchCsrfWarn(payload: CsrfWarnPayload): void {\n const key = `${payload.event}:${payload.method}:${payload.path ?? ''}`\n warnOnce(key, payload as unknown as Record<string, unknown>)\n}\n","/**\n * T4.1 — Audit logging interface + default JSON stdout sink.\n *\n * Per ADR D4: define the interface; ship a zero-dep default; reserve\n * adapter shapes for Postgres, File, OpenTelemetry, Sentry as follow-up\n * packages. Persistence has heavy deps (`pg`, `better-sqlite3`); we\n * keep core dep-free and let users opt in.\n *\n * Compatibility:\n * - Node / Bun / Deno / Vercel — console.log is sync, captured.\n * - Edge runtimes (CF Workers, Vercel Edge) — console.log is captured\n * but may be rate-limited by the platform. For high-volume edge audit,\n * implement a custom sink writing to a queue / HTTP endpoint.\n */\n\nexport interface AuditEvent {\n /** Domain-qualified verb. Convention: `<domain>.<verb>` (e.g. csrf.warn, session.rotated). */\n action: string\n /** Who triggered the event. Anonymous = no auth at time of event. */\n actor?: { type: 'user' | 'system' | 'anonymous'; id?: string }\n /** What was operated on (optional). */\n resource?: { type: string; id?: string }\n /** Arbitrary event-specific metadata. JSON-serializable. */\n metadata?: Record<string, unknown>\n /** ISO 8601 timestamp. If absent, sink fills in `new Date().toISOString()`. */\n timestamp?: string\n /** Optional trace id (populated by middleware from `x-trace-id`). */\n traceId?: string\n}\n\nexport interface AuditLogger {\n log(event: AuditEvent): void | Promise<void>\n}\n\n/**\n * Default sink: one JSON line per event to stdout. Sync. Never throws.\n *\n * EC: circular refs / BigInt values fall back to a placeholder line so\n * the event is still observable (action + traceId) without crashing the\n * request lifecycle.\n */\nexport class JsonStdoutSink implements AuditLogger {\n log(event: AuditEvent): void {\n const enriched = {\n level: 'audit' as const,\n ...event,\n timestamp: event.timestamp ?? new Date().toISOString(),\n }\n try {\n // eslint-disable-next-line no-console -- JsonStdoutSink IS the audit output\n console.log(JSON.stringify(enriched, jsonReplacer))\n } catch {\n // eslint-disable-next-line no-console -- fallback when payload won't serialize\n console.log(\n `{\"level\":\"audit\",\"action\":${JSON.stringify(event.action)},\"timestamp\":${JSON.stringify(enriched.timestamp)},\"note\":\"payload could not be serialized\"}`,\n )\n }\n }\n}\n\n/**\n * Replacer that walks BigInt → string. Circular ref handling is via the\n * outer try/catch (JSON.stringify throws TypeError on cycles; we drop to\n * the fallback line). We don't implement custom cycle-breaking walker\n * because the audit payload is meant to be JSON — if user metadata has\n * a cycle, the right answer is to fix the caller, not silently lose\n * the structure.\n */\nfunction jsonReplacer(_key: string, value: unknown): unknown {\n if (typeof value === 'bigint') return value.toString()\n return value\n}\n\n/**\n * No-op logger. Returned when `config.audit` is unset. Zero overhead;\n * framework wiring sites null-check before calling.\n */\nexport function createNoOpLogger(): AuditLogger {\n return {\n log() {\n // intentionally empty\n },\n }\n}\n\n/**\n * T4.2 — Safe-emit wrapper. Used by framework wiring sites (csrf.ts,\n * rate-limit.ts, session.ts) so a logger throw NEVER propagates into\n * the request handler.\n */\nexport function safeAudit(logger: AuditLogger | undefined, event: AuditEvent): void {\n if (!logger) return\n try {\n const r = logger.log(event)\n // Discard the Promise — async sinks are fire-and-forget by design.\n if (r && typeof r.then === 'function') {\n r.catch(() => {\n // swallow async sink failures — audit must never crash the request\n })\n }\n } catch {\n // swallow sync sink failures — audit must never crash the request\n }\n}\n","import type { IncomingMessage } from 'node:http'\n\nimport type { AuditLogger } from '../observability/audit-log.js'\nimport { safeAudit } from '../observability/audit-log.js'\n\n/**\n * CSRF enforcement mode.\n *\n * - `off` — skip CSRF entirely. Use only when you have another defense\n * (e.g. you don't ship session cookies, all auth is bearer).\n * - `warn` — log a structured warning when the check would fail, but\n * still serve the request. Default for 0.2.0. Migration mode.\n * - `strict` — reject failing requests with 403 + code `CSRF_INVALID`.\n * Will become the default in 0.3.0.\n */\nexport type CsrfMode = 'off' | 'warn' | 'strict'\n\n/**\n * Per-request structured logger surface. Only `warn` is used by enforceCsrf;\n * we don't require a full Logger here so callers can pass a mock or the\n * console directly.\n */\nexport interface CsrfLogger {\n warn: (payload: CsrfWarnPayload) => void\n /** Optional path the request was destined for — used for log correlation. */\n path?: string\n}\n\n/**\n * T5.1 — Rails-inspired per-route escalation.\n *\n * `routes` accepts string (exact match) or RegExp entries. When a request\n * path matches AND the request would otherwise emit a warning, the\n * `behavior` field decides what happens:\n *\n * - `'warn'` → normal warn dispatch (no-op vs default)\n * - `'raise'` → escalate to 403 regardless of global `csrf` mode\n *\n * `'raise'` never downgrades: when global mode is `'off'`, validation is\n * skipped entirely and disallowed dispatch never runs.\n */\nexport interface DisallowedConfig {\n routes: (string | RegExp)[]\n behavior: 'warn' | 'raise'\n}\n\n/**\n * Test whether `path` matches any of the supplied patterns. String\n * patterns are EXACT (trailing slash matters — use RegExp for tolerance).\n *\n * EC-5: when a RegExp carries the `/g` flag, `.test()` mutates\n * `lastIndex` and the next invocation may miss. We reset `lastIndex`\n * before each test so the matcher is a pure function.\n */\nexport function matchDisallowed(path: string, patterns: readonly (string | RegExp)[]): boolean {\n for (const p of patterns) {\n if (typeof p === 'string') {\n if (path === p) return true\n } else if (p instanceof RegExp) {\n p.lastIndex = 0\n if (p.test(path)) return true\n }\n // Neither string nor RegExp: ignore silently (defensive — the public\n // type forbids it but runtime data may slip past).\n }\n return false\n}\n\n/**\n * T2.2 — Stable cutover identifier shipped with every csrf.warn payload.\n *\n * Convention borrowed from Vite's `deprecations.ts:74` — a `code` plus a\n * `docsUrl` lets users (a) grep their logs for a single stable identifier\n * to find every csrf.warn line, and (b) click through directly to the\n * migration guide. Strings are exported constants so the analyzer (T2.3)\n * and migration guide can reference the same source of truth.\n */\nexport const CSRF_WARN_CODE = 'CSRF_STRICT_CUTOVER' as const\nexport const CSRF_WARN_DOCS_URL = 'https://theokit.dev/upgrade/csrf-strict-cutover' as const\n\n/**\n * Pick a header value, choosing the first entry when an array (Node sets\n * arrays for headers that legitimately appear multiple times). Returns\n * `''` when the header is absent or empty — callers treat `''` as \"skip\".\n */\nfunction pickHeader(value: string | string[] | undefined): string {\n if (typeof value === 'string') return value\n if (Array.isArray(value) && value.length > 0) return value[0]\n return ''\n}\n\nexport interface CsrfWarnPayload {\n event: 'csrf.warn'\n method: string\n path: string | undefined\n reason: string\n /**\n * Stable identifier for the 0.2 → 0.3 CSRF strict cutover. Always\n * `'CSRF_STRICT_CUTOVER'`. Grep-able from prod logs.\n */\n code: string\n /**\n * Link to the section of the migration guide explaining how to clear\n * this specific warning class.\n */\n docsUrl: string\n}\n\n/**\n * T5a.2 Phase B (slice 1/6): pure header-only CSRF check extracted from\n * `validateCsrf(req: IncomingMessage)` so it can be re-used by the Web-\n * Standards `validateCsrfRequest(request: Request)` sibling. Per the T5a.2\n * plan v1.0 § Phase B, header-only leaves are first to migrate. This is\n * the dual-signature pattern (anti-pattern #2 avoidance): IncomingMessage\n * consumers unchanged; new Request consumers go through the same logic\n * via the shared helper.\n *\n * Pure logic — accepts pre-extracted header values as strings or null.\n */\nfunction isCsrfValidFromHeaders(opts: {\n csrfActionHeader: string | null\n origin: string | null\n host: string | null\n}): { valid: true } | { valid: false; reason: string } {\n // 1. Custom header must be present (primary defense — simple form posts\n // cannot set custom headers, browsers gate via CORS preflight)\n if (opts.csrfActionHeader !== '1') {\n return { valid: false, reason: 'Missing X-Theo-Action header' }\n }\n\n // 2. Origin matching (secondary defense)\n if (opts.origin === null || opts.origin === '') {\n // Browsers omit Origin for same-origin requests — treat as valid\n return { valid: true }\n }\n\n if (opts.host === null || opts.host === '') {\n return { valid: true }\n }\n\n try {\n const originHost = new URL(opts.origin).host\n if (originHost !== opts.host) {\n return { valid: false, reason: `Origin ${opts.origin} does not match host ${opts.host}` }\n }\n } catch {\n return { valid: false, reason: `Invalid origin: ${opts.origin}` }\n }\n\n return { valid: true }\n}\n\nexport function validateCsrf(\n req: IncomingMessage,\n): { valid: true } | { valid: false; reason: string } {\n // IncomingMessage adapter — normalize Node header shape to the pure\n // helper's input shape (string|null).\n const action = req.headers['x-theo-action']\n const origin = req.headers.origin\n const host = req.headers.host\n\n // RFC 6454: Origin is single-valued. A caller that synthesizes an\n // IncomingMessage — an adapter, a shim, a proxy library — can hand us an\n // array, and choosing one of two conflicting origins is a decision the\n // request never authorized. The disagreement IS the rejection.\n //\n // `node:http` itself joins a repeated Origin with `, ` rather than\n // producing an array, and that string already fails to parse as a URL\n // below. This branch covers the shape the type allows and `pickHeader`\n // used to resolve silently.\n if (Array.isArray(origin)) {\n return { valid: false, reason: 'Multiple Origin headers (RFC 6454 violation)' }\n }\n\n return isCsrfValidFromHeaders({\n csrfActionHeader: typeof action === 'string' ? action : null,\n origin: origin !== undefined ? origin || null : null,\n host: host !== undefined ? pickHeader(host) || null : null,\n })\n}\n\n/**\n * T5a.2 Phase B (slice 1/6) — Web-Standards-shaped CSRF validator.\n *\n * Mirror of `validateCsrf(req: IncomingMessage)` for the Web `Request`\n * shape. Consumes `request.headers.get(name)` (native Web `Headers` API)\n * instead of `req.headers[name]` (Node `IncomingMessage` indexer). Same\n * CSRF policy + same return shape — the difference is only the input\n * extraction.\n *\n * Used by `executeWebRequest` (T5a.2 Phase A) to enforce CSRF on the\n * Web-Standards request handler entry-point.\n */\nexport function validateCsrfRequest(\n request: Request,\n): { valid: true } | { valid: false; reason: string } {\n return isCsrfValidFromHeaders({\n csrfActionHeader: request.headers.get('x-theo-action'),\n origin: request.headers.get('origin'),\n host: request.headers.get('host'),\n })\n}\n\n/**\n * Enforce CSRF policy with mode-aware behavior. Wrapper over `validateCsrf`\n * that turns the boolean valid/invalid into a request-level allow decision,\n * gated by mode + structured warning in warn mode.\n *\n * Phase 5 — CSRF warn-first (EC-1).\n */\n/**\n * Dispatch the csrf.warn payload to both the structured logger and the\n * audit sink (when configured). Extracted from `enforceCsrf` to keep that\n * function's complexity within ceiling.\n */\nfunction dispatchCsrfWarn(\n req: IncomingMessage,\n reason: string,\n logger: CsrfLogger | undefined,\n auditLogger: AuditLogger | undefined,\n pathFallback = '',\n): void {\n const payload: CsrfWarnPayload = {\n event: 'csrf.warn',\n method: req.method ?? 'UNKNOWN',\n path: logger?.path ?? pathFallback,\n reason,\n code: CSRF_WARN_CODE,\n docsUrl: CSRF_WARN_DOCS_URL,\n }\n logger?.warn(payload)\n // `metadata` is typed as Record<string, unknown>; CsrfWarnPayload is a\n // structurally-equivalent shape but lacks the index signature.\n safeAudit(auditLogger, {\n action: 'csrf.warn',\n actor: { type: 'anonymous' },\n metadata: { ...payload },\n })\n}\n\nexport function enforceCsrf(\n req: IncomingMessage,\n mode: CsrfMode,\n logger?: CsrfLogger,\n disallowed?: DisallowedConfig,\n auditLogger?: AuditLogger,\n): { allow: boolean; reason?: string } {\n if (mode === 'off') {\n // `off` short-circuits before disallowed dispatch — users who set\n // csrf: 'off' globally have explicitly turned validation off, and\n // disallowed must never re-introduce it. The escape hatch is to\n // set csrf: 'warn' and use disallowed for surgical strict pockets.\n return { allow: true }\n }\n\n const check = validateCsrf(req)\n if (check.valid) {\n return { allow: true }\n }\n\n // T5.1 — disallowed dispatch: when the failing request matches a\n // disallowed pattern AND behavior is 'raise', escalate to 403 even if\n // global mode is 'warn'. Strict mode would 403 anyway, so the branch\n // is a no-op there.\n if (disallowed?.behavior === 'raise') {\n const path = logger?.path ?? req.url ?? ''\n if (matchDisallowed(path, disallowed.routes)) {\n return { allow: false, reason: check.reason }\n }\n }\n\n if (mode === 'warn') {\n // T2.1: emit via warnOnce by default — callers can override via the\n // injected logger.warn (tests, custom log routers).\n // T2.2: include the stable cutover code + docsUrl so logs are\n // grep-able and click-through-able.\n dispatchCsrfWarn(req, check.reason, logger, auditLogger)\n return { allow: true, reason: check.reason }\n }\n\n // strict — 403 the request, AND emit a warn payload so the dev (and\n // devtools UI) sees WHY it was blocked + the docsUrl to fix it.\n // Without this, strict-mode users get a silent 403 with no context.\n dispatchCsrfWarn(req, check.reason, logger, auditLogger)\n return { allow: false, reason: check.reason }\n}\n"],"mappings":"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","names":["dispatchCsrfWarn"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server/scan/manifest.ts"],"sourcesContent":["/* eslint-disable security/detect-non-literal-fs-filename --\n * Build-time manifest emitter / loader. All paths derived from `distDir`\n * + `serverDir`, themselves resolved from `process.cwd()`. No HTTP input.\n */\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'\nimport { join, resolve, relative, dirname } from 'node:path'\n\nimport { scanServerActions } from './action-scan.js'\nimport type { ActionNode } from './action-scan.js'\nimport { scanAgents } from './agent-scan.js'\nimport type { AgentNode } from './agent-scan.js'\nimport { compilePattern } from './match.js'\nimport type { ServerRouteNode } from './match.js'\nimport { scanServerRoutes } from './scan.js'\nimport { scanWebSocketRoutes } from './ws-scan.js'\nimport type { WebSocketRouteNode } from './ws-scan.js'\n\n// --- Manifest Types ---\n\nexport interface ManifestRoute {\n filePath: string\n routePath: string\n paramNames: string[]\n /** HTTP methods (uppercase) the route file exports. Optional — manifests\n * generated before G1 omit this; loaders treat absence as \"unknown\". */\n methods?: string[]\n /**\n * The subset of `methods` declaring `policy('public')`. Optional for the same reason `methods`\n * is: a manifest written before the field existed has none, and `public-exposure-gate.ts` reads\n * that absence as \"not measured\" rather than as \"nothing is public\".\n */\n publicMethods?: string[]\n}\n\nexport interface ManifestAction {\n filePath: string\n actionPath: string\n}\n\nexport interface ManifestWebSocket {\n filePath: string\n wsPath: string\n}\n\n/** M2 — a top-level `agents/*.ts` convention entry. `filePath` is relative to the\n * project root (agents live OUTSIDE `serverDir`), unlike routes/actions/ws. */\nexport interface ManifestAgent {\n filePath: string\n agentPath: string\n name: string\n}\n\nexport interface TheoManifest {\n version: 1\n generatedAt: string\n /**\n * FILE routes — what `scanServerRoutes` finds under `<serverDir>/routes`. Controllers are NOT\n * here, and that is deliberate: they ship as a separate `controllers.json` so a deploy adapter\n * that knows nothing about them keeps working unchanged (see `emit-controllers.ts`).\n *\n * Which makes `routes: []` ambiguous, and the ambiguity is worth stating rather than leaving for\n * each reader to rediscover. It means \"no file routes\" — never \"this app serves nothing\". Measured\n * on a nine-controller app: sixteen routes served, `routes: []` written (theokit#543). Anything\n * answering \"what does this app serve?\" must read `controllers.json` too; `public-exposure-gate`\n * is the one that had to learn this the expensive way.\n */\n routes: ManifestRoute[]\n actions: ManifestAction[]\n websockets: ManifestWebSocket[]\n /** M2 — optional for backward compat: manifests generated before M2 omit it. */\n agents?: ManifestAgent[]\n}\n\nexport interface LoadedManifest {\n routes: ServerRouteNode[]\n actions: ActionNode[]\n websockets: WebSocketRouteNode[]\n agents: AgentNode[]\n}\n\n// --- Generate ---\n\nexport function generateManifest(\n serverDir: string,\n // Agents live at `<projectRoot>/<agentsDir>`. Defaults to the server dir's parent; overridable\n // for tests / non-standard layouts.\n projectRoot: string = dirname(serverDir),\n // Agents dir NAME (config `agentsDir`, default \"agents\"), relative to projectRoot (#95 follow-up).\n agentsDir = 'agents',\n): TheoManifest {\n const routes = scanServerRoutes(serverDir)\n const actions = scanServerActions(serverDir)\n const websockets = scanWebSocketRoutes(serverDir)\n const agents = scanAgents(projectRoot, agentsDir)\n\n return {\n version: 1,\n generatedAt: new Date().toISOString(),\n routes: routes.map((r) => ({\n filePath: relative(serverDir, r.filePath),\n routePath: r.routePath,\n paramNames: r.paramNames,\n ...(r.methods !== undefined ? { methods: r.methods } : {}),\n ...(r.publicMethods !== undefined ? { publicMethods: r.publicMethods } : {}),\n })),\n actions: actions.map((a) => ({\n filePath: relative(serverDir, a.filePath),\n actionPath: a.actionPath,\n })),\n websockets: websockets.map((w) => ({\n filePath: relative(serverDir, w.filePath),\n wsPath: w.wsPath,\n })),\n agents: agents.map((a) => ({\n // Relative to projectRoot (agents/ is outside serverDir).\n filePath: relative(projectRoot, a.filePath),\n agentPath: a.agentPath,\n name: a.name,\n })),\n }\n}\n\n// --- Write ---\n\nexport function writeManifest(manifest: TheoManifest, outputDir: string): void {\n mkdirSync(outputDir, { recursive: true })\n const manifestPath = join(outputDir, 'manifest.json')\n writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))\n}\n\n// --- Load ---\n\nexport function loadManifest(\n distDir: string,\n serverDir: string,\n // usetheokit/theokit#871 — agent paths are encoded relative to the PROJECT ROOT by\n // `generateManifest`, which is told the root. This was not, and derived it as\n // `dirname(serverDir)`. That is right for `<root>/server` and wrong by one level for\n // `<root>/src/server` — the layout `create-theokit` scaffolds, so the wrong case was the\n // DEFAULT case, and every agent route of a freshly scaffolded app answered 500.\n //\n // The default preserves the old behaviour for callers that genuinely have only the server\n // dir; the caller that knows the root passes it, which is every caller that matters.\n projectRoot: string = dirname(serverDir),\n): LoadedManifest {\n const manifestPath = join(distDir, 'manifest.json')\n\n if (!existsSync(manifestPath)) {\n throw new Error(`No manifest found at ${manifestPath}. Run \"theo build\" first.`)\n }\n\n const raw = JSON.parse(readFileSync(manifestPath, 'utf-8')) as TheoManifest\n\n const routes: ServerRouteNode[] = raw.routes.map((r) => {\n const { pattern, paramNames } = compilePattern(r.routePath)\n return {\n filePath: resolve(serverDir, r.filePath),\n routePath: r.routePath,\n paramNames,\n pattern,\n ...(r.methods !== undefined ? { methods: r.methods } : {}),\n ...(r.publicMethods !== undefined ? { publicMethods: r.publicMethods } : {}),\n }\n })\n\n const actions: ActionNode[] = raw.actions.map((a) => ({\n filePath: resolve(serverDir, a.filePath),\n actionPath: a.actionPath,\n }))\n\n const websockets: WebSocketRouteNode[] = raw.websockets.map((w) => ({\n filePath: resolve(serverDir, w.filePath),\n wsPath: w.wsPath,\n }))\n\n // M2 — agents resolve relative to the project root, which is now a PARAMETER rather than a\n // guess (#871). `?? []` keeps pre-M2 manifests (no `agents` field) loadable (fail-safe).\n const agents: AgentNode[] = (raw.agents ?? []).map((a) => ({\n filePath: resolve(projectRoot, a.filePath),\n agentPath: a.agentPath,\n name: a.name,\n }))\n\n return { routes, actions, websockets, agents }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/adapters/deployed-plugins-module.ts"],"sourcesContent":["/**\n * The plugins module a build writes beside a deployed entry (usetheokit/theokit#425).\n *\n * ## What it is for\n *\n * `config.plugins` used to reach no deployed target at all, so `onRequest`, `preHandler`,\n * `onResponse` and `onError` were dead on every Web-standards deployment while firing locally. The\n * reason was never the hook loop — `executeRoute` has always run it — but that a constructed\n * plugin closes over state, and there is no literal for a closure.\n *\n * A plugin declared as a module SPECIFIER can be carried, because the build can emit a static\n * import for it. That is the road `renderBakedRoutes` already takes for route modules (#369):\n * resolve on the build machine, emit a static specifier, let the target's bundler see through it.\n *\n * ## Why the specifier is `../../`\n *\n * Every target that can carry plugins writes its entry two levels below the project root —\n * `.theokit/cloudflare/`, `.theokit/bun/`, `.theokit/deno/` — and a declared specifier is relative\n * to that root. The same arithmetic `renderBakedRoutes` does for routes, for the same reason.\n *\n * ## Which targets can carry them at all\n *\n * Only the ones whose output is BUNDLED from the project: Cloudflare (wrangler), Bun and Deno all\n * resolve the emitted import against the app's own source tree. Vercel, Netlify and AWS Lambda\n * receive a standalone function directory and never see the app's modules, so for them a named\n * refusal — which `findUnappliedConfig` already prints — remains the honest answer, and this module\n * is not called.\n */\n\n/**\n * A plugin the build was asked to carry and cannot.\n *\n * Its own type because the alternative is the defect this whole issue is: hooks that do not fire\n * and nothing that says so. A target that CAN carry a named module but was handed a closure has\n * something actionable to say, so it says it at build time rather than at the first request\n * (`rules/three-target-parity.md` § 3 — refuse by name).\n */\nexport class UnbakeablePluginError extends Error {\n constructor(index: number, name: string, target: string) {\n super(\n `plugins[${index}] (\"${name}\") cannot be carried to the ${target} target: it is a constructed ` +\n `plugin, and a closure has no literal a generated entry could hold. Declare it by module ` +\n `specifier instead — plugins: ['./src/plugins/${name}.ts'] — so the build can emit a ` +\n `static import for it. Both forms work locally; only the specifier survives a deploy.`,\n )\n this.name = 'UnbakeablePluginError'\n }\n}\n\nexport interface DeployedPluginsPlan {\n /** The module source to write beside the entry. */\n readonly source: string\n /** What the entry imports it as — a sibling, because both are written to the same directory. */\n readonly moduleSpecifier: string\n /** The declared specifiers, in order, for the build to report. */\n readonly specifiers: readonly string[]\n}\n\nconst MODULE_FILENAME = 'theo.plugins.mjs'\n\n/**\n * Plan the plugins module for a target that can carry one.\n *\n * @param plugins - the raw `config.plugins` array.\n * @param target - the deploy target, named in the refusal so the message says where it applies.\n * @returns `undefined` when the app declares no plugins, so an app carrying none emits nothing.\n * @throws UnbakeablePluginError when an entry is a constructed plugin rather than a specifier.\n */\nexport function planDeployedPlugins(\n plugins: readonly unknown[] | undefined,\n target: string,\n): DeployedPluginsPlan | undefined {\n if (plugins === undefined || plugins.length === 0) return undefined\n\n const specifiers = plugins.map((entry, index) => {\n if (typeof entry === 'string') return entry\n throw new UnbakeablePluginError(index, nameOf(entry), target)\n })\n\n const vars = specifiers.map((_, i) => `__theoPlugin${String(i)}`)\n\n return {\n moduleSpecifier: `./${MODULE_FILENAME}`,\n specifiers,\n source: [\n `// Generated by Theo — the plugins the deployed entry registers (usetheokit/theokit#425).`,\n `//`,\n `// Static imports, resolved on the build machine. A constructed plugin has no literal, which`,\n `// is why the config declares these by module specifier and why this file can exist at all.`,\n ...specifiers.map((s, i) => `import ${vars[i]} from '${entrySpecifier(s)}'`),\n ``,\n `// Order is DECLARED order, because plugin order is hook order.`,\n `export default { plugins: [${vars.join(', ')}] }`,\n ``,\n ].join('\\n'),\n }\n}\n\n/**\n * How the emitted module names a declared specifier.\n *\n * A RELATIVE specifier is relative to the project root, and the module sits two levels below it, so\n * it is re-based — the arithmetic `renderBakedRoutes` does for routes. A BARE specifier is a package\n * and must be left alone: `../../my-plugin-pkg` is a path that does not exist, where\n * `my-plugin-pkg` is a dependency the target's bundler resolves from `node_modules` like any other.\n */\nfunction entrySpecifier(specifier: string): string {\n if (!specifier.startsWith('.')) return specifier\n return `../../${specifier.replace(/^\\.\\/+/, '')}`\n}\n\n/** The plugin's own name when it has one, so the refusal names the thing the author wrote. */\nfunction nameOf(entry: unknown): string {\n if (entry != null && typeof entry === 'object') {\n const named = (entry as { name?: unknown }).name\n if (typeof named === 'string' && named.length > 0) return named\n }\n return 'unnamed'\n}\n"],"mappings":";;;;AAqCO,IAAM,wBAAN,cAAoC,MAAM;AAAA,EAC/C,YAAY,OAAe,MAAc,QAAgB;AACvD;AAAA,MACE,WAAW,KAAK,OAAO,IAAI,+BAA+B,MAAM,0KAEd,IAAI;AAAA,IAExD;AACA,SAAK,OAAO;AAAA,EACd;AACF;AAWA,IAAM,kBAAkB;AAUjB,SAAS,oBACd,SACA,QACiC;AACjC,MAAI,YAAY,UAAa,QAAQ,WAAW,EAAG,QAAO;AAE1D,QAAM,aAAa,QAAQ,IAAI,CAAC,OAAO,UAAU;AAC/C,QAAI,OAAO,UAAU,SAAU,QAAO;AACtC,UAAM,IAAI,sBAAsB,OAAO,OAAO,KAAK,GAAG,MAAM;AAAA,EAC9D,CAAC;AAED,QAAM,OAAO,WAAW,IAAI,CAAC,GAAG,MAAM,eAAe,OAAO,CAAC,CAAC,EAAE;AAEhE,SAAO;AAAA,IACL,iBAAiB,KAAK,eAAe;AAAA,IACrC;AAAA,IACA,QAAQ;AAAA,MACN;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,GAAG,WAAW,IAAI,CAAC,GAAG,MAAM,UAAU,KAAK,CAAC,CAAC,UAAU,eAAe,CAAC,CAAC,GAAG;AAAA,MAC3E;AAAA,MACA;AAAA,MACA,8BAA8B,KAAK,KAAK,IAAI,CAAC;AAAA,MAC7C;AAAA,IACF,EAAE,KAAK,IAAI;AAAA,EACb;AACF;AAUA,SAAS,eAAe,WAA2B;AACjD,MAAI,CAAC,UAAU,WAAW,GAAG,EAAG,QAAO;AACvC,SAAO,SAAS,UAAU,QAAQ,UAAU,EAAE,CAAC;AACjD;AAGA,SAAS,OAAO,OAAwB;AACtC,MAAI,SAAS,QAAQ,OAAO,UAAU,UAAU;AAC9C,UAAM,QAAS,MAA6B;AAC5C,QAAI,OAAO,UAAU,YAAY,MAAM,SAAS,EAAG,QAAO;AAAA,EAC5D;AACA,SAAO;AACT;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/adapters/cloudflare.ts"],"sourcesContent":["/* eslint-disable security/detect-non-literal-fs-filename --\n * Cloudflare deploy adapter. All write paths are under `cwd/.theokit/cloudflare/`\n * and `cwd/wrangler.toml`. Build-time tool — no HTTP input.\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'\nimport { resolve } from 'node:path'\n\nimport type { TheoConfig } from '../config/schema.js'\nimport { findRootDiv } from '../core/contracts/find-root-div.js'\nimport { parseAssetsMap } from '../core/contracts/module-preloads.js'\nimport type { SecurityHeadersConfig } from '../core/contracts/security-headers.js'\nimport { assertServicesUnsupported, readManifest } from '../services/index.js'\n\nimport { deployedAgentsFragment, type DeployedAgent } from './deployed-agents.js'\nimport { deployedCorsFragment, type DeployedCorsOptions } from './deployed-cors.js'\nimport { deployedCsrfFragment, type DeployedCsrfOptions } from './deployed-csrf.js'\nimport { planDeployedPlugins } from './deployed-plugins-module.js'\nimport {\n deployedRateLimitFragment,\n rateLimitCheckFragment,\n type DeployedRateLimitOptions,\n} from './deployed-rate-limit.js'\nimport {\n deployedRuntimeConfigFragment,\n serverDirLiteral,\n type DeployedRuntimeConfigOptions,\n type DeployedServerDirOptions,\n} from './deployed-runtime-config.js'\nimport { deployedTraceFragment } from './deployed-trace.js'\nimport { nodeAdapter } from './node.js'\nimport { describeDeployedSecurityHeaders, securityHeadersDeclarations } from './security-headers.js'\nimport type { AdapterBuildContext, DeployAdapter } from './types.js'\n\n/**\n * T2.1 — Cloudflare adapter rewritten to consume `theokit/adapters/web-shim`\n * instead of emitting an inline plain-object Request/Response shim. Reduces\n * template from ~50 lines to ~25 and centralizes maintenance.\n */\n\n/**\n * The built `index.html` split into the part before `<div id=\"root\">` and the\n * part after it.\n *\n * Refuses by name when streaming is on and the template is missing, rather than\n * emitting a worker that serves a headless document. A build that cannot produce\n * a correct artifact should say so at build time; the alternative is a deploy\n * that looks successful and serves pages with no stylesheet.\n */\n/**\n * @internal Exported for tests only. Nothing re-exports this module, so this does not reach the\n * published surface — `findRootDiv`, exported for the same reason, appears 0 times in `dist/`.\n */\nexport function readDocumentShell(\n cwd: string,\n streaming: boolean,\n): { htmlHead?: string; htmlTail?: string } {\n if (!streaming) return {}\n\n const indexPath = resolve(cwd, '.theokit/client/index.html')\n if (!existsSync(indexPath)) {\n throw new Error(\n `[adapter-cloudflare] ssrStreaming is on but ${indexPath} does not exist, so the worker ` +\n `would serve a document with no <head> and no client entry. Run the client build first, ` +\n `or set ssrStreaming: false in theo.config.ts.`,\n )\n }\n\n const indexHtml = readFileSync(indexPath, 'utf-8')\n const rootDiv = findRootDiv(indexHtml)\n if (rootDiv === undefined) {\n throw new Error(\n `[adapter-cloudflare] ${indexPath} has no <div id=\"root\">, so the streamed document has ` +\n `nowhere to put the app. Add one, or set ssrStreaming: false in theo.config.ts.`,\n )\n }\n\n return {\n htmlHead: indexHtml.slice(0, rootDiv.insertAt),\n htmlTail: indexHtml.slice(rootDiv.insertAt),\n }\n}\n\n/**\n * The routes, turned into source: static imports, a module map and a literal table (#369).\n *\n * Extracted so the worker renderer stays inside its length budget, and because this is the whole of\n * what changed about how a Worker finds a route — it reads as one idea rather than as three loops\n * inside a hundred lines of template.\n *\n * `../../` because the worker is written to `.theokit/cloudflare/worker.mjs` and `filePath` is\n * relative to the project root. The imports are static so Wrangler's bundler follows them:\n * `wrangler.toml` uploads `.theokit/client` and has never uploaded `server/`, so a module not\n * bundled INTO the worker is not on the platform at all.\n */\nfunction renderBakedRoutes(\n routes: readonly { filePath: string; routePath: string; methods?: readonly string[] }[],\n): { routeImports: string[]; routeModuleEntries: string[]; routeTableEntries: string[] } {\n const routeVar = (index: number): string => `__theoRoute${String(index)}`\n return {\n routeImports: routes.map(\n (route, index) => `import * as ${routeVar(index)} from '../../${route.filePath}'`,\n ),\n routeModuleEntries: routes.map(\n (route, index) => ` ${JSON.stringify(route.filePath)}: ${routeVar(index)},`,\n ),\n routeTableEntries: routes.map(\n (route) =>\n ` { filePath: ${JSON.stringify(route.filePath)}, routePath: ${JSON.stringify(route.routePath)}, ` +\n `methods: ${JSON.stringify([...(route.methods ?? [])])}, ` +\n `...compilePattern(${JSON.stringify(route.routePath)}) },`,\n ),\n }\n}\n\n/**\n * The route-resolution runtime the Worker gets instead of a directory scan (#369).\n *\n * A module map, the literal table, and a loader that refuses anything the build did not bake.\n * Emitted as its own block because it replaces one idea — \"find the routes\" — wholesale.\n */\nfunction routeRuntimeLines(moduleEntries: string[], tableEntries: string[]): string[] {\n return [\n `// #369 — the routes are baked at build time. The Worker used to reach for`,\n `// \\`scanServerRoutes\\` on the server directory — a readdirSync, in a runtime with no`,\n `// filesystem. The pattern is recompiled here from the same routePath the scanner`,\n `// used, so one function decides precedence on every target.`,\n `const ROUTE_MODULES = {`,\n ...moduleEntries,\n `}`,\n ``,\n `const routes = [`,\n ...tableEntries,\n `]`,\n ``,\n `// The executor asks for a module by the path the table names. Anything else was`,\n `// never bundled, and saying so beats returning undefined and failing later on a`,\n `// property access far from the cause.`,\n `async function loadModule(path) {`,\n ` const mod = ROUTE_MODULES[path]`,\n ` if (mod === undefined) {`,\n // No backticks in this message: it is emitted INTO a template literal, and a stray one closes\n // it. That is how #344 shipped a SyntaxError, and the emitted-entry parse gate caught this one\n // before it left the branch.\n \" throw new Error(`Route module '\" +\n '${path}' +\n \"' was not bundled into this Worker. \" +\n 'A Worker has no filesystem, so every route is imported at build time. ' +\n 'Re-run: theokit build --target cloudflare`)',\n ` }`,\n ` return mod`,\n `}`,\n ``,\n ]\n}\n\n/**\n * Read the map `theokit build` emitted, for baking into the worker (B-035).\n *\n * Returns `undefined` — never throws — for an absent or malformed file. A deploy that failed\n * because an optimisation was missing would turn a lost round trip into a broken build, and the\n * worker is correct without it.\n */\nexport function readAssetsMapForBake(path: string): Record<string, string[]> | undefined {\n try {\n return parseAssetsMap(readFileSync(path, 'utf8'))\n } catch {\n return undefined\n }\n}\n\n/**\n * The baked route-to-chunks map, as worker source (B-035).\n *\n * A literal and not a read, for the same reason the document shell is: a Worker has no filesystem\n * at request time. Emitted only when the build produced a map; otherwise the worker carries\n * nothing about preloads and behaves exactly as before.\n *\n * **It emits the DATA and no longer the code.** An earlier revision emitted a hand-written copy of\n * `injectModulePreloads`, justified by a claim that the worker could not import it. That claim was\n * false — `theokit/server` re-exports from `core/contracts/` and the generated worker already\n * imports that subpath — and the copy drifted within a day: a fix for proxy-form request targets\n * landed in the original and not in the duplicate. The worker now imports the real function, which\n * is the shape `adapters/security-headers.ts` established one directory over for exactly this\n * build-half/runtime-half pair.\n */\n/**\n * Whether this worker can preload at all (B-035).\n *\n * ONE predicate, read by the bake, the import and the call. Two conditions for one decision is\n * how the import came to be emitted for a worker that had no map to give it — found at review, in\n * the same change where a copy and its original had already drifted apart.\n *\n * Streaming is required because the other branch serves the document from `env.ASSETS` as a\n * static file and can inject nothing into it.\n */\nfunction preloadsApplyTo(opts: {\n ssrStreaming?: boolean\n assetsMap?: Record<string, string[]>\n}): boolean {\n return opts.ssrStreaming === true && opts.assetsMap !== undefined\n}\n\nfunction renderPreloadSupport(assetsMap: Record<string, string[]> | undefined): string {\n if (assetsMap === undefined) return ''\n return `const __THEO_ASSETS_MAP = ${JSON.stringify(assetsMap)}`\n}\n\n/**\n * The branch that answers a non-API request: streamed SSR, or the static asset the Worker\n * platform serves. Extracted at review — it is the one decision in this emitter with two whole\n * shapes behind it, and keeping it inline pushed the caller past its line budget.\n */\nfunction renderNonApiBranch(\n opts: NonNullable<Parameters<typeof renderCloudflareWorkerEntry>[0]>,\n preloadsApply: boolean,\n): string {\n return opts.ssrStreaming\n ? [\n ` // T2.3 — streaming SSR for non-API routes`,\n ` // The same primitive \\`theokit start\\` uses, not a second one:`,\n ` // 16 bytes of Web Crypto entropy, base64.`,\n ` const nonce = generateNonce()`,\n ` return withSecurityHeaders(`,\n ` await renderStreamingWeb(request, {`,\n !preloadsApply\n ? ` htmlHead: ${JSON.stringify(opts.htmlHead ?? '')},`\n : ` htmlHead: injectModulePreloads(${JSON.stringify(opts.htmlHead ?? '')}, __THEO_ASSETS_MAP, new URL(request.url).pathname),`,\n ` htmlTail: ${JSON.stringify(opts.htmlTail ?? '')},`,\n ` nonce,`,\n ` }),`,\n ` buildSecurityHeaders(SECURITY_HEADERS_CONFIG, { production: true }, { nonce }),`,\n ` )`,\n ].join('\\n')\n : [\n ` // #412 — the document, served by the worker so it carries the same baseline every`,\n ` // API response carries. It used to return 404 here while wrangler.toml declared a`,\n ` // \\`[site]\\` bucket nothing read, so the page was missing rather than unprotected.`,\n ` const assets = env?.ASSETS`,\n ` // A wrangler.toml that predates this binding has no ASSETS. Reading .fetch off`,\n ` // undefined would turn every page request into a 500; 404 is what this target`,\n ` // answered before, which is the honest fallback rather than a new failure.`,\n ` if (assets === undefined) return notFoundResponse()`,\n ` return withSecurityHeaders(await assets.fetch(request), SECURITY_HEADERS)`,\n ].join('\\n')\n}\n\nexport function renderCloudflareWorkerEntry(\n opts: {\n ssrStreaming?: boolean\n htmlHead?: string\n htmlTail?: string\n /**\n * B-035 — the route-to-chunks map, baked as a literal for the same reason the shell above\n * is: a Worker has no filesystem at request time. Absent when the build produced no map,\n * and then nothing about preloads is emitted at all.\n */\n assetsMap?: Record<string, string[]>\n securityHeaders?: SecurityHeadersConfig\n /**\n * The server routes, scanned on the BUILD machine (#369).\n *\n * A Worker has no filesystem, so the worker used to call `scanServerRoutes` — a `readdirSync` —\n * against a directory that does not exist there, and load each module through `import()` of a\n * file path. Both are baked here instead, which is the road this adapter already takes for the\n * document shell one function away.\n *\n * `filePath` is relative to the project root and is used for two things: the key the executor\n * looks a module up by, and the specifier the static import uses. Paths are emitted relative to\n * `.theokit/cloudflare/`, where the worker is written.\n */\n routes?: readonly { filePath: string; routePath: string; methods?: readonly string[] }[]\n /**\n * The two concerns #410 could not bake (#425), composed from one place so the six targets\n * cannot drift into six spellings. `serialization` is a literal like the security values above;\n * `plugins` is an import, because a closure has no literal.\n */\n runtimeConfigModule?: DeployedRuntimeConfigOptions['runtimeConfigModule']\n serialization?: DeployedRuntimeConfigOptions['serialization']\n /**\n * The app's agents, scanned on the build machine (#367).\n *\n * A different scan from `routes` and served by a different function — which is why a worker\n * that only consulted the route table answered every `/api/agents/<name>` with a 404.\n */\n agents?: readonly DeployedAgent[]\n /** B-185 — the app's `server/context.ts`, project-relative, or absent when it declares none. */\n contextModule?: string\n /** WebSocket route files, scanned on the build machine. Only their presence is used (#369). */\n wsRoutes?: readonly string[]\n } & DeployedServerDirOptions &\n DeployedCsrfOptions &\n DeployedCorsOptions &\n DeployedRateLimitOptions = {},\n): string {\n const streamingImport = opts.ssrStreaming\n ? `import { renderStreamingWeb } from '/@theo/entry-server'`\n : `// (ssrStreaming off: renderStreamingWeb not imported)`\n // #343 — the document shell is inlined as a build-time literal because a Worker\n // has no filesystem to read `index.html` from at request time. Without it,\n // `renderStreamingWeb` falls back to its empty-string defaults and the response\n // is React output with no `<html>`, no `<head>`, no stylesheet and no client\n // entry — hydration data for a page that cannot hydrate. The streaming\n // assembly was fixed in the generated entry and this, its only caller, was\n // left passing nothing.\n //\n // `JSON.stringify` and not a template literal: the shell contains quotes,\n // angle brackets and a `</script>`, and embedding it naively produces a worker\n // that fails to parse at deploy time rather than here.\n //\n // #410 — this is also the ONE deploy path that renders HTML at request time,\n // so the one that can mint a per-request CSP nonce: `renderStreamingWeb`\n // threads `options.nonce` into `renderToReadableStream` and into the hydration\n // script (`router/entry-server.ts`). Every other response below carries the\n // nonce-less baseline, which is what `buildSecurityHeaders` already does for a\n // prerendered route (EC-4).\n // Only the streaming branch renders HTML at request time; the other serves the document from\n // `env.ASSETS` as a static file and can inject nothing. Baking the map for it would ship the whole\n // route table as dead weight — found at review, where the emitter was measured declaring it with\n // zero call sites.\n const preloadsApply = preloadsApplyTo(opts)\n const preloadSupport = preloadsApply ? renderPreloadSupport(opts.assetsMap) : ''\n\n const nonApiBranch = renderNonApiBranch(opts, preloadsApply)\n\n // CR-006: Workers lack `process.cwd()` and the `node:*` import surface\n // is brittle even under `nodejs_compat`. We use the Web Crypto\n // `crypto.randomUUID()` instead of `node:crypto.randomUUID`, and embed\n // the server directory as a build-time literal instead of resolving via\n // `node:path` at runtime.\n const { routeImports, routeModuleEntries, routeTableEntries } = renderBakedRoutes(\n opts.routes ?? [],\n )\n const runtimeConfig = deployedRuntimeConfigFragment(opts)\n const agentsFragment = deployedAgentsFragment(\n opts.agents === undefined\n ? undefined\n : // B-185 — a Worker has no filesystem, so its identity module is baked exactly like its\n // agents and its routes (ADR 0014). `contextModule` is `undefined` for an app that\n // declares none, and the generator then emits no import for it.\n { kind: 'baked', agents: opts.agents, contextModule: opts.contextModule },\n {\n wrapSecurityHeaders: true,\n // B-185 — bound only when the runtime-config fragment declared the const, which is\n // exactly when a plugins module was emitted. Referencing it otherwise would emit an\n // identifier the entry never declares.\n pluginRunnerExpr:\n opts.runtimeConfigModule === undefined ? undefined : 'await THEO_PLUGIN_RUNNER',\n },\n )\n\n return [\n `// Generated by Theo — Cloudflare Workers Adapter`,\n `//`,\n `// REQUIREMENTS (EC-3):`,\n `// - wrangler.toml MUST include compatibility_flags = [\"nodejs_compat\"]`,\n `// (still required for transitive theokit/server deps, e.g. busboy)`,\n `// - package.json MUST list \"theokit\" in dependencies (not devDependencies)`,\n `// so Wrangler bundles theokit and its transitive deps`,\n `// - Deploy: wrangler deploy`,\n ``,\n !preloadsApply\n ? `import { matchRoute, executeRoute, compilePattern, extractTraceIdFromRequest, TRACE_HEADER, createCorsWebHandler } from 'theokit/server'`\n : `import { matchRoute, executeRoute, compilePattern, extractTraceIdFromRequest, TRACE_HEADER, createCorsWebHandler } from 'theokit/server'\\nimport { injectModulePreloads } from 'theokit/server/http'`,\n `import { createWebShim } from 'theokit/adapters/web-shim'`,\n opts.ssrStreaming\n ? `import { buildSecurityHeaders, generateNonce, withSecurityHeaders } from 'theokit/adapters/security-headers'`\n : `import { buildSecurityHeaders, withSecurityHeaders } from 'theokit/adapters/security-headers'`,\n `// T3.4 — WS bridge for Cloudflare Workers`,\n `import { createCloudflareWsBridge } from 'theokit/adapters/ws-shim'`,\n // B-027 — only when a limit is declared. `createRateLimiterWeb` is used at module scope by the\n // fragment below and was imported by no target but bun, so an entry that declared a limit threw\n // when it LOADED. `resolveClientIpFromRequest` is the forwarded-header fallback, behind\n // `trustProxy`; the primary source is `cf-connecting-ip`, which the Workers runtime writes.\n ...(opts.rateLimit === undefined\n ? []\n : [\n `import { createRateLimiterWeb } from 'theokit/server'`,\n `import { resolveClientIpFromRequest } from 'theokit/server/rate-limit'`,\n ]),\n streamingImport,\n ``,\n ...runtimeConfig.imports,\n ...agentsFragment.imports,\n ...routeImports,\n ``,\n `// CR-006: server directory is a build-time literal — Workers cannot`,\n `// call process.cwd() and resolving paths at runtime returned '/server'.`,\n `const serverDir = ${serverDirLiteral(opts)}`,\n ``,\n ...routeRuntimeLines(routeModuleEntries, routeTableEntries),\n `// #410 — the security baseline \\`theokit start\\` puts on every response, carried`,\n `// here as a literal because a Worker has no theo.config.ts to read. Same`,\n `// function, same input, so the deployed page and the local one cannot`,\n `// disagree about what the configuration means.`,\n preloadSupport,\n ...securityHeadersDeclarations(opts.securityHeaders),\n ``,\n ...deployedCsrfFragment(opts, 'a Worker'),\n ``,\n ...runtimeConfig.declarations,\n ...agentsFragment.declarations,\n ...deployedCorsFragment(opts.cors, 'cloudflare'),\n `// #369 — whether this project declares a WebSocket route, decided at build time. It used`,\n `// to answer it with \\`scanWebSocketRoutes\\`, which is the same readdirSync.`,\n `const HAS_WS_ROUTES = ${String((opts.wsRoutes ?? []).length > 0)}`,\n ``,\n `function notFoundResponse() {`,\n ` return withSecurityHeaders(`,\n ` new Response(`,\n ` JSON.stringify({ error: { code: 'NOT_FOUND', message: 'Route not found' } }),`,\n ` { status: 404, headers: { 'Content-Type': 'application/json' } },`,\n ` ),`,\n ` SECURITY_HEADERS,`,\n ` )`,\n `}`,\n ``,\n ...cloudflareHandleRequestFragment(\n nonApiBranch,\n runtimeConfig.executeRouteSpread,\n agentsFragment.branch,\n agentsFragment.hostBypass,\n opts.rateLimit,\n ),\n ].join('\\n')\n}\n\n/**\n * The Worker's request handler, as generated source.\n *\n * Extracted for the reason `vercel.ts` extracts its own fragments: the emitter is one array\n * literal, so every line the entry gains counts against `max-lines-per-function`, and #410 added\n * the CSRF literal to an emitter already sitting exactly at the ceiling.\n *\n * @param nonApiBranch - what a non-`/api/` request gets, which differs with `ssrStreaming`\n */\nfunction cloudflareHandleRequestFragment(\n nonApiBranch: string,\n runtimeSpread: string,\n agentBranch: readonly string[],\n /** SI-020 — the condition that keeps an agent card path out of the static-asset branch. */\n hostBypass: string,\n /** B-027 — the declared limit, or `undefined`. Passed rather than read: this fragment is a\n * separate function from `renderCloudflareWorkerEntry`, where `opts` is bound. */\n rateLimit: DeployedRateLimitOptions['rateLimit'],\n): string[] {\n return [\n `async function handleRequest(request, url, env) {`,\n ` if (!url.pathname.startsWith('/api/')${hostBypass}) {`,\n nonApiBranch,\n ` }`,\n ``,\n ...agentBranch,\n ``,\n ` const match = matchRoute(url.pathname, routes)`,\n ` if (!match) return notFoundResponse()`,\n ``,\n ` const { req, res, toResponse } = createWebShim(request, { trustedProxy: 'platform' })`,\n ...deployedTraceFragment('request', ' '),\n ` const method = request.method.toUpperCase()`,\n ` // #382 — the run is NOT awaited before the Response is taken. toResponse()`,\n ` // settles as soon as status + headers are known and carries a live body, so`,\n ` // the Worker starts flushing while the handler is still writing. Awaiting`,\n ` // executeRoute() first would re-buffer the whole response here even though`,\n ` // the shim streams; awaiting toResponse() does not — it settles at the head.`,\n ` return withSecurityHeaders(await toResponse(executeRoute({`,\n ` route: match.route, method, params: match.params,`,\n ` req, res, loadModule, serverDir, requestId, ...CSRF_CONFIG, ${runtimeSpread}`,\n ` })), SECURITY_HEADERS)`,\n `}`,\n ``,\n ...deployedRateLimitFragment(\n rateLimit,\n 'cloudflare',\n // `cf-connecting-ip` is the RUNTIME's answer and a caller cannot forge it through the edge.\n // A Worker reached directly has none, and then only a declared `trustProxy` produces an\n // address — otherwise `undefined`, which is the named 503 rather than everyone's bucket.\n `request.headers.get('cf-connecting-ip') ?? resolveClientIpFromRequest(request, TRUST_PROXY)`,\n // `request` alone: the handler binds `(request, env, ctx)` and the header is on the request.\n 'request',\n ),\n ``,\n `export default {`,\n ` async fetch(request, env, ctx) {`,\n ` const url = new URL(request.url)`,\n ``,\n ` // #409 — the preflight is answered BEFORE anything routes: an OPTIONS the router handles`,\n ` // is an OPTIONS the browser never gets a CORS answer to. The WebSocket upgrade above is`,\n ` // deliberately upstream of it — a 101 is not a CORS-governed response.`,\n ` const preflight = corsPreflight(request)`,\n ` if (preflight !== null) return withSecurityHeaders(preflight, SECURITY_HEADERS)`,\n ...rateLimitCheckFragment(rateLimit, ' ', 'request'),\n ``,\n ` // LCR0103 — the upgrade branch sits BELOW the limiter, and the order is the point.`,\n ` // It used to sit above, under the comment \"a 101 carries no document and no script,`,\n ` // so the security baseline does not apply to it\". That is true of the security`,\n ` // HEADERS — a document concern — and false of the limiter, which is a resource`,\n ` // concern: a long-lived socket is the most expensive thing this entry hands out, so`,\n ` // the upgrade is the path that most needs a budget, not the one that may skip it.`,\n ` // T3.4 — Detect WebSocket upgrade and delegate to the CF bridge.`,\n ` // A 101 carries no document and no script, so the security baseline does`,\n ` // not apply to it.`,\n ` if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {`,\n ` if (!HAS_WS_ROUTES) return notFoundResponse()`,\n ` const cfWs = createCloudflareWsBridge({`,\n ` onOpen: () => {},`,\n ` onMessage: (ws, data) => { ws.send(data) },`,\n ` onClose: () => {},`,\n ` })`,\n ` return cfWs.handle(request)`,\n ` }`,\n ``,\n ` return withCors(request, await handleRequest(request, url, env))`,\n ` },`,\n `}`,\n ]\n}\n\nexport function renderWranglerToml(): string {\n return [\n `# Generated by Theo — Cloudflare Workers`,\n `name = \"theo-app\"`,\n `main = \".theokit/cloudflare/worker.mjs\"`,\n `compatibility_date = \"2025-09-01\"`,\n `compatibility_flags = [\"nodejs_compat\"]`,\n ``,\n `# #412 — \\`[assets]\\` with a BINDING, not the legacy \\`[site]\\` bucket.`,\n `#`,\n `# \\`[site]\\` uploads to KV and is read through \\`kv-asset-handler\\` and a`,\n `# \\`__STATIC_CONTENT\\` binding — neither of which this worker has ever had. So the`,\n `# bucket was declared, uploaded, and consumed by nothing: with ssrStreaming off, a`,\n `# deploy answered 404 for its own page. This binding is what the worker calls.`,\n `[assets]`,\n `directory = \".theokit/client\"`,\n `binding = \"ASSETS\"`,\n `# A client-routed app asks for /dashboard, which is no file. Without this the asset`,\n `# handler 404s a deep link and the SPA never boots.`,\n `not_found_handling = \"single-page-application\"`,\n ``,\n `# Environment variables are set via wrangler secret or dashboard`,\n `# Example: wrangler secret put DATABASE_URL`,\n ].join('\\n')\n}\n\nexport const cloudflareAdapter: DeployAdapter = {\n name: 'cloudflare',\n streamsResponses: true,\n // #409 / #410 — the generated entry calls `executeRoute` with routes, loader\n // and serverDir only. CSRF, route policy, file middleware and Zod validation\n // still run because they live inside `executeRoute`; none of the remaining\n // configurable concerns reach it. Declared explicitly rather than omitted so\n // the gap is a statement in the source and not an absence.\n //\n // `securityHeaders` IS applied: the worker carries `security.headers` as a\n // literal and puts the built baseline on every response it returns, including\n // the streamed SSR document — with a per-request nonce, the only deploy path\n // that can mint one (`adapters/security-headers.ts`).\n servesAgents: true,\n appliesConfig: ['securityHeaders', 'csrf', 'disallowed', 'cors', 'serialization', 'plugins'],\n // B-257 — per-invocation: an in-process counter does not survive, so a declared limit needs a durable store.\n enforcesRateLimit: 'with-a-store',\n\n async build(config: TheoConfig, cwd: string, ctx?: AdapterBuildContext): Promise<void> {\n // Wave 2 (T2.2) — reject polyglot services on this adapter.\n assertServicesUnsupported('cloudflare', readManifest(cwd))\n\n // 1. Run the standard Node build first (ctx forwarded so nodeAdapter has makeVitePlugins)\n await nodeAdapter.build(config, cwd, ctx)\n\n const outputDir = resolve(cwd, '.theokit/cloudflare')\n mkdirSync(outputDir, { recursive: true })\n\n // 2. Emit Worker entry (now uses the shared web-shim)\n //\n // The document shell is read HERE, after the Node build produced\n // `.theokit/client/index.html`, and inlined into the worker: a Worker has no\n // filesystem at request time. Split on the root div with the same helper the\n // Node server uses (`ssr-setup.ts`), so the two paths cannot disagree about\n // where the shell ends (#343).\n const shell = readDocumentShell(cwd, config.ssrStreaming)\n // B-035 — the route-to-chunks map, read HERE on the build machine for the same reason the\n // shell above is: a Worker has no filesystem at request time. `nodeAdapter.build` ran\n // first, so the map the Node build emitted is on disk by now. Absent map -> nothing about\n // preloads is emitted, and the worker behaves exactly as it did before.\n const assetsMap = readAssetsMapForBake(resolve(cwd, '.theokit', 'client', 'assets-map.json'))\n\n // #369 — the routes are resolved HERE, on the build machine, for the same reason the document\n // shell above is read here: a Worker has no filesystem at request time, and the worker used to\n // run the scan itself against a directory that does not exist there.\n //\n // The scanner is INJECTED rather than imported: importing it would add an `adapters → server`\n // edge, which is the layering inversion ADR-0001 v3 removed for `vite-plugin` and which\n // `adapters-may-only-depend-on-core-router-services` refuses. An absent scanner emits a worker\n // with no routes rather than falling back to a runtime scan — the fallback IS the defect.\n const scanned = ctx?.scanRoutes?.(config.serverDir) ?? {\n routes: [],\n wsRoutes: [],\n agents: [],\n // B-185 — no provider means no scan, and an app whose context nobody looked for is\n // indistinguishable here from one that has none. Both resolve an anonymous caller, which is\n // the honest answer rather than an invented subject.\n contextModule: undefined,\n }\n\n const pluginsPlan = planDeployedPlugins(config.plugins, 'cloudflare')\n if (pluginsPlan !== undefined) {\n // Beside the worker, so the emitted import is a sibling. Wrangler bundles from here, which is\n // what lets the static import reach the app's own module at all (#425).\n writeFileSync(resolve(outputDir, 'theo.plugins.mjs'), pluginsPlan.source)\n }\n\n writeFileSync(\n resolve(outputDir, 'worker.mjs'),\n renderCloudflareWorkerEntry({\n ssrStreaming: config.ssrStreaming,\n ...shell,\n securityHeaders: config.security?.headers,\n csrf: config.security?.csrf,\n disallowed: config.security?.disallowed,\n cors: config.security?.cors,\n assetsMap,\n // #425 — a selector, not a transformer, so it rides as a literal like the values above.\n serialization: config.serialization,\n // #425 — the ONE concern that is not a literal. A closure cannot be baked, so a plugin\n // declared by module specifier is imported by the emitted module instead; a constructed one\n // is refused by name at build time rather than dropped in silence.\n runtimeConfigModule: pluginsPlan?.moduleSpecifier,\n routes: scanned.routes,\n // #367 — a Worker has no filesystem, so its agents are decided here like its routes.\n agents: scanned.agents,\n // B-185 — rides beside the agents because it is decided by the same provider, for the same\n // reason: `build.ts` holds `serverDir` and a Worker cannot look for the module itself.\n contextModule: scanned.contextModule,\n wsRoutes: scanned.wsRoutes,\n }),\n )\n\n // 3. Emit wrangler.toml (with nodejs_compat enforced)\n writeFileSync(resolve(cwd, 'wrangler.toml'), renderWranglerToml())\n\n // eslint-disable-next-line no-console -- CLI build progress\n console.log('\\n ✓ Cloudflare output → .theokit/cloudflare/ + wrangler.toml')\n // eslint-disable-next-line no-console -- CLI build progress\n console.log(\n `${describeDeployedSecurityHeaders({\n target: 'cloudflare',\n securityHeaders: config.security?.headers,\n // Only the streaming worker renders HTML per request, so only it can put\n // the same nonce on the header and on the script tag it emits.\n mintsNonce: config.ssrStreaming,\n // #412 — either way the worker returns the document now: streamed by\n // `renderStreamingWeb`, or from the ASSETS binding it finally calls.\n documentHeaders: 'handler',\n })}\\n`,\n )\n },\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/adapters/deno-deploy.ts"],"sourcesContent":["/* eslint-disable security/detect-non-literal-fs-filename --\n * Deno Deploy adapter. Writes to `cwd/.theokit/deno-deploy/`. Build-time.\n */\nimport { mkdirSync, writeFileSync } from 'node:fs'\nimport { resolve } from 'node:path'\n\nimport type { TheoConfig } from '../config/schema.js'\nimport { assertServicesUnsupported, readManifest } from '../services/index.js'\n\nimport { deployedAgentsFragment, scannedFromLoaderCache } from './deployed-agents.js'\nimport { planDeployedPlugins } from './deployed-plugins-module.js'\nimport { deployedEntryPreamble } from './deployed-preamble.js'\nimport { deployedRateLimitFragment, rateLimitCheckFragment } from './deployed-rate-limit.js'\nimport {\n agentsDirLiteral,\n deployedRuntimeConfigFragment,\n serverDirLiteral,\n} from './deployed-runtime-config.js'\nimport { deployedTraceFragment } from './deployed-trace.js'\nimport { nodeAdapter } from './node.js'\nimport { describeDeployedSecurityHeaders } from './security-headers.js'\nimport type { AdapterBuildContext, DeployAdapter, DeployedEntryOptions } from './types.js'\n\nexport interface DenoBuildDeps {\n runNodeBuild?: (config: TheoConfig, cwd: string, ctx?: AdapterBuildContext) => Promise<void>\n writeEntry?: (path: string, content: string) => void\n ensureDir?: (path: string) => void\n}\n\n/**\n * The framework imports this entry opens with, as `npm:` specifiers.\n *\n * Extracted from `renderDenoEntry` when adding the configured-directory literal pushed it past the\n * per-function line budget. A cohesive block — every line is one import of the same package family\n * — so lifting it keeps the budget a signal about the renderer rather than about its preamble.\n */\nconst FRAMEWORK_IMPORTS: readonly string[] = [\n `// Use npm: specifier so Deno resolves theokit from the user's package.json`,\n `// equivalent (works in both local 'deno run' and Deno Deploy).`,\n `import { scanServerRoutes, matchRoute, executeRoute, createProductionLoader, scanWebSocketRoutes, extractTraceIdFromRequest, TRACE_HEADER, createCorsWebHandler } from 'npm:theokit/server'`,\n `import { createWebShim } from 'npm:theokit/adapters/web-shim'`,\n `import { buildSecurityHeaders, withSecurityHeaders } from 'npm:theokit/adapters/security-headers'`,\n `// T3.3 — WS bridge for Deno runtime`,\n `import { createDenoWsBridge } from 'npm:theokit/adapters/ws-shim'`,\n]\n\n/** The target's name, in the five places that spell it. Adding a sixth tripped the duplicate-literal gate. */\n/* The name in the places a gate does NOT read. `assertServicesUnsupported` keeps the literal:\n * `services-other-adapters-reject.test.ts:30` requires each adapter to name ITSELF there, so a\n * copy-paste carrying another adapter's name cannot hide behind a constant. */\nconst TARGET = 'deno-deploy'\n\nexport function renderDenoEntry(port: number, opts: DeployedEntryOptions = {}): string {\n const runtimeConfig = deployedRuntimeConfigFragment(opts)\n const agentsFragment = deployedAgentsFragment(scannedFromLoaderCache(agentsDirLiteral(opts)), {\n notFound: 'notFound()',\n importPrefix: 'npm:',\n // B-185 — bound only when the runtime-config fragment declared the const, which is\n // exactly when a plugins module was emitted. Referencing it otherwise would emit an\n // identifier the entry never declares.\n pluginRunnerExpr:\n opts.runtimeConfigModule === undefined ? undefined : 'await THEO_PLUGIN_RUNNER',\n })\n return [\n `// Generated by Theo — Deno Deploy Adapter`,\n `// Deploy: deployctl deploy --project=<name> .theokit/deno/server.ts`,\n `// Requires Deno >= 1.40 (Deno.serve + npm: specifier support)`,\n ``,\n `// Runtime guard`,\n `if (typeof Deno === 'undefined') {`,\n ` throw new Error('TheoDenoDeployAdapter must run inside Deno.')`,\n `}`,\n ``,\n ...FRAMEWORK_IMPORTS,\n ``,\n `const port = Number(Deno.env.get('PORT') ?? '${port}')`,\n `const cwd = Deno.cwd()`,\n `const serverDir = cwd + '/' + ${serverDirLiteral(opts)}`,\n `let routesCache = null`,\n `let wsRoutesCache = null`,\n `let loaderCache = null`,\n ``,\n `// #410 — the security baseline \\`theokit start\\` puts on every response,`,\n `// carried here as a literal because the deployed isolate has no`,\n `// theo.config.ts to read. Non-API paths 404 here and are served by Deno`,\n `// Deploy's static asset handler, so this covers the API and not the`,\n `// document (usetheokit/theokit#412).`,\n ...deployedEntryPreamble(runtimeConfig, agentsFragment, opts, TARGET),\n ``,\n `function notFound() {`,\n ` return new Response(JSON.stringify({ error: { code: 'NOT_FOUND' } }), {`,\n ` status: 404,`,\n ` headers: { 'Content-Type': 'application/json' },`,\n ` })`,\n `}`,\n ``,\n // B-027 — `npm:`, like every other specifier this target emits (`:37-43`). A bare one does\n // not resolve on Deno Deploy, and the harness asserts the prefix is here before rewriting it.\n ...(opts.rateLimit === undefined\n ? []\n : [\n `import { createRateLimiterWeb } from 'npm:theokit/server'`,\n `import { resolveClientIpFromRequest } from 'npm:theokit/server/rate-limit'`,\n ``,\n ]),\n ...deployedRateLimitFragment(\n opts.rateLimit,\n TARGET,\n // `info.remoteAddr` is the runtime's own answer. A `UnixAddr` has no `hostname`, so the\n // optional chain is what keeps the read from throwing there rather than a guess.\n `info?.remoteAddr?.hostname ?? resolveClientIpFromRequest(request, TRUST_PROXY)`,\n 'request, info',\n // B-257 — `npm:`, like every other specifier this target emits. A bare one does not resolve\n // on Deno Deploy, and the durable limiter's import is emitted by the fragment rather than\n // here, so the prefix has to travel with it.\n 'npm:',\n ),\n ``,\n // The serve handler's SECOND parameter is bound here for the first time: `Deno.serve` has\n // always passed it and the emitted entry discarded it.\n `Deno.serve({ port }, async (request, info) => {`,\n ` // #409 — the preflight is answered BEFORE anything routes: an OPTIONS the router`,\n ` // handles is an OPTIONS the browser never gets a CORS answer to.`,\n ` const preflight = corsPreflight(request)`,\n ` if (preflight !== null) return withSecurityHeaders(preflight, SECURITY_HEADERS)`,\n ...rateLimitCheckFragment(opts.rateLimit, ' ', 'request, info'),\n ``,\n ` // LCR0103 — the upgrade branch sits BELOW the limiter, and the order is the point.`,\n ` // It used to sit above, under the comment \"a 101 carries no document and no script,`,\n ` // so the security baseline does not apply to it\". That is true of the security`,\n ` // HEADERS — a document concern — and false of the limiter, which is a resource`,\n ` // concern: a long-lived socket is the most expensive thing this entry hands out, so`,\n ` // the upgrade is the path that most needs a budget, not the one that may skip it.`,\n ` // T3.3 — Detect WebSocket upgrade and delegate to the Deno bridge.`,\n ` // A 101 carries no document and no script, so the security baseline does`,\n ` // not apply to it.`,\n ` if (request.headers.get('upgrade')?.toLowerCase() === 'websocket') {`,\n ` if (!wsRoutesCache) wsRoutesCache = scanWebSocketRoutes(serverDir)`,\n ` if (wsRoutesCache.length === 0) return notFound()`,\n ` const denoWs = createDenoWsBridge({`,\n ` onOpen: () => {},`,\n ` onMessage: (ws, data) => { ws.send(data) },`,\n ` onClose: () => {},`,\n ` }, Deno)`,\n ` return denoWs.handle(request)`,\n ` }`,\n ` return withCors(request, withSecurityHeaders(await handleRequest(request), SECURITY_HEADERS))`,\n `})`,\n ``,\n `async function handleRequest(request) {`,\n ` const url = new URL(request.url)`,\n ``,\n ` if (!url.pathname.startsWith('/api/')${agentsFragment.hostBypass}) {`,\n ` // Static + SPA fallback are served by Deno Deploy's static asset handler.`,\n ` return notFound()`,\n ` }`,\n ``,\n ` if (!routesCache) routesCache = scanServerRoutes(serverDir)`,\n ` if (!loaderCache) loaderCache = createProductionLoader()`,\n ``,\n ...agentsFragment.branch,\n ``,\n ` const match = matchRoute(url.pathname, routesCache)`,\n ` if (!match) return notFound()`,\n ``,\n ` const { req, res, toResponse } = createWebShim(request)`,\n ...deployedTraceFragment('request', ' '),\n ` const method = request.method.toUpperCase()`,\n ` // #382 — not awaited: toResponse() settles at the headers and carries a`,\n ` // live body, so Deno.serve streams while the handler writes.`,\n ` return toResponse(executeRoute({ route: match.route, method, params: match.params, req, res, loadModule: loaderCache, serverDir, requestId, ...CSRF_CONFIG, ${runtimeConfig.executeRouteSpread} }))`,\n `}`,\n ``,\n `console.log('Theo (Deno Deploy) listening on :' + port)`,\n ].join('\\n')\n}\n\nexport async function buildDeno(\n config: TheoConfig,\n cwd: string,\n deps: DenoBuildDeps = {},\n ctx?: AdapterBuildContext,\n): Promise<void> {\n // Wave 2 (T2.2) — reject polyglot services on this adapter.\n assertServicesUnsupported('deno-deploy', readManifest(cwd))\n\n const runNodeBuild = deps.runNodeBuild ?? nodeAdapter.build.bind(nodeAdapter)\n await runNodeBuild(config, cwd, ctx)\n\n const outputDir = resolve(cwd, '.theokit/deno')\n const ensureDir = deps.ensureDir ?? ((p: string) => mkdirSync(p, { recursive: true }))\n ensureDir(outputDir)\n\n const pluginsPlan = planDeployedPlugins(config.plugins, denoDeployAdapter.name)\n const entry = renderDenoEntry(config.port, {\n securityHeaders: config.security?.headers,\n csrf: config.security?.csrf,\n disallowed: config.security?.disallowed,\n cors: config.security?.cors,\n // #425 — a selector, not a transformer, so it rides as a literal like the values above.\n serialization: config.serialization,\n // #425 — the ONE concern that is not a literal. A closure cannot be baked, so a plugin\n // declared by module specifier is imported by the emitted module instead; a constructed one\n // is refused by name at build time rather than dropped in silence.\n runtimeConfigModule: pluginsPlan?.moduleSpecifier,\n // #95 — the configured directories, which `build.ts:210` already threads into the Vite\n // plugins. Without these two lines the literals above fall to their defaults and a project\n // with a custom dir deploys an entry that resolves a directory it does not have.\n serverDir: config.serverDir,\n agentsDir: config.agentsDir,\n })\n const write =\n deps.writeEntry ??\n ((p, c) => {\n writeFileSync(p, c)\n })\n if (pluginsPlan !== undefined) {\n // Beside the entry, so the emitted import is a sibling. Written through the same seam as\n // the entry so a test that captures one captures both (#425).\n write(resolve(outputDir, 'theo.plugins.mjs'), pluginsPlan.source)\n }\n write(resolve(outputDir, 'server.ts'), entry)\n\n // eslint-disable-next-line no-console -- CLI build progress\n console.log('\\n ✓ Deno Deploy output → .theokit/deno/server.ts')\n // eslint-disable-next-line no-console -- CLI build progress\n console.log(\n `${describeDeployedSecurityHeaders({\n target: TARGET,\n securityHeaders: config.security?.headers,\n mintsNonce: false,\n documentHeaders: 'platform-unmanaged',\n })}\\n`,\n )\n}\n\nexport const denoDeployAdapter: DeployAdapter = {\n name: 'deno-deploy',\n streamsResponses: true,\n // #409 / #410 — the generated entry calls `executeRoute` with routes, loader\n // and serverDir only. CSRF, route policy, file middleware and Zod validation\n // still run because they live inside `executeRoute`; none of the remaining\n // configurable concerns reach it. Declared explicitly rather than omitted so\n // the gap is a statement in the source and not an absence.\n //\n // `securityHeaders` IS applied -- to every response this isolate returns. The\n // document comes from Deno Deploy's static asset handler and does not pass\n // through it (usetheokit/theokit#412).\n servesAgents: true,\n appliesConfig: ['securityHeaders', 'csrf', 'disallowed', 'cors', 'serialization', 'plugins'],\n // B-257 — per-invocation: an in-process counter does not survive, so a declared limit needs a durable store.\n enforcesRateLimit: 'with-a-store',\n build(config, cwd, ctx) {\n return buildDeno(config, cwd, {}, ctx)\n },\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/vite-plugin/openapi-emit/dev-emit.ts"],"sourcesContent":["/**\n * Dev-mode OpenAPI emit helper for `theokit dev`.\n *\n * Per P#3 plan v1.3 T1.1 (ADRs D3 + D4 + EC-8). Called once on `theokit dev`\n * boot AND on every chokidar 'change'/'add'/'unlink' watcher event for\n * route files when `config.openapi !== undefined`. Re-runs G2's\n * `emitOpenApi()` orchestrator to keep `<distDir>/openapi.json` always\n * fresh in dev.\n *\n * EC-8 absorbed (single-flight guard): if a previous invocation is still\n * awaiting `loadRoutesForOpenApi` (Vite SSR loader can hang on circular\n * imports or syntax errors), skip the new call and emit a `console.warn`\n * so watcher events don't pile up + dev server doesn't deadlock.\n *\n * Best-effort: ALL errors caught + warned. Never throws out of the\n * exported function (would crash Vite dev's chokidar handler).\n *\n * NEVER ship to runtime — dev-only artifact gated on `config.openapi`.\n */\nimport { generateManifest } from '../../server/scan/manifest.js'\n\nimport { emitOpenApi } from './emit.js'\nimport { loadRoutesForOpenApi } from './load-routes.js'\n\ninterface DevEmitConfig {\n servers: { url: string; description?: string }[]\n specVersion: '3.1.0' | '3.0.3'\n title: string\n version: string\n}\n\nlet inFlight = false\n\nexport async function reEmitOpenApi(\n serverDir: string,\n distDir: string,\n openApiConfig: DevEmitConfig,\n): Promise<void> {\n if (inFlight) {\n console.warn(\n '[openapi-emit] previous emit still running; skipping watcher event (EC-8 single-flight guard)',\n )\n return\n }\n inFlight = true\n try {\n const manifest = generateManifest(serverDir)\n const hydrated = await loadRoutesForOpenApi({ serverDir, routes: manifest.routes })\n emitOpenApi({\n manifest: hydrated,\n config: { ...openApiConfig, outDir: distDir },\n })\n } catch (err) {\n console.warn(`[openapi-emit] re-emit failed: ${(err as Error).message}`)\n } finally {\n inFlight = false\n }\n}\n\n/** Test seam — resets the inFlight flag between tests. NOT for production use. */\nexport function _resetInFlightForTests(): void {\n inFlight = false\n}\n"],"mappings":";;;;;;;;;;;;;;AA+BA,IAAI,WAAW;AAEf,eAAsB,cACpB,WACA,SACA,eACe;AACf,MAAI,UAAU;AACZ,YAAQ;AAAA,MACN;AAAA,IACF;AACA;AAAA,EACF;AACA,aAAW;AACX,MAAI;AACF,UAAM,WAAW,iBAAiB,SAAS;AAC3C,UAAM,WAAW,MAAM,qBAAqB,EAAE,WAAW,QAAQ,SAAS,OAAO,CAAC;AAClF,gBAAY;AAAA,MACV,UAAU;AAAA,MACV,QAAQ,EAAE,GAAG,eAAe,QAAQ,QAAQ;AAAA,IAC9C,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,YAAQ,KAAK,kCAAmC,IAAc,OAAO,EAAE;AAAA,EACzE,UAAE;AACA,eAAW;AAAA,EACb;AACF;AAGO,SAAS,yBAA+B;AAC7C,aAAW;AACb;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/adapters/netlify.ts"],"sourcesContent":["/* eslint-disable security/detect-non-literal-fs-filename --\n * Netlify deploy adapter. All paths derived from `cwd` and a fixed\n * `.theokit/netlify/` output layout. Build-time tool — no HTTP input.\n */\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'\nimport { resolve } from 'node:path'\n\nimport type { TheoConfig } from '../config/schema.js'\nimport type { SecurityHeadersConfig } from '../core/contracts/security-headers.js'\nimport { assertServicesUnsupported, readManifest } from '../services/index.js'\n\nimport {\n deployedAgentsFragment,\n JSON_NOT_FOUND_RESPONSE,\n scannedFromLoaderCache,\n} from './deployed-agents.js'\nimport { type DeployedCorsOptions } from './deployed-cors.js'\nimport { type DeployedCsrfOptions } from './deployed-csrf.js'\nimport { deployedEntryPreamble } from './deployed-preamble.js'\nimport { deployedRateLimitFragment, rateLimitCheckFragment } from './deployed-rate-limit.js'\nimport type { DeployedRateLimitOptions } from './deployed-rate-limit.js'\nimport {\n agentsDirLiteral,\n type DeployedAgentsDirOptions,\n deployedRuntimeConfigFragment,\n type DeployedRuntimeConfigOptions,\n} from './deployed-runtime-config.js'\nimport { deployedTraceFragment } from './deployed-trace.js'\nimport { nodeAdapter } from './node.js'\nimport { buildSecurityHeaders, describeDeployedSecurityHeaders } from './security-headers.js'\nimport type { AdapterBuildContext, DeployAdapter } from './types.js'\n\nexport class NetlifyConflictError extends Error {\n constructor(path: string, currentTo: string) {\n super(\n `netlify.toml has a conflicting redirect: \"${path}\" → \"${currentTo}\". ` +\n `Remove it manually or edit it to point to \"/.netlify/functions/theo\" ` +\n `before running the Netlify adapter again.`,\n )\n this.name = 'NetlifyConflictError'\n }\n}\n\nexport interface NetlifyBuildDeps {\n runNodeBuild?: (config: TheoConfig, cwd: string, ctx?: AdapterBuildContext) => Promise<void>\n writeFile?: (path: string, content: string) => void\n ensureDir?: (path: string) => void\n readTomlIfExists?: () => string | null\n}\n\nexport function renderNetlifyFunction(\n opts: { securityHeaders?: SecurityHeadersConfig } & DeployedAgentsDirOptions &\n DeployedCsrfOptions &\n DeployedRuntimeConfigOptions &\n DeployedCorsOptions &\n DeployedRateLimitOptions = {},\n): string {\n const runtimeConfig = deployedRuntimeConfigFragment(opts)\n // B-235. Netlify's handler is `(request, context)` and already RECEIVES a Web `Request`, which\n // is the shape this branch needs — the item filed against it counted `new Request` occurrences\n // and concluded the opposite, measuring how the entry OBTAINS a request rather than whether it\n // has one. Deno is the model: same `loaderCache`, same filesystem `serverDir`, same guard.\n const agentsFragment = deployedAgentsFragment(scannedFromLoaderCache(agentsDirLiteral(opts)), {\n // Netlify answers its own 404 inline rather than through a helper, so the fragment is given\n // the expression instead of a call it would have to declare.\n notFound: `new Response('Not Found', { status: 404 })`,\n })\n return [\n `// Generated by Theo — Netlify Functions adapter`,\n `import { resolve } from 'node:path'`,\n `import { scanServerRoutes, matchRoute, executeRoute, createProductionLoader, extractTraceIdFromRequest, TRACE_HEADER, createCorsWebHandler } from 'theokit/server'`,\n `import { createWebShim } from 'theokit/adapters/web-shim'`,\n `import { buildSecurityHeaders, withSecurityHeaders } from 'theokit/adapters/security-headers'`,\n ``,\n `const cwd = process.cwd()`,\n `const serverDir = resolve(cwd, 'server')`,\n `let routesCache = null`,\n `let loaderCache = null`,\n ``,\n `// #410 — the security baseline \\`theokit start\\` puts on every response,`,\n `// carried here as a literal because the deployed function has no`,\n `// theo.config.ts to read. The netlify.toml redirect routes only /api/* here,`,\n `// so this covers the API and not the document Netlify's static host serves`,\n `// (usetheokit/theokit#412).`,\n ...deployedEntryPreamble(runtimeConfig, agentsFragment, opts, 'netlify'),\n ``,\n // B-027 — emitted only when a limit is declared.\n ...(opts.rateLimit === undefined\n ? []\n : [\n `import { createRateLimiterWeb } from 'theokit/server'`,\n `import { resolveClientIpFromRequest } from 'theokit/server/rate-limit'`,\n ``,\n ]),\n ...deployedRateLimitFragment(\n opts.rateLimit,\n 'netlify',\n // `context.ip` is the platform's own answer, read from the handler context Netlify passes.\n // The forwarded fallback covers a function shape that carries no context, and returns\n // `undefined` unless a `trustProxy` was declared — which is the named 503, not a shared key.\n `context?.ip ?? resolveClientIpFromRequest(request, TRUST_PROXY)`,\n // Both names are bound in the OUTER handler, which is where the check runs. Threading\n // `context` into `handleRequest` — which the plan called for — buys nothing: the limiter\n // never runs in there.\n 'request, context',\n ),\n ``,\n `export default async (request, context) => {`,\n ` // #409 — the preflight is answered BEFORE anything routes: an OPTIONS the router`,\n ` // handles is an OPTIONS the browser never gets a CORS answer to.`,\n ` const preflight = corsPreflight(request)`,\n ` if (preflight !== null) return withSecurityHeaders(preflight, SECURITY_HEADERS)`,\n ...rateLimitCheckFragment(opts.rateLimit, ' ', 'request, context'),\n ` return withCors(request, withSecurityHeaders(await handleRequest(request), SECURITY_HEADERS))`,\n `}`,\n ``,\n `async function handleRequest(request) {`,\n ` const url = new URL(request.url)`,\n ` if (!url.pathname.startsWith('/api/')${agentsFragment.hostBypass}) {`,\n ` return new Response('Not Found', { status: 404 })`,\n ` }`,\n ``,\n ` if (!routesCache) routesCache = scanServerRoutes(serverDir)`,\n ` if (!loaderCache) loaderCache = createProductionLoader()`,\n ``,\n ...agentsFragment.branch,\n ``,\n ` const match = matchRoute(url.pathname, routesCache)`,\n ` if (!match) return ${JSON_NOT_FOUND_RESPONSE}`,\n ``,\n ` const { req, res, toResponse } = createWebShim(request)`,\n ...deployedTraceFragment('request', ' '),\n ` const method = request.method.toUpperCase()`,\n ` // #382 — the run is not awaited: toResponse() settles at the headers and`,\n ` // carries a live body, so Netlify streams while the handler writes.`,\n ` return toResponse(executeRoute({ route: match.route, method, params: match.params, req, res, loadModule: loaderCache, serverDir, requestId, ...CSRF_CONFIG, ${runtimeConfig.executeRouteSpread} }))`,\n `}`,\n ].join('\\n')\n}\n\nconst THEO_REDIRECT_FROM = '/api/*'\nconst THEO_REDIRECT_TO = '/.netlify/functions/theo'\n\n/**\n * TOML merge (EC-2) — non-destructive.\n *\n * We avoid adding a TOML parser dependency by working at the line level. The\n * supported subset is sufficient for the Netlify use case:\n * - detect existing `[[redirects]]` blocks\n * - find a block whose `from = \"...\"` matches our target\n * - error if such a block points elsewhere\n * - otherwise append our block\n *\n * Unknown sections (e.g. `[build]`, `[[headers]]`, `[context.production.environment]`)\n * are preserved as-is.\n */\ninterface NetlifyRedirectBlock {\n startLine: number\n endLine: number\n from?: string\n to?: string\n}\n\nfunction parseRedirectBlocks(lines: readonly string[]): NetlifyRedirectBlock[] {\n const blocks: NetlifyRedirectBlock[] = []\n let current: NetlifyRedirectBlock | null = null\n\n for (let i = 0; i < lines.length; i++) {\n const trimmed = lines[i].trim()\n if (trimmed === '[[redirects]]') {\n if (current) {\n current.endLine = i - 1\n blocks.push(current)\n }\n current = { startLine: i, endLine: i }\n continue\n }\n if (current && /^\\[(\\[|[^[])/.test(trimmed)) {\n // Entering another section — close current.\n current.endLine = i - 1\n blocks.push(current)\n current = null\n }\n if (current) {\n const fromMatch = /^from\\s*=\\s*\"([^\"]+)\"/.exec(trimmed)\n if (fromMatch) current.from = fromMatch[1]\n const toMatch = /^to\\s*=\\s*\"([^\"]+)\"/.exec(trimmed)\n if (toMatch) current.to = toMatch[1]\n current.endLine = i\n }\n }\n if (current) blocks.push(current)\n return blocks\n}\n\nconst THEO_REDIRECT_TARGET = [\n '[[redirects]]',\n ' from = \"/api/*\"',\n ' to = \"/.netlify/functions/theo\"',\n ' status = 200',\n ' force = true',\n].join('\\n')\n\n/**\n * Marks the block this build owns, so it can be REGENERATED rather than merely not duplicated.\n *\n * Idempotence alone would be the wrong contract here: the block carries configuration, so a\n * `security.headers` change has to reach the file. Leaving an existing block in place — which is\n * what the redirect above correctly does, because its content is fixed — would silently pin the\n * baseline to whatever the first build emitted.\n */\nconst THEO_HEADERS_MARKER = '# Generated by Theo — security baseline (usetheokit/theokit#412)'\n\n/**\n * The `[[headers]]` block telling Netlify's static host the baseline.\n *\n * The emitted function applies these to every response IT returns, and it never returns the HTML\n * document: the `[[redirects]]` block routes only `/api/*`, so the page comes from Netlify's static\n * host and carried none of them. The values come from `buildSecurityHeaders` — the same function\n * the handler calls — rather than being written out here, because two lists of headers that must\n * agree are two lists that eventually do not.\n */\nfunction renderHeadersBlock(securityHeaders: SecurityHeadersConfig | undefined): string {\n const values = buildSecurityHeaders(securityHeaders ?? {}, { production: true })\n return [\n THEO_HEADERS_MARKER,\n '[[headers]]',\n ' for = \"/*\"',\n ' [headers.values]',\n ...Object.entries(values).map(([k, v]) => ` ${k} = ${JSON.stringify(v)}`),\n ].join('\\n')\n}\n\n/**\n * Drop a previously generated block, so the next one replaces it.\n *\n * Bounded by the marker and the next top-level section at column 0 (or EOF) — the shape TOML gives\n * a block. A user's own `[[headers]]` is untouched because it does not carry the marker: their\n * file, their rules, and Netlify applies both.\n */\nfunction withoutGeneratedHeaders(source: string): string {\n const lines = source.split(/\\r?\\n/)\n const start = lines.indexOf(THEO_HEADERS_MARKER)\n if (start < 0) return source\n\n const opensSection = (line: string | undefined): boolean => (line ?? '').startsWith('[')\n\n let end = start + 1\n while (end < lines.length && !opensSection(lines[end])) end += 1\n // The marker line itself opens the block, so `end` now sits on the NEXT section — but the block's\n // own `[[headers]]` is the first `[` after the marker, so step past it before scanning again.\n end += 1\n while (end < lines.length && !opensSection(lines[end])) end += 1\n\n return [...lines.slice(0, start), ...lines.slice(end)].join('\\n').replace(/\\n{3,}/gu, '\\n\\n')\n}\n\nexport function mergeNetlifyToml(\n existing: string | null,\n securityHeaders?: SecurityHeadersConfig,\n): string {\n const headersBlock = renderHeadersBlock(securityHeaders)\n\n if (existing === null || existing.trim().length === 0) {\n return `${THEO_REDIRECT_TARGET}\\n\\n${headersBlock}\\n`\n }\n\n existing = withoutGeneratedHeaders(existing)\n\n const blocks = parseRedirectBlocks(existing.split(/\\r?\\n/))\n\n // Detect conflict: every `/api/*` block must point at our function.\n for (const b of blocks) {\n if (b.from !== THEO_REDIRECT_FROM) continue\n if (b.to === THEO_REDIRECT_TO) {\n // The redirect is already there. The headers block is regenerated regardless, because it\n // carries configuration and the redirect does not.\n const sep = existing.endsWith('\\n') ? '' : '\\n'\n return `${existing}${sep}\\n${headersBlock}\\n`\n }\n throw new NetlifyConflictError(b.from, b.to ?? '(unknown)')\n }\n\n // No conflict — append target block.\n const sep = existing.endsWith('\\n') ? '' : '\\n'\n return `${existing}${sep}\\n${THEO_REDIRECT_TARGET}\\n\\n${headersBlock}\\n`\n}\n\nexport async function buildNetlify(\n config: TheoConfig,\n cwd: string,\n deps: NetlifyBuildDeps = {},\n ctx?: AdapterBuildContext,\n): Promise<void> {\n // Wave 2 (T2.2) — reject polyglot services on this adapter.\n assertServicesUnsupported('netlify', readManifest(cwd))\n\n const runNodeBuild = deps.runNodeBuild ?? nodeAdapter.build.bind(nodeAdapter)\n await runNodeBuild(config, cwd, ctx)\n\n const ensureDir = deps.ensureDir ?? ((p: string) => mkdirSync(p, { recursive: true }))\n const writeFile =\n deps.writeFile ??\n ((p, c) => {\n writeFileSync(p, c)\n })\n const readTomlIfExists =\n deps.readTomlIfExists ??\n (() => {\n const p = resolve(cwd, 'netlify.toml')\n return existsSync(p) ? readFileSync(p, 'utf-8') : null\n })\n\n const fnDir = resolve(cwd, '.netlify/functions')\n ensureDir(fnDir)\n writeFile(\n resolve(fnDir, 'theo.mjs'),\n renderNetlifyFunction({\n securityHeaders: config.security?.headers,\n // B-235 — pillar (a): the option existed and no build passed it, so a project with a\n // configured agents directory got the default `agents` on this target. Same defect\n // B-185 fixed for bun and deno, one target over.\n agentsDir: config.agentsDir,\n csrf: config.security?.csrf,\n disallowed: config.security?.disallowed,\n cors: config.security?.cors,\n // #425 — a selector, not a transformer, so it rides as a literal like the values above.\n serialization: config.serialization,\n }),\n )\n\n const existingToml = readTomlIfExists()\n const merged = mergeNetlifyToml(existingToml, config.security?.headers)\n writeFile(resolve(cwd, 'netlify.toml'), merged)\n\n // eslint-disable-next-line no-console -- CLI build progress\n console.log('\\n ✓ Netlify output → .netlify/functions/theo.mjs + netlify.toml')\n // eslint-disable-next-line no-console -- CLI build progress\n console.log(\n `${describeDeployedSecurityHeaders({\n target: 'netlify',\n securityHeaders: config.security?.headers,\n mintsNonce: false,\n documentHeaders: 'platform-configured',\n })}\\n`,\n )\n}\n\nexport const netlifyAdapter: DeployAdapter = {\n name: 'netlify',\n streamsResponses: true,\n // #409 / #410 — the generated entry calls `executeRoute` with routes, loader\n // and serverDir only. CSRF, route policy, file middleware and Zod validation\n // still run because they live inside `executeRoute`; none of the remaining\n // configurable concerns reach it. Declared explicitly rather than omitted so\n // the gap is a statement in the source and not an absence.\n //\n // `securityHeaders` IS applied -- to every response this function returns.\n // The document is served by Netlify's static host and does not pass through\n // it (usetheokit/theokit#412).\n appliesConfig: ['securityHeaders', 'csrf', 'disallowed', 'cors', 'serialization'],\n // B-257 — per-invocation: an in-process counter does not survive, so a declared limit needs a durable store.\n enforcesRateLimit: 'with-a-store',\n build(config, cwd, ctx) {\n return buildNetlify(config, cwd, {}, ctx)\n },\n}\n"],"mappings":"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","names":["sep"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/cli/commands/openapi.ts"],"sourcesContent":["/**\n * `theokit openapi` — regenerate `<distDir>/openapi.json` standalone.\n *\n * Mirrors trpc-openapi's `pnpm codegen` script. Useful in dev workflows\n * when only schemas changed and a full `theokit build` would be\n * overkill.\n *\n * Per G2 plan v1.1 T2.3. Single output (dev location only) — for the\n * dist artifact, run `theokit build`.\n *\n * Flags:\n * --dry-run Validate + print the document to stdout; skip the\n * filesystem write (EC-3 absorbed).\n */\nimport { mkdtempSync, rmSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join, resolve } from 'node:path'\n\nimport { loadConfig } from '../../config/load-config.js'\nimport { generateManifest } from '../../server/scan/manifest.js'\nimport { emitOpenApi } from '../../vite-plugin/openapi-emit/emit.js'\nimport { loadRoutesForOpenApi } from '../../vite-plugin/openapi-emit/load-routes.js'\n\nconst OPENAPI_DOCS_URL = 'https://theokit.dev/concepts/openapi'\n\ninterface OpenApiCommandOptions {\n /** Project root (defaults to `process.cwd()`). */\n cwd?: string\n /** Skip the filesystem write; print the document to stdout (EC-3). */\n dryRun?: boolean\n}\n\nexport async function openapiCommand(options: OpenApiCommandOptions = {}): Promise<void> {\n const cwd = options.cwd ?? process.cwd()\n const config = await loadConfig(cwd)\n\n if (config.openapi === undefined) {\n console.error(\n `\\n ✗ openapi: not configured in theo.config.ts\\n\\n` +\n ` Add the \\`openapi\\` block to opt in:\\n\\n` +\n ` export default defineConfig({\\n` +\n ` openapi: {\\n` +\n ` title: 'My App',\\n` +\n ` version: '1.0.0',\\n` +\n ` },\\n` +\n ` })\\n\\n` +\n ` See ${OPENAPI_DOCS_URL} for the full reference.\\n`,\n )\n process.exit(1)\n return\n }\n\n const serverDir = resolve(cwd, config.serverDir)\n const manifest = generateManifest(serverDir)\n const hydrated = await loadRoutesForOpenApi({ serverDir, routes: manifest.routes })\n\n if (options.dryRun) {\n // Build the document without writing — for the dry-run we still want a\n // valid emit pass, but with an in-memory output dir we never touch disk.\n const result = emitOpenApiInMemory(hydrated, config.openapi)\n console.log(`\\n ✓ openapi.json (dry-run): ${String(hydrated.length)} ops`)\n console.log(JSON.stringify(result.document, null, 2))\n console.log(`\\n Docs: ${OPENAPI_DOCS_URL}\\n`)\n return\n }\n\n const distDir = resolve(cwd, config.distDir)\n const result = emitOpenApi({\n manifest: hydrated,\n config: { ...config.openapi, outDir: distDir },\n })\n console.log(\n `\\n ✓ openapi.json: ${String(hydrated.length)} ops → ${result.path}\\n` +\n ` Docs: ${OPENAPI_DOCS_URL}\\n`,\n )\n}\n\n/**\n * Dry-run helper: build the OpenAPI doc without writing. Routes through\n * the same `emitOpenApi` but discards the side-effecting write by\n * targeting a temp directory we immediately ignore.\n *\n * Honest tradeoff: this still calls `mkdirSync` (no-op on existing tmpdir)\n * + a synchronous write to a discardable tempfile. Cost is < 1ms and the\n * code stays the single transformation. A pure no-write helper would\n * duplicate the orchestration in `emit.ts` — KISS prefers reuse.\n */\nfunction emitOpenApiInMemory(\n manifest: Parameters<typeof emitOpenApi>[0]['manifest'],\n config: Omit<Parameters<typeof emitOpenApi>[0]['config'], 'outDir'>,\n): { document: unknown } {\n const tmp = mkdtempSync(join(tmpdir(), 'theokit-openapi-dryrun-'))\n try {\n const result = emitOpenApi({ manifest, config: { ...config, outDir: tmp } })\n return { document: result.document }\n } finally {\n rmSync(tmp, { recursive: true, force: true })\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAcA,SAAS,aAAa,cAAc;AACpC,SAAS,cAAc;AACvB,SAAS,MAAM,eAAe;AAO9B,IAAM,mBAAmB;AASzB,eAAsB,eAAe,UAAiC,CAAC,GAAkB;AACvF,QAAM,MAAM,QAAQ,OAAO,QAAQ,IAAI;AACvC,QAAM,SAAS,MAAM,WAAW,GAAG;AAEnC,MAAI,OAAO,YAAY,QAAW;AAChC,YAAQ;AAAA,MACN;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,QAQW,gBAAgB;AAAA;AAAA,IAC7B;AACA,YAAQ,KAAK,CAAC;AACd;AAAA,EACF;AAEA,QAAM,YAAY,QAAQ,KAAK,OAAO,SAAS;AAC/C,QAAM,WAAW,iBAAiB,SAAS;AAC3C,QAAM,WAAW,MAAM,qBAAqB,EAAE,WAAW,QAAQ,SAAS,OAAO,CAAC;AAElF,MAAI,QAAQ,QAAQ;AAGlB,UAAMA,UAAS,oBAAoB,UAAU,OAAO,OAAO;AAC3D,YAAQ,IAAI;AAAA,mCAAiC,OAAO,SAAS,MAAM,CAAC,MAAM;AAC1E,YAAQ,IAAI,KAAK,UAAUA,QAAO,UAAU,MAAM,CAAC,CAAC;AACpD,YAAQ,IAAI;AAAA,UAAa,gBAAgB;AAAA,CAAI;AAC7C;AAAA,EACF;AAEA,QAAM,UAAU,QAAQ,KAAK,OAAO,OAAO;AAC3C,QAAM,SAAS,YAAY;AAAA,IACzB,UAAU;AAAA,IACV,QAAQ,EAAE,GAAG,OAAO,SAAS,QAAQ,QAAQ;AAAA,EAC/C,CAAC;AACD,UAAQ;AAAA,IACN;AAAA,yBAAuB,OAAO,SAAS,MAAM,CAAC,eAAU,OAAO,IAAI;AAAA,UACtD,gBAAgB;AAAA;AAAA,EAC/B;AACF;AAYA,SAAS,oBACP,UACA,QACuB;AACvB,QAAM,MAAM,YAAY,KAAK,OAAO,GAAG,yBAAyB,CAAC;AACjE,MAAI;AACF,UAAM,SAAS,YAAY,EAAE,UAAU,QAAQ,EAAE,GAAG,QAAQ,QAAQ,IAAI,EAAE,CAAC;AAC3E,WAAO,EAAE,UAAU,OAAO,SAAS;AAAA,EACrC,UAAE;AACA,WAAO,KAAK,EAAE,WAAW,MAAM,OAAO,KAAK,CAAC;AAAA,EAC9C;AACF;","names":["result"]}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import "tsx/esm";
|
|
3
|
-
|
|
4
|
-
// src/adapters/registry.ts
|
|
5
|
-
var adapterRegistry = {
|
|
6
|
-
node: async () => (await import("./node-QUGTW6XV.js")).nodeAdapter,
|
|
7
|
-
vercel: async () => (await import("./vercel-5PMYEEKF.js")).vercelAdapter,
|
|
8
|
-
cloudflare: async () => (await import("./cloudflare-4M4XVNUH.js")).cloudflareAdapter,
|
|
9
|
-
static: async () => (await import("./static-NQPLPAB6.js")).staticAdapter,
|
|
10
|
-
bun: async () => (await import("./bun-NKVHOFSJ.js")).bunAdapter,
|
|
11
|
-
"deno-deploy": async () => (await import("./deno-deploy-IVACZ62D.js")).denoDeployAdapter,
|
|
12
|
-
netlify: async () => (await import("./netlify-3HEWV375.js")).netlifyAdapter,
|
|
13
|
-
"aws-lambda": async () => (await import("./aws-lambda-7AZNJGWU.js")).awsLambdaAdapter,
|
|
14
|
-
"theo-cloud": async () => (await import("./theo-cloud-HMFNZ3FS.js")).theoCloudAdapter
|
|
15
|
-
};
|
|
16
|
-
async function resolveAdapter(target) {
|
|
17
|
-
const factory = adapterRegistry[target];
|
|
18
|
-
return factory();
|
|
19
|
-
}
|
|
20
|
-
export {
|
|
21
|
-
resolveAdapter
|
|
22
|
-
};
|
|
23
|
-
//# sourceMappingURL=registry-SDBODRU6.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/adapters/registry.ts"],"sourcesContent":["/**\n * Adapter Registry (T1.1 of architecture-medium-deferrals plan, ADR D1).\n *\n * Replaces the 9-case `switch (target)` in `cli/commands/build.ts` with a\n * declarative `Record<BuildTarget, () => Promise<DeployAdapter>>` map.\n *\n * Why a registry?\n * - **OCP** — adding a new adapter is now 1 line in this map + 1 string in\n * `VALID_TARGETS`. The CLI does not change.\n * - **Lazy import preserved** — each factory is `async () => (await import(...))`\n * so deps load only when the matching target is built.\n * - **TypeScript exhaustiveness** — `Record<BuildTarget, ...>` forces every\n * target in the union to have a registered factory. Adding a new target\n * without registering it is a compile error.\n *\n * The registry is the single source of truth for runtime adapter dispatch.\n * The string list `VALID_TARGETS` (in `./types.js`) is the source of truth\n * for CLI flag validation. A test (`test_valid_targets_matches_registry_keys`)\n * pins both lists to the same `BuildTarget` union to prevent drift.\n */\n\nimport type { BuildTarget, DeployAdapter } from './types.js'\n\nconst adapterRegistry: Record<BuildTarget, () => Promise<DeployAdapter>> = {\n node: async () => (await import('./node.js')).nodeAdapter,\n vercel: async () => (await import('./vercel.js')).vercelAdapter,\n cloudflare: async () => (await import('./cloudflare.js')).cloudflareAdapter,\n static: async () => (await import('./static.js')).staticAdapter,\n bun: async () => (await import('./bun.js')).bunAdapter,\n 'deno-deploy': async () => (await import('./deno-deploy.js')).denoDeployAdapter,\n netlify: async () => (await import('./netlify.js')).netlifyAdapter,\n 'aws-lambda': async () => (await import('./aws-lambda.js')).awsLambdaAdapter,\n 'theo-cloud': async () => (await import('./theo-cloud.js')).theoCloudAdapter,\n}\n\n/**\n * Resolve a `DeployAdapter` for the given target. Throws actionable error\n * if the target is not registered (should be unreachable when callers use\n * the typed `BuildTarget` union, but guards against runtime drift).\n */\nexport async function resolveAdapter(target: BuildTarget): Promise<DeployAdapter> {\n // BuildTarget union guarantees the key exists at compile time. The runtime\n // accessor still returns the typed factory; we call it directly.\n const factory = adapterRegistry[target]\n return factory()\n}\n"],"mappings":";;;;AAuBA,IAAM,kBAAqE;AAAA,EACzE,MAAM,aAAa,MAAM,OAAO,oBAAW,GAAG;AAAA,EAC9C,QAAQ,aAAa,MAAM,OAAO,sBAAa,GAAG;AAAA,EAClD,YAAY,aAAa,MAAM,OAAO,0BAAiB,GAAG;AAAA,EAC1D,QAAQ,aAAa,MAAM,OAAO,sBAAa,GAAG;AAAA,EAClD,KAAK,aAAa,MAAM,OAAO,mBAAU,GAAG;AAAA,EAC5C,eAAe,aAAa,MAAM,OAAO,2BAAkB,GAAG;AAAA,EAC9D,SAAS,aAAa,MAAM,OAAO,uBAAc,GAAG;AAAA,EACpD,cAAc,aAAa,MAAM,OAAO,0BAAiB,GAAG;AAAA,EAC5D,cAAc,aAAa,MAAM,OAAO,0BAAiB,GAAG;AAC9D;AAOA,eAAsB,eAAe,QAA6C;AAGhF,QAAM,UAAU,gBAAgB,MAAM;AACtC,SAAO,QAAQ;AACjB;","names":[]}
|