@jimhoyd/urlcode 0.4.8 → 0.5.5

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 (58) hide show
  1. package/README.md +18 -18
  2. package/dist/BUILD-MANIFEST.json +27 -23
  3. package/dist/agents-guide.js +8 -4
  4. package/dist/authoring.js +81 -7
  5. package/dist/build-cloudflare.js +1 -0
  6. package/dist/build-static.js +1 -0
  7. package/dist/cli.js +39 -14
  8. package/dist/config.js +7 -1
  9. package/dist/context.js +32 -4
  10. package/dist/ecosystem-cli.js +6 -0
  11. package/dist/explain-cli.js +1 -1
  12. package/dist/explain.js +2 -2
  13. package/dist/extension-artifacts.js +28 -34
  14. package/dist/extension-bundles.js +62 -0
  15. package/dist/extension-transport.js +41 -0
  16. package/dist/extensions.js +4 -0
  17. package/dist/feature-plan.js +99 -0
  18. package/dist/functions.js +2 -1
  19. package/dist/index.js +4 -2
  20. package/dist/init-with.js +55 -23
  21. package/dist/interchange.js +1 -1
  22. package/dist/match.js +23 -5
  23. package/dist/mcp.js +6 -2
  24. package/dist/readiness.js +2 -2
  25. package/dist/review.js +206 -0
  26. package/dist/router.js +35 -10
  27. package/dist/runtime.js +1 -0
  28. package/dist/tooling.js +4 -0
  29. package/dist/types/agents-guide.d.ts +6 -1
  30. package/dist/types/authoring.d.ts +3 -1
  31. package/dist/types/context.d.ts +12 -0
  32. package/dist/types/explain.d.ts +3 -0
  33. package/dist/types/extension-artifacts.d.ts +15 -4
  34. package/dist/types/extension-bundles.d.ts +49 -0
  35. package/dist/types/extension-transport.d.ts +31 -0
  36. package/dist/types/extensions.d.ts +4 -0
  37. package/dist/types/feature-plan.d.ts +67 -0
  38. package/dist/types/functions.d.ts +4 -0
  39. package/dist/types/index.d.ts +4 -2
  40. package/dist/types/init-with.d.ts +6 -1
  41. package/dist/types/match.d.ts +1 -0
  42. package/dist/types/review.d.ts +30 -0
  43. package/dist/types/tooling.d.ts +4 -0
  44. package/dist/types/types.d.ts +10 -1
  45. package/dist/types.js +10 -3
  46. package/docs/AI-AUTHORING.md +466 -0
  47. package/docs/FUNCTION-SECURITY.md +251 -0
  48. package/docs/README.md +96 -0
  49. package/docs/TOOLING.md +422 -0
  50. package/docs/YAML-REFERENCE.md +473 -0
  51. package/llms-full.txt +190 -83
  52. package/llms.txt +73 -128
  53. package/package.json +15 -4
  54. package/recipes/redirect/README.md +2 -2
  55. package/recipes/store-crud/README.md +9 -10
  56. package/recipes/store-crud/recipe.yaml +1 -1
  57. package/schemas/urlcode.schema.json +3 -0
  58. package/starters/default/AGENTS.md +2 -2
