@jimhoyd/urlcode 0.4.0-alpha.1 → 0.4.0-alpha.3
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 +36 -22
- package/.claude/skills/urlcode-operations/SKILL.md +16 -22
- package/.claude-plugin/marketplace.json +1 -1
- package/CONTRIBUTING.md +20 -16
- package/README.md +83 -80
- package/ROADMAP.md +61 -331
- package/SECURITY.md +23 -8
- package/dist/BUILD-MANIFEST.json +42 -47
- package/dist/agents-guide.js +18 -18
- package/dist/build-static.js +136 -0
- package/dist/capabilities.js +85 -31
- package/dist/capability-query.js +0 -1
- package/dist/cli.js +24 -32
- package/dist/compliance-rules/baseline.js +2 -10
- package/dist/compliance-rules/privacy.js +5 -16
- package/dist/compliance-rules/shared.js +0 -2
- package/dist/compliance.js +6 -8
- package/dist/config.js +15 -8
- package/dist/context.js +9 -10
- package/dist/examples.js +2 -2
- package/dist/explain-cli.js +7 -5
- package/dist/explain.js +10 -5
- package/dist/extensions.js +61 -2
- package/dist/function-sources.js +34 -2
- package/dist/function-worker.js +3 -1
- package/dist/functions.js +84 -13
- package/dist/guest-api.js +29 -3
- package/dist/index.js +3 -6
- package/dist/manifest.js +11 -7
- package/dist/mcp-authoring.js +2 -2
- package/dist/mcp.js +1 -1
- package/dist/observability.js +1 -21
- package/dist/policies/cache.js +8 -3
- package/dist/policies.js +3 -1
- package/dist/policy.js +28 -9
- package/dist/prerender.js +4 -0
- package/dist/project-tests.js +3 -3
- package/dist/readiness.js +35 -9
- package/dist/route-diff.js +12 -5
- package/dist/router.js +5 -7
- package/dist/runtime.js +77 -58
- package/dist/sandbox.js +48 -0
- package/dist/scaffold.js +0 -0
- package/dist/scripts/operational-drills.js +12 -54
- package/dist/server.js +3 -29
- package/dist/tooling.js +1 -1
- package/dist/trusted-functions.js +210 -0
- package/dist/types/build-static.d.ts +43 -0
- package/dist/types/capabilities.d.ts +14 -5
- package/dist/types/compliance-rules/shared.d.ts +0 -2
- package/dist/types/compliance.d.ts +0 -3
- package/dist/types/config.d.ts +2 -1
- package/dist/types/context.d.ts +2 -1
- package/dist/types/examples.d.ts +1 -1
- package/dist/types/explain.d.ts +6 -0
- package/dist/types/extensions.d.ts +55 -0
- package/dist/types/function-sources.d.ts +4 -0
- package/dist/types/functions.d.ts +48 -5
- package/dist/types/guest-api.d.ts +1 -0
- package/dist/types/index.d.ts +3 -6
- package/dist/types/manifest.d.ts +5 -3
- package/dist/types/observability.d.ts +1 -14
- package/dist/types/project-tests.d.ts +1 -2
- package/dist/types/readiness.d.ts +12 -3
- package/dist/types/router.d.ts +2 -1
- package/dist/types/runtime.d.ts +0 -27
- package/dist/types/sandbox.d.ts +12 -0
- package/dist/types/scaffold.d.ts +0 -2
- package/dist/types/server.d.ts +1 -4
- package/dist/types/tooling.d.ts +3 -3
- package/dist/types/trusted-functions.d.ts +29 -0
- package/dist/types/types.d.ts +22 -8
- package/dist/types/verify-deployment.d.ts +2 -2
- package/dist/types.js +25 -5
- package/dist/typescript-authoring.js +67 -18
- package/dist/verify-deployment.js +3 -3
- package/docs/AI-AUTHORING.md +109 -10
- package/docs/ASSETS.md +2 -1
- package/docs/AUTH-BACKUP.md +32 -0
- package/docs/AWS.md +4 -4
- package/docs/BEST-PRACTICES.md +17 -8
- package/docs/CAPABILITIES.md +30 -17
- package/docs/CAPACITY.md +128 -31
- package/docs/CI-FOLLOWUP-2026-09-19.md +97 -0
- package/docs/CI-RELEASE-AUDIT-2026-09-19.md +322 -0
- package/docs/CI.md +8 -3
- package/docs/CLOUDFLARE.md +1 -2
- package/docs/CODEBASE-AUDIT-2026-09-20.md +278 -0
- package/docs/COMPLIANCE.md +6 -9
- package/docs/DEPLOYMENT-CHECKS.md +1 -1
- package/docs/DEVELOPMENT-PIPELINE.md +181 -0
- package/docs/EXTENSIONS.md +193 -11
- package/docs/FRAMEWORK.md +53 -33
- package/docs/FUNCTION-SECURITY.md +173 -32
- package/docs/INSTALL.md +0 -5
- package/docs/LOAD-TESTING.md +4 -4
- package/docs/LOCAL-DEVELOPMENT.md +3 -0
- package/docs/MIDDLEWARE.md +27 -16
- package/docs/MONITORING.md +2 -19
- package/docs/OBSERVABILITY.md +7 -16
- package/docs/OPEN-DECISIONS.md +184 -0
- package/docs/OPERATIONAL-PROOF.md +26 -30
- package/docs/OPERATIONS.md +23 -32
- package/docs/POLICIES.md +27 -8
- package/docs/PRERENDER.md +25 -13
- package/docs/PROJECT-DIRECTION.md +35 -10
- package/docs/READINESS.md +6 -2
- package/docs/README.md +20 -41
- package/docs/RELEASE-0.4.0-alpha.3.md +50 -0
- package/docs/RELEASE-READINESS.md +50 -30
- package/docs/RELEASE-SECURITY.md +72 -86
- package/docs/RESILIENCE.md +16 -15
- package/docs/ROUTING.md +7 -10
- package/docs/SANDBOX-REVIEW.md +19 -6
- package/docs/SCAFFOLDING.md +0 -2
- package/docs/SECURITY-AUDIT.md +42 -2
- package/docs/SPECIFICATION.md +63 -27
- package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +288 -0
- package/docs/SPIKE-BUSINESS-SUITE.md +16 -0
- package/docs/SPIKE-CORE-LAYERING.md +368 -0
- package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +211 -0
- package/docs/SPIKE-LAMBDA-COMPILE.md +12 -2
- package/docs/STARTERS.md +1 -1
- package/docs/STATIC.md +105 -0
- package/docs/TOOLING.md +17 -12
- package/docs/TUNNELS.md +0 -3
- package/docs/TYPESCRIPT-AUTHORING.md +32 -12
- package/docs/TYPESCRIPT.md +25 -4
- package/docs/VERCEL.md +4 -5
- package/docs/VERSION-ALIGNMENT.md +46 -0
- package/docs/YAML-GUIDE.md +0 -3
- package/docs/YAML-REFERENCE.md +16 -6
- package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +68 -0
- package/docs/{MANAGEMENT-SECURITY.md → archive/2026-09-19/MANAGEMENT-SECURITY.md} +20 -0
- package/docs/{NEXT-PHASE-PLAN.md → archive/2026-09-19/NEXT-PHASE-PLAN.md} +26 -8
- package/docs/{NEXT-STEPS.md → archive/2026-09-19/NEXT-STEPS.md} +274 -43
- package/docs/archive/2026-09-19/OPEN-DECISIONS.md +277 -0
- package/docs/archive/2026-09-19/RELEASE-SECURITY.md +186 -0
- package/docs/archive/2026-09-19/ROADMAP.md +386 -0
- package/docs/{SPIKE-EXTENSION-MODEL.md → archive/2026-09-19/SPIKE-EXTENSION-MODEL.md} +12 -2
- package/docs/{SPIKE-EXTENSIONS.md → archive/2026-09-19/SPIKE-EXTENSIONS.md} +28 -12
- package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +776 -0
- package/docs/{USABILITY-REVIEW.md → archive/2026-09-19/USABILITY-REVIEW.md} +18 -2
- package/docs/archive/README.md +27 -0
- package/docs/policies/agents.md +1 -1
- package/docs/policies/cache.md +13 -0
- package/docs/policies/compression.md +3 -2
- package/docs/policies/security.md +3 -2
- package/examples/assets/example.yaml +2 -2
- package/examples/aws/example.yaml +1 -1
- package/examples/cloudflare/example.yaml +1 -1
- package/examples/conditions/example.yaml +1 -1
- package/examples/cookbook/example.yaml +1 -1
- package/examples/cookbook/middleware/bucket.mjs +12 -2
- package/examples/cookbook/middleware/locale.mjs +7 -3
- package/examples/cookbook/route-index.json +1 -1
- package/examples/cookbook/routes/middleware.yaml +1 -1
- package/examples/egress/example.yaml +1 -1
- package/examples/extensions/example.yaml +1 -1
- package/examples/prerender/README.md +14 -6
- package/examples/prerender/example.yaml +1 -1
- package/examples/prerender/functions/page.mjs +4 -2
- package/examples/prerender/middleware/template.mjs +1 -1
- package/examples/prerender/prerender.mjs +1 -1
- package/examples/prerender/urlcode.yaml +8 -4
- package/examples/provider-conformance/example.yaml +1 -1
- package/examples/vercel/example.yaml +1 -1
- package/llms-full.txt +686 -239
- package/llms.txt +27 -15
- package/package.json +32 -5
- package/packaging/claude-plugin/.claude-plugin/plugin.json +2 -2
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +36 -22
- package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +16 -22
- package/recipes/authenticated-json-api/README.md +4 -3
- package/recipes/authenticated-json-api/functions/profile.mjs +2 -1
- package/recipes/authenticated-json-api/recipe.yaml +2 -2
- package/recipes/contact-form/functions/contact.mjs +2 -1
- package/recipes/contact-form/recipe.yaml +3 -3
- package/recipes/cors-api/README.md +2 -2
- package/recipes/cors-api/recipe.yaml +2 -2
- package/recipes/health-page/README.md +1 -1
- package/recipes/health-page/recipe.yaml +1 -1
- package/recipes/json-api/README.md +1 -1
- package/recipes/json-api/recipe.yaml +4 -4
- package/recipes/middleware/README.md +8 -4
- package/recipes/middleware/middleware/bucket.mjs +12 -2
- package/recipes/middleware/middleware/locale.mjs +7 -3
- package/recipes/middleware/recipe.yaml +1 -1
- package/recipes/protected-download/README.md +1 -1
- package/recipes/protected-download/recipe.yaml +2 -2
- package/recipes/redirect/recipe.yaml +1 -1
- package/recipes/static-plus-api/README.md +2 -2
- package/recipes/static-plus-api/public/index.html +1 -1
- package/recipes/static-plus-api/recipe.yaml +2 -2
- package/recipes/static-plus-api/urlcode.yaml +1 -1
- package/recipes/typescript/README.md +2 -1
- package/recipes/typescript/recipe.yaml +5 -5
- package/recipes/webhook-receiver/README.md +5 -1
- package/recipes/webhook-receiver/recipe.yaml +2 -1
- package/recipes/webhook-receiver/urlcode.yaml +7 -0
- package/schemas/recipe.schema.json +4 -3
- package/schemas/urlcode.schema.json +14 -41
- package/skills/urlcode/SKILL.md +32 -11
- package/starters/default/AGENTS.md +18 -18
- package/starters/default/urlcode.yaml +0 -1
- package/dist/link-api.js +0 -136
- package/dist/link-cli.js +0 -141
- package/dist/link-events.js +0 -76
- package/dist/link-records.js +0 -31
- package/dist/link-store-worker.js +0 -150
- package/dist/link-store.js +0 -250
- package/dist/management-policy.js +0 -40
- package/dist/sqlite-version.js +0 -6
- package/dist/types/link-api.d.ts +0 -30
- package/dist/types/link-cli.d.ts +0 -37
- package/dist/types/link-events.d.ts +0 -27
- package/dist/types/link-records.d.ts +0 -11
- package/dist/types/link-store-worker.d.ts +0 -1
- package/dist/types/link-store.d.ts +0 -130
- package/dist/types/management-policy.d.ts +0 -8
- package/dist/types/sqlite-version.d.ts +0 -1
- package/docs/DYNAMIC-LINKS.md +0 -61
- package/docs/links/cli.md +0 -110
- package/docs/links/limits.md +0 -175
- package/docs/links/management-api.md +0 -80
- package/docs/links/pools.md +0 -75
- package/docs/links/setup.md +0 -135
- package/docs/yaml/links.md +0 -30
- package/examples/live-links/README.md +0 -11
- package/examples/live-links/example.yaml +0 -21
- package/examples/live-links/tests/requests.json +0 -6
- package/examples/live-links/urlcode.yaml +0 -16
package/dist/policies.js
CHANGED
|
@@ -9,7 +9,9 @@ import { assert, ConfigError } from './errors.js';
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
// Host-side behavior declared in YAML and enforced
|
|
12
|
+
// Host-side behavior declared in YAML and enforced in the host process, outside
|
|
13
|
+
// function/middleware execution entirely -- trusted routes and sandbox: true
|
|
14
|
+
// routes alike. Every
|
|
13
15
|
// module here follows one contract so a first-party policy and an operator
|
|
14
16
|
// plugin share a code path (PolicyModule in src/types.ts):
|
|
15
17
|
//
|
package/dist/policy.js
CHANGED
|
@@ -3,8 +3,8 @@ import { readFile, realpath, stat } from 'node:fs/promises';
|
|
|
3
3
|
import { relative, isAbsolute, sep } from 'node:path';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { functionFile } from './config.js';
|
|
6
|
-
import { collectFunctionSources, routeFunctions } from './function-sources.js';
|
|
7
|
-
|
|
6
|
+
import { collectFunctionSources, collectTrustedSources, routeFunctions } from './function-sources.js';
|
|
7
|
+
|
|
8
8
|
import { assert } from './errors.js';
|
|
9
9
|
|
|
10
10
|
|
|
@@ -16,18 +16,37 @@ import { assert } from './errors.js';
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
export async function prepareFunctionSnapshot(loaded ) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
19
|
+
// Only `sandbox: true` routes are bundled into the QuickJS module snapshot
|
|
20
|
+
// FunctionPool loads (docs/SPIKE-DEFAULT-TRUST-MODEL.md): a trusted route's
|
|
21
|
+
// module never needs to satisfy the sandbox's relative-static-import-only
|
|
22
|
+
// rule or its per-module/total byte budgets, since it runs through Node's
|
|
23
|
+
// own module resolution, not the WASM guest.
|
|
24
|
+
const sandboxed = [];
|
|
25
|
+
const trusted = [];
|
|
26
|
+
const resolveOne = async (definition ) =>
|
|
27
|
+
({source:await functionFile(loaded.root,definition.source),export:definition.export || 'default'});
|
|
28
|
+
for (const [pattern,route] of Object.entries(loaded.routes)) {
|
|
29
|
+
if (!routeFunctions(route).length) continue;
|
|
30
|
+
const middleware = await Promise.all((route.middleware || []).map(resolveOne));
|
|
31
|
+
const fn = route.function ? await resolveOne(route.function) : undefined;
|
|
32
|
+
if (route.sandbox) { for (const definition of [...middleware, ...(fn ? [fn] : [])]) sandboxed.push({pattern,function:definition}); }
|
|
33
|
+
else trusted.push({middleware, function: fn});
|
|
34
|
+
}
|
|
35
|
+
const collected = await collectFunctionSources(sandboxed,loaded.root);
|
|
36
|
+
const trustedSources = await collectTrustedSources(trusted,loaded.root);
|
|
37
|
+
// The hash operator grants pin to still covers trusted routes' own source, so
|
|
38
|
+
// an env/secret grant is invalidated when the trusted code that could use it
|
|
39
|
+
// changes, even though that code never enters collectFunctionSources's
|
|
40
|
+
// sandbox-shaped snapshot (see collectTrustedSources for what this does not
|
|
41
|
+
// catch: changes to a helper module a trusted entry imports but does not
|
|
42
|
+
// itself change).
|
|
43
|
+
const sources = Object.fromEntries(Object.entries({...collected.sources,...trustedSources}).sort(([a],[b])=>a < b ? -1 : a > b ? 1 : 0));
|
|
25
44
|
// Generated site routes carry no bindings and depend on the origin, so they
|
|
26
45
|
// stay out of the hash that operator grants are pinned to. Declared site and
|
|
27
46
|
// inherited policy/profile behavior are included: changing a pre-egress
|
|
28
47
|
// restriction must require a fresh operator review even if routes are unchanged.
|
|
29
48
|
const declared = Object.fromEntries(Object.entries(loaded.routes).filter(([,route])=>!route.generated));
|
|
30
|
-
const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({...(loaded.document.extensions?{extensions:loaded.document.extensions}:{}),routes:declared,...(loaded.document.
|
|
49
|
+
const snapshot = { ...collected, projectSha256: createHash('sha256').update(JSON.stringify({...(loaded.document.extensions?{extensions:loaded.document.extensions}:{}),routes:declared,...(loaded.document.policies?{policies:loaded.document.policies}:{}),...(loaded.document.profiles?{profiles:loaded.document.profiles}:{}),...(loaded.document.site?{site:loaded.document.site}:{}),sources})).digest('hex') };
|
|
31
50
|
return snapshot;
|
|
32
51
|
}
|
|
33
52
|
export function validatePolicy(value ) {
|
package/dist/prerender.js
CHANGED
|
@@ -102,6 +102,10 @@ async function planPasses(source ) {
|
|
|
102
102
|
const cost = new Map ();
|
|
103
103
|
for (const pattern of patterns) {
|
|
104
104
|
const route = loaded.routes[pattern] ;
|
|
105
|
+
// Trusted routes use Node resolution, not the sandbox module collector.
|
|
106
|
+
// Applying guest import/size limits here would reject valid build-time Node
|
|
107
|
+
// code before the runtime can dispatch it through its declared trust mode.
|
|
108
|
+
if (route.sandbox !== true) { cost.set(pattern, []); continue; }
|
|
105
109
|
const definitions = [];
|
|
106
110
|
for (const definition of routeFunctions(route))
|
|
107
111
|
definitions.push({function: {source: await functionFile(loaded.root, definition.source), export: definition.export || 'default'}});
|
package/dist/project-tests.js
CHANGED
|
@@ -5,12 +5,12 @@ import { startServer } from './server.js';
|
|
|
5
5
|
import { readCases, hit } from './readiness.js';
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
export async function runProjectTests(project , { log = () => {}, permissions,
|
|
11
|
+
export async function runProjectTests(project , { log = () => {}, permissions, origin, extensions, plugins } = {}) {
|
|
12
12
|
const root = await realpath(project), cases = await readCases(root);
|
|
13
|
-
const app = await startServer({ project, port: 0, local: true, log, permissions,
|
|
13
|
+
const app = await startServer({ project, port: 0, local: true, log, permissions, origin, extensions, plugins });
|
|
14
14
|
const agent = new Agent({keepAlive:true,maxSockets:1}); let failed = 0;
|
|
15
15
|
try {
|
|
16
16
|
for (const [i,test] of cases.entries()) {
|
package/dist/readiness.js
CHANGED
|
@@ -13,9 +13,16 @@ import { runCompliance } from './compliance.js';
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
/** One configured route as the inventory reports it: a PlanInventoryEntry with the handler kind named. */
|
|
18
|
-
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
19
26
|
/** One request case: a generated probe or a `tests/requests.json` fixture. */
|
|
20
27
|
|
|
21
28
|
|
|
@@ -25,14 +32,17 @@ import { runCompliance } from './compliance.js';
|
|
|
25
32
|
|
|
26
33
|
|
|
27
34
|
/** A started server as the audit and benchmark see it. structural: the real type is startServer's result in src/server.ts. */
|
|
28
|
-
|
|
35
|
+
|
|
29
36
|
|
|
30
37
|
|
|
31
38
|
|
|
32
|
-
|
|
39
|
+
|
|
33
40
|
|
|
34
41
|
|
|
35
42
|
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
36
46
|
|
|
37
47
|
|
|
38
48
|
|
|
@@ -41,20 +51,35 @@ import { runCompliance } from './compliance.js';
|
|
|
41
51
|
|
|
42
52
|
|
|
43
53
|
|
|
44
|
-
const handlers = ['extension','proxy','conditional','redirect','function','page','static','download','respond'
|
|
54
|
+
const handlers = ['extension','proxy','conditional','redirect','function','page','static','download','respond'] ;
|
|
45
55
|
/** Narrows a compiled route to one that redirects, so redirectLocation can read its spec. */
|
|
46
56
|
export const hasRedirect = (route ) => Boolean(route.redirect);
|
|
47
57
|
const isRecord = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
48
58
|
// Probes identify themselves so an agents policy that denies an empty
|
|
49
59
|
// User-Agent does not fail every generated case; fixtures may override it.
|
|
50
60
|
export const probeAgent = 'Mozilla/5.0 (compatible; RouteProbe/0.1)';
|
|
61
|
+
// Advisory only (docs/AI-AUTHORING.md, "Deciding when a route needs sandbox: true"):
|
|
62
|
+
// a route that runs project code, accepts POST with a declared request.body
|
|
63
|
+
// policy, and declares neither `sandbox: true` nor `sandboxReason` looks
|
|
64
|
+
// plausibly webhook/callback/third-party-input-shaped. This is a nudge to
|
|
65
|
+
// look, never an inferred verdict — it never fails `audit` or changes `ready`.
|
|
66
|
+
function routeAdvisories(route ) {
|
|
67
|
+
const advisories = [];
|
|
68
|
+
const runsCode = Boolean(route.function) || Boolean(route.middleware?.length);
|
|
69
|
+
if (runsCode && route.methods.includes('POST') && route.request?.body && !route.sandbox && !route.sandboxReason) {
|
|
70
|
+
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.");
|
|
71
|
+
}
|
|
72
|
+
return advisories;
|
|
73
|
+
}
|
|
51
74
|
export function projectPlan(compiled ) {
|
|
52
75
|
const routes = [...compiled.exact.values(), ...[...compiled.byLength.values()].flat(), ...compiled.mounts];
|
|
53
76
|
const now = Date.now();
|
|
54
|
-
const inventory = routes.map(route => ({ path:route.pattern, handler:handlers.find(key => route[key]), methods:route.methods, middleware:route.middleware?.length || 0,
|
|
77
|
+
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,
|
|
55
78
|
policies:[...(route.policy ? Object.keys(route.policy.describe) : []),...(route.extensionPolicyNames??[]).map(name=>`extensions.${name}`)],
|
|
79
|
+
sandbox:route.sandbox === true, ...(route.sandboxReason ? { sandboxReason:route.sandboxReason } : {}),
|
|
56
80
|
...(route.generated ? { generated:route.generated } : {}),
|
|
57
|
-
|
|
81
|
+
...(advisories.length ? { advisories } : {}),
|
|
82
|
+
state:route.enabled === false ? 'disabled' : route.expiresAt && now >= route.expiresAt ? 'expired' : 'active' }; });
|
|
58
83
|
const cases = [];
|
|
59
84
|
for (const [i,route] of routes.entries()) {
|
|
60
85
|
const entry = inventory[i];
|
|
@@ -63,7 +88,7 @@ export function projectPlan(compiled )
|
|
|
63
88
|
if(!route.names.length && !route.static && !route.extension && !route.extensionPolicyNames?.length) cases.push({path:route.pattern,method:'GET',status:entry.state==='disabled'?404:410});
|
|
64
89
|
continue;
|
|
65
90
|
}
|
|
66
|
-
if (route.extension || route.extensionPolicyNames?.length || route.proxy || route.signals?.length || route.match || route.conditional || route.function || route.
|
|
91
|
+
if (route.extension || route.extensionPolicyNames?.length || route.proxy || route.signals?.length || route.match || route.conditional || route.function || route.middleware?.length || route.names.length) continue;
|
|
67
92
|
// Required inputs need intentional fixtures; never invent business data.
|
|
68
93
|
let context ;
|
|
69
94
|
try { context = contextFor(route,{},new URLSearchParams(),new Headers()); } catch { continue; }
|
|
@@ -160,9 +185,10 @@ export async function auditProject(app , {expectRoutes,log=()=>{},c
|
|
|
160
185
|
const counts ={configured:plan.inventory.length,active:0,disabled:0,expired:0,byHandler:{}};
|
|
161
186
|
for(const route of plan.inventory){counts[route.state]++;const handler=String(route.handler);counts.byHandler[handler]=(counts.byHandler[handler]||0)+1;}
|
|
162
187
|
const countMatches=expectRoutes===undefined || counts.configured===expectRoutes;
|
|
188
|
+
const advisories=plan.inventory.flatMap(route=>(route.advisories??[]).map(message=>({route:route.path,message})));
|
|
163
189
|
// The per-route capability table: which policies apply and whether this
|
|
164
190
|
// host enforces, compiles or delegates each one. Refusals never get here.
|
|
165
|
-
return {
|
|
191
|
+
return {elapsedMs:performance.now()-began,ready:countMatches && !failed && !uncovered.length && counts.active>0,counts,expectedRoutes:expectRoutes ?? null,countMatches,checks:cases.length,passed,failed,coveredRouteMethods:covered.size,unassertedCases,uncovered,policies:plan.policies ?? {},compliance:compliance?await runCompliance(app,compliance):null,advisories};
|
|
166
192
|
}
|
|
167
193
|
export async function benchmarkProject(app ,{requests=1000,concurrency=2,maxP95Ms,seconds=30,warmup=0,target} ={}) {
|
|
168
194
|
assert(Number.isInteger(requests)&&requests>=1&&requests<=100000,'Requests must be 1–100000');
|
package/dist/route-diff.js
CHANGED
|
@@ -17,7 +17,7 @@ const isStrings = (value ) => Array.isArray(value) &&
|
|
|
17
17
|
function entry(value , index ) {
|
|
18
18
|
const fail = (what ) => { throw new ConfigError(`Route report inventory[${index}] ${what}`); };
|
|
19
19
|
if (!isRecord(value)) return fail('is not an object');
|
|
20
|
-
const { path, handler, methods, middleware, policies, generated, state } = value;
|
|
20
|
+
const { path, handler, methods, middleware, policies, generated, state, sandbox, sandboxReason } = value;
|
|
21
21
|
if (typeof path !== 'string' || !path) return fail('needs a path');
|
|
22
22
|
if (handler !== undefined && typeof handler !== 'string') return fail('has an invalid handler');
|
|
23
23
|
if (!isStrings(methods)) return fail('needs methods');
|
|
@@ -25,7 +25,13 @@ function entry(value , index ) {
|
|
|
25
25
|
if (!isStrings(policies)) return fail('needs policies');
|
|
26
26
|
if (generated !== undefined && typeof generated !== 'string') return fail('has an invalid generated marker');
|
|
27
27
|
if (typeof state !== 'string' || !states.includes(state )) return fail('needs a state');
|
|
28
|
-
|
|
28
|
+
// `sandbox`/`sandboxReason` postdate the report format, so an older report
|
|
29
|
+
// omits them: absent is carried through as absent rather than defaulted to
|
|
30
|
+
// `false`, which would read as a trust change that never happened.
|
|
31
|
+
if (sandbox !== undefined && typeof sandbox !== 'boolean') return fail('has an invalid sandbox flag');
|
|
32
|
+
if (sandboxReason !== undefined && typeof sandboxReason !== 'string') return fail('has an invalid sandboxReason');
|
|
33
|
+
return { path, handler, methods, middleware, policies, ...(generated !== undefined ? { generated } : {}), state:state ,
|
|
34
|
+
...(sandbox !== undefined ? { sandbox } : {}), ...(sandboxReason !== undefined ? { sandboxReason } : {}) };
|
|
29
35
|
}
|
|
30
36
|
/** Validates a parsed `urlcode routes` JSON report (a child-process or file boundary) into a snapshot. */
|
|
31
37
|
export function parseRouteSnapshot(value ) {
|
|
@@ -71,7 +77,7 @@ export function diffRoutes(before , after )
|
|
|
71
77
|
}
|
|
72
78
|
export const hasRouteChanges = (diff ) => diff.added.length + diff.removed.length + diff.changed.length > 0;
|
|
73
79
|
|
|
74
|
-
const fields = ['handler','methods','state','middleware','policies','generated','policy'] ;
|
|
80
|
+
const fields = ['handler','methods','state','sandbox','sandboxReason','middleware','policies','generated','policy'] ;
|
|
75
81
|
|
|
76
82
|
const cell = (value ) => {
|
|
77
83
|
const text = value === undefined ? '' : Array.isArray(value) && value.every(item => typeof item === 'string') ? value.join(', ') : typeof value === 'string' ? value : canonical(value);
|
|
@@ -82,12 +88,13 @@ const code = (value ) => `\`${value.replace(/\\/g, '\\\\').replac
|
|
|
82
88
|
function table(headers , rows ) {
|
|
83
89
|
return [`| ${headers.join(' | ')} |`, `|${headers.map(() => '---').join('|')}|`, ...rows.map(row => `| ${row.join(' | ')} |`)];
|
|
84
90
|
}
|
|
85
|
-
const routeRow = (record ) => [code(record.path), cell(record.handler), cell(record.methods), cell(record.state),
|
|
91
|
+
const routeRow = (record ) => [code(record.path), cell(record.handler), cell(record.methods), cell(record.state),
|
|
92
|
+
cell(record.sandbox), cell(record.sandboxReason), String(record.middleware), cell(record.policies), cell(record.generated)];
|
|
86
93
|
/** Renders a diff as Markdown: one table per nonempty section, or "No route changes". */
|
|
87
94
|
export function renderRouteDiff(diff ) {
|
|
88
95
|
if (!hasRouteChanges(diff)) return 'No route changes\n';
|
|
89
96
|
const lines = [];
|
|
90
|
-
const routeHeaders = ['Route','Handler','Methods','State','Middleware','Policies','Generated'];
|
|
97
|
+
const routeHeaders = ['Route','Handler','Methods','State','Sandbox','Sandbox reason','Middleware','Policies','Generated'];
|
|
91
98
|
const section = (title , list ) => {
|
|
92
99
|
if (!list.length) return;
|
|
93
100
|
lines.push(`### ${title} (${list.length})`, '', ...table(routeHeaders, list.map(routeRow)), '');
|
package/dist/router.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { effectiveExtensionPolicies, isSensitiveExtensionPolicy } from './extensions.js';
|
|
2
|
+
|
|
2
3
|
import { validateProxy } from './proxy.js';
|
|
3
4
|
import { validateSignal } from './signals.js';
|
|
4
5
|
|
|
@@ -57,7 +58,7 @@ function compiledRedirect(redirect ) {
|
|
|
57
58
|
const { pass: _pass, ...query } = redirect.query;
|
|
58
59
|
return { ...redirect, query };
|
|
59
60
|
}
|
|
60
|
-
export async function compileRoutes(loaded , bindings , permissions = {}, projectSha256 ) {
|
|
61
|
+
export async function compileRoutes(loaded , bindings , permissions = {}, projectSha256 , extensions ) {
|
|
61
62
|
const deadline=performance.now()+10000;
|
|
62
63
|
let processed=0;
|
|
63
64
|
const exact = new Map (), dynamic = [], mounts = [], modules = new Map ();
|
|
@@ -80,7 +81,8 @@ export async function compileRoutes(loaded , bindings
|
|
|
80
81
|
compileHttp(route);
|
|
81
82
|
if (config.match) route.match = normalizeMatch(config.match);
|
|
82
83
|
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');}
|
|
83
|
-
|
|
84
|
+
const extensionPolicyNames = Object.keys(effectiveExtensionPolicies(loaded.document,config));
|
|
85
|
+
if (config.match || config.conditional || config.extension || isSensitiveExtensionPolicy(extensionPolicyNames,extensions)) {
|
|
84
86
|
const cache = effectivePolicies(loaded.document,config).cache;
|
|
85
87
|
assert(!cache || cache.strategy === 'no-store', `${pattern}: conditional routing requires cache disabled or no-store`);
|
|
86
88
|
assert(!route.responseHeaders.some(([name,value]) => ['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control'].includes(name.toLowerCase()) && value !== 'no-store'), 'Conditional responses require no-store');
|
|
@@ -165,10 +167,6 @@ export async function compileRoutes(loaded , bindings
|
|
|
165
167
|
for (const key of query.pass || []) assert(!reserved.has(key), 'Query passthrough conflicts with destination or mapping');
|
|
166
168
|
route.redirect = compiledRedirect(declaredRedirect);
|
|
167
169
|
}
|
|
168
|
-
if (config.link) {
|
|
169
|
-
assert(route.methods.every(method => methodsDefault.includes(method)), 'Stored links support only GET and HEAD');
|
|
170
|
-
referenceCheck(config.link.code,route);
|
|
171
|
-
}
|
|
172
170
|
for (const item of config.middleware || []) {
|
|
173
171
|
const source = await functionFile(loaded.root,item.source);
|
|
174
172
|
modules.set(source,true);
|
package/dist/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { prepareExtensions, effectiveExtensionPolicies, hasExtensionPolicy, extensionResponse } from './extensions.js';
|
|
1
|
+
import { prepareExtensions, effectiveExtensionPolicies, hasExtensionPolicy, isSensitiveExtensionPolicy, extensionResponse } from './extensions.js';
|
|
2
2
|
|
|
3
3
|
import { EgressClient, EgressError } from './egress.js';
|
|
4
4
|
|
|
@@ -14,11 +14,9 @@ import { loadDocument, loadBindings } from './config.js';
|
|
|
14
14
|
import { compileRoutes, parseTarget, matchRoute, contextFor, resolveValue, redirectLocation } from './router.js';
|
|
15
15
|
import { FunctionPool } from './functions.js';
|
|
16
16
|
|
|
17
|
+
import { TrustedFunctions } from './trusted-functions.js';
|
|
17
18
|
import { prepareFunctionSnapshot, validatePolicy, authorizeEgress } from './policy.js';
|
|
18
19
|
|
|
19
|
-
import {openLinkStore} from './link-store.js';
|
|
20
|
-
|
|
21
|
-
import {linkCode,linkData,linkCollection} from './link-records.js';
|
|
22
20
|
import {assert} from './errors.js';
|
|
23
21
|
import { HttpError } from './errors.js';
|
|
24
22
|
import { compilePolicies, closePolicies, policyRequest, compileErrorPolicy, errorHeaders } from './policies.js';
|
|
@@ -31,24 +29,17 @@ import { applySite } from './site.js';
|
|
|
31
29
|
|
|
32
30
|
|
|
33
31
|
|
|
34
|
-
/** A stored link as a link store returns it; the runtime validates the fields it uses. */
|
|
35
|
-
|
|
36
|
-
/** What a `linkStores` binding must provide: a reader and, optionally, its health. */
|
|
37
|
-
|
|
38
|
-
/** An operator-owned SQLite link store the runtime opens read-only for one collection. */
|
|
39
|
-
|
|
40
32
|
|
|
41
33
|
/** An operator plugin (src/plugins.ts). */
|
|
42
34
|
|
|
43
35
|
|
|
44
|
-
|
|
36
|
+
|
|
45
37
|
|
|
46
38
|
|
|
47
39
|
|
|
48
40
|
|
|
49
41
|
|
|
50
42
|
|
|
51
|
-
|
|
52
43
|
|
|
53
44
|
|
|
54
45
|
|
|
@@ -59,8 +50,7 @@ import { applySite } from './site.js';
|
|
|
59
50
|
|
|
60
51
|
|
|
61
52
|
/** Per-request facts the host may read after handle() settles; never request text. */
|
|
62
|
-
|
|
63
|
-
|
|
53
|
+
|
|
64
54
|
|
|
65
55
|
|
|
66
56
|
|
|
@@ -97,15 +87,13 @@ export async function createRuntime(project , rawOptions
|
|
|
97
87
|
const kept = new Set(only);
|
|
98
88
|
for (const pattern of Object.keys(loaded.routes)) if (!kept.has(pattern)) delete loaded.routes[pattern];
|
|
99
89
|
}
|
|
100
|
-
assertTargetCompatibility(analyzeProjectCapabilities(loaded, options.target || 'node'));
|
|
101
|
-
const dynamicLinks=loaded.document.dynamicLinks===true;
|
|
102
|
-
assert(dynamicLinks || (!options.linkStore && !Object.keys(options.linkStores||{}).length),'Link-store bindings require dynamicLinks: true in urlcode.yaml');
|
|
90
|
+
assertTargetCompatibility(analyzeProjectCapabilities(loaded, options.target || 'node', options.extensions));
|
|
103
91
|
const snapshot = await prepareFunctionSnapshot(loaded);
|
|
104
92
|
if (options.permissions) validatePolicy(options.permissions);
|
|
105
93
|
const egressGrants=authorizeEgress(loaded,snapshot.projectSha256,options.permissions);
|
|
106
|
-
const extensionPlan=prepareExtensions(loaded.document,loaded.routes,options.extensions,{origin:options.origin??'',target:options.target??'node',projectSha256:snapshot.projectSha256});
|
|
94
|
+
const extensionPlan=prepareExtensions(loaded.document,loaded.routes,options.extensions,{origin:options.origin??'',target:options.target??'node',projectSha256:snapshot.projectSha256,root:loaded.root});
|
|
107
95
|
const bindings = await loadBindings(loaded.root, options.local, options.environment);
|
|
108
|
-
const compiled = await compileRoutes(loaded, bindings, options.permissions, snapshot.projectSha256);
|
|
96
|
+
const compiled = await compileRoutes(loaded, bindings, options.permissions, snapshot.projectSha256, options.extensions);
|
|
109
97
|
const routes = [...compiled.mounts, ...compiled.exact.values(), ...[...compiled.byLength.values()].flat()];
|
|
110
98
|
const assets = await compileAssets(loaded.root, routes);
|
|
111
99
|
// Host policies compile after assets so a policy can see what a route serves
|
|
@@ -124,30 +112,35 @@ export async function createRuntime(project , rawOptions
|
|
|
124
112
|
// Which route's policy an error belongs to, so its headers follow the route
|
|
125
113
|
// the request matched rather than the project default.
|
|
126
114
|
const errorRoutes = new WeakMap ();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
let pool ;
|
|
139
|
-
try{pool=await new FunctionPool(routes, { root:loaded.root, snapshot, log:options.log, workers:options.workers, timeoutMs:options.timeoutMs, maxBytes:options.maxBytes }).start();}
|
|
140
|
-
catch(error){await ownedStore?.close();throw error;}
|
|
115
|
+
// Only `sandbox: true` routes go through the worker/QuickJS pool
|
|
116
|
+
// (docs/SPIKE-DEFAULT-TRUST-MODEL.md): every other function/middleware
|
|
117
|
+
// route is trusted-by-default and dispatches through `trusted` below,
|
|
118
|
+
// in-process, with no worker or WASM engine involved at all.
|
|
119
|
+
const pool=await new FunctionPool(routes.filter(route=>route.sandbox===true), { root:loaded.root, snapshot, log:options.log, workers:options.workers, timeoutMs:options.timeoutMs, maxBytes:options.maxBytes }).start();
|
|
120
|
+
const trusted = new TrustedFunctions({ timeoutMs: options.timeoutMs, maxBytes: options.maxBytes, log: options.log });
|
|
121
|
+
// Eagerly validated up front, exactly like the sandboxed pool above: a
|
|
122
|
+
// trusted route with a broken module or a missing export fails activation
|
|
123
|
+
// here rather than on its first request.
|
|
124
|
+
try{await trusted.start(routes.filter(route=>route.sandbox!==true));}
|
|
125
|
+
catch(error){await pool.close();throw error;}
|
|
141
126
|
const proxyClient=new EgressClient({grantOrigins:egressGrants.proxy},options.egressDependencies);
|
|
142
127
|
const signalClient=new EgressClient({grantOrigins:egressGrants.signals,concurrency:8},options.egressDependencies);
|
|
143
128
|
let lastSignals={accepted:0,delivered:0,failed:0,dropped:0};
|
|
144
129
|
const signalBroker=new SignalBroker(signalClient,8,stats=>{for(const outcome of ['accepted','delivered','failed','dropped'] ){const count=stats[outcome]-lastSignals[outcome];if(count)sink({event:'signal',outcome,count});}lastSignals=stats;});
|
|
145
130
|
let extensionRegistry ;
|
|
146
131
|
try{extensionRegistry=await extensionPlan.activate();}
|
|
147
|
-
catch(error){await pool.close();await
|
|
132
|
+
catch(error){await pool.close();await closePolicies(shared);await proxyClient.close();await signalClient.close();throw error;}
|
|
148
133
|
for(const name of extensionRegistry.credentialHeaders)credentialHeaders.add(name);
|
|
149
134
|
for(const route of routes)route.extensionPolicyNames=Object.keys(effectiveExtensionPolicies(loaded.document,route));
|
|
135
|
+
// Gates `authorize()` invocation, the extension request-body cap and
|
|
136
|
+
// request-phase ordering: unaffected by declared cache sensitivity, so
|
|
137
|
+
// every extension-policy route stays protected here regardless.
|
|
150
138
|
const privateRoutes=new Set(routes.filter(route=>route.extension||hasExtensionPolicy(loaded.document,route)).map(route=>route.pattern));
|
|
139
|
+
// Gates the no-store/compression-disabled/extension-response-cap treatment
|
|
140
|
+
// in `finishPolicies` below. An `extension:` mount is always confidential.
|
|
141
|
+
// A `policies.extensions` route is confidential unless every named
|
|
142
|
+
// extension explicitly declares `cacheSensitive: false` (src/extensions.ts).
|
|
143
|
+
const confidentialRoutes=new Set(routes.filter(route=>route.extension||isSensitiveExtensionPolicy(route.extensionPolicyNames??[],options.extensions)).map(route=>route.pattern));
|
|
151
144
|
// Only an extension's own mount can serve its declared immutable assets.
|
|
152
145
|
const assetPrefixes=new Map(routes.filter(route=>route.extension).map(route=>[route.pattern,extensionRegistry.entries.get(route.extension ) .assetPrefixes]));
|
|
153
146
|
const assetContext=(method ,path ,pattern ) =>{const prefixes=assetPrefixes.get(pattern);return prefixes?.length?{method,path,prefixes}:undefined;};
|
|
@@ -160,7 +153,7 @@ export async function createRuntime(project , rawOptions
|
|
|
160
153
|
// its status is not cacheable). A plugin short-circuit ran before any
|
|
161
154
|
// policy, so it skips every request-phase policy's response hook.
|
|
162
155
|
async function finishPolicies(policy , request , result , producer ) {
|
|
163
|
-
const confidential=
|
|
156
|
+
const confidential=confidentialRoutes.has(request.route),asset=assetContext(request.method,request.path,request.route);
|
|
164
157
|
let out = confidential?extensionResponse(result,asset):result;
|
|
165
158
|
for (const [module, state] of policy?.response || []) {
|
|
166
159
|
if(confidential&&module.name==='compression')continue;
|
|
@@ -174,11 +167,11 @@ export async function createRuntime(project , rawOptions
|
|
|
174
167
|
return Object.fromEntries(routes.flatMap(route => route.policy && Object.keys(route.policy.describe).length ? [[route.pattern, route.policy.describe] ] : []));
|
|
175
168
|
}
|
|
176
169
|
const workers = () => ({ healthy: pool.slots.filter(slot => slot?.ready).length, slots: pool.size });
|
|
177
|
-
const testPlan = () => ({...projectPlan(compiled),
|
|
170
|
+
const testPlan = () => ({...projectPlan(compiled),policies:policyInventory()});
|
|
178
171
|
try{await activatePlugins(plugins, { testPlan, version: loaded.version + assets.digest, root: loaded.root, target });}
|
|
179
|
-
catch(error){await Promise.all([extensionRegistry.close(),signalBroker.close(),signalClient.close(),proxyClient.close(),pool.close(),
|
|
172
|
+
catch(error){await Promise.all([extensionRegistry.close(),signalBroker.close(),signalClient.close(),proxyClient.close(),pool.close(),closePolicies(shared),closePlugins(plugins),sink.close()]);throw error;}
|
|
180
173
|
return {
|
|
181
|
-
get healthy() { return !closing && pool.healthy
|
|
174
|
+
get healthy() { return !closing && pool.healthy; },
|
|
182
175
|
assetWatch: assets.watch, version: loaded.version + assets.digest, count: compiled.count, root: loaded.root,
|
|
183
176
|
testPlan,
|
|
184
177
|
get plugins() { return plugins.map(plugin => ({ name: plugin.name, version: plugin.version })); },
|
|
@@ -219,7 +212,7 @@ export async function createRuntime(project , rawOptions
|
|
|
219
212
|
const protectedRoute=privateRoutes.has(route.pattern);
|
|
220
213
|
const extensionRequest ={method,target,path:parsed.path,query:new URLSearchParams(parsed.query),headers:new Headers(headers),headerCounts:{...headerCounts},body:body??new Uint8Array(),origin:options.origin??origin,route:route.pattern,mount:route.extension?route.pattern.slice(0,-2):null,client:client??null};
|
|
221
214
|
if(protectedRoute&&(body?.byteLength??0)>Math.min(1048576,route.request?.body?.maxBytes??1048576))throw new HttpError(413,'Request body too large');
|
|
222
|
-
const authorize=async() =>{for(const name of
|
|
215
|
+
const authorize=async() =>{for(const name of route.extensionPolicyNames??[]){const entry=extensionRegistry.entries.get(name) ;if(typeof entry.instance.authorize!=='function')continue;const result=await entry.instance.authorize(entry.policies.get(route.pattern) ,extensionRequest);if(result)return result;}return undefined;};
|
|
223
216
|
if (policy || plugins.length || protectedRoute) {
|
|
224
217
|
policyReq = policyRequest({ method, target, path: parsed.path, params: path, query: parsed.query, headers, headerCounts, client, origin, route });
|
|
225
218
|
trace.client = policyReq.client;
|
|
@@ -232,6 +225,13 @@ export async function createRuntime(project , rawOptions
|
|
|
232
225
|
}
|
|
233
226
|
if(protectedRoute){if(!authorized){const denied=await authorize();if(denied)return await finishPolicies(policy,policyReq,denied);}const early=await pluginsRequest(plugins,policyReq);if(early)return await finishPolicies(policy,policyReq,early,'plugin');}
|
|
234
227
|
}
|
|
228
|
+
// Everything from here on (method contract, native reply, the native
|
|
229
|
+
// `middleware:` chain and the handler) is "the rest of the pipeline"
|
|
230
|
+
// for this route. It is wrapped in a callable so an extension's own
|
|
231
|
+
// `middleware()` hook (policies.extensions.<name>, parallel to
|
|
232
|
+
// `authorize` above and never touching this native chain) can run
|
|
233
|
+
// code before and after it via `next()`, or skip it entirely.
|
|
234
|
+
const runPipeline = async () => {
|
|
235
235
|
if (!route.methods.includes(method)) {
|
|
236
236
|
const refused = { status: 405, headers: [['allow', route.methods.join(', ')]], body: Buffer.from('Method not allowed\n') };
|
|
237
237
|
// Counted by throttle already, so it carries the budget headers and
|
|
@@ -266,23 +266,6 @@ export async function createRuntime(project , rawOptions
|
|
|
266
266
|
}
|
|
267
267
|
else if (hasRedirect(route)) native = { status: route.redirect.status || 302,
|
|
268
268
|
headers: [['location', redirectLocation(route, context, parsed.query)]], body: Buffer.alloc(0) };
|
|
269
|
-
else if(route.link){
|
|
270
|
-
// Resolution outcome for a trusted post-response observer. It records
|
|
271
|
-
// why this request ended the way it did; the caller decides whether a
|
|
272
|
-
// finished response is ever reported, and never sees stored data.
|
|
273
|
-
const collection=route.link.collection;
|
|
274
|
-
const observed =trace.link={collection,code:null,result:'invalid_code'};
|
|
275
|
-
let code ;try{code=linkCode(resolveValue(route.link.code,context));}catch{throw new HttpError(404,'Link not found');}
|
|
276
|
-
observed.code=code;observed.result='missing';
|
|
277
|
-
let record ;
|
|
278
|
-
try{const store=stores[collection];assert(store,'Missing operator link store binding');record=await store.get(collection,code);}catch{observed.result='unavailable';throw new HttpError(503,'Link store unavailable');}
|
|
279
|
-
if(!record)throw new HttpError(404,'Link not found');
|
|
280
|
-
let data;try{data=linkData({url:record.url,status:record.status,enabled:record.enabled,expires:record.expires});}catch{observed.result='invalid_record';throw new HttpError(503,'Invalid stored link');}
|
|
281
|
-
if(!data.enabled){observed.result='disabled';throw new HttpError(404,'Link not found');}
|
|
282
|
-
if(data.expires && Date.parse(data.expires)<=Date.now()){observed.result='expired';throw new HttpError(410,'Link expired');}
|
|
283
|
-
observed.result='redirect';
|
|
284
|
-
native={status:data.status,headers:[['location',data.url],['cache-control','no-store']],body:Buffer.alloc(0)};
|
|
285
|
-
}
|
|
286
269
|
else if (route.reply) native = route.reply;
|
|
287
270
|
else if (route.asset) {
|
|
288
271
|
try { native = assetResponse(route, parsed.path, method, headers); }
|
|
@@ -293,7 +276,43 @@ export async function createRuntime(project , rawOptions
|
|
|
293
276
|
}
|
|
294
277
|
if (native && !route.middleware.length) return await finishResponse(native);
|
|
295
278
|
context.args = Object.fromEntries(Object.entries(route.function?.args || {}).map(([key, ref]) => [key, resolveValue(ref, context)]));
|
|
296
|
-
|
|
279
|
+
// Uniform for `function` and `middleware` alike: a route dispatches
|
|
280
|
+
// through the sandboxed worker pool only when it declares
|
|
281
|
+
// `sandbox: true`; every other route runs trusted, in-process
|
|
282
|
+
// (docs/SPIKE-DEFAULT-TRUST-MODEL.md).
|
|
283
|
+
const executor = route.sandbox ? pool : trusted;
|
|
284
|
+
return await finishResponse(await executor.execute(route, { url: origin + target, method, headers: [...guestHeaders], body }, context, native));
|
|
285
|
+
};
|
|
286
|
+
// Extension `middleware()` hooks, declared the same way `authorize` is
|
|
287
|
+
// (policies.extensions.<name> on this route, config already validated
|
|
288
|
+
// against the extension's policySchema): only a name this route names
|
|
289
|
+
// and whose activated instance actually implements `middleware` can
|
|
290
|
+
// ever wrap it, in the route's declared order, each one's `next()`
|
|
291
|
+
// reaching the next one and the innermost `next()` reaching the
|
|
292
|
+
// native pipeline above. `authorize` is untouched: it already ran
|
|
293
|
+
// (or short-circuited) before this point.
|
|
294
|
+
let pipeline = runPipeline;
|
|
295
|
+
for (const name of [...(route.extensionPolicyNames ?? [])].reverse()) {
|
|
296
|
+
const entry = extensionRegistry.entries.get(name) ;
|
|
297
|
+
if (typeof entry.instance.middleware !== 'function') continue;
|
|
298
|
+
const config = entry.policies.get(route.pattern) ;
|
|
299
|
+
const downstream = pipeline;
|
|
300
|
+
pipeline = async () => {
|
|
301
|
+
let called = false;
|
|
302
|
+
const next = async () => {
|
|
303
|
+
assert(!called, `Extension ${name} middleware called next() more than once`);
|
|
304
|
+
called = true;
|
|
305
|
+
return await downstream();
|
|
306
|
+
};
|
|
307
|
+
const result = await entry.instance.middleware (config, extensionRequest, next);
|
|
308
|
+
// `downstream()` already ran the result through `finishPolicies`
|
|
309
|
+
// (it bottoms out in `runPipeline`, whose every exit does). Only
|
|
310
|
+
// a short-circuit that skipped `next()` returns a raw result,
|
|
311
|
+
// which needs exactly the one pass `authorize`'s own denial gets.
|
|
312
|
+
return called ? result : (policyReq ? await finishPolicies(policy, policyReq, result) : result);
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return await pipeline();
|
|
297
316
|
} catch (error) {
|
|
298
317
|
if (policy !== undefined && error && typeof error === 'object') errorRoutes.set(error, policy);
|
|
299
318
|
if (policyReq) {
|
|
@@ -315,7 +334,7 @@ export async function createRuntime(project , rawOptions
|
|
|
315
334
|
await Promise.all([signalBroker.close(),signalClient.close(),proxyClient.close()]);
|
|
316
335
|
if (active) await new Promise (resolve => { finish = resolve; });
|
|
317
336
|
await pool.close();
|
|
318
|
-
await
|
|
337
|
+
await trusted.close();
|
|
319
338
|
await closePolicies(shared);
|
|
320
339
|
await closePlugins(plugins);
|
|
321
340
|
await extensionRegistry.close();
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Public sandbox execution primitive: `@jimhoyd/urlcode/sandbox`.
|
|
2
|
+
//
|
|
3
|
+
// This is the same QuickJS/worker-thread engine that already backs
|
|
4
|
+
// `sandbox: true` `function`/`middleware` routes (src/functions.ts's
|
|
5
|
+
// `SandboxPool`, src/function-worker.ts, src/guest-api.ts) — there is exactly
|
|
6
|
+
// one place that owns worker spawning, dependency-closure module
|
|
7
|
+
// allowlisting, memory/stack limits and deadline enforcement. This module
|
|
8
|
+
// simply exposes that primitive under a route-independent shape, so an
|
|
9
|
+
// extension package (docs/EXTENSIONS.md's "Project-level lifecycle hooks")
|
|
10
|
+
// can run a project-supplied hook or middleware function through the same
|
|
11
|
+
// trusted/sandboxed dispatch selection route dispatch gets, when the
|
|
12
|
+
// project's own config declares `sandbox: true` on that hook.
|
|
13
|
+
//
|
|
14
|
+
// There is no "trusted" mode exported here, and there never will be: a hook
|
|
15
|
+
// that does NOT declare `sandbox: true` needs no primitive at all — it is
|
|
16
|
+
// ordinary first-party project code the extension can `import()` directly
|
|
17
|
+
// (see `ExtensionActivation.root` in src/extensions.ts). `SandboxPool` is
|
|
18
|
+
// specifically, and only, the isolated path. See
|
|
19
|
+
// docs/SPIKE-DEFAULT-TRUST-MODEL.md for why trusted execution needs no API.
|
|
20
|
+
//
|
|
21
|
+
// Every security property of the route-level sandbox applies unchanged here:
|
|
22
|
+
// a fresh QuickJS heap/module registry per invocation, no Node capability
|
|
23
|
+
// ever injected into the guest, the module-denial allowlist walk (only the
|
|
24
|
+
// entries this pool declares, and their own static-relative-import closure,
|
|
25
|
+
// are reachable — anything else, including a dynamic or unlisted relative
|
|
26
|
+
// import, is denied), a 32MB heap / 512KB stack ceiling per invocation, the
|
|
27
|
+
// deadline enforced twice (the QuickJS interrupt handler first, an outer
|
|
28
|
+
// worker-termination timeout as a hard backstop if the guest engine itself
|
|
29
|
+
// stops responding), `maxBytes` on both input and output, and strict
|
|
30
|
+
// validation of the guest's JSON response shape before any of it is trusted.
|
|
31
|
+
// None of these can be loosened from the outside; there is no option here to
|
|
32
|
+
// raise the memory/stack ceiling, add a module to the allowlist after
|
|
33
|
+
// construction, or pass a host object into the guest.
|
|
34
|
+
export { SandboxPool } from './functions.js';
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolves a project-relative hook source string the same way a native
|
|
43
|
+
* `function`/`middleware` route's `source` is resolved (`.mjs`/`.js` only, no
|
|
44
|
+
* path traversal outside the project root) into the absolute path
|
|
45
|
+
* `SandboxPool`'s entries and `execute()` targets expect. `root` must be the
|
|
46
|
+
* project's resolved directory (`ExtensionActivation.root`, never `cwd()`).
|
|
47
|
+
*/
|
|
48
|
+
export { functionFile } from './config.js';
|
package/dist/scaffold.js
CHANGED
|
Binary file
|