@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.1

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.
Files changed (326) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +106 -0
  2. package/.claude/skills/urlcode-operations/SKILL.md +114 -0
  3. package/.claude-plugin/marketplace.json +18 -0
  4. package/CONTRIBUTING.md +30 -2
  5. package/README.md +157 -230
  6. package/ROADMAP.md +85 -2
  7. package/SECURITY.md +8 -1
  8. package/dist/BUILD-MANIFEST.json +62 -32
  9. package/dist/adapters.js +4 -23
  10. package/dist/agent-lists.js +1 -1
  11. package/dist/agents-guide.js +113 -0
  12. package/dist/authoring-files.js +60 -0
  13. package/dist/authoring.js +11 -1
  14. package/dist/aws.js +4 -3
  15. package/dist/build-cloudflare.js +11 -24
  16. package/dist/bulk.js +37 -0
  17. package/dist/capabilities.js +208 -0
  18. package/dist/capability-query.js +72 -0
  19. package/dist/catalog.js +105 -0
  20. package/dist/cli.js +154 -15
  21. package/dist/client-address.js +1 -1
  22. package/dist/compliance-rules/baseline.js +8 -8
  23. package/dist/compliance-rules/privacy.js +3 -3
  24. package/dist/compliance-rules/strict.js +5 -5
  25. package/dist/conditions.js +88 -0
  26. package/dist/config.js +60 -4
  27. package/dist/context.js +156 -0
  28. package/dist/ecosystem-cli.js +88 -0
  29. package/dist/egress.js +98 -0
  30. package/dist/examples.js +92 -0
  31. package/dist/explain-cli.js +64 -0
  32. package/dist/explain.js +132 -0
  33. package/dist/extensions.js +172 -0
  34. package/dist/function-sources.js +15 -3
  35. package/dist/index.js +37 -0
  36. package/dist/init-with.js +165 -0
  37. package/dist/interchange-cli.js +42 -0
  38. package/dist/interchange.js +189 -0
  39. package/dist/link-cli.js +1 -1
  40. package/dist/management-policy.js +0 -1
  41. package/dist/manifest.js +111 -0
  42. package/dist/match.js +2 -2
  43. package/dist/mcp-authoring.js +147 -0
  44. package/dist/mcp.js +97 -0
  45. package/dist/observability.js +6 -0
  46. package/dist/operator-host.js +29 -0
  47. package/dist/plugins.js +12 -0
  48. package/dist/policies/agents.js +2 -2
  49. package/dist/policies/compression.js +2 -1
  50. package/dist/policies/security.js +0 -0
  51. package/dist/policies.js +1 -1
  52. package/dist/policy.js +29 -7
  53. package/dist/prerender.js +100 -41
  54. package/dist/project-tests.js +3 -3
  55. package/dist/provider-verification.js +92 -0
  56. package/dist/proxy.js +44 -0
  57. package/dist/readiness.js +5 -5
  58. package/dist/recipes.js +41 -0
  59. package/dist/route-diff.js +106 -0
  60. package/dist/router.js +42 -2
  61. package/dist/runtime.js +95 -14
  62. package/dist/schema-query.js +62 -0
  63. package/dist/signals.js +24 -0
  64. package/dist/site.js +0 -0
  65. package/dist/tooling.js +96 -0
  66. package/dist/types/adapters.d.ts +7 -4
  67. package/dist/types/agent-lists.d.ts +0 -1
  68. package/dist/types/agents-guide.d.ts +17 -0
  69. package/dist/types/authoring-files.d.ts +10 -0
  70. package/dist/types/aws.d.ts +3 -1
  71. package/dist/types/build-cloudflare.d.ts +1 -0
  72. package/dist/types/bulk.d.ts +27 -0
  73. package/dist/types/capabilities.d.ts +55 -0
  74. package/dist/types/capability-query.d.ts +24 -0
  75. package/dist/types/catalog.d.ts +65 -0
  76. package/dist/types/client-address.d.ts +0 -1
  77. package/dist/types/compliance-rules/baseline.d.ts +1 -9
  78. package/dist/types/compliance-rules/privacy.d.ts +1 -4
  79. package/dist/types/compliance-rules/strict.d.ts +0 -5
  80. package/dist/types/conditions.d.ts +19 -0
  81. package/dist/types/config.d.ts +20 -2
  82. package/dist/types/context.d.ts +65 -0
  83. package/dist/types/ecosystem-cli.d.ts +17 -0
  84. package/dist/types/egress.d.ts +46 -0
  85. package/dist/types/examples.d.ts +50 -0
  86. package/dist/types/explain-cli.d.ts +11 -0
  87. package/dist/types/explain.d.ts +95 -0
  88. package/dist/types/extensions.d.ts +122 -0
  89. package/dist/types/function-sources.d.ts +5 -0
  90. package/dist/types/index.d.ts +33 -0
  91. package/dist/types/init-with.d.ts +30 -0
  92. package/dist/types/interchange-cli.d.ts +16 -0
  93. package/dist/types/interchange.d.ts +42 -0
  94. package/dist/types/link-cli.d.ts +2 -1
  95. package/dist/types/management-policy.d.ts +0 -1
  96. package/dist/types/manifest.d.ts +81 -0
  97. package/dist/types/match.d.ts +1 -0
  98. package/dist/types/mcp-authoring.d.ts +92 -0
  99. package/dist/types/mcp.d.ts +12 -0
  100. package/dist/types/observability.d.ts +2 -0
  101. package/dist/types/operator-host.d.ts +8 -0
  102. package/dist/types/plugins.d.ts +2 -0
  103. package/dist/types/policies/agents.d.ts +0 -2
  104. package/dist/types/policies/compression.d.ts +2 -0
  105. package/dist/types/policies/security.d.ts +0 -1
  106. package/dist/types/policy.d.ts +15 -4
  107. package/dist/types/project-tests.d.ts +3 -1
  108. package/dist/types/provider-verification.d.ts +53 -0
  109. package/dist/types/proxy.d.ts +21 -0
  110. package/dist/types/readiness.d.ts +1 -1
  111. package/dist/types/recipes.d.ts +30 -0
  112. package/dist/types/route-diff.d.ts +27 -0
  113. package/dist/types/runtime.d.ts +11 -0
  114. package/dist/types/schema-query.d.ts +12 -0
  115. package/dist/types/signals.d.ts +25 -0
  116. package/dist/types/site.d.ts +0 -1
  117. package/dist/types/tooling.d.ts +115 -0
  118. package/dist/types/types.d.ts +57 -0
  119. package/dist/types/typescript-authoring.d.ts +12 -0
  120. package/dist/types/vercel.d.ts +3 -1
  121. package/dist/types/verify-deployment.d.ts +47 -0
  122. package/dist/types.js +21 -2
  123. package/dist/typescript-authoring.js +104 -0
  124. package/dist/vercel.js +4 -3
  125. package/dist/verify-deployment.js +270 -0
  126. package/docs/AI-AUTHORING.md +130 -8
  127. package/docs/BULK.md +79 -0
  128. package/docs/CAPABILITIES.md +179 -0
  129. package/docs/CAPACITY.md +1 -1
  130. package/docs/CI.md +142 -0
  131. package/docs/CONDITIONS.md +74 -0
  132. package/docs/DEPLOYMENT-CHECKS.md +108 -0
  133. package/docs/DYNAMIC-LINKS.md +18 -518
  134. package/docs/EGRESS.md +125 -0
  135. package/docs/EXTENSIONS.md +226 -0
  136. package/docs/FRAMEWORK.md +182 -0
  137. package/docs/INSTALL.md +45 -7
  138. package/docs/INTERCHANGE.md +134 -0
  139. package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
  140. package/docs/MIDDLEWARE.md +2 -0
  141. package/docs/NEXT-PHASE-PLAN.md +90 -0
  142. package/docs/NEXT-STEPS.md +415 -0
  143. package/docs/OBSERVABILITY.md +4 -2
  144. package/docs/OPERATIONAL-PROOF.md +4 -1
  145. package/docs/OPERATIONS.md +6 -3
  146. package/docs/PLUGINS.md +37 -0
  147. package/docs/POLICIES.md +12 -309
  148. package/docs/PRERENDER.md +40 -0
  149. package/docs/PROJECT-DIRECTION.md +42 -0
  150. package/docs/PROVIDER-VERIFICATION.md +84 -0
  151. package/docs/READINESS.md +21 -1
  152. package/docs/README.md +82 -31
  153. package/docs/RECIPES.md +99 -0
  154. package/docs/RELEASE-READINESS.md +11 -9
  155. package/docs/RELEASE-SECURITY.md +27 -4
  156. package/docs/SECURITY-AUDIT.md +1 -1
  157. package/docs/SPECIFICATION.md +95 -8
  158. package/docs/SPIKE-BUSINESS-SUITE.md +1013 -0
  159. package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
  160. package/docs/SPIKE-EXTENSIONS.md +1 -0
  161. package/docs/SPIKE-LAMBDA-COMPILE.md +199 -0
  162. package/docs/STANDARDS.md +150 -142
  163. package/docs/STARTERS.md +21 -1
  164. package/docs/TOOLING.md +291 -0
  165. package/docs/TYPESCRIPT-AUTHORING.md +67 -0
  166. package/docs/TYPESCRIPT.md +1 -1
  167. package/docs/USABILITY-REVIEW.md +123 -0
  168. package/docs/YAML-GUIDE.md +18 -479
  169. package/docs/YAML-REFERENCE.md +127 -16
  170. package/docs/links/cli.md +110 -0
  171. package/docs/links/limits.md +175 -0
  172. package/docs/links/management-api.md +80 -0
  173. package/docs/links/pools.md +75 -0
  174. package/docs/links/setup.md +135 -0
  175. package/docs/policies/agents.md +1 -1
  176. package/docs/policies/contract.md +52 -0
  177. package/docs/policies/hardened.md +56 -0
  178. package/docs/policies/interoperability.md +169 -0
  179. package/docs/policies/operations.md +45 -0
  180. package/docs/yaml/assets.md +36 -0
  181. package/docs/yaml/conditions.md +20 -0
  182. package/docs/yaml/functions.md +160 -0
  183. package/docs/yaml/links.md +30 -0
  184. package/docs/yaml/middleware.md +29 -0
  185. package/docs/yaml/organization.md +74 -0
  186. package/docs/yaml/policies.md +37 -0
  187. package/docs/yaml/redirects.md +64 -0
  188. package/docs/yaml/responses.md +57 -0
  189. package/docs/yaml/site.md +24 -0
  190. package/examples/assets/example.yaml +17 -0
  191. package/examples/aws/example.yaml +20 -0
  192. package/examples/cloudflare/example.yaml +19 -0
  193. package/examples/compliance/example.yaml +11 -0
  194. package/examples/conditions/README.md +12 -0
  195. package/examples/conditions/example.yaml +19 -0
  196. package/examples/conditions/tests/requests.json +13 -0
  197. package/examples/conditions/urlcode.yaml +24 -0
  198. package/examples/cookbook/README.md +8 -4
  199. package/examples/cookbook/example.yaml +17 -0
  200. package/examples/cookbook/functions/catalog.mjs +3 -0
  201. package/examples/cookbook/functions/fail.mjs +4 -0
  202. package/examples/cookbook/functions/items.mjs +3 -0
  203. package/examples/cookbook/functions/profile.mjs +3 -0
  204. package/examples/cookbook/functions/resource.mjs +3 -0
  205. package/examples/cookbook/functions/status.mjs +3 -0
  206. package/examples/cookbook/middleware/auth.mjs +48 -0
  207. package/examples/cookbook/middleware/body.mjs +15 -0
  208. package/examples/cookbook/middleware/bucket.mjs +19 -0
  209. package/examples/cookbook/middleware/cors.mjs +21 -0
  210. package/examples/cookbook/middleware/debug.mjs +13 -0
  211. package/examples/cookbook/middleware/envelope.mjs +11 -0
  212. package/examples/cookbook/middleware/errors.mjs +11 -0
  213. package/examples/cookbook/middleware/etag.mjs +18 -0
  214. package/examples/cookbook/middleware/locale.mjs +16 -0
  215. package/examples/cookbook/middleware/maintenance.mjs +10 -0
  216. package/examples/cookbook/middleware/methods.mjs +15 -0
  217. package/examples/cookbook/middleware/negotiate.mjs +20 -0
  218. package/examples/cookbook/middleware/referer.mjs +12 -0
  219. package/examples/cookbook/middleware/request-id.mjs +16 -0
  220. package/examples/cookbook/route-index.json +676 -0
  221. package/examples/cookbook/routes/middleware.yaml +126 -0
  222. package/examples/cookbook/tests/requests.json +526 -0
  223. package/examples/cookbook/urlcode.yaml +1 -0
  224. package/examples/egress/README.md +22 -0
  225. package/examples/egress/example.yaml +19 -0
  226. package/examples/egress/urlcode.yaml +19 -0
  227. package/examples/extensions/README.md +7 -0
  228. package/examples/extensions/example.yaml +21 -0
  229. package/examples/extensions/urlcode.yaml +25 -0
  230. package/examples/live-links/example.yaml +21 -0
  231. package/examples/monitoring/example.yaml +8 -0
  232. package/examples/prerender/example.yaml +16 -0
  233. package/examples/provider-conformance/README.md +12 -0
  234. package/examples/provider-conformance/example.yaml +14 -0
  235. package/examples/provider-conformance/urlcode.yaml +34 -0
  236. package/examples/tunnel/example.yaml +8 -0
  237. package/examples/vercel/example.yaml +19 -0
  238. package/llms-full.txt +2709 -0
  239. package/llms.txt +48 -19
  240. package/package.json +29 -7
  241. package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
  242. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +106 -0
  243. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +114 -0
  244. package/recipes/authenticated-json-api/README.md +51 -0
  245. package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
  246. package/recipes/authenticated-json-api/recipe.yaml +34 -0
  247. package/recipes/authenticated-json-api/tests/requests.json +39 -0
  248. package/recipes/authenticated-json-api/urlcode.yaml +12 -0
  249. package/recipes/contact-form/README.md +25 -0
  250. package/recipes/contact-form/functions/contact.mjs +17 -0
  251. package/recipes/contact-form/recipe.yaml +33 -0
  252. package/recipes/contact-form/tests/requests.json +47 -0
  253. package/recipes/contact-form/urlcode.yaml +18 -0
  254. package/recipes/cors-api/README.md +16 -0
  255. package/recipes/cors-api/functions/items.mjs +3 -0
  256. package/recipes/cors-api/middleware/cors.mjs +21 -0
  257. package/recipes/cors-api/recipe.yaml +26 -0
  258. package/recipes/cors-api/tests/requests.json +65 -0
  259. package/recipes/cors-api/urlcode.yaml +12 -0
  260. package/recipes/health-page/README.md +13 -0
  261. package/recipes/health-page/recipe.yaml +23 -0
  262. package/recipes/health-page/tests/requests.json +36 -0
  263. package/recipes/health-page/urlcode.yaml +19 -0
  264. package/recipes/json-api/README.md +6 -0
  265. package/recipes/json-api/functions/echo.mjs +3 -0
  266. package/recipes/json-api/recipe.yaml +25 -0
  267. package/recipes/json-api/tests/requests.json +34 -0
  268. package/recipes/json-api/urlcode.yaml +12 -0
  269. package/recipes/middleware/README.md +34 -0
  270. package/recipes/middleware/functions/catalog.mjs +3 -0
  271. package/recipes/middleware/functions/fail.mjs +4 -0
  272. package/recipes/middleware/functions/items.mjs +3 -0
  273. package/recipes/middleware/functions/profile.mjs +3 -0
  274. package/recipes/middleware/functions/resource.mjs +3 -0
  275. package/recipes/middleware/functions/status.mjs +3 -0
  276. package/recipes/middleware/middleware/auth.mjs +48 -0
  277. package/recipes/middleware/middleware/body.mjs +15 -0
  278. package/recipes/middleware/middleware/bucket.mjs +19 -0
  279. package/recipes/middleware/middleware/cors.mjs +21 -0
  280. package/recipes/middleware/middleware/debug.mjs +13 -0
  281. package/recipes/middleware/middleware/envelope.mjs +11 -0
  282. package/recipes/middleware/middleware/errors.mjs +11 -0
  283. package/recipes/middleware/middleware/etag.mjs +18 -0
  284. package/recipes/middleware/middleware/locale.mjs +16 -0
  285. package/recipes/middleware/middleware/maintenance.mjs +10 -0
  286. package/recipes/middleware/middleware/methods.mjs +15 -0
  287. package/recipes/middleware/middleware/negotiate.mjs +20 -0
  288. package/recipes/middleware/middleware/referer.mjs +12 -0
  289. package/recipes/middleware/middleware/request-id.mjs +16 -0
  290. package/recipes/middleware/public/guide.txt +1 -0
  291. package/recipes/middleware/recipe.yaml +50 -0
  292. package/recipes/middleware/tests/requests.json +528 -0
  293. package/recipes/middleware/urlcode.yaml +127 -0
  294. package/recipes/protected-download/README.md +22 -0
  295. package/recipes/protected-download/files/report.txt +1 -0
  296. package/recipes/protected-download/recipe.yaml +31 -0
  297. package/recipes/protected-download/tests/requests.json +32 -0
  298. package/recipes/protected-download/urlcode.yaml +15 -0
  299. package/recipes/redirect/README.md +7 -0
  300. package/recipes/redirect/recipe.yaml +25 -0
  301. package/recipes/redirect/tests/requests.json +19 -0
  302. package/recipes/redirect/urlcode.yaml +9 -0
  303. package/recipes/static-plus-api/README.md +15 -0
  304. package/recipes/static-plus-api/functions/info.mjs +3 -0
  305. package/recipes/static-plus-api/public/assets/index.html +3 -0
  306. package/recipes/static-plus-api/public/assets/site.css +1 -0
  307. package/recipes/static-plus-api/public/index.html +8 -0
  308. package/recipes/static-plus-api/recipe.yaml +29 -0
  309. package/recipes/static-plus-api/tests/requests.json +56 -0
  310. package/recipes/static-plus-api/urlcode.yaml +23 -0
  311. package/recipes/typescript/README.md +7 -0
  312. package/recipes/typescript/functions/hello.ts +5 -0
  313. package/recipes/typescript/recipe.yaml +23 -0
  314. package/recipes/typescript/tests/requests.json +18 -0
  315. package/recipes/typescript/urlcode.yaml +5 -0
  316. package/recipes/webhook-receiver/README.md +16 -0
  317. package/recipes/webhook-receiver/functions/receive.mjs +16 -0
  318. package/recipes/webhook-receiver/recipe.yaml +26 -0
  319. package/recipes/webhook-receiver/tests/requests.json +59 -0
  320. package/recipes/webhook-receiver/urlcode.yaml +16 -0
  321. package/schemas/recipe.schema.json +138 -0
  322. package/schemas/urlcode.schema.json +656 -80
  323. package/skills/urlcode/SKILL.md +98 -0
  324. package/starters/default/.github/workflows/urlcode.yml +23 -0
  325. package/starters/default/.mcp.json +12 -0
  326. package/starters/default/AGENTS.md +79 -0