package/dist/context.js CHANGED
@@ -170,17 +170,44 @@ const idParam=(name )=>({name,in:'path',required:true,schema:{type:'string
170
170
  export const redirectShapes =[
171
171
  {need:'fixed',support:'supported',yaml:{routes:{'/old':{redirect:{url:'https://example.com/new',status:301}}}},note:'status defaults to 302; allowed 301, 302, 303, 307, 308. Only GET/HEAD match unless methods is set.'},
172
172
  {need:'parameterized path (/users/:id to /profiles/:id)',support:'supported',yaml:{routes:{'/users/{id}':{parameters:[idParam('id')],redirect:{url:'https://example.com/profiles/{id}',status:308}}}},note:'{name} placeholders only in the destination path, each naming a declared path parameter; the value is encoded as one component.'},
173
- {need:'fixed-depth suffix (/legacy/a/b to /modern/a/b)',support:'supported',yaml:{routes:{'/legacy/{a}/{b}':{parameters:[idParam('a'),idParam('b')],redirect:{url:'https://example.com/modern/{a}/{b}'}}}},note:'One route per depth; a path with more or fewer segments is a 404.'},
173
+ {need:'root-relative destination (/people/:id to /profiles/:id)',support:'supported',yaml:{routes:{'/people/{id}':{parameters:[idParam('id')],redirect:{url:'/profiles/{id}'}}}},note:'A single leading slash keeps the redirect on this site; Location is the path. {name} placeholders as above. `//host`, dot segments and a scheme-less host are refused.'},
174
+ {need:'wildcard suffix (/legacy/* to /modern/*, any depth)',support:'supported',yaml:{routes:{'/legacy/**':{redirect:{url:'https://example.com/modern/{**}'}}}},note:'Route key ends in a terminal `/**` (literal prefix required). `{**}` is the remaining segments, each encoded, at most once and only in the path; one or more segments, so /legacy and /legacy/ are 404. Exact and {param} routes win over it. Redirect only; refused on static and Cloudflare targets. Works with a relative destination too.'},
174
175
  {need:'query-string preservation',support:'supported',yaml:{routes:{'/search':{parameters:[{name:'q',in:'query',schema:{type:'string',maxLength:100}}],redirect:{url:'https://example.com/find',query:{pass:['q','utm_source']}}}}},note:'Nothing is forwarded by default; pass is an explicit allowlist (pass: true is refused by the schema); query.map renames or maps declared inputs.'},
175
176
  {need:'method-preserving redirect',support:'supported',yaml:{routes:{'/form':{methods:['GET','POST'],redirect:{url:'https://example.com/form2',status:307}}}},note:'Default methods GET/HEAD; other methods answer 405. Use 307/308 to keep the method and body.'},
176
177
  {need:'404 for unmatched paths',support:'supported',yaml:{site:{notFound:'404.html'}},note:'Unmatched GET/HEAD answer 404 (plain without site.notFound; that .html file, still status 404, with it). Trailing slashes are not normalized: /old/ is a 404 unless declared as its own route.'},
177
- {need:'wildcard suffix (/legacy/* to /modern/*, any depth)',support:'gap',note:'`/legacy/*` on a redirect fails validation: "Only static or extension routes support a terminal /* wildcard"; `{rest...}` fails with "Invalid route parameter". Report the gap; proposal in docs/OPEN-DECISIONS.md.',workaround:'a fixed-depth route per depth you need, or one route per known path (urlcode bulk-import). A function handler cannot match a subtree either.'},
178
- {need:'host, scheme or relative destination',support:'gap',note:'Destination must be a literal absolute http(s) URL: "/x" and "//h/x" fail with "Redirect URL must be absolute HTTP(S)"; {param} in host or query fails with "Redirect placeholders are allowed only in path segments"; other schemes fail with "Redirect must use HTTP(S) without credentials". Routes do not match on Host.',workaround:'a literal https destination per route; report host-based redirects as a gap.'},
178
+ {need:'host or scheme chosen from the request',support:'gap',note:'A destination is either a literal absolute http(s) URL or a root-relative path: "//h/x" and other schemes fail ("Redirect URL must be an absolute HTTP(S) URL or a root-relative path" / "Redirect must use HTTP(S) without credentials"); {param} in the host or query fails with "Redirect placeholders are allowed only in path segments". Routes do not match on Host.',workaround:'a literal https destination per route; report host-based redirects as a gap.'},
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
+ /** A complete, paste-ready project skeleton: every supported shape merged into one urlcode.yaml, plus the start script. */
182
+
183
+
184
+
185
+
186
+
187
+
188
+
189
+
190
+ /** Merges every supported shape's YAML; test/context-task.test.ts compiles the result, so it cannot drift from the runtime. */
191
+ export function redirectStarter() {
192
+ const document ={version:'1',routes:{}};
193
+ for(const shape of redirectShapes) {
194
+ if(shape.support!=='supported'||!shape.yaml)continue;
195
+ const {routes,site}=shape.yaml ;
196
+ Object.assign(document.routes,routes);
197
+ if(site)document.site={...document.site,...site};
198
+ }
199
+ return {
200
+ file:'urlcode.yaml',
201
+ yaml:stringify(document,{lineWidth:0,aliasDuplicateObjects:false}),
202
+ companions:{'404.html':'<!doctype html><title>Not found</title><h1>404</h1>\n'},
203
+ packageScripts:{start:'urlcode serve --project . --host 0.0.0.0 --port ${PORT:-3000}'},
204
+ note:'Delete the routes you do not need and adjust the rest. `npm start` honors PORT. Shapes marked gap above are not in this file; do not add them.',
205
+ };
206
+ }
181
207
 
182
208
 
183
209
 
210
+
184
211
 
185
212
 
186
213
 
@@ -196,7 +223,7 @@ export async function buildTaskContext(project ,task ,options
196
223
  const budget=options.budget;
197
224
  if(budget!==undefined&&(!Number.isSafeInteger(budget)||budget<1))throw new Error('Invalid context budget');
198
225
  const flag=options.projectFlag??project;
199
- const context ={urlcode:await packageVersion(),schema:'1',task:'redirects',shapes:redirectShapes.map(shape=>({...shape}))};
226
+ const context ={urlcode:await packageVersion(),schema:'1',task:'redirects',shapes:redirectShapes.map(shape=>({...shape})),starter:redirectStarter()};
200
227
  const exists=await readFile(join(project,'urlcode.yaml')).then(()=>true,()=>false);
201
228
  if(exists) {
202
229
  const host=await loadOperatorHost(options.hostFile,project);
@@ -214,6 +241,7 @@ export async function buildTaskContext(project ,task ,options
214
241
  const steps =[
215
242
  ['project',()=>{delete context.project;}],
216
243
  ['commands',()=>{delete context.commands;delete context.recipe;}],
244
+ ['starter',()=>{delete context.starter;}],
217
245
  ['notes',()=>{context.shapes=context.shapes .map(({need,support,yaml})=>({need,support,...(yaml?{yaml}:{})}));}],
218
246
  ['shapes',()=>{delete context.shapes;}],
219
247
  ];
@@ -33,6 +33,12 @@ export async function runEcosystemCommand(command ,args ,options
33
33
  }
34
34
  }
35
35
  else assert(false,'Use examples list or search');
36
+ }else if(command==='docs'){
37
+ const [operation,text]=args;
38
+ assert(operation==='search' && args.length===2,'Use docs search <text>');
39
+ const {searchDocs}=await import('./agent-context.js');
40
+ const found=await searchDocs(text );
41
+ print(options.json?found:found.results.length?found.results.map(hit=>`## ${hit.id}: ${hit.title}\n${hit.summary}\nmatched: ${hit.matched.join(' ')}\n\n${hit.excerpt}\n`).join('\n'):`No match for "${found.query}"\n`);
36
42
  }else if(command==='build-typescript'){
37
43
  assert(args.length===0 && options.out,'Provide --out new-directory');
38
44
  const {buildTypeScriptProject}=await import('./typescript-authoring.js');
@@ -27,7 +27,7 @@ function detail(explanation ,targets )
27
27
  for(const [name,entry] of Object.entries(explanation.policies.inventory))lines.push(` ${name}: ${JSON.stringify(entry)}`);
28
28
  for(const [name,entry] of Object.entries(explanation.policies.extensions))lines.push(` extensions.${name}: requires ${JSON.stringify(entry.requirement)}${entry.provider?entry.provider.registered?` (provider registered, revision ${entry.provider.revisionMatch?'matches':'differs'}, requirement ${entry.provider.requirementValid===false?'invalid':'valid'})`:' (no provider in host file)':''}`);
29
29
  lines.push(`cache: ${explanation.cache.outcome}${explanation.cache.cacheControl?` (${explanation.cache.cacheControl})`:''}${explanation.cache.forcedNoStore?' forced':''}; ${explanation.cache.reason}`);
30
- const env=Object.entries(explanation.bindings.env).map(([alias,ref])=>`${alias}=${'env' in ref?`$${ref.env}`:'literal'}`),secrets=Object.entries(explanation.bindings.secrets).map(([alias,ref])=>`${alias}=secret:${ref.secret}`);
30
+ const env=Object.entries(explanation.bindings.env).map(([alias,ref])=>`${alias}=${'env' in ref?`$${ref.env}${'default' in ref?` (default ${JSON.stringify(ref.default)})`:''}`:'literal'}`),secrets=Object.entries(explanation.bindings.secrets).map(([alias,ref])=>`${alias}=secret:${ref.secret}`);
31
31
  lines.push(`bindings: ${[...env,...secrets].join(', ')||'none'}`);
32
32
  if(explanation.egress.proxy||explanation.egress.signals)lines.push(`egress: ${[explanation.egress.proxy?`proxy ${explanation.egress.proxy}`:'',...(explanation.egress.signals??[]).map(item=>`signal ${item}`)].filter(Boolean).join(', ')}`);
33
33
  if(explanation.responseHeaders.length)lines.push(`response headers: ${explanation.responseHeaders.map(([name,value])=>`${name}: ${value}`).join('; ')}`);
package/dist/explain.js CHANGED
@@ -37,7 +37,7 @@ const handlerNames=['extension','proxy','conditional','redirect','function','pag
37
37
 
38
38
 
39
39
 
40
-
40
+
41
41
 
42
42
 
43
43
 
@@ -102,7 +102,7 @@ export function explainCompiledRoute(loaded ,route ,c
102
102
  const extensions ={};
103
103
  for(const name of extensionNames){const provider=providerOf(name,extensionRequirements[name],options);extensions[name]={requirement:extensionRequirements[name] ,...(provider?{provider}:{})};}
104
104
  const env ={};
105
- for(const [alias,ref]of Object.entries(declared?.env??{}))env[alias]=ref.env?{env:ref.env}:{literal:true};
105
+ for(const [alias,ref]of Object.entries(declared?.env??{}))env[alias]=ref.env?(ref.default!==undefined?{env:ref.env,default:ref.default}:{env:ref.env}):{literal:true};
106
106
  const secrets ={};
107
107
  for(const [alias,ref]of Object.entries(declared?.secrets??{}))secrets[alias]={secret:ref.secret};
108
108
  const inventory=chain?.describe??{};
@@ -1,10 +1,9 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { gunzipSync } from 'node:zlib';
3
- import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
3
  import { dirname, join, relative, resolve } from 'node:path';
5
4
  import { tmpdir } from 'node:os';
6
- import { spawn } from 'node:child_process';
7
5
  import { ConfigError, assert } from './errors.js';
6
+ import { isRecord as record, digestHex as digest, textField, exactKeys as sharedExactKeys, listCachedFiles, writeLockAtomic, createGithubTransport, verifiedReleaseAsset, } from './extension-transport.js';
8
7
 
9
8
  /** Offline, declarative extension bundles. These are deliberately not Node packages. */
10
9
  export const ARTIFACT_REPOSITORY = 'jimhoyd-com/urlcode';
@@ -19,11 +18,8 @@ const tag = /^extensions@v[0-9][0-9A-Za-z._-]{0,100}$/;
19
18
 
20
19
 
21
20
 
22
-
23
- const record=(v ) =>v !== null && typeof v==='object' && !Array.isArray(v);
24
- const digest=(bytes )=>createHash('sha256').update(bytes).digest('hex');
25
- function text(value , what ) { assert(typeof value==='string' && value.length>0 && value.length<256,`Invalid ${what} in extension artifact metadata`); return value; }
26
- 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
+ function text(value , what ) { return textField(value, `${what} in extension artifact metadata`); }
22
+ function exactKeys(value , expected , what ) { sharedExactKeys(value, expected, what); }
27
23
 
28
24
  /** Parse an untrusted catalog only after its GitHub attestation was verified by the caller. */
29
25
  export function parseCatalog(bytes , requestedTag ) {
@@ -47,26 +43,32 @@ export function parseCatalog(bytes , requestedTag ) {
47
43
  return {format:1,tag:catalogTag,commit,artifacts,revoked};
48
44
  }
49
45
 
50
-
46
+
47
+
51
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; }
52
- function archivePath(bytes ) { const value=new TextDecoder().decode(bytes).replace(/\0.*$/,''); assert(value.length>0 && !value.includes('\\') && !value.startsWith('/') && !value.split('/').includes('..'),'Unsafe extension archive path'); return value; }
49
+ function archivePath(name ,prefix ) {
50
+ const decode=(bytes )=>new TextDecoder().decode(bytes).replace(/\0.*$/,'');
51
+ const base=decode(name), directory=decode(prefix), value=directory?`${directory}/${base}`:base;
52
+ assert(base.length>0 && !value.includes('\\') && !value.startsWith('/') && !value.split('/').includes('..'),'Unsafe extension archive path');
53
+ return value;
54
+ }
53
55
  /** A minimal tar reader: only regular files are accepted, before any write occurs. */
54
- function readTgz(source ) {
55
- assert(source.byteLength>0 && source.byteLength<=MAX_ARCHIVE,'Extension archive exceeds the 16 MiB limit');
56
- let bytes ; try { bytes=gunzipSync(source,{maxOutputLength:MAX_EXPANDED}); } catch { throw new ConfigError('Extension artifact is not a valid bounded gzip tarball'); }
56
+ export function readBoundedTgz(source , limits ) {
57
+ assert(source.byteLength>0 && source.byteLength<=limits.archive,`${limits.label} exceeds the size limit`);
58
+ let bytes ; try { bytes=gunzipSync(source,{maxOutputLength:limits.expanded}); } catch { throw new ConfigError(`${limits.label} is not a valid bounded gzip tarball`); }
57
59
  const files =[]; let ended=false;
58
60
  for(let at=0;at<bytes.length;) {
59
- const header=bytes.subarray(at,at+512); if(header.length===512&&header.every(byte=>byte===0)) { const second=bytes.subarray(at+512,at+1024); assert(second.length===512&&second.every(byte=>byte===0)&&bytes.subarray(at+1024).every(byte=>byte===0),'Malformed extension archive terminator'); ended=true; break; }
60
- assert(header.length===512,'Truncated extension archive'); const stored=octal(header.subarray(148,156)); let checksum=0; for(let index=0;index<header.length;index++) checksum+=index>=148&&index<156?32:header[index] ; assert(stored===checksum,'Extension archive has an invalid tar checksum'); const size=octal(header.subarray(124,136)); const type=header[156] ?? 0;
61
- assert(type===0 || type===48,'Extension archives may contain regular files only'); assert(size<=MAX_FILE && at+512+size<=bytes.length,'Invalid extension archive member');
62
- const path=archivePath(header.subarray(0,100)); assert(!files.some(file=>file.path===path),'Extension archive repeats a path');
63
- files.push({path,bytes:bytes.slice(at+512,at+512+size)}); assert(files.length<=MAX_FILES,'Extension archive has too many files'); at+=512+Math.ceil(size/512)*512;
61
+ const header=bytes.subarray(at,at+512); if(header.length===512&&header.every(byte=>byte===0)) { const second=bytes.subarray(at+512,at+1024); assert(second.length===512&&second.every(byte=>byte===0)&&bytes.subarray(at+1024).every(byte=>byte===0),`Malformed ${limits.label} terminator`); ended=true; break; }
62
+ assert(header.length===512,`Truncated ${limits.label}`); const stored=octal(header.subarray(148,156)); let checksum=0; for(let index=0;index<header.length;index++) checksum+=index>=148&&index<156?32:header[index] ; assert(stored===checksum,`${limits.label} has an invalid tar checksum`); const size=octal(header.subarray(124,136)); const type=header[156] ?? 0;
63
+ assert(type===0 || type===48,`${limits.label} may contain regular files only`); assert(size<=limits.file && at+512+size<=bytes.length,`Invalid ${limits.label} member`);
64
+ const path=archivePath(header.subarray(0,100),header.subarray(345,500)); assert(!files.some(file=>file.path===path),`${limits.label} repeats a path`);
65
+ files.push({path,bytes:bytes.slice(at+512,at+512+size)}); assert(files.length<=limits.files,`${limits.label} has too many files`); at+=512+Math.ceil(size/512)*512;
64
66
  }
65
- assert(ended,'Extension archive has no complete tar terminator');
67
+ assert(ended,`${limits.label} has no complete tar terminator`);
66
68
  return files;
67
69
  }
68
- async function diskFiles(root ,prefix='') { const found =[]; for(const item of await readdir(join(root,prefix),{withFileTypes:true})) { const path=prefix?`${prefix}/${item.name}`:item.name; assert(item.isDirectory()||item.isFile(),'Extension cache contains a link or special file'); if(item.isDirectory()) found.push(...await diskFiles(root,path)); else found.push(path); } return found.sort(); }
69
- async function validateCached(root , entry ) { const archive=await readFile(join(root,'.artifact.tgz')); assert(digest(archive)===entry.sha256,`Cached extension artifact ${entry.name} does not match its lockfile`); const files=readTgz(archive); validateFiles(files,entry); const expected=['.artifact.tgz',...files.map(file=>file.path)].sort(); assert(JSON.stringify(await diskFiles(root))===JSON.stringify(expected),`Cached extension artifact ${entry.name} has unexpected files`); for(const file of files) assert(digest(await readFile(join(root,file.path)))===digest(file.bytes),`Cached extension artifact ${entry.name} was modified`); }
70
+ function readTgz(source ) { return readBoundedTgz(source,{archive:MAX_ARCHIVE,expanded:MAX_EXPANDED,files:MAX_FILES,file:MAX_FILE,label:'Extension archive'}); }
71
+ async function validateCached(root , entry ) { const archive=await readFile(join(root,'.artifact.tgz')); assert(digest(archive)===entry.sha256,`Cached extension artifact ${entry.name} does not match its lockfile`); const files=readTgz(archive); validateFiles(files,entry); const expected=['.artifact.tgz',...files.map(file=>file.path)].sort(); assert(JSON.stringify(await listCachedFiles(root,'extension'))===JSON.stringify(expected),`Cached extension artifact ${entry.name} has unexpected files`); for(const file of files) assert(digest(await readFile(join(root,file.path)))===digest(file.bytes),`Cached extension artifact ${entry.name} was modified`); }
70
72
  function validateFiles(files , entry ) {
71
73
  const allowed=/^(?:extension\.json|README\.md|schemas\/[A-Za-z0-9._-]+\.json|config\/[A-Za-z0-9._-]+\.json)$/;
72
74
  assert(files.length>0 && files.every(file=>allowed.test(file.path)),'Extension artifact contains a file type that is not declarative data');
@@ -82,25 +84,17 @@ export async function extractArtifact(bytes , entry , des
82
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}); }
83
85
  }
84
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}; }
85
- 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 writeFile(temporary,JSON.stringify(lock,null,2)+'\n',{flag:'wx'}); try { await rename(temporary,path); } finally { await rm(temporary,{force:true}); } }
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); }
86
88
  export function cachePath(project , sha256 ) { assert(hex.test(sha256),'Invalid extension digest'); return join(project,'.urlcode','extensions',sha256); }
87
89
 
88
-
90
+
89
91
 
90
- function releaseUrl(tagName ) { return `https://api.github.com/repos/${ARTIFACT_REPOSITORY}/releases/tags/${encodeURIComponent(tagName)}`; }
91
- function githubDownloadUrl(value ) { const url=new URL(value); assert(url.protocol==='https:'&&(url.hostname==='github.com'||url.hostname.endsWith('.githubusercontent.com')),'Extension release redirect left GitHub'); return url; }
92
- async function githubDownload(value ) { let url=githubDownloadUrl(value); for(let redirects=0;redirects<=3;redirects++) { const response=await fetch(url,{redirect:'manual'}); if(response.status>=300&&response.status<400) { const location=response.headers.get('location'); assert(location&&redirects<3,'Extension release asset has an invalid redirect'); url=githubDownloadUrl(new URL(location,url).href); continue; } assert(response.ok&&response.body,'Could not download extension release asset'); const length=response.headers.get('content-length'); assert(length===null||(/^\d+$/.test(length)&&Number(length)<=MAX_ARCHIVE),'Extension release asset exceeds the size limit'); const chunks =[]; let size=0; for await(const chunk of response.body) { size+=chunk.byteLength; assert(size<=MAX_ARCHIVE,'Extension release asset exceeds the size limit'); chunks.push(chunk); } const body=new Uint8Array(size); let offset=0; for(const chunk of chunks) { body.set(chunk,offset); offset+=chunk.byteLength; } return body; } throw new ConfigError('Extension release asset redirected too many times'); }
93
92
  /** The default transport accepts only GitHub Release asset URLs and verifies every downloaded subject. */
94
- export const githubTransport ={
95
- async release(tagName) { assert(tag.test(tagName),'Use an immutable extension release tag such as extensions@v1.0.0'); const response=await fetch(releaseUrl(tagName),{headers:{accept:'application/vnd.github+json'}}); assert(response.ok,`Could not fetch extension release ${tagName}`); const raw =await response.json(); assert(record(raw)&&Array.isArray(raw.assets),'Extension release has no asset inventory'); const seen=new Set (); return raw.assets.map(item=>{ assert(record(item)&&typeof item.name==='string'&&typeof item.browser_download_url==='string'&&!seen.has(item.name),'Invalid or duplicate extension release asset'); seen.add(item.name); const url=new URL(item.browser_download_url); assert(url.protocol==='https:'&&url.hostname==='github.com'&&url.pathname.startsWith(`/${ARTIFACT_REPOSITORY}/releases/download/`),'Extension release asset is not a GitHub download'); return {name:item.name,url:url.href}; }); },
96
- async download(url) { return githubDownload(url); },
97
- async attest(path,release) { assert(tag.test(release),'Invalid extension artifact release tag'); await new Promise ((resolveVerify,reject)=>{ const child=spawn('gh',['attestation','verify',path,'--repo',ARTIFACT_REPOSITORY,'--signer-workflow',ARTIFACT_WORKFLOW,'--source-ref',`refs/tags/${release}`,'--deny-self-hosted-runners'],{stdio:'ignore'}); child.on('error',()=>reject(new ConfigError('GitHub CLI with attestation support is required to verify extension artifacts'))); child.on('exit',code=>code===0?resolveVerify():reject(new ConfigError('GitHub attestation verification refused the extension artifact'))); }); },
98
- };
99
- async function verifiedAsset(assets , asset , release , transport ) { const found=assets.filter(item=>item.name===asset); assert(found.length===1,`Extension release is missing or repeats ${asset}`); const bytes=await transport.download(found[0] .url); const temporary=join(tmpdir(),`urlcode-attest-${process.pid}-${Math.random().toString(16).slice(2)}`); await writeFile(temporary,bytes,{flag:'wx'}); try { await transport.attest(temporary,release); return bytes; } finally { await rm(temporary,{force:true}); } }
100
- export async function resolveCatalog(release , transport =githubTransport) { const assets=await transport.release(release); const bytes=await verifiedAsset(assets,'extensions-catalog.json',release,transport); return {catalog:parseCatalog(bytes,release),assets}; }
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}; }
101
95
  export async function installArtifact(project , release , artifactName , transport =githubTransport) {
102
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'}`);
103
- const bytes=await verifiedAsset(assets,entry.asset,release,transport); await extractArtifact(bytes,entry,cachePath(project,entry.sha256));
97
+ const bytes=await verifiedReleaseAsset(assets,entry.asset,release,transport,'extension artifact','urlcode-attest'); await extractArtifact(bytes,entry,cachePath(project,entry.sha256));
104
98
  let prior ; try { prior=await readLock(project); } catch { /* first install */ }
105
99
  const artifacts=(prior?.artifacts ?? []).filter(item=>item.name!==entry.name); artifacts.push({...entry,catalog:{tag:catalog.tag,commit:catalog.commit}}); artifacts.sort((a,b)=>a.name.localeCompare(b.name)); const lock ={format:1,artifacts}; await writeLock(project,lock); return lock;
106
100
  }
