@jimhoyd/urlcode 0.5.6 → 0.5.9

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 (114) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +15 -0
  2. package/.claude/skills/urlcode-operations/SKILL.md +4 -0
  3. package/README.md +24 -14
  4. package/dist/BUILD-MANIFEST.json +51 -50
  5. package/dist/agent-context.js +9 -0
  6. package/dist/agents-guide.js +5 -2
  7. package/dist/assets.js +2 -2
  8. package/dist/authoring.js +3 -58
  9. package/dist/aws.js +21 -4
  10. package/dist/body-schema.js +2 -3
  11. package/dist/capability-query.js +3 -3
  12. package/dist/catalog.js +1 -1
  13. package/dist/cli.js +42 -15
  14. package/dist/cloudflare.js +26 -1
  15. package/dist/config.js +1 -1
  16. package/dist/context.js +6 -6
  17. package/dist/egress.js +1 -1
  18. package/dist/examples.js +2 -2
  19. package/dist/explain.js +4 -6
  20. package/dist/extension-artifacts.js +8 -8
  21. package/dist/extension-transport.js +6 -4
  22. package/dist/function-worker.js +5 -2
  23. package/dist/functions.js +1 -1
  24. package/dist/guest-api.js +22 -15
  25. package/dist/http-policy.js +1 -3
  26. package/dist/http-response.js +61 -10
  27. package/dist/init-with.js +8 -8
  28. package/dist/interchange.js +3 -2
  29. package/dist/logging.js +4 -4
  30. package/dist/match.js +1 -1
  31. package/dist/mcp-authoring.js +1 -2
  32. package/dist/mcp.js +6 -3
  33. package/dist/object-guards.js +17 -0
  34. package/dist/pattern-guard.js +33 -3
  35. package/dist/policies/cache.js +16 -4
  36. package/dist/policies/compression.js +1 -1
  37. package/dist/policies/throttle.js +1 -1
  38. package/dist/policy.js +3 -3
  39. package/dist/prerender.js +1 -2
  40. package/dist/project-dependencies.js +8 -8
  41. package/dist/proxy.js +1 -1
  42. package/dist/readiness.js +16 -17
  43. package/dist/review.js +2 -2
  44. package/dist/route-diff.js +4 -4
  45. package/dist/router.js +1 -1
  46. package/dist/runtime.js +4 -1
  47. package/dist/scaffold.js +1 -1
  48. package/dist/schema-query.js +1 -1
  49. package/dist/server.js +49 -7
  50. package/dist/signals.js +2 -2
  51. package/dist/site.js +2 -2
  52. package/dist/trusted-functions.js +1 -1
  53. package/dist/types/assets.d.ts +3 -2
  54. package/dist/types/authoring.d.ts +3 -6
  55. package/dist/types/body-schema.d.ts +2 -1
  56. package/dist/types/catalog.d.ts +2 -1
  57. package/dist/types/config.d.ts +0 -8
  58. package/dist/types/context.d.ts +3 -1
  59. package/dist/types/egress.d.ts +1 -1
  60. package/dist/types/explain.d.ts +4 -4
  61. package/dist/types/extension-artifacts.d.ts +4 -13
  62. package/dist/types/extension-transport.d.ts +6 -4
  63. package/dist/types/functions.d.ts +1 -1
  64. package/dist/types/http-response.d.ts +7 -3
  65. package/dist/types/init-with.d.ts +3 -9
  66. package/dist/types/logging.d.ts +4 -3
  67. package/dist/types/match.d.ts +2 -1
  68. package/dist/types/object-guards.d.ts +7 -0
  69. package/dist/types/pattern-guard.d.ts +0 -1
  70. package/dist/types/policies/cache.d.ts +4 -3
  71. package/dist/types/policies/compression.d.ts +1 -1
  72. package/dist/types/policies/throttle.d.ts +1 -1
  73. package/dist/types/policy.d.ts +4 -3
  74. package/dist/types/project-dependencies.d.ts +1 -40
  75. package/dist/types/proxy.d.ts +2 -1
  76. package/dist/types/readiness.d.ts +12 -16
  77. package/dist/types/review.d.ts +0 -2
  78. package/dist/types/route-diff.d.ts +4 -3
  79. package/dist/types/router.d.ts +1 -1
  80. package/dist/types/server.d.ts +22 -0
  81. package/dist/types/signals.d.ts +3 -2
  82. package/dist/types/site.d.ts +1 -2
  83. package/dist/types/trusted-functions.d.ts +1 -1
  84. package/dist/types/types.d.ts +17 -11
  85. package/dist/types/vercel.d.ts +1 -0
  86. package/dist/types/verify-deployment.d.ts +1 -1
  87. package/dist/types.js +18 -11
  88. package/dist/vercel.js +12 -3
  89. package/dist/verify-deployment.js +29 -11
  90. package/docs/AI-AUTHORING.md +9 -0
  91. package/docs/FUNCTION-SECURITY.md +4 -0
  92. package/docs/README.md +2 -2
  93. package/docs/TOOLING.md +35 -1
  94. package/examples/assets/Makefile +1 -1
  95. package/examples/compliance/rules.mjs +2 -2
  96. package/llms-full.txt +44 -13
  97. package/llms.txt +9 -2
  98. package/package.json +8 -3
  99. package/skills/urlcode/SKILL.md +6 -6
  100. package/starters/default/.github/workflows/urlcode.yml +2 -1
  101. package/starters/default/AGENTS.md +3 -3
  102. package/starters/default/Makefile +1 -1
  103. package/starters/default/README.md +13 -11
  104. package/starters/default/tests/requests.json +0 -50
  105. package/starters/default/urlcode.yaml +1 -4
  106. package/dist/scripts/operational-drills.js +0 -33
  107. package/starters/default/functions/hello.mjs +0 -3
  108. package/starters/default/middleware/headers.mjs +0 -6
  109. package/starters/default/routes/functions.yaml +0 -20
  110. package/starters/default/routes/marketing/links.yaml +0 -7
  111. package/starters/page/README.md +0 -14
  112. package/starters/page/public/index.html +0 -12
  113. package/starters/page/tests/requests.json +0 -17
  114. package/starters/page/urlcode.yaml +0 -6
