@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.2

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 (380) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +122 -0
  2. package/.claude/skills/urlcode-operations/SKILL.md +108 -0
  3. package/.claude-plugin/marketplace.json +18 -0
  4. package/CONTRIBUTING.md +30 -2
  5. package/README.md +195 -255
  6. package/ROADMAP.md +143 -15
  7. package/SECURITY.md +31 -9
  8. package/dist/BUILD-MANIFEST.json +72 -47
  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/build-static.js +134 -0
  17. package/dist/bulk.js +37 -0
  18. package/dist/capabilities.js +262 -0
  19. package/dist/capability-query.js +71 -0
  20. package/dist/catalog.js +105 -0
  21. package/dist/cli.js +165 -34
  22. package/dist/client-address.js +1 -1
  23. package/dist/compliance-rules/baseline.js +9 -17
  24. package/dist/compliance-rules/privacy.js +7 -18
  25. package/dist/compliance-rules/shared.js +0 -2
  26. package/dist/compliance-rules/strict.js +5 -5
  27. package/dist/compliance.js +6 -8
  28. package/dist/conditions.js +88 -0
  29. package/dist/config.js +69 -6
  30. package/dist/context.js +155 -0
  31. package/dist/ecosystem-cli.js +88 -0
  32. package/dist/egress.js +98 -0
  33. package/dist/examples.js +92 -0
  34. package/dist/explain-cli.js +64 -0
  35. package/dist/explain.js +131 -0
  36. package/dist/extensions.js +231 -0
  37. package/dist/function-sources.js +49 -5
  38. package/dist/function-worker.js +3 -1
  39. package/dist/functions.js +84 -13
  40. package/dist/guest-api.js +29 -3
  41. package/dist/index.js +40 -6
  42. package/dist/init-with.js +165 -0
  43. package/dist/interchange-cli.js +42 -0
  44. package/dist/interchange.js +189 -0
  45. package/dist/manifest.js +109 -0
  46. package/dist/match.js +2 -2
  47. package/dist/mcp-authoring.js +147 -0
  48. package/dist/mcp.js +97 -0
  49. package/dist/observability.js +7 -21
  50. package/dist/operator-host.js +29 -0
  51. package/dist/plugins.js +12 -0
  52. package/dist/policies/agents.js +2 -2
  53. package/dist/policies/cache.js +8 -3
  54. package/dist/policies/compression.js +2 -1
  55. package/dist/policies/security.js +0 -0
  56. package/dist/policies.js +1 -1
  57. package/dist/policy.js +56 -15
  58. package/dist/prerender.js +100 -41
  59. package/dist/project-tests.js +3 -3
  60. package/dist/provider-verification.js +92 -0
  61. package/dist/proxy.js +44 -0
  62. package/dist/readiness.js +34 -11
  63. package/dist/recipes.js +41 -0
  64. package/dist/route-diff.js +106 -0
  65. package/dist/router.js +45 -7
  66. package/dist/runtime.js +164 -64
  67. package/dist/sandbox.js +48 -0
  68. package/dist/scaffold.js +0 -0
  69. package/dist/schema-query.js +62 -0
  70. package/dist/scripts/operational-drills.js +12 -54
  71. package/dist/server.js +3 -29
  72. package/dist/signals.js +24 -0
  73. package/dist/site.js +0 -0
  74. package/dist/tooling.js +96 -0
  75. package/dist/trusted-functions.js +210 -0
  76. package/dist/types/adapters.d.ts +7 -4
  77. package/dist/types/agent-lists.d.ts +0 -1
  78. package/dist/types/agents-guide.d.ts +17 -0
  79. package/dist/types/authoring-files.d.ts +10 -0
  80. package/dist/types/aws.d.ts +3 -1
  81. package/dist/types/build-cloudflare.d.ts +1 -0
  82. package/dist/types/build-static.d.ts +43 -0
  83. package/dist/types/bulk.d.ts +27 -0
  84. package/dist/types/capabilities.d.ts +64 -0
  85. package/dist/types/capability-query.d.ts +24 -0
  86. package/dist/types/catalog.d.ts +65 -0
  87. package/dist/types/client-address.d.ts +0 -1
  88. package/dist/types/compliance-rules/baseline.d.ts +1 -9
  89. package/dist/types/compliance-rules/privacy.d.ts +1 -4
  90. package/dist/types/compliance-rules/shared.d.ts +0 -2
  91. package/dist/types/compliance-rules/strict.d.ts +0 -5
  92. package/dist/types/compliance.d.ts +0 -3
  93. package/dist/types/conditions.d.ts +19 -0
  94. package/dist/types/config.d.ts +21 -2
  95. package/dist/types/context.d.ts +66 -0
  96. package/dist/types/ecosystem-cli.d.ts +17 -0
  97. package/dist/types/egress.d.ts +46 -0
  98. package/dist/types/examples.d.ts +50 -0
  99. package/dist/types/explain-cli.d.ts +11 -0
  100. package/dist/types/explain.d.ts +95 -0
  101. package/dist/types/extensions.d.ts +177 -0
  102. package/dist/types/function-sources.d.ts +9 -0
  103. package/dist/types/functions.d.ts +48 -5
  104. package/dist/types/guest-api.d.ts +1 -0
  105. package/dist/types/index.d.ts +36 -6
  106. package/dist/types/init-with.d.ts +30 -0
  107. package/dist/types/interchange-cli.d.ts +16 -0
  108. package/dist/types/interchange.d.ts +42 -0
  109. package/dist/types/manifest.d.ts +79 -0
  110. package/dist/types/match.d.ts +1 -0
  111. package/dist/types/mcp-authoring.d.ts +92 -0
  112. package/dist/types/mcp.d.ts +12 -0
  113. package/dist/types/observability.d.ts +3 -14
  114. package/dist/types/operator-host.d.ts +8 -0
  115. package/dist/types/plugins.d.ts +2 -0
  116. package/dist/types/policies/agents.d.ts +0 -2
  117. package/dist/types/policies/compression.d.ts +2 -0
  118. package/dist/types/policies/security.d.ts +0 -1
  119. package/dist/types/policy.d.ts +15 -4
  120. package/dist/types/project-tests.d.ts +3 -2
  121. package/dist/types/provider-verification.d.ts +53 -0
  122. package/dist/types/proxy.d.ts +21 -0
  123. package/dist/types/readiness.d.ts +10 -3
  124. package/dist/types/recipes.d.ts +30 -0
  125. package/dist/types/route-diff.d.ts +27 -0
  126. package/dist/types/router.d.ts +2 -1
  127. package/dist/types/runtime.d.ts +11 -27
  128. package/dist/types/sandbox.d.ts +12 -0
  129. package/dist/types/scaffold.d.ts +0 -2
  130. package/dist/types/schema-query.d.ts +12 -0
  131. package/dist/types/server.d.ts +1 -4
  132. package/dist/types/signals.d.ts +25 -0
  133. package/dist/types/site.d.ts +0 -1
  134. package/dist/types/tooling.d.ts +115 -0
  135. package/dist/types/trusted-functions.d.ts +29 -0
  136. package/dist/types/types.d.ts +71 -7
  137. package/dist/types/typescript-authoring.d.ts +12 -0
  138. package/dist/types/vercel.d.ts +3 -1
  139. package/dist/types/verify-deployment.d.ts +47 -0
  140. package/dist/types.js +37 -5
  141. package/dist/typescript-authoring.js +142 -0
  142. package/dist/vercel.js +4 -3
  143. package/dist/verify-deployment.js +270 -0
  144. package/docs/AI-AUTHORING.md +232 -15
  145. package/docs/AWS.md +4 -4
  146. package/docs/BEST-PRACTICES.md +3 -2
  147. package/docs/BULK.md +79 -0
  148. package/docs/CAPABILITIES.md +192 -0
  149. package/docs/CAPACITY.md +129 -32
  150. package/docs/CI.md +142 -0
  151. package/docs/CLOUDFLARE.md +1 -2
  152. package/docs/COMPLIANCE.md +6 -9
  153. package/docs/CONDITIONS.md +74 -0
  154. package/docs/DEPLOYMENT-CHECKS.md +108 -0
  155. package/docs/EGRESS.md +125 -0
  156. package/docs/EXTENSIONS.md +398 -0
  157. package/docs/FRAMEWORK.md +198 -0
  158. package/docs/FUNCTION-SECURITY.md +129 -32
  159. package/docs/INSTALL.md +45 -12
  160. package/docs/INTERCHANGE.md +134 -0
  161. package/docs/LOAD-TESTING.md +4 -4
  162. package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
  163. package/docs/MIDDLEWARE.md +29 -16
  164. package/docs/MONITORING.md +2 -19
  165. package/docs/NEXT-PHASE-PLAN.md +98 -0
  166. package/docs/NEXT-STEPS.md +634 -0
  167. package/docs/OBSERVABILITY.md +11 -18
  168. package/docs/OPEN-DECISIONS.md +212 -0
  169. package/docs/OPERATIONAL-PROOF.md +30 -31
  170. package/docs/OPERATIONS.md +29 -35
  171. package/docs/PLUGINS.md +37 -0
  172. package/docs/POLICIES.md +23 -309
  173. package/docs/PRERENDER.md +41 -1
  174. package/docs/PROJECT-DIRECTION.md +75 -8
  175. package/docs/PROVIDER-VERIFICATION.md +84 -0
  176. package/docs/READINESS.md +21 -1
  177. package/docs/README.md +87 -34
  178. package/docs/RECIPES.md +99 -0
  179. package/docs/RELEASE-READINESS.md +57 -35
  180. package/docs/RELEASE-SECURITY.md +116 -7
  181. package/docs/RESILIENCE.md +16 -15
  182. package/docs/ROUTING.md +8 -10
  183. package/docs/SANDBOX-REVIEW.md +19 -6
  184. package/docs/SCAFFOLDING.md +0 -2
  185. package/docs/SECURITY-AUDIT.md +41 -1
  186. package/docs/SPECIFICATION.md +150 -29
  187. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +287 -0
  188. package/docs/SPIKE-BUSINESS-SUITE.md +1021 -0
  189. package/docs/SPIKE-CORE-LAYERING.md +337 -0
  190. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +209 -0
  191. package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
  192. package/docs/SPIKE-EXTENSIONS.md +6 -0
  193. package/docs/SPIKE-LAMBDA-COMPILE.md +201 -0
  194. package/docs/SPIKE-MONOREPO.md +322 -0
  195. package/docs/STANDARDS.md +150 -142
  196. package/docs/STARTERS.md +21 -1
  197. package/docs/STATIC.md +94 -0
  198. package/docs/TOOLING.md +295 -0
  199. package/docs/TUNNELS.md +0 -3
  200. package/docs/TYPESCRIPT-AUTHORING.md +82 -0
  201. package/docs/TYPESCRIPT.md +25 -4
  202. package/docs/USABILITY-REVIEW.md +129 -0
  203. package/docs/VERCEL.md +4 -5
  204. package/docs/VERSION-ALIGNMENT.md +205 -0
  205. package/docs/YAML-GUIDE.md +15 -479
  206. package/docs/YAML-REFERENCE.md +143 -22
  207. package/docs/policies/agents.md +1 -1
  208. package/docs/policies/cache.md +13 -0
  209. package/docs/policies/contract.md +52 -0
  210. package/docs/policies/hardened.md +56 -0
  211. package/docs/policies/interoperability.md +169 -0
  212. package/docs/policies/operations.md +45 -0
  213. package/docs/yaml/assets.md +36 -0
  214. package/docs/yaml/conditions.md +20 -0
  215. package/docs/yaml/functions.md +160 -0
  216. package/docs/yaml/middleware.md +29 -0
  217. package/docs/yaml/organization.md +74 -0
  218. package/docs/yaml/policies.md +37 -0
  219. package/docs/yaml/redirects.md +64 -0
  220. package/docs/yaml/responses.md +57 -0
  221. package/docs/yaml/site.md +24 -0
  222. package/examples/assets/example.yaml +17 -0
  223. package/examples/aws/example.yaml +20 -0
  224. package/examples/cloudflare/example.yaml +19 -0
  225. package/examples/compliance/example.yaml +11 -0
  226. package/examples/conditions/README.md +12 -0
  227. package/examples/conditions/example.yaml +19 -0
  228. package/examples/conditions/tests/requests.json +13 -0
  229. package/examples/conditions/urlcode.yaml +24 -0
  230. package/examples/cookbook/README.md +8 -4
  231. package/examples/cookbook/example.yaml +17 -0
  232. package/examples/cookbook/functions/catalog.mjs +3 -0
  233. package/examples/cookbook/functions/fail.mjs +4 -0
  234. package/examples/cookbook/functions/items.mjs +3 -0
  235. package/examples/cookbook/functions/profile.mjs +3 -0
  236. package/examples/cookbook/functions/resource.mjs +3 -0
  237. package/examples/cookbook/functions/status.mjs +3 -0
  238. package/examples/cookbook/middleware/auth.mjs +48 -0
  239. package/examples/cookbook/middleware/body.mjs +15 -0
  240. package/examples/cookbook/middleware/bucket.mjs +29 -0
  241. package/examples/cookbook/middleware/cors.mjs +21 -0
  242. package/examples/cookbook/middleware/debug.mjs +13 -0
  243. package/examples/cookbook/middleware/envelope.mjs +11 -0
  244. package/examples/cookbook/middleware/errors.mjs +11 -0
  245. package/examples/cookbook/middleware/etag.mjs +18 -0
  246. package/examples/cookbook/middleware/locale.mjs +20 -0
  247. package/examples/cookbook/middleware/maintenance.mjs +10 -0
  248. package/examples/cookbook/middleware/methods.mjs +15 -0
  249. package/examples/cookbook/middleware/negotiate.mjs +20 -0
  250. package/examples/cookbook/middleware/referer.mjs +12 -0
  251. package/examples/cookbook/middleware/request-id.mjs +16 -0
  252. package/examples/cookbook/route-index.json +676 -0
  253. package/examples/cookbook/routes/middleware.yaml +126 -0
  254. package/examples/cookbook/tests/requests.json +526 -0
  255. package/examples/cookbook/urlcode.yaml +1 -0
  256. package/examples/egress/README.md +22 -0
  257. package/examples/egress/example.yaml +19 -0
  258. package/examples/egress/urlcode.yaml +19 -0
  259. package/examples/extensions/README.md +7 -0
  260. package/examples/extensions/example.yaml +21 -0
  261. package/examples/extensions/urlcode.yaml +25 -0
  262. package/examples/monitoring/example.yaml +8 -0
  263. package/examples/prerender/README.md +2 -2
  264. package/examples/prerender/example.yaml +16 -0
  265. package/examples/provider-conformance/README.md +12 -0
  266. package/examples/provider-conformance/example.yaml +14 -0
  267. package/examples/provider-conformance/urlcode.yaml +34 -0
  268. package/examples/tunnel/example.yaml +8 -0
  269. package/examples/vercel/example.yaml +19 -0
  270. package/llms-full.txt +3084 -0
  271. package/llms.txt +61 -21
  272. package/package.json +36 -7
  273. package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
  274. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +122 -0
  275. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +108 -0
  276. package/recipes/authenticated-json-api/README.md +51 -0
  277. package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
  278. package/recipes/authenticated-json-api/recipe.yaml +34 -0
  279. package/recipes/authenticated-json-api/tests/requests.json +39 -0
  280. package/recipes/authenticated-json-api/urlcode.yaml +12 -0
  281. package/recipes/contact-form/README.md +25 -0
  282. package/recipes/contact-form/functions/contact.mjs +17 -0
  283. package/recipes/contact-form/recipe.yaml +33 -0
  284. package/recipes/contact-form/tests/requests.json +47 -0
  285. package/recipes/contact-form/urlcode.yaml +18 -0
  286. package/recipes/cors-api/README.md +16 -0
  287. package/recipes/cors-api/functions/items.mjs +3 -0
  288. package/recipes/cors-api/middleware/cors.mjs +21 -0
  289. package/recipes/cors-api/recipe.yaml +26 -0
  290. package/recipes/cors-api/tests/requests.json +65 -0
  291. package/recipes/cors-api/urlcode.yaml +12 -0
  292. package/recipes/health-page/README.md +13 -0
  293. package/recipes/health-page/recipe.yaml +23 -0
  294. package/recipes/health-page/tests/requests.json +36 -0
  295. package/recipes/health-page/urlcode.yaml +19 -0
  296. package/recipes/json-api/README.md +6 -0
  297. package/recipes/json-api/functions/echo.mjs +3 -0
  298. package/recipes/json-api/recipe.yaml +25 -0
  299. package/recipes/json-api/tests/requests.json +34 -0
  300. package/recipes/json-api/urlcode.yaml +12 -0
  301. package/recipes/middleware/README.md +34 -0
  302. package/recipes/middleware/functions/catalog.mjs +3 -0
  303. package/recipes/middleware/functions/fail.mjs +4 -0
  304. package/recipes/middleware/functions/items.mjs +3 -0
  305. package/recipes/middleware/functions/profile.mjs +3 -0
  306. package/recipes/middleware/functions/resource.mjs +3 -0
  307. package/recipes/middleware/functions/status.mjs +3 -0
  308. package/recipes/middleware/middleware/auth.mjs +48 -0
  309. package/recipes/middleware/middleware/body.mjs +15 -0
  310. package/recipes/middleware/middleware/bucket.mjs +29 -0
  311. package/recipes/middleware/middleware/cors.mjs +21 -0
  312. package/recipes/middleware/middleware/debug.mjs +13 -0
  313. package/recipes/middleware/middleware/envelope.mjs +11 -0
  314. package/recipes/middleware/middleware/errors.mjs +11 -0
  315. package/recipes/middleware/middleware/etag.mjs +18 -0
  316. package/recipes/middleware/middleware/locale.mjs +20 -0
  317. package/recipes/middleware/middleware/maintenance.mjs +10 -0
  318. package/recipes/middleware/middleware/methods.mjs +15 -0
  319. package/recipes/middleware/middleware/negotiate.mjs +20 -0
  320. package/recipes/middleware/middleware/referer.mjs +12 -0
  321. package/recipes/middleware/middleware/request-id.mjs +16 -0
  322. package/recipes/middleware/public/guide.txt +1 -0
  323. package/recipes/middleware/recipe.yaml +50 -0
  324. package/recipes/middleware/tests/requests.json +528 -0
  325. package/recipes/middleware/urlcode.yaml +127 -0
  326. package/recipes/protected-download/README.md +22 -0
  327. package/recipes/protected-download/files/report.txt +1 -0
  328. package/recipes/protected-download/recipe.yaml +31 -0
  329. package/recipes/protected-download/tests/requests.json +32 -0
  330. package/recipes/protected-download/urlcode.yaml +15 -0
  331. package/recipes/redirect/README.md +7 -0
  332. package/recipes/redirect/recipe.yaml +25 -0
  333. package/recipes/redirect/tests/requests.json +19 -0
  334. package/recipes/redirect/urlcode.yaml +9 -0
  335. package/recipes/static-plus-api/README.md +15 -0
  336. package/recipes/static-plus-api/functions/info.mjs +3 -0
  337. package/recipes/static-plus-api/public/assets/index.html +3 -0
  338. package/recipes/static-plus-api/public/assets/site.css +1 -0
  339. package/recipes/static-plus-api/public/index.html +8 -0
  340. package/recipes/static-plus-api/recipe.yaml +29 -0
  341. package/recipes/static-plus-api/tests/requests.json +56 -0
  342. package/recipes/static-plus-api/urlcode.yaml +23 -0
  343. package/recipes/typescript/README.md +8 -0
  344. package/recipes/typescript/functions/hello.ts +5 -0
  345. package/recipes/typescript/recipe.yaml +23 -0
  346. package/recipes/typescript/tests/requests.json +18 -0
  347. package/recipes/typescript/urlcode.yaml +5 -0
  348. package/recipes/webhook-receiver/README.md +20 -0
  349. package/recipes/webhook-receiver/functions/receive.mjs +16 -0
  350. package/recipes/webhook-receiver/recipe.yaml +27 -0
  351. package/recipes/webhook-receiver/tests/requests.json +59 -0
  352. package/recipes/webhook-receiver/urlcode.yaml +23 -0
  353. package/schemas/recipe.schema.json +139 -0
  354. package/schemas/urlcode.schema.json +659 -110
  355. package/skills/urlcode/SKILL.md +119 -0
  356. package/starters/default/.github/workflows/urlcode.yml +23 -0
  357. package/starters/default/.mcp.json +12 -0
  358. package/starters/default/AGENTS.md +79 -0
  359. package/starters/default/urlcode.yaml +0 -1
  360. package/dist/link-api.js +0 -136
  361. package/dist/link-cli.js +0 -141
  362. package/dist/link-events.js +0 -76
  363. package/dist/link-records.js +0 -31
  364. package/dist/link-store-worker.js +0 -150
  365. package/dist/link-store.js +0 -250
  366. package/dist/management-policy.js +0 -41
  367. package/dist/sqlite-version.js +0 -6
  368. package/dist/types/link-api.d.ts +0 -30
  369. package/dist/types/link-cli.d.ts +0 -36
  370. package/dist/types/link-events.d.ts +0 -27
  371. package/dist/types/link-records.d.ts +0 -11
  372. package/dist/types/link-store-worker.d.ts +0 -1
  373. package/dist/types/link-store.d.ts +0 -130
  374. package/dist/types/management-policy.d.ts +0 -9
  375. package/dist/types/sqlite-version.d.ts +0 -1
  376. package/docs/DYNAMIC-LINKS.md +0 -561
  377. package/docs/MANAGEMENT-SECURITY.md +0 -82
  378. package/examples/live-links/README.md +0 -11
  379. package/examples/live-links/tests/requests.json +0 -6
  380. package/examples/live-links/urlcode.yaml +0 -16
