@jimhoyd/urlcode 0.4.1 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/urlcode-authoring/SKILL.md +60 -7
- package/.claude/skills/urlcode-operations/SKILL.md +4 -0
- package/README.md +19 -15
- package/SECURITY.md +5 -3
- package/dist/BUILD-MANIFEST.json +31 -28
- 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 +29 -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 +138 -3
- 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/policy.js +16 -0
- package/dist/project-tests.js +35 -11
- package/dist/readiness.js +188 -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 +3 -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 +105 -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 +71 -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 +2 -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 +436 -149
- package/llms.txt +44 -6
- package/package.json +15 -22
- 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 +53 -26
- 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 -112
- package/ROADMAP.md +0 -66
- package/docs/AI-AUTHORING.md +0 -338
- 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 -278
- package/docs/CONDITIONS.md +0 -74
- package/docs/DEPLOYMENT-CHECKS.md +0 -108
- package/docs/DEVELOPMENT-PIPELINE.md +0 -270
- package/docs/EGRESS.md +0 -125
- package/docs/EXTENSIONS.md +0 -438
- package/docs/FRAMEWORK.md +0 -217
- package/docs/FUNCTION-SECURITY.md +0 -254
- 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 -224
- 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 -150
- package/docs/README.md +0 -87
- 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-READINESS.md +0 -117
- package/docs/RELEASE-SECURITY.md +0 -96
- 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 -211
- 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 -80
- 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/README.md +0 -28
- 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 -120
- 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.6 — 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.1 — 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
|
|
|
@@ -135,10 +138,18 @@ function formatExtensions(report ) {
|
|
|
135
138
|
if (!report.declared.length) lines.push('Declared: (none)');
|
|
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)'}`,
|
|
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)'}`,
|
|
138
143
|
` configuration schema: ${JSON.stringify(item.schema)}`, ` policy schema: ${item.policySchema ? JSON.stringify(item.policySchema) : '(none)'}`);
|
|
139
144
|
lines.push(report.note);
|
|
140
145
|
return lines.join('\n') + '\n';
|
|
141
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
|
+
}
|
|
142
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' };
|
|
143
154
|
let operatorHost = {};
|
|
144
155
|
let serving = false;
|
|
@@ -153,9 +164,11 @@ try {
|
|
|
153
164
|
// The MCP server and context command load and release the host themselves.
|
|
154
165
|
if (command !== 'mcp' && command !== 'context') operatorHost = await loadOperatorHost(values['host-file'], values.project);
|
|
155
166
|
}
|
|
167
|
+
if (values.template !== undefined && command !== 'init') throw new ConfigError('--template is only supported by init');
|
|
156
168
|
if (values.with !== undefined && command !== 'init') throw new ConfigError('--with is only supported by init');
|
|
157
169
|
if ((values.manifest || values['no-manifest'] || values.pin !== undefined) && command !== 'init') throw new ConfigError('--manifest/--no-manifest/--pin are only supported by init');
|
|
158
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');
|
|
159
172
|
if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
|
|
160
173
|
const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
|
|
161
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');
|
|
@@ -203,7 +216,9 @@ try {
|
|
|
203
216
|
if(!['json','markdown'].includes(format))throw new ConfigError('Use --format json or markdown');
|
|
204
217
|
if(values.format!==undefined && values.compare===undefined)throw new ConfigError('--format applies to routes --compare');
|
|
205
218
|
const started=performance.now();
|
|
206
|
-
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);
|
|
207
222
|
const startupMs=performance.now()-started;
|
|
208
223
|
try {
|
|
209
224
|
if(command==='routes') {
|
|
@@ -280,13 +295,15 @@ try {
|
|
|
280
295
|
const wanted = values.with === undefined ? values.manifest === true : !values['no-manifest'];
|
|
281
296
|
const pins = new Map((values.pin ?? []).map(parsePin));
|
|
282
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');
|
|
283
300
|
if (values.with === undefined) {
|
|
284
301
|
const set = wanted ? await collectDependencySet([], [], { overrides: pins }) : undefined;
|
|
285
|
-
const created = await initProject(arg, { manifest: set });
|
|
302
|
+
const created = await initProject(arg, { manifest: set, template: values.template === 'page' ? 'page' : 'default' });
|
|
286
303
|
print(set ? { event:'created', dependencies:set.pins, nextSteps:installSteps(created, set) } : { event:'created' });
|
|
287
304
|
break;
|
|
288
305
|
}
|
|
289
|
-
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 ?? [] });
|
|
290
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` });
|
|
291
308
|
break;
|
|
292
309
|
}
|
|
@@ -298,7 +315,7 @@ try {
|
|
|
298
315
|
if (!arg) throw new ConfigError('Provide an HTTP(S) destination URL');
|
|
299
316
|
print({ event:'added', path:await addRedirect(values.project,arg,values.alias) }); break;
|
|
300
317
|
case 'test': {
|
|
301
|
-
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 });
|
|
302
319
|
print(result); if (result.failed) process.exitCode = 1; break;
|
|
303
320
|
}
|
|
304
321
|
case 'doctor':
|
|
@@ -323,7 +340,7 @@ try {
|
|
|
323
340
|
}
|
|
324
341
|
} catch (error) {
|
|
325
342
|
const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
|
|
326
|
-
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');
|
|
327
344
|
process.stderr.write(JSON.stringify({ event:'error', message }) + '\n'); process.exitCode = 1;
|
|
328
345
|
} finally {
|
|
329
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+'/';
|