@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,288 @@
1
+ "use strict";
2
+ /**
3
+ * Decryption core — byte-identical across all six SDKs.
4
+ *
5
+ * Every person value arrives as a ciphertext wrapper, encrypted **for the service
6
+ * public key**; the SDK decrypts with the service private key. The algorithm MUST
7
+ * match the platform's Web Crypto encryption exactly:
8
+ *
9
+ * wrapper = {"_enc":1,
10
+ * "k": base64(rsa_oaep_sha256(aesKey, servicePublicKey)),
11
+ * "iv": base64(iv12),
12
+ * "d": base64(aes256gcm_ciphertext_with_tag)}
13
+ *
14
+ * decrypt(wrapper, servicePrivateKey):
15
+ * aesKey = RSA-OAEP(SHA-256, MGF1-SHA256) decrypt wrapper.k // 32 bytes
16
+ * plaintext = AES-256-GCM decrypt wrapper.d with aesKey, iv=wrapper.iv
17
+ * // the 16-byte GCM tag is the LAST 16 bytes of d
18
+ * return utf8(plaintext)
19
+ *
20
+ * The service private key is the OpenSSL-encrypted PKCS#8 PEM downloaded from the
21
+ * portal (PBES2 = PBKDF2-HMAC-SHA256 + AES-256-CBC, ~100k iters). Node's
22
+ * `crypto.createPrivateKey({ key, passphrase })` reads it directly (PBES2 is
23
+ * handled by OpenSSL under the hood).
24
+ *
25
+ * Node specifics (the cross-language gotchas to watch for):
26
+ * - `crypto.privateDecrypt({ key, padding: RSA_PKCS1_OAEP_PADDING,
27
+ * oaepHash: 'sha256' }, k)` — **`oaepHash: 'sha256'` MUST be set explicitly**;
28
+ * Node defaults `oaepHash` to SHA-1, which would mismatch the platform and
29
+ * fail to unwrap the AES key. Setting it to sha256 also pins MGF1 to SHA-256.
30
+ * - `crypto.createDecipheriv('aes-256-gcm', aesKey, iv)` + `setAuthTag(tag)` —
31
+ * the 16-byte tag is the LAST 16 bytes of `d`.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.BinaryHandle = exports.DecryptError = exports.GCM_IV_LEN = exports.GCM_TAG_LEN = void 0;
35
+ exports.loadPrivateKey = loadPrivateKey;
36
+ exports.decrypt = decrypt;
37
+ const node_crypto_1 = require("node:crypto");
38
+ const node_fs_1 = require("node:fs");
39
+ const node_path_1 = require("node:path");
40
+ const errors_js_1 = require("./errors.js");
41
+ exports.GCM_TAG_LEN = 16; // bytes — appended to the AES-GCM ciphertext
42
+ exports.GCM_IV_LEN = 12; // bytes
43
+ // Re-export so `crypto.ts` consumers can pull the error alongside the core.
44
+ var errors_js_2 = require("./errors.js");
45
+ Object.defineProperty(exports, "DecryptError", { enumerable: true, get: function () { return errors_js_2.DecryptError; } });
46
+ /**
47
+ * Load an OpenSSL-encrypted PKCS#8 PEM into an in-memory private key handle.
48
+ *
49
+ * The PEM is PBES2 (PBKDF2-HMAC-SHA256 + AES-256-CBC, ~100k iters). Node's
50
+ * `createPrivateKey` decrypts it with the passphrase (OpenSSL handles the SHA-256
51
+ * PRF). The key is never written back to disk in plaintext.
52
+ *
53
+ * Config-only key handling: this is the single place a passphrase is used, driven
54
+ * by `Config.keyPassphrase` — never passed in by application code.
55
+ */
56
+ function loadPrivateKey(encryptedPem, passphrase) {
57
+ try {
58
+ return (0, node_crypto_1.createPrivateKey)({ key: encryptedPem, passphrase });
59
+ }
60
+ catch (exc) {
61
+ // A wrong passphrase / malformed PEM / unsupported algorithm all land here.
62
+ throw new errors_js_1.DecryptError(`could not load private key PEM: ${exc.message}`);
63
+ }
64
+ }
65
+ function b64decode(value, fieldName) {
66
+ if (typeof value !== 'string') {
67
+ throw new errors_js_1.DecryptError(`wrapper field '${fieldName}' must be a base64 string`);
68
+ }
69
+ // Validate strictly: re-encoding must reproduce the (normalized) input so we
70
+ // reject genuinely malformed base64 like the Python `validate=True` path does.
71
+ const buf = Buffer.from(value, 'base64');
72
+ const normalized = value.replace(/\s+/g, '');
73
+ if (buf.toString('base64').replace(/=+$/, '') !== normalized.replace(/=+$/, '')) {
74
+ throw new errors_js_1.DecryptError(`wrapper field '${fieldName}' is not valid base64`);
75
+ }
76
+ return buf;
77
+ }
78
+ function parseWrapper(wrapper) {
79
+ let obj = wrapper;
80
+ if (typeof wrapper === 'string') {
81
+ try {
82
+ obj = JSON.parse(wrapper);
83
+ }
84
+ catch {
85
+ throw new errors_js_1.DecryptError('wrapper string is not valid JSON');
86
+ }
87
+ }
88
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
89
+ throw new errors_js_1.DecryptError('wrapper must be an object or a JSON object string');
90
+ }
91
+ const rec = obj;
92
+ for (const fieldName of ['k', 'iv', 'd']) {
93
+ if (!(fieldName in rec)) {
94
+ throw new errors_js_1.DecryptError(`wrapper missing required field '${fieldName}'`);
95
+ }
96
+ }
97
+ return rec;
98
+ }
99
+ /**
100
+ * Decrypt a platform `{"_enc":1,k,iv,d}` wrapper → a utf-8 plaintext string.
101
+ *
102
+ * For a *text* value the plaintext is the value itself. For a *binary* value the
103
+ * plaintext is a JSON envelope STRING (photo: `{"full":"data:...","thumb":...}`;
104
+ * document: `{"file":"data:...","original_name":...}`) — NOT raw bytes. The full
105
+ * binary-handle parse (envelope -> data-URI -> bytes) lives on {@link BinaryHandle};
106
+ * here we only ever decrypt to that envelope string.
107
+ *
108
+ * Throws {@link DecryptError} on a malformed wrapper, the wrong key, or a GCM tag
109
+ * mismatch.
110
+ */
111
+ function decrypt(wrapper, privateKey) {
112
+ const w = parseWrapper(wrapper);
113
+ const encKey = b64decode(w.k, 'k');
114
+ const iv = b64decode(w.iv, 'iv');
115
+ const ciphertextWithTag = b64decode(w.d, 'd');
116
+ if (iv.length !== exports.GCM_IV_LEN) {
117
+ throw new errors_js_1.DecryptError(`iv must be ${exports.GCM_IV_LEN} bytes, got ${iv.length}`);
118
+ }
119
+ if (ciphertextWithTag.length < exports.GCM_TAG_LEN) {
120
+ throw new errors_js_1.DecryptError('ciphertext too short to contain a GCM tag');
121
+ }
122
+ // 1) RSA-OAEP(SHA-256, MGF1-SHA256) unwrap the AES key. `oaepHash: 'sha256'`
123
+ // MUST be set explicitly — Node defaults to SHA-1 (and setting the OAEP hash
124
+ // also pins MGF1 to the same digest), matching Web Crypto RSA-OAEP/SHA-256.
125
+ let aesKey;
126
+ try {
127
+ aesKey = (0, node_crypto_1.privateDecrypt)({
128
+ key: privateKey,
129
+ padding: node_crypto_1.constants.RSA_PKCS1_OAEP_PADDING,
130
+ oaepHash: 'sha256',
131
+ }, encKey);
132
+ }
133
+ catch (exc) {
134
+ throw new errors_js_1.DecryptError(`RSA-OAEP unwrap failed (wrong key?): ${exc.message}`);
135
+ }
136
+ if (aesKey.length !== 32) {
137
+ throw new errors_js_1.DecryptError(`unwrapped AES key must be 32 bytes (AES-256), got ${aesKey.length}`);
138
+ }
139
+ // 2) AES-256-GCM decrypt. The 16-byte tag is the LAST 16 bytes of `d`.
140
+ const tag = ciphertextWithTag.subarray(ciphertextWithTag.length - exports.GCM_TAG_LEN);
141
+ const ciphertext = ciphertextWithTag.subarray(0, ciphertextWithTag.length - exports.GCM_TAG_LEN);
142
+ let plaintext;
143
+ try {
144
+ const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', aesKey, iv);
145
+ decipher.setAuthTag(tag);
146
+ plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
147
+ }
148
+ catch {
149
+ throw new errors_js_1.DecryptError('AES-GCM tag mismatch (wrong key or corrupt data)');
150
+ }
151
+ // utf-8 with strict-ish handling: Node's 'utf8' decode replaces invalid bytes,
152
+ // so re-encode and compare to catch a non-UTF-8 plaintext (parity with Python's
153
+ // strict decode → DecryptError).
154
+ const text = plaintext.toString('utf8');
155
+ if (!Buffer.from(text, 'utf8').equals(plaintext)) {
156
+ throw new errors_js_1.DecryptError('decrypted plaintext is not valid UTF-8');
157
+ }
158
+ return text;
159
+ }
160
+ const DATA_URI_KEYS = ['full', 'file'];
161
+ /**
162
+ * Lazy handle for a binary (photo/document) value.
163
+ *
164
+ * A binary answer is stored server-side as a file, exposed in the hardened API as
165
+ * a slot-keyed `value_url` (never the source field). On `.bytes()` / `.save()` the
166
+ * handle GETs that URL, receives the `{"_enc":1,...}` wrapper, runs the same
167
+ * decrypt as text → a JSON envelope STRING (photo: `{"full":"data:...","thumb":...}`;
168
+ * document: `{"file":"data:...",...}`) — NOT raw bytes — then parses the envelope
169
+ * and base64-decodes the primary data-URI payload (`full` for photos, `file` for
170
+ * documents) into the file bytes.
171
+ *
172
+ * The fetch + decrypt are supplied by the client as plain callables (config-only
173
+ * key handling — no key is ever passed to this handle):
174
+ * - `valueUrl` + `fetch` — `fetch(valueUrl)` returns the encrypted wrapper (the
175
+ * client passes a callback that GETs the slot file endpoint and unwraps the
176
+ * `{"encrypted": true, "value": <wrapper>}` envelope to the inner wrapper).
177
+ * - `decrypt` — `decrypt(wrapper)` returns the decrypted envelope string (a
178
+ * closure over the loaded service private key).
179
+ *
180
+ * For the shared crypto test vector the decrypted envelope is already in hand, so
181
+ * a handle can also be built directly from `envelopeJson` (no fetch).
182
+ */
183
+ class BinaryHandle {
184
+ constructor(opts = {}) {
185
+ this.envelopeJson = opts.envelopeJson ?? null;
186
+ this._valueUrl = opts.valueUrl ?? null;
187
+ this.fetch = opts.fetch ?? null;
188
+ this.decryptWrapper = opts.decrypt ?? null;
189
+ }
190
+ /** The slot-keyed file URL this handle fetches from (opaque to callers). */
191
+ get valueUrl() {
192
+ return this._valueUrl;
193
+ }
194
+ async resolveEnvelope() {
195
+ if (this.envelopeJson !== null) {
196
+ return this.envelopeJson;
197
+ }
198
+ if (this.fetch === null || this.decryptWrapper === null || this._valueUrl === null) {
199
+ throw new errors_js_1.DecryptError('BinaryHandle has no envelope and no fetch/decrypt wiring ' +
200
+ '(build it with envelopeJson, or valueUrl + fetch + decrypt)');
201
+ }
202
+ const wrapper = await this.fetch(this._valueUrl);
203
+ const envelopeJson = this.decryptWrapper(wrapper);
204
+ // Cache so repeated .bytes()/.save() don't re-fetch.
205
+ this.envelopeJson = envelopeJson;
206
+ return envelopeJson;
207
+ }
208
+ /**
209
+ * Turn a decrypted binary envelope STRING into the primary file bytes.
210
+ *
211
+ * Photo envelope -> the `full` data-URI payload; document envelope -> the `file`
212
+ * data-URI payload. Throws {@link DecryptError} on a malformed envelope.
213
+ */
214
+ static parseEnvelopeBytes(envelopeJson) {
215
+ let envelope;
216
+ try {
217
+ envelope = JSON.parse(envelopeJson);
218
+ }
219
+ catch {
220
+ throw new errors_js_1.DecryptError('binary envelope is not valid JSON');
221
+ }
222
+ if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) {
223
+ throw new errors_js_1.DecryptError('binary envelope must be a JSON object');
224
+ }
225
+ const rec = envelope;
226
+ let dataUri = null;
227
+ for (const key of DATA_URI_KEYS) {
228
+ if (typeof rec[key] === 'string') {
229
+ dataUri = rec[key];
230
+ break;
231
+ }
232
+ }
233
+ if (dataUri === null) {
234
+ throw new errors_js_1.DecryptError("binary envelope has no 'full'/'file' data-URI payload");
235
+ }
236
+ // data:<mime>;base64,<payload>
237
+ const marker = 'base64,';
238
+ const idx = dataUri.indexOf(marker);
239
+ if (idx === -1) {
240
+ throw new errors_js_1.DecryptError('binary data URI is not base64-encoded');
241
+ }
242
+ const payload = dataUri.slice(idx + marker.length);
243
+ const buf = Buffer.from(payload, 'base64');
244
+ if (buf.length === 0 && payload.length !== 0) {
245
+ throw new errors_js_1.DecryptError('binary data-URI payload is not valid base64');
246
+ }
247
+ return buf;
248
+ }
249
+ /** Fetch (if needed), decrypt, and return the decoded primary file bytes. */
250
+ async bytes() {
251
+ return BinaryHandle.parseEnvelopeBytes(await this.resolveEnvelope());
252
+ }
253
+ /**
254
+ * Write the decoded file bytes to `path`; returns the number of bytes written.
255
+ *
256
+ * Crash-safe (matching the buffer's atomic-write discipline): the
257
+ * bytes are written to a temp file in the same directory, fsync'd, and atomically
258
+ * renamed into place — so a crash mid-write never leaves a truncated output file
259
+ * (the destination is either the old file or the complete new one).
260
+ */
261
+ async save(path) {
262
+ const data = await this.bytes();
263
+ const directory = (0, node_path_1.dirname)((0, node_path_1.resolve)(path));
264
+ const tmp = (0, node_path_1.join)(directory, `.tmp_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2)}.part`);
265
+ try {
266
+ (0, node_fs_1.writeFileSync)(tmp, data);
267
+ const fd = (0, node_fs_1.openSync)(tmp, 'r');
268
+ try {
269
+ (0, node_fs_1.fsyncSync)(fd);
270
+ }
271
+ finally {
272
+ (0, node_fs_1.closeSync)(fd);
273
+ }
274
+ (0, node_fs_1.renameSync)(tmp, path); // atomic rename over any existing file
275
+ }
276
+ catch (exc) {
277
+ try {
278
+ (0, node_fs_1.unlinkSync)(tmp);
279
+ }
280
+ catch {
281
+ // ignore — the temp file may not have been created
282
+ }
283
+ throw exc;
284
+ }
285
+ return data.length;
286
+ }
287
+ }
288
+ exports.BinaryHandle = BinaryHandle;
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ /**
3
+ * Error taxonomy — the same names across all six SDKs.
4
+ *
5
+ * | Error | When |
6
+ * |--------------------------------|---------------------------------------------------|
7
+ * | ConfigError | Missing/invalid config or key file at construction (fail fast). |
8
+ * | AuthError | Token fetch/refresh failed (bad client_id/secret, revoked client). |
9
+ * | ApiError(status, errorKey,…) | Any non-2xx from the API; carries the HTTP status + the platform error_key + message. |
10
+ * | DecryptError | Wrapper malformed, wrong key, or GCM tag mismatch. |
11
+ * | WebhookError | Signature verification failed or an envelope couldn't be unwrapped. |
12
+ * | RateLimitError(retryAfter) | A 429 from a rate-limited endpoint (subclass of ApiError); carries Retry-After. |
13
+ *
14
+ * All errors extend a common {@link AllusError} base so a single `catch (e) { if (e
15
+ * instanceof AllusError) … }` captures the whole taxonomy. `DecryptError` is raised
16
+ * by the decryption core and re-exported here so the full taxonomy lives in one
17
+ * place.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.DecryptError = exports.RateLimitError = exports.WebhookError = exports.ApiError = exports.AuthError = exports.ConfigError = exports.AllusError = void 0;
21
+ /** Base class for every SDK error. */
22
+ class AllusError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = new.target.name;
26
+ // Restore the prototype chain for `instanceof` across the ES5 transpile target.
27
+ Object.setPrototypeOf(this, new.target.prototype);
28
+ }
29
+ }
30
+ exports.AllusError = AllusError;
31
+ /**
32
+ * Missing or invalid configuration (or key file) at construction (fail fast).
33
+ *
34
+ * Canonical home for the error; the config + client layers throw it for a bad
35
+ * config file, a missing required field, an unreadable PEM, or a wrong passphrase.
36
+ */
37
+ class ConfigError extends AllusError {
38
+ }
39
+ exports.ConfigError = ConfigError;
40
+ /**
41
+ * The `client_credentials` token fetch or refresh failed.
42
+ *
43
+ * Thrown when `/oauth2/token` rejects the credentials, or when a 401 mid-flight
44
+ * survives the one automatic refresh-and-retry.
45
+ */
46
+ class AuthError extends AllusError {
47
+ }
48
+ exports.AuthError = AuthError;
49
+ /**
50
+ * Any non-2xx from the API.
51
+ *
52
+ * Carries the HTTP `status`, the platform `errorKey` (when the body provided one),
53
+ * and a human-readable `message`. A transport failure (no HTTP response — e.g. a
54
+ * connection error) surfaces as `new ApiError(0, null, …)`.
55
+ */
56
+ class ApiError extends AllusError {
57
+ constructor(status, errorKey = null, message = null) {
58
+ const parts = [`HTTP ${status}`];
59
+ if (errorKey)
60
+ parts.push(`(${errorKey})`);
61
+ if (message)
62
+ parts.push(`: ${message}`);
63
+ super(parts.join(' '));
64
+ this.status = status;
65
+ this.errorKey = errorKey;
66
+ this.apiMessage = message;
67
+ }
68
+ }
69
+ exports.ApiError = ApiError;
70
+ /** Signature verification failed, or a webhook envelope couldn't be unwrapped. */
71
+ class WebhookError extends AllusError {
72
+ }
73
+ exports.WebhookError = WebhookError;
74
+ /**
75
+ * A 429 from a rate-limited endpoint.
76
+ *
77
+ * Subclass of {@link ApiError} with a fixed status of 429; carries the
78
+ * `retryAfter` value parsed from the `Retry-After` response header (seconds, or
79
+ * `null` when absent).
80
+ */
81
+ class RateLimitError extends ApiError {
82
+ constructor(retryAfter = null, errorKey = null, message = null) {
83
+ super(429, errorKey, message);
84
+ this.retryAfter = retryAfter;
85
+ }
86
+ }
87
+ exports.RateLimitError = RateLimitError;
88
+ /**
89
+ * Wrapper malformed, wrong key, or GCM tag mismatch.
90
+ *
91
+ * Defined here (rather than in `crypto.ts`) so the whole taxonomy is importable
92
+ * from one module; the decryption core imports + throws it.
93
+ */
94
+ class DecryptError extends AllusError {
95
+ }
96
+ exports.DecryptError = DecryptError;
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+ /**
3
+ * OAuth token + HTTP layer.
4
+ *
5
+ * The {@link HttpClient} is the thin transport every higher layer goes through. It
6
+ * owns:
7
+ *
8
+ * - **Auth** — `client_credentials` only. On the first call (or when the cached
9
+ * token is near expiry) it POSTs `client_id`/`client_secret` to
10
+ * `{api_url}/oauth2/token` and caches the bearer token + its expiry. Refresh is
11
+ * automatic and transparent; a 401 mid-flight triggers exactly one
12
+ * refresh-and-retry, then surfaces as {@link AuthError}.
13
+ * - **Format** — sets `Accept` per `config.format` (`application/json` or
14
+ * `application/xml`) and parses the body accordingly. The XML parser is the
15
+ * XXE-safe `parseXml` (mirrors the platform serializer).
16
+ * - **Errors** — maps non-2xx to the error taxonomy: a 401 → refresh+retry then
17
+ * {@link AuthError}; a 429 → read `Retry-After` and back off + retry a bounded
18
+ * number of times, then {@link RateLimitError}; any other non-2xx →
19
+ * {@link ApiError} carrying the body's `error_key` when present.
20
+ *
21
+ * Config-only key handling: the client id/secret come from the {@link Config} —
22
+ * never a method argument.
23
+ *
24
+ * The transport is injectable (`HttpTransport`) so the whole client is testable
25
+ * without the network; the default uses Node's global `fetch`.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.HttpClient = exports.FetchTransport = void 0;
29
+ const errors_js_1 = require("./errors.js");
30
+ const xml_js_1 = require("./xml.js");
31
+ // Refresh the token a little before it actually expires so an in-flight call never
32
+ // races the expiry boundary.
33
+ const TOKEN_EXPIRY_SKEW_S = 30.0;
34
+ // 429 backoff policy: bounded retries with a Retry-After-driven (or default) sleep
35
+ // between attempts. Connections endpoints are heavily limited, so after the bounded
36
+ // retries we surface RateLimitError rather than hammering.
37
+ const DEFAULT_MAX_RETRIES_429 = 3;
38
+ const DEFAULT_BACKOFF_S = 1.0;
39
+ const MAX_BACKOFF_S = 60.0;
40
+ const defaultSleep = (seconds) => new Promise((res) => setTimeout(res, Math.max(0, seconds) * 1000));
41
+ const defaultClock = () => Date.now() / 1000;
42
+ /** Default transport over Node's global `fetch`. */
43
+ class FetchTransport {
44
+ async post(url, form, headers) {
45
+ const body = new URLSearchParams(form).toString();
46
+ const resp = await fetch(url, {
47
+ method: 'POST',
48
+ headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
49
+ body,
50
+ });
51
+ return resp;
52
+ }
53
+ async get(url, params, headers) {
54
+ let full = url;
55
+ if (params && Object.keys(params).length > 0) {
56
+ const qs = new URLSearchParams();
57
+ for (const [k, v] of Object.entries(params))
58
+ qs.set(k, String(v));
59
+ full += (url.includes('?') ? '&' : '?') + qs.toString();
60
+ }
61
+ const resp = await fetch(full, { method: 'GET', headers });
62
+ return resp;
63
+ }
64
+ }
65
+ exports.FetchTransport = FetchTransport;
66
+ /** Authenticated JSON/XML transport for the company-data API. */
67
+ class HttpClient {
68
+ constructor(config, opts = {}) {
69
+ this.token = null;
70
+ this.tokenExpiry = 0; // clock deadline (seconds)
71
+ this.config = config;
72
+ this.transport = opts.transport ?? new FetchTransport();
73
+ this.sleep = opts.sleep ?? defaultSleep;
74
+ this.clock = opts.clock ?? defaultClock;
75
+ this.maxRetries429 = opts.maxRetries429 ?? DEFAULT_MAX_RETRIES_429;
76
+ this.apiUrl = config.apiUrl.replace(/\/+$/, '');
77
+ }
78
+ // ── auth ────────────────────────────────────────────────────────────────
79
+ tokenValid() {
80
+ return this.token !== null && this.clock() < this.tokenExpiry;
81
+ }
82
+ async fetchToken() {
83
+ const url = `${this.apiUrl}/oauth2/token`;
84
+ let resp;
85
+ try {
86
+ resp = await this.transport.post(url, {
87
+ grant_type: 'client_credentials',
88
+ client_id: this.config.clientId,
89
+ client_secret: this.config.clientSecret,
90
+ }, { Accept: 'application/json' });
91
+ }
92
+ catch (exc) {
93
+ throw new errors_js_1.AuthError(`token request failed: ${exc.message}`);
94
+ }
95
+ const status = resp.status;
96
+ if (status < 200 || status >= 300) {
97
+ const { errorKey, message } = await extractError(resp);
98
+ throw new errors_js_1.AuthError(`token request rejected (HTTP ${status})` +
99
+ (errorKey ? ` [${errorKey}]` : '') +
100
+ (message ? `: ${message}` : ''));
101
+ }
102
+ let body;
103
+ try {
104
+ body = JSON.parse(await resp.text());
105
+ }
106
+ catch {
107
+ throw new errors_js_1.AuthError('token response was not valid JSON');
108
+ }
109
+ const accessToken = body !== null && typeof body === 'object' ? body['access_token'] : null;
110
+ if (!accessToken) {
111
+ throw new errors_js_1.AuthError('token response missing access_token');
112
+ }
113
+ let expiresIn = 3600;
114
+ const rawExpires = body['expires_in'];
115
+ if (rawExpires !== undefined) {
116
+ const n = Number(rawExpires);
117
+ if (Number.isFinite(n))
118
+ expiresIn = n;
119
+ }
120
+ this.token = String(accessToken);
121
+ this.tokenExpiry = this.clock() + Math.max(0, expiresIn - TOKEN_EXPIRY_SKEW_S);
122
+ return this.token;
123
+ }
124
+ async bearer(forceRefresh = false) {
125
+ if (forceRefresh || !this.tokenValid()) {
126
+ return this.fetchToken();
127
+ }
128
+ return this.token;
129
+ }
130
+ // ── requests ──────────────────────────────────────────────────────────
131
+ /**
132
+ * GET `path` (e.g. `/api/company-data/connections`) → parsed body.
133
+ *
134
+ * Adds the bearer token + an `Accept` header matching `config.format`, parses
135
+ * JSON or XML, and maps non-2xx responses to the error taxonomy: 401 → one
136
+ * refresh-and-retry then {@link AuthError}; 429 → bounded Retry-After backoff
137
+ * then {@link RateLimitError}; other non-2xx → {@link ApiError} (carrying the
138
+ * body's `error_key` when present).
139
+ */
140
+ async get(path, params) {
141
+ const url = this.url(path);
142
+ const wantsXml = this.config.format === 'xml';
143
+ const accept = wantsXml ? 'application/xml' : 'application/json';
144
+ let retries429 = 0;
145
+ let refreshed401 = false;
146
+ for (;;) {
147
+ const token = await this.bearer(false);
148
+ let resp;
149
+ try {
150
+ resp = await this.transport.get(url, params, {
151
+ Authorization: `Bearer ${token}`,
152
+ Accept: accept,
153
+ });
154
+ }
155
+ catch (exc) {
156
+ throw new errors_js_1.ApiError(0, null, `request to ${path} failed: ${exc.message}`);
157
+ }
158
+ const status = resp.status;
159
+ if (status >= 200 && status < 300) {
160
+ return this.parseBody(resp, wantsXml);
161
+ }
162
+ if (status === 401) {
163
+ // One refresh-and-retry, then give up as AuthError.
164
+ if (!refreshed401) {
165
+ refreshed401 = true;
166
+ await this.bearer(true);
167
+ continue;
168
+ }
169
+ const { errorKey, message } = await extractError(resp);
170
+ throw new errors_js_1.AuthError('unauthorized after token refresh' +
171
+ (errorKey ? ` [${errorKey}]` : '') +
172
+ (message ? `: ${message}` : ''));
173
+ }
174
+ if (status === 429) {
175
+ const retryAfter = parseRetryAfter(resp);
176
+ if (retries429 < this.maxRetries429) {
177
+ retries429 += 1;
178
+ await this.sleep(backoffDelay(retryAfter, retries429));
179
+ continue;
180
+ }
181
+ const { errorKey, message } = await extractError(resp);
182
+ throw new errors_js_1.RateLimitError(retryAfter, errorKey, message);
183
+ }
184
+ // Any other non-2xx → ApiError with the body's error_key.
185
+ const { errorKey, message } = await extractError(resp);
186
+ throw new errors_js_1.ApiError(status, errorKey, message);
187
+ }
188
+ }
189
+ url(path) {
190
+ if (path.startsWith('http://') || path.startsWith('https://')) {
191
+ return path;
192
+ }
193
+ return this.apiUrl + (path.startsWith('/') ? '' : '/') + path;
194
+ }
195
+ async parseBody(resp, wantsXml) {
196
+ const text = await resp.text();
197
+ if (text === null || text.trim() === '') {
198
+ return {};
199
+ }
200
+ if (wantsXml) {
201
+ try {
202
+ return (0, xml_js_1.parseXml)(text);
203
+ }
204
+ catch (exc) {
205
+ throw new errors_js_1.ApiError(resp.status, null, `response was not valid XML: ${exc.message}`);
206
+ }
207
+ }
208
+ try {
209
+ return JSON.parse(text);
210
+ }
211
+ catch (exc) {
212
+ throw new errors_js_1.ApiError(resp.status, null, `response was not valid JSON: ${exc.message}`);
213
+ }
214
+ }
215
+ }
216
+ exports.HttpClient = HttpClient;
217
+ // ── module-level helpers ─────────────────────────────────────────────────────
218
+ /** Pull `error_key` + a message out of a non-2xx body (JSON or XML). */
219
+ async function extractError(resp) {
220
+ let text;
221
+ try {
222
+ text = await resp.text();
223
+ }
224
+ catch {
225
+ return { errorKey: null, message: null };
226
+ }
227
+ let body = null;
228
+ const trimmed = text.trim();
229
+ if (trimmed.startsWith('<')) {
230
+ try {
231
+ body = (0, xml_js_1.parseXml)(trimmed);
232
+ }
233
+ catch {
234
+ return { errorKey: null, message: trimmed || null };
235
+ }
236
+ }
237
+ else {
238
+ try {
239
+ body = JSON.parse(trimmed);
240
+ }
241
+ catch {
242
+ return { errorKey: null, message: trimmed || null };
243
+ }
244
+ }
245
+ if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
246
+ const rec = body;
247
+ const errorKey = rec['error_key'];
248
+ const message = rec['error'] ?? rec['message'];
249
+ return {
250
+ errorKey: errorKey != null ? String(errorKey) : null,
251
+ message: message != null ? String(message) : null,
252
+ };
253
+ }
254
+ return { errorKey: null, message: null };
255
+ }
256
+ /** Parse the `Retry-After` header (delta-seconds form) → number of seconds or null. */
257
+ function parseRetryAfter(resp) {
258
+ const raw = resp.headers.get('Retry-After');
259
+ if (raw === null)
260
+ return null;
261
+ const n = Number(raw.trim());
262
+ // The platform sends delta-seconds; an HTTP-date form falls back to null
263
+ // (default backoff). NaN guards the date case.
264
+ return Number.isFinite(n) ? n : null;
265
+ }
266
+ /** Sleep duration before the next 429 retry: honor Retry-After, else exponential backoff. */
267
+ function backoffDelay(retryAfter, attempt) {
268
+ if (retryAfter !== null && retryAfter >= 0) {
269
+ return Math.min(retryAfter, MAX_BACKOFF_S);
270
+ }
271
+ return Math.min(DEFAULT_BACKOFF_S * 2 ** (attempt - 1), MAX_BACKOFF_S);
272
+ }