@jimhoyd/urlcode 0.4.1 → 0.4.6
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 +60 -7
- package/.claude/skills/urlcode-operations/SKILL.md +4 -0
- package/README.md +19 -15
- package/SECURITY.md +5 -3
- package/dist/BUILD-MANIFEST.json +31 -28
- package/dist/agent-context.js +82 -0
- package/dist/agents-guide.js +42 -42
- package/dist/authoring.js +12 -2
- package/dist/body-schema.js +159 -0
- package/dist/build-cloudflare.js +2 -0
- package/dist/capabilities.js +1 -1
- package/dist/cli.js +29 -12
- package/dist/config.js +74 -6
- package/dist/context.js +4 -6
- package/dist/errors.js +3 -1
- package/dist/examples.js +1 -1
- package/dist/extensions.js +138 -3
- package/dist/http-policy.js +19 -4
- package/dist/http-response.js +2 -2
- package/dist/init-with.js +71 -9
- package/dist/mcp.js +15 -2
- package/dist/pattern-guard.js +32 -0
- package/dist/policies/security.js +0 -0
- package/dist/policy.js +16 -0
- package/dist/project-tests.js +35 -11
- package/dist/readiness.js +188 -32
- package/dist/recipes.js +1 -1
- package/dist/router.js +17 -0
- package/dist/runtime.js +26 -3
- package/dist/scaffold.js +0 -0
- package/dist/server.js +26 -3
- package/dist/site.js +0 -0
- package/dist/tooling.js +3 -1
- package/dist/types/agent-context.d.ts +44 -0
- package/dist/types/authoring.d.ts +3 -1
- package/dist/types/body-schema.d.ts +58 -0
- package/dist/types/config.d.ts +10 -2
- package/dist/types/context.d.ts +1 -1
- package/dist/types/errors.d.ts +9 -1
- package/dist/types/examples.d.ts +1 -1
- package/dist/types/extensions.d.ts +105 -2
- package/dist/types/http-policy.d.ts +3 -0
- package/dist/types/init-with.d.ts +10 -1
- package/dist/types/pattern-guard.d.ts +10 -0
- package/dist/types/project-tests.d.ts +9 -0
- package/dist/types/readiness.d.ts +71 -0
- package/dist/types/recipes.d.ts +1 -1
- package/dist/types/runtime.d.ts +4 -0
- package/dist/types/server.d.ts +9 -1
- package/dist/types/tooling.d.ts +2 -0
- package/dist/types/types.d.ts +13 -0
- package/dist/types.js +10 -2
- package/dist/typescript-authoring.js +5 -3
- package/dist/verify-deployment.js +16 -10
- package/examples/body-validation/README.md +16 -0
- package/examples/body-validation/example.yaml +17 -0
- package/examples/body-validation/tests/requests.json +15 -0
- package/examples/body-validation/urlcode.yaml +34 -0
- package/examples/coverage-waiver/README.md +8 -0
- package/examples/coverage-waiver/example.yaml +16 -0
- package/examples/coverage-waiver/functions/notes.mjs +2 -0
- package/examples/coverage-waiver/tests/requests.json +3 -0
- package/examples/coverage-waiver/urlcode.yaml +10 -0
- package/examples/data-dir/README.md +39 -0
- package/examples/data-dir/data/welcome.txt +1 -0
- package/examples/data-dir/example.yaml +22 -0
- package/examples/data-dir/functions/note.mjs +18 -0
- package/examples/data-dir/tests/requests.json +6 -0
- package/examples/data-dir/urlcode.yaml +6 -0
- package/examples/lifecycle/README.md +15 -0
- package/examples/lifecycle/example.yaml +19 -0
- package/examples/lifecycle/functions/notes.mjs +29 -0
- package/examples/lifecycle/tests/requests.json +12 -0
- package/examples/lifecycle/urlcode.yaml +29 -0
- package/examples/not-found/README.md +10 -0
- package/examples/not-found/example.yaml +17 -0
- package/examples/not-found/public/404.html +3 -0
- package/examples/not-found/public/index.html +3 -0
- package/examples/not-found/tests/requests.json +7 -0
- package/examples/not-found/urlcode.yaml +7 -0
- package/examples/shared-blocks/README.md +11 -0
- package/examples/shared-blocks/example.yaml +17 -0
- package/examples/shared-blocks/tests/requests.json +7 -0
- package/examples/shared-blocks/urlcode.yaml +37 -0
- package/llms-full.txt +436 -149
- package/llms.txt +44 -6
- package/package.json +15 -22
- package/recipes/static-page/README.md +9 -0
- package/recipes/static-page/public/index.html +11 -0
- package/recipes/static-page/recipe.yaml +21 -0
- package/recipes/static-page/tests/requests.json +22 -0
- package/recipes/static-page/urlcode.yaml +7 -0
- package/recipes/static-plus-api/README.md +6 -0
- package/recipes/static-plus-api/urlcode.yaml +4 -0
- package/recipes/store-crud/README.md +53 -0
- package/recipes/store-crud/recipe.yaml +31 -0
- package/recipes/store-crud/tests/requests.json +18 -0
- package/recipes/store-crud/urlcode.yaml +18 -0
- package/schemas/urlcode.schema.json +125 -60
- package/skills/urlcode/SKILL.md +53 -26
- package/starters/default/AGENTS.md +43 -43
- package/starters/page/README.md +14 -0
- package/starters/page/public/index.html +12 -0
- package/starters/page/tests/requests.json +17 -0
- package/starters/page/urlcode.yaml +6 -0
- package/.claude-plugin/marketplace.json +0 -18
- package/CONTRIBUTING.md +0 -112
- package/ROADMAP.md +0 -66
- package/docs/AI-AUTHORING.md +0 -338
- package/docs/ASSETS.md +0 -107
- package/docs/AUTH-BACKUP.md +0 -32
- package/docs/AWS.md +0 -86
- package/docs/BEST-PRACTICES.md +0 -276
- package/docs/BULK.md +0 -79
- package/docs/CAPABILITIES.md +0 -192
- package/docs/CAPACITY.md +0 -305
- package/docs/CI-FOLLOWUP-2026-09-19.md +0 -97
- package/docs/CI-RELEASE-AUDIT-2026-09-19.md +0 -322
- package/docs/CI.md +0 -147
- package/docs/CLOUDFLARE.md +0 -109
- package/docs/CODEBASE-AUDIT-2026-09-20.md +0 -284
- package/docs/COMPLIANCE.md +0 -239
- package/docs/COMPOSING-A-SITE.md +0 -278
- package/docs/CONDITIONS.md +0 -74
- package/docs/DEPLOYMENT-CHECKS.md +0 -108
- package/docs/DEVELOPMENT-PIPELINE.md +0 -270
- package/docs/EGRESS.md +0 -125
- package/docs/EXTENSIONS.md +0 -438
- package/docs/FRAMEWORK.md +0 -217
- package/docs/FUNCTION-SECURITY.md +0 -254
- package/docs/HTTP.md +0 -129
- package/docs/INSTALL.md +0 -128
- package/docs/INTERCHANGE.md +0 -134
- package/docs/LOAD-TESTING.md +0 -91
- package/docs/LOCAL-DEVELOPMENT.md +0 -102
- package/docs/MIDDLEWARE-EXAMPLES.md +0 -75
- package/docs/MIDDLEWARE.md +0 -102
- package/docs/MONITORING.md +0 -115
- package/docs/OBSERVABILITY.md +0 -222
- package/docs/OPEN-DECISIONS.md +0 -224
- package/docs/OPERATIONAL-PROOF.md +0 -41
- package/docs/OPERATIONS.md +0 -201
- package/docs/ORGANIZATION.md +0 -135
- package/docs/PERFORMANCE.md +0 -72
- package/docs/PLUGINS.md +0 -271
- package/docs/POLICIES.md +0 -211
- package/docs/PRERENDER.md +0 -245
- package/docs/PROJECT-DIRECTION.md +0 -118
- package/docs/PROVIDER-VERIFICATION.md +0 -84
- package/docs/READINESS.md +0 -150
- package/docs/README.md +0 -87
- package/docs/RECIPES.md +0 -99
- package/docs/RELEASE-0.4.0-alpha.3.md +0 -50
- package/docs/RELEASE-0.4.1.md +0 -73
- package/docs/RELEASE-READINESS.md +0 -117
- package/docs/RELEASE-SECURITY.md +0 -96
- package/docs/RESILIENCE.md +0 -161
- package/docs/ROUTING.md +0 -92
- package/docs/SANDBOX-REVIEW.md +0 -72
- package/docs/SCAFFOLDING.md +0 -70
- package/docs/SECURITY-AUDIT.md +0 -164
- package/docs/SITE.md +0 -150
- package/docs/SPECIFICATION.md +0 -359
- package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +0 -288
- package/docs/SPIKE-BUSINESS-SUITE.md +0 -1029
- package/docs/SPIKE-CORE-LAYERING.md +0 -368
- package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +0 -211
- package/docs/STANDARDS.md +0 -311
- package/docs/STARTERS.md +0 -83
- package/docs/STATIC.md +0 -105
- package/docs/TOOLING.md +0 -298
- package/docs/TUNNELS.md +0 -72
- package/docs/TYPESCRIPT-AUTHORING.md +0 -87
- package/docs/TYPESCRIPT.md +0 -123
- package/docs/VERCEL.md +0 -114
- package/docs/VERSION-ALIGNMENT.md +0 -80
- package/docs/YAML-GUIDE.md +0 -57
- package/docs/YAML-REFERENCE.md +0 -449
- package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +0 -68
- package/docs/archive/2026-09-19/MANAGEMENT-SECURITY.md +0 -102
- package/docs/archive/2026-09-19/NEXT-PHASE-PLAN.md +0 -108
- package/docs/archive/2026-09-19/NEXT-STEPS.md +0 -646
- package/docs/archive/2026-09-19/OPEN-DECISIONS.md +0 -277
- package/docs/archive/2026-09-19/RELEASE-SECURITY.md +0 -186
- package/docs/archive/2026-09-19/ROADMAP.md +0 -387
- package/docs/archive/2026-09-19/SPIKE-EXTENSION-MODEL.md +0 -430
- package/docs/archive/2026-09-19/SPIKE-EXTENSIONS.md +0 -492
- package/docs/archive/2026-09-19/SPIKE-LAMBDA-COMPILE.md +0 -365
- package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +0 -778
- package/docs/archive/2026-09-19/USABILITY-REVIEW.md +0 -139
- package/docs/archive/README.md +0 -28
- package/docs/policies/agents.md +0 -182
- package/docs/policies/cache.md +0 -152
- package/docs/policies/compression.md +0 -169
- package/docs/policies/contract.md +0 -52
- package/docs/policies/hardened.md +0 -56
- package/docs/policies/interoperability.md +0 -169
- package/docs/policies/operations.md +0 -45
- package/docs/policies/security.md +0 -161
- package/docs/policies/throttle.md +0 -103
- package/docs/yaml/assets.md +0 -36
- package/docs/yaml/conditions.md +0 -20
- package/docs/yaml/functions.md +0 -168
- package/docs/yaml/middleware.md +0 -31
- package/docs/yaml/organization.md +0 -74
- package/docs/yaml/policies.md +0 -37
- package/docs/yaml/redirects.md +0 -64
- package/docs/yaml/responses.md +0 -57
- package/docs/yaml/site.md +0 -24
- package/packaging/claude-plugin/.claude-plugin/plugin.json +0 -19
- package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +0 -120
- package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +0 -108
|
@@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
5
5
|
import { assert } from './errors.js';
|
|
6
6
|
import { createRuntime } from './runtime.js';
|
|
7
7
|
|
|
8
|
-
import { benchmarkTarget, hit,
|
|
8
|
+
import { benchmarkTarget, hit, readFixtures, runFixtures, isStepsFixture, probeAgent } from './readiness.js';
|
|
9
9
|
|
|
10
10
|
import { runCompliance, severities } from './compliance.js';
|
|
11
11
|
|
|
@@ -45,7 +45,7 @@ const CONCURRENCY = 4;
|
|
|
45
45
|
const MAX_REQUESTS = 10000;
|
|
46
46
|
const BODY_LIMIT = 1048576;
|
|
47
47
|
const SNIPPET = 200;
|
|
48
|
-
const siteTypes = { 'site.robots': 'text/plain', 'site.sitemap': 'application/xml', 'site.securityTxt': 'text/plain', 'site.llms': 'text/plain' };
|
|
48
|
+
const siteTypes = { 'site.robots': 'text/plain', 'site.sitemap': 'application/xml', 'site.securityTxt': 'text/plain', 'site.llms': 'text/plain', 'site.notFound': 'text/html' };
|
|
49
49
|
const isRecord = (value ) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
50
50
|
const snippet = (body ) => body.length > SNIPPET ? `${body.length} bytes: ${body.subarray(0, SNIPPET).toString('utf8')}` : body.toString('utf8');
|
|
51
51
|
const tlsCode = /CERT|TLS|SSL|SELF_SIGNED/;
|
|
@@ -129,12 +129,12 @@ export async function verifyDeployment(project , { target, origin, expect
|
|
|
129
129
|
const routes = { local: runtime.count, observed: null, expected: expectRoutes ?? null };
|
|
130
130
|
let complianceReport = null;
|
|
131
131
|
try {
|
|
132
|
-
const plan = runtime.testPlan(), fixtures = await
|
|
132
|
+
const plan = runtime.testPlan(), fixtures = await readFixtures(runtime.root, true);
|
|
133
133
|
const loaded = await loadDocument(runtime.root);
|
|
134
134
|
await applySite(loaded, { origin: publicOrigin, log: () => {} });
|
|
135
|
-
const
|
|
135
|
+
const fixtureRequests = fixtures.reduce((sum, fixture) => sum + (isStepsFixture(fixture) ? fixture.steps.filter(step => !('restart' in step)).length : 1), 0);
|
|
136
136
|
const literal = plan.inventory.filter(route => route.state === 'active' && !route.path.includes('{'));
|
|
137
|
-
assert(4 + cases.length + literal.length * 4 <= MAX_REQUESTS, `Verification would send more than ${MAX_REQUESTS} requests`);
|
|
137
|
+
assert(4 + plan.cases.length + fixtureRequests + literal.length * 4 <= MAX_REQUESTS, `Verification would send more than ${MAX_REQUESTS} requests`);
|
|
138
138
|
check(expectRoutes === undefined || plan.inventory.length === expectRoutes, { check: 'probes', severity: 'high', message: 'configured route count differs from --expect-routes', expected: String(expectRoutes), observed: String(plan.inventory.length) });
|
|
139
139
|
|
|
140
140
|
// 1. Probes. A transport failure on the health probe ends the run: nothing
|
|
@@ -162,13 +162,19 @@ export async function verifyDeployment(project , { target, origin, expect
|
|
|
162
162
|
// 2. Fixtures and generated cases, exactly as `urlcode test --target`
|
|
163
163
|
// would send them: sequentially, through hit(), against the target.
|
|
164
164
|
const stub = { address: { address: '127.0.0.1', family: 'IPv4', port: 0 }, root: runtime.root, testPlan: () => plan };
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
// A fixture with a restart step cannot run here: a live deployment is not ours to close and
|
|
166
|
+
// start. It is skipped as a whole, never partly, and named in the report and the log.
|
|
167
|
+
const record = (n , source , test , original , result ) => {
|
|
168
168
|
let route ; try { route = plan.resolve(test.path); } catch { /* an invalid-path negative fixture */ }
|
|
169
|
-
|
|
169
|
+
// The label prints the fixture as written: a substituted path may hold a captured value.
|
|
170
|
+
const label = `${source} case ${n} ${original.method ?? 'GET'} ${original.path}`;
|
|
170
171
|
check(result.pass, { check: 'fixtures', severity: 'high', ...(route === undefined ? {} : { route }), message: result.error ? `${label}: ${result.error} error` : `${label}: response did not match the case`, expected: String(test.status), observed: String(result.status) });
|
|
171
|
-
}
|
|
172
|
+
};
|
|
173
|
+
for (const [i, test] of plan.cases.entries()) { requests++; record(i + 1, 'generated', test, test, await hit(stub, test, agent, destination)); }
|
|
174
|
+
await runFixtures(fixtures, {
|
|
175
|
+
app: stub, agent, target: destination,
|
|
176
|
+
skipped: (fixture, reason) => { notes.push(`fixture ${fixture} ${reason}; none of its requests were sent and it was not verified`); log({ event: 'skipped', check: 'fixtures', fixture, reason: 'restart' }); },
|
|
177
|
+
}, step => { if (step.result.error !== 'skipped' && step.result.error !== 'unresolved') requests++; record(step.case, 'fixture', step.test, step.original, step.result); }, plan.cases.length + 1);
|
|
172
178
|
|
|
173
179
|
// 3. Declared versus observed, per active literal route.
|
|
174
180
|
await each(literal, async route => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Body validation
|
|
2
|
+
|
|
3
|
+
Declares the shape of a JSON request body and two input formats in YAML, so a
|
|
4
|
+
route needs no hand-written validation code.
|
|
5
|
+
|
|
6
|
+
- `request.body.schema` accepts a small JSON Schema subset. A body that breaks it
|
|
7
|
+
answers **422**; malformed JSON is still 400 and a wrong media type 415.
|
|
8
|
+
- Parameter `format: uuid` and `pattern` reject bad path and query values with 400.
|
|
9
|
+
- `pattern` must set `maxLength` (at most 128) and is refused when it repeats a
|
|
10
|
+
group, uses lookaround or a backreference, or has more than three unbounded
|
|
11
|
+
quantifiers. See [HTTP configuration](../../docs/HTTP.md).
|
|
12
|
+
|
|
13
|
+
The 422 answer is plain text listing only paths the schema declares; it never
|
|
14
|
+
echoes what the client sent. A client that sends `Accept: application/json`
|
|
15
|
+
receives a structured body instead, with each issue's `pointer`, `keyword` and
|
|
16
|
+
expected constraint (never the value); `*/*` and browsers keep the text.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: body-validation
|
|
2
|
+
description: Declarative JSON body validation with request.body.schema, and parameter format uuid and a bounded pattern, with no validation code.
|
|
3
|
+
tags: [request, body, validation, schema, json, parameters, uuid, pattern, "422"]
|
|
4
|
+
complexity: intermediate
|
|
5
|
+
capabilities: [enabled, methods, parameters, request.body, respond]
|
|
6
|
+
targets: {self-hosted: compatible, aws: compatible, vercel: compatible, cloudflare: compatible, static: refused}
|
|
7
|
+
routes: 3
|
|
8
|
+
files: [urlcode.yaml, tests/requests.json, README.md]
|
|
9
|
+
tests:
|
|
10
|
+
fixtures: tests/requests.json
|
|
11
|
+
commands:
|
|
12
|
+
- urlcode validate --local --project examples/body-validation
|
|
13
|
+
- urlcode test --project examples/body-validation
|
|
14
|
+
- urlcode audit --project examples/body-validation --expect-routes 3
|
|
15
|
+
behavior:
|
|
16
|
+
- POST /todos with a valid JSON body answers 201; a body that breaks the schema answers 422 with the declared paths that failed; malformed JSON answers 400; a wrong media type answers 415
|
|
17
|
+
- a path id that is not a uuid, or a slug that does not match the declared pattern, answers 400
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"path":"/todos","method":"POST","headers":{"content-type":"application/json"},"body":"{\"title\":\"Buy milk\"}","status":201,"expectBody":"{\"ok\":true}"},
|
|
3
|
+
{"path":"/todos","method":"POST","headers":{"content-type":"application/json"},"body":"{\"title\":5}","status":422},
|
|
4
|
+
{"path":"/todos","method":"POST","headers":{"content-type":"application/json"},"body":"{\"title\":\"x\",\"extra\":1}","status":422},
|
|
5
|
+
{"path":"/todos","method":"POST","headers":{"content-type":"application/json","accept":"application/json"},"body":"{\"title\":5}","status":422,"expectHeaders":{"content-type":"application/json"},"expectBody":"{\"error\":\"body_validation_failed\",\"message\":\"Request body failed validation\",\"issues\":[{\"pointer\":\"/title\",\"keyword\":\"type\",\"message\":\"must be a string\",\"expected\":\"string\"}]}"},
|
|
6
|
+
{"path":"/todos","method":"POST","headers":{"content-type":"application/json","accept":"*/*"},"body":"{\"title\":5}","status":422,"expectHeaders":{"content-type":"text/plain; charset=utf-8"},"expectBody":"Request body failed validation\n/title must be a string\n"},
|
|
7
|
+
{"path":"/todos","method":"POST","headers":{"content-type":"application/json"},"body":"{nope","status":400},
|
|
8
|
+
{"path":"/todos","method":"POST","headers":{"content-type":"text/plain"},"body":"hi","status":415},
|
|
9
|
+
{"path":"/todos/123e4567-e89b-42d3-a456-426614174000","status":200,"expectBody":"{\"ok\":true}"},
|
|
10
|
+
{"path":"/todos/123e4567-e89b-42d3-a456-426614174000","method":"HEAD","status":200,"expectBody":""},
|
|
11
|
+
{"path":"/todos/not-a-uuid","status":400},
|
|
12
|
+
{"path":"/tags?slug=ok-1","status":200,"expectBody":"{\"ok\":true}"},
|
|
13
|
+
{"path":"/tags?slug=ok-1","method":"HEAD","status":200,"expectBody":""},
|
|
14
|
+
{"path":"/tags?slug=Bad_Slug","status":400}
|
|
15
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
version: "1"
|
|
2
|
+
routes:
|
|
3
|
+
/todos:
|
|
4
|
+
methods: [POST]
|
|
5
|
+
request:
|
|
6
|
+
body:
|
|
7
|
+
maxBytes: 4096
|
|
8
|
+
contentTypes: [application/json]
|
|
9
|
+
format: json
|
|
10
|
+
required: true
|
|
11
|
+
schema:
|
|
12
|
+
type: object
|
|
13
|
+
required: [title]
|
|
14
|
+
additionalProperties: false
|
|
15
|
+
properties:
|
|
16
|
+
title: {type: string, minLength: 1, maxLength: 200}
|
|
17
|
+
description: {type: string, maxLength: 2000}
|
|
18
|
+
completed: {type: boolean}
|
|
19
|
+
respond:
|
|
20
|
+
status: 201
|
|
21
|
+
json: {ok: true}
|
|
22
|
+
/todos/{id}:
|
|
23
|
+
parameters:
|
|
24
|
+
- {name: id, in: path, required: true, schema: {type: string, format: uuid}}
|
|
25
|
+
respond:
|
|
26
|
+
json: {ok: true}
|
|
27
|
+
/tags:
|
|
28
|
+
parameters:
|
|
29
|
+
- name: slug
|
|
30
|
+
in: query
|
|
31
|
+
required: true
|
|
32
|
+
schema: {type: string, pattern: "^[a-z0-9-]+$", maxLength: 32}
|
|
33
|
+
respond:
|
|
34
|
+
json: {ok: true}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Audit coverage waiver
|
|
2
|
+
|
|
3
|
+
`urlcode audit --project examples/coverage-waiver` reports `ready: true` although
|
|
4
|
+
`POST /notes` has no fixture, because the route declares `coveredElsewhere` for
|
|
5
|
+
that method with a reason. The pair is still listed under `waivedRouteMethods`.
|
|
6
|
+
A waiver is honored only when the route has another passing normal-response
|
|
7
|
+
fixture (here `GET`). See
|
|
8
|
+
[readiness](../../docs/READINESS.md#waive-a-method-covered-elsewhere).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
id: coverage-waiver
|
|
2
|
+
description: A stateful POST route waived from audit coverage with a required reason, kept visible under waivedRouteMethods.
|
|
3
|
+
tags: [audit, readiness, coverage, waiver, covered-elsewhere,function, post]
|
|
4
|
+
complexity: intermediate
|
|
5
|
+
capabilities: [enabled, function, methods]
|
|
6
|
+
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused, static: refused}
|
|
7
|
+
routes: 1
|
|
8
|
+
files: [urlcode.yaml, functions/notes.mjs, tests/requests.json, README.md]
|
|
9
|
+
tests:
|
|
10
|
+
fixtures: tests/requests.json
|
|
11
|
+
commands:
|
|
12
|
+
- node src/cli.ts validate --local --project examples/coverage-waiver
|
|
13
|
+
- node src/cli.ts test --project examples/coverage-waiver
|
|
14
|
+
- node src/cli.ts audit --project examples/coverage-waiver --expect-routes 1
|
|
15
|
+
behavior:
|
|
16
|
+
- POST /notes is waived from coverage with a reason; GET is asserted by a fixture; the waiver is listed by audit, not hidden
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
version: "1"
|
|
2
|
+
routes:
|
|
3
|
+
/notes:
|
|
4
|
+
methods: [GET, POST]
|
|
5
|
+
# POST creates a note in a real store, so a fixture would mutate it. The
|
|
6
|
+
# reason is required and is listed by `urlcode audit` under waivedRouteMethods.
|
|
7
|
+
coveredElsewhere:
|
|
8
|
+
POST: "Creates a note; covered by the store's integration tests, not by a repeatable fixture."
|
|
9
|
+
function:
|
|
10
|
+
source: functions/notes.mjs
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Data directory example
|
|
2
|
+
|
|
3
|
+
A trusted function that reads files from a directory the operator picks. The
|
|
4
|
+
project declares only the *name* `DATA_DIR`; the host supplies the value, so a
|
|
5
|
+
test run, a benchmark or a deployment can point the same reviewed project at a
|
|
6
|
+
different directory without copying it.
|
|
7
|
+
|
|
8
|
+
```yaml
|
|
9
|
+
routes:
|
|
10
|
+
/notes/{name}:
|
|
11
|
+
function: functions/note.mjs
|
|
12
|
+
env:
|
|
13
|
+
DATA_DIR: {env: DATA_DIR}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The function receives the resolved value as `env.DATA_DIR` in its second
|
|
17
|
+
argument. It does not read `process.env`, so the binding stays visible to
|
|
18
|
+
`permissions` and `audit`. Validate the request-supplied file name before
|
|
19
|
+
joining it to the directory; the function does that with an allowlist pattern.
|
|
20
|
+
|
|
21
|
+
There is no default: a binding the operator did not grant, or a `DATA_DIR` that
|
|
22
|
+
is not set, refuses to activate rather than falling back to a directory the
|
|
23
|
+
project chose. Use `{value: ...}` only for a fixed literal.
|
|
24
|
+
|
|
25
|
+
Run it from the runtime checkout. The policy lives outside the project, and the
|
|
26
|
+
`--policy` file must be regenerated whenever the config or function changes:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
urlcode permissions --project examples/data-dir > /path/outside/project/policy.json
|
|
30
|
+
export DATA_DIR=./examples/data-dir/data # relative paths resolve from the process working directory
|
|
31
|
+
urlcode validate --local --project examples/data-dir --policy /path/outside/project/policy.json
|
|
32
|
+
urlcode test --project examples/data-dir --policy /path/outside/project/policy.json
|
|
33
|
+
urlcode audit --project examples/data-dir --expect-routes 1 --policy /path/outside/project/policy.json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For local development, `DATA_DIR=...` may live in an ignored `.env.local`;
|
|
37
|
+
production `serve` reads only the process environment. See
|
|
38
|
+
[bindings](../../docs/yaml/organization.md#12-environment-and-secret-references)
|
|
39
|
+
and [policy setup](../../docs/FUNCTION-SECURITY.md).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hello from the data directory
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
id: data-dir
|
|
2
|
+
description: A trusted function reads files from a data directory the operator chooses through an {env} binding, so a test or deployment can point the same project at another directory.
|
|
3
|
+
tags: [env, binding, data, directory, file, function, trusted, policy, operator, data-dir]
|
|
4
|
+
complexity: intermediate
|
|
5
|
+
capabilities: [bindings, enabled, function, methods, parameters]
|
|
6
|
+
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused, static: refused}
|
|
7
|
+
routes: 1
|
|
8
|
+
grants:
|
|
9
|
+
- kind: env
|
|
10
|
+
description: The exact name DATA_DIR granted to /notes/{name} in an operator policy outside the project (urlcode permissions prints the proposal). The process environment supplies its value.
|
|
11
|
+
files: [urlcode.yaml, functions/note.mjs, data/welcome.txt, tests/requests.json, README.md]
|
|
12
|
+
tests:
|
|
13
|
+
fixtures: tests/requests.json
|
|
14
|
+
commands:
|
|
15
|
+
- urlcode permissions --project examples/data-dir > /path/outside/project/policy.json
|
|
16
|
+
- DATA_DIR=./examples/data-dir/data urlcode validate --local --project examples/data-dir --policy /path/outside/project/policy.json
|
|
17
|
+
- DATA_DIR=./examples/data-dir/data urlcode test --project examples/data-dir --policy /path/outside/project/policy.json
|
|
18
|
+
- DATA_DIR=./examples/data-dir/data urlcode audit --project examples/data-dir --expect-routes 1 --policy /path/outside/project/policy.json
|
|
19
|
+
behavior:
|
|
20
|
+
- GET /notes/welcome answers 200 with the text of $DATA_DIR/welcome.txt
|
|
21
|
+
- an unknown or malformed name answers 404 or 400 and never reaches outside the directory
|
|
22
|
+
- without a policy grant, or with DATA_DIR unset, the project refuses to activate
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {readFile} from 'node:fs/promises';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
|
|
4
|
+
const safeName = /^[a-z0-9-]{1,64}$/;
|
|
5
|
+
|
|
6
|
+
// Trusted function: `env.DATA_DIR` is the operator-resolved {env: DATA_DIR}
|
|
7
|
+
// binding, so the host chooses the directory and this code never reads
|
|
8
|
+
// process.env. The name is still request data: validate it before joining.
|
|
9
|
+
export default async function note(request, {args, env}) {
|
|
10
|
+
if (!safeName.test(args.name)) return Response.json({error: 'unknown note'}, {status: 404});
|
|
11
|
+
try {
|
|
12
|
+
const text = await readFile(join(env.DATA_DIR, `${args.name}.txt`), 'utf8');
|
|
13
|
+
return Response.json({name: args.name, text: text.trimEnd()});
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code === 'ENOENT') return Response.json({error: 'unknown note'}, {status: 404});
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"path":"/notes/welcome","status":200,"expectBody":"{\"name\":\"welcome\",\"text\":\"hello from the data directory\"}"},
|
|
3
|
+
{"path":"/notes/welcome","method":"HEAD","status":200,"expectBody":""},
|
|
4
|
+
{"path":"/notes/missing","status":404},
|
|
5
|
+
{"path":"/notes/..%2Fsecret","status":400}
|
|
6
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Create, read, update, restart
|
|
2
|
+
|
|
3
|
+
One ordered fixture in `tests/requests.json` walks a note through its whole life:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
urlcode test --project examples/lifecycle
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The fixture is a `steps` list. The POST captures the new note's `id` (from the JSON
|
|
10
|
+
body) and its `Location` (from a header); later steps use them as `{{id}}` and
|
|
11
|
+
`{{where}}`. The `{"restart": true}` step closes the runtime and starts it again on
|
|
12
|
+
the same project and data directory, so the last request proves the note survived.
|
|
13
|
+
The function keeps notes under `URLCODE_DATA_DIR`, which `urlcode test` points at a
|
|
14
|
+
fresh temporary directory for each run. All data is synthetic. See the
|
|
15
|
+
[fixture reference](../../docs/READINESS.md#multi-step-fixtures).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
id: lifecycle
|
|
2
|
+
description: Create, read, update, restart and read again, driven by one ordered fixture with captured values and a restart step, with no hand-written test harness.
|
|
3
|
+
tags: [fixtures, steps, capture, restart, lifecycle, persistence, data-directory, testing, function]
|
|
4
|
+
complexity: intermediate
|
|
5
|
+
capabilities: [bindings, enabled, function, methods, parameters, request.body]
|
|
6
|
+
targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused, static: refused}
|
|
7
|
+
routes: 2
|
|
8
|
+
grants:
|
|
9
|
+
- kind: env
|
|
10
|
+
description: URLCODE_DATA_DIR, the data directory. `urlcode test` and `urlcode audit` grant it themselves for their own throwaway directory; a real deployment needs an operator policy grant and a directory that outlives the process.
|
|
11
|
+
files: [urlcode.yaml, functions/notes.mjs, tests/requests.json, README.md]
|
|
12
|
+
tests:
|
|
13
|
+
fixtures: tests/requests.json
|
|
14
|
+
commands:
|
|
15
|
+
- node src/cli.ts test --project examples/lifecycle
|
|
16
|
+
- node src/cli.ts audit --project examples/lifecycle --expect-routes 2
|
|
17
|
+
behavior:
|
|
18
|
+
- a note created by POST /notes is readable, replaceable and still present after the runtime restarts on the same data directory
|
|
19
|
+
- the id and Location the create response returns are captured and substituted into later steps as {{id}} and {{where}}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Trusted first-party code: it writes to the data directory.
|
|
6
|
+
// The directory comes from a declared env binding; `urlcode test` gives every run a fresh one.
|
|
7
|
+
const file = (env, id) => join(env.DATA_DIR, `note-${id}.json`);
|
|
8
|
+
|
|
9
|
+
export async function create(request, { env }) {
|
|
10
|
+
const { text } = await request.json();
|
|
11
|
+
const id = randomBytes(4).toString('hex');
|
|
12
|
+
await writeFile(file(env, id), JSON.stringify({ id, text, version: 1 }));
|
|
13
|
+
return new Response(JSON.stringify({ id, text, version: 1 }), {
|
|
14
|
+
status: 201, headers: { 'content-type': 'application/json', location: `/notes/${id}` },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function note(request, { args, env }) {
|
|
19
|
+
// The id becomes part of a file name: accept only what create() issues.
|
|
20
|
+
if (!/^[0-9a-f]{8}$/.test(args.id)) return Response.json({ error: 'not found' }, { status: 404 });
|
|
21
|
+
let current;
|
|
22
|
+
try { current = JSON.parse(await readFile(file(env, args.id), 'utf8')); }
|
|
23
|
+
catch { return Response.json({ error: 'not found' }, { status: 404 }); }
|
|
24
|
+
if (request.method === 'PUT') {
|
|
25
|
+
current = { ...current, text: (await request.json()).text, version: current.version + 1 };
|
|
26
|
+
await writeFile(file(env, args.id), JSON.stringify(current));
|
|
27
|
+
}
|
|
28
|
+
return Response.json(current);
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"path":"/notes/deadbeef","status":404},
|
|
3
|
+
{"steps":[
|
|
4
|
+
{"path":"/notes","method":"POST","headers":{"content-type":"application/json"},"body":"{\"text\":\"first\"}","status":201,"expectHeaders":{"content-type":"application/json"},
|
|
5
|
+
"capture":{"id":{"json":"id"},"where":{"header":"location"}}},
|
|
6
|
+
{"path":"{{where}}","status":200,"expectBody":"{\"id\":\"{{id}}\",\"text\":\"first\",\"version\":1}"},
|
|
7
|
+
{"path":"/notes/{{id}}","method":"PUT","headers":{"content-type":"application/json"},"body":"{\"text\":\"second\"}","status":200,
|
|
8
|
+
"expectBody":"{\"id\":\"{{id}}\",\"text\":\"second\",\"version\":2}"},
|
|
9
|
+
{"restart":true},
|
|
10
|
+
{"path":"/notes/{{id}}","status":200,"expectBody":"{\"id\":\"{{id}}\",\"text\":\"second\",\"version\":2}"}
|
|
11
|
+
]}
|
|
12
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
version: "1"
|
|
2
|
+
routes:
|
|
3
|
+
/notes:
|
|
4
|
+
description: Create a note; the response carries its id and Location
|
|
5
|
+
sandboxReason: Reviewed first-party code; it writes to the data directory, so it is trusted deliberately.
|
|
6
|
+
methods: [POST]
|
|
7
|
+
request:
|
|
8
|
+
body: {required: true, maxBytes: 1024, contentTypes: [application/json]}
|
|
9
|
+
function: {source: functions/notes.mjs, export: create}
|
|
10
|
+
env:
|
|
11
|
+
DATA_DIR: {env: URLCODE_DATA_DIR}
|
|
12
|
+
/notes/{id}:
|
|
13
|
+
description: Read or replace one note, kept in a file under the data directory
|
|
14
|
+
sandboxReason: Reviewed first-party code; it writes to the data directory, so it is trusted deliberately.
|
|
15
|
+
methods: [GET, PUT]
|
|
16
|
+
parameters:
|
|
17
|
+
- name: id
|
|
18
|
+
in: path
|
|
19
|
+
required: true
|
|
20
|
+
schema: {type: string, minLength: 8, maxLength: 8}
|
|
21
|
+
request:
|
|
22
|
+
body: {maxBytes: 1024, contentTypes: [application/json]}
|
|
23
|
+
function:
|
|
24
|
+
source: functions/notes.mjs
|
|
25
|
+
export: note
|
|
26
|
+
args:
|
|
27
|
+
id: {from: path, name: id}
|
|
28
|
+
env:
|
|
29
|
+
DATA_DIR: {env: URLCODE_DATA_DIR}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Custom 404 page
|
|
2
|
+
|
|
3
|
+
`site.notFound` names an HTML file that answers every GET or HEAD matching no
|
|
4
|
+
route, with status 404 and `text/html`. See
|
|
5
|
+
[site conventions](../../docs/SITE.md#notfound--404html).
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
node src/cli.ts test --project examples/not-found
|
|
9
|
+
node src/cli.ts build --target static --project examples/not-found # writes 404.html
|
|
10
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: not-found
|
|
2
|
+
description: A branded 404 page for unmatched routes with one site.notFound key, served with status 404 and buildable as 404.html for static hosting.
|
|
3
|
+
tags: ['404', not-found, error-page, site, html, static]
|
|
4
|
+
complexity: starter
|
|
5
|
+
capabilities: [enabled, methods, page]
|
|
6
|
+
targets: {self-hosted: compatible, aws: compatible, vercel: compatible, cloudflare: refused, static: compatible}
|
|
7
|
+
routes: 2
|
|
8
|
+
files: [urlcode.yaml, public/index.html, public/404.html, tests/requests.json, README.md]
|
|
9
|
+
tests:
|
|
10
|
+
fixtures: tests/requests.json
|
|
11
|
+
commands:
|
|
12
|
+
- node src/cli.ts validate --local --project examples/not-found
|
|
13
|
+
- node src/cli.ts test --project examples/not-found
|
|
14
|
+
- node src/cli.ts audit --project examples/not-found --expect-routes 2
|
|
15
|
+
behavior:
|
|
16
|
+
- an unmatched GET or HEAD is answered with the page and status 404
|
|
17
|
+
- other methods keep the plain-text 404
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"path": "/", "status": 200, "expectHeaders": {"content-type": "text/html; charset=utf-8"}},
|
|
3
|
+
{"path": "/nope", "status": 404, "expectHeaders": {"content-type": "text/html; charset=utf-8", "cache-control": "no-store"}},
|
|
4
|
+
{"path": "/nope", "method": "HEAD", "status": 404, "expectBody": ""},
|
|
5
|
+
{"path": "/nope", "method": "POST", "status": 404, "expectBody": "Not found\n"},
|
|
6
|
+
{"path": "/404.html", "status": 200}
|
|
7
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Shared request and response.headers blocks
|
|
2
|
+
|
|
3
|
+
```sh
|
|
4
|
+
urlcode test --project examples/shared-blocks
|
|
5
|
+
urlcode routes --project examples/shared-blocks
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
`shared` names reusable `request` and `response.headers` blocks; a route picks
|
|
9
|
+
one with `use`. `/orders/preview` overrides `request` and keeps the shared
|
|
10
|
+
headers; `/health` overrides `response` and drops the shared headers. `routes`
|
|
11
|
+
prints the resolved result. See [the specification](../../docs/SPECIFICATION.md).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
id: shared-blocks
|
|
2
|
+
description: Top-level shared request and response.headers blocks that routes select by name with use, resolved at load time.
|
|
3
|
+
tags: [shared, use, reuse, request, response.headers, respond, deduplicate]
|
|
4
|
+
complexity: starter
|
|
5
|
+
capabilities: [enabled, methods, request.body, respond, response.headers]
|
|
6
|
+
targets: {self-hosted: compatible, aws: compatible, vercel: compatible, cloudflare: compatible, static: refused}
|
|
7
|
+
routes: 4
|
|
8
|
+
grants: []
|
|
9
|
+
files: [urlcode.yaml, tests/requests.json, README.md]
|
|
10
|
+
tests:
|
|
11
|
+
fixtures: tests/requests.json
|
|
12
|
+
commands:
|
|
13
|
+
- node src/cli.ts validate --local --project examples/shared-blocks
|
|
14
|
+
- node src/cli.ts test --project examples/shared-blocks
|
|
15
|
+
- node src/cli.ts audit --project examples/shared-blocks --expect-routes 4
|
|
16
|
+
behavior:
|
|
17
|
+
- a route's own request or response key replaces the shared block whole; nothing is deep merged
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[
|
|
2
|
+
{"path":"/orders","method":"POST","headers":{"content-type":"application/json"},"body":"{\"a\":1}","status":200,"expectBody":"{\"accepted\":true}","expectHeaders":{"cache-control":"no-store","x-api-version":"1"}},
|
|
3
|
+
{"path":"/orders","method":"POST","status":400},
|
|
4
|
+
{"path":"/orders/preview","method":"POST","headers":{"content-type":"application/json"},"body":"{\"a\":1}","status":200,"expectHeaders":{"cache-control":"no-store","x-api-version":"1"}},
|
|
5
|
+
{"path":"/about","status":200,"expectBody":"About this service","expectHeaders":{"cache-control":"public, max-age=300"}},
|
|
6
|
+
{"path":"/health","status":200,"expectHeaders":{"cache-control":"no-store"}}
|
|
7
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
version: "1"
|
|
2
|
+
# Named blocks a route selects with `use`. Resolved at load time: `urlcode routes`
|
|
3
|
+
# and `urlcode audit` show each route with the block already applied.
|
|
4
|
+
shared:
|
|
5
|
+
json-api:
|
|
6
|
+
request:
|
|
7
|
+
body: {required: true, maxBytes: 4096, contentTypes: [application/json], format: json}
|
|
8
|
+
response:
|
|
9
|
+
headers:
|
|
10
|
+
Cache-Control: no-store
|
|
11
|
+
X-Api-Version: "1"
|
|
12
|
+
plain-page:
|
|
13
|
+
response:
|
|
14
|
+
headers:
|
|
15
|
+
Cache-Control: public, max-age=300
|
|
16
|
+
routes:
|
|
17
|
+
/orders:
|
|
18
|
+
methods: [POST]
|
|
19
|
+
use: json-api
|
|
20
|
+
respond: {json: {accepted: true}}
|
|
21
|
+
/orders/preview:
|
|
22
|
+
methods: [POST]
|
|
23
|
+
use: json-api
|
|
24
|
+
# The route's own request replaces the shared one as a whole; response.headers still comes from json-api.
|
|
25
|
+
request:
|
|
26
|
+
body: {maxBytes: 512, contentTypes: [application/json]}
|
|
27
|
+
respond: {json: {preview: true}}
|
|
28
|
+
/about:
|
|
29
|
+
use: plain-page
|
|
30
|
+
respond: {text: About this service}
|
|
31
|
+
/health:
|
|
32
|
+
use: plain-page
|
|
33
|
+
# An own response block wins whole: the shared Cache-Control does not leak in.
|
|
34
|
+
response:
|
|
35
|
+
headers:
|
|
36
|
+
Cache-Control: no-store
|
|
37
|
+
respond: {json: {ok: true}}
|