@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +706 -0
  3. package/dist/cjs/buffer.js +352 -0
  4. package/dist/cjs/client.js +396 -0
  5. package/dist/cjs/config.js +241 -0
  6. package/dist/cjs/crypto.js +288 -0
  7. package/dist/cjs/errors.js +96 -0
  8. package/dist/cjs/http.js +272 -0
  9. package/dist/cjs/index.js +74 -0
  10. package/dist/cjs/models.js +300 -0
  11. package/dist/cjs/package.json +1 -0
  12. package/dist/cjs/pump.js +279 -0
  13. package/dist/cjs/webhooks.js +335 -0
  14. package/dist/cjs/xml.js +257 -0
  15. package/dist/esm/buffer.js +348 -0
  16. package/dist/esm/client.js +392 -0
  17. package/dist/esm/config.js +237 -0
  18. package/dist/esm/crypto.js +281 -0
  19. package/dist/esm/errors.js +86 -0
  20. package/dist/esm/http.js +267 -0
  21. package/dist/esm/index.js +37 -0
  22. package/dist/esm/models.js +292 -0
  23. package/dist/esm/package.json +1 -0
  24. package/dist/esm/pump.js +275 -0
  25. package/dist/esm/webhooks.js +329 -0
  26. package/dist/esm/xml.js +252 -0
  27. package/dist/types/buffer.d.ts +109 -0
  28. package/dist/types/client.d.ts +150 -0
  29. package/dist/types/config.d.ts +86 -0
  30. package/dist/types/crypto.d.ts +125 -0
  31. package/dist/types/errors.d.ts +73 -0
  32. package/dist/types/http.d.ts +80 -0
  33. package/dist/types/index.d.ts +36 -0
  34. package/dist/types/models.d.ts +154 -0
  35. package/dist/types/pump.d.ts +118 -0
  36. package/dist/types/webhooks.d.ts +99 -0
  37. package/dist/types/xml.d.ts +42 -0
  38. package/docs/config.md +93 -0
  39. package/docs/errors.md +87 -0
  40. package/docs/model.md +141 -0
  41. package/docs/pump.md +130 -0
  42. package/docs/webhooks.md +140 -0
  43. 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`.