@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
@@ -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
+ ```