package/dist/cli.js CHANGED
@@ -1,23 +1,36 @@
1
1
  #!/usr/bin/env node
2
+ import { getCapabilities, formatCapabilities } from './capabilities.js';
3
+ import { getCapability, formatCapability } from './capability-query.js';
4
+ import { getSchemaFragment } from './schema-query.js';
5
+ import { stringify as stringifyYaml } from 'yaml';
2
6
  import { auditProject, benchmarkProject } from './readiness.js';
3
7
 
4
8
  import { parseArgs } from 'node:util';
5
9
  import { createRuntime } from './runtime.js';
10
+ import { loadOperatorHost } from './operator-host.js';
11
+
6
12
  import { startServer } from './server.js';
7
13
 
8
14
  import {scaffoldProject} from './scaffold.js';
9
15
  import { initProject, addRedirect } from './authoring.js';
16
+ import { initProjectWith, parseWithNames } from './init-with.js';
10
17
  import { runProjectTests } from './project-tests.js';
18
+ import { verifyDeployment, failLevels } from './verify-deployment.js';
19
+
11
20
  import { loadOperatorPolicy, prepareFunctionSnapshot, requestedPermissions } from './policy.js';
12
21
  import { loadDocument } from './config.js';
22
+ import { describeExtensions } from './tooling.js';
23
+
13
24
  import {parseLinkBinding,runLinkCommand,linkPoolOptions} from './link-cli.js';
