@jimhoyd/urlcode 0.4.2 → 0.4.6

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 (214) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +57 -14
  2. package/.claude/skills/urlcode-operations/SKILL.md +4 -0
  3. package/README.md +19 -15
  4. package/SECURITY.md +5 -3
  5. package/dist/BUILD-MANIFEST.json +30 -27
  6. package/dist/agent-context.js +82 -0
  7. package/dist/agents-guide.js +42 -42
  8. package/dist/authoring.js +12 -2
  9. package/dist/body-schema.js +159 -0
  10. package/dist/build-cloudflare.js +2 -0
  11. package/dist/capabilities.js +1 -1
  12. package/dist/cli.js +28 -12
  13. package/dist/config.js +74 -6
  14. package/dist/context.js +4 -6
  15. package/dist/errors.js +3 -1
  16. package/dist/examples.js +1 -1
  17. package/dist/extensions.js +60 -2
  18. package/dist/http-policy.js +19 -4
  19. package/dist/http-response.js +2 -2
  20. package/dist/init-with.js +71 -9
  21. package/dist/mcp.js +15 -2
  22. package/dist/pattern-guard.js +32 -0
  23. package/dist/policies/security.js +0 -0
  24. package/dist/project-tests.js +35 -11
  25. package/dist/readiness.js +184 -32
  26. package/dist/recipes.js +1 -1
  27. package/dist/router.js +17 -0
  28. package/dist/runtime.js +26 -3
  29. package/dist/scaffold.js +0 -0
  30. package/dist/server.js +26 -3
  31. package/dist/site.js +0 -0
  32. package/dist/tooling.js +2 -1
  33. package/dist/types/agent-context.d.ts +44 -0
  34. package/dist/types/authoring.d.ts +3 -1
  35. package/dist/types/body-schema.d.ts +58 -0
  36. package/dist/types/config.d.ts +10 -2
  37. package/dist/types/context.d.ts +1 -1
  38. package/dist/types/errors.d.ts +9 -1
  39. package/dist/types/examples.d.ts +1 -1
  40. package/dist/types/extensions.d.ts +47 -2
  41. package/dist/types/http-policy.d.ts +3 -0
  42. package/dist/types/init-with.d.ts +10 -1
  43. package/dist/types/pattern-guard.d.ts +10 -0
  44. package/dist/types/project-tests.d.ts +9 -0
  45. package/dist/types/readiness.d.ts +68 -0
  46. package/dist/types/recipes.d.ts +1 -1
  47. package/dist/types/runtime.d.ts +4 -0
  48. package/dist/types/server.d.ts +9 -1
  49. package/dist/types/tooling.d.ts +1 -0
  50. package/dist/types/types.d.ts +13 -0
  51. package/dist/types.js +10 -2
  52. package/dist/typescript-authoring.js +5 -3
  53. package/dist/verify-deployment.js +16 -10
  54. package/examples/body-validation/README.md +16 -0
  55. package/examples/body-validation/example.yaml +17 -0
  56. package/examples/body-validation/tests/requests.json +15 -0
  57. package/examples/body-validation/urlcode.yaml +34 -0
  58. package/examples/coverage-waiver/README.md +8 -0
  59. package/examples/coverage-waiver/example.yaml +16 -0
  60. package/examples/coverage-waiver/functions/notes.mjs +2 -0
  61. package/examples/coverage-waiver/tests/requests.json +3 -0
  62. package/examples/coverage-waiver/urlcode.yaml +10 -0
  63. package/examples/data-dir/README.md +39 -0
  64. package/examples/data-dir/data/welcome.txt +1 -0
  65. package/examples/data-dir/example.yaml +22 -0
  66. package/examples/data-dir/functions/note.mjs +18 -0
  67. package/examples/data-dir/tests/requests.json +6 -0
  68. package/examples/data-dir/urlcode.yaml +6 -0
  69. package/examples/lifecycle/README.md +15 -0
  70. package/examples/lifecycle/example.yaml +19 -0
  71. package/examples/lifecycle/functions/notes.mjs +29 -0
  72. package/examples/lifecycle/tests/requests.json +12 -0
  73. package/examples/lifecycle/urlcode.yaml +29 -0
  74. package/examples/not-found/README.md +10 -0
  75. package/examples/not-found/example.yaml +17 -0
  76. package/examples/not-found/public/404.html +3 -0
  77. package/examples/not-found/public/index.html +3 -0
  78. package/examples/not-found/tests/requests.json +7 -0
  79. package/examples/not-found/urlcode.yaml +7 -0
  80. package/examples/shared-blocks/README.md +11 -0
  81. package/examples/shared-blocks/example.yaml +17 -0
  82. package/examples/shared-blocks/tests/requests.json +7 -0
  83. package/examples/shared-blocks/urlcode.yaml +37 -0
  84. package/llms-full.txt +364 -49
  85. package/llms.txt +43 -5
  86. package/package.json +15 -22
  87. package/recipes/static-page/README.md +9 -0
  88. package/recipes/static-page/public/index.html +11 -0
  89. package/recipes/static-page/recipe.yaml +21 -0
  90. package/recipes/static-page/tests/requests.json +22 -0
  91. package/recipes/static-page/urlcode.yaml +7 -0
  92. package/recipes/static-plus-api/README.md +6 -0
  93. package/recipes/static-plus-api/urlcode.yaml +4 -0
  94. package/recipes/store-crud/README.md +53 -0
  95. package/recipes/store-crud/recipe.yaml +31 -0
  96. package/recipes/store-crud/tests/requests.json +18 -0
  97. package/recipes/store-crud/urlcode.yaml +18 -0
  98. package/schemas/urlcode.schema.json +125 -60
  99. package/skills/urlcode/SKILL.md +47 -27
  100. package/starters/default/AGENTS.md +43 -43
  101. package/starters/page/README.md +14 -0
  102. package/starters/page/public/index.html +12 -0
  103. package/starters/page/tests/requests.json +17 -0
  104. package/starters/page/urlcode.yaml +6 -0
  105. package/.claude-plugin/marketplace.json +0 -18
  106. package/CONTRIBUTING.md +0 -148
  107. package/ROADMAP.md +0 -70
  108. package/docs/AI-AUTHORING.md +0 -339
  109. package/docs/ASSETS.md +0 -107
  110. package/docs/AUTH-BACKUP.md +0 -32
  111. package/docs/AWS.md +0 -86
  112. package/docs/BEST-PRACTICES.md +0 -276
  113. package/docs/BULK.md +0 -79
  114. package/docs/CAPABILITIES.md +0 -192
  115. package/docs/CAPACITY.md +0 -305
  116. package/docs/CI-FOLLOWUP-2026-09-19.md +0 -97
  117. package/docs/CI-RELEASE-AUDIT-2026-09-19.md +0 -322
  118. package/docs/CI.md +0 -147
  119. package/docs/CLOUDFLARE.md +0 -109
  120. package/docs/CODEBASE-AUDIT-2026-09-20.md +0 -284
  121. package/docs/COMPLIANCE.md +0 -239
  122. package/docs/COMPOSING-A-SITE.md +0 -287
  123. package/docs/CONDITIONS.md +0 -74
  124. package/docs/CONTAINER-PROMOTION.md +0 -74
  125. package/docs/DEPLOYMENT-CHECKS.md +0 -108
  126. package/docs/DEVELOPMENT-PIPELINE.md +0 -304
  127. package/docs/EGRESS.md +0 -125
  128. package/docs/EXTENSIONS.md +0 -403
  129. package/docs/FRAMEWORK.md +0 -217
  130. package/docs/FUNCTION-SECURITY.md +0 -251
  131. package/docs/HTTP.md +0 -129
  132. package/docs/INSTALL.md +0 -128
  133. package/docs/INTERCHANGE.md +0 -134
  134. package/docs/LOAD-TESTING.md +0 -91
  135. package/docs/LOCAL-DEVELOPMENT.md +0 -102
  136. package/docs/MIDDLEWARE-EXAMPLES.md +0 -75
  137. package/docs/MIDDLEWARE.md +0 -102
  138. package/docs/MONITORING.md +0 -115
  139. package/docs/OBSERVABILITY.md +0 -222
  140. package/docs/OPEN-DECISIONS.md +0 -149
  141. package/docs/OPERATIONAL-PROOF.md +0 -41
  142. package/docs/OPERATIONS.md +0 -201
  143. package/docs/ORGANIZATION.md +0 -135
  144. package/docs/PERFORMANCE.md +0 -72
  145. package/docs/PLUGINS.md +0 -271
  146. package/docs/POLICIES.md +0 -211
  147. package/docs/PRERENDER.md +0 -245
  148. package/docs/PROJECT-DIRECTION.md +0 -118
  149. package/docs/PROVIDER-VERIFICATION.md +0 -84
  150. package/docs/READINESS.md +0 -153
  151. package/docs/README.md +0 -93
  152. package/docs/RECIPES.md +0 -99
  153. package/docs/RELEASE-0.4.0-alpha.3.md +0 -50
  154. package/docs/RELEASE-0.4.1.md +0 -73
  155. package/docs/RELEASE-0.4.2.md +0 -30
  156. package/docs/RELEASE-READINESS.md +0 -146
  157. package/docs/RELEASE-SECURITY.md +0 -100
  158. package/docs/RESILIENCE.md +0 -161
  159. package/docs/ROUTING.md +0 -92
  160. package/docs/SANDBOX-REVIEW.md +0 -72
  161. package/docs/SCAFFOLDING.md +0 -70
  162. package/docs/SECURITY-AUDIT.md +0 -164
  163. package/docs/SITE.md +0 -150
  164. package/docs/SPECIFICATION.md +0 -359
  165. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +0 -288
  166. package/docs/SPIKE-BUSINESS-SUITE.md +0 -1029
  167. package/docs/SPIKE-CORE-LAYERING.md +0 -368
  168. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +0 -207
  169. package/docs/STANDARDS.md +0 -311
  170. package/docs/STARTERS.md +0 -83
  171. package/docs/STATIC.md +0 -105
  172. package/docs/TOOLING.md +0 -298
  173. package/docs/TUNNELS.md +0 -72
  174. package/docs/TYPESCRIPT-AUTHORING.md +0 -87
  175. package/docs/TYPESCRIPT.md +0 -123
  176. package/docs/VERCEL.md +0 -114
  177. package/docs/VERSION-ALIGNMENT.md +0 -88
  178. package/docs/YAML-GUIDE.md +0 -57
  179. package/docs/YAML-REFERENCE.md +0 -449
  180. package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +0 -68
  181. package/docs/archive/2026-09-19/MANAGEMENT-SECURITY.md +0 -102
  182. package/docs/archive/2026-09-19/NEXT-PHASE-PLAN.md +0 -108
  183. package/docs/archive/2026-09-19/NEXT-STEPS.md +0 -646
  184. package/docs/archive/2026-09-19/OPEN-DECISIONS.md +0 -277
  185. package/docs/archive/2026-09-19/RELEASE-SECURITY.md +0 -186
  186. package/docs/archive/2026-09-19/ROADMAP.md +0 -387
  187. package/docs/archive/2026-09-19/SPIKE-EXTENSION-MODEL.md +0 -430
  188. package/docs/archive/2026-09-19/SPIKE-EXTENSIONS.md +0 -492
  189. package/docs/archive/2026-09-19/SPIKE-LAMBDA-COMPILE.md +0 -365
  190. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +0 -778
  191. package/docs/archive/2026-09-19/USABILITY-REVIEW.md +0 -139
  192. package/docs/archive/2026-09-20/OPEN-DECISIONS-COMPLETED.md +0 -116
  193. package/docs/archive/README.md +0 -29
  194. package/docs/policies/agents.md +0 -182
  195. package/docs/policies/cache.md +0 -152
  196. package/docs/policies/compression.md +0 -169
  197. package/docs/policies/contract.md +0 -52
  198. package/docs/policies/hardened.md +0 -56
  199. package/docs/policies/interoperability.md +0 -169
  200. package/docs/policies/operations.md +0 -45
  201. package/docs/policies/security.md +0 -161
  202. package/docs/policies/throttle.md +0 -103
  203. package/docs/yaml/assets.md +0 -36
  204. package/docs/yaml/conditions.md +0 -20
  205. package/docs/yaml/functions.md +0 -168
  206. package/docs/yaml/middleware.md +0 -31
  207. package/docs/yaml/organization.md +0 -74
  208. package/docs/yaml/policies.md +0 -37
  209. package/docs/yaml/redirects.md +0 -64
  210. package/docs/yaml/responses.md +0 -57
  211. package/docs/yaml/site.md +0 -24
  212. package/packaging/claude-plugin/.claude-plugin/plugin.json +0 -19
  213. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +0 -130
  214. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +0 -108
