@jimhoyd/urlcode 0.3.0

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