@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,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;
|
package/dist/cjs/http.js
ADDED
|
@@ -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
|
+
}
|