package/docs/STANDARDS.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # Standards conformance audit
2
2
 
3
3
  What URLCode implements of each RFC and specification it touches or names,
4
- verified against the code and the test suite at the commit this file was
5
- written for (`817f0ab`, 2026-09-17, Node v22.22.2). Every row cites the
6
- lines that implement the behavior and the test that proves it; "untested"
7
- means no test in `test/` asserts that specific behavior. Where the code was
8
- not conclusive, the behavior was observed by running a server with the test
9
- helpers and sending raw requests over `node:net` / `node:http`; those rows
10
- say "probed".
4
+ verified against the code and the test suite. The first pass was written at
5
+ `817f0ab` (2026-09-17, Node v22.22.2); every row was re-verified against the
6
+ source at `8d7dd01` (2026-09-18) and now cites the function or constant that
7
+ implements the behavior rather than a line number, so a row stays checkable
8
+ after the file around it moves. Every row also names the test that proves it;
9
+ "untested" means no test in `test/` asserts that specific behavior. Where the
10
+ code was not conclusive, the behavior was observed by running a server with
11
+ the test helpers and sending raw requests over `node:net` / `node:http`; those
12
+ rows say "probed". A row that could not be re-verified says
13
+ "unverified at 8d7dd01" rather than guessing.
11
14
 
12
15
  Status vocabulary:
13
16
 
@@ -23,183 +26,183 @@ Status vocabulary:
23
26
 
24
27
  | Standard | Status | Section |
25
28
  |---|---|---|
