@jimhoyd/urlcode 0.4.0-alpha.1 → 0.4.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/urlcode-authoring/SKILL.md +36 -22
- package/.claude/skills/urlcode-operations/SKILL.md +16 -22
- package/.claude-plugin/marketplace.json +1 -1
- package/CONTRIBUTING.md +20 -16
- package/README.md +83 -80
- package/ROADMAP.md +61 -331
- package/SECURITY.md +23 -8
- package/dist/BUILD-MANIFEST.json +42 -47
- package/dist/agents-guide.js +18 -18
- package/dist/build-static.js +136 -0
- package/dist/capabilities.js +85 -31
- package/dist/capability-query.js +0 -1
- package/dist/cli.js +24 -32
- package/dist/compliance-rules/baseline.js +2 -10
- package/dist/compliance-rules/privacy.js +5 -16
- package/dist/compliance-rules/shared.js +0 -2
- package/dist/compliance.js +6 -8
- package/dist/config.js +15 -8
- package/dist/context.js +9 -10
- package/dist/examples.js +2 -2
- package/dist/explain-cli.js +7 -5
- package/dist/explain.js +10 -5
- package/dist/extensions.js +61 -2
- package/dist/function-sources.js +34 -2
- package/dist/function-worker.js +3 -1
- package/dist/functions.js +84 -13
- package/dist/guest-api.js +29 -3
- package/dist/index.js +3 -6
- package/dist/manifest.js +11 -7
- package/dist/mcp-authoring.js +2 -2
- package/dist/mcp.js +1 -1
- package/dist/observability.js +1 -21
- package/dist/policies/cache.js +8 -3
- package/dist/policies.js +3 -1
- package/dist/policy.js +28 -9
- package/dist/prerender.js +4 -0
- package/dist/project-tests.js +3 -3
- package/dist/readiness.js +35 -9
- package/dist/route-diff.js +12 -5
- package/dist/router.js +5 -7
- package/dist/runtime.js +77 -58
- package/dist/sandbox.js +48 -0
- package/dist/scaffold.js +0 -0
- package/dist/scripts/operational-drills.js +12 -54
- package/dist/server.js +3 -29
- package/dist/tooling.js +1 -1
- package/dist/trusted-functions.js +210 -0
- package/dist/types/build-static.d.ts +43 -0
- package/dist/types/capabilities.d.ts +14 -5
- package/dist/types/compliance-rules/shared.d.ts +0 -2
- package/dist/types/compliance.d.ts +0 -3
- package/dist/types/config.d.ts +2 -1
- package/dist/types/context.d.ts +2 -1
- package/dist/types/examples.d.ts +1 -1
- package/dist/types/explain.d.ts +6 -0
- package/dist/types/extensions.d.ts +55 -0
- package/dist/types/function-sources.d.ts +4 -0
- package/dist/types/functions.d.ts +48 -5
- package/dist/types/guest-api.d.ts +1 -0
- package/dist/types/index.d.ts +3 -6
- package/dist/types/manifest.d.ts +5 -3
- package/dist/types/observability.d.ts +1 -14
- package/dist/types/project-tests.d.ts +1 -2
- package/dist/types/readiness.d.ts +12 -3
- package/dist/types/router.d.ts +2 -1
- package/dist/types/runtime.d.ts +0 -27
- package/dist/types/sandbox.d.ts +12 -0
- package/dist/types/scaffold.d.ts +0 -2
- package/dist/types/server.d.ts +1 -4
- package/dist/types/tooling.d.ts +3 -3
- package/dist/types/trusted-functions.d.ts +29 -0
- package/dist/types/types.d.ts +22 -8
- package/dist/types/verify-deployment.d.ts +2 -2
- package/dist/types.js +25 -5
- package/dist/typescript-authoring.js +67 -18
- package/dist/verify-deployment.js +3 -3
- package/docs/AI-AUTHORING.md +109 -10
- package/docs/ASSETS.md +2 -1
- package/docs/AUTH-BACKUP.md +32 -0
- package/docs/AWS.md +4 -4
- package/docs/BEST-PRACTICES.md +17 -8
- package/docs/CAPABILITIES.md +30 -17
- package/docs/CAPACITY.md +128 -31
- package/docs/CI-FOLLOWUP-2026-09-19.md +97 -0
- package/docs/CI-RELEASE-AUDIT-2026-09-19.md +322 -0
- package/docs/CI.md +8 -3
- package/docs/CLOUDFLARE.md +1 -2
- package/docs/CODEBASE-AUDIT-2026-09-20.md +278 -0
- package/docs/COMPLIANCE.md +6 -9
- package/docs/DEPLOYMENT-CHECKS.md +1 -1
- package/docs/DEVELOPMENT-PIPELINE.md +181 -0
- package/docs/EXTENSIONS.md +193 -11
- package/docs/FRAMEWORK.md +53 -33
- package/docs/FUNCTION-SECURITY.md +173 -32
- package/docs/INSTALL.md +0 -5
- package/docs/LOAD-TESTING.md +4 -4
- package/docs/LOCAL-DEVELOPMENT.md +3 -0
- package/docs/MIDDLEWARE.md +27 -16
- package/docs/MONITORING.md +2 -19
- package/docs/OBSERVABILITY.md +7 -16
- package/docs/OPEN-DECISIONS.md +184 -0
- package/docs/OPERATIONAL-PROOF.md +26 -30
- package/docs/OPERATIONS.md +23 -32
- package/docs/POLICIES.md +27 -8
- package/docs/PRERENDER.md +25 -13
- package/docs/PROJECT-DIRECTION.md +35 -10
- package/docs/READINESS.md +6 -2
- package/docs/README.md +20 -41
- package/docs/RELEASE-0.4.0-alpha.3.md +50 -0
- package/docs/RELEASE-READINESS.md +50 -30
- package/docs/RELEASE-SECURITY.md +72 -86
- package/docs/RESILIENCE.md +16 -15
- package/docs/ROUTING.md +7 -10
- package/docs/SANDBOX-REVIEW.md +19 -6
- package/docs/SCAFFOLDING.md +0 -2
- package/docs/SECURITY-AUDIT.md +42 -2
- package/docs/SPECIFICATION.md +63 -27
- package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +288 -0
- package/docs/SPIKE-BUSINESS-SUITE.md +16 -0
- package/docs/SPIKE-CORE-LAYERING.md +368 -0
- package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +211 -0
- package/docs/SPIKE-LAMBDA-COMPILE.md +12 -2
- package/docs/STARTERS.md +1 -1
- package/docs/STATIC.md +105 -0
- package/docs/TOOLING.md +17 -12
- package/docs/TUNNELS.md +0 -3
- package/docs/TYPESCRIPT-AUTHORING.md +32 -12
- package/docs/TYPESCRIPT.md +25 -4
- package/docs/VERCEL.md +4 -5
- package/docs/VERSION-ALIGNMENT.md +46 -0
- package/docs/YAML-GUIDE.md +0 -3
- package/docs/YAML-REFERENCE.md +16 -6
- package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +68 -0
- package/docs/{MANAGEMENT-SECURITY.md → archive/2026-09-19/MANAGEMENT-SECURITY.md} +20 -0
- package/docs/{NEXT-PHASE-PLAN.md → archive/2026-09-19/NEXT-PHASE-PLAN.md} +26 -8
- package/docs/{NEXT-STEPS.md → archive/2026-09-19/NEXT-STEPS.md} +274 -43
- package/docs/archive/2026-09-19/OPEN-DECISIONS.md +277 -0
- package/docs/archive/2026-09-19/RELEASE-SECURITY.md +186 -0
- package/docs/archive/2026-09-19/ROADMAP.md +386 -0
- package/docs/{SPIKE-EXTENSION-MODEL.md → archive/2026-09-19/SPIKE-EXTENSION-MODEL.md} +12 -2
- package/docs/{SPIKE-EXTENSIONS.md → archive/2026-09-19/SPIKE-EXTENSIONS.md} +28 -12
- package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +776 -0
- package/docs/{USABILITY-REVIEW.md → archive/2026-09-19/USABILITY-REVIEW.md} +18 -2
- package/docs/archive/README.md +27 -0
- package/docs/policies/agents.md +1 -1
- package/docs/policies/cache.md +13 -0
- package/docs/policies/compression.md +3 -2
- package/docs/policies/security.md +3 -2
- package/examples/assets/example.yaml +2 -2
- package/examples/aws/example.yaml +1 -1
- package/examples/cloudflare/example.yaml +1 -1
- package/examples/conditions/example.yaml +1 -1
- package/examples/cookbook/example.yaml +1 -1
- package/examples/cookbook/middleware/bucket.mjs +12 -2
- package/examples/cookbook/middleware/locale.mjs +7 -3
- package/examples/cookbook/route-index.json +1 -1
- package/examples/cookbook/routes/middleware.yaml +1 -1
- package/examples/egress/example.yaml +1 -1
- package/examples/extensions/example.yaml +1 -1
- package/examples/prerender/README.md +14 -6
- package/examples/prerender/example.yaml +1 -1
- package/examples/prerender/functions/page.mjs +4 -2
- package/examples/prerender/middleware/template.mjs +1 -1
- package/examples/prerender/prerender.mjs +1 -1
- package/examples/prerender/urlcode.yaml +8 -4
- package/examples/provider-conformance/example.yaml +1 -1
- package/examples/vercel/example.yaml +1 -1
- package/llms-full.txt +686 -239
- package/llms.txt +27 -15
- package/package.json +32 -5
- package/packaging/claude-plugin/.claude-plugin/plugin.json +2 -2
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +36 -22
- package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +16 -22
- package/recipes/authenticated-json-api/README.md +4 -3
- package/recipes/authenticated-json-api/functions/profile.mjs +2 -1
- package/recipes/authenticated-json-api/recipe.yaml +2 -2
- package/recipes/contact-form/functions/contact.mjs +2 -1
- package/recipes/contact-form/recipe.yaml +3 -3
- package/recipes/cors-api/README.md +2 -2
- package/recipes/cors-api/recipe.yaml +2 -2
- package/recipes/health-page/README.md +1 -1
- package/recipes/health-page/recipe.yaml +1 -1
- package/recipes/json-api/README.md +1 -1
- package/recipes/json-api/recipe.yaml +4 -4
- package/recipes/middleware/README.md +8 -4
- package/recipes/middleware/middleware/bucket.mjs +12 -2
- package/recipes/middleware/middleware/locale.mjs +7 -3
- package/recipes/middleware/recipe.yaml +1 -1
- package/recipes/protected-download/README.md +1 -1
- package/recipes/protected-download/recipe.yaml +2 -2
- package/recipes/redirect/recipe.yaml +1 -1
- package/recipes/static-plus-api/README.md +2 -2
- package/recipes/static-plus-api/public/index.html +1 -1
- package/recipes/static-plus-api/recipe.yaml +2 -2
- package/recipes/static-plus-api/urlcode.yaml +1 -1
- package/recipes/typescript/README.md +2 -1
- package/recipes/typescript/recipe.yaml +5 -5
- package/recipes/webhook-receiver/README.md +5 -1
- package/recipes/webhook-receiver/recipe.yaml +2 -1
- package/recipes/webhook-receiver/urlcode.yaml +7 -0
- package/schemas/recipe.schema.json +4 -3
- package/schemas/urlcode.schema.json +14 -41
- package/skills/urlcode/SKILL.md +32 -11
- package/starters/default/AGENTS.md +18 -18
- package/starters/default/urlcode.yaml +0 -1
- package/dist/link-api.js +0 -136
- package/dist/link-cli.js +0 -141
- package/dist/link-events.js +0 -76
- package/dist/link-records.js +0 -31
- package/dist/link-store-worker.js +0 -150
- package/dist/link-store.js +0 -250
- package/dist/management-policy.js +0 -40
- package/dist/sqlite-version.js +0 -6
- package/dist/types/link-api.d.ts +0 -30
- package/dist/types/link-cli.d.ts +0 -37
- package/dist/types/link-events.d.ts +0 -27
- package/dist/types/link-records.d.ts +0 -11
- package/dist/types/link-store-worker.d.ts +0 -1
- package/dist/types/link-store.d.ts +0 -130
- package/dist/types/management-policy.d.ts +0 -8
- package/dist/types/sqlite-version.d.ts +0 -1
- package/docs/DYNAMIC-LINKS.md +0 -61
- package/docs/links/cli.md +0 -110
- package/docs/links/limits.md +0 -175
- package/docs/links/management-api.md +0 -80
- package/docs/links/pools.md +0 -75
- package/docs/links/setup.md +0 -135
- package/docs/yaml/links.md +0 -30
- package/examples/live-links/README.md +0 -11
- package/examples/live-links/example.yaml +0 -21
- package/examples/live-links/tests/requests.json +0 -6
- package/examples/live-links/urlcode.yaml +0 -16
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Language redirect from Accept-Language, like Next.js i18n middleware. Only
|
|
2
2
|
// languages listed in LOCALES are chosen; the native redirect is the default.
|
|
3
|
+
// `Response.redirect()`'s headers are immutable (a trusted route's real
|
|
4
|
+
// `Response` enforces the Fetch standard here, unlike the sandbox's guest
|
|
5
|
+
// API), so that branch builds its own `Response` with headers up front.
|
|
3
6
|
export default async function locale(request, context, next) {
|
|
4
7
|
const supported = (context.env.LOCALES || '').split(/\s+/).filter(Boolean);
|
|
5
8
|
const ranked = (request.headers.get('accept-language') || '').split(',').map((part, index) => {
|
|
@@ -8,9 +11,10 @@ export default async function locale(request, context, next) {
|
|
|
8
11
|
return {lang: tag.trim().toLowerCase().split('-')[0], q: q ? Number(q.slice(2)) || 0 : 1, index};
|
|
9
12
|
}).filter(p => p.q > 0).sort((a, b) => b.q - a.q || a.index - b.index);
|
|
10
13
|
const chosen = ranked.find(p => supported.includes(p.lang))?.lang;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
if (chosen && chosen !== supported[0]) {
|
|
15
|
+
return new Response(null, { status: 302, headers: { location: context.env.SITE + '/' + chosen + '/welcome', vary: 'accept-language' } });
|
|
16
|
+
}
|
|
17
|
+
const response = await next();
|
|
14
18
|
response.headers.set('vary', 'accept-language');
|
|
15
19
|
return response;
|
|
16
20
|
}
|
|
@@ -3,7 +3,7 @@ description: Fourteen reusable middleware patterns around fifteen routes, mirror
|
|
|
3
3
|
tags: [middleware, auth, bearer, basic, cors, tracing, request-id, errors, etag, caching, maintenance, negotiation, locale, referer, method-override, envelope, validation, debug, bucket, experiment]
|
|
4
4
|
complexity: advanced
|
|
5
5
|
capabilities: [bindings, download, enabled, function, methods, middleware, parameters, redirect, request.body, respond]
|
|
6
|
-
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused}
|
|
6
|
+
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused, static: refused}
|
|
7
7
|
routes: 15
|
|
8
8
|
inputs:
|
|
9
9
|
- name: credentials
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
`/downloads/report` serves `files/report.txt` as an attachment, but only after
|
|
4
4
|
the operator-installed `auth` extension authorizes the request (`auth: true`,
|
|
5
5
|
the short form of `policies.extensions.auth`). The file is
|
|
6
|
-
served natively: no
|
|
6
|
+
served natively: no project code runs at all, and the response is forced to `no-store`.
|
|
7
7
|
|
|
8
8
|
Like the `authenticated-json-api` recipe, this project declares the extension
|
|
9
9
|
and needs an operator host file outside the project plus the canonical origin
|
|
@@ -3,7 +3,7 @@ description: Native attachment download that only the operator-installed auth ex
|
|
|
3
3
|
tags: [download, attachment, protected, auth, authenticated, file, private, extension, content-disposition, "401"]
|
|
4
4
|
complexity: advanced
|
|
5
5
|
capabilities: [download, enabled, extension, methods, policies.extensions]
|
|
6
|
-
targets: {self-hosted:
|
|
6
|
+
targets: {self-hosted: conditional, aws: conditional, vercel: conditional, cloudflare: refused, static: refused}
|
|
7
7
|
routes: 1
|
|
8
8
|
services:
|
|
9
9
|
- name: auth extension
|
|
@@ -28,4 +28,4 @@ behavior:
|
|
|
28
28
|
- GET /downloads/report without credentials answers 401
|
|
29
29
|
- GET with the accepted credential answers 200, Content-Disposition attachment and Cache-Control no-store
|
|
30
30
|
- HEAD mirrors both cases with an empty body
|
|
31
|
-
- no
|
|
31
|
+
- no project code runs; the file is served natively and snapshotted at activation
|
|
@@ -3,7 +3,7 @@ description: Permanent redirect that forwards only an allowlisted query key.
|
|
|
3
3
|
tags: [redirect, permanent, "301", query, passthrough, documentation, native]
|
|
4
4
|
complexity: starter
|
|
5
5
|
capabilities: [enabled, methods, redirect]
|
|
6
|
-
targets: {self-hosted: compatible, aws: compatible, vercel: compatible, cloudflare: compatible}
|
|
6
|
+
targets: {self-hosted: compatible, aws: compatible, vercel: compatible, cloudflare: compatible, static: compatible}
|
|
7
7
|
routes: 1
|
|
8
8
|
inputs:
|
|
9
9
|
- name: destination
|
|
@@ -5,8 +5,8 @@ Run `urlcode validate --local --project .`, `urlcode test --project .` and
|
|
|
5
5
|
|
|
6
6
|
`/` serves `public/index.html`, `/assets/*` serves everything under
|
|
7
7
|
`public/assets` (with `index.html` for the directory itself), and `/api/info`
|
|
8
|
-
is a
|
|
9
|
-
assets are native: no
|
|
8
|
+
is a function returning JSON built from literal `args`. Pages and
|
|
9
|
+
assets are native: no project code runs for them at all, and they are snapshotted at
|
|
10
10
|
activation, so new files need a reload. Edit the HTML, add files under
|
|
11
11
|
`public/assets`, and change or extend `functions/info.mjs`.
|
|
12
12
|
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
<head><meta charset="utf-8"><title>Example site</title><link rel="stylesheet" href="/assets/site.css"></head>
|
|
4
4
|
<body>
|
|
5
5
|
<h1>Example site</h1>
|
|
6
|
-
<p>Static pages are served natively; <a href="/api/info">/api/info</a> is a
|
|
6
|
+
<p>Static pages are served natively; <a href="/api/info">/api/info</a> is a function route.</p>
|
|
7
7
|
</body>
|
|
8
8
|
</html>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
id: static-plus-api
|
|
2
|
-
description: A native static site (home page and an assets directory) next to one
|
|
2
|
+
description: A native static site (home page and an assets directory) next to one function-backed JSON endpoint.
|
|
3
3
|
tags: [static, site, page, html, assets, css, api, json, function, cache-control, website]
|
|
4
4
|
complexity: intermediate
|
|
5
5
|
capabilities: [enabled, function, methods, page, response.headers, static]
|
|
6
|
-
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused}
|
|
6
|
+
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused, static: refused}
|
|
7
7
|
routes: 3
|
|
8
8
|
inputs:
|
|
9
9
|
- name: pages
|
|
@@ -12,7 +12,7 @@ routes:
|
|
|
12
12
|
index: index.html
|
|
13
13
|
cacheControl: public, max-age=3600
|
|
14
14
|
/api/info:
|
|
15
|
-
description: A small JSON endpoint next to the static site, computed
|
|
15
|
+
description: A small JSON endpoint next to the static site, computed by a trusted function.
|
|
16
16
|
function:
|
|
17
17
|
source: functions/info.mjs
|
|
18
18
|
args:
|
|
@@ -4,4 +4,5 @@ Run `urlcode build-typescript --project . --out ../hello-built` with a new outpu
|
|
|
4
4
|
directory, then `urlcode validate --local --project ../hello-built` and
|
|
5
5
|
`urlcode serve --project ../hello-built`. GET `/hello` returns JSON.
|
|
6
6
|
TypeScript is transpiled ahead of time. The runtime executes only the emitted
|
|
7
|
-
JavaScript in QuickJS/WASM
|
|
7
|
+
JavaScript, in QuickJS/WASM for a `sandbox: true` route and in-process for a
|
|
8
|
+
trusted one. The build does not type-check or read tsconfig.json.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
id: typescript
|
|
2
|
-
description: Typed
|
|
3
|
-
tags: [typescript, function, build, typed, compile
|
|
2
|
+
description: "Typed function transpiled ahead of time; only the emitted JavaScript runs: in-process for a trusted route, in QuickJS/WASM where a route declares `sandbox: true`."
|
|
3
|
+
tags: [typescript, function, build, typed, compile]
|
|
4
4
|
complexity: intermediate
|
|
5
5
|
capabilities: [enabled, function, methods]
|
|
6
|
-
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused}
|
|
6
|
+
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused, static: refused}
|
|
7
7
|
routes: 1
|
|
8
8
|
inputs:
|
|
9
9
|
- name: handler
|
|
10
10
|
file: functions/hello.ts
|
|
11
|
-
description: The typed
|
|
11
|
+
description: "The typed handler; keep to Request/Response, and to the sandbox subset if you add `sandbox: true`."
|
|
12
12
|
files: [urlcode.yaml, functions/hello.ts, tests/requests.json, README.md]
|
|
13
13
|
tests:
|
|
14
14
|
fixtures: tests/requests.json
|
|
@@ -18,6 +18,6 @@ tests:
|
|
|
18
18
|
- urlcode test --project ../hello-built
|
|
19
19
|
- urlcode audit --project ../hello-built --expect-routes 1
|
|
20
20
|
behavior:
|
|
21
|
-
- after the build, GET /hello answers 200 JSON from the compiled
|
|
21
|
+
- after the build, GET /hello answers 200 JSON from the compiled module
|
|
22
22
|
- the unbuilt project does not activate; .ts sources never run
|
|
23
23
|
- POST answers 405
|
|
@@ -5,7 +5,11 @@ Run `urlcode validate --local --project .`, `urlcode test --project .` and
|
|
|
5
5
|
|
|
6
6
|
`POST /webhook` accepts at most 64 KiB of `application/json`. The runtime
|
|
7
7
|
rejects other methods (405), other content types (415), oversized bodies (413)
|
|
8
|
-
and malformed JSON (400) before the sandbox runs. The
|
|
8
|
+
and malformed JSON (400) before the sandbox runs. The route declares
|
|
9
|
+
`sandbox: true`: functions/middleware run trusted by default, but a
|
|
10
|
+
third-party sender's payload is exactly the input a project doesn't fully
|
|
11
|
+
trust, so this recipe isolates parsing it (docs/FUNCTION-SECURITY.md). The
|
|
12
|
+
function then requires an
|
|
9
13
|
`X-Webhook-Event` header and a JSON object carrying a string `id`, and answers
|
|
10
14
|
`202 {"received":true,...}`.
|
|
11
15
|
|
|
@@ -3,7 +3,7 @@ description: Accept a bounded JSON event that names its type in a header, check
|
|
|
3
3
|
tags: [webhook, receiver, event, post, json, body, "202", callback, ingest, function]
|
|
4
4
|
complexity: starter
|
|
5
5
|
capabilities: [enabled, function, methods, request.body, response.headers]
|
|
6
|
-
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused}
|
|
6
|
+
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused, static: refused}
|
|
7
7
|
routes: 1
|
|
8
8
|
inputs:
|
|
9
9
|
- name: shape check
|
|
@@ -24,3 +24,4 @@ behavior:
|
|
|
24
24
|
- a missing event header answers 400; a body that is not an object with a string id answers 422
|
|
25
25
|
- other methods 405, other content types 415, oversized bodies 413 and malformed JSON 400 before the sandbox runs
|
|
26
26
|
- the guest has no crypto or network API, so signatures are not verified here; put signed webhooks behind an operator extension or trusted host
|
|
27
|
+
- "the route declares sandbox: true, isolating the third-party payload from the trusted-by-default execution every other function/middleware route gets"
|
|
@@ -11,6 +11,13 @@ routes:
|
|
|
11
11
|
format: json
|
|
12
12
|
function:
|
|
13
13
|
source: functions/receive.mjs
|
|
14
|
+
# A third-party sender's payload is exactly the kind of input this
|
|
15
|
+
# project does not fully trust (docs/AI-AUTHORING.md, "Deciding when a
|
|
16
|
+
# route needs sandbox: true"), even after body/content-type validation:
|
|
17
|
+
# isolate parsing it from the rest of the process.
|
|
18
|
+
sandbox: true
|
|
19
|
+
sandboxReason: >-
|
|
20
|
+
Third-party webhook payload; isolate parsing it even after body/content-type validation.
|
|
14
21
|
response:
|
|
15
22
|
headers:
|
|
16
23
|
Cache-Control: no-store
|
|
@@ -45,12 +45,13 @@
|
|
|
45
45
|
"description": "Derived: the capability preflight verdict per target. compatible means no issue; otherwise the strongest issue (refused, unknown, conditional).",
|
|
46
46
|
"type": "object",
|
|
47
47
|
"additionalProperties": false,
|
|
48
|
-
"required": ["self-hosted", "aws", "vercel", "cloudflare"],
|
|
48
|
+
"required": ["self-hosted", "aws", "vercel", "cloudflare", "static"],
|
|
49
49
|
"properties": {
|
|
50
50
|
"self-hosted": {"$ref": "#/$defs/verdict"},
|
|
51
51
|
"aws": {"$ref": "#/$defs/verdict"},
|
|
52
52
|
"vercel": {"$ref": "#/$defs/verdict"},
|
|
53
|
-
"cloudflare": {"$ref": "#/$defs/verdict"}
|
|
53
|
+
"cloudflare": {"$ref": "#/$defs/verdict"},
|
|
54
|
+
"static": {"$ref": "#/$defs/verdict"}
|
|
54
55
|
}
|
|
55
56
|
},
|
|
56
57
|
"routes": {
|
|
@@ -81,7 +82,7 @@
|
|
|
81
82
|
"additionalProperties": false,
|
|
82
83
|
"required": ["kind", "description"],
|
|
83
84
|
"properties": {
|
|
84
|
-
"kind": {"enum": ["extension", "signals", "proxy", "env", "secret", "
|
|
85
|
+
"kind": {"enum": ["extension", "signals", "proxy", "env", "secret", "origin"]},
|
|
85
86
|
"description": {"type": "string", "minLength": 1, "maxLength": 300}
|
|
86
87
|
}
|
|
87
88
|
}
|
|
@@ -22,11 +22,6 @@
|
|
|
22
22
|
"uniqueItems": true,
|
|
23
23
|
"maxItems": 256
|
|
24
24
|
},
|
|
25
|
-
"dynamicLinks": {
|
|
26
|
-
"type": "boolean",
|
|
27
|
-
"default": false,
|
|
28
|
-
"description": "Entry-point opt-in for live stored-link routes. Does not enable guest storage or management access."
|
|
29
|
-
},
|
|
30
25
|
"policies": {
|
|
31
26
|
"$ref": "#/$defs/policies"
|
|
32
27
|
},
|
|
@@ -346,6 +341,16 @@
|
|
|
346
341
|
"enabled": {
|
|
347
342
|
"type": "boolean"
|
|
348
343
|
},
|
|
344
|
+
"sandbox": {
|
|
345
|
+
"type": "boolean",
|
|
346
|
+
"default": false,
|
|
347
|
+
"description": "Run this route's function/middleware in the isolated QuickJS/WASM worker pool instead of the trusted, in-process default. false or absent (the default) means trusted, unsandboxed, direct host-process execution."
|
|
348
|
+
},
|
|
349
|
+
"sandboxReason": {
|
|
350
|
+
"type": "string",
|
|
351
|
+
"maxLength": 500,
|
|
352
|
+
"description": "Optional justification for this route's sandbox decision, whether sandbox is true or false: why it needs isolation, or why it is safe to trust. Never inferred or enforced; surfaced verbatim by explain/context/manifest."
|
|
353
|
+
},
|
|
349
354
|
"expires": {
|
|
350
355
|
"type": "string"
|
|
351
356
|
},
|
|
@@ -882,37 +887,6 @@
|
|
|
882
887
|
]
|
|
883
888
|
}
|
|
884
889
|
},
|
|
885
|
-
"link": {
|
|
886
|
-
"type": "object",
|
|
887
|
-
"properties": {
|
|
888
|
-
"collection": {
|
|
889
|
-
"type": "string",
|
|
890
|
-
"pattern": "^[A-Za-z][A-Za-z0-9_-]{0,63}$"
|
|
891
|
-
},
|
|
892
|
-
"code": {
|
|
893
|
-
"type": "object",
|
|
894
|
-
"properties": {
|
|
895
|
-
"from": {
|
|
896
|
-
"const": "path"
|
|
897
|
-
},
|
|
898
|
-
"name": {
|
|
899
|
-
"type": "string",
|
|
900
|
-
"pattern": "^[A-Za-z_][A-Za-z0-9_]*$"
|
|
901
|
-
}
|
|
902
|
-
},
|
|
903
|
-
"required": [
|
|
904
|
-
"from",
|
|
905
|
-
"name"
|
|
906
|
-
],
|
|
907
|
-
"additionalProperties": false
|
|
908
|
-
}
|
|
909
|
-
},
|
|
910
|
-
"required": [
|
|
911
|
-
"collection",
|
|
912
|
-
"code"
|
|
913
|
-
],
|
|
914
|
-
"additionalProperties": false
|
|
915
|
-
},
|
|
916
890
|
"policies": {
|
|
917
891
|
"$ref": "#/$defs/policies"
|
|
918
892
|
},
|
|
@@ -964,6 +938,10 @@
|
|
|
964
938
|
"$ref": "#/$defs/routeAuth"
|
|
965
939
|
}
|
|
966
940
|
]
|
|
941
|
+
},
|
|
942
|
+
"cache": {
|
|
943
|
+
"description": "Short form for policies.cache: the same object, expanded to policies.cache before anything else reads the project. Refused alongside policies.cache; use one form.",
|
|
944
|
+
"$ref": "#/$defs/policyCache"
|
|
967
945
|
}
|
|
968
946
|
},
|
|
969
947
|
"required": [],
|
|
@@ -999,11 +977,6 @@
|
|
|
999
977
|
"respond"
|
|
1000
978
|
]
|
|
1001
979
|
},
|
|
1002
|
-
{
|
|
1003
|
-
"required": [
|
|
1004
|
-
"link"
|
|
1005
|
-
]
|
|
1006
|
-
},
|
|
1007
980
|
{
|
|
1008
981
|
"required": [
|
|
1009
982
|
"conditional"
|
package/skills/urlcode/SKILL.md
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: urlcode
|
|
3
|
-
description: Work on a URLCode project, recognized by a urlcode.yaml file with version "1" and served by the @jimhoyd/urlcode runtime. Use this skill whenever a task touches urlcode.yaml, its included route files, functions or middleware under a URLCode project, or asks to add, change, test or deploy URL behavior (redirects, responses, pages, files, functions,
|
|
3
|
+
description: Work on a URLCode project, recognized by a urlcode.yaml file with version "1" and served by the @jimhoyd/urlcode runtime. Use this skill whenever a task touches urlcode.yaml, its included route files, functions or middleware under a URLCode project, or asks to add, change, test or deploy URL behavior (redirects, responses, pages, files, functions, policies) in such a project. It teaches the authoring loop and how to retrieve the minimum reference from the installed runtime instead of guessing fields.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# URLCode authoring loop
|
|
7
7
|
|
|
8
8
|
A URLCode project declares URL behavior in YAML; the installed runtime serves
|
|
9
|
-
it. Your job is to change the declaration and the minimal
|
|
9
|
+
it. Your job is to change the declaration and the minimal application code it names,
|
|
10
10
|
then prove the change with the runtime's own checks. Never reimplement what the
|
|
11
11
|
runtime provides, and never invent fields.
|
|
12
12
|
|
|
13
|
+
## Declarative-first default
|
|
14
|
+
|
|
15
|
+
> Use URLCode's highest-level declarative features whenever possible. Generate custom code only when the framework cannot express the requirement.
|
|
16
|
+
|
|
17
|
+
Check the installed version's primitives, YAML configuration, policies, supported
|
|
18
|
+
extensions and recipes/templates before writing a custom function or middleware.
|
|
19
|
+
Keep necessary custom code focused and report the capability gap; never invent
|
|
20
|
+
fields or bypass target limits or operator grants. See `docs/PROJECT-DIRECTION.md` in the installed runtime.
|
|
21
|
+
|
|
13
22
|
## 1. Recognize the project
|
|
14
23
|
|
|
15
24
|
- The root has `urlcode.yaml` with `version: "1"`. Included route files are
|
|
@@ -52,18 +61,30 @@ installed package also ships `docs/YAML-REFERENCE.md` (generated field
|
|
|
52
61
|
inventory) and `schemas/urlcode.schema.json`; search them for the one key you
|
|
53
62
|
need rather than reading them whole.
|
|
54
63
|
|
|
55
|
-
## 3.
|
|
64
|
+
## 3. Choose the highest-level supported abstraction
|
|
56
65
|
|
|
57
66
|
1. If a native handler expresses the behavior (`redirect`, `respond`, `page`,
|
|
58
|
-
`static`, `download`, `
|
|
59
|
-
2.
|
|
67
|
+
`static`, `download`, `proxy`, `conditional`), write YAML only.
|
|
68
|
+
2. Check supported extensions and their configuration before custom code. If a
|
|
69
|
+
recipe from `recipes list` is close, `urlcode recipes add NAME --out DIR`
|
|
60
70
|
and adapt the copy into the project's layout.
|
|
61
|
-
3. Only then write a function or middleware: one exported handler,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
71
|
+
3. Only then write a function or middleware: one exported handler, inputs from validated `args`,
|
|
72
|
+
output as a `Response`. `function`/`middleware` routes run trusted and
|
|
73
|
+
unsandboxed by default: full Node, npm, filesystem and `fetch` access, like
|
|
74
|
+
any other project code. Add `sandbox: true` only when that route's own code
|
|
75
|
+
warrants isolation (unreviewed or third-party code, a secret whose blast
|
|
76
|
+
radius matters, complex logic — never merely because it handles request
|
|
77
|
+
data, which is untrusted in both modes) — a `sandbox: true` route then has no
|
|
78
|
+
`fetch`, Node, npm, filesystem, WebSocket, streaming or crypto APIs (bounded timers are supported);
|
|
79
|
+
a need for those in a sandboxed route is a `proxy` route, a binding, or a
|
|
80
|
+
report.
|
|
81
|
+
4. Declare routing, validation, middleware chains, policies, static serving,
|
|
82
|
+
caching, throttling and authentication wherever the runtime or a supported
|
|
83
|
+
extension provides them. Use custom code only for the unmet requirement.
|
|
84
|
+
Where a short form exists, it is the highest-level form: `auth: true` or
|
|
85
|
+
`auth: { role: admin }` on a route whose project declares an `auth`
|
|
86
|
+
extension, and `cache: { … }` for `policies.cache`. Each expands to the long
|
|
87
|
+
form; declaring both is refused.
|
|
67
88
|
|
|
68
89
|
Keep every route you were not asked to change. Match the file organization the
|
|
69
90
|
project already uses.
|
|
@@ -16,11 +16,11 @@ authentication; the runtime provides them. Read this file before changing anythi
|
|
|
16
16
|
3. Run `urlcode recipes list` and `urlcode recipes show NAME` before writing a
|
|
17
17
|
route from scratch. If a recipe covers the need, add it with
|
|
18
18
|
`urlcode recipes add NAME --out DIR` and adapt the copy.
|
|
19
|
-
4.
|
|
19
|
+
4. Use URLCode's highest-level declarative features whenever possible. Generate custom code only when the framework cannot express the requirement. Check supported extensions and recipes first; explain any capability gap.
|
|
20
20
|
|
|
21
21
|
## Ask the runtime through MCP first
|
|
22
22
|
|
|
23
|
-
`.mcp.json` registers the read-only `urlcode mcp` server. When it is
|
|
23
|
+
When present, `.mcp.json` registers the read-only `urlcode mcp` server. When it is
|
|
24
24
|
available, prefer its tools over reading documents: `get_context`,
|
|
25
25
|
`get_capability`, `get_schema`, `search_recipes`, `explain`, `get_manifest`.
|
|
26
26
|
The CLI equivalents are the fallback: `urlcode context`, `urlcode capabilities NAME`,
|
|
@@ -29,24 +29,25 @@ The CLI equivalents are the fallback: `urlcode context`, `urlcode capabilities N
|
|
|
29
29
|
|
|
30
30
|
## What the runtime provides (this version)
|
|
31
31
|
|
|
32
|
-
- Handlers, exactly one per route: `redirect`, `respond`, `page`, `static`, `download`, `function`, `
|
|
33
|
-
- Ordered `middleware` around any handler, declared in YAML,
|
|
32
|
+
- Handlers, exactly one per route: `redirect`, `respond`, `page`, `static`, `download`, `function`, `proxy`, `conditional`.
|
|
33
|
+
- Ordered `middleware` around any handler, declared in YAML, trusted by default.
|
|
34
34
|
- Validated inputs: `parameters`, `request.body` and `methods` on the route;
|
|
35
35
|
functions receive validated `args`, never raw user input.
|
|
36
36
|
- Policies, host-enforced and off by default: `agents`, `throttle`, `cache`, `security`, `compression`.
|
|
37
37
|
- Site conventions under `site`, each generating one native route: `robots` (/robots.txt), `sitemap` (/sitemap.xml), `favicon` (/favicon.ico), `securityTxt` (/.well-known/security.txt), `llms` (/llms.txt).
|
|
38
38
|
- Bindings: named `env` and `secrets` references resolved by the operator, never values in YAML.
|
|
39
39
|
|
|
40
|
-
Never recreate any of these in a function
|
|
41
|
-
|
|
40
|
+
Never recreate any of these in a function; a missing one is a report, not an
|
|
41
|
+
invitation to reimplement it.
|
|
42
42
|
|
|
43
|
-
## Functions and middleware are
|
|
43
|
+
## Functions and middleware are trusted by default; sandbox is opt-in
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
`
|
|
48
|
-
|
|
49
|
-
`
|
|
45
|
+
A route's `function`/`middleware` runs trusted, in-process, with full
|
|
46
|
+
Node/filesystem/`fetch` access, given only declared `args`/`env`/`secrets`. Add
|
|
47
|
+
`sandbox: true` when that code warrants isolation (unreviewed code, a sensitive
|
|
48
|
+
secret, complex logic) — not merely for untrusted input, which both modes share.
|
|
49
|
+
A `sandbox: true` route gets a text/JSON subset only: use `proxy`/a binding, and
|
|
50
|
+
say why in `sandboxReason`.
|
|
50
51
|
|
|
51
52
|
## Checks that count as evidence
|
|
52
53
|
|
|
@@ -56,10 +57,9 @@ urlcode test
|
|
|
56
57
|
urlcode audit --expect-routes 2
|
|
57
58
|
```
|
|
58
59
|
|
|
59
|
-
Run all three after every change
|
|
60
|
-
|
|
61
|
-
every
|
|
62
|
-
Without a global install, invoke `node /path/to/urlcode/src/cli.ts` instead of `urlcode`.
|
|
60
|
+
Run all three after every change, updating the route count deliberately and
|
|
61
|
+
adding `tests/requests.json` fixtures for every new route (positive/negative,
|
|
62
|
+
every active method, HEAD). No global install: use `node /path/to/urlcode/src/cli.ts`.
|
|
63
63
|
|
|
64
64
|
## Rules
|
|
65
65
|
|
|
@@ -69,8 +69,8 @@ Without a global install, invoke `node /path/to/urlcode/src/cli.ts` instead of `
|
|
|
69
69
|
stop; the operator grants it outside this project, pinned to the revision.
|
|
70
70
|
- Secrets stay out of the project: no keys, tokens or credentials in YAML,
|
|
71
71
|
functions, fixtures, `.env` files that are not ignored, or commit messages.
|
|
72
|
-
-
|
|
73
|
-
|
|
72
|
+
- Protect a route with `auth: true`/`auth: { role: admin }` where an `auth`
|
|
73
|
+
extension is declared; `cache` likewise expands to `policies.cache`.
|
|
74
74
|
- Validation, tests and the audit are the evidence. Local checks are not a
|
|
75
75
|
deployment, a soak test or a security review; do not claim otherwise.
|
|
76
76
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/jimhoyd-com/urlcode/669632fb4f188f293a0f622847f87ee019c0627f/schemas/urlcode.schema.json
|
|
2
2
|
# Organize files however you like; list their paths from this project root.
|
|
3
3
|
version: "1"
|
|
4
|
-
dynamicLinks: false
|
|
5
4
|
includes:
|
|
6
5
|
- routes/functions.yaml
|
|
7
6
|
- routes/marketing/links.yaml
|
package/dist/link-api.js
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import http from 'node:http';
|
|
2
|
-
import {createHash,timingSafeEqual,randomUUID} from 'node:crypto';
|
|
3
|
-
import {readFile,lstat} from 'node:fs/promises';
|
|
4
|
-
|
|
5
|
-
import {outsideProject} from './link-store.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import {linkCollection,linkCode} from './link-records.js';
|
|
9
|
-
import {assert,HttpError} from './errors.js';
|
|
10
|
-
import {createJsonLogger} from './logging.js';
|
|
11
|
-
|
|
12
|
-
/** The store surface management needs: the pooled link store, or anything with the same contract. */
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export async function loadLinkToken(file ,project ) {
|
|
27
|
-
const path=await outsideProject(file,project);const info=await lstat(path);
|
|
28
|
-
assert(info.isFile()&&!info.isSymbolicLink()&&info.nlink===1&&info.size<=1024,'Invalid management token file');
|
|
29
|
-
assert(process.platform==='win32'||(info.mode&0o077)===0,'Management token file must be private (mode 600)');
|
|
30
|
-
const token=(await readFile(path,'utf8')).trim();
|
|
31
|
-
assert(/^[A-Za-z0-9_-]{43,256}$/.test(token),'Management token must contain at least 43 base64url characters');return token;
|
|
32
|
-
}
|
|
33
|
-
function body(req ) {
|
|
34
|
-
return new Promise((resolve,reject)=>{
|
|
35
|
-
let bytes=0;const chunks =[];
|
|
36
|
-
const cleanup=()=>{req.off('data',data);req.off('end',end);req.off('error',failed);req.off('aborted',failed);};
|
|
37
|
-
const failed=()=>{cleanup();reject(new HttpError(400,'Incomplete management body'));};
|
|
38
|
-
const data=(chunk )=>{
|
|
39
|
-
bytes+=chunk.length;
|
|
40
|
-
if(bytes>16384){cleanup();req.resume();reject(new HttpError(413,'Management body too large'));return;}
|
|
41
|
-
chunks.push(chunk);
|
|
42
|
-
};
|
|
43
|
-
const end=()=>{
|
|
44
|
-
cleanup();
|
|
45
|
-
if(!bytes){reject(new HttpError(400,'JSON body required'));return;}
|
|
46
|
-
try{resolve(JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(Buffer.concat(chunks))));}
|
|
47
|
-
catch{reject(new HttpError(400,'Invalid JSON body'));}
|
|
48
|
-
};
|
|
49
|
-
req.on('data',data);req.once('end',end);req.once('error',failed);req.once('aborted',failed);
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
const isRecord=(value ) =>value!==null && typeof value==='object' && !Array.isArray(value);
|
|
53
|
-
const mutations ={POST:'create',PUT:'update',DELETE:'delete'};
|
|
54
|
-
export async function startLinkApi({store,collection,token,authorize,host='127.0.0.1',port=3001,maxInFlightRequests=32,socketTimeoutMs=10000,log=createJsonLogger()} ) {
|
|
55
|
-
linkCollection(collection);assert(['127.0.0.1','::1'].includes(host),'Management must bind a literal loopback address; use a private authenticated tunnel');
|
|
56
|
-
assert(!authorize || store.atomicAudit===true,'Scoped management requires a store with atomic audit support');
|
|
57
|
-
assert(authorize===undefined || typeof authorize==='function','Invalid management authorizer');
|
|
58
|
-
assert(authorize || typeof token==='string'&&/^[A-Za-z0-9_-]{43,256}$/.test(token),'Invalid management token');
|
|
59
|
-
assert(Number.isInteger(maxInFlightRequests)&&maxInFlightRequests>=1&&maxInFlightRequests<=64,'Management admission must be 1–64');
|
|
60
|
-
assert(Number.isInteger(socketTimeoutMs)&&socketTimeoutMs>=100&&socketTimeoutMs<=60000,'Management socket timeout must be 100–60000 ms');
|
|
61
|
-
let inFlight=0,shuttingDown=false,closing ;
|
|
62
|
-
const digest=(value )=>createHash('sha256').update(value).digest();const expected=digest('Bearer '+token);
|
|
63
|
-
const server=http.createServer({maxHeaderSize:8192,headersTimeout:5000,requestTimeout:10000,keepAliveTimeout:5000},async(req,res)=>{
|
|
64
|
-
req.on('error',()=>{});res.on('error',()=>{});
|
|
65
|
-
const requestId=randomUUID(),started=performance.now();let principal ;let authenticated=false,action='request',admitted=false,reported=false;
|
|
66
|
-
const target=req.url??'',method=req.method??'';
|
|
67
|
-
const report=()=>{
|
|
68
|
-
if(reported)return;reported=true;if(admitted)inFlight--;
|
|
69
|
-
try{log({event:'management_request',timestamp:new Date().toISOString(),requestId,collection,action,authenticated,principal:principal?.id,status:res.headersSent?res.statusCode:0,outcome:res.writableFinished?'finished':'aborted',durationMs:Math.round((performance.now()-started)*100)/100});}catch{/* Logging must not fail requests. */}
|
|
70
|
-
};
|
|
71
|
-
res.once('finish',report);res.once('close',report);
|
|
72
|
-
const send=(status ,value ,headers ={})=>{const payload=value===undefined?undefined:JSON.stringify(value);res.writeHead(status,{'content-type':'application/json','cache-control':'no-store','x-content-type-options':'nosniff','x-request-id':requestId,...headers});res.end(payload);};
|
|
73
|
-
try {
|
|
74
|
-
if(shuttingDown || inFlight>=maxInFlightRequests)throw new HttpError(503,'Management capacity unavailable');
|
|
75
|
-
inFlight++;admitted=true;
|
|
76
|
-
let authCount=0;for(let i=0;i<req.rawHeaders.length;i+=2)if(req.rawHeaders[i]?.toLowerCase()==='authorization')authCount++;
|
|
77
|
-
if(authCount===1){
|
|
78
|
-
if(authorize){
|
|
79
|
-
const bearer=/^Bearer ([A-Za-z0-9_-]{43,256})$/.exec(req.headers.authorization||'');
|
|
80
|
-
if(bearer?.[1]!==undefined)principal=await authorize(bearer[1]);
|
|
81
|
-
}else if(timingSafeEqual(expected,digest(req.headers.authorization||'')))principal={id:'legacy-shared',collections:[collection],actions:['get','list','create','update','delete']};
|
|
82
|
-
}
|
|
83
|
-
if(!principal) {req.resume();send(401,{error:'Unauthorized'},{'www-authenticate':'Bearer',connection:'close'});return;}
|
|
84
|
-
authenticated=true;
|
|
85
|
-
if(req.headers.origin!==undefined)throw new HttpError(403,'Browser-origin management requests are unsupported');
|
|
86
|
-
if(!target.startsWith('/')||target.startsWith('//')||target.length>2048)throw new HttpError(400,'Invalid management target');
|
|
87
|
-
const url=new URL(target,'http://localhost');
|
|
88
|
-
if(target.split('?')[0]!==url.pathname)throw new HttpError(400,'Noncanonical management path');
|
|
89
|
-
const match=/^\/v1\/links(?:\/([A-Za-z0-9_-]{1,128}))?$/.exec(url.pathname);
|
|
90
|
-
if(!match)throw new HttpError(404,'Not found');
|
|
91
|
-
const code=match[1];if(code)linkCode(code);
|
|
92
|
-
if([...url.searchParams.keys()].some(k=>!['limit','after'].includes(k)) || (code && url.search))throw new HttpError(400,'Unsupported query');
|
|
93
|
-
const allowed=code?['GET','PUT','DELETE']:['GET','POST'];
|
|
94
|
-
if(!allowed.includes(method)){req.resume();send(405,{error:'Method not allowed'},{allow:allowed.join(', '),connection:'close'});return;}
|
|
95
|
-
action=method==='GET'?(code?'get':'list'):mutations[method]??'request';
|
|
96
|
-
if(!principal.collections.includes(collection) || !principal.actions.includes(action))throw new HttpError(403,'Management permission denied');
|
|
97
|
-
const audit ={actor:principal.id,requestId};
|
|
98
|
-
if(method==='GET'){
|
|
99
|
-
req.resume();
|
|
100
|
-
if(code){const value=await store.get(collection,code);if(!value)throw new HttpError(404,'Link not found');send(200,value,{etag:`"${value.version}"`});}
|
|
101
|
-
else {
|
|
102
|
-
if([...url.searchParams.keys()].some(k=>url.searchParams.getAll(k).length!==1))throw new HttpError(400,'Duplicate query');
|
|
103
|
-
const raw=url.searchParams.get('limit')??'100';if(!/^\d{1,3}$/.test(raw))throw new HttpError(400,'Invalid limit');
|
|
104
|
-
const items=await store.list(collection,{limit:Number(raw),after:url.searchParams.get('after')||''});
|
|
105
|
-
send(200,{items,nextAfter:items.length===Number(raw)?items.at(-1)?.code??null:null});
|
|
106
|
-
}
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
if(url.search)throw new HttpError(400,'Query unsupported for mutations');
|
|
110
|
-
let expectedVersion ;
|
|
111
|
-
if(method!=='POST'){
|
|
112
|
-
const etag=req.headers['if-match'];if(!etag)throw new HttpError(428,'If-Match is required');
|
|
113
|
-
if(!/^"[1-9]\d{0,15}"$/.test(etag))throw new HttpError(400,'Invalid If-Match');expectedVersion=Number(etag.slice(1,-1));
|
|
114
|
-
}
|
|
115
|
-
if(method==='DELETE'){req.resume();await store.delete(collection,code??'',expectedVersion,audit);send(204);return;}
|
|
116
|
-
if(req.headers['content-encoding'] && req.headers['content-encoding']!=='identity')throw new HttpError(415,'Encoding unsupported');
|
|
117
|
-
if(req.headers['content-type']?.split(';')[0]?.trim().toLowerCase()!=='application/json')throw new HttpError(415,'Expected application/json');
|
|
118
|
-
const data=await body(req);
|
|
119
|
-
if(!isRecord(data))throw new HttpError(400,'Invalid record');
|
|
120
|
-
let value ;
|
|
121
|
-
if(method==='POST'){const {code:assigned,...record}=data;value=await store.create(collection,record,assigned,audit);}
|
|
122
|
-
else value=await store.update(collection,code??'',data,expectedVersion,audit);
|
|
123
|
-
send(method==='POST'?201:200,value,{etag:`"${value.version}"`});
|
|
124
|
-
}catch(e){req.resume();if(res.headersSent){res.destroy();return;}if(!res.destroyed){const status=e instanceof HttpError?e.status:503;send(status,{error:e instanceof HttpError?e.message:'Management service unavailable'},{connection:'close'});}}
|
|
125
|
-
});
|
|
126
|
-
server.setTimeout(socketTimeoutMs,socket=>socket.destroy());
|
|
127
|
-
server.maxConnections=64;server.maxRequestsPerSocket=100;
|
|
128
|
-
server.on('clientError',(_error,socket)=>{if(socket.writable)socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');});
|
|
129
|
-
await new Promise ((resolve,reject)=>{server.once('error',reject);server.listen(port,host,()=>{server.off('error',reject);resolve();});});
|
|
130
|
-
const address=server.address();
|
|
131
|
-
assert(address!==null && typeof address==='object','Management server has no address');
|
|
132
|
-
return {address,close(){
|
|
133
|
-
if(closing)return closing;shuttingDown=true;
|
|
134
|
-
closing=(async()=>{const timeout=setTimeout(()=>server.closeAllConnections(),10000);timeout.unref();try{await new Promise (resolve=>server.close(()=>resolve()));}finally{clearTimeout(timeout);}})();return closing;
|
|
135
|
-
}};
|
|
136
|
-
}
|