@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.
- package/README.md +18 -18
- package/dist/BUILD-MANIFEST.json +27 -23
- package/dist/agents-guide.js +8 -4
- package/dist/authoring.js +81 -7
- package/dist/build-cloudflare.js +1 -0
- package/dist/build-static.js +1 -0
- package/dist/cli.js +39 -14
- package/dist/config.js +7 -1
- package/dist/context.js +32 -4
- 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 +28 -34
- package/dist/extension-bundles.js +62 -0
- package/dist/extension-transport.js +41 -0
- package/dist/extensions.js +4 -0
- package/dist/feature-plan.js +99 -0
- package/dist/functions.js +2 -1
- package/dist/index.js +4 -2
- package/dist/init-with.js +55 -23
- package/dist/interchange.js +1 -1
- package/dist/match.js +23 -5
- package/dist/mcp.js +6 -2
- package/dist/readiness.js +2 -2
- package/dist/review.js +206 -0
- package/dist/router.js +35 -10
- package/dist/runtime.js +1 -0
- package/dist/tooling.js +4 -0
- package/dist/types/agents-guide.d.ts +6 -1
- package/dist/types/authoring.d.ts +3 -1
- package/dist/types/context.d.ts +12 -0
- package/dist/types/explain.d.ts +3 -0
- package/dist/types/extension-artifacts.d.ts +15 -4
- package/dist/types/extension-bundles.d.ts +49 -0
- package/dist/types/extension-transport.d.ts +31 -0
- package/dist/types/extensions.d.ts +4 -0
- package/dist/types/feature-plan.d.ts +67 -0
- package/dist/types/functions.d.ts +4 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/init-with.d.ts +6 -1
- package/dist/types/match.d.ts +1 -0
- package/dist/types/review.d.ts +30 -0
- package/dist/types/tooling.d.ts +4 -0
- package/dist/types/types.d.ts +10 -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/llms-full.txt +190 -83
- package/llms.txt +73 -128
- package/package.json +15 -4
- package/recipes/redirect/README.md +2 -2
- package/recipes/store-crud/README.md +9 -10
- package/recipes/store-crud/recipe.yaml +1 -1
- package/schemas/urlcode.schema.json +3 -0
- 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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/interchange.js
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
123
|
-
|
|
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.
|
|
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
|
+
}
|