package/dist/cli.js CHANGED
@@ -30,11 +30,10 @@ import { readFile } from 'node:fs/promises';
30
30
  import { installArtifact, inspectArtifacts } from './extension-artifacts.js';
31
31
  import { installBundle, readBundleLock } from './extension-bundles.js';
32
32
 
33
- const usage = `URLCode 0.5.6 — local/self-hosted runtime
34
- urlcode init <directory> [--template page|redirects] [--with ui,auth,admin] [--bundle-release extension-bundles@vX.Y.Z] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
35
- # --template redirects: the tested redirect starter from 'urlcode context --task redirects' (urlcode.yaml, 404.html, package.json with a PORT-aware start script, tests, AGENTS.md, .mcp.json); not combinable with --with
36
- # init works in place in a directory holding only package.json, package-lock.json, node_modules or .git: package.json is merged (scripts.start added, an existing pin kept), any other existing file is refused
37
- # --template page: the smallest project (urlcode.yaml, public/index.html, README.md, tests/requests.json), one page route; not combinable with --with
33
+ const usage = `URLCode 0.5.9 — local/self-hosted runtime
34
+ urlcode init <directory> [--with ui,auth,admin] [--bundle-release extension-bundles@vX.Y.Z] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
35
+ # Writes one bare project scaffold (urlcode.yaml, empty fixtures, AGENTS.md, .mcp.json and project CI). Add routes deliberately after asking the local MCP for task-scoped context.
36
+ # init works in place in a directory holding only package.json, package-lock.json, node_modules or .git; an existing package.json is preserved, any other existing file is refused
38
37
  # --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; --bundle-release instead verifies frozen first-party bundles and writes no npm extension dependency. --with is an unordered set, core orders the host from each extension's declared requirements and refuses a missing requirement, conflict or cycle before writing
39
38
  # --ack: repeatable, qualified acknowledgement of a risk an extension names when it refuses (for example store:public-write); do not pass it pre-emptively, the refusal prints the exact command. Rejected when no scaffold consumes it
40
39
  # --manifest: also pin the runtime for a route-only project; --no-manifest: --with without a package.json
