@jimhoyd/urlcode 0.4.2 → 0.4.7
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 +57 -14
- package/.claude/skills/urlcode-operations/SKILL.md +4 -0
- package/README.md +42 -18
- package/SECURITY.md +5 -3
- package/dist/BUILD-MANIFEST.json +30 -27
- package/dist/agent-context.js +82 -0
- package/dist/agents-guide.js +42 -42
- package/dist/authoring.js +12 -2
- package/dist/body-schema.js +159 -0
- package/dist/build-cloudflare.js +2 -0
- package/dist/capabilities.js +1 -1
- package/dist/cli.js +28 -12
- package/dist/config.js +74 -6
- package/dist/context.js +4 -6
- package/dist/errors.js +3 -1
- package/dist/examples.js +1 -1
- package/dist/extensions.js +60 -2
- package/dist/http-policy.js +19 -4
- package/dist/http-response.js +2 -2
- package/dist/init-with.js +71 -9
- package/dist/mcp.js +15 -2
- package/dist/pattern-guard.js +32 -0
- package/dist/policies/security.js +0 -0
- package/dist/project-tests.js +35 -11
- package/dist/readiness.js +184 -32
- package/dist/recipes.js +1 -1
- package/dist/router.js +17 -0
- package/dist/runtime.js +26 -3
- package/dist/scaffold.js +0 -0
- package/dist/server.js +26 -3
- package/dist/site.js +0 -0
- package/dist/tooling.js +2 -1
- package/dist/types/agent-context.d.ts +44 -0
- package/dist/types/authoring.d.ts +3 -1
- package/dist/types/body-schema.d.ts +58 -0
- package/dist/types/config.d.ts +10 -2
- package/dist/types/context.d.ts +1 -1
- package/dist/types/errors.d.ts +9 -1
- package/dist/types/examples.d.ts +1 -1
- package/dist/types/extensions.d.ts +47 -2
- package/dist/types/http-policy.d.ts +3 -0
- package/dist/types/init-with.d.ts +10 -1
- package/dist/types/pattern-guard.d.ts +10 -0
- package/dist/types/project-tests.d.ts +9 -0
- package/dist/types/readiness.d.ts +68 -0
- package/dist/types/recipes.d.ts +1 -1
- package/dist/types/runtime.d.ts +4 -0
- package/dist/types/server.d.ts +9 -1
- package/dist/types/tooling.d.ts +1 -0
- package/dist/types/types.d.ts +13 -0
- package/dist/types.js +10 -2
- package/dist/typescript-authoring.js +5 -3
- package/dist/verify-deployment.js +16 -10
- package/examples/body-validation/README.md +16 -0
- package/examples/body-validation/example.yaml +17 -0
- package/examples/body-validation/tests/requests.json +15 -0
- package/examples/body-validation/urlcode.yaml +34 -0
- package/examples/coverage-waiver/README.md +8 -0
- package/examples/coverage-waiver/example.yaml +16 -0
- package/examples/coverage-waiver/functions/notes.mjs +2 -0
- package/examples/coverage-waiver/tests/requests.json +3 -0
- package/examples/coverage-waiver/urlcode.yaml +10 -0
- package/examples/data-dir/README.md +39 -0
- package/examples/data-dir/data/welcome.txt +1 -0
- package/examples/data-dir/example.yaml +22 -0
- package/examples/data-dir/functions/note.mjs +18 -0
- package/examples/data-dir/tests/requests.json +6 -0
- package/examples/data-dir/urlcode.yaml +6 -0
- package/examples/lifecycle/README.md +15 -0
- package/examples/lifecycle/example.yaml +19 -0
- package/examples/lifecycle/functions/notes.mjs +29 -0
- package/examples/lifecycle/tests/requests.json +12 -0
- package/examples/lifecycle/urlcode.yaml +29 -0
- package/examples/not-found/README.md +10 -0
- package/examples/not-found/example.yaml +17 -0
- package/examples/not-found/public/404.html +3 -0
- package/examples/not-found/public/index.html +3 -0
- package/examples/not-found/tests/requests.json +7 -0
- package/examples/not-found/urlcode.yaml +7 -0
- package/examples/shared-blocks/README.md +11 -0
- package/examples/shared-blocks/example.yaml +17 -0
- package/examples/shared-blocks/tests/requests.json +7 -0
- package/examples/shared-blocks/urlcode.yaml +37 -0
- package/llms-full.txt +364 -49
- package/llms.txt +44 -6
- package/package.json +15 -26
- package/recipes/static-page/README.md +9 -0
- package/recipes/static-page/public/index.html +11 -0
- package/recipes/static-page/recipe.yaml +21 -0
- package/recipes/static-page/tests/requests.json +22 -0
- package/recipes/static-page/urlcode.yaml +7 -0
- package/recipes/static-plus-api/README.md +6 -0
- package/recipes/static-plus-api/urlcode.yaml +4 -0
- package/recipes/store-crud/README.md +53 -0
- package/recipes/store-crud/recipe.yaml +31 -0
- package/recipes/store-crud/tests/requests.json +18 -0
- package/recipes/store-crud/urlcode.yaml +18 -0
- package/schemas/urlcode.schema.json +125 -60
- package/skills/urlcode/SKILL.md +47 -27
- package/starters/default/AGENTS.md +43 -43
- package/starters/page/README.md +14 -0
- package/starters/page/public/index.html +12 -0
- package/starters/page/tests/requests.json +17 -0
- package/starters/page/urlcode.yaml +6 -0
- package/.claude-plugin/marketplace.json +0 -18
- package/CONTRIBUTING.md +0 -148
- package/ROADMAP.md +0 -70
- package/docs/AI-AUTHORING.md +0 -339
- package/docs/ASSETS.md +0 -107
- package/docs/AUTH-BACKUP.md +0 -32
- package/docs/AWS.md +0 -86
- package/docs/BEST-PRACTICES.md +0 -276
- package/docs/BULK.md +0 -79
- package/docs/CAPABILITIES.md +0 -192
- package/docs/CAPACITY.md +0 -305
- package/docs/CI-FOLLOWUP-2026-09-19.md +0 -97
- package/docs/CI-RELEASE-AUDIT-2026-09-19.md +0 -322
- package/docs/CI.md +0 -147
- package/docs/CLOUDFLARE.md +0 -109
- package/docs/CODEBASE-AUDIT-2026-09-20.md +0 -284
- package/docs/COMPLIANCE.md +0 -239
- package/docs/COMPOSING-A-SITE.md +0 -287
- package/docs/CONDITIONS.md +0 -74
- package/docs/CONTAINER-PROMOTION.md +0 -74
- package/docs/DEPLOYMENT-CHECKS.md +0 -108
- package/docs/DEVELOPMENT-PIPELINE.md +0 -304
- package/docs/EGRESS.md +0 -125
- package/docs/EXTENSIONS.md +0 -403
- package/docs/FRAMEWORK.md +0 -217
- package/docs/FUNCTION-SECURITY.md +0 -251
- package/docs/HTTP.md +0 -129
- package/docs/INSTALL.md +0 -128
- package/docs/INTERCHANGE.md +0 -134
- package/docs/LOAD-TESTING.md +0 -91
- package/docs/LOCAL-DEVELOPMENT.md +0 -102
- package/docs/MIDDLEWARE-EXAMPLES.md +0 -75
- package/docs/MIDDLEWARE.md +0 -102
- package/docs/MONITORING.md +0 -115
- package/docs/OBSERVABILITY.md +0 -222
- package/docs/OPEN-DECISIONS.md +0 -149
- package/docs/OPERATIONAL-PROOF.md +0 -41
- package/docs/OPERATIONS.md +0 -201
- package/docs/ORGANIZATION.md +0 -135
- package/docs/PERFORMANCE.md +0 -72
- package/docs/PLUGINS.md +0 -271
- package/docs/POLICIES.md +0 -211
- package/docs/PRERENDER.md +0 -245
- package/docs/PROJECT-DIRECTION.md +0 -118
- package/docs/PROVIDER-VERIFICATION.md +0 -84
- package/docs/READINESS.md +0 -153
- package/docs/README.md +0 -93
- package/docs/RECIPES.md +0 -99
- package/docs/RELEASE-0.4.0-alpha.3.md +0 -50
- package/docs/RELEASE-0.4.1.md +0 -73
- package/docs/RELEASE-0.4.2.md +0 -30
- package/docs/RELEASE-READINESS.md +0 -146
- package/docs/RELEASE-SECURITY.md +0 -100
- package/docs/RESILIENCE.md +0 -161
- package/docs/ROUTING.md +0 -92
- package/docs/SANDBOX-REVIEW.md +0 -72
- package/docs/SCAFFOLDING.md +0 -70
- package/docs/SECURITY-AUDIT.md +0 -164
- package/docs/SITE.md +0 -150
- package/docs/SPECIFICATION.md +0 -359
- package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +0 -288
- package/docs/SPIKE-BUSINESS-SUITE.md +0 -1029
- package/docs/SPIKE-CORE-LAYERING.md +0 -368
- package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +0 -207
- package/docs/STANDARDS.md +0 -311
- package/docs/STARTERS.md +0 -83
- package/docs/STATIC.md +0 -105
- package/docs/TOOLING.md +0 -298
- package/docs/TUNNELS.md +0 -72
- package/docs/TYPESCRIPT-AUTHORING.md +0 -87
- package/docs/TYPESCRIPT.md +0 -123
- package/docs/VERCEL.md +0 -114
- package/docs/VERSION-ALIGNMENT.md +0 -88
- package/docs/YAML-GUIDE.md +0 -57
- package/docs/YAML-REFERENCE.md +0 -449
- package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +0 -68
- package/docs/archive/2026-09-19/MANAGEMENT-SECURITY.md +0 -102
- package/docs/archive/2026-09-19/NEXT-PHASE-PLAN.md +0 -108
- package/docs/archive/2026-09-19/NEXT-STEPS.md +0 -646
- package/docs/archive/2026-09-19/OPEN-DECISIONS.md +0 -277
- package/docs/archive/2026-09-19/RELEASE-SECURITY.md +0 -186
- package/docs/archive/2026-09-19/ROADMAP.md +0 -387
- package/docs/archive/2026-09-19/SPIKE-EXTENSION-MODEL.md +0 -430
- package/docs/archive/2026-09-19/SPIKE-EXTENSIONS.md +0 -492
- package/docs/archive/2026-09-19/SPIKE-LAMBDA-COMPILE.md +0 -365
- package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +0 -778
- package/docs/archive/2026-09-19/USABILITY-REVIEW.md +0 -139
- package/docs/archive/2026-09-20/OPEN-DECISIONS-COMPLETED.md +0 -116
- package/docs/archive/README.md +0 -29
- package/docs/policies/agents.md +0 -182
- package/docs/policies/cache.md +0 -152
- package/docs/policies/compression.md +0 -169
- package/docs/policies/contract.md +0 -52
- package/docs/policies/hardened.md +0 -56
- package/docs/policies/interoperability.md +0 -169
- package/docs/policies/operations.md +0 -45
- package/docs/policies/security.md +0 -161
- package/docs/policies/throttle.md +0 -103
- package/docs/yaml/assets.md +0 -36
- package/docs/yaml/conditions.md +0 -20
- package/docs/yaml/functions.md +0 -168
- package/docs/yaml/middleware.md +0 -31
- package/docs/yaml/organization.md +0 -74
- package/docs/yaml/policies.md +0 -37
- package/docs/yaml/redirects.md +0 -64
- package/docs/yaml/responses.md +0 -57
- package/docs/yaml/site.md +0 -24
- package/packaging/claude-plugin/.claude-plugin/plugin.json +0 -19
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +0 -130
- package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +0 -108
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { assert } from './errors.js';
|
|
2
|
+
import { assertSafePattern, maxPatternInputLength } from './pattern-guard.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The JSON Schema subset a route may declare for `request.body.schema`.
|
|
6
|
+
* It is interpreted here rather than compiled by Ajv so the same code runs on
|
|
7
|
+
* every host, including the Worker, with no code generation and no author
|
|
8
|
+
* regex outside `pattern-guard.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
export const uuidFormat = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
18
|
+
const types = ['object', 'array', 'string', 'integer', 'number', 'boolean', 'null'];
|
|
19
|
+
const keywords = new Set(['type','properties','required','additionalProperties','items','enum','minLength','maxLength','pattern','format','minimum','maximum','minItems','maxItems']);
|
|
20
|
+
const limits = { depth: 6, nodes: 128, properties: 64, enums: 64, length: 8192, items: 10000 };
|
|
21
|
+
const isRecord = (value ) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
|
+
const own = (object , key ) => Object.hasOwn(object, key);
|
|
23
|
+
const patterns = new WeakMap ();
|
|
24
|
+
|
|
25
|
+
/** Rejects, at load time, any schema outside the supported subset or its limits. */
|
|
26
|
+
export function assertBodySchema(schema ) {
|
|
27
|
+
let nodes = 0;
|
|
28
|
+
const walk = (node , depth ) => {
|
|
29
|
+
assert(isRecord(node), 'Body schema must be an object');
|
|
30
|
+
assert(depth <= limits.depth && ++nodes <= limits.nodes, 'Body schema is too large or deeply nested');
|
|
31
|
+
for (const key of Object.keys(node)) assert(keywords.has(key), `Unsupported body schema keyword: ${key}`);
|
|
32
|
+
const kind = node.type;
|
|
33
|
+
assert(kind === undefined || (typeof kind === 'string' && types.includes(kind)), 'Body schema type must be one of ' + types.join(', '));
|
|
34
|
+
const isKind = (...allowed ) => typeof kind === 'string' && allowed.includes(kind);
|
|
35
|
+
const uses = (...names ) => names.some(name => own(node, name));
|
|
36
|
+
assert(!uses('properties', 'required', 'additionalProperties') || isKind('object'), 'properties, required and additionalProperties require type object');
|
|
37
|
+
assert(!uses('items', 'minItems', 'maxItems') || isKind('array'), 'items, minItems and maxItems require type array');
|
|
38
|
+
assert(!uses('minLength', 'maxLength', 'pattern', 'format') || isKind('string'), 'String keywords require type string');
|
|
39
|
+
assert(!uses('minimum', 'maximum') || isKind('integer', 'number'), 'Numeric bounds require type integer or number');
|
|
40
|
+
for (const name of ['minLength', 'maxLength', 'minItems', 'maxItems']) {
|
|
41
|
+
if (!own(node, name)) continue;
|
|
42
|
+
const value = node[name], cap = name.endsWith('Items') ? limits.items : limits.length;
|
|
43
|
+
assert(Number.isInteger(value) && (value ) >= 0 && (value ) <= cap, `${name} must be an integer from 0 to ${cap}`);
|
|
44
|
+
}
|
|
45
|
+
for (const name of ['minimum', 'maximum']) assert(!own(node, name) || (typeof node[name] === 'number' && Number.isFinite(node[name])), `${name} must be a finite number`);
|
|
46
|
+
if (own(node, 'format')) assert(node.format === 'uuid', 'Unsupported body schema format (supported: uuid)');
|
|
47
|
+
if (own(node, 'pattern')) {
|
|
48
|
+
assert(typeof node.pattern === 'string', 'pattern must be a string');
|
|
49
|
+
assertSafePattern(node.pattern);
|
|
50
|
+
assert(typeof node.maxLength === 'number' && node.maxLength <= maxPatternInputLength, `pattern requires maxLength of at most ${maxPatternInputLength}`);
|
|
51
|
+
}
|
|
52
|
+
if (own(node, 'enum')) {
|
|
53
|
+
assert(Array.isArray(node.enum) && node.enum.length >= 1 && node.enum.length <= limits.enums, `enum must list 1 to ${limits.enums} values`);
|
|
54
|
+
assert(node.enum.every(value => value === null || ['string', 'number', 'boolean'].includes(typeof value)), 'enum values must be scalars');
|
|
55
|
+
}
|
|
56
|
+
if (own(node, 'required')) {
|
|
57
|
+
assert(Array.isArray(node.required) && node.required.every(name => typeof name === 'string') && new Set(node.required).size === node.required.length, 'required must be a list of unique names');
|
|
58
|
+
const declared = isRecord(node.properties) ? node.properties : {};
|
|
59
|
+
assert((node.required ).every(name => own(declared, name)), 'required names must be declared in properties');
|
|
60
|
+
}
|
|
61
|
+
if (own(node, 'additionalProperties')) assert(typeof node.additionalProperties === 'boolean', 'additionalProperties must be true or false');
|
|
62
|
+
if (own(node, 'properties')) {
|
|
63
|
+
assert(isRecord(node.properties) && Object.keys(node.properties).length <= limits.properties, `properties must declare at most ${limits.properties} names`);
|
|
64
|
+
for (const child of Object.values(node.properties)) walk(child, depth + 1);
|
|
65
|
+
}
|
|
66
|
+
if (own(node, 'items')) walk(node.items, depth + 1);
|
|
67
|
+
};
|
|
68
|
+
walk(schema, 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const describe = (value ) => value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
|
|
72
|
+
/**
|
|
73
|
+
* One validation failure. `pointer` is an RFC 6901 pointer built only from names
|
|
74
|
+
* the schema declared (array positions appear as `[]`, not an index), `keyword`
|
|
75
|
+
* is the schema keyword that failed, and `expected` is the schema's own
|
|
76
|
+
* constraint. Nothing the client sent is ever placed in an issue.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
const escapePointer = (name ) => name.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
80
|
+
/** Structured failures for `value`; `checkBodySchema` renders the same list as text. */
|
|
81
|
+
export function bodySchemaIssues(schema , value , path = '', issues = [], max = 8) {
|
|
82
|
+
const fail = (keyword , message , extra = {}) => { if (issues.length < max) issues.push({ pointer: path, keyword, message, ...extra }); };
|
|
83
|
+
const kind = schema.type;
|
|
84
|
+
if (kind) {
|
|
85
|
+
const actual = describe(value);
|
|
86
|
+
const ok = kind === 'integer' ? Number.isInteger(value) : kind === 'number' ? typeof value === 'number' : actual === kind;
|
|
87
|
+
if (!ok) { fail('type', `must be ${kind === 'array' || kind === 'object' || kind === 'integer' ? 'an' : 'a'} ${kind}`, { expected: kind }); return issues; }
|
|
88
|
+
}
|
|
89
|
+
if (schema.enum && !schema.enum.some(item => item === value)) {
|
|
90
|
+
const listable = schema.enum.length <= 16 && schema.enum.every(item => typeof item !== 'string' || item.length <= 64);
|
|
91
|
+
fail('enum', 'must be one of the declared values', listable ? { expected: schema.enum } : {});
|
|
92
|
+
}
|
|
93
|
+
if (typeof value === 'string') {
|
|
94
|
+
if (schema.minLength !== undefined && [...value].length < schema.minLength) fail('minLength', `must be at least ${schema.minLength} characters`, { expected: schema.minLength });
|
|
95
|
+
if (schema.maxLength !== undefined && [...value].length > schema.maxLength) fail('maxLength', `must be at most ${schema.maxLength} characters`, { expected: schema.maxLength });
|
|
96
|
+
else {
|
|
97
|
+
if (schema.format === 'uuid' && !uuidFormat.test(value)) fail('format', 'must be a uuid', { expected: 'uuid' });
|
|
98
|
+
if (schema.pattern !== undefined) {
|
|
99
|
+
let regex = patterns.get(schema);
|
|
100
|
+
if (!regex) patterns.set(schema, regex = new RegExp(schema.pattern, 'u'));
|
|
101
|
+
if (!regex.test(value)) fail('pattern', 'does not match the declared pattern');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === 'number') {
|
|
106
|
+
if (schema.minimum !== undefined && value < schema.minimum) fail('minimum', `must be at least ${schema.minimum}`, { expected: schema.minimum });
|
|
107
|
+
if (schema.maximum !== undefined && value > schema.maximum) fail('maximum', `must be at most ${schema.maximum}`, { expected: schema.maximum });
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) fail('minItems', `must have at least ${schema.minItems} items`, { expected: schema.minItems });
|
|
111
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) fail('maxItems', `must have at most ${schema.maxItems} items`, { expected: schema.maxItems });
|
|
112
|
+
else if (schema.items) for (const item of value) { if (issues.length >= max) break; bodySchemaIssues(schema.items, item, `${path}/[]`, issues, max); }
|
|
113
|
+
}
|
|
114
|
+
if (isRecord(value)) {
|
|
115
|
+
const declared = schema.properties || {};
|
|
116
|
+
for (const name of schema.required || []) if (!own(value, name)) fail('required', `is missing required property ${name}`, { property: name });
|
|
117
|
+
if (schema.additionalProperties === false && Object.keys(value).some(name => !own(declared, name))) fail('additionalProperties', 'has a property the schema does not declare');
|
|
118
|
+
for (const [name, child] of Object.entries(declared)) if (own(value, name)) bodySchemaIssues(child, value[name], `${path}/${escapePointer(name)}`, issues, max);
|
|
119
|
+
}
|
|
120
|
+
return issues;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Returns fixed-wording failures for `value`, each naming only a path the
|
|
124
|
+
* schema itself declared (array positions appear as `[]`). Nothing the client
|
|
125
|
+
* sent is echoed, so the answer stays safe to render as plain text.
|
|
126
|
+
*/
|
|
127
|
+
export function checkBodySchema(schema , value ) {
|
|
128
|
+
return bodySchemaIssues(schema, value).map(bodySchemaLine);
|
|
129
|
+
}
|
|
130
|
+
/** The plain-text line for an issue: array positions print as `[]` appended to the path, root as `/`. */
|
|
131
|
+
export const bodySchemaLine = (issue ) => `${issue.pointer.replace(/\/\[\]/g, '[]') || '/'} ${issue.message}`;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Negotiation rule (conservative): the structured answer is sent only when the
|
|
135
|
+
* Accept header names application/json explicitly with q > 0 and no higher q
|
|
136
|
+
* for an explicit text/plain. Wildcard ranges, a missing header and everything
|
|
137
|
+
* else keep the plain-text answer, so curl, browsers and existing clients see
|
|
138
|
+
* no change.
|
|
139
|
+
*/
|
|
140
|
+
export function prefersJson(accept ) {
|
|
141
|
+
if (!accept || accept.length > 1024) return false;
|
|
142
|
+
let json = 0, text = 0;
|
|
143
|
+
for (const range of accept.split(',')) {
|
|
144
|
+
const [type = '', ...params] = range.split(';').map(part => part.trim().toLowerCase());
|
|
145
|
+
const q = params.find(param => param.startsWith('q='));
|
|
146
|
+
const weight = q === undefined ? 1 : /^q=(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/.test(q) ? Number(q.slice(2)) : 0; // a malformed weight never opts in
|
|
147
|
+
if (type === 'application/json') json = Math.max(json, weight);
|
|
148
|
+
else if (type === 'text/plain') text = Math.max(text, weight);
|
|
149
|
+
}
|
|
150
|
+
return json > 0 && json >= text;
|
|
151
|
+
}
|
|
152
|
+
const maxIssueBytes = 4096;
|
|
153
|
+
/** Renders the JSON answer: never more than `maxIssueBytes`, dropping trailing issues and saying so. */
|
|
154
|
+
export function bodySchemaJson(issues ) {
|
|
155
|
+
const shape = (list , truncated ) => JSON.stringify({ error: 'body_validation_failed', message: 'Request body failed validation', ...(truncated ? { truncated } : {}), issues: list });
|
|
156
|
+
let list = issues, text = shape(list, false);
|
|
157
|
+
while (new TextEncoder().encode(text).length > maxIssueBytes && list.length) { list = list.slice(0, -1); text = shape(list, true); }
|
|
158
|
+
return text;
|
|
159
|
+
}
|
package/dist/build-cloudflare.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import Ajv from 'ajv/dist/2020.js';
|
|
5
5
|
import standaloneCode from 'ajv/dist/standalone/index.js';
|
|
6
|
+
import { uuidFormat } from './body-schema.js';
|
|
6
7
|
import { loadDocument } from './config.js';
|
|
7
8
|
import { compileRoutes } from './router.js';
|
|
8
9
|
import { assert } from './errors.js';
|
|
@@ -100,6 +101,7 @@ export async function buildCloudflare(project , { out = 'dist/cloudflare'
|
|
|
100
101
|
assert(routes.length, 'No routes to build');
|
|
101
102
|
|
|
102
103
|
const ajv = new Ajv.default({ code:{ source:true, esm:true }, strict:false, allErrors:false });
|
|
104
|
+
ajv.addFormat('uuid',uuidFormat); // the same allowlisted format router.ts registers; without it the compiled validator silently ignores `format`
|
|
103
105
|
const validators = {}, serialised = [];
|
|
104
106
|
for (const route of routes) {
|
|
105
107
|
const parameters = [];
|
package/dist/capabilities.js
CHANGED
|
@@ -248,7 +248,7 @@ export const capabilityDetails = {
|
|
|
248
248
|
enabled: { kind: 'routing', summary: 'Route on/off switch; disabled routes are still validated.', schema: ['enabled'], constraints: ['Boolean; defaults to true'], grants: [] },
|
|
249
249
|
expires: { kind: 'routing', summary: 'Timestamp after which the route stops matching.', schema: ['expires'], constraints: ['UTC timestamp YYYY-MM-DDTHH:MM:SS[.mmm]Z; expired routes are still validated'], grants: [] },
|
|
250
250
|
'request.body': { kind: 'request', summary: 'Request body admission limits and format.', schema: ['request.body'],
|
|
251
|
-
constraints: ['`maxBytes` 0 to 1048576; up to 16 lowercase `contentTypes`', '`format` text or json'], grants: [] },
|
|
251
|
+
constraints: ['`maxBytes` 0 to 1048576; up to 16 lowercase `contentTypes`', '`format` text or json', '`schema` (JSON only): a bounded JSON Schema subset; failures return 422'], grants: [] },
|
|
252
252
|
'response.headers': { kind: 'request', summary: 'Static response headers added to the reply.', schema: ['response.headers'],
|
|
253
253
|
constraints: ['At most 64 headers; values up to 4096 characters or lists of at most 16', 'Cloudflare coalesces duplicate headers'], grants: [] },
|
|
254
254
|
bindings: { kind: 'binding', summary: 'Route `env` literals/references and `secrets` references.', schema: ['env', 'secrets'],
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import {scaffoldProject} from './scaffold.js';
|
|
|
15
15
|
import { initProject, addRedirect } from './authoring.js';
|
|
16
16
|
import { initProjectWith, parseWithNames } from './init-with.js';
|
|
17
17
|
import { collectDependencySet, installSteps, parsePin } from './project-dependencies.js';
|
|
18
|
-
import { runProjectTests } from './project-tests.js';
|
|
18
|
+
import { runProjectTests, startRestartable } from './project-tests.js';
|
|
19
19
|
import { verifyDeployment, failLevels } from './verify-deployment.js';
|
|
20
20
|
|
|
21
21
|
import { loadOperatorPolicy, prepareFunctionSnapshot, requestedPermissions } from './policy.js';
|
|
@@ -28,9 +28,11 @@ import { loadComplianceRules, profileNames as complianceProfiles } from './compl
|
|
|
28
28
|
import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
|
|
29
29
|
import { readFile } from 'node:fs/promises';
|
|
30
30
|
|
|
31
|
-
const usage = `URLCode 0.4.
|
|
32
|
-
urlcode init <directory> [--with ui,auth,admin] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
|
|
33
|
-
# --
|
|
31
|
+
const usage = `URLCode 0.4.7 — local/self-hosted runtime
|
|
32
|
+
urlcode init <directory> [--template page] [--with ui,auth,admin] [--ack extension:id] [--manifest|--no-manifest] [--pin @scope/pkg=specifier]
|
|
33
|
+
# --template page: the smallest project (urlcode.yaml, public/index.html, README.md, tests/requests.json), one page route; not combinable with --with
|
|
34
|
+
# --with: layered site from installed @jimhoyd/urlcode-<name> packages, with a package.json pinning them exactly; --with is an unordered set, core orders the host from each extension's declared requirements and refuses a missing requirement, conflict or cycle before writing
|
|
35
|
+
# --ack: repeatable, qualified acknowledgement of a risk an extension names when it refuses (for example store:public-write); do not pass it pre-emptively, the refusal prints the exact command. Rejected when no scaffold consumes it
|
|
34
36
|
# --manifest: also pin the runtime for a route-only project; --no-manifest: --with without a package.json
|
|
35
37
|
# --pin: record a local path or tarball instead of the registry version; repeatable. No install is ever run for you.
|
|
36
38
|
urlcode scaffold [--project directory] [--dry-run]
|
|
@@ -57,6 +59,7 @@ const usage = `URLCode 0.4.2 — local/self-hosted runtime
|
|
|
57
59
|
[--compliance baseline|strict|privacy|none] [--compliance-rules ...] [--compliance-ignore id,id] [--compliance-warn]
|
|
58
60
|
# compares the running deployment's responses with what this project declares; never follows redirects, no --insecure
|
|
59
61
|
urlcode permissions [--project directory] # inspect requested bindings and egress origins; grants nothing
|
|
62
|
+
urlcode test [--project directory] [--verbose] # quiet by default: prints failing cases and the summary; --verbose adds every request log
|
|
60
63
|
urlcode explain [/route] [--project directory] [--target self-hosted|cloudflare|aws|vercel|static] [--host-file ...] [--json]
|
|
61
64
|
# effective methods, handler, middleware, inputs, policies, cache outcome, bindings and target support from the compiled configuration
|
|
62
65
|
urlcode manifest [--project directory] [--json] # generated semantic manifest; build writes the same file as manifest.json
|
|
@@ -83,14 +86,14 @@ Dev loads .env.local and watches; serve does neither. Functions run trusted and
|
|
|
83
86
|
const print = (value ) => process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value) + '\n');
|
|
84
87
|
const options = {
|
|
85
88
|
json:{ type:'boolean' }, yaml:{ type:'boolean' }, report:{type:'string'}, 'accept-provider-differences':{type:'boolean'},
|
|
86
|
-
project:{ type:'string', default:'.' }, 'host-file':{type:'string'}, with:{type:'string'},
|
|
87
|
-
manifest:{type:'boolean'}, 'no-manifest':{type:'boolean'}, pin:{type:'string', multiple:true},
|
|
89
|
+
project:{ type:'string', default:'.' }, 'host-file':{type:'string'}, with:{type:'string'}, template:{type:'string'},
|
|
90
|
+
manifest:{type:'boolean'}, 'no-manifest':{type:'boolean'}, pin:{type:'string', multiple:true}, ack:{type:'string', multiple:true},
|
|
88
91
|
port:{ type:'string' }, host:{ type:'string', default:'127.0.0.1' },
|
|
89
92
|
'expect-routes':{type:'string'}, requests:{type:'string'}, concurrency:{type:'string'}, seconds:{type:'string'}, 'max-p95-ms':{type:'string'}, warmup:{type:'string'}, target:{type:'string'},
|
|
90
93
|
workers:{type:'string'}, 'function-timeout-ms':{type:'string'}, 'max-response-bytes':{type:'string'}, 'max-body-bytes':{type:'string'},
|
|
91
94
|
'max-in-flight':{type:'string'}, 'max-in-flight-health':{type:'string'}, 'request-log':{type:'string'}, 'trust-request-id':{type:'boolean'}, 'trusted-proxies':{type:'string'}, metrics:{type:'boolean'},
|
|
92
95
|
release:{type:'string'}, 'git-commit':{type:'string'}, 'timeout-ms':{type:'string'}, 'fail-on':{type:'string'}, 'expect-metrics':{type:'boolean'},
|
|
93
|
-
budget:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
|
|
96
|
+
budget:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, verbose:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
|
|
94
97
|
} ;
|
|
95
98
|
|
|
96
99
|
|
|
@@ -136,10 +139,17 @@ function formatExtensions(report ) {
|
|
|
136
139
|
for (const item of report.extensions) lines.push(`Registered: ${item.name} (contract ${item.version}; targets ${item.targets.join(', ') || '(none)'}; ${item.declared ? 'declared' : 'not declared'}; revision ${item.revisionPinned ? 'pinned' : 'NOT pinned'})`,
|
|
137
140
|
` mounts: ${item.mounts.join(', ') || '(none)'}`, ` policy routes: ${item.policyRoutes.join(', ') || '(none)'}`, ` credential headers: ${item.credentialHeaders.join(', ') || '(none)'}`,
|
|
138
141
|
` hooks: ${item.hooks.length ? item.hooks.map(hook => `${String((hook ).name)} (${String((hook ).kind)})`).join(', ') : '(none)'}`,
|
|
142
|
+
` authoring: ${item.authoring ? JSON.stringify(item.authoring) : '(none)'}`,
|
|
139
143
|
` configuration schema: ${JSON.stringify(item.schema)}`, ` policy schema: ${item.policySchema ? JSON.stringify(item.policySchema) : '(none)'}`);
|
|
140
144
|
lines.push(report.note);
|
|
141
145
|
return lines.join('\n') + '\n';
|
|
142
146
|
}
|
|
147
|
+
// Name the bound host and port (from the error, never user text) and a next step. Values are validated, not echoed.
|
|
148
|
+
function addressInUseMessage(error ) {
|
|
149
|
+
const { address,port } = error ;
|
|
150
|
+
const where = typeof port === 'number' && Number.isInteger(port) && port > 0 && port < 65536 ? `Port ${port}${typeof address === 'string' && /^[0-9A-Fa-f:.]{2,45}$/.test(address) ? ` on ${address}` : ''}` : 'The port';
|
|
151
|
+
return `${where} is already in use; pick another with --port N, or stop the process using it`;
|
|
152
|
+
}
|
|
143
153
|
const errorMessages = { ERR_PARSE_ARGS_UNKNOWN_OPTION:'Unknown option; use --help', EEXIST:'Destination or edit lock already exists', ENOENT:'Required file or directory not found', EADDRINUSE:'Port is already in use', EACCES:'Permission denied' };
|
|
144
154
|
let operatorHost = {};
|
|
145
155
|
let serving = false;
|
|
@@ -154,9 +164,11 @@ try {
|
|
|
154
164
|
// The MCP server and context command load and release the host themselves.
|
|
155
165
|
if (command !== 'mcp' && command !== 'context') operatorHost = await loadOperatorHost(values['host-file'], values.project);
|
|
156
166
|
}
|
|
167
|
+
if (values.template !== undefined && command !== 'init') throw new ConfigError('--template is only supported by init');
|
|
157
168
|
if (values.with !== undefined && command !== 'init') throw new ConfigError('--with is only supported by init');
|
|
158
169
|
if ((values.manifest || values['no-manifest'] || values.pin !== undefined) && command !== 'init') throw new ConfigError('--manifest/--no-manifest/--pin are only supported by init');
|
|
159
170
|
if (values.manifest && values['no-manifest']) throw new ConfigError('Use either --manifest or --no-manifest');
|
|
171
|
+
if (values.ack !== undefined && (command !== 'init' || values.with === undefined)) throw new ConfigError('--ack is only supported by init with --with');
|
|
160
172
|
if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
|
|
161
173
|
const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
|
|
162
174
|
if ((!['import','recipes','recipe','examples','example','bulk-import'].includes(command) && extra.length) || (!['init','add','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
|
|
@@ -204,7 +216,9 @@ try {
|
|
|
204
216
|
if(!['json','markdown'].includes(format))throw new ConfigError('Use --format json or markdown');
|
|
205
217
|
if(values.format!==undefined && values.compare===undefined)throw new ConfigError('--format applies to routes --compare');
|
|
206
218
|
const started=performance.now();
|
|
207
|
-
const
|
|
219
|
+
const serverOptions={...hostOptions,project:values.project,port:0,local:true,permissions,origin:values.origin,log:()=>{}};
|
|
220
|
+
// Only audit replays fixtures, so only audit needs a server its restart steps can restart.
|
|
221
|
+
const app=command==='audit'?await startRestartable(serverOptions):await startServer(serverOptions);
|
|
208
222
|
const startupMs=performance.now()-started;
|
|
209
223
|
try {
|
|
210
224
|
if(command==='routes') {
|
|
@@ -281,13 +295,15 @@ try {
|
|
|
281
295
|
const wanted = values.with === undefined ? values.manifest === true : !values['no-manifest'];
|
|
282
296
|
const pins = new Map((values.pin ?? []).map(parsePin));
|
|
283
297
|
if (pins.size && !wanted) throw new ConfigError('--pin needs a manifest; drop --no-manifest or add --manifest');
|
|
298
|
+
if (values.template !== undefined && values.template !== 'default' && values.template !== 'page') throw new ConfigError('--template must be page');
|
|
299
|
+
if (values.template === 'page' && values.with !== undefined) throw new ConfigError('--template page cannot be combined with --with');
|
|
284
300
|
if (values.with === undefined) {
|
|
285
301
|
const set = wanted ? await collectDependencySet([], [], { overrides: pins }) : undefined;
|
|
286
|
-
const created = await initProject(arg, { manifest: set });
|
|
302
|
+
const created = await initProject(arg, { manifest: set, template: values.template === 'page' ? 'page' : 'default' });
|
|
287
303
|
print(set ? { event:'created', dependencies:set.pins, nextSteps:installSteps(created, set) } : { event:'created' });
|
|
288
304
|
break;
|
|
289
305
|
}
|
|
290
|
-
const created = await initProjectWith(arg, parseWithNames(values.with), { manifest: wanted, pins });
|
|
306
|
+
const created = await initProjectWith(arg, parseWithNames(values.with), { manifest: wanted, pins, acknowledgements: values.ack ?? [] });
|
|
291
307
|
print({ event:'created', ...created, review:`Review ${created.project}/urlcode.yaml and pin its revision explicitly (for example PROJECT_SHA256=${created.projectSha256}); re-review after any project change` });
|
|
292
308
|
break;
|
|
293
309
|
}
|
|
@@ -299,7 +315,7 @@ try {
|
|
|
299
315
|
if (!arg) throw new ConfigError('Provide an HTTP(S) destination URL');
|
|
300
316
|
print({ event:'added', path:await addRedirect(values.project,arg,values.alias) }); break;
|
|
301
317
|
case 'test': {
|
|
302
|
-
const result = await runProjectTests(values.project, { ...hostOptions, log:print, permissions, origin:values.origin });
|
|
318
|
+
const result = await runProjectTests(values.project, { ...hostOptions, log:values.verbose ? print : (event ) => { if ((event ).event === 'test' && (event ).pass === false) print(event); }, permissions, origin:values.origin });
|
|
303
319
|
print(result); if (result.failed) process.exitCode = 1; break;
|
|
304
320
|
}
|
|
305
321
|
case 'doctor':
|
|
@@ -324,7 +340,7 @@ try {
|
|
|
324
340
|
}
|
|
325
341
|
} catch (error) {
|
|
326
342
|
const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
|
|
327
|
-
const message = (error instanceof ConfigError || error instanceof HttpError) ? error.message : ((code !== undefined ? errorMessages[code] : undefined) || 'Operation failed; check project files, module dependencies and command options');
|
|
343
|
+
const message = code === 'EADDRINUSE' ? addressInUseMessage(error) : (error instanceof ConfigError || error instanceof HttpError) ? error.message : ((code !== undefined ? errorMessages[code] : undefined) || 'Operation failed; check project files, module dependencies and command options');
|
|
328
344
|
process.stderr.write(JSON.stringify({ event:'error', message }) + '\n'); process.exitCode = 1;
|
|
329
345
|
} finally {
|
|
330
346
|
if (!serving) {
|
package/dist/config.js
CHANGED
|
@@ -4,8 +4,10 @@ import { resolve, relative, isAbsolute, extname } from 'node:path';
|
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { parseDocument, visit, isAlias, isScalar, isMap, isNode } from 'yaml';
|
|
6
6
|
import Ajv from 'ajv/dist/2020.js';
|
|
7
|
+
|
|
7
8
|
import { assert, ConfigError } from './errors.js';
|
|
8
|
-
|
|
9
|
+
import { reservedResponseHeaders } from './http-policy.js';
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
/** What config-worker.ts posts back: the loaded document, or the ConfigError message. */
|
|
11
13
|
|
|
@@ -14,7 +16,7 @@ import { assert, ConfigError } from './errors.js';
|
|
|
14
16
|
// The schema file is this package's own; JSON.parse gives unknown and Ajv takes it as a schema object.
|
|
15
17
|
const schema = JSON.parse(await readFile(new URL('../schemas/urlcode.schema.json', import.meta.url), 'utf8')) ;
|
|
16
18
|
// Node hands the CJS module.exports (the class) to a default import; TypeScript types it as the namespace, whose .default is the same class.
|
|
17
|
-
const validate = new Ajv.default({ allErrors: false, strict: true, strictRequired: false, allowUnionTypes: true }).compile(schema);
|
|
19
|
+
const validate = new Ajv.default({ allErrors: false, verbose: true, strict: true, strictRequired: false, allowUnionTypes: true }).compile(schema);
|
|
18
20
|
export const MAX_CONFIG_BYTES = 32 * 1024 * 1024;
|
|
19
21
|
export function parseYaml(text ) {
|
|
20
22
|
assert(Buffer.byteLength(text) <= MAX_CONFIG_BYTES, 'Configuration exceeds 32 MiB');
|
|
@@ -48,15 +50,80 @@ export function parseYaml(text ) {
|
|
|
48
50
|
inspect(data);
|
|
49
51
|
return data;
|
|
50
52
|
}
|
|
51
|
-
|
|
53
|
+
const MAX_NAMED_KEY = 64;
|
|
54
|
+
const quoteKey = (key ) => JSON.stringify(key.length > MAX_NAMED_KEY ? `${key.slice(0, MAX_NAMED_KEY)}...` : key);
|
|
55
|
+
const MAX_LISTED_KEYS = 10;
|
|
56
|
+
const editDistance = (a , b ) => {
|
|
57
|
+
let row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
58
|
+
for (let i = 1; i <= a.length; i++) {
|
|
59
|
+
const next = [i];
|
|
60
|
+
for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
61
|
+
row = next;
|
|
62
|
+
}
|
|
63
|
+
return row[b.length] ;
|
|
64
|
+
};
|
|
65
|
+
/** The allowed key an unknown key most likely meant: case-insensitive match, a prefix (3+ chars) either way, or edit distance <= 2. */
|
|
66
|
+
function closestKey(key , allowed ) {
|
|
67
|
+
if (key.length > MAX_NAMED_KEY) return undefined;
|
|
68
|
+
const k = key.toLowerCase();
|
|
69
|
+
let best ;
|
|
70
|
+
let bestScore = Infinity;
|
|
71
|
+
for (const candidate of allowed) {
|
|
72
|
+
const c = candidate.toLowerCase();
|
|
73
|
+
const prefix = Math.min(k.length, c.length) >= 3 && (c.startsWith(k) || k.startsWith(c));
|
|
74
|
+
const score = k === c ? 0 : Math.min(editDistance(k, c), prefix ? 2 : Infinity);
|
|
75
|
+
if (score <= 2 && score < bestScore) { best = candidate; bestScore = score; }
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One line for the first schema violation. Closed-key-set failures name the offending key and the keys the
|
|
81
|
+
* schema allows, and required failures name the missing key, because a bare keyword sends the reader hunting.
|
|
82
|
+
* Only key names, which come from the schema or the author's own mapping keys, are echoed, never values
|
|
83
|
+
* (values may hold secrets), and never more than MAX_NAMED_KEY characters of a key.
|
|
84
|
+
*/
|
|
85
|
+
export function describeSchemaError(e ) {
|
|
86
|
+
const base = `Invalid configuration at ${e.instancePath || '/'} (${e.keyword})`;
|
|
87
|
+
const parent = e.parentSchema ;
|
|
88
|
+
if (e.keyword === 'additionalProperties') {
|
|
89
|
+
const key = String((e.params ).additionalProperty);
|
|
90
|
+
const allowed = Object.keys(parent?.properties ?? {});
|
|
91
|
+
const close = closestKey(key, allowed);
|
|
92
|
+
let list = '; no keys are allowed here';
|
|
93
|
+
if (close) list = `; did you mean ${quoteKey(close)}?`;
|
|
94
|
+
else if (allowed.length > MAX_LISTED_KEYS) list = `; allowed keys: ${allowed.slice(0, MAX_LISTED_KEYS).join(', ')}, ... (${allowed.length - MAX_LISTED_KEYS} more)`;
|
|
95
|
+
else if (allowed.length) list = `; allowed keys: ${allowed.join(', ')}`;
|
|
96
|
+
return `${base}: unknown key ${quoteKey(key)}${list} (run urlcode schema <path> for the shape)`;
|
|
97
|
+
}
|
|
98
|
+
if (e.keyword === 'required') return `${base}: missing required key ${quoteKey(String((e.params ).missingProperty))}`;
|
|
99
|
+
return base;
|
|
100
|
+
}
|
|
101
|
+
export function validateDocument(data , inherited ) {
|
|
52
102
|
if (!validate(data)) {
|
|
53
103
|
const e = validate.errors [0] ;
|
|
54
|
-
throw new ConfigError(
|
|
104
|
+
throw new ConfigError(describeSchemaError(e));
|
|
55
105
|
}
|
|
56
106
|
const document = data ; // trust boundary: the schema just admitted it
|
|
57
|
-
for (const [
|
|
107
|
+
for (const [name, block] of Object.entries(document.shared ?? {}))
|
|
108
|
+
for (const header of Object.keys(block.response?.headers ?? {})) assert(!reservedResponseHeaders.has(header.toLowerCase()), `shared.${name}: response header ${header} is owned by the runtime or handler`);
|
|
109
|
+
for (const [pattern, route] of Object.entries(document.routes)) document.routes[pattern] = expandShared(pattern, normalizeRoute(pattern, route), inherited ?? document.shared);
|
|
58
110
|
return document;
|
|
59
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolves `use: <name>` at load time. The route's own `request` or `response` key wins as a whole block
|
|
114
|
+
* (no deep merge); otherwise the shared block's key is copied in. `use` is removed, so the route hash,
|
|
115
|
+
* audit and routes output show what actually applies. An unknown name fails validation.
|
|
116
|
+
*/
|
|
117
|
+
function expandShared(pattern , route , shared ) {
|
|
118
|
+
if (route.use === undefined) return route;
|
|
119
|
+
const block = shared !== undefined && Object.hasOwn(shared, route.use) ? shared[route.use] : undefined;
|
|
120
|
+
assert(block, `${pattern}: use references unknown shared block ${route.use}`);
|
|
121
|
+
const { use: _use, ...rest } = route;
|
|
122
|
+
const result = { ...rest };
|
|
123
|
+
if (rest.request === undefined && block.request !== undefined) result.request = structuredClone(block.request);
|
|
124
|
+
if (rest.response === undefined && block.response !== undefined) result.response = structuredClone(block.response);
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
60
127
|
/** The input declaration a short-form function gets for each `{param}` it does not declare itself. */
|
|
61
128
|
export const SHORT_FORM_PATH_SCHEMA = { type: 'string', minLength: 1, maxLength: 128 } ;
|
|
62
129
|
// A short-form path is checked here, before the file system, so the error can name the route.
|
|
@@ -185,9 +252,10 @@ export async function loadDocumentInWorker(project )
|
|
|
185
252
|
const path = await safeFile(root, include);
|
|
186
253
|
assert(!files.includes(path), 'Duplicate include');
|
|
187
254
|
files.push(path);
|
|
188
|
-
const part = validateDocument(await readConfig(path, budget));
|
|
255
|
+
const part = validateDocument(await readConfig(path, budget), document.shared ?? {});
|
|
189
256
|
assert(!part.includes?.length, 'Nested includes are unsupported');
|
|
190
257
|
assert(part.site===undefined, 'site may only be set in the entry urlcode.yaml');
|
|
258
|
+
assert(part.shared===undefined, 'shared may only be set in the entry urlcode.yaml');
|
|
191
259
|
for(const [name,extension]of Object.entries(part.extensions??{})){assert(!Object.hasOwn(extensions,name),'Duplicate extension declaration across files');extensions[name]=extension;assert(Object.keys(extensions).length<=16,'Maximum 16 extensions per project');}
|
|
192
260
|
for (const [pattern, route] of Object.entries(part.routes)) {
|
|
193
261
|
assert(!Object.hasOwn(routes, pattern), 'Duplicate route across files');
|
package/dist/context.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
2
|
import {relative} from 'node:path';
|
|
3
3
|
import {stringify} from 'yaml';
|
|
4
4
|
import {loadDocument} from './config.js';
|
|
@@ -51,6 +51,7 @@ const constraints ={
|
|
|
51
51
|
pathShape:{value:'exact or {param}',note:'No greedy captures or general-purpose wildcards; a segment is a literal or a named placeholder'},
|
|
52
52
|
wildcardMounts:{value:false,note:'Only static and extension routes mount a subtree; nothing else matches below its path'},
|
|
53
53
|
yamlInterpolation:{value:false,note:'No ${...} templating; bind typed inputs through parameters, args and context'},
|
|
54
|
+
builtInBeforeCode:{value:true,note:'Check built-ins before writing code: policies.security (security headers), cacheControl (four fixed values on page/download/static), request.body (size, type, JSON), methods, policies.throttle/agents/compression/cache, site (robots, sitemap, favicon, security.txt); no native storage or CORS'},
|
|
54
55
|
secretsByOperatorGrant:{value:true,note:'Projects request named env and secret bindings; only an operator policy pinned to the project revision grants them'},
|
|
55
56
|
};
|
|
56
57
|
const routesOf=(table ) =>[...table.exact.values(),...[...table.byLength.values()].flat(),...table.mounts];
|
|
@@ -146,10 +147,7 @@ function fitBudget(context ,budget ) {
|
|
|
146
147
|
if(!fits())throw new Error(`Context budget ${budget} is below the smallest rendering`);
|
|
147
148
|
return omitted.length?{...context,omitted}:context;
|
|
148
149
|
}
|
|
149
|
-
/** Estimated size of the shipped documentation
|
|
150
|
+
/** Estimated size of the shipped offline documentation bundle, for comparison with an emitted context. */
|
|
150
151
|
export async function documentationTokens() {
|
|
151
|
-
|
|
152
|
-
for(const name of (await readdir(docs)).filter(name=>name.endsWith('.md')).sort())chars+=(await readFile(new URL(name,docs),'utf8')).length;
|
|
153
|
-
chars+=(await readFile(new URL('../llms.txt',import.meta.url),'utf8')).length;
|
|
154
|
-
return Math.ceil(chars/4);
|
|
152
|
+
return Math.ceil((await readFile(new URL('../llms-full.txt',import.meta.url),'utf8')).length/4);
|
|
155
153
|
}
|
package/dist/errors.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export class ConfigError extends Error {}
|
|
2
2
|
export class HttpError extends Error {
|
|
3
3
|
status ;
|
|
4
|
-
|
|
4
|
+
/** A pre-rendered, fixed-content answer that replaces the plain-text message (used for negotiated 422 bodies). */
|
|
5
|
+
answer ;
|
|
6
|
+
constructor(status , message , answer ) { super(message); this.status = status; this.answer = answer; }
|
|
5
7
|
}
|
|
6
8
|
export function assert(condition , message ) {
|
|
7
9
|
if (!condition) throw new ConfigError(message);
|
package/dist/examples.js
CHANGED
|
@@ -21,7 +21,7 @@ import {assert} from './errors.js';
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
// Fixed package data, like the recipe catalog: names come from here, metadata from each example.yaml.
|
|
24
|
-
export const exampleNames=['assets','aws','cloudflare','compliance','conditions','cookbook','egress','extensions','monitoring','prerender','provider-conformance','tunnel','vercel'] ;
|
|
24
|
+
export const exampleNames=['assets','aws','body-validation','cloudflare','compliance','conditions','cookbook','coverage-waiver','data-dir','egress','extensions','lifecycle','monitoring','not-found','prerender','provider-conformance','shared-blocks','tunnel','vercel'] ;
|
|
25
25
|
export const routeIndexFile='route-index.json';
|
|
26
26
|
const examplesRoot=fileURLToPath(new URL('../examples/',import.meta.url));
|
|
27
27
|
const root=(name )=>examplesRoot+name+'/';
|
package/dist/extensions.js
CHANGED
|
@@ -65,6 +65,27 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
|
|
|
65
65
|
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
|
|
69
|
+
/** One project-owned customization surface, shown to people and authoring agents by CLI/MCP inspection. */
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Machine-readable guidance for changing an installed extension without
|
|
81
|
+
* copying its behavior into the application. This is descriptive only: it
|
|
82
|
+
* grants nothing and is never executed by the runtime.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
68
89
|
|
|
69
90
|
/** Shared schema for project hook references. Omission means trusted execution. */
|
|
70
91
|
export const extensionHookReferenceSchema={
|
|
@@ -124,6 +145,8 @@ export async function loadExtensionHooks (config
|
|
|
124
145
|
|
|
125
146
|
|
|
126
147
|
|
|
148
|
+
|
|
149
|
+
|
|
127
150
|
|
|
128
151
|
|
|
129
152
|
|
|
@@ -151,20 +174,39 @@ export async function loadExtensionHooks (config
|
|
|
151
174
|
|
|
152
175
|
|
|
153
176
|
|
|
154
|
-
|
|
177
|
+
|
|
155
178
|
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
156
186
|
|
|
157
187
|
|
|
158
188
|
|
|
159
189
|
|
|
160
190
|
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
161
203
|
|
|
162
204
|
|
|
163
205
|
|
|
164
206
|
|
|
165
207
|
|
|
166
208
|
|
|
167
|
-
|
|
209
|
+
|
|
168
210
|
|
|
169
211
|
|
|
170
212
|
|
|
@@ -175,6 +217,7 @@ export async function loadExtensionHooks (config
|
|
|
175
217
|
|
|
176
218
|
const namePattern=/^[a-z][a-z0-9-]{0,63}$/;
|
|
177
219
|
const hookNamePattern=/^[a-z][A-Za-z0-9]{0,63}$/;
|
|
220
|
+
const authoringNamePattern=/^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,127}$/;
|
|
178
221
|
const cacheHeaders=new Set(['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control']);
|
|
179
222
|
const segmentPattern=/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
|
|
180
223
|
export const immutableCacheControl='public, max-age=31536000, immutable';
|
|
@@ -230,6 +273,21 @@ export function prepareExtensions(document ,routes
|
|
|
230
273
|
assert(hook.inputSchema&&typeof hook.inputSchema==='object'&&(!hook.outputSchema||typeof hook.outputSchema==='object'),'Invalid extension hook contract');
|
|
231
274
|
hookNames.add(hook.name);
|
|
232
275
|
}
|
|
276
|
+
if(registration.authoring!==undefined){
|
|
277
|
+
const authoring=registration.authoring;
|
|
278
|
+
assert(authoring&&typeof authoring==='object'&&typeof authoring.description==='string'&&authoring.description.length>=1&&authoring.description.length<=1024,'Invalid extension authoring contract');
|
|
279
|
+
assert(Array.isArray(authoring.surfaces)&&authoring.surfaces.length<=64,'Invalid extension authoring surfaces');
|
|
280
|
+
const surfaceNames=new Set ();
|
|
281
|
+
for(const surface of authoring.surfaces){
|
|
282
|
+
assert(surface&&typeof surface==='object'&&['configuration','theme','copy','component','template','stylesheet','hook','extension'].includes(surface.kind),'Invalid extension authoring surface kind');
|
|
283
|
+
assert(typeof surface.name==='string'&&authoringNamePattern.test(surface.name)&&!surfaceNames.has(surface.name),'Invalid extension authoring surface name');
|
|
284
|
+
assert(typeof surface.description==='string'&&surface.description.length>=1&&surface.description.length<=1024,'Invalid extension authoring surface description');
|
|
285
|
+
assert(surface.path===undefined||typeof surface.path==='string'&&surface.path.length>=1&&surface.path.length<=1024,'Invalid extension authoring surface path');
|
|
286
|
+
assert(surface.command===undefined||typeof surface.command==='string'&&surface.command.length>=1&&surface.command.length<=2048,'Invalid extension authoring surface command');
|
|
287
|
+
surfaceNames.add(surface.name);
|
|
288
|
+
}
|
|
289
|
+
assert(authoring.fastChecks===undefined||Array.isArray(authoring.fastChecks)&&authoring.fastChecks.length<=32&&authoring.fastChecks.every(check=>typeof check==='string'&&check.length>=1&&check.length<=2048),'Invalid extension authoring fast checks');
|
|
290
|
+
}
|
|
233
291
|
provided.set(registration.name,registration);
|
|
234
292
|
}
|
|
235
293
|
const declarations=document.extensions??{};
|