@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,168 @@
1
+ # Compression policy
2
+
3
+ `policies.compression` negotiates a content coding (RFC 9110 §12.5.3) on the
4
+ host, outside the sandbox, for every result a route produces: `respond`
5
+ bodies, function results, asset responses, cache hits and early denials.
6
+ It runs last in the response phase, after the cache store and the security
7
+ headers, so every header it reads is final. Asset snapshots are compressed
8
+ once at load and served by reference (the NGINX `gzip_static` / Caddy
9
+ `precompressed` model); other bodies are compressed on the request path
10
+ within a fixed size bound.
11
+
12
+ ## YAML
13
+
14
+ ```yaml
15
+ version: "1"
16
+ policies:
17
+ compression:
18
+ encodings: [br, gzip] # preference order; br | gzip | deflate | zstd
19
+ minBytes: 1024 # bodies below this stay identity (default 1024)
20
+ types: # media types eligible; `type/*` wildcards allowed
21
+ - text/*
22
+ - application/json
23
+ - application/javascript
24
+ - application/xml
25
+ - image/svg+xml
26
+ - application/manifest+json
27
+ - application/ld+json
28
+ level: 6 # 1–11, optional; mapped per codec (table below)
29
+ allowWithSecrets: false # compress even where BREACH applies (default false)
30
+
31
+ routes:
32
+ /api/report:
33
+ function: { source: functions/report.mjs }
34
+ secrets: { KEY: { secret: api-key } }
35
+ policies:
36
+ compression: { allowWithSecrets: true } # route keys merge over project keys
37
+ ```
38
+
39
+ `encodings`, `minBytes`, `types`, `level` and `allowWithSecrets` are the
40
+ only keys; the list above is the default `types`. `zstd` is accepted only
41
+ when the running Node exposes `zlib.zstdCompressSync` (Node 22.15+ / 23.8+);
42
+ otherwise startup fails with the route named, rather than silently serving
43
+ identity. YAML `response.headers` cannot set `Content-Encoding` (it is a
44
+ runtime-owned header); this policy is the only thing that sets it.
45
+
46
+ ### Levels
47
+
48
+ Without `level`, each codec uses a latency-leaning level for request-path
49
+ bodies and a size-leaning level for asset variants that are computed once.
50
+ With `level`, one number in YAML is mapped onto each codec's own scale and
51
+ applies to both paths.
52
+
53
+ | Coding | Dynamic default | Stored default | `level` mapping |
54
+ |---|---|---|---|
55
+ | `br` | quality 4 | quality 9 | `level` (1–11) |
56
+ | `gzip` | 6 | 9 | `min(level, 9)` |
57
+ | `deflate` | 6 | 9 | `min(level, 9)` |
58
+ | `zstd` | 3 | 12 | `min(level × 2, 22)` |
59
+
60
+ ## Negotiation
61
+
62
+ The `Accept-Encoding` field is parsed per RFC 9110: each coding carries a
63
+ `q` weight (default 1, `q=0` means not acceptable), `*` supplies the weight
64
+ of any coding not named, and a coding neither named nor covered by `*` is
65
+ not acceptable. The acceptable coding in `encodings` with the highest weight
66
+ wins; equal weights are broken by the order of `encodings`, so
67
+ `Accept-Encoding: gzip, br` selects `br` when the project lists `br` first.
68
+ When nothing in `encodings` is acceptable, including `identity;q=0` or
69
+ `*;q=0`, the response is sent as identity; a 406 helps nobody.
70
+
71
+ Every response whose media type is in `types` gets `Vary: Accept-Encoding`,
72
+ merged into an existing `Vary` without duplication and preserved on 304 and
73
+ 206, so a shared cache keys the URL by the field even when this particular
74
+ response was not compressed.
75
+
76
+ ## When compression is skipped
77
+
78
+ The response is sent as identity, still with `Vary`, when any of these hold:
79
+
80
+ - the status is 206 (a byte range of the identity representation), 304, 204
81
+ or 205, or the result already carries `Content-Encoding`;
82
+ - `Cache-Control` contains `no-transform`;
83
+ - the media type is not in `types`, or the body is below `minBytes`;
84
+ - the request method is `HEAD` and the body has no precomputed variant (see
85
+ below);
86
+ - the body exceeds 1 MiB and has no precomputed variant: a synchronous
87
+ compression of that size would hold the event loop for every other
88
+ request, so such bodies stay identity on the request path;
89
+ - compressing did not shrink the body;
90
+ - **BREACH:** the route declares `secrets`, or the response carries
91
+ `Set-Cookie`, unless `allowWithSecrets: true`.
92
+
93
+ ### BREACH rationale
94
+
95
+ Compressing a body that mixes a secret (a session token, a CSRF token, an
96
+ API key echoed into a page) with attacker-influenced input leaks the secret
97
+ through the compressed length: an attacker who can make the victim's
98
+ browser issue requests with chosen input measures which guesses shrink the
99
+ response. The signal is only there when secret and input share a
100
+ compressed body, so the policy refuses to compress exactly where a secret is
101
+ plausible: a route that has been granted secrets, and any response that
102
+ sets a cookie. `allowWithSecrets: true` is for routes whose bodies do not
103
+ echo the secret (a route that uses a key to call an upstream API and returns
104
+ public data); set it per route, not for the project.
105
+
106
+ ## ETag and HEAD
107
+
108
+ RFC 9110 requires a strong validator to differ between representations, so
109
+ an encoded body cannot carry the identity ETag unchanged. The policy handles
110
+ the two paths differently:
111
+
112
+ - **Precompressed assets** keep a strong ETag with the coding appended
113
+ inside the quotes: `"<sha256>-br"`, `"<sha256>-gz"`, `"<sha256>-df"`,
114
+ `"<sha256>-zs"`. The asset handler validates `If-None-Match` against the
115
+ identity tag; the policy validates the suffixed tag for the coding it
116
+ selected and answers 304 (with the suffixed ETag and `Vary`) when it
117
+ matches. A suffixed tag presented with a different `Accept-Encoding`
118
+ selects a different representation and gets a fresh 200. `If-Range` only
119
+ ever matches the identity tag, so a range request against a variant tag
120
+ gets the full identity body, as the RFC prescribes for a non-matching
121
+ validator.
122
+ - **Dynamically compressed bodies** (functions, `respond`, middleware
123
+ results, cache hits) keep the handler's ETag but weakened: `W/"v1"`. Weak
124
+ comparison treats `W/"v1"` and `"v1"` as equal, so a client revalidating
125
+ with the weak tag gets the same 304 it would for identity, and a
126
+ handler's own `If-None-Match` logic keeps working. Nothing is appended,
127
+ because a dynamic body has no stable bytes for a strong tag to name.
128
+
129
+ `HEAD` reports what `GET` would send when the answer is free: a
130
+ precompressed asset answers `HEAD` with `Content-Encoding` and the variant's
131
+ `Content-Length`. A dynamic body is not compressed for `HEAD` (it would pay
132
+ the whole compression for one number) and reports the identity length,
133
+ the same `Content-Length` its `GET` would carry uncompressed.
134
+
135
+ ## Precompression and memory bounds
136
+
137
+ `compileAssets` runs before policies compile, so the policy, not the asset
138
+ loader, computes variants: in `compile()` it walks the route's asset
139
+ snapshot (one file for `page`/`download`, the whole tree for `static`) and
140
+ compresses every file whose type is in `types` and whose size is at least
141
+ `minBytes`, once per configured coding. A project without the policy pays
142
+ nothing. Variants are stored on the immutable snapshot and replaced with it
143
+ on reload. Bounds:
144
+
145
+ - a variant at least as large as the original is dropped;
146
+ - the aggregate of all variants across the runtime is capped at 64 MiB
147
+ (the same figure as the asset snapshot itself); beyond it, remaining
148
+ files are served identity or compressed on the request path when they
149
+ fit the 1 MiB dynamic bound;
150
+ - a request for a stored variant costs one buffer reference, no copy.
151
+
152
+ `urlcode audit` and `testPlan()` report `precompressed`, the number of
153
+ variants a route holds, alongside `encodings`, `minBytes`, the count of
154
+ `types` and `level`.
155
+
156
+ ## Per-target behavior
157
+
158
+ | Target | Support | Notes |
159
+ |---|---|---|
160
+ | node | native | Negotiation, precompressed assets and dynamic compression as described. |
161
+ | vercel | delegated | The platform compresses responses at its edge; the policy is accepted and dropped so one YAML serves every host. |
162
+ | aws | delegated | CloudFront/API Gateway compression is configured on the platform; the policy is accepted and dropped. |
163
+ | cloudflare | delegated | Workers responses are compressed by the Cloudflare edge; the build accepts and drops the policy. |
164
+
165
+ A refusal is deliberate: the YAML stays portable and the difference is
166
+ visible at build time rather than as a silent double compression. Remove
167
+ the key, or set `compression: false` on the routes that use a profile which
168
+ declares it, when deploying to those targets.
@@ -0,0 +1,160 @@
1
+ # Security headers policy
2
+
3
+ `policies.security` adds response security headers on the host, outside the
4
+ sandbox, to every result a route produces: redirects, `respond` bodies,
5
+ function results, asset responses, cache hits and early denials from the
6
+ `agents` and `throttle` policies. Values are the OWASP Secure Headers
7
+ Project best-practice recommendations (OSHP 2024 best practices,
8
+ <https://owasp.org/www-project-secure-headers/>). The tables below are
9
+ generated from the frozen `profiles` constant in `src/policies/security.ts`.
10
+
11
+ ## YAML
12
+
13
+ ```yaml
14
+ version: "1"
15
+ policies:
16
+ security:
17
+ headers: oshp # oshp (default) | oshp-no-csp | off
18
+ set: # add or overwrite one header, verbatim
19
+ Content-Security-Policy-Report-Only: "default-src 'self'; report-to csp"
20
+ Clear-Site-Data: '"cache"'
21
+ unset: # drop a header the profile would emit
22
+ - Cross-Origin-Embedder-Policy
23
+ routes:
24
+ /embed:
25
+ respond: { text: ok }
26
+ policies:
27
+ security: { headers: oshp-no-csp } # route keys merge over project keys
28
+ ```
29
+
30
+ `headers`, `set` and `unset` are the only keys. A route may also write
31
+ `security: false` to disable the policy for that route.
32
+
33
+ ## Profile `oshp`
34
+
35
+ | Header | Value |
36
+ |---|---|
37
+ | `strict-transport-security` | `max-age=31536000; includeSubDomains` |
38
+ | `x-frame-options` | `deny` |
39
+ | `x-permitted-cross-domain-policies` | `none` |
40
+ | `referrer-policy` | `strict-origin-when-cross-origin` |
41
+ | `content-security-policy` | `default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content` |
42
+ | `cross-origin-embedder-policy` | `require-corp` |
43
+ | `cross-origin-opener-policy` | `same-origin` |
44
+ | `cross-origin-resource-policy` | `same-origin` |
45
+ | `permissions-policy` | `accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(self), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()` |
46
+
47
+ ## Profile `oshp-no-csp`
48
+
49
+ Identical to `oshp` without `Content-Security-Policy`.
50
+
51
+ | Header | Value |
52
+ |---|---|
53
+ | `strict-transport-security` | `max-age=31536000; includeSubDomains` |
54
+ | `x-frame-options` | `deny` |
55
+ | `x-permitted-cross-domain-policies` | `none` |
56
+ | `referrer-policy` | `strict-origin-when-cross-origin` |
57
+ | `cross-origin-embedder-policy` | `require-corp` |
58
+ | `cross-origin-opener-policy` | `same-origin` |
59
+ | `cross-origin-resource-policy` | `same-origin` |
60
+ | `permissions-policy` | `accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(self), usb=(), web-share=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=()` |
61
+
62
+ ## Profile `off`
63
+
64
+ Emits nothing. `set` still applies, so `off` plus `set` is a hand-written
65
+ header list.
66
+
67
+ Not in any profile:
68
+
69
+ - `X-Content-Type-Options: nosniff`: the runtime sets it on every response,
70
+ including errors, so the profile does not duplicate it.
71
+ - `Cache-Control`: owned by the `cache` policy and the asset handlers;
72
+ `set` refuses it.
73
+ - `Clear-Site-Data`: destructive by design, so only on explicit `set`.
74
+
75
+ ## Precedence
76
+
77
+ 1. Headers already on the result keep their value. YAML `response.headers`
78
+ are applied by the runtime before this policy runs, and a function or asset
79
+ handler sets its headers earlier still, so any of them beats the profile
80
+ header by header (`x-frame-options: sameorigin` on a route wins over the
81
+ profile's `deny`).
82
+ 2. `set` overwrites everything: the profile and an existing header of the
83
+ same name. Writing a header under `set` is explicit operator intent.
84
+ 3. `unset` removes a header from the profile by case-insensitive name. Naming
85
+ a header the selected profile does not emit is a configuration error that
86
+ names the route, so a typo cannot silently leave a header in place. The
87
+ check runs against the profile in effect on each route: a route that
88
+ switches to a profile without that header inherits the project `unset`
89
+ and must write `unset: []` to clear it.
90
+
91
+ `set` names and values are validated at activation with the same rules the
92
+ wire enforces (RFC 7230 token names, no control characters) and may not name a
93
+ header the runtime or a handler owns (`content-length`, `content-type`,
94
+ `location`, `etag`, `content-encoding`, `cache-control`, `set-cookie`,
95
+ `x-request-id`, `x-content-type-options`, hop-by-hop headers), nor one
96
+ another policy emits (`vary`, `ratelimit`, `ratelimit-policy`, `retry-after`,
97
+ `age`). The static
98
+ headers of one route are capped at 8 KiB so the response keeps room under the
99
+ runtime's 16 KiB / 256-header limit; the error names the route.
100
+
101
+ ## Error responses
102
+
103
+ Errors the runtime throws (404, 410, 413 and the rest) do not run the
104
+ response phase, but they do get this policy: the matched route's effective
105
+ profile when the error came after routing, otherwise the project-level one,
106
+ on every host and in the Cloudflare Worker. The fixed error headers
107
+ (`Content-Type`, `Cache-Control: no-store`, `Content-Length`, `X-Request-Id`,
108
+ `X-Content-Type-Options`) are never replaced.
109
+
110
+ ## HSTS and the origin
111
+
112
+ `Strict-Transport-Security` is emitted only when the request origin is
113
+ `https:`. On the self-hosted server that is the `--origin` setting (or the
114
+ `origin` option of `startServer`); the Worker uses the request URL. A
115
+ browser ignores HSTS on a plain-text response, and a forwarded header such as
116
+ `X-Forwarded-Proto` is client-controlled, so the runtime never infers the
117
+ scheme from it. Behind a TLS-terminating proxy or tunnel, state
118
+ `--origin https://your.host` and the header appears. Once emitted, HSTS
119
+ commits the host to HTTPS for a year including subdomains: set the origin only
120
+ when that is true.
121
+
122
+ ## CSP and pages with inline scripts
123
+
124
+ The `oshp` CSP (`default-src 'self'`, no `unsafe-inline`) blocks inline
125
+ `<script>` and `<style>` blocks and any third-party script. For a page that
126
+ needs them, either use `oshp-no-csp` on that route, or keep the profile and
127
+ trial a policy in report-only mode first:
128
+
129
+ ```yaml
130
+ policies:
131
+ security:
132
+ headers: oshp-no-csp
133
+ set:
134
+ Content-Security-Policy-Report-Only: "default-src 'self'; script-src 'self' https://cdn.example; report-to csp"
135
+ ```
136
+
137
+ Once the reports are clean, move the value to `Content-Security-Policy` under
138
+ `set`, which overrides the profile value.
139
+
140
+ ## Targets
141
+
142
+ | Target | Support | Notes |
143
+ |---|---|---|
144
+ | node | native | `--origin` decides HSTS |
145
+ | vercel | native | origin from the adapter's public URL |
146
+ | aws | native | origin from the adapter's public URL |
147
+ | cloudflare | compiled | Validated at build; the Worker compiles the same module synchronously and emits identical headers. HSTS follows the request URL scheme. |
148
+
149
+ ## Interaction with other policies
150
+
151
+ - `cache`: security headers are added after the cache store and on cache
152
+ hits, so a stored body never carries a stale profile; `Cache-Control` is
153
+ never touched.
154
+ - `compression`: runs after this policy, so `Vary`/`Content-Encoding` are
155
+ unaffected and the size check above already includes the profile.
156
+ - `agents` and `throttle`: their denials pass through this policy, so a 403
157
+ or 429 carries the same headers as a normal response.
158
+
159
+ `urlcode audit` and `testPlan().policies` report, per route, the profile
160
+ name, the header names it emits, and the `set` and `unset` names.
@@ -0,0 +1,103 @@
1
+ # Throttle policy
2
+
3
+ `policies.throttle` gives each client, route or client-route pair a request
4
+ budget: `quota` requests per `window` seconds. The runtime counts requests in
5
+ a sliding window (two fixed windows blended by elapsed time, the usual middle
6
+ ground between a fixed window that bursts at its edges and a token bucket
7
+ that is hard to explain in a header) and refuses the request that would
8
+ exceed the budget before it reads a body or reaches the sandbox. Refused
9
+ requests are not counted, so a retrying client cannot keep its own window
10
+ from clearing.
11
+
12
+ ```yaml
13
+ version: "1"
14
+ policies:
15
+ throttle:
16
+ quota: 120 # requests
17
+ window: 60 # seconds
18
+ partition: client # client | route | client-route (default client)
19
+ status: 429 # 4xx or 5xx answered on refusal (default 429)
20
+ mode: enforce # enforce | report (default enforce)
21
+ maxKeys: 100000 # bounded counter table, LRU eviction
22
+
23
+ routes:
24
+ /api/lookup/{id}:
25
+ function: { source: functions/lookup.mjs }
26
+ policies:
27
+ throttle: { quota: 10, window: 60 } # tighter budget with its own counter
28
+ ```
29
+
30
+ A route override that restates `quota` and `window` gets its own counters; a
31
+ route that inherits the project budget shares the client's counter with every
32
+ other route on the same budget. `throttle: false` on a route turns it off there.
33
+
34
+ ## Standards
35
+
36
+ - [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585) status 429 by default;
37
+ any 4xx or 5xx `status` is accepted and the body is a short plain-text line.
38
+ - [RFC 9110 `Retry-After`](https://www.rfc-editor.org/rfc/rfc9110#field.retry-after),
39
+ integer seconds, on every refusal.
40
+ - [IETF httpapi RateLimit header fields](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/)
41
+ on every response, allowed or refused, in structured-field syntax:
42
+ `RateLimit-Policy: "default";q=<quota>;w=<window>` and
43
+ `RateLimit: "default";r=<remaining>;t=<seconds until reset>`.
44
+
45
+ A refusal also carries `content-type: text/plain; charset=utf-8` and
46
+ `cache-control: no-store` so no intermediary keeps it.
47
+
48
+ ## Targets
49
+
50
+ | Target | `partition: route` | `client` / `client-route` |
51
+ |---|---|---|
52
+ | node (`urlcode serve`) | native | native |
53
+ | vercel | native, per instance | refused at activation |
54
+ | aws | native, per instance | refused at activation |
55
+ | cloudflare | refused | refused |
56
+
57
+ Counters live in the process. On serverless targets a route counter is at
58
+ least an honest per-instance ceiling, while a client counter would silently
59
+ become `quota × instances`, so those partitions are refused with the route
60
+ named. The Cloudflare build refuses the policy; map the same `quota` and
61
+ `window` to a provider rate rule instead.
62
+
63
+ ## Client identity
64
+
65
+ `client` is the socket peer address. Behind a load balancer every request
66
+ would share one address, so name the proxies allowed to speak for a client:
67
+ `urlcode serve --trusted-proxies 10.0.0.0/8,::1`. Only then is
68
+ `X-Forwarded-For` consulted, walking from the right past trusted hops. When no
69
+ client can be resolved (a caller that passed none, an adapter without a peer)
70
+ requests share a single bucket rather than escaping the budget; the policy
71
+ inventory (`urlcode audit`, `testPlan().policies`) reports this as
72
+ `unresolvedClient: "shared key"`.
73
+
74
+ ## Report before enforce
75
+
76
+ Run a release with `mode: report`: headers are emitted and every request logs
77
+ `{ event: "throttle", route, outcome: "allowed" | "exceeded", remaining }`
78
+ (never the client address), but nothing is refused. Read the `exceeded` lines
79
+ against real traffic, set `quota` from what you saw, then switch to `enforce`.
80
+ In enforce mode only `exceeded` is logged.
81
+
82
+ ## What it does not do
83
+
84
+ - No shared state across processes or instances. Two `urlcode serve`
85
+ processes behind one balancer each enforce the full budget. A host plugin
86
+ (`onRequest` returning a refusal, backed by whatever store you run) is the
87
+ place for a cluster-wide budget; see the plugin contract in
88
+ `docs/SPIKE-EXTENSIONS.md`.
89
+ - No per-user or per-token keys. The partition is address or route; a token
90
+ bucket per API key is again a plugin.
91
+ - Counters do not survive a reload: a new snapshot starts empty.
92
+ - `maxKeys` bounds memory with least-recently-used eviction; an evicted key
93
+ starts fresh, so a table sized below the number of concurrent clients
94
+ under-counts rather than blocks.
95
+
96
+ ## Interaction with other policies
97
+
98
+ Request order is agents, throttle, cache. A denied agent never touches a
99
+ counter; a throttled request never reaches the cache lookup or the handler.
100
+ Security headers and compression still apply to a refusal. A cache hit skips
101
+ only the cache's own response hook, so a cached response still carries the
102
+ client's `RateLimit` headers; the request was counted. A 405 is counted too
103
+ and carries them.
@@ -0,0 +1 @@
1
+ # Add names and empty placeholders only. Put local values in ignored .env.local.
@@ -0,0 +1,2 @@
1
+ # Keep text assets and test fixtures byte-consistent across platforms.
2
+ * text=auto eol=lf
@@ -0,0 +1,30 @@
1
+ .DEFAULT_GOAL := help
2
+
3
+ # Install the runtime separately, or override: URLCODE='node /path/to/urlcode/src/cli.js'
4
+ URLCODE ?= urlcode
5
+ HOST ?= 127.0.0.1
6
+ PORT ?= 3000
7
+
8
+ .PHONY: help dev serve validate test doctor
9
+ help:
10
+ @echo "make dev Run this app with reload and .env.local"
11
+ @echo "make validate Validate this app and its local bindings"
12
+ @echo "make test Run this app's HTTP assertions"
13
+ @echo "make serve Run a fixed snapshot, without local dotenv"
14
+ @echo "make doctor Show runtime/platform details"
15
+ @echo "Options: PORT=3001 HOST=127.0.0.1 URLCODE=urlcode"
16
+
17
+ dev:
18
+ $(URLCODE) dev --project . --host "$(HOST)" --port "$(PORT)"
19
+ serve:
20
+ $(URLCODE) serve --project . --host "$(HOST)" --port "$(PORT)"
21
+ validate:
22
+ $(URLCODE) validate --local --project .
23
+ test:
24
+ $(URLCODE) test --project .
25
+ doctor:
26
+ $(URLCODE) doctor
27
+
28
+ .PHONY: routes audit benchmark
29
+ routes audit benchmark:
30
+ $(URLCODE) $@ --project . $(ARGS)
@@ -0,0 +1,18 @@
1
+ # Asset demonstration
2
+
3
+ This is a runnable example, not a separate starter choice. From the runtime checkout:
4
+
5
+ ```sh
6
+ make dev PROJECT=examples/assets
7
+ # In another terminal:
8
+ make test-project PROJECT=examples/assets
9
+ ```
10
+
11
+ Try `/hello/Ada` for a function, `/go` for a redirect, `/about` for HTML,
12
+ `/assets/example.txt` for a static file, and `/download` for an attachment.
13
+ Assets live in `public/`; never put secrets there. See the
14
+ [asset guide](../../docs/ASSETS.md) for MIME, caching and resource limits.
15
+
16
+ To start your own small app, use `urlcode init ../my-links`. It creates one
17
+ function route and one regular redirect. Add asset routes when you need them.
18
+ URLCode is licensed under the Apache License 2.0.
@@ -0,0 +1,3 @@
1
+ export default function hello(_request, { args, env }) {
2
+ return Response.json({ message: `${env.GREETING}, ${args.name}!` });
3
+ }
@@ -0,0 +1,7 @@
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ node_modules/
5
+ .urlcode/
6
+ .urlcode-edit-*/
7
+ urlcode.yaml.lock
@@ -0,0 +1,2 @@
1
+ <!doctype html>
2
+ <html lang="en"><meta charset="utf-8"><title>URLCode</title><h1>URLs that run code</h1><p>Try <a href="/hello/Ada">your function</a> or <a href="/download">download the guide</a>.</html>
@@ -0,0 +1 @@
1
+ A native static asset.
@@ -0,0 +1 @@
1
+ URLCode: URLs that run code.
@@ -0,0 +1,44 @@
1
+ [
2
+ {
3
+ "path": "/go",
4
+ "status": 302,
5
+ "expectHeaders": {
6
+ "location": "https://example.com/"
7
+ }
8
+ },
9
+ {
10
+ "path": "/hello/Ada",
11
+ "status": 200,
12
+ "expectBody": "{\"message\":\"Hello, Ada!\"}"
13
+ },
14
+ {
15
+ "path": "/hello/Ada",
16
+ "method": "HEAD",
17
+ "status": 200,
18
+ "expectBody": ""
19
+ },
20
+ {
21
+ "path": "/missing",
22
+ "status": 404
23
+ },
24
+ {
25
+ "path": "/about",
26
+ "status": 200,
27
+ "expectHeaders": {
28
+ "content-type": "text/html; charset=utf-8"
29
+ }
30
+ },
31
+ {
32
+ "path": "/download",
33
+ "status": 200,
34
+ "expectHeaders": {
35
+ "content-disposition": "attachment; filename=urlcode-guide.txt"
36
+ },
37
+ "expectBody": "URLCode: URLs that run code.\n"
38
+ },
39
+ {
40
+ "path": "/assets/example.txt",
41
+ "status": 200,
42
+ "expectBody": "A native static asset.\n"
43
+ }
44
+ ]
@@ -0,0 +1,27 @@
1
+ version: "1"
2
+ routes:
3
+ /hello/{name}:
4
+ parameters:
5
+ - name: name
6
+ in: path
7
+ required: true
8
+ schema: {type: string, minLength: 1, maxLength: 80}
9
+ function:
10
+ source: functions/hello.mjs
11
+ args:
12
+ name: {from: path, name: name}
13
+ env:
14
+ GREETING: {value: Hello}
15
+ /go:
16
+ redirect:
17
+ url: https://example.com
18
+ /about:
19
+ page:
20
+ file: public/about.html
21
+ /assets/*:
22
+ static:
23
+ directory: public/assets
24
+ /download:
25
+ download:
26
+ file: public/guide.txt
27
+ filename: urlcode-guide.txt
@@ -0,0 +1,13 @@
1
+ # URLCode on AWS Lambda
2
+
3
+ A native-handler project served by a Lambda Function URL. See
4
+ [the adapter guide](../../docs/AWS.md) for what is and is not supported.
5
+
6
+ ```sh
7
+ urlcode test --project . # the same assertions run locally
8
+ sam deploy --guided
9
+ ```
10
+
11
+ `handler.mjs` serves the project with `createLambdaHandler`. The deployment
12
+ package must contain the project files the routes read: the entry YAML, any
13
+ includes, and every page, download and static directory.
@@ -0,0 +1,5 @@
1
+ // Lambda entry point. The runtime activates once per execution environment and
2
+ // is reused across warm invocations.
3
+ import { createLambdaHandler } from '@jimhoyd/urlcode/aws';
4
+
5
+ export const handler = createLambdaHandler({ project: process.env.LAMBDA_TASK_ROOT ?? process.cwd() });
@@ -0,0 +1,3 @@
1
+ <!doctype html>
2
+ <html lang="en"><meta charset="utf-8"><title>URLCode on Lambda</title>
3
+ <body><h1>URLCode on Lambda</h1><p>Native handlers served by the runtime adapter.</p></body>
@@ -0,0 +1 @@
1
+ Served by the static handler through the Lambda adapter.
@@ -0,0 +1,33 @@
1
+ # Minimal SAM template: a Function URL in front of the adapter. An API Gateway
2
+ # HTTP API works the same way; a REST API does not, because payload format 1.0
3
+ # cannot preserve the original request encoding.
4
+ AWSTemplateFormatVersion: '2010-09-09'
5
+ Transform: AWS::Serverless-2016-10-31
6
+
7
+ Resources:
8
+ UrlcodeFunction:
9
+ Type: AWS::Serverless::Function
10
+ Properties:
11
+ Handler: handler.handler
12
+ Runtime: nodejs22.x
13
+ MemorySize: 512
14
+ Timeout: 10
15
+ FunctionUrlConfig:
16
+ AuthType: NONE
17
+ Environment:
18
+ Variables:
19
+ # The public origin, so generated URLs name the domain a visitor used.
20
+ URLCODE_PUBLIC_HOST: !Ref PublicHost
21
+ # Paste the output of `urlcode permissions` only if routes declare
22
+ # env or secret bindings.
23
+ # URLCODE_POLICY: '{"version":1,...}'
24
+
25
+ Parameters:
26
+ PublicHost:
27
+ Type: String
28
+ Default: ''
29
+ Description: Public hostname without scheme, e.g. links.example.
30
+
31
+ Outputs:
32
+ FunctionUrl:
33
+ Value: !GetAtt UrlcodeFunctionUrl.FunctionUrl
@@ -0,0 +1,9 @@
1
+ [
2
+ {"path": "/go", "status": 302, "expectHeaders": {"location": "https://example.com/target"}},
3
+ {"path": "/u/42", "status": 302, "expectHeaders": {"location": "https://example.com/users/42"}},
4
+ {"path": "/u/42", "method": "HEAD", "status": 302, "expectHeaders": {"location": "https://example.com/users/42"}, "expectBody": ""},
5
+ {"path": "/health", "status": 200, "expectBody": "{\"ok\":true}"},
6
+ {"path": "/", "status": 200, "expectHeaders": {"content-type": "text/html; charset=utf-8"}},
7
+ {"path": "/files/notes.txt", "status": 200, "expectHeaders": {"content-type": "text/plain; charset=utf-8"}},
8
+ {"path": "/nothing-here", "status": 404}
9
+ ]