14
25
  import { ConfigError, HttpError } from './errors.js';
15
26
  import {supportsConcurrentWal} from './sqlite-version.js';
16
27
  import { registry as policyRegistry } from './policies.js';
17
28
  import { loadComplianceRules, profileNames as complianceProfiles } from './compliance.js';
29
+ import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
30
+ import { readFile } from 'node:fs/promises';
18
31
 
19
- const usage = `URLCode 0.3.0 — local/self-hosted runtime
20
- urlcode init <directory>
32
+ const usage = `URLCode 0.4.0-alpha.1 — local/self-hosted runtime
33
+ urlcode init <directory> [--with auth,admin] # --with: layered site from installed @jimhoyd/urlcode-<name> packages
21
34
  urlcode scaffold [--project directory] [--dry-run]
22
35
  urlcode validate [--project directory] [--local] [--origin https://links.example] # origin: absolute URLs in site.* files
23
36
  urlcode dev [--project directory] [--port 3000] [--host 127.0.0.1]
@@ -30,33 +43,60 @@ const usage = `URLCode 0.3.0 — local/self-hosted runtime
30
43
  urlcode test [--project directory] [--origin https://links.example]
31
44
  urlcode build --target cloudflare [--project directory] [--out dist/cloudflare] [--origin https://links.example]
32
45
  urlcode routes [--project directory] [--origin https://links.example]
46
+ diff: [--compare previous-routes.json] [--format json|markdown] # added/removed/changed routes against an earlier report; always exits 0
33
47
  urlcode audit [--project directory] [--expect-routes 2]
34
48
  compliance: [--compliance baseline|strict|privacy|none] [--compliance-rules /absolute/rules.mjs] [--compliance-ignore id,id]
35
49
  [--compliance-warn] [--origin https://links.example] [--request-log minimal|detailed] # declare the deployment under review
36
50
  urlcode benchmark [--project directory] [--requests 1000] [--concurrency 2] [--seconds 30] [--max-p95-ms 50]
37
51
  [--warmup 50] [--target https://links.example] # target measures a running deployment, not a local snapshot
38
- urlcode permissions [--project directory] # inspect requested bindings; grants nothing
52
+ urlcode verify-deployment --target https://links.example [--project directory] [--origin https://links.example]
53
+ [--expect-routes 2] [--expect-metrics] [--timeout-ms 10000] [--fail-on high|medium|low|info|none]
54
+ [--compliance baseline|strict|privacy|none] [--compliance-rules ...] [--compliance-ignore id,id] [--compliance-warn]
55
+ # compares the running deployment's responses with what this project declares; never follows redirects, no --insecure
56
+ urlcode permissions [--project directory] # inspect requested bindings and egress origins; grants nothing
57
+ urlcode explain [/route] [--project directory] [--target self-hosted|cloudflare|aws|vercel] [--host-file ...] [--json]
58
+ # effective methods, handler, middleware, inputs, policies, cache outcome, bindings and target support from the compiled configuration
59
+ urlcode manifest [--project directory] [--json] # generated semantic manifest; build writes the same file as manifest.json
60
+ urlcode extensions [--project directory] [--host-file /absolute/operator/host.mjs] [--json] # registered contracts and schemas; executes trusted host code, activates nothing
39
61
  urlcode links init|create|get|list|update|delete|export|import|api --store /absolute/links.sqlite [--collection links]
40
62
  create/update: --destination https://example.com [--code abc] [--status 302] [--enabled true] [--expires UTC]
41
63
  update/delete: --code abc --if-version N (update replaces all mutable fields)
42
64
  export: consistent NDJSON snapshot to stdout [--collection links] [--page-size 100]
43
65
  import: --input /absolute/export.ndjson restores into empty collections (versions are reassigned)
44
66
  api: --auth-file /operator/management.json (or legacy --token-file /operator/token) --port 3001 (separate authenticated server)
67
+ urlcode import [netlify|cloudflare|vercel|netlify-toml] <file> [--format csv|json|yaml] [--out new-file] [--dry-run] [--report json]
68
+ urlcode export --target netlify|cloudflare|vercel|netlify-toml|csv|json|yaml [--project directory] [--out new-file] [--report json]
69
+ conversion: [--accept-provider-differences] # explicit non-lossless migration candidate; exact behavior requires runtime
70
+ urlcode recipes [list|search <text>|show <name>|add <name> --out new-directory] [--dry-run] [--json]
71
+ urlcode examples [list|search <text>] [--json] # bundled runnable examples and the cookbook route index
72
+ urlcode build-typescript [--project directory] --out new-directory [--dry-run]
73
+ urlcode bulk-import csv|json|yaml <file> --out new-directory [--dry-run]
74
+ urlcode verify-provider --target self-hosted|aws|vercel|cloudflare --origin https://owned-fixture.example
75
+ [--timeout-ms 3000] [--release label] [--git-commit sha] # explicitly invokes synthetic deployment probes
76
+ urlcode mcp [--project directory] [--allow-authoring] [--host-file ...] # bounded stdio tooling; --allow-authoring adds project-confined authoring tools, host file adds get_extensions
77
+ urlcode capabilities [--target self-hosted|cloudflare|aws|vercel] [--json]
78
+ urlcode capabilities <name> [--json] # one catalog entry: schema fragment, constraints, grants, targets, bundled uses
79
+ urlcode schema <path> [--json|--yaml] # schema fragment for route, redirect, policies.cache, site.sitemap, ...
80
+ urlcode context [--project directory] [--target self-hosted|cloudflare|aws|vercel] [--budget 500] [--json] [--stats]
81
+ # compact facts for an authoring agent from the compiled project; --stats compares estimated tokens with the docs
45
82
  urlcode doctor
83
+ serve/dev/validate/test/routes/audit/benchmark/explain/context/extensions/mcp: --host-file /absolute/operator/host.mjs (trusted code outside project)
46
84
  serve/dev/validate/test/routes/audit/benchmark: --link-store links=/absolute/links.sqlite
47
85
  Store pool controls: --link-readers 2 (1–8), --link-read-limit 32, --link-write-limit 32 (1–32 each)
48
86
  Dev loads .env.local and watches; serve does neither. Functions run in WASM isolation; external bindings require --policy outside the project.
49
87
  `;
50
88
  const print = (value ) => process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value) + '\n');
