@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.
Files changed (212) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +60 -7
  2. package/.claude/skills/urlcode-operations/SKILL.md +4 -0
  3. package/README.md +19 -15
  4. package/SECURITY.md +5 -3
  5. package/dist/BUILD-MANIFEST.json +31 -28
  6. package/dist/agent-context.js +82 -0
  7. package/dist/agents-guide.js +42 -42
  8. package/dist/authoring.js +12 -2
  9. package/dist/body-schema.js +159 -0
  10. package/dist/build-cloudflare.js +2 -0
  11. package/dist/capabilities.js +1 -1
  12. package/dist/cli.js +29 -12
  13. package/dist/config.js +74 -6
  14. package/dist/context.js +4 -6
  15. package/dist/errors.js +3 -1
  16. package/dist/examples.js +1 -1
  17. package/dist/extensions.js +138 -3
  18. package/dist/http-policy.js +19 -4
  19. package/dist/http-response.js +2 -2
  20. package/dist/init-with.js +71 -9
  21. package/dist/mcp.js +15 -2
  22. package/dist/pattern-guard.js +32 -0
  23. package/dist/policies/security.js +0 -0
  24. package/dist/policy.js +16 -0
  25. package/dist/project-tests.js +35 -11
  26. package/dist/readiness.js +188 -32
  27. package/dist/recipes.js +1 -1
  28. package/dist/router.js +17 -0
  29. package/dist/runtime.js +26 -3
  30. package/dist/scaffold.js +0 -0
  31. package/dist/server.js +26 -3
  32. package/dist/site.js +0 -0
  33. package/dist/tooling.js +3 -1
  34. package/dist/types/agent-context.d.ts +44 -0
  35. package/dist/types/authoring.d.ts +3 -1
  36. package/dist/types/body-schema.d.ts +58 -0
  37. package/dist/types/config.d.ts +10 -2
  38. package/dist/types/context.d.ts +1 -1
  39. package/dist/types/errors.d.ts +9 -1
  40. package/dist/types/examples.d.ts +1 -1
  41. package/dist/types/extensions.d.ts +105 -2
  42. package/dist/types/http-policy.d.ts +3 -0
  43. package/dist/types/init-with.d.ts +10 -1
  44. package/dist/types/pattern-guard.d.ts +10 -0
  45. package/dist/types/project-tests.d.ts +9 -0
  46. package/dist/types/readiness.d.ts +71 -0
  47. package/dist/types/recipes.d.ts +1 -1
  48. package/dist/types/runtime.d.ts +4 -0
  49. package/dist/types/server.d.ts +9 -1
  50. package/dist/types/tooling.d.ts +2 -0
  51. package/dist/types/types.d.ts +13 -0
  52. package/dist/types.js +10 -2
  53. package/dist/typescript-authoring.js +5 -3
  54. package/dist/verify-deployment.js +16 -10
  55. package/examples/body-validation/README.md +16 -0
  56. package/examples/body-validation/example.yaml +17 -0
  57. package/examples/body-validation/tests/requests.json +15 -0
  58. package/examples/body-validation/urlcode.yaml +34 -0
  59. package/examples/coverage-waiver/README.md +8 -0
  60. package/examples/coverage-waiver/example.yaml +16 -0
  61. package/examples/coverage-waiver/functions/notes.mjs +2 -0
  62. package/examples/coverage-waiver/tests/requests.json +3 -0
  63. package/examples/coverage-waiver/urlcode.yaml +10 -0
  64. package/examples/data-dir/README.md +39 -0
  65. package/examples/data-dir/data/welcome.txt +1 -0
  66. package/examples/data-dir/example.yaml +22 -0
  67. package/examples/data-dir/functions/note.mjs +18 -0
  68. package/examples/data-dir/tests/requests.json +6 -0
  69. package/examples/data-dir/urlcode.yaml +6 -0
  70. package/examples/lifecycle/README.md +15 -0
  71. package/examples/lifecycle/example.yaml +19 -0
  72. package/examples/lifecycle/functions/notes.mjs +29 -0
  73. package/examples/lifecycle/tests/requests.json +12 -0
  74. package/examples/lifecycle/urlcode.yaml +29 -0
  75. package/examples/not-found/README.md +10 -0
  76. package/examples/not-found/example.yaml +17 -0
  77. package/examples/not-found/public/404.html +3 -0
  78. package/examples/not-found/public/index.html +3 -0
  79. package/examples/not-found/tests/requests.json +7 -0
  80. package/examples/not-found/urlcode.yaml +7 -0
  81. package/examples/shared-blocks/README.md +11 -0
  82. package/examples/shared-blocks/example.yaml +17 -0
  83. package/examples/shared-blocks/tests/requests.json +7 -0
  84. package/examples/shared-blocks/urlcode.yaml +37 -0
  85. package/llms-full.txt +436 -149
  86. package/llms.txt +44 -6
  87. package/package.json +15 -22
  88. package/recipes/static-page/README.md +9 -0
  89. package/recipes/static-page/public/index.html +11 -0
  90. package/recipes/static-page/recipe.yaml +21 -0
  91. package/recipes/static-page/tests/requests.json +22 -0
  92. package/recipes/static-page/urlcode.yaml +7 -0
  93. package/recipes/static-plus-api/README.md +6 -0
  94. package/recipes/static-plus-api/urlcode.yaml +4 -0
  95. package/recipes/store-crud/README.md +53 -0
  96. package/recipes/store-crud/recipe.yaml +31 -0
  97. package/recipes/store-crud/tests/requests.json +18 -0
  98. package/recipes/store-crud/urlcode.yaml +18 -0
  99. package/schemas/urlcode.schema.json +125 -60
  100. package/skills/urlcode/SKILL.md +53 -26
  101. package/starters/default/AGENTS.md +43 -43
  102. package/starters/page/README.md +14 -0
  103. package/starters/page/public/index.html +12 -0
  104. package/starters/page/tests/requests.json +17 -0
  105. package/starters/page/urlcode.yaml +6 -0
  106. package/.claude-plugin/marketplace.json +0 -18
  107. package/CONTRIBUTING.md +0 -112
  108. package/ROADMAP.md +0 -66
  109. package/docs/AI-AUTHORING.md +0 -338
  110. package/docs/ASSETS.md +0 -107
  111. package/docs/AUTH-BACKUP.md +0 -32
  112. package/docs/AWS.md +0 -86
  113. package/docs/BEST-PRACTICES.md +0 -276
  114. package/docs/BULK.md +0 -79
  115. package/docs/CAPABILITIES.md +0 -192
  116. package/docs/CAPACITY.md +0 -305
  117. package/docs/CI-FOLLOWUP-2026-09-19.md +0 -97
  118. package/docs/CI-RELEASE-AUDIT-2026-09-19.md +0 -322
  119. package/docs/CI.md +0 -147
  120. package/docs/CLOUDFLARE.md +0 -109
  121. package/docs/CODEBASE-AUDIT-2026-09-20.md +0 -284
  122. package/docs/COMPLIANCE.md +0 -239
  123. package/docs/COMPOSING-A-SITE.md +0 -278
  124. package/docs/CONDITIONS.md +0 -74
  125. package/docs/DEPLOYMENT-CHECKS.md +0 -108
  126. package/docs/DEVELOPMENT-PIPELINE.md +0 -270
  127. package/docs/EGRESS.md +0 -125
  128. package/docs/EXTENSIONS.md +0 -438
  129. package/docs/FRAMEWORK.md +0 -217
  130. package/docs/FUNCTION-SECURITY.md +0 -254
  131. package/docs/HTTP.md +0 -129
  132. package/docs/INSTALL.md +0 -128
  133. package/docs/INTERCHANGE.md +0 -134
  134. package/docs/LOAD-TESTING.md +0 -91
  135. package/docs/LOCAL-DEVELOPMENT.md +0 -102
  136. package/docs/MIDDLEWARE-EXAMPLES.md +0 -75
  137. package/docs/MIDDLEWARE.md +0 -102
  138. package/docs/MONITORING.md +0 -115
  139. package/docs/OBSERVABILITY.md +0 -222
  140. package/docs/OPEN-DECISIONS.md +0 -224
  141. package/docs/OPERATIONAL-PROOF.md +0 -41
  142. package/docs/OPERATIONS.md +0 -201
  143. package/docs/ORGANIZATION.md +0 -135
  144. package/docs/PERFORMANCE.md +0 -72
  145. package/docs/PLUGINS.md +0 -271
  146. package/docs/POLICIES.md +0 -211
  147. package/docs/PRERENDER.md +0 -245
  148. package/docs/PROJECT-DIRECTION.md +0 -118
  149. package/docs/PROVIDER-VERIFICATION.md +0 -84
  150. package/docs/READINESS.md +0 -150
  151. package/docs/README.md +0 -87
  152. package/docs/RECIPES.md +0 -99
  153. package/docs/RELEASE-0.4.0-alpha.3.md +0 -50
  154. package/docs/RELEASE-0.4.1.md +0 -73
  155. package/docs/RELEASE-READINESS.md +0 -117
  156. package/docs/RELEASE-SECURITY.md +0 -96
  157. package/docs/RESILIENCE.md +0 -161
  158. package/docs/ROUTING.md +0 -92
  159. package/docs/SANDBOX-REVIEW.md +0 -72
  160. package/docs/SCAFFOLDING.md +0 -70
  161. package/docs/SECURITY-AUDIT.md +0 -164
  162. package/docs/SITE.md +0 -150
  163. package/docs/SPECIFICATION.md +0 -359
  164. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +0 -288
  165. package/docs/SPIKE-BUSINESS-SUITE.md +0 -1029
  166. package/docs/SPIKE-CORE-LAYERING.md +0 -368
  167. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +0 -211
  168. package/docs/STANDARDS.md +0 -311
  169. package/docs/STARTERS.md +0 -83
  170. package/docs/STATIC.md +0 -105
  171. package/docs/TOOLING.md +0 -298
  172. package/docs/TUNNELS.md +0 -72
  173. package/docs/TYPESCRIPT-AUTHORING.md +0 -87
  174. package/docs/TYPESCRIPT.md +0 -123
  175. package/docs/VERCEL.md +0 -114
  176. package/docs/VERSION-ALIGNMENT.md +0 -80
  177. package/docs/YAML-GUIDE.md +0 -57
  178. package/docs/YAML-REFERENCE.md +0 -449
  179. package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +0 -68
  180. package/docs/archive/2026-09-19/MANAGEMENT-SECURITY.md +0 -102
  181. package/docs/archive/2026-09-19/NEXT-PHASE-PLAN.md +0 -108
  182. package/docs/archive/2026-09-19/NEXT-STEPS.md +0 -646
  183. package/docs/archive/2026-09-19/OPEN-DECISIONS.md +0 -277
  184. package/docs/archive/2026-09-19/RELEASE-SECURITY.md +0 -186
  185. package/docs/archive/2026-09-19/ROADMAP.md +0 -387
  186. package/docs/archive/2026-09-19/SPIKE-EXTENSION-MODEL.md +0 -430
  187. package/docs/archive/2026-09-19/SPIKE-EXTENSIONS.md +0 -492
  188. package/docs/archive/2026-09-19/SPIKE-LAMBDA-COMPILE.md +0 -365
  189. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +0 -778
  190. package/docs/archive/2026-09-19/USABILITY-REVIEW.md +0 -139
  191. package/docs/archive/README.md +0 -28
  192. package/docs/policies/agents.md +0 -182
  193. package/docs/policies/cache.md +0 -152
  194. package/docs/policies/compression.md +0 -169
  195. package/docs/policies/contract.md +0 -52
  196. package/docs/policies/hardened.md +0 -56
  197. package/docs/policies/interoperability.md +0 -169
  198. package/docs/policies/operations.md +0 -45
  199. package/docs/policies/security.md +0 -161
  200. package/docs/policies/throttle.md +0 -103
  201. package/docs/yaml/assets.md +0 -36
  202. package/docs/yaml/conditions.md +0 -20
  203. package/docs/yaml/functions.md +0 -168
  204. package/docs/yaml/middleware.md +0 -31
  205. package/docs/yaml/organization.md +0 -74
  206. package/docs/yaml/policies.md +0 -37
  207. package/docs/yaml/redirects.md +0 -64
  208. package/docs/yaml/responses.md +0 -57
  209. package/docs/yaml/site.md +0 -24
  210. package/packaging/claude-plugin/.claude-plugin/plugin.json +0 -19
  211. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +0 -120
  212. 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, readCases, probeAgent } from './readiness.js';
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 readCases(runtime.root, true);
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 cases = [...plan.cases, ...fixtures];
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
- for (const [i, test] of cases.entries()) {
166
- requests++;
167
- const result = await hit(stub, test, agent, destination);
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
- const label = `${i < plan.cases.length ? 'generated' : 'fixture'} case ${i + 1} ${test.method ?? 'GET'} ${test.path}`;
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,2 @@
1
+ export default (request) =>
2
+ request.method === 'POST' ? new Response('created', { status: 201 }) : new Response('notes');
@@ -0,0 +1,3 @@
1
+ [
2
+ { "path": "/notes", "status": 200, "expectBody": "notes" }
3
+ ]
@@ -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,6 @@
1
+ version: "1"
2
+ routes:
3
+ /notes/{name}:
4
+ function: functions/note.mjs
5
+ env:
6
+ DATA_DIR: {env: DATA_DIR}
@@ -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,3 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><title>Page not found</title></head>
3
+ <body><h1>Page not found</h1><p>That address matches nothing here. <a href="/">Go home</a>.</p></body></html>
@@ -0,0 +1,3 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><title>Home</title></head>
3
+ <body><h1>Home</h1><p>Request any other path to see the 404 page.</p></body></html>
@@ -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,7 @@
1
+ version: "1"
2
+ site:
3
+ notFound: public/404.html
4
+ routes:
5
+ /:
6
+ page:
7
+ file: public/index.html
@@ -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}}