@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/init-with.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, open, readFile, rm, unlink, lstat } from 'node:fs/promises';
1
+ import { mkdir, mkdtemp, open, readFile, rename, rm, unlink, lstat } from 'node:fs/promises';
2
2
  import { createRequire } from 'node:module';
3
3
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
@@ -11,6 +11,7 @@ import { inspectExtensionRevision } from './extensions.js';
11
11
  import { collectDependencySet, installSteps, renderPackageManifest } from './project-dependencies.js';
12
12
 
13
13
  import { ConfigError, assert } from './errors.js';
14
+ import { installBundle, loadExtensionBundle, } from './extension-bundles.js';
14
15
 
15
16
  /** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
16
17
  const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
@@ -24,6 +25,10 @@ const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9
24
25
 
25
26
 
26
27
 
28
+
29
+
30
+
31
+
27
32
 
28
33
 
29
34
 
@@ -79,15 +84,19 @@ export function orderScaffolds(results )
79
84
  * conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
80
85
  * at build time. Refuses a missing package or a package without `scaffold` before anything is written.
81
86
  */
82
- async function loadScaffold(name , request , cwd , retry ) {
87
+ async function loadScaffold(name , request , cwd , retry , bundleProject ) {
83
88
  const pkg = packageName(name);
84
- let entry ;
85
- try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
86
- catch (error) {
87
- if (isCode(error, 'MODULE_NOT_FOUND')) throw new ConfigError(`Extension package ${pkg} is not installed in ${cwd}; run: npm install ${pkg}`);
88
- throw error;
89
+ let module ;
90
+ if (bundleProject) module = await loadExtensionBundle(bundleProject, name);
91
+ else {
92
+ let entry ;
93
+ try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
94
+ catch (error) {
95
+ if (isCode(error, 'MODULE_NOT_FOUND')) throw new ConfigError(`Extension package ${pkg} is not installed in ${cwd}; run: npm install ${pkg}`);
96
+ throw error;
97
+ }
98
+ module = await import(pathToFileURL(entry).href) ;
89
99
  }
90
- const module = await import(pathToFileURL(entry).href) ;
91
100
  const scaffold = module.scaffold;
92
101
  if (typeof scaffold !== 'function') throw new ConfigError(`${pkg} does not export scaffold; upgrade it to a release that supports urlcode init --with, or add ${name} by hand following its README`);
93
102
  let result ;
@@ -102,6 +111,8 @@ async function loadScaffold(name , request , cwd ,
102
111
  assert(record(result) && result.name === name, `${pkg} scaffold must return a result named ${name}`);
103
112
  assert(record(result.extensions) && record(result.routes), `${pkg} scaffold must return extensions and routes objects`);
104
113
  assert(strings(result.hostImports) && strings(result.hostSetup) && strings(result.hostEntries) && (result.hostClose === undefined || strings(result.hostClose)), `${pkg} scaffold must return host fragments as string arrays`);
114
+ assert(result.hostBundleExports === undefined || (strings(result.hostBundleExports) && result.hostBundleExports.every(value => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value))), `${pkg} scaffold hostBundleExports must be JavaScript identifiers`);
115
+ if (request.distribution === 'bundle') assert(Array.isArray(result.hostBundleExports) && result.hostBundleExports.length > 0, `${pkg} scaffold must declare hostBundleExports for executable bundle distribution`);
105
116
  assert(result.acknowledged === undefined || (strings(result.acknowledged) && result.acknowledged.every(id => request.acknowledgements.includes(id) && id.startsWith(`${name}:`))), `${pkg} scaffold acknowledged may only list ${name}:<id> acknowledgements the operator passed`);
106
117
  assert(result.routeNotes === undefined || (strings(result.routeNotes) && result.routeNotes.every(note => note.length <= 300 && !/[\r\n]/.test(note))), `${pkg} scaffold routeNotes must be single-line strings`);
107
118
  assert(strings(result.nextSteps) && typeof result.readme === 'string', `${pkg} scaffold must return readme text and nextSteps strings`);
@@ -124,8 +135,17 @@ async function write(target , content , mode = 0o644)
124
135
  const file = await open(target, 'wx', mode);
125
136
  try { await file.writeFile(content); await file.sync(); } finally { await file.close(); }
126
137
  }
127
- function renderHost(names , results ) {
138
+ function renderHost(names , results , distribution ='npm') {
128
139
  const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
140
+ if (distribution === 'bundle') {
141
+ lines.push("import {loadExtensionBundle} from '@jimhoyd/urlcode/extension-bundles';", "import {fileURLToPath} from 'node:url';", "const extensionBundleDirectory = fileURLToPath(new URL('.', import.meta.url));");
142
+ const exports = new Set ();
143
+ for (const result of results) {
144
+ const names = result.hostBundleExports ;
145
+ for (const name of names) { assert(!exports.has(name), `Bundle host export ${name} is declared by more than one extension`); exports.add(name); }
146
+ lines.push(`const {${names.join(', ')}} = await loadExtensionBundle(extensionBundleDirectory, '${result.name}');`);
147
+ }
148
+ }
129
149
  // Extensions that need the same module (node:url, for example) each list it; an identical line is written once so the host stays valid ESM.
130
150
  for (const result of results) for (const line of result.hostImports) if (!lines.includes(line)) lines.push(line);
131
151
  lines.push('');
@@ -142,23 +162,23 @@ function demote(markdown ) {
142
162
  let fence = false;
143
163
  return markdown.split('\n').map(line => { if (/^\s*(?:```|~~~)/.test(line)) fence = !fence; return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; }).join('\n');
144
164
  }
145
- function renderDependencySection(directory , set ) {
165
+ function renderDependencySection(directory , set , bundle = false) {
146
166
  const rows = set.pins.map(pin => `- \`${pin.name}\` ${pin.version} (${pin.role})${pin.specifier === pin.version ? '' : ` installed from \`${pin.specifier}\``}`);
147
167
  const lines = ['## Dependencies', '',
148
- '`package.json` pins the runtime, every extension named in `--with` and their declared peers to the exact versions that were installed when this site was generated. Those versions were checked against each package\'s own `peerDependencies` as one set.', '',
168
+ bundle ? '`package.json` pins only the URLCode runtime. Executable extensions are locked GitHub Release bundles in `urlcode.extension-bundles.lock.json`, not npm dependencies.' : '`package.json` pins the runtime, every extension named in `--with` and their declared peers to the exact versions that were installed when this site was generated. Those versions were checked against each package\'s own `peerDependencies` as one set.', '',
149
169
  ...rows, '',
150
170
  ...installSteps(directory, set).flatMap(step => [step, '']),
151
171
  set.local ? 'At least one pin is a local path or tarball rather than a registry version: reproducing this install needs that path to exist, so keep it under your control or replace the specifier before publishing the site.' : 'The pins are registry versions; `npm install` resolves them without the network only if your cache or mirror already holds them.', '',
152
172
  'There is no upgrade command. Changing a pinned version today means editing `package.json` yourself and re-running `npm install`; review the extension changelogs first.', ''];
153
173
  return lines.join('\n');
154
174
  }
155
- function renderReadme(directory , names , results , starter , env , projectSha256 , set ) {
175
+ function renderReadme(directory , names , results , starter , env , projectSha256 , set , distribution ='npm') {
156
176
  const steps = [...(set ? installSteps(directory, set) : []), ...results.flatMap(result => result.nextSteps)];
157
177
  const parts = [`# ${basename(directory)}`, '',
158
- `Created with \`urlcode init ${basename(directory)} --with ${names.join(',')}\`. \`${PROJECT_DIRECTORY}/\` is the route project (\`urlcode.yaml\`, functions, tests); \`${HOST_FILE}\` is the trusted operator host that wires the installed extension packages; operator modules and private data stay outside the project. Run every command with \`--project ${PROJECT_DIRECTORY} --host-file "$PWD/${HOST_FILE}"\`.`, '',
178
+ `Created with \`urlcode init ${basename(directory)} --with ${names.join(',')}\`. \`${PROJECT_DIRECTORY}/\` is the route project (\`urlcode.yaml\`, functions, tests); \`${HOST_FILE}\` is the trusted operator host that wires ${distribution === 'bundle' ? 'the explicitly installed, verified extension bundles' : 'the installed extension packages'}; operator modules and private data stay outside the project. Run every command with \`--project ${PROJECT_DIRECTORY} --host-file "$PWD/${HOST_FILE}"\`.`, '',
159
179
  '## Starter', '', `The starter files live in \`${PROJECT_DIRECTORY}/\`; add \`--project ${PROJECT_DIRECTORY}\` and the host file to the commands below.`, '', demote(starter).trim(), ''];
160
180
  for (const result of results) parts.push(`## Extension: ${result.name}`, '', result.readme.trim(), '');
161
- if (set) parts.push(renderDependencySection(directory, set));
181
+ if (set) parts.push(renderDependencySection(directory, set, distribution === 'bundle'));
162
182
  parts.push('## Next steps', '', ...steps.map((step, index) => `${index + 1}. ${step}`), '');
163
183
  if (Object.keys(env).length) parts.push('## Environment', '', ...Object.entries(env).map(([key, text]) => `- \`${key}\`: ${text}`), '');
164
184
  parts.push('## Project revision', '', `\`${PROJECT_DIRECTORY}/urlcode.yaml\` currently has revision \`${projectSha256}\` (\`inspectExtensionRevision\`). Review the project, then pin exactly that value where the host expects it; any change to extension YAML, policies or mounts changes it and needs a new explicit review.`, '');
@@ -170,7 +190,7 @@ function renderReadme(directory , names , results
170
190
  * `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
171
191
  * their scaffolds computed before anything is written, so a refusal leaves no directory behind.
172
192
  */
173
- export async function initProjectWith(destination , requested , { cwd = process.cwd(), manifest = true, pins, acknowledgements = [] } = {}) {
193
+ export async function initProjectWith(destination , requested , { cwd = process.cwd(), manifest = true, pins, acknowledgements = [], bundleRelease, bundleTransport } = {}) {
174
194
  assert(requested.length > 0, 'Provide at least one --with name');
175
195
  assert(new Set(requested).size === requested.length, 'Duplicate --with names');
176
196
  // --with is an unordered set: scaffolds see one canonical name order, and the emitted order comes from their declared requirements.
@@ -178,13 +198,20 @@ export async function initProjectWith(destination , requested
178
198
  const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
179
199
  assert(acknowledgements.every(id => acknowledgementPattern.test(id)), 'Use --ack <extension>:<id>, for example --ack store:public-write');
180
200
  const acked = [...new Set(acknowledgements)].sort();
181
- const request = { directory, project, hostFile, names: sorted, acknowledgements: acked };
201
+ const distribution =bundleRelease===undefined?'npm':'bundle';
202
+ const request = { directory, project, hostFile, names: sorted, acknowledgements: acked, distribution };
182
203
  const quote = (value ) => /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
183
- const retry = (id ) => ['urlcode init', quote(destination), '--with', requested.join(','), ...(manifest ? [] : ['--no-manifest']), ...[...(pins ?? [])].flatMap(([pkg, specifier]) => ['--pin', quote(`${pkg}=${specifier}`)]), ...[...acked, id].sort().flatMap(item => ['--ack', item])].join(' ');
204
+ const retry = (id ) => ['urlcode init', quote(destination), '--with', requested.join(','), ...(bundleRelease===undefined?[]:['--bundle-release',bundleRelease]), ...(manifest ? [] : ['--no-manifest']), ...[...(pins ?? [])].flatMap(([pkg, specifier]) => ['--pin', quote(`${pkg}=${specifier}`)]), ...[...acked, id].sort().flatMap(item => ['--ack', item])].join(' ');
184
205
  const results = [];
206
+ let bundleRoot ;
185
207
  const wipe = () => { for (const result of results) for (const file of result.files) if (file.content instanceof Uint8Array) file.content.fill(0); };
186
208
  try {
187
- for (const name of sorted) results.push(await loadScaffold(name, request, cwd, retry));
209
+ if (bundleRelease) {
210
+ await mkdir(dirname(directory), { recursive: true });
211
+ bundleRoot=await mkdtemp(join(dirname(directory),'.urlcode-bundle-init-'));
212
+ for (const name of sorted) await installBundle(bundleRoot,bundleRelease,name,bundleTransport);
213
+ }
214
+ for (const name of sorted) results.push(await loadScaffold(name, request, cwd, retry, bundleRoot));
188
215
  const consumed = new Set(results.flatMap(result => result.acknowledged ?? []));
189
216
  const unused = acked.filter(id => !consumed.has(id));
190
217
  assert(unused.length === 0, `--ack ${unused.join(', ')} has no effect here: no scaffold in --with (${sorted.join(', ')}) consumed it. Remove it, or check the extension name and id in that extension's documentation`);
@@ -202,11 +229,16 @@ export async function initProjectWith(destination , requested
202
229
  }
203
230
  // Also resolved before the destination exists: an incompatible or incompletely installed set refuses with
204
231
  // nothing written. It runs after the scaffold conflicts so a composition error is still reported as one.
205
- const dependencies = manifest ? await collectDependencySet(names, names.map(packageName), { cwd, ...(pins === undefined ? {} : { overrides: pins }) }) : undefined;
232
+ const dependencies = manifest ? await collectDependencySet(bundleRelease===undefined?names:[], bundleRelease===undefined?names.map(packageName):[], { cwd, ...(pins === undefined ? {} : { overrides: pins }) }) : undefined;
206
233
  await mkdir(dirname(directory), { recursive: true });
207
234
  await mkdir(directory, { mode: 0o700 }); // refuses an existing destination
208
235
  try {
209
236
  await initProject(project);
237
+ if (bundleRoot) {
238
+ await rename(join(bundleRoot,'.urlcode'),join(directory,'.urlcode'));
239
+ await rename(join(bundleRoot,'urlcode.extension-bundles.lock.json'),join(directory,'urlcode.extension-bundles.lock.json'));
240
+ await rm(bundleRoot,{recursive:true,force:true}); bundleRoot=undefined;
241
+ }
210
242
  const starter = await readFile(join(project, 'README.md'), 'utf8');
211
243
  await unlink(join(project, 'README.md')); // its content moves into the site README
212
244
  await unlink(join(project, mcpConfigFile)); // re-registered at the site root, pointing at app/
@@ -236,17 +268,17 @@ export async function initProjectWith(destination , requested
236
268
  while (probe !== directory && probe.startsWith(directory)) { try { assert(!(await lstat(probe)).isSymbolicLink(), `Scaffold path passes through a symlink: ${file.path}`); } catch (error) { if (!isCode(error, 'ENOENT')) throw error; } probe = dirname(probe); }
237
269
  await write(target, file.content, file.mode ?? 0o644); written.add(target);
238
270
  }
239
- await write(hostFile, renderHost(names, results), 0o600);
271
+ await write(hostFile, renderHost(names, results, distribution), 0o600);
240
272
  if (dependencies) await write(join(directory, 'package.json'), renderPackageManifest(directory, dependencies));
241
- await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256, dependencies));
273
+ await write(join(directory, 'README.md'), renderReadme(directory, names, results, starter, env, projectSha256, dependencies, distribution));
242
274
  await write(join(directory, '.gitignore'), 'node_modules/\ndata/\n.env\n.env.*\n');
243
275
  // The read-only MCP server for agents opened at the site root; --host-file and --allow-authoring stay operator choices.
244
- await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY));
276
+ await write(join(directory, mcpConfigFile), renderMcpConfig(PROJECT_DIRECTORY, { local: dependencies !== undefined }));
245
277
  // AGENTS.md: initProject writes the application-level file into app/ once it produces one (NEXT-STEPS 1.1);
246
278
  // nothing here overrides it. A site-level agent note would be assembled beside README.md at this point.
247
279
  return { directory, project, hostFile, extensions: [...names], projectSha256,
248
280
  nextSteps: [...(dependencies ? installSteps(directory, dependencies) : []), ...results.flatMap(result => result.nextSteps)],
249
281
  dependencies: dependencies?.pins ?? [] };
250
282
  } catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
251
- } finally { wipe(); }
283
+ } finally { if(bundleRoot)await rm(bundleRoot,{recursive:true,force:true}); wipe(); }
252
284
  }
@@ -166,7 +166,7 @@ export async function exportRoutes(options )
166
166
  keys(record(options.document),['version','routes']);validateDocument(options.document);
167
167
  if(Object.keys(options.document.routes).length>100000)throw new Error('Input exceeds 100,000 routes');
168
168
  for(const [path,config]of Object.entries(options.document.routes)) {
169
- try{keys(record(config),['redirect']);if(!config.redirect)throw new Error('Only redirects can be exported');keys(record(config.redirect),['url','status']);rows.push(row({path,url:config.redirect.url,status:config.redirect.status??302},rows.length+1));}
169
+ try{keys(record(config),['redirect']);if(!config.redirect)throw new Error('Only redirects can be exported');if(path.includes('*')||config.redirect.url.startsWith('/'))throw new Error('Suffix wildcards and root-relative destinations have no provider equivalent');keys(record(config.redirect),['url','status']);rows.push(row({path,url:config.redirect.url,status:config.redirect.status??302},rows.length+1));}
170
170
  catch{diagnostics.push({severity:'error',code:'runtime-required',source,path,message:'Only literal redirects with default methods and no extra behavior can be exported'});}
171
171
  }
172
172
  rows.sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
package/dist/match.js CHANGED
@@ -19,7 +19,7 @@ import { HttpError } from './errors.js';
19
19
 
20
20
  /** The part of a compiled route that request-time matching reads. router.ts widens it. */
21
21
 
22
-
22
+
23
23
 
24
24
 
25
25
 
@@ -52,6 +52,8 @@ export function parseTarget(target ) {
52
52
  if (parts.length > 32 || parts.some(p => p === '.' || p === '..')) throw new HttpError(400, 'Invalid path');
53
53
  return { path, parts, query: new URLSearchParams(query) };
54
54
  }
55
+ /** Longest suffix a `/**` redirect captures; a longer one is simply not matched. */
56
+ const wildcardMaxLength = 1024;
55
57
  export function matchRoute (compiled , target ) {
56
58
  const exact = compiled.exact.get(target.path);
57
59
  if (exact) return { route: exact, path: dict() };
@@ -64,7 +66,15 @@ export function matchRoute (compiled
64
66
  return p === actual;
65
67
  })) return { route, path };
