@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
package/dist/readiness.js
CHANGED
|
@@ -27,12 +27,27 @@ import { runCompliance } from './compliance.js';
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
|
|
31
|
+
|
|
30
32
|
|
|
33
|
+
/** Where a captured value comes from: a dotted path into a JSON response body, or one response header. */
|
|
34
|
+
|
|
35
|
+
/** A step that closes and restarts the runtime on the same project and data directory. */
|
|
36
|
+
|
|
37
|
+
/** An ordered fixture: requests that share captured values, optionally with restarts between them. */
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
export const isStepsFixture = (fixture ) => 'steps' in fixture;
|
|
41
|
+
export const isRestartable = (app ) => typeof (app ).restart === 'function';
|
|
42
|
+
const isRestart = (step ) => 'restart' in step;
|
|
31
43
|
|
|
32
|
-
|
|
44
|
+
/** `captured` holds values from a step's `capture`; callers use it for substitution only and never print it. */
|
|
45
|
+
|
|
33
46
|
|
|
34
47
|
/** A started server as the audit and benchmark see it. structural: the real type is startServer's result in src/server.ts. */
|
|
35
48
|
|
|
49
|
+
/** An app that can also close and restart itself on the same project and data directory (fixture `restart` steps). */
|
|
50
|
+
|
|
36
51
|
|
|
37
52
|
|
|
38
53
|
|
|
@@ -40,9 +55,18 @@ import { runCompliance } from './compliance.js';
|
|
|
40
55
|
|
|
41
56
|
|
|
42
57
|
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
|
|
59
|
+
|
|
45
60
|
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
46
70
|
|
|
47
71
|
|
|
48
72
|
|
|
@@ -70,7 +94,7 @@ function routeAdvisories(route ) {
|
|
|
70
94
|
const advisories = [];
|
|
71
95
|
const runsCode = Boolean(route.function) || Boolean(route.middleware?.length);
|
|
72
96
|
if (runsCode && route.methods.includes('POST') && route.request?.body && !route.sandbox && !route.sandboxReason) {
|
|
73
|
-
advisories.push("This route accepts POST with a declared request.body policy but declares neither sandbox: true nor sandboxReason; consider whether this route needs sandbox: true.");
|
|
97
|
+
advisories.push("This route accepts POST with a declared request.body policy but declares neither sandbox: true nor sandboxReason; consider whether this route needs sandbox: true. If it is trusted first-party code (anything that touches the filesystem must be trusted; a sandbox has no filesystem), add to the route: sandboxReason: \"Reviewed first-party code; trusted deliberately.\" If it isolates untrusted input, add sandbox: true and a sandboxReason saying why.");
|
|
74
98
|
}
|
|
75
99
|
return advisories;
|
|
76
100
|
}
|
|
@@ -80,6 +104,7 @@ export function projectPlan(compiled )
|
|
|
80
104
|
const inventory = routes.map(route => { const advisories = routeAdvisories(route); return { path:route.pattern, handler:handlers.find(key => route[key]), methods:route.methods, middleware:route.middleware?.length || 0,
|
|
81
105
|
policies:[...(route.policy ? Object.keys(route.policy.describe) : []),...(route.extensionPolicyNames??[]).map(name=>`extensions.${name}`)],
|
|
82
106
|
sandbox:route.sandbox === true, ...(route.sandboxReason ? { sandboxReason:route.sandboxReason } : {}),
|
|
107
|
+
...(route.coveredElsewhere ? { coveredElsewhere:route.coveredElsewhere } : {}),
|
|
83
108
|
...(route.generated ? { generated:route.generated } : {}),
|
|
84
109
|
...(advisories.length ? { advisories } : {}),
|
|
85
110
|
state:route.enabled === false ? 'disabled' : route.expiresAt && now >= route.expiresAt ? 'expired' : 'active' }; });
|
|
@@ -112,7 +137,33 @@ export function projectPlan(compiled )
|
|
|
112
137
|
}
|
|
113
138
|
return {inventory,cases,resolve:path => matchRoute(compiled,parseTarget(path))?.route.pattern};
|
|
114
139
|
}
|
|
115
|
-
|
|
140
|
+
// Bounds for ordered fixtures. A fixture file is data a project author (or an agent) wrote;
|
|
141
|
+
// none of these limits is a security boundary, they keep a mistake from becoming an unbounded run.
|
|
142
|
+
const MAX_STEPS = 50, MAX_RESTARTS = 5, MAX_TOTAL_RESTARTS = 20, MAX_CAPTURES = 16, MAX_CAPTURE_BYTES = 4096, MAX_CAPTURE_BODY = 1024 * 1024;
|
|
143
|
+
const nameShape = /^[A-Za-z_][A-Za-z0-9_]{0,31}$/, pathShape = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/, control = /[\x00-\x1f\x7f]/;
|
|
144
|
+
const templates = (text ) => [...text.matchAll(/\{\{([A-Za-z_][A-Za-z0-9_]{0,31})\}\}/g)].map(m => m[1] ?? '');
|
|
145
|
+
const templated = (test ) => [test.path, test.body, test.expectBody, ...Object.values(test.headers ?? {}), ...Object.values(test.expectHeaders ?? {})].filter((v) => v !== undefined);
|
|
146
|
+
function checkCase(test , inSteps ) {
|
|
147
|
+
assert(isRecord(test), 'Invalid request test');
|
|
148
|
+
// A step path may start with a {{name}} (a captured Location); it is checked again once filled in.
|
|
149
|
+
assert(typeof test.path === 'string' && (test.path.startsWith('/') || (inSteps && test.path.startsWith('{{'))) && !test.path.startsWith('//') && !/[\r\n]/.test(test.path), 'Test path must be local');
|
|
150
|
+
assert(Number.isInteger(test.status) && typeof test.status === 'number' && test.status >= 200 && test.status <= 599, 'Test must declare an HTTP status');
|
|
151
|
+
assert(!test.method || (typeof test.method === 'string' && ['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(test.method)), 'Invalid test method');
|
|
152
|
+
assert(test.body === undefined || typeof test.body === 'string', 'Test body must be text');
|
|
153
|
+
assert(test.expectBody === undefined || typeof test.expectBody === 'string', 'Expected body must be text');
|
|
154
|
+
for (const headers of [test.headers,test.expectHeaders]) assert(headers === undefined || (isRecord(headers) && Object.values(headers).every(v => typeof v === 'string')), 'Test headers must be string mappings');
|
|
155
|
+
assert(inSteps || test.capture === undefined, 'capture is only valid inside steps');
|
|
156
|
+
if (test.capture !== undefined) {
|
|
157
|
+
assert(isRecord(test.capture) && Object.keys(test.capture).length <= MAX_CAPTURES, `capture must be a mapping of at most ${MAX_CAPTURES} names`);
|
|
158
|
+
for (const [name,spec] of Object.entries(test.capture)) {
|
|
159
|
+
assert(nameShape.test(name), 'Capture names use letters, digits and underscores (at most 32, not starting with a digit)');
|
|
160
|
+
assert(isRecord(spec) && Object.keys(spec).length === 1, 'A capture is exactly one of {json: "a.b.0.c"} or {header: "name"}');
|
|
161
|
+
if (typeof spec.json === 'string') assert(spec.json.length <= 256 && pathShape.test(spec.json), 'Capture json path is dotted keys and array indexes, such as items.0.id');
|
|
162
|
+
else assert(typeof spec.header === 'string' && /^[A-Za-z0-9-]{1,64}$/.test(spec.header), 'A capture is exactly one of {json: "a.b.0.c"} or {header: "name"}');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export async function readFixtures(root , optional = false) {
|
|
116
167
|
if(optional) {
|
|
117
168
|
try {await lstat(join(root,'tests/requests.json'));}
|
|
118
169
|
catch(error){if(error instanceof Error && 'code' in error && error.code==='ENOENT')return [];throw error;}
|
|
@@ -122,16 +173,94 @@ export async function readCases(root , optional = false)
|
|
|
122
173
|
assert(bytes.length <= 16*1024*1024, 'Request fixture file exceeds 16 MiB');
|
|
123
174
|
const cases = JSON.parse(bytes.toString('utf8'));
|
|
124
175
|
assert(Array.isArray(cases) && cases.length <= 10000 && (optional || cases.length), 'Request tests must be an array (maximum 10000)');
|
|
176
|
+
let requests = 0, restarts = 0;
|
|
125
177
|
for (const test of cases ) {
|
|
126
|
-
|
|
127
|
-
assert(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
178
|
+
if (!(isRecord(test) && 'steps' in test)) { checkCase(test,false); requests++; continue; }
|
|
179
|
+
assert(Object.keys(test).length === 1 && Array.isArray(test.steps) && test.steps.length >= 1 && test.steps.length <= MAX_STEPS, `steps must be the only key and hold 1-${MAX_STEPS} steps`);
|
|
180
|
+
const known = new Set (); let here = 0;
|
|
181
|
+
for (const step of test.steps ) {
|
|
182
|
+
if (isRecord(step) && 'restart' in step) {
|
|
183
|
+
assert(step.restart === true && Object.keys(step).length === 1, 'A restart step is exactly {"restart": true}');
|
|
184
|
+
assert(++here <= MAX_RESTARTS && ++restarts <= MAX_TOTAL_RESTARTS, `At most ${MAX_RESTARTS} restarts per fixture and ${MAX_TOTAL_RESTARTS} per file`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
checkCase(step,true); requests++;
|
|
188
|
+
for (const name of templated(step).flatMap(templates)) assert(known.has(name), 'A {{name}} reference needs an earlier step in the same fixture to capture it');
|
|
189
|
+
for (const name of Object.keys(step.capture ?? {})) known.add(name);
|
|
190
|
+
}
|
|
133
191
|
}
|
|
134
|
-
|
|
192
|
+
assert(requests <= 10000, 'Request tests exceed 10000 requests in total');
|
|
193
|
+
return cases ; // trust boundary: fixture JSON, validated field by field above
|
|
194
|
+
}
|
|
195
|
+
/** Single-request fixtures only: the benchmark replays these and cannot run ordered steps. */
|
|
196
|
+
export async function readCases(root , optional = false) {
|
|
197
|
+
return (await readFixtures(root,optional)).filter((fixture) => !isStepsFixture(fixture));
|
|
198
|
+
}
|
|
199
|
+
/** One request a fixture run sent: `test` is the request as sent (captured values filled in), `original` as written. Print `original`, never `test`. */
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
const fill = (text , values ) => {
|
|
209
|
+
let missing = false;
|
|
210
|
+
const out = text.replace(/\{\{([A-Za-z_][A-Za-z0-9_]{0,31})\}\}/g, (_, name ) => { const v = values.get(name); if (v === undefined) missing = true; return v ?? ''; });
|
|
211
|
+
return missing ? undefined : out;
|
|
212
|
+
};
|
|
213
|
+
function resolveStep(test , values ) {
|
|
214
|
+
const map = (headers ) => {
|
|
215
|
+
if (headers === undefined) return undefined;
|
|
216
|
+
const out = {};
|
|
217
|
+
for (const [k,v] of Object.entries(headers)) { const f = fill(v,values); if (f === undefined) return null; out[k] = f; }
|
|
218
|
+
return out;
|
|
219
|
+
};
|
|
220
|
+
const path = fill(test.path,values), body = test.body === undefined ? undefined : fill(test.body,values), expectBody = test.expectBody === undefined ? undefined : fill(test.expectBody,values);
|
|
221
|
+
const headers = map(test.headers), expectHeaders = map(test.expectHeaders);
|
|
222
|
+
if (path === undefined || !path.startsWith('/') || path.startsWith('//') || /[\r\n]/.test(path) || headers === null || expectHeaders === null || (test.body !== undefined && body === undefined) || (test.expectBody !== undefined && expectBody === undefined)) return undefined;
|
|
223
|
+
return { ...test, path, headers, body, expectHeaders, expectBody };
|
|
224
|
+
}
|
|
225
|
+
/** Runs every fixture in file order. A step after a failed step in the same fixture is reported failed with error `skipped` and never sent, so a broken chain cannot pass. */
|
|
226
|
+
export async function runFixtures(fixtures , host , visit , firstCase = 1) {
|
|
227
|
+
let n = firstCase;
|
|
228
|
+
for (const [f,fixture] of fixtures.entries()) {
|
|
229
|
+
if (!isStepsFixture(fixture)) { await visit({case:n++,fixture:f+1,test:fixture,original:fixture,result:await hit(host.app,fixture,host.agent,host.target)}); continue; }
|
|
230
|
+
if (fixture.steps.some(isRestart) && host.restart === undefined) {
|
|
231
|
+
const reason = 'contains a restart step, which needs a runtime this host can close and restart';
|
|
232
|
+
assert(host.skipped !== undefined, `Fixture ${f+1} ${reason}`);
|
|
233
|
+
host.skipped(f+1,reason); continue;
|
|
234
|
+
}
|
|
235
|
+
const values = new Map (); let broken = false;
|
|
236
|
+
for (const step of fixture.steps) {
|
|
237
|
+
if (isRestart(step)) { if (!broken) { try { await host.restart?.(); } catch { broken = true; } } continue; }
|
|
238
|
+
const resolved = broken ? undefined : resolveStep(step,values);
|
|
239
|
+
let result = {pass:false,status:0,durationMs:0,error:'skipped'};
|
|
240
|
+
if (resolved) {
|
|
241
|
+
const sent = await hit(host.app,resolved,host.agent,host.target);
|
|
242
|
+
if (sent.pass) for (const [name,value] of Object.entries(sent.captured ?? {})) values.set(name,value);
|
|
243
|
+
const {captured: _kept, ...visible} = sent; result = visible;
|
|
244
|
+
} else if (!broken) result = {pass:false,status:0,durationMs:0,error:'unresolved'};
|
|
245
|
+
if (!result.pass) broken = true;
|
|
246
|
+
await visit({case:n++,fixture:f+1,test:resolved ?? step,original:step,result});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function extract(spec , headers , body ) {
|
|
251
|
+
let value ;
|
|
252
|
+
if ('header' in spec) value = headers[spec.header.toLowerCase()];
|
|
253
|
+
else {
|
|
254
|
+
if (body.length > MAX_CAPTURE_BODY) return undefined;
|
|
255
|
+
try { value = JSON.parse(body.toString('utf8')); } catch { return undefined; }
|
|
256
|
+
for (const key of spec.json.split('.')) {
|
|
257
|
+
if (Array.isArray(value)) value = /^\d+$/.test(key) ? value[Number(key)] : undefined;
|
|
258
|
+
else if (isRecord(value) && Object.hasOwn(value,key)) value = value[key];
|
|
259
|
+
else return undefined;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const text = typeof value === 'string' ? value : (typeof value === 'number' && Number.isFinite(value)) || typeof value === 'boolean' ? String(value) : undefined;
|
|
263
|
+
return text !== undefined && text.length > 0 && Buffer.byteLength(text) <= MAX_CAPTURE_BYTES && !control.test(text) ? text : undefined;
|
|
135
264
|
}
|
|
136
265
|
export function benchmarkTarget(value ) {
|
|
137
266
|
let url ;
|
|
@@ -152,10 +281,17 @@ export function hit(app ,test ,agent ,target
|
|
|
152
281
|
: {host:'127.0.0.1',port:app.address.port,path:test.path,method:test.method || 'GET',headers:{'user-agent':probeAgent,...(test.headers || {})},agent,timeout:10000};
|
|
153
282
|
req=send(options,(res )=>{
|
|
154
283
|
let size=0;const chunks =[];
|
|
155
|
-
res.on('data',(chunk )=>{size+=chunk.length;if(size>16*1024*1024)res.destroy(new Error('Response limit'));else if(test.expectBody!==undefined)chunks.push(chunk);});
|
|
284
|
+
res.on('data',(chunk )=>{size+=chunk.length;if(size>16*1024*1024)res.destroy(new Error('Response limit'));else if(test.expectBody!==undefined || (test.capture && size<=MAX_CAPTURE_BODY))chunks.push(chunk);});
|
|
156
285
|
res.on('error',fail);
|
|
157
|
-
res.on('end',()=>
|
|
158
|
-
|
|
286
|
+
res.on('end',()=>{
|
|
287
|
+
const status=res.statusCode ?? 0,durationMs=performance.now()-began,body=Buffer.concat(chunks);
|
|
288
|
+
let pass=status===test.status && Object.entries(test.expectHeaders || {}).every(([k,v])=>res.headers[k.toLowerCase()]===v) && (test.expectBody===undefined || body.toString()===test.expectBody);
|
|
289
|
+
if(!pass || !test.capture)return resolve({status,durationMs,pass});
|
|
290
|
+
// Values are kept only for later steps; a missing one fails the step without saying what the response held.
|
|
291
|
+
const captured ={};
|
|
292
|
+
for(const [name,spec] of Object.entries(test.capture)){const value=extract(spec,res.headers,body);if(value===undefined){pass=false;break;}captured[name]=value;}
|
|
293
|
+
resolve(pass?{status,durationMs,pass,captured}:{status,durationMs,pass,error:'capture'});
|
|
294
|
+
});
|
|
159
295
|
});
|
|
160
296
|
req.on('error',fail);req.on('timeout',()=>req?.destroy(new Error('Timeout')));req.end(test.body);
|
|
161
297
|
} catch { req?.destroy();fail(); }
|
|
@@ -167,32 +303,48 @@ export function hit(app ,test ,agent ,target
|
|
|
167
303
|
// folded into it: the exit code decision belongs to the caller.
|
|
168
304
|
export async function auditProject(app , {expectRoutes,log=()=>{},compliance} = {}) {
|
|
169
305
|
const began=performance.now();
|
|
170
|
-
const plan=app.testPlan(), fixtures=await
|
|
306
|
+
const plan=app.testPlan(), fixtures=await readFixtures(app.root,true);
|
|
171
307
|
const metadata=new Map(plan.inventory.map(r=>[r.path,r]));
|
|
172
|
-
const
|
|
308
|
+
const covered=new Set (), unassertedCases =[];let passed=0,failed=0,checks=0;
|
|
173
309
|
const agent=new Agent({keepAlive:true,maxSockets:1});
|
|
310
|
+
// One accounting for generated cases and fixture steps, single or ordered: a step counts as
|
|
311
|
+
// a check, and covers a route/method only when it passes and asserts the response. Coverage
|
|
312
|
+
// uses the route the substituted path actually matched.
|
|
313
|
+
const record=(n ,test ,result ,source ) =>{
|
|
314
|
+
checks++;const method=test.method || 'GET';
|
|
315
|
+
let route ;try {route=plan.resolve(test.path);} catch { /* Invalid-path negative fixture. */ }
|
|
316
|
+
const meta=route===undefined?undefined:metadata.get(route);
|
|
317
|
+
// Error-only fixtures cannot prove a function's normal path works.
|
|
318
|
+
const assertsResponse=test.expectBody!==undefined || Object.keys(test.expectHeaders || {}).length>0;
|
|
319
|
+
if(result.pass && meta?.state==='active' && result.status<400 && !assertsResponse)unassertedCases.push(n);
|
|
320
|
+
if(result.pass && assertsResponse && meta?.state==='active' && (result.status<400 || (meta.handler==='respond' && source==='generated')))covered.add(JSON.stringify([route,method]));
|
|
321
|
+
if(result.pass)passed++;else failed++;
|
|
322
|
+
log({event:'check',case:n,source,pass:result.pass,status:result.status,expectedStatus:test.status});
|
|
323
|
+
};
|
|
174
324
|
try {
|
|
175
|
-
for (const [i,test] of cases.entries())
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const meta=route===undefined?undefined:metadata.get(route);
|
|
179
|
-
// Error-only fixtures cannot prove a function's normal path works.
|
|
180
|
-
const assertsResponse=test.expectBody!==undefined || Object.keys(test.expectHeaders || {}).length>0;
|
|
181
|
-
if(result.pass && meta?.state==='active' && result.status<400 && !assertsResponse)unassertedCases.push(i+1);
|
|
182
|
-
if(result.pass && assertsResponse && meta?.state==='active' && (result.status<400 || (meta.handler==='respond' && i<plan.cases.length)))covered.add(JSON.stringify([route,method]));
|
|
183
|
-
if(result.pass)passed++;else failed++;
|
|
184
|
-
log({event:'check',case:i+1,source:i<plan.cases.length?'generated':'fixture',pass:result.pass,status:result.status,expectedStatus:test.status});
|
|
185
|
-
}
|
|
325
|
+
for (const [i,test] of plan.cases.entries()) record(i+1,test,await hit(app,test,agent),'generated');
|
|
326
|
+
const restart=isRestartable(app)?()=>app.restart():undefined;
|
|
327
|
+
await runFixtures(fixtures,{app,agent,restart},step=>record(step.case,step.test,step.result,'fixture'),plan.cases.length+1);
|
|
186
328
|
} finally {agent.destroy();}
|
|
187
|
-
const
|
|
188
|
-
const
|
|
329
|
+
const missing=plan.inventory.filter(r=>r.state==='active').flatMap(r=>r.methods.filter(m=>!covered.has(JSON.stringify([r.path,m]))).map(method=>({route:r.path,method})));
|
|
330
|
+
const waivedRouteMethods =[],ignoredWaivers =[],redundantWaivers =[];
|
|
331
|
+
const uncovered=missing.filter(({route,method})=>{
|
|
332
|
+
const reason=metadata.get(route)?.coveredElsewhere?.[method];
|
|
333
|
+
if(reason===undefined)return true;
|
|
334
|
+
// A waiver excuses a missing fixture only where the route is otherwise shown to work normally.
|
|
335
|
+
const proven=[...covered].some(key=>(JSON.parse(key) )[0]===route);
|
|
336
|
+
(proven?waivedRouteMethods:ignoredWaivers).push({route,method,reason});
|
|
337
|
+
return !proven;
|
|
338
|
+
});
|
|
339
|
+
for(const r of plan.inventory)if(r.state==='active')for(const [method,reason] of Object.entries(r.coveredElsewhere??{}))if(covered.has(JSON.stringify([r.path,method])))redundantWaivers.push({route:r.path,method,reason});
|
|
340
|
+
const counts ={configured:plan.inventory.length,declared:plan.inventory.filter(r=>!r.generated).length,generated:plan.inventory.filter(r=>r.generated).length,active:0,disabled:0,expired:0,byHandler:{}};
|
|
189
341
|
for(const route of plan.inventory){counts[route.state]++;const handler=String(route.handler);counts.byHandler[handler]=(counts.byHandler[handler]||0)+1;}
|
|
190
342
|
const countMatches=expectRoutes===undefined || counts.configured===expectRoutes;
|
|
191
343
|
const advisories=plan.inventory.flatMap(route=>(route.advisories??[]).map(message=>({route:route.path,message})));
|
|
192
344
|
// The per-route capability table: which policies apply and whether this
|
|
193
345
|
// host enforces, compiles or delegates each one. Refusals never get here.
|
|
194
346
|
const notReadyReasons=[...(counts.active>0?[]:['no-active-routes']),...(countMatches?[]:['route-count-mismatch']),...(failed?['failed-checks']:[]),...(uncovered.length?['uncovered-route-methods']:[])];
|
|
195
|
-
return {elapsedMs:performance.now()-began,ready:!notReadyReasons.length,notReadyReasons,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks
|
|
347
|
+
return {elapsedMs:performance.now()-began,ready:!notReadyReasons.length,notReadyReasons,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,waivedRouteMethods,ignoredWaivers,redundantWaivers,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null,advisories};
|
|
196
348
|
}
|
|
197
349
|
export async function benchmarkProject(app ,{requests=1000,concurrency=2,maxP95Ms,seconds=30,warmup=0,target} ={}) {
|
|
198
350
|
assert(Number.isInteger(requests)&&requests>=1&&requests<=100000,'Requests must be 1–100000');
|
package/dist/recipes.js
CHANGED
|
@@ -12,7 +12,7 @@ import {assert} from './errors.js';
|
|
|
12
12
|
|
|
13
13
|
// This fixed local catalog is trusted package data, never an executable registry:
|
|
14
14
|
// names come from here, metadata from each recipe's schema-checked recipe.yaml.
|
|
15
|
-
export const recipeNames=['redirect','json-api','typescript','middleware','health-page','static-plus-api','cors-api','webhook-receiver','contact-form','authenticated-json-api','protected-download'] ;
|
|
15
|
+
export const recipeNames=['redirect','json-api','typescript','middleware','health-page','static-page','static-plus-api','cors-api','webhook-receiver','contact-form','authenticated-json-api','protected-download','store-crud'] ;
|
|
16
16
|
const recipesRoot=fileURLToPath(new URL('../recipes/',import.meta.url));
|
|
17
17
|
const root=(name )=>recipesRoot+name+'/';
|
|
18
18
|
async function metadata(name ) {const value=await readMetadata(root(name),name,'recipe.yaml');return {name,...value};}
|
package/dist/router.js
CHANGED
|
@@ -7,6 +7,8 @@ import { normalizeMatch, assertDisjointMatches } from './conditions.js';
|
|
|
7
7
|
import { effectivePolicies } from './policies.js';
|
|
8
8
|
import { setImmediate as yieldTurn } from 'node:timers/promises';
|
|
9
9
|
import { compileHttp } from './http-policy.js';
|
|
10
|
+
import { assertSafePattern, maxPatternInputLength } from './pattern-guard.js';
|
|
11
|
+
import { uuidFormat } from './body-schema.js';
|
|
10
12
|
import Ajv from 'ajv/dist/2020.js';
|
|
11
13
|
import { assert } from './errors.js';
|
|
12
14
|
import { functionFile } from './config.js';
|
|
@@ -64,6 +66,7 @@ export async function compileRoutes(loaded , bindings
|
|
|
64
66
|
const exact = new Map (), dynamic = [], mounts = [], modules = new Map ();
|
|
65
67
|
// Node hands the CJS module.exports (the class) to a default import; TypeScript types it as the namespace, whose .default is the same class.
|
|
66
68
|
const ajv = new Ajv.default({ strict: false, allErrors: false }), validators = new Map ();
|
|
69
|
+
ajv.addFormat('uuid', uuidFormat);
|
|
67
70
|
for (const [pattern, config] of Object.entries(loaded.routes)) {
|
|
68
71
|
if (++processed % 64 === 0) await yieldTurn();
|
|
69
72
|
assert(performance.now()<deadline, 'Route compilation deadline exceeded');
|
|
@@ -78,6 +81,14 @@ export async function compileRoutes(loaded , bindings
|
|
|
78
81
|
const { redirect: declaredRedirect, function: declaredFunction, ...declared } = config;
|
|
79
82
|
const route = { ...declared, pattern, parts, names, specificity: parts.length - names.length,
|
|
80
83
|
methods: config.methods || methodsDefault, parameters: [], env: dict(), secrets: dict(), responseHeaders: [], middleware: [] };
|
|
84
|
+
if (config.coveredElsewhere) {
|
|
85
|
+
const waived = Object.entries(config.coveredElsewhere);
|
|
86
|
+
assert(waived.length > 0, 'coveredElsewhere must name at least one method');
|
|
87
|
+
for (const [method, reason] of waived) {
|
|
88
|
+
assert(route.methods.includes(method), `coveredElsewhere names ${method}, which is not one of the route's methods`);
|
|
89
|
+
assert(typeof reason === 'string' && reason.trim().length > 0, `coveredElsewhere.${method} needs a non-empty reason`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
81
92
|
compileHttp(route);
|
|
82
93
|
if (config.match) route.match = normalizeMatch(config.match);
|
|
83
94
|
if(config.extension){assert(!config.middleware?.length&&!config.parameters?.length&&!config.env&&!config.secrets,'Extension handlers cannot declare guest middleware, parameters or bindings');assert(pattern.endsWith('/*')&&!names.length&&pattern!=='/*','Extension handler requires a non-root literal /* mount');}
|
|
@@ -113,6 +124,12 @@ export async function compileRoutes(loaded , bindings
|
|
|
113
124
|
assert(param.in !== 'path' || !own(schema, 'default'), 'Path parameters cannot have defaults');
|
|
114
125
|
assert(!['minLength','maxLength'].some(k => own(schema,k)) || schema.type === 'string', 'String bounds require string type');
|
|
115
126
|
assert(!['minimum','maximum'].some(k => own(schema,k)) || ['integer','number'].includes(schema.type), 'Numeric bounds require numeric type');
|
|
127
|
+
assert(!['pattern','format'].some(k => own(schema,k)) || schema.type === 'string', 'pattern and format require string type');
|
|
128
|
+
assert(!own(schema,'format') || schema.format === 'uuid', 'Unsupported parameter format (supported: uuid)');
|
|
129
|
+
if (own(schema,'pattern')) {
|
|
130
|
+
assert(typeof schema.pattern === 'string', 'pattern must be a string'); assertSafePattern(schema.pattern);
|
|
131
|
+
assert(typeof schema.maxLength === 'number' && schema.maxLength <= maxPatternInputLength, `pattern requires maxLength of at most ${maxPatternInputLength}`);
|
|
132
|
+
}
|
|
116
133
|
assert(!own(schema,'maxItems') || schema.type === 'array', 'maxItems requires array type');
|
|
117
134
|
const p = { ...param, name, required: param.required === true, validate: inputValidator(schema, ajv, validators) };
|
|
118
135
|
if (own(schema, 'default')) assert(p.validate(schema.default), 'Invalid parameter default');
|
package/dist/runtime.js
CHANGED
|
@@ -26,7 +26,7 @@ import { createObserverSink } from './observability.js';
|
|
|
26
26
|
|
|
27
27
|
import { applySite } from './site.js';
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
|
|
@@ -48,7 +48,22 @@ import { applySite } from './site.js';
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
51
55
|
|
|
56
|
+
function withDataDirGrant(loaded , projectSha256 , given ) {
|
|
57
|
+
// An operator policy pinned to another revision stays as it is: it denies, exactly as it would without this option.
|
|
58
|
+
if (given && given.projectSha256 !== projectSha256) return given;
|
|
59
|
+
const routes = { ...(given?.routes ?? {}) };
|
|
60
|
+
for (const [pattern, route] of Object.entries(loaded.routes)) {
|
|
61
|
+
if (!Object.values(route.env || {}).some(ref => ref.env === 'URLCODE_DATA_DIR')) continue;
|
|
62
|
+
const grant = routes[pattern] ?? {};
|
|
63
|
+
routes[pattern] = { ...grant, env: [...new Set([...(grant.env ?? []), 'URLCODE_DATA_DIR'])] };
|
|
64
|
+
}
|
|
65
|
+
return { version: 1, projectSha256, routes };
|
|
66
|
+
}
|
|
52
67
|
/** Per-request facts the host may read after handle() settles; never request text. */
|
|
53
68
|
|
|
54
69
|
|
|
@@ -93,7 +108,8 @@ export async function createRuntime(project , rawOptions
|
|
|
93
108
|
const egressGrants=authorizeEgress(loaded,snapshot.projectSha256,options.permissions);
|
|
94
109
|
const extensionPlan=prepareExtensions(loaded.document,loaded.routes,options.extensions,{origin:options.origin??'',target:options.target??'node',projectSha256:snapshot.projectSha256,root:loaded.root});
|
|
95
110
|
const bindings = await loadBindings(loaded.root, options.local, options.environment);
|
|
96
|
-
const
|
|
111
|
+
const notFoundPage = loaded.document.site?.notFound !== undefined && loaded.document.site.notFound !== null;
|
|
112
|
+
const compiled = await compileRoutes(loaded, bindings, options.grantDataDir ? withDataDirGrant(loaded, snapshot.projectSha256, options.permissions) : options.permissions, snapshot.projectSha256, options.extensions);
|
|
97
113
|
const routes = [...compiled.mounts, ...compiled.exact.values(), ...[...compiled.byLength.values()].flat()];
|
|
98
114
|
const assets = await compileAssets(loaded.root, routes);
|
|
99
115
|
// Host policies compile after assets so a policy can see what a route serves
|
|
@@ -195,7 +211,14 @@ export async function createRuntime(project , rawOptions
|
|
|
195
211
|
try {
|
|
196
212
|
const parsed = parseTarget(target);
|
|
197
213
|
const match = matchRoute(compiled, parsed);
|
|
198
|
-
if (!match)
|
|
214
|
+
if (!match) {
|
|
215
|
+
// site.notFound: answer an unmatched GET/HEAD with the configured page and status 404.
|
|
216
|
+
if (notFoundPage && (method === 'GET' || method === 'HEAD')) {
|
|
217
|
+
const page = await (this ).handle({ target: '/404.html', method, headers, headerCounts, trace: {}, origin, ...(client ? { client } : {}) });
|
|
218
|
+
return { ...page, status: 404 };
|
|
219
|
+
}
|
|
220
|
+
throw new HttpError(404, 'Not found');
|
|
221
|
+
}
|
|
199
222
|
const { route, path } = match;
|
|
200
223
|
// Configured pattern only; never the request path, query or parameter values.
|
|
201
224
|
trace.route = route.pattern;
|
package/dist/scaffold.js
CHANGED
|
Binary file
|
package/dist/server.js
CHANGED
|
@@ -2,8 +2,9 @@ import http from 'node:http';
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
-
import { readdir, lstat } from 'node:fs/promises';
|
|
6
|
-
import {
|
|
5
|
+
import { readdir, lstat, mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
7
8
|
import { createRuntime } from './runtime.js';
|
|
8
9
|
|
|
9
10
|
import { createJsonLogger } from './logging.js';
|
|
@@ -19,6 +20,12 @@ import { compileTrustedProxies, resolveClient } from './client-address.js';
|
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
22
29
|
|
|
23
30
|
|
|
24
31
|
|
|
@@ -78,7 +85,23 @@ function originForm(target ) {
|
|
|
78
85
|
if (rest === undefined) return target;
|
|
79
86
|
return rest === '' || rest.startsWith('?') ? '/' + rest : rest;
|
|
80
87
|
}
|
|
81
|
-
|
|
88
|
+
/** Starts the server. `port: 0` picks a free port, so this is also the in-process helper for tests:
|
|
89
|
+
* `startServer({ project, port: 0, local: true, isolateData: true })`. */
|
|
90
|
+
export async function startServer(options = {}) {
|
|
91
|
+
const { dataDir, isolateData, ...rest } = options;
|
|
92
|
+
if (dataDir === undefined && !isolateData) return startServerCore(rest);
|
|
93
|
+
assert(dataDir === undefined || !isolateData, 'Use dataDir or isolateData, not both');
|
|
94
|
+
assert(dataDir === undefined || (typeof dataDir === 'string' && dataDir !== '' && !dataDir.includes('\0')), 'Data directory must be a path');
|
|
95
|
+
const owned = dataDir === undefined;
|
|
96
|
+
const dir = owned ? await mkdtemp(join(tmpdir(), 'urlcode-data-')) : resolvePath(dataDir);
|
|
97
|
+
try {
|
|
98
|
+
if (!owned) await mkdir(dir, { recursive: true });
|
|
99
|
+
const app = await startServerCore({ ...rest, environment: { ...(rest.environment ?? process.env), URLCODE_DATA_DIR: dir }, grantDataDir: true });
|
|
100
|
+
if (!owned) return app;
|
|
101
|
+
return { ...app, close: async () => { try { await app.close(); } finally { await rm(dir, { recursive: true, force: true }); } } };
|
|
102
|
+
} catch (error) { if (owned) await rm(dir, { recursive: true, force: true }); throw error; }
|
|
103
|
+
}
|
|
104
|
+
async function startServerCore({ project = '.', host = '127.0.0.1', port = 3000, watch = false,
|
|
82
105
|
local = false, log = createJsonLogger(),
|
|
83
106
|
maxBodyBytes = 1048576, maxInFlightRequests = 64, maxInFlightHealthRequests = 16,
|
|
84
107
|
requestLog = 'minimal', trustRequestId = false, origin, trustedProxies = [],
|
package/dist/site.js
CHANGED
|
Binary file
|
package/dist/tooling.js
CHANGED
|
@@ -68,7 +68,7 @@ export async function previewImport(options ) {return importR
|
|
|
68
68
|
export async function previewExport(project ,format ,acceptProviderDifferences=false) {const loaded=await loadDocument(project);const {includes:_includes,...document}=loaded.document;return exportRoutes({format,document:{...document,routes:loaded.routes},acceptProviderDifferences});}
|
|
69
69
|
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
/** Reports registered extension contracts against the project's declarations. Never activates an extension. */
|
|
@@ -85,6 +85,7 @@ export async function describeExtensions(project ,registrations
|
|
|
85
85
|
credentialHeaders:Array.isArray(registration.credentialHeaders)?registration.credentialHeaders.map(String):[],
|
|
86
86
|
schema:structuredClone(registration.schema??{}),policySchema:registration.policySchema?structuredClone(registration.policySchema):null,
|
|
87
87
|
hooks:structuredClone(registration.hooks??[]) ,
|
|
88
|
+
authoring:registration.authoring?structuredClone(registration.authoring) :null,
|
|
88
89
|
declared:Object.hasOwn(loaded.document.extensions??{},registration.name),revisionPinned:registration.projectSha256===projectSha256,
|
|
89
90
|
mounts:mountsOf(registration.name),policyRoutes:policyRoutesOf(registration.name),
|
|
90
91
|
}));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export declare function listSkills(): {
|
|
2
|
+
name: "urlcode";
|
|
3
|
+
description: "Author URLCode projects declaratively, retrieve only the required contract, and validate the result.";
|
|
4
|
+
}[];
|
|
5
|
+
export declare function getSkill(name: string): Promise<{
|
|
6
|
+
name: "urlcode";
|
|
7
|
+
description: "Author URLCode projects declaratively, retrieve only the required contract, and validate the result.";
|
|
8
|
+
content: string;
|
|
9
|
+
}>;
|
|
10
|
+
/** Deterministic lexical search over a deliberately small, agent-facing corpus. */
|
|
11
|
+
export declare function searchDocs(query: string): Promise<{
|
|
12
|
+
query: string;
|
|
13
|
+
results: {
|
|
14
|
+
id: "security" | "authoring" | "llms" | "yaml-reference" | "tooling";
|
|
15
|
+
title: "URLCode agent index" | "AI authoring" | "YAML reference" | "Tooling and local MCP" | "Function security";
|
|
16
|
+
summary: "Compact map of the framework, its declarative primitives and the minimum reference to load next." | "Declarative-first authoring workflow, retrieval order and framework constraints." | "Generated inventory of accepted URLCode YAML fields." | "Bounded local project inspection, validation and MCP tool behavior." | "Trusted versus sandboxed function behavior, bindings and operator grants.";
|
|
17
|
+
matched: string[];
|
|
18
|
+
excerpt: string;
|
|
19
|
+
}[];
|
|
20
|
+
}>;
|
|
21
|
+
/** Returns the two smallest high-value files of a fixed packaged example. */
|
|
22
|
+
export declare function getExample(name: string): Promise<{
|
|
23
|
+
metadata: import("./examples.ts").ExampleSummary;
|
|
24
|
+
content: Record<string, string>;
|
|
25
|
+
}>;
|
|
26
|
+
/** Validates only supplied YAML syntax and the versioned document schema. It never resolves includes or reads source files. */
|
|
27
|
+
export declare function validateYaml(text: string): {
|
|
28
|
+
valid: boolean;
|
|
29
|
+
scope: string;
|
|
30
|
+
version: "1";
|
|
31
|
+
routeCount: number;
|
|
32
|
+
error?: never;
|
|
33
|
+
} | {
|
|
34
|
+
valid: boolean;
|
|
35
|
+
scope: string;
|
|
36
|
+
error: string;
|
|
37
|
+
version?: never;
|
|
38
|
+
routeCount?: never;
|
|
39
|
+
};
|
|
40
|
+
/** Short deterministic remediation for common validator output; no model call or project read occurs. */
|
|
41
|
+
export declare function explainError(error: string): {
|
|
42
|
+
guidance: string;
|
|
43
|
+
nextTools: string[];
|
|
44
|
+
};
|
|
@@ -5,6 +5,8 @@ export interface InitOptions {
|
|
|
5
5
|
* initialization stays the default: a project whose runtime is managed elsewhere gets no manifest at all.
|
|
6
6
|
*/
|
|
7
7
|
manifest?: DependencySet | undefined;
|
|
8
|
+
/** `default` (function, middleware, redirect) or `page`: urlcode.yaml, public/index.html, a README and fixtures only. */
|
|
9
|
+
template?: 'default' | 'page' | undefined;
|
|
8
10
|
}
|
|
9
|
-
export declare function initProject(destination: string, { manifest }?: InitOptions): Promise<string>;
|
|
11
|
+
export declare function initProject(destination: string, { manifest, template }?: InitOptions): Promise<string>;
|
|
10
12
|
export declare function addRedirect(project: string, destination: string, alias?: string | undefined): Promise<string>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON Schema subset a route may declare for `request.body.schema`.
|
|
3
|
+
* It is interpreted here rather than compiled by Ajv so the same code runs on
|
|
4
|
+
* every host, including the Worker, with no code generation and no author
|
|
5
|
+
* regex outside `pattern-guard.ts`.
|
|
6
|
+
*/
|
|
7
|
+
export interface BodySchema {
|
|
8
|
+
type?: 'object' | 'array' | 'string' | 'integer' | 'number' | 'boolean' | 'null';
|
|
9
|
+
properties?: Record<string, BodySchema>;
|
|
10
|
+
required?: string[];
|
|
11
|
+
additionalProperties?: boolean;
|
|
12
|
+
items?: BodySchema;
|
|
13
|
+
enum?: (string | number | boolean | null)[];
|
|
14
|
+
minLength?: number;
|
|
15
|
+
maxLength?: number;
|
|
16
|
+
pattern?: string;
|
|
17
|
+
format?: 'uuid';
|
|
18
|
+
minimum?: number;
|
|
19
|
+
maximum?: number;
|
|
20
|
+
minItems?: number;
|
|
21
|
+
maxItems?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare const uuidFormat: RegExp;
|
|
24
|
+
/** Rejects, at load time, any schema outside the supported subset or its limits. */
|
|
25
|
+
export declare function assertBodySchema(schema: unknown): asserts schema is BodySchema;
|
|
26
|
+
/**
|
|
27
|
+
* One validation failure. `pointer` is an RFC 6901 pointer built only from names
|
|
28
|
+
* the schema declared (array positions appear as `[]`, not an index), `keyword`
|
|
29
|
+
* is the schema keyword that failed, and `expected` is the schema's own
|
|
30
|
+
* constraint. Nothing the client sent is ever placed in an issue.
|
|
31
|
+
*/
|
|
32
|
+
export interface BodySchemaIssue {
|
|
33
|
+
pointer: string;
|
|
34
|
+
keyword: string;
|
|
35
|
+
message: string;
|
|
36
|
+
expected?: string | number | (string | number | boolean | null)[];
|
|
37
|
+
property?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Structured failures for `value`; `checkBodySchema` renders the same list as text. */
|
|
40
|
+
export declare function bodySchemaIssues(schema: BodySchema, value: unknown, path?: string, issues?: BodySchemaIssue[], max?: number): BodySchemaIssue[];
|
|
41
|
+
/**
|
|
42
|
+
* Returns fixed-wording failures for `value`, each naming only a path the
|
|
43
|
+
* schema itself declared (array positions appear as `[]`). Nothing the client
|
|
44
|
+
* sent is echoed, so the answer stays safe to render as plain text.
|
|
45
|
+
*/
|
|
46
|
+
export declare function checkBodySchema(schema: BodySchema, value: unknown): string[];
|
|
47
|
+
/** The plain-text line for an issue: array positions print as `[]` appended to the path, root as `/`. */
|
|
48
|
+
export declare const bodySchemaLine: (issue: BodySchemaIssue) => string;
|
|
49
|
+
/**
|
|
50
|
+
* Negotiation rule (conservative): the structured answer is sent only when the
|
|
51
|
+
* Accept header names application/json explicitly with q > 0 and no higher q
|
|
52
|
+
* for an explicit text/plain. Wildcard ranges, a missing header and everything
|
|
53
|
+
* else keep the plain-text answer, so curl, browsers and existing clients see
|
|
54
|
+
* no change.
|
|
55
|
+
*/
|
|
56
|
+
export declare function prefersJson(accept: string | null | undefined): boolean;
|
|
57
|
+
/** Renders the JSON answer: never more than `maxIssueBytes`, dropping trailing issues and saying so. */
|
|
58
|
+
export declare function bodySchemaJson(issues: BodySchemaIssue[]): string;
|