26
- | RFC 9110 HTTP Semantics | partial (one deviation: HEAD `Content-Length` on non-asset routes) | [1](#1-rfc-9110-http-semantics) |
29
+ | RFC 9110 HTTP Semantics | partial (408 on idle timeout is a SHOULD the server does not send) | [1](#1-rfc-9110-http-semantics) |
27
30
  | RFC 9111 HTTP Caching | partial | [2](#2-rfc-9111-http-caching) |
28
31
  | RFC 5861 stale-while-revalidate / stale-if-error | partial | [3](#3-rfc-5861-stale-while-revalidate-and-stale-if-error) |
29
32
  | RFC 8246 immutable | conforms | [4](#4-rfc-8246-immutable) |
30
33
  | RFC 9213 targeted cache control | conforms | [5](#5-rfc-9213-targeted-cache-control) |
31
- | RFC 6585 additional status codes (429, 431) | partial (431 answered as 400) | [6](#6-rfc-6585-additional-http-status-codes) |
32
- | RFC 9112 HTTP/1.1 message syntax | partial (absolute-form/asterisk-form 400; 414 answered as 400) | [7](#7-rfc-9112-http11-message-syntax) |
34
+ | RFC 6585 additional status codes (429, 431) | conforms | [6](#6-rfc-6585-additional-http-status-codes) |
35
+ | RFC 9112 HTTP/1.1 message syntax | partial (asterisk-form 400) | [7](#7-rfc-9112-http11-message-syntax) |
33
36
  | RFC 3986 URI | partial (dot segments rejected, not normalized) | [8](#8-rfc-3986-uri-and-percent-encoding) |
34
- | RFC 7239 Forwarded / X-Forwarded-For | not applicable (RFC 7239); X-Forwarded-For implemented | [9](#9-rfc-7239-forwarded-and-x-forwarded-for) |
35
- | RFC 9309 robots.txt | not applicable (documented recipe only; recipe YAML is invalid) | [10](#10-rfc-9309-robotstxt) |
37
+ | RFC 7239 Forwarded | not applicable (only `X-Forwarded-For`) | [9](#9-rfc-7239-forwarded-and-x-forwarded-for) |
38
+ | RFC 9309 robots.txt | conforms (generated by `site.robots`) | [10](#10-rfc-9309-robotstxt) |
36
39
  | RFC 6797 HSTS | conforms | [11](#11-rfc-6797-hsts) |
37
- | RFC 1950/1951/1952, 7932, 8878 content codings | conforms | [12](#12-rfc-19501952-rfc-7932-rfc-8878-content-codings) |
38
- | RFC 6265 Set-Cookie | partial (transport only) | [13](#13-rfc-6265-set-cookie) |
39
- | RFC 6266 / RFC 8187 Content-Disposition | conforms | [14](#14-rfc-6266--rfc-8187-content-disposition) |
40
+ | RFC 1950/1952 (deflate/gzip), RFC 7932 (br), RFC 8878 (zstd) | conforms | [12](#12-rfc-19501952-rfc-7932-rfc-8878-content-codings) |
41
+ | RFC 6265 Set-Cookie | partial (emission only) | [13](#13-rfc-6265-set-cookie) |
42
+ | RFC 6266 / 8187 Content-Disposition | conforms (library) | [14](#14-rfc-6266--rfc-8187-content-disposition) |
40
43
  | draft-ietf-httpapi-ratelimit-headers | conforms to the draft syntax (Internet-Draft, not an RFC) | [15](#15-ietf-httpapi-ratelimit-header-fields-internet-draft) |
41
44
  | web-bot-auth drafts | not applicable | [16](#16-web-bot-auth-drafts) |
42
45
  | OWASP Secure Headers Project | conforms to the pinned table | [17](#17-owasp-secure-headers-project) |
43
46
  | CSP Level 3 | partial (emits one obsolete directive from OSHP) | [18](#18-content-security-policy-level-3) |
44
47
  | JSON Schema 2020-12 | conforms (Ajv 8.20.0) | [19](#19-json-schema-2020-12) |
45
48
  | YAML 1.2 | conforms (JSON-compatible profile) | [20](#20-yaml-12) |
46
- | CIDR notation | partial (IPv4-embedded IPv6 other than `::ffff:` mis-parsed) | [21](#21-cidr-notation-for---trusted-proxies) |
49
+ | CIDR notation | conforms | [21](#21-cidr-notation-for---trusted-proxies) |
47
50
 
48
51
  ## 1. RFC 9110 HTTP Semantics
49
52
 
50
53
  | Requirement | Status | Behavior | Test |
51
54
  |---|---|---|---|
52
- | §9 methods; default GET/HEAD | conforms | `src/router.ts:10` `methodsDefault = ['GET','HEAD']`; explicit lists replace it (`:56`); schema enum at `schemas/urlcode.schema.json:107-118` | `test/http.test.ts` "literal precedence, methods, HEAD, query isolation, disabled/expired and health" |
53
- | §15.5.6 405 with `Allow` | conforms | `src/runtime.ts:117-121` answers `405` with `allow: <route.methods>`; health probes at `src/server.ts:122`; Worker at `src/cloudflare.ts:96` | same test (`allow === 'GET, HEAD'`); `test/policies.test.ts` "interoperability: ... 405 carries policy headers"; `test/aws.test.ts` "a Lambda response carries exactly ..." (405 on POST) |
54
- | §9.1 unrecognized method → 501 | partial | Node's parser refuses unknown tokens before the runtime sees them; `src/server.ts:155-157` `clientError` writes `400`. Probed: `BREW /r` → 400. Rationale: llhttp rejects at parse time; the runtime never sees the method | untested |
55
- | §9.3.2 HEAD suppresses body | conforms | `src/http-response.ts:23-24` drops the body for HEAD and 204/205/304 | `test/http.test.ts` (HEAD `/go` body `''`); `test/http-policy.test.ts` lines 16, 23; `test/vercel.test.ts:49` |
56
- | §8.6 `Content-Length` on HEAD MUST equal what GET would send | **deviates** | `src/http-response.ts:28` writes `result.contentLength ?? body?.length ?? 0`; for HEAD `body` is already `undefined` (`:24`), so a `respond` or function result without `contentLength` sends `content-length: 0`. Probed: `GET /r` → `content-length: 13`, `HEAD /r` → `content-length: 0`; same for a function route (25 vs 0). Assets set `contentLength` (`src/assets.ts:108,113`) and cache hits do (`src/policies/cache.ts:116`), so those are correct (probed: `HEAD /a` → 3000). Compression skips dynamic bodies on HEAD (`src/policies/compression.ts:158`) so the identity length would be the right value | untested (tests assert only that the HEAD body is empty) |
57
- | §15.4 redirects: 301/302/303/307/308, absolute `Location` | conforms | schema enum `schemas/urlcode.schema.json:258-262`; `src/match.ts:77-90` builds `Location` from `new URL(...).href`; default 302 at `src/runtime.ts:127`; link handler `:144` | `test/http.test.ts` (location assertions); `test/http-policy.test.ts` "YAML response headers apply to functions and redirects" |
58
- | §10.2.2 `Location` reserved from YAML | conforms | `src/http-policy.ts:7` reserved set includes `location`, `allow`, `content-range`, `accept-ranges`, `etag`, `last-modified`, `content-encoding` | `test/http-policy.test.ts` "invalid response policy and contradictory declarations fail activation" |
59
- | §7.6.1 hop-by-hop headers never set by a handler | conforms | `src/http-response.ts:5` `forbiddenHeaders` (connection, keep-alive, transfer-encoding, content-length, upgrade, trailer, proxy-authenticate, proxy-authorization, te) stripped at `:16`, refused in YAML at `src/http-policy.ts:7,14`, and on error answers `:50` | `test/http-policy.test.ts` "invalid response policy ..." |
60
- | §5.6.2 field names are tokens; §5.5 no CTLs in values | conforms | `src/header-validation.ts:6-7` (`token` regex, `invalidValue = /[^\t -~-ÿ]/`), applied at `src/http-response.ts:17`, `src/http-policy.ts:18-19`, `src/policies/security.ts:73` | `test/header-validation.test.ts` all three tests (compared against `node:http` over 0x00–0x11F) |
61
- | §8.8.3 ETag, strong vs weak | conforms | Assets: strong `"sha256(type+disposition+body)"` at `src/assets.ts:51`. Precompressed variants: strong tag with coding suffix inside the quotes, `src/policies/compression.ts:18,130`. Dynamically compressed bodies: handler tag weakened to `W/...` (`:130`). `revalidate` strategy: strong SHA-256 over the body when the handler sent none, `src/policies/cache.ts:183` | `test/assets.test.ts` "asset conditions, byte ranges ..."; `test/policy-compression.test.ts` "page assets are precompressed once, served by reference with a suffixed strong ETag, and revalidate" and "function JSON compresses with a weak ETag ..."; `test/policy-cache.test.ts` "revalidate answers 304 ..." |
62
- | §8.8.2 `Last-Modified` | conforms | second-resolution IMF-fixdate from mtime, `src/assets.ts:43` | `test/assets.test.ts` (304 on `if-modified-since`) |
63
- | §13.1.1 `If-Match` strong comparison | conforms | `src/assets.ts:90,92`: `weak=false` compares the full tag, so `W/"x"` never matches; `*` matches | `test/assets.test.ts` (`if-match: W/<etag>` → 412) |
64
- | §13.1.2 `If-None-Match` weak comparison | conforms | `src/assets.ts:90,94` strips `W/`; `*` matches; `src/policies/cache.ts:166-169` and `src/policies/compression.ts:133-135` do the same for their own tags | `test/assets.test.ts` (`W/<etag>` → 304); `test/policy-cache.test.ts` "revalidate answers 304 ..." |
65
- | §13.1.3 / §13.1.4 date conditions ignored when the ETag condition is present; invalid dates ignored | conforms | `src/assets.ts:93` (`!match && ...`), `:94` (`none ? ... : ...`); `Date.parse` → `NaN` makes both comparisons false, i.e. the header is ignored | `test/assets.test.ts` (`if-none-match: "other"` + matching `if-modified-since` → 200) |
66
- | §13.2.2 evaluation order If-Match → If-Unmodified-Since → If-None-Match → If-Modified-Since → If-Range | conforms | `src/assets.ts:92-97` in that order; Range evaluated after preconditions and only for GET | `test/assets.test.ts` (`range` + `if-none-match` → 304; HEAD + `range` → 200 with full length) |
67
- | §14 Range, single byte range; §14.4 206 `Content-Range`; §15.5.17 416 with `bytes */size` | conforms | `src/assets.ts:95-110`; suffix and open-ended ranges, `BigInt` arithmetic (`:100-102`), 416 at `:105`; multiple/malformed/non-`bytes` units ignored (§14.2 "MAY ignore") | `test/assets.test.ts` "asset conditions, byte ranges, empty files and HEAD obey HTTP ordering" |
68
- | §13.1.5 `If-Range` | partial | `src/assets.ts:97`: only an exact strong ETag match enables the range; a date `If-Range` is treated as non-matching and the full 200 is sent. Rationale (`src/assets.ts:96` comment, `docs/ASSETS.md`): a date validator is only usable when it is known to be strong; sending the full representation is the prescribed fallback for a non-matching validator | `test/assets.test.ts` (`if-range: "old"` → 200; `if-range: <etag>` → 206) |
69
- | §15.4.5 304 header set | conforms | Assets keep `Content-Type`, `ETag`, `Last-Modified`, `Cache-Control`, `Accept-Ranges` (`src/assets.ts:87-89`); `Content-Length` omitted for 304 (`src/http-response.ts:23,28`). `revalidate` keeps `etag, cache-control, cdn-cache-control, vary, last-modified, content-location, expires, date, content-type` (`src/policies/cache.ts:190`) | `test/policy-cache.test.ts` "revalidate answers 304 ..." (checks `etag`, `cache-control`, `content-type` on the 304) |
70
- | §12.5.3 `Accept-Encoding` negotiation | conforms | `src/policies/compression.ts:92-113`: q-weights, `*` for unnamed codings, highest weight wins, project order breaks ties; identity is sent when nothing is acceptable, including `identity;q=0` / `*;q=0` (§12.5.3 permits either identity or 415; the code chooses identity, comment at `:88-91`) | `test/policy-compression.test.ts` "negotiation follows RFC 9110 q-values with the project order as tie-break" |
71
- | §8.4 `Content-Encoding` | conforms | set only by the compression policy (`src/policies/compression.ts:163`); reserved from YAML (`src/http-policy.ts:7`); request bodies with a non-identity `Content-Encoding` are refused with 415 (`src/http-policy.ts:42`) | `test/policy-compression.test.ts`; `test/http-policy.test.ts` "request body policies reject size, media, encoding and malformed JSON" |
72
- | §12.5.5 `Vary` | conforms | `Vary: Accept-Encoding` added or merged, `*` respected (`src/policies/compression.ts:119-126`), also on 304/206 (`:143-144`); cache strategies merge declared `vary` names (`src/policies/cache.ts:156-165`) | `test/policy-compression.test.ts` "declared text responses compress ... Vary is set once"; `test/policy-cache.test.ts` "vary headers separate keys and are emitted ..." |
73
- | §15 status codes used | conforms | 400 (`src/match.ts:17-24`, `src/http-policy.ts:40,47,50`), 404 (`src/runtime.ts:95,102`, `src/assets.ts:84`), 405 (`:117`), 410 (`:103`), 412/304/206/416 (assets), 413 (`src/server.ts:39,47`, `src/http-policy.ts:39`), 415 (`:42,44,49`), 429/503 (throttle), 502/504 (function failures, see `docs/SPECIFICATION.md` "Functions"), 503 (`src/server.ts:110,116,125`). Rationale for 502/504 on sandbox failures: the function pool is treated as an upstream | `test/http.test.ts`, `test/http-policy.test.ts`, `test/policy-throttle.test.ts` |
74
- | §15.5.9 408 on idle timeout (SHOULD) | partial | `src/server.ts:152` `server.setTimeout(15000, socket => socket.destroy())` closes without a status; probed: an incomplete request head is dropped after ~15 s with no response bytes. Rationale: the socket is destroyed to free the slot; the requirement is a SHOULD | untested |
75
- | §10.1.5 `User-Agent` | conforms | read-only matching in `src/policies/agents.ts:202-213`; the header value is never logged | `test/policy-agents.test.ts` "a bundled deny list refuses matching agents and logs the list name, never the header" |
76
- | §10.2.3 `Retry-After` (delay-seconds) | conforms | `src/policies/throttle.ts:100` integer seconds | `test/policy-throttle.test.ts` "quota reached answers 429 with Retry-After and RateLimit headers" |
77
- | §6.4.1 `Content-Length` on every non-bodyless response | conforms | `src/http-response.ts:28`, error answers `:57` | `test/aws.test.ts` (header comparison includes `content-length`); `test/policy-compression.test.ts` (`content-length` equals encoded length) |
55
+ | §9 methods; default GET/HEAD | conforms | `src/router.ts` `methodsDefault = ['GET','HEAD']`; an explicit `methods` list replaces it when `compileRoutes` builds the route; schema enum under `methods` in `schemas/urlcode.schema.json` | `test/http.test.ts` "literal precedence, methods, HEAD, query isolation, disabled/expired and health" |
56
+ | §15.5.6 405 with `Allow` | conforms | `Runtime.handle` in `src/runtime.ts` answers `405` with `allow: <route.methods>` after host policies ran; health probes in `startServer` (`src/server.ts`) answer `405` with `allow: GET, HEAD`; the Worker does the same in `src/cloudflare.ts` | same test (`allow === 'GET, HEAD'`); `test/policies.test.ts` "interoperability: ... 405 carries policy headers"; `test/aws.test.ts` "a Lambda response carries exactly ..." (405 on POST) |
57
+ | §9.1 unrecognized method → 501 | partial | Node's parser refuses unknown tokens before the runtime sees them; the `clientError` handler in `startServer` writes `400`. Probed: `BREW /r` → 400. Rationale: llhttp rejects at parse time; the runtime never sees the method | untested |
58
+ | §9.3.2 HEAD suppresses body | conforms | `prepareResponse` in `src/http-response.ts` drops the body for HEAD and for 204/205/304 (`bodyless`) | `test/http.test.ts` (HEAD `/go` body `''`); `test/http-policy.test.ts`; `test/vercel.test.ts` |
59
+ | §8.6 `Content-Length` on HEAD MUST equal what GET would send | conforms | `prepareResponse` measures `result.contentLength ?? result.body.length` on the result's body before the body is dropped for HEAD, so a `respond`, function or middleware result reports the GET length. Assets set `contentLength` in `assetResponse` (`src/assets.ts`) and cache hits in `served` (`src/policies/cache.ts`); compression leaves a dynamic body identity on HEAD (`onResponse` in `src/policies/compression.ts`), so the identity length is stated | `test/standards.test.ts` "HEAD states the length GET would send on declared and function responses (RFC 9110 §8.6)" |
60
+ | §15.4 redirects: 301/302/303/307/308, absolute `Location` | conforms | schema enum under `redirect.status` in `schemas/urlcode.schema.json`; `redirectLocation` in `src/match.ts` builds `Location` from `new URL(...).href`; default 302 where `Runtime.handle` builds the redirect result (`route.redirect.status || 302`), and stored links carry their own status | `test/http.test.ts` (location assertions); `test/http-policy.test.ts` "YAML response headers apply to functions and redirects" |
61
+ | §10.2.2 `Location` reserved from YAML | conforms | `reserved` in `src/http-policy.ts` includes `location`, `allow`, `content-range`, `accept-ranges`, `etag`, `last-modified`, `content-encoding` | `test/http-policy.test.ts` "invalid response policy and contradictory declarations fail activation" |
62
+ | §7.6.1 hop-by-hop headers never set by a handler | conforms | `forbiddenHeaders` in `src/http-response.ts` (connection, keep-alive, transfer-encoding, content-length, upgrade, trailer, proxy-authenticate, proxy-authorization, te) stripped by `prepareResponse`, refused in YAML by `compileHttp` (`reserved` in `src/http-policy.ts`), and filtered from error answers by `errorResponse` | `test/http-policy.test.ts` "invalid response policy ..." |
63
+ | §5.6.2 field names are tokens; §5.5 no CTLs in values | conforms | `token` and `invalidValue` in `src/header-validation.ts` (`validateHeaderName`, `validateHeaderValue`), applied by `prepareResponse`, `compileHttp` and `compile` in `src/policies/security.ts` | `test/header-validation.test.ts` all three tests (compared against `node:http` over 0x00–0x11F) |
64
+ | §8.8.3 ETag, strong vs weak | conforms | Assets: strong `"sha256(type+disposition+body)"` computed in `compileAssets` (`read`). Precompressed variants: strong tag with the `SUFFIX` coding suffix inside the quotes (`etagFor` in `src/policies/compression.ts`). Dynamically compressed bodies: handler tag weakened to `W/...` (`etagFor` with `strong = false`). `revalidate` strategy: strong SHA-256 over the body when the handler sent none (`revalidate` in `src/policies/cache.ts`) | `test/assets.test.ts` "asset conditions, byte ranges ..."; `test/policy-compression.test.ts` "page assets are precompressed once, served by reference with a suffixed strong ETag, and revalidate" and "function JSON compresses with a weak ETag ..."; `test/policy-cache.test.ts` "revalidate answers 304 ..." |
65
+ | §8.8.2 `Last-Modified` | conforms | second-resolution IMF-fixdate from mtime (`modified` in `compileAssets`) | `test/assets.test.ts` (304 on `if-modified-since`) |
66
+ | §13.1.1 `If-Match` strong comparison | conforms | `matches(value, weak=false)` in `assetResponse` compares the full tag, so `W/"x"` never matches; `*` matches | `test/assets.test.ts` (`if-match: W/<etag>` → 412) |
67
+ | §13.1.2 `If-None-Match` weak comparison | conforms | `matches(value, weak=true)` in `assetResponse` strips `W/`; `*` matches; `noneMatch` in `src/policies/cache.ts` and in `src/policies/compression.ts` do the same for their own tags | `test/assets.test.ts` (`W/<etag>` → 304); `test/policy-cache.test.ts` "revalidate answers 304 ..." |
68
+ | §13.1.3 / §13.1.4 date conditions ignored when the ETag condition is present; invalid dates ignored | conforms | `assetResponse`: `If-Unmodified-Since` is read only when `If-Match` is absent, `If-Modified-Since` only when `If-None-Match` is absent; `Date.parse` → `NaN` makes both comparisons false, i.e. the header is ignored | `test/assets.test.ts` (`if-none-match: "other"` + matching `if-modified-since` → 200) |
69
+ | §13.2.2 evaluation order If-Match → If-Unmodified-Since → If-None-Match → If-Modified-Since → If-Range | conforms | `assetResponse` in that order; Range evaluated after preconditions and only for GET | `test/assets.test.ts` (`range` + `if-none-match` → 304; HEAD + `range` → 200 with full length) |
70
+ | §14 Range, single byte range; §14.4 206 `Content-Range`; §15.5.17 416 with `bytes */size` | conforms | `assetResponse`: suffix and open-ended ranges, `BigInt` arithmetic, 416 with `bytes */<size>`; multiple/malformed/non-`bytes` units ignored (§14.2 "MAY ignore") | `test/assets.test.ts` "asset conditions, byte ranges, empty files and HEAD obey HTTP ordering" |
71
+ | §13.1.5 `If-Range` | partial | `assetResponse`: only an exact strong ETag match enables the range; a date `If-Range` is treated as non-matching and the full 200 is sent. Rationale (comment above the range check, `docs/ASSETS.md`): a date validator is only usable when it is known to be strong; sending the full representation is the prescribed fallback for a non-matching validator | `test/assets.test.ts` (`if-range: "old"` → 200; `if-range: <etag>` → 206) |
72
+ | §15.4.5 304 header set | conforms | Assets keep `Content-Type`, `ETag`, `Last-Modified`, `Cache-Control`, `Accept-Ranges` (`headers` in `assetResponse`); `Content-Length` omitted for 304 (`bodyless` in `prepareResponse`). `revalidate` keeps the `kept` set in `src/policies/cache.ts` (`etag, cache-control, cdn-cache-control, vary, last-modified, content-location, expires, date, content-type`) | `test/policy-cache.test.ts` "revalidate answers 304 ..." (checks `etag`, `cache-control`, `content-type` on the 304) |
73
+ | §12.5.3 `Accept-Encoding` negotiation | conforms | `negotiate` in `src/policies/compression.ts`: q-weights, `*` for unnamed codings, highest weight wins, project order breaks ties; identity is sent when nothing is acceptable, including `identity;q=0` / `*;q=0` (§12.5.3 permits either identity or 415; the code chooses identity, comment above `negotiate`) | `test/policy-compression.test.ts` "negotiation follows RFC 9110 q-values with the project order as tie-break" |
74
+ | §8.4 `Content-Encoding` | conforms | set only by `onResponse` in `src/policies/compression.ts`; reserved from YAML (`reserved` in `src/http-policy.ts`); request bodies with a non-identity `Content-Encoding` are refused with 415 by `checkRequest` | `test/policy-compression.test.ts`; `test/http-policy.test.ts` "request body policies reject size, media, encoding and malformed JSON" |
75
+ | §12.5.5 `Vary` | conforms | `withVary` in `src/policies/compression.ts` adds or merges `Vary: Accept-Encoding` and respects `*`, also on 304/206 (`varied` is returned for those); cache strategies merge declared `vary` names (`mergeVary` in `src/policies/cache.ts`) | `test/policy-compression.test.ts` "declared text responses compress ... Vary is set once"; `test/policy-cache.test.ts` "vary headers separate keys and are emitted ..." |
76
+ | §15 status codes used | conforms | 400 (`parseTarget` in `src/match.ts`, `checkRequest` in `src/http-policy.ts`), 404 (`Runtime.handle`, `assetResponse`), 405 (`Runtime.handle`), 410 (`Runtime.handle`, expired routes and links), 412/304/206/416 (`assetResponse`), 413 (`readBody` in `src/server.ts`, `checkRequest`), 414 (`parseTarget`), 415 (`checkRequest`), 429/503 (throttle), 431 (`clientError` in `startServer`), 502/504 (function failures, see `docs/SPECIFICATION.md` "Functions"), 503 (`startServer` admission and shutdown). Rationale for 502/504 on sandbox failures: the function pool is treated as an upstream | `test/http.test.ts`, `test/http-policy.test.ts`, `test/policy-throttle.test.ts`, `test/standards.test.ts` |
77
+ | §15.5.9 408 on idle timeout (SHOULD) | partial | `startServer`: `server.setTimeout(15000, socket => socket.destroy())` closes without a status, and the `clientError` handler answers `ERR_HTTP_REQUEST_TIMEOUT` with `400` rather than `408`; probed: an incomplete request head is dropped after ~15 s with no response bytes. Rationale: the socket is destroyed to free the slot; the requirement is a SHOULD | untested |
78
+ | §10.1.5 `User-Agent` | conforms | read-only matching in `onRequest` (`src/policies/agents.ts`); the header value is never logged (the list label is) | `test/policy-agents.test.ts` "a bundled deny list refuses matching agents and logs the list name, never the header" |
79
+ | §10.2.3 `Retry-After` (delay-seconds) | conforms | `onRequest` in `src/policies/throttle.ts` writes `retry-after: <reset>` with integer seconds from `observe` | `test/policy-throttle.test.ts` "quota reached answers 429 with Retry-After and RateLimit headers" |
80
+ | §6.4.1 `Content-Length` on every non-bodyless response | conforms | `prepareResponse`; error answers in `errorResponse` | `test/aws.test.ts` (header comparison includes `content-length`); `test/policy-compression.test.ts` (`content-length` equals encoded length) |
78
81
 
79
82
  ## 2. RFC 9111 HTTP Caching
80
83
 
81
84
  | Requirement | Status | Behavior | Test |
82
85
  |---|---|---|---|
83
- | §5.2.2 response directives `no-store`, `no-cache`, `public`, `private`, `max-age` | conforms | strategy table `src/policies/cache.ts:63-82`: `no-store`→`no-store`, `revalidate`→`no-cache`, `public`→`public, max-age=N`, `private`→`private, max-age=N`; runtime default `cache-control: no-store` when a handler sets none (`src/http-response.ts:30`), errors always `no-store` (`:48`); throttle and agents denials `no-store` (`src/policies/throttle.ts:100`, `src/policies/agents.ts:212`) | `test/policy-cache.test.ts` "each strategy emits its catalogue headers; explicit fields override what the strategy implies" |
84
- | §5.1 `Age` on a stored response | conforms | `src/policies/cache.ts:112-117` replaces any `Age` with `floor((now-storedAt)/1000)` | `test/policy-cache.test.ts` "swr serves fresh from the origin cache ..." |
85
- | §3 storing: never store `Set-Cookie`, `private`, `no-store`, secret-bearing or oversized results | conforms | `src/policies/cache.ts:208,224-225`; only statuses in `statuses` (default `200,301,302,404,410`, `:16`) | `test/policy-cache.test.ts` "Set-Cookie, secret-bearing routes, handler no-store and oversized bodies are never stored" |
86
- | §4.1 `Vary` in the cache key | conforms | key = route, path, query, declared `vary` header values (`src/policies/cache.ts:102-105`); `Vary` emitted on storable statuses (`:217`) | `test/policy-cache.test.ts` "vary headers separate keys and are emitted ..." |
87
- | §4.3 validation requests never served from the origin cache | conforms | `src/policies/cache.ts:119-124` bypasses for `If-None-Match`, `If-Modified-Since`, `If-Match`, `If-Unmodified-Since`, `Range` | `test/policies.test.ts` "interoperability: conditional requests bypass origin hits ..." |
88
- | §4.4 invalidation on unsafe methods | partial | only GET results are stored and only GET/HEAD looked up (`:121,145`); a POST to the same route does not invalidate an entry. Rationale: the store belongs to one runtime and is dropped on reload (`:263-269`); handlers are side-effect free by design | untested |
89
- | §5.2.1 request directives (`no-cache`, `max-age=0`, `no-store`) honored by a cache | **partial** | the origin memory cache ignores request `Cache-Control` and `Pragma`. Probed: `GET /pub` with `Cache-Control: no-cache` and with `max-age=0` both served from memory with `age: 0`. Rationale (`src/policies/cache.ts:4-11` comment, `docs/policies/cache.md` "Origin memory cache"): this is the origin's own micro-cache in the NGINX `proxy_cache` sense, part of the origin server rather than a shared cache in the RFC 9111 sense; a client cannot bypass it, as it cannot bypass an origin's internal memoization | untested |
90
- | §4.2.4 serving stale | partial | stale served only within `stale-while-revalidate` and only once per entry (`:133`), never beyond; see §3 below | `test/policy-cache.test.ts` "swr serves fresh ... serves stale once and refreshes on the next request" |
91
- | `micro` strategy: `no-store` to clients, 1–5 s origin memory | conforms (origin-side) | `src/policies/cache.ts:77-80`; the client-facing header is honest (`no-store`) while the origin memoizes | `test/policy-cache.test.ts` "micro caches at the origin for one second while telling clients no-store" |
92
- | Explicit YAML / handler `Cache-Control` beats the strategy | conforms | `src/policies/cache.ts:93-94,208-214` | `test/policy-cache.test.ts` "vary headers ... explicit YAML cache-control wins over the strategy" and "assets keep their handler cacheControl under an inherited policy ..." |
86
+ | §5.2.2 response directives `no-store`, `no-cache`, `public`, `private`, `max-age` | conforms | the `switch (strategy)` in `compile` (`src/policies/cache.ts`): `no-store`→`no-store`, `revalidate`→`no-cache`, `public`→`public, max-age=N`, `private`→`private, max-age=N`; runtime default `cache-control: no-store` when a handler sets none (`prepareResponse`), errors always `no-store` (`errorResponse`); throttle and agents denials `no-store` (`onRequest` in each policy) | `test/policy-cache.test.ts` "each strategy emits its catalogue headers; explicit fields override what the strategy implies" |
87
+ | §5.1 `Age` on a stored response | conforms | `served` in `src/policies/cache.ts` replaces any `Age` with `floor((now-storedAt)/1000)` | `test/policy-cache.test.ts` "swr serves fresh from the origin cache ..." |
88
+ | §3 storing: never store `Set-Cookie`, `private`, `no-store`, secret-bearing or oversized results | conforms | `storable` in `onResponse` (`src/policies/cache.ts`); only statuses in `statuses` (`DEFAULT_STATUSES = [200,301,302,404,410]`) | `test/policy-cache.test.ts` "Set-Cookie, secret-bearing routes, handler no-store and oversized bodies are never stored" |
89
+ | §4.1 `Vary` in the cache key | conforms | `keyFor` = route, path, query, declared `vary` header values; `mergeVary` emits `Vary` on storable statuses in `onResponse` | `test/policy-cache.test.ts` "vary headers separate keys and are emitted ..." |
90
+ | §4.3 validation requests never served from the origin cache | conforms | `conditional` list in `src/policies/cache.ts`; `onRequest` bypasses for `If-None-Match`, `If-Modified-Since`, `If-Match`, `If-Unmodified-Since`, `Range` | `test/policies.test.ts` "interoperability: conditional requests bypass origin hits ..." |
91
+ | §4.4 invalidation on unsafe methods | partial | only GET results are stored (`onRequest` opens a flight for GET only) and only GET/HEAD looked up; a POST to the same route does not invalidate an entry. Rationale: the store belongs to one runtime and is dropped on reload (`close` in `src/policies/cache.ts`); handlers are side-effect free by design | untested |
92
+ | §5.2.1 request directives (`no-cache`, `max-age=0`, `no-store`) honored by a cache | **partial** | the origin memory cache ignores request `Cache-Control` and `Pragma` (`onRequest` reads only the `conditional` headers). Probed at `817f0ab`: `GET /pub` with `Cache-Control: no-cache` and with `max-age=0` both served from memory with `age: 0`; the code path is unchanged at `8d7dd01`. Rationale (module comment at the top of `src/policies/cache.ts`, `docs/policies/cache.md` "Origin memory cache"): this is the origin's own micro-cache in the NGINX `proxy_cache` sense, part of the origin server rather than a shared cache in the RFC 9111 sense; a client cannot bypass it, as it cannot bypass an origin's internal memoization | untested |
93
+ | §4.2.4 serving stale | partial | stale served only within `stale-while-revalidate` and only once per entry (`entry.revalidating` in `onRequest`), never beyond; see §3 below | `test/policy-cache.test.ts` "swr serves fresh ... serves stale once and refreshes on the next request" |
94
+ | `micro` strategy: `no-store` to clients, 1–5 s origin memory | conforms (origin-side) | `case 'micro'` in `compile` (`MICRO_MAX = 5`); the client-facing header is honest (`no-store`) while the origin memoizes | `test/policy-cache.test.ts` "micro caches at the origin for one second while telling clients no-store" |
95
+ | Explicit YAML / handler `Cache-Control` beats the strategy | conforms | `yamlCacheControl` and `inheritedAsset` in `compile`; `owned` in `onResponse` | `test/policy-cache.test.ts` "vary headers ... explicit YAML cache-control wins over the strategy" and "assets keep their handler cacheControl under an inherited policy ..." |
93
96
 
94
97
  ## 3. RFC 5861 stale-while-revalidate and stale-if-error
95
98
 
96
99
  | Requirement | Status | Behavior | Test |
97
100
  |---|---|---|---|
98
- | §3 `stale-while-revalidate=N` emitted | conforms | `src/policies/cache.ts:73,75` | `test/policy-cache.test.ts` "each strategy emits its catalogue headers" |
99
- | §3 background revalidation at the origin cache | partial | a policy has no handle to the handler, so a stale hit is served once and flagged (`:133`); the next request refreshes synchronously; a failed refresh clears the flag (`:247-249`). Documented in `docs/policies/cache.md` "swr at the origin" | "swr serves fresh from the origin cache, serves stale once and refreshes on the next request"; "a failed fill releases waiters to the handler and never stores" |
100
- | §4 `stale-if-error=N` emitted | conforms | `src/policies/cache.ts:75` | "each strategy emits its catalogue headers" |
101
- | §4 serving stale on error at the origin | partial | header-only: `onError` (`:241-250`) only releases waiters and clears the flag; it never returns a fallback result although the runtime would accept one (`src/runtime.ts:163-167`). Documented in `docs/policies/cache.md` "sie limitation" | untested (no test asserts a 5xx is *not* replaced) |
101
+ | §3 `stale-while-revalidate=N` emitted | conforms | `case 'swr'` and `case 'sie'` in `compile` (`src/policies/cache.ts`) | `test/policy-cache.test.ts` "each strategy emits its catalogue headers" |
102
+ | §3 background revalidation at the origin cache | partial | a policy has no handle to the handler, so a stale hit is served once and flagged (`entry.revalidating` in `onRequest`); the next request refreshes synchronously; a failed refresh clears the flag (`onError`). Documented in `docs/policies/cache.md` "`swr` at the origin" | "swr serves fresh from the origin cache, serves stale once and refreshes on the next request"; "a failed fill releases waiters to the handler and never stores" |
103
+ | §4 `stale-if-error=N` emitted | conforms | `case 'sie'` in `compile` | "each strategy emits its catalogue headers" |
104
+ | §4 serving stale on error at the origin | partial | header-only: `onError` only releases waiters and clears the flag; it never returns a fallback result although `Runtime.handle` would accept one (the `policy.error` loop in its catch block). Documented in `docs/policies/cache.md` "`sie` limitation" | untested (no test asserts a 5xx is *not* replaced) |
102
105
 
103
106
  ## 4. RFC 8246 immutable
104
107
 
105
108
  | Requirement | Status | Behavior | Test |
106
109
  |---|---|---|---|
107
- | `immutable` extension emitted with a long `max-age` | conforms | `src/policies/cache.ts:68-72`: `public, max-age=<maxAge ?? 31536000>, immutable`; refused unless the pattern has an 8+ hex-char segment or a digest-named parameter, or `force: true` (`:23-24,69-70`) | `test/policy-cache.test.ts` "immutable is refused on unhashed paths and accepted with a hashed segment, a hash parameter or force" |
108
- | Asset `cacheControl: public, max-age=31536000, immutable` | conforms | allowed literal in the schema; passed through at `src/assets.ts:53,87` | `test/assets.test.ts` "native page, download and static handlers ..." (cacheControl variants) |
110
+ | `immutable` extension emitted with a long `max-age` | conforms | `case 'immutable'` in `compile`: `public, max-age=<maxAge ?? YEAR>, immutable`; refused unless the pattern has a segment matching `hashedSegment` (8+ hex chars) or `hashedParameter` (a digest-named parameter), or `force: true` | `test/policy-cache.test.ts` "immutable is refused on unhashed paths and accepted with a hashed segment, a hash parameter or force" |
111
+ | Asset `cacheControl: public, max-age=31536000, immutable` | conforms | allowed literal in the schema; passed through as `asset.cache` (`compileAssets`) and emitted by `assetResponse` | `test/assets.test.ts` "native page, download and static handlers ..." (cacheControl variants) |
109
112
 
110
113
  ## 5. RFC 9213 targeted cache control
111
114
 
112
115
  | Requirement | Status | Behavior | Test |
113
116
  |---|---|---|---|
114
- | `CDN-Cache-Control` as a Structured-Field dictionary | conforms | `cdn-only` emits `Cache-Control: no-store` plus `CDN-Cache-Control: max-age=N` (`src/policies/cache.ts:81,213`); reserved from YAML/`set` only indirectly (not in `src/http-policy.ts:7`; a YAML `CDN-Cache-Control` would be replaced by the strategy at `:212` unless YAML also sets `cache-control`) | `test/policy-cache.test.ts` "each strategy emits its catalogue headers" |
117
+ | `CDN-Cache-Control` as a Structured-Field dictionary | conforms | `cdn-only` emits `Cache-Control: no-store` plus `CDN-Cache-Control: max-age=N` (`case 'cdn-only'` in `compile`, written by `onResponse` when `owned`); not in the `reserved` set of `src/http-policy.ts`, so a YAML `CDN-Cache-Control` is replaced by the strategy unless YAML also sets `cache-control` | `test/policy-cache.test.ts` "each strategy emits its catalogue headers" |
115
118
 
116
119
  ## 6. RFC 6585 additional HTTP status codes
117
120
 
118
121
  | Requirement | Status | Behavior | Test |
119
122
  |---|---|---|---|
120
- | §4 429 Too Many Requests with an explanatory body | conforms | `src/policies/throttle.ts:13,100-101`: `429`, `Too many requests\n`, `Retry-After`, `RateLimit*`; any 4xx/5xx `status` allowed with a matching short body | `test/policy-throttle.test.ts` "quota reached answers 429 ..." and "status other than 429 gets a matching short body" |
121
- | §5 431 Request Header Fields Too Large | **deviates** | `src/server.ts:79` sets `maxHeaderSize: 16384`, but the custom `clientError` handler at `:155-157` writes `400 Bad Request` for every parse error. Node's default handler would send 431 for `HPE_HEADER_OVERFLOW` (and 408 for `ERR_HTTP_REQUEST_TIMEOUT`). Probed: a 20 000-byte header value → `400`. Rationale in code: one fixed minimal answer for any malformed head; no comment justifies losing 431 specifically | untested |
123
+ | §4 429 Too Many Requests with an explanatory body | conforms | `bodies` and `onRequest` in `src/policies/throttle.ts`: `429`, `Too many requests\n`, `Retry-After`, `RateLimit*`; any 4xx/5xx `status` allowed with a matching short body | `test/policy-throttle.test.ts` "quota reached answers 429 ..." and "status other than 429 gets a matching short body" |
124
+ | §5 431 Request Header Fields Too Large | conforms | `startServer` sets `maxHeaderSize: 16384`; the `clientError` handler answers `HPE_HEADER_OVERFLOW` with `431 Request Header Fields Too Large` and every other parse failure with `400 Bad Request` | `test/standards.test.ts` "oversized header fields answer 431, not 400 (RFC 6585 §5)" |
122
125
 
123
126
  ## 7. RFC 9112 HTTP/1.1 message syntax
124
127
 
125
128
  | Requirement | Status | Behavior | Test |
126
129
  |---|---|---|---|
127
- | §3.2.1 origin-form request target | conforms | `src/match.ts:16-26` requires a leading `/`, rejects `//`, `#`, controls, `\`, length > 8192 | `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" (`//evil.example`, `/%00` ...) |
128
- | §3.2.2 absolute-form MUST be accepted by servers | **deviates** | `src/match.ts:17` rejects anything not starting with `/`. Probed: `GET http://127.0.0.1:<port>/r HTTP/1.1` → `400 Invalid request target`. Rationale (`docs/CLOUDFLARE.md` "The request target"): the self-hosted server inspects the request line verbatim and shares `parseTarget` with runtimes that never see absolute-form; the Worker/Lambda paths reconstruct an origin-form target | untested |
129
- | §3.2.4 asterisk-form for `OPTIONS *` | deviates (minor) | same check; probed `OPTIONS * HTTP/1.1` → 400. No route can be `*`, so no handler could answer it anyway | untested |
130
- | §3 414 for over-long request targets | deviates | `src/match.ts:17` throws `HttpError(400)` for targets over 8192 bytes; probed → `400 Invalid request target`. RFC 9112 §3: "MUST respond with a 414". Node's own limit (`maxHeaderSize`) would 431/400 earlier for the whole head | untested (test covers the 400 for other malformed targets only) |
130
+ | §3.2.1 origin-form request target | conforms | `parseTarget` in `src/match.ts` requires a leading `/`, rejects `//`, `#`, controls, `\`; length > 8192 is 414 | `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" (`//evil.example`, `/%00` ...) |
131
+ | §3.2.2 absolute-form MUST be accepted by servers | conforms | `originForm` in `src/server.ts` strips the scheme and authority textually before `parseTarget`, so `GET http://example.test/go?x=1` routes as `/go?x=1` and a bare authority becomes `/`. The Worker and Lambda paths reconstruct an origin-form target themselves | `test/standards.test.ts` "request targets: 414 for over-long, absolute-form accepted, asterisk-form refused (RFC 9112 §3)" |
132
+ | §3.2.4 asterisk-form for `OPTIONS *` | deviates (minor) | `parseTarget` refuses `*` (no leading `/`) → 400. No route can be `*`, so no handler could answer it anyway | same test (`OPTIONS *` → 400) |
133
+ | §3 414 for over-long request targets | conforms | `parseTarget` throws `HttpError(414, 'URI too long')` for targets over 8192 bytes. Node's own limit (`maxHeaderSize`) answers 431 earlier for the whole head | same test (9000-byte path → 414) |
131
134
  | §3.2 missing `Host` → 400 | conforms | enforced by Node's parser; probed `GET /r HTTP/1.1` with no `Host` → 400 | untested |
132
135
  | §6.1 `Content-Length` + `Transfer-Encoding` both present → reject | conforms | Node parser; probed → 400 | untested |
133
- | §7 chunked request bodies | conforms | Node decodes; `src/server.ts:38-53` counts decoded bytes against the limit and answers 413 | `test/http.test.ts` "chunked oversized body returns 413 and server remains usable" |
134
- | header field limits | conforms | `maxHeaderSize: 16384`, `headersTimeout: 10000`, `requestTimeout: 15000`, `keepAliveTimeout: 5000` (`src/server.ts:79`), `maxRequestsPerSocket 1000`, `maxConnections 1024` (`:153-154`); response headers ≤ 16 KiB / 256 pairs (`src/http-policy.ts:24,58`) | `test/http.test.ts` "limits reject oversized requests and responses" |
135
- | §9.3 `Connection: close` on error answers | conforms | `src/http-response.ts:64`; `clientError` reply at `src/server.ts:156` | untested |
136
- | duplicate header fields | conforms | raw headers walked (`src/server.ts:132-135`), counts passed to policy; duplicated scalar header parameters → 400 (`src/match.ts:59`), duplicated `Content-Type` → 400 (`src/http-policy.ts:41`), duplicate `X-Forwarded-For` ignored (`src/server.ts:138`), duplicate `X-Request-Id` not trusted (`:87`) | `test/http.test.ts` "header inputs are case insensitive and duplicate scalars fail" |
136
+ | §7 chunked request bodies | conforms | Node decodes; `readBody` in `src/server.ts` counts decoded bytes against the limit and answers 413 | `test/http.test.ts` "chunked oversized body returns 413 and server remains usable" |
137
+ | header field limits | conforms | `maxHeaderSize: 16384`, `headersTimeout: 10000`, `requestTimeout: 15000`, `keepAliveTimeout: 5000` in `http.createServer` options, `maxRequestsPerSocket = 1000`, `maxConnections = 1024` (all in `startServer`); response headers ≤ 16 KiB / 256 pairs (`compileHttp` and `decorateResponse` in `src/http-policy.ts`) | `test/http.test.ts` "limits reject oversized requests and responses" |
138
+ | §9.3 `Connection: close` on error answers | conforms | `writeError` in `src/http-response.ts`; the `clientError` reply in `startServer` | untested |
139
+ | duplicate header fields | conforms | raw headers walked in `startServer` (`headerCounts`), counts passed to policy; duplicated scalar header parameters → 400 (`contextFor` in `src/match.ts`), duplicated `Content-Type` → 400 (`checkRequest`), duplicate `X-Forwarded-For` ignored (`startServer` passes it only when the count is exactly 1), duplicate `X-Request-Id` not trusted (`startServer`, `safeRequestId`) | `test/http.test.ts` "header inputs are case insensitive and duplicate scalars fail" |
137
140
  | HTTP/1.0 requests | conforms | Node answers with `Connection: close`; probed | untested |
138
141
 
139
142
  ## 8. RFC 3986 URI and percent-encoding
140
143
 
141
144
  | Requirement | Status | Behavior | Test |
142
145
  |---|---|---|---|
143
- | §2.1 `%HH` must be two hex digits, case-insensitive | conforms | `src/match.ts:19` `/%(?![0-9a-f]{2})/i` → 400; probed `/r%41` → decoded to `/rA` (404, i.e. accepted and decoded) | `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" (`/%ZZ`, `/ok?q=%`) |
144
- | §2.2 encoded delimiters keep their encoding (`%2F` is not `/`) | conforms (stricter) | `%2F` and `%5C` in the path are refused with 400 rather than kept encoded (`src/match.ts:19`), so an encoded slash can never span a segment. Rationale: `docs/ROUTING.md` "Encoded slashes ... are rejected" | same test (`/%2f`, `/%5c`) |
145
- | decode exactly once | conforms | `decodeURIComponent` once on path and once on query at `src/match.ts:21`; `%252F` stays `%2F` after one decode and is re-encoded into the redirect (`:78`) | same test (`/a%252Fb` → `.../a%252Fb`) |
146
+ | §2.1 `%HH` must be two hex digits, case-insensitive | conforms | `parseTarget`: `/%(?![0-9a-f]{2})/i` → 400; probed `/r%41` → decoded to `/rA` (404, i.e. accepted and decoded) | `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" (`/%ZZ`, `/ok?q=%`) |
147
+ | §2.2 encoded delimiters keep their encoding (`%2F` is not `/`) | conforms (stricter) | `%2F` and `%5C` in the path are refused with 400 rather than kept encoded (`parseTarget`), so an encoded slash can never span a segment. Rationale: `docs/ROUTING.md` "Encoded slashes ... are rejected" | same test (`/%2f`, `/%5c`) |
148
+ | decode exactly once | conforms | `decodeURIComponent` once on path and once on query in `parseTarget`; `%252F` stays `%2F` after one decode and is re-encoded into the redirect by `redirectLocation` (`encodeURIComponent`) | same test (`/a%252Fb` → `.../a%252Fb`) |
146
149
  | non-UTF-8 percent sequences | stricter than RFC 3986 | `/%ff` → 400 because `decodeURIComponent` requires UTF-8 (RFC 3987/WHATWG behavior); RFC 3986 itself permits arbitrary octets | same test (`/%ff`, `/ok?q=%ff`) |
147
- | §5.2.4 dot segments | deviates (deliberate) | `.` and `..` segments are refused with 400 (`src/match.ts:24`) rather than removed by `remove_dot_segments`; `%2e` decodes to `.` and is refused the same way. Rationale: `docs/SPECIFICATION.md` "Routes" — no normalization so a route key means exactly one path | same test (`/..`, `/%2e`); `test/assets.test.ts` (`/assets/../urlcode.yaml`, `/assets/%2e%2e/urlcode.yaml` → 400) |
148
- | §3.3 empty path segments | conforms | `//` at the start refused (`:17`, network-path ambiguity); internal empty segments only match a literal route containing them; `{param}` requires a non-empty segment (`:34`) | `test/http.test.ts` (`//evil.example`) |
149
- | §3.4 query | conforms | split on the first `?` (`:18`); parsed by `URLSearchParams`, which applies `application/x-www-form-urlencoded` rules (`+` = space), a WHATWG rule RFC 3986 does not define; the validity check at `:21` mirrors it | "typed inputs, defaults, arrays, mapping and passthrough" |
150
- | §3.5 fragment never in a request target | conforms | `#` refused (`:17`) | untested directly |
150
+ | §5.2.4 dot segments | deviates (deliberate) | `.` and `..` segments are refused with 400 (`parseTarget`) rather than removed by `remove_dot_segments`; `%2e` decodes to `.` and is refused the same way. Rationale: `docs/SPECIFICATION.md` "Routes" — no normalization so a route key means exactly one path | same test (`/..`, `/%2e`); `test/assets.test.ts` (`/assets/../urlcode.yaml`, `/assets/%2e%2e/urlcode.yaml` → 400) |
151
+ | §3.3 empty path segments | conforms | `//` at the start refused (`parseTarget`, network-path ambiguity); internal empty segments only match a literal route containing them; `{param}` requires a non-empty segment (`matchRoute`) | `test/http.test.ts` (`//evil.example`) |
152
+ | §3.4 query | conforms | split on the first `?` (`parseTarget`); parsed by `URLSearchParams`, which applies `application/x-www-form-urlencoded` rules (`+` = space), a WHATWG rule RFC 3986 does not define; the validity check in `parseTarget` mirrors it | "typed inputs, defaults, arrays, mapping and passthrough" |
153
+ | §3.5 fragment never in a request target | conforms | `#` refused (`parseTarget`) | untested directly |
151
154
  | non-ASCII octets in the target | conforms | refused by Node's parser (probed raw `GET /caf\xe9` → 400) | untested |
152
- | route keys | conforms | `src/router.ts:24` forbids `?#%\` and whitespace/controls in route patterns; ≤ 2048 chars, ≤ 32 segments (`:26`) | `test/config.test.ts` "semantic validation rejects ambiguous routes and unsafe redirects" |
153
- | redirect URL validation | conforms | absolute `http(s)`, no userinfo, placeholders only in path segments, single-component `encodeURIComponent` (`src/router.ts:91-109`, `src/match.ts:78`); 16 KiB `Location` cap (`:88`) | `test/http.test.ts` (`/p/hello%20world` → `.../hello%20world`); `test/config.test.ts` |
155
+ | route keys | conforms | `segments` in `src/router.ts` forbids `?#%\` and whitespace/controls in route patterns; ≤ 2048 chars, ≤ 32 segments | `test/config.test.ts` "semantic validation rejects ambiguous routes and unsafe redirects" |
156
+ | redirect URL validation | conforms | absolute `http(s)`, no userinfo, placeholders only in path segments (`compileRoutes` redirect block in `src/router.ts`); single-component `encodeURIComponent` and the 16 KiB `Location` cap in `redirectLocation` | `test/http.test.ts` (`/p/hello%20world` → `.../hello%20world`); `test/config.test.ts` |
154
157
 
155
158
  ## 9. RFC 7239 Forwarded and X-Forwarded-For
156
159
 
157
160
  | Requirement | Status | Behavior | Test |
158
161
  |---|---|---|---|
159
- | RFC 7239 `Forwarded` field | **not applicable** (not implemented) | no code reads `Forwarded`; only `x-forwarded-for` is read (`src/server.ts:138`, `src/vercel.ts:46`). `docs/SPIKE-EXTENSIONS.md:212` claims "RFC 7239 `Forwarded` is accepted from the same trusted set", which is false (see factual errors) | none |
160
- | `X-Forwarded-For` (de-facto, no RFC) trusted only from configured proxies | conforms to common practice | `src/client-address.ts:59-66`: peer must be in `--trusted-proxies`; walk from the right skipping trusted hops; all-trusted chain → leftmost; header ignored when absent or duplicated (`src/server.ts:138` requires exactly one field) | `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies" |
161
- | malformed entries | partial, and mis-documented | entries that are not a bare IP are **filtered out and the walk continues** (`src/client-address.ts:62`); the test proves `'garbage, 198.51.100.1'` → `198.51.100.1`. The comment at `src/client-address.ts:58` and `docs/POLICIES.md:240` say "a malformed entry stops the walk at the peer", which is not what the code does. Consequence: RFC 7239-style `node:port` (`203.0.113.5:1234`) or `"[2001:db8::1]:443"` entries are dropped; probed `203.0.113.5:1234, 10.0.0.2` behind a trusted peer → client resolved as `10.0.0.2` (the proxy itself) | test proves the skip; the documented "stop at the peer" is untested because it is not implemented |
162
- | bracketed IPv6 and IPv4-mapped peers | conforms | `normalizeAddress` strips `[...]` and `::ffff:` (`:68-75`) | same test (`::ffff:10.1.2.3` peer) |
163
- | HSTS / origin never derived from `X-Forwarded-Proto`/`Host` | conforms | `src/policies/security.ts:100` uses the operator origin; `src/server.ts:151` | `test/policy-security.test.ts` "HSTS follows the request origin scheme" (`x-forwarded-proto: https` does not enable it) |
162
+ | RFC 7239 `Forwarded` field | **not applicable** (not implemented) | no code reads `Forwarded`; only `x-forwarded-for` is read (`startServer` in `src/server.ts`, the request adapter in `src/vercel.ts`). `docs/SPIKE-EXTENSIONS.md` now states "RFC 7239 `Forwarded` is not parsed" | none |
163
+ | `X-Forwarded-For` (de-facto, no RFC) trusted only from configured proxies | conforms to common practice | `resolveClient` in `src/client-address.ts`: peer must be in `--trusted-proxies`; walk from the right skipping trusted hops; all-trusted chain → leftmost; header ignored when absent or duplicated (`startServer` requires exactly one field) | `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies" |
164
+ | malformed entries | conforms, documented | entries that are not a bare IP are skipped and the walk continues (`resolveClient` filters `normalizeAddress` misses); `normalizeAddress` strips a trailing `:port` from IPv4 entries and the brackets from `[v6]:port`, so RFC 7239-style `203.0.113.5:1234` resolves to `203.0.113.5`. The comment above `resolveClient` and `docs/POLICIES.md` now say "a malformed entry is skipped" | `test/standards.test.ts` "forwarded entries with ports and embedded IPv4 in IPv6 ranges resolve correctly"; `test/policies.test.ts` (`'garbage, 198.51.100.1'` → `198.51.100.1`) |
165
+ | bracketed IPv6 and IPv4-mapped peers | conforms | `normalizeAddress` strips `[...]` and `::ffff:` | same test (`::ffff:10.1.2.3` peer) |
166
+ | HSTS / origin never derived from `X-Forwarded-Proto`/`Host` | conforms | `onResponse` in `src/policies/security.ts` reads `request.origin`, which is the operator origin (`publicOrigin` in `startServer`) | `test/policy-security.test.ts` "HSTS follows the request origin scheme" (`x-forwarded-proto: https` does not enable it) |
164
167
 
165
168
  ## 10. RFC 9309 robots.txt
166
169
 
167
170
  | Requirement | Status | Behavior | Test |
168
171
  |---|---|---|---|
169
- | Serving or honoring `robots.txt` | **not applicable** | the runtime neither serves nor reads `robots.txt`; `docs/policies/agents.md:153-178` gives a `respond` recipe. That recipe is **invalid**: it puts `headers:` under `respond`, which the schema refuses (`schemas/urlcode.schema.json:605-617` has only `status`, `text`, `json`; probed: `Invalid configuration at /routes/~1robots.txt/respond (additionalProperties)`). `respond.text` already defaults to `text/plain; charset=utf-8` (`src/http-policy.ts:33`), so the header is unnecessary | none |
172
+ | Serving `robots.txt` | conforms | `robotsRoute` in `src/site.ts` generates `/robots.txt` from `site.robots` (bundled agent lists or paths; `User-agent:` / `Disallow:` / `Allow:` groups, an optional `Sitemap:` line when the public origin is known); a hand-written `/robots.txt` route takes precedence. The runtime never reads a `robots.txt` itself; the `agents` policy enforces the same lists for clients that ignore it (`docs/policies/agents.md` "robots.txt") | `test/site.test.ts` "every site key generates a native route with the expected body, type and cache policy" and "robots lists resolve through the bundled agent lists and skip non-token names" |
170
173
 
171
174
  ## 11. RFC 6797 HSTS
172
175
 
173
176
  | Requirement | Status | Behavior | Test |
174
177
  |---|---|---|---|
175
- | §6.1 directive syntax `max-age=...; includeSubDomains` | conforms | `src/policies/security.ts:20` `max-age=31536000; includeSubDomains` | `test/policy-security.test.ts` "HSTS follows the request origin scheme" |
176
- | §7.2 MUST NOT send STS over non-secure transport | conforms | emitted only when the configured origin is `https:` (`src/policies/security.ts:100,111`); never inferred from forwarded headers | same test (plain origin → absent; `x-forwarded-proto` ignored; `origin: https://...` → present) |
178
+ | §6.1 directive syntax `max-age=...; includeSubDomains` | conforms | `oshp` table in `src/policies/security.ts`: `max-age=31536000; includeSubDomains` | `test/policy-security.test.ts` "HSTS follows the request origin scheme" |
179
+ | §7.2 MUST NOT send STS over non-secure transport | conforms | `onResponse` in `src/policies/security.ts` skips `strict-transport-security` unless `request.origin` starts with `https:`; never inferred from forwarded headers | same test (plain origin → absent; `x-forwarded-proto` ignored; `origin: https://...` → present) |
177
180
  | Worker | conforms | scheme from the request URL (`docs/policies/security.md` "Targets") | `test/policy-security.test.ts` "the Worker emits the same security headers as the self-hosted server" |
178
181
 
179
182
  ## 12. RFC 1950/1952, RFC 7932, RFC 8878 content codings
180
183
 
181
184
  | Coding | Status | Behavior | Test |
182
185
  |---|---|---|---|
183
- | `gzip` (RFC 1952) | conforms | `zlib.gzipSync` (`src/policies/compression.ts:27`); dynamic level 6, stored 9 | `test/policy-compression.test.ts` "declared text responses compress per Accept-Encoding ..." (round-trips with `gunzipSync`) |
184
- | `deflate` = zlib container (RFC 1950 over RFC 1951), as RFC 9110 §8.4.1.1 defines the coding | conforms | `zlib.deflateSync` (`:28`) produces the zlib format, not raw deflate | untested end to end (only `decode.deflate` is defined in the test) |
185
- | `br` (RFC 7932) | conforms | `zlib.brotliCompressSync` with `BROTLI_PARAM_QUALITY` and `SIZE_HINT` (`:25-26`); dynamic quality 4, stored 9 | same test (`brotliDecompressSync` round-trip) |
186
- | `zstd` (RFC 8878) | conforms when available | `zlib.zstdCompressSync` (`:29-30`); refused at activation when Node lacks it (`:57`); available on the audited Node (probed) | "zstd is honoured only when node:zlib provides it; serverless targets delegate the policy" |
187
- | request-body decompression | not implemented (documented) | `src/http-policy.ts:42` → 415 for any non-identity `Content-Encoding`; `docs/HTTP.md` "no automatic decompression" | `test/http-policy.test.ts` "request body policies reject size, media, encoding ..." |
188
- | BREACH mitigation (not an RFC; CVE-2013-3587) | conforms to the documented rule | skip when the route holds secrets or the response sets a cookie unless `allowWithSecrets` (`src/policies/compression.ts:146`) | "function JSON compresses with a weak ETag; Set-Cookie and secrets skip unless allowWithSecrets" |
186
+ | `gzip` (RFC 1952) | conforms | `codecs.gzip` in `src/policies/compression.ts` (`zlib.gzipSync`); dynamic level 6, stored 9 | `test/policy-compression.test.ts` "declared text responses compress per Accept-Encoding ..." (round-trips with `gunzipSync`) |
187
+ | `deflate` = zlib container (RFC 1950 over RFC 1951), as RFC 9110 §8.4.1.1 defines the coding | conforms | `codecs.deflate` (`zlib.deflateSync`) produces the zlib format, not raw deflate | untested end to end (only `decode.deflate` is defined in the test) |
188
+ | `br` (RFC 7932) | conforms | `codecs.br` (`zlib.brotliCompressSync` with `BROTLI_PARAM_QUALITY` and `BROTLI_PARAM_SIZE_HINT`); dynamic quality 4, stored 9 | same test (`brotliDecompressSync` round-trip) |
189
+ | `zstd` (RFC 8878) | conforms when available | `codecs.zstd` (`zlib.zstdCompressSync`, dynamic 3, stored 12); `zstdAvailable` refuses it at activation (`compile`) when Node lacks it; available on the audited Node (probed) | "zstd is honoured only when node:zlib provides it; serverless targets delegate the policy" |
190
+ | request-body decompression | not implemented (documented) | `checkRequest` in `src/http-policy.ts` → 415 for any non-identity `Content-Encoding`; `docs/HTTP.md` "no automatic decompression" | `test/http-policy.test.ts` "request body policies reject size, media, encoding ..." |
191
+ | BREACH mitigation (not an RFC; CVE-2013-3587) | conforms to the documented rule | `onResponse` in `src/policies/compression.ts` skips when the route holds secrets or the response sets a cookie unless `allowWithSecrets` | "function JSON compresses with a weak ETag; Set-Cookie and secrets skip unless allowWithSecrets" |
189
192
 
190
193
  ## 13. RFC 6265 Set-Cookie
191
194
 
192
195
  | Requirement | Status | Behavior | Test |
193
196
  |---|---|---|---|
194
- | §3 one `Set-Cookie` header per cookie, never folded | conforms | cookies collected separately (`src/http-response.ts:18`) and written as an array (`:36`); YAML `Set-Cookie` is the only list-valued header (`src/http-policy.ts:15`); AWS format 2.0 `cookies` array (`src/aws.ts`) | `test/http.test.ts` "function Request/Response ABI, scoped bindings, cookies ..."; `test/aws.test.ts` "cookies arrive through the format 2.0 array and leave through it" |
197
+ | §3 one `Set-Cookie` header per cookie, never folded | conforms | `prepareResponse` collects cookies separately (`cookies`) and `writeResponse` writes them as an array; YAML `Set-Cookie` is the only list-valued header (`compileHttp`); AWS format 2.0 `cookies` array (`src/aws.ts`) | `test/http.test.ts` "function Request/Response ABI, scoped bindings, cookies ..."; `test/aws.test.ts` "cookies arrive through the format 2.0 array and leave through it" |
195
198
  | §4.1.1 cookie syntax validation | partial | only the generic header rules apply (token name, no CTLs); no attribute parsing, signing or `Cookie` request parsing (`docs/HTTP.md` "Still outside this contract") | n/a |
196
- | cookies vs caching/compression | conforms | responses with `Set-Cookie` are never stored (`src/policies/cache.ts:225`) and not compressed by default (`src/policies/compression.ts:146`) | `test/policy-cache.test.ts` "Set-Cookie, secret-bearing routes ..."; `test/policy-compression.test.ts` |
199
+ | cookies vs caching/compression | conforms | responses with `Set-Cookie` are never stored (`storable` in `onResponse`, `src/policies/cache.ts`) and not compressed by default (`onResponse` in `src/policies/compression.ts`) | `test/policy-cache.test.ts` "Set-Cookie, secret-bearing routes ..."; `test/policy-compression.test.ts` |
197
200
 
198
201
  ## 14. RFC 6266 / RFC 8187 Content-Disposition
199
202
 
200
203
  | Requirement | Status | Behavior | Test |
201
204
  |---|---|---|---|
202
- | `attachment; filename=...; filename*=UTF-8''...` | conforms | `content-disposition@3.0.0` `create()` at `src/assets.ts:50`; filenames with separators or controls refused (`:49`) | `test/assets.test.ts` "native page, download and static handlers ..." (`filename*=UTF-8''r%C3%A9sum%C3%A9.bin`) and "assets reject unsafe paths ... " (`bad\r\nname`) |
205
+ | `attachment; filename=...; filename*=UTF-8''...` | conforms | `content-disposition@3.0.0` `create()` (imported as `disposition`) in `compileAssets`; filenames with separators or controls refused just before it | `test/assets.test.ts` "native page, download and static handlers ..." (`filename*=UTF-8''r%C3%A9sum%C3%A9.bin`) and "assets reject unsafe paths ... " (`bad\r\nname`) |
203
206
 
204
207
  ## 15. IETF httpapi RateLimit header fields (Internet-Draft)
205
208
 
@@ -209,32 +212,32 @@ names and syntax have changed between revisions and may change again.
209
212
 
210
213
  | Requirement | Status | Behavior | Test |
211
214
  |---|---|---|---|
212
- | `RateLimit-Policy` as a Structured-Field list of items with `q` and `w` parameters | conforms to the current draft syntax | `src/policies/throttle.ts:76` `"default";q=<quota>;w=<window>` | `test/policy-throttle.test.ts` "quota reached answers 429 with Retry-After and RateLimit headers" (`'"default";q=2;w=60'`) |
213
- | `RateLimit` item with `r` (remaining) and `t` (seconds to reset) | conforms | `:76` `"default";r=<remaining>;t=<reset>`; `t` from the sliding-window end (`:72`) | same test (`/^"default";r=1;t=\d+$/`) |
214
- | present on allowed and refused responses | conforms | request phase attaches the budget (`:97`), response phase writes it (`:104-107`); refusals carry it directly (`:100-101`) | same test; `test/policies.test.ts` "... 405 carries policy headers" |
215
- | `pk` (partition key) parameter | not emitted | keys are internal (`:44-50`) | n/a |
216
- | refused requests not counted | conforms to the draft's guidance | `:89-91` | "window slides: the previous window fades out instead of resetting at once" |
215
+ | `RateLimit-Policy` as a Structured-Field list of items with `q` and `w` parameters | conforms to the current draft syntax | `headersFor` in `src/policies/throttle.ts`: `"default";q=<quota>;w=<window>` | `test/policy-throttle.test.ts` "quota reached answers 429 with Retry-After and RateLimit headers" (`'"default";q=2;w=60'`) |
216
+ | `RateLimit` item with `r` (remaining) and `t` (seconds to reset) | conforms | `headersFor`: `"default";r=<remaining>;t=<reset>`; `t` from the sliding-window end (`observe`) | same test (`/^"default";r=1;t=\d+$/`) |
217
+ | present on allowed and refused responses | conforms | `onRequest` attaches the budget to `state.pending`, `onResponse` writes it; refusals carry it directly (`withHeaders` in `onRequest`) | same test; `test/policies.test.ts` "... 405 carries policy headers" |
218
+ | `pk` (partition key) parameter | not emitted | keys are internal (`keyFor`) | n/a |
219
+ | refused requests not counted | conforms to the draft's guidance | `onRequest`: `entry.current++` only when not exceeded (or in `report` mode) | "window slides: the previous window fades out instead of resetting at once" |
217
220
 
218
221
  ## 16. web-bot-auth drafts
219
222
 
220
223
  | Requirement | Status | Behavior | Test |
221
224
  |---|---|---|---|
222
- | HTTP Message Signatures for bots (IETF `web-bot-auth` drafts) | **not applicable** | named in `docs/policies/agents.md:66-69` as future plugin territory; no code | none |
225
+ | HTTP Message Signatures for bots (IETF `web-bot-auth` drafts) | **not applicable** | named in `docs/policies/agents.md` ("Matching is string matching") as host-plugin territory; no code | none |
223
226
 
224
227
  ## 17. OWASP Secure Headers Project
225
228
 
226
229
  | Requirement | Status | Behavior | Test |
227
230
  |---|---|---|---|
228
- | OSHP "best practices" values | conforms to the pinned table | `src/policies/security.ts:19-29`, pinned as `oshpRevision = 'OSHP 2024 best practices'` (`:13`); docs table generated from the same constant; `X-Content-Type-Options: nosniff` supplied by the runtime on every response (`src/http-response.ts:29,48`) rather than the profile. The upstream JSON could not be fetched from the audit environment (proxy 403), so the table is verified against the code and docs, not re-verified against owasp.org | `test/policy-security.test.ts` "profile tables are frozen, ordered and consistent with each other", "profile headers land on redirects, declared responses and function results; existing headers win" |
229
- | fill-gaps semantics; `set`/`unset` | conforms | `src/policies/security.ts:98-116` | "set overrides the profile and existing headers; unset drops a profile header" |
230
- | applied to errors and early denials | conforms | `src/runtime.ts:80-83,142`; `src/http-response.ts:46-58` | `test/policies.test.ts` "error responses carry the security headers of the matched route or the project"; `test/policy-security.test.ts` "early results that skip the handler still carry the profile" |
231
- | `X-Frame-Options` (RFC 7034, informational) `deny` | conforms | `:21` | as above |
231
+ | OSHP "best practices" values | conforms to the pinned table | `oshp` table in `src/policies/security.ts`, pinned as `oshpRevision = 'OSHP 2024 best practices'`; docs table generated from the same exported `profiles`; `X-Content-Type-Options: nosniff` supplied by the runtime on every response (`prepareResponse`, `errorResponse`) rather than the profile. The upstream JSON could not be fetched from the audit environment (proxy 403), so the table is verified against the code and docs, not re-verified against owasp.org | `test/policy-security.test.ts` "profile tables are frozen, ordered and consistent with each other", "profile headers land on redirects, declared responses and function results; existing headers win" |
232
+ | fill-gaps semantics; `set`/`unset` | conforms | `compile` (`unset`, `set`, `overrideKeys`) and `onResponse` in `src/policies/security.ts` | "set overrides the profile and existing headers; unset drops a profile header" |
233
+ | applied to errors and early denials | conforms | `Runtime.errorHeaders` and `finishPolicies` in `src/runtime.ts`; `errorResponse` merges the resolved policy headers after its fixed set | `test/policies.test.ts` "error responses carry the security headers of the matched route or the project"; `test/policy-security.test.ts` "early results that skip the handler still carry the profile" |
234
+ | `X-Frame-Options` (RFC 7034, informational) `deny` | conforms | `oshp` table entry `x-frame-options: deny` | as above |
232
235
 
233
236
  ## 18. Content Security Policy Level 3
234
237
 
235
238
  | Requirement | Status | Behavior | Test |
236
239
  |---|---|---|---|
237
- | CSP3 serialized policy syntax | conforms | `default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content` (`src/policies/security.ts:24`) | `test/policy-security.test.ts` (value equality with the frozen table) |
240
+ | CSP3 serialized policy syntax | conforms | `default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content` (`oshp` table, `content-security-policy`) | `test/policy-security.test.ts` (value equality with the frozen table) |
238
241
  | `block-all-mixed-content` | partial | this directive is obsolete in CSP3 / the current Mixed Content specification and is ignored by current browsers; it is emitted because OSHP recommends it. Harmless, but not a Level 3 directive | n/a |
239
242
  | `upgrade-insecure-requests` | conforms | defined by the W3C Upgrade Insecure Requests specification, delivered through CSP; emitted regardless of scheme (browsers ignore it on plain HTTP) | n/a |
240
243
  | Report-only and `report-to` | conforms (operator-supplied) | any `Content-Security-Policy-Report-Only` value is passed verbatim through `set` (`docs/policies/security.md`); the runtime does not host a reporting endpoint | "set overrides the profile ..." |
@@ -243,61 +246,66 @@ names and syntax have changed between revisions and may change again.
243
246
 
244
247
  | Requirement | Status | Behavior | Test |
245
248
  |---|---|---|---|
246
- | project schema dialect | conforms | `schemas/urlcode.schema.json:2` `$schema: https://json-schema.org/draft/2020-12/schema`; compiled by `ajv/dist/2020.js` (Ajv 8.20.0, `package.json:60`) at `src/config.ts:6,10` with `strict: true`, `allowUnionTypes: true`, `allErrors: false` | `test/config.test.ts` "strict schema rejects unknown features and multiple handlers"; `test/policies.test.ts` "policies validate in YAML and unknown keys fail" |
247
- | input parameter schemas (subset: `type`, `enum`, `default`, `minLength`/`maxLength`, `minimum`/`maximum`, `items`, `maxItems`) | conforms | `src/router.ts:14-22` compiles each distinct schema with Ajv 2020 (`strict: false`, `:44`); `default` is stripped before compiling because 2020-12 treats it as an annotation (`:15`); semantic cross-checks at `:63-71`; unsupported keywords (`pattern`, `format`, nested objects) are refused by the project schema, not silently ignored | `test/http.test.ts` "typed inputs, defaults, arrays, mapping and passthrough" |
249
+ | project schema dialect | conforms | `schemas/urlcode.schema.json` `$schema: https://json-schema.org/draft/2020-12/schema`; compiled by `ajv/dist/2020.js` (Ajv 8.20.0, `package.json` dependencies) as `validate` in `src/config.ts` with `strict: true`, `strictRequired: false`, `allowUnionTypes: true`, `allErrors: false` | `test/config.test.ts` "strict schema rejects unknown features and multiple handlers"; `test/policies.test.ts` "policies validate in YAML and unknown keys fail" |
250
+ | input parameter schemas (subset: `type`, `enum`, `default`, `minLength`/`maxLength`, `minimum`/`maximum`, `items`, `maxItems`) | conforms | `inputValidator` in `src/router.ts` compiles each distinct schema with Ajv 2020 (`strict: false`, `allErrors: false` in `compileRoutes`); `default` is stripped before compiling because 2020-12 treats it as an annotation; semantic cross-checks in the `parameters` loop of `compileRoutes`; unsupported keywords (`pattern`, `format`, nested objects) are refused by the project schema, not silently ignored | `test/http.test.ts` "typed inputs, defaults, arrays, mapping and passthrough" |
248
251
  | standalone validators for the Worker | conforms | precompiled by the build into ES modules (`docs/CLOUDFLARE.md` "validators.js") | `test/cloudflare.test.ts` "the generated Worker entry and validators carry no imports the platform cannot resolve" |
249
- | bounded compilation | conforms | ≤ 1024 distinct schemas per snapshot (`src/router.ts:18`) | untested |
252
+ | bounded compilation | conforms | ≤ 1024 distinct schemas per snapshot (`inputValidator`) | untested |
250
253
 
251
254
  ## 20. YAML 1.2
252
255
 
253
256
  | Requirement | Status | Behavior | Test |
254
257
  |---|---|---|---|
255
- | YAML 1.2 core schema, JSON-compatible profile | conforms | `yaml@2.9.1` (`package.json:65`) `parseDocument(text, { version: '1.2', uniqueKeys: false, strict: true })` at `src/config.ts:14`; parser errors and warnings fail (`:15`) | `test/config.test.ts` "YAML rejects ambiguity and nonportable constructs" |
256
- | no aliases, anchors, tags, merge keys, multiple documents | conforms (deliberate subset) | `:17` (aliases/anchors/tags), `:37` (`<<` and prototype keys), multi-document rejected by the parser in strict mode, `maxAliasCount: 0` (`:30`) | same test (`&x`/`*x`, `!custom`, `!!str`, `---`, `<<`) |
257
- | string keys only, unique keys | conforms | `:22-25` (linear duplicate check, documented at `:20-21`) | same test (`x: 1\nx: 2`) |
258
- | scalars: string, finite number, boolean, null | conforms | `:28`, `:33` (`.inf`/`.nan` refused) | same test (`.inf`, `.NaN`) |
258
+ | YAML 1.2 core schema, JSON-compatible profile | conforms | `yaml@2.9.1` (`package.json` dependencies); `parseYaml` in `src/config.ts` calls `parseDocument(text, { version: '1.2', uniqueKeys: false, strict: true })`; parser errors and warnings fail | `test/config.test.ts` "YAML rejects ambiguity and nonportable constructs" |
259
+ | no aliases, anchors, tags, merge keys, multiple documents | conforms (deliberate subset) | `parseYaml`: the `visit` callback refuses aliases, anchors and tags; `inspect` refuses `<<` and prototype keys; multi-document rejected by the parser in strict mode; `toJS({ maxAliasCount: 0 })` | same test (`&x`/`*x`, `!custom`, `!!str`, `---`, `<<`) |
260
+ | string keys only, unique keys | conforms | the linear duplicate check over `node.items` in `parseYaml` (comment: "Our string-only profile permits equivalent linear duplicate detection") | same test (`x: 1\nx: 2`) |
261
+ | scalars: string, finite number, boolean, null | conforms | `parseYaml`: the scalar check in `visit` and the `Number.isFinite` check in `inspect` (`.inf`/`.nan` refused) | same test (`.inf`, `.NaN`) |
259
262
  | YAML 1.2 boolean semantics (`yes`/`no` are strings) | conforms | follows from `version: '1.2'` | untested |
260
- | resource bounds | conforms | 32 MiB per file, 64 MiB aggregate, 40 nesting levels, worker with heap and wall-clock limits (`src/config.ts:11,31,52,84-105`) | "configuration worker deadline terminates loading and releases admission"; "configuration worker enforces aggregate source budget across includes" |
263
+ | resource bounds | conforms | `MAX_CONFIG_BYTES` (32 MiB per file), `MAX_PROJECT_CONFIG_BYTES` (64 MiB aggregate, enforced by `readConfig`), 40 nesting levels (`inspect`), and `loadDocument` runs the parse in a worker with `resourceLimits` and a `timeoutMs` deadline (`src/config.ts`) | "configuration worker deadline terminates loading and releases admission"; "configuration worker enforces aggregate source budget across includes" |
261
264
 
262
265
  ## 21. CIDR notation for `--trusted-proxies`
263
266
 
264
267
  | Requirement | Status | Behavior | Test |
265
268
  |---|---|---|---|
266
- | `address[/prefix]` for IPv4 and IPv6 (RFC 4632 / RFC 4291 §2.3 notation) | conforms | `src/client-address.ts:25-32`; prefix defaults to the full length; prefix range checked; ≤ 256 ranges (`:36`); comma-separated string or array (`:35`) | `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies" (`10.0.0.0/8, ::1, 192.168.1.5`; `/33` refused) |
267
- | host bits set beyond the prefix | lenient | `10.1.2.3/8` accepted and masked at match time (`:40-50`); probed | untested |
268
- | IPv4-mapped IPv6 (`::ffff:a.b.c.d`) | conforms | `:15-16`, `:72-73` | same test (`::ffff:10.1.2.3`) |
269
- | other IPv4-embedded IPv6 forms (`64:ff9b::1.2.3.4`, RFC 4291 §2.2 form 3) | **deviates** | `toBytes` splits on `:` and `parseInt`s each group as hex, so `1.2.3.4` becomes group value `1`; probed: `64:ff9b::1.2.3.4/96` → last group `0x0001` instead of `0x0102 0x0304`. Only `::ffff:` is special-cased | untested |
270
- | zone identifiers (`fe80::1%eth0`) | accidental | `net.isIP` accepts them (probed → 6); `parseInt('1%eth0', 16)` yields `1`, so the zone is silently ignored | untested |
269
+ | `address[/prefix]` for IPv4 and IPv6 (RFC 4632 / RFC 4291 §2.3 notation) | conforms | `parseCidr` in `src/client-address.ts`; prefix defaults to the full length; prefix range checked; `compileTrustedProxies` accepts a comma-separated string or array, ≤ 256 ranges | `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies" (`10.0.0.0/8, ::1, 192.168.1.5`; `/33` refused) |
270
+ | host bits set beyond the prefix | lenient | `10.1.2.3/8` accepted and masked at match time (`within`); probed | untested |
271
+ | IPv4-mapped IPv6 (`::ffff:a.b.c.d`) | conforms | `toBytes` and `normalizeAddress` special-case `::ffff:` | same test (`::ffff:10.1.2.3`) |
272
+ | other IPv4-embedded IPv6 forms (`64:ff9b::1.2.3.4`, RFC 4291 §2.2 form 3) | conforms | `toBytes` converts a trailing dotted quad into two 16-bit groups before splitting on `:` | `test/standards.test.ts` "forwarded entries with ports and embedded IPv4 in IPv6 ranges resolve correctly" (`parseCidr('64:ff9b::1.2.3.4/96')` bytes) |
273
+ | zone identifiers (`fe80::1%eth0`) | accidental | `net.isIP` accepts them (probed → 6); `parseInt('1%eth0', 16)` in `toBytes` yields `1`, so the zone is silently ignored | untested |
271
274
 
272
275
  ## Gaps, ranked
273
276
 
274
- 1. **HEAD `Content-Length: 0` on `respond`, function and middleware results** (RFC 9110 §8.6 MUST NOT; §1). Fix: in `src/http-response.ts:28` compute the length from `result.body` before it is dropped for HEAD — e.g. `const declared = result.contentLength ?? (result.body?.length ?? 0);` then `if (!bodyless) headers.push(['content-length', String(declared)])`. `docs/policies/compression.md:131-132` already describes the intended behavior ("reports the identity length"). Test: extend `test/http-policy.test.ts` "declarative text, JSON and empty responses use correct bodies and statuses" to assert HEAD `content-length` equals GET's for `/fn` and `/json`; the Cloudflare and AWS parity tests then cover the adapters.
275
- 2. **431 and 408 collapsed into 400** (RFC 6585 §5; RFC 9110 §15.5.9; §6, §7). Fix: in `src/server.ts:155-157` branch on `error.code`: `HPE_HEADER_OVERFLOW` → `431`, `ERR_HTTP_REQUEST_TIMEOUT` → `408`, else `400`; keep `Connection: close` and `Content-Length: 0`. Test: add to `test/http.test.ts` "limits reject oversized requests and responses" a raw-socket request with a 20 KiB header expecting `431`.
276
- 3. **`X-Forwarded-For` malformed-entry semantics are mis-documented and `node:port` entries are dropped** (§9). Fix (documentation, minimal): change `docs/POLICIES.md:240` and the comment at `src/client-address.ts:58` to "a malformed entry is skipped". Fix (behavior, optional): in `normalizeAddress` strip a trailing `:port` from IPv4 and `[v6]:port` forms before `isIP`. Test: extend `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies" with `'203.0.113.5:1234, 10.0.0.2'`.
277
- 4. **absolute-form and asterisk-form request targets answered 400** (RFC 9112 §3.2.2 MUST accept; §7). Fix: in `src/server.ts:137` (and `requestLimit` at `src/runtime.ts:85`) rewrite `req.url` when it matches `/^https?:\/\/[^/?#]*(\/.*)?$/i` to its path-and-query part (or `/` when empty) before `parseTarget`; leave `*` as 400 or answer `OPTIONS *` with 405 + `Allow`. Test: raw-socket request in `test/http.test.ts` expecting the same answer as origin-form.
278
- 5. **414 for over-long targets answered 400** (RFC 9112 §3; §7). Fix: `src/match.ts:17` split the length check into its own `throw new HttpError(414, 'URI too long')`. Test: `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" add a 9000-byte path expecting 414.
279
- 6. **origin memory cache ignores request `Cache-Control: no-cache` / `max-age=0`** (RFC 9111 §5.2.1; §2). Either document it explicitly in `docs/policies/cache.md` "Origin memory cache" ("request cache directives are not honored: the store is part of the origin"), or add `'cache-control'` to the bypass at `src/policies/cache.ts:119-124` when it contains `no-cache` or `no-store`. Test: add a request with `cache-control: no-cache` to `test/policy-cache.test.ts` "swr serves fresh from the origin cache ..." asserting the chosen behavior.
280
- 7. **IPv4-embedded IPv6 CIDRs other than `::ffff:`** (§21). Fix: in `toBytes` (`src/client-address.ts:17-22`), if the last group contains `.` convert it to two 16-bit groups. Test: `parseCidr('64:ff9b::1.2.3.4/96')` bytes in `test/policies.test.ts`.
281
- 8. **`stale-if-error` is header-only** (RFC 5861 §4; §3): documented; `onError` could return `served(entry, age)` when an entry exists within `staleIfError` — the runtime already accepts a fallback (`src/runtime.ts:163-167`). Test: a function that throws after a first successful fill.
282
- 9. **Untested conformance points** that hold today but have no guard: 304 omits `Content-Length` (assets and `revalidate`); `deflate` end-to-end round-trip; missing `Host` → 400; CL+TE → 400; `Connection: close` on error answers; YAML 1.2 `yes`/`no` as strings; ≤ 1024 input schemas. Each is one assertion in the test named in its row.
283
- 10. **`block-all-mixed-content`** in the OSHP CSP (§18): obsolete but harmless; keep while the pinned OSHP revision recommends it, and note it in `docs/policies/security.md` next to the CSP paragraph.
277
+ Closed since the first pass (rows above flipped to conforms): HEAD
278
+ `Content-Length` on `respond`, function and middleware results (RFC 9110
279
+ §8.6); 431 for oversized header fields (RFC 6585 §5); absolute-form request
280
+ targets (RFC 9112 §3.2.2); 414 for over-long targets (RFC 9112 §3);
281
+ `X-Forwarded-For` entries with a port and the "malformed entry" wording in the
282
+ docs; IPv4-embedded IPv6 CIDRs other than `::ffff:`.
283
+
284
+ 1. **408 collapsed into 400** (RFC 9110 §15.5.9; §1). Fix: in the `clientError` handler of `startServer` (`src/server.ts`) branch on `error.code === 'ERR_HTTP_REQUEST_TIMEOUT'` → `408`, keeping `Connection: close` and `Content-Length: 0`. Test: a raw-socket request head left incomplete past `headersTimeout` in `test/standards.test.ts`.
285
+ 2. **asterisk-form request target answered 400** (RFC 9112 §3.2.4; §7). Fix: in `originForm` or `startServer` answer `OPTIONS *` with 405 + `Allow`, or leave as 400 and state it. Test: `test/standards.test.ts` already asserts the current 400; flip it with the fix.
286
+ 3. **origin memory cache ignores request `Cache-Control: no-cache` / `max-age=0`** (RFC 9111 §5.2.1; §2). Either document it explicitly in `docs/policies/cache.md` "Origin memory cache" ("request cache directives are not honored: the store is part of the origin"), or add `cache-control` to the `conditional` bypass list in `src/policies/cache.ts` when it contains `no-cache` or `no-store`. Test: add a request with `cache-control: no-cache` to `test/policy-cache.test.ts` "swr serves fresh from the origin cache ..." asserting the chosen behavior.
287
+ 4. **`stale-if-error` is header-only** (RFC 5861 §4; §3): documented; `onError` in `src/policies/cache.ts` could return `served(entry, age)` when an entry exists within `staleIfError` — `Runtime.handle` already accepts a fallback from an error hook. Test: a function that throws after a first successful fill.
288
+ 5. **Untested conformance points** that hold today but have no guard: 304 omits `Content-Length` (assets and `revalidate`); `deflate` end-to-end round-trip; missing `Host` → 400; CL+TE → 400; `Connection: close` on error answers; YAML 1.2 `yes`/`no` as strings; ≤ 1024 input schemas. Each is one assertion in the test named in its row.
289
+ 6. **`block-all-mixed-content`** in the OSHP CSP (§18): obsolete but harmless; keep while the pinned OSHP revision recommends it, and note it in `docs/policies/security.md` next to the CSP paragraph.
284
290
 
285
291
  ## Factual errors in existing documentation
286
292
 
287
- - `docs/policies/agents.md:163-175`: the `robots.txt` recipe puts `headers:` under `respond:`; the schema refuses it. Remove the `headers:` line (the default content type is already `text/plain; charset=utf-8`), or move it to `response: { headers: { ... } }` beside `respond`.
288
- - `docs/policies/compression.md:131-132`: "A dynamic body is not compressed for `HEAD` ... and reports the identity length" — it currently reports `content-length: 0` (gap 1). Until fixed, the sentence should read "reports no usable length (`Content-Length: 0`)"; after the fix the sentence is correct.
289
- - `docs/POLICIES.md:240` and the comment at `src/client-address.ts:58`: "a malformed entry stops the walk at the peer" — the code skips the entry and continues (proven by the test). Should read "a malformed entry is skipped".
290
- - `docs/SPIKE-EXTENSIONS.md:212`: "RFC 7239 `Forwarded` is accepted from the same trusted set" — nothing reads `Forwarded`. Should read "only `X-Forwarded-For` is read; RFC 7239 `Forwarded` is not parsed".
293
+ All four errors found in the first pass are corrected at `8d7dd01`:
294
+
295
+ - `docs/policies/agents.md` "robots.txt" no longer carries a `respond` recipe with `headers:`; it points at `site.robots`, which generates the file.
296
+ - `docs/policies/compression.md` "ETag and HEAD": "reports the identity length" is now true (`prepareResponse` measures the body before dropping it).
297
+ - `docs/POLICIES.md` and the comment above `resolveClient` say "a malformed entry is skipped".
298
+ - `docs/SPIKE-EXTENSIONS.md` says "RFC 7239 `Forwarded` is not parsed".
291
299
 
292
300
  ## How to keep this current
293
301
 
294
302
  - Any change to `src/http-response.ts` (what a response *is*) must extend `test/http-policy.test.ts` "declarative text, JSON and empty responses ..." and rerun the parity tests (`test/aws.test.ts` "a Lambda response carries exactly ...", `test/cloudflare.test.ts` "the Worker runtime answers exactly ..."), then update §1 here.
295
- - Any change to `src/match.ts` `parseTarget` or `src/router.ts` `segments` must extend `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" and update §7–§8.
296
- - Any change to `src/assets.ts` conditional or range logic must extend `test/assets.test.ts` "asset conditions, byte ranges, empty files and HEAD obey HTTP ordering" and update §1 (validators, ranges).
297
- - A new cache strategy or directive touches `src/policies/cache.ts:63-82` and `test/policy-cache.test.ts` "each strategy emits its catalogue headers"; update §2–§5.
298
- - A new content coding touches the `codecs` table in `src/policies/compression.ts:24-31` and `test/policy-compression.test.ts` (round-trip decode table at the top of the file); update §12.
299
- - A change to the OSHP table bumps `oshpRevision` in `src/policies/security.ts:13`, regenerates the docs table, and must keep `test/policy-security.test.ts` "profile tables are frozen, ordered and consistent" passing; update §17–§18.
300
- - A change to the RateLimit field syntax (when the draft moves) touches `src/policies/throttle.ts:75-77` and the header regexes in `test/policy-throttle.test.ts`; update §15 with the draft revision.
301
- - A change to `--trusted-proxies` parsing touches `src/client-address.ts` and `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies"; update §9 and §21.
302
- - A change to the YAML profile or schema dialect touches `src/config.ts:10-42`, `test/config.test.ts` "YAML rejects ambiguity ..." and "strict schema rejects unknown features ..."; update §19–§20.
303
- - When a gap above is closed, delete its row from the ranked list and flip the row status in the table; when a new standard is touched, add a section with the same four columns (requirement, status, behavior with `file:line`, test name).
303
+ - Any change to `parseTarget` in `src/match.ts`, `originForm` in `src/server.ts` or `segments` in `src/router.ts` must extend `test/http.test.ts` "malformed and ambiguous URL encodings fail before routing" or `test/standards.test.ts`, and update §7–§8.
304
+ - Any change to `assetResponse` in `src/assets.ts` (conditional or range logic) must extend `test/assets.test.ts` "asset conditions, byte ranges, empty files and HEAD obey HTTP ordering" and update §1 (validators, ranges).
305
+ - A new cache strategy or directive touches the `switch (strategy)` in `compile` (`src/policies/cache.ts`) and `test/policy-cache.test.ts` "each strategy emits its catalogue headers"; update §2–§5.
306
+ - A new content coding touches the `codecs` table in `src/policies/compression.ts` and `test/policy-compression.test.ts` (round-trip decode table at the top of the file); update §12.
307
+ - A change to the OSHP table bumps `oshpRevision` in `src/policies/security.ts`, regenerates the docs table, and must keep `test/policy-security.test.ts` "profile tables are frozen, ordered and consistent" passing; update §17–§18.
308
+ - A change to the RateLimit field syntax (when the draft moves) touches `headersFor` in `src/policies/throttle.ts` and the header regexes in `test/policy-throttle.test.ts`; update §15 with the draft revision.
309
+ - A change to `--trusted-proxies` parsing touches `src/client-address.ts`, `test/policies.test.ts` "client identity trusts forwarded headers only from configured proxies" and `test/standards.test.ts`; update §9 and §21.
310
+ - A change to the YAML profile or schema dialect touches `parseYaml` and `validate` in `src/config.ts`, `test/config.test.ts` "YAML rejects ambiguity ..." and "strict schema rejects unknown features ..."; update §19–§20.
311
+ - When a gap above is closed, delete its row from the ranked list and flip the row status in the table; when a new standard is touched, add a section with the same four columns (requirement, status, behavior citing the symbol, test name). Cite functions and constants, not line numbers: a symbol is checkable with `grep` after the file changes around it.