@jimhoyd/urlcode 0.4.0-alpha.2 → 0.4.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 (147) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +17 -19
  2. package/.claude/skills/urlcode-operations/SKILL.md +9 -9
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CONTRIBUTING.md +20 -16
  5. package/README.md +59 -64
  6. package/ROADMAP.md +66 -376
  7. package/dist/BUILD-MANIFEST.json +26 -25
  8. package/dist/agents-guide.js +6 -6
  9. package/dist/authoring.js +15 -1
  10. package/dist/build-static.js +2 -0
  11. package/dist/capability-query.js +0 -1
  12. package/dist/catalog.js +0 -1
  13. package/dist/cli.js +25 -9
  14. package/dist/config.js +1 -1
  15. package/dist/explain-cli.js +4 -2
  16. package/dist/explain.js +8 -2
  17. package/dist/extensions.js +1 -1
  18. package/dist/http-response.js +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/init-with.js +36 -11
  21. package/dist/manifest.js +8 -2
  22. package/dist/mcp-authoring.js +4 -4
  23. package/dist/mcp.js +1 -1
  24. package/dist/policies/cache.js +2 -2
  25. package/dist/policies.js +3 -1
  26. package/dist/prerender.js +4 -0
  27. package/dist/project-dependencies.js +305 -0
  28. package/dist/readiness.js +3 -0
  29. package/dist/route-diff.js +12 -5
  30. package/dist/runtime.js +1 -1
  31. package/dist/trusted-functions.js +4 -5
  32. package/dist/types/authoring.d.ts +9 -1
  33. package/dist/types/capability-query.d.ts +0 -1
  34. package/dist/types/catalog.d.ts +0 -4
  35. package/dist/types/config.d.ts +1 -9
  36. package/dist/types/explain.d.ts +6 -1
  37. package/dist/types/extensions.d.ts +1 -1
  38. package/dist/types/http-response.d.ts +0 -1
  39. package/dist/types/index.d.ts +1 -0
  40. package/dist/types/init-with.d.ts +7 -13
  41. package/dist/types/manifest.d.ts +5 -2
  42. package/dist/types/project-dependencies.d.ts +78 -0
  43. package/dist/types/readiness.d.ts +2 -0
  44. package/dist/types/trusted-functions.d.ts +1 -4
  45. package/dist/types/types.d.ts +8 -1
  46. package/dist/types.js +8 -1
  47. package/dist/typescript-authoring.js +18 -7
  48. package/docs/AI-AUTHORING.md +15 -7
  49. package/docs/ASSETS.md +2 -1
  50. package/docs/AUTH-BACKUP.md +32 -0
  51. package/docs/AWS.md +9 -0
  52. package/docs/BEST-PRACTICES.md +17 -9
  53. package/docs/CAPABILITIES.md +1 -1
  54. package/docs/CI-FOLLOWUP-2026-09-19.md +97 -0
  55. package/docs/CI-RELEASE-AUDIT-2026-09-19.md +322 -0
  56. package/docs/CI.md +8 -3
  57. package/docs/CODEBASE-AUDIT-2026-09-20.md +284 -0
  58. package/docs/COMPOSING-A-SITE.md +278 -0
  59. package/docs/DEVELOPMENT-PIPELINE.md +270 -0
  60. package/docs/EXTENSIONS.md +51 -11
  61. package/docs/FRAMEWORK.md +67 -48
  62. package/docs/FUNCTION-SECURITY.md +44 -0
  63. package/docs/INSTALL.md +13 -8
  64. package/docs/LOCAL-DEVELOPMENT.md +3 -0
  65. package/docs/MIDDLEWARE.md +10 -4
  66. package/docs/OPEN-DECISIONS.md +224 -212
  67. package/docs/OPERATIONAL-PROOF.md +3 -3
  68. package/docs/OPERATIONS.md +3 -3
  69. package/docs/POLICIES.md +13 -5
  70. package/docs/PRERENDER.md +23 -11
  71. package/docs/PROJECT-DIRECTION.md +3 -3
  72. package/docs/READINESS.md +10 -5
  73. package/docs/README.md +20 -44
  74. package/docs/RELEASE-0.4.0-alpha.3.md +50 -0
  75. package/docs/RELEASE-0.4.1.md +73 -0
  76. package/docs/RELEASE-READINESS.md +6 -6
  77. package/docs/RELEASE-SECURITY.md +96 -181
  78. package/docs/RESILIENCE.md +3 -3
  79. package/docs/ROUTING.md +3 -4
  80. package/docs/SECURITY-AUDIT.md +2 -2
  81. package/docs/SPECIFICATION.md +12 -6
  82. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +6 -5
  83. package/docs/SPIKE-BUSINESS-SUITE.md +14 -6
  84. package/docs/SPIKE-CORE-LAYERING.md +49 -18
  85. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +7 -5
  86. package/docs/STARTERS.md +17 -5
  87. package/docs/STATIC.md +14 -3
  88. package/docs/TOOLING.md +10 -7
  89. package/docs/TYPESCRIPT-AUTHORING.md +6 -1
  90. package/docs/VERCEL.md +10 -2
  91. package/docs/VERSION-ALIGNMENT.md +76 -201
  92. package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +68 -0
  93. package/docs/archive/2026-09-19/MANAGEMENT-SECURITY.md +102 -0
  94. package/docs/{NEXT-PHASE-PLAN.md → archive/2026-09-19/NEXT-PHASE-PLAN.md} +15 -5
  95. package/docs/{NEXT-STEPS.md → archive/2026-09-19/NEXT-STEPS.md} +15 -3
  96. package/docs/archive/2026-09-19/OPEN-DECISIONS.md +277 -0
  97. package/docs/archive/2026-09-19/RELEASE-SECURITY.md +186 -0
  98. package/docs/archive/2026-09-19/ROADMAP.md +387 -0
  99. package/docs/{SPIKE-EXTENSION-MODEL.md → archive/2026-09-19/SPIKE-EXTENSION-MODEL.md} +11 -0
  100. package/docs/{SPIKE-EXTENSIONS.md → archive/2026-09-19/SPIKE-EXTENSIONS.md} +25 -14
  101. package/docs/archive/2026-09-19/SPIKE-LAMBDA-COMPILE.md +365 -0
  102. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +778 -0
  103. package/docs/{USABILITY-REVIEW.md → archive/2026-09-19/USABILITY-REVIEW.md} +12 -2
  104. package/docs/archive/README.md +28 -0
  105. package/docs/policies/agents.md +1 -1
  106. package/docs/policies/compression.md +3 -2
  107. package/docs/policies/security.md +3 -2
  108. package/docs/yaml/functions.md +10 -2
  109. package/docs/yaml/middleware.md +5 -3
  110. package/examples/assets/example.yaml +1 -1
  111. package/examples/cookbook/middleware/envelope.mjs +4 -2
  112. package/examples/cookbook/route-index.json +1 -1
  113. package/examples/cookbook/routes/middleware.yaml +1 -1
  114. package/examples/prerender/README.md +14 -6
  115. package/examples/prerender/functions/page.mjs +4 -2
  116. package/examples/prerender/middleware/template.mjs +1 -1
  117. package/examples/prerender/prerender.mjs +1 -1
  118. package/examples/prerender/urlcode.yaml +8 -4
  119. package/llms-full.txt +503 -88
  120. package/llms.txt +6 -4
  121. package/package.json +27 -4
  122. package/packaging/claude-plugin/.claude-plugin/plugin.json +2 -2
  123. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +17 -19
  124. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +9 -9
  125. package/recipes/authenticated-json-api/README.md +4 -3
  126. package/recipes/authenticated-json-api/functions/profile.mjs +2 -1
  127. package/recipes/authenticated-json-api/recipe.yaml +1 -1
  128. package/recipes/contact-form/functions/contact.mjs +2 -1
  129. package/recipes/contact-form/recipe.yaml +2 -2
  130. package/recipes/cors-api/README.md +2 -2
  131. package/recipes/cors-api/recipe.yaml +1 -1
  132. package/recipes/health-page/README.md +1 -1
  133. package/recipes/json-api/README.md +1 -1
  134. package/recipes/json-api/recipe.yaml +3 -3
  135. package/recipes/middleware/README.md +8 -4
  136. package/recipes/middleware/middleware/envelope.mjs +4 -2
  137. package/recipes/protected-download/README.md +1 -1
  138. package/recipes/protected-download/recipe.yaml +1 -1
  139. package/recipes/static-plus-api/README.md +2 -2
  140. package/recipes/static-plus-api/public/index.html +1 -1
  141. package/recipes/static-plus-api/recipe.yaml +1 -1
  142. package/recipes/static-plus-api/urlcode.yaml +1 -1
  143. package/recipes/typescript/recipe.yaml +4 -4
  144. package/skills/urlcode/SKILL.md +6 -6
  145. package/starters/default/AGENTS.md +6 -6
  146. package/docs/SPIKE-LAMBDA-COMPILE.md +0 -201
  147. package/docs/SPIKE-MONOREPO.md +0 -322
