@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
@@ -44,7 +44,6 @@ function usage(base ,files ,name )
44
44
  }
45
45
  return result;
46
46
  }
47
- export function capabilityNameList() {return capabilityNames;}
48
47
  /** One catalog entry with its schema fragments and bundled usage. No project, credentials or network are read. */
49
48
  export function getCapability(name ) {
50
49
  if(typeof name!=='string'||!(capabilityNames ).includes(name))throw new ConfigError('Unknown capability; valid names: '+capabilityNames.join(', '));
package/dist/catalog.js CHANGED
@@ -36,7 +36,6 @@ async function validate() {
36
36
  }
37
37
  return validator;
38
38
  }
39
- export const metadataFiles={recipe:'recipe.yaml',example:'example.yaml'} ;
40
39
  /** Reads and schema-validates one metadata file; the id must equal the directory name, and file lists stay authoring-safe paths. */
41
40
  export async function readMetadata(root ,id ,file ) {
42
41
  const text=(await readAuthoringFile(root,file,65536)).toString('utf8');
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { startServer } from './server.js';
14
14
  import {scaffoldProject} from './scaffold.js';
15
15
  import { initProject, addRedirect } from './authoring.js';
16
16
  import { initProjectWith, parseWithNames } from './init-with.js';
17
+ import { collectDependencySet, installSteps, parsePin } from './project-dependencies.js';
17
18
  import { runProjectTests } from './project-tests.js';
18
19
  import { verifyDeployment, failLevels } from './verify-deployment.js';
19
20
 
@@ -27,8 +28,11 @@ import { loadComplianceRules, profileNames as complianceProfiles } from './compl
27
28
  import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
28
29
  import { readFile } from 'node:fs/promises';
29
30
 
30
- const usage = `URLCode 0.4.0-alpha.2 — local/self-hosted runtime
31
- urlcode init <directory> [--with auth,admin] # --with: layered site from installed @jimhoyd/urlcode-<name> packages
31
+ const usage = `URLCode 0.4.1 — local/self-hosted runtime
32
+ urlcode init <directory> [--with ui,auth,admin] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
33
+ # --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; name ui first
34
+ # --manifest: also pin the runtime for a route-only project; --no-manifest: --with without a package.json
35
+ # --pin: record a local path or tarball instead of the registry version; repeatable. No install is ever run for you.
32
36
  urlcode scaffold [--project directory] [--dry-run]
33
37
  urlcode validate [--project directory] [--local] [--origin https://links.example] # origin: absolute URLs in site.* files
34
38
  urlcode dev [--project directory] [--port 3000] [--host 127.0.0.1]
@@ -74,12 +78,13 @@ const usage = `URLCode 0.4.0-alpha.2 — local/self-hosted runtime
74
78
  # compact facts for an authoring agent from the compiled project; --stats compares estimated tokens with the docs
75
79
  urlcode doctor
76
80
  serve/dev/validate/test/routes/audit/benchmark/explain/context/extensions/mcp: --host-file /absolute/operator/host.mjs (trusted code outside project)
77
- Dev loads .env.local and watches; serve does neither. Functions run in WASM isolation; external bindings require --policy outside the project.
81
+ Dev loads .env.local and watches; serve does neither. Functions run trusted and in-process by default; a route declaring sandbox: true runs in WASM isolation. External bindings require --policy outside the project.
78
82
  `;
79
83
  const print = (value ) => process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value) + '\n');
