@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/docs/model.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Output model reference
|
|
2
|
+
|
|
3
|
+
The conclusions — the only objects you work with. Importable from
|
|
4
|
+
`@allus/company-data`. Each carries `.raw` (the underlying hardened API object;
|
|
5
|
+
never contains the person's source field).
|
|
6
|
+
|
|
7
|
+
## `RequestField`
|
|
8
|
+
|
|
9
|
+
Your request-field **definition** — your config, never the person's fields.
|
|
10
|
+
Returned by `client.requestFields()`.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
class RequestField {
|
|
14
|
+
slug: string; // the stable, company-set key — the contract for value access
|
|
15
|
+
label: string; // the human label (rename freely; the slug stays)
|
|
16
|
+
type: string; // email|phone|url|text|address|bank|creditcard|date|date_of_birth|photo|document|legal_document
|
|
17
|
+
oneTime: boolean; // a one-time snapshot vs a live (auto-updating) answer
|
|
18
|
+
mandatory: boolean; // mandatory-to-provide OR mandatory-to-stay-connected (the API's two flags, folded)
|
|
19
|
+
raw: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## `Connection`
|
|
24
|
+
|
|
25
|
+
A connected person — identity + the slug-keyed value map. No source field
|
|
26
|
+
anywhere; `values` is keyed by **your** request slug.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
class Connection {
|
|
30
|
+
id: string;
|
|
31
|
+
personId: string;
|
|
32
|
+
displayName: string | null; // null on connection(id) (the list endpoint carries it)
|
|
33
|
+
connectedAt: Date | null; // likewise null on connection(id)
|
|
34
|
+
values: Record<string, Value>; // {<your_slug>: Value}
|
|
35
|
+
raw: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
conn.values['work_email'].value // "alice@acme.com"
|
|
41
|
+
conn.values['mobile'] // undefined if the person didn't answer that slot
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## `Value`
|
|
45
|
+
|
|
46
|
+
One answer for one of your request slots.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
class Value {
|
|
50
|
+
value: unknown; // typed plaintext (see below)
|
|
51
|
+
live: boolean; // true = "keep connected" (auto-updates); false = one-time snapshot
|
|
52
|
+
updatedAt: Date | null; // when this answer last changed
|
|
53
|
+
raw: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `value` types (resolved from the field's `type`)
|
|
58
|
+
|
|
59
|
+
| Field type | JS `value` | Notes |
|
|
60
|
+
|------------|------------|-------|
|
|
61
|
+
| `email`, `phone`, `url`, `text` | `string` | The decrypted plaintext. |
|
|
62
|
+
| `address`, `bank`, `creditcard` | `object` | The decrypted plaintext is a JSON object → parsed. A non-JSON structured value throws `DecryptError`. |
|
|
63
|
+
| `date`, `date_of_birth` | `Date` | Parsed from ISO `YYYY-MM-DD` (UTC midnight, the leading 10 chars); falls back to the raw string if unparseable. |
|
|
64
|
+
| `photo`, `document`, `legal_document` | `BinaryHandle` | Lazy — nothing fetched/decrypted until `.bytes()`/`.save()`. |
|
|
65
|
+
| unanswered / no value | `null` | The slot has no answer. |
|
|
66
|
+
|
|
67
|
+
## `BinaryHandle`
|
|
68
|
+
|
|
69
|
+
A lazy handle for a binary value. No network or decryption happens at construction.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
class BinaryHandle {
|
|
73
|
+
get valueUrl(): string | null; // the opaque slot-keyed file URL (read-only)
|
|
74
|
+
bytes(): Promise<Buffer>; // fetch (if needed) → decrypt → decoded primary file bytes
|
|
75
|
+
save(path: string): Promise<number>; // write bytes() to path; resolves to bytes written
|
|
76
|
+
static parseEnvelopeBytes(envelopeJson: string): Buffer; // envelope string → file bytes
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
On first `.bytes()`/`.save()`:
|
|
81
|
+
|
|
82
|
+
1. GET the slot-keyed file endpoint → the API serves `{"encrypted": true, "value": <wrapper>}`.
|
|
83
|
+
2. Decrypt the inner `{"_enc":1,…}` wrapper with the service key → a JSON file-envelope string (`{"full": "data:…", "thumb": …}` for photos, `{"file": "data:…", …}` for documents).
|
|
84
|
+
3. Base64-decode the primary data URI (`full` for photos, `file` for documents) → a `Buffer`. Cached on the handle (repeated calls don't re-fetch).
|
|
85
|
+
|
|
86
|
+
`.save()` is crash-safe (temp file → fsync → atomic rename — never a truncated
|
|
87
|
+
output). An unanswered binary slot yields an empty handle; calling `.bytes()` on it
|
|
88
|
+
throws `DecryptError`.
|
|
89
|
+
|
|
90
|
+
## `Change`
|
|
91
|
+
|
|
92
|
+
A change-feed / webhook event. Returned by the pump (`processChanges`, `drainBatch`)
|
|
93
|
+
and the webhook helpers.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
class Change {
|
|
97
|
+
id: string; // the stable server change-row id — YOUR dedup key
|
|
98
|
+
event: string; // see the event table
|
|
99
|
+
personId: string | null;
|
|
100
|
+
shareCode: string | null; // the person's profile share code (every event; may be null)
|
|
101
|
+
slug: string | null; // field_updated/field_deleted/consent_* only
|
|
102
|
+
value: unknown; // field_updated only; typed exactly like Value.value
|
|
103
|
+
live: boolean | null; // field_updated only
|
|
104
|
+
at: Date | null; // the change time (no separate updatedAt on a change)
|
|
105
|
+
raw: Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Events
|
|
110
|
+
|
|
111
|
+
| `event` | Carries |
|
|
112
|
+
|---------|---------|
|
|
113
|
+
| `connection_created` | identity only (no slot/value) |
|
|
114
|
+
| `connection_deleted` | identity only (no slot/value) |
|
|
115
|
+
| `field_updated` | `slug` + decrypted `value` (+ `live`); binary → a lazy `BinaryHandle` |
|
|
116
|
+
| `field_deleted` | `slug`, no value |
|
|
117
|
+
| `consent_accepted` / `consent_declined` | `slug` |
|
|
118
|
+
|
|
119
|
+
`Change.id` is captured before the server's drain-delete, so it survives a crash +
|
|
120
|
+
replay unchanged — dedup on it.
|
|
121
|
+
|
|
122
|
+
## `LogEntry`
|
|
123
|
+
|
|
124
|
+
A service activity-log entry — ops events only (email / purge / webhook), never
|
|
125
|
+
person field data.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
class LogEntry {
|
|
129
|
+
type: string;
|
|
130
|
+
message: string | null;
|
|
131
|
+
metadata: unknown;
|
|
132
|
+
at: Date | null;
|
|
133
|
+
raw: Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## `.raw`
|
|
138
|
+
|
|
139
|
+
Every model has a `.raw` property: the underlying (hardened) API object, for
|
|
140
|
+
debugging or an edge case the SDK didn't model. It never contains the person's
|
|
141
|
+
source field — the hardened API doesn't return it.
|
package/docs/pump.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# The changes pump
|
|
2
|
+
|
|
3
|
+
The changes feed is a server-side **drain-on-fetch queue**:
|
|
4
|
+
`GET /api/company-data/changes?limit=N` returns up to N events (default 100, max
|
|
5
|
+
500) **and deletes exactly those rows in the same transaction**. There is no
|
|
6
|
+
offset/cursor/page, and the API keeps no copy after a fetch. So a consumer must:
|
|
7
|
+
|
|
8
|
+
* not lose a drained batch if it crashes mid-batch (the API already deleted it), and
|
|
9
|
+
* not materialize a huge backlog in memory.
|
|
10
|
+
|
|
11
|
+
`client.processChanges(handler)` (delegating to the `Pump`) does both.
|
|
12
|
+
|
|
13
|
+
## `processChanges(handler, options?)`
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
processChanges(
|
|
17
|
+
handler: (change: Change) => void | Promise<void>,
|
|
18
|
+
options?: {
|
|
19
|
+
batchSize?: number; // clamped to [1, 500]; default 100
|
|
20
|
+
maxRetries?: number; // default 3
|
|
21
|
+
onError?: 'deadletter' | 'halt'; // default 'deadletter'
|
|
22
|
+
backoff?: (attempt: number) => number; // attempt(1-based) -> seconds
|
|
23
|
+
},
|
|
24
|
+
): Promise<void>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Drains the feed through `handler` one `Change` at a time, **until the feed is empty,
|
|
28
|
+
then resolves**. No follow/daemon mode — schedule re-runs yourself. The handler may
|
|
29
|
+
be sync or async.
|
|
30
|
+
|
|
31
|
+
## The cycle
|
|
32
|
+
|
|
33
|
+
1. **Replay first** — deliver any un-acked events already in the local buffer (a previous crashed run), oldest-first.
|
|
34
|
+
2. **Drain** — when the buffer is empty, fetch one batch (≤ `batchSize`, ≤ 500) and **persist it to the durable buffer (fsync) BEFORE handing anything out**.
|
|
35
|
+
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(change)`.
|
|
36
|
+
4. **Ack / retry / dead-letter** — on handler success, remove the event from the buffer (ack). On a handler error, retry with `backoff` up to `maxRetries`; then:
|
|
37
|
+
* `onError="deadletter"` (default) → move it to the dead-letter store, log it, and continue (one poison event never wedges the stream);
|
|
38
|
+
* `onError="halt"` → re-throw the handler's error (the event stays un-acked in the buffer for the next run).
|
|
39
|
+
A **`DecryptError`** (corrupt/truncated ciphertext, rotated key) is special: the decrypt runs *inside* the delivery attempt, and an undecryptable event is **dead-lettered immediately** — re-decrypting can't fix it, so it does **not** burn `maxRetries`. Under `onError="halt"` it re-throws like a handler error. Either way it never propagates out of `processChanges` and wedges step-1 replay.
|
|
40
|
+
5. Repeat until a drain returns empty **and** the buffer is drained → resolve.
|
|
41
|
+
|
|
42
|
+
## Crash safety · at-least-once · idempotency
|
|
43
|
+
|
|
44
|
+
A batch is durably buffered *before* any delivery, and acked per-item only *after*
|
|
45
|
+
the handler succeeds. A crash between a handler's success and its ack re-delivers
|
|
46
|
+
that event on the next run. Delivery is therefore **at-least-once**:
|
|
47
|
+
|
|
48
|
+
> **Your handler must be idempotent. Dedup on `Change.id`** (the stable server
|
|
49
|
+
> change-row id, captured before the server delete).
|
|
50
|
+
|
|
51
|
+
## The durable buffer (on disk)
|
|
52
|
+
|
|
53
|
+
Under `cacheDir`:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
<cacheDir>/pending/<seq>_<change_id>.json # un-acked events, oldest-first
|
|
57
|
+
<cacheDir>/deadletter/<seq>_<change_id>.json # events that exhausted retries
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
* Stored events keep their **ciphertext** `value`/`value_url` — **no plaintext PII is ever written to disk**. Decryption happens only at delivery.
|
|
61
|
+
* `<seq>` is a zero-padded, monotonically increasing sequence, so lexicographic filename order == oldest-first (stable even if `at` timestamps are equal/missing).
|
|
62
|
+
* Writes are crash-safe: temp file → `fsyncSync` → atomic rename → dir fsync. A crash never leaves a half-written file.
|
|
63
|
+
* Re-instantiating the buffer on the same `cacheDir` recovers whatever is on disk — that recovery **is** the replay-on-restart.
|
|
64
|
+
|
|
65
|
+
## Options
|
|
66
|
+
|
|
67
|
+
| Option | Default | Meaning |
|
|
68
|
+
|--------|---------|---------|
|
|
69
|
+
| `batchSize` | 100 | Events per drain; clamped to `[1, 500]`. |
|
|
70
|
+
| `maxRetries` | 3 | Handler retries before dead-letter/halt. |
|
|
71
|
+
| `onError` | `"deadletter"` | `"deadletter"` (continue) or `"halt"` (re-throw). Any other value throws `TypeError`. |
|
|
72
|
+
| `backoff` | exponential, capped 30s | `attempt -> seconds` between retries. |
|
|
73
|
+
|
|
74
|
+
> `logger` is **not** a `processChanges` option — pass it to the `Client`
|
|
75
|
+
> constructor (`Client.fromConfig('allus.json', { logger: myLogger })`). Every
|
|
76
|
+
> drain, deliver, ack, retry, dead-letter, and replay is logged (any
|
|
77
|
+
> console-compatible sink with `debug`/`info`/`warn`/`error`).
|
|
78
|
+
|
|
79
|
+
## Durability guarantees
|
|
80
|
+
|
|
81
|
+
Held by the pump across all six SDKs, validated by the test suite:
|
|
82
|
+
|
|
83
|
+
1. **Decrypt inside the delivery attempt** — a poison/undecryptable event is dead-lettered immediately, never wedges replay, and does not burn retries.
|
|
84
|
+
2. **A re-failing dead-letter is updated in place** within `deadletter/` (atomic temp+fsync+rename) — never routed back through `pending/` (which has a crash window that could resurrect a dead-letter as a live event).
|
|
85
|
+
3. **Stored attempt count is monotonic** across separate retry runs (`max(existing, new)`).
|
|
86
|
+
4. **At-least-once dead-lettering** — the new dead-letter copy is written *before* the pending copy is unlinked, so a crash between leaves the event in both dirs (harmless re-delivery, absorbed by the id-dedup handler). This is intentional — do not "fix" it by deleting-first.
|
|
87
|
+
|
|
88
|
+
## No follow mode — schedule re-runs
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
92
|
+
for (;;) {
|
|
93
|
+
await client.processChanges(handle); // resolves when the feed empties
|
|
94
|
+
await sleep(5000); // the feed is cheap to poll (see rate limits)
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A cron job, a worker loop, or any scheduler works equally well.
|
|
99
|
+
|
|
100
|
+
## Dead-letter inspect / re-drive
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
client.deadLetters(): DeadLetterRecord[]
|
|
104
|
+
client.retryDeadLetters(handler, options?): Promise<number>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
* `deadLetters()` — each record is the stored (ciphertext) event with a flattened `error` and `attempts`, plus its `id`.
|
|
108
|
+
* `retryDeadLetters(handler)` — re-drives every dead-lettered event through `handler`. On success the record is removed. On repeated failure (or a `DecryptError`) the dead-letter record is **updated in place** with the new error + attempt count and stays in `deadletter/` (`"deadletter"`), or the error re-throws (`"halt"`). Resolves to the count successfully re-driven.
|
|
109
|
+
|
|
110
|
+
A re-failing dead-letter never re-enters `pending/` — it is rewritten in place
|
|
111
|
+
within `deadletter/`, so a crash mid-re-drive can't resurrect it as a live event on
|
|
112
|
+
the next run. Dead letters are **never silently dropped** and **never re-fetched
|
|
113
|
+
from the API** (it already deleted them) — the local store is their only home,
|
|
114
|
+
which is exactly why it's durable.
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
for (const dl of client.deadLetters()) console.log(dl.id, dl.error, dl.attempts);
|
|
118
|
+
const fixed = await client.retryDeadLetters(handle); // after fixing the handler bug
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Advanced: `drainBatch(max?)`
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
client.drainBatch(max?: number): Promise<Change[]>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A raw, **UNBUFFERED** drain: fetches one batch (clamped ≤ 500) and returns the
|
|
128
|
+
decrypted `Change`s directly — it does **not** persist anything to the buffer, so
|
|
129
|
+
**you own durability** if you use it (a crash loses what the API already deleted).
|
|
130
|
+
Prefer `processChanges` for safe consumption.
|
package/docs/webhooks.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Webhook receiver helpers
|
|
2
|
+
|
|
3
|
+
The lower-latency push alternative to polling the changes feed. The platform POSTs
|
|
4
|
+
each change event to your configured webhook URL with:
|
|
5
|
+
|
|
6
|
+
* `X-Allus-Webhook-Id` — which webhook this is (selects the HMAC secret from config).
|
|
7
|
+
* `X-Allus-Signature` — `HMAC-SHA256(rawBody, secret)` as lowercase hex.
|
|
8
|
+
* the body — the same slug-keyed `Change` shape as the pull feed (JSON or XML). If `encrypt_payload` is on, the body is replaced by a `{"_enc":1,…}` envelope encrypted to the company **account** key (and the HMAC is over that envelope).
|
|
9
|
+
|
|
10
|
+
**All secrets/keys come from config — these helpers take NO key or secret
|
|
11
|
+
arguments.** Always pass the **raw request body bytes** (a `Buffer`); don't
|
|
12
|
+
re-serialize a parsed body — the HMAC is over the exact bytes sent.
|
|
13
|
+
|
|
14
|
+
## Client methods (the usual form)
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
client.verifyWebhook(rawBody: Buffer | Uint8Array | string, headers): boolean
|
|
18
|
+
client.parseWebhook(rawBody, headers): Change
|
|
19
|
+
client.handleWebhook(rawBody, headers): Change // verify + parse
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Method | Returns | Errors |
|
|
23
|
+
|--------|---------|--------|
|
|
24
|
+
| `verifyWebhook` | `boolean` — recomputes `HMAC-SHA256(rawBody, secret)` and constant-time-compares to `X-Allus-Signature`. `false` on missing signature / unknown id / mismatch. | **Never throws** for a bad signature. |
|
|
25
|
+
| `parseWebhook` | a typed `Change`. Does **not** verify. Handles JSON, XML, and the `encrypt_payload` account-key envelope. | `WebhookError` on a malformed/unparseable body or envelope. |
|
|
26
|
+
| `handleWebhook` | a typed `Change` — verify **then** parse. | `WebhookError` on a bad/unknown signature, or any `parseWebhook` error. |
|
|
27
|
+
|
|
28
|
+
> The client webhook methods are **synchronous** but need the request-fields catalog
|
|
29
|
+
> (to type the value). Call `await client.requestFields()` once at startup so the
|
|
30
|
+
> catalog is cached — the methods then make no network calls. `headers` may be a
|
|
31
|
+
> plain object or a Node `IncomingHttpHeaders` (case-insensitive lookup; array
|
|
32
|
+
> header values use the first element).
|
|
33
|
+
|
|
34
|
+
## Standalone functions
|
|
35
|
+
|
|
36
|
+
The same three are importable as module functions. They take the `config` and the
|
|
37
|
+
decrypt/type closures explicitly — used by `Client` internally; you'll normally use
|
|
38
|
+
the client methods inside an app.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { verifyWebhook, parseWebhook, handleWebhook } from '@allus/company-data';
|
|
42
|
+
|
|
43
|
+
verifyWebhook(rawBody, headers, config): boolean
|
|
44
|
+
parseWebhook(rawBody, headers, config, { typeForSlug, decryptValue, binaryFetch?, accountKey? }): Change
|
|
45
|
+
handleWebhook(rawBody, headers, config, { typeForSlug, decryptValue, binaryFetch?, accountKey? }): Change
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## In a web route
|
|
49
|
+
|
|
50
|
+
### Express
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import express from 'express';
|
|
54
|
+
import { Client, WebhookError } from '@allus/company-data';
|
|
55
|
+
|
|
56
|
+
const app = express();
|
|
57
|
+
const client = Client.fromConfig('allus.json');
|
|
58
|
+
await client.requestFields(); // warm the catalog (the webhook methods are sync)
|
|
59
|
+
|
|
60
|
+
// Capture the RAW body bytes — do NOT let a JSON parser replace them.
|
|
61
|
+
app.post('/allus/webhook', express.raw({ type: '*/*' }), (req, res) => {
|
|
62
|
+
let change;
|
|
63
|
+
try {
|
|
64
|
+
change = client.handleWebhook(req.body, req.headers);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e instanceof WebhookError) return res.sendStatus(401);
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
if (!seen(change.id)) { // idempotency — same rule as the pump
|
|
70
|
+
applyChange(change);
|
|
71
|
+
recordSeen(change.id);
|
|
72
|
+
}
|
|
73
|
+
res.sendStatus(204);
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Fastify
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import Fastify from 'fastify';
|
|
81
|
+
import { Client, WebhookError } from '@allus/company-data';
|
|
82
|
+
|
|
83
|
+
const app = Fastify();
|
|
84
|
+
const client = Client.fromConfig('allus.json');
|
|
85
|
+
await client.requestFields();
|
|
86
|
+
|
|
87
|
+
// Keep the raw body: a contentTypeParser that returns the Buffer untouched.
|
|
88
|
+
app.addContentTypeParser('*', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
|
|
89
|
+
|
|
90
|
+
app.post('/allus/webhook', (req, reply) => {
|
|
91
|
+
let change;
|
|
92
|
+
try {
|
|
93
|
+
change = client.handleWebhook(req.body as Buffer, req.headers);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e instanceof WebhookError) return reply.code(401).send();
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
if (!seen(change.id)) { applyChange(change); recordSeen(change.id); }
|
|
99
|
+
return reply.code(204).send();
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Split the steps if you prefer:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
if (!client.verifyWebhook(rawBody, headers)) return res.sendStatus(401);
|
|
107
|
+
const change = client.parseWebhook(rawBody, headers);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Config-driven secrets
|
|
111
|
+
|
|
112
|
+
Per-webhook HMAC secrets live in the config `webhooks` map, keyed by webhook id;
|
|
113
|
+
the SDK reads `X-Allus-Webhook-Id` and looks up the matching secret. A
|
|
114
|
+
single-webhook service can use the flat `"webhook_secret": "…"` shortcut (or
|
|
115
|
+
`ALLUS_WEBHOOK_SECRET`). An unknown/unconfigured id ⇒ `verifyWebhook` returns
|
|
116
|
+
`false` (and `handleWebhook` throws `WebhookError`).
|
|
117
|
+
|
|
118
|
+
## The `encrypt_payload` account-key envelope
|
|
119
|
+
|
|
120
|
+
If a webhook has `encrypt_payload` enabled, the whole body is a `{"_enc":1,…}`
|
|
121
|
+
envelope encrypted to your company **account** key, and the HMAC is over that
|
|
122
|
+
envelope. `parseWebhook`/`handleWebhook`:
|
|
123
|
+
|
|
124
|
+
1. Unwrap the envelope with the configured `account_private_key` + `account_passphrase` (loaded once at `Client` construction — no per-webhook PBKDF2).
|
|
125
|
+
2. Parse the inner payload (JSON or XML per `format`).
|
|
126
|
+
3. Decrypt the inner field `value` (a service-key wrapper) with the service key.
|
|
127
|
+
|
|
128
|
+
So an `encrypt_payload` `Change` is identical to a plain one. Receiving such a
|
|
129
|
+
webhook without an `account_private_key` configured throws `WebhookError`.
|
|
130
|
+
|
|
131
|
+
> The envelope uses RSA-OAEP-**SHA1** (OpenSSL's default), distinct from the
|
|
132
|
+
> OAEP-SHA256 used for person field values. The SDK has two OAEP code paths and
|
|
133
|
+
> handles this difference internally — you only supply the account key in config.
|
|
134
|
+
|
|
135
|
+
## XXE safety
|
|
136
|
+
|
|
137
|
+
XML webhook bodies (and the inner payload after an envelope unwrap) are parsed by
|
|
138
|
+
the same **XXE-safe** parser the HTTP layer uses: no DOCTYPE/DTD processing, no
|
|
139
|
+
custom or external entities. The HMAC is always computed over the **raw bytes**,
|
|
140
|
+
never the parsed tree.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@allus-fyi/company-data",
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "TypeScript/Node SDK for the allus company-data API: typed, plaintext, slug-keyed conclusions with transparent decryption.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "allme.fyi",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"allus",
|
|
9
|
+
"allme",
|
|
10
|
+
"company-data",
|
|
11
|
+
"sdk"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/allus-fyi/company-data-typescript#readme",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/allus-fyi/company-data-typescript.git"
|
|
17
|
+
},
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/allus-fyi/company-data-typescript/issues"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/types/index.d.ts",
|
|
28
|
+
"import": "./dist/esm/index.js",
|
|
29
|
+
"require": "./dist/cjs/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"main": "./dist/cjs/index.js",
|
|
33
|
+
"module": "./dist/esm/index.js",
|
|
34
|
+
"types": "./dist/types/index.d.ts",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md",
|
|
38
|
+
"docs"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"clean": "rimraf dist",
|
|
42
|
+
"build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:types && node scripts/fixup-cjs.mjs",
|
|
43
|
+
"build:esm": "tsc -p tsconfig.esm.json",
|
|
44
|
+
"build:cjs": "tsc -p tsconfig.cjs.json",
|
|
45
|
+
"build:types": "tsc -p tsconfig.types.json",
|
|
46
|
+
"test": "node --test --import tsx test/*.test.ts"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^22.0.0",
|
|
50
|
+
"rimraf": "^5.0.0",
|
|
51
|
+
"tsx": "^4.19.0",
|
|
52
|
+
"typescript": "^5.6.0"
|
|
53
|
+
}
|
|
54
|
+
}
|