@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 +5 -0
- package/README.md +154 -0
- package/dist/chunk-NSOYUOFG.js +2881 -0
- package/dist/chunk-NSOYUOFG.js.map +7 -0
- package/dist/chunk-RY6PVZDL.js +360 -0
- package/dist/chunk-RY6PVZDL.js.map +7 -0
- package/dist/cli.js +19 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.d.ts +987 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +7 -0
- package/dist/server.d.ts +1320 -0
- package/dist/server.js +12 -0
- package/dist/server.js.map +7 -0
- package/package.json +87 -0
package/CHANGELOG.md
ADDED
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).
|