@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,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;