@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +106 -0
  2. package/.claude/skills/urlcode-operations/SKILL.md +114 -0
  3. package/.claude-plugin/marketplace.json +18 -0
  4. package/CONTRIBUTING.md +30 -2
  5. package/README.md +157 -230
  6. package/ROADMAP.md +85 -2
  7. package/SECURITY.md +8 -1
  8. package/dist/BUILD-MANIFEST.json +62 -32
  9. package/dist/adapters.js +4 -23
  10. package/dist/agent-lists.js +1 -1
  11. package/dist/agents-guide.js +113 -0
  12. package/dist/authoring-files.js +60 -0
  13. package/dist/authoring.js +11 -1
  14. package/dist/aws.js +4 -3
  15. package/dist/build-cloudflare.js +11 -24
  16. package/dist/bulk.js +37 -0
  17. package/dist/capabilities.js +208 -0
  18. package/dist/capability-query.js +72 -0
  19. package/dist/catalog.js +105 -0
  20. package/dist/cli.js +154 -15
  21. package/dist/client-address.js +1 -1
  22. package/dist/compliance-rules/baseline.js +8 -8
  23. package/dist/compliance-rules/privacy.js +3 -3
  24. package/dist/compliance-rules/strict.js +5 -5
  25. package/dist/conditions.js +88 -0
  26. package/dist/config.js +60 -4
  27. package/dist/context.js +156 -0
  28. package/dist/ecosystem-cli.js +88 -0
  29. package/dist/egress.js +98 -0
  30. package/dist/examples.js +92 -0
  31. package/dist/explain-cli.js +64 -0
  32. package/dist/explain.js +132 -0
  33. package/dist/extensions.js +172 -0
  34. package/dist/function-sources.js +15 -3
  35. package/dist/index.js +37 -0
  36. package/dist/init-with.js +165 -0
  37. package/dist/interchange-cli.js +42 -0
  38. package/dist/interchange.js +189 -0
  39. package/dist/link-cli.js +1 -1
  40. package/dist/management-policy.js +0 -1
  41. package/dist/manifest.js +111 -0
  42. package/dist/match.js +2 -2
  43. package/dist/mcp-authoring.js +147 -0
  44. package/dist/mcp.js +97 -0
  45. package/dist/observability.js +6 -0
  46. package/dist/operator-host.js +29 -0
  47. package/dist/plugins.js +12 -0
  48. package/dist/policies/agents.js +2 -2
  49. package/dist/policies/compression.js +2 -1
  50. package/dist/policies/security.js +0 -0
  51. package/dist/policies.js +1 -1
  52. package/dist/policy.js +29 -7
  53. package/dist/prerender.js +100 -41
  54. package/dist/project-tests.js +3 -3
  55. package/dist/provider-verification.js +92 -0
  56. package/dist/proxy.js +44 -0
  57. package/dist/readiness.js +5 -5
  58. package/dist/recipes.js +41 -0
  59. package/dist/route-diff.js +106 -0
  60. package/dist/router.js +42 -2
  61. package/dist/runtime.js +95 -14
  62. package/dist/schema-query.js +62 -0
  63. package/dist/signals.js +24 -0
  64. package/dist/site.js +0 -0
  65. package/dist/tooling.js +96 -0
  66. package/dist/types/adapters.d.ts +7 -4
  67. package/dist/types/agent-lists.d.ts +0 -1
  68. package/dist/types/agents-guide.d.ts +17 -0
  69. package/dist/types/authoring-files.d.ts +10 -0
  70. package/dist/types/aws.d.ts +3 -1
  71. package/dist/types/build-cloudflare.d.ts +1 -0
  72. package/dist/types/bulk.d.ts +27 -0
  73. package/dist/types/capabilities.d.ts +55 -0
  74. package/dist/types/capability-query.d.ts +24 -0
  75. package/dist/types/catalog.d.ts +65 -0
  76. package/dist/types/client-address.d.ts +0 -1
  77. package/dist/types/compliance-rules/baseline.d.ts +1 -9
  78. package/dist/types/compliance-rules/privacy.d.ts +1 -4
  79. package/dist/types/compliance-rules/strict.d.ts +0 -5
  80. package/dist/types/conditions.d.ts +19 -0
  81. package/dist/types/config.d.ts +20 -2
  82. package/dist/types/context.d.ts +65 -0
  83. package/dist/types/ecosystem-cli.d.ts +17 -0
  84. package/dist/types/egress.d.ts +46 -0
  85. package/dist/types/examples.d.ts +50 -0
  86. package/dist/types/explain-cli.d.ts +11 -0
  87. package/dist/types/explain.d.ts +95 -0
  88. package/dist/types/extensions.d.ts +122 -0
  89. package/dist/types/function-sources.d.ts +5 -0
  90. package/dist/types/index.d.ts +33 -0
  91. package/dist/types/init-with.d.ts +30 -0
  92. package/dist/types/interchange-cli.d.ts +16 -0
  93. package/dist/types/interchange.d.ts +42 -0
  94. package/dist/types/link-cli.d.ts +2 -1
  95. package/dist/types/management-policy.d.ts +0 -1
  96. package/dist/types/manifest.d.ts +81 -0
  97. package/dist/types/match.d.ts +1 -0
  98. package/dist/types/mcp-authoring.d.ts +92 -0
  99. package/dist/types/mcp.d.ts +12 -0
  100. package/dist/types/observability.d.ts +2 -0
  101. package/dist/types/operator-host.d.ts +8 -0
  102. package/dist/types/plugins.d.ts +2 -0
  103. package/dist/types/policies/agents.d.ts +0 -2
  104. package/dist/types/policies/compression.d.ts +2 -0
  105. package/dist/types/policies/security.d.ts +0 -1
  106. package/dist/types/policy.d.ts +15 -4
  107. package/dist/types/project-tests.d.ts +3 -1
  108. package/dist/types/provider-verification.d.ts +53 -0
  109. package/dist/types/proxy.d.ts +21 -0
  110. package/dist/types/readiness.d.ts +1 -1
  111. package/dist/types/recipes.d.ts +30 -0
  112. package/dist/types/route-diff.d.ts +27 -0
  113. package/dist/types/runtime.d.ts +11 -0
  114. package/dist/types/schema-query.d.ts +12 -0
  115. package/dist/types/signals.d.ts +25 -0
  116. package/dist/types/site.d.ts +0 -1
  117. package/dist/types/tooling.d.ts +115 -0
  118. package/dist/types/types.d.ts +57 -0
  119. package/dist/types/typescript-authoring.d.ts +12 -0
  120. package/dist/types/vercel.d.ts +3 -1
  121. package/dist/types/verify-deployment.d.ts +47 -0
  122. package/dist/types.js +21 -2
  123. package/dist/typescript-authoring.js +104 -0
  124. package/dist/vercel.js +4 -3
  125. package/dist/verify-deployment.js +270 -0
  126. package/docs/AI-AUTHORING.md +130 -8
  127. package/docs/BULK.md +79 -0
  128. package/docs/CAPABILITIES.md +179 -0
  129. package/docs/CAPACITY.md +1 -1
  130. package/docs/CI.md +142 -0
  131. package/docs/CONDITIONS.md +74 -0
  132. package/docs/DEPLOYMENT-CHECKS.md +108 -0
  133. package/docs/DYNAMIC-LINKS.md +18 -518
  134. package/docs/EGRESS.md +125 -0
  135. package/docs/EXTENSIONS.md +226 -0
  136. package/docs/FRAMEWORK.md +182 -0
  137. package/docs/INSTALL.md +45 -7
  138. package/docs/INTERCHANGE.md +134 -0
  139. package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
  140. package/docs/MIDDLEWARE.md +2 -0
  141. package/docs/NEXT-PHASE-PLAN.md +90 -0
  142. package/docs/NEXT-STEPS.md +415 -0
  143. package/docs/OBSERVABILITY.md +4 -2
  144. package/docs/OPERATIONAL-PROOF.md +4 -1
  145. package/docs/OPERATIONS.md +6 -3
  146. package/docs/PLUGINS.md +37 -0
  147. package/docs/POLICIES.md +12 -309
  148. package/docs/PRERENDER.md +40 -0
  149. package/docs/PROJECT-DIRECTION.md +42 -0
  150. package/docs/PROVIDER-VERIFICATION.md +84 -0
  151. package/docs/READINESS.md +21 -1
  152. package/docs/README.md +82 -31
  153. package/docs/RECIPES.md +99 -0
  154. package/docs/RELEASE-READINESS.md +11 -9
  155. package/docs/RELEASE-SECURITY.md +27 -4
  156. package/docs/SECURITY-AUDIT.md +1 -1
  157. package/docs/SPECIFICATION.md +95 -8
  158. package/docs/SPIKE-BUSINESS-SUITE.md +1013 -0
  159. package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
  160. package/docs/SPIKE-EXTENSIONS.md +1 -0
  161. package/docs/SPIKE-LAMBDA-COMPILE.md +199 -0
  162. package/docs/STANDARDS.md +150 -142
  163. package/docs/STARTERS.md +21 -1
  164. package/docs/TOOLING.md +291 -0
  165. package/docs/TYPESCRIPT-AUTHORING.md +67 -0
  166. package/docs/TYPESCRIPT.md +1 -1
  167. package/docs/USABILITY-REVIEW.md +123 -0
  168. package/docs/YAML-GUIDE.md +18 -479
  169. package/docs/YAML-REFERENCE.md +127 -16
  170. package/docs/links/cli.md +110 -0
  171. package/docs/links/limits.md +175 -0
  172. package/docs/links/management-api.md +80 -0
  173. package/docs/links/pools.md +75 -0
  174. package/docs/links/setup.md +135 -0
  175. package/docs/policies/agents.md +1 -1
  176. package/docs/policies/contract.md +52 -0
  177. package/docs/policies/hardened.md +56 -0
  178. package/docs/policies/interoperability.md +169 -0
  179. package/docs/policies/operations.md +45 -0
  180. package/docs/yaml/assets.md +36 -0
  181. package/docs/yaml/conditions.md +20 -0
  182. package/docs/yaml/functions.md +160 -0
  183. package/docs/yaml/links.md +30 -0
  184. package/docs/yaml/middleware.md +29 -0
  185. package/docs/yaml/organization.md +74 -0
  186. package/docs/yaml/policies.md +37 -0
  187. package/docs/yaml/redirects.md +64 -0
  188. package/docs/yaml/responses.md +57 -0
  189. package/docs/yaml/site.md +24 -0
  190. package/examples/assets/example.yaml +17 -0
  191. package/examples/aws/example.yaml +20 -0
  192. package/examples/cloudflare/example.yaml +19 -0
  193. package/examples/compliance/example.yaml +11 -0
  194. package/examples/conditions/README.md +12 -0
  195. package/examples/conditions/example.yaml +19 -0
  196. package/examples/conditions/tests/requests.json +13 -0
  197. package/examples/conditions/urlcode.yaml +24 -0
  198. package/examples/cookbook/README.md +8 -4
  199. package/examples/cookbook/example.yaml +17 -0
  200. package/examples/cookbook/functions/catalog.mjs +3 -0
  201. package/examples/cookbook/functions/fail.mjs +4 -0
  202. package/examples/cookbook/functions/items.mjs +3 -0
  203. package/examples/cookbook/functions/profile.mjs +3 -0
  204. package/examples/cookbook/functions/resource.mjs +3 -0
  205. package/examples/cookbook/functions/status.mjs +3 -0
  206. package/examples/cookbook/middleware/auth.mjs +48 -0
  207. package/examples/cookbook/middleware/body.mjs +15 -0
  208. package/examples/cookbook/middleware/bucket.mjs +19 -0
  209. package/examples/cookbook/middleware/cors.mjs +21 -0
  210. package/examples/cookbook/middleware/debug.mjs +13 -0
  211. package/examples/cookbook/middleware/envelope.mjs +11 -0
  212. package/examples/cookbook/middleware/errors.mjs +11 -0
  213. package/examples/cookbook/middleware/etag.mjs +18 -0
  214. package/examples/cookbook/middleware/locale.mjs +16 -0
  215. package/examples/cookbook/middleware/maintenance.mjs +10 -0
  216. package/examples/cookbook/middleware/methods.mjs +15 -0
  217. package/examples/cookbook/middleware/negotiate.mjs +20 -0
  218. package/examples/cookbook/middleware/referer.mjs +12 -0
  219. package/examples/cookbook/middleware/request-id.mjs +16 -0
  220. package/examples/cookbook/route-index.json +676 -0
  221. package/examples/cookbook/routes/middleware.yaml +126 -0
  222. package/examples/cookbook/tests/requests.json +526 -0
  223. package/examples/cookbook/urlcode.yaml +1 -0
  224. package/examples/egress/README.md +22 -0
  225. package/examples/egress/example.yaml +19 -0
  226. package/examples/egress/urlcode.yaml +19 -0
  227. package/examples/extensions/README.md +7 -0
  228. package/examples/extensions/example.yaml +21 -0
  229. package/examples/extensions/urlcode.yaml +25 -0
  230. package/examples/live-links/example.yaml +21 -0
  231. package/examples/monitoring/example.yaml +8 -0
  232. package/examples/prerender/example.yaml +16 -0
  233. package/examples/provider-conformance/README.md +12 -0
  234. package/examples/provider-conformance/example.yaml +14 -0
  235. package/examples/provider-conformance/urlcode.yaml +34 -0
  236. package/examples/tunnel/example.yaml +8 -0
  237. package/examples/vercel/example.yaml +19 -0
  238. package/llms-full.txt +2709 -0
  239. package/llms.txt +48 -19
  240. package/package.json +29 -7
  241. package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
  242. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +106 -0
  243. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +114 -0
  244. package/recipes/authenticated-json-api/README.md +51 -0
  245. package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
  246. package/recipes/authenticated-json-api/recipe.yaml +34 -0
  247. package/recipes/authenticated-json-api/tests/requests.json +39 -0
  248. package/recipes/authenticated-json-api/urlcode.yaml +12 -0
  249. package/recipes/contact-form/README.md +25 -0
  250. package/recipes/contact-form/functions/contact.mjs +17 -0
  251. package/recipes/contact-form/recipe.yaml +33 -0
  252. package/recipes/contact-form/tests/requests.json +47 -0
  253. package/recipes/contact-form/urlcode.yaml +18 -0
  254. package/recipes/cors-api/README.md +16 -0
  255. package/recipes/cors-api/functions/items.mjs +3 -0
  256. package/recipes/cors-api/middleware/cors.mjs +21 -0
  257. package/recipes/cors-api/recipe.yaml +26 -0
  258. package/recipes/cors-api/tests/requests.json +65 -0
  259. package/recipes/cors-api/urlcode.yaml +12 -0
  260. package/recipes/health-page/README.md +13 -0
  261. package/recipes/health-page/recipe.yaml +23 -0
  262. package/recipes/health-page/tests/requests.json +36 -0
  263. package/recipes/health-page/urlcode.yaml +19 -0
  264. package/recipes/json-api/README.md +6 -0
  265. package/recipes/json-api/functions/echo.mjs +3 -0
  266. package/recipes/json-api/recipe.yaml +25 -0
  267. package/recipes/json-api/tests/requests.json +34 -0
  268. package/recipes/json-api/urlcode.yaml +12 -0
  269. package/recipes/middleware/README.md +34 -0
  270. package/recipes/middleware/functions/catalog.mjs +3 -0
  271. package/recipes/middleware/functions/fail.mjs +4 -0
  272. package/recipes/middleware/functions/items.mjs +3 -0
  273. package/recipes/middleware/functions/profile.mjs +3 -0
  274. package/recipes/middleware/functions/resource.mjs +3 -0
  275. package/recipes/middleware/functions/status.mjs +3 -0
  276. package/recipes/middleware/middleware/auth.mjs +48 -0
  277. package/recipes/middleware/middleware/body.mjs +15 -0
  278. package/recipes/middleware/middleware/bucket.mjs +19 -0
  279. package/recipes/middleware/middleware/cors.mjs +21 -0
  280. package/recipes/middleware/middleware/debug.mjs +13 -0
  281. package/recipes/middleware/middleware/envelope.mjs +11 -0
  282. package/recipes/middleware/middleware/errors.mjs +11 -0
  283. package/recipes/middleware/middleware/etag.mjs +18 -0
  284. package/recipes/middleware/middleware/locale.mjs +16 -0
  285. package/recipes/middleware/middleware/maintenance.mjs +10 -0
  286. package/recipes/middleware/middleware/methods.mjs +15 -0
  287. package/recipes/middleware/middleware/negotiate.mjs +20 -0
  288. package/recipes/middleware/middleware/referer.mjs +12 -0
  289. package/recipes/middleware/middleware/request-id.mjs +16 -0
  290. package/recipes/middleware/public/guide.txt +1 -0
  291. package/recipes/middleware/recipe.yaml +50 -0
  292. package/recipes/middleware/tests/requests.json +528 -0
  293. package/recipes/middleware/urlcode.yaml +127 -0
  294. package/recipes/protected-download/README.md +22 -0
  295. package/recipes/protected-download/files/report.txt +1 -0
  296. package/recipes/protected-download/recipe.yaml +31 -0
  297. package/recipes/protected-download/tests/requests.json +32 -0
  298. package/recipes/protected-download/urlcode.yaml +15 -0
  299. package/recipes/redirect/README.md +7 -0
  300. package/recipes/redirect/recipe.yaml +25 -0
  301. package/recipes/redirect/tests/requests.json +19 -0
  302. package/recipes/redirect/urlcode.yaml +9 -0
  303. package/recipes/static-plus-api/README.md +15 -0
  304. package/recipes/static-plus-api/functions/info.mjs +3 -0
  305. package/recipes/static-plus-api/public/assets/index.html +3 -0
  306. package/recipes/static-plus-api/public/assets/site.css +1 -0
  307. package/recipes/static-plus-api/public/index.html +8 -0
  308. package/recipes/static-plus-api/recipe.yaml +29 -0
  309. package/recipes/static-plus-api/tests/requests.json +56 -0
  310. package/recipes/static-plus-api/urlcode.yaml +23 -0
  311. package/recipes/typescript/README.md +7 -0
  312. package/recipes/typescript/functions/hello.ts +5 -0
  313. package/recipes/typescript/recipe.yaml +23 -0
  314. package/recipes/typescript/tests/requests.json +18 -0
  315. package/recipes/typescript/urlcode.yaml +5 -0
  316. package/recipes/webhook-receiver/README.md +16 -0
  317. package/recipes/webhook-receiver/functions/receive.mjs +16 -0
  318. package/recipes/webhook-receiver/recipe.yaml +26 -0
  319. package/recipes/webhook-receiver/tests/requests.json +59 -0
  320. package/recipes/webhook-receiver/urlcode.yaml +16 -0
  321. package/schemas/recipe.schema.json +138 -0
  322. package/schemas/urlcode.schema.json +656 -80
  323. package/skills/urlcode/SKILL.md +98 -0
  324. package/starters/default/.github/workflows/urlcode.yml +23 -0
  325. package/starters/default/.mcp.json +12 -0
  326. package/starters/default/AGENTS.md +79 -0
