@allus-fyi/company-data 0.0.3
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/LICENSE +21 -0
- package/README.md +706 -0
- package/dist/cjs/buffer.js +352 -0
- package/dist/cjs/client.js +396 -0
- package/dist/cjs/config.js +241 -0
- package/dist/cjs/crypto.js +288 -0
- package/dist/cjs/errors.js +96 -0
- package/dist/cjs/http.js +272 -0
- package/dist/cjs/index.js +74 -0
- package/dist/cjs/models.js +300 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/pump.js +279 -0
- package/dist/cjs/webhooks.js +335 -0
- package/dist/cjs/xml.js +257 -0
- package/dist/esm/buffer.js +348 -0
- package/dist/esm/client.js +392 -0
- package/dist/esm/config.js +237 -0
- package/dist/esm/crypto.js +281 -0
- package/dist/esm/errors.js +86 -0
- package/dist/esm/http.js +267 -0
- package/dist/esm/index.js +37 -0
- package/dist/esm/models.js +292 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/pump.js +275 -0
- package/dist/esm/webhooks.js +329 -0
- package/dist/esm/xml.js +252 -0
- package/dist/types/buffer.d.ts +109 -0
- package/dist/types/client.d.ts +150 -0
- package/dist/types/config.d.ts +86 -0
- package/dist/types/crypto.d.ts +125 -0
- package/dist/types/errors.d.ts +73 -0
- package/dist/types/http.d.ts +80 -0
- package/dist/types/index.d.ts +36 -0
- package/dist/types/models.d.ts +154 -0
- package/dist/types/pump.d.ts +118 -0
- package/dist/types/webhooks.d.ts +99 -0
- package/dist/types/xml.d.ts +42 -0
- package/docs/config.md +93 -0
- package/docs/errors.md +87 -0
- package/docs/model.md +141 -0
- package/docs/pump.md +130 -0
- package/docs/webhooks.md +140 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
# @allus-fyi/company-data (TypeScript / Node)
|
|
2
|
+
|
|
3
|
+
The TypeScript/Node SDK for the **allus company-data API**. Point it at a JSON
|
|
4
|
+
config file and it hands back typed, plaintext, **your-slug-keyed conclusions**:
|
|
5
|
+
for each connected person, a map of *your request-field slug → plaintext value*
|
|
6
|
+
(plus whether the value is live and when it last changed).
|
|
7
|
+
|
|
8
|
+
The SDK hides everything else — the OAuth token, the field catalog, the id
|
|
9
|
+
plumbing, the hybrid decryption, binary fetching, the changes-queue mechanics,
|
|
10
|
+
JSON-vs-XML. The platform is **zero-knowledge**: the API only ever holds
|
|
11
|
+
ciphertext, so all decryption happens inside the SDK with your service private
|
|
12
|
+
key. **The person's own field choices are never exposed** — you only ever see
|
|
13
|
+
the request slots you configured.
|
|
14
|
+
|
|
15
|
+
> This SDK is one of six language ports that share an identical API surface.
|
|
16
|
+
> This manual is the TypeScript view of it.
|
|
17
|
+
|
|
18
|
+
**Contents:** [TL;DR — fetch new updates](#tldr--fetch-new-updates) ·
|
|
19
|
+
[Quickstart](#quickstart) · [Every call](#every-call) ·
|
|
20
|
+
[The typed value model](#the-typed-value-model) ·
|
|
21
|
+
[The changes pump](#the-changes-pump) · [Webhooks](#webhooks) ·
|
|
22
|
+
[Rate limits](#rate-limits) · [Errors](#errors) ·
|
|
23
|
+
[How it's wired](#how-its-wired)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## TL;DR — fetch new updates
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @allus-fyi/company-data
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Point a config.json at your service keys:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"api_url": "https://api.allme.fyi",
|
|
38
|
+
"client_id": "svc_xxx",
|
|
39
|
+
"client_secret": "xxx",
|
|
40
|
+
"service_private_key": "/path/to/service.pem",
|
|
41
|
+
"key_passphrase": "xxx",
|
|
42
|
+
"cache_dir": "./allus-cache"
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Drain everything new, handled one update at a time:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { Client } from '@allus-fyi/company-data';
|
|
50
|
+
|
|
51
|
+
const client = Client.fromConfig('config.json');
|
|
52
|
+
|
|
53
|
+
await client.processChanges((change) => {
|
|
54
|
+
// event, person, slug, value, live, at
|
|
55
|
+
console.log(change.event, change.personId, change.slug, change.value, change.live, change.at);
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`processChanges` pulls every pending change, decrypts it, and hands them to your
|
|
60
|
+
callback ONE BY ONE, acking each only after your code returns. Crash mid-batch? The
|
|
61
|
+
next run replays exactly what wasn't acked — nothing is lost, and the API keeps no
|
|
62
|
+
backlog of its own. Run it on a schedule (cron / systemd timer); there is no
|
|
63
|
+
daemon/follow mode by design. Connections, binary values, and webhooks are
|
|
64
|
+
documented below.
|
|
65
|
+
|
|
66
|
+
Deeper reference pages live in [`docs/`](docs/):
|
|
67
|
+
[config](docs/config.md) · [model](docs/model.md) · [pump](docs/pump.md) ·
|
|
68
|
+
[webhooks](docs/webhooks.md) · [errors](docs/errors.md).
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Quickstart
|
|
73
|
+
|
|
74
|
+
Requires **Node ≥ 18** (it uses the built-in global `fetch` and `node:crypto`).
|
|
75
|
+
The package ships **dual ESM + CommonJS** with bundled `.d.ts` types.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm install @allus-fyi/company-data
|
|
79
|
+
# or, working from this repo: npm install && npm run build # from sdks/typescript/
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// ESM
|
|
84
|
+
import { Client } from '@allus-fyi/company-data';
|
|
85
|
+
```
|
|
86
|
+
```js
|
|
87
|
+
// CommonJS
|
|
88
|
+
const { Client } = require('@allus-fyi/company-data');
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 1. Write a config file
|
|
92
|
+
|
|
93
|
+
A single JSON file holds everything. Any field can be overridden by an `ALLUS_*`
|
|
94
|
+
env var, so secrets needn't live in the file. **No SDK method ever takes a key,
|
|
95
|
+
passphrase, or secret as an argument** — they all come from here.
|
|
96
|
+
|
|
97
|
+
`allus.json`:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"api_url": "https://api.allme.fyi",
|
|
102
|
+
"client_id": "svc_1a2b3c…",
|
|
103
|
+
"client_secret": "…",
|
|
104
|
+
"service_private_key": "./service-CRM.pem",
|
|
105
|
+
"key_passphrase": "…",
|
|
106
|
+
|
|
107
|
+
"account_private_key": "./account.pem",
|
|
108
|
+
"account_passphrase": "…",
|
|
109
|
+
|
|
110
|
+
"webhooks": {
|
|
111
|
+
"wh_abc123": "hmac_secret_for_that_webhook"
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
"cache_dir": "./allus-cache",
|
|
115
|
+
"format": "json"
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Field | Required | Meaning |
|
|
120
|
+
|-------|----------|---------|
|
|
121
|
+
| `api_url` | yes | API base, e.g. `https://api.allme.fyi`. |
|
|
122
|
+
| `client_id` / `client_secret` | yes | The registered `client_credentials` credentials for **one** service. |
|
|
123
|
+
| `service_private_key` | yes | Path to the OpenSSL-encrypted PKCS#8 PEM you downloaded from the portal. |
|
|
124
|
+
| `key_passphrase` | yes | Decrypts that PEM in memory at startup. |
|
|
125
|
+
| `account_private_key` / `account_passphrase` | only for `encrypt_payload` webhooks | The company **account** key, used to unwrap an encrypted webhook envelope. |
|
|
126
|
+
| `webhooks` / `webhook_secret` | webhook auth — HMAC (default) | Per-webhook HMAC secrets keyed by webhook id (matched via the `X-Allus-Webhook-Id` header). A single-webhook service can use a flat `"webhook_secret": "…"` instead of the map. |
|
|
127
|
+
| `webhook_bearer_token` | webhook auth — bearer | Verify `Authorization: Bearer <token>` deliveries. |
|
|
128
|
+
| `webhook_basic` | webhook auth — basic | `{"username","password"}` — verify HTTP Basic deliveries. |
|
|
129
|
+
| `webhook_header` | webhook auth — header | `{"name","value"}` — verify a custom-header delivery. |
|
|
130
|
+
| `webhook_auth_none` | webhook auth — none | `true` — explicit opt-out; `verifyWebhook` always passes (use only behind your own gateway). **Configure at most one** webhook auth method (two+ → `ConfigError`). |
|
|
131
|
+
| `cache_dir` | no (default `./allus-cache`) | Durable local buffer for the changes pump. Must be writable + durable. |
|
|
132
|
+
| `format` | no (default `json`) | Wire format `json` or `xml`. Invisible in the output. |
|
|
133
|
+
|
|
134
|
+
The config-file keys are **snake_case** (`api_url`, `client_secret`, …); the SDK
|
|
135
|
+
exposes them as camelCase (`config.apiUrl`, …). Env overrides use the `ALLUS_`
|
|
136
|
+
prefix, e.g. `ALLUS_CLIENT_SECRET`, `ALLUS_KEY_PASSPHRASE`,
|
|
137
|
+
`ALLUS_ACCOUNT_PASSPHRASE`, `ALLUS_WEBHOOK_SECRET`. A missing/invalid config (or an
|
|
138
|
+
unreadable PEM / wrong passphrase) throws `ConfigError` at construction — fail
|
|
139
|
+
fast.
|
|
140
|
+
|
|
141
|
+
### 2. First call — list a connection's values
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { Client } from '@allus-fyi/company-data';
|
|
145
|
+
|
|
146
|
+
const client = Client.fromConfig('allus.json');
|
|
147
|
+
|
|
148
|
+
// Iterate every connected person (lazy, auto-paged).
|
|
149
|
+
for await (const conn of client.connections()) {
|
|
150
|
+
console.log(conn.displayName, conn.personId);
|
|
151
|
+
for (const [slug, val] of Object.entries(conn.values)) {
|
|
152
|
+
console.log(` ${slug} = ${JSON.stringify(val.value)} (live=${val.live}, updated=${val.updatedAt})`);
|
|
153
|
+
}
|
|
154
|
+
break; // just the first one for the demo
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Or fetch one connection by id:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const conn = await client.connection('019xxxxxxxxxxxxxxxxxxxxxxxxx');
|
|
162
|
+
const email = conn.values['work_email'].value; // "alice@acme.com" (a string)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`Client.fromEnv()` builds the same client entirely from `ALLUS_*` env vars (no
|
|
166
|
+
file).
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Every call
|
|
171
|
+
|
|
172
|
+
`Client` is the only object you construct. Build it from config, then:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
Client.fromConfig(path, opts?): Client // from a JSON file (env overrides secrets)
|
|
176
|
+
Client.fromEnv(opts?): Client // entirely from ALLUS_* env vars
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`opts` are advanced/optional: `http` (an injected `HttpClient`), `httpOptions`
|
|
180
|
+
(passed to the default `HttpClient`: `transport`, `clock`, `maxRetries429`),
|
|
181
|
+
`logger` (a console-compatible sink for the pump), `sleep` (a
|
|
182
|
+
`(seconds) => Promise<void>`, for tests).
|
|
183
|
+
|
|
184
|
+
### `requestFields()`
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
requestFields(): Promise<RequestField[]>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Your request-field **definitions** — fetched once from
|
|
191
|
+
`GET /api/company-data/request-fields` and cached for the life of the client (it
|
|
192
|
+
types every value). Returns *your* request config, never the person's fields.
|
|
193
|
+
|
|
194
|
+
* **Params:** none.
|
|
195
|
+
* **Returns:** `Promise<RequestField[]>` — each `RequestField { slug, label, type, oneTime, mandatory, raw }`. `mandatory` is true when the field is mandatory-to-provide **or** mandatory-to-stay-connected.
|
|
196
|
+
* **Throws:** `AuthError`, `ApiError`, `RateLimitError`.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
for (const f of await client.requestFields()) {
|
|
200
|
+
const flag = f.mandatory ? 'mandatory' : 'optional';
|
|
201
|
+
console.log(`${f.slug} ${f.type} ${flag}${f.oneTime ? ' (one-time)' : ''}`);
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### `connections(limit?, offset?)`
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
connections(limit?: number, offset?: number): AsyncGenerator<Connection>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
A **lazy async generator** that auto-pages
|
|
212
|
+
`GET /api/company-data/connections?limit&offset` and yields one typed `Connection`
|
|
213
|
+
at a time (bounded memory for a large book). Each `conn.values[slug]` is already
|
|
214
|
+
decrypted (or a lazy binary handle). It honors the response's `total` so it never
|
|
215
|
+
over-fetches a page past the end (and also stops on a short page).
|
|
216
|
+
|
|
217
|
+
* **Params:** `limit` — page size (default 100); `offset` — starting offset.
|
|
218
|
+
* **Returns:** `AsyncGenerator<Connection>` — consume with `for await`.
|
|
219
|
+
* **Throws:** `AuthError`, `ApiError`, `DecryptError` (per value, on access), `RateLimitError` (after the iterator's bounded internal backoff — see [Rate limits](#rate-limits)).
|
|
220
|
+
|
|
221
|
+
> **Heavily rate-limited.** Use for the initial full sync + occasional
|
|
222
|
+
> reconciliation only — never as a poll substitute for the changes feed. The
|
|
223
|
+
> generator paces itself within the limit (backs off on `Retry-After`).
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// Initial full sync, streaming so a 100k-connection book never lands in memory.
|
|
227
|
+
for await (const conn of client.connections(200)) {
|
|
228
|
+
await upsertLocalRecord(conn);
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### `connection(id)`
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
connection(id: string): Promise<Connection>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Fetch one connection by its connection id
|
|
239
|
+
(`GET /api/company-data/connections/{id}`).
|
|
240
|
+
|
|
241
|
+
* **Params:** `id` — the connection id (`Connection.id`).
|
|
242
|
+
* **Returns:** `Promise<Connection>`. Note: this endpoint returns `{connection_id, user_id, values}` and **no** `displayName`/`connectedAt`, so those identity fields are `null` here (the list endpoint carries them).
|
|
243
|
+
* **Throws:** `AuthError`, `ApiError` (404 if unknown), `DecryptError`, `RateLimitError`.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const conn = await client.connection(connId);
|
|
247
|
+
const phone = conn.values['mobile'];
|
|
248
|
+
if (phone) console.log(phone.value, phone.live ? 'live' : 'snapshot');
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### `logs(limit?, offset?)`
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
logs(limit?: number, offset?: number): Promise<LogEntry[]>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The service's activity log (`GET /api/company-data/logs?limit&offset`) — **ops
|
|
258
|
+
events only** (email / purge / webhook), never person field data.
|
|
259
|
+
|
|
260
|
+
* **Params:** `limit` (default 50), `offset` (default 0).
|
|
261
|
+
* **Returns:** `Promise<LogEntry[]>` — each `LogEntry { type, message, metadata, at, raw }`.
|
|
262
|
+
* **Throws:** `AuthError`, `ApiError`, `RateLimitError`.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
for (const entry of await client.logs(20)) {
|
|
266
|
+
console.log(entry.at, entry.type, entry.message);
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### `processChanges(handler, options?)`
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
processChanges(handler: (change: Change) => void | Promise<void>, options?): Promise<void>
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The crash-safe changes pump: drains the feed through `handler` **one `Change` at a
|
|
277
|
+
time**, durably buffering each batch before delivery, with per-item ack and
|
|
278
|
+
retry → dead-letter → continue. Runs **until the feed is empty, then resolves** —
|
|
279
|
+
there is **no follow/daemon mode** (you schedule re-runs yourself). Delivery is
|
|
280
|
+
**at-least-once**, so your handler **must be idempotent** (dedup on `Change.id`).
|
|
281
|
+
See [The changes pump](#the-changes-pump) for the full model.
|
|
282
|
+
|
|
283
|
+
* **Params:** `handler` — your callback; called with one `Change`. Resolving/returning is an ack; throwing triggers retry. May be sync or async.
|
|
284
|
+
* **Options:** `batchSize` (clamped to ≤ 500, default 100), `maxRetries` (default 3), `onError` (`"deadletter"` — default — or `"halt"`), `backoff` (`(attempt) => seconds`).
|
|
285
|
+
* **Returns:** `Promise<void>` (resolves when the feed is empty + the buffer is drained).
|
|
286
|
+
* **Throws:** `AuthError`, `ApiError`, `RateLimitError` (during a drain); `TypeError` (bad `onError`); whatever the handler throws if `onError="halt"` and retries are exhausted.
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
async function handle(change) {
|
|
290
|
+
if (await alreadyProcessed(change.id)) return; // idempotency — dedup on the stable id
|
|
291
|
+
if (change.event === 'field_updated') {
|
|
292
|
+
await store(change.personId, change.slug, change.value);
|
|
293
|
+
} else if (change.event === 'connection_deleted' || change.event === 'field_deleted') {
|
|
294
|
+
await remove(change.personId, change.slug);
|
|
295
|
+
}
|
|
296
|
+
await markProcessed(change.id);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await client.processChanges(handle); // resolves when the feed is empty
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
> `logger` is **not** a `processChanges` option — pass it once to the `Client`
|
|
303
|
+
> constructor (`Client.fromConfig('allus.json', { logger: myLogger })`).
|
|
304
|
+
|
|
305
|
+
### Advanced changes primitives
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
drainBatch(max?: number) : Promise<Change[]> // raw, UNBUFFERED — you own durability
|
|
309
|
+
deadLetters() : DeadLetterRecord[] // the local dead-letter store
|
|
310
|
+
retryDeadLetters(handler, options?) : Promise<number> // re-drive dead-lettered events; resolves to count re-driven
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
* `drainBatch(max)` — fetches one batch (clamped ≤ 500) and returns the decrypted `Change`s directly. It does **not** persist anything, so a crash loses what the API already deleted. Prefer `processChanges` for safe consumption.
|
|
314
|
+
* `deadLetters()` — each record is the stored (ciphertext) event plus a flattened `error` and `attempts`.
|
|
315
|
+
* `retryDeadLetters(handler, options?)` — same `maxRetries` / `onError` / `backoff` options as `processChanges`; on success a record is removed, on repeated failure it stays dead-lettered (or re-throws under `"halt"`). Dead letters are never re-fetched from the API — the local store is their only home.
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
for (const dl of client.deadLetters()) {
|
|
319
|
+
console.log('stuck:', dl.id, dl.error, 'after', dl.attempts, 'attempts');
|
|
320
|
+
}
|
|
321
|
+
const n = await client.retryDeadLetters(handle); // after you've fixed the bug
|
|
322
|
+
console.log(`re-drove ${n} dead letters`);
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Webhook helpers (on the client)
|
|
326
|
+
|
|
327
|
+
The webhook receiver helpers are also exposed as `Client` methods (they delegate
|
|
328
|
+
to the module functions, fully config-driven — no key/secret arguments):
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
client.verifyWebhook(rawBody: Buffer | Uint8Array | string, headers): boolean
|
|
332
|
+
client.parseWebhook(rawBody, headers): Change
|
|
333
|
+
client.handleWebhook(rawBody, headers): Change // verify + parse
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
* `verifyWebhook` — recomputes `HMAC-SHA256(rawBody, secret)` and constant-time-compares it to `X-Allus-Signature`. Returns `true`/`false`; **never throws** for a bad signature.
|
|
337
|
+
* `parseWebhook` — body → a typed `Change`. Does **not** verify. Handles JSON, XML, and the `encrypt_payload` account-key envelope. Throws `WebhookError` on a malformed/unparseable body.
|
|
338
|
+
* `handleWebhook` — verify **then** parse; throws `WebhookError` on a bad/unknown signature, otherwise returns the `Change`. The typical one-liner inside a route.
|
|
339
|
+
|
|
340
|
+
> The client webhook methods are **synchronous** and require the request-fields
|
|
341
|
+
> catalog (for value typing). Call `await client.requestFields()` once at startup
|
|
342
|
+
> so the catalog is cached before you handle webhooks (the catalog fetch is the
|
|
343
|
+
> only network call these methods would need, and it must be done up front since
|
|
344
|
+
> they are sync).
|
|
345
|
+
|
|
346
|
+
The same three are importable as standalone functions
|
|
347
|
+
(`import { verifyWebhook, parseWebhook, handleWebhook } from '@allus-fyi/company-data'`),
|
|
348
|
+
which take the `config` and the decrypt/type closures explicitly — but inside an
|
|
349
|
+
app you'll almost always use the client methods. See [Webhooks](#webhooks).
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## The typed value model
|
|
354
|
+
|
|
355
|
+
You work with these objects and nothing else (`import { … } from '@allus-fyi/company-data'`):
|
|
356
|
+
|
|
357
|
+
```text
|
|
358
|
+
RequestField { slug, label, type, oneTime, mandatory } // YOUR request config
|
|
359
|
+
Connection { id, personId, displayName, connectedAt, values: {<slug>: Value} }
|
|
360
|
+
Value { value, live, updatedAt }
|
|
361
|
+
Change { id, event, personId, slug?, value?, live?, at }
|
|
362
|
+
LogEntry { type, message, metadata, at }
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Keyed by *your* slug
|
|
366
|
+
|
|
367
|
+
`conn.values['work_email'].value` → `"alice@acme.com"`. The key is the stable,
|
|
368
|
+
explicit slug you set per request field in the portal — rename the label freely,
|
|
369
|
+
the slug is the contract. **The person's source field is never exposed**: no
|
|
370
|
+
source slug, no `field_id`, not even via `.raw`.
|
|
371
|
+
|
|
372
|
+
### `Value { value, live, updatedAt }`
|
|
373
|
+
|
|
374
|
+
| Property | Meaning |
|
|
375
|
+
|----------|---------|
|
|
376
|
+
| `value` | The typed plaintext (see the table below). |
|
|
377
|
+
| `live` | `true` if the person chose "keep connected" (auto-updates); `false` for a one-time snapshot. |
|
|
378
|
+
| `updatedAt` | `Date` of when this answer last changed (per-answer, rides on the `Value`), or `null`. |
|
|
379
|
+
|
|
380
|
+
### Value types (from the field's `type`)
|
|
381
|
+
|
|
382
|
+
| Field type | JS `value` |
|
|
383
|
+
|------------|------------|
|
|
384
|
+
| `email`, `phone`, `url`, `text` | `string` |
|
|
385
|
+
| `address`, `bank`, `creditcard` | a parsed `object` — the decrypted plaintext is a JSON object, parsed for you |
|
|
386
|
+
| `date`, `date_of_birth` | a `Date` (UTC midnight; falls back to the raw string if it can't be parsed) |
|
|
387
|
+
| `photo`, `document`, `legal_document` | a lazy `BinaryHandle` — see below |
|
|
388
|
+
| unanswered / no value | `null` |
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
const addr = conn.values['home_address'].value as Record<string, unknown>; // {street, city, …}
|
|
392
|
+
const dob = conn.values['birthday'].value as Date; // Date(1990-05-17)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Binary fields — the lazy `BinaryHandle`
|
|
396
|
+
|
|
397
|
+
A photo/document value is a `BinaryHandle`. Nothing is fetched or decrypted until
|
|
398
|
+
you call `.bytes()` or `.save()`:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
const handle = conn.values['passport_scan'].value as BinaryHandle; // no network yet
|
|
402
|
+
|
|
403
|
+
const data = await handle.bytes(); // GET the slot file → decrypt → Buffer
|
|
404
|
+
const n = await handle.save('/tmp/passport.jpg'); // same, written to disk; returns bytes written
|
|
405
|
+
console.log(handle.valueUrl); // the opaque slot-keyed URL it fetches from
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
`.bytes()` GETs the slot-keyed file endpoint, unwraps the API's
|
|
409
|
+
`{"encrypted": true, "value": <wrapper>}` envelope, decrypts with your service key,
|
|
410
|
+
parses the inner JSON envelope (`{"full": "data:…"}` for photos, `{"file": "data:…"}`
|
|
411
|
+
for documents) and base64-decodes the data URI into a `Buffer`. The result is cached
|
|
412
|
+
on the handle, so repeated calls don't re-fetch. `.save()` is crash-safe (temp file →
|
|
413
|
+
fsync → atomic rename).
|
|
414
|
+
|
|
415
|
+
### `Change { id, event, personId, slug?, value?, live?, at }`
|
|
416
|
+
|
|
417
|
+
A change-feed / webhook event.
|
|
418
|
+
|
|
419
|
+
| Property | Meaning |
|
|
420
|
+
|----------|---------|
|
|
421
|
+
| `id` | **The stable server change-row id — your dedup key** (captured before the server delete). |
|
|
422
|
+
| `event` | `connection_created`, `connection_deleted`, `field_updated`, `field_deleted`, `consent_accepted`, `consent_declined`. |
|
|
423
|
+
| `personId` | The person the change is about (may be `null`). |
|
|
424
|
+
| `slug`, `value`, `live` | Present only on `field_updated`; `value` is typed exactly like `Value.value` (incl. a lazy `BinaryHandle` for binaries). Connection/consent events carry no slot/value. |
|
|
425
|
+
| `at` | `Date` of the change. (There is no separate `updatedAt` on a change.) |
|
|
426
|
+
|
|
427
|
+
### `.raw`
|
|
428
|
+
|
|
429
|
+
Every model carries `.raw` — the underlying *hardened* API object — for debugging
|
|
430
|
+
or an edge case the SDK didn't model. It still never contains the person's source
|
|
431
|
+
field.
|
|
432
|
+
|
|
433
|
+
See [`docs/model.md`](docs/model.md) for the full reference.
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## The changes pump
|
|
438
|
+
|
|
439
|
+
The changes feed is a server-side **drain-on-fetch queue**:
|
|
440
|
+
`GET /api/company-data/changes?limit=N` returns up to N events (default 100, max
|
|
441
|
+
500) **and deletes exactly those rows in the same transaction** — no
|
|
442
|
+
offset/cursor, and the API keeps no copy afterward. So consumption can't be a
|
|
443
|
+
plain list: a consumer crash mid-batch would lose events the API already deleted,
|
|
444
|
+
and a huge backlog must not materialize in memory. `processChanges` solves both.
|
|
445
|
+
|
|
446
|
+
**Per run, repeating until the feed is empty then resolving:**
|
|
447
|
+
|
|
448
|
+
1. **Replay first.** Deliver any un-acked events already in the local buffer (from a previous crashed run), oldest-first.
|
|
449
|
+
2. **Drain.** When the buffer is empty, fetch one batch and **persist it to the durable file buffer (fsync) BEFORE handing anything out.** This is the backup the API no longer has.
|
|
450
|
+
3. **Deliver one-by-one.** For each buffered event, oldest-first: decrypt its value *at delivery* (never on disk), build the typed `Change`, call `handler`.
|
|
451
|
+
4. **Ack / retry / dead-letter.** On success, remove the event from the buffer (ack). On a handler error, retry with backoff up to `maxRetries`; then either move it to the dead-letter store and continue (`onError="deadletter"`, default — one poison event never wedges the stream) or stop and re-throw (`onError="halt"`). A `DecryptError` on a buffered event (corrupt/truncated ciphertext, rotated key) is **dead-lettered immediately** — re-decrypting can't fix it, so it does *not* burn retries (under `onError="halt"` it re-throws). Either way it never propagates out and wedges replay.
|
|
452
|
+
5. Repeat until a drain returns empty **and** the buffer is drained → resolve.
|
|
453
|
+
|
|
454
|
+
### The durable buffer
|
|
455
|
+
|
|
456
|
+
* Plain files under `cacheDir` (zero extra dependencies): `pending/` for un-acked events, `deadletter/` for ones that exhausted retries.
|
|
457
|
+
* Stored events keep their **ciphertext** value — **no plaintext PII is ever written to disk**. Decryption happens only at delivery.
|
|
458
|
+
* Writes are crash-safe (temp file → `fsyncSync` → atomic rename → dir fsync). Files are named with a monotonic, zero-padded sequence so they replay oldest-first.
|
|
459
|
+
|
|
460
|
+
### Crash safety, at-least-once, and idempotency
|
|
461
|
+
|
|
462
|
+
A batch is durably buffered *before* any delivery, and acked per-item only *after*
|
|
463
|
+
the handler succeeds. The ack can't be atomic with your side-effects — a crash
|
|
464
|
+
between your handler's success and its ack re-delivers that event on the next run.
|
|
465
|
+
That makes delivery **at-least-once**, so:
|
|
466
|
+
|
|
467
|
+
> **Your handler must be idempotent. Dedup on `Change.id`.**
|
|
468
|
+
|
|
469
|
+
`Change.id` is the stable server change-row id, captured before the server delete,
|
|
470
|
+
so it survives crash + replay unchanged.
|
|
471
|
+
|
|
472
|
+
### No follow mode
|
|
473
|
+
|
|
474
|
+
`processChanges` resolves when the feed empties. **You** schedule re-runs — a cron
|
|
475
|
+
job, a `while (true) { await client.processChanges(handle); await sleep(5000); }`
|
|
476
|
+
loop, a worker queue, whatever fits. The feed is cheap to poll (see
|
|
477
|
+
[Rate limits](#rate-limits)).
|
|
478
|
+
|
|
479
|
+
### Worked example
|
|
480
|
+
|
|
481
|
+
```ts
|
|
482
|
+
import { Client } from '@allus-fyi/company-data';
|
|
483
|
+
|
|
484
|
+
const client = Client.fromConfig('allus.json');
|
|
485
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
486
|
+
|
|
487
|
+
async function handle(change) {
|
|
488
|
+
if (await seen(change.id)) return; // idempotent: skip what we've applied
|
|
489
|
+
switch (change.event) {
|
|
490
|
+
case 'field_updated':
|
|
491
|
+
await storeValue(change.personId, change.slug, change.value, change.live);
|
|
492
|
+
break;
|
|
493
|
+
case 'field_deleted':
|
|
494
|
+
await clearValue(change.personId, change.slug);
|
|
495
|
+
break;
|
|
496
|
+
case 'connection_deleted':
|
|
497
|
+
await dropPerson(change.personId);
|
|
498
|
+
break;
|
|
499
|
+
case 'connection_created':
|
|
500
|
+
case 'consent_accepted':
|
|
501
|
+
case 'consent_declined':
|
|
502
|
+
await noteEvent(change.personId, change.event, change.at);
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
await recordSeen(change.id);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Schedule your own re-runs; processChanges itself resolves when empty.
|
|
509
|
+
for (;;) {
|
|
510
|
+
await client.processChanges(handle, { batchSize: 200, maxRetries: 5 });
|
|
511
|
+
await sleep(5000);
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
If a handler keeps failing, the event lands in the dead-letter store instead of
|
|
516
|
+
blocking the stream; inspect with `client.deadLetters()` and re-drive with
|
|
517
|
+
`client.retryDeadLetters(handle)` after fixing the cause. See
|
|
518
|
+
[`docs/pump.md`](docs/pump.md).
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
522
|
+
## Webhooks
|
|
523
|
+
|
|
524
|
+
Webhooks are the lower-latency push alternative to polling the changes feed. The
|
|
525
|
+
platform POSTs each change event to your configured webhook URL with:
|
|
526
|
+
|
|
527
|
+
* `X-Allus-Webhook-Id` — which webhook this is (selects the HMAC secret from config).
|
|
528
|
+
* `X-Allus-Signature` — `HMAC-SHA256(rawBody, secret)` as lowercase hex.
|
|
529
|
+
* the body — the same slug-keyed `Change` shape as the pull feed (JSON or XML).
|
|
530
|
+
|
|
531
|
+
All secrets/keys come from config; the helpers take **no key or secret
|
|
532
|
+
arguments**. Use the raw request body **bytes** (`Buffer`) — do not re-serialize a
|
|
533
|
+
parsed body, the HMAC is over the exact bytes the platform sent.
|
|
534
|
+
|
|
535
|
+
### In a web route (Express)
|
|
536
|
+
|
|
537
|
+
```ts
|
|
538
|
+
import express from 'express';
|
|
539
|
+
import { Client, WebhookError } from '@allus-fyi/company-data';
|
|
540
|
+
|
|
541
|
+
const app = express();
|
|
542
|
+
const client = Client.fromConfig('allus.json');
|
|
543
|
+
await client.requestFields(); // warm the catalog once (the webhook methods are sync)
|
|
544
|
+
|
|
545
|
+
// IMPORTANT: capture the RAW body bytes — do not let a JSON body-parser replace them.
|
|
546
|
+
app.post('/allus/webhook', express.raw({ type: '*/*' }), (req, res) => {
|
|
547
|
+
let change;
|
|
548
|
+
try {
|
|
549
|
+
change = client.handleWebhook(req.body /* Buffer */, req.headers);
|
|
550
|
+
} catch (e) {
|
|
551
|
+
if (e instanceof WebhookError) return res.sendStatus(401); // bad/unknown signature
|
|
552
|
+
throw e;
|
|
553
|
+
}
|
|
554
|
+
// Same idempotency rule as the pump: dedup on change.id.
|
|
555
|
+
if (!seen(change.id)) {
|
|
556
|
+
applyChange(change);
|
|
557
|
+
recordSeen(change.id);
|
|
558
|
+
}
|
|
559
|
+
res.sendStatus(204);
|
|
560
|
+
});
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
`verifyWebhook` / `parseWebhook` let you split the steps if you prefer:
|
|
564
|
+
|
|
565
|
+
```ts
|
|
566
|
+
if (!client.verifyWebhook(rawBody, headers)) return res.sendStatus(401);
|
|
567
|
+
const change = client.parseWebhook(rawBody, headers);
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
### Config-driven secrets
|
|
571
|
+
|
|
572
|
+
Per-webhook HMAC secrets live in the config `webhooks` map, keyed by webhook id;
|
|
573
|
+
the SDK reads `X-Allus-Webhook-Id` off the request and looks up the matching
|
|
574
|
+
secret. A single-webhook service can use the flat `"webhook_secret": "…"`
|
|
575
|
+
shortcut (or `ALLUS_WEBHOOK_SECRET`). An unknown/unconfigured id ⇒ verification
|
|
576
|
+
returns `false` (and `handleWebhook` throws `WebhookError`).
|
|
577
|
+
|
|
578
|
+
### The `encrypt_payload` account-key envelope
|
|
579
|
+
|
|
580
|
+
If a webhook has `encrypt_payload` enabled, the body is **replaced** by a
|
|
581
|
+
`{"_enc":1,…}` envelope encrypted to your company **account** key (and the HMAC is
|
|
582
|
+
over that envelope — the final bytes sent). `parseWebhook`/`handleWebhook` unwrap
|
|
583
|
+
it transparently using the configured `account_private_key` + `account_passphrase`,
|
|
584
|
+
then decrypt the inner field value with the service key — so an encrypted-payload
|
|
585
|
+
`Change` is identical to a plain one. If you receive such a webhook without an
|
|
586
|
+
`account_private_key` configured, you get a `WebhookError`.
|
|
587
|
+
|
|
588
|
+
> The account-key envelope uses OAEP-**SHA1** (OpenSSL's default), distinct from
|
|
589
|
+
> the OAEP-SHA256 used for person field values — the SDK handles this difference
|
|
590
|
+
> internally; you only supply the account key in config.
|
|
591
|
+
|
|
592
|
+
See [`docs/webhooks.md`](docs/webhooks.md).
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
## Rate limits
|
|
597
|
+
|
|
598
|
+
| Endpoint | Limit | Use it for |
|
|
599
|
+
|----------|-------|-----------|
|
|
600
|
+
| `changes` (the pump) | **generous** | Poll **as often as you like** — it's a cheap drain-on-fetch queue. |
|
|
601
|
+
| `request-fields`, `logs` | moderate | Occasional reads. |
|
|
602
|
+
| `connections`, `connection(id)`, binary `/file` | **heavily limited** | Initial full sync + occasional reconciliation **only** — never as a poll substitute. |
|
|
603
|
+
|
|
604
|
+
A 429 carries `Retry-After`. The SDK backs off and retries automatically:
|
|
605
|
+
|
|
606
|
+
* The transport (`HttpClient`) retries a 429 a bounded number of times honoring `Retry-After`, then surfaces `RateLimitError`.
|
|
607
|
+
* The `connections(...)` generator additionally backs off per `Retry-After` on a surfaced `RateLimitError` and retries the page a bounded number of times before re-throwing — so it paces itself within the limit instead of hammering.
|
|
608
|
+
|
|
609
|
+
If you catch a `RateLimitError`, its `.retryAfter` is the seconds to wait (or
|
|
610
|
+
`null` when the header was absent).
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
## Errors
|
|
615
|
+
|
|
616
|
+
All from `@allus-fyi/company-data`. Same taxonomy + names across all six SDKs.
|
|
617
|
+
Every error extends `AllusError`, so `catch (e) { if (e instanceof AllusError) … }`
|
|
618
|
+
captures the whole taxonomy.
|
|
619
|
+
|
|
620
|
+
| Error | When |
|
|
621
|
+
|-------|------|
|
|
622
|
+
| `ConfigError` | Missing/invalid config, unreadable key file, or wrong passphrase — at construction (fail fast). |
|
|
623
|
+
| `AuthError` | Token fetch/refresh failed (bad `client_id`/`secret`, revoked client); or a 401 survives the one automatic refresh-and-retry. |
|
|
624
|
+
| `ApiError` | Any non-2xx from the API; carries `status`, `errorKey` (the platform `error_key`, when present), and `apiMessage`. |
|
|
625
|
+
| `DecryptError` | A ciphertext wrapper is malformed, the key is wrong, or the GCM tag mismatches. Surfaces when a value is accessed/decrypted. |
|
|
626
|
+
| `WebhookError` | Signature verification failed, or an envelope couldn't be unwrapped/parsed. |
|
|
627
|
+
| `RateLimitError` | A 429 from a rate-limited endpoint. Subclass of `ApiError` (status fixed at 429); carries `retryAfter` (seconds, or `null`). |
|
|
628
|
+
|
|
629
|
+
```ts
|
|
630
|
+
import {
|
|
631
|
+
Client, AllusError, ConfigError, AuthError, ApiError,
|
|
632
|
+
DecryptError, WebhookError, RateLimitError,
|
|
633
|
+
} from '@allus-fyi/company-data';
|
|
634
|
+
|
|
635
|
+
try {
|
|
636
|
+
const client = Client.fromConfig('allus.json');
|
|
637
|
+
for await (const conn of client.connections()) { /* … */ }
|
|
638
|
+
} catch (e) {
|
|
639
|
+
if (e instanceof ConfigError) { /* fix the config / key file */ }
|
|
640
|
+
else if (e instanceof RateLimitError) { await wait((e.retryAfter ?? 60) * 1000); }
|
|
641
|
+
else if (e instanceof ApiError) { log(e.status, e.errorKey, e.apiMessage); }
|
|
642
|
+
else throw e;
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
See [`docs/errors.md`](docs/errors.md).
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## How it's wired
|
|
651
|
+
|
|
652
|
+
Everything below is what the SDK hides so your code only ever sees conclusions.
|
|
653
|
+
|
|
654
|
+
**Auth / token.** An `HttpClient` owns a `client_credentials`-only token. On the
|
|
655
|
+
first call (or when the cached token nears expiry) it POSTs
|
|
656
|
+
`client_id`/`client_secret` to `{api_url}/oauth2/token` and caches the bearer
|
|
657
|
+
token + its expiry; refresh is automatic. A mid-flight 401 triggers exactly one
|
|
658
|
+
refresh-and-retry, then `AuthError`. The token is scoped server-side to **one**
|
|
659
|
+
service, so every call is implicitly that service's data. The transport is over
|
|
660
|
+
Node's global `fetch` by default, but injectable (`HttpTransport`) for tests.
|
|
661
|
+
|
|
662
|
+
**Slug resolution.** `requestFields()` is fetched once and cached; its slug→type
|
|
663
|
+
map types every value (so `address` parses to an object, `photo` becomes a lazy
|
|
664
|
+
binary handle, etc.). The connection/changes endpoints return values keyed by
|
|
665
|
+
**your** request slug — the person's source field is dropped server-side and never
|
|
666
|
+
reaches the SDK.
|
|
667
|
+
|
|
668
|
+
**Decryption (zero-knowledge).** The service private key is loaded **once** at
|
|
669
|
+
construction from the configured encrypted PEM + passphrase
|
|
670
|
+
(`crypto.createPrivateKey({ key, passphrase })` — PBES2 handled by OpenSSL). A
|
|
671
|
+
`decryptValue` closure over it is handed to every model factory and the pump — the
|
|
672
|
+
key never appears in a method signature. Each value is a hybrid wrapper
|
|
673
|
+
(`{"_enc":1,"k":rsa_oaep_sha256(aesKey),"iv":…,"d":aes256gcm(…)}`); the SDK
|
|
674
|
+
RSA-OAEP-SHA256 unwraps the AES key (`privateDecrypt({ …, oaepHash: 'sha256' })` —
|
|
675
|
+
Node defaults to SHA-1, so the SHA-256 pin is essential), then AES-256-GCM decrypts
|
|
676
|
+
the payload (the 16-byte tag is the last 16 bytes of `d`). **The platform only ever
|
|
677
|
+
holds ciphertext — it never sees your plaintext.**
|
|
678
|
+
|
|
679
|
+
**Binary fetch.** A binary value is a lazy `BinaryHandle` over a slot-keyed
|
|
680
|
+
`value_url`. On `.bytes()`/`.save()` it GETs that file endpoint, unwraps the
|
|
681
|
+
`{"encrypted":true,"value":<wrapper>}` envelope, runs the same service-key decrypt
|
|
682
|
+
to a JSON file-envelope, and base64-decodes its data URI to the file bytes.
|
|
683
|
+
(Slot-keyed, never source-field-keyed.)
|
|
684
|
+
|
|
685
|
+
**XML, safely.** When `format: "xml"`, responses (and webhook bodies) are parsed by
|
|
686
|
+
a small, **XXE-safe** hand-written parser: no DOCTYPE/DTD processing, no custom or
|
|
687
|
+
external entities — those vectors are simply absent, and a DOCTYPE / unknown entity
|
|
688
|
+
is rejected. HMAC verification is always over the raw bytes, never the parsed tree.
|
|
689
|
+
|
|
690
|
+
**The drain-on-fetch feed.** `processChanges` delegates to a `Pump` wired to a
|
|
691
|
+
`fetchChanges` closure (`GET /changes?limit=`, returning raw ciphertext events) and
|
|
692
|
+
a `decrypt` closure (builds a typed `Change`). Because the fetch deletes the rows it
|
|
693
|
+
returns, the pump persists each batch to the durable file buffer (ciphertext at
|
|
694
|
+
rest) before delivery, acks per-item after your handler succeeds, and replays the
|
|
695
|
+
buffer on restart — see [The changes pump](#the-changes-pump).
|
|
696
|
+
|
|
697
|
+
---
|
|
698
|
+
|
|
699
|
+
## Status
|
|
700
|
+
|
|
701
|
+
**Crypto parity gate:** the decryption core is verified against the shared
|
|
702
|
+
cross-language decryption vector — PEM-load (PBES2 / PBKDF2-SHA256 / AES-256-CBC,
|
|
703
|
+
100k iters), text decrypt, and the binary decrypt → envelope → inner-bytes hash —
|
|
704
|
+
plus an independent OpenSSL cross-check (anti-circularity). The full test suite
|
|
705
|
+
(config, crypto, http/auth, models, the crash-safe pump, webhooks, and the XXE-safe
|
|
706
|
+
XML parser) is green under `npm test`.
|