@jimhoyd/urlcode 0.3.0 → 0.4.0-alpha.1
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 +106 -0
- package/.claude/skills/urlcode-operations/SKILL.md +114 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/CONTRIBUTING.md +30 -2
- package/README.md +157 -230
- package/ROADMAP.md +85 -2
- package/SECURITY.md +8 -1
- package/dist/BUILD-MANIFEST.json +62 -32
- 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/bulk.js +37 -0
- package/dist/capabilities.js +208 -0
- package/dist/capability-query.js +72 -0
- package/dist/catalog.js +105 -0
- package/dist/cli.js +154 -15
- package/dist/client-address.js +1 -1
- package/dist/compliance-rules/baseline.js +8 -8
- package/dist/compliance-rules/privacy.js +3 -3
- package/dist/compliance-rules/strict.js +5 -5
- package/dist/conditions.js +88 -0
- package/dist/config.js +60 -4
- package/dist/context.js +156 -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 +132 -0
- package/dist/extensions.js +172 -0
- package/dist/function-sources.js +15 -3
- package/dist/index.js +37 -0
- package/dist/init-with.js +165 -0
- package/dist/interchange-cli.js +42 -0
- package/dist/interchange.js +189 -0
- package/dist/link-cli.js +1 -1
- package/dist/management-policy.js +0 -1
- package/dist/manifest.js +111 -0
- package/dist/match.js +2 -2
- package/dist/mcp-authoring.js +147 -0
- package/dist/mcp.js +97 -0
- package/dist/observability.js +6 -0
- package/dist/operator-host.js +29 -0
- package/dist/plugins.js +12 -0
- package/dist/policies/agents.js +2 -2
- package/dist/policies/compression.js +2 -1
- package/dist/policies/security.js +0 -0
- package/dist/policies.js +1 -1
- package/dist/policy.js +29 -7
- 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 +5 -5
- package/dist/recipes.js +41 -0
- package/dist/route-diff.js +106 -0
- package/dist/router.js +42 -2
- package/dist/runtime.js +95 -14
- package/dist/schema-query.js +62 -0
- package/dist/signals.js +24 -0
- package/dist/site.js +0 -0
- package/dist/tooling.js +96 -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/bulk.d.ts +27 -0
- package/dist/types/capabilities.d.ts +55 -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/strict.d.ts +0 -5
- package/dist/types/conditions.d.ts +19 -0
- package/dist/types/config.d.ts +20 -2
- package/dist/types/context.d.ts +65 -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 +122 -0
- package/dist/types/function-sources.d.ts +5 -0
- package/dist/types/index.d.ts +33 -0
- 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/link-cli.d.ts +2 -1
- package/dist/types/management-policy.d.ts +0 -1
- package/dist/types/manifest.d.ts +81 -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 +2 -0
- 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 -1
- package/dist/types/provider-verification.d.ts +53 -0
- package/dist/types/proxy.d.ts +21 -0
- package/dist/types/readiness.d.ts +1 -1
- package/dist/types/recipes.d.ts +30 -0
- package/dist/types/route-diff.d.ts +27 -0
- package/dist/types/runtime.d.ts +11 -0
- package/dist/types/schema-query.d.ts +12 -0
- 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/types.d.ts +57 -0
- 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 +21 -2
- package/dist/typescript-authoring.js +104 -0
- package/dist/vercel.js +4 -3
- package/dist/verify-deployment.js +270 -0
- package/docs/AI-AUTHORING.md +130 -8
- package/docs/BULK.md +79 -0
- package/docs/CAPABILITIES.md +179 -0
- package/docs/CAPACITY.md +1 -1
- package/docs/CI.md +142 -0
- package/docs/CONDITIONS.md +74 -0
- package/docs/DEPLOYMENT-CHECKS.md +108 -0
- package/docs/DYNAMIC-LINKS.md +18 -518
- package/docs/EGRESS.md +125 -0
- package/docs/EXTENSIONS.md +226 -0
- package/docs/FRAMEWORK.md +182 -0
- package/docs/INSTALL.md +45 -7
- package/docs/INTERCHANGE.md +134 -0
- package/docs/MIDDLEWARE-EXAMPLES.md +75 -0
- package/docs/MIDDLEWARE.md +2 -0
- package/docs/NEXT-PHASE-PLAN.md +90 -0
- package/docs/NEXT-STEPS.md +415 -0
- package/docs/OBSERVABILITY.md +4 -2
- package/docs/OPERATIONAL-PROOF.md +4 -1
- package/docs/OPERATIONS.md +6 -3
- package/docs/PLUGINS.md +37 -0
- package/docs/POLICIES.md +12 -309
- package/docs/PRERENDER.md +40 -0
- package/docs/PROJECT-DIRECTION.md +42 -0
- package/docs/PROVIDER-VERIFICATION.md +84 -0
- package/docs/READINESS.md +21 -1
- package/docs/README.md +82 -31
- package/docs/RECIPES.md +99 -0
- package/docs/RELEASE-READINESS.md +11 -9
- package/docs/RELEASE-SECURITY.md +27 -4
- package/docs/SECURITY-AUDIT.md +1 -1
- package/docs/SPECIFICATION.md +95 -8
- package/docs/SPIKE-BUSINESS-SUITE.md +1013 -0
- package/docs/SPIKE-EXTENSION-MODEL.md +419 -0
- package/docs/SPIKE-EXTENSIONS.md +1 -0
- package/docs/SPIKE-LAMBDA-COMPILE.md +199 -0
- package/docs/STANDARDS.md +150 -142
- package/docs/STARTERS.md +21 -1
- package/docs/TOOLING.md +291 -0
- package/docs/TYPESCRIPT-AUTHORING.md +67 -0
- package/docs/TYPESCRIPT.md +1 -1
- package/docs/USABILITY-REVIEW.md +123 -0
- package/docs/YAML-GUIDE.md +18 -479
- package/docs/YAML-REFERENCE.md +127 -16
- package/docs/links/cli.md +110 -0
- package/docs/links/limits.md +175 -0
- package/docs/links/management-api.md +80 -0
- package/docs/links/pools.md +75 -0
- package/docs/links/setup.md +135 -0
- package/docs/policies/agents.md +1 -1
- 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/links.md +30 -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 +19 -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 +16 -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/live-links/example.yaml +21 -0
- package/examples/monitoring/example.yaml +8 -0
- 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 +2709 -0
- package/llms.txt +48 -19
- package/package.json +29 -7
- package/packaging/claude-plugin/.claude-plugin/plugin.json +19 -0
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +106 -0
- package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +114 -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 +19 -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 +16 -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 +7 -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 +16 -0
- package/recipes/webhook-receiver/functions/receive.mjs +16 -0
- package/recipes/webhook-receiver/recipe.yaml +26 -0
- package/recipes/webhook-receiver/tests/requests.json +59 -0
- package/recipes/webhook-receiver/urlcode.yaml +16 -0
- package/schemas/recipe.schema.json +138 -0
- package/schemas/urlcode.schema.json +656 -80
- package/skills/urlcode/SKILL.md +98 -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/dist/cli.js
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { getCapabilities, formatCapabilities } from './capabilities.js';
|
|
3
|
+
import { getCapability, formatCapability } from './capability-query.js';
|
|
4
|
+
import { getSchemaFragment } from './schema-query.js';
|
|
5
|
+
import { stringify as stringifyYaml } from 'yaml';
|
|
2
6
|
import { auditProject, benchmarkProject } from './readiness.js';
|
|
3
7
|
|
|
4
8
|
import { parseArgs } from 'node:util';
|
|
5
9
|
import { createRuntime } from './runtime.js';
|
|
10
|
+
import { loadOperatorHost } from './operator-host.js';
|
|
11
|
+
|
|
6
12
|
import { startServer } from './server.js';
|
|
7
13
|
|
|
8
14
|
import {scaffoldProject} from './scaffold.js';
|
|
9
15
|
import { initProject, addRedirect } from './authoring.js';
|
|
16
|
+
import { initProjectWith, parseWithNames } from './init-with.js';
|
|
10
17
|
import { runProjectTests } from './project-tests.js';
|
|
18
|
+
import { verifyDeployment, failLevels } from './verify-deployment.js';
|
|
19
|
+
|
|
11
20
|
import { loadOperatorPolicy, prepareFunctionSnapshot, requestedPermissions } from './policy.js';
|
|
12
21
|
import { loadDocument } from './config.js';
|
|
22
|
+
import { describeExtensions } from './tooling.js';
|
|
23
|
+
|
|
13
24
|
import {parseLinkBinding,runLinkCommand,linkPoolOptions} from './link-cli.js';
|
|
14
25
|
import { ConfigError, HttpError } from './errors.js';
|
|
15
26
|
import {supportsConcurrentWal} from './sqlite-version.js';
|
|
16
27
|
import { registry as policyRegistry } from './policies.js';
|
|
17
28
|
import { loadComplianceRules, profileNames as complianceProfiles } from './compliance.js';
|
|
29
|
+
import { parseRouteSnapshot, diffRoutes, renderRouteDiff } from './route-diff.js';
|
|
30
|
+
import { readFile } from 'node:fs/promises';
|
|
18
31
|
|
|
19
|
-
const usage = `URLCode 0.
|
|
20
|
-
urlcode init <directory>
|
|
32
|
+
const usage = `URLCode 0.4.0-alpha.1 — local/self-hosted runtime
|
|
33
|
+
urlcode init <directory> [--with auth,admin] # --with: layered site from installed @jimhoyd/urlcode-<name> packages
|
|
21
34
|
urlcode scaffold [--project directory] [--dry-run]
|
|
22
35
|
urlcode validate [--project directory] [--local] [--origin https://links.example] # origin: absolute URLs in site.* files
|
|
23
36
|
urlcode dev [--project directory] [--port 3000] [--host 127.0.0.1]
|
|
@@ -30,33 +43,60 @@ const usage = `URLCode 0.3.0 — local/self-hosted runtime
|
|
|
30
43
|
urlcode test [--project directory] [--origin https://links.example]
|
|
31
44
|
urlcode build --target cloudflare [--project directory] [--out dist/cloudflare] [--origin https://links.example]
|
|
32
45
|
urlcode routes [--project directory] [--origin https://links.example]
|
|
46
|
+
diff: [--compare previous-routes.json] [--format json|markdown] # added/removed/changed routes against an earlier report; always exits 0
|
|
33
47
|
urlcode audit [--project directory] [--expect-routes 2]
|
|
34
48
|
compliance: [--compliance baseline|strict|privacy|none] [--compliance-rules /absolute/rules.mjs] [--compliance-ignore id,id]
|
|
35
49
|
[--compliance-warn] [--origin https://links.example] [--request-log minimal|detailed] # declare the deployment under review
|
|
36
50
|
urlcode benchmark [--project directory] [--requests 1000] [--concurrency 2] [--seconds 30] [--max-p95-ms 50]
|
|
37
51
|
[--warmup 50] [--target https://links.example] # target measures a running deployment, not a local snapshot
|
|
38
|
-
urlcode
|
|
52
|
+
urlcode verify-deployment --target https://links.example [--project directory] [--origin https://links.example]
|
|
53
|
+
[--expect-routes 2] [--expect-metrics] [--timeout-ms 10000] [--fail-on high|medium|low|info|none]
|
|
54
|
+
[--compliance baseline|strict|privacy|none] [--compliance-rules ...] [--compliance-ignore id,id] [--compliance-warn]
|
|
55
|
+
# compares the running deployment's responses with what this project declares; never follows redirects, no --insecure
|
|
56
|
+
urlcode permissions [--project directory] # inspect requested bindings and egress origins; grants nothing
|
|
57
|
+
urlcode explain [/route] [--project directory] [--target self-hosted|cloudflare|aws|vercel] [--host-file ...] [--json]
|
|
58
|
+
# effective methods, handler, middleware, inputs, policies, cache outcome, bindings and target support from the compiled configuration
|
|
59
|
+
urlcode manifest [--project directory] [--json] # generated semantic manifest; build writes the same file as manifest.json
|
|
60
|
+
urlcode extensions [--project directory] [--host-file /absolute/operator/host.mjs] [--json] # registered contracts and schemas; executes trusted host code, activates nothing
|
|
39
61
|
urlcode links init|create|get|list|update|delete|export|import|api --store /absolute/links.sqlite [--collection links]
|
|
40
62
|
create/update: --destination https://example.com [--code abc] [--status 302] [--enabled true] [--expires UTC]
|
|
41
63
|
update/delete: --code abc --if-version N (update replaces all mutable fields)
|
|
42
64
|
export: consistent NDJSON snapshot to stdout [--collection links] [--page-size 100]
|
|
43
65
|
import: --input /absolute/export.ndjson restores into empty collections (versions are reassigned)
|
|
44
66
|
api: --auth-file /operator/management.json (or legacy --token-file /operator/token) --port 3001 (separate authenticated server)
|
|
67
|
+
urlcode import [netlify|cloudflare|vercel|netlify-toml] <file> [--format csv|json|yaml] [--out new-file] [--dry-run] [--report json]
|
|
68
|
+
urlcode export --target netlify|cloudflare|vercel|netlify-toml|csv|json|yaml [--project directory] [--out new-file] [--report json]
|
|
69
|
+
conversion: [--accept-provider-differences] # explicit non-lossless migration candidate; exact behavior requires runtime
|
|
70
|
+
urlcode recipes [list|search <text>|show <name>|add <name> --out new-directory] [--dry-run] [--json]
|
|
71
|
+
urlcode examples [list|search <text>] [--json] # bundled runnable examples and the cookbook route index
|
|
72
|
+
urlcode build-typescript [--project directory] --out new-directory [--dry-run]
|
|
73
|
+
urlcode bulk-import csv|json|yaml <file> --out new-directory [--dry-run]
|
|
74
|
+
urlcode verify-provider --target self-hosted|aws|vercel|cloudflare --origin https://owned-fixture.example
|
|
75
|
+
[--timeout-ms 3000] [--release label] [--git-commit sha] # explicitly invokes synthetic deployment probes
|
|
76
|
+
urlcode mcp [--project directory] [--allow-authoring] [--host-file ...] # bounded stdio tooling; --allow-authoring adds project-confined authoring tools, host file adds get_extensions
|
|
77
|
+
urlcode capabilities [--target self-hosted|cloudflare|aws|vercel] [--json]
|
|
78
|
+
urlcode capabilities <name> [--json] # one catalog entry: schema fragment, constraints, grants, targets, bundled uses
|
|
79
|
+
urlcode schema <path> [--json|--yaml] # schema fragment for route, redirect, policies.cache, site.sitemap, ...
|
|
80
|
+
urlcode context [--project directory] [--target self-hosted|cloudflare|aws|vercel] [--budget 500] [--json] [--stats]
|
|
81
|
+
# compact facts for an authoring agent from the compiled project; --stats compares estimated tokens with the docs
|
|
45
82
|
urlcode doctor
|
|
83
|
+
serve/dev/validate/test/routes/audit/benchmark/explain/context/extensions/mcp: --host-file /absolute/operator/host.mjs (trusted code outside project)
|
|
46
84
|
serve/dev/validate/test/routes/audit/benchmark: --link-store links=/absolute/links.sqlite
|
|
47
85
|
Store pool controls: --link-readers 2 (1–8), --link-read-limit 32, --link-write-limit 32 (1–32 each)
|
|
48
86
|
Dev loads .env.local and watches; serve does neither. Functions run in WASM isolation; external bindings require --policy outside the project.
|
|
49
87
|
`;
|
|
50
88
|
const print = (value ) => process.stdout.write(typeof value === 'string' ? value : JSON.stringify(value) + '\n');
|
|
51
89
|
const options = {
|
|
52
|
-
|
|
90
|
+
json:{ type:'boolean' }, yaml:{ type:'boolean' }, report:{type:'string'}, 'accept-provider-differences':{type:'boolean'},
|
|
91
|
+
project:{ type:'string', default:'.' }, 'host-file':{type:'string'}, with:{type:'string'},
|
|
53
92
|
port:{ type:'string' }, host:{ type:'string', default:'127.0.0.1' },
|
|
54
93
|
'expect-routes':{type:'string'}, requests:{type:'string'}, concurrency:{type:'string'}, seconds:{type:'string'}, 'max-p95-ms':{type:'string'}, warmup:{type:'string'}, target:{type:'string'},
|
|
55
94
|
'link-readers':{type:'string'}, 'link-read-limit':{type:'string'}, 'link-write-limit':{type:'string'},
|
|
56
95
|
workers:{type:'string'}, 'function-timeout-ms':{type:'string'}, 'max-response-bytes':{type:'string'}, 'max-body-bytes':{type:'string'},
|
|
57
96
|
'max-in-flight':{type:'string'}, 'max-in-flight-health':{type:'string'}, 'request-log':{type:'string'}, 'trust-request-id':{type:'boolean'}, 'trusted-proxies':{type:'string'}, metrics:{type:'boolean'},
|
|
58
97
|
'link-store':{type:'string'}, store:{type:'string'}, collection:{type:'string'}, code:{type:'string'}, destination:{type:'string'}, status:{type:'string'}, enabled:{type:'string'}, expires:{type:'string'}, 'if-version':{type:'string'}, limit:{type:'string'}, after:{type:'string'}, 'token-file':{type:'string'}, 'auth-file':{type:'string'}, input:{type:'string'}, 'page-size':{type:'string'},
|
|
59
|
-
|
|
98
|
+
release:{type:'string'}, 'git-commit':{type:'string'}, 'timeout-ms':{type:'string'}, 'fail-on':{type:'string'}, 'expect-metrics':{type:'boolean'},
|
|
99
|
+
budget:{type:'string'}, stats:{type:'boolean'}, out:{type:'string'}, 'dry-run':{type:'boolean'}, compare:{type:'string'}, format:{type:'string'}, compliance:{type:'string'}, 'compliance-rules':{type:'string'}, 'compliance-ignore':{type:'string'}, 'compliance-warn':{type:'boolean'}, policy:{ type:'string' }, origin:{ type:'string' }, alias:{ type:'string' }, local:{ type:'boolean' }, 'allow-authoring':{ type:'boolean' }, help:{ type:'boolean', short:'h' },
|
|
60
100
|
} ;
|
|
61
101
|
|
|
62
102
|
|
|
@@ -95,15 +135,62 @@ async function complianceOptions(values )
|
|
|
95
135
|
if(!['minimal','detailed'].includes(host.requestLog))throw new ConfigError('Use --request-log minimal or detailed');
|
|
96
136
|
return {profile,ignore,origin:values.origin,host,rules:operator?.rules ?? [],disable:operator?.disable ?? [],override:operator?.override ?? {}};
|
|
97
137
|
}
|
|
138
|
+
function formatExtensions(report ) {
|
|
139
|
+
const lines = [`Project revision: ${report.projectSha256}`];
|
|
140
|
+
for (const item of report.declared) lines.push(`Declared: ${item.name} (contract ${item.version}) ${item.registered ? 'registered' : report.hostLoaded ? 'NOT registered by the host file' : 'schemas need --host-file'}`, ` mounts: ${item.mounts.join(', ') || '(none)'}`, ` policy routes: ${item.policyRoutes.join(', ') || '(none)'}`);
|
|
141
|
+
if (!report.declared.length) lines.push('Declared: (none)');
|
|
142
|
+
for (const item of report.extensions) lines.push(`Registered: ${item.name} (contract ${item.version}; targets ${item.targets.join(', ') || '(none)'}; ${item.declared ? 'declared' : 'not declared'}; revision ${item.revisionPinned ? 'pinned' : 'NOT pinned'})`,
|
|
143
|
+
` mounts: ${item.mounts.join(', ') || '(none)'}`, ` policy routes: ${item.policyRoutes.join(', ') || '(none)'}`, ` credential headers: ${item.credentialHeaders.join(', ') || '(none)'}`,
|
|
144
|
+
` configuration schema: ${JSON.stringify(item.schema)}`, ` policy schema: ${item.policySchema ? JSON.stringify(item.policySchema) : '(none)'}`);
|
|
145
|
+
lines.push(report.note);
|
|
146
|
+
return lines.join('\n') + '\n';
|
|
147
|
+
}
|
|
98
148
|
const errorMessages = { ERR_PARSE_ARGS_UNKNOWN_OPTION:'Unknown option; use --help', EEXIST:'Destination or edit lock already exists', ENOENT:'Required file or directory not found', EADDRINUSE:'Port is already in use', EACCES:'Permission denied' };
|
|
149
|
+
let operatorHost = {};
|
|
150
|
+
let serving = false;
|
|
99
151
|
try {
|
|
100
152
|
const { values, positionals } = parseArgs({ allowPositionals:true, options });
|
|
101
153
|
const [command, arg, ...extra] = positionals;
|
|
102
154
|
values.port ??= command==='links' && arg==='api' ? '3001' : '3000';
|
|
103
155
|
if (values.help || !command) print(usage);
|
|
104
156
|
else {
|
|
105
|
-
if (
|
|
106
|
-
|
|
157
|
+
if (values['host-file'] !== undefined) {
|
|
158
|
+
if (!['serve','dev','validate','test','routes','audit','benchmark','explain','context','extensions','mcp'].includes(command)) throw new ConfigError('--host-file is only supported by serve/dev/validate/test/routes/audit/benchmark/explain/context/extensions/mcp');
|
|
159
|
+
// The MCP server and context command load and release the host themselves.
|
|
160
|
+
if (command !== 'mcp' && command !== 'context') operatorHost = await loadOperatorHost(values['host-file'], values.project);
|
|
161
|
+
}
|
|
162
|
+
if (values.with !== undefined && command !== 'init') throw new ConfigError('--with is only supported by init');
|
|
163
|
+
if (values['allow-authoring'] && command !== 'mcp') throw new ConfigError('--allow-authoring is only supported by mcp');
|
|
164
|
+
const hostOptions = { extensions: operatorHost.extensions, plugins: operatorHost.plugins };
|
|
165
|
+
if ((!['import','recipes','recipe','examples','example','bulk-import'].includes(command) && extra.length) || (!['init','add','links','import','recipes','recipe','examples','example','bulk-import','explain','capabilities','schema'].includes(command) && arg)) throw new ConfigError('Unexpected positional arguments');
|
|
166
|
+
|
|
167
|
+
if(command==='import'||command==='export'){
|
|
168
|
+
const { runInterchange } = await import('./interchange-cli.js');
|
|
169
|
+
const converted = await runInterchange(command,positionals.slice(1),{project:values.project,target:values.target,format:values.format,out:values.out,report:values.report,dryRun:values['dry-run'],acceptProviderDifferences:values['accept-provider-differences']});
|
|
170
|
+
print(converted.text); if(!converted.report.ok)process.exitCode=1;
|
|
171
|
+
}else if(['recipes','recipe','examples','example','build-typescript','bulk-import','verify-provider','mcp'].includes(command)){
|
|
172
|
+
const {runEcosystemCommand}=await import('./ecosystem-cli.js');
|
|
173
|
+
await runEcosystemCommand(command,positionals.slice(1),values,print);
|
|
174
|
+
}else if(command==='explain'||command==='manifest'){
|
|
175
|
+
const {runExplainCommand}=await import('./explain-cli.js');
|
|
176
|
+
const exitCode=await runExplainCommand(command,arg,{project:values.project,target:values.target,origin:values.origin,json:values.json,extensions:operatorHost.extensions},print);
|
|
177
|
+
if(exitCode)process.exitCode=exitCode;
|
|
178
|
+
}else if(command==='capabilities'){
|
|
179
|
+
if(arg!==undefined){ if(values.target!==undefined)throw new ConfigError('--target applies to the full catalog, not one entry'); const entry=getCapability(arg); print(values.json ? entry : formatCapability(entry)); }
|
|
180
|
+
else { const catalog = getCapabilities(values.target); print(values.json ? catalog : formatCapabilities(catalog)); }
|
|
181
|
+
}else if(command==='schema'){
|
|
182
|
+
if(arg===undefined)throw new ConfigError('Use urlcode schema <path>');
|
|
183
|
+
const fragment=getSchemaFragment(arg);
|
|
184
|
+
print(values.yaml ? stringifyYaml(fragment.schema) : JSON.stringify(fragment.schema,null,2)+'\n');
|
|
185
|
+
}else if(command==='context'){
|
|
186
|
+
if (values.budget !== undefined && !/^\d{1,9}$/.test(values.budget)) throw new ConfigError('Invalid --budget');
|
|
187
|
+
const { buildContext, renderContext, estimateTokens, documentationTokens } = await import('./context.js');
|
|
188
|
+
const context = await buildContext(values.project, { target:values.target, hostFile:values['host-file'], ...(values.budget === undefined ? {} : { budget:Number(values.budget) }) });
|
|
189
|
+
const text = values.json ? JSON.stringify(context) + '\n' : renderContext(context);
|
|
190
|
+
print(text);
|
|
191
|
+
// Estimates only (characters / 4); a tokenizer is not a dependency. Stats go to stderr so stdout stays parseable.
|
|
192
|
+
if (values.stats) process.stderr.write(JSON.stringify({ event:'stats', estimate:'characters/4', documentationTokens:await documentationTokens(), contextTokens:estimateTokens(text) }) + '\n');
|
|
193
|
+
}else if(command==='links'){await runLinkCommand(arg,values,print);}else{
|
|
107
194
|
const permissions = await loadOperatorPolicy(values.policy,values.project);
|
|
108
195
|
const linkStore=parseLinkBinding(values['link-store'],linkPoolOptions(values));
|
|
109
196
|
switch (command) {
|
|
@@ -117,12 +204,22 @@ try {
|
|
|
117
204
|
const expected=number('expect-routes');
|
|
118
205
|
if(expected!==undefined && !Number.isSafeInteger(expected))throw new ConfigError('Expected route count must be an integer');
|
|
119
206
|
const compliance=command==='audit'?await complianceOptions(values):undefined;
|
|
207
|
+
const format=values.format ?? 'json';
|
|
208
|
+
if(!['json','markdown'].includes(format))throw new ConfigError('Use --format json or markdown');
|
|
209
|
+
if(values.format!==undefined && values.compare===undefined)throw new ConfigError('--format applies to routes --compare');
|
|
120
210
|
const started=performance.now();
|
|
121
|
-
const app=await startServer({project:values.project,port:0,local:true,permissions,linkStore,origin:values.origin,log:()=>{}});
|
|
211
|
+
const app=await startServer({...hostOptions,project:values.project,port:0,local:true,permissions,linkStore,origin:values.origin,log:()=>{}});
|
|
122
212
|
const startupMs=performance.now()-started;
|
|
123
213
|
try {
|
|
124
214
|
if(command==='routes') {
|
|
125
|
-
const plan=app.testPlan();
|
|
215
|
+
const plan=app.testPlan();
|
|
216
|
+
if(values.compare===undefined) print({routes:plan.inventory.length,dynamicLinks:plan.dynamicLinks,inventory:plan.inventory,policies:plan.policies});
|
|
217
|
+
else {
|
|
218
|
+
// The diff reports; it never judges, so the exit code stays 0.
|
|
219
|
+
const before=parseRouteSnapshot(JSON.parse(await readFile(values.compare,'utf8'))); // file boundary: an earlier `routes` report
|
|
220
|
+
const diff=diffRoutes(before,{inventory:plan.inventory,policies:plan.policies});
|
|
221
|
+
print(format==='markdown'?renderRouteDiff(diff):diff);
|
|
222
|
+
}
|
|
126
223
|
} else if(command==='audit') {
|
|
127
224
|
const report=await auditProject(app,{expectRoutes:expected,log:print,compliance});print(report);if(!report.ready)process.exitCode=1;
|
|
128
225
|
if(report.compliance && !report.compliance.pass && !values['compliance-warn'])process.exitCode=1;
|
|
@@ -134,6 +231,34 @@ try {
|
|
|
134
231
|
} finally {await app.close();}
|
|
135
232
|
break;
|
|
136
233
|
}
|
|
234
|
+
case 'verify-deployment': {
|
|
235
|
+
if (!values.target) throw new ConfigError('Provide --target https://host');
|
|
236
|
+
const integer = (key ) => {
|
|
237
|
+
const value = values[key];
|
|
238
|
+
if (value === undefined) return undefined;
|
|
239
|
+
if (!/^\d{1,9}$/.test(value)) throw new ConfigError(`Invalid --${key}`);
|
|
240
|
+
return Number(value);
|
|
241
|
+
};
|
|
242
|
+
const failOn = values['fail-on'] ?? 'high';
|
|
243
|
+
const isFailOn = (value ) => (failLevels ).includes(value);
|
|
244
|
+
if (!isFailOn(failOn)) throw new ConfigError(`Use --fail-on ${failLevels.join('|')}`);
|
|
245
|
+
const report = await verifyDeployment(values.project, { target: values.target, origin: values.origin, expectRoutes: integer('expect-routes'), timeoutMs: integer('timeout-ms'),
|
|
246
|
+
expectMetrics: values['expect-metrics'], failOn, compliance: await complianceOptions(values), complianceWarn: values['compliance-warn'], permissions, linkStore, log: print });
|
|
247
|
+
print(report); if (!report.pass) process.exitCode = 1; break;
|
|
248
|
+
}
|
|
249
|
+
case 'verify-provider': {
|
|
250
|
+
const target=values.target;
|
|
251
|
+
if(target!=='self-hosted'&&target!=='aws'&&target!=='vercel'&&target!=='cloudflare')throw new ConfigError('Provide --target self-hosted|aws|vercel|cloudflare');
|
|
252
|
+
if(!values.origin)throw new ConfigError('Provide --origin https://owned-fixture.example');
|
|
253
|
+
if(values['timeout-ms']!==undefined&&!/^\d{1,5}$/.test(values['timeout-ms']))throw new ConfigError('Invalid --timeout-ms');
|
|
254
|
+
const {verifyProviderDeployment}=await import('./provider-verification.js');
|
|
255
|
+
const report=await verifyProviderDeployment(target,values.origin,{
|
|
256
|
+
...(values['timeout-ms']===undefined?{}:{timeoutMs:Number(values['timeout-ms'])}),
|
|
257
|
+
...(values.release===undefined?{}:{release:values.release}),
|
|
258
|
+
...(values['git-commit']===undefined?{}:{gitCommit:values['git-commit']}),
|
|
259
|
+
});
|
|
260
|
+
print(report);if(!report.pass)process.exitCode=1;break;
|
|
261
|
+
}
|
|
137
262
|
case 'build': {
|
|
138
263
|
if (values.target !== 'cloudflare') throw new ConfigError('Use --target cloudflare');
|
|
139
264
|
const { buildCloudflare } = await import('./build-cloudflare.js');
|
|
@@ -141,36 +266,46 @@ try {
|
|
|
141
266
|
}
|
|
142
267
|
case 'scaffold':
|
|
143
268
|
print(await scaffoldProject(values.project,{dryRun:values['dry-run']}));break;
|
|
269
|
+
case 'extensions': {
|
|
270
|
+
const report = await describeExtensions(values.project, values['host-file'] === undefined ? undefined : operatorHost.extensions ?? []);
|
|
271
|
+
print(values.json ? report : formatExtensions(report)); break;
|
|
272
|
+
}
|
|
144
273
|
case 'permissions': {
|
|
145
274
|
const loaded = await loadDocument(values.project);
|
|
146
275
|
print(requestedPermissions(loaded,await prepareFunctionSnapshot(loaded))); break;
|
|
147
276
|
}
|
|
148
277
|
case 'init':
|
|
149
278
|
if (!arg) throw new ConfigError('Provide a new project directory');
|
|
150
|
-
await initProject(arg); print({ event:'created' }); break;
|
|
279
|
+
if (values.with === undefined) { await initProject(arg); print({ event:'created' }); break; }
|
|
280
|
+
{
|
|
281
|
+
const created = await initProjectWith(arg, parseWithNames(values.with));
|
|
282
|
+
print({ event:'created', ...created, review:`Review ${created.project}/urlcode.yaml and pin its revision explicitly (for example PROJECT_SHA256=${created.projectSha256}); re-review after any project change` });
|
|
283
|
+
}
|
|
284
|
+
break;
|
|
151
285
|
case 'validate': {
|
|
152
|
-
const runtime = await createRuntime(values.project, { local:values.local, permissions, linkStore, origin:values.origin });
|
|
286
|
+
const runtime = await createRuntime(values.project, { ...hostOptions, local:values.local, permissions, linkStore, origin:values.origin });
|
|
153
287
|
print({ event:'valid', dynamicLinks:runtime.testPlan().dynamicLinks, routes:runtime.count, version:runtime.version }); await runtime.close(); break;
|
|
154
288
|
}
|
|
155
289
|
case 'add':
|
|
156
290
|
if (!arg) throw new ConfigError('Provide an HTTP(S) destination URL');
|
|
157
291
|
print({ event:'added', path:await addRedirect(values.project,arg,values.alias) }); break;
|
|
158
292
|
case 'test': {
|
|
159
|
-
const result = await runProjectTests(values.project, { log:print, permissions, linkStore, origin:values.origin });
|
|
293
|
+
const result = await runProjectTests(values.project, { ...hostOptions, log:print, permissions, linkStore, origin:values.origin });
|
|
160
294
|
print(result); if (result.failed) process.exitCode = 1; break;
|
|
161
295
|
}
|
|
162
296
|
case 'doctor':
|
|
163
|
-
print({ node:process.version, sqlite:process.versions.sqlite, liveLinks:supportsConcurrentWal(process.versions.sqlite), platform:process.platform, architecture:process.arch, runtime:'node-process', functionSandbox:'quickjs-wasm', network:false, filesystem:false, providers:[], policies:Object.keys(policyRegistry), license:'Apache-2.0' }); break;
|
|
297
|
+
print({ node:process.version, sqlite:process.versions.sqlite, liveLinks:supportsConcurrentWal(process.versions.sqlite), platform:process.platform, architecture:process.arch, runtime:'node-process', functionSandbox:'quickjs-wasm', network:false, filesystem:false, guestNetwork:false, hostEgress:'revision-pinned-origin-grants', tooling:['recipes','examples','bulk-import','build-typescript','mcp','verify-provider'], providers:[], capabilityTargets:getCapabilities().targets, policies:Object.keys(policyRegistry), license:'Apache-2.0' }); break;
|
|
164
298
|
case 'dev': case 'serve': {
|
|
165
299
|
const port = Number(values.port);
|
|
166
300
|
if (!/^\d+$/.test(values.port) || !Number.isInteger(port) || port < 0 || port > 65535) throw new ConfigError('Invalid port');
|
|
167
301
|
if (command === 'serve' && values.local) throw new ConfigError('serve never reads local dotenv files');
|
|
168
|
-
const app = await startServer({ project:values.project, host:values.host, port,
|
|
302
|
+
const app = await startServer({ ...hostOptions, project:values.project, host:values.host, port,
|
|
169
303
|
local:command === 'dev', watch:command === 'dev', origin:values.origin, permissions, linkStore,
|
|
170
304
|
...serverCapacity(values) });
|
|
171
305
|
print({ event:'listening', address:app.address.address, port:app.address.port, mode:command, origin:app.origin });
|
|
306
|
+
serving = true;
|
|
172
307
|
let stopping = false;
|
|
173
|
-
const stop = async () => { if (stopping) return; stopping = true; await app.close(); };
|
|
308
|
+
const stop = async () => { if (stopping) return; stopping = true; try { await app.close(); } finally { await operatorHost.close?.(); } };
|
|
174
309
|
process.once('SIGINT',stop); process.once('SIGTERM',stop);
|
|
175
310
|
break;
|
|
176
311
|
}
|
|
@@ -182,4 +317,8 @@ try {
|
|
|
182
317
|
const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' ? error.code : undefined;
|
|
183
318
|
const message = (error instanceof ConfigError || error instanceof HttpError) ? error.message : ((code !== undefined ? errorMessages[code] : undefined) || 'Operation failed; check project files, module dependencies and command options');
|
|
184
319
|
process.stderr.write(JSON.stringify({ event:'error', message }) + '\n'); process.exitCode = 1;
|
|
320
|
+
} finally {
|
|
321
|
+
if (!serving) {
|
|
322
|
+
try { await operatorHost.close?.(); } catch { process.stderr.write('Operator host cleanup failed\n'); process.exitCode = 1; }
|
|
323
|
+
}
|
|
185
324
|
}
|
package/dist/client-address.js
CHANGED
|
@@ -3,7 +3,7 @@ import { oshp, rfc9111, rfc9110, rfc6585, rfc9309, breach, management, active, f
|
|
|
3
3
|
|
|
4
4
|
export const profile = 'baseline';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const securityHeaders = {
|
|
7
7
|
id: 'oshp/security-headers', title: 'Every active route carries a security headers profile', standard: oshp, severity: 'medium', appliesTo: 'route',
|
|
8
8
|
check({ route, effective }) {
|
|
9
9
|
if (!active(route) || effective.security) return [];
|
|
@@ -11,7 +11,7 @@ export const securityHeaders = {
|
|
|
11
11
|
},
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
const hstsOrigin = {
|
|
15
15
|
id: 'oshp/hsts-origin', title: 'HSTS is only reachable on an https origin', standard: { ...oshp, section: 'Strict-Transport-Security' }, severity: 'low', appliesTo: 'project',
|
|
16
16
|
check({ plan, policies, origin }) {
|
|
17
17
|
const emitting = plan.inventory.filter(route => active(route) && emittedSecurityHeaders(policies[route.path], plan.policies?.[route.path]).has('strict-transport-security'));
|
|
@@ -23,7 +23,7 @@ export const hstsOrigin = {
|
|
|
23
23
|
},
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
const secretsCompression = {
|
|
27
27
|
id: 'breach/secrets-compression', title: 'Routes bound to secrets are never compressed', standard: breach, severity: 'high', appliesTo: 'route',
|
|
28
28
|
check({ route, config, effective }) {
|
|
29
29
|
if (!hasSecrets(config) || effective.compression?.allowWithSecrets !== true) return [];
|
|
@@ -31,7 +31,7 @@ export const secretsCompression = {
|
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
const secretsNoStore = {
|
|
35
35
|
id: 'rfc9111/secrets-no-store', title: 'Routes bound to secrets declare no-store or private caching', standard: { ...rfc9111, section: '5.2.2.5 no-store, 5.2.2.7 private' }, severity: 'medium', appliesTo: 'route',
|
|
36
36
|
check({ route, config, effective }) {
|
|
37
37
|
if (!hasSecrets(config)) return [];
|
|
@@ -47,7 +47,7 @@ export const secretsNoStore = {
|
|
|
47
47
|
},
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
const cacheControlDeclared = {
|
|
51
51
|
id: 'rfc9111/cache-control-declared', title: 'Every declared response states its Cache-Control', standard: rfc9111, severity: 'low', appliesTo: 'route',
|
|
52
52
|
check({ route, config, effective }) {
|
|
53
53
|
if (!active(route) || effective.cache) return [];
|
|
@@ -60,7 +60,7 @@ export const cacheControlDeclared = {
|
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
const throttleFunctions = {
|
|
64
64
|
id: 'rfc6585/throttle-functions', title: 'Function and middleware routes declare a request budget', standard: rfc6585, severity: 'medium', appliesTo: 'route',
|
|
65
65
|
check({ route, effective }) {
|
|
66
66
|
if (!active(route) || !functionLike(route) || effective.throttle) return [];
|
|
@@ -78,7 +78,7 @@ export const robots = {
|
|
|
78
78
|
},
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
const expiredRoutes = {
|
|
82
82
|
id: 'rfc9110/expired-routes', title: 'Expired routes still present in YAML', standard: { ...rfc9110, section: '15.5.11 410 Gone' }, severity: 'info', appliesTo: 'project',
|
|
83
83
|
check({ plan }) {
|
|
84
84
|
const expired = plan.inventory.filter(route => route.state === 'expired').map(route => route.path);
|
|
@@ -87,7 +87,7 @@ export const expiredRoutes = {
|
|
|
87
87
|
},
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
const managementPrivate = {
|
|
91
91
|
id: 'ops/management-private', title: 'Management and probe listeners stay private', standard: management, severity: 'info', appliesTo: 'project',
|
|
92
92
|
check({ document }) {
|
|
93
93
|
if (document.dynamicLinks !== true) return [];
|
|
@@ -8,7 +8,7 @@ export const profile = 'privacy';
|
|
|
8
8
|
// `host.includeCode`), because the runtime's log guarantees are settings of
|
|
9
9
|
// the serving process, not of the project. An undeclared setting is reported
|
|
10
10
|
// as unknown rather than assumed.
|
|
11
|
-
|
|
11
|
+
const requestLogMinimal = {
|
|
12
12
|
id: 'privacy/request-log-minimal', title: 'Request log records stay minimal', standard: monitoring, severity: 'medium', appliesTo: 'project',
|
|
13
13
|
check({ host }) {
|
|
14
14
|
if (host.requestLog === 'minimal') return [];
|
|
@@ -17,7 +17,7 @@ export const requestLogMinimal = {
|
|
|
17
17
|
},
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const linkEventsOff = {
|
|
21
21
|
id: 'privacy/link-events-off', title: 'Link events stay off unless declared', standard: linkChannel, severity: 'medium', appliesTo: 'project',
|
|
22
22
|
check({ document, host }) {
|
|
23
23
|
if (document.dynamicLinks !== true || host.linkEvents === false) return [];
|
|
@@ -27,7 +27,7 @@ export const linkEventsOff = {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
const detailedParameters = {
|
|
31
31
|
id: 'privacy/detailed-log-parameters', title: 'No detailed logging on parameterised routes', standard: monitoring, severity: 'low', appliesTo: 'route',
|
|
32
32
|
check({ route, config, host }) {
|
|
33
33
|
if (host.requestLog !== 'detailed' || !active(route) || !((config.parameters?.length ?? 0) > 0)) return [];
|
|
@@ -6,7 +6,7 @@ export const profile = 'strict';
|
|
|
6
6
|
|
|
7
7
|
// The runtime caps a response at 16 KiB of headers; the security policy
|
|
8
8
|
// bounds its own static headers at half of that so the handler keeps room.
|
|
9
|
-
|
|
9
|
+
const headerBudgetBytes = 8192;
|
|
10
10
|
|
|
11
11
|
export const csp = {
|
|
12
12
|
id: 'oshp/csp', title: 'Every active route emits a Content-Security-Policy', standard: { ...oshp, section: 'Content-Security-Policy' }, severity: 'medium', appliesTo: 'route',
|
|
@@ -16,7 +16,7 @@ export const csp = {
|
|
|
16
16
|
},
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
const throttleAll = {
|
|
20
20
|
id: 'rfc6585/throttle-all', title: 'Every active route declares a request budget', standard: rfc6585, severity: 'medium', appliesTo: 'route',
|
|
21
21
|
check({ route, effective }) {
|
|
22
22
|
if (!active(route) || functionLike(route) || effective.throttle) return [];
|
|
@@ -24,7 +24,7 @@ export const throttleAll = {
|
|
|
24
24
|
},
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const listsPinned = {
|
|
28
28
|
id: 'agents/lists-pinned', title: 'Agent deny and allow lists are pinned to a revision', standard: agentLists, severity: 'low', appliesTo: 'route',
|
|
29
29
|
check({ route, effective, policy }) {
|
|
30
30
|
if (!active(route) || !effective.agents) return [];
|
|
@@ -39,7 +39,7 @@ export const listsPinned = {
|
|
|
39
39
|
},
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
const redirectHttps = {
|
|
43
43
|
id: 'rfc9110/redirect-https', title: 'Redirect targets are https', standard: { ...rfc9110, section: '15.4 Redirection 3xx' }, severity: 'medium', appliesTo: 'route',
|
|
44
44
|
check({ route, config }) {
|
|
45
45
|
const url = config.redirect?.url;
|
|
@@ -48,7 +48,7 @@ export const redirectHttps = {
|
|
|
48
48
|
},
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
const headerBudget = {
|
|
52
52
|
id: 'http/header-budget', title: 'Declared response headers stay under the static budget', standard: { ...rfc9110, section: '5.4 Field Limits' }, severity: 'low', appliesTo: 'route',
|
|
53
53
|
check({ route, config, effective, policy }) {
|
|
54
54
|
if (!active(route)) return [];
|
|
@@ -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
|
+
}
|