@@ -0,0 +1,419 @@
1
+ # Review: the extension model, its precedents and its alignment
2
+
3
+ Status: review of the [auth](https://github.com/jimhoyd-com/urlcode-auth/blob/main/docs/SPIKE-AUTH.md), [admin](https://github.com/jimhoyd-com/urlcode-admin/blob/main/docs/SPIKE-ADMIN.md) and
4
+ [UI kit](https://github.com/jimhoyd-com/urlcode-ui/blob/main/docs/SPIKE-UI.md) spikes against the runtime's principles and against
5
+ how established frameworks add the same capabilities. Core portability is
6
+ the fixed point: a project's YAML must work unchanged on another host.
7
+ Section 1 is what the review changed; section 2 is what other projects do
8
+ and what was taken from each; section 3 is the case for the model.
9
+
10
+ ## 1. Alignment issues found and how they were resolved
11
+
12
+ 1. **A YAML-only project has no server file.** The spikes said `init`
13
+ writes "a plugin line into the server file", but projects run through
14
+ `urlcode dev` and `urlcode serve`; the starter is YAML, functions and a
15
+ Makefile. Operator material already loads from explicit paths
16
+ (`--policy`, `--compliance-rules`, `--auth-file`), never from inside the
17
+ project. Resolved: a host file, `host.js`, exporting `{ store, plugins }`,
18
+ loaded with `--host-file <path>` and written by `init`; the starter's
19
+ `make dev` and `make serve` pass it. It is the one file of code an
20
+ extended project has, it is operator code by the same rule as the grant
21
+ file, and it is never discovered by convention inside the project
22
+ because the project is untrusted content.
23
+ 2. **`origin` in `auth.yaml` broke portability.** The passkey relying-party
24
+ id and provider redirect base were in the auth block. An origin is a
25
+ deployment fact; the runtime already takes `--origin` for the sitemap
26
+ and compliance. Resolved: removed from YAML, taken from `--origin`.
27
+ 3. **`mount` duplicated the route.** The auth block carried `mount:
28
+ /account` while the route `/account/*: { extension: auth }` already
29
+ said where the extension lives. Resolved: the route is the mount.
30
+ 4. **Two ways to protect a route.** `protect` path patterns in `auth.yaml`
31
+ and `policies.auth` on routes would drift apart. Policies already have
32
+ profiles for sharing a requirement across routes. Resolved: `protect`
33
+ removed; `policies.auth` on a route or in a profile is the only way.
34
+ 5. **`extensions.ui` had no owner.** The runtime seam refuses a block no
35
+ plugin claims, and the kit was described as a library. Resolved: the kit
36
+ ships one small plugin that owns the block and serves the stylesheet and
37
+ scripts at one route, which also answers how two extensions avoid
38
+ serving the same asset twice.
39
+ 6. **Extension routes had no fixtures.** The audit generates fixtures for
40
+ native routes from their declarations; it cannot for an extension route.
41
+ Resolved: the plugin supplies fixtures for its routes through the
42
+ existing `testPlan` seam, so the audit, `urlcode test` and
43
+ `verify-deployment` cover them.
44
+ 7. **Translations were "later".** Every string in the kit and the notices
45
+ is catalogued from the first release, with language negotiation, plural
46
+ rules, locale formatting and RTL-safe templates; English ships, any
47
+ language is a file. Adding this later would have meant re-touching every
48
+ template.
49
+ 8. **`__Host-` cookies in development.** The prefix requires `Secure`;
50
+ browsers treat `http://localhost` as a secure context so it works there,
51
+ but not on a LAN address. `urlcode dev` uses a `__Host-` cookie on
52
+ localhost and a plain-named one elsewhere, and `hardened` refuses
53
+ anything but `__Host-` in production. Noted in the auth spike.
54
+
55
+ What did not need changing: the four runtime seams stay generic; the
56
+ store contract stays a document store with declared indexes and no joins;
57
+ the plugin remains the only host-code seam; nothing in YAML names a
58
+ package; every target either enforces or refuses at activation; the
59
+ runtime never depends on an extension.
60
+
61
+ ## 2. How other frameworks do this, and what was taken
62
+
63
+ The pattern "core stays small, capabilities arrive as installable pieces
64
+ that bring routes, storage, pages and configuration, and the app overrides
65
+ them by file" is thirty years old. The instructive precedents:
66
+
67
+ | Framework | The piece | Adds routes | Adds storage | Pages and override | Configuration | Taken |
68
+ |---|---|---|---|---|---|---|
69
+ | **Django** | `contrib.auth`, `contrib.admin`, `django-allauth` | `include()` in `urls.py` | models plus migrations | template override by path (`templates/account/login.html` shadows the app's); admin registered per model | `settings.py` keys per app | The include model, per-path template shadowing, auth and admin as two apps where admin builds on auth |
70
+ | **Rails** | engines: Devise, ActiveAdmin | `devise_for` route helper, `mount Engine => "/admin"` | migrations generated into the app | `rails generate devise:views` copies views into the app to edit (ejecting); copy in `config/locales/devise.en.yml` | initializer file | Eject as the override mechanism; copy in a locale YAML file with ids, the origin of the copy catalogue |
71
+ | **Laravel** | Fortify (headless auth) plus Breeze or Jetstream (UI), Filament (admin) | service provider registers routes | migrations published | `php artisan vendor:publish` copies views, config and translations into the app; Filament resources per model | published config file | The split between a headless behaviour package and a UI package, which is the auth versus UI kit split; publish equals eject |
72
+ | **WordPress** | plugins and themes | plugins register rewrite rules | plugin tables | template hierarchy and child themes: the app's file wins over the plugin's | options table, filters | The override order "project file, then extension default, then kit default", and the lesson that logic in templates is where plugin ecosystems rot |
73
+ | **Keycloak** | realms, themes | its own server | its own database | theme directories override templates, CSS and messages per realm | realm JSON | Message bundles per theme; the account console's surface |
74
+ | **Ory Kratos** | identity schemas, self-service flows | its own server | its own database | no UI, flow JSON for any renderer; Elements as optional components | identity schema JSON | Flows as resumable records with ids; identifiers, traits and credentials modelled apart |
75
+ | **Better Auth** | server plugins with a client counterpart | plugin declares endpoints | plugin declares schema, CLI generates migrations | no UI | TypeScript config | An extension declares its routes and collections and the tooling derives the rest; hooks for the app |
76
+ | **Supabase Auth**, Auth.js, Lucia | libraries or a service | fixed endpoints | fixed schema | none | config object | Mostly what to avoid: UI is always the app's job, so every app rebuilds the same pages |
77
+ | **Payload CMS**, Strapi | plugins that inject into the admin | yes | collections | admin injection points | config | Admin as an extension that other extensions can add sections to, reserved for later |
78
+
79
+ Two observations from the table shape the model:
80
+
81
+ - **Every mature framework ended up with the same four things**: a way to
82
+ include routes, a way to add storage with migrations, a way to override
83
+ pages by file, and a configuration surface per piece. The runtime's four
84
+ seams are those four things, named for this runtime.
85
+ - **The frameworks that ship a UI with the capability (Django admin,
86
+ Devise views, Breeze, Filament, Keycloak) are the ones people reach for
87
+ first**, and the ones that leave UI to the app (Kratos, Supabase, Auth.js)
88
+ are the ones where every project rebuilds the same sign-in page. The kit
89
+ and the accounts page are the difference.
90
+
91
+ Where this model departs from all of them, deliberately: the
92
+ configuration is YAML validated by a schema rather than code, so a project
93
+ is data that can be checked, diffed, audited and moved; the extension
94
+ brings no framework of its own to the browser; and the trust boundary
95
+ between the project (untrusted) and the operator (host code) is explicit,
96
+ which none of the precedents have because they assume the app author and
97
+ the operator are the same person.
98
+
99
+ ## 3. Does this let a builder, human or AI, build less?
100
+
101
+ Yes, with conditions. The claim is not that a framework makes building
102
+ faster in general; it is that this shape removes the specific work that is
103
+ both repetitive and dangerous to get wrong.
104
+
105
+ What it removes:
106
+
107
+ - **Auth, accounts and admin are the parts every product needs and the
108
+ parts most often built badly.** Password storage, session fixation,
109
+ enumeration, CSRF, open redirects, recovery abuse: an AI writing these
110
+ from scratch reproduces the average of what it has read, and the average
111
+ is not good. A declared method with the decisions already made is safer
112
+ than a well-prompted rewrite.
113
+ - **Declaration is a smaller target than code.** A schema-validated YAML
114
+ key has a few valid values; a hand-written sign-in flow has unbounded
115
+ ways to be subtly wrong. Generation against a schema, with `validate`,
116
+ `audit` and `test` as the loop, is where an AI is reliable. The runtime
117
+ already leans this way (`llms.txt`, the YAML reference, executable
118
+ examples with fixtures).
119
+ - **Overrides by file, not by fork.** Restyling by theme variables and
120
+ ejecting one template is work an AI does well and cannot break the flow
121
+ with, because the template cannot change behaviour.
122
+ - **Portability means the work survives the next decision.** A project
123
+ that moves from a laptop to a server to a Worker keeps its YAML; the
124
+ builder does not redo the product to change hosts.
125
+
126
+ The conditions, without which the claim fails:
127
+
128
+ - **Coverage.** The extensions must cover what a typical product needs;
129
+ if the first thing a builder wants is missing, they are back to building.
130
+ The scope cut in the auth spike is a bet that passwords, passkeys, email
131
+ codes, roles, an accounts page and an admin cover the first ten
132
+ products. That bet should be checked against the first three real ones.
133
+ - **The escape hatch must be as easy as the declaration.** The twenty
134
+ percent that is product-specific goes into functions in the WASM guest
135
+ with granted bindings. If that path is harder than declaring, builders
136
+ will route around the runtime.
137
+ - **Documentation written for a reader with no history.** An AI has read
138
+ a great deal of Django and Rails and nothing of this runtime. The
139
+ schema, the reference, the cookbook and `llms.txt` are the training
140
+ data; every extension needs the same set on day one, and `init` must
141
+ print what it did in words a first-time reader can follow.
142
+ - **Small surface, stable contracts.** The advantage disappears if the
143
+ YAML keys churn. View-model versioning, the seam contracts and the store
144
+ contract are the promises; they should change rarely and loudly.
145
+
146
+ The honest comparison is not "this framework versus writing it by hand".
147
+ It is "this framework versus Clerk, Supabase or Firebase", which also let
148
+ a builder skip auth. Those win on time to first sign-in today and lose on
149
+ portability, on cost at scale, on data ownership and on running offline or
150
+ on a Worker. This model's claim to be better for a builder is that it
151
+ offers the same skip with the YAML, the data and the pages staying theirs.
152
+ That is a real position, and it holds only while the runtime stays as
153
+ portable as it is now.
154
+
155
+ ## 4. Against Clerk
156
+
157
+ Clerk is the product a developer reaches for when they want auth, an
158
+ accounts page and organizations without building any of it, so it is the
159
+ right yardstick. Feature by feature, with Clerk as of 2026:
160
+
161
+ | Clerk has | This model, first release | Gap |
162
+ |---|---|---|
163
+ | Prebuilt sign-in, sign-up, user profile, user button components (React, Next.js, Expo, iOS, Android) | Server-rendered pages on the kit, restyled by theme and ejected templates; no component library | Drop-in React components. Cut for now; a `urlcode-ui/react` package is the answer if React apps are the audience |
164
+ | Hosted account portal | Self-hosted accounts page at `/account` | None: self-hosted is the point |
165
+ | Passwords, passkeys, email code, magic link, SMS code, 20+ social providers, Web3 wallets | Passwords, passkeys, email code, Google, Apple, plus any OpenID Connect provider by issuer URL (added below) | Named buttons and icons for the long tail of providers; SMS; Web3 |
166
+ | MFA: TOTP, SMS, backup codes | TOTP, passkey, recovery codes | SMS as a factor (deliberately) |
167
+ | Multi-session: several accounts signed in, switch between them | One session per browser | Account switching. Small to add later; the session model allows it |
168
+ | Organizations: roles, permissions, invitations, domain auto-join, switcher, B2B SSO (SAML, OIDC), SCIM | Column reserved; nothing else | The largest gap, and Clerk's moat for B2B. Planned, not first release |
169
+ | Bot protection (Turnstile built in), disposable-email blocking, email and domain allowlist and blocklist, sign-up restrictions, waitlist mode | `challenge` hook, honeypot, velocity limits; allowlist, blocklist, disposable list and waitlist added below | A shipped Turnstile adapter, added below |
170
+ | User metadata: public, private, unsafe per user | Added below as `metadata` on the account with the same three visibilities | |
171
+ | Impersonation, dashboard with analytics, user management UI | Admin extension: dashboard and full user management | Same shape, ships one release later |
172
+ | Webhooks (Svix) for every event | Observability events and host hooks | A webhook sender with signing and retries; added to the later list |
173
+ | JWT templates and integrations (Supabase, Hasura, Convex) | Sessions are opaque; no token issuance | Issuing tokens for third-party services comes with "being a provider", later |
174
+ | Email and SMS template editor in the dashboard | Templates as files in the copy catalogue, previewable with `preview` | An editor. Files are the deliberate choice: reviewable, portable |
175
+ | Localization: many languages shipped | Mechanism day one; English shipped | Translated catalogues. Community and native review needed |
176
+ | Theming: appearance prop, CSS variables, themes | Theme variables, ejected templates, own stylesheet | None |
177
+ | Testing tokens, test mode | Test mode with deterministic codes and a fake identity provider | None |
178
+ | SOC 2 Type II, HIPAA BAA, GDPR DPA as the vendor's paper | Compliance evidence export, audit log, retention, a security review before 1.0 | The paper itself. A self-hosted product cannot hand over a vendor's certification; it hands over the evidence for the operator's own |
179
+ | Managed infrastructure, uptime, free tier to 10,000 monthly users, then per-user pricing | Runs on the operator's host; no per-user cost; no one to page | The absence of a vendor is both the gap and the reason |
180
+
181
+ Added to the first release from this comparison, because each is small
182
+ and each is something a builder would notice missing on day one:
183
+
184
+ - **Any OpenID Connect provider** by issuer URL and client id, with
185
+ discovery, beside the named Google and Apple: `oidc: { okta: { issuer:
186
+ … } }`. Named providers are sugar over this.
187
+ - **Account `metadata`** with `public`, `private` and `unsafe` scopes:
188
+ public is readable by the guest binding and the accounts page, private
189
+ only by the host and admin, unsafe writable by the user. Declared
190
+ fields with types in YAML, so it is still a schema.
191
+ - **Sign-up controls**: `registration: open | invite-only | waitlist |
192
+ off`, with an allowlist and blocklist of emails and domains and the
193
+ bundled disposable-domain list.
194
+ - **A Turnstile adapter** for the `challenge` hook (and the hook stays
195
+ vendor-neutral; hCaptcha and reCAPTCHA adapters are a few lines each).
196
+
197
+ Moved onto the later list: multi-session account switching, a signed
198
+ webhook sender with retries, token issuance for third-party services, and
199
+ the React component package.
200
+
201
+ ### Will it be the obvious choice?
202
+
203
+ Not for everyone, and it should not try to be. It becomes the obvious
204
+ choice for a specific developer, and that developer is common:
205
+
206
+ - Someone who wants to own the data and the pages, run on their own host
207
+ or a Worker, and never pay per user. Clerk's pricing and hosted portal
208
+ are the reasons people leave it at scale.
209
+ - Someone building with an AI, or as a small team, who wants the whole
210
+ product declared and checked rather than assembled from SDK calls. The
211
+ YAML, the audit and the fixtures are the pitch; Clerk has no equivalent
212
+ of "diff this pull request's route and policy changes".
213
+ - Someone in a regulated or data-residency context who needs the
214
+ evidence, not a vendor's certificate.
215
+ - Someone whose site started as redirects and pages and is adding
216
+ accounts, which is exactly the runtime's on-ramp.
217
+
218
+ It is not the obvious choice, today, for a React or Next.js team that
219
+ wants drop-in components and organizations with SAML this quarter, or for
220
+ a team that wants a vendor to hold the compliance paper. Both are
221
+ reachable: organizations and SSO are the planned second phase, and the
222
+ React package is a cut, not a rejection.
223
+
224
+ What decides it in practice is not the feature table. It is whether a
225
+ developer gets from `npm install` to a working, good-looking sign-in with
226
+ passkeys in under five minutes, whether the docs answer the next question
227
+ before it is asked, and whether the first three real products fit the
228
+ scope. Those three are the work.
229
+
230
+ ## 5. Stepping back: is this still one system, and do the extensions make sense?
231
+
232
+ With auth, admin, the kit and the candidates after them, the runtime stops
233
+ being "a portable URL runtime" and becomes a declarative web application
234
+ platform: a small kernel and a set of installable capabilities. That is a
235
+ change of identity and it should be said out loud rather than drift. The
236
+ question is whether the extension shape is the right way to become that,
237
+ against the two alternatives.
238
+
239
+ - **Everything in core**, the Django `contrib` way. Fastest to build and
240
+ the most coherent to document, but every site would carry auth code it
241
+ does not use, the Cloudflare closure would grow, and the runtime's
242
+ promise that a redirect-only project is tiny and portable would erode.
243
+ Rejected.
244
+ - **Separate services**, the Keycloak way: auth as its own server the
245
+ site talks to. Cleanest isolation, but a second process to run, a
246
+ second store, and the accounts page lives somewhere else. It is what
247
+ people leave Keycloak to avoid. Rejected.
248
+ - **Extensions on generic seams**, the Rails engine and Laravel package
249
+ way. Core stays a kernel; a capability is a package that brings routes,
250
+ collections, pages and a YAML block; the operator installs it. This is
251
+ what every long-lived framework converged on, and it is the shape the
252
+ spikes take.
253
+
254
+ So yes, the extensions make sense, on four conditions that the review
255
+ adds to the plan:
256
+
257
+ 1. **Name the whole.** The runtime is the kernel; the kit and the
258
+ extensions are the distribution. The README should say "a portable
259
+ runtime for sites and the accounts, admin and forms they grow into",
260
+ and the roadmap should show the path in section 1 of the kit spike.
261
+ The principles do not change; the pitch does.
262
+ 2. **Keep customisation in the untrusted tier.** Extensions are trusted
263
+ host code; a project's own logic is untrusted WASM. A builder who
264
+ wants a custom rule in a flow ("only `@acme.com` may register",
265
+ "after sign-up, create a workspace") must not have to write host
266
+ code. Extension lifecycle hooks should be able to call a project
267
+ function in the guest, through a granted binding, with a typed input
268
+ and output. Customisation then stays portable YAML plus a guest
269
+ function, and the host file stays what `init` wrote.
270
+ 3. **The store needs aggregates.** A document store with equality
271
+ lookups serves auth, but the admin dashboard and every product feature
272
+ want counts and time buckets. Add `count(where)` and a bucketed count
273
+ by a declared timestamp index to the contract now, so no extension is
274
+ tempted to open the backend directly.
275
+ 4. **The Node-free rule needs tooling.** Requiring extension cores to be
276
+ free of Node imports is the price of every target working. It is
277
+ only bearable if the kit ships the closure check and a scaffold
278
+ (`create-urlcode-extension`) that starts an extension in the right
279
+ shape, so third parties can add extensions on the same seams without
280
+ reading the runtime's source.
281
+
282
+ One tension remains and should stay visible: two tiers of trust. An
283
+ operator who installs an extension trusts it completely; a project author
284
+ is trusted with nothing. That is the browser's model (extensions versus
285
+ pages) and it is right for a runtime that hosts other people's YAML, but
286
+ it means the extension repositories carry the security burden of the
287
+ whole system. The review before 1.0, the threat models and the dependency
288
+ policy in the auth spike are that burden made explicit.
289
+
290
+ ## 6. How to make it better than the alternatives
291
+
292
+ The feature table in section 4 is the floor. What makes it the choice is
293
+ below, ordered by leverage.
294
+
295
+ 1. **Five minutes to a passkey sign-in, visibly.** `npm create urlcode`
296
+ asks three questions and produces a site with auth on; a public demo
297
+ runs the cookbook with the accounts page; the README's first screen is
298
+ that demo. Measure the time and print it in the docs.
299
+ 2. **Import from where people are.** Importers for Clerk, Supabase,
300
+ Auth.js and Firebase user exports, including verifying their password
301
+ hashes (bcrypt and PBKDF2 alongside scrypt and Argon2id, recorded per
302
+ hash and upgraded on sign-in). Nobody switches auth if their users
303
+ must reset passwords.
304
+ 3. **Built for the AI that builds with it.** A `llms.txt` per package,
305
+ the schema published to SchemaStore for editor completion, errors that
306
+ name the YAML key and the fix, and an MCP server that exposes
307
+ `validate`, `audit`, `test`, `routes --compare` and `doctor` so an
308
+ agent can check its own work before a human sees it. The runtime's
309
+ fixtures and audit already make a project checkable; this makes it
310
+ checkable from inside the tools people build with.
311
+ 4. **Extension authoring for third parties.** The scaffold, the closure
312
+ check, the seam contracts as published types, and one worked example
313
+ (`forms`) small enough to read in an hour. A platform with two
314
+ first-party extensions is a product; one with twenty third-party ones
315
+ is an ecosystem.
316
+ 5. **Starters that are products.** A links site, a docs site, a
317
+ members-only site and a small SaaS skeleton, each a YAML project with
318
+ fixtures, each the answer to "what does this look like finished".
319
+ 6. **Trust made public.** The threat models, the independent review's
320
+ report, the release provenance and the benchmarks published, not
321
+ summarised. This is the answer to "why not a vendor".
322
+ 7. **The edge story finished.** Cloudflare with the D1 backend and the
323
+ `--extension` build is the deployment nobody else offers for a full
324
+ accounts system in a Worker; it should be the second target, not the
325
+ fourth.
326
+ 8. **Operations that a small team can run.** One store, one export, one
327
+ restore drill, `doctor` for every target, the breach-response
328
+ commands, and the compliance evidence export: the argument that
329
+ self-hosting is not a burden is that these exist.
330
+
331
+ ## 7. Forkable by design
332
+
333
+ Apache-2.0 makes forking legal. The design has to make it practical: a
334
+ team should be able to take `urlcode-auth`, change what they disagree
335
+ with, publish `acme-auth`, and have every project that says `extension:
336
+ auth` work with it unchanged. That is only possible if the *name* in the
337
+ YAML is a contract and the package is one implementation of it.
338
+
339
+ **The contract is separate from the implementation.** For each extension
340
+ kind there is a small contract package, owned by the runtime's
341
+ organisation, versioned by semver, with no code that does anything:
342
+
343
+ ```
344
+ @jimhoyd/urlcode-auth-contract
345
+ schema/ the JSON schema for extensions.auth and for policies.auth
346
+ routes.md the routes an implementation must serve under its mount and what each returns
347
+ collections/ the store collections, keys and indexes an implementation uses
348
+ view-models/ the typed view model of every page, versioned
349
+ copy/ the catalogue ids and the English strings
350
+ fixtures/ request fixtures every implementation must pass
351
+ conformance/ a test suite that runs against any implementation
352
+ ```
353
+
354
+ The original and every fork depend on the contract, never on each other.
355
+ A fork that keeps the contract is a drop-in: same YAML, same templates,
356
+ same translations, same admin extension on top. A fork that changes the
357
+ contract picks a new name (`extension: acme-auth`) and its own contract
358
+ package, and is honest about not being a drop-in. The runtime's
359
+ `extensions` seam only cares that exactly one plugin claims a name.
360
+
361
+ **What the runtime provides so forks need nothing private:**
362
+
363
+ - The seam types (`Plugin`, `PolicyModule`, the store contract, the
364
+ context bag, the fixtures shape) as published declarations from
365
+ `@jimhoyd/urlcode`, with semver and a deprecation window. Nothing an
366
+ extension needs is reachable only through an unexported path.
367
+ - `provides: 'auth'` in a plugin's registration, distinct from the
368
+ package name, so `admin` requires "a plugin providing `auth` at
369
+ contract `^1`", not `@jimhoyd/urlcode-auth`.
370
+ - The conformance runner: `urlcode extension conform --contract
371
+ @jimhoyd/urlcode-auth-contract --host-file host.js` runs the fixtures
372
+ and the conformance suite against whatever is installed.
373
+
374
+ **What each extension repository does so a fork is an afternoon, not a
375
+ month:**
376
+
377
+ - One package per repository, the runtime's own CI workflows, release
378
+ scripts and container build copied rather than referenced, so the fork
379
+ builds and releases on its own the day it is created.
380
+ - No product name, colour or URL in code or templates; everything comes
381
+ from the theme block and the catalogue, so a fork is not full of the
382
+ original's branding.
383
+ - No telemetry, no update check, no call home. A fork has nothing to
384
+ remove.
385
+ - `FORKING.md` at the root: what to rename, which contract version the
386
+ code implements, how to run conformance, how to publish under a scope,
387
+ and the trademark rule: the runtime's name is not granted by the
388
+ licence, so a fork is `acme-auth`, not `urlcode-auth-acme`, while
389
+ `provides: 'auth'` stays.
390
+ - The scaffold, `create-urlcode-extension`, creates a new extension in
391
+ the same shape, and `--from @jimhoyd/urlcode-auth` creates a fork with
392
+ the renames done.
393
+ - Contract changes are proposals on the contract repository, not commits
394
+ to an implementation, and the original implementation has no special
395
+ standing there beyond being first.
396
+
397
+ **Why this is worth the extra package.** It is the same discipline the
398
+ runtime applies to YAML: behaviour is declared in a portable document and
399
+ any conforming host runs it. Applied to extensions, the portable document
400
+ is the contract and any conforming implementation serves it. It also
401
+ keeps the original honest: if the contract is good enough to fork
402
+ against, it is good enough to build against.
403
+
404
+ ## 8. The work, by repository
405
+
406
+ What sections 5 through 7 add, placed where it belongs. Nothing here is
407
+ started.
408
+
409
+ | Repository | Adds |
410
+ |---|---|
411
+ | `urlcode` (runtime) | The four seams and the store additions (with aggregates); `--host-file`; the Cloudflare `--extension` build option; `provides` and contract-version matching in plugin registration; the conformance runner; published seam types with a deprecation policy; hooks that call a project function in the guest with typed input and verdict; the schema on SchemaStore; `llms.txt` per published entry; an MCP server exposing `validate`, `audit`, `test`, `routes --compare`, `verify-deployment` and `doctor`; `npm create urlcode` with the three questions; the four product starters; the public demo |
412
+ | `urlcode-ui` | The kit; the closure check as a reusable test; `create-urlcode-extension` with `--from`; the worked `forms` example |
413
+ | `urlcode-auth-contract`, `urlcode-admin-contract`, `urlcode-ui-contract` | Schema, routes, collections, view models, copy ids, fixtures, conformance |
414
+ | `urlcode-auth` | The first release as scoped, plus bcrypt and PBKDF2 verification for imported hashes and a generic JSON import; Clerk, Supabase, Auth.js and Firebase importers next; `FORKING.md`; threat model; the pre-1.0 review |
415
+ | `urlcode-admin` | Dashboard and users as specified; requires a provider of `auth`, not a package; `FORKING.md` |
416
+
417
+ Cloudflare moves to the second target after `node`, before Vercel and
418
+ AWS, because it is the deployment no alternative offers for a full
419
+ accounts system.
@@ -106,6 +106,7 @@ Ranked by how often a self-hosted operator hits it before the first deploy.
106
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
107
  | G10 | No JSON Schema body validation | `request.body.format: json` checks syntax only | Low |
108
108
  | G11 | No CORS preflight helper | Documented gap | Low |
109
+ | 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 |
109
110
 
110
111
  ## 4. Design constraints these must respect
111
112
 
@@ -0,0 +1,199 @@
1
+ # Spike: compiling `function` routes into their own Lambdas
2
+
3
+ Status: proposal, nothing implemented. No code in this repository does any of
4
+ this, and nothing here is committed scope.
5
+
6
+ AWS already deploys today. `createLambdaHandler` (`src/aws.ts`) runs a project
7
+ as **one** Lambda behind a Function URL or an API Gateway HTTP API, reading the
8
+ same `urlcode.yaml` that runs locally — see [AWS](AWS.md). What it cannot serve
9
+ is `function`, `middleware` and `link`, which
10
+ `activateNativeOnly` (`src/adapters.ts`) refuses for the whole deployment at
11
+ activation rather than letting individual routes fail per request.
12
+
13
+ This spike asks one question: **is the refusal of `function` a fact about
14
+ Lambda, or a fact about the adapter?** It argues the second, sketches the
15
+ lowering that follows, and is deliberate about what that lowering costs.
16
+
17
+ ## 1. Where the refusal actually comes from
18
+
19
+ `src/capabilities.ts` gives the reason:
20
+
21
+ ```
22
+ capability === 'function' ? 'isolated functions need worker threads and the WASM engine'
23
+ ```
24
+
25
+ That is true of the runtime's *own* mechanism. Isolation for guest code is
26
+ QuickJS inside WebAssembly, driven from worker threads, with the boundaries
27
+ [function security](FUNCTION-SECURITY.md) lists: no `process`, no filesystem,
28
+ no sockets, no `fetch`, a fresh guest heap per invocation, and bindings denied
29
+ unless an operator granted them by exact name.
30
+
31
+ A single Lambda cannot host that engine cheaply, because every cold start pays
32
+ worker startup and WASM instantiation before the first request. So the adapter
33
+ refuses — correctly, for the shape it is.
34
+
35
+ But nothing in that sentence is about Lambda. It is about *one process serving
36
+ every route*. Change the deployment unit and the sentence stops applying.
37
+
38
+ ## 2. The lowering
39
+
40
+ Cloudflare already establishes the pattern: where a platform forbids what the
41
+ adapter needs, URLCode **compiles ahead of time** instead of adapting at
42
+ runtime. `urlcode build --target cloudflare` (`src/build-cloudflare.ts`) emits
43
+ an artifact the Worker reads, and refuses at build time anything it cannot
44
+ serve, with the route named — see [Cloudflare](CLOUDFLARE.md).
45
+
46
+ The same move for AWS: a build step emits **one Lambda per `function` route**,
47
+ plus the existing native-handler Lambda for everything else.
48
+
49
+ ```
50
+ urlcode build --target aws --project . --out dist
51
+
52
+ dist/
53
+ routes/ the native handler Lambda (redirect, respond, page, static, download)
54
+ fn/<route-id>/ one directory per function route
55
+ template.yaml the generated stack
56
+ ```
57
+
58
+ The guest source becomes the Lambda's handler. There is no QuickJS in the
59
+ request path, because the request never crosses a guest boundary inside a
60
+ process — the process *is* the boundary.
61
+
62
+ This is not a smaller change than it looks. Three things follow from it.
63
+
64
+ ## 3. What changes, stated plainly
65
+
66
+ ### 3.1 The isolation guarantee is replaced, not preserved
67
+
68
+ This is the claim most likely to be made too early, so it goes first.
69
+
70
+ QuickJS-WASM and a Lambda are both real isolation. They are **not the same
71
+ isolation**, and neither strictly contains the other:
72
+
73
+ | | QuickJS-WASM | per-route Lambda |
74
+ | --- | --- | --- |
75
+ | Network | unavailable unless a binding is granted | available by default; must be removed |
76
+ | Filesystem | unavailable | a writable `/tmp`, and the deployment package |
77
+ | Environment | not exposed to the guest | ambient unless scrubbed |
78
+ | Blast radius of an escape | the guest heap | the function's IAM role |
79
+ | Per-invocation state | fresh heap, guaranteed | a warm container may be reused |
80
+
81
+ The two rows that matter most are the last two. A guest that escapes QuickJS
82
+ reaches a heap. A guest that misbehaves in a Lambda reaches **whatever that
83
+ Lambda's execution role can reach** — so the compiler would have to emit a role
84
+ per route that grants exactly the route's declared bindings and nothing else,
85
+ and that emitted role becomes a security-critical generated artifact.
86
+ Warm-container reuse is the other: the runtime currently *guarantees* fresh
87
+ state per invocation, and Lambda does not.
88
+
89
+ The honest framing, and the one the docs would have to carry: per-route Lambdas
90
+ are **a substitute for the sandbox, not the sandbox**. Anything that says
91
+ "functions now work on AWS" without saying which guarantee changed is a claim
92
+ this project should not make.
93
+
94
+ ### 3.2 `middleware` is the hard part, not `function`
95
+
96
+ `function` lowers cleanly because it is a leaf. `middleware` is a per-request
97
+ chain, and there are only two ways to lower it, both with a real cost:
98
+
99
+ - **Inline** the chain into each function Lambda at build time. Cheap at
100
+ runtime; duplicates the middleware into every function's package, and a
101
+ middleware change rebuilds every function.
102
+ - **Orchestrate** — a hop per middleware. Composable; adds a Lambda invocation
103
+ of latency and cost to every request, on the hot path.
104
+
105
+ Neither is obviously right, which is exactly why this spike scopes middleware
106
+ out rather than picking one under time pressure.
107
+
108
+ ### 3.3 `link` does not fall out of this at all
109
+
110
+ Stored live links need a durable writable store that instances share. That is
111
+ the same refusal before and after this change. DynamoDB is the natural lowering,
112
+ but it is a store implementation with its own export and restore discipline
113
+ ([dynamic links](DYNAMIC-LINKS.md)), not something a compile step produces.
114
+
115
+ ## 4. Emitting infrastructure is a new kind of output
116
+
117
+ `examples/aws/template.yaml` is hand-written today. Generating a stack means
118
+ this project starts owning a surface it has never owned:
119
+
120
+ - The generated template is only as correct as the provider's current
121
+ behaviour, which changes without asking.
122
+ - A generated IAM role is a security artifact (3.1), so "the template is
123
+ internal, don't rely on it" is a weaker disclaimer here than it was for the
124
+ Cloudflare artifact.
125
+ - The gap simply *moves* unless the emitted stack is checked against the
126
+ project. `urlcode verify-deployment --target <url>` already probes a running
127
+ deployment; it would need to cover the multi-Lambda shape, or the build gains
128
+ a new unverified claim while retiring an honest refusal.
129
+
130
+ That last point is the one worth holding onto. The project's current position on
131
+ AWS is **honest**: the capability catalog reports `deployment: 'unverified'` for
132
+ every target that is not self-hosted, and [AWS](AWS.md) tells a reader to treat
133
+ the limits as unverified until they deploy the example themselves. A compiler
134
+ that emits infrastructure nobody has deployed would be a larger unverified claim
135
+ wearing the clothes of a capability.
136
+
137
+ ## 5. The capability model already has the right shape
138
+
139
+ `src/capabilities.ts` (from the target-capability centralization) is where this
140
+ lands with no new concept:
141
+
142
+ ```
143
+ function / aws: refused → compiled
144
+ ```
145
+
146
+ `compiled` already exists as a `CapabilitySupport` value and already means what
147
+ is needed here — Cloudflare uses it. `deployment` stays `'unverified'` until
148
+ something is actually deployed. The catalog would tell the truth about the new
149
+ lowering without anything else in the model changing, and `urlcode capabilities
150
+ --target aws` would report it.
151
+
152
+ ## 6. Proposed scope for a first spike
153
+
154
+ Narrow, so that the isolation story stays clean and the win is real:
155
+
156
+ **In:** `function` routes, Function URL only, one Lambda per function route,
157
+ a generated role per route carrying exactly that route's granted bindings,
158
+ and the existing native-handler Lambda unchanged for everything else.
159
+
160
+ **Out:** `middleware` (3.2), `link` (3.3), API Gateway, VPC, custom domains,
161
+ warm-start tuning, and any claim about cost.
162
+
163
+ **Done looks like:** a project in `examples/` that builds, a generated template
164
+ a reader can inspect, `urlcode capabilities --target aws` reporting `function`
165
+ as `compiled`, and a written comparison of the two isolation models that a
166
+ reviewer can disagree with.
167
+
168
+ **Not done by that:** a deployment. Everything above can pass without anyone
169
+ having run it on AWS, and the spike should say so rather than imply otherwise.
170
+
171
+ ## 7. Prior art worth reading before building this
172
+
173
+ - **Cloudflare target in this repository** — the closest precedent, and the one
174
+ that establishes compile-not-adapt as a thing URLCode already does.
175
+ - **SST, Serverless Framework, AWS CDK** — all generate per-function
176
+ infrastructure from a declaration. The interesting question is not how they
177
+ emit it but how they keep the emitted stack honest as the provider moves.
178
+ - **Deno Deploy and Vercel functions** — isolate-per-request models, the closest
179
+ commercial thing to the guarantee QuickJS-WASM gives today.
180
+
181
+ ## 8. Open questions
182
+
183
+ - Is warm-container reuse acceptable at all, given the runtime currently
184
+ *guarantees* fresh per-invocation state? If not, this lowering is wrong for
185
+ any route that relies on that guarantee, and there is no build-time way to
186
+ tell which ones do.
187
+ - Does the generated IAM role belong in URLCode's output, or should the build
188
+ emit a *description* of the permissions each route needs and leave the role to
189
+ the operator — closer to how [function security](FUNCTION-SECURITY.md) already
190
+ keeps grants operator-controlled and outside the checkout?
191
+ - Does a per-route Lambda change what `policies` can promise? `throttle` on AWS
192
+ is already `conditional` — "counters are per instance" — and more instances
193
+ make that weaker, not stronger.
194
+ - Is one Lambda per route the right granularity, or one per *project* with a
195
+ route parameter, which keeps deployment small but reintroduces a shared
196
+ process?
197
+ - What happens to the 6 MB Lambda response limit ([AWS](AWS.md)) for a function
198
+ route that returns a large body — refuse at build time, as the Cloudflare
199
+ target refuses what it cannot serve?