66
68
  }
67
- for (const route of compiled.mounts) if (route.prefix !== undefined && (target.path.startsWith(route.prefix)||(route.extension&&target.path===route.prefix.slice(0,-1)))) return { route, path: dict() };
69
+ for (const route of compiled.mounts) {
70
+ if (route.wildcard) {
71
+ // `/prefix/**`: one or more whole segments, no empty segment (so a redirect can never gain a `//`), bounded length.
72
+ const rest = route.prefix !== undefined && target.path.startsWith(route.prefix) ? target.path.slice(route.prefix.length) : '';
73
+ if (rest && rest.length <= wildcardMaxLength && !rest.split('/').includes('')) { const path = dict (); path['**'] = rest; return { route, path }; }
74
+ continue;
75
+ }
76
+ if (route.prefix !== undefined && (target.path.startsWith(route.prefix)||(route.extension&&target.path===route.prefix.slice(0,-1)))) return { route, path: dict() };
77
+ }
68
78
  return null;
69
79
  }
70
80
  function scalar(value , type ) {
@@ -102,14 +112,21 @@ export function contextFor(route , path ,
102
112
  if (!p.validate(value)) throw new HttpError(400, 'Invalid parameter');
103
113
  (inputs[p.in] )[p.name] = value;
104
114
  }
115
+ if (route.wildcard) inputs.path['**'] = path['**'] ?? '';
105
116
  return { inputs, env: route.env, secrets: route.secrets };
106
117
  }