80
84
  const options = {
81
85
  json:{ type:'boolean' }, yaml:{ type:'boolean' }, report:{type:'string'}, 'accept-provider-differences':{type:'boolean'},
82
86
  project:{ type:'string', default:'.' }, 'host-file':{type:'string'}, with:{type:'string'},
87
+ manifest:{type:'boolean'}, 'no-manifest':{type:'boolean'}, pin:{type:'string', multiple:true},
83
88
  port:{ type:'string' }, host:{ type:'string', default:'127.0.0.1' },
84
89
  'expect-routes':{type:'string'}, requests:{type:'string'}, concurrency:{type:'string'}, seconds:{type:'string'}, 'max-p95-ms':{type:'string'}, warmup:{type:'string'}, target:{type:'string'},
85
90
  workers:{type:'string'}, 'function-timeout-ms':{type:'string'}, 'max-response-bytes':{type:'string'}, 'max-body-bytes':{type:'string'},
@@ -149,6 +154,8 @@ try {
149
154
  if (command !== 'mcp' && command !== 'context') operatorHost = await loadOperatorHost(values['host-file'], values.project);
150
155
  }
151
156
  if (values.with !== undefined && command !== 'init') throw new ConfigError('--with is only supported by init');
157
+ if ((values.manifest || values['no-manifest'] || values.pin !== undefined) && command !== 'init') throw new ConfigError('--manifest/--no-manifest/--pin are only supported by init');
158
+ if (values.manifest && values['no-manifest']) throw new ConfigError('Use either --manifest or --no-manifest');
152
159
  if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
153
160
  const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
154
161
  if ((!['import','recipes','recipe','examples','example','bulk-import'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
@@ -266,14 +273,23 @@ try {
266
273
  const loaded = await loadDocument(values.project);
267
274
  print(requestedPermissions(loaded,await prepareFunctionSnapshot(loaded))); break;
268
275
  }
269
- case 'init':
276
+ case 'init': {
270
277
  if (!arg) throw new ConfigError('Provide a new project directory');
271
- if (values.with === undefined) { await initProject(arg); print({ event:'created' }); break; }
272
- {
273
- const created = await initProjectWith(arg, parseWithNames(values.with));
274
- print({ event:'created', ...created, review:`Review ${created.project}/urlcode.yaml and pin its revision explicitly (for example PROJECT_SHA256=${created.projectSha256}); re-review after any project change` });
278
+ // Pins are opt-in for a route-only project (its runtime may be managed elsewhere) and the default for
279
+ // --with, which has just resolved the very packages the generated site depends on.
280
+ const wanted = values.with === undefined ? values.manifest === true : !values['no-manifest'];
281
+ const pins = new Map((values.pin ?? []).map(parsePin));
282
+ if (pins.size && !wanted) throw new ConfigError('--pin needs a manifest; drop --no-manifest or add --manifest');
283
+ if (values.with === undefined) {
284
+ const set = wanted ? await collectDependencySet([], [], { overrides: pins }) : undefined;
285
+ const created = await initProject(arg, { manifest: set });
286
+ print(set ? { event:'created', dependencies:set.pins, nextSteps:installSteps(created, set) } : { event:'created' });
287
+ break;
275
288
  }
289
+ const created = await initProjectWith(arg, parseWithNames(values.with), { manifest: wanted, pins });
290
+ print({ event:'created', ...created, review:`Review ${created.project}/urlcode.yaml and pin its revision explicitly (for example PROJECT_SHA256=${created.projectSha256}); re-review after any project change` });
276
291
  break;
292
+ }
277
293
  case 'validate': {
278
294
  const runtime = await createRuntime(values.project, { ...hostOptions, local:values.local, permissions, origin:values.origin });
279
295
  print({ event:'valid', routes:runtime.count, version:runtime.version }); await runtime.close(); break;
@@ -286,7 +302,7 @@ try {
286
302
  print(result); if (result.failed) process.exitCode = 1; break;
287
303
  }
288
304
  case 'doctor':
289
- print({ node:process.version, platform:process.platform, architecture:process.arch, runtime:'node-process', functionSandbox:'quickjs-wasm', network:false, filesystem:false, guestNetwork:false, hostEgress:'revision-pinned-origin-grants', tooling:['recipes','examples','bulk-import','build-typescript','mcp','verify-provider'], providers:[], capabilityTargets:getCapabilities().targets, policies:Object.keys(policyRegistry), license:'Apache-2.0' }); break;
305
+ print({ node:process.version, platform:process.platform, architecture:process.arch, runtime:'node-process', functionDefault:'trusted-in-process', sandboxEngine:'quickjs-wasm', trustedFilesystem:true, trustedNetwork:true, sandboxedFilesystem:false, sandboxedNetwork:false, hostEgress:'revision-pinned-origin-grants', tooling:['recipes','examples','bulk-import','build-typescript','mcp','verify-provider'], providers:[], capabilityTargets:getCapabilities().targets, policies:Object.keys(policyRegistry), license:'Apache-2.0' }); break;
290
306
  case 'dev': case 'serve': {
291
307
  const port = Number(values.port);
292
308
  if (!/^\d+$/.test(values.port) || !Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError('Invalid port');
package/dist/config.js CHANGED
@@ -74,7 +74,7 @@ function modulePath(pattern , kind , file
74
74
  * a route-level `cache` becomes `policies.cache` (refused alongside a direct `policies.cache`).
75
75
  * Everything downstream (routes, audit, the compiled table) sees only the long form.
76
76
  */
77
- export function normalizeRoute(pattern , route ) {
77
+ function normalizeRoute(pattern , route ) {
78
78
  const authored = route ;
79
79
  const needsFunction = typeof authored.function === 'string';
80
80
  const needsMiddleware = authored.middleware?.some(entry => typeof entry === 'string') ?? false;
@@ -12,7 +12,7 @@ const targetsOf=(target ) =>target===undefined
12
12
  function summary(explanation ,targets ) {
13
13
  const handler=explanation.handler,detail=handler.kind==='function'?`${handler.source }#${handler.export }`:handler.kind==='redirect'?`${handler.status } ${handler.url }`:handler.kind==='extension'?handler.name :handler.kind==='page'||handler.kind==='download'?handler.file :handler.kind==='static'?handler.directory :handler.kind==='proxy'?handler.url :handler.kind==='respond'?String(handler.status):'';
14
14
  const support=targets.map(target=>explanation.targets[target].compatible?target:`${target}:refused`).join(',');
15
- return [explanation.path,explanation.methods.join(','),`${handler.kind}${detail?` ${detail}`:''}`,explanation.state,String(explanation.middleware.length),explanation.policies.names.join(',')||'-',explanation.cache.outcome,support].join('\t');
15
+ return [explanation.path,explanation.methods.join(','),`${handler.kind}${detail?` ${detail}`:''}`,explanation.state,explanation.sandbox?'sandboxed':'trusted',String(explanation.middleware.length),explanation.policies.names.join(',')||'-',explanation.cache.outcome,support].join('\t');
16
16
  }
17
17
  function detail(explanation ,targets ) {
18
18
  const lines =[`route: ${explanation.path}`];
@@ -20,6 +20,8 @@ function detail(explanation ,targets )
20
20
  if(explanation.generated)lines.push(`generated: site.${explanation.generated}`);
21
21
  lines.push(`state: ${explanation.state}${explanation.expires?` (expires ${explanation.expires})`:''}`,`methods: ${explanation.methods.join(', ')}`,`handler: ${JSON.stringify(explanation.handler)}`);
22
22
  lines.push(`middleware: ${explanation.middleware.length?explanation.middleware.map(item=>`${item.source}#${item.export}`).join(' -> '):'none'}`);
23
+ // Route-level, so it is printed for a native handler with middleware too.
24
+ lines.push(`execution: ${explanation.sandbox?'sandboxed (QuickJS)':'trusted (in-process)'}${explanation.sandboxReason?`; ${explanation.sandboxReason}`:''}`);
23
25
  lines.push(`inputs: ${explanation.inputs.parameters.length?explanation.inputs.parameters.map(p=>`${p.in}:${p.name}${p.required?'':'?'}`).join(', '):'none'}${explanation.inputs.body?`; body ${JSON.stringify(explanation.inputs.body)}`:''}`);
24
26
  lines.push(`policies: ${explanation.policies.names.length?explanation.policies.names.join(', '):'none'}`);
25
27
  for(const [name,entry] of Object.entries(explanation.policies.inventory))lines.push(` ${name}: ${JSON.stringify(entry)}`);
@@ -52,7 +54,7 @@ export async function runExplainCommand(command ,route
52
54
  if(route===undefined){
53
55
  const report=await explainProject(options.project,base);
54
56
  if(options.json){print(report);return 0;}
55
- print(table([['route','methods','handler','state','mw','policies','cache','targets'],...report.routes.map(item=>summary(item,targets).split('\t'))]));return 0;
57
+ print(table([['route','methods','handler','state','execution','mw','policies','cache','targets'],...report.routes.map(item=>summary(item,targets).split('\t'))]));return 0;
56
58
  }
57
59
  if(!route.startsWith('/'))throw new ConfigError('Provide an absolute route path such as /docs');
58
60
  const explanation=await explainRoute(options.project,route,base);
package/dist/explain.js CHANGED
@@ -29,6 +29,11 @@ const handlerNames=['extension','proxy','conditional','redirect','function','pag
29
29
 
30
30
 
31
31
 
32
+
33
+
34
+
35
+
36
+
32
37
 
33
38
 
34
39
 
@@ -52,7 +57,7 @@ function handlerOf(route ,root ) {
52
57
  return {kind,cases:(route.conditionalRoutes?.cases??[]).map(item=>({match:item.match,...branch(item.route)})),...(route.conditionalRoutes?.fallback?{fallback:branch(route.conditionalRoutes.fallback)}:{})};
53
58
  }
54
59
  case 'redirect':return {kind,url:route.redirect .url,status:route.redirect .status??302,...(route.redirect .query?{query:route.redirect .query}:{})};
55
- case 'function':return {kind,source:relativeSource(root,route.function .source),export:route.function .export,...(route.function .args?{args:route.function .args}:{}),sandbox:route.sandbox===true,...(route.sandboxReason?{sandboxReason:route.sandboxReason}:{})};
60
+ case 'function':return {kind,source:relativeSource(root,route.function .source),export:route.function .export,...(route.function .args?{args:route.function .args}:{})};
56
61
  case 'page':return {kind,file:route.page .file,...(route.page .contentType?{contentType:route.page .contentType}:{})};
57
62
  case 'static':return {kind,directory:route.static .directory,...(route.static .index?{index:route.static .index}:{})};
58
63
  case 'download':return {kind,file:route.download .file,...(route.download .filename?{filename:route.download .filename}:{}),...(route.download .contentType?{contentType:route.download .contentType}:{})};
@@ -89,7 +94,7 @@ function targetsOf(loaded ,route ,options
89
94
  }
90
95
  return result;
91
96
  }
92
- export function routeState(route ,now ) {return route.enabled===false?'disabled':route.expiresAt&&now>=route.expiresAt?'expired':'active';}
97
+ function routeState(route ,now ) {return route.enabled===false?'disabled':route.expiresAt&&now>=route.expiresAt?'expired':'active';}
93
98
  /** Describe one compiled route. `chain` is the policy chain compiled for it, when the project declares policies. */
94
99
  export function explainCompiledRoute(loaded ,route ,chain ,options ={}) {
95
100
  const root=loaded.root,declared=loaded.routes[route.pattern];
@@ -110,6 +115,7 @@ export function explainCompiledRoute(loaded ,route ,c
110
115
  state:routeState(route,options.now??Date.now()),enabled:route.enabled!==false,...(route.expires?{expires:route.expires}:{}),
111
116
  methods:[...route.methods],conditional:Boolean(route.match||route.conditional),handler,
112
117
  middleware:route.middleware.map(item=>({source:relativeSource(root,item.source),export:item.export})),
118
+ sandbox:route.sandbox===true,...(route.sandboxReason?{sandboxReason:route.sandboxReason}:{}),
113
119
  inputs:{parameters:route.parameters.map(({name,in:location,required,schema})=>({name,in:location,required,schema})),...(route.request?.body?{body:route.request.body}:{})},
114
120
  policies:{names,inventory,extensions},
115
121
  cache:cacheOf(route,chain,extensionNames),
@@ -63,7 +63,7 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
63
63
 
64
64
 
65
65
 
66
-
66
+
67
67
 
68
68
 
69
69
 
@@ -16,7 +16,7 @@ import { HttpError } from './errors.js';
16
16
 
17
17
 
18
18
  // Hop-by-hop and runtime-owned headers a handler must never set on the wire.
19
- export const forbiddenHeaders = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te']);
19
+ const forbiddenHeaders = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te']);
20
20
 
21
21
  // One place decides what a URLCode response *is*, independent of how a host
22
22
  // delivers it. A Node server writes it to a socket; a Lambda returns it as
package/dist/index.js CHANGED
@@ -42,4 +42,5 @@ export {scaffoldProject} from './scaffold.js';
42
42
 
43
43
  export {initProject, addRedirect} from './authoring.js';
44
44
  export {initProjectWith} from './init-with.js';
45
+ export {collectDependencySet,renderPackageManifest,installSteps} from './project-dependencies.js';
45
46
 
package/dist/init-with.js CHANGED
@@ -8,13 +8,21 @@ import { mcpConfigFile, renderMcpConfig } from './agents-guide.js';
8
8
  import { loadDocument, parseYaml, validateDocument } from './config.js';
9
9
  import { inspectExtensionRevision } from './extensions.js';
10
10
 
11
+ import { collectDependencySet, installSteps, renderPackageManifest } from './project-dependencies.js';
12
+
11
13
  import { ConfigError, assert } from './errors.js';
12
14
 
13
15
  /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
14
- export const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
16
+ const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
15
17
  const namePattern = /^[a-z][a-z0-9-]{0,63}$/;
16
-
17
-
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
18
26
 
19
27
  export function parseWithNames(value ) {
20
28
  const names = value.split(',').map(name => name.trim());
@@ -22,7 +30,7 @@ export function parseWithNames(value ) {
22
30
  assert(new Set(names).size === names.length, 'Duplicate --with names');
23
31
  return names;
24
32
  }
25
- export const packageName = (name ) => `@jimhoyd/urlcode-${name}`;
33
+ const packageName = (name ) => `@jimhoyd/urlcode-${name}`;
26
34
  const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
27
35
  const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
28
36
  const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -32,7 +40,7 @@ const record = (value ) => value !== n
32
40
  * conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
33
41
  * at build time. Refuses a missing package or a package without `scaffold` before anything is written.
34
42
  */
35
- export async function loadScaffold(name , request , cwd ) {
43
+ async function loadScaffold(name , request , cwd ) {
36
44
  const pkg = packageName(name);
37
45
  let entry ;
38
46
  try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
@@ -68,7 +76,7 @@ async function write(target , content , mode = 0o644)
68
76
  const file = await open(target, 'wx', mode);
69
77
  try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
70
78
  }
71
- export function renderHost(names , results ) {
79
+ function renderHost(names , results ) {
72
80
  const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
73
81
  for (const result of results) lines.push(...result.hostImports);
74
82
  lines.push('');
@@ -85,12 +93,23 @@ function demote(markdown ) {
85
93
  let fence = false;
86
94
  return markdown.split('\n').map(line => { if (/^\s*(?:```|~~~)/.test(line)) fence = !fence; return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; }).join('\n');
87
95
  }
88
- export function renderReadme(directory , names , results , starter , env , projectSha256 ) {
89
- const steps = results.flatMap(result => result.nextSteps);
96
+ function renderDependencySection(directory , set ) {
97
+ const rows = set.pins.map(pin => `- \`${pin.name}\` ${pin.version} (${pin.role})${pin.specifier === pin.version ? '' : ` installed from \`${pin.specifier}\``}`);
98
+ const lines = ['## Dependencies', '',
99
+ '`package.json` pins the runtime, every extension named in `--with` and their declared peers to the exact versions that were installed when this site was generated. Those versions were checked against each package\'s own `peerDependencies` as one set.', '',
100
+ ...rows, '',
101
+ ...installSteps(directory, set).flatMap(step => [step, '']),
102
+ set.local ? 'At least one pin is a local path or tarball rather than a registry version: reproducing this install needs that path to exist, so keep it under your control or replace the specifier before publishing the site.' : 'The pins are registry versions; `npm install` resolves them without the network only if your cache or mirror already holds them.', '',
103
+ 'There is no upgrade command. Changing a pinned version today means editing `package.json` yourself and re-running `npm install`; review the extension changelogs first.', ''];
104
+ return lines.join('\n');
105
+ }
106
+ function renderReadme(directory , names , results , starter , env , projectSha256 , set ) {
107
+ const steps = [...(set ? installSteps(directory, set) : []), ...results.flatMap(result => result.nextSteps)];
90
108
  const parts = [`# ${basename(directory)}`, '',
91
109
  `Created with \`urlcode init ${basename(directory)} --with ${names.join(',')}\`. \`${PROJECT_DIRECTORY}/\` is the route project (\`urlcode.yaml\`, functions, tests); \`${HOST_FILE}\` is the trusted operator host that wires the installed extension packages; operator modules and private data stay outside the project. Run every command with \`--project ${PROJECT_DIRECTORY} --host-file "$PWD/${HOST_FILE}"\`.`, '',
92
110
  '## Starter', '', `The starter files live in \`${PROJECT_DIRECTORY}/\`; add \`--project ${PROJECT_DIRECTORY}\` and the host file to the commands below.`, '', demote(starter).trim(), ''];
93
111
  for (const result of results) parts.push(`## Extension: ${result.name}`, '', result.readme.trim(), '');
112
+ if (set) parts.push(renderDependencySection(directory, set));
94
113
  parts.push('## Next steps', '', ...steps.map((step, index) => `${index + 1}. ${step}`), '');
95
114
  if (Object.keys(env).length) parts.push('## Environment', '', ...Object.entries(env).map(([key, text]) => `- \`${key}\`: ${text}`), '');
96
115
  parts.push('## Project revision', '', `\`${PROJECT_DIRECTORY}/urlcode.yaml\` currently has revision \`${projectSha256}\` (\`inspectExtensionRevision\`). Review the project, then pin exactly that value where the host expects it; any change to extension YAML, policies or mounts changes it and needs a new explicit review.`, '');
@@ -102,7 +121,7 @@ export function renderReadme(directory , names , result
102
121
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
103
122
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
104
123
  */
105
- export async function initProjectWith(destination , names , { cwd = process.cwd() } = {}) {
124
+ export async function initProjectWith(destination , names , { cwd = process.cwd(), manifest = true, pins } = {}) {
106
125
  assert(names.length > 0, 'Provide at least one --with name');
107
126
  const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
108
127
  const request = { directory, project, hostFile, names };
@@ -120,6 +139,9 @@ export async function initProjectWith(destination , names
120
139
  const seen = new Set ();
121
140
  for (const file of result.files) { const path = filePath(directory, file.path); assert(!seen.has(path), `${result.name} scaffolds ${file.path} twice`); seen.add(path); }
122
141
  }
142
+ // Also resolved before the destination exists: an incompatible or incompletely installed set refuses with
143
+ // nothing written. It runs after the scaffold conflicts so a composition error is still reported as one.
144
+ const dependencies = manifest ? await collectDependencySet(names, names.map(packageName), { cwd, ...(pins === undefined ? {} : { overrides: pins }) }) : undefined;
123
145
  await mkdir(dirname(directory), { recursive: true });
124
146
  await mkdir(directory, { mode: 0o700 }); // refuses an existing destination
125
147
  try {
@@ -153,13 +175,16 @@ export async function initProjectWith(destination , names
153
175
  await write(target, file.content, file.mode ?? 0o644); written.add(target);
154
176
  }
155
177
  await write(hostFile, renderHost(names, results), 0o600);
156
- await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256));
178
+ if (dependencies) await write(join(directory, 'package.json'), renderPackageManifest(directory, dependencies));
179
+ await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256, dependencies));
157
180
  await write(join(directory, '.gitignore'), 'node_modules/\ndata/\n.env\n.env.*\n');
158
181
  // The read-only MCP server for agents opened at the site root; --host-file and --allow-authoring stay operator choices.
159
182
  await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY));
160
183
  // AGENTS.md: initProject writes the application-level file into app/ once it produces one (NEXT-STEPS 1.1);
161
184
  // nothing here overrides it. A site-level agent note would be assembled beside README.md at this point.
162
- return { directory, project, hostFile, extensions: [...names], projectSha256, nextSteps: results.flatMap(result => result.nextSteps) };
185
+ return { directory, project, hostFile, extensions: [...names], projectSha256,
186
+ nextSteps: [...(dependencies ? installSteps(directory, dependencies) : []), ...results.flatMap(result => result.nextSteps)],
187
+ dependencies: dependencies?.pins ?? [] };
163
188
  } catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
164
189
  } finally { wipe(); }
165
190
  }
package/dist/manifest.js CHANGED
@@ -15,9 +15,14 @@ import {explainCompiledRoute} from './explain.js';
15
15
  // checked in as a source of truth, and it is deterministic: the same project
16
16
  // yields the same bytes. Nothing in it is a binding value or source text.
17
17
 
18
- export const MANIFEST_SCHEMA_VERSION=2;
18
+ // Bumped to 3 when `sandbox`/`sandboxReason` moved from the `function` handler
19
+ // record to the route, so a middleware-only route reports its execution mode too.
20
+ export const MANIFEST_SCHEMA_VERSION=3;
19
21
 
20
22
 
23
+
24
+
25
+
21
26
 
22
27
 
23
28
 
@@ -77,6 +82,7 @@ export async function buildManifest(project ,options ={})
77
82
  manifestRoutes.push({
78
83
  path:explanation.path,methods:explanation.methods,handler:explanation.handler,enabled:explanation.enabled,
79
84
  ...(explanation.expires?{expires:explanation.expires}:{}),...(explanation.generated?{generated:explanation.generated}:{}),...(explanation.description?{description:explanation.description}:{}),
85
+ sandbox:explanation.sandbox,...(explanation.sandboxReason?{sandboxReason:explanation.sandboxReason}:{}),
80
86
  middleware:explanation.middleware,parameters:explanation.inputs.parameters,...(explanation.inputs.body?{body:explanation.inputs.body}:{}),
81
87
  policies:explanation.policies.names,extensions,cache:explanation.cache,bindings:{env:routeEnv,secrets:routeSecrets},egress:explanation.egress,
82
88
  capabilities:explanation.capabilities,targets,
@@ -105,5 +111,5 @@ export async function buildManifest(project ,options ={})
105
111
  }
106
112
  /** The manifest as `build` writes it: two-space JSON with a trailing newline. */
107
113
  export function renderManifest(manifest ) {return JSON.stringify(manifest,null,2)+'\n';}
108
- export const manifestFileName='manifest.json';
114
+ const manifestFileName='manifest.json';
109
115
  export function manifestPath(out ) {return join(out,manifestFileName);}
@@ -17,8 +17,8 @@ import {assert} from './errors.js';
17
17
  * Authoring tools for `urlcode mcp --allow-authoring`. Every write lands inside
18
18
  * the operator-selected project root (after realpath), through the existing
19
19
  * authoring, recipe and scaffold paths. Nothing here reads bindings, creates
20
- * grants, deploys, or touches operator policy, compliance rules, host files or
21
- * link stores. The runners spawn the CLI against the same root only.
20
+ * grants, deploys, or touches operator policy, compliance rules or host files.
21
+ * The runners spawn the CLI against the same root only.
22
22
  */
23
23
  const text={type:'string',maxLength:1024};
24
24
  const handler={anyOf:[{type:'string',maxLength:2048},{type:'object'}]};
@@ -54,7 +54,7 @@ async function verdict(root ,origin ) {
54
54
  catch{return {valid:false ,note:'Project does not validate; call run_validate for the CLI report.'};}
55
55
  }
56
56
  function object(value ) {return value!==null&&typeof value==='object'&&!Array.isArray(value);}
57
- function expandHandler(path ,handler ) {
57
+ function expandHandler(handler ) {
58
58
  if(object(handler))return handler;
59
59
  assert(typeof handler==='string','Handler must be a route object or a short form');
60
60
  if(/^https?:\/\//.test(handler))return {redirect:{url:handler}};
@@ -83,7 +83,7 @@ async function createRoute(root ,args ,origin
83
83
  assert(file==='urlcode.yaml'||(loaded.document.includes??[]).includes(file),'file must be urlcode.yaml or an include listed in it');
84
84
  const target=await confinedPath(root,file);
85
85
  assert(!Object.hasOwn(loaded.routes,path),'Route already exists');
86
- const route={...expandHandler(path,args.handler)};
86
+ const route={...expandHandler(args.handler)};
87
87
  const middleware=expandMiddleware(args.middleware);if(middleware)route.middleware=middleware;
88
88
  const lockPath=join(root,'urlcode.yaml.lock'),lock=await open(lockPath,'wx',0o600);
89
89
  let temp ;
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.0-alpha.2'}}});return;
81
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.1'}}});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;}
@@ -198,7 +198,7 @@ function bodyOf(result ) { return result.body ? (Buffer.is
198
198
 
199
199
  // Conditional requests for results the handler did not validate itself:
200
200
  // assets answer 304 before this phase, so only 200 results are examined.
201
- function revalidate(state , req , result ) {
201
+ function revalidate(req , result ) {
202
202
  if (result.status !== 200 || (req.method !== 'GET' && req.method !== 'HEAD')) return result;
203
203
  let headers = result.headers, etag = header(headers, 'etag');
204
204
  if (!etag) {
@@ -249,7 +249,7 @@ export function onResponse(state , req , result
249
249
  // refusal produced ahead of the handler keeps its own headers.
250
250
  if (flight || state.statuses.has(result.status)) headers = mergeVary(headers, state.vary);
251
251
  let out = { ...result, headers };
252
- if (state.strategy === 'revalidate') out = revalidate(state, req, out);
252
+ if (state.strategy === 'revalidate') out = revalidate(req, out);
253
253
  if (!flight) return out;
254
254
  // Store decision for the request that reached the handler; waiters are
255
255
  // released either way, with the entry or with nothing.
package/dist/policies.js CHANGED
@@ -9,7 +9,9 @@ import { assert, ConfigError } from './errors.js';
9
9
 
10
10
 
11
11
 
12
- // Host-side behavior declared in YAML and enforced outside the sandbox. Every
12
+ // Host-side behavior declared in YAML and enforced in the host process, outside
13
+ // function/middleware execution entirely -- trusted routes and sandbox: true
14
+ // routes alike. Every
13
15
  // module here follows one contract so a first-party policy and an operator
14
16
  // plugin share a code path (PolicyModule in src/types.ts):
15
17
  //
package/dist/prerender.js CHANGED
@@ -102,6 +102,10 @@ async function planPasses(source ) {
102
102
  const cost = new Map ();
103
103
  for (const pattern of patterns) {
104
104
  const route = loaded.routes[pattern] ;
105
+ // Trusted routes use Node resolution, not the sandbox module collector.
106
+ // Applying guest import/size limits here would reject valid build-time Node
107
+ // code before the runtime can dispatch it through its declared trust mode.
108
+ if (route.sandbox !== true) { cost.set(pattern, []); continue; }
105
109
  const definitions = [];
106
110
  for (const definition of routeFunctions(route))
107
111
  definitions.push({function: {source: await functionFile(loaded.root, definition.source), export: definition.export || 'default'}});