@crvouga/mockingbird-service-junction 0.1.2 → 0.3.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,26 @@
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 40 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 (the mock enforces no sandbox limit unless a test [asks for one](#sandbox-limits)), no
12
+ cross-suite interference. Serve it as a local origin with one command, or run it in-process.
13
+
14
+ ```bash
15
+ npx mockingbird-junction serve # http://127.0.0.1:8787, recorded sandbox corpus loaded
16
+ ```
12
17
 
13
18
  - 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:
19
+ - Drop-in readiness and the sandbox quirks the mock mirrors:
15
20
  [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
21
  - Behaviour notes: [docs/behavior.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/junction/docs/behavior.md)
22
+ - **What the mock does not model:** [below](#what-is-and-is-not-modelled). Read it before trusting a
23
+ green suite.
18
24
 
19
25
  ## Install
20
26
 
@@ -22,22 +28,125 @@ run offline, deterministically, with no sandbox key.
22
28
  npm install -D @crvouga/mockingbird-service-junction
23
29
  ```
24
30
 
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.
31
+ ESM only; Node >= 22 or Bun >= 1.2 (CommonJS callers on Node >= 22.12 can `require()` it). No
32
+ native dependencies: state lives in an in-memory SQLite engine written in TypeScript, bundled in.
33
+
34
+ | Entry point | Runtime | What it is |
35
+ | --- | --- | --- |
36
+ | `@crvouga/mockingbird-service-junction` | any (Node, Bun, Workers, browsers) | `JunctionAPI`, `createRuntime`, corpus and verify tools |
37
+ | `@crvouga/mockingbird-service-junction/server` | Node | `createServer()` — a listening HTTP server |
38
+ | `@crvouga/mockingbird-service-junction/corpus` | any | `defaultCorpus` — the recording shipped in the package (a separate entry, so importing the mock never parses it) |
39
+ | `mockingbird-junction` (bin) | Node | `serve`, `corpus pull`, `corpus diff`, `verify` |
28
40
 
29
41
  ## Usage
30
42
 
31
- Behaviour the examples rely on (all from the source):
43
+ ### `mockingbird-junction serve`
44
+
45
+ ```bash
46
+ npx mockingbird-junction serve --port 8787
47
+ npx mockingbird-junction serve --corpus ./test/junction-corpus.json --lab-accounts ./test/lab-accounts.json
48
+ npx mockingbird-junction serve --webhook-url http://127.0.0.1:3100/webhooks/junction --webhook-secret whsec_...
49
+ npx mockingbird-junction serve --help
50
+ ```
51
+
52
+ | Flag | Default | Meaning |
53
+ | --- | --- | --- |
54
+ | `--port <n>` | `8787` | Port to listen on (`0` for any free port; the bound URL is printed) |
55
+ | `--host <host>` | `127.0.0.1` | Interface to bind |
56
+ | `--corpus <default\|none\|file>` | `default` | The shipped corpus, no corpus (synthetic data), or a file from `corpus pull` |
57
+ | `--geo <corpus\|synthetic>` | `corpus` with a corpus | How unknown ZIPs are answered; see [Geo](#geo-corpus-or-synthetic) |
58
+ | `--lab-accounts <file>` | built-in fixtures | [Lab accounts](#lab-accounts): a JSON array, or `{ "presets": [...], "accounts": [...] }` |
59
+ | `--team-id <uuid>` | the corpus's team | The [team](#team-identity) the mock answers as |
60
+ | `--max-users <n>` | unlimited | Enforce the sandbox's [live-user cap](#sandbox-limits) |
61
+ | `--identity <strict\|adopt-users>` | `strict` | Unknown `user_id`s: 404, or [create on first use](#fixtures-and-identity) |
62
+ | `--fixtures <file>` | — | [Users and orders](#fixtures-and-identity) every namespace starts with |
63
+ | `--journal-size <n>` | `1000` | Requests each namespace's [journal](#is-this-the-mock) keeps |
64
+ | `--webhook-url <url>` / `--webhook-secret <whsec_…>` | off | Deliver [signed webhooks](#webhooks) (secret also from `MOCKINGBIRD_JUNCTION_WEBHOOK_SECRET`) |
65
+ | `--webhook-retry-delays <ms,…>` | Svix's schedule | Delay before each delivery attempt, e.g. `0,1000,5000` in tests |
66
+ | `--webhook-scope <scope>` | — | Sent as `x-mockingbird-scope` on every delivery |
67
+ | `--admin-key <key>` | open | Require `x-mockingbird-admin-key` on `/__admin/*` (also `MOCKINGBIRD_ADMIN_KEY`) |
68
+ | `--seed <seed>` | `0` | Seeds fault rates and retry jitter |
69
+ | `--log <pretty\|json\|off>` | `pretty` | One line per request: operation id, status, duration, namespace, fault |
70
+ | `--log-requests` | — | Same as `--log json`: one JSON line per request with the ids it touched, never bodies |
71
+ | `--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 |
72
+ | `--config <file>` | — | Serve every service in a `mockingbird.json` instead ([below](#many-services-from-one-config)) |
73
+
74
+ At startup it prints the listen address, the loaded corpus (its version label, observation, ZIP,
75
+ lab-test and lab-account counts, recording date and source), the geo mode, the team id, the
76
+ lab-account count, which sandbox limits are on (normally none), the identity mode, and whether
77
+ webhook delivery is on.
78
+
79
+ ### Many services from one config
80
+
81
+ Any Mockingbird service's CLI can boot every service in a config file, so a stack adds a config
82
+ entry per vendor instead of a wrapper process per vendor. Each service named must be installed
83
+ (`junction` loads `@crvouga/mockingbird-service-junction`).
84
+
85
+ ```json
86
+ {
87
+ "log": "json",
88
+ "services": {
89
+ "junction": {
90
+ "port": 8787,
91
+ "adminKey": "local-admin",
92
+ "options": { "corpus": "./test/junction-corpus.json", "webhook-url": "http://127.0.0.1:3100/webhooks/junction" }
93
+ },
94
+ "stripe": { "port": 12111 }
95
+ }
96
+ }
97
+ ```
98
+
99
+ ```bash
100
+ npx mockingbird-junction serve --config mockingbird.json
101
+ ```
102
+
103
+ `options` takes the service's own `serve` flags by long name.
104
+
105
+ ### `createServer` (Node)
106
+
107
+ ```ts
108
+ import { createServer } from "@crvouga/mockingbird-service-junction/server"
109
+
110
+ const server = await createServer() // shipped corpus, any free port
111
+ const health = await fetch(`${server.url}/health`)
112
+ console.log(health.status) // 200
113
+
114
+ // Point the Vital SDK (or your backend's Junction base URL) at server.url, then:
115
+ await server.close()
116
+ ```
117
+
118
+ `createServer(options)` takes every [`createRuntime`](#createruntime-any-fetch-server) option plus
119
+ `port` (default `0`), `host` (default `127.0.0.1`) and `corpus` (`"default"`, `"none"`, a file path
120
+ or a loaded corpus). The result has `url`, `port`, `runtime` and `close()`.
121
+
122
+ ### `createRuntime` (any Fetch server)
123
+
124
+ `createRuntime` is the whole served mock — health, admin, namespaces, clock, faults, metrics — as a
125
+ single runtime-neutral `fetch(request)`. Hand it to `Bun.serve`, a Worker, or call it directly:
126
+
127
+ ```ts
128
+ import { createRuntime } from "@crvouga/mockingbird-service-junction"
129
+ import { defaultCorpus } from "@crvouga/mockingbird-service-junction/corpus"
130
+
131
+ const junction = createRuntime({ corpus: defaultCorpus })
132
+ const as = (worker: string) => ({
133
+ "x-vital-api-key": "sk_us_mockingbird",
134
+ "x-mockingbird-namespace": worker, // isolates this worker's data
135
+ "content-type": "application/json",
136
+ })
137
+
138
+ await junction.fetch(
139
+ new Request("http://junction.local/v2/user", {
140
+ method: "POST",
141
+ headers: as("worker-1"),
142
+ body: JSON.stringify({ client_user_id: "patient-1" }),
143
+ }),
144
+ )
145
+ const other = await junction.fetch(new Request("http://junction.local/v2/user", { headers: as("worker-2") }))
146
+ console.log(await other.json()) // worker-2 sees none of worker-1's users
32
147
 
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.
148
+ await junction.reset("worker-1") // or junction.reset("*") for every namespace
149
+ ```
41
150
 
42
151
  ### In-process (inject `fetch`)
43
152
 
@@ -95,169 +204,559 @@ console.log(junction.webhookEvents().map((event) => event.event_type)) // ["labt
95
204
  simulations (`POST /v3/order/{id}/test?final_status=completed&delay=<seconds>`) become due; the
96
205
  delayed transition is applied on the next `GET /v3/order/{id}` whose `now` is past the due time.
97
206
 
98
- ### Over HTTP
207
+ ### Pointing `@tryvital/vital-node` at it
99
208
 
100
- ```ts
101
- import { JunctionAPI } from "@crvouga/mockingbird-service-junction"
209
+ The official SDK takes the base URL as `environment` (this is how the package's SDK drop-in test
210
+ constructs it):
102
211
 
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}`
212
+ ```js
213
+ import { VitalClient } from "@tryvital/vital-node"
214
+
215
+ const client = new VitalClient({ apiKey: "sk_us_mockingbird", environment: baseUrl })
216
+ const user = await client.user.create({ clientUserId: "app-user-1" })
217
+ ```
218
+
219
+ If your app guards Junction hosts with an allowlist, allow `127.0.0.1` / `localhost` for tests
220
+ while keeping the production and sandbox guards intact.
221
+
222
+ ## The service contract
223
+
224
+ Every Mockingbird service answers the same control surface, outside the vendor's auth gate:
225
+
226
+ - `GET /health` — unauthenticated readiness probe:
227
+ `{ "status": "ok", "service": "junction", "corpus": "<label>", "geo": "corpus", … }`.
228
+ - `/__admin/*` — the control plane. Open by default; with `--admin-key` / `adminKey` it requires
229
+ `x-mockingbird-admin-key`, which is separate from any vendor key. Admin errors have one shape,
230
+ `{ "error": { "type": "mockingbird_admin", "message": "…" } }`, so they can never be confused
231
+ with a Junction error. `GET /__admin` lists every route.
232
+ - `x-mockingbird-namespace: <name>` — isolates a request's data (`[A-Za-z0-9_.-]{1,64}`). Each
233
+ namespace is a separate team over one shared database, so parallel workers share one process
234
+ without seeing each other. The corpus is shared read-only; creating a namespace is cheap. Admin
235
+ routes take the namespace from `?namespace=`, then the header, then `default`. Configuration set
236
+ through the admin API (lab accounts, limits, identity, geo) belongs to one namespace and survives
237
+ that namespace's `POST /__admin/reset`; data does not.
238
+ - `x-mockingbird: junction@<version>; ns=<namespace>` — on **every** response: vendor answers,
239
+ errors, faults, admin and health. Junction never sends it, so it tells the mock from the vendor
240
+ (see [Is this the mock?](#is-this-the-mock)).
241
+
242
+ | Route | Does |
243
+ | --- | --- |
244
+ | `POST /__admin/reset` | Reset the request's namespace; `?all=1` resets every namespace |
245
+ | `GET /__admin/namespaces` | Namespaces created so far |
246
+ | `POST /__admin/snapshots` | Snapshot the namespace; returns `{ id }` |
247
+ | `POST /__admin/snapshots/{id}/restore` | Restore it (an assignment: later writes are discarded) |
248
+ | `DELETE /__admin/snapshots/{id}` | Forget a snapshot |
249
+ | `GET` / `POST /__admin/clock` | Read or move the clock: `{ "set": "2030-01-01T00:00:00Z" }`, `{ "advance": "2h" }`, `{ "freeze": true }`, `{ "reset": true }` |
250
+ | `GET` / `POST` / `DELETE /__admin/faults` | List, add or clear [fault rules](#faults-and-error-shapes) (`DELETE ?id=` removes one) |
251
+ | `POST /__admin/faults/presets/{name}` | Add a named Junction fault; body may override `count`, `rate`, `operationId`… |
252
+ | `GET /__admin/metrics` | Requests by operation and status, fault count, and **unmatched routes** |
253
+ | `GET /__admin/orders` | Orders in the namespace with their current status |
254
+ | `POST /__admin/orders/{id}/transition` | Move an order: `{ "to": "completed", "result": "abnormal" }` ([order control](#order-control)) |
255
+ | `GET` / `PUT /__admin/results/{id}` | Read or install an exact result payload / PDF for an order |
256
+ | `GET /__admin/result-fixtures` | The named results |
257
+ | `GET` / `PUT /__admin/lab-accounts` | Read or replace the namespace's [lab accounts](#lab-accounts) (`{ "accounts": [...], "presets": [...] }`; `{ "accounts": null }` restores the default) |
258
+ | `POST /__admin/lab-accounts` | Add one account; 409 if the id exists |
259
+ | `PATCH` / `DELETE /__admin/lab-accounts/{id}` | Merge fields into an account (`{ "status": "suspended" }`, `{ "states": [...] }`) / remove it; 404 if absent |
260
+ | `GET /__admin/lab-accounts/presets` | Every [preset](#lab-account-presets), with its full record |
261
+ | `POST /__admin/lab-accounts/presets/{name}` | Add a preset account; body `{ "id": "…" }` overrides its id |
262
+ | `GET /__admin/team` | `{ "teamId" }` — the [team](#team-identity) the namespace answers as |
263
+ | `GET` / `PUT /__admin/limits` | Read or change the namespace's [sandbox limits](#sandbox-limits) |
264
+ | `GET` / `PUT /__admin/identity` | Read or set `{ "mode": "strict" \| "adopt-users" }` ([identity](#fixtures-and-identity)) |
265
+ | `POST /__admin/users`, `POST /__admin/users/bulk` | Insert a user (or `{ "users": [...] }`, all or none) with a chosen `user_id`; 409 on a duplicate id or `client_user_id` |
266
+ | `DELETE /__admin/users/{id}` | Remove a user outright, leaving no deletion tombstone |
267
+ | `POST /__admin/orders` | Insert an order, optionally already in a `status`, through `create_order`'s record builder |
268
+ | `POST /__admin/import` | `{ "users": [...], "orders": [...] }` in one call |
269
+ | `GET` / `DELETE /__admin/requests` | The namespace's [request journal](#is-this-the-mock) (`?operationId=`, `?status=`, `?since=`, `?limit=`, `?all=1`) / clear it |
270
+ | `GET /__admin/corpus`, `PUT /__admin/geo` | The loaded corpus; switch [geo mode](#geo-corpus-or-synthetic) |
271
+ | `GET /__admin/webhooks` | [Deliveries](#webhooks) with every attempt |
272
+ | `POST /__admin/webhooks/{id}/replay`, `POST /__admin/webhooks/flush` | Redeliver one message; run pending retries now |
273
+ | `GET /__admin/webhooks/events` | Every event recorded, delivered or not |
274
+
275
+ `GET /__admin/metrics` → `unmatched` counts requests to paths the contract does not have. It is how
276
+ a new SDK call shows up — as a count — before it fails a suite as a 404.
277
+
278
+ ## Corpus
279
+
280
+ Provider-owned inventory — area serviceability, PSC site lists, the lab catalog, labs, lab
281
+ accounts — cannot be synthesized faithfully. A corpus is an exact recording of those
282
+ parameter-stable reads, and the mock answers them byte for byte.
283
+
284
+ **The shipped corpus** (`defaultCorpus`, and what `serve` loads unless told otherwise) was recorded
285
+ from the Junction sandbox. It covers area and PSC serviceability for 57 ZIPs across the US, the PSC
286
+ labs those ZIPs reach, and the recording team's catalog. The catalog and lab accounts belong to the
287
+ **recording team**: lab-test ids are per team, so your product's ids will not be in it. For your
288
+ catalog and your lab accounts, record your own team and commit the file:
289
+
290
+ ```bash
291
+ npx mockingbird-junction corpus pull --real-key "$JUNCTION_SANDBOX_KEY" --out test/junction-corpus.json
292
+ npx mockingbird-junction corpus pull --real-key "$JUNCTION_SANDBOX_KEY" --base test/junction-corpus.json \
293
+ --zip 10001,94105 --coverage-only --out test/junction-corpus.json # add ZIPs to it
294
+ npx mockingbird-junction corpus diff test/junction-corpus.json /tmp/fresh.json # exit 1 when they differ
295
+ ```
296
+
297
+ `corpus pull` only issues GETs, refuses non-sandbox keys (`sk_us_…` / `sk_eu_…`) unless given
298
+ `--allow-any-key`, and stamps the file with a SHA-256 `fingerprint` of its content (not its
299
+ recording time), so re-pulling unchanged data yields the same fingerprint. Availability is never
300
+ recorded: its slot dates and single-use `booking_key`s cannot be replayed, so it always comes from
301
+ the deterministic generator.
302
+
303
+ ### Geo: corpus or synthetic
304
+
305
+ With a corpus loaded, serviceability reads (`area/info`, `psc/info`, phlebotomy and PSC
306
+ availability) answer **only** for ZIPs the corpus covers. Any other well-formed ZIP gets:
307
+
308
+ ```json
309
+ {
310
+ "detail": {
311
+ "error_type": "MOCKINGBIRD_UNKNOWN_ZIP",
312
+ "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.",
313
+ "zip_code": "00501",
314
+ "corpus": "v1-2026-09-18-HqFDuEAuhkwD"
315
+ }
316
+ }
317
+ ```
318
+
319
+ with status **424**, which Junction never sends and HTTP clients do not retry: a fixture gap fails
320
+ loudly instead of silently disagreeing with production. A malformed ZIP still gets Junction's own
321
+ 422. `--geo synthetic` (the default without a corpus) invents plausible, deterministic coverage
322
+ instead — right for property tests that walk random ZIPs, wrong for a suite standing in for
323
+ production.
110
324
 
111
- const response = await fetch(`${baseUrl}/v2/user`, {
112
- headers: { "x-vital-api-key": "sk_us_mockingbird" },
325
+ ## Lab accounts
326
+
327
+ `create_order` routes, accepts and rejects `lab_account_id` against the team's lab accounts,
328
+ following Junction's documented rules: an explicit id must exist, be linked to the team, belong to
329
+ the ordered lab and be active; with no id, one active account for the lab is selected, several are
330
+ an error, and a lab with none falls back to Junction's platform account. The billing type must be
331
+ allowed in the patient's state by the account's `allowed_billing`.
332
+
333
+ The accounts come from, in order: `labAccounts` / `--lab-accounts`, else the corpus (a pulled
334
+ corpus carries your team's real accounts), else built-in fixtures. Configure them as
335
+ `ClientFacingLabAccount`s, with `states` as shorthand for client-bill states:
336
+
337
+ ```ts
338
+ import { createRuntime, US_STATES } from "@crvouga/mockingbird-service-junction"
339
+
340
+ const junction = createRuntime({
341
+ labAccounts: [
342
+ { id: "acct-bioref-nynj", lab: "bioreference", states: ["NY", "NJ"] },
343
+ {
344
+ id: "acct-labcorp-47",
345
+ lab: "labcorp",
346
+ states: US_STATES.filter((state) => !["NY", "NJ", "RI"].includes(state)),
347
+ },
348
+ { id: "acct-quest", lab: "quest", delegated_flow: "order_delegated" },
349
+ ],
113
350
  })
114
- console.log(response.status) // 200
351
+ console.log(junction.instance().labAccounts().length) // 3
352
+ ```
353
+
354
+ Rejections keep Junction's body shape, `400 {"detail": "Lab account is not associated with lab labcorp"}`.
355
+ `delegated_flow` is stored and listed, but see [not modelled](#what-is-and-is-not-modelled).
356
+
357
+ Availability reads that take a `lab_account_id` (`GET /v3/order/area/info`) check it against the
358
+ same live list, so an id `create_order` accepts is never a 404 there.
115
359
 
116
- server.stop()
360
+ ### Changing accounts at runtime
361
+
362
+ Each namespace has its own layout, kept across its own reset. Change one account at a time, or
363
+ replace them all:
364
+
365
+ ```bash
366
+ curl -X POST localhost:8787/__admin/lab-accounts/presets/bioreference_ny_nj_delegated
367
+ curl -X PATCH localhost:8787/__admin/lab-accounts/$ID -d '{"status": "suspended"}'
368
+ curl -X POST localhost:8787/__admin/lab-accounts -d '{"id": "…", "lab": "quest", "states": ["AZ"]}'
369
+ curl -X DELETE localhost:8787/__admin/lab-accounts/$ID
370
+ curl -X PUT localhost:8787/__admin/lab-accounts -d '{"presets": ["quest_platform"], "accounts": []}'
117
371
  ```
118
372
 
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:
373
+ A bad field is a 400 in the admin shape naming it, e.g. `lab account x: states: ZZ is not a US
374
+ state code`. `--lab-accounts` and the `labAccounts` option take the same layout, so a stack can
375
+ declare it at boot with no admin call:
121
376
 
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()
377
+ ```json
378
+ { "presets": ["bioreference_ny_nj_delegated", { "name": "quest_platform", "id": "…" }], "accounts": [] }
129
379
  ```
130
380
 
131
- ### Pointing `@tryvital/vital-node` at it
381
+ ### Lab-account presets
132
382
 
133
- The official SDK takes the base URL as `environment` (this is how the package's SDK drop-in test
134
- constructs it):
383
+ Named after what they model; ids are `deterministicUuid("junction:lab-account:<name>")` unless
384
+ given. Where a preset copies an account recorded from a real BioReference-linked sandbox team
385
+ (2026-09-21), its billing states are the recording's. `PLATFORM_ACCOUNT_STATES` is every state
386
+ but NJ, NY and RI (47).
135
387
 
136
- ```js
137
- import { VitalClient } from "@tryvital/vital-node"
388
+ | Preset | `lab` | `delegated_flow` | `allowed_billing` | `team_id_allowlist` | Source |
389
+ | --- | --- | --- | --- | --- | --- |
390
+ | `bioreference_ny_nj_delegated` | `bioreference` | `order_delegated` | `client_bill`: NY, NJ | the team | requested shape, **not** a recorded account |
391
+ | `bioreference_delegated_multi_state` | `bioreference` | `order_delegated` | `client_bill`: every state but NY, NJ (48) | the team | recorded |
392
+ | `bioreference_customer_multi_state` | `bioreference` | `not_delegated` | `client_bill`: `PLATFORM_ACCOUNT_STATES` | `[]` | recorded ("Junction BioReference Account") |
393
+ | `bioreference_patient_bill_passthrough` | `bioreference` | `not_delegated` | `patient_bill_passthrough`: NJ, NY | the team | recorded |
394
+ | `quest_platform` / `labcorp_platform` | `quest` / `labcorp` | `not_delegated` | `client_bill`: `PLATFORM_ACCOUNT_STATES` | `[]` | recorded ("Junction Quest/Labcorp Account") |
395
+ | `suspended_bioreference` / `suspended_quest` / `suspended_labcorp` | as above | `not_delegated` | as the platform accounts | `[]` | the platform shapes with `status: "suspended"` |
138
396
 
139
- const client = new VitalClient({ apiKey: "sk_us_mockingbird", environment: baseUrl })
140
- const user = await client.user.create({ clientUserId: "app-user-1" })
397
+ Every preset is `status: "active"` unless named `suspended_*`. `GET /__admin/lab-accounts/presets`
398
+ returns the full records.
399
+
400
+ ### Team identity
401
+
402
+ The mock answers as one team: `team_id` on users, orders and webhooks, and the team that
403
+ `team_id_allowlist` is checked against. It is, in order: `teamId` / `--team-id`, the team a
404
+ version-2 corpus recorded (`corpus pull` reads it from a listed user, else from the accounts'
405
+ allowlists), else the fixed `MOCK_TEAM_ID`. `GET /__admin/team` reports it.
406
+
407
+ A version-2 corpus keeps each account's allowlist **verbatim**, so an account the real team is not
408
+ linked to answers `400 {"detail": "Lab account is not linked to your team"}` exactly as it does
409
+ live. A version-1 corpus (no `teamId`) still loads and behaves as 0.2.0 did: every recorded account
410
+ is also linked to the mock team.
411
+
412
+ **Open question — empty allowlists.** Junction's own Quest, Labcorp and BioReference accounts carry
413
+ `team_id_allowlist: []`, and a real team's listing returns them, so the mock lists them and treats
414
+ them as linked. Whether *ordering* through one — by explicit id, or when it is one of several
415
+ active accounts for a lab with the id omitted — behaves the same live is **not verified**.
416
+ `verify --orders` checks it whenever the corpus has such an account; until a run records the
417
+ answer, a suite that depends on it should run against the sandbox too.
418
+
419
+ ## Sandbox limits
420
+
421
+ The mock exists to escape the sandbox's restrictions, so it enforces none of them unless a test
422
+ asks — this is a tested guarantee (500 users in a loop all succeed), not an accident.
423
+
424
+ | Sandbox limit | Modelled | Default | Turn it on |
425
+ | --- | --- | --- | --- |
426
+ | 50 live users per team | yes, byte-exact body; deleted users free their slot | off (unlimited) | `limits: { maxUsers: 50 }`, `--max-users 50`, `PUT /__admin/limits {"maxUsers": 50}` |
427
+ | `POST /v3/order/{id}/test` only in sandbox | as a switch: refuses a key that is not `sk_us_…`/`sk_eu_…` (400, body not recorded) | off | `simulateRequiresSandbox: true` |
428
+ | Rate limits | as a per-namespace budget: 429 `{"detail":"Too Many Requests"}`, `retry-after: 1` (shape not recorded) | off | `rateLimitPerSecond: n` |
429
+
430
+ `PUT /__admin/limits` merges (`null` lifts a numeric limit) and is per namespace;
431
+ `GET /__admin/limits` returns the effective values. For a one-off failure instead of a standing
432
+ limit, the `sandbox_user_quota` [fault preset](#faults-and-error-shapes) is still there.
433
+
434
+ ## Fixtures and identity
435
+
436
+ A suite often needs state the public API cannot build: database fixtures that already carry real
437
+ Junction `user_id`s, or an order already in some status. Insert them directly:
438
+
439
+ ```bash
440
+ curl -X POST localhost:8787/__admin/users -d '{"user_id": "3f0c…", "client_user_id": "patient-1"}'
441
+ curl -X POST localhost:8787/__admin/orders -d '{"user_id": "3f0c…", "lab_test_id": "…", "status": "completed"}'
442
+ curl -X POST localhost:8787/__admin/import -d @test/junction-fixtures.json
141
443
  ```
142
444
 
143
- If your app guards Junction hosts with an allowlist, allow `127.0.0.1` / `localhost` for tests
144
- while keeping the production and sandbox guards intact.
445
+ - Users take `user_id?`, `client_user_id`, `created_on?`, `fallback_time_zone?`,
446
+ `fallback_birth_date?`, `ingestion_start?`, `ingestion_end?`. A duplicate `user_id` or
447
+ `client_user_id` is a 409; `POST /__admin/users/bulk` inserts all or none.
448
+ - Orders take `order_id?`, `user_id`, `lab_test_id`, `lab_account_id?`, `status?` (any
449
+ [transition target](#order-control)), `collection_method?`, `patient_details?`,
450
+ `patient_address?`, `billing_type?`, `created_at?` and `result_fixture?` (as
451
+ `PUT /__admin/results/{id}` takes it). Request validation is skipped; references are not — the
452
+ user and lab test must exist (404 otherwise).
453
+ - Inserted records go through the same builders as `create_user` / `create_order`, so every read
454
+ path (`GET /v2/user/{id}`, resolve by `client_user_id`, `GET /v3/order/{id}`,
455
+ `GET /v3/orders?user_id=`) sees them as API-created. Default timestamps read the mock clock.
456
+ - **No webhook fires** unless the body has `"emitWebhooks": true`: fixtures should not trigger
457
+ backend side effects.
458
+ - `fixtures` / `--fixtures <file>` loads `{ "users": [...], "orders": [...] }` into every namespace
459
+ at creation and again on each reset.
460
+
461
+ For flow suites that do not care about Junction identity, `identity: "adopt-users"` (or
462
+ `--identity adopt-users`, or `PUT /__admin/identity {"mode": "adopt-users"}` per namespace) creates
463
+ an unknown, well-formed `user_id` on first use — in `create_order` and `GET /v3/orders?user_id=` —
464
+ and the request proceeds. The journal marks the request `adopted: true`. **Orders are never
465
+ adopted**: an unknown order id always 404s, because inventing an order hides real bugs. The default,
466
+ `strict`, is Junction's own behaviour.
467
+
468
+ ## Is this the mock?
469
+
470
+ A mock 404 and a sandbox 404 are byte-identical, which is how a backend half-pointed at the sandbox
471
+ goes unnoticed. Three things make it visible:
472
+
473
+ - **`x-mockingbird` on every response.** Assert on it in a test helper:
474
+
475
+ ```ts
476
+ import { expect } from "vitest"
477
+
478
+ /** Wrap the fetch your Junction client uses; fails any call that did not reach the mock. */
479
+ export const mockOnlyFetch =
480
+ (inner: typeof fetch = fetch): typeof fetch =>
481
+ async (input, init) => {
482
+ const response = await inner(input, init)
483
+ expect(response.headers.get("x-mockingbird"), `${String(input)} did not reach the Junction mock`).toMatch(
484
+ /^junction@/,
485
+ )
486
+ return response
487
+ }
488
+ ```
489
+
490
+ - **The request journal.** Each namespace keeps its last 1000 requests (`--journal-size`,
491
+ `journalSize`): operation id, method, path, status, duration, fault id, the user / order /
492
+ lab-account ids it touched, and when (mock clock). `GET /__admin/requests?operationId=create_order_v3_order_post`
493
+ proves an order was placed here; an empty answer proves it was not. Never bodies.
494
+ - **Miss headers on 404s.** A missing user or order keeps Junction's body byte for byte and adds
495
+ `x-mockingbird-miss: order <id>` (or `user <id>`, `user client:<client_user_id>`) and
496
+ `x-mockingbird-known: users=<n> orders=<n>`.
497
+
498
+ `--log-requests` prints the same fields as one JSON line per request.
499
+
500
+ ## Order control
501
+
502
+ `simulate_order` (`POST /v3/order/{id}/test`) behaves like the sandbox's, including its stepwise
503
+ rules. For direct control, the admin API moves an order straight to a status, recording the same
504
+ event, dates and appointment cascade, and publishing the same webhook, a real transition does:
145
505
 
146
- ### Webhooks
506
+ ```bash
507
+ curl -X POST localhost:8787/__admin/orders/$ORDER/transition -d '{"to": "collected"}'
508
+ curl -X POST localhost:8787/__admin/orders/$ORDER/transition -d '{"to": "completed", "result": "critical"}'
509
+ curl -X POST localhost:8787/__admin/orders/$ORDER/transition -d '{"to": "cancelled.testkit.do_not_process"}'
510
+ ```
147
511
 
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:
512
+ `to` is a full status (`completed.testkit.completed`), a `<phase>.<event>` pair, or a name that maps
513
+ onto the contract's own status for the order's collection method:
514
+
515
+ | Name | `at_home_phlebotomy` | `on_site_collection` | `testkit` | `walk_in_test` |
516
+ | --- | --- | --- | --- | --- |
517
+ | `requisition_created` | `received.…requisition_created` | same | same | same |
518
+ | `scheduled` | `collecting_sample.…appointment_scheduled` | — | — | `collecting_sample.…appointment_scheduled` |
519
+ | `shipped` / `delivered` | — | — | `collecting_sample.…transit_customer` / `…with_customer` | — |
520
+ | `collected` | `collecting_sample.…draw_completed` | `sample_with_lab.…draw_completed` | `collecting_sample.…transit_lab` | — |
521
+ | `at_lab` | — | `sample_with_lab.…draw_completed` | `sample_with_lab.…delivered_to_lab` | — |
522
+ | `partial_results` | `sample_with_lab.…partial_results` | same | — | same |
523
+ | `completed` / `corrected` / `cancelled` / `failed` | `completed.…completed` / `completed.…corrected` / `cancelled.…cancelled` / `failed.…sample_error` | same | same | same |
524
+
525
+ A name with no real status for the method (`collected` for a walk-in test) is refused with the
526
+ valid choices, never approximated. `result` picks a named result — `normal`, `abnormal`,
527
+ `critical`, `missing_results` — which steers the generator so the payload keeps Junction's exact
528
+ shape. For an exact payload, `PUT /__admin/results/{id}` with `{ "results": [...],
529
+ "missing_results": null, "interpretation": "critical", "pdf_base64": "…" }`.
530
+
531
+ There is no `isTestCancelled` in Junction's contract; cancelling produces the vendor's
532
+ `cancelled.<method>.cancelled` event and webhook, which is what a consumer derives that flag from.
533
+
534
+ **Time.** Every timestamp reads the runtime clock. `POST /__admin/clock {"advance": "2h"}` makes
535
+ delayed simulations (`?delay=<seconds>`) due and moves appointment windows, with no sleeping.
536
+
537
+ ## Faults and error shapes
538
+
539
+ Error bodies the mock reproduces byte for byte, checked against the sandbox by `verify` and the
540
+ parity suite (`ORDER_NOT_FOUND` holds the unknown-order text per operation id):
541
+
542
+ | Case | Status | Body |
543
+ | --- | --- | --- |
544
+ | `GET /v2/user/{unknown id}` | 404 | `{"detail":"Not found"}` |
545
+ | `GET /v2/user/resolve/{unknown client_user_id}` | 404 | `{"detail":"User not found"}` |
546
+ | Unknown order: get order, requisition PDF | 404 | `{"detail":"This order doesn't exist"}` |
547
+ | Unknown order: cancel, simulate | 404 | `{"detail":"Order doesn't exist"}` |
548
+ | Unknown order: result, result metadata, result PDF | 404 | `{"detail":"Order not found"}` |
549
+ | Unknown order: every phlebotomy / PSC appointment route | 404 | `{"detail":"This order doesn't exist."}` (trailing period included) |
550
+ | Missing `x-vital-api-key` | 401 | `{"detail":"Missing x-vital-api-key"}` |
551
+ | Request validation | 422 | `{"detail":[{"type":…,"loc":[…],"msg":…,"input":…}]}` (Pydantic shape) |
552
+ | Business rule | 400 | `{"detail":"<message>"}` or `{"detail":{"error_type":"INVALID_REQUEST","error_message":"…"}}` |
553
+
554
+ Faults reproduce the sandbox's failure modes on demand, for retry paths and error handling:
555
+
556
+ | Preset | Fault | Fidelity |
557
+ | --- | --- | --- |
558
+ | `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 |
559
+ | `rate_limited` | 429 `{"detail":"Too Many Requests"}`, `retry-after: 1` | shape-plausible, not verified |
560
+ | `server_error` / `bad_gateway` / `unavailable` | 500 / 502 / 503 | shape-plausible, not verified |
153
561
 
154
562
  ```ts
155
- import { createHmac, randomUUID } from "node:crypto"
156
- import { JunctionAPI } from "@crvouga/mockingbird-service-junction"
563
+ import { createRuntime, FAULT_PRESETS } from "@crvouga/mockingbird-service-junction"
157
564
 
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
565
+ const junction = createRuntime()
566
+ junction.faults.add({ ...FAULT_PRESETS.sandbox_user_quota, id: "quota", count: 1 }) // next create_user only
567
+ junction.faults.add({ id: "flaky-orders", operationId: "create_order_v3_order_post", status: 503, rate: 0.25 })
180
568
  ```
181
569
 
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.
570
+ A rule matches on `operationId`, `method`, `pathPrefix` and `namespace` (all optional), fires
571
+ `count` times or forever, for a `rate` of matching requests (seeded, so a run replays identically),
572
+ after an optional `delayMs`. A rule added through the admin API defaults to the calling namespace,
573
+ so one worker's injected failure never lands on another's request; one added in-process, as above,
574
+ applies to every namespace unless given a `namespace`. Over HTTP: `POST /__admin/faults` with the same fields, or
575
+ `POST /__admin/faults/presets/sandbox_user_quota`.
576
+
577
+ ## Webhooks
185
578
 
186
- ### Resetting between tests
579
+ With `webhooks` / `--webhook-url`, every event is delivered signed, with retries:
187
580
 
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:
581
+ - **Standard Svix signatures**: `svix-id`, `svix-timestamp`, and `svix-signature: v1,<base64
582
+ HMAC-SHA256 of "<svix-id>.<svix-timestamp>.<body>">`, keyed by the base64 part of `whsec_…`. The
583
+ official `svix` verifier, and a receiver written for real Junction, accept it unchanged. (Signing
584
+ `"<timestamp>.<body>"` without the message id, as this package's own server script did before,
585
+ is not Svix-compatible.)
586
+ - Timestamps are wall-clock even when the mock clock is moved, because receivers reject timestamps
587
+ outside a few minutes of their own clock.
588
+ - Any 2xx is delivered; anything else is retried on Svix's schedule (immediately, 5 s, 5 min,
589
+ 30 min, 2 h, 5 h, 10 h, 10 h) or `retryDelaysMs`. `POST /__admin/webhooks/flush` runs pending
590
+ retries now; `POST /__admin/webhooks/{id}/replay` redelivers one message.
190
591
 
191
592
  ```ts
192
- import { beforeEach, expect, test } from "bun:test"
193
- import { JunctionAPI } from "@crvouga/mockingbird-service-junction"
593
+ import { createRuntime, verifySvix } from "@crvouga/mockingbird-service-junction"
194
594
 
195
- const junction = new JunctionAPI()
196
- beforeEach(() => junction.reset())
595
+ const secret = "whsec_bW9ja2luZ2JpcmQtdGVzdC1zZWNyZXQ="
596
+ const junction = createRuntime({
597
+ webhooks: { url: "http://127.0.0.1:3100/webhooks/junction", secret, retryDelaysMs: [0, 500, 2000] },
598
+ })
197
599
 
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
- }),
600
+ // In a test receiver:
601
+ const accept = async (request: Request) =>
602
+ verifySvix(
603
+ secret,
604
+ {
605
+ "svix-id": request.headers.get("svix-id") ?? "",
606
+ "svix-timestamp": request.headers.get("svix-timestamp") ?? "",
607
+ "svix-signature": request.headers.get("svix-signature") ?? "",
608
+ },
609
+ await request.text(),
203
610
  )
204
- expect(await response.json()).toMatchObject({ users: [], total: 0 })
205
- })
611
+ console.log(typeof accept, junction.webhooks?.deliveries().length) // "function" 0
206
612
  ```
207
613
 
208
- ### Sealed corpus (optional, higher fidelity)
209
-
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).
614
+ ## Verify
215
615
 
216
- ```ts
217
- import { readFileSync } from "node:fs"
218
- import { JunctionAPI, parseSealedCorpus } from "@crvouga/mockingbird-service-junction"
616
+ `verify` is the fidelity guarantee: it runs the same requests against real Junction and the mock
617
+ and reports every divergence.
219
618
 
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()
619
+ ```bash
620
+ npx mockingbird-junction verify --real-key "$JUNCTION_SANDBOX_KEY" --corpus test/junction-corpus.json
621
+ npx mockingbird-junction verify --real-key "$JUNCTION_SANDBOX_KEY" --orders --sample 50 --json
223
622
  ```
224
623
 
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.
624
+ - **Drift**: re-fetches each recorded observation (or `--sample n` of them) and reports any that no
625
+ longer match production — the signal to re-pull.
626
+ - **Scenario**: creates a user, reads it back, provokes the documented 404s (including every
627
+ order-scoped operation against a random unknown order), reads the catalog (paged and bare) and a
628
+ covered ZIP's serviceability, and deletes the user; with `--orders`, also places, reads and
629
+ cancels an order for the corpus's first lab test, and — when the corpus has an account with an
630
+ empty `team_id_allowlist` — orders through it with and without its id. Status codes must match;
631
+ bodies must match in shape, and byte for byte for the documented error bodies.
632
+
633
+ It exits 1 on any divergence. The scenario always deletes its user (and cancels its order), because
634
+ it runs against the same capped sandbox the mock exists to relieve. `verifyAgainstReal` is the same
635
+ check as a function. This repo runs it in CI against the Junction sandbox.
636
+
637
+ ## Determinism
638
+
639
+ With a fixed clock (`clock` / `POST /__admin/clock {"set": …, "freeze": true}`) and `seed`, a run
640
+ replays exactly. Stable across runs: user ids, order ids, event ids, sample ids, booking keys and
641
+ webhook message ids (derived from sequence numbers), timestamps (from the clock), generated
642
+ availability, and which requests a partial-`rate` fault hits. Wall-clock by design: webhook
643
+ `svix-timestamp`, `/health` uptime, and request log durations.
644
+
645
+ ## What is and is not modelled
646
+
647
+ A mock that is silent about its gaps is how a green suite starts lying. Specifically:
648
+
649
+ - **Corpus-backed** (byte-exact): area and PSC serviceability for covered ZIPs; the catalog, labs,
650
+ expected results and lab accounts of the team the corpus was recorded from.
651
+ - **Synthetic** (deterministic, shape-faithful, values invented): availability slots and booking
652
+ keys; results (biomarker lines and PDFs) unless a fixture is installed; requisition PDFs;
653
+ coverage for any ZIP in `--geo synthetic` mode.
654
+ - **Not modelled**:
655
+ - `delegated_flow`-specific ordering. Accounts carry the field and orders follow the documented
656
+ selection and billing rules above, but any additional behaviour Junction applies to delegated
657
+ accounts is not reproduced. Run `verify --orders` with your team's corpus to check what you rely on.
658
+ - Rate limits and outages, except when injected as faults or switched on as
659
+ [limits](#sandbox-limits).
660
+ - Whether an account with an empty `team_id_allowlist` can be ordered through (see
661
+ [Team identity](#team-identity)); the mock assumes it can.
662
+ - Adopted users (`adopt-users`) have `client_user_id` equal to their `user_id`; nothing in
663
+ Junction corresponds to adoption.
664
+ - Real 429 / 5xx bodies: the presets are plausible, not recorded.
665
+ - Phlebotomy availability breadth: the sandbox serves it only for `85004`; the mock generates slots
666
+ for any covered ZIP.
667
+ - Webhook retry jitter across a snapshot restore (the jitter stream is not part of a snapshot).
668
+ - Operations outside the vendored OpenAPI subset: they return 404 and are counted as
669
+ `unmatched` in `/__admin/metrics`.
227
670
 
228
671
  ## API
229
672
 
673
+ Main entry (`@crvouga/mockingbird-service-junction`, runtime-neutral):
674
+
230
675
  | Export | Description |
231
676
  | --- | --- |
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. |
677
+ | `createRuntime` | `(options?: JunctionRuntimeOptions) => JunctionRuntime` — the served mock (health, admin, namespaces, clock, faults, metrics, journal, webhooks) as one `fetch`. Options: `corpus`, `geo`, `labAccounts`, `teamId`, `limits`, `identity`, `fixtures`, `journalSize`, `webhooks`, `onWebhook`, `sqlite`, `clock`, `seed`, `adminKey`, `onLog`. |
678
+ | `JunctionAPI` | Class. `new JunctionAPI(options?)`: one namespace's mock, implementing `fetch(request: Request): Promise<Response>`. |
679
+ | `JUNCTION_NAMESPACE` | `"junction"` — the default namespace's storage key when sharing a `sqlite` client. |
234
680
  | `document` | The vendored Junction OpenAPI document (Mockingbird subset) that drives routing. |
235
681
  | `operationIds` | Every `operationId` in `document`. |
236
682
  | `supportedOperationIds` | The `operationId`s the mock implements (all of them). |
683
+ | `pullCorpus` | `(options: PullCorpusOptions) => Promise<SealedCorpus>` — record a corpus from a real team (GETs only). |
684
+ | `diffCorpus` | `(before, after) => CorpusDiff` — ZIPs, lab tests, lab accounts and observations added / removed / changed. |
685
+ | `fingerprintCorpus` | `(corpus) => Promise<string>` — SHA-256 of a corpus's content. |
686
+ | `corpusLabel` | `(corpus) => string` — the short label `/health` and logs show, e.g. `v1-2026-09-18-HqFDuEAuhkwD`. |
237
687
  | `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`. |
688
+ | `SEALED_CORPUS_VERSION` | Current corpus format version (`2`: adds `teamId`). |
689
+ | `SUPPORTED_SEALED_CORPUS_VERSIONS` | Versions `parseSealedCorpus` loads (`[1, 2]`). |
690
+ | `MOCK_TEAM_ID` | The team id used when neither `teamId` nor the corpus gives one. |
691
+ | `OTHER_TEAM_ID` | A team that is not the mock's, for fixtures modelling another team's account. |
692
+ | `isLinkedToTeam` | `(account, teamId) => boolean` — the allowlist rule (`[]` counts as linked). |
693
+ | `LAB_ACCOUNT_PRESETS` | The [lab-account presets](#lab-account-presets), without ids. |
694
+ | `presetAccountId` | `(name) => string` — a preset's default id. |
695
+ | `PLATFORM_ACCOUNT_STATES` / `DELEGATED_ACCOUNT_STATES` | The recorded 47- and 48-state client-bill lists. |
696
+ | `LabAccountConflict` | Thrown by `addLabAccount` for an id that exists. |
697
+ | `DEFAULT_LIMITS` | Every [sandbox limit](#sandbox-limits), off. |
698
+ | `sandboxUserQuotaBody` | `(max) => body` — the sandbox's user-cap 400 body. |
699
+ | `IDENTITY_MODES` | `["strict", "adopt-users"]`. |
700
+ | `FixtureError` | Thrown by the fixture inserts; `status` is 400, 404 or 409. |
701
+ | `ORDER_NOT_FOUND` | Junction's unknown-order `detail`, by operation id. |
702
+ | `MISS_HEADER` / `KNOWN_HEADER` | `"x-mockingbird-miss"` / `"x-mockingbird-known"`. |
703
+ | `verifyAgainstReal` | `(options: VerifyOptions) => Promise<VerifyReport>` — the `verify` command as a function. |
704
+ | `DEFAULT_JUNCTION_BASE_URL` | `"https://api.sandbox.tryvital.io"`. |
705
+ | `isSandboxKey` / `SANDBOX_KEY_PREFIXES` | Whether a key is a sandbox team key (`sk_us_` / `sk_eu_`). |
706
+ | `UNKNOWN_ZIP_STATUS` / `UNKNOWN_ZIP_ERROR_TYPE` | `424` / `"MOCKINGBIRD_UNKNOWN_ZIP"` — the corpus-mode unknown-ZIP error. |
707
+ | `labAccountFromInput` | `(input: LabAccountInput) => LabAccountRecord` — normalize (and validate) a configured account. |
708
+ | `TEAM_LAB_ACCOUNTS` | The built-in lab-account fixtures. |
709
+ | `US_STATES` | The 50 `USState` codes Junction accepts. |
710
+ | `ORDER_STATUSES_BY_METHOD` | Every order status in the contract, by collection method. |
711
+ | `TRANSITION_ALIASES` | The transition names above, by method. |
712
+ | `resolveTransition` | `(method, target) => { status } \| { error }` — how `/__admin/orders/{id}/transition` reads `to`. |
713
+ | `RESULT_FIXTURES` | Named results (`normal`, `abnormal`, `critical`, `missing_results`). |
714
+ | `FAULT_PRESETS` | Named Junction faults (`sandbox_user_quota`, `rate_limited`, `server_error`, `bad_gateway`, `unavailable`). |
715
+ | `createWebhookDispatcher` | `(endpoint: WebhookEndpoint) => WebhookDispatcher` — signed delivery with retries, standalone. |
716
+ | `signSvix` / `verifySvix` | Produce / check a standard Svix `svix-signature`. |
717
+ | `observationCacheKey` | `(url, method = "GET", body?) => string` — the `"<METHOD> <path>?<sorted query> <json>"` key corpora use. |
718
+ | `prefetchCoverageObservations` | Parity helper: records coverage-ZIP reads from the sandbox into an observation cache before `seedFrom`. |
241
719
  | `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. |
720
+ | `COVERAGE_ZIPS` | The ZIPs the shipped corpus covers. |
243
721
  | `PSC_AVAILABILITY_ZIPS` | ZIPs that also get PSC availability observations. |
244
- | `PHLEBOTOMY_AVAILABILITY_ZIPS` | ZIPs served for phlebotomy availability (`["85004"]`). |
722
+ | `PHLEBOTOMY_AVAILABILITY_ZIPS` | ZIPs the sandbox serves phlebotomy availability for (`["85004"]`). |
245
723
  | `PSC_LAB_IDS` | Lab ids used for PSC reads (`[4, 6, 13, 25]`). |
246
724
  | `AVAILABILITY_ADDRESS` | Stable Phoenix, AZ address used in availability requests so cache keys match. |
247
725
  | `AVAILABILITY_START_DATE` | Far-future availability `start_date` (`"2099-06-15"`). |
248
726
 
727
+ `./server` (Node): `createServer(options?) => Promise<JunctionServer>`, `serveTarget` (what
728
+ `serve` and `serve --config` use), `loadCorpus(source)`, `DEFAULT_PORT` (`8787`).
729
+ `./corpus`: `defaultCorpus`.
730
+
731
+ `JunctionRuntime` (from `createRuntime`) members: `fetch`, `instance(namespace?)`, `namespaces()`,
732
+ `reset(namespace? | "*")`, `snapshot(namespace?)`, `restore(snapshot, namespace?)`, `clock`
733
+ (`now`, `set`, `advance`, `freeze`, `unfreeze`, `reset`, `state`), `faults` (`add`, `list`,
734
+ `remove`, `clear`), `metrics` (`report`, `reset`), `journal` (`list`, `clear`, `size`), `webhooks`
735
+ (`deliveries`, `replay`, `flush`, `idle`, `clear`), `sqlite`, `rng`.
736
+
249
737
  `JunctionAPI` members:
250
738
 
251
739
  | Member | Description |
252
740
  | --- | --- |
253
741
  | `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). |
742
+ | `reset()` | `Promise<void>` — clear state; re-install the corpus or default catalog, and the configured lab accounts. |
743
+ | `installCorpus(corpus)` | Install a `SealedCorpus`: observations (shared, read-only), catalog, labs, lab accounts, ZIP coverage. |
744
+ | `corpusInfo()` | `CorpusInfo \| undefined` — label, recording date, source and counts. |
745
+ | `geoMode` | `"corpus" \| "synthetic"`, settable. |
746
+ | `configureLabAccounts(layout?)` / `labAccounts()` | Replace (a list, or `{ presets, accounts }`; `undefined` restores the default) / read the team's lab accounts. |
747
+ | `addLabAccount(input)` / `addLabAccountPreset(name, id?)` / `patchLabAccount(id, patch)` / `removeLabAccount(id)` | Change one account; kept across `reset()`. |
748
+ | `teamId` | The team this namespace answers as. |
749
+ | `limits` / `configureLimits(input)` | Read / change the sandbox limits; kept across `reset()`. |
750
+ | `identity` | `"strict" \| "adopt-users"`, settable. |
751
+ | `insertUsers(users)` / `insertOrders(orders, { emitWebhooks? })` / `importFixtures(fixtures, options?)` / `hardDeleteUser(id)` | The [fixture backdoor](#fixtures-and-identity). |
752
+ | `order(id)` / `orders()` | Read orders. |
753
+ | `transitionOrder(id, status, { now, flags? })` | Move an order to a full status and publish its webhook. |
754
+ | `installResultFixture(orderId, fixture)` / `resultFixture(orderId)` | Serve an exact result payload / PDF for an order. |
256
755
  | `seedFrom(source, observations?)` | `Promise<SeedReport>` — copy users, orders, appointments, catalog and labs from a real Junction environment. |
257
756
  | `ensureLabTests(source, ids)` / `ensureOrders(source, ids)` | `Promise<number>` — fetch specific lab tests / orders from `source` if missing. |
258
757
  | `markUserDeleted(userId)` | Mark a user as deleted (subsequent reads behave like a deleted user). |
259
758
  | `webhookEvents()` | `JunctionWebhookEvent[]`, oldest first. |
260
- | `webhookDeliveryAttempts()` | Recorded retry schedule per event. |
759
+ | `webhookDeliveryAttempts()` | The retry schedule Junction would follow per event (a record; see `webhooks` for delivery). |
261
760
  | `app` / `sqlite` | The underlying Hono app and `SqliteClient`. |
262
761
 
263
762
  Options and types:
@@ -265,21 +764,31 @@ Options and types:
265
764
  ```text
266
765
  type JunctionAPIOptions = {
267
766
  sqlite?: SqliteClient // share one client across services; default: fresh in-memory DB
767
+ namespace?: string // storage namespace; default "junction"
268
768
  now?: () => number // clock in ms; default Date.now
769
+ corpus?: SealedCorpus // installed now and on every reset()
770
+ geo?: "corpus" | "synthetic" // default "corpus" with a corpus, else "synthetic"
771
+ labAccounts?: LabAccountInput[] | { presets?: (string | { name; id? })[]; accounts?: LabAccountInput[] }
772
+ teamId?: string // default: the corpus's recorded team, else MOCK_TEAM_ID
773
+ limits?: { maxUsers?: number | null; simulateRequiresSandbox?: boolean; rateLimitPerSecond?: number | null }
774
+ identity?: "strict" | "adopt-users" // default "strict"
775
+ fixtures?: { users?: UserFixture[]; orders?: OrderFixture[] } // loaded now and on every reset()
269
776
  onWebhook?: WebhookPublisher // called with every recorded event
270
777
  webhook?: JunctionWebhookOptions
271
778
  }
272
- type JunctionWebhookOptions = { seed?: number; jitterRatio?: number /* 0..1, default 0.1 */; timeoutMs?: number /* default 15000 */ }
779
+ type LabAccountInput = { id; lab; status?; delegated_flow?; account_name?; provider_account_id?; org_id?;
780
+ business_units?; default_clinical_notes?; allowed_billing?: Record<billingType, State[]>; states?: State[];
781
+ team_id_allowlist? }
782
+ type WebhookEndpoint = { url; secret: "whsec_…"; retryDelaysMs?: number[]; timeoutMs?; headers?; fetch? }
783
+ type ResultFixture = { name; results?: unknown[]; missing_results?: unknown[] | null; interpretation?; pdf_base64? }
784
+ type FaultRule = { id; operationId?; method?; pathPrefix?; status; body?; headers?; count?; rate?; delayMs? }
785
+ type JunctionWebhookOptions = { seed?: number; jitterRatio?: number /* 0..1, default 0.1 */; timeoutMs?: number }
273
786
  type JunctionWebhookEvent = {
274
787
  event_type: "labtest.order.created" | "labtest.order.updated" | "labtest.appointment.updated"
275
788
  data: Record<string, unknown>; team_id: string; user_id: string; client_user_id: string
276
789
  }
277
- type WebhookPublisher = (event: JunctionWebhookEvent) => void
790
+ type SealedCorpus = { version: 1 | 2; recordedAt; source; teamId?; fingerprint?; observations; catalog: { labTests; labs; expectedResults }; labAccounts }
278
791
  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
792
  type OperationId / SupportedOperationId // string unions of operationIds / supportedOperationIds
284
793
  ```
285
794
 
@@ -299,23 +808,19 @@ console.log(report.users, report.orders)
299
808
 
300
809
  ## Development
301
810
 
302
- For contributors to the mockingbird repo only; these scripts and the corpus are not shipped in the
303
- npm package.
811
+ For contributors to the mockingbird repo only.
304
812
 
305
813
  ```bash
306
814
  bun test # offline suites, incl. scheduling state space
307
- bun test junction.seed.property.test.ts # offline monkey (no network)
815
+ bun test junction.runtime.property.test.ts # service contract, corpus, lab accounts, faults, webhooks, verify
308
816
  bun test junction.sdk.property.test.ts # drives the served mock through @tryvital/vital-node
309
817
 
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)
818
+ bun run mock:serve # env-configured `serve`: HOST, PORT, MOCKINGBIRD_JUNCTION_CORPUS,
819
+ # MOCKINGBIRD_JUNCTION_WEBHOOK_URL / _SECRET / _SCOPE
820
+ bun run corpus:record -- --force # re-record the shipped corpus/sandbox-sealed.json (Vault key)
821
+ bun run verify -- --real-key "$KEY" # `mockingbird-junction verify` from source
313
822
  ```
314
823
 
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
824
  Live parity (primary proof is seedParity: warmup N on the sandbox, `seedFrom`, then lockstep M):
320
825
 
321
826
  ```bash
@@ -326,6 +831,5 @@ bun run parity -- --mode=empty # legacy empty-start differenti
326
831
 
327
832
  Webhook parity needs a public receiver; see
328
833
  [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
834
 
331
835
  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).