@crvouga/mockingbird-service-junction 0.2.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Changelog — @crvouga/mockingbird-service-junction
2
2
 
3
- ## 0.2.0 (2026-09-21)
3
+ ## 0.4.0 (2026-09-21)
4
+
5
+ ### Features
6
+
7
+ - add vendor mocks for the full service catalog ([f870287](https://github.com/crvouga/mockingbird/commit/f8702874c807b1be4ce4ae2c5b46c8fd59a5e8eb))
8
+
9
+ ## 0.3.0 (2026-09-20)
10
+
11
+ ### Features
12
+
13
+ - team identity, lab-account admin, sandbox limits, fixtures and request journal ([6cb1be3](https://github.com/crvouga/mockingbird/commit/6cb1be324a048ac9fe832061632c0ee9c9d01f8b))
14
+
15
+ ## 0.2.0 (2026-09-20)
4
16
 
5
17
  ### Features
6
18
 
@@ -12,10 +24,6 @@
12
24
  - scope admin-injected faults to the calling namespace ([3c1d573](https://github.com/crvouga/mockingbird/commit/3c1d573ac0c404324bbd20855a5ac7a2cd50f738))
13
25
  - take the admin namespace override only on admin routes ([37836a5](https://github.com/crvouga/mockingbird/commit/37836a5b0c51d17dd24e7b01c12dd6d8049646da))
14
26
 
15
- ### Dependencies
16
-
17
- - `@crvouga/mockingbird-service-sqlite`
18
-
19
27
  ## 0.1.2 (2026-09-19)
20
28
 
21
29
  ### Fixes and improvements
package/README.md CHANGED
@@ -3,12 +3,13 @@
3
3
  Stateful mock of the [Junction (formerly Vital) API](https://docs.junction.com/): users
4
4
  (`/v2/user`), the lab-testing catalog, lab orders (create, cancel, simulate, results,
5
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
6
+ `labtest.order.*` / `labtest.appointment.updated` webhooks. All 40 operations in the vendored
7
7
  OpenAPI subset are served, and behaviour is checked by differential property tests against the
8
8
  Junction sandbox.
9
9
 
10
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.
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.
12
13
 
13
14
  ```bash
14
15
  npx mockingbird-junction serve # http://127.0.0.1:8787, recorded sandbox corpus loaded
@@ -54,19 +55,26 @@ npx mockingbird-junction serve --help
54
55
  | `--host <host>` | `127.0.0.1` | Interface to bind |
55
56
  | `--corpus <default\|none\|file>` | `default` | The shipped corpus, no corpus (synthetic data), or a file from `corpus pull` |
56
57
  | `--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
+ | `--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 |
58
64
  | `--webhook-url <url>` / `--webhook-secret <whsec_…>` | off | Deliver [signed webhooks](#webhooks) (secret also from `MOCKINGBIRD_JUNCTION_WEBHOOK_SECRET`) |
59
65
  | `--webhook-retry-delays <ms,…>` | Svix's schedule | Delay before each delivery attempt, e.g. `0,1000,5000` in tests |
60
66
  | `--webhook-scope <scope>` | — | Sent as `x-mockingbird-scope` on every delivery |
61
67
  | `--admin-key <key>` | open | Require `x-mockingbird-admin-key` on `/__admin/*` (also `MOCKINGBIRD_ADMIN_KEY`) |
62
68
  | `--seed <seed>` | `0` | Seeds fault rates and retry jitter |
63
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 |
64
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 |
65
72
  | `--config <file>` | — | Serve every service in a `mockingbird.json` instead ([below](#many-services-from-one-config)) |
66
73
 
67
74
  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.
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.
70
78
 
71
79
  ### Many services from one config
72
80
 
@@ -224,7 +232,12 @@ Every Mockingbird service answers the same control surface, outside the vendor's
224
232
  - `x-mockingbird-namespace: <name>` — isolates a request's data (`[A-Za-z0-9_.-]{1,64}`). Each
225
233
  namespace is a separate team over one shared database, so parallel workers share one process
226
234
  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`.
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)).
228
241
 
229
242
  | Route | Does |
230
243
  | --- | --- |
@@ -241,7 +254,19 @@ Every Mockingbird service answers the same control surface, outside the vendor's
241
254
  | `POST /__admin/orders/{id}/transition` | Move an order: `{ "to": "completed", "result": "abnormal" }` ([order control](#order-control)) |
242
255
  | `GET` / `PUT /__admin/results/{id}` | Read or install an exact result payload / PDF for an order |
243
256
  | `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) |
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 |
245
270
  | `GET /__admin/corpus`, `PUT /__admin/geo` | The loaded corpus; switch [geo mode](#geo-corpus-or-synthetic) |
246
271
  | `GET /__admin/webhooks` | [Deliveries](#webhooks) with every attempt |
247
272
  | `POST /__admin/webhooks/{id}/replay`, `POST /__admin/webhooks/flush` | Redeliver one message; run pending retries now |
@@ -327,8 +352,150 @@ console.log(junction.instance().labAccounts().length) // 3
327
352
  ```
328
353
 
329
354
  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).
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.
359
+
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": []}'
371
+ ```
372
+
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:
376
+
377
+ ```json
378
+ { "presets": ["bioreference_ny_nj_delegated", { "name": "quest_platform", "id": "…" }], "accounts": [] }
379
+ ```
380
+
381
+ ### Lab-account presets
382
+
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).
387
+
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"` |
396
+
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
443
+ ```
444
+
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.
332
499
 
333
500
  ## Order control
334
501
 
@@ -370,12 +537,16 @@ delayed simulations (`?delay=<seconds>`) due and moves appointment windows, with
370
537
  ## Faults and error shapes
371
538
 
372
539
  Error bodies the mock reproduces byte for byte, checked against the sandbox by `verify` and the
373
- parity suite:
540
+ parity suite (`ORDER_NOT_FOUND` holds the unknown-order text per operation id):
374
541
 
375
542
  | Case | Status | Body |
376
543
  | --- | --- | --- |
377
544
  | `GET /v2/user/{unknown id}` | 404 | `{"detail":"Not found"}` |
378
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) |
379
550
  | Missing `x-vital-api-key` | 401 | `{"detail":"Missing x-vital-api-key"}` |
380
551
  | Request validation | 422 | `{"detail":[{"type":…,"loc":[…],"msg":…,"input":…}]}` (Pydantic shape) |
381
552
  | Business rule | 400 | `{"detail":"<message>"}` or `{"detail":{"error_type":"INVALID_REQUEST","error_message":"…"}}` |
@@ -452,10 +623,12 @@ npx mockingbird-junction verify --real-key "$JUNCTION_SANDBOX_KEY" --orders --sa
452
623
 
453
624
  - **Drift**: re-fetches each recorded observation (or `--sample n` of them) and reports any that no
454
625
  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
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
456
628
  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.
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.
459
632
 
460
633
  It exits 1 on any divergence. The scenario always deletes its user (and cancels its order), because
461
634
  it runs against the same capped sandbox the mock exists to relieve. `verifyAgainstReal` is the same
@@ -482,7 +655,12 @@ A mock that is silent about its gaps is how a green suite starts lying. Specific
482
655
  - `delegated_flow`-specific ordering. Accounts carry the field and orders follow the documented
483
656
  selection and billing rules above, but any additional behaviour Junction applies to delegated
484
657
  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.
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.
486
664
  - Real 429 / 5xx bodies: the presets are plausible, not recorded.
487
665
  - Phlebotomy availability breadth: the sandbox serves it only for `85004`; the mock generates slots
488
666
  for any covered ZIP.
@@ -496,7 +674,7 @@ Main entry (`@crvouga/mockingbird-service-junction`, runtime-neutral):
496
674
 
497
675
  | Export | Description |
498
676
  | --- | --- |
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`. |
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`. |
500
678
  | `JunctionAPI` | Class. `new JunctionAPI(options?)`: one namespace's mock, implementing `fetch(request: Request): Promise<Response>`. |
501
679
  | `JUNCTION_NAMESPACE` | `"junction"` — the default namespace's storage key when sharing a `sqlite` client. |
502
680
  | `document` | The vendored Junction OpenAPI document (Mockingbird subset) that drives routing. |
@@ -507,7 +685,21 @@ Main entry (`@crvouga/mockingbird-service-junction`, runtime-neutral):
507
685
  | `fingerprintCorpus` | `(corpus) => Promise<string>` — SHA-256 of a corpus's content. |
508
686
  | `corpusLabel` | `(corpus) => string` — the short label `/health` and logs show, e.g. `v1-2026-09-18-HqFDuEAuhkwD`. |
509
687
  | `parseSealedCorpus` | `(value: unknown) => SealedCorpus`; throws on a non-object or unsupported `version`. |
510
- | `SEALED_CORPUS_VERSION` | Current corpus format version (`1`). |
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"`. |
511
703
  | `verifyAgainstReal` | `(options: VerifyOptions) => Promise<VerifyReport>` — the `verify` command as a function. |
512
704
  | `DEFAULT_JUNCTION_BASE_URL` | `"https://api.sandbox.tryvital.io"`. |
513
705
  | `isSandboxKey` / `SANDBOX_KEY_PREFIXES` | Whether a key is a sandbox team key (`sk_us_` / `sk_eu_`). |
@@ -539,8 +731,8 @@ Main entry (`@crvouga/mockingbird-service-junction`, runtime-neutral):
539
731
  `JunctionRuntime` (from `createRuntime`) members: `fetch`, `instance(namespace?)`, `namespaces()`,
540
732
  `reset(namespace? | "*")`, `snapshot(namespace?)`, `restore(snapshot, namespace?)`, `clock`
541
733
  (`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`.
734
+ `remove`, `clear`), `metrics` (`report`, `reset`), `journal` (`list`, `clear`, `size`), `webhooks`
735
+ (`deliveries`, `replay`, `flush`, `idle`, `clear`), `sqlite`, `rng`.
544
736
 
545
737
  `JunctionAPI` members:
546
738
 
@@ -551,7 +743,12 @@ Main entry (`@crvouga/mockingbird-service-junction`, runtime-neutral):
551
743
  | `installCorpus(corpus)` | Install a `SealedCorpus`: observations (shared, read-only), catalog, labs, lab accounts, ZIP coverage. |
552
744
  | `corpusInfo()` | `CorpusInfo \| undefined` — label, recording date, source and counts. |
553
745
  | `geoMode` | `"corpus" \| "synthetic"`, settable. |
554
- | `configureLabAccounts(accounts?)` / `labAccounts()` | Replace (or restore with `undefined`) / read the team's lab accounts. |
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). |
555
752
  | `order(id)` / `orders()` | Read orders. |
556
753
  | `transitionOrder(id, status, { now, flags? })` | Move an order to a full status and publish its webhook. |
557
754
  | `installResultFixture(orderId, fixture)` / `resultFixture(orderId)` | Serve an exact result payload / PDF for an order. |
@@ -571,7 +768,11 @@ type JunctionAPIOptions = {
571
768
  now?: () => number // clock in ms; default Date.now
572
769
  corpus?: SealedCorpus // installed now and on every reset()
573
770
  geo?: "corpus" | "synthetic" // default "corpus" with a corpus, else "synthetic"
574
- labAccounts?: LabAccountInput[]
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()
575
776
  onWebhook?: WebhookPublisher // called with every recorded event
576
777
  webhook?: JunctionWebhookOptions
577
778
  }
@@ -586,7 +787,7 @@ type JunctionWebhookEvent = {
586
787
  event_type: "labtest.order.created" | "labtest.order.updated" | "labtest.appointment.updated"
587
788
  data: Record<string, unknown>; team_id: string; user_id: string; client_user_id: string
588
789
  }
589
- type SealedCorpus = { version: 1; recordedAt; source; fingerprint?; observations; catalog: { labTests; labs; expectedResults }; labAccounts }
790
+ type SealedCorpus = { version: 1 | 2; recordedAt; source; teamId?; fingerprint?; observations; catalog: { labTests; labs; expectedResults }; labAccounts }
590
791
  type SeedSource = { fetch: (request: Request) => Promise<Response>; baseUrl: string; headers: Record<string, string> }
591
792
  type OperationId / SupportedOperationId // string unions of operationIds / supportedOperationIds
592
793
  ```
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  parseSealedCorpus
3
- } from "./chunk-4C6VYRJN.js";
3
+ } from "./chunk-ISDNA2TU.js";
4
4
 
5
5
  // corpus/sandbox-sealed.json
6
6
  var sandbox_sealed_default = {
@@ -145755,4 +145755,4 @@ var defaultCorpus = parseSealedCorpus(sandbox_sealed_default);
145755
145755
  export {
145756
145756
  defaultCorpus
145757
145757
  };
145758
- //# sourceMappingURL=chunk-MD4KYEIM.js.map
145758
+ //# sourceMappingURL=chunk-C2UGIPPM.js.map
@@ -1,13 +1,14 @@
1
1
  import {
2
+ IDENTITY_MODES,
2
3
  createRuntime,
3
4
  pullCorpus
4
- } from "./chunk-MA3HSUVD.js";
5
+ } from "./chunk-ICINEQ7D.js";
5
6
  import {
6
7
  defaultCorpus
7
- } from "./chunk-MD4KYEIM.js";
8
+ } from "./chunk-C2UGIPPM.js";
8
9
  import {
9
10
  parseSealedCorpus
10
- } from "./chunk-4C6VYRJN.js";
11
+ } from "./chunk-ISDNA2TU.js";
11
12
 
12
13
  // src/server.ts
13
14
  import { readFile as readFile2 } from "node:fs/promises";
@@ -35,14 +36,56 @@ var serve = async (api, options = {}) => {
35
36
  if (method !== "GET" && method !== "HEAD" && body.length > 0) {
36
37
  init.body = body;
37
38
  }
39
+ const aborted = new AbortController();
40
+ res.once("close", () => {
41
+ if (!res.writableFinished)
42
+ aborted.abort();
43
+ });
44
+ init.signal = aborted.signal;
38
45
  const request = new Request(url, init);
39
- const response = await api.fetch(request);
46
+ let response;
47
+ try {
48
+ response = await api.fetch(request);
49
+ } catch (error) {
50
+ if (error.code === "MOCKINGBIRD_DROP") {
51
+ req.socket.destroy();
52
+ return;
53
+ }
54
+ res.writeHead(500, { "content-type": "application/json" });
55
+ res.end(JSON.stringify({
56
+ error: {
57
+ type: "mockingbird_internal",
58
+ message: error instanceof Error ? error.message : String(error)
59
+ }
60
+ }));
61
+ return;
62
+ }
40
63
  const headers = Object.fromEntries(response.headers);
41
64
  const cookies = response.headers.getSetCookie();
42
65
  if (cookies.length > 0)
43
66
  headers["set-cookie"] = cookies;
67
+ if (!response.body) {
68
+ res.writeHead(response.status, headers);
69
+ res.end();
70
+ return;
71
+ }
44
72
  res.writeHead(response.status, headers);
45
- res.end(Buffer.from(await response.arrayBuffer()));
73
+ res.flushHeaders();
74
+ const reader = response.body.getReader();
75
+ try {
76
+ for (; ; ) {
77
+ const { done, value } = await reader.read();
78
+ if (done)
79
+ break;
80
+ if (!res.write(value))
81
+ await new Promise((resolve) => res.once("drain", resolve));
82
+ }
83
+ res.end();
84
+ } catch {
85
+ res.destroy();
86
+ } finally {
87
+ reader.releaseLock();
88
+ }
46
89
  });
47
90
  await new Promise((resolve, reject) => {
48
91
  server.once("error", reject);
@@ -156,6 +199,10 @@ var COMMON_SERVE_OPTIONS = {
156
199
  description: "Request log format",
157
200
  default: "pretty"
158
201
  },
202
+ "log-requests": {
203
+ type: "boolean",
204
+ description: "One JSON line per request: namespace, operationId, status, ids touched (never bodies). Same as --log json"
205
+ },
159
206
  config: {
160
207
  type: "string",
161
208
  value: "<file>",
@@ -171,7 +218,8 @@ var formatLog = (format) => {
171
218
  const op = entry.operationId ?? (entry.unmatched ? "UNMATCHED" : "-");
172
219
  const ns = entry.namespace === "default" ? "" : ` [${entry.namespace}]`;
173
220
  const fault = entry.faultId ? ` fault=${entry.faultId}` : "";
174
- console.log(`${entry.service} ${entry.method} ${entry.path} ${entry.status} ${op} ${entry.durationMs}ms${ns}${fault}`);
221
+ const adopted = entry.adopted ? " adopted" : "";
222
+ console.log(`${entry.service} ${entry.method} ${entry.path} ${entry.status} ${op} ${entry.durationMs}ms${ns}${fault}${adopted}`);
175
223
  };
176
224
  };
177
225
  var asString = (value) => typeof value === "string" ? value : void 0;
@@ -215,7 +263,7 @@ var serveCommand = (target) => ({
215
263
  summary: `Serve the ${target.name} mock over HTTP`,
216
264
  options: { ...COMMON_SERVE_OPTIONS, ...target.options },
217
265
  async run(values) {
218
- const log = asString(values.log) ?? "pretty";
266
+ const log = values["log-requests"] === true ? "json" : asString(values.log) ?? "pretty";
219
267
  if (!["pretty", "json", "off"].includes(log)) {
220
268
  console.error(`--log must be pretty, json or off (got ${log})`);
221
269
  return 2;
@@ -283,6 +331,21 @@ var createServer2 = async (options = {}) => {
283
331
  return { ...listening, runtime };
284
332
  };
285
333
  var asString2 = (value) => typeof value === "string" && value !== "" ? value : void 0;
334
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
335
+ var count = (value, flag) => {
336
+ if (value === void 0) return void 0;
337
+ if (!/^\d+$/.test(value)) throw new Error(`${flag} must be a non-negative integer (got ${value})`);
338
+ return Number.parseInt(value, 10);
339
+ };
340
+ var readJson = async (path) => {
341
+ const raw = await readFile2(path, "utf8").catch(() => void 0);
342
+ if (raw === void 0) throw new Error(`junction mock: file not found: ${path}`);
343
+ try {
344
+ return JSON.parse(raw);
345
+ } catch (error) {
346
+ throw new Error(`junction mock: ${path} is not JSON (${error.message})`);
347
+ }
348
+ };
286
349
  var retryDelays = (value) => {
287
350
  if (value === void 0) return void 0;
288
351
  const delays = value.split(",").map((part) => Number.parseInt(part.trim(), 10));
@@ -309,7 +372,34 @@ var serveTarget = {
309
372
  "lab-accounts": {
310
373
  type: "string",
311
374
  value: "<file>",
312
- description: "JSON array of lab accounts to route orders through"
375
+ description: 'Lab accounts to route orders through: a JSON array, or { "presets": [...], "accounts": [...] }'
376
+ },
377
+ "team-id": {
378
+ type: "string",
379
+ value: "<uuid>",
380
+ description: "Team the mock answers as (default: the corpus's recorded team)"
381
+ },
382
+ "max-users": {
383
+ type: "string",
384
+ value: "<n>",
385
+ description: "Enforce the sandbox's live-user cap (default: unlimited)"
386
+ },
387
+ identity: {
388
+ type: "string",
389
+ value: "<strict|adopt-users>",
390
+ description: "Unknown user ids: 404 (strict) or create on first use",
391
+ default: "strict"
392
+ },
393
+ fixtures: {
394
+ type: "string",
395
+ value: "<file>",
396
+ description: 'Users and orders every namespace starts with: { "users": [...], "orders": [...] }'
397
+ },
398
+ "journal-size": {
399
+ type: "string",
400
+ value: "<n>",
401
+ description: "Requests each namespace's journal keeps (GET /__admin/requests)",
402
+ default: "1000"
313
403
  },
314
404
  "seed-url": {
315
405
  type: "string",
@@ -356,7 +446,19 @@ var serveTarget = {
356
446
  throw new Error(`--geo must be corpus or synthetic (got ${geo})`);
357
447
  }
358
448
  const accountsFile = asString2(values["lab-accounts"]);
359
- const labAccounts = accountsFile === void 0 ? void 0 : JSON.parse(await readFile2(accountsFile, "utf8"));
449
+ const labAccounts = accountsFile === void 0 ? void 0 : await readJson(accountsFile);
450
+ const fixturesFile = asString2(values.fixtures);
451
+ const fixtures = fixturesFile === void 0 ? void 0 : await readJson(fixturesFile);
452
+ const teamId = asString2(values["team-id"]);
453
+ if (teamId !== void 0 && !UUID.test(teamId)) {
454
+ throw new Error(`--team-id must be a UUID (got ${teamId})`);
455
+ }
456
+ const maxUsers = count(asString2(values["max-users"]), "--max-users");
457
+ const journalSize = count(asString2(values["journal-size"]), "--journal-size");
458
+ const identity = asString2(values.identity);
459
+ if (identity !== void 0 && !IDENTITY_MODES.includes(identity)) {
460
+ throw new Error(`--identity must be one of ${IDENTITY_MODES.join(", ")} (got ${identity})`);
461
+ }
360
462
  const webhookUrl = asString2(values["webhook-url"]);
361
463
  const webhookSecret = asString2(values["webhook-secret"]) ?? process.env.MOCKINGBIRD_JUNCTION_WEBHOOK_SECRET;
362
464
  if (webhookUrl !== void 0 && webhookSecret === void 0) {
@@ -368,6 +470,11 @@ var serveTarget = {
368
470
  ...corpus ? { corpus } : {},
369
471
  ...geo ? { geo } : {},
370
472
  ...labAccounts ? { labAccounts } : {},
473
+ ...fixtures ? { fixtures } : {},
474
+ ...teamId !== void 0 ? { teamId } : {},
475
+ ...maxUsers !== void 0 ? { limits: { maxUsers } } : {},
476
+ ...identity !== void 0 ? { identity } : {},
477
+ ...journalSize !== void 0 ? { journalSize } : {},
371
478
  ...webhookUrl !== void 0 && webhookSecret !== void 0 ? {
372
479
  webhooks: {
373
480
  url: webhookUrl,
@@ -388,7 +495,10 @@ var serveTarget = {
388
495
  return [
389
496
  info ? `corpus ${info.label}: ${info.observations} observations, ${info.zips} ZIPs, ${info.labTests} lab tests, ${info.labAccounts} lab accounts (recorded ${info.recordedAt} from ${info.source})` : "corpus none: synthetic catalog and coverage",
390
497
  `geo ${api.geoMode}${api.geoMode === "corpus" ? " (unknown ZIPs answer 424 MOCKINGBIRD_UNKNOWN_ZIP)" : ""}`,
498
+ `team ${api.teamId}`,
391
499
  `lab accounts: ${api.labAccounts().length}`,
500
+ `limits: ${Object.entries(api.limits).filter(([, value]) => value !== null && value !== false).map(([key, value]) => `${key}=${String(value)}`).join(", ") || "none (sandbox caps off)"}`,
501
+ `identity ${api.identity}`,
392
502
  `webhooks: ${junction.webhooks ? "signed delivery on" : "off"}`,
393
503
  "auth: any x-vital-api-key value (e.g. sk_us_mockingbird)"
394
504
  ];
@@ -403,4 +513,4 @@ export {
403
513
  createServer2 as createServer,
404
514
  serveTarget
405
515
  };
406
- //# sourceMappingURL=chunk-KKNULV7X.js.map
516
+ //# sourceMappingURL=chunk-CR35KYXY.js.map