@jimhoyd/urlcode 0.4.1 → 0.4.2

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.
@@ -30,6 +30,11 @@ Start with `urlcode context --project <dir> --budget 4000`, then retrieve the
30
30
  capability, schema fragment, recipe or example relevant to the change. Use the
31
31
  read-only MCP equivalents when available. `llms.txt` is the index; read the
32
32
  matching task guide from `docs/` when a query needs more explanation.
33
+ When the project has an operator host file, inspect `urlcode extensions
34
+ --project <dir> --host-file <absolute-file> --json` (MCP: `get_extensions`)
35
+ before writing extension configuration or project hooks. The report is the
36
+ machine-readable source for config/policy schemas and hook names, purposes and
37
+ input/output schemas.
33
38
  `docs/SPECIFICATION.md` and `schemas/urlcode.schema.json` resolve contract
34
39
  questions. Archived plans are historical, not valid YAML guidance.
35
40
 
@@ -49,6 +54,11 @@ questions. Archived plans are historical, not valid YAML guidance.
49
54
  - Create every referenced module, page and asset **before** validating. All
50
55
  source paths resolve from the project root. Trusted modules can import Node built-ins and npm packages;
51
56
  only `sandbox: true` modules are restricted to the relative snapshotted graph.
57
+ - Customize installed extensions in this order: declarative configuration;
58
+ `urlcode-ui` copy, theme, template and CSS overrides; a hook declared by the
59
+ extension; a new extension only when the installed contract cannot express
60
+ the behavior. Extension hooks run trusted in-process and reject `sandbox:
61
+ true` in contract v1.
52
62
  - Write exact response fixtures for success and failure, covering every active
53
63
  method, middleware behavior, HEAD, and any range or cache semantics.
54
64
  - Follow `docs/BEST-PRACTICES.md` for layout and readability as the project grows.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude plugins published from the URLCode repository.",
9
- "version": "0.4.1"
9
+ "version": "0.4.2"
10
10
  },
