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