@crvouga/mockingbird-service-persona 0.1.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 ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog — @crvouga/mockingbird-service-persona
2
+
3
+ ## 0.1.0 (2026-09-22)
4
+
5
+ Initial release.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # @crvouga/mockingbird-service-persona
2
+
3
+ Stateful mock of **Persona**'s identity-verification API for test suites: create an inquiry,
4
+ the "reusable inquiry" list lookup, fetch one inquiry, the hosted flow page members are sent
5
+ to, and the `Persona-Signature`-signed events Persona posts back. Inquiries move only when a
6
+ test says so (an admin action or a click on the hosted page), so the Rx consultation's ID
7
+ verification step (flag `rx-id-verification`) runs without a real sandbox or a real selfie.
8
+
9
+ - Operation coverage: [SUPPORT.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/persona/SUPPORT.md)
10
+ - Persona publishes no OpenAPI document: the contract (`openapi.yaml`) is hand-authored from
11
+ Persona's API reference (JSON:API, `Persona-Version: 2023-01-05`) and our EMR's zod schemas.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -D @crvouga/mockingbird-service-persona
17
+ ```
18
+
19
+ ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
20
+ `npx mockingbird-persona serve`, `createServer` from `./server` (Node), or `createRuntime` with
21
+ any Fetch server.
22
+
23
+ ## Usage
24
+
25
+ Point the EMR at the mock (all of these are required in `E/config/env.ts`):
26
+
27
+ | EMR env | Value |
28
+ | --- | --- |
29
+ | `PERSONA_API_URL` | `http://127.0.0.1:8815` (or `…/ns/<namespace>`) |
30
+ | `PERSONA_API_KEY` | any bearer (or the one passed as `--api-key`) |
31
+ | `PERSONA_WEB_INQUIRY_URL`, `PERSONA_MOBILE_INQUIRY_URL` | `http://127.0.0.1:8815/verify` |
32
+ | `PERSONA_IDENTITY_INQUIRY_TEMPLATE_ID`, `PERSONA_PHONE_INQUIRY_TEMPLATE_ID` | any `itmpl_…` ids |
33
+ | `PERSONA_WEBHOOK_SECRET` | the value passed as `--webhook-secret` |
34
+
35
+ ```bash
36
+ npx mockingbird-persona serve --port 8815 \
37
+ --webhook-url http://127.0.0.1:4000/v1/identify-verification/webhook \
38
+ --webhook-secret "$PERSONA_WEBHOOK_SECRET"
39
+ ```
40
+
41
+ ```ts
42
+ import { createRuntime } from "@crvouga/mockingbird-service-persona"
43
+
44
+ const persona = createRuntime({
45
+ webhooks: { url: "http://127.0.0.1:4000/v1/identify-verification/webhook", secret: "wbhsec_test" },
46
+ })
47
+ const created = await persona.fetch(
48
+ new Request("http://persona.test/inquiries", {
49
+ method: "POST",
50
+ headers: { authorization: "Bearer persona_sandbox_x", "content-type": "application/json" },
51
+ body: JSON.stringify({
52
+ data: { type: "inquiry", attributes: { "inquiry-template-id": "itmpl_identity", "reference-id": "patient-1" } },
53
+ }),
54
+ }),
55
+ )
56
+ const { data } = (await created.json()) as { data: { id: string } }
57
+
58
+ // Finish it the way a member and Persona's workflow would: started → completed → approved,
59
+ // one signed event per step.
60
+ await persona.fetch(new Request(`http://persona.test/__admin/inquiries/${data.id}/approve`, { method: "POST" }))
61
+ ```
62
+
63
+ ### Routes
64
+
65
+ | Route | Behaviour |
66
+ | --- | --- |
67
+ | `POST /inquiries` | JSON:API `{data: {type: "inquiry", attributes: {inquiry-template-id \| template-id, reference-id, redirect-uri, fields, note, platform}}}` → 201 `{data: <inquiry>, included: []}`, status `created`, id `inq_` + 24 chars. No template → 400; a template not in `settings.templates` (or not `itmpl_…`/`tmpl_…` when unset) → 422. Emits `inquiry.created`. |
68
+ | `GET /inquiries` | `filter[reference-id]`, `filter[inquiry-template-id]`, `filter[status]` (comma list), `page[size]` (1–100, default 10), `page[after]`. Newest first; `links.next` is the next page. |
69
+ | `GET /inquiries/{id}` | The inquiry, or 404 `{errors: [{title: "Record not found", detail, status: "404"}]}`. |
70
+ | `GET /verify?inquiry-id=&redirect-uri=` | The hosted flow page (`PERSONA_WEB_INQUIRY_URL`). Opening it starts the inquiry (`pending`, `inquiry.started`); its links finish it. |
71
+ | `GET /verify/complete?inquiry-id=&outcome=&redirect-uri=` | `outcome` = `approve`, `decline`, `needs_review`, `complete` or `fail`; then 302 to `redirect-uri?inquiry-id=&status=&reference-id=`. |
72
+
73
+ The inquiry resource carries Persona's attributes (`status`, `reference-id`, `note`,
74
+ `created-at`, `started-at`, `completed-at`, `failed-at`, `decisioned-at`, `expired-at`,
75
+ `name-first`/`name-last`/`birthdate` from prefill, `fields` as `{type, value}`) and
76
+ relationships (`inquiry-template.data {type: "inquiry-template", id}`, empty `reports`,
77
+ `verifications`, `sessions`, `documents`, `selfies`). Errors are JSON:API with a **string**
78
+ `status`, which our consumer's `PersonaErrorSchema` requires.
79
+
80
+ Auth: `Authorization: Bearer <key>` on every API route (missing → 401 JSON:API error).
81
+ `PUT /__admin/settings {"apiKeys": [...]}` restricts which keys are accepted.
82
+
83
+ ### Lifecycle and webhooks
84
+
85
+ `created → pending → completed → approved | declined | needs_review`, plus `failed` and
86
+ `expired` from an open inquiry. A decision on an open inquiry passes through `completed` first
87
+ (as a member finishing the flow and a workflow deciding would), so each step emits its own
88
+ event: `inquiry.created`, `inquiry.started`, `inquiry.completed`, `inquiry.approved`,
89
+ `inquiry.declined`, `inquiry.marked-for-review`, `inquiry.failed`, `inquiry.expired`.
90
+
91
+ Each event is Persona's envelope, `{data: {type: "event", id: "evt_…", attributes: {name,
92
+ payload: {data: <inquiry>, included: [], meta: {}}, created-at}}}`, posted with
93
+ `Persona-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`. The timestamp is
94
+ wall clock. Non-2xx answers are retried (immediately, 5 s, 5 min, 30 min, 2 h);
95
+ `GET /__admin/webhooks`, `/__admin/webhooks/events`, `POST /__admin/webhooks/flush`,
96
+ `/__admin/webhooks/:id/replay` and `PUT /__admin/webhook-endpoints` work as everywhere.
97
+
98
+ ### Admin (beyond the standard contract)
99
+
100
+ | Route | Effect |
101
+ | --- | --- |
102
+ | `POST /__admin/inquiries/:id/{approve\|decline\|needs_review}` | The catalog's decisions (via `completed` when the inquiry is still open). |
103
+ | `POST /__admin/inquiries/:id/{start\|complete\|fail\|expire}` | The other lifecycle steps. An illegal move is 409, an unknown id 404. |
104
+ | `GET /__admin/inquiries` | The namespace's inquiries. |
105
+ | `GET/PUT /__admin/settings` | `{apiKeys?: string[], templates?: string[]}` for the calling namespace. |
106
+ | `POST /__admin/signature-faults` | `{mode: "mismatch" \| "short", count?}`: the next events are signed wrong. `mismatch` keeps the length (our receiver answers 401); `short` truncates the hex (our receiver's `timingSafeEqual` throws: a 500). |
107
+
108
+ Fault presets (`POST /__admin/faults {"preset": "<name>", "count"?: n}`): `list_fails` (the
109
+ reusable lookup 500s; our client fails open and creates), `create_fails`, `not_found`,
110
+ `unauthorized`, `rate_limited` (429), `server_error`, `slow` (3 s), `webhook_duplicate`,
111
+ `webhook_reorder`, `webhook_drop`.
112
+
113
+ ### Namespaces
114
+
115
+ `x-mockingbird-namespace`, a `/ns/<name>` prefix on `PERSONA_API_URL` and
116
+ `PERSONA_WEB_INQUIRY_URL` (the hosted page's links keep it), or by API key:
117
+ `PUT /__admin/credentials {"credentials": {"<PERSONA_API_KEY>": "<namespace>"}}`.
118
+
119
+ ### What our consumer does with it (discrepancies)
120
+
121
+ - Our EMR acts only on `status === "completed"` (`updateIdentityVerificationForPatient`);
122
+ `approved`/`declined` events are parsed and ignored. So a declined inquiry has already marked
123
+ the member verified at `completed`. The mock sends both events so suites can see that.
124
+ - Our receiver compares signatures with `crypto.timingSafeEqual`, which throws on a length
125
+ mismatch: a wrong-length `v1=` is an uncaught 500, not a 401 (`signature-faults` `short`).
126
+ - There is no official Persona Node SDK in our consumer (plain `fetch`), so there is no SDK
127
+ drop-in test.
128
+
129
+ ### Deliberately not modelled
130
+
131
+ - Verifications, reports, sessions, documents and selfies: the relationships are always empty
132
+ and no government-ID or selfie capture happens. The hosted page is a set of links.
133
+ - Inquiry templates' steps and workflows (`next-step-name` is only `start` / `success`), resume
134
+ session tokens (`POST /inquiries/{id}/resume`), redaction, tags and accounts.
135
+ - Inquiry expiry on a timer: inquiries expire only through `…/expire`.
136
+ - The embedded (JS SDK / iframe) flow and mobile SDKs.
137
+
138
+ ## API
139
+
140
+ | Export | Kind | Description |
141
+ | --- | --- | --- |
142
+ | `PersonaAPI` | class | The in-process mock: `fetch(request)`, `reset()`, `transition(id, action)`, `inquiries()`. Options: `sqlite`, `now`, `namespace`, `publicNamespace`, `settings`, `onWebhook`. |
143
+ | `createRuntime` | function | The mock with the full service contract (health, admin, namespaces, credentials, presets, webhooks). Options: `webhooks: {url, secret, retryDelaysMs?, fetch?}`, `settings`, `clock`, `seed`, `adminKey`, `onLog`. |
144
+ | `PERSONA_PRESETS` | object | Every named fault preset. |
145
+ | `PERSONA_SIGNATURE_HEADER` | string | `"Persona-Signature"`. |
146
+ | `PERSONA_NAMESPACE`, `PERSONA_VERSION` | string | `"persona"`, `"2023-01-05"`. |
147
+ | `INQUIRY_ACTIONS` | array | The admin lifecycle actions. |
148
+ | `inquiryResource` | function | Serialize an inquiry record as Persona's JSON:API resource. |
149
+ | `personaErrors` | function | A JSON:API error response (string `status`). |
150
+ | `DEFAULT_SETTINGS` | object | Default per-namespace settings. |
151
+ | `document`, `operationIds`, `supportedOperationIds` | values | The vendored OpenAPI contract and its operation ids. |
152
+ | `createServer`, `serveTarget`, `DEFAULT_PORT` (`./server`) | Node | Serve over `node:http`; the `serve` CLI target; port 8815. |
153
+
154
+ Part of [mockingbird](https://github.com/crvouga/mockingbird).