51
89
  const options = {
52
- project:{ type:'string', default:'.' },
90
+ json:{ type:'boolean' }, yaml:{ type:'boolean' }, report:{type:'string'}, 'accept-provider-differences':{type:'boolean'},
91
+ project:{ type:'string', default:'.' }, 'host-file':{type:'string'}, with:{type:'string'},
53
92
  port:{ type:'string' }, host:{ type:'string', default:'127.0.0.1' },
54
93
  'expect-routes':{type:'string'}, requests:{type:'string'}, concurrency:{type:'string'}, seconds:{type:'string'}, 'max-p95-ms':{type:'string'}, warmup:{type:'string'}, target:{type:'string'},
55
94
  'link-readers':{type:'string'}, 'link-read-limit':{type:'string'}, 'link-write-limit':{type:'string'},
56
95
  workers:{type:'string'}, 'function-timeout-ms':{type:'string'}, 'max-response-bytes':{type:'string'}, 'max-body-bytes':{type:'string'},
57
96
  'max-in-flight':{type:'string'}, 'max-in-flight-health':{type:'string'}, 'request-log':{type:'string'}, 'trust-request-id':{type:'boolean'}, 'trusted-proxies':{type:'string'}, metrics:{type:'boolean'},
58
97
  'link-store':{type:'string'}, store:{type:'string'}, collection:{type:'string'}, code:{type:'string'}, destination:{type:'string'}, status:{type:'string'}, enabled:{type:'string'}, expires:{type:'string'}, 'if-version':{type:'string'}, limit:{type:'string'}, after:{type:'string'}, 'token-file':{type:'string'}, 'auth-file':{type:'string'}, input:{type:'string'}, 'page-size':{type:'string'},
59
- out:{type:'string'}, 'dry-run':{type:'boolean'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, help:{ type:'boolean', short:'h' },
98
+ release:{type:'string'}, 'git-commit':{type:'string'}, 'timeout-ms':{type:'string'}, 'fail-on':{type:'string'}, 'expect-metrics':{type:'boolean'},
99
+ budget:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
60
100
  } ;
61
101
 
62
102
 
@@ -95,15 +135,62 @@ async function complianceOptions(values )
95
135
  if(!['minimal','detailed'].includes(host.requestLog))throw new ConfigError('Use --request-log minimal or detailed');
96
136
  return {profile,ignore,origin:values.origin,host,rules:operator?.rules ?? [],disable:operator?.disable ?? [],override:operator?.override ?? {}};
97
137
  }