107
118
  // The router accepts a placeholder only for a declared path input, and a path
108
119
  // input always matches a segment, so the '' fallback is unreachable through a
109
120
  // compiled route; it keeps a direct call with an undeclared name from writing
110
121
  // the text "undefined" into the location.
122
+ // A root-relative destination (`/profiles/{id}`) is resolved against this placeholder origin only to normalize and encode it;
123
+ // the origin never appears in the Location.
124
+ const relativeBase = 'https://relative.invalid';
111
125
  export function redirectLocation(route , context , query ) {
112
- const location = new URL(route.redirect.url.replace(/\{([^}]+)\}/g, (_m, name ) => encodeURIComponent(context.inputs.path[name] ?? '')));
126
+ const relative = route.redirect.url.startsWith('/');
127
+ // `{**}` is the captured suffix: each segment encoded on its own, joined by the `/` that separated them.
128
+ const encode = (name ) => name === '**' ? (context.inputs.path['**'] ?? '').split('/').map(encodeURIComponent).join('/') : encodeURIComponent(context.inputs.path[name] ?? '');
129
+ const location = new URL(route.redirect.url.replace(/\{([^}]+)\}/g, (_m, name ) => encode(name)), relativeBase);
113
130
  function append(key , value ) {
114
131
  if (value === undefined) return;
115
132
  for (const item of Array.isArray(value) ? value : [value]) location.searchParams.append(key, String(item));
@@ -119,6 +136,7 @@ export function redirectLocation(route
119
136
  if (own(context.inputs.query, key)) append(key, context.inputs.query[key]);
120
137
  else for (const value of query.getAll(key)) append(key,value);
121
138
  }
122
- if (location.href.length > 16384) throw new HttpError(400, 'Redirect URL too long');
123
- return location.href;
139
+ const result = relative ? location.pathname + location.search + location.hash : location.href;
140
+ if (result.length > 16384) throw new HttpError(400, 'Redirect URL too long');
141
+ return result;
124
142
  }
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.4.8'}}});return;
105
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.5'}}});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/readiness.js CHANGED
@@ -119,11 +119,11 @@ export function projectPlan(compiled )
119
119
  if (route.extension || route.extensionPolicyNames?.length || route.proxy || route.signals?.length || route.match || route.conditional || route.function || route.middleware?.length || route.names.length) continue;
120
120
  // Required inputs need intentional fixtures; never invent business data.
121
121
  let context ;
122
- try { context = contextFor(route,{},new URLSearchParams(),new Headers()); } catch { continue; }
122
+ try { context = contextFor(route,route.wildcard ? {'**':'sample'} : {},new URLSearchParams(),new Headers()); } catch { continue; }
123
123
  if (route.request?.body?.required) continue;
124
124
  const files = route.asset instanceof Map ? route.asset : undefined;
125
125
  const prefix = route.prefix ?? '';
126
- const paths = route.static && files ? [...files.keys()].map(key => prefix + key.split('/').map(encodeURIComponent).join('/')) : [route.pattern];
126
+ const paths = route.wildcard ? [prefix + 'sample'] : route.static && files ? [...files.keys()].map(key => prefix + key.split('/').map(encodeURIComponent).join('/')) : [route.pattern];
127
127
  for (const path of paths) for (const method of route.methods) {
128
128
  if (!['GET','HEAD'].includes(method)) continue;
129
129
  const test = {path,method,status:(route.redirect ? route.redirect.status || 302 : route.reply?.status || 200)};
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
+ }