@crvouga/mockingbird-service-junction 0.1.2 → 0.2.0

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/README.md CHANGED
@@ -1,20 +1,25 @@
1
1
  # @crvouga/mockingbird-service-junction
2
2
 
3
- Stateful, in-process mock of the [Junction (formerly Vital) API](https://docs.junction.com/) for
4
- test suites: users (`/v2/user`), the lab-testing catalog, lab orders (create, cancel, simulate,
5
- results, requisitions), and at-home phlebotomy / patient-service-center (PSC) scheduling
6
- (availability, booking, reschedule, cancel), plus the `labtest.order.*` and
7
- `labtest.appointment.updated` webhook events. All 39 operations in the vendored OpenAPI subset are
8
- served; behaviour is verified by differential property tests against the Junction sandbox.
3
+ Stateful mock of the [Junction (formerly Vital) API](https://docs.junction.com/): users
4
+ (`/v2/user`), the lab-testing catalog, lab orders (create, cancel, simulate, results,
5
+ requisitions), at-home phlebotomy and patient-service-center (PSC) scheduling, and the
6
+ `labtest.order.*` / `labtest.appointment.updated` webhooks. All 39 operations in the vendored
7
+ OpenAPI subset are served, and behaviour is checked by differential property tests against the
8
+ Junction sandbox.
9
9
 
10
- Use it when your backend calls Junction via `fetch` or `@tryvital/vital-node` and you want tests to
11
- run offline, deterministically, with no sandbox key.
10
+ Use it to take Junction off your suite's critical path: no sandbox key, no shared 50-user sandbox
11
+ cap, no cross-suite interference. Serve it as a local origin with one command, or run it in-process.
12
+
13
+ ```bash
14
+ npx mockingbird-junction serve # http://127.0.0.1:8787, recorded sandbox corpus loaded
15
+ ```
12
16
 
13
17
  - Operation coverage: [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/junction/SUPPORT.md)
14
- - Drop-in readiness, sealed corpus and sandbox quirks the mock mirrors:
18
+ - Drop-in readiness and the sandbox quirks the mock mirrors:
15
19
  [docs/drop-in.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/junction/docs/drop-in.md)
16
- - Consumer wiring checklist: [docs/qa-followon.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/junction/docs/qa-followon.md)
17
20
  - Behaviour notes: [docs/behavior.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/junction/docs/behavior.md)
21
+ - **What the mock does not model:** [below](#what-is-and-is-not-modelled). Read it before trusting a
22
+ green suite.
18
23
 
19
24
  ## Install
20
25
 
@@ -22,22 +27,118 @@ run offline, deterministically, with no sandbox key.
22
27
  npm install -D @crvouga/mockingbird-service-junction
23
28
  ```
24
29
 
25
- ESM only. Requires Node >= 22 or Bun >= 1.2. No native dependencies: state lives in an in-memory
26
- SQLite engine (pure TypeScript, bundled in). To serve it over HTTP use `Bun.serve` under Bun, or
27
- install `@hono/node-server` under Node.
30
+ ESM only; Node >= 22 or Bun >= 1.2 (CommonJS callers on Node >= 22.12 can `require()` it). No
31
+ native dependencies: state lives in an in-memory SQLite engine written in TypeScript, bundled in.
32
+
33
+ | Entry point | Runtime | What it is |
34
+ | --- | --- | --- |
35
+ | `@crvouga/mockingbird-service-junction` | any (Node, Bun, Workers, browsers) | `JunctionAPI`, `createRuntime`, corpus and verify tools |
36
+ | `@crvouga/mockingbird-service-junction/server` | Node | `createServer()` — a listening HTTP server |
37
+ | `@crvouga/mockingbird-service-junction/corpus` | any | `defaultCorpus` — the recording shipped in the package (a separate entry, so importing the mock never parses it) |
38
+ | `mockingbird-junction` (bin) | Node | `serve`, `corpus pull`, `corpus diff`, `verify` |
28
39
 
29
40
  ## Usage
30
41
 
31
- Behaviour the examples rely on (all from the source):
42
+ ### `mockingbird-junction serve`
43
+
44
+ ```bash
45
+ npx mockingbird-junction serve --port 8787
46
+ npx mockingbird-junction serve --corpus ./test/junction-corpus.json --lab-accounts ./test/lab-accounts.json
47
+ npx mockingbird-junction serve --webhook-url http://127.0.0.1:3100/webhooks/junction --webhook-secret whsec_...
48
+ npx mockingbird-junction serve --help
49
+ ```
50
+
51
+ | Flag | Default | Meaning |
52
+ | --- | --- | --- |
53
+ | `--port <n>` | `8787` | Port to listen on (`0` for any free port; the bound URL is printed) |
54
+ | `--host <host>` | `127.0.0.1` | Interface to bind |
55
+ | `--corpus <default\|none\|file>` | `default` | The shipped corpus, no corpus (synthetic data), or a file from `corpus pull` |
56
+ | `--geo <corpus\|synthetic>` | `corpus` with a corpus | How unknown ZIPs are answered; see [Geo](#geo-corpus-or-synthetic) |
57
+ | `--lab-accounts <file>` | built-in fixtures | JSON array of [lab accounts](#lab-accounts) |
58
+ | `--webhook-url <url>` / `--webhook-secret <whsec_…>` | off | Deliver [signed webhooks](#webhooks) (secret also from `MOCKINGBIRD_JUNCTION_WEBHOOK_SECRET`) |
59
+ | `--webhook-retry-delays <ms,…>` | Svix's schedule | Delay before each delivery attempt, e.g. `0,1000,5000` in tests |
60
+ | `--webhook-scope <scope>` | — | Sent as `x-mockingbird-scope` on every delivery |
61
+ | `--admin-key <key>` | open | Require `x-mockingbird-admin-key` on `/__admin/*` (also `MOCKINGBIRD_ADMIN_KEY`) |
62
+ | `--seed <seed>` | `0` | Seeds fault rates and retry jitter |
63
+ | `--log <pretty\|json\|off>` | `pretty` | One line per request: operation id, status, duration, namespace, fault |
64
+ | `--seed-url <url>` / `--seed-key <key>` | — | Pull a corpus from a live team at boot. Slow and a live dependency; prefer a committed `corpus pull` file |
65
+ | `--config <file>` | — | Serve every service in a `mockingbird.json` instead ([below](#many-services-from-one-config)) |
66
+
67
+ At startup it prints the listen address, the loaded corpus (its version label, observation, ZIP,
68
+ lab-test and lab-account counts, recording date and source), the geo mode, and whether webhook
69
+ delivery is on.
70
+
71
+ ### Many services from one config
72
+
73
+ Any Mockingbird service's CLI can boot every service in a config file, so a stack adds a config
74
+ entry per vendor instead of a wrapper process per vendor. Each service named must be installed
75
+ (`junction` loads `@crvouga/mockingbird-service-junction`).
76
+
77
+ ```json
78
+ {
79
+ "log": "json",
80
+ "services": {
81
+ "junction": {
82
+ "port": 8787,
83
+ "adminKey": "local-admin",
84
+ "options": { "corpus": "./test/junction-corpus.json", "webhook-url": "http://127.0.0.1:3100/webhooks/junction" }
85
+ },
86
+ "stripe": { "port": 12111 }
87
+ }
88
+ }
89
+ ```
90
+
91
+ ```bash
92
+ npx mockingbird-junction serve --config mockingbird.json
93
+ ```
94
+
95
+ `options` takes the service's own `serve` flags by long name.
96
+
97
+ ### `createServer` (Node)
98
+
99
+ ```ts
100
+ import { createServer } from "@crvouga/mockingbird-service-junction/server"
101
+
102
+ const server = await createServer() // shipped corpus, any free port
103
+ const health = await fetch(`${server.url}/health`)
104
+ console.log(health.status) // 200
105
+
106
+ // Point the Vital SDK (or your backend's Junction base URL) at server.url, then:
107
+ await server.close()
108
+ ```
109
+
110
+ `createServer(options)` takes every [`createRuntime`](#createruntime-any-fetch-server) option plus
111
+ `port` (default `0`), `host` (default `127.0.0.1`) and `corpus` (`"default"`, `"none"`, a file path
112
+ or a loaded corpus). The result has `url`, `port`, `runtime` and `close()`.
113
+
114
+ ### `createRuntime` (any Fetch server)
115
+
116
+ `createRuntime` is the whole served mock — health, admin, namespaces, clock, faults, metrics — as a
117
+ single runtime-neutral `fetch(request)`. Hand it to `Bun.serve`, a Worker, or call it directly:
118
+
119
+ ```ts
120
+ import { createRuntime } from "@crvouga/mockingbird-service-junction"
121
+ import { defaultCorpus } from "@crvouga/mockingbird-service-junction/corpus"
122
+
123
+ const junction = createRuntime({ corpus: defaultCorpus })
124
+ const as = (worker: string) => ({
125
+ "x-vital-api-key": "sk_us_mockingbird",
126
+ "x-mockingbird-namespace": worker, // isolates this worker's data
127
+ "content-type": "application/json",
128
+ })
129
+
130
+ await junction.fetch(
131
+ new Request("http://junction.local/v2/user", {
132
+ method: "POST",
133
+ headers: as("worker-1"),
134
+ body: JSON.stringify({ client_user_id: "patient-1" }),
135
+ }),
136
+ )
137
+ const other = await junction.fetch(new Request("http://junction.local/v2/user", { headers: as("worker-2") }))
138
+ console.log(await other.json()) // worker-2 sees none of worker-1's users
32
139
 
33
- - **Any host works.** Routing uses only the path (`/v2/...`, `/v3/...`).
34
- - **Auth:** every request needs an `x-vital-api-key` header; any value is accepted (sandbox keys
35
- look like `sk_us_*` / `sk_eu_*`). Without it the response is `401 {"detail":"Missing x-vital-api-key"}`.
36
- State is not partitioned by key: all requests share one team (`team_id`
37
- `11111111-1111-4111-8111-111111111111`).
38
- - Bodies are JSON with the API's snake_case field names.
39
- - A default synthetic catalog is installed at construction (lab tests with methods
40
- `walk_in_test`, `testkit` and `at_home_phlebotomy`); `installCorpus` replaces it with a recording.
140
+ await junction.reset("worker-1") // or junction.reset("*") for every namespace
141
+ ```
41
142
 
42
143
  ### In-process (inject `fetch`)
43
144
 
@@ -95,39 +196,6 @@ console.log(junction.webhookEvents().map((event) => event.event_type)) // ["labt
95
196
  simulations (`POST /v3/order/{id}/test?final_status=completed&delay=<seconds>`) become due; the
96
197
  delayed transition is applied on the next `GET /v3/order/{id}` whose `now` is past the due time.
97
198
 
98
- ### Over HTTP
99
-
100
- ```ts
101
- import { JunctionAPI } from "@crvouga/mockingbird-service-junction"
102
-
103
- const junction = new JunctionAPI()
104
- const server = Bun.serve({
105
- port: 0, // ephemeral
106
- hostname: "127.0.0.1",
107
- fetch: (request) => junction.fetch(request),
108
- })
109
- const baseUrl = `http://127.0.0.1:${server.port}`
110
-
111
- const response = await fetch(`${baseUrl}/v2/user`, {
112
- headers: { "x-vital-api-key": "sk_us_mockingbird" },
113
- })
114
- console.log(response.status) // 200
115
-
116
- server.stop()
117
- ```
118
-
119
- On Node use any Fetch-style server, e.g. `@hono/node-server` (`npm install -D @hono/node-server`),
120
- whose callback receives the bound port:
121
-
122
- ```js
123
- import { serve } from "@hono/node-server"
124
- const server = serve(
125
- { fetch: (request) => junction.fetch(request), port: 0, hostname: "127.0.0.1" },
126
- (info) => console.log(`http://127.0.0.1:${info.port}`),
127
- )
128
- // ... later: server.close()
129
- ```
130
-
131
199
  ### Pointing `@tryvital/vital-node` at it
132
200
 
133
201
  The official SDK takes the base URL as `environment` (this is how the package's SDK drop-in test
@@ -143,121 +211,355 @@ const user = await client.user.create({ clientUserId: "app-user-1" })
143
211
  If your app guards Junction hosts with an allowlist, allow `127.0.0.1` / `localhost` for tests
144
212
  while keeping the production and sandbox guards intact.
145
213
 
146
- ### Webhooks
214
+ ## The service contract
147
215
 
148
- `onWebhook` is called synchronously with every event the mock records
149
- (`labtest.order.created`, `labtest.order.updated`, `labtest.appointment.updated`). Delivery and
150
- signing are up to you. Junction delivers through Svix; the contributor server script signs with
151
- `svix-id`, `svix-timestamp` and `svix-signature: v1,<base64 HMAC-SHA256 of "<timestamp>.<body>">`,
152
- keyed by the base64-decoded secret after stripping a `whsec_` prefix:
216
+ Every Mockingbird service answers the same control surface, outside the vendor's auth gate:
153
217
 
154
- ```ts
155
- import { createHmac, randomUUID } from "node:crypto"
156
- import { JunctionAPI } from "@crvouga/mockingbird-service-junction"
218
+ - `GET /health` — unauthenticated readiness probe:
219
+ `{ "status": "ok", "service": "junction", "corpus": "<label>", "geo": "corpus", … }`.
220
+ - `/__admin/*` — the control plane. Open by default; with `--admin-key` / `adminKey` it requires
221
+ `x-mockingbird-admin-key`, which is separate from any vendor key. Admin errors have one shape,
222
+ `{ "error": { "type": "mockingbird_admin", "message": "…" } }`, so they can never be confused
223
+ with a Junction error. `GET /__admin` lists every route.
224
+ - `x-mockingbird-namespace: <name>` — isolates a request's data (`[A-Za-z0-9_.-]{1,64}`). Each
225
+ namespace is a separate team over one shared database, so parallel workers share one process
226
+ without seeing each other. The corpus is shared read-only; creating a namespace is cheap. Admin
227
+ routes take the namespace from `?namespace=`, then the header, then `default`.
157
228
 
158
- const WEBHOOK_URL = "http://127.0.0.1:3100/webhooks/junction"
159
- const WEBHOOK_SECRET = "whsec_bW9ja2luZ2JpcmQtdGVzdC1zZWNyZXQ="
160
-
161
- const junction = new JunctionAPI({
162
- onWebhook: (event) => {
163
- const timestamp = Math.floor(Date.now() / 1000).toString()
164
- const body = JSON.stringify(event)
165
- const key = Buffer.from(WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64")
166
- const signature = createHmac("sha256", key).update(`${timestamp}.${body}`).digest("base64")
167
- void fetch(WEBHOOK_URL, {
168
- method: "POST",
169
- headers: {
170
- "content-type": "application/json",
171
- "svix-id": randomUUID(),
172
- "svix-timestamp": timestamp,
173
- "svix-signature": `v1,${signature}`,
174
- },
175
- body,
176
- }).catch(() => undefined)
177
- },
178
- })
179
- console.log(junction.webhookEvents().length) // 0
229
+ | Route | Does |
230
+ | --- | --- |
231
+ | `POST /__admin/reset` | Reset the request's namespace; `?all=1` resets every namespace |
232
+ | `GET /__admin/namespaces` | Namespaces created so far |
233
+ | `POST /__admin/snapshots` | Snapshot the namespace; returns `{ id }` |
234
+ | `POST /__admin/snapshots/{id}/restore` | Restore it (an assignment: later writes are discarded) |
235
+ | `DELETE /__admin/snapshots/{id}` | Forget a snapshot |
236
+ | `GET` / `POST /__admin/clock` | Read or move the clock: `{ "set": "2030-01-01T00:00:00Z" }`, `{ "advance": "2h" }`, `{ "freeze": true }`, `{ "reset": true }` |
237
+ | `GET` / `POST` / `DELETE /__admin/faults` | List, add or clear [fault rules](#faults-and-error-shapes) (`DELETE ?id=` removes one) |
238
+ | `POST /__admin/faults/presets/{name}` | Add a named Junction fault; body may override `count`, `rate`, `operationId`… |
239
+ | `GET /__admin/metrics` | Requests by operation and status, fault count, and **unmatched routes** |
240
+ | `GET /__admin/orders` | Orders in the namespace with their current status |
241
+ | `POST /__admin/orders/{id}/transition` | Move an order: `{ "to": "completed", "result": "abnormal" }` ([order control](#order-control)) |
242
+ | `GET` / `PUT /__admin/results/{id}` | Read or install an exact result payload / PDF for an order |
243
+ | `GET /__admin/result-fixtures` | The named results |
244
+ | `GET` / `PUT /__admin/lab-accounts` | Read or replace the namespace's [lab accounts](#lab-accounts) (`{ "accounts": null }` restores the default) |
245
+ | `GET /__admin/corpus`, `PUT /__admin/geo` | The loaded corpus; switch [geo mode](#geo-corpus-or-synthetic) |
246
+ | `GET /__admin/webhooks` | [Deliveries](#webhooks) with every attempt |
247
+ | `POST /__admin/webhooks/{id}/replay`, `POST /__admin/webhooks/flush` | Redeliver one message; run pending retries now |
248
+ | `GET /__admin/webhooks/events` | Every event recorded, delivered or not |
249
+
250
+ `GET /__admin/metrics` → `unmatched` counts requests to paths the contract does not have. It is how
251
+ a new SDK call shows up — as a count — before it fails a suite as a 404.
252
+
253
+ ## Corpus
254
+
255
+ Provider-owned inventory — area serviceability, PSC site lists, the lab catalog, labs, lab
256
+ accounts — cannot be synthesized faithfully. A corpus is an exact recording of those
257
+ parameter-stable reads, and the mock answers them byte for byte.
258
+
259
+ **The shipped corpus** (`defaultCorpus`, and what `serve` loads unless told otherwise) was recorded
260
+ from the Junction sandbox. It covers area and PSC serviceability for 57 ZIPs across the US, the PSC
261
+ labs those ZIPs reach, and the recording team's catalog. The catalog and lab accounts belong to the
262
+ **recording team**: lab-test ids are per team, so your product's ids will not be in it. For your
263
+ catalog and your lab accounts, record your own team and commit the file:
264
+
265
+ ```bash
266
+ npx mockingbird-junction corpus pull --real-key "$JUNCTION_SANDBOX_KEY" --out test/junction-corpus.json
267
+ npx mockingbird-junction corpus pull --real-key "$JUNCTION_SANDBOX_KEY" --base test/junction-corpus.json \
268
+ --zip 10001,94105 --coverage-only --out test/junction-corpus.json # add ZIPs to it
269
+ npx mockingbird-junction corpus diff test/junction-corpus.json /tmp/fresh.json # exit 1 when they differ
180
270
  ```
181
271
 
182
- `webhookDeliveryAttempts()` returns the retry schedule Junction would follow for each event
183
- (8 attempts: immediately, then 5s, 5m, 30m, 2h, 5h, 10h, 10h, with jitter controlled by the
184
- `webhook` option). It is a record only; nothing is re-sent.
272
+ `corpus pull` only issues GETs, refuses non-sandbox keys (`sk_us_…` / `sk_eu_…`) unless given
273
+ `--allow-any-key`, and stamps the file with a SHA-256 `fingerprint` of its content (not its
274
+ recording time), so re-pulling unchanged data yields the same fingerprint. Availability is never
275
+ recorded: its slot dates and single-use `booking_key`s cannot be replayed, so it always comes from
276
+ the deterministic generator.
277
+
278
+ ### Geo: corpus or synthetic
279
+
280
+ With a corpus loaded, serviceability reads (`area/info`, `psc/info`, phlebotomy and PSC
281
+ availability) answer **only** for ZIPs the corpus covers. Any other well-formed ZIP gets:
282
+
283
+ ```json
284
+ {
285
+ "detail": {
286
+ "error_type": "MOCKINGBIRD_UNKNOWN_ZIP",
287
+ "error_message": "ZIP 00501 is not in the loaded Junction corpus (57 ZIPs covered). Record it with `mockingbird-junction corpus pull --zip 00501`, or serve with --geo synthetic to invent coverage.",
288
+ "zip_code": "00501",
289
+ "corpus": "v1-2026-09-18-HqFDuEAuhkwD"
290
+ }
291
+ }
292
+ ```
185
293
 
186
- ### Resetting between tests
294
+ with status **424**, which Junction never sends and HTTP clients do not retry: a fixture gap fails
295
+ loudly instead of silently disagreeing with production. A malformed ZIP still gets Junction's own
296
+ 422. `--geo synthetic` (the default without a corpus) invents plausible, deterministic coverage
297
+ instead — right for property tests that walk random ZIPs, wrong for a suite standing in for
298
+ production.
187
299
 
188
- `reset()` clears users, orders, appointments and webhook events, then re-installs the default
189
- catalog, or the corpus passed to `installCorpus` if there was one:
300
+ ## Lab accounts
190
301
 
191
- ```ts
192
- import { beforeEach, expect, test } from "bun:test"
193
- import { JunctionAPI } from "@crvouga/mockingbird-service-junction"
302
+ `create_order` routes, accepts and rejects `lab_account_id` against the team's lab accounts,
303
+ following Junction's documented rules: an explicit id must exist, be linked to the team, belong to
304
+ the ordered lab and be active; with no id, one active account for the lab is selected, several are
305
+ an error, and a lab with none falls back to Junction's platform account. The billing type must be
306
+ allowed in the patient's state by the account's `allowed_billing`.
194
307
 
195
- const junction = new JunctionAPI()
196
- beforeEach(() => junction.reset())
308
+ The accounts come from, in order: `labAccounts` / `--lab-accounts`, else the corpus (a pulled
309
+ corpus carries your team's real accounts), else built-in fixtures. Configure them as
310
+ `ClientFacingLabAccount`s, with `states` as shorthand for client-bill states:
197
311
 
198
- test("starts with no users", async () => {
199
- const response = await junction.fetch(
200
- new Request("https://api.sandbox.tryvital.io/v2/user", {
201
- headers: { "x-vital-api-key": "sk_us_mockingbird" },
202
- }),
203
- )
204
- expect(await response.json()).toMatchObject({ users: [], total: 0 })
312
+ ```ts
313
+ import { createRuntime, US_STATES } from "@crvouga/mockingbird-service-junction"
314
+
315
+ const junction = createRuntime({
316
+ labAccounts: [
317
+ { id: "acct-bioref-nynj", lab: "bioreference", states: ["NY", "NJ"] },
318
+ {
319
+ id: "acct-labcorp-47",
320
+ lab: "labcorp",
321
+ states: US_STATES.filter((state) => !["NY", "NJ", "RI"].includes(state)),
322
+ },
323
+ { id: "acct-quest", lab: "quest", delegated_flow: "order_delegated" },
324
+ ],
205
325
  })
326
+ console.log(junction.instance().labAccounts().length) // 3
327
+ ```
328
+
329
+ Rejections keep Junction's body shape, `400 {"detail": "Lab account is not associated with lab labcorp"}`.
330
+ `PUT /__admin/lab-accounts` swaps them per namespace at runtime. `delegated_flow` is stored and
331
+ listed, but see [not modelled](#what-is-and-is-not-modelled).
332
+
333
+ ## Order control
334
+
335
+ `simulate_order` (`POST /v3/order/{id}/test`) behaves like the sandbox's, including its stepwise
336
+ rules. For direct control, the admin API moves an order straight to a status, recording the same
337
+ event, dates and appointment cascade, and publishing the same webhook, a real transition does:
338
+
339
+ ```bash
340
+ curl -X POST localhost:8787/__admin/orders/$ORDER/transition -d '{"to": "collected"}'
341
+ curl -X POST localhost:8787/__admin/orders/$ORDER/transition -d '{"to": "completed", "result": "critical"}'
342
+ curl -X POST localhost:8787/__admin/orders/$ORDER/transition -d '{"to": "cancelled.testkit.do_not_process"}'
343
+ ```
344
+
345
+ `to` is a full status (`completed.testkit.completed`), a `<phase>.<event>` pair, or a name that maps
346
+ onto the contract's own status for the order's collection method:
347
+
348
+ | Name | `at_home_phlebotomy` | `on_site_collection` | `testkit` | `walk_in_test` |
349
+ | --- | --- | --- | --- | --- |
350
+ | `requisition_created` | `received.…requisition_created` | same | same | same |
351
+ | `scheduled` | `collecting_sample.…appointment_scheduled` | — | — | `collecting_sample.…appointment_scheduled` |
352
+ | `shipped` / `delivered` | — | — | `collecting_sample.…transit_customer` / `…with_customer` | — |
353
+ | `collected` | `collecting_sample.…draw_completed` | `sample_with_lab.…draw_completed` | `collecting_sample.…transit_lab` | — |
354
+ | `at_lab` | — | `sample_with_lab.…draw_completed` | `sample_with_lab.…delivered_to_lab` | — |
355
+ | `partial_results` | `sample_with_lab.…partial_results` | same | — | same |
356
+ | `completed` / `corrected` / `cancelled` / `failed` | `completed.…completed` / `completed.…corrected` / `cancelled.…cancelled` / `failed.…sample_error` | same | same | same |
357
+
358
+ A name with no real status for the method (`collected` for a walk-in test) is refused with the
359
+ valid choices, never approximated. `result` picks a named result — `normal`, `abnormal`,
360
+ `critical`, `missing_results` — which steers the generator so the payload keeps Junction's exact
361
+ shape. For an exact payload, `PUT /__admin/results/{id}` with `{ "results": [...],
362
+ "missing_results": null, "interpretation": "critical", "pdf_base64": "…" }`.
363
+
364
+ There is no `isTestCancelled` in Junction's contract; cancelling produces the vendor's
365
+ `cancelled.<method>.cancelled` event and webhook, which is what a consumer derives that flag from.
366
+
367
+ **Time.** Every timestamp reads the runtime clock. `POST /__admin/clock {"advance": "2h"}` makes
368
+ delayed simulations (`?delay=<seconds>`) due and moves appointment windows, with no sleeping.
369
+
370
+ ## Faults and error shapes
371
+
372
+ Error bodies the mock reproduces byte for byte, checked against the sandbox by `verify` and the
373
+ parity suite:
374
+
375
+ | Case | Status | Body |
376
+ | --- | --- | --- |
377
+ | `GET /v2/user/{unknown id}` | 404 | `{"detail":"Not found"}` |
378
+ | `GET /v2/user/resolve/{unknown client_user_id}` | 404 | `{"detail":"User not found"}` |
379
+ | Missing `x-vital-api-key` | 401 | `{"detail":"Missing x-vital-api-key"}` |
380
+ | Request validation | 422 | `{"detail":[{"type":…,"loc":[…],"msg":…,"input":…}]}` (Pydantic shape) |
381
+ | Business rule | 400 | `{"detail":"<message>"}` or `{"detail":{"error_type":"INVALID_REQUEST","error_message":"…"}}` |
382
+
383
+ Faults reproduce the sandbox's failure modes on demand, for retry paths and error handling:
384
+
385
+ | Preset | Fault | Fidelity |
386
+ | --- | --- | --- |
387
+ | `sandbox_user_quota` | `create_user` → 400 `{"detail":{"error_type":"INVALID_REQUEST","error_message":"You have reached the maximum of 50 Sandbox users"}}` | byte-exact, as observed from a sandbox team |
388
+ | `rate_limited` | 429 `{"detail":"Too Many Requests"}`, `retry-after: 1` | shape-plausible, not verified |
389
+ | `server_error` / `bad_gateway` / `unavailable` | 500 / 502 / 503 | shape-plausible, not verified |
390
+
391
+ ```ts
392
+ import { createRuntime, FAULT_PRESETS } from "@crvouga/mockingbird-service-junction"
393
+
394
+ const junction = createRuntime()
395
+ junction.faults.add({ ...FAULT_PRESETS.sandbox_user_quota, id: "quota", count: 1 }) // next create_user only
396
+ junction.faults.add({ id: "flaky-orders", operationId: "create_order_v3_order_post", status: 503, rate: 0.25 })
206
397
  ```
207
398
 
208
- ### Sealed corpus (optional, higher fidelity)
399
+ A rule matches on `operationId`, `method`, `pathPrefix` and `namespace` (all optional), fires
400
+ `count` times or forever, for a `rate` of matching requests (seeded, so a run replays identically),
401
+ after an optional `delayMs`. A rule added through the admin API defaults to the calling namespace,
402
+ so one worker's injected failure never lands on another's request; one added in-process, as above,
403
+ applies to every namespace unless given a `namespace`. Over HTTP: `POST /__admin/faults` with the same fields, or
404
+ `POST /__admin/faults/presets/sandbox_user_quota`.
209
405
 
210
- Provider-owned inventory (area serviceability, PSC site lists, the team's lab catalog, labs, lab
211
- accounts) cannot be synthesized faithfully. A sealed corpus is an exact sandbox recording of those
212
- parameter-stable GETs; installing it makes the mock answer them byte-for-byte. The recording in the
213
- repo (`corpus/sandbox-sealed.json`) is not shipped in the npm package; copy it or record your own
214
- (see Development).
406
+ ## Webhooks
407
+
408
+ With `webhooks` / `--webhook-url`, every event is delivered signed, with retries:
409
+
410
+ - **Standard Svix signatures**: `svix-id`, `svix-timestamp`, and `svix-signature: v1,<base64
411
+ HMAC-SHA256 of "<svix-id>.<svix-timestamp>.<body>">`, keyed by the base64 part of `whsec_…`. The
412
+ official `svix` verifier, and a receiver written for real Junction, accept it unchanged. (Signing
413
+ `"<timestamp>.<body>"` without the message id, as this package's own server script did before,
414
+ is not Svix-compatible.)
415
+ - Timestamps are wall-clock even when the mock clock is moved, because receivers reject timestamps
416
+ outside a few minutes of their own clock.
417
+ - Any 2xx is delivered; anything else is retried on Svix's schedule (immediately, 5 s, 5 min,
418
+ 30 min, 2 h, 5 h, 10 h, 10 h) or `retryDelaysMs`. `POST /__admin/webhooks/flush` runs pending
419
+ retries now; `POST /__admin/webhooks/{id}/replay` redelivers one message.
215
420
 
216
421
  ```ts
217
- import { readFileSync } from "node:fs"
218
- import { JunctionAPI, parseSealedCorpus } from "@crvouga/mockingbird-service-junction"
422
+ import { createRuntime, verifySvix } from "@crvouga/mockingbird-service-junction"
219
423
 
220
- const junction = new JunctionAPI()
221
- const recording: unknown = JSON.parse(readFileSync("test/junction-sandbox-sealed.json", "utf8"))
222
- junction.installCorpus(parseSealedCorpus(recording)) // re-applied on every reset()
424
+ const secret = "whsec_bW9ja2luZ2JpcmQtdGVzdC1zZWNyZXQ="
425
+ const junction = createRuntime({
426
+ webhooks: { url: "http://127.0.0.1:3100/webhooks/junction", secret, retryDelaysMs: [0, 500, 2000] },
427
+ })
428
+
429
+ // In a test receiver:
430
+ const accept = async (request: Request) =>
431
+ verifySvix(
432
+ secret,
433
+ {
434
+ "svix-id": request.headers.get("svix-id") ?? "",
435
+ "svix-timestamp": request.headers.get("svix-timestamp") ?? "",
436
+ "svix-signature": request.headers.get("svix-signature") ?? "",
437
+ },
438
+ await request.text(),
439
+ )
440
+ console.log(typeof accept, junction.webhooks?.deliveries().length) // "function" 0
441
+ ```
442
+
443
+ ## Verify
444
+
445
+ `verify` is the fidelity guarantee: it runs the same requests against real Junction and the mock
446
+ and reports every divergence.
447
+
448
+ ```bash
449
+ npx mockingbird-junction verify --real-key "$JUNCTION_SANDBOX_KEY" --corpus test/junction-corpus.json
450
+ npx mockingbird-junction verify --real-key "$JUNCTION_SANDBOX_KEY" --orders --sample 50 --json
223
451
  ```
224
452
 
225
- Availability is never recorded (its slot dates and single-use `booking_key`s cannot be replayed);
226
- it always comes from the deterministic generator.
453
+ - **Drift**: re-fetches each recorded observation (or `--sample n` of them) and reports any that no
454
+ longer match production — the signal to re-pull.
455
+ - **Scenario**: creates a user, reads it back, provokes the documented 404s, reads the catalog and a
456
+ covered ZIP's serviceability, and deletes the user; with `--orders`, also places, reads and
457
+ cancels an order for the corpus's first lab test. Status codes must match; bodies must match in
458
+ shape, and byte for byte for the documented error bodies.
459
+
460
+ It exits 1 on any divergence. The scenario always deletes its user (and cancels its order), because
461
+ it runs against the same capped sandbox the mock exists to relieve. `verifyAgainstReal` is the same
462
+ check as a function. This repo runs it in CI against the Junction sandbox.
463
+
464
+ ## Determinism
465
+
466
+ With a fixed clock (`clock` / `POST /__admin/clock {"set": …, "freeze": true}`) and `seed`, a run
467
+ replays exactly. Stable across runs: user ids, order ids, event ids, sample ids, booking keys and
468
+ webhook message ids (derived from sequence numbers), timestamps (from the clock), generated
469
+ availability, and which requests a partial-`rate` fault hits. Wall-clock by design: webhook
470
+ `svix-timestamp`, `/health` uptime, and request log durations.
471
+
472
+ ## What is and is not modelled
473
+
474
+ A mock that is silent about its gaps is how a green suite starts lying. Specifically:
475
+
476
+ - **Corpus-backed** (byte-exact): area and PSC serviceability for covered ZIPs; the catalog, labs,
477
+ expected results and lab accounts of the team the corpus was recorded from.
478
+ - **Synthetic** (deterministic, shape-faithful, values invented): availability slots and booking
479
+ keys; results (biomarker lines and PDFs) unless a fixture is installed; requisition PDFs;
480
+ coverage for any ZIP in `--geo synthetic` mode.
481
+ - **Not modelled**:
482
+ - `delegated_flow`-specific ordering. Accounts carry the field and orders follow the documented
483
+ selection and billing rules above, but any additional behaviour Junction applies to delegated
484
+ accounts is not reproduced. Run `verify --orders` with your team's corpus to check what you rely on.
485
+ - Rate limits and outages, except when injected as faults.
486
+ - Real 429 / 5xx bodies: the presets are plausible, not recorded.
487
+ - Phlebotomy availability breadth: the sandbox serves it only for `85004`; the mock generates slots
488
+ for any covered ZIP.
489
+ - Webhook retry jitter across a snapshot restore (the jitter stream is not part of a snapshot).
490
+ - Operations outside the vendored OpenAPI subset: they return 404 and are counted as
491
+ `unmatched` in `/__admin/metrics`.
227
492
 
228
493
  ## API
229
494
 
495
+ Main entry (`@crvouga/mockingbird-service-junction`, runtime-neutral):
496
+
230
497
  | Export | Description |
231
498
  | --- | --- |
232
- | `JunctionAPI` | Class. `new JunctionAPI(options?)`; implements the Fetch contract `fetch(request: Request): Promise<Response>`. |
233
- | `JUNCTION_NAMESPACE` | `"junction"` — SQLite namespace holding the mock's state when sharing a `sqlite` client. |
499
+ | `createRuntime` | `(options?: JunctionRuntimeOptions) => JunctionRuntime` — the served mock (health, admin, namespaces, clock, faults, metrics, webhooks) as one `fetch`. Options: `corpus`, `geo`, `labAccounts`, `webhooks`, `onWebhook`, `sqlite`, `clock`, `seed`, `adminKey`, `onLog`. |
500
+ | `JunctionAPI` | Class. `new JunctionAPI(options?)`: one namespace's mock, implementing `fetch(request: Request): Promise<Response>`. |
501
+ | `JUNCTION_NAMESPACE` | `"junction"` — the default namespace's storage key when sharing a `sqlite` client. |
234
502
  | `document` | The vendored Junction OpenAPI document (Mockingbird subset) that drives routing. |
235
503
  | `operationIds` | Every `operationId` in `document`. |
236
504
  | `supportedOperationIds` | The `operationId`s the mock implements (all of them). |
505
+ | `pullCorpus` | `(options: PullCorpusOptions) => Promise<SealedCorpus>` — record a corpus from a real team (GETs only). |
506
+ | `diffCorpus` | `(before, after) => CorpusDiff` — ZIPs, lab tests, lab accounts and observations added / removed / changed. |
507
+ | `fingerprintCorpus` | `(corpus) => Promise<string>` — SHA-256 of a corpus's content. |
508
+ | `corpusLabel` | `(corpus) => string` — the short label `/health` and logs show, e.g. `v1-2026-09-18-HqFDuEAuhkwD`. |
237
509
  | `parseSealedCorpus` | `(value: unknown) => SealedCorpus`; throws on a non-object or unsupported `version`. |
238
- | `SEALED_CORPUS_VERSION` | Current sealed-corpus format version (`1`). |
239
- | `observationCacheKey` | `(url, method = "GET", body?) => string` — the `"<METHOD> <path>?<sorted query> <json>"` key used by the observation cache and corpus. |
240
- | `prefetchCoverageObservations` | Parity helper: records coverage-ZIP area/PSC/availability reads from the real sandbox into an observation cache before `seedFrom`. |
510
+ | `SEALED_CORPUS_VERSION` | Current corpus format version (`1`). |
511
+ | `verifyAgainstReal` | `(options: VerifyOptions) => Promise<VerifyReport>` — the `verify` command as a function. |
512
+ | `DEFAULT_JUNCTION_BASE_URL` | `"https://api.sandbox.tryvital.io"`. |
513
+ | `isSandboxKey` / `SANDBOX_KEY_PREFIXES` | Whether a key is a sandbox team key (`sk_us_` / `sk_eu_`). |
514
+ | `UNKNOWN_ZIP_STATUS` / `UNKNOWN_ZIP_ERROR_TYPE` | `424` / `"MOCKINGBIRD_UNKNOWN_ZIP"` — the corpus-mode unknown-ZIP error. |
515
+ | `labAccountFromInput` | `(input: LabAccountInput) => LabAccountRecord` — normalize (and validate) a configured account. |
516
+ | `TEAM_LAB_ACCOUNTS` | The built-in lab-account fixtures. |
517
+ | `US_STATES` | The 50 `USState` codes Junction accepts. |
518
+ | `ORDER_STATUSES_BY_METHOD` | Every order status in the contract, by collection method. |
519
+ | `TRANSITION_ALIASES` | The transition names above, by method. |
520
+ | `resolveTransition` | `(method, target) => { status } \| { error }` — how `/__admin/orders/{id}/transition` reads `to`. |
521
+ | `RESULT_FIXTURES` | Named results (`normal`, `abnormal`, `critical`, `missing_results`). |
522
+ | `FAULT_PRESETS` | Named Junction faults (`sandbox_user_quota`, `rate_limited`, `server_error`, `bad_gateway`, `unavailable`). |
523
+ | `createWebhookDispatcher` | `(endpoint: WebhookEndpoint) => WebhookDispatcher` — signed delivery with retries, standalone. |
524
+ | `signSvix` / `verifySvix` | Produce / check a standard Svix `svix-signature`. |
525
+ | `observationCacheKey` | `(url, method = "GET", body?) => string` — the `"<METHOD> <path>?<sorted query> <json>"` key corpora use. |
526
+ | `prefetchCoverageObservations` | Parity helper: records coverage-ZIP reads from the sandbox into an observation cache before `seedFrom`. |
241
527
  | `reshapeCoverageGeoCommand` | Parity-walk hook that pins geo/availability parameters onto the coverage corpus. |
242
- | `COVERAGE_ZIPS` | ZIP codes the coverage corpus seals for area/PSC reads. |
528
+ | `COVERAGE_ZIPS` | The ZIPs the shipped corpus covers. |
243
529
  | `PSC_AVAILABILITY_ZIPS` | ZIPs that also get PSC availability observations. |
244
- | `PHLEBOTOMY_AVAILABILITY_ZIPS` | ZIPs served for phlebotomy availability (`["85004"]`). |
530
+ | `PHLEBOTOMY_AVAILABILITY_ZIPS` | ZIPs the sandbox serves phlebotomy availability for (`["85004"]`). |
245
531
  | `PSC_LAB_IDS` | Lab ids used for PSC reads (`[4, 6, 13, 25]`). |
246
532
  | `AVAILABILITY_ADDRESS` | Stable Phoenix, AZ address used in availability requests so cache keys match. |
247
533
  | `AVAILABILITY_START_DATE` | Far-future availability `start_date` (`"2099-06-15"`). |
248
534
 
535
+ `./server` (Node): `createServer(options?) => Promise<JunctionServer>`, `serveTarget` (what
536
+ `serve` and `serve --config` use), `loadCorpus(source)`, `DEFAULT_PORT` (`8787`).
537
+ `./corpus`: `defaultCorpus`.
538
+
539
+ `JunctionRuntime` (from `createRuntime`) members: `fetch`, `instance(namespace?)`, `namespaces()`,
540
+ `reset(namespace? | "*")`, `snapshot(namespace?)`, `restore(snapshot, namespace?)`, `clock`
541
+ (`now`, `set`, `advance`, `freeze`, `unfreeze`, `reset`, `state`), `faults` (`add`, `list`,
542
+ `remove`, `clear`), `metrics` (`report`, `reset`), `webhooks` (`deliveries`, `replay`, `flush`,
543
+ `idle`, `clear`), `sqlite`, `rng`.
544
+
249
545
  `JunctionAPI` members:
250
546
 
251
547
  | Member | Description |
252
548
  | --- | --- |
253
549
  | `fetch(request)` | Handle one Junction REST request. |
254
- | `reset()` | `Promise<void>` — clear state; re-install the default catalog or the installed corpus. |
255
- | `installCorpus(corpus)` | Install a `SealedCorpus` (observation cache, catalog, labs, lab accounts). |
550
+ | `reset()` | `Promise<void>` — clear state; re-install the corpus or default catalog, and the configured lab accounts. |
551
+ | `installCorpus(corpus)` | Install a `SealedCorpus`: observations (shared, read-only), catalog, labs, lab accounts, ZIP coverage. |
552
+ | `corpusInfo()` | `CorpusInfo \| undefined` — label, recording date, source and counts. |
553
+ | `geoMode` | `"corpus" \| "synthetic"`, settable. |
554
+ | `configureLabAccounts(accounts?)` / `labAccounts()` | Replace (or restore with `undefined`) / read the team's lab accounts. |
555
+ | `order(id)` / `orders()` | Read orders. |
556
+ | `transitionOrder(id, status, { now, flags? })` | Move an order to a full status and publish its webhook. |
557
+ | `installResultFixture(orderId, fixture)` / `resultFixture(orderId)` | Serve an exact result payload / PDF for an order. |
256
558
  | `seedFrom(source, observations?)` | `Promise<SeedReport>` — copy users, orders, appointments, catalog and labs from a real Junction environment. |
257
559
  | `ensureLabTests(source, ids)` / `ensureOrders(source, ids)` | `Promise<number>` — fetch specific lab tests / orders from `source` if missing. |
258
560
  | `markUserDeleted(userId)` | Mark a user as deleted (subsequent reads behave like a deleted user). |
259
561
  | `webhookEvents()` | `JunctionWebhookEvent[]`, oldest first. |
260
- | `webhookDeliveryAttempts()` | Recorded retry schedule per event. |
562
+ | `webhookDeliveryAttempts()` | The retry schedule Junction would follow per event (a record; see `webhooks` for delivery). |
261
563
  | `app` / `sqlite` | The underlying Hono app and `SqliteClient`. |
262
564
 
263
565
  Options and types:
@@ -265,21 +567,27 @@ Options and types:
265
567
  ```text
266
568
  type JunctionAPIOptions = {
267
569
  sqlite?: SqliteClient // share one client across services; default: fresh in-memory DB
570
+ namespace?: string // storage namespace; default "junction"
268
571
  now?: () => number // clock in ms; default Date.now
572
+ corpus?: SealedCorpus // installed now and on every reset()
573
+ geo?: "corpus" | "synthetic" // default "corpus" with a corpus, else "synthetic"
574
+ labAccounts?: LabAccountInput[]
269
575
  onWebhook?: WebhookPublisher // called with every recorded event
270
576
  webhook?: JunctionWebhookOptions
271
577
  }
272
- type JunctionWebhookOptions = { seed?: number; jitterRatio?: number /* 0..1, default 0.1 */; timeoutMs?: number /* default 15000 */ }
578
+ type LabAccountInput = { id; lab; status?; delegated_flow?; account_name?; provider_account_id?; org_id?;
579
+ business_units?; default_clinical_notes?; allowed_billing?: Record<billingType, State[]>; states?: State[];
580
+ team_id_allowlist? }
581
+ type WebhookEndpoint = { url; secret: "whsec_…"; retryDelaysMs?: number[]; timeoutMs?; headers?; fetch? }
582
+ type ResultFixture = { name; results?: unknown[]; missing_results?: unknown[] | null; interpretation?; pdf_base64? }
583
+ type FaultRule = { id; operationId?; method?; pathPrefix?; status; body?; headers?; count?; rate?; delayMs? }
584
+ type JunctionWebhookOptions = { seed?: number; jitterRatio?: number /* 0..1, default 0.1 */; timeoutMs?: number }
273
585
  type JunctionWebhookEvent = {
274
586
  event_type: "labtest.order.created" | "labtest.order.updated" | "labtest.appointment.updated"
275
587
  data: Record<string, unknown>; team_id: string; user_id: string; client_user_id: string
276
588
  }
277
- type WebhookPublisher = (event: JunctionWebhookEvent) => void
589
+ type SealedCorpus = { version: 1; recordedAt; source; fingerprint?; observations; catalog: { labTests; labs; expectedResults }; labAccounts }
278
590
  type SeedSource = { fetch: (request: Request) => Promise<Response>; baseUrl: string; headers: Record<string, string> }
279
- type SeedObservations = { getCache?: ReadonlyMap<string, GetCacheEntry> }
280
- type SeedReport = { users; orders; appointments; catalogTests; labs; cacheEntries: number }
281
- type GetCacheEntry = { status: number; headers: Record<string, string>; body: unknown }
282
- type SealedCorpus = { version: 1; recordedAt; source; observations; catalog: { labTests; labs; expectedResults }; labAccounts }
283
591
  type OperationId / SupportedOperationId // string unions of operationIds / supportedOperationIds
284
592
  ```
285
593
 
@@ -299,23 +607,19 @@ console.log(report.users, report.orders)
299
607
 
300
608
  ## Development
301
609
 
302
- For contributors to the mockingbird repo only; these scripts and the corpus are not shipped in the
303
- npm package.
610
+ For contributors to the mockingbird repo only.
304
611
 
305
612
  ```bash
306
613
  bun test # offline suites, incl. scheduling state space
307
- bun test junction.seed.property.test.ts # offline monkey (no network)
614
+ bun test junction.runtime.property.test.ts # service contract, corpus, lab accounts, faults, webhooks, verify
308
615
  bun test junction.sdk.property.test.ts # drives the served mock through @tryvital/vital-node
309
616
 
310
- bun run mock:serve # HOST=127.0.0.1 PORT=8787; GET /health, POST /__admin/reset
311
- MOCKINGBIRD_JUNCTION_CORPUS=corpus/sandbox-sealed.json bun run mock:serve
312
- bun run corpus:record # record corpus/sandbox-sealed.json (needs a sandbox key)
617
+ bun run mock:serve # env-configured `serve`: HOST, PORT, MOCKINGBIRD_JUNCTION_CORPUS,
618
+ # MOCKINGBIRD_JUNCTION_WEBHOOK_URL / _SECRET / _SCOPE
619
+ bun run corpus:record -- --force # re-record the shipped corpus/sandbox-sealed.json (Vault key)
620
+ bun run verify -- --real-key "$KEY" # `mockingbird-junction verify` from source
313
621
  ```
314
622
 
315
- `mock:serve` installs `corpus/sandbox-sealed.json` when present, and signs webhooks to
316
- `MOCKINGBIRD_JUNCTION_WEBHOOK_URL` with `MOCKINGBIRD_JUNCTION_WEBHOOK_SECRET` (delivery is disabled
317
- without a secret). `/health` and `/__admin/reset` bypass the `x-vital-api-key` gate.
318
-
319
623
  Live parity (primary proof is seedParity: warmup N on the sandbox, `seedFrom`, then lockstep M):
320
624
 
321
625
  ```bash
@@ -326,6 +630,5 @@ bun run parity -- --mode=empty # legacy empty-start differenti
326
630
 
327
631
  Webhook parity needs a public receiver; see
328
632
  [docs/webhook-parity.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/junction/docs/webhook-parity.md).
329
- The `client-parity*.ts` SDK scenarios are deprecated as proof and kept only as manual probes.
330
633
 
331
634
  Part of [mockingbird](https://github.com/crvouga/mockingbird) — agent integration guide: [README](https://github.com/crvouga/mockingbird#readme) · [llms.txt](https://github.com/crvouga/mockingbird/blob/main/llms.txt).