@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.
Files changed (232) hide show
  1. package/.claude/skills/urlcode-authoring/SKILL.md +36 -22
  2. package/.claude/skills/urlcode-operations/SKILL.md +16 -22
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CONTRIBUTING.md +20 -16
  5. package/README.md +83 -80
  6. package/ROADMAP.md +61 -331
  7. package/SECURITY.md +23 -8
  8. package/dist/BUILD-MANIFEST.json +42 -47
  9. package/dist/agents-guide.js +18 -18
  10. package/dist/build-static.js +136 -0
  11. package/dist/capabilities.js +85 -31
  12. package/dist/capability-query.js +0 -1
  13. package/dist/cli.js +24 -32
  14. package/dist/compliance-rules/baseline.js +2 -10
  15. package/dist/compliance-rules/privacy.js +5 -16
  16. package/dist/compliance-rules/shared.js +0 -2
  17. package/dist/compliance.js +6 -8
  18. package/dist/config.js +15 -8
  19. package/dist/context.js +9 -10
  20. package/dist/examples.js +2 -2
  21. package/dist/explain-cli.js +7 -5
  22. package/dist/explain.js +10 -5
  23. package/dist/extensions.js +61 -2
  24. package/dist/function-sources.js +34 -2
  25. package/dist/function-worker.js +3 -1
  26. package/dist/functions.js +84 -13
  27. package/dist/guest-api.js +29 -3
  28. package/dist/index.js +3 -6
  29. package/dist/manifest.js +11 -7
  30. package/dist/mcp-authoring.js +2 -2
  31. package/dist/mcp.js +1 -1
  32. package/dist/observability.js +1 -21
  33. package/dist/policies/cache.js +8 -3
  34. package/dist/policies.js +3 -1
  35. package/dist/policy.js +28 -9
  36. package/dist/prerender.js +4 -0
  37. package/dist/project-tests.js +3 -3
  38. package/dist/readiness.js +35 -9
  39. package/dist/route-diff.js +12 -5
  40. package/dist/router.js +5 -7
  41. package/dist/runtime.js +77 -58
  42. package/dist/sandbox.js +48 -0
  43. package/dist/scaffold.js +0 -0
  44. package/dist/scripts/operational-drills.js +12 -54
  45. package/dist/server.js +3 -29
  46. package/dist/tooling.js +1 -1
  47. package/dist/trusted-functions.js +210 -0
  48. package/dist/types/build-static.d.ts +43 -0
  49. package/dist/types/capabilities.d.ts +14 -5
  50. package/dist/types/compliance-rules/shared.d.ts +0 -2
  51. package/dist/types/compliance.d.ts +0 -3
  52. package/dist/types/config.d.ts +2 -1
  53. package/dist/types/context.d.ts +2 -1
  54. package/dist/types/examples.d.ts +1 -1
  55. package/dist/types/explain.d.ts +6 -0
  56. package/dist/types/extensions.d.ts +55 -0
  57. package/dist/types/function-sources.d.ts +4 -0
  58. package/dist/types/functions.d.ts +48 -5
  59. package/dist/types/guest-api.d.ts +1 -0
  60. package/dist/types/index.d.ts +3 -6
  61. package/dist/types/manifest.d.ts +5 -3
  62. package/dist/types/observability.d.ts +1 -14
  63. package/dist/types/project-tests.d.ts +1 -2
  64. package/dist/types/readiness.d.ts +12 -3
  65. package/dist/types/router.d.ts +2 -1
  66. package/dist/types/runtime.d.ts +0 -27
  67. package/dist/types/sandbox.d.ts +12 -0
  68. package/dist/types/scaffold.d.ts +0 -2
  69. package/dist/types/server.d.ts +1 -4
  70. package/dist/types/tooling.d.ts +3 -3
  71. package/dist/types/trusted-functions.d.ts +29 -0
  72. package/dist/types/types.d.ts +22 -8
  73. package/dist/types/verify-deployment.d.ts +2 -2
  74. package/dist/types.js +25 -5
  75. package/dist/typescript-authoring.js +67 -18
  76. package/dist/verify-deployment.js +3 -3
  77. package/docs/AI-AUTHORING.md +109 -10
  78. package/docs/ASSETS.md +2 -1
  79. package/docs/AUTH-BACKUP.md +32 -0
  80. package/docs/AWS.md +4 -4
  81. package/docs/BEST-PRACTICES.md +17 -8
  82. package/docs/CAPABILITIES.md +30 -17
  83. package/docs/CAPACITY.md +128 -31
  84. package/docs/CI-FOLLOWUP-2026-09-19.md +97 -0
  85. package/docs/CI-RELEASE-AUDIT-2026-09-19.md +322 -0
  86. package/docs/CI.md +8 -3
  87. package/docs/CLOUDFLARE.md +1 -2
  88. package/docs/CODEBASE-AUDIT-2026-09-20.md +278 -0
  89. package/docs/COMPLIANCE.md +6 -9
  90. package/docs/DEPLOYMENT-CHECKS.md +1 -1
  91. package/docs/DEVELOPMENT-PIPELINE.md +181 -0
  92. package/docs/EXTENSIONS.md +193 -11
  93. package/docs/FRAMEWORK.md +53 -33
  94. package/docs/FUNCTION-SECURITY.md +173 -32
  95. package/docs/INSTALL.md +0 -5
  96. package/docs/LOAD-TESTING.md +4 -4
  97. package/docs/LOCAL-DEVELOPMENT.md +3 -0
  98. package/docs/MIDDLEWARE.md +27 -16
  99. package/docs/MONITORING.md +2 -19
  100. package/docs/OBSERVABILITY.md +7 -16
  101. package/docs/OPEN-DECISIONS.md +184 -0
  102. package/docs/OPERATIONAL-PROOF.md +26 -30
  103. package/docs/OPERATIONS.md +23 -32
  104. package/docs/POLICIES.md +27 -8
  105. package/docs/PRERENDER.md +25 -13
  106. package/docs/PROJECT-DIRECTION.md +35 -10
  107. package/docs/READINESS.md +6 -2
  108. package/docs/README.md +20 -41
  109. package/docs/RELEASE-0.4.0-alpha.3.md +50 -0
  110. package/docs/RELEASE-READINESS.md +50 -30
  111. package/docs/RELEASE-SECURITY.md +72 -86
  112. package/docs/RESILIENCE.md +16 -15
  113. package/docs/ROUTING.md +7 -10
  114. package/docs/SANDBOX-REVIEW.md +19 -6
  115. package/docs/SCAFFOLDING.md +0 -2
  116. package/docs/SECURITY-AUDIT.md +42 -2
  117. package/docs/SPECIFICATION.md +63 -27
  118. package/docs/SPIKE-AI-FRAMEWORK-BENCHMARK.md +288 -0
  119. package/docs/SPIKE-BUSINESS-SUITE.md +16 -0
  120. package/docs/SPIKE-CORE-LAYERING.md +368 -0
  121. package/docs/SPIKE-DEFAULT-TRUST-MODEL.md +211 -0
  122. package/docs/SPIKE-LAMBDA-COMPILE.md +12 -2
  123. package/docs/STARTERS.md +1 -1
  124. package/docs/STATIC.md +105 -0
  125. package/docs/TOOLING.md +17 -12
  126. package/docs/TUNNELS.md +0 -3
  127. package/docs/TYPESCRIPT-AUTHORING.md +32 -12
  128. package/docs/TYPESCRIPT.md +25 -4
  129. package/docs/VERCEL.md +4 -5
  130. package/docs/VERSION-ALIGNMENT.md +46 -0
  131. package/docs/YAML-GUIDE.md +0 -3
  132. package/docs/YAML-REFERENCE.md +16 -6
  133. package/docs/archive/2026-09-19/EXTENSION-IMPLEMENTATION.md +68 -0
  134. package/docs/{MANAGEMENT-SECURITY.md → archive/2026-09-19/MANAGEMENT-SECURITY.md} +20 -0
  135. package/docs/{NEXT-PHASE-PLAN.md → archive/2026-09-19/NEXT-PHASE-PLAN.md} +26 -8
  136. package/docs/{NEXT-STEPS.md → archive/2026-09-19/NEXT-STEPS.md} +274 -43
  137. package/docs/archive/2026-09-19/OPEN-DECISIONS.md +277 -0
  138. package/docs/archive/2026-09-19/RELEASE-SECURITY.md +186 -0
  139. package/docs/archive/2026-09-19/ROADMAP.md +386 -0
  140. package/docs/{SPIKE-EXTENSION-MODEL.md → archive/2026-09-19/SPIKE-EXTENSION-MODEL.md} +12 -2
  141. package/docs/{SPIKE-EXTENSIONS.md → archive/2026-09-19/SPIKE-EXTENSIONS.md} +28 -12
  142. package/docs/archive/2026-09-19/SPIKE-MONOREPO.md +776 -0
  143. package/docs/{USABILITY-REVIEW.md → archive/2026-09-19/USABILITY-REVIEW.md} +18 -2
  144. package/docs/archive/README.md +27 -0
  145. package/docs/policies/agents.md +1 -1
  146. package/docs/policies/cache.md +13 -0
  147. package/docs/policies/compression.md +3 -2
  148. package/docs/policies/security.md +3 -2
  149. package/examples/assets/example.yaml +2 -2
  150. package/examples/aws/example.yaml +1 -1
  151. package/examples/cloudflare/example.yaml +1 -1
  152. package/examples/conditions/example.yaml +1 -1
  153. package/examples/cookbook/example.yaml +1 -1
  154. package/examples/cookbook/middleware/bucket.mjs +12 -2
  155. package/examples/cookbook/middleware/locale.mjs +7 -3
  156. package/examples/cookbook/route-index.json +1 -1
  157. package/examples/cookbook/routes/middleware.yaml +1 -1
  158. package/examples/egress/example.yaml +1 -1
  159. package/examples/extensions/example.yaml +1 -1
  160. package/examples/prerender/README.md +14 -6
  161. package/examples/prerender/example.yaml +1 -1
  162. package/examples/prerender/functions/page.mjs +4 -2
  163. package/examples/prerender/middleware/template.mjs +1 -1
  164. package/examples/prerender/prerender.mjs +1 -1
  165. package/examples/prerender/urlcode.yaml +8 -4
  166. package/examples/provider-conformance/example.yaml +1 -1
  167. package/examples/vercel/example.yaml +1 -1
  168. package/llms-full.txt +686 -239
  169. package/llms.txt +27 -15
  170. package/package.json +32 -5
  171. package/packaging/claude-plugin/.claude-plugin/plugin.json +2 -2
  172. package/packaging/claude-plugin/skills/urlcode-authoring/SKILL.md +36 -22
  173. package/packaging/claude-plugin/skills/urlcode-operations/SKILL.md +16 -22
  174. package/recipes/authenticated-json-api/README.md +4 -3
  175. package/recipes/authenticated-json-api/functions/profile.mjs +2 -1
  176. package/recipes/authenticated-json-api/recipe.yaml +2 -2
  177. package/recipes/contact-form/functions/contact.mjs +2 -1
  178. package/recipes/contact-form/recipe.yaml +3 -3
  179. package/recipes/cors-api/README.md +2 -2
  180. package/recipes/cors-api/recipe.yaml +2 -2
  181. package/recipes/health-page/README.md +1 -1
  182. package/recipes/health-page/recipe.yaml +1 -1
  183. package/recipes/json-api/README.md +1 -1
  184. package/recipes/json-api/recipe.yaml +4 -4
  185. package/recipes/middleware/README.md +8 -4
  186. package/recipes/middleware/middleware/bucket.mjs +12 -2
  187. package/recipes/middleware/middleware/locale.mjs +7 -3
  188. package/recipes/middleware/recipe.yaml +1 -1
  189. package/recipes/protected-download/README.md +1 -1
  190. package/recipes/protected-download/recipe.yaml +2 -2
  191. package/recipes/redirect/recipe.yaml +1 -1
  192. package/recipes/static-plus-api/README.md +2 -2
  193. package/recipes/static-plus-api/public/index.html +1 -1
  194. package/recipes/static-plus-api/recipe.yaml +2 -2
  195. package/recipes/static-plus-api/urlcode.yaml +1 -1
  196. package/recipes/typescript/README.md +2 -1
  197. package/recipes/typescript/recipe.yaml +5 -5
  198. package/recipes/webhook-receiver/README.md +5 -1
  199. package/recipes/webhook-receiver/recipe.yaml +2 -1
  200. package/recipes/webhook-receiver/urlcode.yaml +7 -0
  201. package/schemas/recipe.schema.json +4 -3
  202. package/schemas/urlcode.schema.json +14 -41
  203. package/skills/urlcode/SKILL.md +32 -11
  204. package/starters/default/AGENTS.md +18 -18
  205. package/starters/default/urlcode.yaml +0 -1
  206. package/dist/link-api.js +0 -136
  207. package/dist/link-cli.js +0 -141
  208. package/dist/link-events.js +0 -76
  209. package/dist/link-records.js +0 -31
  210. package/dist/link-store-worker.js +0 -150
  211. package/dist/link-store.js +0 -250
  212. package/dist/management-policy.js +0 -40
  213. package/dist/sqlite-version.js +0 -6
  214. package/dist/types/link-api.d.ts +0 -30
  215. package/dist/types/link-cli.d.ts +0 -37
  216. package/dist/types/link-events.d.ts +0 -27
  217. package/dist/types/link-records.d.ts +0 -11
  218. package/dist/types/link-store-worker.d.ts +0 -1
  219. package/dist/types/link-store.d.ts +0 -130
  220. package/dist/types/management-policy.d.ts +0 -8
  221. package/dist/types/sqlite-version.d.ts +0 -1
  222. package/docs/DYNAMIC-LINKS.md +0 -61
  223. package/docs/links/cli.md +0 -110
  224. package/docs/links/limits.md +0 -175
  225. package/docs/links/management-api.md +0 -80
  226. package/docs/links/pools.md +0 -75
  227. package/docs/links/setup.md +0 -135
  228. package/docs/yaml/links.md +0 -30
  229. package/examples/live-links/README.md +0 -11
  230. package/examples/live-links/example.yaml +0 -21
  231. package/examples/live-links/tests/requests.json +0 -6
  232. package/examples/live-links/urlcode.yaml +0 -16