@@ -0,0 +1,62 @@
1
+ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { ConfigError, assert } from './errors.js';
6
+ import { readBoundedTgz, } from './extension-artifacts.js';
7
+ import { isRecord as record, digestHex as digest, textField, exactKeys as sharedExactKeys, listCachedFiles, writeLockAtomic, createGithubTransport, verifiedReleaseAsset, } from './extension-transport.js';
8
+
9
+ /** Verified, executable first-party bundles. Unlike extension artifacts, these are trusted operator code. */
10
+ export const BUNDLE_REPOSITORY='jimhoyd-com/urlcode';
11
+ export const BUNDLE_WORKFLOW='jimhoyd-com/urlcode/.github/workflows/extension-bundles.yml';
12
+ const tag=/^extension-bundles@v[0-9][0-9A-Za-z._-]{0,100}$/;
13
+ const name=/^[a-z][a-z0-9-]{0,63}$/;
14
+ const version=/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/;
15
+ const hex=/^[a-f0-9]{64}$/;
16
+ const entry=/^node_modules\/@jimhoyd\/urlcode-[a-z][a-z0-9-]{0,63}\/dist\/[A-Za-z0-9._/-]+\.js$/;
17
+ const safeEntry=(value ) =>entry.test(value)&&value.split('/').every(segment=>segment!=='.'&&segment!=='..');
18
+ const limits={archive:128*1024*1024,expanded:512*1024*1024,files:12000,file:32*1024*1024,label:'Extension bundle archive'};
19
+ const text=(value ,what ) =>textField(value,`${what} in extension bundle metadata`);
20
+ const exact=(value ,keys ,what ) =>sharedExactKeys(value,keys,what);
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+ function parseEntry(value ,what ,strict=true) {
29
+ assert(record(value),`Invalid ${what}`); if(strict)exact(value,['name','version','asset','sha256','entry'],what);
30
+ const item={name:text(value.name,'bundle name'),version:text(value.version,'bundle version'),asset:text(value.asset,'bundle asset'),sha256:text(value.sha256,'bundle SHA-256'),entry:text(value.entry,'bundle entry')};
31
+ assert(name.test(item.name)&&version.test(item.version)&&/^[A-Za-z0-9._-]+\.tgz$/.test(item.asset)&&hex.test(item.sha256)&&safeEntry(item.entry),`Invalid ${what}`);
32
+ return item;
33
+ }
34
+ /** Parse only a catalog whose attestation was already verified against the requested immutable tag. */
35
+ export function parseBundleCatalog(bytes ,requested ) {
36
+ let raw ;try{raw=JSON.parse(new TextDecoder().decode(bytes));}catch{throw new ConfigError('Extension bundle catalog is not valid JSON');}
37
+ assert(record(raw)&&raw.format===1,'Unsupported extension bundle catalog format');exact(raw,['format','tag','commit','coreVersion','bundles','revoked'],'Extension bundle catalog');
38
+ const catalogTag=text(raw.tag,'catalog tag'),commit=text(raw.commit,'catalog commit'),coreVersion=text(raw.coreVersion,'catalog core version');
39
+ assert(tag.test(catalogTag)&&catalogTag===requested,'Extension bundle catalog tag does not match the immutable requested release');assert(/^[a-f0-9]{40}$/.test(commit)&&version.test(coreVersion),'Extension bundle catalog has an invalid pin');assert(Array.isArray(raw.bundles)&&Array.isArray(raw.revoked),'Extension bundle catalog is incomplete');
40
+ const names=new Set (),bundles=raw.bundles.map(value=>{const item=parseEntry(value,'extension bundle catalog entry');assert(!names.has(item.name),`Extension bundle catalog names ${item.name} more than once`);names.add(item.name);return item;});
41
+ const revoked =[],digests=new Set ();for(const value of raw.revoked){assert(record(value),'Invalid extension bundle revocation');exact(value,['sha256','reason'],'Extension bundle revocation');const sha256=text(value.sha256,'revocation SHA-256'),reason=text(value.reason,'revocation reason');assert(hex.test(sha256)&&!digests.has(sha256),'Invalid or duplicate extension bundle revocation');digests.add(sha256);revoked.push({sha256,reason});}
42
+ return {format:1,tag:catalogTag,commit,coreVersion,bundles,revoked};
43
+ }
44
+
45
+ function bundleManifest(files ) {
46
+ const member=files.find(file=>file.path==='bundle.json');assert(member,'Extension bundle is missing bundle.json');let raw ;try{raw=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(member.bytes));}catch{throw new ConfigError('Extension bundle bundle.json is not valid UTF-8 JSON');}
47
+ assert(record(raw)&&raw.format===1,'Unsupported extension bundle manifest format');exact(raw,['format','coreVersion','bundles'],'Extension bundle manifest');const coreVersion=text(raw.coreVersion,'bundle core version');assert(version.test(coreVersion)&&Array.isArray(raw.bundles)&&raw.bundles.length>0,'Invalid extension bundle manifest');const seen=new Set ();const bundles=raw.bundles.map(value=>{assert(record(value),'Invalid extension bundle manifest entry');exact(value,['name','version','entry'],'Extension bundle manifest entry');const item={name:text(value.name,'bundle name'),version:text(value.version,'bundle version'),entry:text(value.entry,'bundle entry')};assert(name.test(item.name)&&version.test(item.version)&&safeEntry(item.entry)&&!seen.has(item.name),'Invalid or duplicate extension bundle manifest entry');seen.add(item.name);return item;});return {format:1,coreVersion,bundles};
48
+ }
49
+ function validateFiles(files ,item ,coreVersion ) {
50
+ assert(files.length>1&&files.every(file=>file.path==='bundle.json'||file.path.startsWith('node_modules/')),'Extension bundle contains a file outside its frozen module tree');const manifest=bundleManifest(files);assert(manifest.coreVersion===coreVersion,'Extension bundle core version does not match its signed catalog');assert(manifest.bundles.some(value=>value.name===item.name&&value.version===item.version&&value.entry===item.entry),'Extension bundle manifest does not match its signed catalog entry');assert(files.some(file=>file.path===item.entry),'Extension bundle is missing its declared entry module');
51
+ }
52
+ async function validateCached(root ,item ) {const archive=await readFile(join(root,'.bundle.tgz'));assert(digest(archive)===item.sha256,`Cached extension bundle ${item.name} does not match its lockfile`);const files=readBoundedTgz(archive,limits);validateFiles(files,item,item.coreVersion);const expected=['.bundle.tgz',...files.map(file=>file.path)].sort();assert(JSON.stringify(await listCachedFiles(root,'extension bundle'))===JSON.stringify(expected),`Cached extension bundle ${item.name} has unexpected files`);for(const file of files)assert(digest(await readFile(join(root,file.path)))===digest(file.bytes),`Cached extension bundle ${item.name} was modified`);}
53
+ export async function extractBundle(bytes ,item ,destination ) {assert(digest(bytes)===item.sha256,`Extension bundle ${item.name} does not match its signed SHA-256`);const files=readBoundedTgz(bytes,limits);validateFiles(files,item,item.coreVersion);const root=resolve(destination),temporary=join(tmpdir(),`urlcode-extension-bundle-${process.pid}-${Math.random().toString(16).slice(2)}`);await mkdir(temporary,{recursive:true});try{for(const file of files){const target=resolve(temporary,file.path),rel=relative(temporary,target);assert(rel&&!rel.startsWith('..'),'Unsafe extension bundle path');await mkdir(dirname(target),{recursive:true});await writeFile(target,file.bytes,{flag:'wx'});}await writeFile(join(temporary,'.bundle.tgz'),bytes,{flag:'wx'});await mkdir(dirname(root),{recursive:true});try{await rename(temporary,root);}catch{try{await validateCached(root,item );return;}catch{throw new ConfigError(`Extension bundle cache entry ${item.sha256} already exists but is not identical`);}}}finally{await rm(temporary,{recursive:true,force:true});}}
54
+ export function bundleCachePath(project ,sha256 ) {assert(hex.test(sha256),'Invalid extension bundle digest');return join(project,'.urlcode','extension-bundles',sha256);}
55
+ export async function readBundleLock(project ) {let raw ;try{const path=join(project,'urlcode.extension-bundles.lock.json'),info=await lstat(path);assert(info.isFile()&&!info.isSymbolicLink()&&info.nlink===1,'Extension bundle 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 bundle lockfile; install a bundle first');}assert(record(raw)&&raw.format===1&&Array.isArray(raw.bundles),'Invalid extension bundle lockfile');exact(raw,['format','bundles'],'Extension bundle lockfile');const seen=new Set (),bundles=raw.bundles.map(value=>{assert(record(value)&&record(value.catalog),'Invalid extension bundle lock entry');exact(value,['name','version','asset','sha256','entry','catalog','coreVersion'],'Extension bundle lock entry');exact(value.catalog,['tag','commit'],'Extension bundle lock catalog');const item={...parseEntry(value,'extension bundle lock entry',false),catalog:{tag:text(value.catalog.tag,'lock tag'),commit:text(value.catalog.commit,'lock commit')},coreVersion:text(value.coreVersion,'lock core version')};assert(tag.test(item.catalog.tag)&&/^[a-f0-9]{40}$/.test(item.catalog.commit)&&version.test(item.coreVersion)&&!seen.has(item.name),'Invalid or duplicate extension bundle lock entry');seen.add(item.name);return item;});return {format:1,bundles};}
56
+ async function writeLock(project ,lock ) {const path=join(project,'urlcode.extension-bundles.lock.json'),temporary=join(project,`.urlcode.extension-bundles.lock.${process.pid}.${Math.random().toString(16).slice(2)}`);await writeLockAtomic(path,temporary,lock);}
57
+ export const githubBundleTransport =createGithubTransport({repository:BUNDLE_REPOSITORY,workflow:BUNDLE_WORKFLOW,tagPattern:tag,exampleTag:'extension-bundles@v1.0.0',maxAssetSize:limits.archive,itemLabel:'extension bundle'});
58
+ async function verified(assets ,asset ,release ,transport ) {return verifiedReleaseAsset(assets,asset,release,transport,'extension bundle','urlcode-bundle-attest');}
59
+ export async function installBundle(project ,release ,bundleName ,transport =githubBundleTransport) {assert(name.test(bundleName),'Invalid extension bundle name');const assets=await transport.release(release),catalog=parseBundleCatalog(await verified(assets,'extension-bundles-catalog.json',release,transport),release),item=catalog.bundles.find(value=>value.name===bundleName);assert(item,`Extension bundle ${bundleName} is not in the signed catalog`);const revoked=catalog.revoked.find(value=>value.sha256===item.sha256);assert(!revoked,`Extension bundle ${bundleName} is revoked: ${revoked?.reason??'unknown reason'}`);const locked ={...item,catalog:{tag:catalog.tag,commit:catalog.commit},coreVersion:catalog.coreVersion};await extractBundle(await verified(assets,item.asset,release,transport),locked,bundleCachePath(project,item.sha256));let prior ;try{prior=await readBundleLock(project);}catch{/* first install */}const bundles=(prior?.bundles??[]).filter(value=>value.name!==item.name);bundles.push(locked);bundles.sort((left,right)=>left.name.localeCompare(right.name));const lock={format:1 ,bundles};await writeLock(project,lock);return lock;}
60
+ async function runningCoreVersion() {const raw =JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8'));assert(record(raw)&&typeof raw.version==='string'&&version.test(raw.version),'Could not read the running core version');return raw.version;}
61
+ /** Explicit host-only loader. It never reads project YAML, downloads, updates, or discovers code. */
62
+ export async function loadExtensionBundle(project ,bundleName ) {assert(name.test(bundleName),'Invalid extension bundle name');const lock=await readBundleLock(project),item=lock.bundles.find(value=>value.name===bundleName);assert(item,`Extension bundle ${bundleName} is not locked`);assert(item.coreVersion===await runningCoreVersion(),`Extension bundle ${bundleName} requires core ${item.coreVersion}; this runtime is incompatible`);const root=bundleCachePath(project,item.sha256);await validateCached(root,item);const module=await import(pathToFileURL(join(root,item.entry)).href);assert(module&&typeof module==='object',`Extension bundle ${bundleName} entry did not export a module`);return module ;}
@@ -0,0 +1,41 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { readdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ConfigError, assert } from './errors.js';
7
+
8
+ /** 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
+
10
+
11
+ export const isRecord=(v ) =>v !== null && typeof v==='object' && !Array.isArray(v);
12
+ export const digestHex=(bytes ) =>createHash('sha256').update(bytes).digest('hex');
13
+ export function textField(value , what ) { assert(typeof value==='string' && value.length>0 && value.length<256,`Invalid ${what}`); return value; }
14
+ 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`); }
15
+ const capitalize=(value ) =>value.charAt(0).toUpperCase()+value.slice(1);
16
+
17
+ /** Sorted, recursive listing of an extension cache directory; refuses links and special files. */
18
+ export async function listCachedFiles(root , itemLabel , prefix='') { const found =[]; for(const item of await readdir(join(root,prefix),{withFileTypes:true})) { const path=prefix?`${prefix}/${item.name}`:item.name; assert(item.isDirectory()||item.isFile(),`${capitalize(itemLabel)} cache contains a link or special file`); if(item.isDirectory()) found.push(...await listCachedFiles(root,itemLabel,path)); else found.push(path); } return found.sort(); }
19
+
20
+ /** Atomic write-then-rename for a JSON lockfile, refusing to clobber a concurrent writer. */
21
+ 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
+
23
+
24
+
25
+
26
+
27
+ /** A transport that accepts only GitHub Release asset URLs and verifies every downloaded subject. */
28
+ export function createGithubTransport(config ) {
29
+ const {repository,workflow,tagPattern,exampleTag,maxAssetSize,itemLabel}=config, releaseLabel=`${itemLabel} release`;
30
+ const releaseUrl=(tagName ) =>`https://api.github.com/repos/${repository}/releases/tags/${encodeURIComponent(tagName)}`;
31
+ function downloadUrl(value ) { const url=new URL(value); assert(url.protocol==='https:'&&(url.hostname==='github.com'||url.hostname.endsWith('.githubusercontent.com')),`${capitalize(releaseLabel)} redirect left GitHub`); return url; }
32
+ async function download(value ) { let url=downloadUrl(value); for(let redirects=0;redirects<=3;redirects++) { const response=await fetch(url,{redirect:'manual'}); if(response.status>=300&&response.status<400) { const location=response.headers.get('location'); assert(location&&redirects<3,`${capitalize(releaseLabel)} asset has an invalid redirect`); url=downloadUrl(new URL(location,url).href); continue; } assert(response.ok&&response.body,`Could not download ${releaseLabel} asset`); const length=response.headers.get('content-length'); assert(length===null||(/^\d+$/.test(length)&&Number(length)<=maxAssetSize),`${capitalize(releaseLabel)} asset exceeds the size limit`); const chunks =[]; let size=0; for await(const chunk of response.body) { size+=chunk.byteLength; assert(size<=maxAssetSize,`${capitalize(releaseLabel)} asset exceeds the size limit`); chunks.push(chunk); } const body=new Uint8Array(size); let offset=0; for(const chunk of chunks) { body.set(chunk,offset); offset+=chunk.byteLength; } return body; } throw new ConfigError(`${capitalize(releaseLabel)} asset redirected too many times`); }
33
+ return {
34
+ async release(tagName) { assert(tagPattern.test(tagName),`Use an immutable ${releaseLabel} tag such as ${exampleTag}`); const response=await fetch(releaseUrl(tagName),{headers:{accept:'application/vnd.github+json'}}); assert(response.ok,`Could not fetch ${releaseLabel} ${tagName}`); const raw =await response.json(); assert(isRecord(raw)&&Array.isArray(raw.assets),`${capitalize(releaseLabel)} has no asset inventory`); const seen=new Set (); return raw.assets.map(item=>{ assert(isRecord(item)&&typeof item.name==='string'&&typeof item.browser_download_url==='string'&&!seen.has(item.name),`Invalid or duplicate ${releaseLabel} asset`); seen.add(item.name); const url=new URL(item.browser_download_url); assert(url.protocol==='https:'&&url.hostname==='github.com'&&url.pathname.startsWith(`/${repository}/releases/download/`),`${capitalize(releaseLabel)} asset is not a GitHub download`); return {name:item.name,url:url.href}; }); },
35
+ download,
36
+ async attest(path,release) { assert(tagPattern.test(release),`Invalid ${itemLabel} release tag`); await new Promise ((resolveVerify,reject)=>{ const child=spawn('gh',['attestation','verify',path,'--repo',repository,'--signer-workflow',workflow,'--source-ref',`refs/tags/${release}`,'--deny-self-hosted-runners'],{stdio:'ignore'}); child.on('error',()=>reject(new ConfigError(`GitHub CLI with attestation support is required to verify ${itemLabel}s`))); child.on('exit',code=>code===0?resolveVerify():reject(new ConfigError(`GitHub attestation verification refused the ${itemLabel}`))); }); },
37
+ };
38
+ }
39
+
40
+ /** Downloads one named release asset and has the transport attest it before returning its bytes. */
41
+ export async function verifiedReleaseAsset(assets , asset , release , transport , itemLabel , tempPrefix ) { const found=assets.filter(item=>item.name===asset); assert(found.length===1,`${capitalize(itemLabel)} release is missing or repeats ${asset}`); const bytes=await transport.download(found[0] .url); const temporary=join(tmpdir(),`${tempPrefix}-${process.pid}-${Math.random().toString(16).slice(2)}`); await writeFile(temporary,bytes,{flag:'wx'}); try { await transport.attest(temporary,release); return bytes; } finally { await rm(temporary,{force:true}); } }
@@ -176,6 +176,8 @@ export async function loadExtensionHooks (config
176
176
 
177
177
 
178
178
 
179
+
180
+
179
181
 
180
182
 
181
183
 
@@ -204,6 +206,8 @@ export async function loadExtensionHooks (config
204
206
 
205
207
 
206
208
 
209
+
210
+
207
211
 
208
212
 
209
213
 
@@ -0,0 +1,99 @@
1
+ import {buildContext,estimateTokens} from './context.js';
2
+ import {getCapabilities,normalizeCapabilityTarget} from './capabilities.js';
3
+
4
+ import {listRecipes} from './recipes.js';
5
+ import {describeArtifactCache} from './extension-artifacts.js';
6
+
7
+
8
+ /** The planner is deliberately a small, local projection. It never treats goal
9
+ * text as instructions, opens a host, or reads extension/project source. */
10
+ export const featurePlanMaxBytes=32768;
11
+ export const featurePlanMaxGoalLength=512;
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+ const stop=new Set(['a','an','and','the','with','for','to','of','in','on','that','my','i','want','need','from']);
21
+ function terms(goal ) {
22
+ const words=goal.toLowerCase().split(/[^a-z0-9.-]+/).filter(word=>word.length>1&&!stop.has(word));
23
+ return [...new Set(words)].slice(0,16);
24
+ }
25
+ const recipeTerms ={
26
+ 'contact-form':['contact','form','message','submit','submission','email'],
27
+ 'authenticated-json-api':['auth','authenticated','account','sign','signed','private','protected'],
28
+ 'store-crud':['store','persist','persisted','persistence','durable','database','crud','record','records','submission','submissions'],
29
+ };
30
+ const extensionReason ={
31
+ ui:'Presentation is an operator-installed extension; its registered authoring surfaces govern project-owned UI customization.',
32
+ auth:'Authentication is an operator-installed extension; its registration and revision pin, not project YAML, select the executable package and grants.',
33
+ store:'Durable state is an operator-installed extension; its data directory and revision pin remain operator-owned.',
34
+ forms:'Form workflow is available only through the already-registered forms extension contract; the project cannot select or install it.',
35
+ };
36
+ const outline ={
37
+ 'contact-form':{kind:'contact endpoint',note:'The bundled recipe establishes one POST JSON endpoint with bounded validation and a revision-pinned signal; it does not provide a browser form flow.'},
38
+ 'authenticated-json-api':{kind:'protected endpoint',note:'The bundled recipe protects a function route through the auth extension policy; the project declares the requirement but never loads the package.'},
39
+ 'store-crud':{kind:'durable collection',note:'The bundled recipe declares a collection and an extension mount; CRUD behavior belongs to the registered store extension, not a generated handler.'},
40
+ };
41
+ function selectedRecipes(goalTerms , recipes ) {
42
+ const mapped=recipes.filter(recipe=>{
43
+ const known=recipeTerms[recipe.name]??[];
44
+ return known.some(term=>goalTerms.includes(term));
45
+ });
46
+ return (mapped.length?mapped:recipes.filter(recipe=>recipe.tags.some(tag=>goalTerms.includes(tag.toLowerCase())))).slice(0,4);
47
+ }
48
+
49
+ /**
50
+ * Plans only from the current compiled project, package-owned catalogs, locked
51
+ * inert artifacts, and registrations passed by the already-opened operator
52
+ * session. It intentionally has no filesystem path, host-file, binding, or
53
+ * execution argument.
54
+ */
55
+ export async function planFeature(project ,goal ,options ={}) {
56
+ if(typeof goal!=='string'||goal.length<1||goal.length>featurePlanMaxGoalLength)throw new Error(`Feature goal must be 1 to ${featurePlanMaxGoalLength} characters`);
57
+ const goalTerms=terms(goal),target=normalizeCapabilityTarget(options.target??'self-hosted');
58
+ const context=await buildContext(project,{target,projectFlag:'.'}), recipes=selectedRecipes(goalTerms,await listRecipes());
59
+ const capabilities=[...new Set(recipes.flatMap(recipe=>recipe.capabilities??[]).filter((name) =>typeof name==='string'))].sort();
60
+ const catalog=getCapabilities(target), rows=new Map(catalog.capabilities.map(row=>[row.capability,row]));
61
+ const registrations=new Map((options.extensions??[]).map(extension=>[extension.name,extension]));
62
+ const wanted=new Set ();
63
+ for(const recipe of recipes) for(const service of recipe.services??[]) {
64
+ const match=/\b(ui|auth|store) extension\b/i.exec(service.name); if(match)wanted.add(match[1] .toLowerCase());
65
+ }
66
+ // These nouns request composition, not a project-controlled package choice.
67
+ if(goalTerms.some(term=>['form','contact','screen','page','ui'].includes(term)))wanted.add('ui');
68
+ // A forms capability is discoverable only from an operator registration. Do
69
+ // not turn a goal noun into an implied package choice or YAML declaration.
70
+ if(goalTerms.some(term=>['form','flow','workflow','multistep','multi-step','wizard'].includes(term))&&registrations.has('forms'))wanted.add('forms');
71
+ if(goalTerms.some(term=>['auth','authenticated','account','sign','signed','private','protected'].includes(term)))wanted.add('auth');
72
+ if(goalTerms.some(term=>['store','persist','persisted','persistence','durable','database','crud','record','records','submission','submissions'].includes(term)))wanted.add('store');
73
+ let artifacts =[];
74
+ try {artifacts=(await describeArtifactCache(project)).artifacts;} catch {/* no lock is an ordinary absence, never a reason to read elsewhere */}
75
+ const declared=new Set(context.project.extensions);
76
+ const required=[...wanted].sort().map(name=>{
77
+ const registration=registrations.get(name), artifact=artifacts.find(item=>item.name===name);
78
+ const supported=target!=='static'&&registration?.targets.includes(target==='self-hosted'?'node':target);
79
+ return {name,reason:extensionReason[name]??'This feature needs an operator-registered extension contract.',declared:declared.has(name),registered:Boolean(registration),target:registration?(supported?'supported':'refused'):'unknown',artifact:artifact?.status??'none'} ;
80
+ });
81
+ const unsupported =[];
82
+ if(goalTerms.some(term=>['flow','workflow','multistep','multi-step','wizard'].includes(term))&&!registrations.has('forms'))unsupported.push({requirement:'Declarative form flow',reason:'No already-registered forms extension contract is available, and no bundled core capability or recipe declares multi-step form state, transitions, or submission orchestration. Keep that application behavior focused, or define it as an extension boundary.'});
83
+ if(goalTerms.some(term=>['idempotent','idempotency'].includes(term)))unsupported.push({requirement:'Idempotent mutation',reason:'The core capability catalog has no idempotent mutation primitive. Require an installed extension contract that exposes it, or keep the idempotency key and mutation logic in application code.'});
84
+ for(const capability of capabilities){const row=rows.get(capability);if(row?.targets[target]?.support==='refused')unsupported.push({requirement:capability,reason:row.targets[target] .reason});}
85
+ for(const extension of required)if(extension.target==='refused')unsupported.push({requirement:`${extension.name} extension on ${target}`,reason:'The already-registered extension does not declare support for this target.'});
86
+ const applicationCode =[];
87
+ if(recipes.some(recipe=>recipe.name==='contact-form'))applicationCode.push({requirement:'Product-specific form presentation and submission rules',reason:'The contact recipe covers a bounded JSON endpoint and optional signal only; it does not generate browser UI or business workflow code.'});
88
+ if(!recipes.length)applicationCode.push({requirement:'Feature-specific behavior',reason:'No bundled declarative recipe matched the bounded goal terms. Check capability and extension contracts before writing focused application code.'});
89
+ const plan ={
90
+ format:1,goalTerms,target,project:{routes:context.project.routes,extensions:context.project.extensions},
91
+ applicable:{capabilities:capabilities.map(name=>{const decision=rows.get(name)?.targets[target];return {name,support:decision?.support??'unknown',reason:decision?.reason??'Not in this revision\'s capability catalog'};}),recipes:recipes.map(recipe=>({name:recipe.name,description:recipe.description,matched:(recipeTerms[recipe.name]??[]).filter(term=>goalTerms.includes(term)).slice(0,4)}))},
92
+ extensions:{required,ordering:{status:'operator-resolved',names:[...wanted].sort(),note:'Extension package selection, prerequisites, and canonical activation order are resolved by the operator-approved init/host composition. This read-only plan neither loads a bundle nor turns project YAML into an operator decision.'}},
93
+ outline:recipes.map(recipe=>outline[recipe.name]??{kind:recipe.name,note:'Use the bundled recipe metadata as the known contract.'}),applicationCode,unsupported,
94
+ next:['get_context','search_recipes','get_capability','get_extensions','get_extension_artifacts'].filter((name,index,all)=>all.indexOf(name)===index),
95
+ };
96
+ const estimatedTokens=estimateTokens(JSON.stringify(plan)); const result={...plan,estimatedTokens};
97
+ if(Buffer.byteLength(JSON.stringify(result))>featurePlanMaxBytes)throw new Error('Feature plan exceeds output limit');
98
+ return result;
99
+ }
package/dist/functions.js CHANGED
@@ -30,7 +30,8 @@ import { assert, ConfigError, HttpError } from './errors.js';
30
30
 
31
31
  // The worker protocol. Only JSON-shaped data and byte buffers cross it.
32
32
 
33
-
33
+ /** `route.pattern` is the route key that matched, so one module can serve several routes without reading `request.url`. */
34
+
34
35
 
35
36
 
36
37
 
package/dist/index.js CHANGED
@@ -21,8 +21,8 @@ export {buildTypeScriptProject} from './typescript-authoring.js';
21
21
 
22
22
  export {importBulkProject} from './bulk.js';
23
23
 
24
- export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks} from './tooling.js';
25
-
24
+ export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks, planFeature, featurePlanMaxBytes, featurePlanMaxGoalLength} from './tooling.js';
25
+
26
26
  export {buildManifest, renderManifest, MANIFEST_SCHEMA_VERSION} from './manifest.js';
27
27
 
28
28
  export {serveMcp} from './mcp.js';
@@ -44,3 +44,5 @@ export {initProject, addRedirect} from './authoring.js';
44
44
  export {initProjectWith} from './init-with.js';
45
45
  export {collectDependencySet,renderPackageManifest,installSteps} from './project-dependencies.js';
46
46
 
47
+ export {installBundle,loadExtensionBundle,readBundleLock,parseBundleCatalog} from './extension-bundles.js';
48
+