@jimhoyd/urlcode 0.5.0 → 0.5.6
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.
- package/.claude/skills/urlcode-authoring/SKILL.md +1 -1
- package/.claude/skills/urlcode-operations/SKILL.md +1 -1
- package/README.md +13 -16
- package/dist/BUILD-MANIFEST.json +21 -18
- package/dist/agents-guide.js +1 -1
- package/dist/authoring.js +50 -9
- package/dist/capabilities.js +1 -1
- package/dist/cli.js +20 -7
- package/dist/ecosystem-cli.js +6 -0
- package/dist/explain-cli.js +1 -1
- package/dist/explain.js +2 -2
- package/dist/extension-artifacts.js +10 -23
- package/dist/extension-bundles.js +8 -16
- package/dist/extension-transport.js +41 -0
- package/dist/feature-plan.js +99 -0
- package/dist/index.js +2 -2
- package/dist/mcp.js +6 -2
- package/dist/policies/agents.js +1 -1
- package/dist/policies/security.js +1 -1
- package/dist/policies.js +1 -1
- package/dist/review.js +206 -0
- package/dist/router.js +15 -2
- package/dist/scripts/operational-drills.js +1 -1
- package/dist/tooling.js +4 -0
- package/dist/types/authoring.d.ts +2 -0
- package/dist/types/explain.d.ts +3 -0
- package/dist/types/extension-artifacts.d.ts +2 -4
- package/dist/types/extension-transport.d.ts +31 -0
- package/dist/types/feature-plan.d.ts +67 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/review.d.ts +30 -0
- package/dist/types/tooling.d.ts +4 -0
- package/dist/types/types.d.ts +9 -1
- package/dist/types.js +10 -3
- package/docs/AI-AUTHORING.md +466 -0
- package/docs/FUNCTION-SECURITY.md +251 -0
- package/docs/README.md +96 -0
- package/docs/TOOLING.md +422 -0
- package/docs/YAML-REFERENCE.md +473 -0
- package/examples/assets/example.yaml +3 -3
- package/examples/aws/example.yaml +3 -3
- package/examples/cloudflare/example.yaml +3 -3
- package/examples/compliance/README.md +1 -1
- package/examples/compliance/example.yaml +1 -1
- package/examples/conditions/example.yaml +3 -3
- package/examples/cookbook/README.md +4 -4
- package/examples/cookbook/example.yaml +3 -3
- package/examples/coverage-waiver/example.yaml +3 -3
- package/examples/egress/example.yaml +2 -2
- package/examples/extensions/example.yaml +1 -1
- package/examples/lifecycle/example.yaml +2 -2
- package/examples/not-found/README.md +2 -2
- package/examples/not-found/example.yaml +3 -3
- package/examples/prerender/README.md +4 -4
- package/examples/prerender/example.yaml +2 -2
- package/examples/provider-conformance/example.yaml +2 -2
- package/examples/shared-blocks/example.yaml +3 -3
- package/examples/vercel/example.yaml +3 -3
- package/llms-full.txt +119 -84
- package/llms.txt +28 -19
- package/package.json +19 -13
- package/recipes/store-crud/README.md +9 -10
- package/recipes/store-crud/recipe.yaml +1 -1
- package/schemas/urlcode.schema.json +3 -0
- package/skills/urlcode/SKILL.md +1 -1
- package/starters/default/AGENTS.md +1 -1
- package/starters/default/README.md +2 -2
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
2
|
import { tmpdir } from 'node:os';
|
|
5
3
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
6
4
|
import { pathToFileURL } from 'node:url';
|
|
7
5
|
import { ConfigError, assert } from './errors.js';
|
|
8
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';
|
|
9
8
|
|
|
10
9
|
/** Verified, executable first-party bundles. Unlike extension artifacts, these are trusted operator code. */
|
|
11
10
|
export const BUNDLE_REPOSITORY='jimhoyd-com/urlcode';
|
|
@@ -17,11 +16,8 @@ const hex=/^[a-f0-9]{64}$/;
|
|
|
17
16
|
const entry=/^node_modules\/@jimhoyd\/urlcode-[a-z][a-z0-9-]{0,63}\/dist\/[A-Za-z0-9._/-]+\.js$/;
|
|
18
17
|
const safeEntry=(value ) =>entry.test(value)&&value.split('/').every(segment=>segment!=='.'&&segment!=='..');
|
|
19
18
|
const limits={archive:128*1024*1024,expanded:512*1024*1024,files:12000,file:32*1024*1024,label:'Extension bundle archive'};
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const digest=(bytes )=>createHash('sha256').update(bytes).digest('hex');
|
|
23
|
-
const text=(value ,what ) =>{assert(typeof value==='string'&&value.length>0&&value.length<256,`Invalid ${what} in extension bundle metadata`);return value;};
|
|
24
|
-
const exact=(value ,keys ,what ) =>assert(JSON.stringify(Object.keys(value).sort())===JSON.stringify([...keys].sort()),`${what} has unknown or missing fields`);
|
|
19
|
+
const text=(value ,what ) =>textField(value,`${what} in extension bundle metadata`);
|
|
20
|
+
const exact=(value ,keys ,what ) =>sharedExactKeys(value,keys,what);
|
|
25
21
|
|
|
26
22
|
|
|
27
23
|
|
|
@@ -53,17 +49,13 @@ function bundleManifest(files ) {
|
|
|
53
49
|
function validateFiles(files ,item ,coreVersion ) {
|
|
54
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');
|
|
55
51
|
}
|
|
56
|
-
async function
|
|
57
|
-
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 diskFiles(root))===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`);}
|
|
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`);}
|
|
58
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});}}
|
|
59
54
|
export function bundleCachePath(project ,sha256 ) {assert(hex.test(sha256),'Invalid extension bundle digest');return join(project,'.urlcode','extension-bundles',sha256);}
|
|
60
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};}
|
|
61
|
-
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
|
|
62
|
-
|
|
63
|
-
function
|
|
64
|
-
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,'Extension bundle release asset has an invalid redirect');url=downloadUrl(new URL(location,url).href);continue;}assert(response.ok&&response.body,'Could not download extension bundle release asset');const chunks =[];let size=0;for await(const chunk of response.body){size+=chunk.byteLength;assert(size<=limits.archive,'Extension bundle release asset exceeds the size limit');chunks.push(chunk);}const result=new Uint8Array(size);let offset=0;for(const chunk of chunks){result.set(chunk,offset);offset+=chunk.byteLength;}return result;}throw new ConfigError('Extension bundle release asset redirected too many times');}
|
|
65
|
-
export const githubBundleTransport ={async release(value){assert(tag.test(value),'Use an immutable extension bundle release tag such as extension-bundles@v1.0.0');const response=await fetch(releaseUrl(value),{headers:{accept:'application/vnd.github+json'}});assert(response.ok,`Could not fetch extension bundle release ${value}`);const raw =await response.json();assert(record(raw)&&Array.isArray(raw.assets),'Extension bundle release has no asset inventory');const seen=new Set ();return raw.assets.map(asset=>{assert(record(asset)&&typeof asset.name==='string'&&typeof asset.browser_download_url==='string'&&!seen.has(asset.name),'Invalid or duplicate extension bundle release asset');seen.add(asset.name);const url=new URL(asset.browser_download_url);assert(url.protocol==='https:'&&url.hostname==='github.com'&&url.pathname.startsWith(`/${BUNDLE_REPOSITORY}/releases/download/`),'Extension bundle release asset is not a GitHub download');return {name:asset.name,url:url.href};});},download,async attest(path,release){assert(tag.test(release),'Invalid extension bundle release tag');await new Promise ((accept,reject)=>{const child=spawn('gh',['attestation','verify',path,'--repo',BUNDLE_REPOSITORY,'--signer-workflow',BUNDLE_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 bundles')));child.on('exit',code=>code===0?accept():reject(new ConfigError('GitHub attestation verification refused the extension bundle')));});}};
|
|
66
|
-
async function verified(assets ,asset ,release ,transport ) {const found=assets.filter(value=>value.name===asset);assert(found.length===1,`Extension bundle release is missing or repeats ${asset}`);const bytes=await transport.download(found[0] .url),temporary=join(tmpdir(),`urlcode-bundle-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});}}
|
|
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');}
|
|
67
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;}
|
|
68
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;}
|
|
69
61
|
/** Explicit host-only loader. It never reads project YAML, downloads, updates, or discovers code. */
|
|
@@ -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}); } }
|
|
@@ -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))&®istrations.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'&®istration?.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/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';
|
package/dist/mcp.js
CHANGED
|
@@ -2,7 +2,7 @@ import {realpath} from 'node:fs/promises';
|
|
|
2
2
|
|
|
3
3
|
import {once} from 'node:events';
|
|
4
4
|
import {Ajv} from 'ajv';
|
|
5
|
-
import {inspectProject,validateProject,explainRoute,getCapabilities,getCapability,getSchemaFragment,previewImport,previewExport,listRecipes,showRecipe,searchRecipes,searchExamples,describeExtensions,buildContext,buildTaskContext} from './tooling.js';
|
|
5
|
+
import {inspectProject,validateProject,explainRoute,getCapabilities,getCapability,getSchemaFragment,previewImport,previewExport,listRecipes,showRecipe,searchRecipes,searchExamples,describeExtensions,buildContext,buildTaskContext,planFeature,reviewProject} from './tooling.js';
|
|
6
6
|
import {loadOperatorHost} from './operator-host.js';
|
|
7
7
|
import {buildManifest} from './manifest.js';
|
|
8
8
|
|
|
@@ -36,6 +36,8 @@ const definitions=[
|
|
|
36
36
|
{name:'get_extension_artifacts',description:'Validate and list the project\'s locked declarative extension artifacts and their allowlisted files. Artifacts are inert data and do not activate extension code.',properties:{}},
|
|
37
37
|
{name:'get_extension_artifact',description:'Read one bounded JSON or Markdown file from a verified cached declarative extension artifact. The artifact name and member path must exist in the project lock/cache.',properties:{name:{type:'string',maxLength:64},path:{type:'string',maxLength:128}},required:['name','path']},
|
|
38
38
|
{name:'get_context',description:'Emit the compact project context an authoring agent needs: versions, project summary, constraints, target support and exact commands, derived from the compiled project. Pass `task: "redirects"` for a bounded, redirect-focused call instead (supported/gap shapes, exact YAML, this project\'s redirects). Optional token budget drops sections in a fixed order.',properties:{target:text,task:{enum:['redirects']},budget:{type:'integer',minimum:1}}},
|
|
39
|
+
{name:'plan_feature',description:'Plan a bounded feature from the compiled project, current capability catalog, local recipes, locked inert artifacts and already-loaded operator registrations. Returns contracts and next calls, never generated application code, binding values, remote content or mutations.',properties:{goal:{type:'string',minLength:1,maxLength:512},target:{enum:['self-hosted','cloudflare','aws','vercel','static']}},required:['goal']},
|
|
40
|
+
{name:'review_project',description:'Opt-in, read-only static review of the project\'s own function/middleware source for avoidable plumbing: native-alternative/extension-alternative/gap/manual-review. Already-loaded operator registrations (--host-file) sharpen extension-alternative findings with registered/revision-pinned state; without a host file that state stays conservative ("declared, setup unconfirmed"). No execution, no secrets, no network.',properties:{target:text}},
|
|
39
41
|
];
|
|
40
42
|
// Only the operator's own --host-file exposes registered extension contracts; no tool argument can name one.
|
|
41
43
|
const hostDefinition={name:'get_extensions',description:'List operator-registered extension contracts, schemas, hooks, and supported project-owned customization surfaces with fast checks; use these before generating replacement framework code. Activates nothing.',properties:{}};
|
|
@@ -85,6 +87,8 @@ export async function serveMcp(options ) {
|
|
|
85
87
|
case 'get_context':return typeof args.task==='string'
|
|
86
88
|
?buildTaskContext(project,args.task,{...(typeof args.budget==='number'?{budget:args.budget}:{})})
|
|
87
89
|
:buildContext(project,{projectFlag:'.',...(typeof args.target==='string'?{target:args.target}:{}),...(typeof args.budget==='number'?{budget:args.budget}:{})});
|
|
90
|
+
case 'plan_feature':return planFeature(project,args.goal ,{...(typeof args.target==='string'?{target:args.target}:{}),extensions:host.extensions});
|
|
91
|
+
case 'review_project':return reviewProject(project,{...base,...(typeof args.target==='string'?{target:args.target}:{}),extensions:host.extensions});
|
|
88
92
|
default:if(authoring)return callAuthoringTool(project,name,args,options.origin);throw new Error('Unknown tool');
|
|
89
93
|
}
|
|
90
94
|
};
|
|
@@ -98,7 +102,7 @@ export async function serveMcp(options ) {
|
|
|
98
102
|
if(message.method==='initialize') {
|
|
99
103
|
if(initialized){await error(id,-32600,'Already initialized');return;}
|
|
100
104
|
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;}
|
|
101
|
-
initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.
|
|
105
|
+
initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.6'}}});return;
|
|
102
106
|
}
|
|
103
107
|
if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
|
|
104
108
|
if(!ready){await error(id,-32002,'Initialize first');return;}
|
package/dist/policies/agents.js
CHANGED
|
@@ -2,7 +2,7 @@ import { assert, ConfigError } from '../errors.js';
|
|
|
2
2
|
import { lists as bundled } from '../../data/agents/index.js';
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
// User-Agent policy. Contract in src/policies.ts. This module also runs inside
|
|
5
|
+
// User-Agent policy. Contract in packages/core/src/policies.ts. This module also runs inside
|
|
6
6
|
// the Cloudflare Worker, so it has no Node imports and never touches the
|
|
7
7
|
// filesystem: bundled lists arrive through the generated data/agents/index.js
|
|
8
8
|
// and project-relative list files arrive already loaded (src/agent-lists.ts
|
|
@@ -43,7 +43,7 @@ export const profiles
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
// Headers the runtime or a handler owns; `set` may not claim them. Mirrors
|
|
46
|
-
// the set in src/http-policy.ts (that file uses the Node Buffer global, so the
|
|
46
|
+
// the set in packages/core/src/http-policy.ts (that file uses the Node Buffer global, so the
|
|
47
47
|
// list is reproduced rather than imported).
|
|
48
48
|
export const reservedHeaders = Object.freeze(new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te','location','allow','content-range','accept-ranges','etag','last-modified','content-encoding','x-request-id','x-content-type-options','content-type','set-cookie','cache-control','vary','ratelimit','ratelimit-policy','retry-after','age']));
|
|
49
49
|
|
package/dist/policies.js
CHANGED
|
@@ -13,7 +13,7 @@ import { assert, ConfigError } from './errors.js';
|
|
|
13
13
|
// function/middleware execution entirely -- trusted routes and sandbox: true
|
|
14
14
|
// routes alike. Every
|
|
15
15
|
// module here follows one contract so a first-party policy and an operator
|
|
16
|
-
// plugin share a code path (PolicyModule in src/types.ts):
|
|
16
|
+
// plugin share a code path (PolicyModule in packages/core/src/types.ts):
|
|
17
17
|
//
|
|
18
18
|
// name the YAML key under `policies`
|
|
19
19
|
// phases 'request' | 'response' | both; fixed order below
|
package/dist/review.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {relative, sep} from 'node:path';
|
|
3
|
+
import {functionFile} from './config.js';
|
|
4
|
+
import {routeFunctions, MODULE_BYTE_LIMIT} from './function-sources.js';
|
|
5
|
+
import {prepare} from './tooling.js';
|
|
6
|
+
|
|
7
|
+
import {effectivePolicies} from './policies.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
// Read-only static review; see docs/TOOLING.md#project-review.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
export const reviewModuleByteLimit = MODULE_BYTE_LIMIT;
|
|
28
|
+
export const reviewExcerptLimit = 240;
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
function locate(source , at ) {
|
|
32
|
+
const start = Math.max(0, at - 40), end = Math.min(source.length, at + 200);
|
|
33
|
+
return {line: source.slice(0, at).split('\n').length, excerpt: source.slice(start, end).replace(/\s+/g, ' ').trim().slice(0, reviewExcerptLimit)};
|
|
34
|
+
}
|
|
35
|
+
const bodyHints = [/typeof\s+\w+\s*(!==|===)/, /\brequired\b/i, /\bmissing\b/i, /\binvalid\b/i, /throw\s+new\s+(Error|TypeError)/];
|
|
36
|
+
function detectBodyValidation(source ) {
|
|
37
|
+
const parse = /JSON\.parse\s*\(/.exec(source);
|
|
38
|
+
return parse && bodyHints.filter(re => re.test(source)).length >= 2 ? locate(source, parse.index) : undefined;
|
|
39
|
+
}
|
|
40
|
+
const cookieHints = [/randomUUID\s*\(/, /randomBytes\s*\(/, /\bsession\b/i, /\btoken\b/i, /expires=/i, /httponly/i];
|
|
41
|
+
function detectCookieSession(source ) {
|
|
42
|
+
const cookie = /set-cookie/i.exec(source);
|
|
43
|
+
return cookie && cookieHints.filter(re => re.test(source)).length >= 2 ? locate(source, cookie.index) : undefined;
|
|
44
|
+
}
|
|
45
|
+
function detectGlobalState(source ) {
|
|
46
|
+
const decl = /^(?:export\s+)?(?:let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:\[\s*\]|\{\s*\}|new\s+Map\s*\(\s*\)|new\s+Set\s*\(\s*\)|0)\s*;?\s*$/m.exec(source);
|
|
47
|
+
if (!decl) return undefined;
|
|
48
|
+
const name = decl[1] , mutated = new RegExp(`\\b${name}\\s*(?:\\+\\+|--|\\+=|-=|\\.push\\s*\\(|\\.set\\s*\\(|\\.add\\s*\\(|\\.delete\\s*\\(|\\[[^\\]]*\\]\\s*=)`);
|
|
49
|
+
return mutated.test(source.slice(decl.index + decl[0].length)) ? locate(source, decl.index) : undefined;
|
|
50
|
+
}
|
|
51
|
+
function detectEgress(source ) {
|
|
52
|
+
const call = /\bfetch\s*\(|\bhttps?\.request\s*\(|\bhttps?\.get\s*\(/.exec(source);
|
|
53
|
+
return call ? locate(source, call.index) : undefined;
|
|
54
|
+
}
|
|
55
|
+
const methodCompare = /\brequest\s*\.\s*method\s*(?:===|==)\s*(['"])[A-Z]+\1/g;
|
|
56
|
+
function detectMethodDispatch(source ) {
|
|
57
|
+
const compares = [...source.matchAll(methodCompare)];
|
|
58
|
+
if (compares.length >= 2) return locate(source, compares[0] .index );
|
|
59
|
+
const dispatch = /switch\s*\(\s*request\s*\.\s*method\s*\)/.exec(source);
|
|
60
|
+
if (!dispatch) return undefined;
|
|
61
|
+
const tail = source.slice(dispatch.index, dispatch.index + 2000);
|
|
62
|
+
const cases = [...tail.matchAll(/case\s+(['"])[A-Z]+\1\s*:/g)];
|
|
63
|
+
return cases.length >= 2 ? locate(source, dispatch.index) : undefined;
|
|
64
|
+
}
|
|
65
|
+
const rateLimitHints = [/\b(?:count|counts|hits|attempts|requests)\w*\s*(?:\+\+|\+=\s*1)/i, /Date\.now\s*\(\)/, /\bwindow\b/i, /\bquota\b/i, /retry-after/i, /too many requests/i];
|
|
66
|
+
function detectRateLimit(source ) {
|
|
67
|
+
const anchor = /\b429\b/.exec(source) ?? /retry-after/i.exec(source);
|
|
68
|
+
return anchor && rateLimitHints.filter(re => re.test(source)).length >= 2 ? locate(source, anchor.index) : undefined;
|
|
69
|
+
}
|
|
70
|
+
const securityHeaderNames = ['x-frame-options', 'content-security-policy', 'strict-transport-security', 'x-content-type-options', 'referrer-policy', 'permissions-policy', 'x-xss-protection'];
|
|
71
|
+
function detectSecurityHeaders(source ) {
|
|
72
|
+
const found = securityHeaderNames.filter(name => new RegExp(name, 'i').test(source));
|
|
73
|
+
if (found.length < 2) return undefined;
|
|
74
|
+
const first = new RegExp(found[0] , 'i').exec(source) ;
|
|
75
|
+
return locate(source, first.index);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const emptyCategory = () => ({'native-alternative': 0, 'extension-alternative': 0, gap: 0, 'manual-review': 0});
|
|
79
|
+
/** Whether an operator extension is actually registered (not merely declared in YAML), from the caller-supplied registrations
|
|
80
|
+
* (InspectOptions.extensions) that a --host-file/MCP host already loaded; review.ts never loads or executes a host file itself.
|
|
81
|
+
* `undefined` when the caller supplied no registrations at all, meaning registration state is genuinely unconfirmed. */
|
|
82
|
+
function extensionStatus(name , extensions , projectSha256 ) {
|
|
83
|
+
if (extensions === undefined) return undefined;
|
|
84
|
+
const registration = extensions.find(item => item.name === name);
|
|
85
|
+
return registration ? {registered: true, revisionPinned: registration.projectSha256 === projectSha256} : {registered: false};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function reviewProject(project , options = {}) {
|
|
89
|
+
const {loaded, projectSha256, routes} = await prepare(project, options);
|
|
90
|
+
const declaredExtensions = new Set(Object.keys(loaded.document.extensions ?? {}));
|
|
91
|
+
|
|
92
|
+
const modules = new Map ();
|
|
93
|
+
for (const route of routes) {
|
|
94
|
+
const declared = loaded.routes[route.pattern];
|
|
95
|
+
if (!declared) continue;
|
|
96
|
+
const hasSchema = declared.request?.body?.schema !== undefined;
|
|
97
|
+
const effective = effectivePolicies(loaded.document, declared);
|
|
98
|
+
const hasThrottle = Boolean(effective.throttle), hasSecurity = Boolean(effective.security);
|
|
99
|
+
for (const definition of routeFunctions(declared)) {
|
|
100
|
+
let absolute ;
|
|
101
|
+
try { absolute = await functionFile(loaded.root, definition.source); } catch { continue; }
|
|
102
|
+
let info = modules.get(absolute);
|
|
103
|
+
if (!info) { info = {source: '/' + relative(loaded.root, absolute).split(sep).join('/'), routes: new Set(), routesMissingSchema: new Set(), routesWithThrottle: new Set(), routesWithSecurity: new Set()}; modules.set(absolute, info); }
|
|
104
|
+
info.routes.add(route.pattern);
|
|
105
|
+
if (!hasSchema) info.routesMissingSchema.add(route.pattern);
|
|
106
|
+
if (hasThrottle) info.routesWithThrottle.add(route.pattern);
|
|
107
|
+
if (hasSecurity) info.routesWithSecurity.add(route.pattern);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const observations = [], summary = emptyCategory();
|
|
111
|
+
for (const [absolute, info] of [...modules.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
112
|
+
let source ;
|
|
113
|
+
try { const text = await readFile(absolute, 'utf8'); source = text.length > reviewModuleByteLimit ? text.slice(0, reviewModuleByteLimit) : text; } catch { continue; }
|
|
114
|
+
const routesList = [...info.routes].sort();
|
|
115
|
+
const push = (match , rest ) => {
|
|
116
|
+
const observation = {...rest, source: info.source, line: match.line, excerpt: match.excerpt};
|
|
117
|
+
observations.push(observation); summary[observation.category]++;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const bodyValidation = detectBodyValidation(source);
|
|
121
|
+
if (bodyValidation && info.routesMissingSchema.size) push(bodyValidation, {
|
|
122
|
+
category: 'native-alternative', signal: 'manual-body-validation', routes: [...info.routesMissingSchema].sort(), confidence: 'medium',
|
|
123
|
+
reason: 'JSON.parse plus hand checks; no request.body.schema.', capability: 'request.body',
|
|
124
|
+
note: 'request.body.schema validates this; see get_capability("request.body").',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const cookieSession = detectCookieSession(source);
|
|
128
|
+
if (cookieSession) {
|
|
129
|
+
const authDeclared = declaredExtensions.has('auth');
|
|
130
|
+
const authStatus = authDeclared ? extensionStatus('auth', options.extensions, projectSha256) : undefined;
|
|
131
|
+
push(cookieSession, {
|
|
132
|
+
category: authDeclared ? 'extension-alternative' : 'manual-review', signal: 'manual-cookie-session', routes: routesList, confidence: 'medium',
|
|
133
|
+
reason: 'Hand-built Set-Cookie with session values (id, token, expiry, HttpOnly).',
|
|
134
|
+
...(authDeclared ? {extension: 'auth'} : {}),
|
|
135
|
+
...(authStatus?.registered ? {registered: true, revisionPinned: authStatus.revisionPinned} : {}),
|
|
136
|
+
note: authDeclared
|
|
137
|
+
? authStatus?.registered
|
|
138
|
+
? authStatus.revisionPinned
|
|
139
|
+
? 'auth is registered and revision-pinned to this project; hand-built cookies still need a human decision.'
|
|
140
|
+
: 'auth is registered but not revision-pinned to this project\'s current revision; hand-built cookies still need a human decision.'
|
|
141
|
+
: 'auth owns sessions once registered; hand-built cookies still need a human decision.'
|
|
142
|
+
: 'No session extension declared; needs human review (rotation, invalidation).',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const globalState = detectGlobalState(source);
|
|
147
|
+
if (globalState) {
|
|
148
|
+
const storeDeclared = declaredExtensions.has('store');
|
|
149
|
+
const storeStatus = storeDeclared ? extensionStatus('store', options.extensions, projectSha256) : undefined;
|
|
150
|
+
push(globalState, {
|
|
151
|
+
category: storeDeclared ? 'extension-alternative' : 'gap', signal: 'global-mutable-state', routes: routesList, confidence: 'medium',
|
|
152
|
+
reason: 'Module-scope let/var starts empty, later mutated: local state.',
|
|
153
|
+
...(storeDeclared ? {extension: 'store'} : {}),
|
|
154
|
+
...(storeStatus?.registered ? {registered: true, revisionPinned: storeStatus.revisionPinned} : {}),
|
|
155
|
+
note: storeDeclared
|
|
156
|
+
? storeStatus?.registered
|
|
157
|
+
? storeStatus.revisionPinned
|
|
158
|
+
? 'store is registered and revision-pinned to this project; resets on restart, not shared across multiple instances.'
|
|
159
|
+
: 'store is registered but not revision-pinned to this project\'s current revision; resets on restart, not shared across multiple instances.'
|
|
160
|
+
: 'store can own this once registered; resets on restart, not shared across multiple instances.'
|
|
161
|
+
: 'Resets on restart, not shared across multiple instances; no alternative yet: a real gap.',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const egress = detectEgress(source);
|
|
166
|
+
if (egress) push(egress, {
|
|
167
|
+
category: 'manual-review', signal: 'outbound-network-call', routes: routesList, confidence: 'low',
|
|
168
|
+
reason: 'Direct outbound call (fetch/http(s).request/get) from app code.', capability: 'proxy',
|
|
169
|
+
note: 'proxy/signals centralizes egress but equivalence isn\'t verifiable; review by hand.',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const methodDispatch = detectMethodDispatch(source);
|
|
173
|
+
if (methodDispatch) push(methodDispatch, {
|
|
174
|
+
category: 'native-alternative', signal: 'method-dispatch', routes: routesList, confidence: 'medium',
|
|
175
|
+
reason: 'Hand-written request.method branching/switch dispatches per-method logic in code.', capability: 'methods',
|
|
176
|
+
note: 'Native routing already dispatches by method; declare one route per method instead of branching on request.method. See get_capability("methods").',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const rateLimit = detectRateLimit(source);
|
|
180
|
+
if (rateLimit) {
|
|
181
|
+
const declaredRoutes = [...info.routesWithThrottle].sort(), duplicate = declaredRoutes.length > 0;
|
|
182
|
+
push(rateLimit, {
|
|
183
|
+
category: duplicate ? 'manual-review' : 'native-alternative', signal: 'manual-rate-limit',
|
|
184
|
+
routes: duplicate ? declaredRoutes : routesList, confidence: 'medium', capability: 'policies.throttle',
|
|
185
|
+
reason: 'Hand-rolled request counting with a 429/Retry-After response: a rate-limit pattern.',
|
|
186
|
+
note: duplicate
|
|
187
|
+
? 'policies.throttle is already declared for these routes; hand-rolled counting duplicates the host-enforced quota and needs a human decision to remove one.'
|
|
188
|
+
: 'policies.throttle is not declared for these routes; see get_capability("policies.throttle") for quota/window enforcement without application code.',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const securityHeaders = detectSecurityHeaders(source);
|
|
193
|
+
if (securityHeaders) {
|
|
194
|
+
const declaredRoutes = [...info.routesWithSecurity].sort(), duplicate = declaredRoutes.length > 0;
|
|
195
|
+
push(securityHeaders, {
|
|
196
|
+
category: duplicate ? 'manual-review' : 'native-alternative', signal: 'manual-security-headers',
|
|
197
|
+
routes: duplicate ? declaredRoutes : routesList, confidence: 'medium', capability: 'policies.security',
|
|
198
|
+
reason: 'Hand-set security response headers (two or more of X-Frame-Options, CSP, HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy).',
|
|
199
|
+
note: duplicate
|
|
200
|
+
? 'policies.security is already declared for these routes; hand-set headers duplicate the host-enforced profile and need a human decision to remove one.'
|
|
201
|
+
: 'policies.security is not declared for these routes; see get_capability("policies.security") for header configuration without application code.',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {format: 1, projectSha256, routeCount: routes.length, moduleCount: modules.size, observations, summary};
|
|
206
|
+
}
|
package/dist/router.js
CHANGED
|
@@ -140,8 +140,21 @@ export async function compileRoutes(loaded , bindings
|
|
|
140
140
|
}
|
|
141
141
|
assert(names.every(name => route.parameters.some(p => p.in === 'path' && p.name === name)), 'Every path placeholder requires an input declaration');
|
|
142
142
|
for (const [alias, ref] of Object.entries(config.env || {})) {
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
// `env` always requires an operator grant for that route/name. A missing grant is not
|
|
144
|
+
// fatal when a `default` is declared: the binding just degrades to its literal default
|
|
145
|
+
// and never reads the host (issue #258). With no `default`, a missing grant still fails
|
|
146
|
+
// route compilation, as before. `value`-only bindings are plain literals and never
|
|
147
|
+
// consult the grant or the host environment.
|
|
148
|
+
let value ;
|
|
149
|
+
if (ref.env) {
|
|
150
|
+
const granted = permissions.projectSha256 === projectSha256 && permissions.routes?.[pattern]?.env?.includes(ref.env);
|
|
151
|
+
assert(granted || ref.default !== undefined, 'Environment binding denied by operator policy');
|
|
152
|
+
const hostValue = bindings[ref.env];
|
|
153
|
+
const hostSet = ref.default !== undefined ? typeof hostValue === 'string' && hostValue.length > 0 : hostValue !== undefined;
|
|
154
|
+
value = granted ? (hostSet ? hostValue : ref.default) : ref.default;
|
|
155
|
+
} else {
|
|
156
|
+
value = ref.value;
|
|
157
|
+
}
|
|
145
158
|
assert(typeof value === 'string', 'Missing required environment binding');
|
|
146
159
|
route.env[alias] = value;
|
|
147
160
|
}
|
|
@@ -4,7 +4,7 @@ import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises';
|
|
|
4
4
|
import {tmpdir} from 'node:os';
|
|
5
5
|
import {join} from 'node:path';
|
|
6
6
|
import {startServer} from '../server.js';
|
|
7
|
-
|
|
7
|
+
|
|
8
8
|
|
|
9
9
|
const seconds=Number(process.env.URLCODE_SOAK_SECONDS||5);
|
|
10
10
|
assert(Number.isInteger(seconds)&&seconds>=1&&seconds<=3600,'Soak must be 1–3600 seconds');
|
package/dist/tooling.js
CHANGED
|
@@ -23,7 +23,11 @@ export {getSchemaFragment,schemaPathNames} from './schema-query.js';
|
|
|
23
23
|
export {listRecipes,showRecipe,searchRecipes,listExamples,searchExamples};
|
|
24
24
|
export {buildContext,renderContext,estimateTokens,documentationTokens,buildTaskContext,renderTaskContext,contextTasks} from './context.js';
|
|
25
25
|
|
|
26
|
+
export {planFeature,featurePlanMaxBytes,featurePlanMaxGoalLength} from './feature-plan.js';
|
|
27
|
+
|
|
26
28
|
|
|
29
|
+
export {reviewProject} from './review.js';
|
|
30
|
+
|
|
27
31
|
/** `extensions` are operator registrations from a host file; explain reports whether each requirement has a provider. Nothing is activated. */
|
|
28
32
|
|
|
29
33
|
function routesOf(table ) {return [...table.exact.values(),...[...table.byLength.values()].flat(),...table.mounts];}
|
|
@@ -8,5 +8,7 @@ export interface InitOptions {
|
|
|
8
8
|
/** `default` (function, middleware, redirect) or `page`: urlcode.yaml, public/index.html, a README and fixtures only. */
|
|
9
9
|
template?: 'default' | 'page' | 'redirects' | undefined;
|
|
10
10
|
}
|
|
11
|
+
/** Adds what the starter needs to an existing package.json and changes nothing else; a conflicting script is refused, never overwritten. */
|
|
12
|
+
export declare function mergePackageJson(text: string, scripts: Record<string, string>, dependency: string, version: string): string;
|
|
11
13
|
export declare function initProject(destination: string, { manifest, template }?: InitOptions): Promise<string>;
|
|
12
14
|
export declare function addRedirect(project: string, destination: string, alias?: string | undefined): Promise<string>;
|
package/dist/types/explain.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReleaseAsset } from './extension-transport.ts';
|
|
1
2
|
/** Offline, declarative extension bundles. These are deliberately not Node packages. */
|
|
2
3
|
export declare const ARTIFACT_REPOSITORY = "jimhoyd-com/urlcode";
|
|
3
4
|
export declare const ARTIFACT_WORKFLOW = "jimhoyd-com/urlcode/.github/workflows/extension-artifacts.yml";
|
|
@@ -47,10 +48,7 @@ export declare function extractArtifact(bytes: Uint8Array, entry: ArtifactEntry,
|
|
|
47
48
|
export declare function readLock(project: string): Promise<ExtensionLock>;
|
|
48
49
|
export declare function writeLock(project: string, lock: ExtensionLock): Promise<void>;
|
|
49
50
|
export declare function cachePath(project: string, sha256: string): string;
|
|
50
|
-
export
|
|
51
|
-
name: string;
|
|
52
|
-
url: string;
|
|
53
|
-
}
|
|
51
|
+
export type { ReleaseAsset };
|
|
54
52
|
export interface ArtifactTransport {
|
|
55
53
|
release(tag: string): Promise<ReleaseAsset[]>;
|
|
56
54
|
download(url: string): Promise<Uint8Array>;
|