@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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output model — the conclusions.
|
|
3
|
+
*
|
|
4
|
+
* The consumer works with these and nothing else. They are produced by factories
|
|
5
|
+
* that turn a *hardened* API JSON object (slug-keyed `values`; NO person source
|
|
6
|
+
* field) into typed objects, decrypting ciphertext via the injected crypto closures.
|
|
7
|
+
*
|
|
8
|
+
* RequestField { slug, label, type, oneTime, mandatory } // YOUR request config
|
|
9
|
+
* Connection { id, personId, displayName, connectedAt, values: {<slug>: Value} }
|
|
10
|
+
* Value { value, live, updatedAt }
|
|
11
|
+
* Change { id, event, personId, shareCode?, slug?, value?, live?, at } // id = stable dedup key
|
|
12
|
+
* LogEntry { type, message, metadata, at }
|
|
13
|
+
*
|
|
14
|
+
* Typed values:
|
|
15
|
+
* - `email`/`phone`/`url`/`text` → string
|
|
16
|
+
* - `address`/`bank`/`creditcard` → a parsed object (the decrypted plaintext is a
|
|
17
|
+
* JSON object string → parsed)
|
|
18
|
+
* - `date`/`date_of_birth` → a JS `Date` (UTC midnight; falls back to the
|
|
19
|
+
* raw string if it can't be parsed)
|
|
20
|
+
* - `photo`/`document`/`legal_document` → a lazy {@link BinaryHandle}
|
|
21
|
+
* (`.bytes()` fetches the slot file endpoint, decrypts, parses the envelope,
|
|
22
|
+
* base64-decodes the `full`/`file` data URI)
|
|
23
|
+
*
|
|
24
|
+
* Every model carries `.raw` — the underlying (hardened) API object — for debugging
|
|
25
|
+
* or an edge case the SDK didn't model. It never contains the person's source field.
|
|
26
|
+
*
|
|
27
|
+
* Decryption is config-driven: the factory takes a `decryptValue`
|
|
28
|
+
* callable (a closure over the loaded service private key) and, for binaries, a
|
|
29
|
+
* `binaryFetch` callable — never a key/secret argument.
|
|
30
|
+
*/
|
|
31
|
+
import { type BinaryFetch, type DecryptWrapper } from './crypto.js';
|
|
32
|
+
/** Field types whose decrypted plaintext is a JSON object → a parsed object. */
|
|
33
|
+
export declare const STRUCTURED_TYPES: readonly ["address", "bank", "creditcard"];
|
|
34
|
+
/** Field types whose value is a lazy binary handle (served as a value_url). */
|
|
35
|
+
export declare const BINARY_TYPES: readonly ["photo", "document", "legal_document"];
|
|
36
|
+
/** Field types whose decrypted plaintext is an ISO date. */
|
|
37
|
+
export declare const DATE_TYPES: readonly ["date", "date_of_birth"];
|
|
38
|
+
/** A type resolver: slug -> the request field's type (e.g. "email", "photo"). */
|
|
39
|
+
export type TypeForSlug = (slug: string) => string | null | undefined;
|
|
40
|
+
type Json = Record<string, unknown>;
|
|
41
|
+
/**
|
|
42
|
+
* A request-field DEFINITION — YOUR config, never the person's.
|
|
43
|
+
*
|
|
44
|
+
* `mandatory` folds the API's two flags: it is true when the field is mandatory to
|
|
45
|
+
* provide OR mandatory to stay connected.
|
|
46
|
+
*/
|
|
47
|
+
export declare class RequestField {
|
|
48
|
+
readonly slug: string;
|
|
49
|
+
readonly label: string;
|
|
50
|
+
readonly type: string;
|
|
51
|
+
readonly oneTime: boolean;
|
|
52
|
+
readonly mandatory: boolean;
|
|
53
|
+
readonly raw: Json;
|
|
54
|
+
constructor(slug: string, label: string, type: string, oneTime: boolean, mandatory: boolean, raw: Json);
|
|
55
|
+
static fromApi(obj: Json): RequestField;
|
|
56
|
+
/** Parse the `/request-fields` response → a list of definitions. */
|
|
57
|
+
static listFromApi(body: unknown): RequestField[];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A single answer for one of YOUR request slots.
|
|
61
|
+
*
|
|
62
|
+
* `value` is the typed plaintext (string / object / Date / lazy BinaryHandle);
|
|
63
|
+
* `live` = the person chose "keep connected" (auto-updates) vs a one-time snapshot;
|
|
64
|
+
* `updatedAt` = when this answer last changed. Both ride on the Value (per-answer),
|
|
65
|
+
* not the definition.
|
|
66
|
+
*/
|
|
67
|
+
export declare class Value {
|
|
68
|
+
readonly value: unknown;
|
|
69
|
+
readonly live: boolean;
|
|
70
|
+
readonly updatedAt: Date | null;
|
|
71
|
+
readonly raw: Json;
|
|
72
|
+
constructor(value: unknown, live: boolean, updatedAt: Date | null, raw: Json);
|
|
73
|
+
/** Build a typed Value from one hardened `{value|value_url, live, updatedAt}` entry. */
|
|
74
|
+
static fromApi(obj: Json, opts: {
|
|
75
|
+
fieldType: string | null | undefined;
|
|
76
|
+
decryptValue: DecryptWrapper;
|
|
77
|
+
binaryFetch?: BinaryFetch | null;
|
|
78
|
+
}): Value;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A connected person — identity + the slug-keyed value map.
|
|
82
|
+
*
|
|
83
|
+
* NO source field anywhere: `values` is keyed by YOUR request slug.
|
|
84
|
+
*/
|
|
85
|
+
export declare class Connection {
|
|
86
|
+
readonly id: string;
|
|
87
|
+
readonly personId: string;
|
|
88
|
+
readonly displayName: string | null;
|
|
89
|
+
readonly connectedAt: Date | null;
|
|
90
|
+
readonly values: Record<string, Value>;
|
|
91
|
+
readonly raw: Json;
|
|
92
|
+
constructor(id: string, personId: string, displayName: string | null, connectedAt: Date | null, values: Record<string, Value>, raw: Json);
|
|
93
|
+
/**
|
|
94
|
+
* Build a Connection from a hardened `connectionDetail` (or list) object.
|
|
95
|
+
*
|
|
96
|
+
* `connectionDetail` returns `{connection_id, user_id, values}` and no
|
|
97
|
+
* displayName/connectedAt, so those can be supplied via `identity` (the matching
|
|
98
|
+
* row from the list endpoint, which carries them).
|
|
99
|
+
*/
|
|
100
|
+
static fromApi(obj: Json, opts: {
|
|
101
|
+
typeForSlug: TypeForSlug;
|
|
102
|
+
decryptValue: DecryptWrapper;
|
|
103
|
+
binaryFetch?: BinaryFetch | null;
|
|
104
|
+
identity?: Json;
|
|
105
|
+
}): Connection;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A change feed / webhook event.
|
|
109
|
+
*
|
|
110
|
+
* `id` is the stable server change-row id (the pump dedupes on it after a
|
|
111
|
+
* crash/replay); `at` is the change time (there is NO separate
|
|
112
|
+
* `updatedAt` on a change). `slug`/`value`/`live` are present only on
|
|
113
|
+
* `field_updated` (connection/consent events carry no slot/value).
|
|
114
|
+
*/
|
|
115
|
+
export declare class Change {
|
|
116
|
+
readonly id: string;
|
|
117
|
+
readonly event: string;
|
|
118
|
+
readonly personId: string | null;
|
|
119
|
+
/** The person's profile share code (present on every event; may be null). */
|
|
120
|
+
readonly shareCode: string | null;
|
|
121
|
+
readonly slug: string | null;
|
|
122
|
+
readonly value: unknown;
|
|
123
|
+
readonly live: boolean | null;
|
|
124
|
+
readonly at: Date | null;
|
|
125
|
+
readonly raw: Json;
|
|
126
|
+
constructor(id: string, event: string, personId: string | null,
|
|
127
|
+
/** The person's profile share code (present on every event; may be null). */
|
|
128
|
+
shareCode: string | null, slug: string | null, value: unknown, live: boolean | null, at: Date | null, raw: Json);
|
|
129
|
+
/** Build a Change from one hardened changes-feed / webhook event object. */
|
|
130
|
+
static fromApi(obj: Json, opts: {
|
|
131
|
+
typeForSlug: TypeForSlug;
|
|
132
|
+
decryptValue: DecryptWrapper;
|
|
133
|
+
binaryFetch?: BinaryFetch | null;
|
|
134
|
+
}): Change;
|
|
135
|
+
/** Parse the `/changes` response → a list of typed Change events. */
|
|
136
|
+
static listFromApi(body: unknown, opts: {
|
|
137
|
+
typeForSlug: TypeForSlug;
|
|
138
|
+
decryptValue: DecryptWrapper;
|
|
139
|
+
binaryFetch?: BinaryFetch | null;
|
|
140
|
+
}): Change[];
|
|
141
|
+
}
|
|
142
|
+
/** A service activity-log entry — ops events only, never person data. */
|
|
143
|
+
export declare class LogEntry {
|
|
144
|
+
readonly type: string;
|
|
145
|
+
readonly message: string | null;
|
|
146
|
+
readonly metadata: unknown;
|
|
147
|
+
readonly at: Date | null;
|
|
148
|
+
readonly raw: Json;
|
|
149
|
+
constructor(type: string, message: string | null, metadata: unknown, at: Date | null, raw: Json);
|
|
150
|
+
static fromApi(obj: Json): LogEntry;
|
|
151
|
+
/** Parse the `/logs` response → a list of log entries. */
|
|
152
|
+
static listFromApi(body: unknown): LogEntry[];
|
|
153
|
+
}
|
|
154
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-safe streaming changes pump.
|
|
3
|
+
*
|
|
4
|
+
* The changes feed is a server-side **drain-on-fetch queue**: a fetch returns up to
|
|
5
|
+
* N events (default 100, max 500) and deletes those rows in the same transaction —
|
|
6
|
+
* the API keeps no copy. So consumption cannot be a plain list: a consumer crash
|
|
7
|
+
* mid-batch would lose events the API already deleted, and a huge backlog must not
|
|
8
|
+
* materialize in memory. The pump solves both:
|
|
9
|
+
*
|
|
10
|
+
* processChanges(handler) — one Change at a time, until the feed is empty, then
|
|
11
|
+
* RETURNS. No follow/daemon mode (you schedule re-runs
|
|
12
|
+
* yourself).
|
|
13
|
+
*
|
|
14
|
+
* Per cycle:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Replay first** — deliver any un-acked events already in the local buffer
|
|
17
|
+
* (from a previous crashed run), oldest-first.
|
|
18
|
+
* 2. **Drain** — when the buffer is empty, fetch ONE batch (≤ batchSize, ≤500) and
|
|
19
|
+
* **persist it to the durable buffer (fsync) BEFORE handing anything out**.
|
|
20
|
+
* 3. **Deliver one-by-one** — for each buffered event oldest-first: decrypt its
|
|
21
|
+
* value (at delivery — never on disk), build the typed Change, call the handler.
|
|
22
|
+
* 4. **Ack / retry / dead-letter** — on success remove the event from the buffer;
|
|
23
|
+
* on error retry with backoff up to maxRetries, then (onError "deadletter")
|
|
24
|
+
* move it to the dead-letter store and continue (one poison event never wedges
|
|
25
|
+
* the stream), or (onError "halt") stop and re-throw.
|
|
26
|
+
* 5. Repeat until a drain returns empty AND the buffer is drained → return.
|
|
27
|
+
*
|
|
28
|
+
* Durability invariants (every port preserves these):
|
|
29
|
+
* (1) Decrypt INSIDE the delivery attempt — a DecryptError on a persisted poison
|
|
30
|
+
* event is dead-lettered IMMEDIATELY (re-decrypt can't help → it does NOT burn
|
|
31
|
+
* the retry budget); it never propagates out and wedges replay.
|
|
32
|
+
* (2) A re-failing dead-letter is updated IN PLACE within deadletter/ (never routed
|
|
33
|
+
* back through pending/).
|
|
34
|
+
* (3) Stored attempt count is monotonic = max(existing, new) (handled by the buffer).
|
|
35
|
+
* (4) dead_letter writes the new copy BEFORE unlinking pending (at-least-once safe).
|
|
36
|
+
*
|
|
37
|
+
* Injection (so tests + the real Client share one pump): the pump takes a
|
|
38
|
+
* `fetchChanges(limit) -> Promise<event[]>` source (the raw drain-on-fetch call,
|
|
39
|
+
* returning ciphertext event objects) and a `decrypt(event) -> Change` callable
|
|
40
|
+
* (closes over the loaded service private key — config-only key handling). No
|
|
41
|
+
* key/secret is ever a method argument.
|
|
42
|
+
*/
|
|
43
|
+
import { FileBuffer, type BufferedEvent, type DeadLetterRecord } from './buffer.js';
|
|
44
|
+
import { Config } from './config.js';
|
|
45
|
+
import { Change } from './models.js';
|
|
46
|
+
export declare const MAX_BATCH = 500;
|
|
47
|
+
/** A fetch source: given a limit, drain-and-return up to that many raw event objects. */
|
|
48
|
+
export type FetchChanges = (limit: number) => Promise<BufferedEvent[]> | BufferedEvent[];
|
|
49
|
+
/** A decrypt callable: raw event object -> typed Change (value decrypted at delivery). */
|
|
50
|
+
export type DecryptChange = (event: BufferedEvent) => Change;
|
|
51
|
+
/** The consumer handler: it does the side-effect; success acks, a throw retries. */
|
|
52
|
+
export type Handler = (change: Change) => void | Promise<void>;
|
|
53
|
+
/** A minimal logger sink (console-compatible). */
|
|
54
|
+
export interface Logger {
|
|
55
|
+
debug?(message: string, ...args: unknown[]): void;
|
|
56
|
+
info?(message: string, ...args: unknown[]): void;
|
|
57
|
+
warn?(message: string, ...args: unknown[]): void;
|
|
58
|
+
error?(message: string, ...args: unknown[]): void;
|
|
59
|
+
}
|
|
60
|
+
export type OnError = 'deadletter' | 'halt';
|
|
61
|
+
export interface ProcessOptions {
|
|
62
|
+
batchSize?: number;
|
|
63
|
+
maxRetries?: number;
|
|
64
|
+
onError?: OnError;
|
|
65
|
+
backoff?: (attempt: number) => number;
|
|
66
|
+
}
|
|
67
|
+
export interface PumpOptions {
|
|
68
|
+
fetchChanges: FetchChanges;
|
|
69
|
+
decrypt: DecryptChange;
|
|
70
|
+
logger?: Logger;
|
|
71
|
+
sleep?: (seconds: number) => Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The crash-safe changes pump.
|
|
75
|
+
*
|
|
76
|
+
* Wires a durable {@link FileBuffer} (under `config.cacheDir`) to an injected drain
|
|
77
|
+
* source + decrypt callable.
|
|
78
|
+
*/
|
|
79
|
+
export declare class Pump {
|
|
80
|
+
private readonly fetchChanges;
|
|
81
|
+
private readonly decryptChange;
|
|
82
|
+
private readonly log;
|
|
83
|
+
private readonly sleep;
|
|
84
|
+
private readonly _buffer;
|
|
85
|
+
constructor(config: Config, opts: PumpOptions);
|
|
86
|
+
get buffer(): FileBuffer;
|
|
87
|
+
/**
|
|
88
|
+
* Stream events through `handler` until the feed is empty, then return.
|
|
89
|
+
*
|
|
90
|
+
* `handler` is called with one typed {@link Change} at a time and must be
|
|
91
|
+
* idempotent (at-least-once delivery; dedup on `Change.id`).
|
|
92
|
+
*
|
|
93
|
+
* Options: `batchSize` (clamped ≤500), `maxRetries`, `onError`
|
|
94
|
+
* (`"deadletter"` — default — or `"halt"`), `backoff` (attempt → seconds).
|
|
95
|
+
*/
|
|
96
|
+
processChanges(handler: Handler, options?: ProcessOptions): Promise<void>;
|
|
97
|
+
private drainIntoBuffer;
|
|
98
|
+
private deliverOne;
|
|
99
|
+
/**
|
|
100
|
+
* Raw, UNBUFFERED drain → a list of typed Changes (advanced).
|
|
101
|
+
*
|
|
102
|
+
* Fetches one batch (clamped ≤500) and returns the decrypted Changes directly — it
|
|
103
|
+
* does NOT persist anything to the buffer, so **you own durability** if you use it.
|
|
104
|
+
* Prefer {@link processChanges} for safe consumption.
|
|
105
|
+
*/
|
|
106
|
+
drainBatch(max?: number): Promise<Change[]>;
|
|
107
|
+
/** The local dead-letter store (ciphertext + error + attempt count). */
|
|
108
|
+
deadLetters(): DeadLetterRecord[];
|
|
109
|
+
/**
|
|
110
|
+
* Re-drive every dead-lettered event through `handler`.
|
|
111
|
+
*
|
|
112
|
+
* On success the dead-letter record is removed; on repeated failure it is
|
|
113
|
+
* re-dead-lettered IN PLACE (`"deadletter"`) or the error is re-thrown (`"halt"`).
|
|
114
|
+
* They are never re-fetched from the API (it already deleted them) — the local
|
|
115
|
+
* store is their only home. Returns the count successfully re-driven.
|
|
116
|
+
*/
|
|
117
|
+
retryDeadLetters(handler: Handler, options?: ProcessOptions): Promise<number>;
|
|
118
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook receiver helpers.
|
|
3
|
+
*
|
|
4
|
+
* The lower-latency push alternative to polling the changes feed. The platform
|
|
5
|
+
* delivers each change event to the company's configured webhook URL with:
|
|
6
|
+
*
|
|
7
|
+
* - `X-Allus-Webhook-Id` — which webhook this is (selects the HMAC secret).
|
|
8
|
+
* - `X-Allus-Signature` — `HMAC-SHA256(rawBody, secret)` as lowercase hex.
|
|
9
|
+
* - the body — the same slug-keyed {@link Change} shape as the pull feed,
|
|
10
|
+
* JSON or XML. If the webhook has `encrypt_payload` on, the body is REPLACED
|
|
11
|
+
* by a `{"_enc":1,...}` envelope encrypted to the company **account** key (and
|
|
12
|
+
* the HMAC is then over that envelope — it is the final body that was sent).
|
|
13
|
+
*
|
|
14
|
+
* Webhook delivery auth is per-webhook and may be any of five methods (hmac,
|
|
15
|
+
* bearer, basic, custom header, or none); {@link verifyWebhook} dispatches on the
|
|
16
|
+
* single method configured in {@link Config} ({@link Config.webhookAuthMethod}).
|
|
17
|
+
*
|
|
18
|
+
* All secrets/keys come from {@link Config}. **These helpers take NO key or secret
|
|
19
|
+
* arguments** — only the raw body, the headers, the config, and (for value typing)
|
|
20
|
+
* the same decrypt/type closures the {@link Client} already holds.
|
|
21
|
+
*
|
|
22
|
+
* The account-key envelope is webhook-specific: the platform wraps it with
|
|
23
|
+
* OpenSSL's DEFAULT OAEP padding (MGF1-**SHA1**), NOT the SHA-256 wrapper used for
|
|
24
|
+
* person field values. So unwrapping the envelope uses an OAEP-SHA1 path here
|
|
25
|
+
* (Node's default `oaepHash`, pinned explicitly to `'sha1'` for clarity), while the
|
|
26
|
+
* inner field `value` (still a service-key wrapper) decrypts with the normal
|
|
27
|
+
* SHA-256 {@link decrypt}. HMAC is always computed over the raw bytes, never the
|
|
28
|
+
* parsed tree.
|
|
29
|
+
*/
|
|
30
|
+
import { type KeyObject } from 'node:crypto';
|
|
31
|
+
import { Config } from './config.js';
|
|
32
|
+
import { type BinaryFetch, type DecryptWrapper } from './crypto.js';
|
|
33
|
+
import { Change, type TypeForSlug } from './models.js';
|
|
34
|
+
/** Headers as a (possibly mixed-case) string map. */
|
|
35
|
+
export type Headers = Record<string, string | string[] | undefined>;
|
|
36
|
+
interface ParseDeps {
|
|
37
|
+
typeForSlug: TypeForSlug;
|
|
38
|
+
decryptValue: DecryptWrapper;
|
|
39
|
+
binaryFetch?: BinaryFetch | null;
|
|
40
|
+
/** A pre-loaded account private key the Client caches; loaded on demand otherwise. */
|
|
41
|
+
accountKey?: KeyObject | null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Verify a webhook against the SINGLE configured auth method.
|
|
45
|
+
*
|
|
46
|
+
* Mirrors the platform's per-webhook delivery auth (one method per webhook):
|
|
47
|
+
*
|
|
48
|
+
* - `hmac` — recompute `HMAC-SHA256(rawBody, secret)` (secret selected by
|
|
49
|
+
* `X-Allus-Webhook-Id`) and constant-time-compare to `X-Allus-Signature`.
|
|
50
|
+
* - `bearer` — `Authorization` equals `Bearer <token>`.
|
|
51
|
+
* - `basic` — `Authorization` equals `Basic <base64(user:pass)>`.
|
|
52
|
+
* - `header` — the configured custom header equals the configured value.
|
|
53
|
+
* - `none` — always `true` (explicit opt-out).
|
|
54
|
+
*
|
|
55
|
+
* All comparisons are constant-time. Returns `false` on a missing/mismatched
|
|
56
|
+
* credential, or when no method is configured — never throws for a bad credential
|
|
57
|
+
* (that is {@link handleWebhook}'s job). Which method is used is decided entirely
|
|
58
|
+
* by config ({@link Config.webhookAuthMethod}); config loading guarantees at most
|
|
59
|
+
* one is set. The HMAC is over the exact raw bytes.
|
|
60
|
+
*/
|
|
61
|
+
export declare function verifyWebhook(rawBody: Buffer | Uint8Array | string, headers: Headers, config: Config): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Parse a webhook body → a typed {@link Change}.
|
|
64
|
+
*
|
|
65
|
+
* Does NOT verify the signature (use {@link handleWebhook} for verify+parse).
|
|
66
|
+
* Handles JSON and XML bodies, and an `encrypt_payload` account-key envelope: if
|
|
67
|
+
* the (JSON) body is a `{"_enc":1,...}` wrapper, it is first unwrapped with the
|
|
68
|
+
* account private key (OAEP-SHA1) into the inner serialized payload, which is then
|
|
69
|
+
* parsed. The inner field `value` (a service-key wrapper) is decrypted by the same
|
|
70
|
+
* model factory the feed uses, so a webhook `Change` is byte-identical to a feed
|
|
71
|
+
* `Change`.
|
|
72
|
+
*
|
|
73
|
+
* `deps.accountKey` is an optional pre-loaded account private key (the
|
|
74
|
+
* {@link Client} loads it ONCE and reuses it, so an `encrypt_payload` webhook
|
|
75
|
+
* doesn't re-read the PEM + re-run PBKDF2 ~100k iters per request). When undefined,
|
|
76
|
+
* the key is loaded from config on demand — config-only key handling either way.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseWebhook(rawBody: Buffer | Uint8Array | string, headers: Headers, config: Config, deps: ParseDeps): Change;
|
|
79
|
+
/**
|
|
80
|
+
* Verify + parse a webhook in one call.
|
|
81
|
+
*
|
|
82
|
+
* Throws {@link WebhookError} on a bad/unknown signature; otherwise returns the
|
|
83
|
+
* typed {@link Change}. The typical one-liner inside a webhook route. `deps.accountKey`
|
|
84
|
+
* (optional) is a pre-loaded account private key reused for the `encrypt_payload`
|
|
85
|
+
* envelope (see {@link parseWebhook}).
|
|
86
|
+
*/
|
|
87
|
+
export declare function handleWebhook(rawBody: Buffer | Uint8Array | string, headers: Headers, config: Config, deps: ParseDeps): Change;
|
|
88
|
+
/**
|
|
89
|
+
* Load the account private key from config ONCE (or `null` if not configured).
|
|
90
|
+
*
|
|
91
|
+
* Reused by the {@link Client} so an `encrypt_payload` webhook never re-reads the
|
|
92
|
+
* PEM + re-runs PBKDF2 (~100k iters) per request — the account key is loaded a
|
|
93
|
+
* single time at client construction, exactly like the service key. Returns `null`
|
|
94
|
+
* when no `accountPrivateKey` is configured (the SDK only needs it for
|
|
95
|
+
* `encrypt_payload` webhooks). Throws {@link WebhookError} on a read / passphrase /
|
|
96
|
+
* PEM problem.
|
|
97
|
+
*/
|
|
98
|
+
export declare function loadAccountKey(config: Config): KeyObject | null;
|
|
99
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, XXE-safe XML parser for the platform's wire serialization.
|
|
3
|
+
*
|
|
4
|
+
* The company-data API can serve XML (`Accept: application/xml` / `format: "xml"`).
|
|
5
|
+
* The platform serializer renders:
|
|
6
|
+
*
|
|
7
|
+
* - a `<response>` document root;
|
|
8
|
+
* - a list (int keys) as repeated `<item>` children — so an element whose
|
|
9
|
+
* every child is `<item>` becomes an array;
|
|
10
|
+
* - an associative array as named child tags — an object;
|
|
11
|
+
* - scalars as element text (booleans were written as `"true"`/`"false"`).
|
|
12
|
+
*
|
|
13
|
+
* **XXE-safe by construction.** This is a hand-written recursive-descent parser
|
|
14
|
+
* (NOT a general XML library). It supports ONLY elements, text, comments, the XML
|
|
15
|
+
* declaration, CDATA, and the five built-in entities. It does NOT process a DOCTYPE
|
|
16
|
+
* / DTD, does NOT define or expand custom/general entities, and never resolves
|
|
17
|
+
* external entities or system identifiers — the classic XXE / billion-laughs
|
|
18
|
+
* vectors cannot occur because the machinery for them is simply absent. A DOCTYPE,
|
|
19
|
+
* a processing instruction other than the XML decl, or an unknown `&entity;`
|
|
20
|
+
* reference is rejected — entity expansion and external entity resolution don't
|
|
21
|
+
* exist here at all. HMAC verification is always computed over the raw bytes,
|
|
22
|
+
* never the parsed tree.
|
|
23
|
+
*
|
|
24
|
+
* This is intentionally small — JSON is the default wire format; XML is the opt-in
|
|
25
|
+
* alternative — and it only needs to invert the company-data payloads (dicts of
|
|
26
|
+
* lists of dicts of scalars).
|
|
27
|
+
*/
|
|
28
|
+
export declare class XmlParseError extends Error {
|
|
29
|
+
}
|
|
30
|
+
type XmlValue = string | XmlValue[] | {
|
|
31
|
+
[key: string]: XmlValue;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Parse the platform's XML serialization back into JS data (XXE-safe).
|
|
35
|
+
*
|
|
36
|
+
* Mirrors the platform serializer (see the module doc). Returns the document root
|
|
37
|
+
* element's value (a `<response>` element → an object). Throws {@link XmlParseError}
|
|
38
|
+
* on malformed XML, a DOCTYPE/DTD, a processing instruction, or any non-builtin
|
|
39
|
+
* entity reference.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseXml(text: string): XmlValue;
|
|
42
|
+
export {};
|
package/docs/config.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Config reference
|
|
2
|
+
|
|
3
|
+
`Config` (`import { Config } from '@allus/company-data'`).
|
|
4
|
+
|
|
5
|
+
A single JSON file holds the whole SDK configuration. **Config-only key handling is
|
|
6
|
+
a hard rule:** no SDK method ever takes a key, passphrase, or secret as an argument
|
|
7
|
+
— everything cryptographic (decrypting the service PEM, decrypting field values,
|
|
8
|
+
verifying the webhook HMAC, unwrapping the account-key envelope) is driven entirely
|
|
9
|
+
by this config. Your only key responsibility is putting the right values here.
|
|
10
|
+
|
|
11
|
+
The config-file keys are **snake_case**; the SDK exposes them as **camelCase**
|
|
12
|
+
properties.
|
|
13
|
+
|
|
14
|
+
## Fields
|
|
15
|
+
|
|
16
|
+
| File key | Property | Type | Required | Default | Meaning |
|
|
17
|
+
|----------|----------|------|----------|---------|---------|
|
|
18
|
+
| `api_url` | `apiUrl` | string | yes | — | API base, e.g. `https://api.allme.fyi`. |
|
|
19
|
+
| `client_id` | `clientId` | string | yes | — | The `client_credentials` client id (scoped to one service). |
|
|
20
|
+
| `client_secret` | `clientSecret` | string | yes | — | The client secret. |
|
|
21
|
+
| `service_private_key` | `servicePrivateKey` | string | yes | — | Path to the OpenSSL-encrypted PKCS#8 PEM (downloaded from the portal). |
|
|
22
|
+
| `key_passphrase` | `keyPassphrase` | string | yes | — | Decrypts the service PEM in memory at startup. |
|
|
23
|
+
| `account_private_key` | `accountPrivateKey` | string \| null | no | `null` | Path to the company **account** key PEM — only needed to receive `encrypt_payload` webhooks. |
|
|
24
|
+
| `account_passphrase` | `accountPassphrase` | string \| null | no | `null` | Decrypts the account PEM. |
|
|
25
|
+
| `webhooks` | `webhooks` | object | no | `{}` | Per-webhook HMAC secrets, keyed by webhook id (matched via the `X-Allus-Webhook-Id` header). |
|
|
26
|
+
| `cache_dir` | `cacheDir` | string | no | `"./allus-cache"` | Durable local buffer dir for the changes pump. Must be writable + durable. |
|
|
27
|
+
| `format` | `format` | `"json"` \| `"xml"` | no | `"json"` | Wire format. Invisible in the output. |
|
|
28
|
+
|
|
29
|
+
The PEM is PBES2 (PBKDF2-HMAC-SHA256 + AES-256-CBC, 100k iters); it is decrypted in
|
|
30
|
+
memory at construction (`crypto.createPrivateKey({ key, passphrase })`) and never
|
|
31
|
+
written back to disk in plaintext.
|
|
32
|
+
|
|
33
|
+
## Constructors
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
Config.fromFile(path: string): Config // load JSON; ALLUS_* env vars override file values
|
|
37
|
+
Config.fromEnv(): Config // build entirely from ALLUS_* env vars
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
In practice you build the client directly:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { Client } from '@allus/company-data';
|
|
44
|
+
const client = Client.fromConfig('allus.json'); // == new Client(Config.fromFile('allus.json'))
|
|
45
|
+
const client2 = Client.fromEnv(); // == new Client(Config.fromEnv())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Env overrides
|
|
49
|
+
|
|
50
|
+
Every scalar field can be overridden by its `ALLUS_*` env var (so secrets needn't
|
|
51
|
+
live in the file). An env value, when set, wins over the file value.
|
|
52
|
+
|
|
53
|
+
| Property | Env var |
|
|
54
|
+
|----------|---------|
|
|
55
|
+
| `apiUrl` | `ALLUS_API_URL` |
|
|
56
|
+
| `clientId` | `ALLUS_CLIENT_ID` |
|
|
57
|
+
| `clientSecret` | `ALLUS_CLIENT_SECRET` |
|
|
58
|
+
| `servicePrivateKey` | `ALLUS_SERVICE_PRIVATE_KEY` |
|
|
59
|
+
| `keyPassphrase` | `ALLUS_KEY_PASSPHRASE` |
|
|
60
|
+
| `accountPrivateKey` | `ALLUS_ACCOUNT_PRIVATE_KEY` |
|
|
61
|
+
| `accountPassphrase` | `ALLUS_ACCOUNT_PASSPHRASE` |
|
|
62
|
+
| `cacheDir` | `ALLUS_CACHE_DIR` |
|
|
63
|
+
| `format` | `ALLUS_FORMAT` |
|
|
64
|
+
| flat single-webhook secret | `ALLUS_WEBHOOK_SECRET` |
|
|
65
|
+
|
|
66
|
+
## Webhook secrets
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
"webhooks": { "wh_abc123": "secret_a", "wh_def456": "secret_b" }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Keyed by webhook id; the SDK reads `X-Allus-Webhook-Id` off the incoming request
|
|
73
|
+
and looks up the matching secret. A service with a single webhook can use the flat
|
|
74
|
+
shortcut instead of the map:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
"webhook_secret": "the_one_secret"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
(stored internally under a reserved key `Config.SINGLE_WEBHOOK_KEY` and used as the
|
|
81
|
+
fallback when there is no id-specific match). `ALLUS_WEBHOOK_SECRET` overrides the
|
|
82
|
+
flat shortcut.
|
|
83
|
+
|
|
84
|
+
`config.webhookSecret(webhookId?)` resolves the secret for an id (falling back to
|
|
85
|
+
the single-webhook shortcut). The webhook helpers call this for you — you never
|
|
86
|
+
pass a secret in. **The method takes a webhook *id*, never a secret.**
|
|
87
|
+
|
|
88
|
+
## Validation
|
|
89
|
+
|
|
90
|
+
* A missing required field (`api_url`, `client_id`, `client_secret`, `service_private_key`, `key_passphrase`) throws `ConfigError` listing the missing **config-file keys**.
|
|
91
|
+
* A `format` other than `json`/`xml` throws `ConfigError`.
|
|
92
|
+
* A malformed/missing config file throws `ConfigError`.
|
|
93
|
+
* An unreadable `service_private_key` PEM, or a wrong `key_passphrase`, throws `ConfigError` at `Client` construction (fail fast — a bad key is a config problem, not a runtime decrypt error).
|
package/docs/errors.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Error model
|
|
2
|
+
|
|
3
|
+
Same taxonomy + names across all six SDKs. All importable from
|
|
4
|
+
`@allus/company-data`. Every error extends a common `AllusError` base.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import {
|
|
8
|
+
AllusError, ConfigError, AuthError, ApiError, DecryptError, WebhookError, RateLimitError,
|
|
9
|
+
} from '@allus/company-data';
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
| Error | Thrown when |
|
|
13
|
+
|-------|-------------|
|
|
14
|
+
| `ConfigError` | Missing/invalid config, an unreadable key file, or a wrong passphrase — at construction (fail fast). |
|
|
15
|
+
| `AuthError` | The `client_credentials` token fetch/refresh failed (bad `client_id`/`secret`, revoked client); or a mid-flight 401 survived the one automatic refresh-and-retry. |
|
|
16
|
+
| `ApiError` | Any non-2xx from the API. |
|
|
17
|
+
| `DecryptError` | A ciphertext wrapper is malformed, the key is wrong, or the GCM tag mismatches. |
|
|
18
|
+
| `WebhookError` | Signature verification failed, or a webhook envelope couldn't be unwrapped/parsed. |
|
|
19
|
+
| `RateLimitError` | A 429 from a rate-limited endpoint. Subclass of `ApiError`. |
|
|
20
|
+
|
|
21
|
+
## `AllusError`
|
|
22
|
+
|
|
23
|
+
The base class. `catch (e) { if (e instanceof AllusError) … }` captures the whole
|
|
24
|
+
taxonomy. `instanceof` works correctly across the transpile target (the prototype
|
|
25
|
+
chain is restored in the constructor).
|
|
26
|
+
|
|
27
|
+
## `ApiError`
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
class ApiError extends AllusError {
|
|
31
|
+
status: number; // the HTTP status
|
|
32
|
+
errorKey: string | null; // the platform error_key, when the body provided one
|
|
33
|
+
apiMessage: string | null; // a human-readable message
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`err.message` is formatted as `"HTTP <status> (<errorKey>): <apiMessage>"`. A
|
|
38
|
+
transport failure (no HTTP response — e.g. a connection error) surfaces as
|
|
39
|
+
`new ApiError(0, null, …)`.
|
|
40
|
+
|
|
41
|
+
## `RateLimitError`
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
class RateLimitError extends ApiError { // status is always 429
|
|
45
|
+
retryAfter: number | null; // seconds from the Retry-After header, or null
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The SDK already retries a 429 with backoff before surfacing this:
|
|
50
|
+
|
|
51
|
+
* the transport (`HttpClient`) retries a bounded number of times honoring `Retry-After`;
|
|
52
|
+
* the `connections(...)` generator additionally backs off + retries a page a bounded number of times.
|
|
53
|
+
|
|
54
|
+
For the heavily-limited connections endpoints it surfaces after that backoff so you
|
|
55
|
+
don't accidentally hammer them; on the changes feed it auto-backs-off within reason.
|
|
56
|
+
If you catch it, wait `err.retryAfter` (or a default) before retrying.
|
|
57
|
+
|
|
58
|
+
## Where each surfaces
|
|
59
|
+
|
|
60
|
+
| Layer | Common errors |
|
|
61
|
+
|-------|---------------|
|
|
62
|
+
| `Client.fromConfig` / `fromEnv` | `ConfigError` |
|
|
63
|
+
| Token / any call (auth) | `AuthError` |
|
|
64
|
+
| `connections`, `connection`, `requestFields`, `logs`, pump drains | `ApiError`, `RateLimitError` |
|
|
65
|
+
| Value access / `BinaryHandle.bytes()` / pump delivery | `DecryptError` |
|
|
66
|
+
| `verifyWebhook` / `parseWebhook` / `handleWebhook` | `WebhookError` (`verifyWebhook` returns `false` rather than throwing on a bad signature) |
|
|
67
|
+
|
|
68
|
+
## Example
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import {
|
|
72
|
+
Client, ConfigError, AuthError, ApiError,
|
|
73
|
+
DecryptError, WebhookError, RateLimitError,
|
|
74
|
+
} from '@allus/company-data';
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const client = Client.fromConfig('allus.json');
|
|
78
|
+
for await (const conn of client.connections()) process(conn);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (e instanceof ConfigError) { /* fix the config / key file */ }
|
|
81
|
+
else if (e instanceof AuthError) { /* bad/revoked credentials */ }
|
|
82
|
+
else if (e instanceof RateLimitError) { await sleep((e.retryAfter ?? 60) * 1000); }
|
|
83
|
+
else if (e instanceof DecryptError) { /* wrong service key or corrupt data */ }
|
|
84
|
+
else if (e instanceof ApiError) { log(e.status, e.errorKey, e.apiMessage); }
|
|
85
|
+
else throw e;
|
|
86
|
+
}
|
|
87
|
+
```
|