package/docs/links/cli.md DELETED
@@ -1,110 +0,0 @@
1
- # Dynamic links: CLI: create, update, export and restore
2
-
3
- Part of [dynamic links](../DYNAMIC-LINKS.md), which indexes every page.
4
-
5
- ## Update, disable, expire, list and delete
6
-
7
- ```sh
8
- urlcode links get --store /absolute/links.sqlite --code demo
9
- urlcode links list --store /absolute/links.sqlite --limit 100
10
- urlcode links list --store /absolute/links.sqlite --limit 100 --after last-code
11
- urlcode links update --store /absolute/links.sqlite --code demo \
12
- --if-version 1 --destination https://example.com/new --enabled false
13
- urlcode links delete --store /absolute/links.sqlite --code demo --if-version 2
14
- ```
15
-
16
- Replace illustrative versions with the actual returned `version`. Update is a
17
- **full replacement**: omitted status becomes 302, enabled becomes true, and expiry
18
- becomes null. Use `--status 307` or `--expires 2030-01-01T00:00:00Z` as needed.
19
- Allowed status codes match ordinary redirects. Disabled/expired records remain
20
- stored and reserve the code until explicitly deleted.
21
-
22
- Create is insert-only: an existing code returns conflict, never an overwrite.
23
- Updates/deletes require a matching positive version. Every mutation uses a
24
- transaction; revisions advance globally within that database, including deletion,
25
- so a stale edit cannot silently affect a deleted-and-recreated code. Competing
26
- writers receive a conflict. Read the latest record before making a new decision;
27
- do not blindly retry a stale update. Listing is ordered by code, at most 100 per
28
- page, within one collection. Pagination is not a snapshot across concurrent writes.
29
-
30
- CLI commands print record data intentionally; treat output as operational data.
31
- Do not store secrets in destinations or capture output into public logs.
32
- `urlcode add` remains the separate command for adding a Git/YAML-defined redirect.
33
-
34
- ## Consistent operator export and restore
35
-
36
- Listing pages one after another is not a snapshot: inserts, updates and deletes
37
- between pages can produce a logically inconsistent copy. `links export` instead
38
- holds one SQLite read transaction for the whole export, so every record it writes
39
- comes from a single point in time.
40
-
41
- ```sh
42
- umask 077
43
- urlcode links export --store /absolute/links.sqlite > /absolute/backups/links-export.ndjson
44
- urlcode links import --store /absolute/restored.sqlite --input /absolute/backups/links-export.ndjson
45
- ```
46
-
47
- Export is an operator command on the operator's own database. It is not reachable
48
- from the public redirect server, from route YAML, from guest function code or from
49
- the management HTTP API, and it grants guest code no storage capability. The
50
- output is operational data: write it somewhere only operators can read, keep it out
51
- of the project, Git and build artifacts, and treat it like the database itself.
52
-
53
- **Consistency contract.** The export reflects the database exactly as of the moment
54
- the snapshot is pinned, which is the first read after the transaction opens. Writers
55
- are never blocked and keep committing; none of their later commits appear in the
56
- export, and no record appears twice or is skipped. The header line carries
57
- `format`, `schemaVersion`, `applicationId`, the store `revision` at that instant and
58
- `generatedAt`, so a restored copy can be identified and ordered against others. This
59
- is a consistent logical copy, not a point-in-time recovery system: it has no
60
- continuous log and cannot reconstruct a moment between two exports.
61
-
62
- **Contents.** Every record in the store, or in one `--collection`, including
63
- enabled, disabled and expired records, with `collection`, `code`, `url`, `status`,
64
- `enabled`, `expires` and `version`. Disabled and expired records are exported
65
- because they still reserve their codes. The output is NDJSON: a
66
- `link-export-begin` header line, one `{"record": {...}}` line per record ordered by
67
- collection and code, and a `link-export-complete` line carrying the record count
68
- and a SHA-256 digest over every preceding line. A stream without that final line is
69
- truncated, and `links import` rejects it.
70
-
71
- **Restore semantics.** `links import` replays the records into the target store and
72
- refuses to touch a collection that already holds records, so a restore never
73
- overwrites live data. It verifies the format, schema version, store identity, record
74
- count and digest before it commits the last record. Codes, destinations, statuses,
75
- enabled flags and expiries are restored exactly. **Versions are not.** The target
76
- assigns its own revisions, which is why the report sets `versionsReassigned: true`:
77
- management ETags taken against the exported database are stale after a restore, so
78
- discard them and re-read records before the next conditional write, exactly as after
79
- restoring an older database file.
80
-
81
- **Audit journal.** The export carries records only. The mutation audit journal stays
82
- in the source database and is not part of an export, so a store restored from one
83
- starts a fresh journal covering only mutations made after the restore. Keep the
84
- journal by backing up the database file itself, as described under *Persistence,
85
- bounds and recovery*; that file backup, not the export, is the archival copy of who
86
- changed what. See [management security](../MANAGEMENT-SECURITY.md) for retention.
87
-
88
- **Bounds.** One export runs at a time per store and a second is rejected with 409.
89
- It pins exactly one reader connection and holds that reader's admission for its whole
90
- life, so it can never exceed the pool's read budget or starve redirects of every
91
- reader — run it against a management store or size `--link-readers` accordingly.
92
- Pages are at most 100 records (`--page-size`), each page carries the usual
93
- five-second operation deadline, and the export as a whole has a 60-second default
94
- deadline after which it fails and releases the reader. A consumer that fails or a
95
- process that stops ends the read transaction rather than leaving it open. Because
96
- the transaction pins a WAL read mark, a long export delays WAL checkpointing: keep
97
- exports short and do not leave one running against a busy store.
98
-
99
- Embedders call the same mechanism directly:
100
-
101
- ```js
102
- await store.exportSnapshot({collection: 'links', pageSize: 100, deadlineMs: 60000}, {
103
- onHeader: header => sink.write(header),
104
- onRecords: records => sink.write(records),
105
- });
106
- ```
107
-
108
- `onRecords` is awaited, so a slow sink applies backpressure to the export instead of
109
- buffering the store in memory; anything it throws aborts the export and releases the
110
- reader. `stats()` reports `exporting` while one is in flight.
@@ -1,175 +0,0 @@
1
- # Dynamic links: Persistence, bounds, recovery and events
2
-
3
- Part of [dynamic links](../DYNAMIC-LINKS.md), which indexes every page.
4
-
5
- ## Persistence, bounds and recovery
6
-
7
- SQLite operations use separate reader and writer pools, outside the HTTP event
8
- loop and function workers. Default: two read-only worker connections plus one
9
- writer for writable stores; public serving opens readers only. Reads and writes
10
- have independent 32-operation admission limits and 5-second deadlines including
11
- waiting. Lock wait is one second. Excess work returns 503. Failed connections
12
- are excluded from selection and readiness degrades; surviving readers can still
13
- serve requests. Plain YAML redirects remain independent.
14
-
15
- Startup and established-worker failures recover differently.
16
-
17
- * **Startup failure.** A connection that never reports ready is terminated and its
18
- error is returned to the caller: `openLinkStore` rejects and activation fails
19
- closed. Nothing is retried behind the operator's back, so a `serve` or `links api`
20
- process that cannot open the store does not start, and a reload that cannot open
21
- it keeps the last-good runtime.
22
- * **Established-worker failure.** A connection that had been serving and then
23
- errors, exits or misses an operation deadline is replaced automatically.
24
- In-flight operations on it reject with 503, the connection is marked unhealthy
25
- and excluded from selection, and a replacement worker is launched after an
26
- exponential backoff from 250 ms up to 30 seconds. Each attempt emits a
27
- `link_store_worker` event with `status: "restarting"`, the attempt number and the
28
- delay; a replacement that serves an operation resets the backoff, and one that
29
- starts but dies on every operation keeps backing off instead of spinning. Close
30
- cancels a pending replacement.
31
-
32
- While a pool member is down the pool is degraded, not off: `readHealthy`,
33
- `writeHealthy` and `healthy` report false and readiness degrades, but surviving
34
- readers keep answering and a recovered writer resumes accepting mutations with no
35
- operator action. Records live on disk, so a replaced connection loses no committed
36
- data.
37
-
38
- Restart the process when recovery cannot help: an unsuitable Node/SQLite build,
39
- missing or invalid revision metadata, an incompatible schema, a store file that was
40
- replaced, moved or symlinked under a running connection, or a host-level fault such
41
- as a full or read-only disk. Those fail activation rather than reconnecting, and
42
- the replacement worker will keep failing until the underlying cause is fixed.
43
-
44
- Automatic connection recovery does not make writes idempotent. A mutation whose
45
- reply was lost to a worker failure or deadline may still have committed, and the
46
- records it touched carry versions that advance globally. Callers must therefore
47
- re-read the record and decide again instead of blindly retrying a write; the
48
- optimistic-version rules under *Update, disable, expire, list and delete* apply
49
- unchanged. Recovery behavior is covered by the `acknowledged writes survive abrupt
50
- writer exit and pagination retains records`, `a blocked writer does not occupy read
51
- connections and recovers after lock release` and `stores with missing revision
52
- metadata fail activation` cases in `test/links.test.ts`.
53
-
54
- The initial store has a 100,000-record cap across collections and an 8,192-byte
55
- normalized destination limit. WAL + FULL synchronous commits provide transactional
56
- persistence subject to the disk/filesystem's guarantees. The format has an
57
- application identifier and schema version; incompatible databases fail activation.
58
- Use trusted local storage and a protected parent directory. Do not replace,
59
- symlink or move an open database or its WAL/SHM files. The database and token must
60
- be outside the project; keep them outside public directories, Git and artifacts.
61
-
62
- An acknowledged mutation is committed. If a caller loses the response or receives
63
- a timeout, the write may nevertheless have committed: inspect state before retry.
64
- For retryable creation, choose a stable code and resolve conflicts; automatic
65
- code generation cannot give exactly-once semantics after a lost response.
66
-
67
- `links export` gives a consistent logical copy of the records while the store keeps
68
- serving; it does not replace a file backup, which is what preserves the audit
69
- journal and the exact record versions. For offline backups, stop management writers and all readers, then copy the
70
- database together with any remaining WAL file as one consistent stopped set,
71
- preserving their matching basenames. Restore into a separate private directory
72
- while no connection is open. Do not discard a WAL just because the app stopped.
73
- For online backups, use SQLite-aware tooling rather than copying only the live
74
- main file. SQLite's [WAL documentation](https://www.sqlite.org/wal.html) explains why
75
- committed state may still be in the WAL. Test restores on a separate closed store.
76
- Restoring an older database also restores older record versions: discard old
77
- management ETags and re-read records after restore. This is not a replication or
78
- point-in-time recovery system. Define retention, RPO/RTO and disk limits yourself.
79
-
80
- Multiple same-host processes can share the local file; a distributed deployment
81
- needs another adapter. The trusted embedding API accepts
82
- `linkStores: {links: adapter}` where `get(collection, code)` resolves to null or a
83
- record with url/status/enabled/expires. The caller owns adapter shutdown and must
84
- provide bounded operations, validation and consistency. Optional `healthy=false`
85
- makes readiness fail. `openLinkStore` provides the built-in implementation plus
86
- create/update/delete/list/exportSnapshot/close methods. Adapter code is operator code, never
87
- loaded from route YAML. No remote provider adapter ships in this release.
88
-
89
- ## Opt-in completed-redirect events
90
-
91
- Default request logs stay minimal: they carry status and timing, and with
92
- `--request-log detailed` the method and the configured route pattern. They never
93
- carry a short code or a request target. Counting store lookups is not a substitute
94
- either, because a lookup cannot tell a completed redirect from a HEAD probe, an
95
- error or a client that disconnected.
96
-
97
- A trusted operator embedding the runtime can instead enable a post-response
98
- observer. It is explicitly enabled in operator code, off by default, and there is no
99
- `serve` flag and no YAML setting for it: route YAML cannot name a callback, and no
100
- untrusted code is ever loaded as one.
101
-
102
- ```js
103
- import {startServer} from '@jimhoyd/urlcode';
104
-
105
- await startServer({
106
- project: './links',
107
- linkStore: {collection: 'links', file: '/absolute/links.sqlite'},
108
- linkEvents: {
109
- observe: event => collector.record(event), // operator code, awaited off the request path
110
- includeCode: false, // set true to disclose the short code to this collector
111
- maxQueue: 256, // 1–4096 events; excess is dropped and counted
112
- timeoutMs: 1000, // 1–10000 ms budget per observer call
113
- },
114
- });
115
- ```
116
-
117
- In TypeScript the collector's argument is `LinkEvent` and the option block is
118
- `LinkObserverOptions`, both exported from `urlcode` beside `LinkStore`,
119
- `LinkRow`, `LinkStoreOptions`, `LinkApi` and `LinkApiOptions`; the declarations
120
- ship with the package:
121
-
122
- ```ts
123
- import { startServer, type LinkEvent, type LinkObserverOptions } from '@jimhoyd/urlcode';
124
-
125
- const linkEvents: LinkObserverOptions = {
126
- observe: (event: LinkEvent) => collector.record(event), // event.code is null for an invalid code
127
- includeCode: false,
128
- };
129
- await startServer({ project: './links', linkStore: { collection: 'links', file: '/absolute/links.sqlite' }, linkEvents });
130
- ```
131
-
132
- Each event is `{event: 'link_request', requestId, collection, route, method, status,
133
- outcome, durationMs}`, plus `code` only when `includeCode` is true. `route` is the
134
- configured route pattern, never the request target. Nothing else from the request is
135
- included: no token, destination URL, query string, headers, body, cookie or client
136
- IP address, and no stored record. Only stored-link routes produce events; a plain
137
- YAML redirect never does. Disclosing a short code identifies a link, so treat a
138
- collector that receives one as holding operational data and keep it off public logs.
139
-
140
- | `outcome` | Meaning |
141
- |---|---|
142
- | `completed` | The redirect response finished. With `method: "GET"` this is the closest thing to a click; `HEAD` is a probe, not a click |
143
- | `aborted` | A redirect was produced but the response never finished, because the peer disconnected |
144
- | `missing` | No record for that code |
145
- | `disabled` | The record exists but is disabled |
146
- | `expired` | The record exists but its expiry has passed |
147
- | `invalid_code` | The code failed route input validation |
148
- | `invalid_record` | The stored record failed validation |
149
- | `unavailable` | The store was unavailable or over its admission budget |
150
-
151
- Nothing here is a human click count. Bots, prefetchers, proxies and repeat requests
152
- all produce `completed` events, the runtime does not deduplicate, and browser and
153
- proxy caches mean a real navigation may produce no request at all. Do the
154
- interpretation in your own collector.
155
-
156
- **The observer cannot affect a redirect.** Events are enqueued after the response
157
- finishes or the connection closes, never before, so an observer cannot delay,
158
- rewrite or fail a redirect. Delivery is sequential and bounded: at most `maxQueue`
159
- events are held, each call gets `timeoutMs` and a slow or hung collector is
160
- abandoned rather than allowed to pin the queue, and a call that throws is counted
161
- instead of propagated. Drops and failures are counted, reported through
162
- `link_observer` events on the normal log and readable at any time through
163
- `app.linkEventStats()` as `{queued, delivered, dropped, failed, timedOut, closed}`.
164
- An overloaded collector loses events, by design, instead of growing memory.
165
-
166
- Shutdown closes the observer after the server's connections are gone, drains what
167
- was already accepted within one bounded deadline, drops the rest and emits a final
168
- `link_observer` event with `status: "closed"` and those totals. Events are
169
- best-effort operational signals, not durable analytics or an audit record: the
170
- durable, atomic record of mutations remains the store's audit journal.
171
-
172
- Tests cover GET and HEAD, completed and aborted responses, missing, disabled and
173
- expired records, default redaction and opt-in code disclosure, a failing collector,
174
- a hung collector hitting its budget, queue overflow with counted drops, and drain on
175
- shutdown.
@@ -1,80 +0,0 @@
1
- # Dynamic links: The management API
2
-
3
- Part of [dynamic links](../DYNAMIC-LINKS.md), which indexes every page.
4
-
5
- ## A separate authenticated management API
6
-
7
- For a web product, your trusted backend calls the management API after applying
8
- its own user authentication, authorization, quotas and abuse rules. The API is an
9
- operator interface, not a public anonymous link-creation endpoint. Never put its
10
- shared token in browser JavaScript. It is not available on the public route server.
11
-
12
- Generate a private token file outside the application (POSIX-compatible Node example):
13
-
14
- ```sh
15
- node --input-type=module -e 'import {writeFileSync} from "node:fs"; import {randomBytes} from "node:crypto"; writeFileSync(process.argv[1],randomBytes(32).toString("base64url"),{mode:0o600,flag:"wx"})' /absolute/link-admin.token
16
- urlcode links api --store /absolute/links.sqlite --collection links \
17
- --token-file /absolute/link-admin.token --host 127.0.0.1 --port 3001
18
- ```
19
-
20
- Use a securely generated token, at least 43 base64url characters. File permissions
21
- must exclude group/other access on POSIX; protect Windows files with operator ACLs.
22
- The API reads the token at startup; rotate by replacing it and restarting this
23
- management process. This does not require restarting public resolution.
24
-
25
- Every request needs `Authorization: Bearer <token>`. Authenticate before body
26
- processing. Use application/json for POST/PUT, with no compression; max body is
27
- 16 KiB. Browser Origin-bearing requests are rejected. No CORS, cookies, user
28
- accounts, per-user scopes, JWT system or automatic rate limiter is provided.
29
- Keep it on a private network/loopback behind authenticated TLS ingress as needed.
30
- If changing `--host`, protect it before exposing it; no built-in HTTPS exists.
31
-
32
- | Request | Result |
33
- |---|---|
34
- | `POST /v1/links` with `{ "code": "demo", "url": "https://example.com/demo" }` | 201 with record and ETag; code optional |
35
- | `GET /v1/links/demo` | 200 with record and ETag, or 404 |
36
- | `GET /v1/links?limit=100&after=demo` | `{items, nextAfter}`; nextAfter null on a short page |
37
- | `PUT /v1/links/demo`, `If-Match: "VERSION"`, complete record fields | Replace and return new record/ETag |
38
- | `DELETE /v1/links/demo`, `If-Match: "VERSION"` | 204 |
39
-
40
- POST accepts optional `code` plus `url`, optional `status`, `enabled`, `expires`.
41
- PUT accepts the same record fields except code, which is immutable. `expires`
42
- may be null. Responses include collection, code, url, status, enabled, expires,
43
- version. Missing precondition: 428; stale version or duplicate code: 409;
44
- invalid input: 400; authentication failure: 401; unsupported media: 415;
45
- record limit: 507; store failure/capacity: 503. Error messages omit credentials,
46
- submitted URLs and SQL details. A full page can return a cursor even when the
47
- next page will be empty. No bulk mutation API is implemented, and the management
48
- API exposes no export: sequential list pages are not a snapshot, so use the
49
- operator `links export` command above for a consistent copy.
50
-
51
- A token authorizes its configured collection, not all collections. There are no
52
- per-end-user permissions: those belong to your backend. The API can also list
53
- inactive records for management. The public `/r/{code}` route never exposes
54
- management JSON, token files or a mutation endpoint.
55
-
56
- ## Management HTTP and audit safeguards
57
-
58
- The private API admits up to 32 in-flight HTTP requests through response finish
59
- or disconnect, returning 503 on overload. A 10-second socket inactivity timeout
60
- closes stalled peers; this is not a total response deadline. The embedding API
61
- accepts `maxInFlightRequests` (1–64) and `socketTimeoutMs` (100–60,000). Existing
62
- connection/body/header and database admission limits still apply. Noncanonical
63
- dot-segment/backslash path normalization is rejected.
64
-
65
- JSON `management_request` events go to stdout by default and include timestamp,
66
- request ID, collection, action, authentication result, status and finish/abort
67
- outcome. No token, code, destination, URL, headers or body is logged. Embedders
68
- can supply `log(event)`; failed sinks cannot crash request handling. These are
69
- best-effort operational events, not durable per-actor audit records. Aborted
70
- mutations may have committed: re-read state before retrying. See the
71
- [security review](../SECURITY-AUDIT.md) for remaining controls.
72
-
73
- ## Management hardening baseline
74
-
75
- Management is now restricted to literal loopback addresses. Prefer `--auth-file`
76
- for individual expiring, revocable credentials with collection/action scopes.
77
- Every successful built-in store mutation has an atomic, durable SQLite audit row;
78
- HTTP request logs remain best effort. See [management security](../MANAGEMENT-SECURITY.md)
79
- for policy examples, compatibility, archival and rollback requirements, and
80
- [operational proof](../OPERATIONAL-PROOF.md) for executable recovery drills.
@@ -1,75 +0,0 @@
1
- # Dynamic links: Reader and writer pools
2
-
3
- Part of [dynamic links](../DYNAMIC-LINKS.md), which indexes every page.
4
-
5
- ## Separate reader and writer pools
6
-
7
- Pool sizes are operator infrastructure settings, not portable behavior YAML.
8
- `dynamicLinks: true` and logical collection names stay the same across targets.
9
-
10
- ```sh
11
- urlcode serve --project ./my-links \
12
- --link-store links=/absolute/links.sqlite \
13
- --link-readers 4 --link-read-limit 32
14
-
15
- urlcode links api --project ./my-links \
16
- --store /absolute/links.sqlite --token-file /absolute/link-admin.token \
17
- --link-readers 2 --link-read-limit 16 --link-write-limit 8
18
- ```
19
-
20
- | Control | Default | Meaning |
21
- |---|---|---|
22
- | `--link-readers` / JS `readers` | 2 | 1–8 read-only connections per store instance |
23
- | `--link-read-limit` / JS `maxReads` | 32 | 1–32 admitted reads across all readers, not per reader |
24
- | `--link-write-limit` / JS `maxWrites` | 32 | 1–32 admitted mutations across the single writer |
25
-
26
- `get` and `list` use the least-busy healthy reader. `create`, `update` and `delete`
27
- use the writer. Each worker serializes its own accepted operations. There is no
28
- additional unbounded acquisition queue. A read flood cannot take writer admission
29
- and a mutation backlog cannot take reader admission. CPU, disk, database locks
30
- and the event loop remain shared resources: separation is not an isolation SLA.
31
- A write response resolves after commit; a subsequent awaited read sees committed
32
- data on the same database. Reads started before the commit may see the old value.
33
- There is no replica lag or application cache in this adapter.
34
-
35
- `openLinkStore({file, project, readOnly, readers, maxReads, maxWrites})` exposes
36
- `stats()` with separate read/write connection counts, healthy counts, in-flight
37
- counts, limits, completed/failed/rejected totals and cumulative durationMs. These
38
- process-local counters reset at restart and are for trusted operator monitoring;
39
- there is no public metrics endpoint/exporter yet. `readHealthy` and `writeHealthy`
40
- are separate. Public runtime readiness uses `readHealthy` when available; management
41
- operators must monitor writer health separately. A read-only pool has zero writers
42
- and rejects mutations. Shutdown drains both groups and rejects new admission.
43
-
44
- Do not multiply connection counts blindly across processes: N public replicas at
45
- R readers use N×R connections, plus management readers/writers. Each connection
46
- has its own worker and memory budget. Measure mixed load and lock contention;
47
- adding readers can reduce performance on an already saturated disk.
48
-
49
- SQLite WAL supports simultaneous readers and a single active writer across the
50
- database. Multiple writer connections cannot create parallel write throughput.
51
- All connections must access the same local database on one host; never mount it
52
- across hosts over a network filesystem. See [SQLite WAL](https://www.sqlite.org/wal.html).
53
- We require a Node build with SQLite 3.51.3+ or patched branches 3.50.7+/3.44.6+
54
- to avoid the documented [WAL-reset concurrency bug](https://www.sqlite.org/wal.html#the_wal_reset_bug).
55
- Live-link initialization checks this before opening files and verifies WAL mode.
56
- Use `urlcode doctor` to inspect the bundled SQLite version; upgrade Node when
57
- rejected. Static YAML projects do not require SQLite.
58
-
59
- For multi-host scaling, a future server-database adapter must provide distinct
60
- read/write credentials and pool budgets, connect/acquire/query deadlines,
61
- transactional version checks, primary-read or explicit replica consistency,
62
- replica-lag monitoring, migration coordination, backup/restore and failover drills.
63
- These are adapter acceptance requirements, not implemented PostgreSQL support.
64
- Keep writes on the primary and avoid automatic retries of ambiguous commits.
65
- The existing operator adapter boundary remains available, but deploying a custom
66
- adapter requires its own conformance/load/recovery evidence.
67
-
68
- ## Shutdown and management defaults
69
-
70
- `links api` defaults to private address `127.0.0.1:3001`; public serving defaults
71
- to port 3000. Endpoint method errors return 405 with an `Allow` header.
72
- Store shutdown rejects new work, drains accepted operations within each connection and
73
- then closes SQLite. Repeated `close()` calls share completion. Existing operation
74
- deadlines still apply: a timeout can leave a mutation outcome unknown, so read
75
- the record before retrying. Missing/invalid revision metadata rejects startup.
@@ -1,135 +0,0 @@
1
- # Dynamic links: Setup: requirements, YAML and the example
2
-
3
- Part of [dynamic links](../DYNAMIC-LINKS.md), which indexes every page.
4
-
5
- ## Node build requirement
6
-
7
- Live links need a Node build carrying the patched SQLite WAL fix: SQLite 3.51.3
8
- or newer, 3.50.7 or 3.44.6. Node bundles SQLite, so this is a property of the
9
- build, not something you can install separately, and some current releases on a
10
- supported Node line ship an unpatched version. Run `urlcode doctor` and check
11
- `liveLinks`; activation fails closed and names the detected version when the
12
- build is unsuitable. Everything except live links works on any supported Node.
13
-
14
- Available in URLCode 0.1.0. Define a stable route once; create, update and delete
15
- short-code records while `serve` keeps running. No YAML rewrite, route snapshot
16
- rebuild, Git commit or public-server restart is needed for record changes.
17
-
18
- This first adapter uses optional SQLite storage on one host. Multiple processes
19
- on that host can share it. No database is required for ordinary YAML-defined
20
- routes. Network filesystems, multi-host replication and serverless ephemeral disks
21
- are not supported by this adapter. SQLite WAL requires processes on the same
22
- host; see [SQLite WAL](https://www.sqlite.org/wal.html).
23
-
24
- ## Behavior in YAML, data outside Git
25
-
26
- ```yaml
27
- version: "1"
28
- dynamicLinks: true
29
- routes:
30
- /r/{code}:
31
- parameters:
32
- - name: code
33
- in: path
34
- required: true
35
- schema: {type: string, minLength: 1, maxLength: 128}
36
- link:
37
- collection: links
38
- code: {from: path, name: code}
39
- ```
40
-
41
- `link` is a seventh primary handler, exclusive with `function`, `redirect`,
42
- `respond`, `page`, `static` and `download`. Its `code` is a declared path-input
43
- reference. `collection` is a logical name, not a filesystem path. GET and HEAD
44
- are supported; normal route precedence, lifecycle and input validation apply.
45
- An exact YAML route can intentionally shadow a stored code's URL.
46
-
47
- The operator binds `links` to a database outside the project. YAML cannot choose
48
- a database file or grant itself storage. The built-in public binding opens it
49
- read-only. It performs an indexed database lookup for each request, with no
50
- application cache or negative cache. Requests starting after an acknowledged
51
- mutation can see it immediately; an already-running request can finish with an
52
- older result. Browser/proxy caches remain outside that guarantee.
53
-
54
- Missing or disabled record: 404. Expired record: 410. Unavailable/overloaded
55
- store: 503. Invalid code syntax: 404 after route input validation. Redirects
56
- default to 302 and no-store. Query strings are not forwarded. A record's URL is
57
- literal HTTP(S), with no embedded credentials, control characters or interpolation.
58
- It is a redirect, not a server-side fetch. The store does not verify destination
59
- ownership, safety or availability; application abuse prevention remains necessary.
60
-
61
- ## Run the included example
62
-
63
- From the runtime checkout, choose a private data directory **outside the app**.
64
- Its parent directory must already exist. The following POSIX-shell example uses
65
- an operator-created directory; on Windows use an absolute local drive path.
66
-
67
- ```sh
68
- mkdir -p ../urlcode-data
69
- URLCODE_DATA="$(cd ../urlcode-data && pwd)"
70
- node src/cli.ts links init --store "$URLCODE_DATA/links.sqlite"
71
- node src/cli.ts links create --store "$URLCODE_DATA/links.sqlite" \
72
- --code demo --destination https://example.com/demo
73
- node src/cli.ts serve --project examples/live-links \
74
- --link-store "links=$URLCODE_DATA/links.sqlite" --port 3000
75
- ```
76
-
77
- Open `/r/demo`. In a second terminal, run another `links create` with an unused
78
- code against the same absolute file; its URL works immediately. Omit `--code`
79
- to generate a cryptographically random 16-character base64url code. Codes allow
80
- 1–128 letters, digits, underscores and hyphens; collection names begin with a
81
- letter and contain at most 64 letters/digits/underscores/hyphens.
82
-
83
- The CLI defaults to collection `links`; specify `--collection` to manage another.
84
- Serving currently accepts one `--link-store collection=/absolute/file` binding;
85
- embedded applications may supply multiple adapters. Commands do not copy records
86
- into YAML or create route definitions automatically.
87
-
88
- ## Explicit project opt-in
89
-
90
- Only the entry `urlcode.yaml` may set `dynamicLinks: true`. It defaults to false;
91
- the starter writes `dynamicLinks: false` explicitly. Included route files cannot
92
- set or override it. Any `link` handler, including a disabled route, or runtime
93
- link-store binding requires the opt-in. This flag means live stored-link records,
94
- not parameterized redirects, custom functions, middleware or development reload.
95
-
96
- `validate`, `routes`, `audit` and `scaffold` reports expose `dynamicLinks` as a
97
- boolean. Enabling it grants no storage access to guest code and starts no
98
- management endpoint. The operator still supplies the external store binding;
99
- management remains a separate authenticated service. Standalone `links` CRUD/API
100
- commands operate the operator's store independently of this public-runtime flag.
101
-
102
- Migration: existing live-link projects must add `dynamicLinks: true` to their
103
- entry file. Refresh revision-pinned function policies for those projects using
104
- the normal operator review flow; enabling the capability changes the approval
105
- digest. Projects that omit it or explicitly set false retain their prior digest.
106
- To disable, remove `link` declarations and serving-store bindings, then set false
107
- and validate/redeploy. Editing the flag alone does not stop an already running
108
- production process or management API. Invalid reloads retain the last-good state.
109
-
110
- ## Middleware, sandbox and tests
111
-
112
- Middleware may wrap a successful link response; the usual native metadata/body
113
- rules apply. Lookup/missing/disabled/expiry errors happen before middleware. The
114
- handler does not make database objects available to guest code. Functions still
115
- have no filesystem, SQL, fetch, storage broker or management token capability.
116
- Link changes do not alter configuration/source digests or invalidate unrelated
117
- function-binding grants. Changing YAML still does.
118
-
119
- `routes` and `audit --expect-routes` count definitions, not stored records. Link
120
- routes require explicit successful GET/HEAD fixtures; there is no assumed fixture
121
- for live data. Seed a disposable test database, then pass `--link-store` to
122
- validate/test/audit/benchmark. Do not run mutation tests against production.
123
-
124
- ```sh
125
- node src/cli.ts test --project examples/live-links --link-store links=/absolute/test-links.sqlite
126
- node src/cli.ts audit --project examples/live-links --link-store links=/absolute/test-links.sqlite --expect-routes 2
127
- ```
128
-
129
- The example expects `demo -> https://example.com/demo` and an unused `not-created`
130
- code. Tests cover create/update/delete visibility without reload, persistence,
131
- concurrent conflicts, expiry/disabled semantics, scope, public/admin separation,
132
- body/token boundaries, overload and acknowledged writes after abrupt writer exit.
133
- Production durability, sustained load and recovery drills still require validation
134
- on your actual storage. General application state, WebRTC sessions, user-account
135
- APIs and arbitrary runtime code registration remain separate future work.
@@ -1,30 +0,0 @@
1
- # YAML guide: Live short-link records
2
-
3
- Snippets are entries under `routes:` unless stated otherwise; the [guide index](../YAML-GUIDE.md) lists every page.
4
-
5
- ## 15. Live short-link records
6
-
7
- Set `dynamicLinks: true` in the entry `urlcode.yaml` before adding this route.
8
- It defaults to false and cannot be enabled by an included file.
9
-
10
- ```yaml
11
- /r/{code}:
12
- parameters:
13
- - name: code
14
- in: path
15
- required: true
16
- schema: {type: string, minLength: 1, maxLength: 128}
17
- link:
18
- collection: links
19
- code: {from: path, name: code}
20
- ```
21
-
22
- The seventh handler resolves stored records without rebuilding YAML. It requires
23
- an external operator store binding; the [live-link example](../../examples/live-links/README.md)
24
- has separate setup and fixtures. See [dynamic links](../DYNAMIC-LINKS.md) for CLI/API
25
- creation, optimistic updates, disabled/expired records, persistence and backups.
26
- This is not a general database capability for sandboxed functions.
27
-
28
- Live-link recipes require `dynamicLinks: true` in the entry `urlcode.yaml`. It is
29
- false by default and cannot be set in included route files. Parameterized routes
30
- and functions alone do not need it. See [dynamic-link opt-in](../DYNAMIC-LINKS.md#explicit-project-opt-in).
@@ -1,11 +0,0 @@
1
- # Live links without route reloads
2
-
3
- Follow [the dynamic-link guide](../../docs/DYNAMIC-LINKS.md) to create an external
4
- SQLite store and seed `demo -> https://example.com/demo`. Then start this project
5
- with `--link-store links=/absolute/path/links.sqlite`. Its fixtures expect that
6
- seed and an unused `not-created` code; use a disposable test store, not production.
7
-
8
- Create/update/delete records through the CLI or separate authenticated API while
9
- the public server keeps serving. YAML, code, route counts and health version do
10
- not change per link. This example deliberately requires explicit operator storage
11
- binding; the default starter still runs without a database.
@@ -1,21 +0,0 @@
1
- id: live-links
2
- description: Stored links served from an external SQLite store without a route reload, with dynamicLinks enabled in the entry file.
3
- tags: [links, live, dynamic, sqlite, store, short-links, link, redirect]
4
- complexity: intermediate
5
- capabilities: [dynamicLinks, enabled, link, methods, parameters, respond]
6
- targets: {self-hosted: compatible, aws: refused, vercel: refused, cloudflare: refused}
7
- routes: 2
8
- services:
9
- - name: link store
10
- description: An operator-owned SQLite store passed as --link-store, seeded with demo -> https://example.com/demo.
11
- grants:
12
- - kind: link-store
13
- description: The store binding is an operator flag, never a project file.
14
- files: [urlcode.yaml, tests/requests.json, README.md]
15
- tests:
16
- fixtures: tests/requests.json
17
- commands:
18
- - node src/cli.ts links init --store /absolute/links.sqlite
19
- - node src/cli.ts test --project examples/live-links --link-store links=/absolute/links.sqlite
20
- behavior:
21
- - "/r/{code} answers the stored destination for a seeded code and 404 for one that was never created"
@@ -1,6 +0,0 @@
1
- [
2
- {"path":"/r/demo","status":302,"expectHeaders":{"location":"https://example.com/demo","cache-control":"no-store"}},
3
- {"path":"/r/demo","method":"HEAD","status":302,"expectBody":"","expectHeaders":{"location":"https://example.com/demo"}},
4
- {"path":"/r/not-created","status":404},
5
- {"path":"/r/demo","method":"POST","status":405,"expectHeaders":{"allow":"GET, HEAD"}}
6
- ]