138
+ function formatExtensions(report ) {
139
+ const lines = [`Project revision: ${report.projectSha256}`];
140
+ for (const item of report.declared) lines.push(`Declared: ${item.name} (contract ${item.version}) ${item.registered ? 'registered' : report.hostLoaded ? 'NOT registered by the host file' : 'schemas need --host-file'}`, ` mounts: ${item.mounts.join(', ') || '(none)'}`, ` policy routes: ${item.policyRoutes.join(', ') || '(none)'}`);
141
+ if (!report.declared.length) lines.push('Declared: (none)');
142
+ for (const item of report.extensions) lines.push(`Registered: ${item.name} (contract ${item.version}; targets ${item.targets.join(', ') || '(none)'}; ${item.declared ? 'declared' : 'not declared'}; revision ${item.revisionPinned ? 'pinned' : 'NOT pinned'})`,
143
+ ` mounts: ${item.mounts.join(', ') || '(none)'}`, ` policy routes: ${item.policyRoutes.join(', ') || '(none)'}`, ` credential headers: ${item.credentialHeaders.join(', ') || '(none)'}`,
144
+ ` configuration schema: ${JSON.stringify(item.schema)}`, ` policy schema: ${item.policySchema ? JSON.stringify(item.policySchema) : '(none)'}`);
145
+ lines.push(report.note);
146
+ return lines.join('\n') + '\n';
147
+ }
98
148
  const errorMessages = { ERR_PARSE_ARGS_UNKNOWN_OPTION:'Unknown option; use --help', EEXIST:'Destination or edit lock already exists', ENOENT:'Required file or directory not found', EADDRINUSE:'Port is already in use', EACCES:'Permission denied' };
