@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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { assert, HttpError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/** Bounded conjunction of exact string comparisons; no regex or implicit detection. */
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
const token = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
10
|
+
export function normalizeMatch(match ) {
|
|
11
|
+
assert(match && typeof match === 'object' && !Array.isArray(match), 'match must be an object');
|
|
12
|
+
assert(Object.keys(match).length > 0 && Object.keys(match).every(key => ['query','headers','cookies','host','method'].includes(key)), 'Unsupported or empty match');
|
|
13
|
+
const normalized = {};
|
|
14
|
+
for (const group of ['query','headers','cookies'] ) if (match[group] !== undefined) {
|
|
15
|
+
assert(match[group] !== null && typeof match[group] === 'object' && !Array.isArray(match[group]), `match.${group} must be an object`);
|
|
16
|
+
const fields = Object.entries(match[group]);
|
|
17
|
+
assert(fields.length > 0 && fields.length <= 16, `match.${group} requires 1–16 entries`);
|
|
18
|
+
const values = Object.create(null) ;
|
|
19
|
+
for (const [name,value] of fields) {
|
|
20
|
+
const key = group === 'headers' ? name.toLowerCase() : name;
|
|
21
|
+
assert(token.test(key) && key.length <= 128 && !Object.hasOwn(values,key), `Invalid or duplicate match.${group} name`);
|
|
22
|
+
assert(typeof value === 'string' && value.length <= 1024 && !/[\u0000-\u001f\u007f]/.test(value), `Invalid match.${group} value`);
|
|
23
|
+
if (group === 'headers') assert(!['host','cookie','authorization','proxy-authorization','connection','transfer-encoding','content-length','keep-alive','proxy-authenticate','te','trailer','upgrade'].includes(key), 'Use match.host/cookies; credential and transport headers cannot be conditions');
|
|
24
|
+
if (group === 'cookies') assert(!/[;,\s"\\]/.test(value), 'Cookie conditions use unquoted wire values');
|
|
25
|
+
values[key] = value;
|
|
26
|
+
}
|
|
27
|
+
normalized[group] = values;
|
|
28
|
+
}
|
|
29
|
+
if (match.method !== undefined) {
|
|
30
|
+
assert(['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(match.method), 'Invalid match.method');
|
|
31
|
+
normalized.method = match.method;
|
|
32
|
+
}
|
|
33
|
+
if (match.host !== undefined) {
|
|
34
|
+
assert(typeof match.host === 'string' && match.host.length <= 255 && /^[a-z0-9.-]+(?::[0-9]{1,5})?$/i.test(match.host), 'match.host must be a literal host with optional port');
|
|
35
|
+
const url = new URL('https://' + match.host);
|
|
36
|
+
assert(url.host === match.host.toLowerCase(), 'match.host must be a canonical authority');
|
|
37
|
+
normalized.host = url.host;
|
|
38
|
+
}
|
|
39
|
+
assert(Object.keys(normalized).length > 0, 'Empty match');
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
function equalities(match ) {
|
|
43
|
+
const values = new Map ();
|
|
44
|
+
for (const group of ['query','headers','cookies'] ) for (const [name,value] of Object.entries(match[group] ?? {})) values.set(`${group}:${name}`,value);
|
|
45
|
+
if (match.host) values.set('host',match.host);
|
|
46
|
+
if (match.method) values.set('method',match.method);
|
|
47
|
+
return values;
|
|
48
|
+
}
|
|
49
|
+
/** Cases must be disjoint: at least one shared fact must demand different values. */
|
|
50
|
+
export function assertDisjointMatches(matches ) {
|
|
51
|
+
assert(Array.isArray(matches) && matches.length >= 1 && matches.length <= 16, 'Conditional routes require 1–16 cases');
|
|
52
|
+
const facts = matches.map(normalizeMatch).map(equalities);
|
|
53
|
+
for (let i = 0; i < facts.length; i++) for (let j = i + 1; j < facts.length; j++) {
|
|
54
|
+
assert([...facts[i] ].some(([key,value]) => facts[j] .has(key) && facts[j] .get(key) !== value), `Conditional cases ${i + 1} and ${j + 1} overlap`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function matchesRoute(match , request ) {
|
|
58
|
+
if (match.method && match.method !== request.method) return false;
|
|
59
|
+
// The operator's origin is authoritative, never forwarded/client Host text.
|
|
60
|
+
if (match.host && new URL(request.origin).host !== match.host) return false;
|
|
61
|
+
for (const [name,value] of Object.entries(match.query ?? {})) {
|
|
62
|
+
const actual = request.query.getAll(name);
|
|
63
|
+
if (actual.length > 1) throw new HttpError(400,'Duplicate condition query input');
|
|
64
|
+
if (actual.length !== 1 || actual[0] !== value) return false;
|
|
65
|
+
}
|
|
66
|
+
for (const [name,value] of Object.entries(match.headers ?? {})) {
|
|
67
|
+
if ((request.headerCounts?.[name] ?? 0) > 1) throw new HttpError(400,'Duplicate condition header input');
|
|
68
|
+
if (request.headers.get(name) !== value) return false;
|
|
69
|
+
}
|
|
70
|
+
if (match.cookies) {
|
|
71
|
+
const raw = request.headers.get('cookie') ?? '';
|
|
72
|
+
if (raw.length > 8192) throw new HttpError(400,'Cookie conditions exceed input limit');
|
|
73
|
+
const values = new Map ();
|
|
74
|
+
for (const part of raw.split(';')) {
|
|
75
|
+
const index = part.indexOf('=');
|
|
76
|
+
if (index < 0) continue;
|
|
77
|
+
const name = part.slice(0,index).trim(), value = part.slice(index + 1).trim();
|
|
78
|
+
if (!Object.hasOwn(match.cookies,name)) continue;
|
|
79
|
+
const entries = values.get(name) ?? []; entries.push(value); values.set(name,entries);
|
|
80
|
+
}
|
|
81
|
+
for (const [name,value] of Object.entries(match.cookies)) {
|
|
82
|
+
const actual = values.get(name) ?? [];
|
|
83
|
+
if (actual.length > 1) throw new HttpError(400,'Duplicate condition cookie input');
|
|
84
|
+
if (actual.length !== 1 || actual[0] !== value) return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto';
|
|
|
5
5
|
import { parseDocument, visit, isAlias, isScalar, isMap, isNode } from 'yaml';
|
|
6
6
|
import Ajv from 'ajv/dist/2020.js';
|
|
7
7
|
import { assert, ConfigError } from './errors.js';
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
|
|
10
10
|
/** What config-worker.ts posts back: the loaded document, or the ConfigError message. */
|
|
11
11
|
|
|
@@ -53,7 +53,50 @@ export function validateDocument(data ) {
|
|
|
53
53
|
const e = validate.errors [0] ;
|
|
54
54
|
throw new ConfigError(`Invalid configuration at ${e.instancePath || '/'} (${e.keyword})`);
|
|
55
55
|
}
|
|
56
|
-
|
|
56
|
+
const document = data ; // trust boundary: the schema just admitted it
|
|
57
|
+
for (const [pattern, route] of Object.entries(document.routes)) document.routes[pattern] = normalizeRoute(pattern, route);
|
|
58
|
+
return document;
|
|
59
|
+
}
|
|
60
|
+
/** The input declaration a short-form function gets for each `{param}` it does not declare itself. */
|
|
61
|
+
export const SHORT_FORM_PATH_SCHEMA = { type: 'string', minLength: 1, maxLength: 128 } ;
|
|
62
|
+
// A short-form path is checked here, before the file system, so the error can name the route.
|
|
63
|
+
function modulePath(pattern , kind , file ) {
|
|
64
|
+
const relativePosix = !isAbsolute(file) && !/^(?:[A-Za-z]:|[\\/])/.test(file);
|
|
65
|
+
const segments = file.split(/[\\/]/);
|
|
66
|
+
assert(relativePosix && segments.every(s => s !== '..') && ['.mjs', '.js'].includes(extname(file)) && !file.endsWith('/') && !file.endsWith('\\'),
|
|
67
|
+
`${pattern}: ${kind} short form must be a project-relative .mjs or .js path without .. segments`);
|
|
68
|
+
return file;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Expands the YAML short forms into the canonical long form. `function: functions/x.mjs`
|
|
72
|
+
* becomes `{source, args}` with an argument per `{param}` in the path, declaring any
|
|
73
|
+
* parameter the route does not declare itself; a string middleware entry becomes `{source}`;
|
|
74
|
+
* a route-level `cache` becomes `policies.cache` (refused alongside a direct `policies.cache`).
|
|
75
|
+
* Everything downstream (routes, audit, the compiled table) sees only the long form.
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeRoute(pattern , route ) {
|
|
78
|
+
const authored = route ;
|
|
79
|
+
const needsFunction = typeof authored.function === 'string';
|
|
80
|
+
const needsMiddleware = authored.middleware?.some(entry => typeof entry === 'string') ?? false;
|
|
81
|
+
const needsCache = authored.cache !== undefined;
|
|
82
|
+
if (!needsFunction && !needsMiddleware && !needsCache) return route ;
|
|
83
|
+
const result = { ...(route ) };
|
|
84
|
+
if (needsMiddleware) result.middleware = authored.middleware .map((entry) => typeof entry === 'string' ? { source: modulePath(pattern, 'middleware', entry) } : entry);
|
|
85
|
+
if (needsFunction) {
|
|
86
|
+
const source = modulePath(pattern, 'function', authored.function );
|
|
87
|
+
const names = pattern.split('/').flatMap(part => { const match = /^\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(part); return match ? [match[1] ] : []; });
|
|
88
|
+
const declared = authored.parameters ?? [];
|
|
89
|
+
const parameters = [...declared, ...names.filter(name => !declared.some(p => p.in === 'path' && p.name === name)).map(name => ({ name, in: 'path' , required: true, schema: { ...SHORT_FORM_PATH_SCHEMA } }))];
|
|
90
|
+
const expanded = { source, args: Object.fromEntries(names.map(name => [name, { from: 'path' , name }])) };
|
|
91
|
+
if (parameters.length) result.parameters = parameters;
|
|
92
|
+
result.function = expanded;
|
|
93
|
+
}
|
|
94
|
+
if (needsCache) {
|
|
95
|
+
assert(result.policies?.cache === undefined, `Route ${pattern} declares both cache and policies.cache; use one form`);
|
|
96
|
+
result.policies = { ...result.policies, cache: authored.cache };
|
|
97
|
+
delete result.cache;
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
57
100
|
}
|
|
58
101
|
export async function safeFile(root , file ) {
|
|
59
102
|
root = await realpath(root);
|
|
@@ -64,7 +107,7 @@ export async function safeFile(root , file ) {
|
|
|
64
107
|
assert((await stat(actual)).isFile(), 'Reference must point to a file');
|
|
65
108
|
return actual;
|
|
66
109
|
}
|
|
67
|
-
|
|
110
|
+
const MAX_PROJECT_CONFIG_BYTES = 64 * 1024 * 1024;
|
|
68
111
|
async function readConfig(file , budget ) {
|
|
69
112
|
const handle = await open(file, 'r');
|
|
70
113
|
try {
|
|
@@ -111,12 +154,31 @@ export async function loadDocument(project , {timeoutMs=10000}
|
|
|
111
154
|
});
|
|
112
155
|
} finally { try { await worker?.terminate(); } finally { activeLoads--; } }
|
|
113
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Expands the route-level `auth` short form into the canonical `policies.extensions.auth` requirement so every
|
|
159
|
+
* downstream consumer (compiler, routes, audit, explain, revision hash) sees one form. `auth: {required: false}`
|
|
160
|
+
* documents intent and emits nothing. Refuses routes that use both forms or lack an `extensions.auth` declaration.
|
|
161
|
+
*/
|
|
162
|
+
export function normalizeRouteAuth(document , routes ) {
|
|
163
|
+
for (const [pattern, route] of Object.entries(routes)) {
|
|
164
|
+
if (route.auth === undefined) continue;
|
|
165
|
+
assert(document.extensions?.auth !== undefined, `Route ${pattern} declares auth but the project declares no extensions.auth`);
|
|
166
|
+
const extensions = route.policies?.extensions;
|
|
167
|
+
assert(extensions !== false, `Route ${pattern} declares auth alongside policies.extensions: false`);
|
|
168
|
+
assert(!(extensions && Object.hasOwn(extensions, 'auth')), `Route ${pattern} declares both auth and policies.extensions.auth; use one form`);
|
|
169
|
+
const { required = true, ...requirement } = route.auth === true ? {} : route.auth;
|
|
170
|
+
delete route.auth;
|
|
171
|
+
if (!required) continue;
|
|
172
|
+
route.policies = { ...route.policies, extensions: { ...extensions, auth: requirement } };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
114
175
|
export async function loadDocumentInWorker(project ) {
|
|
115
176
|
const budget={remaining:MAX_PROJECT_CONFIG_BYTES};
|
|
116
177
|
const root = await realpath(project);
|
|
117
178
|
const file = await safeFile(root, 'urlcode.yaml');
|
|
118
179
|
const document = validateDocument(await readConfig(file, budget));
|
|
119
180
|
const routes = Object.assign(Object.create(null) , document.routes);
|
|
181
|
+
const extensions = Object.assign(Object.create(null),document.extensions??{}) ;
|
|
120
182
|
const files = [file];
|
|
121
183
|
let routeCount=Object.keys(routes).length;
|
|
122
184
|
for (const include of document.includes || []) {
|
|
@@ -125,17 +187,18 @@ export async function loadDocumentInWorker(project )
|
|
|
125
187
|
files.push(path);
|
|
126
188
|
const part = validateDocument(await readConfig(path, budget));
|
|
127
189
|
assert(!part.includes?.length, 'Nested includes are unsupported');
|
|
128
|
-
assert(part.dynamicLinks===undefined, 'dynamicLinks may only be set in the entry urlcode.yaml');
|
|
129
190
|
assert(part.site===undefined, 'site may only be set in the entry urlcode.yaml');
|
|
191
|
+
for(const [name,extension]of Object.entries(part.extensions??{})){assert(!Object.hasOwn(extensions,name),'Duplicate extension declaration across files');extensions[name]=extension;assert(Object.keys(extensions).length<=16,'Maximum 16 extensions per project');}
|
|
130
192
|
for (const [pattern, route] of Object.entries(part.routes)) {
|
|
131
193
|
assert(!Object.hasOwn(routes, pattern), 'Duplicate route across files');
|
|
132
194
|
routes[pattern] = route;
|
|
133
195
|
assert(++routeCount <= 100000, 'Maximum 100000 routes per project');
|
|
134
196
|
}
|
|
135
197
|
}
|
|
198
|
+
if(Object.keys(extensions).length)document.extensions=extensions;
|
|
199
|
+
normalizeRouteAuth(document, routes);
|
|
136
200
|
assert(Object.keys(routes).length <= 100000, 'Maximum 100000 routes per project');
|
|
137
|
-
|
|
138
|
-
return { root, document, routes, files, version: createHash('sha256').update(JSON.stringify(document.site ? {...(document.dynamicLinks===true?{routes,dynamicLinks:true}:{routes}), site:document.site} : document.dynamicLinks===true?{routes,dynamicLinks:true}:routes)).digest('hex').slice(0, 16) };
|
|
201
|
+
return { root, document, routes, files, version: createHash('sha256').update(JSON.stringify(document.extensions?{routes,extensions:document.extensions,policies:document.policies,profiles:document.profiles,site:document.site}: document.site ? {routes, site:document.site} : routes)).digest('hex').slice(0, 16) };
|
|
139
202
|
}
|
|
140
203
|
export async function loadBindings(root , local = false, environment = process.env) {
|
|
141
204
|
const vars = {};
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import {readdir,readFile} from 'node:fs/promises';
|
|
2
|
+
import {relative} from 'node:path';
|
|
3
|
+
import {stringify} from 'yaml';
|
|
4
|
+
import {loadDocument} from './config.js';
|
|
5
|
+
import {applySite} from './site.js';
|
|
6
|
+
import {prepareFunctionSnapshot,requestedPermissions} from './policy.js';
|
|
7
|
+
import {compileRoutes} from './router.js';
|
|
8
|
+
import {compilePolicies,closePolicies,effectivePolicies,registry} from './policies.js';
|
|
9
|
+
import {capabilityTargets,getCapabilities,normalizeCapabilityTarget,routeCapabilities} from './capabilities.js';
|
|
10
|
+
|
|
11
|
+
import {loadOperatorHost} from './operator-host.js';
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
/** Characters divided by four, rounded up: an estimate, not a tokenizer. */
|
|
41
|
+
export function estimateTokens(text ) {return Math.ceil(text.length/4);}
|
|
42
|
+
export function renderContext(context ) {return stringify(context,{lineWidth:0,aliasDuplicateObjects:false});}
|
|
43
|
+
const handlerNames=['redirect','respond','page','static','download','function','proxy','conditional','extension'] ;
|
|
44
|
+
const policyNames=Object.keys(registry).sort() ;
|
|
45
|
+
// Fixed for every project: what generation must not attempt, whatever the documentation says.
|
|
46
|
+
const constraints ={
|
|
47
|
+
guestNetwork:{value:true,note:'Trusted (default) functions and middleware run in-process with full Node network access; route `sandbox: true` runs that route in a WASM sandbox without fetch or sockets, where outbound calls must be proxy or signals routes under operator grants'},
|
|
48
|
+
nodeApis:{value:true,note:'Trusted (default) functions and middleware have full Node built-ins, process, filesystem and npm packages available, same as any other project code; route `sandbox: true` restricts that route to relative ES-module imports only, no Node built-ins/filesystem/npm packages'},
|
|
49
|
+
regexRoutes:{value:false,note:'Paths are whole segments: exact literals or {param} placeholders declared as required string parameters'},
|
|
50
|
+
oneHandlerPerRoute:{value:true,note:'Exactly one of redirect, respond, page, static, download, function, proxy, conditional or extension; middleware wraps it'},
|
|
51
|
+
pathShape:{value:'exact or {param}',note:'No greedy captures or general-purpose wildcards; a segment is a literal or a named placeholder'},
|
|
52
|
+
wildcardMounts:{value:false,note:'Only static and extension routes mount a subtree; nothing else matches below its path'},
|
|
53
|
+
yamlInterpolation:{value:false,note:'No ${...} templating; bind typed inputs through parameters, args and context'},
|
|
54
|
+
secretsByOperatorGrant:{value:true,note:'Projects request named env and secret bindings; only an operator policy pinned to the project revision grants them'},
|
|
55
|
+
};
|
|
56
|
+
const routesOf=(table ) =>[...table.exact.values(),...[...table.byLength.values()].flat(),...table.mounts];
|
|
57
|
+
const sorted=(values )=>[...new Set(values)].sort();
|
|
58
|
+
async function packageVersion() {return (JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8')) ).version;}
|
|
59
|
+
/** Same loader and semantic compiler as inspectProject: no binding reads, no guest execution, no network. */
|
|
60
|
+
async function compile(project ) {
|
|
61
|
+
const loaded=await loadDocument(project);await applySite(loaded,{});
|
|
62
|
+
const snapshot=await prepareFunctionSnapshot(loaded),bindings =Object.create(null);
|
|
63
|
+
for(const route of Object.values(loaded.routes)) {for(const ref of Object.values(route.env||{}))if(ref.env)bindings[ref.env]='validation-only';for(const ref of Object.values(route.secrets||{}))bindings[ref.secret]='validation-only';}
|
|
64
|
+
const compiled=await compileRoutes(loaded,bindings,requestedPermissions(loaded,snapshot),snapshot.projectSha256),routes=routesOf(compiled);
|
|
65
|
+
const shared ={target:'node',routes:routes.length,log:()=>{}};
|
|
66
|
+
try {for(const route of routes)await compilePolicies(loaded.document,route,{route,shared,target:'node',root:loaded.root});}finally{await closePolicies(shared);}
|
|
67
|
+
return {loaded,compiled,routes};
|
|
68
|
+
}
|
|
69
|
+
function handlerOf(route ) {return handlerNames.find(name=>route[name])??'none';}
|
|
70
|
+
/** Derived only from the compiled project and the capability catalog, never from prose. Key order is fixed. */
|
|
71
|
+
export async function buildContext(project ,options ={}) {
|
|
72
|
+
const budget=options.budget;
|
|
73
|
+
if(budget!==undefined&&(!Number.isSafeInteger(budget)||budget<1))throw new Error('Invalid context budget');
|
|
74
|
+
const selected =options.target===undefined?[...capabilityTargets]:[normalizeCapabilityTarget(options.target)];
|
|
75
|
+
const host=await loadOperatorHost(options.hostFile,project);
|
|
76
|
+
try {
|
|
77
|
+
const {loaded,compiled,routes}=await compile(project),document=loaded.document;
|
|
78
|
+
const handlers ={},policyCounts ={},used=new Set ();
|
|
79
|
+
for(const name of handlerNames)handlers[name]=0;
|
|
80
|
+
for(const name of policyNames)policyCounts[name]=0;
|
|
81
|
+
if(Object.keys(document.extensions??{}).length)used.add('extension');
|
|
82
|
+
for(const route of routes) {
|
|
83
|
+
handlers[handlerOf(route)]=(handlers[handlerOf(route)]??0)+1;
|
|
84
|
+
for(const capability of routeCapabilities(route,document))used.add(capability);
|
|
85
|
+
const effective=effectivePolicies(document,route);
|
|
86
|
+
for(const name of policyNames)if(effective[name])policyCounts[name] ++;
|
|
87
|
+
}
|
|
88
|
+
for(const name of handlerNames)if(!handlers[name])delete handlers[name];
|
|
89
|
+
for(const name of policyNames)if(!policyCounts[name])delete policyCounts[name];
|
|
90
|
+
const declared=Object.values(loaded.routes),env=new Set (),secrets=new Set (),functions=new Set (),middleware=new Set ();
|
|
91
|
+
for(const route of declared) {
|
|
92
|
+
for(const ref of Object.values(route.env??{}))if(ref.env)env.add(ref.env);
|
|
93
|
+
for(const ref of Object.values(route.secrets??{}))secrets.add(ref.secret);
|
|
94
|
+
if(route.function)functions.add(route.function.source);
|
|
95
|
+
for(const item of route.middleware??[])middleware.add(item.source);
|
|
96
|
+
}
|
|
97
|
+
const topLevel=effectivePolicies(document,undefined);
|
|
98
|
+
const catalog=getCapabilities();
|
|
99
|
+
const targets ={};
|
|
100
|
+
for(const target of selected) {
|
|
101
|
+
const entry={deployment:catalog.targets.find(item=>item.target===target) .deployment,supported:[] ,conditional:[] ,refused:[] ,unknown:[] };
|
|
102
|
+
for(const row of catalog.capabilities) {
|
|
103
|
+
if(!used.has(row.capability))continue;
|
|
104
|
+
const support=row.targets[target]?.support??'unknown';
|
|
105
|
+
(support==='refused'?entry.refused:support==='conditional'?entry.conditional:support==='unknown'?entry.unknown:entry.supported).push(row.capability);
|
|
106
|
+
}
|
|
107
|
+
targets[target]=entry;
|
|
108
|
+
}
|
|
109
|
+
const flag=options.projectFlag??project;
|
|
110
|
+
const context ={
|
|
111
|
+
urlcode:await packageVersion(),schema:'1',
|
|
112
|
+
project:{
|
|
113
|
+
entry:'urlcode.yaml',routes:compiled.count,handlers,extensions:sorted(Object.keys(document.extensions??{})),
|
|
114
|
+
policies:{project:policyNames.filter(name=>topLevel[name]),routes:policyCounts},
|
|
115
|
+
bindings:{env:sorted(env),secrets:sorted(secrets)},site:sorted(Object.keys(document.site??{})),
|
|
116
|
+
files:{includes:loaded.files.slice(1).map(file=>relative(loaded.root,file).split('\\').join('/')),functions:sorted(functions),middleware:sorted(middleware)},
|
|
117
|
+
...(options.hostFile===undefined?{}:{host:{extensions:sorted((host.extensions??[]).map(item=>item.name)),plugins:(host.plugins??[]).length}}),
|
|
118
|
+
},
|
|
119
|
+
routes:routes.map(route=>({path:route.pattern,methods:route.methods,handler:handlerOf(route),sandbox:route.sandbox===true,...(route.sandboxReason?{sandboxReason:route.sandboxReason}:{})})).sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0),
|
|
120
|
+
constraints:{...constraints},
|
|
121
|
+
targets,
|
|
122
|
+
commands:{
|
|
123
|
+
validate:`urlcode validate --local --project ${flag}`,
|
|
124
|
+
test:`urlcode test --project ${flag}`,
|
|
125
|
+
audit:`urlcode audit --project ${flag} --expect-routes ${compiled.count}`,
|
|
126
|
+
routes:`urlcode routes --project ${flag}`,
|
|
127
|
+
capabilities:`urlcode capabilities${options.target===undefined?'':` --target ${selected[0]}`}`,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
return budget===undefined?context:fitBudget(context,budget);
|
|
131
|
+
} finally {await host.close?.();}
|
|
132
|
+
}
|
|
133
|
+
// Sections leave in this order; each step is a fixed transformation so two runs agree.
|
|
134
|
+
const drops =[
|
|
135
|
+
['routes',context=>{delete context.routes;}],
|
|
136
|
+
['targets',context=>{delete context.targets;}],
|
|
137
|
+
['constraintNotes',context=>{for(const [key,item] of Object.entries(context.constraints))context.constraints[key]=typeof item==='object'?item.value:item;}],
|
|
138
|
+
['files',context=>{delete context.project.files;}],
|
|
139
|
+
['commands',context=>{delete context.commands;}],
|
|
140
|
+
['summary',context=>{context.project={entry:context.project.entry,routes:context.project.routes,handlers:context.project.handlers,extensions:[],policies:{project:[],routes:{}},bindings:{env:[],secrets:[]},site:[]};}],
|
|
141
|
+
];
|
|
142
|
+
function fitBudget(context ,budget ) {
|
|
143
|
+
const omitted =[];
|
|
144
|
+
const fits=()=>estimateTokens(renderContext(omitted.length?{...context,omitted}:context))<=budget;
|
|
145
|
+
for(const [section,drop] of drops) {if(fits())break;drop(context);omitted.push(section);}
|
|
146
|
+
if(!fits())throw new Error(`Context budget ${budget} is below the smallest rendering`);
|
|
147
|
+
return omitted.length?{...context,omitted}:context;
|
|
148
|
+
}
|
|
149
|
+
/** Estimated size of the shipped documentation (docs/*.md and llms.txt), for comparison with an emitted context. */
|
|
150
|
+
export async function documentationTokens() {
|
|
151
|
+
const docs=new URL('../docs/',import.meta.url);let chars=0;
|
|
152
|
+
for(const name of (await readdir(docs)).filter(name=>name.endsWith('.md')).sort())chars+=(await readFile(new URL(name,docs),'utf8')).length;
|
|
153
|
+
chars+=(await readFile(new URL('../llms.txt',import.meta.url),'utf8')).length;
|
|
154
|
+
return Math.ceil(chars/4);
|
|
155
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {basename} from 'node:path';
|
|
2
|
+
import {assert} from './errors.js';
|
|
3
|
+
import {readConversionInput} from './interchange-cli.js';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export async function runEcosystemCommand(command ,args ,options ,print ) {
|
|
11
|
+
if(command==='recipes'||command==='recipe'){
|
|
12
|
+
const {listRecipes,showRecipe,searchRecipes,addRecipe}=await import('./recipes.js');
|
|
13
|
+
const [operation='list',name]=args;
|
|
14
|
+
assert(args.length<=2,'Unexpected recipe arguments');
|
|
15
|
+
if(operation==='list'){assert(name===undefined,'Unexpected recipe name');const recipes=await listRecipes();print(options.json?recipes:formatCatalog(recipes));}
|
|
16
|
+
else if(operation==='search'){assert(name,'Provide search text');const found=await searchRecipes(name);print(options.json?found:formatSearch(found.query,found.results));}
|
|
17
|
+
else if(operation==='show'){assert(name,'Provide a recipe name');const recipe=await showRecipe(name);print(options.json?recipe:formatMetadata(recipe)+Object.entries(recipe.content).map(([file,text])=>`\n--- ${file}\n${text}`).join(''));}
|
|
18
|
+
else if(operation==='add'){assert(name && options.out,'Provide a recipe name and --out new-directory');print(await addRecipe(name,options.out,{dryRun:options['dry-run']}));}
|
|
19
|
+
else assert(false,'Use recipes list, search, show or add');
|
|
20
|
+
}else if(command==='examples'||command==='example'){
|
|
21
|
+
const {listExamples,searchExamples}=await import('./examples.js');
|
|
22
|
+
const [operation='list',text]=args;
|
|
23
|
+
assert(args.length<=2,'Unexpected example arguments');
|
|
24
|
+
if(operation==='list'){assert(text===undefined,'Unexpected example name');const examples=await listExamples();print(options.json?examples:formatCatalog(examples));}
|
|
25
|
+
else if(operation==='search'){
|
|
26
|
+
assert(text,'Provide search text');const found=await searchExamples(text);
|
|
27
|
+
if(options.json)print(found);
|
|
28
|
+
else{
|
|
29
|
+
const lines=[formatSearch(found.query,found.results)];
|
|
30
|
+
if(found.best)lines.push(`\nSmallest runnable match: examples/${found.best.id} (${found.best.routes??0} routes)`+(found.best.route?`, route ${found.best.route.path} in ${found.best.route.file}`:''));
|
|
31
|
+
for(const result of found.results)for(const route of result.matchedRoutes.slice(0,5))lines.push(` ${result.id} ${route.path.padEnd(24)} ${route.file} ${route.tags.join(' ')}`);
|
|
32
|
+
print(lines.join('\n')+'\n');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
else assert(false,'Use examples list or search');
|
|
36
|
+
}else if(command==='build-typescript'){
|
|
37
|
+
assert(args.length===0 && options.out,'Provide --out new-directory');
|
|
38
|
+
const {buildTypeScriptProject}=await import('./typescript-authoring.js');
|
|
39
|
+
print(await buildTypeScriptProject(options.project,options.out,{dryRun:options['dry-run']}));
|
|
40
|
+
}else if(command==='bulk-import'){
|
|
41
|
+
const format=options.format??args[0],input=options.input??args[options.format?0:1];
|
|
42
|
+
assert(args.length<=(options.format?1:2),'Unexpected bulk import arguments');
|
|
43
|
+
assert(format==='csv'||format==='json'||format==='yaml','Use bulk-import csv|json|yaml <file> --out new-directory');
|
|
44
|
+
assert(input && options.out,'Provide an input file and --out new-directory');
|
|
45
|
+
const {importBulkProject}=await import('./bulk.js');
|
|
46
|
+
const report=await importBulkProject(await readConversionInput(input),format,options.out,{dryRun:options['dry-run'],source:basename(input)});
|
|
47
|
+
print(report);if(!report.ok)process.exitCode=1;
|
|
48
|
+
}else if(command==='verify-provider'){
|
|
49
|
+
assert(args.length===0,'Unexpected provider verification arguments');
|
|
50
|
+
const target=options.target;
|
|
51
|
+
assert(target==='self-hosted'||target==='aws'||target==='vercel'||target==='cloudflare','Provide --target self-hosted|aws|vercel|cloudflare');
|
|
52
|
+
assert(options.origin,'Provide --origin https://owned-fixture.example');
|
|
53
|
+
assert(options['timeout-ms']===undefined || /^\d{1,5}$/.test(options['timeout-ms']),'Invalid --timeout-ms');
|
|
54
|
+
const {verifyProviderDeployment}=await import('./provider-verification.js');
|
|
55
|
+
const report=await verifyProviderDeployment(target,options.origin,{
|
|
56
|
+
...(options['timeout-ms']===undefined?{}:{timeoutMs:Number(options['timeout-ms'])}),
|
|
57
|
+
...(options.release===undefined?{}:{release:options.release}),
|
|
58
|
+
...(options['git-commit']===undefined?{}:{gitCommit:options['git-commit']}),
|
|
59
|
+
});
|
|
60
|
+
print(report);if(!report.pass)process.exitCode=1;
|
|
61
|
+
}else if(command==='mcp'){
|
|
62
|
+
assert(args.length===0,'Unexpected MCP arguments');
|
|
63
|
+
const {serveMcp}=await import('./mcp.js');
|
|
64
|
+
// The flags reach the server from parsed argv only; no tool argument or environment variable can set them.
|
|
65
|
+
await serveMcp({project:options.project,...(options.origin===undefined?{}:{origin:options.origin}),...(options['allow-authoring']?{allowAuthoring:true}:{}),...(options['host-file']===undefined?{}:{hostFile:options['host-file']})});
|
|
66
|
+
}else assert(false,'Unknown ecosystem command');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
function formatCatalog(entries ) {
|
|
71
|
+
return entries.map(entry=>`${entry.id.padEnd(24)} ${entry.complexity.padEnd(13)} ${String(entry.routes??'-').padStart(2)} routes ${entry.description}`).join('\n')+'\n';
|
|
72
|
+
}
|
|
73
|
+
function formatSearch(query ,results ) {
|
|
74
|
+
if(!results.length)return `No match for "${query}"\n`;
|
|
75
|
+
return results.map(entry=>`${entry.id.padEnd(24)} score ${String(entry.score).padStart(2)} ${entry.description}\n${''.padEnd(24)} tags: ${entry.tags.join(' ')}`).join('\n')+'\n';
|
|
76
|
+
}
|
|
77
|
+
/** Metadata first, so a reader sees what a recipe needs before its files scroll past. */
|
|
78
|
+
function formatMetadata(entry ) {
|
|
79
|
+
const lines=[`${entry.id}: ${entry.description}`,`complexity: ${entry.complexity} routes: ${entry.routes??'-'} runnable: ${entry.runnable!==false}`,`tags: ${entry.tags.join(' ')}`];
|
|
80
|
+
if(entry.capabilities?.length)lines.push(`capabilities: ${entry.capabilities.join(' ')}`);
|
|
81
|
+
if(entry.targets)lines.push(`targets: ${Object.entries(entry.targets).map(([target,verdict])=>`${target}=${verdict}`).join(' ')}`);
|
|
82
|
+
for(const [title,items] of [['services',entry.services],['grants',entry.grants]] )if(items?.length)lines.push(`${title}:`,...items.map(item=>` - ${'kind' in item?item.kind:item.name}: ${item.description}`));
|
|
83
|
+
if(entry.inputs?.length)lines.push('inputs:',...entry.inputs.map(input=>` - ${input.name} (${input.file}): ${input.description}`));
|
|
84
|
+
if(entry.tests)lines.push('tests:',...(entry.tests.fixtures?[` fixtures: ${entry.tests.fixtures}`]:[]),...entry.tests.commands.map(command=>` $ ${command}`));
|
|
85
|
+
if(entry.behavior?.length)lines.push('behavior:',...entry.behavior.map(line=>` - ${line}`));
|
|
86
|
+
lines.push(`files: ${entry.files.join(' ')}`);
|
|
87
|
+
return lines.join('\n')+'\n';
|
|
88
|
+
}
|
package/dist/egress.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { lookup } from 'node:dns/promises';
|
|
2
|
+
import { request } from 'node:https';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
4
|
+
|
|
5
|
+
export const EGRESS_DNS_LIMIT=64;
|
|
6
|
+
let unresolvedDns=0;
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export class EgressError extends Error { code ; constructor(code ) { super(`Egress ${code}`); this.code=code; } }
|
|
12
|
+
/** Conservative public-unicast filter. IPv4-mapped IPv6 and transition mechanisms are denied. */
|
|
13
|
+
export function isPublicAddress(address ) {
|
|
14
|
+
if (isIP(address)===4) {
|
|
15
|
+
const [a=0,b=0,c=0] = address.split('.').map(Number);
|
|
16
|
+
return !(a===0 || a===10 || a===127 || a>=224 || (a===100&&b>=64&&b<=127) || (a===169&&b===254) || (a===172&&b>=16&&b<=31) || (a===192&&(b===168 || b===0 || (b===88&&c===99) || (b===0&&c===2))) || (a===198&&(b===18||b===19|| (b===51&&c===100))) || (a===203&&b===0&&c===113));
|
|
17
|
+
}
|
|
18
|
+
if (isIP(address)!==6) return false;
|
|
19
|
+
// Only ordinary global unicast; reject documentation and special 2001 allocations.
|
|
20
|
+
if(address.includes('.')) return false;
|
|
21
|
+
const first = parseInt(address.split(':')[0] ,16);
|
|
22
|
+
return first>=0x2000 && first<=0x3fff && !/^2001:/i.test(address) && !/^2002:/i.test(address) && !/^3fff:/i.test(address);
|
|
23
|
+
}
|
|
24
|
+
export function egressUrl(value ) {
|
|
25
|
+
if(typeof value!=='string'||value.length>8192||/[\\\x00-\x20\x7f]/.test(value)) throw new EgressError('denied');
|
|
26
|
+
let url ; try { url=new URL(value); } catch { throw new EgressError('denied'); }
|
|
27
|
+
if(url.protocol!=='https:' || url.username || url.password || url.hash || !url.hostname || url.hostname.endsWith('.') || url.hostname.includes('%')) throw new EgressError('denied');
|
|
28
|
+
return url;
|
|
29
|
+
}
|
|
30
|
+
const forbidden = new Set(['host','connection','keep-alive','proxy-authenticate','proxy-authorization','te','trailer','transfer-encoding','upgrade','content-length']);
|
|
31
|
+
export function safeEgressHeaders(headers , maxBytes=16384) {
|
|
32
|
+
if(!headers||typeof headers!=='object'||Array.isArray(headers)) throw new EgressError('denied');
|
|
33
|
+
const result = Object.create(null); let bytes=0;
|
|
34
|
+
for(const [name,value] of Object.entries(headers)) {
|
|
35
|
+
const key=name.toLowerCase();
|
|
36
|
+
if(typeof value!=='string'||!/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(key) || forbidden.has(key) || /[\x00-\x1f\x7f]/.test(value)) throw new EgressError('denied');
|
|
37
|
+
bytes+=Buffer.byteLength(key)+Buffer.byteLength(value)+4;
|
|
38
|
+
if(bytes>maxBytes) throw new EgressError('limit'); result[key]=value;
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
/** Operator-owned transport. Never exposed to guests. Each request has its own nonpooled socket. */
|
|
43
|
+
|
|
44
|
+
export class EgressClient {
|
|
45
|
+
#dependencies ; #origins ; #options ;
|
|
46
|
+
#active = new Set (); #pending = new Set (); #closed=false; #resolving=0;
|
|
47
|
+
constructor(options , dependencies ={}) {
|
|
48
|
+
this.#dependencies=dependencies;
|
|
49
|
+
if(!Array.isArray(options.grantOrigins)||options.grantOrigins.length>64) throw new EgressError('denied');
|
|
50
|
+
this.#origins=new Set(options.grantOrigins.map(origin=> {const u=egressUrl(origin); if(u.pathname!=='/'||u.search) throw new EgressError('denied'); return u.origin;}));
|
|
51
|
+
this.#options={timeoutMs:5000,maxRequestBytes:1048576,maxResponseBytes:1048576,maxHeaderBytes:16384,concurrency:16,...options};
|
|
52
|
+
for(const value of Object.values(this.#options)) if(typeof value==='number'&&(!Number.isSafeInteger(value)||value<1)) throw new EgressError('denied');
|
|
53
|
+
}
|
|
54
|
+
request(input ) {
|
|
55
|
+
if(this.#closed) return Promise.reject(new EgressError('closed'));
|
|
56
|
+
if(this.#active.size>=this.#options.concurrency||this.#resolving>=this.#options.concurrency) return Promise.reject(new EgressError('busy'));
|
|
57
|
+
const controller=new AbortController(); this.#active.add(controller);
|
|
58
|
+
const pending=this.#request(input,controller).finally(()=>{this.#active.delete(controller);this.#pending.delete(pending);});
|
|
59
|
+
this.#pending.add(pending); return pending;
|
|
60
|
+
}
|
|
61
|
+
async close() {this.#closed=true; for(const controller of this.#active) controller.abort(); await Promise.allSettled([...this.#pending]);}
|
|
62
|
+
async #resolve(hostname ) {
|
|
63
|
+
if(this.#resolving>=this.#options.concurrency||unresolvedDns>=EGRESS_DNS_LIMIT) throw new EgressError('busy');
|
|
64
|
+
this.#resolving++;unresolvedDns++;
|
|
65
|
+
try {return await (this.#dependencies.resolve?this.#dependencies.resolve(hostname):lookup(hostname,{all:true,verbatim:true}));}
|
|
66
|
+
finally {this.#resolving--;unresolvedDns--;}
|
|
67
|
+
}
|
|
68
|
+
async #request(input ,controller ) {
|
|
69
|
+
const opts=this.#options, url=egressUrl(input.url);
|
|
70
|
+
if(!this.#origins.has(url.origin)||! /^[A-Z]+$/.test(input.method)) throw new EgressError('denied');
|
|
71
|
+
if((input.body?.byteLength||0)>opts.maxRequestBytes) throw new EgressError('limit');
|
|
72
|
+
const headers=safeEgressHeaders(input.headers||{},opts.maxHeaderBytes);
|
|
73
|
+
let timedOut=false; const timer=setTimeout(()=>{timedOut=true;controller.abort();},opts.timeoutMs);
|
|
74
|
+
const abort=()=>controller.abort(); input.signal?.addEventListener('abort',abort,{once:true}); if(input.signal?.aborted) controller.abort();
|
|
75
|
+
try {
|
|
76
|
+
return await new Promise ((resolve,reject)=>{
|
|
77
|
+
const fail=()=>reject(new EgressError(timedOut?'timeout':'aborted'));
|
|
78
|
+
controller.signal.addEventListener('abort',fail,{once:true}); if(controller.signal.aborted) {fail();return;}
|
|
79
|
+
void (async()=> {
|
|
80
|
+
const hostname=url.hostname.replace(/^\[|\]$/g,'');
|
|
81
|
+
const answers=isIP(hostname)?[{address:hostname,family:isIP(hostname)}]:await this.#resolve(hostname);
|
|
82
|
+
if(controller.signal.aborted) return;
|
|
83
|
+
if(!answers.length||answers.some(answer=>!isPublicAddress(answer.address))) throw new EgressError('denied');
|
|
84
|
+
const pinned=answers[0] ;
|
|
85
|
+
const req=(this.#dependencies.request||request)(url,{method:input.method,headers,agent:false,maxHeaderSize:opts.maxHeaderBytes,signal:controller.signal,
|
|
86
|
+
lookup:(_host,options,callback)=>options.all?callback(null,[pinned]):callback(null,pinned.address,pinned.family)},res=>{
|
|
87
|
+
const chunks =[]; let size=0;
|
|
88
|
+
res.on('data',(chunk )=>{size+=chunk.length; if(size>opts.maxResponseBytes) {reject(new EgressError('limit'));req.destroy();} else chunks.push(chunk);});
|
|
89
|
+
res.on('error',()=>reject(new EgressError('upstream')));
|
|
90
|
+
res.on('end',()=>{const result =Object.create(null); for(const [name,value] of Object.entries(res.headers)) if(typeof value==='string') result[name]=value; resolve({status:res.statusCode||502,headers:result,body:Buffer.concat(chunks)});});
|
|
91
|
+
});
|
|
92
|
+
req.on('error',()=>reject(new EgressError(controller.signal.aborted?(timedOut?'timeout':'aborted'):'upstream')));
|
|
93
|
+
req.end(input.body);
|
|
94
|
+
})().catch(error=>reject(error instanceof EgressError?error:new EgressError('upstream')));
|
|
95
|
+
});
|
|
96
|
+
} finally {clearTimeout(timer); input.signal?.removeEventListener('abort',abort);}
|
|
97
|
+
}
|
|
98
|
+
}
|