11
11
  "plugins": [
12
12
  {
package/CONTRIBUTING.md CHANGED
@@ -79,6 +79,42 @@ A behavior change that a reader depends on is not finished until the matching
79
79
  page in `docs/` is updated. Put both in the **same** pull request so review can
80
80
  see both halves and neither can land alone.
81
81
 
82
+ ### Maintain existing pages first
83
+
84
+ Before creating a page, search the docs and update the existing home for the
85
+ reader's task. A new page needs a distinct purpose and a link from the relevant
86
+ guide or index. Routine task summaries and verification transcripts belong in
87
+ the pull request, not a new permanent report.
88
+
89
+ Keep each kind of information in its authoritative home:
90
+
91
+ | Information | Home |
92
+ |---|---|
93
+ | Implemented behavior and accepted fields | Specification, topic contracts and generated schema reference |
94
+ | How to accomplish a task | The existing guide or executable recipe |
95
+ | Current versions and publication state | Manifests and the commands in [version alignment](docs/VERSION-ALIGNMENT.md) |
96
+ | Actionable bugs and proposed work | GitHub issues; the roadmap links to priorities rather than copying task lists |
97
+ | Unresolved design choices | [Open decisions](docs/OPEN-DECISIONS.md), linked to the relevant issue |
98
+ | Security and operational evidence | Dated, scoped evidence records and the readiness register |
99
+ | Completed or superseded plans | [Archive](docs/archive/README.md), with a link to the current owner of any remaining work |
100
+
101
+ Link to these sources instead of copying changing status or entire explanations.
102
+ Examples and brief task-specific explanations are useful; a second maintained
103
+ version table or backlog is not. Do not treat archiving a plan as closing its
104
+ unperformed security or deployment checks.
105
+
106
+ When a decision is implemented, update the reader-facing guide and remove its
107
+ obsolete next steps. Preserve useful rationale and evidence with an explicit
108
+ status; do not leave historical proposals presenting themselves as current
109
+ instructions. Avoid moving files only for tidiness: existing links and anchors
110
+ are part of the documentation interface.
111
+
112
+ Run `npm run check:docs` after documentation changes. It checks local links,
113
+ retired repository references, guidance claims and generated resources. These
114
+ checks cannot prove prose is current: review the affected facts against code
115
+ and evidence too. Reviewers should ask which page owns the changed information
116
+ and whether this PR introduced a competing explanation.
117
+
82
118
  `urlcode-docs` was deleted on 2026-09-19. It held its own copy of most of these
83
119
  pages and had drifted from them; the content that was ahead has been brought
84
120
  across, and the repository was retired rather than reconciled page by page.
package/ROADMAP.md CHANGED
@@ -7,7 +7,7 @@ principles; [the specification](docs/SPECIFICATION.md) owns implemented behavior
7
7
 
8
8
  ## What works now
9
9
 
10
- The source at `db375bf` provides declarative routing, responses, assets, policies,
10
+ The current source provides declarative routing, responses, assets, policies,
11
11
  conditions, proxy/signals, trusted Node functions and middleware, and opt-in
12
12
  `sandbox: true` isolation. Target support differs: use `urlcode capabilities`
13
13
  before promising a deployment. Stored short links have no supported package.
@@ -22,19 +22,19 @@ not future phases. See [the framework](docs/FRAMEWORK.md).
22
22
 
23
23
  ## Next work
24
24
 
25
- Auth, admin and UI are workspace packages here, and all three have been released
26
- from this repository; that migration is
27
- [done](docs/OPEN-DECISIONS.md#done-the-monorepo-migration-is-complete) and its
28
- plan is [archived](docs/archive/2026-09-19/SPIKE-MONOREPO.md). The separate
29
- middleware package was withdrawn rather than migrated —
30
- `@jimhoyd/urlcode-middleware` is unpublished and its repository deleted — so
31
- there is nothing to move in and nothing to fold into core afterward.
25
+ Auth, admin and UI are workspace packages here, released from this repository;
26
+ the migration is [done](docs/archive/2026-09-20/OPEN-DECISIONS-COMPLETED.md) and
27
+ its plan is [archived](docs/archive/2026-09-19/SPIKE-MONOREPO.md). The separate
28
+ middleware package was withdrawn rather than migrated
29
+ ([the decision](docs/OPEN-DECISIONS.md#accepted-middleware-withdrawn-rather-than-consolidated)).
30
+ Versions and channels are in [version alignment](docs/VERSION-ALIGNMENT.md) and
31
+ `npm run release:status`, not in this page.
32
32
 
33
33
  1. **Make the existing product coherent.** Keep docs, examples, generated LLM
34
34
  resources, installed skills and the standalone template consistent with their
35
35
  runtime version. Resolve the [open decisions](docs/OPEN-DECISIONS.md).
36
- [Issue 168](https://github.com/jimhoyd-com/urlcode/issues/168) tracks checking
37
- schema-invalid documentation examples beyond the existing prose checks.
36
+ Schema-invalid YAML examples in Markdown are now checked
37
+ (`scripts/check-guidance-claims.ts`; issue 168 is closed).
38
38
  [Issue 174](https://github.com/jimhoyd-com/urlcode/issues/174) retains the
39
39
  extension-schema retrieval proposal.
40
40
  2. **Measure the agent experience.** The benchmark harness and authoring evals
@@ -47,8 +47,12 @@ there is nothing to move in and nothing to fold into core afterward.
47
47
  independent security review, deployed recovery/soak tests and real provider
48
48
  verification remain distinct from source implementation and local tests.
49
49
  [Issue 58](https://github.com/jimhoyd-com/urlcode/issues/58) and
50
- [release readiness](docs/RELEASE-READINESS.md) retain those gates. Live
51
- Google/Apple/SES checks remain explicitly deferred.
50
+ [release readiness](docs/RELEASE-READINESS.md) retain those gates; a stable
51
+ release or green CI does not close them. Live Google/Apple/SES checks remain
52
+ explicitly deferred. [Issue 185](https://github.com/jimhoyd-com/urlcode/issues/185)
53
+ (CI lane measurement and release-train validation) and
54
+ [issue 202](https://github.com/jimhoyd-com/urlcode/issues/202) (Windows auth
55
+ worker startup timeout) are also still open.
52
56
  4. **Choose expansion from evidence.** Collections and a business application
53
57
  suite are proposals, not available features. Decide scope before implementing
54
58
  them. Per-route Lambda compilation is **decided against**: projects using
@@ -14,7 +14,7 @@
14
14
  "dist/capabilities.js": "28dba05b3851b8b12b3c1b809af87b732f84d601c737afe9225abe429886e691",
15
15
  "dist/capability-query.js": "9f68bc94901451d7b9ecdd3aee8c3376dca7f0b890e31d8205306cf8120dea86",
16
16
  "dist/catalog.js": "c378ef9bfd63b940790f6975f7db54557c2459722b65a7c170b97df24184bc52",
17
- "dist/cli.js": "45b8adaae66a46e7b05067f1ced488b9dd7ccff8d15ba71e858459d76f5ddbb8",
17
+ "dist/cli.js": "76d3741fa476ff20c9dae2daff56bc5c51701c7fdd61ac924ed0f3e2809928ca",
18
18
  "dist/client-address.js": "9d0d01466aab23124330605c5a0e0f981f87c897858acfad909f546a63df165b",
19
19
  "dist/cloudflare.js": "17b43b0a6b6a0ef8a893415c989e4ae3976d97cc966adbf897b785f9adc472e3",
20
20
  "dist/compliance-rules/baseline.js": "6296dea6bcb9f9bb80ed6ed5ab2f456e7f74e6de2be5d2d98ca8c983fb4df3ab",
@@ -32,7 +32,7 @@
32
32
  "dist/examples.js": "89ae2b764a688fe52acfb9b94d3d262d7742401ebd0ef10c9ebfc76edfa5631c",
33
33
  "dist/explain-cli.js": "f0f8f3b5046430d03c6abb629b38651c9c734b03456040735ce9d7e582b2bcd5",
34
34
  "dist/explain.js": "7fb632036cc68280701dd512c74d27d6e0f97b4848e9024b284d7c5393b35eb5",
35
- "dist/extensions.js": "0506ec6289fa0582399d8c6d27727ffeb629bd442264248c921c63cc5b46ff01",
35
+ "dist/extensions.js": "07ae8888a5944372ee452b58fb0b6de226b0bc6ed73c5d5a198c1788e1a55fd6",
36
36
  "dist/function-sources.js": "16fad4abc81c7ee07b6cbcef2d23a9fc50e97a17cb4dde1db53469bbb555c96f",
37
37
  "dist/function-worker.js": "35771790ada4e1b36d447d4967a5e6cf0b543f32944e4c904f5fbf8998e824a5",
38
38
  "dist/functions.js": "0f09d1bd27eb655ceb5900a4a5e2b760d2028b02cb637a6a89a4eb5940828008",
@@ -48,7 +48,7 @@
48
48
  "dist/manifest.js": "83eed0d59621039ef364cf6e7b54c5bd47e0d8cf0a5abe7b07344bfc1db56ed8",
49
49
  "dist/match.js": "53ebcc2cda529a8d07fb83f8cb69a1446036ffc1641af3c4bd45a55851bded28",
50
50
  "dist/mcp-authoring.js": "1183a56c5decb99ecb67a5da7bd91d1b494bc17a2965051aa63c4a7e291462b5",
51
- "dist/mcp.js": "484c03cdf81f94116ec973feb70754298f8b1d5b3b42561969f8cce26b3c97c2",
51
+ "dist/mcp.js": "4b818de2567ae8fb03adf3e5984931672ee2b469bc7ae9586388552846dae15e",
52
52
  "dist/observability.js": "f4b1ab496f051fe3ef2ed36b2b2e469c63a8940700e2df852e6ff2d9cccdd5ae",
53
53
  "dist/operator-host.js": "e3dac9d43a83beb775202b4ab9eeeba7ac63cebfc5dcc669be38d825be587bd0",
54
54
  "dist/plugins.js": "a61aa933cd9e1b41020b86fae78aa3a7f45f5313124aa11b50cb57898722385e",
@@ -58,13 +58,13 @@
58
58
  "dist/policies/security.js": "2b3bfdf82140156fea4b162adc28ee43ccd85c402fb200680dbb8d8706417420",
59
59
  "dist/policies/throttle.js": "bbb0cb1c24cf70d0a1ac32fc0618efb6c44e5b5d42564842f9c585ff7e501603",
60
60
  "dist/policies.js": "3750a7032ec7326aa544f4808683ad9eb91d4f2c5afe79904420cd8a355710a9",
61
- "dist/policy.js": "33c527008fafe4af18243ba668d069517c20659150f9f1d1a5a1d076e8724f89",
61
+ "dist/policy.js": "2da95b3f1f77249b1e9f399a06b88909b68594fdbc958176075e91ff23104a7c",
62
62
  "dist/prerender.js": "6210999e848ab86c87f265943960ef63716896da7a40e248221a6a30425c56b8",
63
63
  "dist/project-dependencies.js": "b43a8f3125f62d38f0e71237b7813a785ef0613a83fcdec3fa8314f33758a534",
64
64
  "dist/project-tests.js": "445eaa139d2f1a2534369cfdeef081ef518de03218a467e7c5c2059caf840270",
65
65
  "dist/provider-verification.js": "ffcf9f05e8cb5f71ea46a97e279e8a613fdfdf9435ab02bd57af11e2680a9107",
66
66
  "dist/proxy.js": "34107e59ac0dbbea090efa3cf5badf3226c5302ba25e8f8ff9b2b5596ed80e50",
67
- "dist/readiness.js": "90b54bc0cf60dd155d05789a1aad767bd67483d5fa6064ae8b1143f8cf9e06b2",
67
+ "dist/readiness.js": "28b1ade5addbc3c7427fe6a52b93850001904187c0e03c1552cbb77d8fb8572d",
68
68
  "dist/recipes.js": "11853f23e63c3c65c5bed03a278cde0349d28c9fa23266c9907a2df318bb5fc3",
69
69
  "dist/route-diff.js": "8d71d50d332ffaaed588656bd32aa36763260d1ad87bb62d7ea1c8fb23383969",
70
70
  "dist/router.js": "cf831025d5ed6778d1d6a0e541177799794e76766c82084ded81d3e87317ca1e",
@@ -75,7 +75,7 @@
75
75
  "dist/server.js": "d878bc8f0d6a05f8fd25997cace3150e2ff4d44ee9c8f4b5530e29601e164be1",
76
76
  "dist/signals.js": "b55e54efc8fb6703e08f2a1e808ec8e011bda67738db8ceadf1b248f695795be",
77
77
  "dist/site.js": "c40a06a6f9e2d6d3bea94727783c115fed4309bb0190787f58434eed64cbde84",
78
- "dist/tooling.js": "7409db98422325cd4181d073d645ea57debca0e54cb151123474b85f4d4ed7dc",
78
+ "dist/tooling.js": "191d88eda3bf174f7000fa34995c940f3cf7e9ceafeb4b813e16eb0006767055",
79
79
  "dist/trusted-functions.js": "f3800c75f45ce90faf85ba6664398ad9031c5a9ac51d5ce42bdcff08a497d48e",
80
80
  "dist/types.js": "2fe7664dbb5695ec72114197abde72de4031df9c3b211740eacefa68e9b21844",
81
81
  "dist/typescript-authoring.js": "2e98498a15bdbb76f36fbee84afb7c42ca95bbf80a2e6dd4040147f3b1642a29",
package/dist/cli.js CHANGED
@@ -28,7 +28,7 @@ import { loadComplianceRules, profileNames as complianceProfiles } from './compl
28
28
  import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
29
29
  import { readFile } from 'node:fs/promises';
30
30
 
31
- const usage = `URLCode 0.4.1 — local/self-hosted runtime
31
+ const usage = `URLCode 0.4.2 — local/self-hosted runtime
32
32
  urlcode init <directory> [--with ui,auth,admin] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
33
33
  # --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; name ui first
34
34
  # --manifest: also pin the runtime for a route-only project; --no-manifest: --with without a package.json
@@ -135,6 +135,7 @@ function formatExtensions(report ) {
135
135
  if (!report.declared.length) lines.push('Declared: (none)');
136
136
  for (const item of report.extensions) lines.push(`Registered: ${item.name} (contract ${item.version}; targets ${item.targets.join(', ') || '(none)'}; ${item.declared ? 'declared' : 'not declared'}; revision ${item.revisionPinned ? 'pinned' : 'NOT pinned'})`,
137
137
  ` mounts: ${item.mounts.join(', ') || '(none)'}`, ` policy routes: ${item.policyRoutes.join(', ') || '(none)'}`, ` credential headers: ${item.credentialHeaders.join(', ') || '(none)'}`,
138
+ ` hooks: ${item.hooks.length ? item.hooks.map(hook => `${String((hook ).name)} (${String((hook ).kind)})`).join(', ') : '(none)'}`,
138
139
  ` configuration schema: ${JSON.stringify(item.schema)}`, ` policy schema: ${item.policySchema ? JSON.stringify(item.policySchema) : '(none)'}`);
139
140
  lines.push(report.note);
140
141
  return lines.join('\n') + '\n';
@@ -1,6 +1,8 @@
1
1
  import Ajv from 'ajv/dist/2020.js';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { pathToFileURL } from 'node:url';
2
4
  import { assert, HttpError } from './errors.js';
3
- import { loadDocument } from './config.js';
5
+ import { functionFile, loadDocument } from './config.js';
4
6
  import { prepareFunctionSnapshot } from './policy.js';
5
7
  import { validateHeaderName, validateHeaderValue } from './header-validation.js';
6
8
 
@@ -52,9 +54,76 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
52
54
  * vary on credentials. Everything else under the mount stays no-store.
53
55
  */
54
56
 
57
+
58
+
59
+
60
+ /** Machine-readable contract for one project hook an extension exposes. */
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+ /** Shared schema for project hook references. Omission means trusted execution. */
70
+ export const extensionHookReferenceSchema={
71
+ oneOf:[
72
+ {type:'string',minLength:1,maxLength:1024},
73
+ {type:'object',additionalProperties:false,required:['source'],properties:{
74
+ source:{type:'string',minLength:1,maxLength:1024},
75
+ export:{type:'string',pattern:'^[A-Za-z_][A-Za-z0-9_]*$'},
76
+ sandbox:{type:'boolean'},
77
+ sandboxReason:{type:'string',minLength:1,maxLength:512},
78
+ }},
79
+ ],
80
+ } ;
81
+ /** Builds the strict `config.hooks` schema from an extension's declared hook names. */
82
+ export function extensionHooksSchema(contracts ) {
83
+ return {type:'object',additionalProperties:false,properties:Object.fromEntries(contracts.map(contract=>[contract.name,extensionHookReferenceSchema]))};
84
+ }
85
+ /**
86
+ * Loads project hooks once per activation. Project hooks are trusted first-party
87
+ * code by default, matching function/middleware routes. Sandboxed arbitrary-value
88
+ * hooks are not part of contract v1 and are refused rather than run trusted.
89
+ */
90
+ export async function loadExtensionHooks (config ,contracts ,context ) {
91
+ const known=new Map(contracts.map(contract=>[contract.name,contract]));
92
+ const hooks =Object.create(null) ;
93
+ if(config===undefined)return hooks ;
94
+ assert(config&&typeof config==='object'&&!Array.isArray(config),'Extension hooks must be an object');
95
+ const epoch=randomUUID();
96
+ for(const [name,raw] of Object.entries(config)){
97
+ const contract=known.get(name);assert(contract,`Unknown extension hook: ${name}`);
98
+ assert(typeof raw==='string'||raw&&typeof raw==='object'&&!Array.isArray(raw),`Invalid extension hook: ${name}`);
99
+ const reference =typeof raw==='string'?{source:raw}:raw ;
100
+ if(reference.sandbox===true)throw new Error(`hook ${name}: sandbox: true is not supported for extension hooks; hooks run trusted by default`);
101
+ const exportName=reference.export??'default';
102
+ let modulePath ;
103
+ try{modulePath=await functionFile(context.root,reference.source);}
104
+ catch(error){throw new Error(`hook ${name}: failed to load module "${reference.source}"`,{cause:error});}
105
+ let module ;
106
+ try{module=await import(pathToFileURL(modulePath).href+'?urlcode-extension-hook-epoch='+epoch) ;}
107
+ catch(error){throw new Error(`hook ${name}: failed to load module "${reference.source}"`,{cause:error});}
108
+ const fn=module[exportName];
109
+ if(typeof fn!=='function')throw new Error(`hook ${name}: export "${exportName}" in "${reference.source}" is not a function`);
110
+ const ajv=new Ajv.default({strict:false,allErrors:false});
111
+ const validateInput=ajv.compile(contract.inputSchema);
112
+ const validateOutput=contract.outputSchema?ajv.compile(contract.outputSchema):undefined;
113
+ hooks[name]=(input ) =>{
114
+ assert(validateInput(input),`Invalid extension hook input: ${name}`);
115
+ const validate=(output ) =>{if(validateOutput)assert(validateOutput(output),`Invalid extension hook output: ${name}`);return output;};
116
+ const output=(fn )(input);
117
+ return output instanceof Promise?output.then(validate):validate(output);
118
+ };
119
+ }
120
+ return hooks ;
121
+ }
55
122
 
56
123
 
57
124
 
125
+
126
+
58
127
 
59
128
 
60
129
 
@@ -105,6 +174,7 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
105
174
 
106
175
 
107
176
  const namePattern=/^[a-z][a-z0-9-]{0,63}$/;
177
+ const hookNamePattern=/^[a-z][A-Za-z0-9]{0,63}$/;
108
178
  const cacheHeaders=new Set(['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control']);
109
179
  const segmentPattern=/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
110
180
  export const immutableCacheControl='public, max-age=31536000, immutable';
@@ -153,6 +223,13 @@ export function prepareExtensions(document ,routes
153
223
  assert(registration.version==='1'&&typeof registration.activate==='function','Invalid extension version or activation hook');
154
224
  assert(Array.isArray(registration.targets)&&registration.targets.every(target=>['node','aws','vercel'].includes(target)),'Extension targets must be node, aws or vercel');
155
225
  assert(typeof registration.projectSha256==='string'&&/^[a-f0-9]{64}$/.test(registration.projectSha256),'Extension requires an explicit operator revision pin');
226
+ const hookNames=new Set ();
227
+ for(const hook of registration.hooks??[]){
228
+ assert(hook&&typeof hook==='object'&&hookNamePattern.test(hook.name)&&!hookNames.has(hook.name),'Invalid extension hook contract');
229
+ assert(['filter','action'].includes(hook.kind)&&typeof hook.description==='string'&&hook.description.length>=1&&hook.description.length<=512,'Invalid extension hook contract');
230
+ assert(hook.inputSchema&&typeof hook.inputSchema==='object'&&(!hook.outputSchema||typeof hook.outputSchema==='object'),'Invalid extension hook contract');
231
+ hookNames.add(hook.name);
232
+ }
156
233
  provided.set(registration.name,registration);
157
234
  }
158
235
  const declarations=document.extensions??{};
package/dist/mcp.js CHANGED
@@ -78,7 +78,7 @@ export async function serveMcp(options ) {
78
78
  if(message.method==='initialize') {
79
79
  if(initialized){await error(id,-32600,'Already initialized');return;}
80
80
  if(typeof params.protocolVersion!=='string'||!object(params.capabilities)||!object(params.clientInfo)||typeof params.clientInfo.name!=='string'||typeof params.clientInfo.version!=='string'){await error(id,-32602,'Invalid initialize params');return;}
81
- initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.1'}}});return;
81
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.2'}}});return;
82
82
  }
83
83
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
84
84
  if(!ready){await error(id,-32002,'Initialize first');return;}
package/dist/policy.js CHANGED
@@ -32,6 +32,22 @@ export async function prepareFunctionSnapshot(loaded )
32
32
  if (route.sandbox) { for (const definition of [...middleware, ...(fn ? [fn] : [])]) sandboxed.push({pattern,function:definition}); }
33
33
  else trusted.push({middleware, function: fn});
34
34
  }
35
+ // Extension hooks are a core primitive even though their names and payloads
36
+ // belong to each extension. Include every declared entry module in the
37
+ // reviewed project revision, so editing trusted hook code invalidates the
38
+ // operator's extension pin just like editing a trusted route function.
39
+ for(const [extension,declaration] of Object.entries(loaded.document.extensions??{})){
40
+ const hooks=declaration.config.hooks;
41
+ if(hooks===undefined)continue;
42
+ assert(hooks&&typeof hooks==='object'&&!Array.isArray(hooks),`Extension ${extension} hooks must be an object`);
43
+ for(const [name,raw] of Object.entries(hooks )){
44
+ assert(typeof raw==='string'||raw&&typeof raw==='object'&&!Array.isArray(raw),`Invalid extension hook: ${extension}.${name}`);
45
+ const reference=typeof raw==='string'?{source:raw}:raw ;
46
+ assert(typeof reference.source==='string',`Invalid extension hook: ${extension}.${name}`);
47
+ assert(reference.export===undefined||typeof reference.export==='string',`Invalid extension hook: ${extension}.${name}`);
48
+ trusted.push({function:await resolveOne({source:reference.source,...(reference.export===undefined?{}:{export:reference.export })})});
49
+ }
50
+ }
35
51
  const collected = await collectFunctionSources(sandboxed,loaded.root);
36
52
  const trustedSources = await collectTrustedSources(trusted,loaded.root);
37
53
  // The hash operator grants pin to still covers trusted routes' own source, so
package/dist/readiness.js CHANGED
@@ -37,6 +37,9 @@ import { runCompliance } from './compliance.js';
37
37
 
38
38
 
39
39
 
40
+
41
+
42
+
40
43
 
41
44
 
42
45
 
@@ -188,7 +191,8 @@ export async function auditProject(app , {expectRoutes,log=()=>{},c
188
191
  const advisories=plan.inventory.flatMap(route=>(route.advisories??[]).map(message=>({route:route.path,message})));
189
192
  // The per-route capability table: which policies apply and whether this
190
193
  // host enforces, compiles or delegates each one. Refusals never get here.
191
- return {elapsedMs:performance.now()-began,ready:countMatches && !failed && !uncovered.length && counts.active>0,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks:cases.length,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null,advisories};
194
+ const notReadyReasons=[...(counts.active>0?[]:['no-active-routes']),...(countMatches?[]:['route-count-mismatch']),...(failed?['failed-checks']:[]),...(uncovered.length?['uncovered-route-methods']:[])];
195
+ return {elapsedMs:performance.now()-began,ready:!notReadyReasons.length,notReadyReasons,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks:cases.length,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null,advisories};
192
196
  }
193
197
  export async function benchmarkProject(app ,{requests=1000,concurrency=2,maxP95Ms,seconds=30,warmup=0,target} ={}) {
194
198
  assert(Number.isInteger(requests)&&requests>=1&&requests<=100000,'Requests must be 1–100000');
package/dist/tooling.js CHANGED
@@ -68,7 +68,7 @@ export async function previewImport(options ) {return importR
68
68
  export async function previewExport(project ,format ,acceptProviderDifferences=false) {const loaded=await loadDocument(project);const {includes:_includes,...document}=loaded.document;return exportRoutes({format,document:{...document,routes:loaded.routes},acceptProviderDifferences});}
69
69
 
70
70
 
71
-
71
+
72
72
 
73
73
 
74
74
  /** Reports registered extension contracts against the project's declarations. Never activates an extension. */
@@ -84,6 +84,7 @@ export async function describeExtensions(project ,registrations
84
84
  name:String(registration.name),version:String(registration.version),targets:Array.isArray(registration.targets)?registration.targets.map(String):[],
85
85
  credentialHeaders:Array.isArray(registration.credentialHeaders)?registration.credentialHeaders.map(String):[],
86
86
  schema:structuredClone(registration.schema??{}),policySchema:registration.policySchema?structuredClone(registration.policySchema):null,
87
+ hooks:structuredClone(registration.hooks??[]) ,
87
88
  declared:Object.hasOwn(loaded.document.extensions??{},registration.name),revisionPinned:registration.projectSha256===projectSha256,
88
89
  mounts:mountsOf(registration.name),policyRoutes:policyRoutesOf(registration.name),
89
90
  }));
@@ -66,6 +66,62 @@ export interface ExtensionInstance {
66
66
  export interface ExtensionImmutableAssets {
67
67
  prefix: string;
68
68
  }
69
+ export interface ExtensionHookReference {
70
+ source: string;
71
+ export?: string;
72
+ sandbox?: boolean;
73
+ sandboxReason?: string;
74
+ }
75
+ export type ExtensionHookConfig = string | ExtensionHookReference;
76
+ export type ExtensionHookKind = 'filter' | 'action';
77
+ /** Machine-readable contract for one project hook an extension exposes. */
78
+ export interface ExtensionHookContract {
79
+ name: string;
80
+ kind: ExtensionHookKind;
81
+ description: string;
82
+ inputSchema: object;
83
+ outputSchema?: object;
84
+ }
85
+ export type LoadedExtensionHooks<T extends string = string> = Partial<Record<T, (input: unknown) => unknown>>;
86
+ /** Shared schema for project hook references. Omission means trusted execution. */
87
+ export declare const extensionHookReferenceSchema: {
88
+ readonly oneOf: readonly [{
89
+ readonly type: "string";
90
+ readonly minLength: 1;
91
+ readonly maxLength: 1024;
92
+ }, {
93
+ readonly type: "object";
94
+ readonly additionalProperties: false;
95
+ readonly required: readonly ["source"];
96
+ readonly properties: {
97
+ readonly source: {
98
+ readonly type: "string";
99
+ readonly minLength: 1;
100
+ readonly maxLength: 1024;
101
+ };
102
+ readonly export: {
103
+ readonly type: "string";
104
+ readonly pattern: "^[A-Za-z_][A-Za-z0-9_]*$";
105
+ };
106
+ readonly sandbox: {
107
+ readonly type: "boolean";
108
+ };
109
+ readonly sandboxReason: {
110
+ readonly type: "string";
111
+ readonly minLength: 1;
112
+ readonly maxLength: 512;
113
+ };
114
+ };
115
+ }];
116
+ };
117
+ /** Builds the strict `config.hooks` schema from an extension's declared hook names. */
118
+ export declare function extensionHooksSchema(contracts: readonly ExtensionHookContract[]): object;
119
+ /**
120
+ * Loads project hooks once per activation. Project hooks are trusted first-party
121
+ * code by default, matching function/middleware routes. Sandboxed arbitrary-value
122
+ * hooks are not part of contract v1 and are refused rather than run trusted.
123
+ */
124
+ export declare function loadExtensionHooks<T extends string>(config: Readonly<Record<string, unknown>> | undefined, contracts: readonly ExtensionHookContract[], context: Pick<ExtensionActivation, 'root'>): Promise<LoadedExtensionHooks<T>>;
69
125
  export interface RuntimeExtension {
70
126
  name: string;
71
127
  version: '1';
@@ -75,6 +131,8 @@ export interface RuntimeExtension {
75
131
  policySchema?: object;
76
132
  credentialHeaders?: string[];
77
133
  immutableAssets?: ExtensionImmutableAssets;
134
+ /** Project customization points, exposed by CLI/MCP for authors and agents. */
135
+ hooks?: readonly ExtensionHookContract[];
78
136
  /**
79
137
  * Reviewed, operator-declared cache sensitivity for `policies.extensions.<name>`
80
138
  * routes (never for an `extension:` mount, which is always treated as
@@ -57,6 +57,9 @@ export interface AuditOptions {
57
57
  export interface AuditReport {
58
58
  elapsedMs: number;
59
59
  ready: boolean;
60
+ /** Empty when `ready`; otherwise one stable code per failed condition:
61
+ * `no-active-routes`, `route-count-mismatch`, `failed-checks`, `uncovered-route-methods`. */
62
+ notReadyReasons: string[];
60
63
  counts: {
61
64
  configured: number;
62
65
  active: number;
@@ -93,6 +93,7 @@ export interface ExtensionInspection {
93
93
  credentialHeaders: string[];
94
94
  schema: object;
95
95
  policySchema: object | null;
96
+ hooks: object[];
96
97
  declared: boolean;
97
98
  revisionPinned: boolean;
98
99
  mounts: string[];
@@ -273,9 +273,10 @@ either `true` or `false`) or `sandbox: true` is enough to silence it.
273
273
 
274
274
  The same judgment call applies to a project-level lifecycle hook an
275
275
  extension invokes (`onSignUp`, `beforeRegister` and the like) — it is
276
- first-party project code with the same trusted-by-default rule and the same
277
- `sandbox: true` opt-in as any `function`/`middleware` route, no special
278
- case. See [EXTENSIONS.md](EXTENSIONS.md#project-level-lifecycle-hooks).
276
+ first-party project code with the same trusted-by-default rule as a
277
+ `function`/`middleware` route. Extension hook contract v1 is trusted-only;
278
+ `sandbox: true` is rejected rather than silently ignored. See
279
+ [EXTENSIONS.md](EXTENSIONS.md#project-level-lifecycle-hooks).
279
280
 
280
281
  Guest TypeScript needs `build-typescript --project SOURCE --out NEW_DIRECTORY`
281
282
  before serving. Only the emitted `.js`/`.mjs` executes, in QuickJS for a
@@ -294,7 +295,7 @@ origin grants pinned to the project revision. These are self-hosted features;
294
295
  providers refuse them. Signals are bounded best effort with drops, no retries
295
296
  or persistence. Never turn a user request into an implicit network grant.
296
297
 
297
- Before using a feature, ask `urlcode capabilities <name>` for its constraints, grants and target support and `urlcode schema <path>` for only that YAML fragment (MCP: `get_capability`, `get_schema`), instead of guessing.
298
+ Before using a feature, ask `urlcode capabilities <name>` for its constraints, grants and target support and `urlcode schema <path>` for only that YAML fragment (MCP: `get_capability`, `get_schema`), instead of guessing. For an installed extension, use `urlcode extensions --host-file ... --json` or MCP `get_extensions`; its hook contracts include the accepted names, purpose and input/output schemas. Prefer extension configuration and UI copy/templates/theme/CSS, then a declared project hook, and only then a new extension or fork.
298
299
  The [tooling SDK and stdio MCP](TOOLING.md) inspect, validate, explain and preview
299
300
  without guest execution, environment reads or writes. Run `urlcode explain /route`
300
301
  to check effective methods, policies and cache outcome, and `urlcode manifest`
@@ -200,15 +200,24 @@ extensions:
200
200
  onSignUp: ./hooks/on-signup.mjs
201
201
  ```
202
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.
203
+ `hooks` remains each package's own config, but core supplies the reference
204
+ schema, trusted loader and machine-readable hook contract. `get_extensions`
205
+ reports those contracts, so an agent can discover accepted names, purpose and
206
+ input/output shapes without guessing from prose.
206
207
 
207
208
  Hooks are first-party project code and run **trusted and in-process**, the
208
209
  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.
210
+ ([FUNCTION-SECURITY.md](FUNCTION-SECURITY.md)). Extension hook contract v1 is
211
+ trusted-only; `sandbox: true` is rejected loudly at activation.
212
+
213
+ ### `@jimhoyd/urlcode-ui`
214
+
215
+ | Hook | Input | Returns | Called |
216
+ |---|---|---|---|
217
+ | `transformView` | `{template, view}` | the view object to render | Synchronously before each public `ui.kit.render()` or `ui.kit.page()` call. Use it only when theme, copy, templates and CSS cannot express the change. |
218
+
219
+ For example, `transformView: ./hooks/ui-view.mjs` can add project-computed
220
+ navigation or labels to an auth/admin view without editing either package.
212
221
 
213
222
  ### `@jimhoyd/urlcode-auth`
214
223
 
@@ -0,0 +1,74 @@
1
+ # Container image promotion (design, issue #233)
2
+
3
+ Status: design plus an inert helper. Nothing described under "Proposed" is
4
+ implemented, and no workflow was changed. This does not authorize enabling
5
+ `PUBLISH_CONTAINER`, publishing, or changing any existing tag. The point is
6
+ supply-chain integrity (tested bytes are shipped bytes), not speed: the sampled
7
+ CI image build took about 12 seconds, so build caching is deliberately out of scope.
8
+
9
+ ## Current behavior (read from the workflows at 67a6996)
10
+
11
+ - `ci.yml` job `container` (a required check) runs `docker build -t urlcode:test .`
12
+ and smoke-tests it. That image is discarded; no digest is recorded.
13
+ - `candidate.yml` builds and signs the npm archives, SBOM and manifests. It builds no
14
+ image. `manifest.json` and the exact-file-set check in `validateCandidate` know nothing
15
+ about images.
16
+ - `release.yml`, only when `vars.PUBLISH_CONTAINER == 'true'`, runs a fresh
17
+ `docker build` of the tagged commit with `revision`/`version` labels and pushes
18
+ `:VERSION` and the channel tag.
19
+ - On retry, if `:VERSION` already exists it is accepted after checking only the
20
+ `revision` label. A label is asserted by whoever built the image, so a differently
21
+ built image with the same label passes. Bytes are not compared.
22
+ - Channel monotonicity is enforced by reading the `version` label of the existing
23
+ channel tag (semver `gte`). That guard is sound and should be kept.
24
+ - A rebuild is not byte-reproducible (it runs `npm ci`, embeds timestamps), so the image
25
+ the release publishes is never the image CI tested, even for the same commit.
26
+
27
+ Not verified: live GHCR contents, package visibility/permissions, whether
28
+ `PUBLISH_CONTAINER` is set, and whether any image was ever pushed.
29
+
30
+ ## Proposed path (not implemented)
31
+
32
+ 1. Candidate build. In `candidate.yml`, build the image once from the exact commit
33
+ (`docker buildx build --output type=oci,dest=candidate/image.oci.tar` with the
34
+ revision/version labels), run the same three smoke tests as `ci.yml` against that
35
+ archive, and record `image: { digest, sourceCommit, version }` in `manifest.json`.
36
+ Add `image.oci.tar` to the attested subjects so the existing `attestation verify` and
37
+ the manifest SHA-256 pinned in the annotated tag cover the image as well.
38
+ 2. Manifest schema. Add the archive to the expected file set in `validateCandidate`
39
+ and validate the record with `candidateImage()` from `scripts/release-image.ts`.
40
+ Because the tag pins the manifest hash, the digest is immutable once tagged.
41
+ 3. Promotion. `release.yml` verifies the bundle (already done by `restore`), then copies
42
+ the archive to the registry preserving the manifest digest (`skopeo copy` or
43
+ `crane push` of the OCI layout; not `docker load` then `docker push`, which can
44
+ change digests under some storage drivers) and pushes `:VERSION` and the channel tag
45
+ as pointers to that digest. It never runs `docker build`.
46
+ 4. Retry identity. If `:VERSION` exists, resolve its digest with
47
+ `docker buildx imagetools inspect` and require `assertPromotedImage()` to pass. A
48
+ rebuilt image with matching labels but different bytes is refused. Channel tags keep
49
+ the existing `assertChannel` check via `assertImageChannel()`.
50
+ 5. Attestation. Optionally attest the image digest with `actions/attest` using
51
+ `push-to-registry`; this needs `packages: write` and its GHCR behavior is unverified.
52
+ 6. Self-hosting. The `Dockerfile` and `docker build` remain the documented way to build
53
+ from source. CI's `container` check stays as is to preserve the required check name.
54
+
55
+ ## What the inert helper proves
56
+
57
+ `scripts/release-image.ts` and `test/release-image.test.ts` are pure functions and unit
58
+ tests. They pin the invariant (promoted digest equals tested digest; labels match;
59
+ channel never regresses; a same-label different-bytes image is rejected). No workflow
60
+ imports them, so the release path is unchanged.
61
+
62
+ ## Could not establish
63
+
64
+ - Whether GHCR preserves the OCI digest through `skopeo`/`crane` from the runner, and
65
+ whether the org's package settings permit it.
66
+ - Behavior of image attestation and OIDC for GHCR.
67
+ - Whether a candidate-built OCI archive fits comfortably within the artifact size and
68
+ 90-day retention model; recovery from GitHub release assets would need the archive
69
+ attached there too.
70
+ - Any real run: none was dispatched.
71
+
72
+ A first implementation should be a separate reviewed PR that lands the candidate-side
73
+ build behind an operator-controlled input, and leaves the publisher change until one
74
+ candidate has been produced and inspected.