@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.2
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 +122 -0
- package/.claude/skills/urlcode-operations/SKILL.md +108 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/CONTRIBUTING.md +30 -2
- package/README.md +195 -255
- package/ROADMAP.md +143 -15
- package/SECURITY.md +31 -9
- package/dist/BUILD-MANIFEST.json +72 -47
- package/dist/adapters.js +4 -23
- package/dist/agent-lists.js +1 -1
- package/dist/agents-guide.js +113 -0
- package/dist/authoring-files.js +60 -0
- package/dist/authoring.js +11 -1
- package/dist/aws.js +4 -3
- package/dist/build-cloudflare.js +11 -24
- package/dist/build-static.js +134 -0
- package/dist/bulk.js +37 -0
- package/dist/capabilities.js +262 -0
- package/dist/capability-query.js +71 -0
- package/dist/catalog.js +105 -0
- package/dist/cli.js +165 -34
- package/dist/client-address.js +1 -1
- package/dist/compliance-rules/baseline.js +9 -17
- package/dist/compliance-rules/privacy.js +7 -18
- package/dist/compliance-rules/shared.js +0 -2
- package/dist/compliance-rules/strict.js +5 -5
- package/dist/compliance.js +6 -8
- package/dist/conditions.js +88 -0
- package/dist/config.js +69 -6
- package/dist/context.js +155 -0
- package/dist/ecosystem-cli.js +88 -0
- package/dist/egress.js +98 -0
- package/dist/examples.js +92 -0
- package/dist/explain-cli.js +64 -0
- package/dist/explain.js +131 -0
- package/dist/extensions.js +231 -0
- package/dist/function-sources.js +49 -5
- package/dist/function-worker.js +3 -1
- package/dist/functions.js +84 -13
- package/dist/guest-api.js +29 -3
- package/dist/index.js +40 -6
- package/dist/init-with.js +165 -0
- package/dist/interchange-cli.js +42 -0
- package/dist/interchange.js +189 -0
- package/dist/manifest.js +109 -0
- package/dist/match.js +2 -2
- package/dist/mcp-authoring.js +147 -0
- package/dist/mcp.js +97 -0
- package/dist/observability.js +7 -21
- package/dist/operator-host.js +29 -0
- package/dist/plugins.js +12 -0
- package/dist/policies/agents.js +2 -2
- package/dist/policies/cache.js +8 -3
- package/dist/policies/compression.js +2 -1
- package/dist/policies/security.js +0 -0
- package/dist/policies.js +1 -1
- package/dist/policy.js +56 -15
- package/dist/prerender.js +100 -41
- package/dist/project-tests.js +3 -3
- package/dist/provider-verification.js +92 -0
- package/dist/proxy.js +44 -0
- package/dist/readiness.js +34 -11
- package/dist/recipes.js +41 -0
- package/dist/route-diff.js +106 -0
- package/dist/router.js +45 -7
- package/dist/runtime.js +164 -64
- package/dist/sandbox.js +48 -0
- package/dist/scaffold.js +0 -0
- package/dist/schema-query.js +62 -0
- package/dist/scripts/operational-drills.js +12 -54
- package/dist/server.js +3 -29
- package/dist/signals.js +24 -0
- package/dist/site.js +0 -0
- package/dist/tooling.js +96 -0
- package/dist/trusted-functions.js +210 -0
- package/dist/types/adapters.d.ts +7 -4
- package/dist/types/agent-lists.d.ts +0 -1
- package/dist/types/agents-guide.d.ts +17 -0
- package/dist/types/authoring-files.d.ts +10 -0
- package/dist/types/aws.d.ts +3 -1
- package/dist/types/build-cloudflare.d.ts +1 -0
- package/dist/types/build-static.d.ts +43 -0
- package/dist/types/bulk.d.ts +27 -0
- package/dist/types/capabilities.d.ts +64 -0
- package/dist/types/capability-query.d.ts +24 -0
- package/dist/types/catalog.d.ts +65 -0
- package/dist/types/client-address.d.ts +0 -1
- package/dist/types/compliance-rules/baseline.d.ts +1 -9
- package/dist/types/compliance-rules/privacy.d.ts +1 -4
- package/dist/types/compliance-rules/shared.d.ts +0 -2
- package/dist/types/compliance-rules/strict.d.ts +0 -5
- package/dist/types/compliance.d.ts +0 -3
- package/dist/types/conditions.d.ts +19 -0
- package/dist/types/config.d.ts +21 -2
- package/dist/types/context.d.ts +66 -0
- package/dist/types/ecosystem-cli.d.ts +17 -0
- package/dist/types/egress.d.ts +46 -0
- package/dist/types/examples.d.ts +50 -0
- package/dist/types/explain-cli.d.ts +11 -0
- package/dist/types/explain.d.ts +95 -0
- package/dist/types/extensions.d.ts +177 -0
- package/dist/types/function-sources.d.ts +9 -0
- package/dist/types/functions.d.ts +48 -5
- package/dist/types/guest-api.d.ts +1 -0
- package/dist/types/index.d.ts +36 -6
- package/dist/types/init-with.d.ts +30 -0
- package/dist/types/interchange-cli.d.ts +16 -0
- package/dist/types/interchange.d.ts +42 -0
- package/dist/types/manifest.d.ts +79 -0
- package/dist/types/match.d.ts +1 -0
- package/dist/types/mcp-authoring.d.ts +92 -0
- package/dist/types/mcp.d.ts +12 -0
- package/dist/types/observability.d.ts +3 -14
- package/dist/types/operator-host.d.ts +8 -0
- package/dist/types/plugins.d.ts +2 -0
- package/dist/types/policies/agents.d.ts +0 -2
- package/dist/types/policies/compression.d.ts +2 -0
- package/dist/types/policies/security.d.ts +0 -1
- package/dist/types/policy.d.ts +15 -4
- package/dist/types/project-tests.d.ts +3 -2
- package/dist/types/provider-verification.d.ts +53 -0
- package/dist/types/proxy.d.ts +21 -0
- package/dist/types/readiness.d.ts +10 -3
- package/dist/types/recipes.d.ts +30 -0
- package/dist/types/route-diff.d.ts +27 -0
- package/dist/types/router.d.ts +2 -1
- package/dist/types/runtime.d.ts +11 -27
- package/dist/types/sandbox.d.ts +12 -0
- package/dist/types/scaffold.d.ts +0 -2
- package/dist/types/schema-query.d.ts +12 -0
- package/dist/types/server.d.ts +1 -4
- package/dist/types/signals.d.ts +25 -0
- package/dist/types/site.d.ts +0 -1
- package/dist/types/tooling.d.ts +115 -0
- package/dist/types/trusted-functions.d.ts +29 -0
- package/dist/types/types.d.ts +71 -7
- package/dist/types/typescript-authoring.d.ts +12 -0
- package/dist/types/vercel.d.ts +3 -1
- package/dist/types/verify-deployment.d.ts +47 -0
- package/dist/types.js +37 -5
- package/dist/typescript-authoring.js +142 -0
- package/dist/vercel.js +4 -3
- package/dist/verify-deployment.js +270 -0
- package/docs/AI-AUTHORING.md +232 -15
- package/docs/AWS.md +4 -4
- package/docs/BEST-PRACTICES.md +3 -2
- package/docs/BULK.md +79 -0
- package/docs/CAPABILITIES.md +192 -0
- package/docs/CAPACITY.md +129 -32
- package/docs/CI.md +142 -0
- package/docs/CLOUDFLARE.md +1 -2
- package/docs/COMPLIANCE.md +6 -9
- package/docs/CONDITIONS.md +74 -0
- package/docs/DEPLOYMENT-CHECKS.md +108 -0
- package/docs/EGRESS.md +125 -0
- package/docs/EXTENSIONS.md +398 -0
- package/docs/FRAMEWORK.md +198 -0
- package/docs/FUNCTION-SECURITY.md +129 -32
- package/docs/INSTALL.md +45 -12
- package/docs/INTERCHANGE.md +134 -0
- package/docs/LOAD-TESTING.md +4 -4
- package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
- package/docs/MIDDLEWARE.md +29 -16
- package/docs/MONITORING.md +2 -19
- package/docs/NEXT-PHASE-PLAN.md +98 -0
- package/docs/NEXT-STEPS.md +634 -0
- package/docs/OBSERVABILITY.md +11 -18
- package/docs/OPEN-DECISIONS.md +212 -0
- package/docs/OPERATIONAL-PROOF.md +30 -31
- package/docs/OPERATIONS.md +29 -35
- package/docs/PLUGINS.md +37 -0
- package/docs/POLICIES.md +23 -309
- package/docs/PRERENDER.md +41 -1
- package/docs/PROJECT-DIRECTION.md +75 -8
- package/docs/PROVIDER-VERIFICATION.md +84 -0
- package/docs/READINESS.md +21 -1
- package/docs/README.md +87 -34
- package/docs/RECIPES.md +99 -0
- package/docs/RELEASE-READINESS.md +57 -35
- package/docs/RELEASE-SECURITY.md +116 -7
- package/docs/RESILIENCE.md +16 -15
- package/docs/ROUTING.md +8 -10
- package/docs/SANDBOX-REVIEW.md +19 -6
- package/docs/SCAFFOLDING.md +0 -2
- package/docs/SECURITY-AUDIT.md +41 -1
- package/docs/SPECIFICATION.md +150 -29
- package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +287 -0
- package/docs/SPIKE-BUSINESS-SUITE.md +1021 -0
- package/docs/SPIKE-CORE-LAYERING.md +337 -0
- package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +209 -0
- package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
- package/docs/SPIKE-EXTENSIONS.md +6 -0
- package/docs/SPIKE-LAMBDA-COMPILE.md +201 -0
- package/docs/SPIKE-MONOREPO.md +322 -0
- package/docs/STANDARDS.md +150 -142
- package/docs/STARTERS.md +21 -1
- package/docs/STATIC.md +94 -0
- package/docs/TOOLING.md +295 -0
- package/docs/TUNNELS.md +0 -3
- package/docs/TYPESCRIPT-AUTHORING.md +82 -0
- package/docs/TYPESCRIPT.md +25 -4
- package/docs/USABILITY-REVIEW.md +129 -0
- package/docs/VERCEL.md +4 -5
- package/docs/VERSION-ALIGNMENT.md +205 -0
- package/docs/YAML-GUIDE.md +15 -479
- package/docs/YAML-REFERENCE.md +143 -22
- package/docs/policies/agents.md +1 -1
- package/docs/policies/cache.md +13 -0
- package/docs/policies/contract.md +52 -0
- package/docs/policies/hardened.md +56 -0
- package/docs/policies/interoperability.md +169 -0
- package/docs/policies/operations.md +45 -0
- package/docs/yaml/assets.md +36 -0
- package/docs/yaml/conditions.md +20 -0
- package/docs/yaml/functions.md +160 -0
- package/docs/yaml/middleware.md +29 -0
- package/docs/yaml/organization.md +74 -0
- package/docs/yaml/policies.md +37 -0
- package/docs/yaml/redirects.md +64 -0
- package/docs/yaml/responses.md +57 -0
- package/docs/yaml/site.md +24 -0
- package/examples/assets/example.yaml +17 -0
- package/examples/aws/example.yaml +20 -0
- package/examples/cloudflare/example.yaml +19 -0
- package/examples/compliance/example.yaml +11 -0
- package/examples/conditions/README.md +12 -0
- package/examples/conditions/example.yaml +19 -0
- package/examples/conditions/tests/requests.json +13 -0
- package/examples/conditions/urlcode.yaml +24 -0
- package/examples/cookbook/README.md +8 -4
- package/examples/cookbook/example.yaml +17 -0
- package/examples/cookbook/functions/catalog.mjs +3 -0
- package/examples/cookbook/functions/fail.mjs +4 -0
- package/examples/cookbook/functions/items.mjs +3 -0
- package/examples/cookbook/functions/profile.mjs +3 -0
- package/examples/cookbook/functions/resource.mjs +3 -0
- package/examples/cookbook/functions/status.mjs +3 -0
- package/examples/cookbook/middleware/auth.mjs +48 -0
- package/examples/cookbook/middleware/body.mjs +15 -0
- package/examples/cookbook/middleware/bucket.mjs +29 -0
- package/examples/cookbook/middleware/cors.mjs +21 -0
- package/examples/cookbook/middleware/debug.mjs +13 -0
- package/examples/cookbook/middleware/envelope.mjs +11 -0
- package/examples/cookbook/middleware/errors.mjs +11 -0
- package/examples/cookbook/middleware/etag.mjs +18 -0
- package/examples/cookbook/middleware/locale.mjs +20 -0
- package/examples/cookbook/middleware/maintenance.mjs +10 -0
- package/examples/cookbook/middleware/methods.mjs +15 -0
- package/examples/cookbook/middleware/negotiate.mjs +20 -0
- package/examples/cookbook/middleware/referer.mjs +12 -0
- package/examples/cookbook/middleware/request-id.mjs +16 -0
- package/examples/cookbook/route-index.json +676 -0
- package/examples/cookbook/routes/middleware.yaml +126 -0
- package/examples/cookbook/tests/requests.json +526 -0
- package/examples/cookbook/urlcode.yaml +1 -0
- package/examples/egress/README.md +22 -0
- package/examples/egress/example.yaml +19 -0
- package/examples/egress/urlcode.yaml +19 -0
- package/examples/extensions/README.md +7 -0
- package/examples/extensions/example.yaml +21 -0
- package/examples/extensions/urlcode.yaml +25 -0
- package/examples/monitoring/example.yaml +8 -0
- package/examples/prerender/README.md +2 -2
- package/examples/prerender/example.yaml +16 -0
- package/examples/provider-conformance/README.md +12 -0
- package/examples/provider-conformance/example.yaml +14 -0
- package/examples/provider-conformance/urlcode.yaml +34 -0
- package/examples/tunnel/example.yaml +8 -0
- package/examples/vercel/example.yaml +19 -0
- package/llms-full.txt +3084 -0
- package/llms.txt +61 -21
- package/package.json +36 -7
- package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +122 -0
- package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +108 -0
- package/recipes/authenticated-json-api/README.md +51 -0
- package/recipes/authenticated-json-api/functions/profile.mjs +5 -0
- package/recipes/authenticated-json-api/recipe.yaml +34 -0
- package/recipes/authenticated-json-api/tests/requests.json +39 -0
- package/recipes/authenticated-json-api/urlcode.yaml +12 -0
- package/recipes/contact-form/README.md +25 -0
- package/recipes/contact-form/functions/contact.mjs +17 -0
- package/recipes/contact-form/recipe.yaml +33 -0
- package/recipes/contact-form/tests/requests.json +47 -0
- package/recipes/contact-form/urlcode.yaml +18 -0
- package/recipes/cors-api/README.md +16 -0
- package/recipes/cors-api/functions/items.mjs +3 -0
- package/recipes/cors-api/middleware/cors.mjs +21 -0
- package/recipes/cors-api/recipe.yaml +26 -0
- package/recipes/cors-api/tests/requests.json +65 -0
- package/recipes/cors-api/urlcode.yaml +12 -0
- package/recipes/health-page/README.md +13 -0
- package/recipes/health-page/recipe.yaml +23 -0
- package/recipes/health-page/tests/requests.json +36 -0
- package/recipes/health-page/urlcode.yaml +19 -0
- package/recipes/json-api/README.md +6 -0
- package/recipes/json-api/functions/echo.mjs +3 -0
- package/recipes/json-api/recipe.yaml +25 -0
- package/recipes/json-api/tests/requests.json +34 -0
- package/recipes/json-api/urlcode.yaml +12 -0
- package/recipes/middleware/README.md +34 -0
- package/recipes/middleware/functions/catalog.mjs +3 -0
- package/recipes/middleware/functions/fail.mjs +4 -0
- package/recipes/middleware/functions/items.mjs +3 -0
- package/recipes/middleware/functions/profile.mjs +3 -0
- package/recipes/middleware/functions/resource.mjs +3 -0
- package/recipes/middleware/functions/status.mjs +3 -0
- package/recipes/middleware/middleware/auth.mjs +48 -0
- package/recipes/middleware/middleware/body.mjs +15 -0
- package/recipes/middleware/middleware/bucket.mjs +29 -0
- package/recipes/middleware/middleware/cors.mjs +21 -0
- package/recipes/middleware/middleware/debug.mjs +13 -0
- package/recipes/middleware/middleware/envelope.mjs +11 -0
- package/recipes/middleware/middleware/errors.mjs +11 -0
- package/recipes/middleware/middleware/etag.mjs +18 -0
- package/recipes/middleware/middleware/locale.mjs +20 -0
- package/recipes/middleware/middleware/maintenance.mjs +10 -0
- package/recipes/middleware/middleware/methods.mjs +15 -0
- package/recipes/middleware/middleware/negotiate.mjs +20 -0
- package/recipes/middleware/middleware/referer.mjs +12 -0
- package/recipes/middleware/middleware/request-id.mjs +16 -0
- package/recipes/middleware/public/guide.txt +1 -0
- package/recipes/middleware/recipe.yaml +50 -0
- package/recipes/middleware/tests/requests.json +528 -0
- package/recipes/middleware/urlcode.yaml +127 -0
- package/recipes/protected-download/README.md +22 -0
- package/recipes/protected-download/files/report.txt +1 -0
- package/recipes/protected-download/recipe.yaml +31 -0
- package/recipes/protected-download/tests/requests.json +32 -0
- package/recipes/protected-download/urlcode.yaml +15 -0
- package/recipes/redirect/README.md +7 -0
- package/recipes/redirect/recipe.yaml +25 -0
- package/recipes/redirect/tests/requests.json +19 -0
- package/recipes/redirect/urlcode.yaml +9 -0
- package/recipes/static-plus-api/README.md +15 -0
- package/recipes/static-plus-api/functions/info.mjs +3 -0
- package/recipes/static-plus-api/public/assets/index.html +3 -0
- package/recipes/static-plus-api/public/assets/site.css +1 -0
- package/recipes/static-plus-api/public/index.html +8 -0
- package/recipes/static-plus-api/recipe.yaml +29 -0
- package/recipes/static-plus-api/tests/requests.json +56 -0
- package/recipes/static-plus-api/urlcode.yaml +23 -0
- package/recipes/typescript/README.md +8 -0
- package/recipes/typescript/functions/hello.ts +5 -0
- package/recipes/typescript/recipe.yaml +23 -0
- package/recipes/typescript/tests/requests.json +18 -0
- package/recipes/typescript/urlcode.yaml +5 -0
- package/recipes/webhook-receiver/README.md +20 -0
- package/recipes/webhook-receiver/functions/receive.mjs +16 -0
- package/recipes/webhook-receiver/recipe.yaml +27 -0
- package/recipes/webhook-receiver/tests/requests.json +59 -0
- package/recipes/webhook-receiver/urlcode.yaml +23 -0
- package/schemas/recipe.schema.json +139 -0
- package/schemas/urlcode.schema.json +659 -110
- package/skills/urlcode/SKILL.md +119 -0
- package/starters/default/.github/workflows/urlcode.yml +23 -0
- package/starters/default/.mcp.json +12 -0
- package/starters/default/AGENTS.md +79 -0
- 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 -41
- package/dist/sqlite-version.js +0 -6
- package/dist/types/link-api.d.ts +0 -30
- package/dist/types/link-cli.d.ts +0 -36
- 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 -9
- package/dist/types/sqlite-version.d.ts +0 -1
- package/docs/DYNAMIC-LINKS.md +0 -561
- package/docs/MANAGEMENT-SECURITY.md +0 -82
- package/examples/live-links/README.md +0 -11
- package/examples/live-links/tests/requests.json +0 -6
- package/examples/live-links/urlcode.yaml +0 -16
package/dist/runtime.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { prepareExtensions, effectiveExtensionPolicies, hasExtensionPolicy, isSensitiveExtensionPolicy, extensionResponse } from './extensions.js';
|
|
2
|
+
|
|
3
|
+
import { EgressClient, EgressError } from './egress.js';
|
|
4
|
+
|
|
5
|
+
import { executeProxy } from './proxy.js';
|
|
6
|
+
import { SignalBroker } from './signals.js';
|
|
7
|
+
import { matchesRoute } from './conditions.js';
|
|
8
|
+
import { analyzeProjectCapabilities, assertTargetCompatibility } from './capabilities.js';
|
|
1
9
|
import { projectPlan, hasRedirect } from './readiness.js';
|
|
2
10
|
|
|
3
11
|
import { checkRequest, decorateResponse } from './http-policy.js';
|
|
@@ -6,11 +14,9 @@ import { loadDocument, loadBindings } from './config.js';
|
|
|
6
14
|
import { compileRoutes, parseTarget, matchRoute, contextFor, resolveValue, redirectLocation } from './router.js';
|
|
7
15
|
import { FunctionPool } from './functions.js';
|
|
8
16
|
|
|
9
|
-
import {
|
|
17
|
+
import { TrustedFunctions } from './trusted-functions.js';
|
|
18
|
+
import { prepareFunctionSnapshot, validatePolicy, authorizeEgress } from './policy.js';
|
|
10
19
|
|
|
11
|
-
import {openLinkStore} from './link-store.js';
|
|
12
|
-
|
|
13
|
-
import {linkCode,linkData,linkCollection} from './link-records.js';
|
|
14
20
|
import {assert} from './errors.js';
|
|
15
21
|
import { HttpError } from './errors.js';
|
|
16
22
|
import { compilePolicies, closePolicies, policyRequest, compileErrorPolicy, errorHeaders } from './policies.js';
|
|
@@ -23,27 +29,28 @@ import { applySite } from './site.js';
|
|
|
23
29
|
|
|
24
30
|
|
|
25
31
|
|
|
26
|
-
/** A stored link as a link store returns it; the runtime validates the fields it uses. */
|
|
27
|
-
|
|
28
|
-
/** What a `linkStores` binding must provide: a reader and, optionally, its health. */
|
|
29
|
-
|
|
30
|
-
/** An operator-owned SQLite link store the runtime opens read-only for one collection. */
|
|
31
|
-
|
|
32
32
|
|
|
33
33
|
/** An operator plugin (src/plugins.ts). */
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
38
41
|
|
|
39
42
|
|
|
40
|
-
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
43
51
|
|
|
44
52
|
/** Per-request facts the host may read after handle() settles; never request text. */
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
|
|
47
54
|
|
|
48
55
|
|
|
49
56
|
|
|
@@ -71,12 +78,22 @@ export async function createRuntime(project , rawOptions
|
|
|
71
78
|
// route at the same path wins. The public origin, when the server knows
|
|
72
79
|
// it, is what absolute URLs in generated files are built from.
|
|
73
80
|
await applySite(loaded, { origin: options.origin, log: options.log });
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
if (options.only !== undefined) {
|
|
82
|
+
const only = options.only;
|
|
83
|
+
assert(Array.isArray(only) && only.every(pattern => typeof pattern === 'string'), 'Route restriction must be a string array');
|
|
84
|
+
// An unknown pattern is a caller mistake, not an empty selection: a silent
|
|
85
|
+
// miss would prerender a partial site that looks whole.
|
|
86
|
+
for (const pattern of only) assert(Object.hasOwn(loaded.routes, pattern), `Route restriction names unknown route ${pattern}`);
|
|
87
|
+
const kept = new Set(only);
|
|
88
|
+
for (const pattern of Object.keys(loaded.routes)) if (!kept.has(pattern)) delete loaded.routes[pattern];
|
|
89
|
+
}
|
|
90
|
+
assertTargetCompatibility(analyzeProjectCapabilities(loaded, options.target || 'node', options.extensions));
|
|
77
91
|
const snapshot = await prepareFunctionSnapshot(loaded);
|
|
78
92
|
if (options.permissions) validatePolicy(options.permissions);
|
|
79
|
-
const
|
|
93
|
+
const egressGrants=authorizeEgress(loaded,snapshot.projectSha256,options.permissions);
|
|
94
|
+
const extensionPlan=prepareExtensions(loaded.document,loaded.routes,options.extensions,{origin:options.origin??'',target:options.target??'node',projectSha256:snapshot.projectSha256,root:loaded.root});
|
|
95
|
+
const bindings = await loadBindings(loaded.root, options.local, options.environment);
|
|
96
|
+
const compiled = await compileRoutes(loaded, bindings, options.permissions, snapshot.projectSha256, options.extensions);
|
|
80
97
|
const routes = [...compiled.mounts, ...compiled.exact.values(), ...[...compiled.byLength.values()].flat()];
|
|
81
98
|
const assets = await compileAssets(loaded.root, routes);
|
|
82
99
|
// Host policies compile after assets so a policy can see what a route serves
|
|
@@ -85,6 +102,9 @@ export async function createRuntime(project , rawOptions
|
|
|
85
102
|
// reloads: a new snapshot starts with empty counters and an empty cache.
|
|
86
103
|
const target = options.target || 'node';
|
|
87
104
|
const plugins = validatePlugins(options.plugins, target);
|
|
105
|
+
// Capture the operator declaration once, before activation hooks can mutate
|
|
106
|
+
// their plugin objects. This boundary applies to public guest routes too.
|
|
107
|
+
const credentialHeaders=new Set(plugins.flatMap(plugin=>plugin.credentialHeaders||[]).map(name=>name.toLowerCase()));
|
|
88
108
|
const shared = { target, log: options.log, routes: routes.length };
|
|
89
109
|
const anyPolicy = Boolean(loaded.document.policies) || routes.some(route => route.policies);
|
|
90
110
|
for (const route of routes) route.policy = anyPolicy ? await compilePolicies(loaded.document, route, { route, shared, target, root: loaded.root }) : null;
|
|
@@ -92,20 +112,38 @@ export async function createRuntime(project , rawOptions
|
|
|
92
112
|
// Which route's policy an error belongs to, so its headers follow the route
|
|
93
113
|
// the request matched rather than the project default.
|
|
94
114
|
const errorRoutes = new WeakMap ();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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;}
|
|
126
|
+
const proxyClient=new EgressClient({grantOrigins:egressGrants.proxy},options.egressDependencies);
|
|
127
|
+
const signalClient=new EgressClient({grantOrigins:egressGrants.signals,concurrency:8},options.egressDependencies);
|
|
128
|
+
let lastSignals={accepted:0,delivered:0,failed:0,dropped:0};
|
|
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;});
|
|
130
|
+
let extensionRegistry ;
|
|
131
|
+
try{extensionRegistry=await extensionPlan.activate();}
|
|
132
|
+
catch(error){await pool.close();await closePolicies(shared);await proxyClient.close();await signalClient.close();throw error;}
|
|
133
|
+
for(const name of extensionRegistry.credentialHeaders)credentialHeaders.add(name);
|
|
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.
|
|
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));
|
|
144
|
+
// Only an extension's own mount can serve its declared immutable assets.
|
|
145
|
+
const assetPrefixes=new Map(routes.filter(route=>route.extension).map(route=>[route.pattern,extensionRegistry.entries.get(route.extension ) .assetPrefixes]));
|
|
146
|
+
const assetContext=(method ,path ,pattern ) =>{const prefixes=assetPrefixes.get(pattern);return prefixes?.length?{method,path,prefixes}:undefined;};
|
|
109
147
|
let active = 0, closing = false, finish ;
|
|
110
148
|
// Response phase: cache store, throttle headers, security headers,
|
|
111
149
|
// compression, then operator plugins in reverse. A result produced by a
|
|
@@ -115,21 +153,25 @@ export async function createRuntime(project , rawOptions
|
|
|
115
153
|
// its status is not cacheable). A plugin short-circuit ran before any
|
|
116
154
|
// policy, so it skips every request-phase policy's response hook.
|
|
117
155
|
async function finishPolicies(policy , request , result , producer ) {
|
|
118
|
-
|
|
156
|
+
const confidential=confidentialRoutes.has(request.route),asset=assetContext(request.method,request.path,request.route);
|
|
157
|
+
let out = confidential?extensionResponse(result,asset):result;
|
|
119
158
|
for (const [module, state] of policy?.response || []) {
|
|
159
|
+
if(confidential&&module.name==='compression')continue;
|
|
120
160
|
if (producer === 'plugin' ? module.onRequest : module === producer) continue;
|
|
121
161
|
out = await module.onResponse?.(state, request, out) ?? out;
|
|
122
162
|
}
|
|
123
|
-
|
|
163
|
+
out=await pluginsResponse(plugins, request, out);
|
|
164
|
+
return confidential?extensionResponse(out,asset):out;
|
|
124
165
|
}
|
|
125
166
|
function policyInventory() {
|
|
126
167
|
return Object.fromEntries(routes.flatMap(route => route.policy && Object.keys(route.policy.describe).length ? [[route.pattern, route.policy.describe] ] : []));
|
|
127
168
|
}
|
|
128
169
|
const workers = () => ({ healthy: pool.slots.filter(slot => slot?.ready).length, slots: pool.size });
|
|
129
|
-
const testPlan = () => ({...projectPlan(compiled),
|
|
130
|
-
await activatePlugins(plugins, { testPlan, version: loaded.version + assets.digest, root: loaded.root, target });
|
|
170
|
+
const testPlan = () => ({...projectPlan(compiled),policies:policyInventory()});
|
|
171
|
+
try{await activatePlugins(plugins, { testPlan, version: loaded.version + assets.digest, root: loaded.root, target });}
|
|
172
|
+
catch(error){await Promise.all([extensionRegistry.close(),signalBroker.close(),signalClient.close(),proxyClient.close(),pool.close(),closePolicies(shared),closePlugins(plugins),sink.close()]);throw error;}
|
|
131
173
|
return {
|
|
132
|
-
get healthy() { return !closing && pool.healthy
|
|
174
|
+
get healthy() { return !closing && pool.healthy; },
|
|
133
175
|
assetWatch: assets.watch, version: loaded.version + assets.digest, count: compiled.count, root: loaded.root,
|
|
134
176
|
testPlan,
|
|
135
177
|
get plugins() { return plugins.map(plugin => ({ name: plugin.name, version: plugin.version })); },
|
|
@@ -144,7 +186,7 @@ export async function createRuntime(project , rawOptions
|
|
|
144
186
|
},
|
|
145
187
|
requestLimit(target) {
|
|
146
188
|
const match = matchRoute(compiled, parseTarget(target));
|
|
147
|
-
return match?.route.request?.body?.maxBytes;
|
|
189
|
+
return match?.route.request?.body?.maxBytes ?? (match?.route.proxy||match?.route.extension?1048576:undefined);
|
|
148
190
|
},
|
|
149
191
|
async handle({ target, method = 'GET', headers = new Headers(), body, headerCounts, trace = {}, origin = 'http://localhost', client }) {
|
|
150
192
|
if (closing) throw new HttpError(503, 'Runtime unavailable');
|
|
@@ -160,21 +202,36 @@ export async function createRuntime(project , rawOptions
|
|
|
160
202
|
// Known from here on, so an error thrown by the route's own checks
|
|
161
203
|
// (disabled, expired) is answered with that route's security headers.
|
|
162
204
|
policy = route.policy;
|
|
205
|
+
const conditionRequest = { query: parsed.query, headers, method, origin, headerCounts };
|
|
206
|
+
if (route.match && !matchesRoute(route.match,conditionRequest)) throw new HttpError(404,'Not found');
|
|
163
207
|
if (route.enabled === false) throw new HttpError(404, 'Not found');
|
|
164
208
|
if (route.expiresAt && Date.now() >= route.expiresAt) throw new HttpError(410, 'Gone');
|
|
165
209
|
// Host policies and plugins run once the route is known and before
|
|
166
210
|
// its contract is checked: a denied agent or an exhausted budget is
|
|
167
211
|
// answered without reading a body or touching the sandbox.
|
|
168
|
-
|
|
212
|
+
const protectedRoute=privateRoutes.has(route.pattern);
|
|
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};
|
|
214
|
+
if(protectedRoute&&(body?.byteLength??0)>Math.min(1048576,route.request?.body?.maxBytes??1048576))throw new HttpError(413,'Request body too large');
|
|
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;};
|
|
216
|
+
if (policy || plugins.length || protectedRoute) {
|
|
169
217
|
policyReq = policyRequest({ method, target, path: parsed.path, params: path, query: parsed.query, headers, headerCounts, client, origin, route });
|
|
170
218
|
trace.client = policyReq.client;
|
|
171
|
-
const early
|
|
172
|
-
|
|
219
|
+
if(!protectedRoute){const early=await pluginsRequest(plugins,policyReq);if(early)return await finishPolicies(policy,policyReq,early,'plugin');}
|
|
220
|
+
let authorized=false;
|
|
173
221
|
for (const [module, state] of policy?.request || []) {
|
|
222
|
+
if(protectedRoute&&module.name==='cache'&&!authorized){const denied=await authorize();authorized=true;if(denied)return await finishPolicies(policy,policyReq,denied);}
|
|
174
223
|
const result = await module.onRequest?.(state, policyReq);
|
|
175
224
|
if (result) return await finishPolicies(policy, policyReq, result, module);
|
|
176
225
|
}
|
|
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');}
|
|
177
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 () => {
|
|
178
235
|
if (!route.methods.includes(method)) {
|
|
179
236
|
const refused = { status: 405, headers: [['allow', route.methods.join(', ')]], body: Buffer.from('Method not allowed\n') };
|
|
180
237
|
// Counted by throttle already, so it carries the budget headers and
|
|
@@ -182,28 +239,33 @@ export async function createRuntime(project , rawOptions
|
|
|
182
239
|
return policyReq ? await finishPolicies(policy, policyReq, refused) : refused;
|
|
183
240
|
}
|
|
184
241
|
checkRequest(route, body || Buffer.alloc(0), headers, headerCounts);
|
|
185
|
-
const finishResponse = async (result )
|
|
186
|
-
|
|
242
|
+
const finishResponse = async (result ) => {
|
|
243
|
+
const out = policyReq ? await finishPolicies(policy, policyReq, decorateResponse(route,result)) : decorateResponse(route,result);
|
|
244
|
+
if(method!=='HEAD'&&!trace.probe)for(const signal of route.compiledSignals||[])signalBroker.emit(signal,{route:route.pattern,status:out.status,method});
|
|
245
|
+
if (route.proxy || route.match || route.conditional) return { ...out, headers: [...out.headers.filter(([name]) => !['cache-control','cdn-cache-control','vercel-cdn-cache-control','surrogate-control'].includes(name.toLowerCase())), ['cache-control','no-store']] };
|
|
246
|
+
return out;
|
|
247
|
+
};
|
|
248
|
+
// Policies, plugins and body checks retain the original request. Project
|
|
249
|
+
// inputs and the guest receive a separate, credential-free projection.
|
|
250
|
+
const guestHeaders=credentialHeaders.size?new Headers(headers):headers;
|
|
251
|
+
for(const name of credentialHeaders)guestHeaders.delete(name);
|
|
252
|
+
const context = contextFor(route, path, parsed.query, guestHeaders, headerCounts);
|
|
253
|
+
// A declared schema default must not recreate a withheld header entry.
|
|
254
|
+
for(const name of credentialHeaders)delete context.inputs.header[name];
|
|
187
255
|
let native ;
|
|
188
|
-
if
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// why this request ended the way it did; the caller decides whether a
|
|
193
|
-
// finished response is ever reported, and never sees stored data.
|
|
194
|
-
const collection=route.link.collection;
|
|
195
|
-
const observed =trace.link={collection,code:null,result:'invalid_code'};
|
|
196
|
-
let code ;try{code=linkCode(resolveValue(route.link.code,context));}catch{throw new HttpError(404,'Link not found');}
|
|
197
|
-
observed.code=code;observed.result='missing';
|
|
198
|
-
let record ;
|
|
199
|
-
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');}
|
|
200
|
-
if(!record)throw new HttpError(404,'Link not found');
|
|
201
|
-
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');}
|
|
202
|
-
if(!data.enabled){observed.result='disabled';throw new HttpError(404,'Link not found');}
|
|
203
|
-
if(data.expires && Date.parse(data.expires)<=Date.now()){observed.result='expired';throw new HttpError(410,'Link expired');}
|
|
204
|
-
observed.result='redirect';
|
|
205
|
-
native={status:data.status,headers:[['location',data.url],['cache-control','no-store']],body:Buffer.alloc(0)};
|
|
256
|
+
if(route.extension){native=extensionResponse(await extensionRegistry.entries.get(route.extension) .instance.handle(extensionRequest),assetContext(method,parsed.path,route.pattern));}
|
|
257
|
+
else if(route.compiledProxy){
|
|
258
|
+
try {const result=await executeProxy(proxyClient,route.compiledProxy,{method,url:origin+target,params:path,headers:Object.fromEntries(headers),...(body?{body}:{})});native={status:result.status,headers:Object.entries(result.headers),body:result.body};}
|
|
259
|
+
catch(error){throw new HttpError(error instanceof EgressError&&error.code==='timeout'?504:error instanceof EgressError&&['busy','closed','aborted'].includes(error.code)?503:502,'Proxy upstream unavailable');}
|
|
206
260
|
}
|
|
261
|
+
else if (route.conditionalRoutes) {
|
|
262
|
+
const selected = route.conditionalRoutes.cases.find(item => matchesRoute(item.match,conditionRequest))?.route ?? route.conditionalRoutes.fallback;
|
|
263
|
+
if (!selected) throw new HttpError(404,'Not found');
|
|
264
|
+
if (hasRedirect(selected)) native = { status: selected.redirect.status || 302, headers: [['location',redirectLocation(selected,context,parsed.query)]], body: Buffer.alloc(0) };
|
|
265
|
+
else native = selected.reply;
|
|
266
|
+
}
|
|
267
|
+
else if (hasRedirect(route)) native = { status: route.redirect.status || 302,
|
|
268
|
+
headers: [['location', redirectLocation(route, context, parsed.query)]], body: Buffer.alloc(0) };
|
|
207
269
|
else if (route.reply) native = route.reply;
|
|
208
270
|
else if (route.asset) {
|
|
209
271
|
try { native = assetResponse(route, parsed.path, method, headers); }
|
|
@@ -212,9 +274,45 @@ export async function createRuntime(project , rawOptions
|
|
|
212
274
|
native = {status:error.status,headers:[['content-type','text/plain; charset=utf-8'],['cache-control','no-store']],body:Buffer.from(error.message+'\n')};
|
|
213
275
|
}
|
|
214
276
|
}
|
|
215
|
-
if (native && !route.middleware.length) return finishResponse(native);
|
|
277
|
+
if (native && !route.middleware.length) return await finishResponse(native);
|
|
216
278
|
context.args = Object.fromEntries(Object.entries(route.function?.args || {}).map(([key, ref]) => [key, resolveValue(ref, context)]));
|
|
217
|
-
|
|
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();
|
|
218
316
|
} catch (error) {
|
|
219
317
|
if (policy !== undefined && error && typeof error === 'object') errorRoutes.set(error, policy);
|
|
220
318
|
if (policyReq) {
|
|
@@ -233,11 +331,13 @@ export async function createRuntime(project , rawOptions
|
|
|
233
331
|
},
|
|
234
332
|
async close() {
|
|
235
333
|
closing = true;
|
|
334
|
+
await Promise.all([signalBroker.close(),signalClient.close(),proxyClient.close()]);
|
|
236
335
|
if (active) await new Promise (resolve => { finish = resolve; });
|
|
237
336
|
await pool.close();
|
|
238
|
-
await
|
|
337
|
+
await trusted.close();
|
|
239
338
|
await closePolicies(shared);
|
|
240
339
|
await closePlugins(plugins);
|
|
340
|
+
await extensionRegistry.close();
|
|
241
341
|
await sink.close();
|
|
242
342
|
},
|
|
243
343
|
};
|
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
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import {ConfigError} from './errors.js';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const maxDepth=12;
|
|
8
|
+
let cached ;
|
|
9
|
+
function schema() {
|
|
10
|
+
cached??=JSON.parse(readFileSync(fileURLToPath(new URL('../schemas/urlcode.schema.json',import.meta.url)),'utf8')) ;
|
|
11
|
+
return cached;
|
|
12
|
+
}
|
|
13
|
+
const object=(value ) =>value!==null&&typeof value==='object'&&!Array.isArray(value);
|
|
14
|
+
const properties=(node ) =>object(node.properties)?node.properties:{};
|
|
15
|
+
function definition(root ,ref ) {
|
|
16
|
+
const defs=object(root.$defs)?root.$defs:{};
|
|
17
|
+
const name=ref.startsWith('#/$defs/')?ref.slice('#/$defs/'.length):undefined;
|
|
18
|
+
const found=name===undefined?undefined:defs[name];
|
|
19
|
+
if(!object(found))throw new ConfigError('Schema reference cannot be resolved');
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
/** Definitions that are their own dotted path stay summarized when nested, so `routes` does not inline the whole route object. */
|
|
23
|
+
const summarized ={'#/$defs/route':'route','#/$defs/policies':'policies'};
|
|
24
|
+
/** Inline every local `$ref` with a depth bound and a cycle guard. The bundled schema has no cycles; a cycle would surface as an annotation, not a crash. */
|
|
25
|
+
function inline(root ,node ,stack ) {
|
|
26
|
+
if(Array.isArray(node))return node.map(item=>inline(root,item,stack));
|
|
27
|
+
if(!object(node))return node;
|
|
28
|
+
if(typeof node.$ref==='string') {
|
|
29
|
+
const {$ref,...rest}=node;
|
|
30
|
+
if(stack.includes($ref)||stack.length>=maxDepth)return {...rest,$comment:`unresolved reference ${$ref}: ${stack.includes($ref)?'cycle':'depth limit'}`};
|
|
31
|
+
const path=summarized[$ref];
|
|
32
|
+
if(path!==undefined)return {type:'object',...rest,$comment:`${path} object; see urlcode schema ${path}`};
|
|
33
|
+
return {...inline(root,definition(root,$ref),[...stack,$ref]) ,...inline(root,rest,stack) };
|
|
34
|
+
}
|
|
35
|
+
return Object.fromEntries(Object.entries(node).map(([key,value])=>[key,inline(root,value,stack)]));
|
|
36
|
+
}
|
|
37
|
+
const resolved=(root ,node ) =>typeof node.$ref==='string'?{...definition(root,node.$ref),...Object.fromEntries(Object.entries(node).filter(([key])=>key!=='$ref'))}:node;
|
|
38
|
+
/** Top-level document keys first, then `route` itself and every route property (`redirect`, `function`, `middleware`, ...). */
|
|
39
|
+
export function schemaPathNames() {
|
|
40
|
+
const root=schema();
|
|
41
|
+
const route=definition(root,'#/$defs/route');
|
|
42
|
+
return [...new Set([...Object.keys(properties(root)),'route',...Object.keys(properties(route))])];
|
|
43
|
+
}
|
|
44
|
+
/** Only the fragment for a dotted path, with references resolved inline. No validation, defaults or authority. */
|
|
45
|
+
export function getSchemaFragment(path ) {
|
|
46
|
+
if(typeof path!=='string'||path.length>256||!/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/.test(path))throw new ConfigError('Schema path must be dotted property names; top-level names: '+schemaPathNames().join(', '));
|
|
47
|
+
const root=schema(),route=definition(root,'#/$defs/route');
|
|
48
|
+
const [first,...rest]=path.split('.');
|
|
49
|
+
let node ,pointer ;
|
|
50
|
+
if(first!==undefined&&object(properties(root)[first])){node=properties(root)[first] ;pointer='#/properties/'+first;}
|
|
51
|
+
else if(first==='route'){node=route;pointer='#/$defs/route';}
|
|
52
|
+
else if(first!==undefined&&object(properties(route)[first])){node=properties(route)[first] ;pointer='#/$defs/route/properties/'+first;}
|
|
53
|
+
else throw new ConfigError(`Unknown schema path; top-level names: ${schemaPathNames().join(', ')}`);
|
|
54
|
+
for(const segment of rest) {
|
|
55
|
+
const current=resolved(root,node);
|
|
56
|
+
const alternatives=[current,...(Array.isArray(current.oneOf)?current.oneOf.filter(object).map(item=>resolved(root,item)):[])];
|
|
57
|
+
const next=alternatives.map(item=>properties(item)[segment]).find(object);
|
|
58
|
+
if(!next)throw new ConfigError(`Unknown schema path segment; names under ${pointer}: ${[...new Set(alternatives.flatMap(item=>Object.keys(properties(item))))].join(', ')||'(none)'}`);
|
|
59
|
+
node=next;pointer+='/properties/'+segment;
|
|
60
|
+
}
|
|
61
|
+
return {format:1,path,pointer,schema:inline(root,resolved(root,node),[]) };
|
|
62
|
+
}
|
|
@@ -1,75 +1,33 @@
|
|
|
1
1
|
// Disposable local proof, never a claim about the production deployment.
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {mkdtemp,mkdir,writeFile,
|
|
3
|
+
import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises';
|
|
4
4
|
import {tmpdir} from 'node:os';
|
|
5
|
-
import {join
|
|
6
|
-
import {DatabaseSync} from 'node:sqlite';
|
|
7
|
-
import {openLinkStore} from '../link-store.js';
|
|
5
|
+
import {join} from 'node:path';
|
|
8
6
|
import {startServer} from '../server.js';
|
|
9
|
-
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
// The SQLite rows the drills inspect; node:sqlite returns untyped records.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
// node:sqlite boundary: a single-row query, typed by the caller's expectation.
|
|
18
|
-
const row= (db ,sql ) =>{const r=db.prepare(sql).get();assert.ok(r,`${sql} returned no row`);return r ;};
|
|
8
|
+
|
|
19
9
|
const seconds=Number(process.env.URLCODE_SOAK_SECONDS||5);
|
|
20
10
|
assert(Number.isInteger(seconds)&&seconds>=1&&seconds<=3600,'Soak must be 1–3600 seconds');
|
|
21
11
|
const directory=await mkdtemp(join(tmpdir(),'urlcode-drills-')),project=join(directory,'app');
|
|
22
|
-
let
|
|
12
|
+
let app ;
|
|
23
13
|
try {
|
|
24
14
|
await mkdir(project);
|
|
25
|
-
const config='version: "1"\
|
|
15
|
+
const config='version: "1"\nroutes:\n /go:\n redirect: {url: "https://example.com/v1"}\n /function:\n function: {source: f.mjs}\n';
|
|
26
16
|
await writeFile(join(project,'urlcode.yaml'),config);
|
|
27
17
|
await writeFile(join(project,'f.mjs'),'export default () => new Response("isolated");');
|
|
28
|
-
|
|
29
|
-
store=await openLinkStore({file,project});let link=await store.create('links',{url:'https://example.com/live'},'demo');
|
|
30
|
-
app=await startServer({project,port:0,linkStore:{collection:'links',file},log:()=>{}});
|
|
18
|
+
app=await startServer({project,port:0,log:()=>{}});
|
|
31
19
|
const server=app;
|
|
32
20
|
const get=async(path )=>{const r=await fetch(`http://127.0.0.1:${server.address.port}${path}`,{redirect:'manual'});const body=await r.text();return {status:r.status,location:r.headers.get('location'),body};};
|
|
33
21
|
const start=performance.now(),samples =[];let requests=0;
|
|
34
22
|
while(performance.now()-start<seconds*1000){
|
|
35
|
-
const before=performance.now();const responses=await Promise.all(['/go','/function'
|
|
36
|
-
assert.deepEqual(responses.map(r=>r.status),[302,200
|
|
37
|
-
|
|
38
|
-
if(requests%30===0)link=await store.update('links','demo',{url:`https://example.com/live-${requests}`},link.version);
|
|
23
|
+
const before=performance.now();const responses=await Promise.all(['/go','/function'].map(get));
|
|
24
|
+
assert.deepEqual(responses.map(r=>r.status),[302,200]);assert.equal(responses[1]?.body,'isolated');
|
|
25
|
+
samples.push(performance.now()-before);requests+=2;
|
|
39
26
|
}
|
|
40
27
|
await writeFile(join(project,'urlcode.yaml'),'invalid: configuration');assert.equal(await app.reload(),false);assert.equal((await get('/go')).location,'https://example.com/v1');
|
|
41
28
|
await writeFile(join(project,'urlcode.yaml'),config.replace('/v1','/v2'));assert.equal(await app.reload(),true);assert.equal((await get('/go')).location,'https://example.com/v2');
|
|
42
29
|
await writeFile(join(project,'urlcode.yaml'),config);assert.equal(await app.reload(),true);assert.equal((await get('/go')).location,'https://example.com/v1');
|
|
43
|
-
await app.close();app=undefined;
|
|
44
|
-
// A read-only connection may have been the last to close, leaving WAL frames.
|
|
45
|
-
// Quiesce all users, explicitly checkpoint, then close before copying.
|
|
46
|
-
const checkpointDb=new DatabaseSync(file);
|
|
47
|
-
try{const result=row (checkpointDb,'PRAGMA wal_checkpoint(TRUNCATE)');assert.equal(result.busy,0);assert.equal(result.log,0);assert.equal(result.checkpointed,0);}finally{checkpointDb.close();}
|
|
48
|
-
const restoreStart=performance.now(),backup=join(directory,'restored.sqlite');await copyFile(file,backup);
|
|
49
|
-
store=await openLinkStore({file:backup,project,readOnly:true});assert.deepEqual(await store.get('links','demo'),link);
|
|
50
|
-
const db=new DatabaseSync(backup,{readOnly:true});assert.equal(row (db,'PRAGMA integrity_check').integrity_check,'ok');assert.equal(row (db,'SELECT max(revision) AS revision FROM urlcode_link_audit').revision,link.version);db.close();
|
|
30
|
+
await app.close();app=undefined;
|
|
51
31
|
samples.sort((a,b)=>a-b);
|
|
52
|
-
console.log(JSON.stringify({event:'local-operational-proof',seconds,requests,batchP99Ms:samples[Math.floor(samples.length*.99)],rssBytes:process.memoryUsage().rss,
|
|
53
|
-
await
|
|
54
|
-
// Only enable inside a disposable, size-limited mount (CI uses 16 MiB tmpfs).
|
|
55
|
-
if(process.argv[2]==='--disk-full-dir'){
|
|
56
|
-
const volume=resolve(process.argv[3] ?? '');const isolated=await mkdtemp(join(volume,'urlcode-full-'));
|
|
57
|
-
try {
|
|
58
|
-
const reserve=join(isolated,'reserve');await writeFile(reserve,Buffer.alloc(4*1024*1024));
|
|
59
|
-
const fullFile=join(isolated,'full.sqlite');store=await openLinkStore({file:fullFile,project});let full=false,committed=0;
|
|
60
|
-
for(let i=0;i<12000;i++){
|
|
61
|
-
try{await store.create('links',{url:'https://example.com/'+ 'x'.repeat(7000)},'code-'+i);committed++;}
|
|
62
|
-
catch(error){assert.ok(error instanceof HttpError,'store failure must be an HttpError');assert.equal(error.status,503);full=true;break;}
|
|
63
|
-
}
|
|
64
|
-
assert(full,'Disposable volume did not fill within the 84 MiB write budget');
|
|
65
|
-
const space=await statfs(isolated);assert(space.bavail*space.bsize<1024*1024,'Expected actual volume exhaustion');
|
|
66
|
-
await rm(reserve);await store.create('links',{url:'https://example.com/recovered'},'recovered');committed++;
|
|
67
|
-
await store.close();store=undefined;
|
|
68
|
-
const check=new DatabaseSync(fullFile,{readOnly:true});
|
|
69
|
-
assert.equal(row (check,'PRAGMA integrity_check').integrity_check,'ok');
|
|
70
|
-
assert.equal(row (check,'SELECT count(*) AS n FROM urlcode_links').n,committed);
|
|
71
|
-
assert.equal(row (check,'SELECT count(*) AS n FROM urlcode_link_audit').n,committed);check.close();
|
|
72
|
-
console.log(JSON.stringify({event:'disposable-volume-exhaustion',committed,integrity:'ok',auditAtomic:true}));
|
|
73
|
-
}finally{await store?.close();store=undefined;await rm(isolated,{recursive:true,force:true});}
|
|
74
|
-
}
|
|
75
|
-
} finally {await app?.close();await store?.close();await rm(directory,{recursive:true,force:true});}
|
|
32
|
+
console.log(JSON.stringify({event:'local-operational-proof',seconds,requests,batchP99Ms:samples[Math.floor(samples.length*.99)],rssBytes:process.memoryUsage().rss,checks:['mixed HTTP load','invalid reload preserves last-good','configuration rollback'],deploymentProof:false}));
|
|
33
|
+
} finally {await app?.close();await rm(directory,{recursive:true,force:true});}
|