@jimhoyd/urlcode 0.5.6 → 0.5.9
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 +15 -0
- package/.claude/skills/urlcode-operations/SKILL.md +4 -0
- package/README.md +24 -14
- package/dist/BUILD-MANIFEST.json +51 -50
- package/dist/agent-context.js +9 -0
- package/dist/agents-guide.js +5 -2
- package/dist/assets.js +2 -2
- package/dist/authoring.js +3 -58
- package/dist/aws.js +21 -4
- package/dist/body-schema.js +2 -3
- package/dist/capability-query.js +3 -3
- package/dist/catalog.js +1 -1
- package/dist/cli.js +42 -15
- package/dist/cloudflare.js +26 -1
- package/dist/config.js +1 -1
- package/dist/context.js +6 -6
- package/dist/egress.js +1 -1
- package/dist/examples.js +2 -2
- package/dist/explain.js +4 -6
- package/dist/extension-artifacts.js +8 -8
- package/dist/extension-transport.js +6 -4
- package/dist/function-worker.js +5 -2
- package/dist/functions.js +1 -1
- package/dist/guest-api.js +22 -15
- package/dist/http-policy.js +1 -3
- package/dist/http-response.js +61 -10
- package/dist/init-with.js +8 -8
- package/dist/interchange.js +3 -2
- package/dist/logging.js +4 -4
- package/dist/match.js +1 -1
- package/dist/mcp-authoring.js +1 -2
- package/dist/mcp.js +6 -3
- package/dist/object-guards.js +17 -0
- package/dist/pattern-guard.js +33 -3
- package/dist/policies/cache.js +16 -4
- package/dist/policies/compression.js +1 -1
- package/dist/policies/throttle.js +1 -1
- package/dist/policy.js +3 -3
- package/dist/prerender.js +1 -2
- package/dist/project-dependencies.js +8 -8
- package/dist/proxy.js +1 -1
- package/dist/readiness.js +16 -17
- package/dist/review.js +2 -2
- package/dist/route-diff.js +4 -4
- package/dist/router.js +1 -1
- package/dist/runtime.js +4 -1
- package/dist/scaffold.js +1 -1
- package/dist/schema-query.js +1 -1
- package/dist/server.js +49 -7
- package/dist/signals.js +2 -2
- package/dist/site.js +2 -2
- package/dist/trusted-functions.js +1 -1
- package/dist/types/assets.d.ts +3 -2
- package/dist/types/authoring.d.ts +3 -6
- package/dist/types/body-schema.d.ts +2 -1
- package/dist/types/catalog.d.ts +2 -1
- package/dist/types/config.d.ts +0 -8
- package/dist/types/context.d.ts +3 -1
- package/dist/types/egress.d.ts +1 -1
- package/dist/types/explain.d.ts +4 -4
- package/dist/types/extension-artifacts.d.ts +4 -13
- package/dist/types/extension-transport.d.ts +6 -4
- package/dist/types/functions.d.ts +1 -1
- package/dist/types/http-response.d.ts +7 -3
- package/dist/types/init-with.d.ts +3 -9
- package/dist/types/logging.d.ts +4 -3
- package/dist/types/match.d.ts +2 -1
- package/dist/types/object-guards.d.ts +7 -0
- package/dist/types/pattern-guard.d.ts +0 -1
- package/dist/types/policies/cache.d.ts +4 -3
- package/dist/types/policies/compression.d.ts +1 -1
- package/dist/types/policies/throttle.d.ts +1 -1
- package/dist/types/policy.d.ts +4 -3
- package/dist/types/project-dependencies.d.ts +1 -40
- package/dist/types/proxy.d.ts +2 -1
- package/dist/types/readiness.d.ts +12 -16
- package/dist/types/review.d.ts +0 -2
- package/dist/types/route-diff.d.ts +4 -3
- package/dist/types/router.d.ts +1 -1
- package/dist/types/server.d.ts +22 -0
- package/dist/types/signals.d.ts +3 -2
- package/dist/types/site.d.ts +1 -2
- package/dist/types/trusted-functions.d.ts +1 -1
- package/dist/types/types.d.ts +17 -11
- package/dist/types/vercel.d.ts +1 -0
- package/dist/types/verify-deployment.d.ts +1 -1
- package/dist/types.js +18 -11
- package/dist/vercel.js +12 -3
- package/dist/verify-deployment.js +29 -11
- package/docs/AI-AUTHORING.md +9 -0
- package/docs/FUNCTION-SECURITY.md +4 -0
- package/docs/README.md +2 -2
- package/docs/TOOLING.md +35 -1
- package/examples/assets/Makefile +1 -1
- package/examples/compliance/rules.mjs +2 -2
- package/llms-full.txt +44 -13
- package/llms.txt +9 -2
- package/package.json +8 -3
- package/skills/urlcode/SKILL.md +6 -6
- package/starters/default/.github/workflows/urlcode.yml +2 -1
- package/starters/default/AGENTS.md +3 -3
- package/starters/default/Makefile +1 -1
- package/starters/default/README.md +13 -11
- package/starters/default/tests/requests.json +0 -50
- package/starters/default/urlcode.yaml +1 -4
- package/dist/scripts/operational-drills.js +0 -33
- package/starters/default/functions/hello.mjs +0 -3
- package/starters/default/middleware/headers.mjs +0 -6
- package/starters/default/routes/functions.yaml +0 -20
- package/starters/default/routes/marketing/links.yaml +0 -7
- package/starters/page/README.md +0 -14
- package/starters/page/public/index.html +0 -12
- package/starters/page/tests/requests.json +0 -17
- package/starters/page/urlcode.yaml +0 -6
package/dist/http-response.js
CHANGED
|
@@ -5,18 +5,27 @@ import { HttpError } from './errors.js';
|
|
|
5
5
|
|
|
6
6
|
/** What a handler, policy or asset produced; every host turns it into a wire response here. */
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
11
|
/** The node:http ServerResponse surface this module writes to, kept structural so the module stays Node-free. */
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
// Hop-by-hop and runtime-owned headers a handler must never set on the wire.
|
|
19
20
|
const forbiddenHeaders = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te']);
|
|
21
|
+
// Unlike Cache-Control (a real default a handler may explicitly override),
|
|
22
|
+
// these two are always the runtime's own value, appended once below. A
|
|
23
|
+
// handler-set copy of either (defense-in-depth code setting nosniff itself,
|
|
24
|
+
// say) is dropped here rather than kept alongside the runtime's: now that
|
|
25
|
+
// repeated headers are sent as separate wire lines instead of silently
|
|
26
|
+
// collapsing to the last `setHeader` call, keeping both would put two
|
|
27
|
+
// X-Content-Type-Options or X-Request-Id lines on the wire instead of one.
|
|
28
|
+
const runtimeOwnedHeaders = new Set(['x-content-type-options','x-request-id']);
|
|
20
29
|
|
|
21
30
|
// One place decides what a URLCode response *is*, independent of how a host
|
|
22
31
|
// delivers it. A Node server writes it to a socket; a Lambda returns it as
|
|
@@ -27,7 +36,7 @@ export function prepareResponse(result , { requestId, method }
|
|
|
27
36
|
if (!Number.isInteger(status) || status < 200 || status > 599) throw new HttpError(502, 'Invalid function response');
|
|
28
37
|
const headers = [], cookies = [];
|
|
29
38
|
for (const [key,value] of result.headers) {
|
|
30
|
-
if (forbiddenHeaders.has(key.toLowerCase())) continue;
|
|
39
|
+
if (forbiddenHeaders.has(key.toLowerCase()) || runtimeOwnedHeaders.has(key.toLowerCase())) continue;
|
|
31
40
|
validateHeaderName(key); validateHeaderValue(key,value);
|
|
32
41
|
if (key.toLowerCase() === 'set-cookie') cookies.push(value);
|
|
33
42
|
else headers.push([key,value]);
|
|
@@ -39,16 +48,57 @@ export function prepareResponse(result , { requestId, method }
|
|
|
39
48
|
// State the length rather than leaving a host to infer it. A Node server
|
|
40
49
|
// computes this itself, but a host that returns JSON does not, so the policy
|
|
41
50
|
// has to say it for every host to agree.
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
51
|
+
// A response that carries its body is framed by the bytes it carries, never
|
|
52
|
+
// by a stated length. Only HEAD may state one, because it sends no body and
|
|
53
|
+
// reports the length GET would send (RFC 9110 §8.6); without a stated
|
|
54
|
+
// length it is measured on the result's body before the body is dropped.
|
|
55
|
+
if (!bodyless) headers.push(['content-length', String(method === 'HEAD' ? headLength(result) : byteLength(result.body))]);
|
|
45
56
|
headers.push(['x-request-id',requestId],['x-content-type-options','nosniff']);
|
|
46
57
|
if (!headers.some(([key]) => key.toLowerCase() === 'cache-control')) headers.push(['cache-control','no-store']);
|
|
47
58
|
return { status, headers, cookies, body };
|
|
48
59
|
}
|
|
60
|
+
function headLength(result ) {
|
|
61
|
+
if (result.contentLength === undefined) return byteLength(result.body);
|
|
62
|
+
if (!Number.isSafeInteger(result.contentLength) || result.contentLength < 0) throw new HttpError(502, 'Invalid function response');
|
|
63
|
+
return result.contentLength;
|
|
64
|
+
}
|
|
65
|
+
/** The UTF-8 bytes a body occupies on the wire; a string's `.length` counts UTF-16 units, not bytes. */
|
|
66
|
+
export function byteLength(body ) {
|
|
67
|
+
if (body === null || body === undefined) return 0;
|
|
68
|
+
if (typeof body !== 'string') return body.byteLength;
|
|
69
|
+
let bytes = 0;
|
|
70
|
+
for (let i = 0; i < body.length; i++) {
|
|
71
|
+
const code = body.charCodeAt(i);
|
|
72
|
+
if (code < 0x80) bytes += 1;
|
|
73
|
+
else if (code < 0x800) bytes += 2;
|
|
74
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < body.length && (body.charCodeAt(i + 1) & 0xfc00) === 0xdc00) { bytes += 4; i++; }
|
|
75
|
+
else bytes += 3; // other BMP characters, and a lone surrogate encoded as U+FFFD
|
|
76
|
+
}
|
|
77
|
+
return bytes;
|
|
78
|
+
}
|
|
79
|
+
// `res.setHeader(name, value)` called twice for the same name (case
|
|
80
|
+
// insensitively) replaces the first call rather than adding to it, so a
|
|
81
|
+
// handler, policy or profile that legitimately repeats a header (`Link`,
|
|
82
|
+
// `WWW-Authenticate`, a second `Vary` fragment produced upstream of this
|
|
83
|
+
// module) would otherwise lose every value but the last on the wire. Group
|
|
84
|
+
// by name first, in the case first seen, and hand Node the whole array; that
|
|
85
|
+
// is the one call shape ResponseWriter#setHeader accepts for a repeated
|
|
86
|
+
// header on every host this module writes to.
|
|
87
|
+
function setGroupedHeaders(res , headers ) {
|
|
88
|
+
const order = [], grouped = new Map ();
|
|
89
|
+
for (const [key, value] of headers) {
|
|
90
|
+
const lower = key.toLowerCase();
|
|
91
|
+
const values = grouped.get(lower);
|
|
92
|
+
if (values) values.push(value);
|
|
93
|
+
else { grouped.set(lower, [value]); order.push(key); }
|
|
94
|
+
}
|
|
95
|
+
for (const key of order) { const values = grouped.get(key.toLowerCase()) ; res.setHeader(key, values.length === 1 ? values[0] : values); }
|
|
96
|
+
}
|
|
49
97
|
export function writeResponse(res , result , options ) {
|
|
50
98
|
const prepared = prepareResponse(result, options);
|
|
51
|
-
|
|
99
|
+
// Node then refuses to send a body whose size differs from the stated length.
|
|
100
|
+
res.strictContentLength = true;
|
|
101
|
+
setGroupedHeaders(res, prepared.headers);
|
|
52
102
|
if (prepared.cookies.length) res.setHeader('set-cookie',prepared.cookies);
|
|
53
103
|
res.statusCode = prepared.status;
|
|
54
104
|
res.end(prepared.body);
|
|
@@ -70,14 +120,15 @@ export function errorResponse(error , { requestId, method, headers = []
|
|
|
70
120
|
const text = error instanceof HttpError && error.answer ? error.answer.text + '\n' : `${error instanceof HttpError ? String(error.message).replace(/[<>&"']/g, '') : 'Internal server error'}\n`;
|
|
71
121
|
const body = method === 'HEAD' ? undefined : text;
|
|
72
122
|
// Stated explicitly so every host agrees, as prepareResponse does for results.
|
|
73
|
-
return { status, headers: [...fixed, ['content-length', String(
|
|
123
|
+
return { status, headers: [...fixed, ['content-length', String(byteLength(text))], ...extra], body };
|
|
74
124
|
}
|
|
75
125
|
export function writeError(res , error , options ) {
|
|
76
126
|
const prepared = errorResponse(error, options);
|
|
77
127
|
if (res.headersSent) { res.destroy(); return prepared.status; }
|
|
78
128
|
for (const key of res.getHeaderNames()) res.removeHeader(key);
|
|
79
|
-
|
|
129
|
+
setGroupedHeaders(res, prepared.headers);
|
|
80
130
|
res.setHeader('connection','close');
|
|
131
|
+
res.strictContentLength = true;
|
|
81
132
|
res.statusCode = prepared.status;
|
|
82
133
|
res.end(prepared.body);
|
|
83
134
|
return prepared.status;
|
package/dist/init-with.js
CHANGED
|
@@ -12,12 +12,13 @@ import { collectDependencySet, installSteps, renderPackageManifest } from './pro
|
|
|
12
12
|
|
|
13
13
|
import { ConfigError, assert } from './errors.js';
|
|
14
14
|
import { installBundle, loadExtensionBundle, } from './extension-bundles.js';
|
|
15
|
+
import { isRecord as record, isCode } from './object-guards.js';
|
|
15
16
|
|
|
16
17
|
/** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
|
|
17
18
|
const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
|
|
18
19
|
const acknowledgementPattern = /^[a-z][a-z0-9-]{0,63}:[a-z][a-z0-9-]{0,63}$/;
|
|
19
20
|
const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9.:-]{0,63}$/;
|
|
20
|
-
|
|
21
|
+
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
|
|
@@ -30,7 +31,7 @@ const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9
|
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
|
|
34
35
|
|
|
35
36
|
export function parseWithNames(value ) {
|
|
36
37
|
const names = value.split(',').map(name => name.trim());
|
|
@@ -39,16 +40,14 @@ export function parseWithNames(value ) {
|
|
|
39
40
|
return names;
|
|
40
41
|
}
|
|
41
42
|
const packageName = (name ) => `@jimhoyd/urlcode-${name}`;
|
|
42
|
-
const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
|
|
43
43
|
const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
44
|
-
const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
45
44
|
|
|
46
45
|
/**
|
|
47
46
|
* Orders the requested set from the scaffolds' declared `requires`, `after`, `provides` and `conflicts`, never from
|
|
48
47
|
* the `--with` spelling. Kahn's algorithm with the lexically smallest ready extension first, so the result is
|
|
49
48
|
* deterministic and identical for every permutation. Refuses a missing requirement, a conflict or a cycle by name.
|
|
50
49
|
*/
|
|
51
|
-
|
|
50
|
+
function orderScaffolds(results ) {
|
|
52
51
|
const byName = new Map (), providers = new Map ();
|
|
53
52
|
for (const result of results) byName.set(result.name, result);
|
|
54
53
|
for (const result of results) for (const capability of result.provides ?? []) {
|
|
@@ -248,10 +247,11 @@ export async function initProjectWith(destination , requested
|
|
|
248
247
|
for (const key of Object.keys(extensions)) assert(!Object.hasOwn(loaded.document.extensions ?? {}, key), `Extension ${key} from ${owners.get('e:' + key)} already exists in the starter`);
|
|
249
248
|
const yamlFile = join(project, 'urlcode.yaml'), original = await readFile(yamlFile, 'utf8');
|
|
250
249
|
const doc = parseDocument(original);
|
|
251
|
-
// Extensions are declared in the entry file; their routes go into a last include
|
|
252
|
-
//
|
|
250
|
+
// Extensions are declared in the entry file; their routes go into a last include.
|
|
251
|
+
// The bare starter has no includes until an extension needs one.
|
|
253
252
|
doc.set('extensions', { ...loaded.document.extensions, ...extensions });
|
|
254
|
-
doc.addIn(['includes'], ROUTES_FILE);
|
|
253
|
+
if (doc.has('includes')) doc.addIn(['includes'], ROUTES_FILE);
|
|
254
|
+
else doc.set('includes', [ROUTES_FILE]);
|
|
255
255
|
const fragment = stringify({ version: '1', routes });
|
|
256
256
|
validateDocument(doc.toJS()); validateDocument(parseYaml(fragment));
|
|
257
257
|
const notes = results.flatMap(result => (result.routeNotes ?? []).map(note => `# ${result.name}: ${note}\n`)).join('');
|
package/dist/interchange.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { stringify } from 'yaml';
|
|
2
2
|
import { parseYaml, validateDocument, MAX_CONFIG_BYTES } from './config.js';
|
|
3
3
|
import { compileRoutes } from './router.js';
|
|
4
|
+
import { isRecord } from './object-guards.js';
|
|
4
5
|
|
|
5
6
|
|
|
6
7
|
|
|
@@ -17,8 +18,8 @@ const formats = new Set(['csv','json','yaml','netlify','cloudflare','vercel','ne
|
|
|
17
18
|
const providers = new Set(['netlify','cloudflare','vercel','netlify-toml']);
|
|
18
19
|
const statuses = new Set([301,302,303,307,308]);
|
|
19
20
|
function record(value ) {
|
|
20
|
-
if (!
|
|
21
|
-
return value
|
|
21
|
+
if (!isRecord(value)) throw new Error('Expected an object');
|
|
22
|
+
return value;
|
|
22
23
|
}
|
|
23
24
|
function keys(value , allowed ) {
|
|
24
25
|
if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error('Unsupported field; conversion would discard behavior');
|
package/dist/logging.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
/** What the logger needs from its sink: the members of a Writable it touches. process.stdout satisfies it. */
|
|
3
|
-
|
|
4
1
|
// Bound buffered operational output when the log collector cannot keep up.
|
|
5
2
|
// Dropped records are counted and reported when output becomes writable again.
|
|
6
|
-
const sinkStates = new WeakMap ();
|
|
7
3
|
export function createJsonLogger(stream = process.stdout, maxBufferBytes = 1048576) {
|
|
8
4
|
let state=sinkStates.get(stream);
|
|
9
5
|
if(!state){const created={failed:false};state=created;sinkStates.set(stream,created);stream.on?.('error',()=>{created.failed=true;});}
|
|
@@ -20,3 +16,7 @@ export function createJsonLogger(stream = process.stdout, maxBufferByte
|
|
|
20
16
|
} catch {sink.failed=true;dropped++;}
|
|
21
17
|
};
|
|
22
18
|
}
|
|
19
|
+
|
|
20
|
+
/** What the logger needs from its sink: the members of a Writable it touches. process.stdout satisfies it. */
|
|
21
|
+
|
|
22
|
+
const sinkStates = new WeakMap ();
|
package/dist/match.js
CHANGED
package/dist/mcp-authoring.js
CHANGED
|
@@ -8,6 +8,7 @@ import {compileRoutes} from './router.js';
|
|
|
8
8
|
import {prepareFunctionSnapshot,requestedPermissions} from './policy.js';
|
|
9
9
|
import {validateProject} from './tooling.js';
|
|
10
10
|
import {scaffoldProject} from './scaffold.js';
|
|
11
|
+
import {isRecord as object, isCode} from './object-guards.js';
|
|
11
12
|
import {addRecipe} from './recipes.js';
|
|
12
13
|
import {authoringPath} from './authoring-files.js';
|
|
13
14
|
import {assert} from './errors.js';
|
|
@@ -31,7 +32,6 @@ export const authoringDefinitions=[
|
|
|
31
32
|
{name:'run_test',description:'Run `urlcode test` against the project (activates the local runtime and executes fixtures); returns exit code and bounded output.',properties:{},required:[]},
|
|
32
33
|
{name:'run_audit',description:'Run `urlcode audit` against the project; returns exit code and bounded output.',properties:{},required:[]},
|
|
33
34
|
];
|
|
34
|
-
const isCode=(error ,code ) =>error instanceof Error&&'code' in error&&error.code===code;
|
|
35
35
|
// Operator-owned material never lives under an authoring write, even when an
|
|
36
36
|
// operator mistakenly placed it in the checkout.
|
|
37
37
|
const operatorFile=/^(?:.*policy.*\.json|.*compliance.*\.(?:json|mjs|js|cjs|ts)|host(?:-file)?\.(?:mjs|js|cjs|ts)|.*\.(?:sqlite3?|db)(?:-wal|-shm|-journal)?|urlcode\.yaml\.lock)$/i;
|
|
@@ -53,7 +53,6 @@ async function verdict(root ,origin ) {
|
|
|
53
53
|
try{return await validateProject(root,origin?{origin}:{});}
|
|
54
54
|
catch{return {valid:false ,note:'Project does not validate; call run_validate for the CLI report.'};}
|
|
55
55
|
}
|
|
56
|
-
function object(value ) {return value!==null&&typeof value==='object'&&!Array.isArray(value);}
|
|
57
56
|
function expandHandler(handler ) {
|
|
58
57
|
if(object(handler))return handler;
|
|
59
58
|
assert(typeof handler==='string','Handler must be a route object or a short form');
|
package/dist/mcp.js
CHANGED
|
@@ -7,7 +7,11 @@ import {loadOperatorHost} from './operator-host.js';
|
|
|
7
7
|
import {buildManifest} from './manifest.js';
|
|
8
8
|
|
|
9
9
|
import {authoringDefinitions,callAuthoringTool} from './mcp-authoring.js';
|
|
10
|
-
|
|
10
|
+
// Imported through the public subpath, not a relative path into this module's own
|
|
11
|
+
// package, to prove @jimhoyd/urlcode/agent-context is sufficient for the framework's
|
|
12
|
+
// own MCP server rather than a documentation-only promise (see docs/TOOLING.md).
|
|
13
|
+
import {listSkills,getSkill,searchDocs,getExample,validateYaml,explainError} from '@jimhoyd/urlcode/agent-context';
|
|
14
|
+
import {isRecord as object} from './object-guards.js';
|
|
11
15
|
import {describeArtifactCache,readArtifactMember} from './extension-artifacts.js';
|
|
12
16
|
const protocolVersion='2025-11-25';
|
|
13
17
|
const maxBytes=1048576;
|
|
@@ -46,7 +50,6 @@ const readTools=definitions.map(def=>({name:def.name,description:def.description
|
|
|
46
50
|
const hostTool={name:hostDefinition.name,description:hostDefinition.description,inputSchema:{type:'object',properties:hostDefinition.properties,required:[],additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}};
|
|
47
51
|
const authoringTools=authoringDefinitions.map(def=>({name:def.name,description:def.description,inputSchema:{type:'object',properties:def.properties,required:def.required,additionalProperties:false},annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false}}));
|
|
48
52
|
const validators=new Map([...readTools,hostTool,...authoringTools].map(tool=>[tool.name,ajv.compile(tool.inputSchema)]));
|
|
49
|
-
function object(value ) {return value!==null&&typeof value==='object'&&!Array.isArray(value);}
|
|
50
53
|
/** `allowAuthoring` and `hostFile` are set only by the `--allow-authoring` and `--host-file` command-line flags; tool arguments and the environment never enable them. */
|
|
51
54
|
|
|
52
55
|
/** Operator selects the only project root. Read tools have no path, credential, write or execution authority; authoring tools write inside that root only. */
|
|
@@ -102,7 +105,7 @@ export async function serveMcp(options ) {
|
|
|
102
105
|
if(message.method==='initialize') {
|
|
103
106
|
if(initialized){await error(id,-32600,'Already initialized');return;}
|
|
104
107
|
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;}
|
|
105
|
-
initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.
|
|
108
|
+
initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.9'}}});return;
|
|
106
109
|
}
|
|
107
110
|
if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
|
|
108
111
|
if(!ready){await error(id,-32002,'Initialize first');return;}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Small, dependency-free predicates shared across the runtime and its
|
|
2
|
+
// scripts. Kept separate from extension-transport.ts, which owns GitHub
|
|
3
|
+
// release/cache/lockfile plumbing (and imports Node-only modules) rather
|
|
4
|
+
// than generic object checks; router.ts and body-schema.ts reach this file
|
|
5
|
+
// instead so a Worker/edge bundle never pulls in Node builtins through it.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
/** True for a non-null, non-array object -- the shape most parsed JSON/YAML fields expect. */
|
|
9
|
+
export const isRecord = (value ) =>
|
|
10
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
|
|
12
|
+
/** True for a `key` that `object` itself declares (not inherited). */
|
|
13
|
+
export const own = (object , key ) => Object.hasOwn(object, key);
|
|
14
|
+
|
|
15
|
+
/** True when `error` is a Node `Error` carrying the given `code` (e.g. `'ENOENT'`). */
|
|
16
|
+
export const isCode = (error , code ) =>
|
|
17
|
+
error instanceof Error && 'code' in error && error.code === code;
|
package/dist/pattern-guard.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assert } from './errors.js';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const maxPatternLength = 128;
|
|
4
4
|
export const maxPatternInputLength = 128;
|
|
5
5
|
const maxUnboundedQuantifiers = 3;
|
|
6
6
|
|
|
@@ -15,6 +15,14 @@ export function assertSafePattern(pattern ) {
|
|
|
15
15
|
assert(pattern.length > 0 && pattern.length <= maxPatternLength, `Pattern must be 1 to ${maxPatternLength} characters`);
|
|
16
16
|
try { new RegExp(pattern, 'u'); } catch { assert(false, 'Invalid pattern'); }
|
|
17
17
|
let unbounded = 0, inClass = false;
|
|
18
|
+
// One entry per currently open group, true once its body has shown a
|
|
19
|
+
// quantifier (`*+?{n,m}`) or a top-level alternation `|`. A *bounded*
|
|
20
|
+
// repeat of such a group (`(a+){2,3}`, `(a|b){2,3}`) still lets the
|
|
21
|
+
// backtracker retry each of the `n` copies against every inner
|
|
22
|
+
// possibility, which is super-linear even though the outer bound is
|
|
23
|
+
// finite; only a group with a genuinely flat body may be bound-repeated.
|
|
24
|
+
const groups = [];
|
|
25
|
+
const markQuantifier = () => { if (groups.length) groups[groups.length - 1] = true; };
|
|
18
26
|
for (let i = 0; i < pattern.length; i++) {
|
|
19
27
|
const char = pattern[i] ;
|
|
20
28
|
if (char === '\\') {
|
|
@@ -24,8 +32,30 @@ export function assertSafePattern(pattern ) {
|
|
|
24
32
|
}
|
|
25
33
|
if (inClass) { if (char === ']') inClass = false; continue; }
|
|
26
34
|
if (char === '[') { inClass = true; continue; }
|
|
27
|
-
if (char === '
|
|
28
|
-
if (char === '
|
|
35
|
+
if (char === '|') { markQuantifier(); continue; }
|
|
36
|
+
if (char === '(') {
|
|
37
|
+
assert(!/^\(\?<?[=!]/.test(pattern.slice(i, i + 4)), 'Pattern lookaround is not supported');
|
|
38
|
+
groups.push(false);
|
|
39
|
+
// Skip past `(?:` and `(?<name>` group syntax so its `?` (and the
|
|
40
|
+
// name's own characters) are never mistaken for a body quantifier.
|
|
41
|
+
if (pattern[i + 1] === '?') {
|
|
42
|
+
if (pattern[i + 2] === ':') i += 2;
|
|
43
|
+
else if (pattern[i + 2] === '<') { const end = pattern.indexOf('>', i + 3); i = end === -1 ? i + 2 : end; }
|
|
44
|
+
else i += 1;
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === ')') {
|
|
49
|
+
const hadInnerQuantifier = groups.pop() ?? false;
|
|
50
|
+
const rest = pattern.slice(i + 1);
|
|
51
|
+
const unboundedRepeat = /^(?:[*+]|\{\d+,\})/.test(rest);
|
|
52
|
+
const boundedRepeat = !unboundedRepeat && /^\{\d+(,\d+)?\}/.test(rest);
|
|
53
|
+
assert(!unboundedRepeat, 'Pattern cannot repeat a group without a bound');
|
|
54
|
+
assert(!(boundedRepeat && hadInnerQuantifier), 'Pattern cannot bound-repeat a group that contains a quantifier or alternation');
|
|
55
|
+
if (boundedRepeat) markQuantifier();
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (char === '*' || char === '+' || char === '?' || (char === '{' && /^\{\d+(,\d*)?\}/.test(pattern.slice(i)))) markQuantifier();
|
|
29
59
|
if (char === '*' || char === '+' || (char === '{' && /^\{\d+,\}/.test(pattern.slice(i)))) unbounded++;
|
|
30
60
|
}
|
|
31
61
|
assert(unbounded <= maxUnboundedQuantifiers, `Pattern allows at most ${maxUnboundedQuantifiers} unbounded quantifiers`);
|
package/dist/policies/cache.js
CHANGED
|
@@ -14,15 +14,15 @@ import { assert, ConfigError } from '../errors.js';
|
|
|
14
14
|
export const name = 'cache';
|
|
15
15
|
export const phases = ['request','response'];
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
/** A stored 200 representation: the result minus its body and headers, which are kept separately. */
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
/** The in-flight fill for one key; waiters share its promise up to MAX_WAITERS. */
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
/** The one origin cache per runtime, shared by every route with an origin-caching strategy. */
|
|
27
27
|
|
|
28
28
|
|
|
@@ -240,6 +240,14 @@ export function onResponse(state , req , result
|
|
|
240
240
|
// no-store (a personalized answer under a public route stays private).
|
|
241
241
|
const restrictive = directive(handlerControl, 'no-store') || directive(handlerControl, 'private');
|
|
242
242
|
const owned = !state.yamlCacheControl && !state.inheritedAsset && !restrictive;
|
|
243
|
+
// The handler's own Vary, read before the declared names are merged in: the
|
|
244
|
+
// cache key only ever covers `state.vary`, so a handler that names another
|
|
245
|
+
// header (or `*`) has a representation this key cannot distinguish, and
|
|
246
|
+
// storing it would serve that variant to every caller regardless of what
|
|
247
|
+
// they actually sent for the undeclared header.
|
|
248
|
+
const handlerVaryNames = (header(result.headers, 'vary') || '').split(',').map(v => v.trim().toLowerCase()).filter(Boolean);
|
|
249
|
+
const declaredVary = new Set(state.vary);
|
|
250
|
+
const undeclaredVary = handlerVaryNames.some(name => name === '*' || !declaredVary.has(name));
|
|
243
251
|
let headers = result.headers;
|
|
244
252
|
if (owned) {
|
|
245
253
|
headers = [...without(headers, 'cache-control', 'cdn-cache-control'), ['cache-control', state.cacheControl]];
|
|
@@ -255,7 +263,7 @@ export function onResponse(state , req , result
|
|
|
255
263
|
// released either way, with the entry or with nothing.
|
|
256
264
|
const { store } = state, body = bodyOf(out);
|
|
257
265
|
const storable = !req.secrets && state.statuses.has(out.status) && !restrictive && body.length <= state.maxBytes
|
|
258
|
-
&& !out.headers.some(([k]) => String(k).toLowerCase() === 'set-cookie');
|
|
266
|
+
&& !out.headers.some(([k]) => String(k).toLowerCase() === 'set-cookie') && !undeclaredVary;
|
|
259
267
|
let entry = null;
|
|
260
268
|
if (storable) {
|
|
261
269
|
const previous = store.entries.get(flight.key);
|
|
@@ -265,6 +273,10 @@ export function onResponse(state , req , result
|
|
|
265
273
|
store.entries.set(flight.key, entry); store.bytes += body.length;
|
|
266
274
|
evict(store, state.maxEntries);
|
|
267
275
|
log(state, 'store');
|
|
276
|
+
} else if (undeclaredVary) {
|
|
277
|
+
// A distinct bypass reason from a plain miss/no-store, so an operator can
|
|
278
|
+
// see a route whose handler needs its own `Vary` name added to the policy.
|
|
279
|
+
log(state, 'vary-bypass');
|
|
268
280
|
}
|
|
269
281
|
store.pending.delete(flight.key);
|
|
270
282
|
flight.flight.resolve(entry);
|
|
@@ -11,7 +11,7 @@ import { assert } from '../errors.js';
|
|
|
11
11
|
// group; multi-instance sharing is a plugin concern.
|
|
12
12
|
export const name = 'throttle';
|
|
13
13
|
export const phases = ['request','response'];
|
|
14
|
-
|
|
14
|
+
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
/** The one counter table per runtime, shared by every route that declares a throttle. */
|
package/dist/policy.js
CHANGED
|
@@ -8,12 +8,12 @@ import { collectFunctionSources, collectTrustedSources, routeFunctions } from '.
|
|
|
8
8
|
import { assert } from './errors.js';
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
|
|
12
|
+
|
|
13
13
|
/** The operator's binding grants: which env and secret names each route may read, pinned to a project hash. */
|
|
14
14
|
|
|
15
15
|
/** The function snapshot plus the hash operator grants are pinned to. */
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
|
|
18
18
|
export async function prepareFunctionSnapshot(loaded ) {
|
|
19
19
|
// Only `sandbox: true` routes are bundled into the QuickJS module snapshot
|
package/dist/prerender.js
CHANGED
|
@@ -5,6 +5,7 @@ import {loadDocument, functionFile} from './config.js';
|
|
|
5
5
|
import {applySite} from './site.js';
|
|
6
6
|
import {collectFunctionSources, routeFunctions, MODULE_LIMIT, TOTAL_BYTE_LIMIT} from './function-sources.js';
|
|
7
7
|
import {ConfigError, assert} from './errors.js';
|
|
8
|
+
import {isCode} from './object-guards.js';
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
|
|
@@ -38,8 +39,6 @@ const overlaps = (first , second ) => {
|
|
|
38
39
|
const rel = relative(first, second);
|
|
39
40
|
return rel === '' || !(isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep));
|
|
40
41
|
};
|
|
41
|
-
const isCode = (error , code ) => error instanceof Error && 'code' in error && error.code === code;
|
|
42
|
-
|
|
43
42
|
// One route path to one flat filename, deterministically. `~` cannot occur in a
|
|
44
43
|
// route segment, so it separates segments unambiguously: `/a/b` and `/a-b` are
|
|
45
44
|
// distinct names rather than a silent collision. The root becomes `index`, and a
|
|
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { ConfigError, assert } from './errors.js';
|
|
5
|
+
import { isRecord as record } from './object-guards.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Exact dependency pins for a generated application.
|
|
@@ -17,13 +18,13 @@ import { ConfigError, assert } from './errors.js';
|
|
|
17
18
|
* a name the caller supplied -- so core's generic extension boundary is unchanged.
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
const CORE_PACKAGE = '@jimhoyd/urlcode';
|
|
21
22
|
const namePattern = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
const versionPattern = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
function parseVersion(value ) {
|
|
27
28
|
const match = versionPattern.exec(value.trim());
|
|
28
29
|
if (!match) return null;
|
|
29
30
|
const pre = match[4] === undefined ? [] : match[4].split('.').map(part => /^\d+$/.test(part) ? Number(part) : part);
|
|
@@ -44,7 +45,7 @@ function comparePre(a , b
|
|
|
44
45
|
}
|
|
45
46
|
return 0;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
+
function compareVersions(a , b ) {
|
|
48
49
|
for (const key of ['major', 'minor', 'patch'] ) if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
|
|
49
50
|
return comparePre(a.pre, b.pre);
|
|
50
51
|
}
|
|
@@ -101,12 +102,11 @@ export function satisfiesRange(version , range , context = 'peer r
|
|
|
101
102
|
});
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
|
|
105
106
|
|
|
106
107
|
|
|
107
108
|
|
|
108
109
|
|
|
109
|
-
const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
110
110
|
const strings = (value ) => {
|
|
111
111
|
const out = {};
|
|
112
112
|
if (record(value)) for (const [key, item] of Object.entries(value)) if (typeof item === 'string') out[key] = item;
|
|
@@ -128,7 +128,7 @@ async function readManifest(file ) {
|
|
|
128
128
|
* means a package whose `exports` does not expose `./package.json` is still inspectable, and nothing in the
|
|
129
129
|
* package is loaded or executed.
|
|
130
130
|
*/
|
|
131
|
-
|
|
131
|
+
async function findInstalledPackage(name , from ) {
|
|
132
132
|
assert(namePattern.test(name), `Invalid package name: ${name}`);
|
|
133
133
|
let directory = resolve(from);
|
|
134
134
|
for (;;) {
|
|
@@ -140,7 +140,7 @@ export async function findInstalledPackage(name , from )
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
/** The version of the runtime executing this command; that is the version a generated site is pinned to. */
|
|
143
|
-
|
|
143
|
+
async function runningCore() {
|
|
144
144
|
const file = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
145
145
|
const manifest = await readManifest(file);
|
|
146
146
|
assert(manifest && manifest.name === CORE_PACKAGE, `Could not read the running runtime manifest at ${file}`);
|
|
@@ -185,7 +185,7 @@ async function localSpecifiers(cwd ) {
|
|
|
185
185
|
|
|
186
186
|
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
|
|
189
189
|
|
|
190
190
|
|
|
191
191
|
|
package/dist/proxy.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EgressError, egressUrl, safeEgressHeaders } from './egress.js';
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
|
|
6
6
|
const sensitive=new Set(['authorization','cookie','set-cookie','proxy-authorization','forwarded','x-forwarded-for','x-forwarded-host','x-forwarded-proto']);
|
|
7
7
|
export function validateProxy(definition ) {
|