@@ -0,0 +1,278 @@
1
+ # Composing a site from ui, auth and admin
2
+
3
+ One command produces a site that already has accounts, an administration
4
+ console and a presentation kit wired together:
5
+
6
+ ```sh
7
+ npm install @jimhoyd/urlcode @jimhoyd/urlcode-ui @jimhoyd/urlcode-auth @jimhoyd/urlcode-admin
8
+ urlcode init site --with ui,auth,admin
9
+ ```
10
+
11
+ This page is the map of what you may then change, and with which tool. It
12
+ covers three different activities that are easy to confuse:
13
+
14
+ | You want to | Use | Code? |
15
+ |---|---|---|
16
+ | Change what an extension is configured to do | the `config` block in `app/urlcode.yaml` | none |
17
+ | Change how its screens look or read | files under `<site>/ui/` | none (templates are data) |
18
+ | Run your own logic at one of its lifecycle points | a project function named from that `config` | first-party project JavaScript |
19
+ | Add a capability none of the three provides | a new extension package | TypeScript against the runtime contract |
20
+
21
+ Work down that list, not up. The [declarative-first
22
+ principle](PROJECT-DIRECTION.md#design-principle-declarative-first) applies
23
+ here as much as it does to routes: reach for the next row only when the one
24
+ above it cannot express the requirement.
25
+
26
+ ## What `--with ui,auth,admin` generates
27
+
28
+ - `site/app/` — the route project: `urlcode.yaml` with an `extensions` block
29
+ per package, and `routes/extensions.yaml` holding `/assets/ui/*`,
30
+ `/account/*`, `/private` and `/admin/*`.
31
+ - `site/host.mjs` — the operator host module, the one place that holds code.
32
+ It builds the kit with `createUiExtension`, passes the returned object into
33
+ `authExtension` and `adminExtension`, and lists `ui.registration` first.
34
+ - `site/ui/` — `copy/`, `templates/` and `extra.css`, the project's
35
+ presentation overrides, beside the host and **outside** `app/`.
36
+ - `site/operator-service.mjs`, `site/data/` — auth's operator service and its
37
+ private key material, mode `0600`.
38
+ - `site/README.md` — the merged next steps, environment table and the project
39
+ revision to review and pin.
40
+
41
+ Nothing about that wiring is manual any more. The generated host registers
42
+ `authCatalogue` as a copy source and both `authUiTemplates` and
43
+ `adminUiTemplates` as template namespaces, because auth and admin render only
44
+ through the kit and refuse to activate without their own templates present.
45
+
46
+ ### Supported combinations
47
+
48
+ `--with` order is the activation order, and the kit must be active before
49
+ anything that renders through it.
50
+
51
+ | `--with` | Result |
52
+ |---|---|
53
+ | `ui` | Kit only; the host wires no peer catalogue or templates. |
54
+ | `ui,auth` | Accounts on `/account/*`, rendered through the kit. |
55
+ | `ui,auth,admin` | The full composition above. |
56
+ | `auth` or `auth,admin` | Refused: the scaffold names the missing `ui`. |
57
+ | `auth,admin,ui` | Refused: `ui` must come before the extensions it renders. |
58
+ | `admin` without `auth` | Refused: admin reuses auth's service, CSRF key and revision. |
59
+
60
+ Every refusal happens before anything is written, and leaves no directory
61
+ behind. There is no auth-without-ui or admin-without-ui configuration in this
62
+ revision: the UI primitive fallback was retired, so the kit is the only render
63
+ path (see [OPEN-DECISIONS.md](OPEN-DECISIONS.md)).
64
+
65
+ ## Declarative configuration
66
+
67
+ Each package owns one `extensions.<name>` block. The block itself is core
68
+ schema (`version` plus `config`); what may go inside `config` is the
69
+ package's own JSON Schema, which you can print rather than guess:
70
+
71
+ ```sh
72
+ urlcode extensions --project ./site/app --host-file "$PWD/site/host.mjs" --json
73
+ ```
74
+
75
+ The same report is the MCP tool `get_extensions`, and it is the authoritative
76
+ answer for both the configuration schema and the per-route policy schema. The
77
+ generated site starts from something like this:
78
+
79
+ ```yaml
80
+ version: "1"
81
+ extensions:
82
+ ui:
83
+ version: "1"
84
+ config:
85
+ theme:
86
+ name: My Site
87
+ languages: [en]
88
+ copy: ui/copy
89
+ templates: ui/templates
90
+ stylesheet: ui/extra.css
91
+ auth:
92
+ version: "1"
93
+ config:
94
+ registration: "off"
95
+ admin:
96
+ version: "1"
97
+ config: {}
98
+ ```
99
+
100
+ Routes mount an extension, and policies require one:
101
+
102
+ ```yaml
103
+ routes:
104
+ /assets/ui/*:
105
+ extension: ui
106
+ methods: [GET, HEAD]
107
+ /account/*:
108
+ extension: auth
109
+ methods: [GET, HEAD, POST]
110
+ /admin/*:
111
+ extension: admin
112
+ methods: [GET, HEAD, POST]
113
+ /private:
114
+ respond:
115
+ text: Signed in
116
+ policies:
117
+ extensions:
118
+ auth: {}
119
+ ```
120
+
121
+ See [EXTENSIONS.md](EXTENSIONS.md) for the `auth` route short form, extension
122
+ middleware, and the host-file trust boundary.
123
+
124
+ ## Presentation overrides
125
+
126
+ The `ui` config's `copy`, `templates` and `stylesheet` paths point at the
127
+ project's own directories. Nothing here forks a package.
128
+
129
+ | Override | File | Effect |
130
+ |---|---|---|
131
+ | Wording and translation | `ui/copy/<locale>.json` | Replaces catalogue ids, including ids the auth and admin packages own. Listed in `languages`. |
132
+ | A whole screen | `ui/templates/<name>.html` | Shadows a kit or extension template of that name, for example `ui/templates/auth/sign-in.html` or `ui/templates/admin/dashboard.html`. |
133
+ | Styling | `ui/extra.css` | Appended after the kit stylesheet; `{file, replace: true}` replaces it instead. |
134
+ | Colours, logo, favicon, radius, font | the `theme` block | Declarative; no file needed. |
135
+
136
+ A template is data in the kit's own language. It cannot add a script, change
137
+ what a form validates, or change what a page sends in headers — so an override
138
+ cannot weaken the screen it restyles. Stylesheets containing `@import`,
139
+ `script`, `javascript:` or `expression(` are refused.
140
+
141
+ Names, coverage and what the runtime will actually load:
142
+
143
+ ```sh
144
+ npx urlcode-ui list --project ./site --extensions @jimhoyd/urlcode-auth,@jimhoyd/urlcode-admin
145
+ npx urlcode-ui doctor --project ./site --extensions @jimhoyd/urlcode-auth,@jimhoyd/urlcode-admin --copy ui/copy --templates ui/templates --stylesheet ui/extra.css
146
+ npx urlcode-ui eject auth/sign-in --out ./site/ui/templates --project ./site --extensions @jimhoyd/urlcode-auth
147
+ npx urlcode-ui copy --missing fr --project ./site --extensions @jimhoyd/urlcode-auth --copy ui/copy --languages en,fr
148
+ ```
149
+
150
+ `eject` copies the shipped source so an override starts from what ships and
151
+ never overwrites an existing file. `ui/` lives outside `app/`, so editing copy
152
+ or templates does **not** change the project revision and does not require
153
+ re-pinning `PROJECT_SHA256`.
154
+
155
+ **Name the packages that ship the other namespaces.** `urlcode-ui` is this
156
+ kit alone until `--extensions` names them. Each package is resolved from
157
+ `--project` with Node package resolution and imported for the namespace it
158
+ exports; one that is not installed there is skipped with a note, so the
159
+ command still runs. The site's `host.mjs` is never read: it builds services
160
+ and reads secrets at its top level, and a read-only `list` or `doctor` must
161
+ not run it. With the packages named:
162
+
163
+ - `list` shows the `auth/*` and `admin/*` names beside the kit's own, each
164
+ with its origin, and `eject auth/sign-in` copies the shipped source.
165
+ - `doctor` reports an `expected` view model for an extension template, so its
166
+ `behind` flag tells you when an override of one has fallen behind what
167
+ ships. Its `extensions` field names the namespaces the report covers, so a
168
+ report built without a peer is visible as such.
169
+ - `preview auth/sign-in` renders the extension's own sample view model.
170
+ - `copy --missing` skeletons cover the auth ids the account screens use.
171
+ Admin-owned `adminUi.*` ids are deliberately not offered: admin composes
172
+ its catalogue onto the kit's presentation rather than registering it there,
173
+ and those translations do not currently reach the console
174
+ ([#227](https://github.com/jimhoyd-com/urlcode/issues/227)).
175
+
176
+ `urlcode init <directory> --with ui,auth,admin` writes these commands into the
177
+ generated README with the flag already set. `@jimhoyd/urlcode-ui` depends on
178
+ neither peer; the operator names them.
179
+
180
+ Overrides of extension templates and of extension-owned catalogue ids reach
181
+ the rendered screens, which is what the regression test below asserts.
182
+
183
+ ## Project functions: lifecycle hooks
184
+
185
+ A hook is your own function, named from the extension's `config`, that the
186
+ extension calls at a lifecycle point it defines. It uses the same source shape
187
+ a `function` route uses — a bare path, or `{source, export}` — resolved
188
+ relative to the route project.
189
+
190
+ ```yaml
191
+ extensions:
192
+ auth:
193
+ version: "1"
194
+ config:
195
+ registration: open
196
+ hooks:
197
+ beforeRegister:
198
+ source: ./hooks/registration-rule.mjs
199
+ export: default
200
+ onSignUp: ./hooks/on-signup.mjs
201
+ ```
202
+
203
+ `hooks` is not a core schema key. It is each package's own config, validated
204
+ by that package's schema before `activate()`, which is why `get_extensions` is
205
+ the place to check what a given version accepts.
206
+
207
+ Hooks are first-party project code and run **trusted and in-process**, the
208
+ same default `function` and `middleware` routes have
209
+ ([FUNCTION-SECURITY.md](FUNCTION-SECURITY.md)). Neither package implements
210
+ sandboxed hook execution yet; `sandbox: true` on a hook is rejected loudly at
211
+ activation rather than accepted and silently run trusted.
212
+
213
+ ### `@jimhoyd/urlcode-auth`
214
+
215
+ | Hook | Input | Returns | Called |
216
+ |---|---|---|---|
217
+ | `beforeRegister` | `{email, profile?}` | `{allow: boolean, reason?}` | Before an account is created, on `POST /account/register` and on `POST /account/signup/begin`. |
218
+ | `onSignUp` | `{accountId, email}` | ignored | After a genuinely new account is created — on `/account/register`, and on `/account/signup/complete` only when that completion created an account rather than signing an existing one in. |
219
+ | `onDelete` | `{accountId, email}` | ignored | After the account owner's own deletion is scheduled. Not on an administrator-initiated deletion, and not on the background purge when the grace period ends. |
220
+
221
+ ### `@jimhoyd/urlcode-admin`
222
+
223
+ | Hook | Input | Returns | Called |
224
+ |---|---|---|---|
225
+ | `beforeRoleChange` | `{accountId, currentRoles, requestedRoles, actorId, reason}` | `{allow: boolean, reason?}` | Before roles are applied, after the administrator's permission check. A veto means the auth service is never asked. |
226
+ | `onRegistrationApproved` | `{requestId, accountId, email, actorId, reason}` | ignored | After a registration request is approved. |
227
+ | `onAccountStatusChanged` | `{accountId, status, actorId, reason}` | ignored | After an account is locked or unlocked. |
228
+
229
+ ### Verdicts and failure
230
+
231
+ - **A veto is explicit.** A pre-action hook allows only by returning
232
+ `allow: true`. `allow: false`, or no verdict at all, rejects the operation
233
+ with `403` and the hook's own `reason`, or a generic message when it gave
234
+ none. Nothing is written. A hook that *throws* has not returned a verdict:
235
+ the operation is still refused, but as a generic `500`, so return a verdict
236
+ rather than throwing when you mean to deny.
237
+ - **Broken hooks fail at activation, not at the first request.** A missing
238
+ module, a source path escaping the project, an export that is not a
239
+ function, or `sandbox: true` all throw while the extension activates, naming
240
+ the hook. The site does not start.
241
+ - **A post-action hook cannot undo anything.** `onSignUp`,
242
+ `onDelete`, `onRegistrationApproved` and `onAccountStatusChanged` run after
243
+ the operation has committed. Throwing from one replaces the success response
244
+ with a `500` while the account, approval or status change stands. There is
245
+ no retry and no rollback. Keep them non-throwing: catch your own errors and
246
+ queue the work instead of failing the request.
247
+ - **A hook's message is not a channel to the browser.** Only a pre-action
248
+ `reason` is shown. An uncaught error surfaces as a generic failure.
249
+ - **An edited hook needs a restart.** Activation re-imports the hook's entry
250
+ module, so a reload picks up an edit to that file — but modules it imports
251
+ stay on Node's module cache, exactly as for trusted route functions.
252
+
253
+ ## TypeScript: implementing a new extension
254
+
255
+ Only write an extension when a capability is genuinely absent — not to
256
+ customize one of the three above. An extension is an operator-installed
257
+ package whose host object core activates; it is named in `host.mjs`, never
258
+ in YAML. The contract, the activation inputs, `ExtensionActivation.root`,
259
+ credential headers and the `projectSha256` pin are in
260
+ [EXTENSIONS.md](EXTENSIONS.md) and [TYPESCRIPT.md](TYPESCRIPT.md); the
261
+ `scaffold` export that makes a package work with `init --with` is in
262
+ [EXTENSIONS.md](EXTENSIONS.md#scaffolding-with-init---with).
263
+
264
+ If its screens should be themeable the same way auth's and admin's are, it
265
+ also exports a template namespace (and, if it ships English wording, a
266
+ catalogue) for a host to pass to `createUiExtension`. That is what makes
267
+ `ui/templates/<yourname>/<screen>.html` work in a consumer project without a
268
+ fork.
269
+
270
+ ## What this page does not claim
271
+
272
+ The composition, the refusals and the override path are exercised by
273
+ `test/workspace-scaffold.integration.ts`, which runs `init --with` against the
274
+ built packages, drops a template and a copy catalogue into the generated
275
+ `ui/` directory and asserts both reach a rendered auth screen and a rendered
276
+ admin screen. That runs in-process against the generated host: no HTTP
277
+ listener, TLS proxy, browser or deployed site is exercised, and no published
278
+ npm tarball is checked against this checkout.
@@ -0,0 +1,270 @@
1
+ # Development and release pipeline
2
+
3
+ The repository uses npm workspaces, independent package versions, and Changesets
4
+ for UI, auth and admin. Core remains at the repository root and is explicitly
5
+ included in the shared release inventory. Moving it is not required to use the
6
+ same release checks and coordinator.
7
+
8
+ ## Pull requests and main pushes
9
+
10
+ Every PR and every push to main starts `verify`. A complete Git diff selects one
11
+ of two lanes:
12
+
13
+ - **Prose:** root project Markdown, `docs/**/*.md`, `llms.txt`, `llms-full.txt`,
14
+ `benchmarks/agent/README.md`, `benchmarks/results/README.md` and each
15
+ package's `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` and `GOVERNANCE.md` run
16
+ guidance/generated-resource checks, runtime audit, and the required container
17
+ job. CodeQL retains its repository policy.
18
+ - **Full:** all other changes, mixed changes, and empty, unclassifiable or
19
+ unavailable diffs run static checks once and core and workspace suites
20
+ separately. Both suites retain Linux on Node 22/24/26. Main adds
21
+ Windows/macOS on Node 24. PRs add those platform legs for runtime, CLI,
22
+ SQLite, fixture, dependency, workflow and unknown changes; known UI
23
+ presentation-only changes omit them. Package, action, cookbook,
24
+ reproducibility and operational checks retain their coverage.
25
+
26
+ A pull request is classified against its merge base; a push to main is
27
+ classified tip to tip from the event's `before`/`after` SHAs, so a force-push or
28
+ rewritten history is measured by what actually moved. Classification fails
29
+ closed: a missing, malformed or all-zero SHA (branch creation or deletion), and
30
+ history this checkout cannot read, select full verification. Scheduled and
31
+ manually dispatched runs are never classified from paths at all, so exact-SHA
32
+ release coverage cannot silently become a docs-only run.
33
+
34
+ The prose allowlist is deliberately narrow, and it is a list of reviewed,
35
+ non-executable contributor prose rather than "every Markdown file". Skills,
36
+ starters, recipes, examples, schemas, manifests, workflows, benchmark prompts,
37
+ tasks, answers and acceptance notes, and any package document that ships inside
38
+ a published tarball or is read by an agent surface (`README.md`, `SECURITY.md`,
39
+ `CONTRACT.md`, `THREAT-MODEL.md`, `IMPLEMENTATION-STATUS.md`, `AGENTS.md`,
40
+ `CHANGELOG.md`) select full checks. Anything feeding a generator stays in the
41
+ code lane. A rename from source into docs also selects full checks, because the
42
+ diff is read without rename detection and shows both paths. Every prose path is
43
+ still covered by the always-run `docs` job, which walks all authored Markdown.
44
+ No required workflow uses `paths-ignore`.
45
+
46
+ The always-run `docs` job runs `npm run check:docs`; in the full lane the
47
+ `static` job runs `npm run check:code`, which is the rest of `npm run check`.
48
+ The two together are exactly `npm run check`, which stays complete for local
49
+ use. This removes a duplicated dependency install plus seven repeated checks on
50
+ the same commit, not meaningful wall time: the sampled documentation checking
51
+ was about two seconds. Job names, lane selection and `verify-complete`
52
+ dependencies are unchanged.
53
+
54
+ `verify-complete` accepts only the results specified by the successful plan.
55
+ Failed, canceled, missing or unexpectedly skipped work fails the gate. Required
56
+ check names (`verify-complete`, `container`) and CodeQL enforcement are preserved.
57
+ The repository ruleset currently does not require a branch to be up to date;
58
+ release publication separately requires verification of the exact main commit.
59
+
60
+ ```sh
61
+ npm run check:docs # prose checks without the runtime suite
62
+ npm run check:code # everything in `check` except the prose checks
63
+ npm run ci:plan -- BASE_SHA HEAD_SHA # previews as a pull request outside Actions
64
+ npm run ci:report -- RUN_ID # read GitHub job/step durations
65
+ npm run ci:history -- 100 2026-09-19 # group historical timing samples
66
+ npm run verify # full local validation remains available
67
+ npm run test:package # builds and installs a real archive
68
+ ```
69
+
70
+ CI uses `test:package:built` only after building in that same job. Core tests and
71
+ workspace tests run in separate jobs to shorten their serial critical path;
72
+ this increases job setup overhead and needs monitoring for runner queue pressure.
73
+ After building all three extensions, the workspace job also runs the real
74
+ `init --with ui,auth,admin` scaffold integration. Missing workspace outputs fail
75
+ instead of silently skipping an absent external checkout.
76
+ The [audit](CI-RELEASE-AUDIT-2026-09-19.md) records the previous timings.
77
+ The [follow-up measurements](CI-FOLLOWUP-2026-09-19.md) record early compact-main observations; issue #185 contains the later decision
78
+ and current sample sizes.
79
+
80
+ ## Version preparation and release ownership
81
+
82
+ Core remains at the repository root. Independent extension versions remain
83
+ supported; a coordinated version is an explicit maintainer choice, not a
84
+ permanent fixed-version policy. An explicitly selected stable version exits
85
+ Changesets alpha pre-mode; subsequent stable patches stay out of pre-mode. Feature PRs record workspace release intent in
86
+ Changesets; core release notes remain an explicit maintainer responsibility.
87
+
88
+ `release:check` checks manifest/lock versions and peer ranges, CLI and MCP
89
+ versions, generated plugin metadata, local peer compatibility, and channel policy.
90
+ The preparation helper updates these together, adds release notes and extension
91
+ changelogs, and records the version decision. Pending Changesets must be
92
+ explicitly consumed; they are archived under `.changeset/pre/` and their summaries
93
+ included in the release notes. Review the resulting diff and peer minimums.
94
+
95
+ ```sh
96
+ # Example only: choose the next intended version before executing.
97
+ npm run release:prepare -- --version 0.4.1 --consume-changesets
98
+ # Apply local edits on a clean non-main branch; no remote writes or publication:
99
+ npm run release:prepare -- --version 0.4.1 --consume-changesets --execute
100
+ ```
101
+
102
+ An optional `--notes PATH` adds reviewed maintainer notes. Dry runs do not change
103
+ files. Preparation rejects downgrades, reused local tags, dirty checkouts and
104
+ stale plans. A stable target removes `.changeset/pre.json`, publishes to npm
105
+ `latest`, and leaves the historical `alpha` pointer unchanged. Alpha targets
106
+ require existing alpha mode; the helper never silently re-enters prerelease mode.
107
+ It does not invoke a permanent Changesets fixed-version policy.
108
+
109
+ ## One-command release and resume
110
+
111
+ Inspect without writing:
112
+
113
+ ```sh
114
+ npm run release:status # registry channels, peer compatibility, tag SHAs
115
+ npm run release:plan # manifest-derived inventory
116
+ npm run release:run # ordered states at this checkout: pending/resume/unchanged
117
+ npm run release:run -- --version 0.4.1 --consume-changesets
118
+ ```
119
+
120
+ For an explicitly authorized coordinated release:
121
+
122
+ ```sh
123
+ npm run release:run -- --version 0.4.1 --consume-changesets --execute
124
+ ```
125
+
126
+ `--execute` authorizes the entire sequence: create the release branch/PR, wait
127
+ for checks and merge, run the release gates, create version tags, publish, verify
128
+ an installed consumer, and create/check/merge the starter update. It never
129
+ approves a review or bypasses a required check. A required human review still
130
+ blocks merging. No write or publication occurs without `--execute`.
131
+
132
+ The coordinator works in a temporary clone and prints its location. It prepares
133
+ `codex/release-VERSION`, or resumes the existing PR/branch after checking its
134
+ versions and receipt. It checks out the actual merged SHA and reinstalls that
135
+ commit's locked dependencies. Repeating the command discovers existing PRs,
136
+ gates, tags and workflow state rather than creating another version. Temporary
137
+ release clones are retained for diagnosis and can be removed after completion.
138
+
139
+ For an independently prepared release PR already merged to main, use a clean
140
+ checkout of its exact commit:
141
+
142
+ ```sh
143
+ npm run release:run -- --execute
144
+ ```
145
+
146
+ The coordinator creates `codex/release-validation/SHA` at the already-merged
147
+ commit when gates are missing. An existing validation branch must name that
148
+ exact SHA; it is never moved. This lets main advance without changing the release
149
+ being tested. The branches remain as audit/resume references. It dispatches full
150
+ `ci.yml` and `candidate.yml`, reuses existing successful runs, and waits for
151
+ running ones. A failed gate stops with its run ID; diagnose it and rerun that
152
+ exact run before resuming. Compact PR/main checks cannot replace the full
153
+ OS/Node matrix or CodeQL on the selected commit.
154
+
155
+ Before creating any version tags, it downloads and verifies the candidate bundle.
156
+ A green run with missing artifacts does not authorize tags. New version tags are
157
+ annotated with the source commit, chosen candidate run ID and the SHA256 of its
158
+ signed manifest. The manifest binds every package and supporting asset by hash. Every package in a
159
+ resumed train must select that same candidate. Neither a later candidate of the
160
+ same source nor a newer main commit can silently replace the chosen bytes.
161
+ A successful rerun of the same candidate ID cannot substitute a changed bundle: its
162
+ manifest must still match the immutable checksum in the release tag.
163
+ After tags exist, rerun package publishers rather than the pinned candidate run.
164
+ If a later attempt of that candidate run fails, the coordinator stops even when
165
+ an earlier attempt succeeded; it does not infer which attempt should be trusted.
166
+
167
+ Publication is sequential: core → UI → auth → admin, skipping unchanged published
168
+ versions. Each publisher must succeed and its version must be readable through
169
+ npm's abbreviated install metadata, with a downloadable SHA512-verified tarball,
170
+ before dependents begin. Bounded retries handle propagation, transport failures,
171
+ 429 and server errors; authentication and integrity failures stop immediately.
172
+ Afterward, an external consumer with a fresh npm cache installs the four exact
173
+ registry versions, checks its peer tree and imports, and generates the combined
174
+ extension scaffold in dependency order (`ui,auth,admin`). The candidate archive
175
+ smoke uses the same scaffold check before any package is published.
176
+
177
+ The standalone starter helper updates the exact core pin, lockfile, matching
178
+ schema/docs links and guide from the installed published core package, then runs
179
+ validation/tests/audit/benchmark,
180
+ and opens a resumable PR. The coordinator waits for checks and merges it, checking
181
+ for a newer template pin immediately before merge. `--skip-template` explicitly
182
+ leaves this follow-up to the maintainer. To run only that follow-up:
183
+
184
+ ```sh
185
+ npm run release:template -- --version 0.4.1 --execute
186
+ ```
187
+
188
+ That standalone helper opens a PR but does not merge it. All helpers stop on
189
+ errors; rerun after diagnosis. A failed publisher is retried at most once per
190
+ coordinator invocation and must pass the original-byte recovery checks below.
191
+
192
+ The maintainer identity needs repository Contents, Actions and Pull requests
193
+ write, plus Checks read, on the affected repositories. GitHub App installations
194
+ should be scoped to URLCode and its template. No ruleset bypass or long-lived
195
+ npm token is needed. The workflow `GITHUB_TOKEN` must not create the triggering
196
+ version tags because its push events do not start ordinary push workflows.
197
+ [GitHub App guidance](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/deciding-when-to-build-a-github-app)
198
+
199
+ ## Build once, publish verified bytes
200
+
201
+ The four publisher filenames remain unchanged for npm trusted-publisher identity.
202
+ The candidate builds in the digest-pinned environment, runs verification,
203
+ packaging and local operational checks, packs all four packages, and tests an
204
+ isolated combined consumer. The signed bundle contains all four archives,
205
+ SBOM, Homebrew formula, source/build manifest, train identity and checksums.
206
+ The manifest binds it to the candidate run as well as the commit.
207
+
208
+ Publishers verify the selected candidate's workflow provenance, exact source SHA,
209
+ run identity, manifest/package identities and hashes. They publish the selected
210
+ package's existing archive without rebuilding it. Auth/admin still run isolated
211
+ compatibility tests against their actual published peer floors; temporary test
212
+ builds do not replace the promoted archive. This preserves the distinction
213
+ between workspace compatibility and registry compatibility.
214
+
215
+ Each package's GitHub release stores the complete signed bundle for durable
216
+ recovery. Supporting sibling archives are candidate evidence: an independent
217
+ package release does not imply every sibling archive was published to npm.
218
+ Candidate and release Actions artifacts retain 90 days; retention is not an
219
+ archival guarantee. Keep independent last-good copies for deployment rollback.
220
+
221
+ ## Recovery, immutable tags and channels
222
+
223
+ A retry restores the original retained bundle, or recovers the complete verified
224
+ bundle from that package's GitHub release. Missing, incomplete or unverifiable
225
+ originals stop the retry. It never rebuilds archives or substitutes a new
226
+ candidate. If publication stopped before a complete durable release existed and
227
+ the retained artifact is gone, a new version may be required.
228
+
229
+ Existing npm versions must match SHA512 integrity; existing GitHub assets must
230
+ match byte for byte. Partial npm/GitHub/GHCR success is possible and cannot be
231
+ made atomic. Fix registry identity/settings where appropriate and resume the
232
+ original run. A source change requires a new version and tag. Never delete,
233
+ recreate, move or force-push version tags.
234
+
235
+ These recovery changes apply to releases made with the new workflows. They cannot
236
+ change the immutable workflow source at `0.4.0-alpha.3` or repair that historical
237
+ run by rerunning it. The missing-artifact behavior observed there is recorded in
238
+ [issue #223](https://github.com/jimhoyd-com/urlcode/issues/223).
239
+
240
+ npm uses OIDC with pinned npm 11.5.1. Alpha versions use npm/GHCR `alpha`, and
241
+ GitHub prerelease classification with `--latest=false`. Existing `latest`
242
+ pointers are not promoted by this flow. Core GHCR publication remains conditional
243
+ on `PUBLISH_CONTAINER=true`; its existing version/channel identity guards remain.
244
+ Historical GHCR verification is still a separate follow-up.
245
+
246
+ The [Immutable release tags rule](https://github.com/jimhoyd-com/urlcode/rules/23712319)
247
+ blocks updates/deletions of `v*` and `@jimhoyd/urlcode-*@*`, permits creation, and
248
+ has no bypass actors. Main protection is unchanged. Source and artifact checks
249
+ are not an independent security assessment, provider deployment or recovery
250
+ proof. The next explicitly authorized new release must exercise the complete
251
+ new promotion/recovery path. Progress and remaining work are recorded in
252
+ [issue #185](https://github.com/jimhoyd-com/urlcode/issues/185).
253
+
254
+ ### Windows fixture cleanup
255
+
256
+ Auth/admin tests register resources with their package-local `test/cleanup.ts`.
257
+ Cleanup runs in reverse acquisition order: close servers and SQLite services
258
+ before deleting temporary directories, including services reopened by a test.
259
+ Every registered callback is attempted even if another closer throws, and the
260
+ combined error fails the test. Register each closer as soon as its resource opens.
261
+ The suites use a five-minute test-file timeout so a stuck fixture is diagnosed
262
+ before the CI job limit. Node applies this limit to whole test files too; the
263
+ large auth-core file legitimately exceeds two minutes on Windows Node 22.
264
+ Windows regression coverage runs on Node 24 for platform-sensitive PRs and
265
+ main; nightly/manual runs cover Node 22/24/26.
266
+
267
+ Failed auth service initialization also waits for its SQLite worker to terminate
268
+ before rejecting. Callers can clean up or retry after a rejected open without
269
+ racing a remaining database handle; configuration identity failures still fail
270
+ closed with the same error code.
@@ -6,10 +6,11 @@ and admin implementations live in `urlcode-auth` and `urlcode-admin`; the runtim
6
6
  supplies only the generic integration contract. No project file can import a host
7
7
  extension or choose its npm package.
8
8
 
9
- Stored short links are moving out of core this way too: a future
10
- `urlcode-dynamic-link` package (mount-based, like `auth`/`admin`, not yet
11
- published) will own the durable link store, its CLI and management API. Core
12
- no longer has a native `link` handler or a `dynamicLinks` project flag.
9
+ Stored short links moved out of core this way too: a `urlcode-dynamic-link`
10
+ package (mount-based, like `auth`/`admin`) owned the durable link store, its CLI
11
+ and management API. That package has since been retired and unpublished, so no
12
+ supported stored-link extension ships today. Core no longer has a native `link`
13
+ handler or a `dynamicLinks` project flag.
13
14
 
14
15
  A project declares versioned configuration and exclusive route mounts:
15
16
 
@@ -192,7 +193,7 @@ project customizes an extension's *look* through `urlcode-ui`'s
192
193
  standard, or every extension author (and every project depending on one)
193
194
  either reinvents it or forks the extension. `urlcode-auth` and
194
195
  `urlcode-admin` independently hit this gap (auth/#35, admin/#32); auth's own
195
- [SPIKE-AUTH.md](https://github.com/jimhoyd-com/urlcode-auth/blob/main/docs/SPIKE-AUTH.md)
196
+ [SPIKE-AUTH.md](../packages/auth/docs/SPIKE-AUTH.md)
196
197
  already scoped a shape for `onSignUp`/`beforeRegister`/`onDelete`.
197
198
 
198
199
  **The pattern.** An extension with lifecycle events an author judges worth
@@ -256,7 +257,16 @@ each half of a hook's `sandbox: true` opt-in has its own answer:
256
257
  or provided: this is ordinary first-party project code, and the
257
258
  extension's own `activate()` already has `ExtensionActivation.root` to
258
259
  resolve the hook's `source` against and can `import()` it directly, the
259
- same way any trusted `function`/`middleware` route does.
260
+ same way any trusted `function`/`middleware` route does. Do that import
261
+ with a per-activation cache-busting query, the way core's own trusted
262
+ route activation does (`src/trusted-functions.ts`): Node's ESM loader
263
+ caches a resolved module forever by URL, so a plain `import()` of the
264
+ unchanged file URL makes a second activation in the same process keep
265
+ serving the hook code that was on disk at the first one
266
+ (jimhoyd-com/urlcode#198). Only the hook's **entry** module is refreshed
267
+ this way — modules the hook itself imports stay on Node's module cache,
268
+ the same limitation the trusted route path has, so a change to a hook's
269
+ own dependency still needs a process restart.
260
270
  - **Sandboxed (`sandbox: true`).** `@jimhoyd/urlcode/sandbox` exports
261
271
  `SandboxPool`, the same QuickJS/worker-thread engine that backs a
262
272
  sandboxed `function`/`middleware` route — the identical module-allowlist
@@ -326,16 +336,15 @@ boundary, not a JavaScript sandbox or an independent security review.
326
336
 
327
337
  ## Scaffolding with `init --with`
328
338
 
329
- `urlcode init <directory> --with auth,admin` produces the layered site the
339
+ `urlcode init <directory> --with ui,auth,admin` produces the layered site the
330
340
  [framework page](FRAMEWORK.md#the-composition-contract) describes in one
331
341
  command: the starter under `<directory>/app/`, one `host.mjs`, one `README.md`,
332
342
  and each extension's own operator files. Core never bundles or imports the
333
343
  extension packages at build time; at run time it resolves
334
344
  `@jimhoyd/urlcode-<name>` for each name with Node's package resolution from
335
- the invoking directory (so `npm install @jimhoyd/urlcode-auth` in that
336
- directory, from npm where the packages are published as `0.1.0-alpha.x`
337
- prereleases, is the normal path and what makes `--with auth` work), imports
338
- the package and calls its
345
+ the invoking directory. Install a compatible package set there, as shown in
346
+ [the framework guide](FRAMEWORK.md#the-composition-contract), before using
347
+ `--with ui,auth`; UI must activate before auth. Core imports the package and calls its
339
348
  `scaffold` export with this request:
340
349
 
341
350
  ```ts
@@ -391,6 +400,37 @@ Assembly rules, in `--with` order:
391
400
  review the project and pin it explicitly; the host is generated to require
392
401
  the pin, never to compute it.
393
402
 
403
+ ### Recorded versions
404
+
405
+ `init --with` also writes `<directory>/package.json`: a private manifest that
406
+ pins, at exactly the version that was resolved, the running runtime, every
407
+ extension named in `--with`, and every package those extensions declare in
408
+ `peerDependencies` (so `@jimhoyd/urlcode-ui` is pinned for an `auth,admin` site
409
+ although nobody named it). Before anything is written, the whole set is checked
410
+ against every declared peer range; an incompatible combination or a missing
411
+ required peer refuses and names it, leaving no directory behind.
412
+
413
+ Nothing is installed. The generated site has no `node_modules` and no
414
+ `package-lock.json` until you run `npm install` in it yourself, which the
415
+ command and the generated README both state as the next step. Reproducibility
416
+ comes from that install, not from generation.
417
+
418
+ - `--no-manifest` generates the site without a `package.json`, for a site whose
419
+ dependencies are managed elsewhere. Plain `urlcode init` is unchanged and
420
+ still writes no manifest; add `--manifest` to pin the runtime for a
421
+ route-only project too.
422
+ - `--pin <package>=<specifier>` records a specifier instead of the resolved
423
+ version, for local tarball or offline development
424
+ (`--pin @jimhoyd/urlcode-auth=file:/abs/urlcode-auth-0.1.0-alpha.6.tgz`). A
425
+ package installed from a local path or tarball is detected from npm's own
426
+ install record and pinned by that path without any flag; the README says so,
427
+ because such a pin only reproduces where that path exists.
428
+
429
+ There is no upgrade command. Moving a generated project to newer versions today
430
+ means editing its `package.json` and re-running `npm install` yourself; nothing
431
+ in this runtime selects a newer tested set, shows the change, or updates a
432
+ lockfile for you.
433
+
394
434
  Serving the result is the usual explicit host binding:
395
435
 
396
436
  ```sh