@@ -1,492 +0,0 @@
1
- # Historical record
2
-
3
- Archived 2026-09-19. This records an earlier implementation or proposal, not
4
- current instructions. See the [current roadmap](../../../ROADMAP.md),
5
- [current contract](../../SPECIFICATION.md) and [open decisions](../../OPEN-DECISIONS.md).
6
- Remaining acceptance work is not declared complete by archiving this record.
7
-
8
- <!-- trust-model-prose: historical-file -->
9
- <!-- guidance-claims: ignore-file -->
10
-
11
- # Spike: plugins, adapters and optional runtime features
12
-
13
- > Status update: the host hook seam, the plugin API and the five policies of
14
- > section 5 are implemented; [policies](../../POLICIES.md) and [plugins](../../PLUGINS.md)
15
- > describe the shipped behavior, which takes precedence where this text differs.
16
- >
17
- > Also stale: the embedding-API row below (`openLinkStore`, `startLinkApi`)
18
- > and the `link` mentions in the gaps table describe core from before `link`
19
- > was extracted. Core has no native `link`/`LinkStore` API any more, and the
20
- > `urlcode-dynamic-link` package it was extracted into has since been retired,
21
- > unpublished and deleted (September 2026).
22
-
23
- Status: exploratory. Nothing here is committed scope; it records what the
24
- runtime has today, how comparable tools expose the same needs, which gaps
25
- matter for real deployments, and a proposed shape for closing them without
26
- breaking the [project boundary](../../PROJECT-DIRECTION.md). The
27
- [roadmap](ROADMAP.md) owns sequence; the [specification](../../SPECIFICATION.md)
28
- owns what is implemented.
29
-
30
- Every feature below is **optional and off by default**. A `version: "1"`
31
- project with none of them keeps its current behavior byte for byte.
32
-
33
- ## 1. What exists today
34
-
35
- ### Extension surfaces
36
-
37
- | Surface | Where | Who can use it | Runs where |
38
- |---|---|---|---|
39
- | Route middleware | `routes.<path>.middleware[]` | Project author | QuickJS/WASM guest, one chain per request, 16 max, route-local only |
40
- | Functions | `routes.<path>.function` | Project author | Same guest, one per route |
41
- | Provider adapters | `@jimhoyd/urlcode/vercel`, `@jimhoyd/urlcode/aws`, `@jimhoyd/urlcode/cloudflare` | Operator | Host process; wrap `runtime.handle()` |
42
- | Build helpers | `@jimhoyd/urlcode/prerender` | Operator/build | Host process, build time only |
43
- | Embedding API | `createRuntime`, `startServer`, `openLinkStore`, `startLinkApi` | Operator application | Host process |
44
- | Operator policy | `URLCODE_POLICY` / policy file | Operator | Host, revision-pinned grants |
45
-
46
- Two facts shape every proposal in this spike:
47
-
48
- 1. **There is no trusted, host-side, project-declared hook.** The only
49
- project-level code path is guest middleware, which cannot see the socket,
50
- client address, other requests or a clock beyond `Date.now()`. Anything that
51
- needs cross-request state (rate counters, caches) or raw transport
52
- (compression, connection budgets) cannot be middleware under the current
53
- isolation model, and must not become one.
54
- 2. **There is no global middleware.** Every wrap is per route. Cross-cutting
55
- behavior such as "block these agents everywhere" has to be repeated on
56
- every route, and cannot cover native routes without dragging them into the
57
- sandbox and losing their fast path.
58
-
59
- ### Request pipeline (self-hosted server)
60
-
61
- ```
62
- socket limits (1024 conns, 15 s idle, 1000 req/socket)
63
- → in-flight admission (64 app, 16 health)
64
- → body read (bounded)
65
- → runtime.handle(): match → enabled/expires → methods → request.body policy
66
- → [guest chain: middleware… → handler] or native handler
67
- → response.headers overrides → writeResponse
68
- ```
69
-
70
- Adapters call `handle()` directly and skip the socket/admission stages,
71
- delegating them to the provider.
72
-
73
- ### Already answered, in the operator's stack
74
-
75
- The docs are consistent that these belong at ingress, not in route YAML:
76
- rate limiting, WAF, TLS, DDoS mitigation, forwarded-header trust
77
- ([resilience](../../RESILIENCE.md), [capacity](../../CAPACITY.md)). Compression, CORS,
78
- content negotiation and streaming are listed as explicitly outside the
79
- [HTTP contract](../../HTTP.md). Caching is declarative and limited to a fixed
80
- `cacheControl` vocabulary on asset handlers ([assets](../../ASSETS.md)).
81
-
82
- ## 2. How comparable tools do it
83
-
84
- | Concern | Express / Koa | Fastify | Hono | Caddy | NGINX | Cloudflare / Vercel / Netlify | URLCode today |
85
- |---|---|---|---|---|---|---|---|
86
- | Plugin model | `app.use(fn)` global + router-local | `fastify.register(plugin, opts)` with encapsulation, `fastify-plugin` to break out; typed hooks (`onRequest`, `preHandler`, `onSend`…) | `app.use(path?, mw)`; first-party `hono/*` middleware | Modules compiled in; JSON/Caddyfile directives | Compiled modules; directives per `location` | Config file + edge middleware (`middleware.ts`, `_middleware`) | Route-local guest middleware only |
87
- | Throttle | `express-rate-limit` (memory/Redis stores) | `@fastify/rate-limit` | `hono-rate-limiter` | `rate_limit` (plugin) | `limit_req`, `limit_conn` | Provider WAF/rate rules; Vercel Firewall; Netlify Rate Limiting | Global in-flight cap only; no per-client budget |
88
- | Block bots / UA | `express-useragent`, `isbot` in middleware | `isbot` in hook | `isbot` in middleware | Request matchers on `header` | `map $http_user_agent`, `if`, `deny` | Bot Fight Mode, Super Bot Fight, WAF managed rules | None; would need per-route guest middleware, cannot cover native routes |
89
- | Injection / hardening | `helmet`, `express-validator` | `@fastify/helmet`, schema validation built-in | `secureHeaders`, `validator` | Built-in header directives | Header directives | Managed WAF rulesets | Header injection prevented at runtime; body JSON syntax check; **no** security-header preset, no schema body validation |
90
- | Compression | `compression` | `@fastify/compress` | `hono/compress` | `encode gzip zstd` | `gzip on; brotli` | Automatic at edge | None; identity only |
91
- | Caching | `apicache`, CDN | `@fastify/caching` | `hono/cache` | `cache` (plugin) | `proxy_cache` | Edge cache + `Cache-Control`, ISR | Asset `cacheControl` vocabulary; no response cache |
92
- | Templates | `res.render()`, view engines | `@fastify/view` | `hono/jsx`, `html` helper | `templates` directive | SSI | Framework-owned | None at request time; build-time [prerender](../../PRERENDER.md) only |
93
- | Adapters | `serverless-http`, `@vendia`, `@hono/node-server` | `@fastify/aws-lambda` | First-party adapters for every runtime | n/a | n/a | n/a | Vercel, AWS, Cloudflare; native handlers only |
94
-
95
- Three patterns recur and are worth borrowing:
96
-
97
- - **Fastify's encapsulated `register` with typed lifecycle hooks.** Plugins
98
- declare which phase they attach to. That gives a static, inspectable plugin
99
- graph, which is exactly the property URLCode wants for a portable format.
100
- - **Hono's "one first-party set of small middleware, each optional".** Small,
101
- named, zero-config-by-default modules, every one of which is also expressible
102
- on any adapter. That matches the "declarative first, code only when needed"
103
- contract.
104
- - **Caddy/NGINX declarative directives.** Behavior is data, not code, and can
105
- be validated and exported. URLCode YAML is already this. The gap is that the
106
- vocabulary stops at routes and headers.
107
-
108
- ## 3. Gap list
109
-
110
- Ranked by how often a self-hosted operator hits it before the first deploy.
111
-
112
- | # | Gap | Evidence | Severity |
113
- |---|---|---|---|
114
- | G1 | No cross-cutting (global) behavior | Middleware is route-local; every operator concern is copy-pasted per route or done at ingress | High |
115
- | G2 | No per-client throttle | Only the 64/16 in-flight caps; shortener demo built its own | High |
116
- | G3 | No bot / user-agent policy | Retrospectives, resilience doc: "filter upstream" | High for short-link use cases (crawlers hitting redirects) |
117
- | G4 | No security-header preset | `helmet`-equivalent is the first thing every framework user adds | Medium |
118
- | G5 | No compression | Assets and JSON go uncompressed unless a proxy handles it | Medium |
119
- | G6 | No response cache / stale-while-revalidate | Functions are `no-store`; a hot function re-runs the sandbox per hit | Medium |
120
- | G7 | No request-time template layer | Native bodies are opaque by design; prerender covers static sites but not dynamic pages | Medium |
121
- | G8 | No host-side plugin contract | Adapters and `startServer` are the only host extension points and are undocumented as such | Medium |
122
- | G9 | Adapters refuse functions/middleware/links | Documented and deliberate, but it means any feature built as guest middleware is also refused on serverless | Design constraint |
123
- | G10 | No JSON Schema body validation | `request.body.format: json` checks syntax only | Low |
124
- | G11 | No CORS preflight helper | Documented gap | Low |
125
- | G12 | No SPA fallback for client-routed apps | [Assets](../../ASSETS.md) rules it out beside directory listing and trailing-slash redirects; a host plugin cannot supply it either, because an unmatched path throws 404 before the request object or any plugin hook exists | Low |
126
-
127
- ## 4. Design constraints these must respect
128
-
129
- From [AGENTS.md](../../../AGENTS.md), [project direction](../../PROJECT-DIRECTION.md)
130
- and [function security](../../FUNCTION-SECURITY.md):
131
-
132
- - Route YAML describes **behavior**, not infrastructure. A throttle budget is
133
- behavior ("this route allows 10 requests per minute per client"); a Redis
134
- URL is infrastructure and stays in operator config.
135
- - Guest code stays untrusted and capability-free. None of the features below
136
- expose new host APIs to the sandbox.
137
- - A project must run on a laptop, a container and an adapter. Every feature
138
- needs a documented answer for each target: native, refused at activation,
139
- or delegated to the provider with the same declared semantics.
140
- - Unknown YAML fields fail. New vocabulary must land in the schema with
141
- generated reference docs and executable examples.
142
-
143
- ## 5. Proposal: a `policies` block plus host-side plugins
144
-
145
- Two additions, both optional. Declarative policies cover the common cases
146
- without code; a host plugin contract covers the rest for operators who embed
147
- the runtime.
148
-
149
- The runtime stays generic: it ships mechanisms and named profiles, never an
150
- opinion about who should be blocked or which vendor should sit in front of
151
- the origin. Recommendations for a hardened deployment are collected in
152
- section 6 as guidance an operator applies, not as defaults the runtime
153
- imposes.
154
-
155
- ### 5.1 Portability rule
156
-
157
- A project file is portable when a second person can run it on a different
158
- host and get the same declared behavior or an explicit refusal. Every policy
159
- therefore follows four rules:
160
-
161
- 1. **Vocabulary comes from a published standard** wherever one exists, so
162
- the values are already documented outside this project and can be
163
- translated to any proxy, CDN or framework.
164
- 2. **No operator identity in YAML.** Trusted proxy ranges, storage URLs,
165
- list-refresh credentials and vendor rule identifiers live in operator
166
- configuration (`urlcode serve` flags, environment, policy file), never in
167
- the project.
168
- 3. **Enforce or refuse, never degrade silently.** Each policy has a
169
- per-target row (self-hosted, Vercel/AWS, Cloudflare). A target that cannot
170
- enforce a policy refuses activation naming the route and policy, exactly as
171
- adapters already refuse functions. `urlcode audit` reports the table.
172
- 4. **Deterministic on identical input.** Given the same request bytes and the
173
- same project, every target answers the same status and headers; only
174
- cross-request state (throttle counters, cache hits) may differ, and that
175
- difference is documented.
176
-
177
- ### 5.2 Declarative `policies`
178
-
179
- ```yaml
180
- version: "1"
181
- policies: # project defaults; each key optional
182
- profile: hardened # named preset, see 6.1; explicit keys override
183
- throttle:
184
- quota: 60 # RateLimit-Policy: "default";q=60;w=60
185
- window: 60
186
- partition: client # client | route | client-route
187
- status: 429
188
- agents:
189
- deny: [ai-crawlers] # named list bundled with the runtime, see 5.3
190
- denyPatterns: ["^curl/"] # anchored, bounded, linear-time subset
191
- allowPatterns: ["^Mozilla/5\\.0 \\(compatible; Googlebot"]
192
- status: 403
193
- security:
194
- headers: oshp # oshp | oshp-no-csp | off
195
- compression:
196
- encodings: [br, gzip] # RFC 9110 content codings, preference order
197
- minBytes: 1024
198
- types: [text/*, application/json, application/javascript, image/svg+xml]
199
- cache:
200
- strategy: swr # see 5.4 catalogue
201
- maxAge: 30
202
- staleWhileRevalidate: 300
203
- vary: [accept-language]
204
-
205
- routes:
206
- /api/lookup/{id}:
207
- function: { source: functions/lookup.mjs }
208
- policies:
209
- throttle: { quota: 10, window: 60 }
210
- cache: false
211
- ```
212
-
213
- | Policy | Standard it is expressed in | Self-hosted | Vercel / AWS | Cloudflare build |
214
- |---|---|---|---|---|
215
- | throttle | RFC 6585 (429), RFC 9110 `Retry-After`, IETF httpapi `RateLimit`/`RateLimit-Policy` draft fields | native, in-process | refused unless `partition: route` (no shared state) | refused; guide maps quota/window to a provider rate rule |
216
- | agents | RFC 9110 `User-Agent` product tokens; RFC 9309 for the companion `robots.txt`; bot-auth drafts for verified allow | native | native | compiled |
217
- | security | OWASP Secure Headers Project values; CSP Level 3; RFC 6797 HSTS | native | native | compiled |
218
- | compression | RFC 9110 `Accept-Encoding`/`Content-Encoding`; RFC 1952 gzip, RFC 7932 brotli, RFC 8878 zstd | native, assets precompressed at snapshot | refused: provider does it | refused: provider does it |
219
- | cache | RFC 9111; RFC 5861 `stale-while-revalidate`/`stale-if-error`; RFC 8246 `immutable`; RFC 9213 `CDN-Cache-Control` | native origin cache plus headers | headers only | headers only |
220
-
221
- **Throttle.** Values mirror the IETF `RateLimit-Policy` structured field
222
- (`q` quota, `w` window in seconds) so the runtime can emit
223
- `RateLimit-Policy` and `RateLimit` on every response and `Retry-After` on
224
- 429 without inventing a second vocabulary; an NGINX `limit_req` or a CDN
225
- rule expresses the same numbers. Algorithm is a sliding-window counter,
226
- the standard middle ground between fixed windows (burst at boundaries) and
227
- token buckets (harder to explain in headers). `client` identity is the
228
- socket peer unless `urlcode serve --trusted-proxies <cidr,...>` names the
229
- proxies allowed to set `X-Forwarded-For`; only `X-Forwarded-For` is read;
230
- RFC 7239 `Forwarded` is not parsed. Counters are in-process with a bounded LRU
231
- table; multi-instance sharing is a host plugin concern (5.5).
232
-
233
- **Agents.** Matching is against the `User-Agent` field only; product tokens
234
- are compared case-insensitively. `deny` names a bundled list; patterns use
235
- a linear-time subset (anchors, classes, alternation, bounded repetition,
236
- no backreferences or lookaround, 256 bytes max) so a project cannot make
237
- the matcher a ReDoS vector. `allowPatterns` win over `deny`, which lets an
238
- operator keep a search crawler while denying a category. The runtime
239
- matches strings only; verifying that a claimed agent is genuine (reverse
240
- DNS as documented by the major search engines, or the HTTP Message
241
- Signature based web-bot-auth drafts) is a plugin concern. Denials answer
242
- with the configured status and an empty body and log the list name, never
243
- the raw header. A `robots.txt` route remains an ordinary `respond` or
244
- `page` handler; `urlcode init` can generate one from the same lists.
245
-
246
- **Security headers.** Profiles copy the current OWASP Secure Headers
247
- Project recommended values verbatim and record the OSHP revision in the
248
- generated reference, so "what does `oshp` set" is answerable from a public
249
- source. Explicit `response.headers` override profile values header by
250
- header. HSTS is only emitted when `--origin` is `https`.
251
-
252
- **Compression.** Negotiation follows RFC 9110 `Accept-Encoding` q-values
253
- with the project's `encodings` order as tie-breaker. Skipped when the
254
- response already carries `Content-Encoding`, is `206`, carries
255
- `Cache-Control: no-transform`, or is below `minBytes`. Asset snapshots are
256
- precompressed at load, the same trick as NGINX `gzip_static` and Caddy
257
- `precompressed`, so a request costs a buffer copy. The runtime adds
258
- `Vary: Accept-Encoding`. When a response carries a session cookie or a
259
- route declares `secrets`, compression is skipped (BREACH mitigation) unless
260
- the route says `compression: { allowWithSecrets: true }`.
261
-
262
- ### 5.3 Bot lists: which to bundle and how to refresh
263
-
264
- Vendoring a list into an Apache-2.0 package requires a license that allows
265
- redistribution with attribution and no share-alike obligation. Findings:
266
-
267
- | Source | License | Format | Maintenance | Bundle? |
268
- |---|---|---|---|---|
269
- | [ai-robots-txt/ai.robots.txt](https://github.com/ai-robots-txt/ai.robots.txt) | MIT | `robots.json` plus generated `robots.txt`, NGINX, Caddy, HAProxy, Apache files | Tagged releases, Atom feed, GitHub Action regenerates outputs from JSON | **Yes**: `ai-crawlers` |
270
- | [monperrus/crawler-user-agents](https://github.com/monperrus/crawler-user-agents) | MIT (CC-SA before 2016-11-07; use only later revisions) | JSON with `pattern`, `url`, `instances`, `tags` | npm/PyPI/Go packages, PR-driven | **Yes**: `crawlers`, tags give `seo`, `monitoring` sub-lists |
271
- | [atmire/COUNTER-Robots](https://github.com/atmire/COUNTER-Robots) | MIT | JSON with `pattern`, dates, generated plain text | Library-statistics community, periodic | Optional: `counter-robots` for analytics-exclusion use |
272
- | [omrilotan/isbot](https://github.com/omrilotan/isbot) | Unlicense (public domain) | Aggregated regex parts exported as `list` | npm releases; aggregates the two above plus device-detector and vendor lists | Not directly: it includes LGPL-derived device-detector data, so vendor its upstream sources instead |
273
- | matomo device-detector | LGPL-3.0 | YAML regexes | Active | **No**: copyleft data, not vendored |
274
- | Cloudflare / Google / Bing verified-bot data | Proprietary or API-only | Reverse-DNS and IP ranges | Vendor | **No**: verification belongs in a plugin that calls the vendor |
275
- | IAB/ABC spiders list | Paid, proprietary | Text | Commercial | **No** |
276
-
277
- Bundling plan:
278
-
279
- - Ship `data/agents/<list>.json` normalized to one schema
280
- (`{name, pattern, source, sourceRevision, addedAt}`), with each upstream
281
- `LICENSE` reproduced under `data/agents/LICENSES/` and named in `NOTICE`
282
- as Apache-2.0 §4(d) requires.
283
- - A `scripts/sync-agent-lists.ts` pulls pinned upstream tags, validates every
284
- pattern against the linear-time subset (rejecting or rewriting the rest),
285
- and records the upstream revision. Refresh is a normal pull request run by
286
- Dependabot-style automation on a schedule; a release notes the list
287
- revisions it carries, and `urlcode doctor` prints them.
288
- - Projects may also point at their own file (`deny: [./agents/deny.json]`)
289
- in the same schema, which keeps the YAML portable while letting an
290
- operator use a list the project does not want to redistribute.
291
-
292
- ### 5.4 Caching strategies catalogue
293
-
294
- Caching is where "just one setting" fails users most. The policy therefore
295
- names a strategy from a fixed catalogue; each row is a standard pattern
296
- with a known name outside this project and a defined header output, so a
297
- CDN or proxy in front of the origin interprets the result correctly.
298
-
299
- | `strategy` | Emitted headers | Origin memory cache | Typical use |
300
- |---|---|---|---|
301
- | `no-store` (current function/redirect default) | `Cache-Control: no-store` | off | personalized, secret-bearing |
302
- | `revalidate` (current asset default) | `Cache-Control: no-cache`, `ETag`, `Last-Modified`; answers `304` to `If-None-Match`/`If-Modified-Since` | off | HTML, anything that must be fresh but is cheap to validate |
303
- | `public` | `Cache-Control: public, max-age=N` | optional | stable API answers, feeds |
304
- | `immutable` | `Cache-Control: public, max-age=31536000, immutable` (RFC 8246) | off | content-hashed asset URLs only; the runtime refuses it on unhashed paths unless `force: true` |
305
- | `swr` | `Cache-Control: public, max-age=N, stale-while-revalidate=M` (RFC 5861) | on: serves stale and refreshes once in the background | hot functions, link previews |
306
- | `sie` | adds `stale-if-error=M` (RFC 5861) | on | keep answering during an upstream failure |
307
- | `micro` | `Cache-Control: no-store` to clients; origin cache TTL of 1 to 5 seconds | on | the NGINX micro-caching pattern: absorb thundering herds on a function without changing client semantics |
308
- | `cdn-only` | `Cache-Control: no-store` plus `CDN-Cache-Control: max-age=N` (RFC 9213) | off | let the CDN cache while browsers do not |
309
- | `private` | `Cache-Control: private, max-age=N` | off | per-user data that a browser may keep |
310
-
311
- Rules common to all strategies:
312
-
313
- - Only `GET`/`HEAD` and status 200, 301, 302, 404, 410 enter the origin
314
- cache. Responses carrying `Set-Cookie`, routes declaring `secrets`, and
315
- responses with `Cache-Control: private` or `no-store` are never stored.
316
- - Cache key is method, path, query and the declared `vary` headers, and
317
- the runtime emits a matching `Vary`. `Accept-Encoding` is added
318
- automatically when compression is on.
319
- - The origin cache is keyed by snapshot version and dropped on reload, so a
320
- deploy never serves the previous code's output.
321
- - Concurrent misses for one key coalesce into one handler invocation
322
- (`singleflight`, NGINX `proxy_cache_lock`), which is the actual reason
323
- to cache a function at all.
324
- - Memory bound and hit/miss/stale counters are exposed through the health
325
- endpoint and request logs.
326
- - Surrogate keys and purge (`Surrogate-Key`, `Cache-Tag`) are out of scope
327
- for the runtime; a plugin can add them.
328
-
329
- ### 5.5 Host-side plugin contract (`@jimhoyd/urlcode/plugins`)
330
-
331
- For operators embedding the runtime who need behavior the declarative block
332
- cannot express, add a small, documented, host-trusted hook API modelled on
333
- Fastify's phases. Plugins are **not** part of the project format; they are
334
- passed by the operator application to `startServer`/`createRuntime`, so a
335
- project stays portable while an operator can still add a shared-store
336
- limiter, a verified-bot check or a cache purge endpoint.
337
-
338
- ```js
339
- import { startServer } from '@jimhoyd/urlcode';
340
-
341
- await startServer({
342
- project: './site',
343
- plugins: [
344
- {
345
- name: 'shared-throttle', version: '1.0.0', targets: ['node'],
346
- onActivate(runtime) { /* inspect runtime.testPlan(); throw to refuse */ },
347
- onRequest(ctx) { /* ctx.method, path, headers, client; return a result to short-circuit */ },
348
- onResponse(ctx, result) { /* return a replaced result; body stays a Buffer */ },
349
- onError(ctx, error) {},
350
- onClose() {},
351
- },
352
- ],
353
- });
354
- ```
355
-
356
- Rules:
357
-
358
- - No hook can reach inside the guest, extend a deadline, or see bindings.
359
- - Plugins declare `name`, `version` and `targets`; activation refuses a
360
- plugin on a host it does not list, mirroring route refusal.
361
- - The declarative `policies` are implemented on this same interface, so
362
- first-party and third-party behavior share one code path and one test
363
- harness.
364
- - Plugins are host code and therefore the operator's trust boundary, not
365
- the project's. The documentation must say so plainly.
366
-
367
- ### 5.6 Templates
368
-
369
- Request-time templating conflicts with the opaque-native-body rule, and the
370
- [prerender](../../PRERENDER.md) helper already handles the static case. Two
371
- bounded options fit the boundary:
372
-
373
- 1. **Build-time only (recommended first).** Promote prerender into a CLI
374
- command, `urlcode build --prerender`, and add a `layouts` convention in
375
- the starter so a page function can import a layout module and the output
376
- is native. Zero runtime change, works on every target.
377
- 2. **Declarative `page.template` (later, if demanded).** A `page` handler
378
- may name a template file plus a `slots` map of literal strings or
379
- validated inputs. Rendering is pure substitution with contextual HTML
380
- escaping, done at snapshot time for literal slots and on the request path
381
- only for input-driven slots. No expressions, no loops, no guest code.
382
- Closer to server-side includes than to a view engine, and that is the
383
- point: inspectable and portable.
384
-
385
- A general view engine is out of scope: it would be a second code path with
386
- its own sandbox questions.
387
-
388
- ## 6. Hardened configuration guidance
389
-
390
- This section is advice, not defaults. It reflects patterns that have held
391
- up under public traffic in the frameworks and proxies surveyed in section 2.
392
-
393
- ### 6.1 The `hardened` profile
394
-
395
- `policies.profile: hardened` expands to the following and nothing else, so
396
- it can be read in one place and overridden key by key:
397
-
398
- ```yaml
399
- policies:
400
- security: { headers: oshp }
401
- agents: { deny: [ai-crawlers], status: 403 }
402
- throttle: { quota: 120, window: 60, partition: client, status: 429 }
403
- compression: { encodings: [br, gzip], minBytes: 1024 }
404
- cache: { strategy: revalidate }
405
- ```
406
-
407
- The numbers are starting points chosen to be safe for a single small
408
- instance, not tuned for any workload. A `strict` profile is deliberately
409
- not offered: anything stricter is a per-project decision.
410
-
411
- ### 6.2 Layering, in order of where a request is stopped
412
-
413
- 1. **Network and edge.** Volumetric protection, TLS termination and
414
- per-client connection budgets stay with the hosting provider or the
415
- reverse proxy, as [resilience](../../RESILIENCE.md) already states. Runtime
416
- policies are a second layer, never the first.
417
- 2. **Ingress to origin.** Bind privately; allow only the proxy's addresses;
418
- pass `--trusted-proxies` so `client` partitioning uses the real peer.
419
- Never trust `X-Forwarded-For` from an untrusted hop.
420
- 3. **Runtime request policies.** Agents before throttle (denials are
421
- cheaper than counting), then admission, then routing.
422
- 4. **Route contract.** Exact methods, `request.body` limits and content
423
- types, `expires` on campaign routes.
424
- 5. **Response policies.** Security headers on everything, compression only
425
- on listed types, caching only on the strategies whose semantics you can
426
- state, `no-store` everywhere else.
427
-
428
- ### 6.3 Strategies that have proven out elsewhere
429
-
430
- - **Emit rate-limit headers even before enforcing.** Running throttle in
431
- `report` mode (headers plus a log line, no 429) for a release before
432
- turning on enforcement is how most API operators find their real quotas.
433
- Proposed: `throttle.mode: enforce | report`.
434
- - **Deny lists as data with a pinned revision.** Every proxy that blocks
435
- agents well treats the list as a versioned artifact that ships with the
436
- deploy, not a live feed, so a rollback also rolls back the list.
437
- - **Allow before deny.** Keep an explicit allow for the crawlers you need
438
- indexed; broad denies without it are the most common self-inflicted
439
- outage in this space.
440
- - **Micro-cache the expensive path, revalidate the rest.** One-second
441
- origin caching on a hot function removes most thundering-herd load
442
- without changing what a browser sees.
443
- - **Immutable only with content hashes.** Long `max-age` on a path that can
444
- change is the classic stale-asset bug; the runtime refusing `immutable`
445
- on unhashed paths encodes that lesson.
446
- - **Compression off on secret-bearing responses.** Compression plus
447
- attacker-controlled input in the same response is the BREACH class of
448
- attack; skip it where secrets or session cookies are present.
449
- - **Report the capability table.** Print which policies are enforced, which
450
- are refused and which are delegated on the current target at startup and
451
- in `urlcode audit`, so a person who copies the YAML to another host sees
452
- the difference immediately.
453
-
454
- ## 7. What this spike does not recommend
455
-
456
- - **Global guest middleware.** It would pull every native route into the
457
- sandbox and end the fast path. Cross-cutting behavior belongs on the host.
458
- - **A plugin field in route YAML that names npm packages.** It breaks the
459
- "project is portable, operator owns trust" split.
460
- - **Provider settings in YAML** such as vendor rule identifiers. Declared
461
- semantics map to provider features in the adapter, not in the project.
462
- - **Shared-state throttling in the runtime.** Multi-instance coordination is
463
- a plugin's job.
464
- - **Copyleft or proprietary agent data.** Only MIT/public-domain lists are
465
- vendored; verification against vendor systems stays in plugins.
466
-
467
- ## 8. Suggested sequence
468
-
469
- | Step | Scope | Why first |
470
- |---|---|---|
471
- | 1 | Internal host hook interface + `policies.security` | Smallest change; establishes the plugin seam with pure header math that works on every target |
472
- | 2 | `policies.agents` with bundled lists, sync script, NOTICE entries | High demand for short-link projects; stateless; compiles to Cloudflare |
473
- | 3 | `policies.throttle` with `report` mode, RateLimit headers and `--trusted-proxies` | Removes the most repeated application-layer code; reuses existing admission counters |
474
- | 4 | `policies.compression` with precompressed asset snapshots | Measurable win in benchmarks |
475
- | 5 | `policies.cache` catalogue | Depends on clear rules from steps 1 to 4 for what is cacheable |
476
- | 6 | Public `@jimhoyd/urlcode/plugins` API + one reference package (shared-store throttle) | Proves the seam from outside the repo |
477
- | 7 | `urlcode build --prerender` and starter layouts | Template story without a runtime change |
478
-
479
- Each step ships with schema changes, `npm run docs:reference`, cookbook
480
- routes, capability-table updates and fixtures that assert the self-hosted
481
- server and each adapter agree on status, body and headers.
482
-
483
- ## 9. Open questions
484
-
485
- - Should `policies` be a top-level key or nested under a `server` key so
486
- project-level defaults are visibly separate from routes?
487
- - Does a denied agent count against the throttle? (Proposed: no.)
488
- - The IETF `RateLimit` header fields and the web-bot-auth architecture are
489
- still Internet-Drafts; the YAML keys are chosen to survive renames in the
490
- header syntax, but the emitted field names may need a version switch.
491
- - Which OSHP revision to pin first, and whether the CSP in `oshp` should be
492
- report-only by default for `page` routes that carry inline scripts.