149
+ let operatorHost = {};
150
+ let serving = false;
99
151
  try {
100
152
  const { values, positionals } = parseArgs({ allowPositionals:true, options });
101
153
  const [command, arg, ...extra] = positionals;
102
154
  values.port ??= command==='links' && arg==='api' ? '3001' : '3000';
103
155
  if (values.help || !command) print(usage);
104
156
  else {
105
- if (extra.length || (!['init','add','links'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
106
- if(command==='links'){await runLinkCommand(arg,values,print);}else{
157
+ if (values['host-file'] !== undefined) {
158
+ if (!['serve','dev','validate','test','routes','audit','benchmark','explain','context','extensions','mcp'].includes(command)) throw new ConfigError('--host-file is only supported by serve/dev/validate/test/routes/audit/benchmark/explain/context/extensions/mcp');
159
+ // The MCP server and context command load and release the host themselves.
160
+ if (command !== 'mcp' && command !== 'context') operatorHost = await loadOperatorHost(values['host-file'], values.project);
161
+ }
162
+ if (values.with !== undefined && command !== 'init') throw new ConfigError('--with is only supported by init');
163
+ if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
164
+ const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
165
+ if ((!['import','recipes','recipe','examples','example','bulk-import'].includes(command) && extra.length) || (!['init','add','links','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
166
+
167
+ if(command==='import'||command==='export'){
168
+ const { runInterchange } = await import('./interchange-cli.js');
169
+ const converted = await runInterchange(command,positionals.slice(1),{project:values.project,target:values.target,format:values.format,out:values.out,report:values.report,dryRun:values['dry-run'],acceptProviderDifferences:values['accept-provider-differences']});
170
+ print(converted.text); if(!converted.report.ok)process.exitCode=1;
171
+ }else if(['recipes','recipe','examples','example','build-typescript','bulk-import','verify-provider','mcp'].includes(command)){
172
+ const {runEcosystemCommand}=await import('./ecosystem-cli.js');
173
+ await runEcosystemCommand(command,positionals.slice(1),values,print);
174
+ }else if(command==='explain'||command==='manifest'){
175
+ const {runExplainCommand}=await import('./explain-cli.js');
176
+ const exitCode=await runExplainCommand(command,arg,{project:values.project,target:values.target,origin:values.origin,json:values.json,extensions:operatorHost.extensions},print);
177
+ if(exitCode)process.exitCode=exitCode;
178
+ }else if(command==='capabilities'){
179
+ if(arg!==undefined){ if(values.target!==undefined)throw new ConfigError('--target applies to the full catalog, not one entry'); const entry=getCapability(arg); print(values.json ? entry : formatCapability(entry)); }
180
+ else { const catalog = getCapabilities(values.target); print(values.json ? catalog : formatCapabilities(catalog)); }
181
+ }else if(command==='schema'){
182
+ if(arg===undefined)throw new ConfigError('Use urlcode schema <path>');
183
+ const fragment=getSchemaFragment(arg);
184
+ print(values.yaml ? stringifyYaml(fragment.schema) : JSON.stringify(fragment.schema,null,2)+'\n');
185
+ }else if(command==='context'){
186
+ if (values.budget !== undefined && !/^\d{1,9}$/.test(values.budget)) throw new ConfigError('Invalid --budget');
187
+ const { buildContext, renderContext, estimateTokens, documentationTokens } = await import('./context.js');
188
+ const context = await buildContext(values.project, { target:values.target, hostFile:values['host-file'], ...(values.budget === undefined ? {} : { budget:Number(values.budget) }) });
189
+ const text = values.json ? JSON.stringify(context) + '\n' : renderContext(context);
190
+ print(text);
191
+ // Estimates only (characters / 4); a tokenizer is not a dependency. Stats go to stderr so stdout stays parseable.
192
+ if (values.stats) process.stderr.write(JSON.stringify({ event:'stats', estimate:'characters/4', documentationTokens:await documentationTokens(), contextTokens:estimateTokens(text) }) + '\n');
193
+ }else if(command==='links'){await runLinkCommand(arg,values,print);}else{
107
194
  const permissions = await loadOperatorPolicy(values.policy,values.project);
108
195
  const linkStore=parseLinkBinding(values['link-store'],linkPoolOptions(values));
109
196
  switch (command) {
@@ -117,12 +204,22 @@ try {
117
204
  const expected=number('expect-routes');
118
205
  if(expected!==undefined && !Number.isSafeInteger(expected))throw new ConfigError('Expected route count must be an integer');
119
206
  const compliance=command==='audit'?await complianceOptions(values):undefined;
207
+ const format=values.format ?? 'json';
208
+ if(!['json','markdown'].includes(format))throw new ConfigError('Use --format json or markdown');
209
+ if(values.format!==undefined && values.compare===undefined)throw new ConfigError('--format applies to routes --compare');
120
210
  const started=performance.now();
121
- const app=await startServer({project:values.project,port:0,local:true,permissions,linkStore,origin:values.origin,log:()=>{}});
211
+ const app=await startServer({...hostOptions,project:values.project,port:0,local:true,permissions,linkStore,origin:values.origin,log:()=>{}});
122
212
  const startupMs=performance.now()-started;
123
213
  try {
124
214
  if(command==='routes') {
125
- const plan=app.testPlan(); print({routes:plan.inventory.length,dynamicLinks:plan.dynamicLinks,inventory:plan.inventory,policies:plan.policies});
215
+ const plan=app.testPlan();
216
+ if(values.compare===undefined) print({routes:plan.inventory.length,dynamicLinks:plan.dynamicLinks,inventory:plan.inventory,policies:plan.policies});
217
+ else {
218
+ // The diff reports; it never judges, so the exit code stays 0.
219
+ const before=parseRouteSnapshot(JSON.parse(await readFile(values.compare,'utf8'))); // file boundary: an earlier `routes` report
220
+ const diff=diffRoutes(before,{inventory:plan.inventory,policies:plan.policies});
221
+ print(format==='markdown'?renderRouteDiff(diff):diff);
222
+ }
126
223
  } else if(command==='audit') {
127
224
  const report=await auditProject(app,{expectRoutes:expected,log:print,compliance});print(report);if(!report.ready)process.exitCode=1;
128
225
  if(report.compliance && !report.compliance.pass && !values['compliance-warn'])process.exitCode=1;
@@ -134,6 +231,34 @@ try {
134
231
  } finally {await app.close();}
135
232
  break;
136
233
  }
234
+ case 'verify-deployment': {
235
+ if (!values.target) throw new ConfigError('Provide --target https://host');
236
+ const integer = (key ) => {
237
+ const value = values[key];
238
+ if (value === undefined) return undefined;
239
+ if (!/^\d{1,9}$/.test(value)) throw new ConfigError(`Invalid --${key}`);
240
+ return Number(value);
241
+ };
242
+ const failOn = values['fail-on'] ?? 'high';
243
+ const isFailOn = (value ) => (failLevels ).includes(value);
244
+ if (!isFailOn(failOn)) throw new ConfigError(`Use --fail-on ${failLevels.join('|')}`);
245
+ const report = await verifyDeployment(values.project, { target: values.target, origin: values.origin, expectRoutes: integer('expect-routes'), timeoutMs: integer('timeout-ms'),
246
+ expectMetrics: values['expect-metrics'], failOn, compliance: await complianceOptions(values), complianceWarn: values['compliance-warn'], permissions, linkStore, log: print });
247
+ print(report); if (!report.pass) process.exitCode = 1; break;
248
+ }
249
+ case 'verify-provider': {
250
+ const target=values.target;
251
+ if(target!=='self-hosted'&&target!=='aws'&&target!=='vercel'&&target!=='cloudflare')throw new ConfigError('Provide --target self-hosted|aws|vercel|cloudflare');
252
+ if(!values.origin)throw new ConfigError('Provide --origin https://owned-fixture.example');
253
+ if(values['timeout-ms']!==undefined&&!/^\d{1,5}$/.test(values['timeout-ms']))throw new ConfigError('Invalid --timeout-ms');
254
+ const {verifyProviderDeployment}=await import('./provider-verification.js');
255
+ const report=await verifyProviderDeployment(target,values.origin,{
256
+ ...(values['timeout-ms']===undefined?{}:{timeoutMs:Number(values['timeout-ms'])}),
257
+ ...(values.release===undefined?{}:{release:values.release}),
258
+ ...(values['git-commit']===undefined?{}:{gitCommit:values['git-commit']}),
259
+ });
260
+ print(report);if(!report.pass)process.exitCode=1;break;
261
+ }
137
262
  case 'build': {
138
263
  if (values.target !== 'cloudflare') throw new ConfigError('Use --target cloudflare');
139
264
  const { buildCloudflare } = await import('./build-cloudflare.js');
@@ -141,36 +266,46 @@ try {
141
266
  }
142
267
  case 'scaffold':
143
268
  print(await scaffoldProject(values.project,{dryRun:values['dry-run']}));break;
269
+ case 'extensions': {
270
+ const report = await describeExtensions(values.project, values['host-file'] === undefined ? undefined : operatorHost.extensions ?? []);
271
+ print(values.json ? report : formatExtensions(report)); break;
272
+ }
144
273
  case 'permissions': {
145
274
  const loaded = await loadDocument(values.project);
146
275
  print(requestedPermissions(loaded,await prepareFunctionSnapshot(loaded))); break;
147
276
  }
148
277
  case 'init':
149
278
  if (!arg) throw new ConfigError('Provide a new project directory');
150
- await initProject(arg); print({ event:'created' }); break;
279
+ if (values.with === undefined) { await initProject(arg); print({ event:'created' }); break; }
280
+ {
281
+ const created = await initProjectWith(arg, parseWithNames(values.with));
282
+ print({ event:'created', ...created, review:`Review ${created.project}/urlcode.yaml and pin its revision explicitly (for example PROJECT_SHA256=${created.projectSha256}); re-review after any project change` });
283
+ }
284
+ break;
151
285
  case 'validate': {
152
- const runtime = await createRuntime(values.project, { local:values.local, permissions, linkStore, origin:values.origin });
286
+ const runtime = await createRuntime(values.project, { ...hostOptions, local:values.local, permissions, linkStore, origin:values.origin });
153
287
  print({ event:'valid', dynamicLinks:runtime.testPlan().dynamicLinks, routes:runtime.count, version:runtime.version }); await runtime.close(); break;
154
288
  }
155
289
  case 'add':
156
290
  if (!arg) throw new ConfigError('Provide an HTTP(S) destination URL');
157
291
  print({ event:'added', path:await addRedirect(values.project,arg,values.alias) }); break;
158
292
  case 'test': {
159
- const result = await runProjectTests(values.project, { log:print, permissions, linkStore, origin:values.origin });
293
+ const result = await runProjectTests(values.project, { ...hostOptions, log:print, permissions, linkStore, origin:values.origin });
160
294
  print(result); if (result.failed) process.exitCode = 1; break;
161
295
  }
162
296
  case 'doctor':
163
- print({ node:process.version, sqlite:process.versions.sqlite, liveLinks:supportsConcurrentWal(process.versions.sqlite), platform:process.platform, architecture:process.arch, runtime:'node-process', functionSandbox:'quickjs-wasm', network:false, filesystem:false, providers:[], policies:Object.keys(policyRegistry), license:'Apache-2.0' }); break;
297
+ print({ node:process.version, sqlite:process.versions.sqlite, liveLinks:supportsConcurrentWal(process.versions.sqlite), platform:process.platform, architecture:process.arch, runtime:'node-process', functionSandbox:'quickjs-wasm', network:false, filesystem:false, guestNetwork:false, hostEgress:'revision-pinned-origin-grants', tooling:['recipes','examples','bulk-import','build-typescript','mcp','verify-provider'], providers:[], capabilityTargets:getCapabilities().targets, policies:Object.keys(policyRegistry), license:'Apache-2.0' }); break;
164
298
  case 'dev': case 'serve': {
165
299
  const port = Number(values.port);
166
300
  if (!/^\d+$/.test(values.port) || !Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError('Invalid port');
167
301
  if (command === 'serve' && values.local) throw new ConfigError('serve never reads local dotenv files');
168
- const app = await startServer({ project:values.project, host:values.host, port,
302
+ const app = await startServer({ ...hostOptions, project:values.project, host:values.host, port,
169
303
  local:command === 'dev', watch:command === 'dev', origin:values.origin, permissions, linkStore,
170
304
  ...serverCapacity(values) });
171
305
  print({ event:'listening', address:app.address.address, port:app.address.port, mode:command, origin:app.origin });
306
+ serving = true;
172
307
  let stopping = false;
173
- const stop = async () => { if (stopping) return; stopping = true; await app.close(); };
308
+ const stop = async () => { if (stopping) return; stopping = true; try { await app.close(); } finally { await operatorHost.close?.(); } };
174
309
  process.once('SIGINT',stop); process.once('SIGTERM',stop);
175
310
  break;
176
311
  }
@@ -182,4 +317,8 @@ try {
182
317
  const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
183
318
  const message = (error instanceof ConfigError || error instanceof HttpError) ? error.message : ((code !== undefined ? errorMessages[code] : undefined) || 'Operation failed; check project files, module dependencies and command options');
184
319
  process.stderr.write(JSON.stringify({ event:'error', message }) + '\n'); process.exitCode = 1;
320
+ } finally {
321
+ if (!serving) {
322
+ try { await operatorHost.close?.(); } catch { process.stderr.write('Operator host cleanup failed\n'); process.exitCode = 1; }
323
+ }
185
324
  }
@@ -57,7 +57,7 @@ function within(address , { bytes, prefix } ) {
57
57
  return true;
58
58
  }
59
59
 
60
- export function isTrustedProxy(address , trusted ) {
60
+ function isTrustedProxy(address , trusted ) {
61
61
  return trusted.some(range => within(address, range));
62
62
  }
63
63
 
@@ -3,7 +3,7 @@ import { oshp, rfc9111, rfc9110, rfc6585, rfc9309, breach, management, active, f
3
3
 
4
4
  export const profile = 'baseline';
5
5
 
6
- export const securityHeaders = {
6
+ const securityHeaders = {
7
7
  id: 'oshp/security-headers', title: 'Every active route carries a security headers profile', standard: oshp, severity: 'medium', appliesTo: 'route',
8
8
  check({ route, effective }) {
9
9
  if (!active(route) || effective.security) return [];
@@ -11,7 +11,7 @@ export const securityHeaders = {
11
11
  },
12
12
  };
13
13
 
14
- export const hstsOrigin = {
14
+ const hstsOrigin = {
15
15
  id: 'oshp/hsts-origin', title: 'HSTS is only reachable on an https origin', standard: { ...oshp, section: 'Strict-Transport-Security' }, severity: 'low', appliesTo: 'project',
16
16
  check({ plan, policies, origin }) {
17
17
  const emitting = plan.inventory.filter(route => active(route) && emittedSecurityHeaders(policies[route.path], plan.policies?.[route.path]).has('strict-transport-security'));
@@ -23,7 +23,7 @@ export const hstsOrigin = {
23
23
  },
24
24
  };
25
25
 
26
- export const secretsCompression = {
26
+ const secretsCompression = {
27
27
  id: 'breach/secrets-compression', title: 'Routes bound to secrets are never compressed', standard: breach, severity: 'high', appliesTo: 'route',
28
28
  check({ route, config, effective }) {
29
29
  if (!hasSecrets(config) || effective.compression?.allowWithSecrets !== true) return [];
@@ -31,7 +31,7 @@ export const secretsCompression = {
31
31
  },
32
32
  };
33
33
 
34
- export const secretsNoStore = {
34
+ const secretsNoStore = {
35
35
  id: 'rfc9111/secrets-no-store', title: 'Routes bound to secrets declare no-store or private caching', standard: { ...rfc9111, section: '5.2.2.5 no-store, 5.2.2.7 private' }, severity: 'medium', appliesTo: 'route',
36
36
  check({ route, config, effective }) {
37
37
  if (!hasSecrets(config)) return [];
@@ -47,7 +47,7 @@ export const secretsNoStore = {
47
47
  },
48
48
  };
49
49
 
50
- export const cacheControlDeclared = {
50
+ const cacheControlDeclared = {
51
51
  id: 'rfc9111/cache-control-declared', title: 'Every declared response states its Cache-Control', standard: rfc9111, severity: 'low', appliesTo: 'route',
52
52
  check({ route, config, effective }) {
53
53
  if (!active(route) || effective.cache) return [];
@@ -60,7 +60,7 @@ export const cacheControlDeclared = {
60
60
  },
61
61
  };
62
62
 
63
- export const throttleFunctions = {
63
+ const throttleFunctions = {
64
64
  id: 'rfc6585/throttle-functions', title: 'Function and middleware routes declare a request budget', standard: rfc6585, severity: 'medium', appliesTo: 'route',
65
65
  check({ route, effective }) {
66
66
  if (!active(route) || !functionLike(route) || effective.throttle) return [];
@@ -78,7 +78,7 @@ export const robots = {
78
78
  },
79
79
  };
80
80
 
81
- export const expiredRoutes = {
81
+ const expiredRoutes = {
82
82
  id: 'rfc9110/expired-routes', title: 'Expired routes still present in YAML', standard: { ...rfc9110, section: '15.5.11 410 Gone' }, severity: 'info', appliesTo: 'project',
83
83
  check({ plan }) {
84
84
  const expired = plan.inventory.filter(route => route.state === 'expired').map(route => route.path);
@@ -87,7 +87,7 @@ export const expiredRoutes = {
87
87
  },
88
88
  };
89
89
 
90
- export const managementPrivate = {
90
+ const managementPrivate = {
91
91
  id: 'ops/management-private', title: 'Management and probe listeners stay private', standard: management, severity: 'info', appliesTo: 'project',
92
92
  check({ document }) {
93
93
  if (document.dynamicLinks !== true) return [];
@@ -8,7 +8,7 @@ export const profile = 'privacy';
8
8
  // `host.includeCode`), because the runtime's log guarantees are settings of
9
9
  // the serving process, not of the project. An undeclared setting is reported
10
10
  // as unknown rather than assumed.
11
- export const requestLogMinimal = {
11
+ const requestLogMinimal = {
12
12
  id: 'privacy/request-log-minimal', title: 'Request log records stay minimal', standard: monitoring, severity: 'medium', appliesTo: 'project',
13
13
  check({ host }) {
14
14
  if (host.requestLog === 'minimal') return [];
@@ -17,7 +17,7 @@ export const requestLogMinimal = {
17
17
  },
18
18
  };
19
19
 
20
- export const linkEventsOff = {
20
+ const linkEventsOff = {
21
21
  id: 'privacy/link-events-off', title: 'Link events stay off unless declared', standard: linkChannel, severity: 'medium', appliesTo: 'project',
22
22
  check({ document, host }) {
23
23
  if (document.dynamicLinks !== true || host.linkEvents === false) return [];
@@ -27,7 +27,7 @@ export const linkEventsOff = {
27
27
  },
28
28
  };
29
29
 
30
- export const detailedParameters = {
30
+ const detailedParameters = {
31
31
  id: 'privacy/detailed-log-parameters', title: 'No detailed logging on parameterised routes', standard: monitoring, severity: 'low', appliesTo: 'route',
32
32
  check({ route, config, host }) {
33
33
  if (host.requestLog !== 'detailed' || !active(route) || !((config.parameters?.length ?? 0) > 0)) return [];
@@ -6,7 +6,7 @@ export const profile = 'strict';
6
6
 
7
7
  // The runtime caps a response at 16 KiB of headers; the security policy
8
8
  // bounds its own static headers at half of that so the handler keeps room.
9
- export const headerBudgetBytes = 8192;
9
+ const headerBudgetBytes = 8192;
10
10
 
11
11
  export const csp = {
12
12
  id: 'oshp/csp', title: 'Every active route emits a Content-Security-Policy', standard: { ...oshp, section: 'Content-Security-Policy' }, severity: 'medium', appliesTo: 'route',
@@ -16,7 +16,7 @@ export const csp = {
16
16
  },
17
17
  };
18
18
 
19
- export const throttleAll = {
19
+ const throttleAll = {
20
20
  id: 'rfc6585/throttle-all', title: 'Every active route declares a request budget', standard: rfc6585, severity: 'medium', appliesTo: 'route',
21
21
  check({ route, effective }) {
22
22
  if (!active(route) || functionLike(route) || effective.throttle) return [];
@@ -24,7 +24,7 @@ export const throttleAll = {
24
24
  },
25
25
  };
26
26
 
27
- export const listsPinned = {
27
+ const listsPinned = {
28
28
  id: 'agents/lists-pinned', title: 'Agent deny and allow lists are pinned to a revision', standard: agentLists, severity: 'low', appliesTo: 'route',
29
29
  check({ route, effective, policy }) {
30
30
  if (!active(route) || !effective.agents) return [];
@@ -39,7 +39,7 @@ export const listsPinned = {
39
39
  },
40
40
  };
41
41
 
42
- export const redirectHttps = {
42
+ const redirectHttps = {
43
43
  id: 'rfc9110/redirect-https', title: 'Redirect targets are https', standard: { ...rfc9110, section: '15.4 Redirection 3xx' }, severity: 'medium', appliesTo: 'route',
44
44
  check({ route, config }) {
45
45
  const url = config.redirect?.url;
@@ -48,7 +48,7 @@ export const redirectHttps = {
48
48
  },
49
49
  };
50
50
 
51
- export const headerBudget = {
51
+ const headerBudget = {
52
52
  id: 'http/header-budget', title: 'Declared response headers stay under the static budget', standard: { ...rfc9110, section: '5.4 Field Limits' }, severity: 'low', appliesTo: 'route',
53
53
  check({ route, config, effective, policy }) {
54
54
  if (!active(route)) return [];
@@ -0,0 +1,88 @@
1
+ import { assert, HttpError } from './errors.js';
2
+
3
+ /** Bounded conjunction of exact string comparisons; no regex or implicit detection. */
4
+
5
+
6
+
7
+
8
+
9
+ const token = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
10
+ export function normalizeMatch(match ) {
11
+ assert(match && typeof match === 'object' && !Array.isArray(match), 'match must be an object');
12
+ assert(Object.keys(match).length > 0 && Object.keys(match).every(key => ['query','headers','cookies','host','method'].includes(key)), 'Unsupported or empty match');
13
+ const normalized = {};
14
+ for (const group of ['query','headers','cookies'] ) if (match[group] !== undefined) {
15
+ assert(match[group] !== null && typeof match[group] === 'object' && !Array.isArray(match[group]), `match.${group} must be an object`);
16
+ const fields = Object.entries(match[group]);
17
+ assert(fields.length > 0 && fields.length <= 16, `match.${group} requires 1–16 entries`);
18
+ const values = Object.create(null) ;
19
+ for (const [name,value] of fields) {
20
+ const key = group === 'headers' ? name.toLowerCase() : name;
21
+ assert(token.test(key) && key.length <= 128 && !Object.hasOwn(values,key), `Invalid or duplicate match.${group} name`);
22
+ assert(typeof value === 'string' && value.length <= 1024 && !/[\u0000-\u001f\u007f]/.test(value), `Invalid match.${group} value`);
23
+ if (group === 'headers') assert(!['host','cookie','authorization','proxy-authorization','connection','transfer-encoding','content-length','keep-alive','proxy-authenticate','te','trailer','upgrade'].includes(key), 'Use match.host/cookies; credential and transport headers cannot be conditions');
24
+ if (group === 'cookies') assert(!/[;,\s"\\]/.test(value), 'Cookie conditions use unquoted wire values');
25
+ values[key] = value;
26
+ }
27
+ normalized[group] = values;
28
+ }
29
+ if (match.method !== undefined) {
30
+ assert(['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(match.method), 'Invalid match.method');
31
+ normalized.method = match.method;
32
+ }
33
+ if (match.host !== undefined) {
34
+ assert(typeof match.host === 'string' && match.host.length <= 255 && /^[a-z0-9.-]+(?::[0-9]{1,5})?$/i.test(match.host), 'match.host must be a literal host with optional port');
35
+ const url = new URL('https://' + match.host);
36
+ assert(url.host === match.host.toLowerCase(), 'match.host must be a canonical authority');
37
+ normalized.host = url.host;
38
+ }
39
+ assert(Object.keys(normalized).length > 0, 'Empty match');
40
+ return normalized;
41
+ }
42
+ function equalities(match ) {
43
+ const values = new Map ();
44
+ for (const group of ['query','headers','cookies'] ) for (const [name,value] of Object.entries(match[group] ?? {})) values.set(`${group}:${name}`,value);
45
+ if (match.host) values.set('host',match.host);
46
+ if (match.method) values.set('method',match.method);
47
+ return values;
48
+ }
49
+ /** Cases must be disjoint: at least one shared fact must demand different values. */
50
+ export function assertDisjointMatches(matches ) {
51
+ assert(Array.isArray(matches) && matches.length >= 1 && matches.length <= 16, 'Conditional routes require 1–16 cases');
52
+ const facts = matches.map(normalizeMatch).map(equalities);
53
+ for (let i = 0; i < facts.length; i++) for (let j = i + 1; j < facts.length; j++) {
54
+ assert([...facts[i] ].some(([key,value]) => facts[j] .has(key) && facts[j] .get(key) !== value), `Conditional cases ${i + 1} and ${j + 1} overlap`);
55
+ }
56
+ }
57
+ export function matchesRoute(match , request ) {
58
+ if (match.method && match.method !== request.method) return false;
59
+ // The operator's origin is authoritative, never forwarded/client Host text.
60
+ if (match.host && new URL(request.origin).host !== match.host) return false;
61
+ for (const [name,value] of Object.entries(match.query ?? {})) {
62
+ const actual = request.query.getAll(name);
63
+ if (actual.length > 1) throw new HttpError(400,'Duplicate condition query input');
64
+ if (actual.length !== 1 || actual[0] !== value) return false;
65
+ }
66
+ for (const [name,value] of Object.entries(match.headers ?? {})) {
67
+ if ((request.headerCounts?.[name] ?? 0) > 1) throw new HttpError(400,'Duplicate condition header input');
68
+ if (request.headers.get(name) !== value) return false;
69
+ }
70
+ if (match.cookies) {
71
+ const raw = request.headers.get('cookie') ?? '';
72
+ if (raw.length > 8192) throw new HttpError(400,'Cookie conditions exceed input limit');
73
+ const values = new Map ();
74
+ for (const part of raw.split(';')) {
75
+ const index = part.indexOf('=');
76
+ if (index < 0) continue;
77
+ const name = part.slice(0,index).trim(), value = part.slice(index + 1).trim();
78
+ if (!Object.hasOwn(match.cookies,name)) continue;
79
+ const entries = values.get(name) ?? []; entries.push(value); values.set(name,entries);
80
+ }
81
+ for (const [name,value] of Object.entries(match.cookies)) {
82
+ const actual = values.get(name) ?? [];
83
+ if (actual.length > 1) throw new HttpError(400,'Duplicate condition cookie input');
84
+ if (actual.length !== 1 || actual[0] !== value) return false;
85
+ }
86
+ }
87
+ return true;
88
+ }