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