@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
package/dist/extensions.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import Ajv from 'ajv/dist/2020.js';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
2
4
|
import { assert, HttpError } from './errors.js';
|
|
3
|
-
import { loadDocument } from './config.js';
|
|
5
|
+
import { functionFile, loadDocument } from './config.js';
|
|
4
6
|
import { prepareFunctionSnapshot } from './policy.js';
|
|
5
7
|
import { validateHeaderName, validateHeaderValue } from './header-validation.js';
|
|
6
8
|
|
|
@@ -52,9 +54,99 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
|
|
|
52
54
|
* vary on credentials. Everything else under the mount stays no-store.
|
|
53
55
|
*/
|
|
54
56
|
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
/** Machine-readable contract for one project hook an extension exposes. */
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
/** One project-owned customization surface, shown to people and authoring agents by CLI/MCP inspection. */
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Machine-readable guidance for changing an installed extension without
|
|
81
|
+
* copying its behavior into the application. This is descriptive only: it
|
|
82
|
+
* grants nothing and is never executed by the runtime.
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
/** Shared schema for project hook references. Omission means trusted execution. */
|
|
91
|
+
export const extensionHookReferenceSchema={
|
|
92
|
+
oneOf:[
|
|
93
|
+
{type:'string',minLength:1,maxLength:1024},
|
|
94
|
+
{type:'object',additionalProperties:false,required:['source'],properties:{
|
|
95
|
+
source:{type:'string',minLength:1,maxLength:1024},
|
|
96
|
+
export:{type:'string',pattern:'^[A-Za-z_][A-Za-z0-9_]*$'},
|
|
97
|
+
sandbox:{type:'boolean'},
|
|
98
|
+
sandboxReason:{type:'string',minLength:1,maxLength:512},
|
|
99
|
+
}},
|
|
100
|
+
],
|
|
101
|
+
} ;
|
|
102
|
+
/** Builds the strict `config.hooks` schema from an extension's declared hook names. */
|
|
103
|
+
export function extensionHooksSchema(contracts ) {
|
|
104
|
+
return {type:'object',additionalProperties:false,properties:Object.fromEntries(contracts.map(contract=>[contract.name,extensionHookReferenceSchema]))};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Loads project hooks once per activation. Project hooks are trusted first-party
|
|
108
|
+
* code by default, matching function/middleware routes. Sandboxed arbitrary-value
|
|
109
|
+
* hooks are not part of contract v1 and are refused rather than run trusted.
|
|
110
|
+
*/
|
|
111
|
+
export async function loadExtensionHooks (config ,contracts ,context ) {
|
|
112
|
+
const known=new Map(contracts.map(contract=>[contract.name,contract]));
|
|
113
|
+
const hooks =Object.create(null) ;
|
|
114
|
+
if(config===undefined)return hooks ;
|
|
115
|
+
assert(config&&typeof config==='object'&&!Array.isArray(config),'Extension hooks must be an object');
|
|
116
|
+
const epoch=randomUUID();
|
|
117
|
+
for(const [name,raw] of Object.entries(config)){
|
|
118
|
+
const contract=known.get(name);assert(contract,`Unknown extension hook: ${name}`);
|
|
119
|
+
assert(typeof raw==='string'||raw&&typeof raw==='object'&&!Array.isArray(raw),`Invalid extension hook: ${name}`);
|
|
120
|
+
const reference =typeof raw==='string'?{source:raw}:raw ;
|
|
121
|
+
if(reference.sandbox===true)throw new Error(`hook ${name}: sandbox: true is not supported for extension hooks; hooks run trusted by default`);
|
|
122
|
+
const exportName=reference.export??'default';
|
|
123
|
+
let modulePath ;
|
|
124
|
+
try{modulePath=await functionFile(context.root,reference.source);}
|
|
125
|
+
catch(error){throw new Error(`hook ${name}: failed to load module "${reference.source}"`,{cause:error});}
|
|
126
|
+
let module ;
|
|
127
|
+
try{module=await import(pathToFileURL(modulePath).href+'?urlcode-extension-hook-epoch='+epoch) ;}
|
|
128
|
+
catch(error){throw new Error(`hook ${name}: failed to load module "${reference.source}"`,{cause:error});}
|
|
129
|
+
const fn=module[exportName];
|
|
130
|
+
if(typeof fn!=='function')throw new Error(`hook ${name}: export "${exportName}" in "${reference.source}" is not a function`);
|
|
131
|
+
const ajv=new Ajv.default({strict:false,allErrors:false});
|
|
132
|
+
const validateInput=ajv.compile(contract.inputSchema);
|
|
133
|
+
const validateOutput=contract.outputSchema?ajv.compile(contract.outputSchema):undefined;
|
|
134
|
+
hooks[name]=(input ) =>{
|
|
135
|
+
assert(validateInput(input),`Invalid extension hook input: ${name}`);
|
|
136
|
+
const validate=(output ) =>{if(validateOutput)assert(validateOutput(output),`Invalid extension hook output: ${name}`);return output;};
|
|
137
|
+
const output=(fn )(input);
|
|
138
|
+
return output instanceof Promise?output.then(validate):validate(output);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return hooks ;
|
|
142
|
+
}
|
|
55
143
|
|
|
56
144
|
|
|
57
145
|
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
58
150
|
|
|
59
151
|
|
|
60
152
|
|
|
@@ -82,20 +174,39 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
|
|
|
82
174
|
|
|
83
175
|
|
|
84
176
|
|
|
85
|
-
|
|
177
|
+
|
|
86
178
|
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
87
186
|
|
|
88
187
|
|
|
89
188
|
|
|
90
189
|
|
|
91
190
|
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
92
203
|
|
|
93
204
|
|
|
94
205
|
|
|
95
206
|
|
|
96
207
|
|
|
97
208
|
|
|
98
|
-
|
|
209
|
+
|
|
99
210
|
|
|
100
211
|
|
|
101
212
|
|
|
@@ -105,6 +216,8 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
|
|
|
105
216
|
|
|
106
217
|
|
|
107
218
|
const namePattern=/^[a-z][a-z0-9-]{0,63}$/;
|
|
219
|
+
const hookNamePattern=/^[a-z][A-Za-z0-9]{0,63}$/;
|
|
220
|
+
const authoringNamePattern=/^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,127}$/;
|
|
108
221
|
const cacheHeaders=new Set(['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control']);
|
|
109
222
|
const segmentPattern=/^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
|
|
110
223
|
export const immutableCacheControl='public, max-age=31536000, immutable';
|
|
@@ -153,6 +266,28 @@ export function prepareExtensions(document ,routes
|
|
|
153
266
|
assert(registration.version==='1'&&typeof registration.activate==='function','Invalid extension version or activation hook');
|
|
154
267
|
assert(Array.isArray(registration.targets)&®istration.targets.every(target=>['node','aws','vercel'].includes(target)),'Extension targets must be node, aws or vercel');
|
|
155
268
|
assert(typeof registration.projectSha256==='string'&&/^[a-f0-9]{64}$/.test(registration.projectSha256),'Extension requires an explicit operator revision pin');
|
|
269
|
+
const hookNames=new Set ();
|
|
270
|
+
for(const hook of registration.hooks??[]){
|
|
271
|
+
assert(hook&&typeof hook==='object'&&hookNamePattern.test(hook.name)&&!hookNames.has(hook.name),'Invalid extension hook contract');
|
|
272
|
+
assert(['filter','action'].includes(hook.kind)&&typeof hook.description==='string'&&hook.description.length>=1&&hook.description.length<=512,'Invalid extension hook contract');
|
|
273
|
+
assert(hook.inputSchema&&typeof hook.inputSchema==='object'&&(!hook.outputSchema||typeof hook.outputSchema==='object'),'Invalid extension hook contract');
|
|
274
|
+
hookNames.add(hook.name);
|
|
275
|
+
}
|
|
276
|
+
if(registration.authoring!==undefined){
|
|
277
|
+
const authoring=registration.authoring;
|
|
278
|
+
assert(authoring&&typeof authoring==='object'&&typeof authoring.description==='string'&&authoring.description.length>=1&&authoring.description.length<=1024,'Invalid extension authoring contract');
|
|
279
|
+
assert(Array.isArray(authoring.surfaces)&&authoring.surfaces.length<=64,'Invalid extension authoring surfaces');
|
|
280
|
+
const surfaceNames=new Set ();
|
|
281
|
+
for(const surface of authoring.surfaces){
|
|
282
|
+
assert(surface&&typeof surface==='object'&&['configuration','theme','copy','component','template','stylesheet','hook','extension'].includes(surface.kind),'Invalid extension authoring surface kind');
|
|
283
|
+
assert(typeof surface.name==='string'&&authoringNamePattern.test(surface.name)&&!surfaceNames.has(surface.name),'Invalid extension authoring surface name');
|
|
284
|
+
assert(typeof surface.description==='string'&&surface.description.length>=1&&surface.description.length<=1024,'Invalid extension authoring surface description');
|
|
285
|
+
assert(surface.path===undefined||typeof surface.path==='string'&&surface.path.length>=1&&surface.path.length<=1024,'Invalid extension authoring surface path');
|
|
286
|
+
assert(surface.command===undefined||typeof surface.command==='string'&&surface.command.length>=1&&surface.command.length<=2048,'Invalid extension authoring surface command');
|
|
287
|
+
surfaceNames.add(surface.name);
|
|
288
|
+
}
|
|
289
|
+
assert(authoring.fastChecks===undefined||Array.isArray(authoring.fastChecks)&&authoring.fastChecks.length<=32&&authoring.fastChecks.every(check=>typeof check==='string'&&check.length>=1&&check.length<=2048),'Invalid extension authoring fast checks');
|
|
290
|
+
}
|
|
156
291
|
provided.set(registration.name,registration);
|
|
157
292
|
}
|
|
158
293
|
const declarations=document.extensions??{};
|
package/dist/http-policy.js
CHANGED
|
@@ -2,9 +2,11 @@ import { validateHeaderName, validateHeaderValue } from './header-validation.js'
|
|
|
2
2
|
import { assert, HttpError } from './errors.js';
|
|
3
3
|
|
|
4
4
|
|
|
5
|
+
import { assertBodySchema, bodySchemaIssues, bodySchemaLine, bodySchemaJson, prefersJson } from './body-schema.js';
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
|
|
8
10
|
/** A static reply compiled from `respond`; the body is bytes so every host, including the Worker, shares the type. */
|
|
9
11
|
|
|
10
12
|
/** The declared HTTP surface of a route: response headers, request body policy and a static reply. */
|
|
@@ -18,14 +20,14 @@ import { assert, HttpError } from './errors.js';
|
|
|
18
20
|
const encoder = new TextEncoder();
|
|
19
21
|
const byteLength = (value ) => encoder.encode(value).length;
|
|
20
22
|
|
|
21
|
-
const
|
|
23
|
+
export const reservedResponseHeaders = new Set(['connection','keep-alive','transfer-encoding','content-length','upgrade','trailer','proxy-authenticate','proxy-authorization','te','location','allow','content-range','accept-ranges','etag','last-modified','content-encoding','x-request-id','x-content-type-options']);
|
|
22
24
|
export function compileHttp(route ) {
|
|
23
25
|
const seen = new Set (); let size = 0;
|
|
24
26
|
const responseHeaders = route.responseHeaders = [];
|
|
25
27
|
for (const [name,value] of Object.entries(route.response?.headers || {})) {
|
|
26
28
|
const key = name.toLowerCase();
|
|
27
29
|
assert(!seen.has(key), 'Duplicate response header (case insensitive)'); seen.add(key);
|
|
28
|
-
assert(!
|
|
30
|
+
assert(!reservedResponseHeaders.has(key), 'Response header is owned by the runtime or handler');
|
|
29
31
|
assert(!Array.isArray(value) || key === 'set-cookie', 'Only Set-Cookie supports a header array');
|
|
30
32
|
assert(!(route.page || route.static || route.download) || !['content-type','content-disposition','cache-control'].includes(key), 'Configure asset metadata on its handler');
|
|
31
33
|
for (const item of Array.isArray(value) ? value : [value]) {
|
|
@@ -36,6 +38,11 @@ export function compileHttp(route ) {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
assert(size <= 16384, 'Response headers exceed 16 KiB');
|
|
41
|
+
const bodySchema = route.request?.body?.schema;
|
|
42
|
+
if (bodySchema !== undefined) {
|
|
43
|
+
assert(route.request?.body?.format === 'json', 'request.body.schema requires format json');
|
|
44
|
+
assertBodySchema(bodySchema);
|
|
45
|
+
}
|
|
39
46
|
if (route.respond) {
|
|
40
47
|
const status = route.respond.status ?? 200;
|
|
41
48
|
assert(![206,304].includes(status), 'Use native asset handlers for partial/conditional responses');
|
|
@@ -61,7 +68,15 @@ export function checkRequest(route , body , headers
|
|
|
61
68
|
try { text = new TextDecoder('utf-8',{fatal:true}).decode(body); } catch { throw new HttpError(400,'Body must be UTF-8'); }
|
|
62
69
|
if (policy.format === 'json') {
|
|
63
70
|
if (!/^application\/(?:[\w.+-]+\+)?json$/.test(type)) throw new HttpError(415,'Expected JSON media type');
|
|
64
|
-
|
|
71
|
+
let parsed ;
|
|
72
|
+
try { parsed = JSON.parse(text); } catch { throw new HttpError(400,'Invalid JSON body'); }
|
|
73
|
+
if (policy.schema) {
|
|
74
|
+
const issues = bodySchemaIssues(policy.schema, parsed);
|
|
75
|
+
if (issues.length) {
|
|
76
|
+
const text = `Request body failed validation\n${issues.map(bodySchemaLine).join('\n')}`;
|
|
77
|
+
throw new HttpError(422, text, prefersJson(headers.get('accept')) ? { contentType: 'application/json', text: bodySchemaJson(issues) } : undefined);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
65
80
|
}
|
|
66
81
|
}
|
|
67
82
|
}
|
package/dist/http-response.js
CHANGED
|
@@ -61,13 +61,13 @@ export function writeResponse(res , result , option
|
|
|
61
61
|
// or sniffed whatever a project declares.
|
|
62
62
|
export function errorResponse(error , { requestId, method, headers = [] } ) {
|
|
63
63
|
const status = error instanceof HttpError ? error.status : 500;
|
|
64
|
-
const fixed = [['content-type','text/plain; charset=utf-8'],['cache-control','no-store'],['x-request-id',requestId],['x-content-type-options','nosniff']];
|
|
64
|
+
const fixed = [['content-type',error instanceof HttpError && error.answer ? error.answer.contentType : 'text/plain; charset=utf-8'],['cache-control','no-store'],['x-request-id',requestId],['x-content-type-options','nosniff']];
|
|
65
65
|
const taken = new Set(fixed.map(([key]) => key));
|
|
66
66
|
const extra = headers.filter(([key]) => !taken.has(key.toLowerCase()) && !forbiddenHeaders.has(key.toLowerCase()));
|
|
67
67
|
// Runtime error messages are fixed words, and the answer is text/plain
|
|
68
68
|
// with nosniff; markup characters are still stripped so the body can never
|
|
69
69
|
// be read as HTML by a client that ignores both.
|
|
70
|
-
const text = `${error instanceof HttpError ? String(error.message).replace(/[<>&"']/g, '') : 'Internal server error'}\n`;
|
|
70
|
+
const text = error instanceof HttpError && error.answer ? error.answer.text + '\n' : `${error instanceof HttpError ? String(error.message).replace(/[<>&"']/g, '') : 'Internal server error'}\n`;
|
|
71
71
|
const body = method === 'HEAD' ? undefined : text;
|
|
72
72
|
// Stated explicitly so every host agrees, as prepareResponse does for results.
|
|
73
73
|
return { status, headers: [...fixed, ['content-length', String(new TextEncoder().encode(text).length)], ...extra], body };
|
package/dist/init-with.js
CHANGED
|
@@ -14,13 +14,16 @@ import { ConfigError, assert } from './errors.js';
|
|
|
14
14
|
|
|
15
15
|
/** Directory names inside the generated site. The route project lives under `app/`; everything else is operator-owned. */
|
|
16
16
|
const PROJECT_DIRECTORY = 'app', HOST_FILE = 'host.mjs', ROUTES_FILE = 'routes/extensions.yaml';
|
|
17
|
-
const
|
|
17
|
+
const acknowledgementPattern = /^[a-z][a-z0-9-]{0,63}:[a-z][a-z0-9-]{0,63}$/;
|
|
18
|
+
const namePattern = /^[a-z][a-z0-9-]{0,63}$/, capabilityPattern = /^[a-z][a-z0-9.:-]{0,63}$/;
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
|
|
@@ -35,12 +38,48 @@ const isCode = (error , code ) => error instanceof Error
|
|
|
35
38
|
const strings = (value ) => Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
36
39
|
const record = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Orders the requested set from the scaffolds' declared `requires`, `after`, `provides` and `conflicts`, never from
|
|
43
|
+
* the `--with` spelling. Kahn's algorithm with the lexically smallest ready extension first, so the result is
|
|
44
|
+
* deterministic and identical for every permutation. Refuses a missing requirement, a conflict or a cycle by name.
|
|
45
|
+
*/
|
|
46
|
+
export function orderScaffolds(results ) {
|
|
47
|
+
const byName = new Map (), providers = new Map ();
|
|
48
|
+
for (const result of results) byName.set(result.name, result);
|
|
49
|
+
for (const result of results) for (const capability of result.provides ?? []) {
|
|
50
|
+
assert(!byName.has(capability) || capability === result.name, `${result.name} provides ${capability}, which is also an extension name`);
|
|
51
|
+
assert(!providers.has(capability) || providers.get(capability) === result.name, `Capability ${capability} is provided by both ${providers.get(capability)} and ${result.name}`);
|
|
52
|
+
providers.set(capability, result.name);
|
|
53
|
+
}
|
|
54
|
+
const locate = (dependency ) => byName.has(dependency) ? dependency : providers.get(dependency);
|
|
55
|
+
const edges = new Map (results.map(result => [result.name, new Set ()]));
|
|
56
|
+
for (const result of results) {
|
|
57
|
+
for (const other of result.conflicts ?? []) { const target = locate(other); assert(target === undefined || target === result.name, `${result.name} conflicts with ${target}; remove one from --with`); }
|
|
58
|
+
for (const dependency of result.requires ?? []) {
|
|
59
|
+
const target = locate(dependency);
|
|
60
|
+
assert(target !== undefined, `${result.name} requires ${dependency}${byName.has(dependency) ? '' : ', which is not part of this composition; add the extension that provides it to --with'}`);
|
|
61
|
+
if (target !== result.name) edges.get(result.name) .add(target);
|
|
62
|
+
}
|
|
63
|
+
for (const dependency of result.after ?? []) { const target = locate(dependency); if (target !== undefined && target !== result.name) edges.get(result.name) .add(target); }
|
|
64
|
+
}
|
|
65
|
+
const ordered = [], placed = new Set ();
|
|
66
|
+
while (ordered.length < results.length) {
|
|
67
|
+
const ready = [...byName.keys()].filter(name => !placed.has(name) && [...edges.get(name) ].every(dependency => placed.has(dependency))).sort();
|
|
68
|
+
if (ready.length === 0) {
|
|
69
|
+
const stuck = [...byName.keys()].filter(name => !placed.has(name)).sort();
|
|
70
|
+
throw new ConfigError(`Extension ordering has a cycle among ${stuck.map(name => `${name} (needs ${[...edges.get(name) ].filter(dependency => !placed.has(dependency)).sort().join(', ')})`).join('; ')}`);
|
|
71
|
+
}
|
|
72
|
+
placed.add(ready[0] ); ordered.push(byName.get(ready[0] ) );
|
|
73
|
+
}
|
|
74
|
+
return ordered;
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
/**
|
|
39
78
|
* Resolves the extension package from the invoking directory (Node's package resolution with the default
|
|
40
79
|
* conditions), imports it, and calls its `scaffold` export. Nothing is bundled; core never imports these packages
|
|
41
80
|
* at build time. Refuses a missing package or a package without `scaffold` before anything is written.
|
|
42
81
|
*/
|
|
43
|
-
async function loadScaffold(name , request , cwd ) {
|
|
82
|
+
async function loadScaffold(name , request , cwd , retry ) {
|
|
44
83
|
const pkg = packageName(name);
|
|
45
84
|
let entry ;
|
|
46
85
|
try { entry = createRequire(join(cwd, 'package.json')).resolve(pkg); }
|
|
@@ -53,11 +92,20 @@ async function loadScaffold(name , request , cwd )
|
|
|
53
92
|
if (typeof scaffold !== 'function') throw new ConfigError(`${pkg} does not export scaffold; upgrade it to a release that supports urlcode init --with, or add ${name} by hand following its README`);
|
|
54
93
|
let result ;
|
|
55
94
|
try { result = await (scaffold )(request); }
|
|
56
|
-
catch (error) {
|
|
95
|
+
catch (error) {
|
|
96
|
+
// A refusal that names an acknowledgement id gets the exact command that would proceed; the extension owns the id and the risk wording, core only formats the retry.
|
|
97
|
+
const id = record(error) ? error.acknowledgement : undefined;
|
|
98
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
99
|
+
if (typeof id === 'string' && acknowledgementPattern.test(id) && id.startsWith(`${name}:`) && !request.acknowledgements.includes(id)) throw new ConfigError(`${pkg} scaffold refused: ${message}. If you accept that risk, re-run with the acknowledgement: ${retry(id)}`);
|
|
100
|
+
throw new ConfigError(`${pkg} scaffold refused: ${message}`);
|
|
101
|
+
}
|
|
57
102
|
assert(record(result) && result.name === name, `${pkg} scaffold must return a result named ${name}`);
|
|
58
103
|
assert(record(result.extensions) && record(result.routes), `${pkg} scaffold must return extensions and routes objects`);
|
|
59
104
|
assert(strings(result.hostImports) && strings(result.hostSetup) && strings(result.hostEntries) && (result.hostClose === undefined || strings(result.hostClose)), `${pkg} scaffold must return host fragments as string arrays`);
|
|
105
|
+
assert(result.acknowledged === undefined || (strings(result.acknowledged) && result.acknowledged.every(id => request.acknowledgements.includes(id) && id.startsWith(`${name}:`))), `${pkg} scaffold acknowledged may only list ${name}:<id> acknowledgements the operator passed`);
|
|
106
|
+
assert(result.routeNotes === undefined || (strings(result.routeNotes) && result.routeNotes.every(note => note.length <= 300 && !/[\r\n]/.test(note))), `${pkg} scaffold routeNotes must be single-line strings`);
|
|
60
107
|
assert(strings(result.nextSteps) && typeof result.readme === 'string', `${pkg} scaffold must return readme text and nextSteps strings`);
|
|
108
|
+
for (const key of ['provides', 'requires', 'after', 'conflicts'] ) assert(result[key] === undefined || (strings(result[key]) && (result[key] ).every(item => capabilityPattern.test(item))), `${pkg} scaffold ${key} must list extension names or capability names`);
|
|
61
109
|
assert(result.env === undefined || (record(result.env) && Object.values(result.env).every(item => typeof item === 'string')), `${pkg} scaffold env must map names to descriptions`);
|
|
62
110
|
assert(Array.isArray(result.files) && result.files.every((file ) => record(file) && typeof file.path === 'string' && (typeof file.content === 'string' || file.content instanceof Uint8Array) && (file.mode === undefined || (Number.isInteger(file.mode) && (file.mode ) >= 0 && (file.mode ) <= 0o777))), `${pkg} scaffold files must carry a path, content and an optional mode`);
|
|
63
111
|
return result ;
|
|
@@ -78,7 +126,8 @@ async function write(target , content , mode = 0o644)
|
|
|
78
126
|
}
|
|
79
127
|
function renderHost(names , results ) {
|
|
80
128
|
const lines = [`// Generated by urlcode init --with ${names.join(',')}. Trusted operator code: keep it outside ${PROJECT_DIRECTORY}/ and review before serving.`];
|
|
81
|
-
|
|
129
|
+
// Extensions that need the same module (node:url, for example) each list it; an identical line is written once so the host stays valid ESM.
|
|
130
|
+
for (const result of results) for (const line of result.hostImports) if (!lines.includes(line)) lines.push(line);
|
|
82
131
|
lines.push('');
|
|
83
132
|
for (const result of results) if (result.hostSetup.length) lines.push(...result.hostSetup);
|
|
84
133
|
lines.push('export default {', ' extensions: [');
|
|
@@ -121,14 +170,26 @@ function renderReadme(directory , names , results
|
|
|
121
170
|
* `urlcode.yaml`, one `host.mjs`, one `README.md` and the extensions' own files. All packages are resolved and
|
|
122
171
|
* their scaffolds computed before anything is written, so a refusal leaves no directory behind.
|
|
123
172
|
*/
|
|
124
|
-
export async function initProjectWith(destination ,
|
|
125
|
-
assert(
|
|
173
|
+
export async function initProjectWith(destination , requested , { cwd = process.cwd(), manifest = true, pins, acknowledgements = [] } = {}) {
|
|
174
|
+
assert(requested.length > 0, 'Provide at least one --with name');
|
|
175
|
+
assert(new Set(requested).size === requested.length, 'Duplicate --with names');
|
|
176
|
+
// --with is an unordered set: scaffolds see one canonical name order, and the emitted order comes from their declared requirements.
|
|
177
|
+
const sorted = [...requested].sort();
|
|
126
178
|
const directory = resolve(destination), project = join(directory, PROJECT_DIRECTORY), hostFile = join(directory, HOST_FILE);
|
|
127
|
-
|
|
179
|
+
assert(acknowledgements.every(id => acknowledgementPattern.test(id)), 'Use --ack <extension>:<id>, for example --ack store:public-write');
|
|
180
|
+
const acked = [...new Set(acknowledgements)].sort();
|
|
181
|
+
const request = { directory, project, hostFile, names: sorted, acknowledgements: acked };
|
|
182
|
+
const quote = (value ) => /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
183
|
+
const retry = (id ) => ['urlcode init', quote(destination), '--with', requested.join(','), ...(manifest ? [] : ['--no-manifest']), ...[...(pins ?? [])].flatMap(([pkg, specifier]) => ['--pin', quote(`${pkg}=${specifier}`)]), ...[...acked, id].sort().flatMap(item => ['--ack', item])].join(' ');
|
|
128
184
|
const results = [];
|
|
129
185
|
const wipe = () => { for (const result of results) for (const file of result.files) if (file.content instanceof Uint8Array) file.content.fill(0); };
|
|
130
186
|
try {
|
|
131
|
-
for (const name of
|
|
187
|
+
for (const name of sorted) results.push(await loadScaffold(name, request, cwd, retry));
|
|
188
|
+
const consumed = new Set(results.flatMap(result => result.acknowledged ?? []));
|
|
189
|
+
const unused = acked.filter(id => !consumed.has(id));
|
|
190
|
+
assert(unused.length === 0, `--ack ${unused.join(', ')} has no effect here: no scaffold in --with (${sorted.join(', ')}) consumed it. Remove it, or check the extension name and id in that extension's documentation`);
|
|
191
|
+
results.splice(0, results.length, ...orderScaffolds(results));
|
|
192
|
+
const names = results.map(result => result.name);
|
|
132
193
|
// Cross-result conflicts are refused before the destination exists.
|
|
133
194
|
const extensions = Object.create(null) , routes = Object.create(null) , env = {};
|
|
134
195
|
const owners = new Map ();
|
|
@@ -161,7 +222,8 @@ export async function initProjectWith(destination , names
|
|
|
161
222
|
doc.addIn(['includes'], ROUTES_FILE);
|
|
162
223
|
const fragment = stringify({ version: '1', routes });
|
|
163
224
|
validateDocument(doc.toJS()); validateDocument(parseYaml(fragment));
|
|
164
|
-
|
|
225
|
+
const notes = results.flatMap(result => (result.routeNotes ?? []).map(note => `# ${result.name}: ${note}\n`)).join('');
|
|
226
|
+
await write(join(project, ROUTES_FILE), `# Routes added by urlcode init --with ${names.join(',')}. Mounts are exclusive to the named extension.\n${notes}${fragment}`);
|
|
165
227
|
await rm(yamlFile); await write(yamlFile, String(doc));
|
|
166
228
|
await loadDocument(project);
|
|
167
229
|
const projectSha256 = await inspectExtensionRevision(project);
|
package/dist/mcp.js
CHANGED
|
@@ -7,6 +7,7 @@ import {loadOperatorHost} from './operator-host.js';
|
|
|
7
7
|
import {buildManifest} from './manifest.js';
|
|
8
8
|
|
|
9
9
|
import {authoringDefinitions,callAuthoringTool} from './mcp-authoring.js';
|
|
10
|
+
import {listSkills,getSkill,searchDocs,getExample,validateYaml,explainError} from './agent-context.js';
|
|
10
11
|
const protocolVersion='2025-11-25';
|
|
11
12
|
const maxBytes=1048576;
|
|
12
13
|
const text={type:'string',maxLength:8192};
|
|
@@ -25,10 +26,16 @@ const definitions=[
|
|
|
25
26
|
{name:'recipes_show',description:'Show a bundled local recipe without writing it; metadata (capabilities, targets, grants, inputs, expected behavior) comes before file contents.',properties:{name:{type:'string',maxLength:64}},required:['name']},
|
|
26
27
|
{name:'search_recipes',description:'Search bundled recipes by id, description, tags and capabilities; local text matching, no service. Check here before generating a common route by hand.',properties:{text:{type:'string',maxLength:256}},required:['text']},
|
|
27
28
|
{name:'search_examples',description:'Search bundled runnable examples and the cookbook route index; returns the smallest matching example and its route.',properties:{text:{type:'string',maxLength:256}},required:['text']},
|
|
29
|
+
{name:'list_skills',description:'List compact metadata for the bundled agent skills. Load a skill only when it applies.',properties:{}},
|
|
30
|
+
{name:'get_skill',description:'Load one bundled agent SKILL.md by name.',properties:{name:{type:'string',maxLength:64}},required:['name']},
|
|
31
|
+
{name:'search_docs',description:'Deterministically search the small packaged agent documentation corpus and return at most three short excerpts.',properties:{text:{type:'string',maxLength:256}},required:['text']},
|
|
32
|
+
{name:'get_example',description:'Return the README and urlcode.yaml from one bundled runnable example.',properties:{name:{type:'string',maxLength:64}},required:['name']},
|
|
33
|
+
{name:'validate_yaml',description:'Validate supplied URLCode YAML syntax and schema only. It never reads includes, source files, bindings or a project directory.',properties:{yaml:{type:'string',maxLength:524288}},required:['yaml']},
|
|
34
|
+
{name:'explain_error',description:'Give deterministic next-step guidance for supplied URLCode validation output.',properties:{error:{type:'string',maxLength:8192}},required:['error']},
|
|
28
35
|
{name:'get_context',description:'Emit the compact project context an authoring agent needs: versions, project summary, constraints, target support and exact commands, derived from the compiled project. Optional token budget drops sections in a fixed order.',properties:{target:text,budget:{type:'integer',minimum:1}}},
|
|
29
36
|
];
|
|
30
37
|
// Only the operator's own --host-file exposes registered extension contracts; no tool argument can name one.
|
|
31
|
-
const hostDefinition={name:'get_extensions',description:'List operator-registered extension contracts
|
|
38
|
+
const hostDefinition={name:'get_extensions',description:'List operator-registered extension contracts, schemas, hooks, and supported project-owned customization surfaces with fast checks; use these before generating replacement framework code. Activates nothing.',properties:{}};
|
|
32
39
|
const ajv=new Ajv({strict:false});
|
|
33
40
|
const readTools=definitions.map(def=>({name:def.name,description:def.description,inputSchema:{type:'object',properties:def.properties,required:def.required??[],additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}}));
|
|
34
41
|
const hostTool={name:hostDefinition.name,description:hostDefinition.description,inputSchema:{type:'object',properties:hostDefinition.properties,required:[],additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,openWorldHint:false}};
|
|
@@ -63,6 +70,12 @@ export async function serveMcp(options ) {
|
|
|
63
70
|
case 'recipes_show':return showRecipe(args.name );
|
|
64
71
|
case 'search_recipes':return searchRecipes(args.text );
|
|
65
72
|
case 'search_examples':return searchExamples(args.text );
|
|
73
|
+
case 'list_skills':return listSkills();
|
|
74
|
+
case 'get_skill':return getSkill(args.name );
|
|
75
|
+
case 'search_docs':return searchDocs(args.text );
|
|
76
|
+
case 'get_example':return getExample(args.name );
|
|
77
|
+
case 'validate_yaml':return validateYaml(args.yaml );
|
|
78
|
+
case 'explain_error':return explainError(args.error );
|
|
66
79
|
case 'get_extensions':return describeExtensions(project,host.extensions??[]);
|
|
67
80
|
case 'get_context':return buildContext(project,{projectFlag:'.',...(typeof args.target==='string'?{target:args.target}:{}),...(typeof args.budget==='number'?{budget:args.budget}:{})});
|
|
68
81
|
default:if(authoring)return callAuthoringTool(project,name,args,options.origin);throw new Error('Unknown tool');
|
|
@@ -78,7 +91,7 @@ export async function serveMcp(options ) {
|
|
|
78
91
|
if(message.method==='initialize') {
|
|
79
92
|
if(initialized){await error(id,-32600,'Already initialized');return;}
|
|
80
93
|
if(typeof params.protocolVersion!=='string'||!object(params.capabilities)||!object(params.clientInfo)||typeof params.clientInfo.name!=='string'||typeof params.clientInfo.version!=='string'){await error(id,-32602,'Invalid initialize params');return;}
|
|
81
|
-
initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.
|
|
94
|
+
initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.4.6'}}});return;
|
|
82
95
|
}
|
|
83
96
|
if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
|
|
84
97
|
if(!ready){await error(id,-32002,'Initialize first');return;}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { assert } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const maxPatternLength = 128;
|
|
4
|
+
export const maxPatternInputLength = 128;
|
|
5
|
+
const maxUnboundedQuantifiers = 3;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Accepts an author regex only when it is conservatively safe to run on every
|
|
9
|
+
* request in the host process. Node has no linear-time engine, so this refuses
|
|
10
|
+
* the constructs that make backtracking blow up — repeated groups, lookaround
|
|
11
|
+
* and backreferences — and caps unbounded quantifiers. It is a restriction, not
|
|
12
|
+
* a proof: callers must also bound the input length to `maxPatternInputLength`.
|
|
13
|
+
*/
|
|
14
|
+
export function assertSafePattern(pattern ) {
|
|
15
|
+
assert(pattern.length > 0 && pattern.length <= maxPatternLength, `Pattern must be 1 to ${maxPatternLength} characters`);
|
|
16
|
+
try { new RegExp(pattern, 'u'); } catch { assert(false, 'Invalid pattern'); }
|
|
17
|
+
let unbounded = 0, inClass = false;
|
|
18
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
19
|
+
const char = pattern[i] ;
|
|
20
|
+
if (char === '\\') {
|
|
21
|
+
const next = pattern[i + 1] ?? '';
|
|
22
|
+
assert(inClass || !/[1-9k]/.test(next), 'Pattern backreferences are not supported');
|
|
23
|
+
i++; continue;
|
|
24
|
+
}
|
|
25
|
+
if (inClass) { if (char === ']') inClass = false; continue; }
|
|
26
|
+
if (char === '[') { inClass = true; continue; }
|
|
27
|
+
if (char === '(') assert(!/^\(\?<?[=!]/.test(pattern.slice(i, i + 4)), 'Pattern lookaround is not supported');
|
|
28
|
+
if (char === ')') assert(!/^(?:[*+]|\{\d+,\})/.test(pattern.slice(i + 1)), 'Pattern cannot repeat a group without a bound');
|
|
29
|
+
if (char === '*' || char === '+' || (char === '{' && /^\{\d+,\}/.test(pattern.slice(i)))) unbounded++;
|
|
30
|
+
}
|
|
31
|
+
assert(unbounded <= maxUnboundedQuantifiers, `Pattern allows at most ${maxUnboundedQuantifiers} unbounded quantifiers`);
|
|
32
|
+
}
|
|
Binary file
|
package/dist/policy.js
CHANGED
|
@@ -32,6 +32,22 @@ export async function prepareFunctionSnapshot(loaded )
|
|
|
32
32
|
if (route.sandbox) { for (const definition of [...middleware, ...(fn ? [fn] : [])]) sandboxed.push({pattern,function:definition}); }
|
|
33
33
|
else trusted.push({middleware, function: fn});
|
|
34
34
|
}
|
|
35
|
+
// Extension hooks are a core primitive even though their names and payloads
|
|
36
|
+
// belong to each extension. Include every declared entry module in the
|
|
37
|
+
// reviewed project revision, so editing trusted hook code invalidates the
|
|
38
|
+
// operator's extension pin just like editing a trusted route function.
|
|
39
|
+
for(const [extension,declaration] of Object.entries(loaded.document.extensions??{})){
|
|
40
|
+
const hooks=declaration.config.hooks;
|
|
41
|
+
if(hooks===undefined)continue;
|
|
42
|
+
assert(hooks&&typeof hooks==='object'&&!Array.isArray(hooks),`Extension ${extension} hooks must be an object`);
|
|
43
|
+
for(const [name,raw] of Object.entries(hooks )){
|
|
44
|
+
assert(typeof raw==='string'||raw&&typeof raw==='object'&&!Array.isArray(raw),`Invalid extension hook: ${extension}.${name}`);
|
|
45
|
+
const reference=typeof raw==='string'?{source:raw}:raw ;
|
|
46
|
+
assert(typeof reference.source==='string',`Invalid extension hook: ${extension}.${name}`);
|
|
47
|
+
assert(reference.export===undefined||typeof reference.export==='string',`Invalid extension hook: ${extension}.${name}`);
|
|
48
|
+
trusted.push({function:await resolveOne({source:reference.source,...(reference.export===undefined?{}:{export:reference.export })})});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
35
51
|
const collected = await collectFunctionSources(sandboxed,loaded.root);
|
|
36
52
|
const trustedSources = await collectTrustedSources(trusted,loaded.root);
|
|
37
53
|
// The hash operator grants pin to still covers trusted routes' own source, so
|
package/dist/project-tests.js
CHANGED
|
@@ -1,23 +1,47 @@
|
|
|
1
|
-
import { realpath } from 'node:fs/promises';
|
|
1
|
+
import { mkdtemp, realpath, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { Agent } from 'node:http';
|
|
3
5
|
import { startServer } from './server.js';
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
+
|
|
7
|
+
import { readFixtures, runFixtures } from './readiness.js';
|
|
8
|
+
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A server that fixture `restart` steps can close and start again on the same project and the
|
|
16
|
+
* same data directory. The data directory is fresh and empty, offered to the project as
|
|
17
|
+
* `URLCODE_DATA_DIR`, and removed by `close()`; on a filesystem where it cannot be created, none is offered. `restart()` gets a new port; read `address` after it.
|
|
18
|
+
*/
|
|
19
|
+
export async function startRestartable(options ) {
|
|
20
|
+
// A read-only filesystem (a locked-down container) has nowhere to put it: run without one instead of
|
|
21
|
+
// failing every test run. A project that reads `URLCODE_DATA_DIR` then refuses to activate, as it would unset.
|
|
22
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'urlcode-data-')).catch(() => undefined);
|
|
23
|
+
const cleanup = async () => { if (dataDir !== undefined) await rm(dataDir, { recursive: true, force: true }); };
|
|
24
|
+
let current ;
|
|
25
|
+
try { current = await startServer({ ...options, dataDir }); } catch (error) { await cleanup(); throw error; }
|
|
26
|
+
const running = () => { if (!current) throw new Error('Server is not running'); return current; };
|
|
27
|
+
return {
|
|
28
|
+
get address() { return running().address; }, get root() { return running().root; }, testPlan: () => running().testPlan(),
|
|
29
|
+
async restart() { const old = running(); current = undefined; await old.close(); current = await startServer({ ...options, dataDir }); },
|
|
30
|
+
async close() { try { await current?.close(); } finally { current = undefined; await cleanup(); } },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
export async function runProjectTests(project , { log = () => {}, permissions, origin, extensions, plugins } = {}) {
|
|
12
|
-
const root = await realpath(project),
|
|
13
|
-
const app = await
|
|
14
|
-
const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0;
|
|
35
|
+
const root = await realpath(project), fixtures = await readFixtures(root);
|
|
36
|
+
const app = await startRestartable({ project, port: 0, local: true, log, permissions, origin, extensions, plugins });
|
|
37
|
+
const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0, total = 0;
|
|
15
38
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
39
|
+
// Only case number, pass and status are logged: never a path, header or body, which may hold captured values.
|
|
40
|
+
await runFixtures(fixtures, { app, agent, restart: () => app.restart() }, ({ case: n, result }) => {
|
|
41
|
+
total++;
|
|
18
42
|
if(!result.pass)failed++;
|
|
19
|
-
log({event:'test',case:
|
|
20
|
-
}
|
|
43
|
+
log({event:'test',case:n,pass:result.pass,status:result.status});
|
|
44
|
+
});
|
|
21
45
|
} finally {agent.destroy();await app.close();}
|
|
22
|
-
return {total
|
|
46
|
+
return {total,failed};
|
|
23
47
|
}
|