@edge-markets/connect-node 1.6.1 → 1.8.0
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/README.md +319 -11
- package/dist/index.d.mts +442 -4
- package/dist/index.d.ts +442 -4
- package/dist/index.js +1509 -62
- package/dist/index.mjs +1473 -34
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -30,34 +30,191 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
EDGE_WEBHOOK_EVENT_TYPES: () => import_connect12.EDGE_WEBHOOK_EVENT_TYPES,
|
|
34
|
+
EdgeApiError: () => import_connect11.EdgeApiError,
|
|
35
|
+
EdgeAuthenticationError: () => import_connect11.EdgeAuthenticationError,
|
|
35
36
|
EdgeConnectServer: () => EdgeConnectServer,
|
|
36
|
-
EdgeConsentRequiredError: () =>
|
|
37
|
-
EdgeError: () =>
|
|
38
|
-
EdgeIdentityVerificationError: () =>
|
|
39
|
-
EdgeInsufficientScopeError: () =>
|
|
40
|
-
EdgeNetworkError: () =>
|
|
41
|
-
EdgeNotFoundError: () =>
|
|
42
|
-
EdgeTokenExchangeError: () =>
|
|
37
|
+
EdgeConsentRequiredError: () => import_connect11.EdgeConsentRequiredError,
|
|
38
|
+
EdgeError: () => import_connect11.EdgeError,
|
|
39
|
+
EdgeIdentityVerificationError: () => import_connect11.EdgeIdentityVerificationError,
|
|
40
|
+
EdgeInsufficientScopeError: () => import_connect11.EdgeInsufficientScopeError,
|
|
41
|
+
EdgeNetworkError: () => import_connect11.EdgeNetworkError,
|
|
42
|
+
EdgeNotFoundError: () => import_connect11.EdgeNotFoundError,
|
|
43
|
+
EdgeTokenExchangeError: () => import_connect11.EdgeTokenExchangeError,
|
|
44
|
+
EdgeTokenVault: () => EdgeTokenVault,
|
|
43
45
|
EdgeUserClient: () => EdgeUserClient,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
EdgeUserSession: () => EdgeUserSession,
|
|
47
|
+
EdgeValidationError: () => import_connect11.EdgeValidationError,
|
|
48
|
+
EdgeWebhookReconciler: () => EdgeWebhookReconciler,
|
|
49
|
+
TRANSFER_CATEGORIES: () => import_connect12.TRANSFER_CATEGORIES,
|
|
50
|
+
assertSameTransferIntent: () => assertSameTransferIntent,
|
|
51
|
+
assertTransferEventMatchesIntent: () => assertTransferEventMatchesIntent,
|
|
52
|
+
buildVerifyIdentityPayload: () => buildVerifyIdentityPayload,
|
|
53
|
+
createEdgeConnectServerFromEnv: () => createEdgeConnectServerFromEnv,
|
|
54
|
+
createEdgeTokenVault: () => createEdgeTokenVault,
|
|
55
|
+
createEdgeUserSession: () => createEdgeUserSession,
|
|
56
|
+
createTransferIdempotencyKey: () => createTransferIdempotencyKey,
|
|
57
|
+
createWebhookReconciler: () => createWebhookReconciler,
|
|
58
|
+
evaluateIdentityResult: () => evaluateIdentityResult,
|
|
59
|
+
extractWebhookSignatureTimestamp: () => extractWebhookSignatureTimestamp,
|
|
60
|
+
getEnvironmentConfig: () => import_connect12.getEnvironmentConfig,
|
|
61
|
+
isApiError: () => import_connect11.isApiError,
|
|
62
|
+
isAuthenticationError: () => import_connect11.isAuthenticationError,
|
|
63
|
+
isConsentRequiredError: () => import_connect11.isConsentRequiredError,
|
|
64
|
+
isEdgeError: () => import_connect11.isEdgeError,
|
|
65
|
+
isEdgeTokenVaultEnvelope: () => isEdgeTokenVaultEnvelope,
|
|
66
|
+
isEdgeWebhookEvent: () => isEdgeWebhookEvent,
|
|
67
|
+
isIdentityVerificationError: () => import_connect11.isIdentityVerificationError,
|
|
68
|
+
isNetworkError: () => import_connect11.isNetworkError,
|
|
69
|
+
isProductionEnvironment: () => import_connect12.isProductionEnvironment,
|
|
70
|
+
isTransferWebhookEvent: () => isTransferWebhookEvent,
|
|
71
|
+
normalizeMoneyAmount: () => normalizeMoneyAmount,
|
|
72
|
+
parseAndVerifyWebhook: () => parseAndVerifyWebhook,
|
|
73
|
+
startTransferVerification: () => startTransferVerification,
|
|
74
|
+
toEdgeHttpError: () => toEdgeHttpError,
|
|
75
|
+
toEdgeProblemJson: () => toEdgeProblemJson,
|
|
76
|
+
validateEdgeWebhookEvent: () => validateEdgeWebhookEvent,
|
|
77
|
+
validateTransferAmount: () => validateTransferAmount,
|
|
78
|
+
verifyWebhookSignature: () => verifyWebhookSignature
|
|
53
79
|
});
|
|
54
80
|
module.exports = __toCommonJS(index_exports);
|
|
55
81
|
|
|
56
82
|
// src/edge-connect-server.ts
|
|
57
|
-
var
|
|
83
|
+
var import_connect4 = require("@edge-markets/connect");
|
|
58
84
|
|
|
59
|
-
// src/
|
|
85
|
+
// src/transfer-helpers.ts
|
|
60
86
|
var import_connect = require("@edge-markets/connect");
|
|
87
|
+
var import_node_crypto = __toESM(require("crypto"));
|
|
88
|
+
var DEFAULT_MIN_AMOUNT = "1.00";
|
|
89
|
+
var DEFAULT_MAX_AMOUNT = "10000.00";
|
|
90
|
+
function normalizeMoneyAmount(value) {
|
|
91
|
+
const cents = parseMoneyToCents(value, "amount");
|
|
92
|
+
const dollars = cents / 100n;
|
|
93
|
+
const remainder = cents % 100n;
|
|
94
|
+
return `${dollars.toString()}.${remainder.toString().padStart(2, "0")}`;
|
|
95
|
+
}
|
|
96
|
+
function validateTransferAmount(value, limits = {}) {
|
|
97
|
+
const amount = parseMoneyToCents(value, "amount");
|
|
98
|
+
const min = parseMoneyToCents(limits.min ?? DEFAULT_MIN_AMOUNT, "min");
|
|
99
|
+
const max = parseMoneyToCents(limits.max ?? DEFAULT_MAX_AMOUNT, "max");
|
|
100
|
+
const errors = {};
|
|
101
|
+
if (min <= 0n) errors.min = ["Minimum amount must be greater than 0"];
|
|
102
|
+
if (max < min) errors.max = ["Maximum amount must be greater than or equal to minimum amount"];
|
|
103
|
+
if (amount < min) errors.amount = [`Must be at least ${formatCents(min)}`];
|
|
104
|
+
if (amount > max) errors.amount = [`Must be at most ${formatCents(max)}`];
|
|
105
|
+
if (Object.keys(errors).length > 0) {
|
|
106
|
+
throw new import_connect.EdgeValidationError("Invalid transfer amount", errors);
|
|
107
|
+
}
|
|
108
|
+
return formatCents(amount);
|
|
109
|
+
}
|
|
110
|
+
function createTransferIdempotencyKey(options) {
|
|
111
|
+
const namespace = sanitizeKeyPart(options.namespace || "edge");
|
|
112
|
+
const partnerUserId = requireKeyPart(options.partnerUserId, "partnerUserId");
|
|
113
|
+
const externalId = options.externalId ? sanitizeKeyPart(options.externalId) : void 0;
|
|
114
|
+
const nonce = options.nonce ? sanitizeKeyPart(options.nonce) : void 0;
|
|
115
|
+
const amount = normalizeMoneyAmount(options.amount);
|
|
116
|
+
const digest = import_node_crypto.default.createHash("sha256").update(
|
|
117
|
+
JSON.stringify({
|
|
118
|
+
partnerUserId,
|
|
119
|
+
type: options.type,
|
|
120
|
+
amount,
|
|
121
|
+
category: options.category ?? null,
|
|
122
|
+
externalId: externalId ?? null,
|
|
123
|
+
nonce: nonce ?? null
|
|
124
|
+
})
|
|
125
|
+
).digest("hex").slice(0, 32);
|
|
126
|
+
return [namespace, options.type, digest].join("_");
|
|
127
|
+
}
|
|
128
|
+
function assertSameTransferIntent(existing, requested) {
|
|
129
|
+
const errors = {};
|
|
130
|
+
if (existing.type !== requested.type) {
|
|
131
|
+
errors.type = [`Expected ${existing.type}, received ${requested.type}`];
|
|
132
|
+
}
|
|
133
|
+
const existingAmount = normalizeMoneyAmount(existing.amount);
|
|
134
|
+
const requestedAmount = normalizeMoneyAmount(requested.amount);
|
|
135
|
+
if (existingAmount !== requestedAmount) {
|
|
136
|
+
errors.amount = [`Expected ${existingAmount}, received ${requestedAmount}`];
|
|
137
|
+
}
|
|
138
|
+
const existingCategory = existing.category ?? void 0;
|
|
139
|
+
const requestedCategory = requested.category ?? void 0;
|
|
140
|
+
if (existingCategory !== requestedCategory) {
|
|
141
|
+
errors.category = [`Expected ${existingCategory || "unset"}, received ${requestedCategory || "unset"}`];
|
|
142
|
+
}
|
|
143
|
+
if (Object.keys(errors).length > 0) {
|
|
144
|
+
throw new import_connect.EdgeValidationError("Transfer intent does not match existing idempotent transfer", errors);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function startTransferVerification(client, options) {
|
|
148
|
+
const origin = typeof options.origin === "string" ? options.origin.trim() : "";
|
|
149
|
+
if (!origin) {
|
|
150
|
+
throw new import_connect.EdgeValidationError("Verification origin is required", {
|
|
151
|
+
origin: ["Required"]
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const transfer = await client.initiateTransfer({
|
|
155
|
+
...options.transfer,
|
|
156
|
+
amount: normalizeMoneyAmount(options.transfer.amount)
|
|
157
|
+
});
|
|
158
|
+
const transferId = transfer.transferId;
|
|
159
|
+
if (!transferId) {
|
|
160
|
+
throw new import_connect.EdgeValidationError("Transfer response missing transferId", {
|
|
161
|
+
transferId: ["Required to create a verification session"]
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const verificationSession = await client.createVerificationSession(transferId, { origin });
|
|
165
|
+
return { transfer, verificationSession };
|
|
166
|
+
}
|
|
167
|
+
function parseMoneyToCents(value, field) {
|
|
168
|
+
let input;
|
|
169
|
+
if (typeof value === "number") {
|
|
170
|
+
if (!Number.isFinite(value)) {
|
|
171
|
+
throw new import_connect.EdgeValidationError("Money amount must be finite", { [field]: ["Must be a finite number"] });
|
|
172
|
+
}
|
|
173
|
+
input = value.toFixed(2);
|
|
174
|
+
} else if (typeof value === "string") {
|
|
175
|
+
input = value.trim();
|
|
176
|
+
} else {
|
|
177
|
+
throw new import_connect.EdgeValidationError("Money amount must be a string or number", { [field]: ["Invalid type"] });
|
|
178
|
+
}
|
|
179
|
+
if (!/^\d+(\.\d{0,2})?$/.test(input)) {
|
|
180
|
+
throw new import_connect.EdgeValidationError("Money amount must be a positive decimal with at most 2 places", {
|
|
181
|
+
[field]: ['Use a decimal string like "25.00"']
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const [dollars, cents = ""] = input.split(".");
|
|
185
|
+
const normalizedDollars = dollars.replace(/^0+(?=\d)/, "") || "0";
|
|
186
|
+
const amount = BigInt(normalizedDollars) * 100n + BigInt(cents.padEnd(2, "0"));
|
|
187
|
+
if (amount <= 0n) {
|
|
188
|
+
throw new import_connect.EdgeValidationError("Money amount must be greater than 0", { [field]: ["Must be greater than 0"] });
|
|
189
|
+
}
|
|
190
|
+
return amount;
|
|
191
|
+
}
|
|
192
|
+
function formatCents(cents) {
|
|
193
|
+
return `${(cents / 100n).toString()}.${(cents % 100n).toString().padStart(2, "0")}`;
|
|
194
|
+
}
|
|
195
|
+
function requireKeyPart(value, field) {
|
|
196
|
+
const sanitized = sanitizeKeyPart(value);
|
|
197
|
+
if (!sanitized) {
|
|
198
|
+
throw new import_connect.EdgeValidationError("Idempotency key input is incomplete", {
|
|
199
|
+
[field]: ["Required"]
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return sanitized;
|
|
203
|
+
}
|
|
204
|
+
function sanitizeKeyPart(value) {
|
|
205
|
+
return value.trim().replace(/[^a-zA-Z0-9_.:-]/g, "_").slice(0, 64);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/validators.ts
|
|
209
|
+
var import_connect2 = require("@edge-markets/connect");
|
|
210
|
+
var UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
211
|
+
function validateTransferId(transferId) {
|
|
212
|
+
if (typeof transferId !== "string" || !UUID_V4_REGEX.test(transferId)) {
|
|
213
|
+
throw new import_connect2.EdgeValidationError("transferId must be a UUID v4", {
|
|
214
|
+
transferId: ["Must be a valid UUID v4"]
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
61
218
|
function validateVerifyIdentityOptions(options) {
|
|
62
219
|
const errors = {};
|
|
63
220
|
const firstName = typeof options.firstName === "string" ? options.firstName.trim() : "";
|
|
@@ -73,7 +230,7 @@ function validateVerifyIdentityOptions(options) {
|
|
|
73
230
|
}
|
|
74
231
|
if (Object.keys(errors).length > 0) {
|
|
75
232
|
const firstMessage = Object.values(errors)[0][0];
|
|
76
|
-
throw new
|
|
233
|
+
throw new import_connect2.EdgeValidationError(firstMessage, errors);
|
|
77
234
|
}
|
|
78
235
|
}
|
|
79
236
|
function validateTransferOptions(options) {
|
|
@@ -102,7 +259,7 @@ function validateTransferOptions(options) {
|
|
|
102
259
|
errors.category = ["Must be one of: sportsbook, casino, dfs, sweepstakes"];
|
|
103
260
|
}
|
|
104
261
|
if (Object.keys(errors).length > 0) {
|
|
105
|
-
throw new
|
|
262
|
+
throw new import_connect2.EdgeValidationError("Invalid transfer options", errors);
|
|
106
263
|
}
|
|
107
264
|
}
|
|
108
265
|
|
|
@@ -134,7 +291,11 @@ var EdgeUserClient = class {
|
|
|
134
291
|
}
|
|
135
292
|
return this.server._apiRequest("POST", "/transfer", this.accessToken, body);
|
|
136
293
|
}
|
|
294
|
+
async startTransferVerification(options) {
|
|
295
|
+
return startTransferVerification(this, options);
|
|
296
|
+
}
|
|
137
297
|
async getTransfer(transferId) {
|
|
298
|
+
validateTransferId(transferId);
|
|
138
299
|
return this.server._apiRequest("GET", `/transfer/${encodeURIComponent(transferId)}`, this.accessToken);
|
|
139
300
|
}
|
|
140
301
|
async listTransfers(params) {
|
|
@@ -155,6 +316,7 @@ var EdgeUserClient = class {
|
|
|
155
316
|
* The handoff token is single-use and expires in 120 seconds.
|
|
156
317
|
*/
|
|
157
318
|
async createVerificationSession(transferId, options) {
|
|
319
|
+
validateTransferId(transferId);
|
|
158
320
|
return this.server._apiRequest(
|
|
159
321
|
"POST",
|
|
160
322
|
`/transfer/${encodeURIComponent(transferId)}/verification-session`,
|
|
@@ -167,6 +329,7 @@ var EdgeUserClient = class {
|
|
|
167
329
|
* Use as an alternative to postMessage events.
|
|
168
330
|
*/
|
|
169
331
|
async getVerificationSessionStatus(transferId, sessionId) {
|
|
332
|
+
validateTransferId(transferId);
|
|
170
333
|
return this.server._apiRequest(
|
|
171
334
|
"GET",
|
|
172
335
|
`/transfer/${encodeURIComponent(transferId)}/verification-session/${encodeURIComponent(sessionId)}`,
|
|
@@ -251,16 +414,46 @@ function fromBase64Url(value) {
|
|
|
251
414
|
}
|
|
252
415
|
|
|
253
416
|
// src/mtls.ts
|
|
417
|
+
var import_node_fs = __toESM(require("fs"));
|
|
254
418
|
var import_node_https = __toESM(require("https"));
|
|
419
|
+
var import_node_tls = __toESM(require("tls"));
|
|
420
|
+
var CERT_PATTERN = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
|
421
|
+
function normalizeCaBundle(ca) {
|
|
422
|
+
if (!ca) return [];
|
|
423
|
+
const values = Array.isArray(ca) ? ca : [ca];
|
|
424
|
+
return values.flatMap((value) => {
|
|
425
|
+
const normalized = value.replace(/\\n/g, "\n").trim();
|
|
426
|
+
if (!normalized) return [];
|
|
427
|
+
const certificates = normalized.match(CERT_PATTERN);
|
|
428
|
+
return certificates ? certificates.map((certificate) => certificate.trim()) : [normalized];
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
function getNodeExtraCaCerts() {
|
|
432
|
+
const extraCaPath = process.env.NODE_EXTRA_CA_CERTS?.trim();
|
|
433
|
+
if (!extraCaPath) return [];
|
|
434
|
+
try {
|
|
435
|
+
return normalizeCaBundle(import_node_fs.default.readFileSync(extraCaPath, "utf8"));
|
|
436
|
+
} catch (error) {
|
|
437
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
438
|
+
throw new Error(`EdgeConnectServer: failed to read NODE_EXTRA_CA_CERTS from "${extraCaPath}": ${message}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function buildServerCaBundle(ca) {
|
|
442
|
+
const customCa = normalizeCaBundle(ca);
|
|
443
|
+
if (customCa.length === 0) return void 0;
|
|
444
|
+
return Array.from(/* @__PURE__ */ new Set([...import_node_tls.default.rootCertificates, ...getNodeExtraCaCerts(), ...customCa]));
|
|
445
|
+
}
|
|
255
446
|
function createHttpsAgent(config) {
|
|
447
|
+
const ca = buildServerCaBundle(config.ca);
|
|
256
448
|
return new import_node_https.default.Agent({
|
|
257
449
|
cert: config.cert,
|
|
258
450
|
key: config.key,
|
|
259
451
|
rejectUnauthorized: true,
|
|
260
|
-
...
|
|
452
|
+
...ca ? { ca } : {}
|
|
261
453
|
});
|
|
262
454
|
}
|
|
263
455
|
function createUndiciDispatcher(config) {
|
|
456
|
+
const ca = buildServerCaBundle(config.ca);
|
|
264
457
|
const moduleName = "undici";
|
|
265
458
|
const { Agent } = require(moduleName);
|
|
266
459
|
return new Agent({
|
|
@@ -268,7 +461,7 @@ function createUndiciDispatcher(config) {
|
|
|
268
461
|
cert: config.cert,
|
|
269
462
|
key: config.key,
|
|
270
463
|
rejectUnauthorized: true,
|
|
271
|
-
...
|
|
464
|
+
...ca ? { ca } : {}
|
|
272
465
|
}
|
|
273
466
|
});
|
|
274
467
|
}
|
|
@@ -281,6 +474,112 @@ function validateMtlsConfig(config) {
|
|
|
281
474
|
}
|
|
282
475
|
}
|
|
283
476
|
|
|
477
|
+
// src/session.ts
|
|
478
|
+
var import_connect3 = require("@edge-markets/connect");
|
|
479
|
+
var EdgeUserSession = class {
|
|
480
|
+
constructor(options) {
|
|
481
|
+
if (!options.edge?.forUser || !options.edge?.refreshTokens) {
|
|
482
|
+
throw new import_connect3.EdgeValidationError("EdgeUserSession requires an EdgeConnectServer instance", {
|
|
483
|
+
edge: ["Provide edge.forUser and edge.refreshTokens"]
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const subjectId = typeof options.subjectId === "string" ? options.subjectId.trim() : "";
|
|
487
|
+
if (!subjectId) {
|
|
488
|
+
throw new import_connect3.EdgeValidationError("EdgeUserSession subjectId is required", {
|
|
489
|
+
subjectId: ["Required"]
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (!options.tokenStore?.load || !options.tokenStore?.save) {
|
|
493
|
+
throw new import_connect3.EdgeValidationError("EdgeUserSession tokenStore is required", {
|
|
494
|
+
tokenStore: ["Provide load(subjectId) and save(subjectId, value, context)"]
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
if (options.refreshSkewMs !== void 0 && (!Number.isFinite(options.refreshSkewMs) || options.refreshSkewMs < 0)) {
|
|
498
|
+
throw new import_connect3.EdgeValidationError("refreshSkewMs must be a non-negative number", {
|
|
499
|
+
refreshSkewMs: ["Must be greater than or equal to 0"]
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
this.edge = options.edge;
|
|
503
|
+
this.subjectId = subjectId;
|
|
504
|
+
this.tokenStore = options.tokenStore;
|
|
505
|
+
this.tokenVault = options.tokenVault;
|
|
506
|
+
this.refreshSkewMs = options.refreshSkewMs ?? 6e4;
|
|
507
|
+
this.now = options.now ?? Date.now;
|
|
508
|
+
this.onRefresh = options.onRefresh;
|
|
509
|
+
}
|
|
510
|
+
async getValidTokens(options = {}) {
|
|
511
|
+
const tokens = await this.loadTokens();
|
|
512
|
+
if (!options.forceRefresh && tokens.expiresAt > this.now() + this.refreshSkewMs) {
|
|
513
|
+
return tokens;
|
|
514
|
+
}
|
|
515
|
+
const refreshed = await this.edge.refreshTokens(tokens.refreshToken);
|
|
516
|
+
await this.saveTokens(refreshed, true);
|
|
517
|
+
await this.onRefresh?.(refreshed);
|
|
518
|
+
return refreshed;
|
|
519
|
+
}
|
|
520
|
+
async getClient(options = {}) {
|
|
521
|
+
const tokens = await this.getValidTokens(options);
|
|
522
|
+
return this.edge.forUser(tokens.accessToken);
|
|
523
|
+
}
|
|
524
|
+
async withClient(callback) {
|
|
525
|
+
const tokens = await this.getValidTokens();
|
|
526
|
+
return callback(this.edge.forUser(tokens.accessToken), tokens);
|
|
527
|
+
}
|
|
528
|
+
async saveTokens(tokens, refreshed = false) {
|
|
529
|
+
validateTokens(tokens);
|
|
530
|
+
const value = this.tokenVault ? this.tokenVault.encryptTokens(tokens) : tokens;
|
|
531
|
+
await this.tokenStore.save(this.subjectId, value, { tokens, refreshed });
|
|
532
|
+
}
|
|
533
|
+
async loadTokens() {
|
|
534
|
+
const value = await this.tokenStore.load(this.subjectId);
|
|
535
|
+
if (!value) {
|
|
536
|
+
throw new import_connect3.EdgeAuthenticationError("No EDGE tokens are stored for this subject", {
|
|
537
|
+
subjectId: this.subjectId
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
if (typeof value === "string") {
|
|
541
|
+
if (!this.tokenVault) {
|
|
542
|
+
throw new import_connect3.EdgeAuthenticationError("Encrypted EDGE token envelope loaded but no tokenVault was configured", {
|
|
543
|
+
subjectId: this.subjectId
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
const tokens = this.tokenVault.decryptTokens(value);
|
|
547
|
+
validateTokens(tokens);
|
|
548
|
+
return tokens;
|
|
549
|
+
}
|
|
550
|
+
validateTokens(value);
|
|
551
|
+
return value;
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
function createEdgeUserSession(options) {
|
|
555
|
+
return new EdgeUserSession(options);
|
|
556
|
+
}
|
|
557
|
+
function validateTokens(tokens) {
|
|
558
|
+
const errors = {};
|
|
559
|
+
if (!tokens || typeof tokens !== "object") {
|
|
560
|
+
errors.tokens = ["Expected token object"];
|
|
561
|
+
} else {
|
|
562
|
+
if (typeof tokens.accessToken !== "string" || !tokens.accessToken.trim()) {
|
|
563
|
+
errors.accessToken = ["Required"];
|
|
564
|
+
}
|
|
565
|
+
if (typeof tokens.refreshToken !== "string" || !tokens.refreshToken.trim()) {
|
|
566
|
+
errors.refreshToken = ["Required"];
|
|
567
|
+
}
|
|
568
|
+
if (typeof tokens.expiresAt !== "number" || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) {
|
|
569
|
+
errors.expiresAt = ["Must be a positive Unix timestamp in milliseconds"];
|
|
570
|
+
}
|
|
571
|
+
if (typeof tokens.expiresIn !== "number" || !Number.isFinite(tokens.expiresIn) || tokens.expiresIn <= 0) {
|
|
572
|
+
errors.expiresIn = ["Must be a positive number"];
|
|
573
|
+
}
|
|
574
|
+
if (typeof tokens.scope !== "string") {
|
|
575
|
+
errors.scope = ["Required"];
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (Object.keys(errors).length > 0) {
|
|
579
|
+
throw new import_connect3.EdgeValidationError("Invalid EDGE token payload", errors);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
284
583
|
// src/types.ts
|
|
285
584
|
var DEFAULT_TIMEOUT = 3e4;
|
|
286
585
|
var USER_AGENT = "@edge-markets/connect-node/1.0.0";
|
|
@@ -291,12 +590,113 @@ var DEFAULT_RETRY_CONFIG = {
|
|
|
291
590
|
baseDelayMs: 1e3
|
|
292
591
|
};
|
|
293
592
|
|
|
593
|
+
// src/webhook-reconciler.ts
|
|
594
|
+
var DEFAULT_MAX_PAGES = 10;
|
|
595
|
+
var EdgeWebhookReconciler = class {
|
|
596
|
+
constructor(options) {
|
|
597
|
+
this.running = false;
|
|
598
|
+
if (!options.edge?.syncWebhookEvents) {
|
|
599
|
+
throw new Error("EdgeWebhookReconciler: edge.syncWebhookEvents is required");
|
|
600
|
+
}
|
|
601
|
+
if (!options.cursorStore?.load || !options.cursorStore?.save) {
|
|
602
|
+
throw new Error("EdgeWebhookReconciler: cursorStore.load and cursorStore.save are required");
|
|
603
|
+
}
|
|
604
|
+
if (!options.processEvent) {
|
|
605
|
+
throw new Error("EdgeWebhookReconciler: processEvent is required");
|
|
606
|
+
}
|
|
607
|
+
if (options.maxPages !== void 0 && (!Number.isInteger(options.maxPages) || options.maxPages <= 0)) {
|
|
608
|
+
throw new Error("EdgeWebhookReconciler: maxPages must be a positive integer");
|
|
609
|
+
}
|
|
610
|
+
this.options = options;
|
|
611
|
+
}
|
|
612
|
+
async run() {
|
|
613
|
+
if (this.running) {
|
|
614
|
+
return {
|
|
615
|
+
processed: 0,
|
|
616
|
+
pages: 0,
|
|
617
|
+
hasMore: false,
|
|
618
|
+
skipped: true,
|
|
619
|
+
reason: "already_running"
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
this.running = true;
|
|
623
|
+
try {
|
|
624
|
+
return await this.runInternal();
|
|
625
|
+
} finally {
|
|
626
|
+
this.running = false;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async runInternal() {
|
|
630
|
+
let cursor = normalizeCursor(await this.options.cursorStore.load());
|
|
631
|
+
let processed = 0;
|
|
632
|
+
let pages = 0;
|
|
633
|
+
let hasMore = false;
|
|
634
|
+
const maxPages = this.options.maxPages ?? DEFAULT_MAX_PAGES;
|
|
635
|
+
while (pages < maxPages) {
|
|
636
|
+
const result = await this.options.edge.syncWebhookEvents({
|
|
637
|
+
...cursor ? { afterEventId: cursor } : {},
|
|
638
|
+
...this.options.limit !== void 0 ? { limit: this.options.limit } : {}
|
|
639
|
+
});
|
|
640
|
+
pages++;
|
|
641
|
+
hasMore = result.hasMore;
|
|
642
|
+
if (result.events.length === 0) {
|
|
643
|
+
return {
|
|
644
|
+
processed,
|
|
645
|
+
pages,
|
|
646
|
+
...cursor ? { cursor } : {},
|
|
647
|
+
hasMore,
|
|
648
|
+
skipped: false,
|
|
649
|
+
...hasMore ? { reason: "empty_page_with_has_more" } : {}
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
for (const event of result.events) {
|
|
653
|
+
await this.options.processEvent(event);
|
|
654
|
+
cursor = event.id;
|
|
655
|
+
await this.options.cursorStore.save(cursor);
|
|
656
|
+
processed++;
|
|
657
|
+
}
|
|
658
|
+
if (!result.hasMore) {
|
|
659
|
+
return {
|
|
660
|
+
processed,
|
|
661
|
+
pages,
|
|
662
|
+
...cursor ? { cursor } : {},
|
|
663
|
+
hasMore: false,
|
|
664
|
+
skipped: false
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
processed,
|
|
670
|
+
pages,
|
|
671
|
+
...cursor ? { cursor } : {},
|
|
672
|
+
hasMore,
|
|
673
|
+
skipped: false,
|
|
674
|
+
...hasMore ? { reason: "max_pages_reached" } : {}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
function createWebhookReconciler(options) {
|
|
679
|
+
return new EdgeWebhookReconciler(options);
|
|
680
|
+
}
|
|
681
|
+
function normalizeCursor(cursor) {
|
|
682
|
+
if (typeof cursor !== "string") return void 0;
|
|
683
|
+
const trimmed = cursor.trim();
|
|
684
|
+
return trimmed || void 0;
|
|
685
|
+
}
|
|
686
|
+
|
|
294
687
|
// src/edge-connect-server.ts
|
|
295
688
|
var instances = /* @__PURE__ */ new Map();
|
|
296
689
|
function getInstanceKey(config) {
|
|
297
|
-
return
|
|
690
|
+
return JSON.stringify([
|
|
691
|
+
config.clientId,
|
|
692
|
+
config.environment,
|
|
693
|
+
config.apiBaseUrl || "",
|
|
694
|
+
config.oauthBaseUrl || "",
|
|
695
|
+
config.partnerApiBaseUrl || "",
|
|
696
|
+
config.partnerClientId || ""
|
|
697
|
+
]);
|
|
298
698
|
}
|
|
299
|
-
var MTLS_ENFORCED_HOSTS = /* @__PURE__ */ new Set(["connect-staging.edgeboost.io", "
|
|
699
|
+
var MTLS_ENFORCED_HOSTS = /* @__PURE__ */ new Set(["connect-staging.edgeboost.io", "connect.edgeboost.io"]);
|
|
300
700
|
function isMtlsEnforcedHost(host) {
|
|
301
701
|
if (MTLS_ENFORCED_HOSTS.has(host)) {
|
|
302
702
|
return true;
|
|
@@ -318,19 +718,32 @@ function environmentRequiresMtls(environment, apiBaseUrl) {
|
|
|
318
718
|
return false;
|
|
319
719
|
}
|
|
320
720
|
}
|
|
321
|
-
var
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const instance = new _EdgeConnectServer(config);
|
|
327
|
-
instances.set(key, instance);
|
|
328
|
-
return instance;
|
|
721
|
+
var CONNECT_API_BASE_PATH = "/connect/v1";
|
|
722
|
+
function stripTrailingSlash(value) {
|
|
723
|
+
let end = value.length;
|
|
724
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) {
|
|
725
|
+
end--;
|
|
329
726
|
}
|
|
330
|
-
|
|
331
|
-
|
|
727
|
+
return value.slice(0, end);
|
|
728
|
+
}
|
|
729
|
+
function derivePartnerApiBaseUrl(apiBaseUrl) {
|
|
730
|
+
const url = new URL(apiBaseUrl);
|
|
731
|
+
const normalizedPath = stripTrailingSlash(url.pathname);
|
|
732
|
+
if (normalizedPath.endsWith(CONNECT_API_BASE_PATH)) {
|
|
733
|
+
const prefix = normalizedPath.slice(0, -CONNECT_API_BASE_PATH.length);
|
|
734
|
+
url.pathname = `${prefix}/v1`;
|
|
735
|
+
} else {
|
|
736
|
+
url.pathname = normalizedPath || "/";
|
|
332
737
|
}
|
|
738
|
+
url.search = "";
|
|
739
|
+
url.hash = "";
|
|
740
|
+
return stripTrailingSlash(url.toString());
|
|
741
|
+
}
|
|
742
|
+
var SYNC_WEBHOOK_EVENTS_MIN_LIMIT = 1;
|
|
743
|
+
var SYNC_WEBHOOK_EVENTS_MAX_LIMIT = 100;
|
|
744
|
+
var EdgeConnectServer = class _EdgeConnectServer {
|
|
333
745
|
constructor(config) {
|
|
746
|
+
this.partnerTokenCache = null;
|
|
334
747
|
if (!config.clientId) {
|
|
335
748
|
throw new Error("EdgeConnectServer: clientId is required");
|
|
336
749
|
}
|
|
@@ -341,8 +754,9 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
341
754
|
throw new Error("EdgeConnectServer: environment is required");
|
|
342
755
|
}
|
|
343
756
|
this.config = config;
|
|
344
|
-
const envConfig = (0,
|
|
757
|
+
const envConfig = (0, import_connect4.getEnvironmentConfig)(config.environment);
|
|
345
758
|
this.apiBaseUrl = config.apiBaseUrl || envConfig.apiBaseUrl;
|
|
759
|
+
this.partnerApiBaseUrl = stripTrailingSlash(config.partnerApiBaseUrl || derivePartnerApiBaseUrl(this.apiBaseUrl));
|
|
346
760
|
this.oauthBaseUrl = config.oauthBaseUrl || envConfig.oauthBaseUrl;
|
|
347
761
|
this.timeout = config.timeout || DEFAULT_TIMEOUT;
|
|
348
762
|
this.retryConfig = {
|
|
@@ -359,6 +773,17 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
359
773
|
);
|
|
360
774
|
}
|
|
361
775
|
}
|
|
776
|
+
static getInstance(config) {
|
|
777
|
+
const key = getInstanceKey(config);
|
|
778
|
+
const existing = instances.get(key);
|
|
779
|
+
if (existing) return existing;
|
|
780
|
+
const instance = new _EdgeConnectServer(config);
|
|
781
|
+
instances.set(key, instance);
|
|
782
|
+
return instance;
|
|
783
|
+
}
|
|
784
|
+
static clearInstances() {
|
|
785
|
+
instances.clear();
|
|
786
|
+
}
|
|
362
787
|
/**
|
|
363
788
|
* Returns the `node:https.Agent` configured with mTLS client certificates.
|
|
364
789
|
* Use this with axios, got, or other HTTP clients. Returns `undefined` if mTLS is not configured.
|
|
@@ -374,10 +799,13 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
374
799
|
*/
|
|
375
800
|
forUser(accessToken) {
|
|
376
801
|
if (!accessToken) {
|
|
377
|
-
throw new
|
|
802
|
+
throw new import_connect4.EdgeAuthenticationError("accessToken is required when calling forUser()");
|
|
378
803
|
}
|
|
379
804
|
return new EdgeUserClient(this, accessToken);
|
|
380
805
|
}
|
|
806
|
+
createUserSession(options) {
|
|
807
|
+
return new EdgeUserSession({ ...options, edge: this });
|
|
808
|
+
}
|
|
381
809
|
async exchangeCode(code, codeVerifier, redirectUri) {
|
|
382
810
|
const tokenUrl = `${this.oauthBaseUrl}/token`;
|
|
383
811
|
const body = {
|
|
@@ -408,8 +836,8 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
408
836
|
const data = await response.json();
|
|
409
837
|
return this.parseTokenResponse(data);
|
|
410
838
|
} catch (error) {
|
|
411
|
-
if (error instanceof
|
|
412
|
-
throw new
|
|
839
|
+
if (error instanceof import_connect4.EdgeError) throw error;
|
|
840
|
+
throw new import_connect4.EdgeNetworkError("Failed to exchange code", error);
|
|
413
841
|
}
|
|
414
842
|
}
|
|
415
843
|
async refreshTokens(refreshToken) {
|
|
@@ -435,7 +863,7 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
435
863
|
);
|
|
436
864
|
if (!response.ok) {
|
|
437
865
|
const error = await response.json().catch(() => ({}));
|
|
438
|
-
throw new
|
|
866
|
+
throw new import_connect4.EdgeAuthenticationError(
|
|
439
867
|
error.message || error.error_description || "Token refresh failed. User may need to reconnect.",
|
|
440
868
|
{ tokenError: error }
|
|
441
869
|
);
|
|
@@ -443,8 +871,8 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
443
871
|
const data = await response.json();
|
|
444
872
|
return this.parseTokenResponse(data, refreshToken);
|
|
445
873
|
} catch (error) {
|
|
446
|
-
if (error instanceof
|
|
447
|
-
throw new
|
|
874
|
+
if (error instanceof import_connect4.EdgeError) throw error;
|
|
875
|
+
throw new import_connect4.EdgeNetworkError("Failed to refresh tokens", error);
|
|
448
876
|
}
|
|
449
877
|
}
|
|
450
878
|
/** @internal Called by {@link EdgeUserClient} — not part of the public API. */
|
|
@@ -486,14 +914,14 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
486
914
|
if (mleEnabled && typeof rawResponseBody?.jwe === "string") {
|
|
487
915
|
responseBody = decryptMle(rawResponseBody.jwe, mleConfig);
|
|
488
916
|
} else if (!mleEnabled && typeof rawResponseBody?.jwe === "string") {
|
|
489
|
-
throw new
|
|
917
|
+
throw new import_connect4.EdgeApiError(
|
|
490
918
|
"mle_required",
|
|
491
919
|
"The API responded with message-level encryption. Enable MLE in SDK config.",
|
|
492
920
|
response.status,
|
|
493
921
|
rawResponseBody
|
|
494
922
|
);
|
|
495
923
|
} else if (mleEnabled && mleConfig?.strictResponseEncryption !== false && response.ok && typeof rawResponseBody?.jwe !== "string") {
|
|
496
|
-
throw new
|
|
924
|
+
throw new import_connect4.EdgeApiError(
|
|
497
925
|
"mle_response_missing",
|
|
498
926
|
"Expected encrypted response payload but received plaintext.",
|
|
499
927
|
response.status,
|
|
@@ -511,8 +939,8 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
511
939
|
}
|
|
512
940
|
return responseBody;
|
|
513
941
|
} catch (error) {
|
|
514
|
-
if (error instanceof
|
|
515
|
-
if (!(error instanceof
|
|
942
|
+
if (error instanceof import_connect4.EdgeError) {
|
|
943
|
+
if (!(error instanceof import_connect4.EdgeNetworkError) || attempt >= this.retryConfig.maxRetries) {
|
|
516
944
|
throw error;
|
|
517
945
|
}
|
|
518
946
|
lastError = error;
|
|
@@ -520,15 +948,327 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
520
948
|
}
|
|
521
949
|
lastError = error;
|
|
522
950
|
if (attempt >= this.retryConfig.maxRetries) {
|
|
523
|
-
throw new
|
|
951
|
+
throw new import_connect4.EdgeNetworkError(`API request failed: ${method} ${path}`, lastError);
|
|
524
952
|
}
|
|
525
953
|
}
|
|
526
954
|
}
|
|
527
|
-
throw new
|
|
955
|
+
throw new import_connect4.EdgeNetworkError(
|
|
528
956
|
`API request failed after ${this.retryConfig.maxRetries} retries: ${method} ${path}`,
|
|
529
957
|
lastError
|
|
530
958
|
);
|
|
531
959
|
}
|
|
960
|
+
/**
|
|
961
|
+
* Reconcile webhook events from the partner sync endpoint.
|
|
962
|
+
*
|
|
963
|
+
* Use this from a scheduled cron (every ~5 minutes is typical) to catch
|
|
964
|
+
* events your primary webhook receiver missed — partner downtime, retry
|
|
965
|
+
* exhaustion, dead-letter queue. This is the third reliability layer
|
|
966
|
+
* ("Leg 3") on top of HTTP delivery + DLQ.
|
|
967
|
+
*
|
|
968
|
+
* **Authentication.** This method uses the OAuth `client_credentials`
|
|
969
|
+
* grant against {@link EdgeConnectServerConfig.partnerClientId} and
|
|
970
|
+
* {@link EdgeConnectServerConfig.partnerClientSecret}. The token is
|
|
971
|
+
* cached internally and refreshed automatically.
|
|
972
|
+
*
|
|
973
|
+
* **Pagination.** Pass `afterEventId` with the last `id` you processed.
|
|
974
|
+
* Loop while `hasMore` is true. The server enforces a 30-day lookback;
|
|
975
|
+
* cursors older than 30 days return events from 30 days ago, not an error.
|
|
976
|
+
*
|
|
977
|
+
* @example
|
|
978
|
+
* ```typescript
|
|
979
|
+
* let cursor: string | undefined = await loadCursorFromDb()
|
|
980
|
+
*
|
|
981
|
+
* while (true) {
|
|
982
|
+
* const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
|
|
983
|
+
* for (const event of events) {
|
|
984
|
+
* await processEvent(event)
|
|
985
|
+
* cursor = event.id
|
|
986
|
+
* }
|
|
987
|
+
* if (events.length > 0) await saveCursorToDb(cursor!)
|
|
988
|
+
* if (!hasMore) break
|
|
989
|
+
* }
|
|
990
|
+
* ```
|
|
991
|
+
*
|
|
992
|
+
* @throws {EdgeAuthenticationError} If partner credentials are missing
|
|
993
|
+
* or the partner token cannot be obtained / refreshed
|
|
994
|
+
* @throws {EdgeNetworkError} On network failure
|
|
995
|
+
* @throws {EdgeApiError} For other non-2xx responses after retries
|
|
996
|
+
*/
|
|
997
|
+
async syncWebhookEvents(options = {}) {
|
|
998
|
+
if (!this.config.partnerClientId) {
|
|
999
|
+
throw new import_connect4.EdgeAuthenticationError("syncWebhookEvents requires `partnerClientId` in EdgeConnectServer config");
|
|
1000
|
+
}
|
|
1001
|
+
if (!this.config.partnerClientSecret) {
|
|
1002
|
+
throw new import_connect4.EdgeAuthenticationError("syncWebhookEvents requires `partnerClientSecret` in EdgeConnectServer config");
|
|
1003
|
+
}
|
|
1004
|
+
const normalizedOptions = this.normalizeSyncWebhookEventsOptions(options);
|
|
1005
|
+
const url = this.buildSyncUrl(normalizedOptions);
|
|
1006
|
+
let token = await this.getPartnerToken();
|
|
1007
|
+
let { response, durationMs, attempt } = await this.partnerFetch(url, token);
|
|
1008
|
+
if (response.status === 401) {
|
|
1009
|
+
await this.drainResponseBody(response);
|
|
1010
|
+
this.config.onResponse?.({
|
|
1011
|
+
status: response.status,
|
|
1012
|
+
body: { error: "unauthorized" },
|
|
1013
|
+
durationMs,
|
|
1014
|
+
method: "GET",
|
|
1015
|
+
url,
|
|
1016
|
+
endpoint: "partner_webhook_sync",
|
|
1017
|
+
attempt
|
|
1018
|
+
});
|
|
1019
|
+
this.partnerTokenCache = null;
|
|
1020
|
+
token = await this.getPartnerToken();
|
|
1021
|
+
({ response, durationMs, attempt } = await this.partnerFetch(url, token));
|
|
1022
|
+
if (response.status === 401) {
|
|
1023
|
+
const body = await response.json().catch(() => ({}));
|
|
1024
|
+
this.config.onResponse?.({
|
|
1025
|
+
status: response.status,
|
|
1026
|
+
body: this.redactPartnerSyncResponseBody(body),
|
|
1027
|
+
durationMs,
|
|
1028
|
+
method: "GET",
|
|
1029
|
+
url,
|
|
1030
|
+
endpoint: "partner_webhook_sync",
|
|
1031
|
+
attempt
|
|
1032
|
+
});
|
|
1033
|
+
throw new import_connect4.EdgeAuthenticationError(
|
|
1034
|
+
body.message || "Partner token rejected after refresh \u2014 check partnerClientId/Secret",
|
|
1035
|
+
body
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (!response.ok) {
|
|
1040
|
+
const body = await response.json().catch(() => ({}));
|
|
1041
|
+
this.config.onResponse?.({
|
|
1042
|
+
status: response.status,
|
|
1043
|
+
body: this.redactPartnerSyncResponseBody(body),
|
|
1044
|
+
durationMs,
|
|
1045
|
+
method: "GET",
|
|
1046
|
+
url,
|
|
1047
|
+
endpoint: "partner_webhook_sync",
|
|
1048
|
+
attempt
|
|
1049
|
+
});
|
|
1050
|
+
throw new import_connect4.EdgeApiError(
|
|
1051
|
+
body.error || "sync_failed",
|
|
1052
|
+
body.message || `syncWebhookEvents failed with status ${response.status}`,
|
|
1053
|
+
response.status,
|
|
1054
|
+
body
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
const wire = await response.json().catch(() => ({}));
|
|
1058
|
+
this.config.onResponse?.({
|
|
1059
|
+
status: response.status,
|
|
1060
|
+
body: {
|
|
1061
|
+
eventCount: wire.events?.length ?? 0,
|
|
1062
|
+
hasMore: !!wire.has_more
|
|
1063
|
+
},
|
|
1064
|
+
durationMs,
|
|
1065
|
+
method: "GET",
|
|
1066
|
+
url,
|
|
1067
|
+
endpoint: "partner_webhook_sync",
|
|
1068
|
+
attempt
|
|
1069
|
+
});
|
|
1070
|
+
const events = (wire.events ?? []).map((entry) => entry.payload).filter((p) => !!p);
|
|
1071
|
+
return {
|
|
1072
|
+
events,
|
|
1073
|
+
hasMore: !!wire.has_more
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Creates a cursor-based webhook reconciler bound to this SDK instance.
|
|
1078
|
+
*
|
|
1079
|
+
* Use this from cron jobs to process events returned by
|
|
1080
|
+
* {@link EdgeConnectServer.syncWebhookEvents} without hand-writing
|
|
1081
|
+
* pagination, cursor advancement, or process-level concurrency guards.
|
|
1082
|
+
*/
|
|
1083
|
+
createWebhookReconciler(options) {
|
|
1084
|
+
return new EdgeWebhookReconciler({ ...options, edge: this });
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Builds the sync URL with normalized query parameters.
|
|
1088
|
+
*/
|
|
1089
|
+
normalizeSyncWebhookEventsOptions(options) {
|
|
1090
|
+
const normalized = {};
|
|
1091
|
+
if (typeof options.afterEventId === "string" && options.afterEventId.trim()) {
|
|
1092
|
+
normalized.afterEventId = options.afterEventId.trim();
|
|
1093
|
+
}
|
|
1094
|
+
if (options.limit !== void 0) {
|
|
1095
|
+
if (!Number.isInteger(options.limit) || options.limit < SYNC_WEBHOOK_EVENTS_MIN_LIMIT || options.limit > SYNC_WEBHOOK_EVENTS_MAX_LIMIT) {
|
|
1096
|
+
throw new import_connect4.EdgeApiError(
|
|
1097
|
+
"invalid_sync_options",
|
|
1098
|
+
`syncWebhookEvents limit must be an integer between ${SYNC_WEBHOOK_EVENTS_MIN_LIMIT} and ${SYNC_WEBHOOK_EVENTS_MAX_LIMIT}`,
|
|
1099
|
+
400,
|
|
1100
|
+
{ limit: options.limit }
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
normalized.limit = options.limit;
|
|
1104
|
+
}
|
|
1105
|
+
return normalized;
|
|
1106
|
+
}
|
|
1107
|
+
buildSyncUrl(options) {
|
|
1108
|
+
const url = new URL(`${this.partnerApiBaseUrl}/partner/webhooks/events/sync`);
|
|
1109
|
+
if (options.afterEventId) {
|
|
1110
|
+
url.searchParams.set("after_event_id", options.afterEventId);
|
|
1111
|
+
}
|
|
1112
|
+
if (options.limit !== void 0) {
|
|
1113
|
+
url.searchParams.set("limit", String(options.limit));
|
|
1114
|
+
}
|
|
1115
|
+
return url.toString();
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Issues a GET to the sync endpoint with the partner Bearer token. Kept
|
|
1119
|
+
* separate from `_apiRequest` because partner tokens have a different
|
|
1120
|
+
* lifecycle (process-wide, client_credentials) than user tokens.
|
|
1121
|
+
*/
|
|
1122
|
+
async partnerFetch(url, token) {
|
|
1123
|
+
let lastError = null;
|
|
1124
|
+
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
1125
|
+
if (attempt > 0) {
|
|
1126
|
+
await this.sleep(this.getRetryDelay(attempt - 1));
|
|
1127
|
+
}
|
|
1128
|
+
const startTime = Date.now();
|
|
1129
|
+
this.config.onRequest?.({
|
|
1130
|
+
method: "GET",
|
|
1131
|
+
url,
|
|
1132
|
+
endpoint: "partner_webhook_sync",
|
|
1133
|
+
attempt
|
|
1134
|
+
});
|
|
1135
|
+
try {
|
|
1136
|
+
const response = await this.fetchWithTimeout(
|
|
1137
|
+
url,
|
|
1138
|
+
{
|
|
1139
|
+
method: "GET",
|
|
1140
|
+
headers: {
|
|
1141
|
+
Authorization: `Bearer ${token}`,
|
|
1142
|
+
"Content-Type": "application/json",
|
|
1143
|
+
"User-Agent": USER_AGENT
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
this.dispatcher
|
|
1147
|
+
);
|
|
1148
|
+
const durationMs = Date.now() - startTime;
|
|
1149
|
+
if (response.status !== 401 && this.retryConfig.retryOn.includes(response.status) && attempt < this.retryConfig.maxRetries) {
|
|
1150
|
+
await this.drainResponseBody(response);
|
|
1151
|
+
this.config.onResponse?.({
|
|
1152
|
+
status: response.status,
|
|
1153
|
+
body: { retrying: true },
|
|
1154
|
+
durationMs,
|
|
1155
|
+
method: "GET",
|
|
1156
|
+
url,
|
|
1157
|
+
endpoint: "partner_webhook_sync",
|
|
1158
|
+
attempt
|
|
1159
|
+
});
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
return { response, durationMs, attempt };
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
lastError = error;
|
|
1165
|
+
if (attempt >= this.retryConfig.maxRetries) {
|
|
1166
|
+
throw new import_connect4.EdgeNetworkError("syncWebhookEvents network failure", lastError);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
throw new import_connect4.EdgeNetworkError(
|
|
1171
|
+
`syncWebhookEvents network failure after ${this.retryConfig.maxRetries} retries`,
|
|
1172
|
+
lastError
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Fetches (or returns the cached) partner token via the
|
|
1177
|
+
* `client_credentials` grant. Caches with a 60s safety margin so the
|
|
1178
|
+
* token is renewed *before* the server-side expiry, never racing it.
|
|
1179
|
+
*
|
|
1180
|
+
* Side-effects: writes `this.partnerTokenCache` on success.
|
|
1181
|
+
*/
|
|
1182
|
+
async getPartnerToken() {
|
|
1183
|
+
const cached = this.partnerTokenCache;
|
|
1184
|
+
if (cached && Date.now() < cached.expiresAt) {
|
|
1185
|
+
return cached.token;
|
|
1186
|
+
}
|
|
1187
|
+
const tokenUrl = `${this.oauthBaseUrl}/token`;
|
|
1188
|
+
let response;
|
|
1189
|
+
const startTime = Date.now();
|
|
1190
|
+
this.config.onRequest?.({
|
|
1191
|
+
method: "POST",
|
|
1192
|
+
url: tokenUrl,
|
|
1193
|
+
endpoint: "oauth_token",
|
|
1194
|
+
body: {
|
|
1195
|
+
grant_type: "client_credentials",
|
|
1196
|
+
client_id: this.config.partnerClientId,
|
|
1197
|
+
client_secret: "[REDACTED]"
|
|
1198
|
+
}
|
|
1199
|
+
});
|
|
1200
|
+
try {
|
|
1201
|
+
response = await this.fetchWithTimeout(
|
|
1202
|
+
tokenUrl,
|
|
1203
|
+
{
|
|
1204
|
+
method: "POST",
|
|
1205
|
+
headers: {
|
|
1206
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1207
|
+
"User-Agent": USER_AGENT
|
|
1208
|
+
},
|
|
1209
|
+
body: new URLSearchParams({
|
|
1210
|
+
grant_type: "client_credentials",
|
|
1211
|
+
client_id: this.config.partnerClientId,
|
|
1212
|
+
client_secret: this.config.partnerClientSecret
|
|
1213
|
+
}).toString()
|
|
1214
|
+
},
|
|
1215
|
+
this.dispatcher
|
|
1216
|
+
);
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
throw new import_connect4.EdgeNetworkError("Partner token request network failure", error);
|
|
1219
|
+
}
|
|
1220
|
+
const durationMs = Date.now() - startTime;
|
|
1221
|
+
const data = await response.json().catch(() => ({}));
|
|
1222
|
+
this.config.onResponse?.({
|
|
1223
|
+
status: response.status,
|
|
1224
|
+
body: this.redactPartnerTokenResponseBody(data),
|
|
1225
|
+
durationMs,
|
|
1226
|
+
method: "POST",
|
|
1227
|
+
url: tokenUrl,
|
|
1228
|
+
endpoint: "oauth_token"
|
|
1229
|
+
});
|
|
1230
|
+
if (!response.ok) {
|
|
1231
|
+
throw new import_connect4.EdgeAuthenticationError(
|
|
1232
|
+
data.message || data.error_description || `Partner token request failed with status ${response.status}`,
|
|
1233
|
+
data
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
if (!data.access_token || typeof data.expires_in !== "number") {
|
|
1237
|
+
throw new import_connect4.EdgeAuthenticationError("Partner token response missing access_token or expires_in");
|
|
1238
|
+
}
|
|
1239
|
+
this.partnerTokenCache = {
|
|
1240
|
+
token: data.access_token,
|
|
1241
|
+
expiresAt: Date.now() + (data.expires_in - 60) * 1e3
|
|
1242
|
+
};
|
|
1243
|
+
return data.access_token;
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* @internal Test-only: clears the cached partner token so the next call
|
|
1247
|
+
* to `syncWebhookEvents` re-fetches it. Not part of the public API.
|
|
1248
|
+
*/
|
|
1249
|
+
_resetPartnerTokenCache() {
|
|
1250
|
+
this.partnerTokenCache = null;
|
|
1251
|
+
}
|
|
1252
|
+
redactPartnerTokenResponseBody(body) {
|
|
1253
|
+
return {
|
|
1254
|
+
...body,
|
|
1255
|
+
...typeof body.access_token === "string" ? { access_token: "[REDACTED]" } : {},
|
|
1256
|
+
...typeof body.refresh_token === "string" ? { refresh_token: "[REDACTED]" } : {},
|
|
1257
|
+
...typeof body.id_token === "string" ? { id_token: "[REDACTED]" } : {}
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
redactPartnerSyncResponseBody(body) {
|
|
1261
|
+
if (Array.isArray(body.events)) {
|
|
1262
|
+
return {
|
|
1263
|
+
...body,
|
|
1264
|
+
events: `[REDACTED ${body.events.length} events]`
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
return body;
|
|
1268
|
+
}
|
|
1269
|
+
async drainResponseBody(response) {
|
|
1270
|
+
await response.arrayBuffer().catch(() => void 0);
|
|
1271
|
+
}
|
|
532
1272
|
getRetryDelay(attempt) {
|
|
533
1273
|
const { backoff, baseDelayMs } = this.retryConfig;
|
|
534
1274
|
if (backoff === "exponential") {
|
|
@@ -569,37 +1309,37 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
569
1309
|
const errorCode = error.error;
|
|
570
1310
|
const errorMessage = error.message || error.error_description;
|
|
571
1311
|
if (errorCode === "invalid_grant" || errorMessage?.includes("Invalid or expired")) {
|
|
572
|
-
return new
|
|
1312
|
+
return new import_connect4.EdgeTokenExchangeError("Authorization code is invalid, expired, or already used. Please try again.", {
|
|
573
1313
|
edgeBoostError: error
|
|
574
1314
|
});
|
|
575
1315
|
}
|
|
576
1316
|
if (errorCode === "invalid_client" || errorMessage?.includes("Invalid client")) {
|
|
577
|
-
return new
|
|
1317
|
+
return new import_connect4.EdgeAuthenticationError("Invalid client credentials. Check your client ID and secret.", {
|
|
578
1318
|
edgeBoostError: error
|
|
579
1319
|
});
|
|
580
1320
|
}
|
|
581
1321
|
if (errorMessage?.includes("code_verifier") || errorMessage?.includes("PKCE")) {
|
|
582
|
-
return new
|
|
1322
|
+
return new import_connect4.EdgeTokenExchangeError("PKCE verification failed. Please try again.", { edgeBoostError: error });
|
|
583
1323
|
}
|
|
584
|
-
return new
|
|
1324
|
+
return new import_connect4.EdgeTokenExchangeError(errorMessage || "Failed to exchange authorization code", {
|
|
585
1325
|
edgeBoostError: error,
|
|
586
1326
|
statusCode: status
|
|
587
1327
|
});
|
|
588
1328
|
}
|
|
589
1329
|
async handleApiErrorFromBody(error, status, path) {
|
|
590
1330
|
if (status === 401) {
|
|
591
|
-
return new
|
|
1331
|
+
return new import_connect4.EdgeAuthenticationError(error.message || "Access token is invalid or expired", error);
|
|
592
1332
|
}
|
|
593
1333
|
if (status === 403) {
|
|
594
1334
|
if (error.error === "consent_required") {
|
|
595
|
-
return new
|
|
1335
|
+
return new import_connect4.EdgeConsentRequiredError(
|
|
596
1336
|
this.config.clientId,
|
|
597
1337
|
error.consentUrl,
|
|
598
1338
|
error.message
|
|
599
1339
|
);
|
|
600
1340
|
}
|
|
601
1341
|
if (error.error === "insufficient_scope" || error.error === "insufficient_consent") {
|
|
602
|
-
return new
|
|
1342
|
+
return new import_connect4.EdgeInsufficientScopeError(
|
|
603
1343
|
error.missing_scopes || error.missingScopes || [],
|
|
604
1344
|
error.message
|
|
605
1345
|
);
|
|
@@ -621,15 +1361,15 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
621
1361
|
} else if (path.includes("/transfer")) {
|
|
622
1362
|
resourceType = "Transfer";
|
|
623
1363
|
}
|
|
624
|
-
return new
|
|
1364
|
+
return new import_connect4.EdgeNotFoundError(resourceType, resourceId);
|
|
625
1365
|
}
|
|
626
1366
|
if (status === 422 && error.error === "identity_verification_failed") {
|
|
627
|
-
return new
|
|
1367
|
+
return new import_connect4.EdgeIdentityVerificationError(
|
|
628
1368
|
error.fieldErrors || {},
|
|
629
1369
|
error.message
|
|
630
1370
|
);
|
|
631
1371
|
}
|
|
632
|
-
return new
|
|
1372
|
+
return new import_connect4.EdgeApiError(
|
|
633
1373
|
error.error || "api_error",
|
|
634
1374
|
error.message || error.error_description || `Request failed with status ${status}`,
|
|
635
1375
|
status,
|
|
@@ -638,11 +1378,693 @@ var EdgeConnectServer = class _EdgeConnectServer {
|
|
|
638
1378
|
}
|
|
639
1379
|
};
|
|
640
1380
|
|
|
1381
|
+
// src/env.ts
|
|
1382
|
+
var import_connect5 = require("@edge-markets/connect");
|
|
1383
|
+
var import_node_fs2 = __toESM(require("fs"));
|
|
1384
|
+
var ENVIRONMENT_NAMES = new Set(Object.keys(import_connect5.EDGE_ENVIRONMENTS));
|
|
1385
|
+
function readEnv(env, name) {
|
|
1386
|
+
const value = env[name];
|
|
1387
|
+
if (typeof value !== "string") return void 0;
|
|
1388
|
+
const trimmed = value.trim();
|
|
1389
|
+
return trimmed ? trimmed : void 0;
|
|
1390
|
+
}
|
|
1391
|
+
function readFirstEnv(env, names) {
|
|
1392
|
+
for (const name of names) {
|
|
1393
|
+
const value = readEnv(env, name);
|
|
1394
|
+
if (value) return value;
|
|
1395
|
+
}
|
|
1396
|
+
return void 0;
|
|
1397
|
+
}
|
|
1398
|
+
function normalizePem(value) {
|
|
1399
|
+
return value.replace(/\\n/g, "\n").trim();
|
|
1400
|
+
}
|
|
1401
|
+
function readPemValue(env, valueName, pathName, warnings) {
|
|
1402
|
+
const inlineValue = readEnv(env, valueName);
|
|
1403
|
+
const filePath = readEnv(env, pathName);
|
|
1404
|
+
if (inlineValue && filePath) {
|
|
1405
|
+
warnings.push(`${valueName} and ${pathName} are both set; using ${valueName}`);
|
|
1406
|
+
}
|
|
1407
|
+
if (inlineValue) {
|
|
1408
|
+
return normalizePem(inlineValue);
|
|
1409
|
+
}
|
|
1410
|
+
if (!filePath) {
|
|
1411
|
+
return void 0;
|
|
1412
|
+
}
|
|
1413
|
+
try {
|
|
1414
|
+
return normalizePem(import_node_fs2.default.readFileSync(filePath, "utf8"));
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1417
|
+
throw new import_connect5.EdgeValidationError(`Failed to read ${pathName}`, {
|
|
1418
|
+
[pathName]: [`Unable to read PEM file "${filePath}": ${message}`]
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
function readEnvironment(env) {
|
|
1423
|
+
const value = readEnv(env, "EDGE_ENVIRONMENT") || "staging";
|
|
1424
|
+
if (!ENVIRONMENT_NAMES.has(value)) {
|
|
1425
|
+
throw new import_connect5.EdgeValidationError("Invalid EDGE_ENVIRONMENT", {
|
|
1426
|
+
EDGE_ENVIRONMENT: [`Must be one of: ${Array.from(ENVIRONMENT_NAMES).join(", ")}`]
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return value;
|
|
1430
|
+
}
|
|
1431
|
+
function readTimeout(env, override) {
|
|
1432
|
+
if (override !== void 0) return override;
|
|
1433
|
+
const value = readFirstEnv(env, ["EDGE_TIMEOUT_MS", "EDGE_REQUEST_TIMEOUT_MS"]);
|
|
1434
|
+
if (!value) return void 0;
|
|
1435
|
+
const timeout = Number(value);
|
|
1436
|
+
if (!Number.isInteger(timeout) || timeout <= 0) {
|
|
1437
|
+
throw new import_connect5.EdgeValidationError("Invalid EDGE_TIMEOUT_MS", {
|
|
1438
|
+
EDGE_TIMEOUT_MS: ["Must be a positive integer number of milliseconds"]
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
return timeout;
|
|
1442
|
+
}
|
|
1443
|
+
function buildMtlsConfig(env, requireMtls, warnings) {
|
|
1444
|
+
const cert = readPemValue(env, "EDGE_MTLS_CERT", "EDGE_MTLS_CERT_PATH", warnings);
|
|
1445
|
+
const key = readPemValue(env, "EDGE_MTLS_KEY", "EDGE_MTLS_KEY_PATH", warnings);
|
|
1446
|
+
const ca = readPemValue(env, "EDGE_MTLS_CA", "EDGE_MTLS_CA_PATH", warnings);
|
|
1447
|
+
if (!cert && !key && !ca) {
|
|
1448
|
+
if (requireMtls) {
|
|
1449
|
+
throw new import_connect5.EdgeValidationError("mTLS is required but no EDGE_MTLS_CERT or EDGE_MTLS_KEY was provided", {
|
|
1450
|
+
EDGE_MTLS_CERT: ["Required when requireMtls is true"],
|
|
1451
|
+
EDGE_MTLS_KEY: ["Required when requireMtls is true"]
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
return void 0;
|
|
1455
|
+
}
|
|
1456
|
+
if (!cert || !key) {
|
|
1457
|
+
throw new import_connect5.EdgeValidationError("Incomplete mTLS configuration", {
|
|
1458
|
+
EDGE_MTLS_CERT: cert ? [] : ["Required when mTLS is configured"],
|
|
1459
|
+
EDGE_MTLS_KEY: key ? [] : ["Required when mTLS is configured"]
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
return {
|
|
1463
|
+
enabled: true,
|
|
1464
|
+
cert,
|
|
1465
|
+
key,
|
|
1466
|
+
...ca ? { ca } : {}
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function inferMtlsRequirement(environment, apiBaseUrl) {
|
|
1470
|
+
if (environment === "staging" || environment === "production") return true;
|
|
1471
|
+
const resolvedApiBaseUrl = apiBaseUrl || (0, import_connect5.getEnvironmentConfig)(environment).apiBaseUrl;
|
|
1472
|
+
try {
|
|
1473
|
+
const host = new URL(resolvedApiBaseUrl).host;
|
|
1474
|
+
return host === "connect-staging.edgeboost.io" || host === "connect.edgeboost.io";
|
|
1475
|
+
} catch {
|
|
1476
|
+
return false;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
function createEdgeConnectServerFromEnv(options = {}) {
|
|
1480
|
+
const env = options.env ?? process.env;
|
|
1481
|
+
const warnings = [];
|
|
1482
|
+
const environment = readEnvironment(env);
|
|
1483
|
+
const apiBaseUrl = readEnv(env, "EDGE_API_BASE_URL");
|
|
1484
|
+
const mtlsRequiredByTarget = inferMtlsRequirement(environment, apiBaseUrl);
|
|
1485
|
+
const requireMtls = options.requireMtls ?? false;
|
|
1486
|
+
const mtls = buildMtlsConfig(env, requireMtls, warnings);
|
|
1487
|
+
const timeout = readTimeout(env, options.timeout);
|
|
1488
|
+
const clientId = readFirstEnv(env, ["EDGE_CLIENT_ID", "EDGE_CONNECT_CLIENT_ID"]) || "";
|
|
1489
|
+
const clientSecret = readFirstEnv(env, ["EDGE_CLIENT_SECRET", "EDGE_CONNECT_CLIENT_SECRET"]) || "";
|
|
1490
|
+
const oauthBaseUrl = readEnv(env, "EDGE_OAUTH_BASE_URL");
|
|
1491
|
+
const partnerApiBaseUrl = readEnv(env, "EDGE_PARTNER_API_BASE_URL");
|
|
1492
|
+
const partnerClientId = readFirstEnv(env, ["EDGE_PARTNER_CLIENT_ID", "EDGE_DASHBOARD_CLIENT_ID"]);
|
|
1493
|
+
const partnerClientSecret = readFirstEnv(env, ["EDGE_PARTNER_CLIENT_SECRET", "EDGE_DASHBOARD_CLIENT_SECRET"]);
|
|
1494
|
+
const validationErrors = {};
|
|
1495
|
+
if (!clientId) validationErrors.EDGE_CLIENT_ID = ["Required"];
|
|
1496
|
+
if (!clientSecret) validationErrors.EDGE_CLIENT_SECRET = ["Required"];
|
|
1497
|
+
if (Object.keys(validationErrors).length > 0) {
|
|
1498
|
+
throw new import_connect5.EdgeValidationError("Missing required EDGE Connect environment variables", validationErrors);
|
|
1499
|
+
}
|
|
1500
|
+
if (mtlsRequiredByTarget && !mtls) {
|
|
1501
|
+
warnings.push(
|
|
1502
|
+
`EDGE_ENVIRONMENT=${environment} uses an mTLS-enforced EDGE Connect target; configure EDGE_MTLS_CERT and EDGE_MTLS_KEY before making API calls.`
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
const config = {
|
|
1506
|
+
clientId,
|
|
1507
|
+
clientSecret,
|
|
1508
|
+
environment,
|
|
1509
|
+
...apiBaseUrl ? { apiBaseUrl } : {},
|
|
1510
|
+
...oauthBaseUrl ? { oauthBaseUrl } : {},
|
|
1511
|
+
...partnerApiBaseUrl ? { partnerApiBaseUrl } : {},
|
|
1512
|
+
...partnerClientId ? { partnerClientId } : {},
|
|
1513
|
+
...partnerClientSecret ? { partnerClientSecret } : {},
|
|
1514
|
+
...timeout ? { timeout } : {},
|
|
1515
|
+
...options.retry ? { retry: options.retry } : {},
|
|
1516
|
+
...options.onRequest ? { onRequest: options.onRequest } : {},
|
|
1517
|
+
...options.onResponse ? { onResponse: options.onResponse } : {},
|
|
1518
|
+
...options.mle ? { mle: options.mle } : {},
|
|
1519
|
+
...mtls ? { mtls } : {}
|
|
1520
|
+
};
|
|
1521
|
+
const server = new EdgeConnectServer(config);
|
|
1522
|
+
return {
|
|
1523
|
+
server,
|
|
1524
|
+
config,
|
|
1525
|
+
capabilities: {
|
|
1526
|
+
mtlsEnabled: !!config.mtls?.enabled,
|
|
1527
|
+
webhookSyncConfigured: !!config.partnerClientId && !!config.partnerClientSecret,
|
|
1528
|
+
customApiBaseUrl: !!config.apiBaseUrl,
|
|
1529
|
+
customOAuthBaseUrl: !!config.oauthBaseUrl,
|
|
1530
|
+
customPartnerApiBaseUrl: !!config.partnerApiBaseUrl
|
|
1531
|
+
},
|
|
1532
|
+
warnings
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// src/error-adapter.ts
|
|
1537
|
+
var import_connect6 = require("@edge-markets/connect");
|
|
1538
|
+
function toEdgeHttpError(error) {
|
|
1539
|
+
const body = toEdgeProblemJson(error);
|
|
1540
|
+
return { status: body.status, body };
|
|
1541
|
+
}
|
|
1542
|
+
function toEdgeProblemJson(error) {
|
|
1543
|
+
if (error instanceof import_connect6.EdgeConsentRequiredError) {
|
|
1544
|
+
return problem(error, "Consent required", error.statusCode ?? 403, {
|
|
1545
|
+
clientId: error.clientId,
|
|
1546
|
+
consentUrl: error.consentUrl
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
if (error instanceof import_connect6.EdgeValidationError) {
|
|
1550
|
+
return problem(error, "Validation failed", error.statusCode ?? 400, {
|
|
1551
|
+
validationErrors: error.validationErrors
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
if (error instanceof import_connect6.EdgeIdentityVerificationError) {
|
|
1555
|
+
return problem(error, "Identity verification failed", error.statusCode ?? 422, {
|
|
1556
|
+
fieldErrors: error.fieldErrors
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
if (error instanceof import_connect6.EdgeNetworkError) {
|
|
1560
|
+
return problem(error, "EDGE network error", error.statusCode ?? 502);
|
|
1561
|
+
}
|
|
1562
|
+
if (error instanceof import_connect6.EdgeError) {
|
|
1563
|
+
return problem(error, edgeTitle(error), error.statusCode ?? 500, error.details);
|
|
1564
|
+
}
|
|
1565
|
+
const message = error instanceof Error ? error.message : "Unexpected error";
|
|
1566
|
+
return {
|
|
1567
|
+
type: "https://developer.edgemarkets.io/errors/internal_error",
|
|
1568
|
+
title: "Internal error",
|
|
1569
|
+
status: 500,
|
|
1570
|
+
detail: message,
|
|
1571
|
+
code: "internal_error"
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
function problem(error, title, status, details) {
|
|
1575
|
+
return {
|
|
1576
|
+
type: `https://developer.edgemarkets.io/errors/${error.code}`,
|
|
1577
|
+
title,
|
|
1578
|
+
status,
|
|
1579
|
+
detail: error.message,
|
|
1580
|
+
code: error.code,
|
|
1581
|
+
...details && Object.keys(details).length > 0 ? { details } : {}
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
function edgeTitle(error) {
|
|
1585
|
+
return error.code.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// src/identity.ts
|
|
1589
|
+
var import_connect7 = require("@edge-markets/connect");
|
|
1590
|
+
var DEFAULT_IDENTITY_POLICY = {
|
|
1591
|
+
minNameScore: 70,
|
|
1592
|
+
minAddressScore: 65,
|
|
1593
|
+
requireZipMatch: true,
|
|
1594
|
+
requireApiVerified: true
|
|
1595
|
+
};
|
|
1596
|
+
function buildVerifyIdentityPayload(profile) {
|
|
1597
|
+
const firstName = firstNonEmpty(profile.firstName, profile.givenName);
|
|
1598
|
+
const lastName = firstNonEmpty(profile.lastName, profile.familyName);
|
|
1599
|
+
const address = profile.address ?? {};
|
|
1600
|
+
const payload = {
|
|
1601
|
+
firstName,
|
|
1602
|
+
lastName,
|
|
1603
|
+
address: {
|
|
1604
|
+
street: firstNonEmpty(address.street, address.line1, address.address1),
|
|
1605
|
+
city: firstNonEmpty(address.city),
|
|
1606
|
+
state: firstNonEmpty(address.state),
|
|
1607
|
+
zip: firstNonEmpty(address.zip, address.postalCode)
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
const errors = {};
|
|
1611
|
+
if (!payload.firstName) errors.firstName = ["Required"];
|
|
1612
|
+
if (!payload.lastName) errors.lastName = ["Required"];
|
|
1613
|
+
if (!Object.values(payload.address).some((value) => !!value)) {
|
|
1614
|
+
errors.address = ["At least one address field is required"];
|
|
1615
|
+
}
|
|
1616
|
+
if (Object.keys(errors).length > 0) {
|
|
1617
|
+
throw new import_connect7.EdgeValidationError("Partner identity profile is incomplete", errors);
|
|
1618
|
+
}
|
|
1619
|
+
return payload;
|
|
1620
|
+
}
|
|
1621
|
+
function evaluateIdentityResult(result, policy = {}) {
|
|
1622
|
+
const resolved = { ...DEFAULT_IDENTITY_POLICY, ...policy };
|
|
1623
|
+
validatePolicy(resolved);
|
|
1624
|
+
const reasons = [];
|
|
1625
|
+
const verifiedFlag = result.verified;
|
|
1626
|
+
if (resolved.requireApiVerified && verifiedFlag !== true) {
|
|
1627
|
+
reasons.push("api_not_verified");
|
|
1628
|
+
}
|
|
1629
|
+
const scores = result.scores;
|
|
1630
|
+
if (!scores) {
|
|
1631
|
+
reasons.push("scores_missing");
|
|
1632
|
+
} else {
|
|
1633
|
+
if (typeof scores.name === "number" && scores.name < resolved.minNameScore) {
|
|
1634
|
+
reasons.push("name_score_below_threshold");
|
|
1635
|
+
}
|
|
1636
|
+
if (typeof scores.address === "number" && scores.address < resolved.minAddressScore) {
|
|
1637
|
+
reasons.push("address_score_below_threshold");
|
|
1638
|
+
}
|
|
1639
|
+
if (resolved.requireZipMatch && scores.zipMatch !== true) {
|
|
1640
|
+
reasons.push("zip_mismatch");
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
return {
|
|
1644
|
+
verified: reasons.length === 0,
|
|
1645
|
+
reasons,
|
|
1646
|
+
...scores ? { scores } : {}
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
function validatePolicy(policy) {
|
|
1650
|
+
const errors = {};
|
|
1651
|
+
if (!isScore(policy.minNameScore)) errors.minNameScore = ["Must be between 0 and 100"];
|
|
1652
|
+
if (!isScore(policy.minAddressScore)) errors.minAddressScore = ["Must be between 0 and 100"];
|
|
1653
|
+
if (Object.keys(errors).length > 0) {
|
|
1654
|
+
throw new import_connect7.EdgeValidationError("Invalid identity policy", errors);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
function isScore(value) {
|
|
1658
|
+
return Number.isFinite(value) && value >= 0 && value <= 100;
|
|
1659
|
+
}
|
|
1660
|
+
function firstNonEmpty(...values) {
|
|
1661
|
+
for (const value of values) {
|
|
1662
|
+
if (typeof value === "string") {
|
|
1663
|
+
const trimmed = value.trim();
|
|
1664
|
+
if (trimmed) return trimmed;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return "";
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// src/token-vault.ts
|
|
1671
|
+
var import_connect8 = require("@edge-markets/connect");
|
|
1672
|
+
var import_node_crypto2 = __toESM(require("crypto"));
|
|
1673
|
+
var TOKEN_VAULT_VERSION = "edge_vault_v1";
|
|
1674
|
+
var IV_BYTES = 12;
|
|
1675
|
+
var TAG_BYTES = 16;
|
|
1676
|
+
var KEY_BYTES = 32;
|
|
1677
|
+
var EdgeTokenVault = class {
|
|
1678
|
+
constructor(options) {
|
|
1679
|
+
this.currentKey = normalizeVaultKey(options.currentKey, "currentKey");
|
|
1680
|
+
this.keysById = /* @__PURE__ */ new Map([[this.currentKey.id, this.currentKey.key]]);
|
|
1681
|
+
for (const [index, key] of (options.previousKeys ?? []).entries()) {
|
|
1682
|
+
const normalized = normalizeVaultKey(key, `previousKeys[${index}]`);
|
|
1683
|
+
if (this.keysById.has(normalized.id)) {
|
|
1684
|
+
throw new import_connect8.EdgeValidationError("Duplicate token vault key ID", {
|
|
1685
|
+
keyId: [`Duplicate key ID "${normalized.id}"`]
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
this.keysById.set(normalized.id, normalized.key);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
encryptTokens(tokens) {
|
|
1692
|
+
validateEdgeTokens(tokens);
|
|
1693
|
+
const iv = import_node_crypto2.default.randomBytes(IV_BYTES);
|
|
1694
|
+
const aad = Buffer.from(`${TOKEN_VAULT_VERSION}.${this.currentKey.id}`, "utf8");
|
|
1695
|
+
const cipher = import_node_crypto2.default.createCipheriv("aes-256-gcm", this.currentKey.key, iv);
|
|
1696
|
+
cipher.setAAD(aad);
|
|
1697
|
+
const plaintext = Buffer.from(JSON.stringify(tokens), "utf8");
|
|
1698
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
1699
|
+
const tag = cipher.getAuthTag();
|
|
1700
|
+
return [
|
|
1701
|
+
TOKEN_VAULT_VERSION,
|
|
1702
|
+
toBase64Url2(Buffer.from(this.currentKey.id, "utf8")),
|
|
1703
|
+
toBase64Url2(iv),
|
|
1704
|
+
toBase64Url2(tag),
|
|
1705
|
+
toBase64Url2(ciphertext)
|
|
1706
|
+
].join(".");
|
|
1707
|
+
}
|
|
1708
|
+
decryptTokens(envelope) {
|
|
1709
|
+
const parsed = parseEnvelope(envelope);
|
|
1710
|
+
const key = this.keysById.get(parsed.keyId);
|
|
1711
|
+
if (!key) {
|
|
1712
|
+
throw new import_connect8.EdgeValidationError("Unknown token vault key ID", {
|
|
1713
|
+
keyId: [`No key configured for "${parsed.keyId}"`]
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
try {
|
|
1717
|
+
const decipher = import_node_crypto2.default.createDecipheriv("aes-256-gcm", key, parsed.iv);
|
|
1718
|
+
decipher.setAAD(Buffer.from(`${TOKEN_VAULT_VERSION}.${parsed.keyId}`, "utf8"));
|
|
1719
|
+
decipher.setAuthTag(parsed.tag);
|
|
1720
|
+
const plaintext = Buffer.concat([decipher.update(parsed.ciphertext), decipher.final()]).toString("utf8");
|
|
1721
|
+
const tokens = JSON.parse(plaintext);
|
|
1722
|
+
validateEdgeTokens(tokens);
|
|
1723
|
+
return tokens;
|
|
1724
|
+
} catch (error) {
|
|
1725
|
+
if (error instanceof import_connect8.EdgeValidationError) throw error;
|
|
1726
|
+
throw new import_connect8.EdgeValidationError("Failed to decrypt EDGE tokens", {
|
|
1727
|
+
tokenEnvelope: ["Envelope is malformed, corrupted, or encrypted with a different key"]
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
isEncrypted(value) {
|
|
1732
|
+
return typeof value === "string" && value.startsWith(`${TOKEN_VAULT_VERSION}.`);
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
function createEdgeTokenVault(options) {
|
|
1736
|
+
return new EdgeTokenVault(options);
|
|
1737
|
+
}
|
|
1738
|
+
function isEdgeTokenVaultEnvelope(value) {
|
|
1739
|
+
return typeof value === "string" && value.startsWith(`${TOKEN_VAULT_VERSION}.`);
|
|
1740
|
+
}
|
|
1741
|
+
function normalizeVaultKey(input, fieldName) {
|
|
1742
|
+
const id = typeof input.id === "string" ? input.id.trim() : "";
|
|
1743
|
+
if (!id) {
|
|
1744
|
+
throw new import_connect8.EdgeValidationError("Token vault key ID is required", {
|
|
1745
|
+
[`${fieldName}.id`]: ["Required"]
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
return {
|
|
1749
|
+
id,
|
|
1750
|
+
key: normalizeKeyMaterial(input.key, `${fieldName}.key`)
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
function normalizeKeyMaterial(input, fieldName) {
|
|
1754
|
+
const candidates = [];
|
|
1755
|
+
if (Buffer.isBuffer(input)) {
|
|
1756
|
+
candidates.push(input);
|
|
1757
|
+
} else if (input instanceof Uint8Array) {
|
|
1758
|
+
candidates.push(Buffer.from(input));
|
|
1759
|
+
} else if (typeof input === "string") {
|
|
1760
|
+
const value = input.trim();
|
|
1761
|
+
if (value.startsWith("base64:")) {
|
|
1762
|
+
candidates.push(Buffer.from(value.slice("base64:".length), "base64"));
|
|
1763
|
+
} else if (value.startsWith("base64url:")) {
|
|
1764
|
+
candidates.push(fromBase64Url2(value.slice("base64url:".length)));
|
|
1765
|
+
} else if (/^[0-9a-f]{64}$/i.test(value)) {
|
|
1766
|
+
candidates.push(Buffer.from(value, "hex"));
|
|
1767
|
+
} else {
|
|
1768
|
+
candidates.push(Buffer.from(value, "base64"));
|
|
1769
|
+
candidates.push(fromBase64Url2(value));
|
|
1770
|
+
candidates.push(Buffer.from(value, "utf8"));
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
const key = candidates.find((candidate) => candidate.length === KEY_BYTES);
|
|
1774
|
+
if (!key) {
|
|
1775
|
+
throw new import_connect8.EdgeValidationError("Token vault key must decode to 32 bytes", {
|
|
1776
|
+
[fieldName]: ["Provide a 32-byte key as base64, base64url, hex, Buffer, or Uint8Array"]
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
return key;
|
|
1780
|
+
}
|
|
1781
|
+
function validateEdgeTokens(tokens) {
|
|
1782
|
+
const errors = {};
|
|
1783
|
+
if (!tokens || typeof tokens !== "object") {
|
|
1784
|
+
errors.tokens = ["Expected token object"];
|
|
1785
|
+
} else {
|
|
1786
|
+
if (typeof tokens.accessToken !== "string" || !tokens.accessToken) {
|
|
1787
|
+
errors.accessToken = ["Required"];
|
|
1788
|
+
}
|
|
1789
|
+
if (typeof tokens.refreshToken !== "string" || !tokens.refreshToken) {
|
|
1790
|
+
errors.refreshToken = ["Required"];
|
|
1791
|
+
}
|
|
1792
|
+
if (typeof tokens.expiresIn !== "number" || !Number.isFinite(tokens.expiresIn) || tokens.expiresIn <= 0) {
|
|
1793
|
+
errors.expiresIn = ["Must be a positive number"];
|
|
1794
|
+
}
|
|
1795
|
+
if (typeof tokens.expiresAt !== "number" || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) {
|
|
1796
|
+
errors.expiresAt = ["Must be a positive Unix timestamp in milliseconds"];
|
|
1797
|
+
}
|
|
1798
|
+
if (typeof tokens.scope !== "string") {
|
|
1799
|
+
errors.scope = ["Required"];
|
|
1800
|
+
}
|
|
1801
|
+
if (tokens.idToken !== void 0 && typeof tokens.idToken !== "string") {
|
|
1802
|
+
errors.idToken = ["Must be a string when provided"];
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
if (Object.keys(errors).length > 0) {
|
|
1806
|
+
throw new import_connect8.EdgeValidationError("Invalid EDGE token payload", errors);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
function parseEnvelope(envelope) {
|
|
1810
|
+
if (typeof envelope !== "string") {
|
|
1811
|
+
throw new import_connect8.EdgeValidationError("Token envelope must be a string", {
|
|
1812
|
+
tokenEnvelope: ["Expected string"]
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
const parts = envelope.split(".");
|
|
1816
|
+
if (parts.length !== 5 || parts[0] !== TOKEN_VAULT_VERSION) {
|
|
1817
|
+
throw new import_connect8.EdgeValidationError("Invalid EDGE token vault envelope", {
|
|
1818
|
+
tokenEnvelope: [`Expected ${TOKEN_VAULT_VERSION}.<keyId>.<iv>.<tag>.<ciphertext>`]
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
const [, encodedKeyId, encodedIv, encodedTag, encodedCiphertext] = parts;
|
|
1822
|
+
const keyId = fromBase64Url2(encodedKeyId).toString("utf8");
|
|
1823
|
+
const iv = fromBase64Url2(encodedIv);
|
|
1824
|
+
const tag = fromBase64Url2(encodedTag);
|
|
1825
|
+
const ciphertext = fromBase64Url2(encodedCiphertext);
|
|
1826
|
+
const errors = {};
|
|
1827
|
+
if (!keyId) errors.keyId = ["Required"];
|
|
1828
|
+
if (iv.length !== IV_BYTES) errors.iv = [`Must be ${IV_BYTES} bytes`];
|
|
1829
|
+
if (tag.length !== TAG_BYTES) errors.tag = [`Must be ${TAG_BYTES} bytes`];
|
|
1830
|
+
if (ciphertext.length === 0) errors.ciphertext = ["Required"];
|
|
1831
|
+
if (Object.keys(errors).length > 0) {
|
|
1832
|
+
throw new import_connect8.EdgeValidationError("Invalid EDGE token vault envelope", errors);
|
|
1833
|
+
}
|
|
1834
|
+
return { keyId, iv, tag, ciphertext };
|
|
1835
|
+
}
|
|
1836
|
+
function toBase64Url2(value) {
|
|
1837
|
+
return value.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
1838
|
+
}
|
|
1839
|
+
function fromBase64Url2(value) {
|
|
1840
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
1841
|
+
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - normalized.length % 4);
|
|
1842
|
+
return Buffer.from(normalized + padding, "base64");
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// src/webhook-guards.ts
|
|
1846
|
+
var import_connect9 = require("@edge-markets/connect");
|
|
1847
|
+
function isTransferWebhookEvent(event) {
|
|
1848
|
+
return event.type.startsWith("transfer.");
|
|
1849
|
+
}
|
|
1850
|
+
function assertTransferEventMatchesIntent(event, intent) {
|
|
1851
|
+
if (!isTransferWebhookEvent(event)) {
|
|
1852
|
+
throw new import_connect9.EdgeValidationError("Webhook event is not a transfer event", {
|
|
1853
|
+
type: [`Received ${event.type}`]
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
const data = event.data;
|
|
1857
|
+
const errors = {};
|
|
1858
|
+
if (data.transferId !== intent.transferId) {
|
|
1859
|
+
errors.transferId = [`Expected ${intent.transferId}, received ${data.transferId}`];
|
|
1860
|
+
}
|
|
1861
|
+
if (intent.type && data.type !== intent.type) {
|
|
1862
|
+
errors.type = [`Expected ${intent.type}, received ${data.type}`];
|
|
1863
|
+
}
|
|
1864
|
+
if (intent.status && data.status !== intent.status) {
|
|
1865
|
+
errors.status = [`Expected ${intent.status}, received ${data.status}`];
|
|
1866
|
+
}
|
|
1867
|
+
if (intent.amount !== void 0) {
|
|
1868
|
+
const expectedAmount = normalizeMoneyAmount(intent.amount);
|
|
1869
|
+
const receivedAmount = normalizeMoneyAmount(data.amount);
|
|
1870
|
+
if (receivedAmount !== expectedAmount) {
|
|
1871
|
+
errors.amount = [`Expected ${expectedAmount}, received ${receivedAmount}`];
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
if (Object.keys(errors).length > 0) {
|
|
1875
|
+
throw new import_connect9.EdgeValidationError("Webhook transfer event does not match expected transfer intent", errors);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// src/webhook-parser.ts
|
|
1880
|
+
var import_connect10 = require("@edge-markets/connect");
|
|
1881
|
+
|
|
1882
|
+
// src/webhook-signing.ts
|
|
1883
|
+
var import_node_crypto3 = __toESM(require("crypto"));
|
|
1884
|
+
function verifyWebhookSignature(header, body, secret, options = {}) {
|
|
1885
|
+
if (typeof header !== "string" || typeof body !== "string" || typeof secret !== "string") {
|
|
1886
|
+
return false;
|
|
1887
|
+
}
|
|
1888
|
+
const requestedTolerance = options.toleranceSeconds;
|
|
1889
|
+
const toleranceSeconds = typeof requestedTolerance === "number" && Number.isFinite(requestedTolerance) && requestedTolerance >= 0 ? requestedTolerance : 300;
|
|
1890
|
+
const parts = header.split(",").map((p) => p.trim());
|
|
1891
|
+
const tPart = parts.find((p) => p.startsWith("t="));
|
|
1892
|
+
const v1Part = parts.find((p) => p.startsWith("v1="));
|
|
1893
|
+
if (!tPart || !v1Part) return false;
|
|
1894
|
+
const timestamp = parseInt(tPart.slice(2), 10);
|
|
1895
|
+
const signature = v1Part.slice(3);
|
|
1896
|
+
if (isNaN(timestamp) || !signature) return false;
|
|
1897
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1898
|
+
if (Math.abs(now - timestamp) > toleranceSeconds) return false;
|
|
1899
|
+
const signedContent = `${timestamp}.${body}`;
|
|
1900
|
+
const expectedHmac = import_node_crypto3.default.createHmac("sha256", secret).update(signedContent).digest("hex");
|
|
1901
|
+
try {
|
|
1902
|
+
const sigBuf = Buffer.from(signature, "hex");
|
|
1903
|
+
const expBuf = Buffer.from(expectedHmac, "hex");
|
|
1904
|
+
if (sigBuf.length !== expBuf.length) return false;
|
|
1905
|
+
return import_node_crypto3.default.timingSafeEqual(sigBuf, expBuf);
|
|
1906
|
+
} catch {
|
|
1907
|
+
return false;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// src/webhook-parser.ts
|
|
1912
|
+
var EVENT_TYPES = new Set(import_connect10.EDGE_WEBHOOK_EVENT_TYPES);
|
|
1913
|
+
var TRANSFER_TYPES = /* @__PURE__ */ new Set(["debit", "credit"]);
|
|
1914
|
+
function parseAndVerifyWebhook(options) {
|
|
1915
|
+
const body = normalizeRawBody(options.rawBody);
|
|
1916
|
+
const header = normalizeSignatureHeader(options.signatureHeader);
|
|
1917
|
+
const signatureTimestamp = extractWebhookSignatureTimestamp(header);
|
|
1918
|
+
if (!header || !signatureTimestamp) {
|
|
1919
|
+
throw new import_connect10.EdgeAuthenticationError("Missing or malformed EDGE webhook signature");
|
|
1920
|
+
}
|
|
1921
|
+
if (!verifyWebhookSignature(header, body, options.secret, { toleranceSeconds: options.toleranceSeconds })) {
|
|
1922
|
+
throw new import_connect10.EdgeAuthenticationError("Invalid EDGE webhook signature");
|
|
1923
|
+
}
|
|
1924
|
+
let parsed;
|
|
1925
|
+
try {
|
|
1926
|
+
parsed = JSON.parse(body);
|
|
1927
|
+
} catch {
|
|
1928
|
+
throw new import_connect10.EdgeValidationError("EDGE webhook body must be valid JSON", {
|
|
1929
|
+
rawBody: ["Unable to parse JSON after signature verification"]
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
const event = validateEdgeWebhookEvent(parsed);
|
|
1933
|
+
return {
|
|
1934
|
+
event,
|
|
1935
|
+
eventId: event.id,
|
|
1936
|
+
eventType: event.type,
|
|
1937
|
+
signatureTimestamp,
|
|
1938
|
+
receivedAt: options.receivedAt ?? /* @__PURE__ */ new Date()
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
function extractWebhookSignatureTimestamp(header) {
|
|
1942
|
+
const normalizedHeader = normalizeSignatureHeader(header);
|
|
1943
|
+
if (!normalizedHeader) return void 0;
|
|
1944
|
+
const part = normalizedHeader.split(",").map((value) => value.trim()).find((value) => value.startsWith("t="));
|
|
1945
|
+
if (!part) return void 0;
|
|
1946
|
+
const timestamp = Number(part.slice(2));
|
|
1947
|
+
if (!Number.isInteger(timestamp) || timestamp <= 0) return void 0;
|
|
1948
|
+
return timestamp;
|
|
1949
|
+
}
|
|
1950
|
+
function isEdgeWebhookEvent(value) {
|
|
1951
|
+
try {
|
|
1952
|
+
validateEdgeWebhookEvent(value);
|
|
1953
|
+
return true;
|
|
1954
|
+
} catch {
|
|
1955
|
+
return false;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
function validateEdgeWebhookEvent(value) {
|
|
1959
|
+
const errors = {};
|
|
1960
|
+
if (!isRecord(value)) {
|
|
1961
|
+
throw new import_connect10.EdgeValidationError("EDGE webhook event must be an object", {
|
|
1962
|
+
event: ["Expected JSON object"]
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
const id = value.id;
|
|
1966
|
+
const type = value.type;
|
|
1967
|
+
const createdAt = value.created_at;
|
|
1968
|
+
const data = value.data;
|
|
1969
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
1970
|
+
errors.id = ["Required string"];
|
|
1971
|
+
}
|
|
1972
|
+
if (typeof type !== "string" || !EVENT_TYPES.has(type)) {
|
|
1973
|
+
errors.type = [`Must be one of: ${import_connect10.EDGE_WEBHOOK_EVENT_TYPES.join(", ")}`];
|
|
1974
|
+
}
|
|
1975
|
+
if (typeof createdAt !== "string" || !createdAt.trim() || Number.isNaN(Date.parse(createdAt))) {
|
|
1976
|
+
errors.created_at = ["Required ISO 8601 timestamp string"];
|
|
1977
|
+
}
|
|
1978
|
+
if (!isRecord(data)) {
|
|
1979
|
+
errors.data = ["Required object"];
|
|
1980
|
+
}
|
|
1981
|
+
if (Object.keys(errors).length === 0) {
|
|
1982
|
+
validateKnownEventData(type, data, errors);
|
|
1983
|
+
}
|
|
1984
|
+
if (Object.keys(errors).length > 0) {
|
|
1985
|
+
throw new import_connect10.EdgeValidationError("Invalid EDGE webhook event", errors);
|
|
1986
|
+
}
|
|
1987
|
+
return value;
|
|
1988
|
+
}
|
|
1989
|
+
function validateKnownEventData(type, data, errors) {
|
|
1990
|
+
switch (type) {
|
|
1991
|
+
case "transfer.completed":
|
|
1992
|
+
validateTransferEventData(data, "completed", errors);
|
|
1993
|
+
return;
|
|
1994
|
+
case "transfer.failed":
|
|
1995
|
+
validateTransferEventData(data, "failed", errors);
|
|
1996
|
+
if (data.reason !== void 0 && typeof data.reason !== "string") {
|
|
1997
|
+
errors["data.reason"] = ["Must be a string when provided"];
|
|
1998
|
+
}
|
|
1999
|
+
return;
|
|
2000
|
+
case "transfer.expired":
|
|
2001
|
+
validateTransferEventData(data, "expired", errors);
|
|
2002
|
+
return;
|
|
2003
|
+
case "transfer.processing":
|
|
2004
|
+
validateTransferEventData(data, "processing", errors);
|
|
2005
|
+
return;
|
|
2006
|
+
case "consent.revoked":
|
|
2007
|
+
validateRequiredString(data, "userId", errors);
|
|
2008
|
+
validateRequiredString(data, "clientId", errors);
|
|
2009
|
+
validateRequiredIsoTimestamp(data, "revokedAt", errors);
|
|
2010
|
+
return;
|
|
2011
|
+
default: {
|
|
2012
|
+
const exhaustive = type;
|
|
2013
|
+
throw new import_connect10.EdgeValidationError("Unsupported EDGE webhook event type", {
|
|
2014
|
+
type: [`Unsupported event type: ${exhaustive}`]
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
function validateTransferEventData(data, status, errors) {
|
|
2020
|
+
validateRequiredString(data, "transferId", errors);
|
|
2021
|
+
if (data.status !== status) {
|
|
2022
|
+
errors["data.status"] = [`Must be "${status}" for this event type`];
|
|
2023
|
+
}
|
|
2024
|
+
if (typeof data.type !== "string" || !TRANSFER_TYPES.has(data.type)) {
|
|
2025
|
+
errors["data.type"] = ['Must be "debit" or "credit"'];
|
|
2026
|
+
}
|
|
2027
|
+
validateRequiredString(data, "amount", errors);
|
|
2028
|
+
}
|
|
2029
|
+
function validateRequiredString(data, field, errors) {
|
|
2030
|
+
if (typeof data[field] !== "string" || !data[field].trim()) {
|
|
2031
|
+
errors[`data.${field}`] = ["Required string"];
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
function validateRequiredIsoTimestamp(data, field, errors) {
|
|
2035
|
+
if (typeof data[field] !== "string" || !data[field].trim() || Number.isNaN(Date.parse(data[field]))) {
|
|
2036
|
+
errors[`data.${field}`] = ["Required ISO 8601 timestamp string"];
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
function normalizeRawBody(rawBody) {
|
|
2040
|
+
if (typeof rawBody === "string") {
|
|
2041
|
+
return rawBody;
|
|
2042
|
+
}
|
|
2043
|
+
if (Buffer.isBuffer(rawBody) || rawBody instanceof Uint8Array) {
|
|
2044
|
+
return Buffer.from(rawBody).toString("utf8");
|
|
2045
|
+
}
|
|
2046
|
+
throw new import_connect10.EdgeValidationError("EDGE webhook raw body is required", {
|
|
2047
|
+
rawBody: ["Pass the raw UTF-8 request body, not a parsed JSON object"]
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
function normalizeSignatureHeader(header) {
|
|
2051
|
+
if (Array.isArray(header)) {
|
|
2052
|
+
return header.map((value) => value.trim()).filter(Boolean).join(",");
|
|
2053
|
+
}
|
|
2054
|
+
if (typeof header !== "string") return void 0;
|
|
2055
|
+
const trimmed = header.trim();
|
|
2056
|
+
return trimmed || void 0;
|
|
2057
|
+
}
|
|
2058
|
+
function isRecord(value) {
|
|
2059
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2060
|
+
}
|
|
2061
|
+
|
|
641
2062
|
// src/index.ts
|
|
642
|
-
var
|
|
643
|
-
var
|
|
2063
|
+
var import_connect11 = require("@edge-markets/connect");
|
|
2064
|
+
var import_connect12 = require("@edge-markets/connect");
|
|
644
2065
|
// Annotate the CommonJS export names for ESM import in node:
|
|
645
2066
|
0 && (module.exports = {
|
|
2067
|
+
EDGE_WEBHOOK_EVENT_TYPES,
|
|
646
2068
|
EdgeApiError,
|
|
647
2069
|
EdgeAuthenticationError,
|
|
648
2070
|
EdgeConnectServer,
|
|
@@ -653,14 +2075,39 @@ var import_connect4 = require("@edge-markets/connect");
|
|
|
653
2075
|
EdgeNetworkError,
|
|
654
2076
|
EdgeNotFoundError,
|
|
655
2077
|
EdgeTokenExchangeError,
|
|
2078
|
+
EdgeTokenVault,
|
|
656
2079
|
EdgeUserClient,
|
|
2080
|
+
EdgeUserSession,
|
|
2081
|
+
EdgeValidationError,
|
|
2082
|
+
EdgeWebhookReconciler,
|
|
657
2083
|
TRANSFER_CATEGORIES,
|
|
2084
|
+
assertSameTransferIntent,
|
|
2085
|
+
assertTransferEventMatchesIntent,
|
|
2086
|
+
buildVerifyIdentityPayload,
|
|
2087
|
+
createEdgeConnectServerFromEnv,
|
|
2088
|
+
createEdgeTokenVault,
|
|
2089
|
+
createEdgeUserSession,
|
|
2090
|
+
createTransferIdempotencyKey,
|
|
2091
|
+
createWebhookReconciler,
|
|
2092
|
+
evaluateIdentityResult,
|
|
2093
|
+
extractWebhookSignatureTimestamp,
|
|
658
2094
|
getEnvironmentConfig,
|
|
659
2095
|
isApiError,
|
|
660
2096
|
isAuthenticationError,
|
|
661
2097
|
isConsentRequiredError,
|
|
662
2098
|
isEdgeError,
|
|
2099
|
+
isEdgeTokenVaultEnvelope,
|
|
2100
|
+
isEdgeWebhookEvent,
|
|
663
2101
|
isIdentityVerificationError,
|
|
664
2102
|
isNetworkError,
|
|
665
|
-
isProductionEnvironment
|
|
2103
|
+
isProductionEnvironment,
|
|
2104
|
+
isTransferWebhookEvent,
|
|
2105
|
+
normalizeMoneyAmount,
|
|
2106
|
+
parseAndVerifyWebhook,
|
|
2107
|
+
startTransferVerification,
|
|
2108
|
+
toEdgeHttpError,
|
|
2109
|
+
toEdgeProblemJson,
|
|
2110
|
+
validateEdgeWebhookEvent,
|
|
2111
|
+
validateTransferAmount,
|
|
2112
|
+
verifyWebhookSignature
|
|
666
2113
|
});
|