@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,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client facade.
|
|
3
|
+
*
|
|
4
|
+
* The one object an integrating company touches. Build it from config (the keys
|
|
5
|
+
* live there and nowhere else), then call:
|
|
6
|
+
*
|
|
7
|
+
* client.requestFields() -> Promise<RequestField[]> (slug -> meta, cached)
|
|
8
|
+
* client.connections(limit, offset) -> AsyncIterable<Connection> (auto-paged, lazy)
|
|
9
|
+
* client.connection(id) -> Promise<Connection>
|
|
10
|
+
* client.logs(limit, offset) -> Promise<LogEntry[]>
|
|
11
|
+
* client.processChanges(handler, opts) -> Promise<void> (the crash-safe pump)
|
|
12
|
+
* client.drainBatch(max) -> Promise<Change[]> (raw unbuffered drain — advanced)
|
|
13
|
+
* client.deadLetters() / client.retryDeadLetters(handler)
|
|
14
|
+
*
|
|
15
|
+
* Plus the webhook receiver helpers, exposed as methods that delegate to the
|
|
16
|
+
* `webhooks` module (all config-driven, no key/secret args):
|
|
17
|
+
*
|
|
18
|
+
* client.verifyWebhook(rawBody, headers) -> bool
|
|
19
|
+
* client.parseWebhook(rawBody, headers) -> Change
|
|
20
|
+
* client.handleWebhook(rawBody, headers) -> Change
|
|
21
|
+
*
|
|
22
|
+
* How it is wired (everything else the SDK hides):
|
|
23
|
+
* - **Auth + transport** — an {@link HttpClient} owns the `client_credentials`
|
|
24
|
+
* token, the JSON/XML accept+parse, and the error mapping (incl. 429 backoff).
|
|
25
|
+
* - **Decryption** — the service private key is loaded **once** at construction
|
|
26
|
+
* from the configured encrypted PEM + passphrase into an in-memory key; a
|
|
27
|
+
* `decryptValue` closure over it is handed to every model factory and the pump
|
|
28
|
+
* (config-only key handling — the key never appears in a method signature).
|
|
29
|
+
* - **Slug catalog** — `requestFields()` is fetched once and cached; its slug→type
|
|
30
|
+
* map types every value (so `address` parses to an object, `photo` becomes a
|
|
31
|
+
* lazy binary handle, etc.).
|
|
32
|
+
* - **Binary** — a value's `BinaryHandle.bytes()` GETs the slot file endpoint,
|
|
33
|
+
* unwraps the API's `{"encrypted":true,"value":<wrapper>}` envelope, and runs
|
|
34
|
+
* the same service-key decrypt → the file bytes.
|
|
35
|
+
* - **Changes feed** — `processChanges` delegates to the {@link Pump}, injecting a
|
|
36
|
+
* `fetchChanges` closure (`GET /changes?limit=`, returning the raw ciphertext
|
|
37
|
+
* events) and a `decrypt` closure that builds a typed {@link Change}.
|
|
38
|
+
*/
|
|
39
|
+
import { readFileSync } from 'node:fs';
|
|
40
|
+
import { Config } from './config.js';
|
|
41
|
+
import { decrypt as cryptoDecrypt, loadPrivateKey } from './crypto.js';
|
|
42
|
+
import { ConfigError, DecryptError, RateLimitError } from './errors.js';
|
|
43
|
+
import { HttpClient } from './http.js';
|
|
44
|
+
import { Change, Connection, LogEntry, RequestField } from './models.js';
|
|
45
|
+
import { Pump } from './pump.js';
|
|
46
|
+
import { handleWebhook, loadAccountKey, parseWebhook, verifyWebhook } from './webhooks.js';
|
|
47
|
+
// Endpoint paths (the API base comes from Config; HttpClient joins them).
|
|
48
|
+
const BASE = '/api/company-data';
|
|
49
|
+
const CONNECTIONS = `${BASE}/connections`;
|
|
50
|
+
const CHANGES = `${BASE}/changes`;
|
|
51
|
+
const REQUEST_FIELDS = `${BASE}/request-fields`;
|
|
52
|
+
const LOGS = `${BASE}/logs`;
|
|
53
|
+
// Default page size for the connections iterator. The endpoint is heavily
|
|
54
|
+
// rate-limited, so we keep pages reasonably large to minimize the
|
|
55
|
+
// number of requests for a full sync, while the iterator stays lazy.
|
|
56
|
+
const DEFAULT_CONN_PAGE = 100;
|
|
57
|
+
// Bounded extra backoff for the connections iterator on a surfaced 429. The
|
|
58
|
+
// HttpClient already retries a 429 internally; if it still surfaces a
|
|
59
|
+
// RateLimitError we honor Retry-After once more here (the connections endpoints are
|
|
60
|
+
// expensive snapshots, not a poll target) before re-throwing.
|
|
61
|
+
const CONN_MAX_429_BACKOFFS = 5;
|
|
62
|
+
const CONN_DEFAULT_BACKOFF_S = 5.0;
|
|
63
|
+
const CONN_MAX_BACKOFF_S = 120.0;
|
|
64
|
+
const defaultSleep = (seconds) => new Promise((res) => setTimeout(res, Math.max(0, seconds) * 1000));
|
|
65
|
+
/** The company-data SDK client facade. */
|
|
66
|
+
export class Client {
|
|
67
|
+
constructor(config, opts = {}) {
|
|
68
|
+
// The slug catalog, fetched once on first requestFields() and cached.
|
|
69
|
+
this.cachedRequestFields = null;
|
|
70
|
+
this.typeBySlug = new Map();
|
|
71
|
+
this.requestFieldsInFlight = null;
|
|
72
|
+
this._pump = null;
|
|
73
|
+
// ── decryption wiring (closures over the loaded key — never a method arg) ──
|
|
74
|
+
this.decryptValue = (wrapper) => cryptoDecrypt(wrapper, this.privateKey);
|
|
75
|
+
/**
|
|
76
|
+
* Fetch a slot file endpoint and unwrap its `{"encrypted":true,"value":...}` envelope.
|
|
77
|
+
*
|
|
78
|
+
* Returns the inner `{"_enc":1,...}` wrapper, which the {@link BinaryHandle} then
|
|
79
|
+
* decrypts with the same service key.
|
|
80
|
+
*/
|
|
81
|
+
this.binaryFetch = async (valueUrl) => {
|
|
82
|
+
const body = await this.http.get(valueUrl);
|
|
83
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body) && 'value' in body) {
|
|
84
|
+
return body['value'];
|
|
85
|
+
}
|
|
86
|
+
// Defensive: some shapes might return the wrapper directly.
|
|
87
|
+
return body;
|
|
88
|
+
};
|
|
89
|
+
/** Resolve a request slug to its field type (loads the catalog once). */
|
|
90
|
+
this.typeForSlug = (slug) => {
|
|
91
|
+
return this.typeBySlug.get(slug) ?? null;
|
|
92
|
+
};
|
|
93
|
+
this.config = config;
|
|
94
|
+
this.log = opts.logger ?? {};
|
|
95
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
96
|
+
// Transport (auth + JSON/XML + error taxonomy). Injectable for tests.
|
|
97
|
+
this.http = opts.http ?? new HttpClient(config, opts.httpOptions);
|
|
98
|
+
// Load the service private key ONCE from the configured encrypted PEM +
|
|
99
|
+
// passphrase (config-only key handling). This is the single place
|
|
100
|
+
// the key material is read; a closure over it does every decrypt.
|
|
101
|
+
this.privateKey = loadServiceKey(config);
|
|
102
|
+
// Load the ACCOUNT private key ONCE too (null unless configured). Reused for
|
|
103
|
+
// every encrypt_payload webhook so we don't re-read the PEM + re-run PBKDF2
|
|
104
|
+
// (~100k iters) per request — same one-time-load discipline as the service key.
|
|
105
|
+
this.accountKey = loadAccountKey(config);
|
|
106
|
+
}
|
|
107
|
+
// ── constructors (config-only keys) ────────────────────────────────────────
|
|
108
|
+
/** Build from a JSON config file (env vars override secrets). */
|
|
109
|
+
static fromConfig(path, opts = {}) {
|
|
110
|
+
return new Client(Config.fromFile(path), opts);
|
|
111
|
+
}
|
|
112
|
+
/** Build entirely from `ALLUS_*` env vars. */
|
|
113
|
+
static fromEnv(opts = {}) {
|
|
114
|
+
return new Client(Config.fromEnv(), opts);
|
|
115
|
+
}
|
|
116
|
+
// ── definitions ────────────────────────────────────────────────────────────
|
|
117
|
+
/**
|
|
118
|
+
* The cached request-field DEFINITIONS.
|
|
119
|
+
*
|
|
120
|
+
* Fetched once from `GET /api/company-data/request-fields` and cached for the life
|
|
121
|
+
* of the client (it's the company's static config, and it types every value).
|
|
122
|
+
* Returns YOUR request config — never the person's fields. Concurrent callers
|
|
123
|
+
* share a single in-flight fetch.
|
|
124
|
+
*/
|
|
125
|
+
async requestFields() {
|
|
126
|
+
if (this.cachedRequestFields !== null) {
|
|
127
|
+
return this.cachedRequestFields;
|
|
128
|
+
}
|
|
129
|
+
if (this.requestFieldsInFlight === null) {
|
|
130
|
+
this.requestFieldsInFlight = (async () => {
|
|
131
|
+
const body = await this.http.get(REQUEST_FIELDS);
|
|
132
|
+
const fields = RequestField.listFromApi(body);
|
|
133
|
+
this.cachedRequestFields = fields;
|
|
134
|
+
this.typeBySlug = new Map(fields.filter((f) => f.slug).map((f) => [f.slug, f.type]));
|
|
135
|
+
return fields;
|
|
136
|
+
})();
|
|
137
|
+
try {
|
|
138
|
+
return await this.requestFieldsInFlight;
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
this.requestFieldsInFlight = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return this.requestFieldsInFlight;
|
|
145
|
+
}
|
|
146
|
+
// ── connections (heavily rate-limited — initial sync / reconciliation) ─────
|
|
147
|
+
/**
|
|
148
|
+
* A lazy async generator paging the list endpoint, yielding one Connection at a time.
|
|
149
|
+
*
|
|
150
|
+
* `limit` is the page size; `offset` the starting offset. The generator auto-pages
|
|
151
|
+
* `GET /api/company-data/connections?limit&offset` and yields typed
|
|
152
|
+
* {@link Connection} objects (each `values[slug]` already decrypted / a lazy binary
|
|
153
|
+
* handle) one at a time — bounded memory for a large book. It honors the response's
|
|
154
|
+
* `total` (when present) so it never over-fetches a page past the end, and also
|
|
155
|
+
* stops on a short page as a fallback.
|
|
156
|
+
*
|
|
157
|
+
* The connections endpoints are **heavily rate-limited**: use this
|
|
158
|
+
* for the initial full sync + occasional reconciliation, never as a poll substitute
|
|
159
|
+
* for the changes feed. On a surfaced {@link RateLimitError} the generator backs off
|
|
160
|
+
* per `Retry-After` and retries the page a bounded number of times before
|
|
161
|
+
* re-throwing — so it paces itself within the limit rather than hammering.
|
|
162
|
+
*/
|
|
163
|
+
async *connections(limit = DEFAULT_CONN_PAGE, offset = 0) {
|
|
164
|
+
const page = Math.max(1, Math.trunc(limit));
|
|
165
|
+
let cur = Math.max(0, Math.trunc(offset));
|
|
166
|
+
// Ensure the slug catalog is loaded so values are typed correctly.
|
|
167
|
+
await this.requestFields();
|
|
168
|
+
let total = null;
|
|
169
|
+
for (;;) {
|
|
170
|
+
const body = await this.getConnectionsPage(page, cur);
|
|
171
|
+
total = readTotal(body, total);
|
|
172
|
+
const items = listItems(body);
|
|
173
|
+
if (items.length === 0) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
for (const obj of items) {
|
|
177
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj))
|
|
178
|
+
continue;
|
|
179
|
+
yield Connection.fromApi(obj, {
|
|
180
|
+
typeForSlug: this.typeForSlug,
|
|
181
|
+
decryptValue: this.decryptValue,
|
|
182
|
+
binaryFetch: this.binaryFetch,
|
|
183
|
+
// The list row carries identity (displayName/connectedAt) AND the values
|
|
184
|
+
// map, so the same object is both detail + identity.
|
|
185
|
+
identity: obj,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
cur += items.length;
|
|
189
|
+
// Stop when we've consumed `total` rows (honor the API's total so we don't
|
|
190
|
+
// over-fetch a final empty/short page), or on a short page as a fallback.
|
|
191
|
+
if (total !== null && cur >= total) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (items.length < page) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async getConnectionsPage(page, offset) {
|
|
200
|
+
let attempts = 0;
|
|
201
|
+
for (;;) {
|
|
202
|
+
try {
|
|
203
|
+
return await this.http.get(CONNECTIONS, { limit: page, offset });
|
|
204
|
+
}
|
|
205
|
+
catch (exc) {
|
|
206
|
+
if (!(exc instanceof RateLimitError))
|
|
207
|
+
throw exc;
|
|
208
|
+
attempts += 1;
|
|
209
|
+
if (attempts > CONN_MAX_429_BACKOFFS)
|
|
210
|
+
throw exc;
|
|
211
|
+
const delay = connBackoff(exc.retryAfter, attempts);
|
|
212
|
+
this.log.warn?.(`connections rate-limited (offset=${offset}); backoff ${delay}s (attempt ${attempts})`);
|
|
213
|
+
if (delay)
|
|
214
|
+
await this.sleep(delay);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Fetch a single connection by id → one {@link Connection}.
|
|
220
|
+
*
|
|
221
|
+
* `GET /api/company-data/connections/{id}` returns `{connection_id, user_id,
|
|
222
|
+
* values}` and no displayName/connectedAt; those identity fields simply stay
|
|
223
|
+
* `null` (the list endpoint carries them).
|
|
224
|
+
*/
|
|
225
|
+
async connection(id) {
|
|
226
|
+
await this.requestFields();
|
|
227
|
+
let body = await this.http.get(`${CONNECTIONS}/${id}`);
|
|
228
|
+
if (body !== null &&
|
|
229
|
+
typeof body === 'object' &&
|
|
230
|
+
!Array.isArray(body) &&
|
|
231
|
+
'items' in body &&
|
|
232
|
+
!('values' in body)) {
|
|
233
|
+
// Defensive: a single-item list shape.
|
|
234
|
+
const items = listItems(body);
|
|
235
|
+
body = items.length > 0 ? items[0] : {};
|
|
236
|
+
}
|
|
237
|
+
return Connection.fromApi(body, {
|
|
238
|
+
typeForSlug: this.typeForSlug,
|
|
239
|
+
decryptValue: this.decryptValue,
|
|
240
|
+
binaryFetch: this.binaryFetch,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
// ── logs (moderate rate-limit) ──────────────────────────────────────────────
|
|
244
|
+
/**
|
|
245
|
+
* The service's activity log → `LogEntry[]`.
|
|
246
|
+
*
|
|
247
|
+
* `GET /api/company-data/logs?limit&offset`. Ops events only (email / purge /
|
|
248
|
+
* webhook) — never person field data.
|
|
249
|
+
*/
|
|
250
|
+
async logs(limit = 50, offset = 0) {
|
|
251
|
+
const body = await this.http.get(LOGS, {
|
|
252
|
+
limit: Math.max(1, Math.trunc(limit)),
|
|
253
|
+
offset: Math.max(0, Math.trunc(offset)),
|
|
254
|
+
});
|
|
255
|
+
return LogEntry.listFromApi(body);
|
|
256
|
+
}
|
|
257
|
+
// ── changes feed — the crash-safe pump ──────────────────────────────────────
|
|
258
|
+
/** The crash-safe changes {@link Pump} (built lazily). */
|
|
259
|
+
get pump() {
|
|
260
|
+
if (this._pump === null) {
|
|
261
|
+
this._pump = new Pump(this.config, {
|
|
262
|
+
fetchChanges: (limit) => this.fetchChanges(limit),
|
|
263
|
+
decrypt: (event) => this.decryptChange(event),
|
|
264
|
+
logger: this.log,
|
|
265
|
+
sleep: this.sleep,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return this._pump;
|
|
269
|
+
}
|
|
270
|
+
async fetchChanges(limit) {
|
|
271
|
+
const body = await this.http.get(CHANGES, { limit: Math.trunc(limit) });
|
|
272
|
+
let items;
|
|
273
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
274
|
+
items = body['changes'] ?? [];
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
items = body ?? [];
|
|
278
|
+
}
|
|
279
|
+
if (!Array.isArray(items))
|
|
280
|
+
return [];
|
|
281
|
+
return items.filter((o) => o !== null && typeof o === 'object' && !Array.isArray(o));
|
|
282
|
+
}
|
|
283
|
+
decryptChange(event) {
|
|
284
|
+
return Change.fromApi(event, {
|
|
285
|
+
typeForSlug: this.typeForSlug,
|
|
286
|
+
decryptValue: this.decryptValue,
|
|
287
|
+
binaryFetch: this.binaryFetch,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Drain the changes feed through `handler` one at a time, crash-safely.
|
|
292
|
+
*
|
|
293
|
+
* Delegates to the {@link Pump}: replay the durable buffer, drain ≤500 at a time,
|
|
294
|
+
* persist-before-deliver, per-item ack, retry→dead-letter→continue, until the feed
|
|
295
|
+
* is empty then return (no daemon mode — schedule re-runs yourself). `handler` must
|
|
296
|
+
* be idempotent (at-least-once; dedup on `Change.id`). Options:
|
|
297
|
+
* `batchSize` (≤500), `maxRetries`, `onError` (`deadletter`|`halt`), `backoff`.
|
|
298
|
+
*/
|
|
299
|
+
async processChanges(handler, options = {}) {
|
|
300
|
+
await this.requestFields(); // ensure the catalog is loaded for value typing
|
|
301
|
+
await this.pump.processChanges(handler, options);
|
|
302
|
+
}
|
|
303
|
+
/** Raw, UNBUFFERED drain → `Change[]` (advanced — you own durability). */
|
|
304
|
+
async drainBatch(max = DEFAULT_CONN_PAGE) {
|
|
305
|
+
await this.requestFields();
|
|
306
|
+
return this.pump.drainBatch(max);
|
|
307
|
+
}
|
|
308
|
+
/** The local dead-letter store. */
|
|
309
|
+
deadLetters() {
|
|
310
|
+
return this.pump.deadLetters();
|
|
311
|
+
}
|
|
312
|
+
/** Re-drive dead-lettered events through `handler`. */
|
|
313
|
+
async retryDeadLetters(handler, options = {}) {
|
|
314
|
+
await this.requestFields();
|
|
315
|
+
return this.pump.retryDeadLetters(handler, options);
|
|
316
|
+
}
|
|
317
|
+
// ── webhook receiver helpers (config-driven, no key args) ───────────────────
|
|
318
|
+
/** Verify a webhook's `X-Allus-Signature` HMAC. */
|
|
319
|
+
verifyWebhook(rawBody, headers) {
|
|
320
|
+
return verifyWebhook(rawBody, headers, this.config);
|
|
321
|
+
}
|
|
322
|
+
/** Parse a webhook body → a typed {@link Change}. */
|
|
323
|
+
parseWebhook(rawBody, headers) {
|
|
324
|
+
return parseWebhook(rawBody, headers, this.config, {
|
|
325
|
+
typeForSlug: this.typeForSlug,
|
|
326
|
+
decryptValue: this.decryptValue,
|
|
327
|
+
binaryFetch: this.binaryFetch,
|
|
328
|
+
accountKey: this.accountKey, // cached once; no per-webhook PBKDF2
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/** Verify + parse a webhook in one call → {@link Change}. */
|
|
332
|
+
handleWebhook(rawBody, headers) {
|
|
333
|
+
return handleWebhook(rawBody, headers, this.config, {
|
|
334
|
+
typeForSlug: this.typeForSlug,
|
|
335
|
+
decryptValue: this.decryptValue,
|
|
336
|
+
binaryFetch: this.binaryFetch,
|
|
337
|
+
accountKey: this.accountKey, // cached once; no per-webhook PBKDF2
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// ── module-level helpers ──────────────────────────────────────────────────────
|
|
342
|
+
/** Read the configured encrypted PEM and decrypt it with the passphrase (once). */
|
|
343
|
+
function loadServiceKey(config) {
|
|
344
|
+
let pemBytes;
|
|
345
|
+
try {
|
|
346
|
+
pemBytes = readFileSync(config.servicePrivateKey);
|
|
347
|
+
}
|
|
348
|
+
catch (exc) {
|
|
349
|
+
throw new ConfigError(`could not read servicePrivateKey PEM: ${config.servicePrivateKey}: ${exc.message}`);
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
return loadPrivateKey(pemBytes, config.keyPassphrase);
|
|
353
|
+
}
|
|
354
|
+
catch (exc) {
|
|
355
|
+
if (exc instanceof DecryptError) {
|
|
356
|
+
// A bad passphrase / malformed PEM is a configuration problem (fail fast).
|
|
357
|
+
throw new ConfigError(`could not load service private key: ${exc.message}`);
|
|
358
|
+
}
|
|
359
|
+
throw exc;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/** Pull the `items` array out of a `{total, items}` list response. */
|
|
363
|
+
function listItems(body) {
|
|
364
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
365
|
+
const items = body['items'];
|
|
366
|
+
if (items === undefined || items === null)
|
|
367
|
+
return [];
|
|
368
|
+
return Array.isArray(items) ? items : [];
|
|
369
|
+
}
|
|
370
|
+
if (Array.isArray(body))
|
|
371
|
+
return body;
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
/** Read the `total` count out of a `{total, items}` list response (or keep the prior). */
|
|
375
|
+
function readTotal(body, prior) {
|
|
376
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
377
|
+
const t = body['total'];
|
|
378
|
+
if (t !== undefined && t !== null) {
|
|
379
|
+
const n = Number(t);
|
|
380
|
+
if (Number.isFinite(n))
|
|
381
|
+
return n;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return prior;
|
|
385
|
+
}
|
|
386
|
+
/** Backoff before retrying a rate-limited connections page. */
|
|
387
|
+
function connBackoff(retryAfter, attempt) {
|
|
388
|
+
if (retryAfter !== null && retryAfter >= 0) {
|
|
389
|
+
return Math.min(retryAfter, CONN_MAX_BACKOFF_S);
|
|
390
|
+
}
|
|
391
|
+
return Math.min(CONN_DEFAULT_BACKOFF_S * 2 ** (attempt - 1), CONN_MAX_BACKOFF_S);
|
|
392
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration loading.
|
|
3
|
+
*
|
|
4
|
+
* Config-only key handling is a hard rule: **no SDK method ever takes a key,
|
|
5
|
+
* passphrase, or secret as an argument.** Everything cryptographic — decrypting
|
|
6
|
+
* the service PEM, decrypting field values, verifying the webhook HMAC, unwrapping
|
|
7
|
+
* the account-key envelope — is driven entirely by this config. The developer's
|
|
8
|
+
* only key responsibility is putting the right values here.
|
|
9
|
+
*
|
|
10
|
+
* A single JSON file holds everything; any field may be overridden by an `ALLUS_*`
|
|
11
|
+
* env var, so secrets needn't live in the file.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { ConfigError } from './errors.js';
|
|
15
|
+
/** Mapping from a Config field name to its `ALLUS_*` env-var override. */
|
|
16
|
+
const ENV_MAP = {
|
|
17
|
+
apiUrl: 'ALLUS_API_URL',
|
|
18
|
+
clientId: 'ALLUS_CLIENT_ID',
|
|
19
|
+
clientSecret: 'ALLUS_CLIENT_SECRET',
|
|
20
|
+
servicePrivateKey: 'ALLUS_SERVICE_PRIVATE_KEY',
|
|
21
|
+
keyPassphrase: 'ALLUS_KEY_PASSPHRASE',
|
|
22
|
+
accountPrivateKey: 'ALLUS_ACCOUNT_PRIVATE_KEY',
|
|
23
|
+
accountPassphrase: 'ALLUS_ACCOUNT_PASSPHRASE',
|
|
24
|
+
cacheDir: 'ALLUS_CACHE_DIR',
|
|
25
|
+
format: 'ALLUS_FORMAT',
|
|
26
|
+
};
|
|
27
|
+
// JSON file keys are snake_case (identical across all six SDKs' config files);
|
|
28
|
+
// this maps a Config attribute back to the file key it reads.
|
|
29
|
+
const FILE_KEY = {
|
|
30
|
+
apiUrl: 'api_url',
|
|
31
|
+
clientId: 'client_id',
|
|
32
|
+
clientSecret: 'client_secret',
|
|
33
|
+
servicePrivateKey: 'service_private_key',
|
|
34
|
+
keyPassphrase: 'key_passphrase',
|
|
35
|
+
accountPrivateKey: 'account_private_key',
|
|
36
|
+
accountPassphrase: 'account_passphrase',
|
|
37
|
+
cacheDir: 'cache_dir',
|
|
38
|
+
format: 'format',
|
|
39
|
+
};
|
|
40
|
+
const WEBHOOK_SECRET_ENV = 'ALLUS_WEBHOOK_SECRET';
|
|
41
|
+
const REQUIRED = ['apiUrl', 'clientId', 'clientSecret', 'servicePrivateKey', 'keyPassphrase'];
|
|
42
|
+
const VALID_FORMATS = ['json', 'xml'];
|
|
43
|
+
/** Reserved webhook-map key under which a flat `webhook_secret` is stored. */
|
|
44
|
+
export const SINGLE_WEBHOOK_KEY = '__single__';
|
|
45
|
+
/** The whole SDK configuration. Keys live here and nowhere else. */
|
|
46
|
+
export class Config {
|
|
47
|
+
constructor(init) {
|
|
48
|
+
this.apiUrl = init.apiUrl;
|
|
49
|
+
this.clientId = init.clientId;
|
|
50
|
+
this.clientSecret = init.clientSecret;
|
|
51
|
+
this.servicePrivateKey = init.servicePrivateKey;
|
|
52
|
+
this.keyPassphrase = init.keyPassphrase;
|
|
53
|
+
this.accountPrivateKey = init.accountPrivateKey ?? null;
|
|
54
|
+
this.accountPassphrase = init.accountPassphrase ?? null;
|
|
55
|
+
this.webhooks = init.webhooks ?? {};
|
|
56
|
+
this.webhookBearerToken = init.webhookBearerToken ?? null;
|
|
57
|
+
this.webhookBasic = init.webhookBasic ?? null;
|
|
58
|
+
this.webhookHeader = init.webhookHeader ?? null;
|
|
59
|
+
this.webhookAuthNone = init.webhookAuthNone ?? false;
|
|
60
|
+
this.cacheDir = init.cacheDir ?? './allus-cache';
|
|
61
|
+
this.format = init.format ?? 'json';
|
|
62
|
+
}
|
|
63
|
+
/** Load from a JSON file; env vars override file values. */
|
|
64
|
+
static fromFile(path) {
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(path, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch (exc) {
|
|
70
|
+
const e = exc;
|
|
71
|
+
if (e.code === 'ENOENT') {
|
|
72
|
+
throw new ConfigError(`config file not found: ${path}`);
|
|
73
|
+
}
|
|
74
|
+
throw new ConfigError(`could not read config file: ${path}: ${e.message}`);
|
|
75
|
+
}
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(raw);
|
|
79
|
+
}
|
|
80
|
+
catch (exc) {
|
|
81
|
+
throw new ConfigError(`config file is not valid JSON: ${path}: ${exc.message}`);
|
|
82
|
+
}
|
|
83
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
84
|
+
throw new ConfigError(`config file must be a JSON object: ${path}`);
|
|
85
|
+
}
|
|
86
|
+
return Config.build(data);
|
|
87
|
+
}
|
|
88
|
+
/** Build entirely from `ALLUS_*` env vars. */
|
|
89
|
+
static fromEnv() {
|
|
90
|
+
return Config.build({});
|
|
91
|
+
}
|
|
92
|
+
/** Merge file values with env overrides, validate, and construct. */
|
|
93
|
+
static build(data) {
|
|
94
|
+
const values = {};
|
|
95
|
+
// Scalar fields: env var (if set) overrides the file value.
|
|
96
|
+
for (const attr of Object.keys(ENV_MAP)) {
|
|
97
|
+
const envVal = process.env[ENV_MAP[attr]];
|
|
98
|
+
const fileKey = FILE_KEY[attr];
|
|
99
|
+
if (envVal !== undefined) {
|
|
100
|
+
values[attr] = envVal;
|
|
101
|
+
}
|
|
102
|
+
else if (data[fileKey] !== undefined && data[fileKey] !== null) {
|
|
103
|
+
values[attr] = data[fileKey];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Webhook secrets: the "webhooks" map plus the flat "webhook_secret" shortcut
|
|
107
|
+
// (and its env override), normalized into a single dict.
|
|
108
|
+
const webhooks = {};
|
|
109
|
+
const fileWebhooks = data['webhooks'];
|
|
110
|
+
if (fileWebhooks !== undefined && fileWebhooks !== null) {
|
|
111
|
+
if (typeof fileWebhooks !== 'object' || Array.isArray(fileWebhooks)) {
|
|
112
|
+
throw new ConfigError('"webhooks" must be an object mapping webhook id -> secret');
|
|
113
|
+
}
|
|
114
|
+
for (const [k, v] of Object.entries(fileWebhooks)) {
|
|
115
|
+
webhooks[String(k)] = String(v);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
let flatSecret = process.env[WEBHOOK_SECRET_ENV];
|
|
119
|
+
if (flatSecret === undefined) {
|
|
120
|
+
flatSecret = data['webhook_secret'];
|
|
121
|
+
}
|
|
122
|
+
if (flatSecret !== undefined && flatSecret !== null) {
|
|
123
|
+
webhooks[SINGLE_WEBHOOK_KEY] = String(flatSecret);
|
|
124
|
+
}
|
|
125
|
+
const hasWebhooks = Object.keys(webhooks).length > 0;
|
|
126
|
+
// Alternative webhook auth methods (file-config only; no env overrides).
|
|
127
|
+
// Validate object shapes.
|
|
128
|
+
let webhookBearerToken = null;
|
|
129
|
+
const bearer = data['webhook_bearer_token'];
|
|
130
|
+
if (bearer) {
|
|
131
|
+
webhookBearerToken = String(bearer);
|
|
132
|
+
}
|
|
133
|
+
let webhookBasic = null;
|
|
134
|
+
const basic = data['webhook_basic'];
|
|
135
|
+
if (basic !== undefined && basic !== null) {
|
|
136
|
+
const b = basic;
|
|
137
|
+
if (typeof basic !== 'object' || Array.isArray(basic) || !b['username'] || !b['password']) {
|
|
138
|
+
throw new ConfigError('"webhook_basic" must be an object with non-empty "username" and "password"');
|
|
139
|
+
}
|
|
140
|
+
webhookBasic = { username: String(b['username']), password: String(b['password']) };
|
|
141
|
+
}
|
|
142
|
+
let webhookHeader = null;
|
|
143
|
+
const hdr = data['webhook_header'];
|
|
144
|
+
if (hdr !== undefined && hdr !== null) {
|
|
145
|
+
const h = hdr;
|
|
146
|
+
if (typeof hdr !== 'object' || Array.isArray(hdr) || !h['name'] || !h['value']) {
|
|
147
|
+
throw new ConfigError('"webhook_header" must be an object with non-empty "name" and "value"');
|
|
148
|
+
}
|
|
149
|
+
webhookHeader = { name: String(h['name']), value: String(h['value']) };
|
|
150
|
+
}
|
|
151
|
+
const webhookAuthNone = data['webhook_auth_none'] === true;
|
|
152
|
+
// At most one webhook auth method may be configured.
|
|
153
|
+
const present = [];
|
|
154
|
+
if (hasWebhooks)
|
|
155
|
+
present.push('hmac');
|
|
156
|
+
if (webhookBearerToken)
|
|
157
|
+
present.push('bearer');
|
|
158
|
+
if (webhookBasic)
|
|
159
|
+
present.push('basic');
|
|
160
|
+
if (webhookHeader)
|
|
161
|
+
present.push('header');
|
|
162
|
+
if (webhookAuthNone)
|
|
163
|
+
present.push('none');
|
|
164
|
+
if (present.length > 1) {
|
|
165
|
+
throw new ConfigError('configure at most one webhook auth method (found: ' + present.join(', ') + ')');
|
|
166
|
+
}
|
|
167
|
+
// Required fields (fail fast). Report by the config-file key
|
|
168
|
+
// (snake_case) so the message is actionable for whoever wrote the file.
|
|
169
|
+
const missing = REQUIRED.filter((name) => {
|
|
170
|
+
const v = values[name];
|
|
171
|
+
return v === undefined || v === null || v === '';
|
|
172
|
+
}).map((name) => FILE_KEY[name]);
|
|
173
|
+
if (missing.length > 0) {
|
|
174
|
+
throw new ConfigError(`missing required config field(s): ${missing.join(', ')}`);
|
|
175
|
+
}
|
|
176
|
+
// Validate the wire format if supplied.
|
|
177
|
+
let format = 'json';
|
|
178
|
+
if (values['format'] !== undefined) {
|
|
179
|
+
const fmt = String(values['format']).toLowerCase();
|
|
180
|
+
if (!VALID_FORMATS.includes(fmt)) {
|
|
181
|
+
throw new ConfigError(`invalid "format": '${fmt}' (expected one of ${VALID_FORMATS.join(', ')})`);
|
|
182
|
+
}
|
|
183
|
+
format = fmt;
|
|
184
|
+
}
|
|
185
|
+
return new Config({
|
|
186
|
+
apiUrl: String(values['apiUrl']),
|
|
187
|
+
clientId: String(values['clientId']),
|
|
188
|
+
clientSecret: String(values['clientSecret']),
|
|
189
|
+
servicePrivateKey: String(values['servicePrivateKey']),
|
|
190
|
+
keyPassphrase: String(values['keyPassphrase']),
|
|
191
|
+
accountPrivateKey: values['accountPrivateKey'] !== undefined ? String(values['accountPrivateKey']) : null,
|
|
192
|
+
accountPassphrase: values['accountPassphrase'] !== undefined ? String(values['accountPassphrase']) : null,
|
|
193
|
+
webhooks: hasWebhooks ? webhooks : {},
|
|
194
|
+
webhookBearerToken,
|
|
195
|
+
webhookBasic,
|
|
196
|
+
webhookHeader,
|
|
197
|
+
webhookAuthNone,
|
|
198
|
+
cacheDir: values['cacheDir'] !== undefined ? String(values['cacheDir']) : './allus-cache',
|
|
199
|
+
format,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Resolve the HMAC secret for a webhook id.
|
|
204
|
+
*
|
|
205
|
+
* Falls back to the single-webhook shortcut secret when there is no id or no
|
|
206
|
+
* id-specific match. The webhook helpers read this — application code never
|
|
207
|
+
* passes a secret in. (This method takes a webhook *id*, never a secret.)
|
|
208
|
+
*/
|
|
209
|
+
webhookSecret(webhookId) {
|
|
210
|
+
if (webhookId !== undefined && webhookId !== null && webhookId in this.webhooks) {
|
|
211
|
+
return this.webhooks[webhookId];
|
|
212
|
+
}
|
|
213
|
+
return this.webhooks[SINGLE_WEBHOOK_KEY] ?? null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The single configured webhook auth method, or `null` if none is set.
|
|
217
|
+
*
|
|
218
|
+
* Returns one of `"hmac" | "bearer" | "basic" | "header" | "none"`. Config
|
|
219
|
+
* loading guarantees at most one is configured, so the order here is only a
|
|
220
|
+
* tie-break that never triggers.
|
|
221
|
+
*/
|
|
222
|
+
webhookAuthMethod() {
|
|
223
|
+
if (this.webhookAuthNone)
|
|
224
|
+
return 'none';
|
|
225
|
+
if (this.webhookBearerToken)
|
|
226
|
+
return 'bearer';
|
|
227
|
+
if (this.webhookBasic)
|
|
228
|
+
return 'basic';
|
|
229
|
+
if (this.webhookHeader)
|
|
230
|
+
return 'header';
|
|
231
|
+
if (Object.keys(this.webhooks).length > 0)
|
|
232
|
+
return 'hmac';
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Reserved webhook-map key under which a flat `webhook_secret` is stored. */
|
|
237
|
+
Config.SINGLE_WEBHOOK_KEY = SINGLE_WEBHOOK_KEY;
|