@@ -43,10 +42,16 @@ const usage = `URLCode 0.5.6 — local/self-hosted runtime
43
42
  urlcode validate [--project directory] [--local] [--origin https://links.example] # origin: absolute URLs in site.* files
44
43
  urlcode dev [--project directory] [--port 3000] [--host 127.0.0.1]
45
44
  urlcode serve [--project directory] [--port 3000] [--host 127.0.0.1] [--origin https://links.example]
45
+ # --port defaults to the PORT environment variable, then 3000, so a container/PaaS can set the listen port without changing the command
46
46
  capacity: [--workers 2] [--function-timeout-ms 5000] [--max-response-bytes 1048576]
47
47
  [--max-body-bytes 1048576] [--max-in-flight 64] [--max-in-flight-health 16]
48
48
  logging: [--request-log minimal|detailed] [--trust-request-id] [--metrics] # metrics: GET /_urlcode/metrics, Prometheus text; keep internal
49
49
  policies: [--trusted-proxies 10.0.0.0/8,fd00::/8] # peers allowed to set X-Forwarded-For for client policies
50
+ health: [--health-details] # include version/route count on GET /_urlcode/health (default: only with --metrics); keep internal
51
+ shutdown: [--drain-delay-ms 0] [--close-timeout-ms 10000]
52
+ # drain-delay-ms: /_urlcode/ready reports unhealthy this long before the listener stops accepting connections, for a load balancer to notice
53
+ # close-timeout-ms: in-flight connections get this long to finish once accepting stops, then are forced closed; keep below the process supervisor's stop grace period (Docker --stop-timeout, Kubernetes terminationGracePeriodSeconds)
54
+ timeouts: [--headers-timeout-ms 10000] [--request-timeout-ms 15000] [--keep-alive-timeout-ms 5000]
50
55
  urlcode add <destination-url> [--alias short-code] [--project directory]
51
56
  urlcode test [--project directory] [--origin https://links.example]
52
57
  urlcode build --target cloudflare|static [--project directory] [--out dist/cloudflare|dist/static] [--origin https://links.example]
@@ -102,22 +107,26 @@ Dev loads .env.local and watches; serve does neither. Functions run trusted and
102
107
  const print = (value ) => process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value) + '\n');
103
108
  const options = {
104
109
  json:{ type:'boolean' }, yaml:{ type:'boolean' }, report:{type:'string'}, 'accept-provider-differences':{type:'boolean'},
105
- project:{ type:'string', default:'.' }, 'host-file':{type:'string'}, with:{type:'string'}, template:{type:'string'},
110
+ project:{ type:'string', default:'.' }, 'host-file':{type:'string'}, with:{type:'string'},
106
111
  manifest:{type:'boolean'}, 'no-manifest':{type:'boolean'}, pin:{type:'string', multiple:true}, ack:{type:'string', multiple:true},
107
112
  port:{ type:'string' }, host:{ type:'string', default:'127.0.0.1' },
108
113
  'expect-routes':{type:'string'}, requests:{type:'string'}, concurrency:{type:'string'}, seconds:{type:'string'}, 'max-p95-ms':{type:'string'}, warmup:{type:'string'}, target:{type:'string'},
109
114
  workers:{type:'string'}, 'function-timeout-ms':{type:'string'}, 'max-response-bytes':{type:'string'}, 'max-body-bytes':{type:'string'},
110
115
  'max-in-flight':{type:'string'}, 'max-in-flight-health':{type:'string'}, 'request-log':{type:'string'}, 'trust-request-id':{type:'boolean'}, 'trusted-proxies':{type:'string'}, metrics:{type:'boolean'},
116
+ 'health-details':{type:'boolean'}, 'close-timeout-ms':{type:'string'}, 'drain-delay-ms':{type:'string'},
117
+ 'headers-timeout-ms':{type:'string'}, 'request-timeout-ms':{type:'string'}, 'keep-alive-timeout-ms':{type:'string'},
111
118
  release:{type:'string'}, 'git-commit':{type:'string'}, 'timeout-ms':{type:'string'}, 'fail-on':{type:'string'}, 'expect-metrics':{type:'boolean'},
112
119
  budget:{type:'string'}, task:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, verbose:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
113
120
  'artifact-release':{type:'string'}, 'bundle-release':{type:'string'},
114
121
  } ;
115
122
 
116
-
123
+
117
124
  // Deployment controls the container/CLI must be able to set; the embedding JS
118
125
  // API is not reachable from `urlcode serve`.
119
126
  const capacityFlags = [['workers','workers'],['function-timeout-ms','timeoutMs'],['max-response-bytes','maxBytes'],
120
- ['max-body-bytes','maxBodyBytes'],['max-in-flight','maxInFlightRequests'],['max-in-flight-health','maxInFlightHealthRequests']] ;
127
+ ['max-body-bytes','maxBodyBytes'],['max-in-flight','maxInFlightRequests'],['max-in-flight-health','maxInFlightHealthRequests'],
128
+ ['close-timeout-ms','closeTimeoutMs'],['drain-delay-ms','readinessDrainMs'],
129
+ ['headers-timeout-ms','headersTimeoutMs'],['request-timeout-ms','requestTimeoutMs'],['keep-alive-timeout-ms','keepAliveTimeoutMs']] ;
121
130
  function serverCapacity(values ) {
122
131
  const options = {};
123
132
  for (const [flag,key] of capacityFlags) {
@@ -132,6 +141,7 @@ function serverCapacity(values ) {
132
141
  }
133
142
  if (values['trust-request-id']) options.trustRequestId = true;
134
143
  if (values.metrics) options.metrics = true;
144
+ if (values['health-details']) options.healthDetails = true;
135
145
  if (values['trusted-proxies'] !== undefined) options.trustedProxies = values['trusted-proxies'];
136
146
  return options;
137
147
  }
@@ -168,12 +178,25 @@ function addressInUseMessage(error ) {
168
178
  return `${where} is already in use; pick another with --port N, or stop the process using it`;
169
179
  }
170
180
  const errorMessages = { ERR_PARSE_ARGS_UNKNOWN_OPTION:'Unknown option; use --help', EEXIST:'Destination or edit lock already exists', ENOENT:'Required file or directory not found', EADDRINUSE:'Port is already in use', EACCES:'Permission denied' };
181
+ // An unhandled rejection anywhere in the process (this CLI's own code, a
182
+ // trusted project function, an observer) must not fail silently as a bare
183
+ // Node warning: log a structured event and exit non-zero so a supervisor
184
+ // notices and restarts.
185
+ process.on('unhandledRejection', reason => {
186
+ const message = reason instanceof Error ? reason.message : String(reason);
187
+ process.stderr.write(JSON.stringify({ event:'error', message:'Unhandled rejection: ' + message }) + '\n');
188
+ process.exitCode = 1;
189
+ process.exit(1);
190
+ });
171
191
  let operatorHost = {};
172
192
  let serving = false;
173
193
  try {
174
194
  const { values, positionals } = parseArgs({ allowPositionals:true, options });
175
195
  const [command, arg, ...extra] = positionals;
176
- values.port ??= '3000';
196
+ // PORT follows the common container convention (Heroku/Cloud Run/Docker
197
+ // `-e PORT=`) so an operator can change the listen port without editing the
198
+ // image's CMD; --port still wins when given explicitly.
199
+ values.port ??= process.env.PORT ?? '3000';
177
200
  if (values.help || !command) print(usage);
178
201
  else {
179
202
  if (values['host-file'] !== undefined) {
@@ -181,7 +204,6 @@ try {
181
204
  // The MCP server and context command load and release the host themselves.
182
205
  if (command !== 'mcp' && command !== 'context') operatorHost = await loadOperatorHost(values['host-file'], values.project);
183
206
  }
184
- if (values.template !== undefined && command !== 'init') throw new ConfigError('--template is only supported by init');
185
207
  if (values.with !== undefined && command !== 'init') throw new ConfigError('--with is only supported by init');
186
208
  if ((values.manifest || values['no-manifest'] || values.pin !== undefined) && command !== 'init') throw new ConfigError('--manifest/--no-manifest/--pin are only supported by init');
187
209
  if (values.manifest && values['no-manifest']) throw new ConfigError('Use either --manifest or --no-manifest');
@@ -339,12 +361,9 @@ try {
339
361
  const wanted = values.with === undefined ? values.manifest === true : !values['no-manifest'];
340
362
  const pins = new Map((values.pin ?? []).map(parsePin));
341
363
  if (pins.size && !wanted) throw new ConfigError('--pin needs a manifest; drop --no-manifest or add --manifest');
342
- if (values.template !== undefined && values.template !== 'default' && values.template !== 'page' && values.template !== 'redirects') throw new ConfigError('--template must be page or redirects');
343
- if ((values.template === 'page' || values.template === 'redirects') && values.with !== undefined) throw new ConfigError(`--template ${values.template} cannot be combined with --with`);
344
- if (values.template === 'redirects' && (wanted || pins.size)) throw new ConfigError('--template redirects writes its own package.json pinning this runtime; drop --manifest and --pin');
345
364
  if (values.with === undefined) {
346
365
  const set = wanted ? await collectDependencySet([], [], { overrides: pins }) : undefined;
347
- const created = await initProject(arg, { manifest: set, template: values.template === 'page' || values.template === 'redirects' ? values.template : 'default' });
366
+ const created = await initProject(arg, { manifest: set });
348
367
  print(set ? { event:'created', dependencies:set.pins, nextSteps:installSteps(created, set) } : { event:'created' });
349
368
  break;
350
369
  }
@@ -375,7 +394,15 @@ try {
375
394
  print({ event:'listening', address:app.address.address, port:app.address.port, mode:command, origin:app.origin });
376
395
  serving = true;
377
396
  let stopping = false;
378
- const stop = async () => { if (stopping) return; stopping = true; try { await app.close(); } finally { await operatorHost.close?.(); } };
397
+ const stop = async () => {
398
+ if (stopping) return; stopping = true;
399
+ try { await app.close(); }
400
+ catch (error) {
401
+ process.stderr.write(JSON.stringify({ event:'error', message:'Shutdown failed: ' + (error instanceof Error ? error.message : String(error)) }) + '\n');
402
+ process.exitCode = 1;
403
+ }
404
+ finally { await operatorHost.close?.(); }
405
+ };
379
406
  process.once('SIGINT',stop); process.once('SIGTERM',stop);
380
407
  break;
381
408
  }
@@ -42,6 +42,31 @@ const encoder = new TextEncoder();
42
42
  const dict = () => Object.create(null) ;
43
43
  const redirecting = (route ) => Boolean(route.redirect);
44
44
 
45
+ // Enforces `maxBytes` while reading, rather than buffering the whole body
46
+ // with `arrayBuffer()` and only then checking its length: an oversized body
47
+ // would otherwise sit fully in Worker memory before this runtime ever gets
48
+ // to refuse it. A declared Content-Length over the limit is refused before a
49
+ // single byte is read; a running total over the limit cancels the stream.
50
+ async function readCappedBody(request , limit ) {
51
+ const declared = request.headers.get('content-length');
52
+ if (declared !== null && Number(declared) > limit) throw new HttpError(413, 'Request body too large');
53
+ if (!request.body) return new Uint8Array(0);
54
+ const reader = request.body.getReader();
55
+ const chunks = [];
56
+ let total = 0;
57
+ for (;;) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+ total += value.byteLength;
61
+ if (total > limit) { await reader.cancel().catch(() => {}); throw new HttpError(413, 'Request body too large'); }
62
+ chunks.push(value);
63
+ }
64
+ const body = new Uint8Array(total);
65
+ let offset = 0;
66
+ for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
67
+ return body;
68
+ }
69
+
45
70
  // The artifact stores what the compiler produced; validators arrive separately
46
71
  // because a schema validator cannot be serialised and this platform forbids
47
72
  // compiling one at runtime.
@@ -132,7 +157,7 @@ export function createFetchHandler(artifact , validators )
132
157
  body: encoder.encode('Method not allowed\n') }), { requestId, method }), requestId, method);
133
158
  }
134
159
  const body = route.request?.body
135
- ? new Uint8Array(await request.arrayBuffer())
160
+ ? await readCappedBody(request, route.request.body.maxBytes ?? 1048576)
136
161
  : new Uint8Array(0);
137
162
  // Duplicate request headers are joined by the platform before this runs,
138
163
  // so per-header counts are unavailable and the duplicate-scalar check
package/dist/config.js CHANGED
@@ -82,7 +82,7 @@ function closestKey(key , allowed ) {
82
82
  * Only key names, which come from the schema or the author's own mapping keys, are echoed, never values
83
83
  * (values may hold secrets), and never more than MAX_NAMED_KEY characters of a key.
84
84
  */
85
- export function describeSchemaError(e ) {
85
+ function describeSchemaError(e ) {
86
86
  const base = `Invalid configuration at ${e.instancePath || '/'} (${e.keyword})`;
87
87
  const parent = e.parentSchema ;
88
88
  if (e.keyword === 'additionalProperties') {
package/dist/context.js CHANGED
@@ -9,6 +9,7 @@ import {compilePolicies,closePolicies,effectivePolicies,registry} from './polici
9
9
  import {capabilityTargets,getCapabilities,normalizeCapabilityTarget,routeCapabilities} from './capabilities.js';
10
10
 
11
11
  import {loadOperatorHost} from './operator-host.js';
12
+ import {handlerNames,resolveHandlerName} from './types.js';
12
13
 
13
14
 
14
15
 
@@ -25,7 +26,7 @@ import {loadOperatorHost} from './operator-host.js';
25
26
 
26
27
 
27
28
 
28
-
29
+
29
30
 
30
31
 
31
32
 
@@ -40,7 +41,6 @@ import {loadOperatorHost} from './operator-host.js';
40
41
  /** Characters divided by four, rounded up: an estimate, not a tokenizer. */
41
42
  export function estimateTokens(text ) {return Math.ceil(text.length/4);}
42
43
  export function renderContext(context ) {return stringify(context,{lineWidth:0,aliasDuplicateObjects:false});}
43
- const handlerNames=['redirect','respond','page','static','download','function','proxy','conditional','extension'] ;
44
44
  const policyNames=Object.keys(registry).sort() ;
45
45
  // Fixed for every project: what generation must not attempt, whatever the documentation says.
46
46
  const constraints ={
@@ -67,7 +67,7 @@ async function compile(project ) {
67
67
  try {for(const route of routes)await compilePolicies(loaded.document,route,{route,shared,target:'node',root:loaded.root});}finally{await closePolicies(shared);}
68
68
  return {loaded,compiled,routes};
69
69
  }
70
- function handlerOf(route ) {return handlerNames.find(name=>route[name])??'none';}
70
+ const handlerOf = (route ) => resolveHandlerName(route,'none');
71
71
  /** Derived only from the compiled project and the capability catalog, never from prose. Key order is fixed. */
72
72
  export async function buildContext(project ,options ={}) {
73
73
  const budget=options.budget;
@@ -111,7 +111,7 @@ export async function buildContext(project ,options ={})
111
111
  const context ={
112
112
  urlcode:await packageVersion(),schema:'1',
113
113
  project:{
114
- entry:'urlcode.yaml',routes:compiled.count,handlers,extensions:sorted(Object.keys(document.extensions??{})),
114
+ entry:'urlcode.yaml',routes:compiled.count,routeCountNote:'routes includes declared routes plus generated site.* convention routes; audit reports the declared/generated split on mismatch.',handlers,extensions:sorted(Object.keys(document.extensions??{})),
115
115
  policies:{project:policyNames.filter(name=>topLevel[name]),routes:policyCounts},
116
116
  bindings:{env:sorted(env),secrets:sorted(secrets)},site:sorted(Object.keys(document.site??{})),
117
117
  files:{includes:loaded.files.slice(1).map(file=>relative(loaded.root,file).split('\\').join('/')),functions:sorted(functions),middleware:sorted(middleware)},
@@ -138,7 +138,7 @@ const drops =[
138
138
  ['constraintNotes',context=>{for(const [key,item] of Object.entries(context.constraints))context.constraints[key]=typeof item==='object'?item.value:item;}],
139
139
  ['files',context=>{delete context.project.files;}],
140
140
  ['commands',context=>{delete context.commands;}],
141
- ['summary',context=>{context.project={entry:context.project.entry,routes:context.project.routes,handlers:context.project.handlers,extensions:[],policies:{project:[],routes:{}},bindings:{env:[],secrets:[]},site:[]};}],
141
+ ['summary',context=>{context.project={entry:context.project.entry,routes:context.project.routes,routeCountNote:context.project.routeCountNote,handlers:context.project.handlers,extensions:[],policies:{project:[],routes:{}},bindings:{env:[],secrets:[]},site:[]};}],
142
142
  ];
143
143
  function fitBudget(context ,budget ) {
144
144
  const omitted =[];
@@ -179,7 +179,7 @@ export const redirectShapes =[
179
179
  {need:'redirect loop detection',support:'gap',note:'Validation accepts a route that redirects to its own URL; nothing detects cycles. Write a fixture with expectHeaders location for each redirect and review chains by hand.'},
180
180
  ];
181
181
  /** A complete, paste-ready project skeleton: every supported shape merged into one urlcode.yaml, plus the start script. */
182
-
182
+
183
183
 
184
184
 
185
185
 
package/dist/egress.js CHANGED
@@ -6,7 +6,7 @@ export const EGRESS_DNS_LIMIT=64;
6
6
  let unresolvedDns=0;
7
7
 
8
8
 
9
-
9
+
10
10
 
11
11
  export class EgressError extends Error { code ; constructor(code ) { super(`Egress ${code}`); this.code=code; } }
12
12
  /** Conservative public-unicast filter. IPv4-mapped IPv6 and transition mechanisms are denied. */
package/dist/examples.js CHANGED
@@ -7,6 +7,7 @@ import {routeCapabilities} from './capabilities.js';
7
7
  import {readMetadata,searchMetadata,searchTerms} from './catalog.js';
8
8
 
9
9
  import {assert} from './errors.js';
10
+ import {resolveHandlerName} from './types.js';
10
11
 
11
12
 
12
13
  /** example.yaml, the same schema as recipe.yaml; `name` repeats `id`. */
@@ -31,8 +32,7 @@ export async function listExamples() {
31
32
  for(const name of exampleNames)result.push(await metadata(name));
32
33
  return result;
33
34
  }
34
- const handlerNames=['extension','proxy','conditional','redirect','function','page','static','download','respond'] ;
35
- function handlerOf(route ) {return handlerNames.find(name=>route[name]!==undefined)??'unknown';}
35
+ const handlerOf = (route ) => resolveHandlerName(route, 'unknown');
36
36
  /**
37
37
  * Derives the per-route tag index of one project from its loaded routes: handler,
38
38
  * methods, capabilities, policy names and middleware module names. The file each
package/dist/explain.js CHANGED
@@ -5,8 +5,8 @@ import {analyzeCompiledCapabilities,capabilityTargets,routeCapabilities} from '.
5
5
  import {effectiveExtensionPolicies} from './extensions.js';
6
6
 
7
7
  import {effectivePolicies} from './policies.js';
8
-
9
-
8
+ import {handlerNames} from './types.js';
9
+
10
10
 
11
11
 
12
12
 
@@ -15,10 +15,8 @@ import {effectivePolicies} from './policies.js';
15
15
  // binding values are replaced by their names, module paths are made
16
16
  // project-relative and secrets never appear.
17
17
 
18
- const handlerNames=['extension','proxy','conditional','redirect','function','page','static','download','respond'] ;
19
-
20
18
 
21
-
19
+
22
20
 
23
21
 
24
22
 
@@ -42,7 +40,7 @@ const handlerNames=['extension','proxy','conditional','redirect','function','pag
42
40
 
43
41
 
44
42
 
45
-
43
+
46
44
 
47
45
  const relativeSource=(root ,source ) =>relative(root,source).split('\\').join('/');
48
46
  function origin(url ) {try{return new URL(url).origin;}catch{return url;}}
@@ -6,7 +6,7 @@ import { ConfigError, assert } from './errors.js';
6
6
  import { isRecord as record, digestHex as digest, textField, exactKeys as sharedExactKeys, listCachedFiles, writeLockAtomic, createGithubTransport, verifiedReleaseAsset, } from './extension-transport.js';
7
7
 
8
8
  /** Offline, declarative extension bundles. These are deliberately not Node packages. */
9
- export const ARTIFACT_REPOSITORY = 'jimhoyd-com/urlcode';
9
+ const ARTIFACT_REPOSITORY = 'jimhoyd-com/urlcode';
10
10
  export const ARTIFACT_WORKFLOW = 'jimhoyd-com/urlcode/.github/workflows/extension-artifacts.yml';
11
11
  const MAX_ARCHIVE = 16 * 1024 * 1024, MAX_EXPANDED = 32 * 1024 * 1024, MAX_FILES = 128, MAX_FILE = 2 * 1024 * 1024;
12
12
  const MAX_TOOL_FILE = 512 * 1024;
@@ -14,10 +14,10 @@ const hex = /^[a-f0-9]{64}$/;
14
14
  const name = /^[a-z][a-z0-9-]{0,63}$/;
15
15
  const tag = /^extensions@v[0-9][0-9A-Za-z._-]{0,100}$/;
16
16
 
17
-
17
+
18
18
 
19
-
20
-
19
+
20
+
21
21
  function text(value , what ) { return textField(value, `${what} in extension artifact metadata`); }
22
22
  function exactKeys(value , expected , what ) { sharedExactKeys(value, expected, what); }
23
23
 
@@ -44,7 +44,7 @@ export function parseCatalog(bytes , requestedTag ) {
44
44
  }
45
45
 
46
46
 
47
-
47
+
48
48
  function octal(bytes ) { const value=new TextDecoder().decode(bytes).replace(/\0.*$/,'').trim(); assert(/^[0-7]*$/.test(value),'Malformed extension archive'); return value ? Number.parseInt(value,8) : 0; }
49
49
  function archivePath(name ,prefix ) {
50
50
  const decode=(bytes )=>new TextDecoder().decode(bytes).replace(/\0.*$/,'');
@@ -83,15 +83,15 @@ export async function extractArtifact(bytes , entry , des
83
83
  const root=resolve(destination), temporary=join(tmpdir(),`urlcode-extension-${process.pid}-${Math.random().toString(16).slice(2)}`); await mkdir(temporary,{recursive:true});
84
84
  try { for(const file of files) { const target=resolve(temporary,file.path); assert(relative(temporary,target) && !relative(temporary,target).startsWith('..'),'Unsafe extension archive path'); await mkdir(dirname(target),{recursive:true}); await writeFile(target,file.bytes,{flag:'wx'}); } await writeFile(join(temporary,'.artifact.tgz'),bytes,{flag:'wx'}); await mkdir(dirname(root),{recursive:true}); try { await rename(temporary,root); } catch { try { await validateCached(root,entry); return; } catch { throw new ConfigError(`Extension cache entry ${entry.sha256} already exists but is not identical`); } } } finally { await rm(temporary,{recursive:true,force:true}); }
85
85
  }
86
- export async function readLock(project ) { let raw ; try { const path=join(project,'urlcode.extensions.lock.json'), info=await lstat(path); assert(info.isFile()&&!info.isSymbolicLink()&&info.nlink===1,'Extension artifact lockfile must be an ordinary file'); raw=JSON.parse(await readFile(path,'utf8')); } catch(error) { if(error instanceof ConfigError)throw error; throw new ConfigError('No extension artifact lockfile; install an artifact first'); } assert(record(raw)&&raw.format===1&&Array.isArray(raw.artifacts),'Invalid extension artifact lockfile'); exactKeys(raw,['format','artifacts'],'Extension artifact lockfile'); const seen=new Set (),digests=new Set (); const artifacts=raw.artifacts.map(value=>{ assert(record(value)&&record(value.catalog),'Invalid extension artifact lockfile'); exactKeys(value,['name','version','asset','sha256','kind','catalog'],'Extension artifact lock entry'); exactKeys(value.catalog,['tag','commit'],'Extension artifact lock catalog'); const item ={name:text(value.name,'lockfile artifact'),version:text(value.version,'lockfile artifact'),asset:text(value.asset,'lockfile artifact'),sha256:text(value.sha256,'lockfile artifact'),kind:value.kind==='declarative'?'declarative':(()=>{throw new ConfigError('Invalid extension artifact lockfile');})(),catalog:{tag:text(value.catalog.tag,'lockfile tag'),commit:text(value.catalog.commit,'lockfile commit')}}; assert(name.test(item.name)&&/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(item.version)&&/^[A-Za-z0-9._-]+\.tgz$/.test(item.asset)&&hex.test(item.sha256)&&tag.test(item.catalog.tag)&&/^[a-f0-9]{40}$/.test(item.catalog.commit)&&!seen.has(item.name)&&!digests.has(item.sha256),'Invalid or duplicate extension artifact lock entry'); seen.add(item.name); digests.add(item.sha256); return item; }); return {format:1,artifacts}; }
86
+ async function readLock(project ) { let raw ; try { const path=join(project,'urlcode.extensions.lock.json'), info=await lstat(path); assert(info.isFile()&&!info.isSymbolicLink()&&info.nlink===1,'Extension artifact lockfile must be an ordinary file'); raw=JSON.parse(await readFile(path,'utf8')); } catch(error) { if(error instanceof ConfigError)throw error; throw new ConfigError('No extension artifact lockfile; install an artifact first'); } assert(record(raw)&&raw.format===1&&Array.isArray(raw.artifacts),'Invalid extension artifact lockfile'); exactKeys(raw,['format','artifacts'],'Extension artifact lockfile'); const seen=new Set (),digests=new Set (); const artifacts=raw.artifacts.map(value=>{ assert(record(value)&&record(value.catalog),'Invalid extension artifact lockfile'); exactKeys(value,['name','version','asset','sha256','kind','catalog'],'Extension artifact lock entry'); exactKeys(value.catalog,['tag','commit'],'Extension artifact lock catalog'); const item ={name:text(value.name,'lockfile artifact'),version:text(value.version,'lockfile artifact'),asset:text(value.asset,'lockfile artifact'),sha256:text(value.sha256,'lockfile artifact'),kind:value.kind==='declarative'?'declarative':(()=>{throw new ConfigError('Invalid extension artifact lockfile');})(),catalog:{tag:text(value.catalog.tag,'lockfile tag'),commit:text(value.catalog.commit,'lockfile commit')}}; assert(name.test(item.name)&&/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(item.version)&&/^[A-Za-z0-9._-]+\.tgz$/.test(item.asset)&&hex.test(item.sha256)&&tag.test(item.catalog.tag)&&/^[a-f0-9]{40}$/.test(item.catalog.commit)&&!seen.has(item.name)&&!digests.has(item.sha256),'Invalid or duplicate extension artifact lock entry'); seen.add(item.name); digests.add(item.sha256); return item; }); return {format:1,artifacts}; }
87
87
  export async function writeLock(project , lock ) { const path=join(project,'urlcode.extensions.lock.json'), temporary=join(project,`.urlcode.extensions.lock.${process.pid}.${Math.random().toString(16).slice(2)}`); await writeLockAtomic(path,temporary,lock); }
88
88
  export function cachePath(project , sha256 ) { assert(hex.test(sha256),'Invalid extension digest'); return join(project,'.urlcode','extensions',sha256); }
89
89
 
90
90
 
91
91
 
92
92
  /** The default transport accepts only GitHub Release asset URLs and verifies every downloaded subject. */
93
- export const githubTransport =createGithubTransport({repository:ARTIFACT_REPOSITORY,workflow:ARTIFACT_WORKFLOW,tagPattern:tag,exampleTag:'extensions@v1.0.0',maxAssetSize:MAX_ARCHIVE,itemLabel:'extension artifact'});
94
- export async function resolveCatalog(release , transport =githubTransport) { const assets=await transport.release(release); const bytes=await verifiedReleaseAsset(assets,'extensions-catalog.json',release,transport,'extension artifact','urlcode-attest'); return {catalog:parseCatalog(bytes,release),assets}; }
93
+ const githubTransport =createGithubTransport({repository:ARTIFACT_REPOSITORY,workflow:ARTIFACT_WORKFLOW,tagPattern:tag,exampleTag:'extensions@v1.0.0',maxAssetSize:MAX_ARCHIVE,itemLabel:'extension artifact'});
94
+ async function resolveCatalog(release , transport =githubTransport) { const assets=await transport.release(release); const bytes=await verifiedReleaseAsset(assets,'extensions-catalog.json',release,transport,'extension artifact','urlcode-attest'); return {catalog:parseCatalog(bytes,release),assets}; }
95
95
  export async function installArtifact(project , release , artifactName , transport =githubTransport) {
96
96
  assert(name.test(artifactName),'Invalid extension artifact name'); const {catalog,assets}=await resolveCatalog(release,transport); const entry=catalog.artifacts.find(item=>item.name===artifactName); assert(entry,`Extension artifact ${artifactName} is not in the signed catalog`); const revoked=catalog.revoked.find(item=>item.sha256===entry.sha256); assert(!revoked,`Extension artifact ${artifactName} is revoked: ${revoked?.reason ?? 'unknown reason'}`);
97
97
  const bytes=await verifiedReleaseAsset(assets,entry.asset,release,transport,'extension artifact','urlcode-attest'); await extractArtifact(bytes,entry,cachePath(project,entry.sha256));
@@ -4,11 +4,13 @@ import { readdir, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { ConfigError, assert } from './errors.js';
7
+ import { isRecord } from './object-guards.js';
8
+
7
9
 
8
10
  /** Shared GitHub release/cache/lockfile plumbing for extension-artifacts.ts and extension-bundles.ts. No opinion on what content is allowed or executable; that trust boundary stays local to each caller (#441). */
9
11
 
10
-
11
- export const isRecord=(v ) =>v !== null && typeof v==='object' && !Array.isArray(v);
12
+ export { isRecord };
13
+
12
14
  export const digestHex=(bytes ) =>createHash('sha256').update(bytes).digest('hex');
13
15
  export function textField(value , what ) { assert(typeof value==='string' && value.length>0 && value.length<256,`Invalid ${what}`); return value; }
14
16
  export function exactKeys(value , expected , what ) { const actual=Object.keys(value).sort(); assert(JSON.stringify(actual)===JSON.stringify([...expected].sort()),`${what} has unknown or missing fields`); }
@@ -21,8 +23,8 @@ export async function listCachedFiles(root , itemLabel , prefix='')
21
23
  export async function writeLockAtomic(path , temporary , data ) { await writeFile(temporary,JSON.stringify(data,null,2)+'\n',{flag:'wx'}); try { await rename(temporary,path); } finally { await rm(temporary,{force:true}); } }
22
24
 
23
25
 
24
-
25
-
26
+
27
+
26
28
 
27
29
  /** A transport that accepts only GitHub Release asset URLs and verifies every downloaded subject. */
28
30
  export function createGithubTransport(config ) {
@@ -45,6 +45,7 @@ async function evaluate(entry , name , pay
45
45
  run(guestBootstrap);
46
46
  if (payload !== undefined) {
47
47
  const value = vm.newString(payload); vm.setProp(vm.global,'__payload',value); value.dispose();
48
+ run("Object.defineProperty(globalThis,'__payload',{writable:false,enumerable:false,configurable:false})");
48
49
  }
49
50
  if (chain.length) {
50
51
  const imports = chain.map((item,i) => `import * as mw${i} from ${JSON.stringify(item.source)};`).join('\n');
@@ -56,7 +57,7 @@ async function evaluate(entry , name , pay
56
57
  } else {
57
58
  run(`import * as entry from ${JSON.stringify(entry)};
58
59
  if (typeof entry[${JSON.stringify(name)}] !== 'function') throw new Error('Invalid export');
59
- ${payload === undefined ? "globalThis.__state = 'done';" : `globalThis.__invoke(entry[${JSON.stringify(name)}], globalThis.__payload);`}`,
60
+ ${payload === undefined ? 'globalThis.__ready();' : `globalThis.__invoke(entry[${JSON.stringify(name)}], globalThis.__payload);`}`,
60
61
  '/__urlcode_entry.mjs','module');
61
62
  }
62
63
  while (string('__state') === 'pending') {
@@ -83,7 +84,9 @@ port.on('message', async ({id,source,name,request,context,maxBytes,timeoutMs,cha
83
84
  if (output === undefined || Buffer.byteLength(output) > maxBytes * 6 + 65536) throw new Error('Output limit');
84
85
  const value = JSON.parse(output) ; // trust boundary: guest JSON, checked below
85
86
  if (!value || !Number.isInteger(value.status) || value.status < 200 || value.status > 599 || typeof value.body !== 'string' || !Array.isArray(value.headers) || value.headers.length > 256) throw new Error('Invalid response');
86
- if (value.contentLength !== undefined && (!Number.isInteger(value.contentLength) || value.contentLength < 0)) throw new Error('Invalid response');
87
+ // Only HEAD may state a length: it carries no body. Any other method is
88
+ // framed by the body bytes, so a stated length there is a shape violation.
89
+ if (value.contentLength !== undefined && (request.method !== 'HEAD' || !Number.isSafeInteger(value.contentLength) || value.contentLength < 0)) throw new Error('Invalid response');
87
90
  if (value.nativeBody) {
88
91
  if (!native || value.status !== native.status || value.body !== '') throw new Error('Invalid native response');
89
92
  // Preserve native status and metadata (validators, ranges, redirect Location).
package/dist/functions.js CHANGED
@@ -26,7 +26,7 @@ import { assert, ConfigError, HttpError } from './errors.js';
26
26
 
27
27
 
28
28
 
29
-
29
+
30
30
 
31
31
  // The worker protocol. Only JSON-shaped data and byte buffers cross it.
32
32
 
package/dist/guest-api.js CHANGED
@@ -31,15 +31,25 @@ export const guestBootstrap = String.raw`
31
31
  }
32
32
  return bytes;
33
33
  }
34
+ // The invocation's outcome lives in this closure. The host reads it through
35
+ // accessors and calls the entry points below, all fixed before guest code
36
+ // runs, so a guest can neither rewrite its result nor replace the code that
37
+ // shapes it. The first outcome settles the invocation.
38
+ let state = 'pending', output = '';
39
+ const settle = (next, text = '') => { if (state === 'pending') { state = next; output = text; } };
40
+ const fix = (name, value) => Object.defineProperty(globalThis, name, {value, writable:false, enumerable:false, configurable:false});
41
+ Object.defineProperty(globalThis, '__state', {get: () => state, enumerable:false, configurable:false});
42
+ Object.defineProperty(globalThis, '__output', {get: () => output, enumerable:false, configurable:false});
43
+ fix('__ready', () => settle('done'));
34
44
  const timers = new Map(); let next = 1;
35
45
  globalThis.setTimeout = (fn, delay = 0) => {
36
46
  if (typeof fn !== 'function' || timers.size >= 128) throw new Error('Timer limit');
37
47
  const id = next++; timers.set(id, {fn, at:now() + Math.max(0, Number(delay) || 0)}); return id;
38
48
  };
39
49
  globalThis.clearTimeout = id => timers.delete(id);
40
- globalThis.__pump = () => {
50
+ fix('__pump', () => {
41
51
  for (const [id,timer] of timers) if (timer.at <= now()) { timers.delete(id); timer.fn(); }
42
- };
52
+ });
43
53
  globalThis.console = Object.freeze({log(){},error(){},warn(){},info(){},debug(){}});
44
54
  class Headers {
45
55
  constructor(init = []) {
@@ -83,7 +93,7 @@ export const guestBootstrap = String.raw`
83
93
  static redirect(url, status = 302) { if (![301,302,303,307,308].includes(status)) throw new TypeError('Invalid redirect status'); return new Response(null,{status,headers:{location:String(url)}}); }
84
94
  }
85
95
  globalThis.Headers = Headers; globalThis.Request = Request; globalThis.Response = Response;
86
- globalThis.__invokePipeline = async (middleware, handler, payload) => {
96
+ fix('__invokePipeline', async (middleware, handler, payload) => {
87
97
  try {
88
98
  const input = NativeJSON.parse(payload);
89
99
  const request = new Request(input.request.url, input.request);
@@ -117,24 +127,21 @@ export const guestBootstrap = String.raw`
117
127
  // materialized string at construction time, no stream read needed
118
128
  // (#144, mirroring #139's fix for the trusted path). Only the
119
129
  // transmitted bytes are suppressed for HEAD, never the length.
120
- globalThis.__output = stringify({status:response.status,headers:response.headers._pairs,
130
+ settle('done', stringify({status:response.status,headers:response.headers._pairs,
121
131
  body:nativeBody || isHead ? '' : response._text,nativeBody,
122
- ...(isHead && !nativeBody ? {contentLength:byteLength(response._text)} : {})});
123
- globalThis.__state = 'done';
124
- } catch { globalThis.__state = 'failed'; }
125
- };
126
- globalThis.__state = 'pending'; globalThis.__output = '';
127
- globalThis.__invoke = async (handler, payload) => {
132
+ ...(isHead && !nativeBody ? {contentLength:byteLength(response._text)} : {})}));
133
+ } catch { settle('failed'); }
134
+ });
135
+ fix('__invoke', async (handler, payload) => {
128
136
  try {
129
137
  const input = NativeJSON.parse(payload);
130
138
  const response = await handler(new Request(input.request.url, input.request), input.context);
131
139
  if (!(response instanceof Response)) throw new TypeError('Return a Response');
132
140
  const headers = response.headers._pairs;
133
141
  const isHead = input.request.method === 'HEAD';
134
- globalThis.__output = stringify({status:response.status,headers,body: isHead ? '' : response._text,
135
- ...(isHead ? {contentLength:byteLength(response._text)} : {})});
136
- globalThis.__state = 'done';
137
- } catch { globalThis.__state = 'failed'; }
138
- };
142
+ settle('done', stringify({status:response.status,headers,body: isHead ? '' : response._text,
143
+ ...(isHead ? {contentLength:byteLength(response._text)} : {})}));
144
+ } catch { settle('failed'); }
145
+ });
139
146
  })();
140
147
  `;
@@ -1,5 +1,6 @@
1
1
  import { validateHeaderName, validateHeaderValue } from './header-validation.js';
2
2
  import { assert, HttpError } from './errors.js';
3
+ import { byteLength } from './http-response.js';
3
4
 
4
5
 
5
6
  import { assertBodySchema, bodySchemaIssues, bodySchemaLine, bodySchemaJson, prefersJson } from './body-schema.js';
@@ -17,9 +18,6 @@ import { assertBodySchema, bodySchemaIssues, bodySchemaLine, bodySchemaJson, pre
17
18
 
18
19
 
19
20
 
20
- const encoder = new TextEncoder();
21
- const byteLength = (value ) => encoder.encode(value).length;
22
-
23
21
  export const reservedResponseHeaders = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te','location','allow','content-range','accept-ranges','etag','last-modified','content-encoding','x-request-id','x-content-type-options']);
24
22
  export function compileHttp(route ) {
25
23
  const seen = new Set (); let size = 0;