@edge-markets/connect-node 1.0.3 → 1.2.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/dist/index.js CHANGED
@@ -43,13 +43,28 @@ module.exports = __toCommonJS(index_exports);
43
43
  var import_connect = require("@edge-markets/connect");
44
44
  var DEFAULT_TIMEOUT = 3e4;
45
45
  var USER_AGENT = "@edge-markets/connect-node/1.0.0";
46
- var EdgeConnectServer = class {
47
- /**
48
- * Creates a new EdgeConnectServer instance.
49
- *
50
- * @param config - Server configuration
51
- * @throws Error if required config is missing
52
- */
46
+ var DEFAULT_RETRY_CONFIG = {
47
+ maxRetries: 3,
48
+ retryOn: [408, 429, 500, 502, 503, 504],
49
+ backoff: "exponential",
50
+ baseDelayMs: 1e3
51
+ };
52
+ var instances = /* @__PURE__ */ new Map();
53
+ function getInstanceKey(config) {
54
+ return `${config.clientId}:${config.environment}`;
55
+ }
56
+ var EdgeConnectServer = class _EdgeConnectServer {
57
+ static getInstance(config) {
58
+ const key = getInstanceKey(config);
59
+ const existing = instances.get(key);
60
+ if (existing) return existing;
61
+ const instance = new _EdgeConnectServer(config);
62
+ instances.set(key, instance);
63
+ return instance;
64
+ }
65
+ static clearInstances() {
66
+ instances.clear();
67
+ }
53
68
  constructor(config) {
54
69
  if (!config.clientId) {
55
70
  throw new Error("EdgeConnectServer: clientId is required");
@@ -63,66 +78,29 @@ var EdgeConnectServer = class {
63
78
  this.config = config;
64
79
  const envConfig = (0, import_connect.getEnvironmentConfig)(config.environment);
65
80
  this.apiBaseUrl = config.apiBaseUrl || envConfig.apiBaseUrl;
66
- this.oauthBaseUrl = envConfig.oauthBaseUrl;
81
+ this.oauthBaseUrl = config.oauthBaseUrl || envConfig.oauthBaseUrl;
67
82
  this.timeout = config.timeout || DEFAULT_TIMEOUT;
83
+ this.retryConfig = {
84
+ ...DEFAULT_RETRY_CONFIG,
85
+ ...config.retry
86
+ };
68
87
  }
69
- // ===========================================================================
70
- // TOKEN OPERATIONS
71
- // ===========================================================================
72
- /**
73
- * Exchanges an authorization code for tokens.
74
- *
75
- * Call this after receiving the code from EdgeLink's `onSuccess` callback.
76
- * The code is single-use and expires in ~10 minutes.
77
- *
78
- * @param code - Authorization code from EdgeLink
79
- * @param codeVerifier - PKCE code verifier from EdgeLink
80
- * @returns Access token, refresh token, and metadata
81
- * @throws EdgeTokenExchangeError if exchange fails
82
- *
83
- * @example
84
- * ```typescript
85
- * // In your /api/edge/exchange endpoint
86
- * const { code, codeVerifier } = req.body
87
- *
88
- * try {
89
- * const tokens = await edge.exchangeCode(code, codeVerifier)
90
- *
91
- * // Store tokens securely
92
- * await db.edgeConnections.upsert({
93
- * userId: req.user.id,
94
- * accessToken: encrypt(tokens.accessToken),
95
- * refreshToken: encrypt(tokens.refreshToken),
96
- * expiresAt: new Date(tokens.expiresAt),
97
- * })
98
- *
99
- * return { success: true }
100
- * } catch (error) {
101
- * if (error instanceof EdgeTokenExchangeError) {
102
- * // Code expired or already used
103
- * return { error: 'Please try connecting again' }
104
- * }
105
- * throw error
106
- * }
107
- * ```
108
- */
109
- async exchangeCode(code, codeVerifier) {
110
- const tokenUrl = `${this.oauthBaseUrl}/oauth/token`;
88
+ async exchangeCode(code, codeVerifier, redirectUri) {
89
+ const tokenUrl = `${this.oauthBaseUrl}/token`;
111
90
  const body = {
112
91
  grant_type: "authorization_code",
113
92
  code,
114
93
  code_verifier: codeVerifier,
115
94
  client_id: this.config.clientId,
116
- client_secret: this.config.clientSecret
95
+ client_secret: this.config.clientSecret,
96
+ redirect_uri: redirectUri || this.config.redirectUri || ""
117
97
  };
118
98
  try {
119
99
  const response = await this.fetchWithTimeout(tokenUrl, {
120
100
  method: "POST",
121
101
  headers: {
122
102
  "Content-Type": "application/json",
123
- "User-Agent": USER_AGENT,
124
- // Required for ngrok tunnels in local development
125
- "ngrok-skip-browser-warning": "true"
103
+ "User-Agent": USER_AGENT
126
104
  },
127
105
  body: JSON.stringify(body)
128
106
  });
@@ -137,44 +115,8 @@ var EdgeConnectServer = class {
137
115
  throw new import_connect.EdgeNetworkError("Failed to exchange code", error);
138
116
  }
139
117
  }
140
- /**
141
- * Refreshes an access token using a refresh token.
142
- *
143
- * Call this when the access token is expired or about to expire.
144
- * Check `tokens.expiresAt` to know when to refresh.
145
- *
146
- * @param refreshToken - Refresh token from previous exchange
147
- * @returns New tokens (refresh token may or may not change)
148
- * @throws EdgeAuthenticationError if refresh fails
149
- *
150
- * @example
151
- * ```typescript
152
- * // Check if token needs refresh (with 5 minute buffer)
153
- * const BUFFER_MS = 5 * 60 * 1000
154
- *
155
- * async function getValidAccessToken(userId: string): Promise<string> {
156
- * const connection = await db.edgeConnections.get(userId)
157
- *
158
- * if (Date.now() > connection.expiresAt.getTime() - BUFFER_MS) {
159
- * // Token expired or expiring soon - refresh it
160
- * const newTokens = await edge.refreshTokens(decrypt(connection.refreshToken))
161
- *
162
- * // Update stored tokens
163
- * await db.edgeConnections.update(userId, {
164
- * accessToken: encrypt(newTokens.accessToken),
165
- * refreshToken: encrypt(newTokens.refreshToken),
166
- * expiresAt: new Date(newTokens.expiresAt),
167
- * })
168
- *
169
- * return newTokens.accessToken
170
- * }
171
- *
172
- * return decrypt(connection.accessToken)
173
- * }
174
- * ```
175
- */
176
118
  async refreshTokens(refreshToken) {
177
- const tokenUrl = `${this.oauthBaseUrl}/oauth/token`;
119
+ const tokenUrl = `${this.oauthBaseUrl}/token`;
178
120
  const body = {
179
121
  grant_type: "refresh_token",
180
122
  refresh_token: refreshToken,
@@ -186,15 +128,14 @@ var EdgeConnectServer = class {
186
128
  method: "POST",
187
129
  headers: {
188
130
  "Content-Type": "application/json",
189
- "User-Agent": USER_AGENT,
190
- "ngrok-skip-browser-warning": "true"
131
+ "User-Agent": USER_AGENT
191
132
  },
192
133
  body: JSON.stringify(body)
193
134
  });
194
135
  if (!response.ok) {
195
136
  const error = await response.json().catch(() => ({}));
196
137
  throw new import_connect.EdgeAuthenticationError(
197
- error.error_description || "Token refresh failed. User may need to reconnect.",
138
+ error.message || error.error_description || "Token refresh failed. User may need to reconnect.",
198
139
  { tokenError: error }
199
140
  );
200
141
  }
@@ -205,80 +146,14 @@ var EdgeConnectServer = class {
205
146
  throw new import_connect.EdgeNetworkError("Failed to refresh tokens", error);
206
147
  }
207
148
  }
208
- // ===========================================================================
209
- // USER & BALANCE
210
- // ===========================================================================
211
- /**
212
- * Gets the connected user's profile.
213
- *
214
- * Requires scope: `user.read`
215
- *
216
- * @param accessToken - Valid access token
217
- * @returns User profile information
218
- *
219
- * @example
220
- * ```typescript
221
- * const user = await edge.getUser(accessToken)
222
- * console.log(`Connected: ${user.firstName} ${user.lastName}`)
223
- * ```
224
- */
225
149
  async getUser(accessToken) {
226
150
  return this.apiRequest("GET", "/user", accessToken);
227
151
  }
228
- /**
229
- * Gets the connected user's EdgeBoost balance.
230
- *
231
- * Requires scope: `balance.read`
232
- *
233
- * @param accessToken - Valid access token
234
- * @returns Balance information
235
- *
236
- * @example
237
- * ```typescript
238
- * const balance = await edge.getBalance(accessToken)
239
- * console.log(`Balance: $${balance.availableBalance.toFixed(2)} ${balance.currency}`)
240
- * ```
241
- */
242
152
  async getBalance(accessToken) {
243
153
  return this.apiRequest("GET", "/balance", accessToken);
244
154
  }
245
- // ===========================================================================
246
- // TRANSFERS
247
- // ===========================================================================
248
- /**
249
- * Initiates a fund transfer.
250
- *
251
- * Requires scope: `transfer.write`
252
- *
253
- * **Transfer Types:**
254
- * - `debit`: Pull funds FROM user's EdgeBoost TO your platform
255
- * - `credit`: Push funds FROM your platform TO user's EdgeBoost
256
- *
257
- * **Idempotency:** Using the same `idempotencyKey` returns the existing
258
- * transfer instead of creating a duplicate. Use a unique key per transaction.
259
- *
260
- * **OTP Verification:** Transfers require OTP verification before completion.
261
- * The response includes `otpMethod` indicating how the user will receive the code.
262
- *
263
- * @param accessToken - Valid access token
264
- * @param options - Transfer options
265
- * @returns Transfer with status and OTP method
266
- *
267
- * @example
268
- * ```typescript
269
- * const transfer = await edge.initiateTransfer(accessToken, {
270
- * type: 'debit',
271
- * amount: '100.00',
272
- * idempotencyKey: `withdraw_${userId}_${Date.now()}`,
273
- * })
274
- *
275
- * if (transfer.status === 'pending_verification') {
276
- * // Show OTP input to user
277
- * console.log(`Enter code sent via ${transfer.otpMethod}`)
278
- * }
279
- * ```
280
- */
281
155
  async initiateTransfer(accessToken, options) {
156
+ this.validateTransferOptions(options);
282
157
  const body = {
283
158
  type: options.type,
284
159
  amount: options.amount,
@@ -286,28 +161,32 @@ var EdgeConnectServer = class {
286
161
  };
287
162
  return this.apiRequest("POST", "/transfer", accessToken, body);
288
163
  }
289
- /**
290
- * Verifies a pending transfer with OTP.
291
- *
292
- * Call this after the user enters the OTP code they received.
293
- * The OTP is valid for ~5 minutes.
294
- *
295
- * @param accessToken - Valid access token
296
- * @param transferId - Transfer ID from initiateTransfer
297
- * @param otp - 6-digit OTP code from user
298
- * @returns Updated transfer (status will be 'completed' or 'failed')
299
- *
300
- * @example
301
- * ```typescript
302
- * const result = await edge.verifyTransfer(accessToken, transferId, userOtp)
303
- *
304
- * if (result.status === 'completed') {
305
- * console.log('Transfer successful!')
306
- * } else if (result.status === 'failed') {
307
- * console.log('Transfer failed - possibly wrong OTP')
308
- * }
309
- * ```
310
- */
164
+ validateTransferOptions(options) {
165
+ const errors = {};
166
+ if (!options.type || !["debit", "credit"].includes(options.type)) {
167
+ errors.type = ['Must be "debit" or "credit"'];
168
+ }
169
+ if (!options.amount) {
170
+ errors.amount = ["Amount is required"];
171
+ } else {
172
+ const amount = parseFloat(options.amount);
173
+ if (isNaN(amount)) {
174
+ errors.amount = ["Must be a valid number"];
175
+ } else if (amount <= 0) {
176
+ errors.amount = ["Must be greater than 0"];
177
+ } else if (!/^\d+(\.\d{1,2})?$/.test(options.amount)) {
178
+ errors.amount = ["Must have at most 2 decimal places"];
179
+ }
180
+ }
181
+ if (!options.idempotencyKey || options.idempotencyKey.trim() === "") {
182
+ errors.idempotencyKey = ["idempotencyKey is required"];
183
+ } else if (options.idempotencyKey.length > 255) {
184
+ errors.idempotencyKey = ["Must be 255 characters or less"];
185
+ }
186
+ if (Object.keys(errors).length > 0) {
187
+ throw new import_connect.EdgeValidationError("Invalid transfer options", errors);
188
+ }
189
+ }
311
190
  async verifyTransfer(accessToken, transferId, otp) {
312
191
  return this.apiRequest(
313
192
  "POST",
@@ -316,21 +195,6 @@ var EdgeConnectServer = class {
316
195
  { otp }
317
196
  );
318
197
  }
319
- /**
320
- * Gets the status of a transfer.
321
- *
322
- * Use for polling after initiating a transfer.
323
- *
324
- * @param accessToken - Valid access token
325
- * @param transferId - Transfer ID
326
- * @returns Current transfer status
327
- *
328
- * @example
329
- * ```typescript
330
- * const transfer = await edge.getTransfer(accessToken, transferId)
331
- * console.log(`Status: ${transfer.status}`)
332
- * ```
333
- */
334
198
  async getTransfer(accessToken, transferId) {
335
199
  return this.apiRequest(
336
200
  "GET",
@@ -338,27 +202,6 @@ var EdgeConnectServer = class {
338
202
  accessToken
339
203
  );
340
204
  }
341
- /**
342
- * Lists transfers for the connected user.
343
- *
344
- * Useful for reconciliation and showing transfer history.
345
- *
346
- * @param accessToken - Valid access token
347
- * @param params - Pagination and filter options
348
- * @returns Paginated list of transfers
349
- *
350
- * @example
351
- * ```typescript
352
- * // Get first page of completed transfers
353
- * const { transfers, total } = await edge.listTransfers(accessToken, {
354
- * status: 'completed',
355
- * limit: 10,
356
- * offset: 0,
357
- * })
358
- *
359
- * console.log(`Showing ${transfers.length} of ${total} transfers`)
360
- * ```
361
- */
362
205
  async listTransfers(accessToken, params) {
363
206
  const queryParams = new URLSearchParams();
364
207
  if (params?.limit) queryParams.set("limit", String(params.limit));
@@ -368,68 +211,65 @@ var EdgeConnectServer = class {
368
211
  const path = query ? `/transfers?${query}` : "/transfers";
369
212
  return this.apiRequest("GET", path, accessToken);
370
213
  }
371
- // ===========================================================================
372
- // CONSENT
373
- // ===========================================================================
374
- /**
375
- * Revokes the user's consent (disconnects their account).
376
- *
377
- * After revocation:
378
- * - All API calls will fail with `consent_required` error
379
- * - User must go through EdgeLink again to reconnect
380
- * - Stored tokens become invalid
381
- *
382
- * Use this for "Disconnect" or "Unlink" features in your app.
383
- *
384
- * @param accessToken - Valid access token
385
- * @returns Confirmation of revocation
386
- *
387
- * @example
388
- * ```typescript
389
- * // Disconnect user's EdgeBoost account
390
- * await edge.revokeConsent(accessToken)
391
- *
392
- * // Clean up stored tokens
393
- * await db.edgeConnections.delete(userId)
394
- *
395
- * console.log('EdgeBoost disconnected')
396
- * ```
397
- */
398
214
  async revokeConsent(accessToken) {
399
215
  return this.apiRequest("DELETE", "/consent", accessToken);
400
216
  }
401
- // ===========================================================================
402
- // PRIVATE HELPERS
403
- // ===========================================================================
404
- /**
405
- * Makes an authenticated API request.
406
- */
217
+ getRetryDelay(attempt) {
218
+ const { backoff, baseDelayMs } = this.retryConfig;
219
+ if (backoff === "exponential") {
220
+ return baseDelayMs * Math.pow(2, attempt);
221
+ }
222
+ return baseDelayMs * (attempt + 1);
223
+ }
224
+ async sleep(ms) {
225
+ return new Promise((resolve) => setTimeout(resolve, ms));
226
+ }
407
227
  async apiRequest(method, path, accessToken, body) {
408
228
  const url = `${this.apiBaseUrl}${path}`;
409
- try {
410
- const response = await this.fetchWithTimeout(url, {
411
- method,
412
- headers: {
413
- Authorization: `Bearer ${accessToken}`,
414
- "Content-Type": "application/json",
415
- "User-Agent": USER_AGENT,
416
- // For ngrok during development
417
- "ngrok-skip-browser-warning": "true"
418
- },
419
- body: body ? JSON.stringify(body) : void 0
420
- });
421
- if (!response.ok) {
422
- throw await this.handleApiError(response, path);
229
+ let lastError = null;
230
+ for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
231
+ const startTime = Date.now();
232
+ if (attempt > 0) {
233
+ await this.sleep(this.getRetryDelay(attempt - 1));
234
+ }
235
+ this.config.onRequest?.({ method, url, body });
236
+ try {
237
+ const response = await this.fetchWithTimeout(url, {
238
+ method,
239
+ headers: {
240
+ Authorization: `Bearer ${accessToken}`,
241
+ "Content-Type": "application/json",
242
+ "User-Agent": USER_AGENT
243
+ },
244
+ body: body ? JSON.stringify(body) : void 0
245
+ });
246
+ const responseBody = await response.json().catch(() => ({}));
247
+ const durationMs = Date.now() - startTime;
248
+ this.config.onResponse?.({ status: response.status, body: responseBody, durationMs });
249
+ if (!response.ok) {
250
+ if (this.retryConfig.retryOn.includes(response.status) && attempt < this.retryConfig.maxRetries) {
251
+ lastError = await this.handleApiErrorFromBody(responseBody, response.status, path);
252
+ continue;
253
+ }
254
+ throw await this.handleApiErrorFromBody(responseBody, response.status, path);
255
+ }
256
+ return responseBody;
257
+ } catch (error) {
258
+ if (error instanceof import_connect.EdgeError) {
259
+ if (!(error instanceof import_connect.EdgeNetworkError) || attempt >= this.retryConfig.maxRetries) {
260
+ throw error;
261
+ }
262
+ lastError = error;
263
+ continue;
264
+ }
265
+ lastError = error;
266
+ if (attempt >= this.retryConfig.maxRetries) {
267
+ throw new import_connect.EdgeNetworkError(`API request failed: ${method} ${path}`, lastError);
268
+ }
423
269
  }
424
- return response.json();
425
- } catch (error) {
426
- if (error instanceof import_connect.EdgeError) throw error;
427
- throw new import_connect.EdgeNetworkError(`API request failed: ${method} ${path}`, error);
428
270
  }
271
+ throw new import_connect.EdgeNetworkError(`API request failed after ${this.retryConfig.maxRetries} retries: ${method} ${path}`, lastError);
429
272
  }
430
- /**
431
- * Fetch with timeout support.
432
- */
433
273
  async fetchWithTimeout(url, options) {
434
274
  const controller = new AbortController();
435
275
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
@@ -442,9 +282,6 @@ var EdgeConnectServer = class {
442
282
  clearTimeout(timeoutId);
443
283
  }
444
284
  }
445
- /**
446
- * Parses token response from Cognito.
447
- */
448
285
  parseTokenResponse(data, existingRefreshToken) {
449
286
  return {
450
287
  accessToken: data.access_token,
@@ -455,35 +292,33 @@ var EdgeConnectServer = class {
455
292
  scope: data.scope || ""
456
293
  };
457
294
  }
458
- /**
459
- * Handles token exchange errors.
460
- */
461
295
  handleTokenError(error, status) {
462
296
  const errorCode = error.error;
463
- const errorDescription = error.error_description;
464
- if (errorCode === "invalid_grant") {
297
+ const errorMessage = error.message || error.error_description;
298
+ if (errorCode === "invalid_grant" || errorMessage?.includes("Invalid or expired")) {
465
299
  return new import_connect.EdgeTokenExchangeError(
466
300
  "Authorization code is invalid, expired, or already used. Please try again.",
467
- { cognitoError: error }
301
+ { edgeBoostError: error }
468
302
  );
469
303
  }
470
- if (errorCode === "invalid_client") {
304
+ if (errorCode === "invalid_client" || errorMessage?.includes("Invalid client")) {
471
305
  return new import_connect.EdgeAuthenticationError(
472
306
  "Invalid client credentials. Check your client ID and secret.",
473
- { cognitoError: error }
307
+ { edgeBoostError: error }
308
+ );
309
+ }
310
+ if (errorMessage?.includes("code_verifier") || errorMessage?.includes("PKCE")) {
311
+ return new import_connect.EdgeTokenExchangeError(
312
+ "PKCE verification failed. Please try again.",
313
+ { edgeBoostError: error }
474
314
  );
475
315
  }
476
316
  return new import_connect.EdgeTokenExchangeError(
477
- errorDescription || "Failed to exchange authorization code",
478
- { cognitoError: error, statusCode: status }
317
+ errorMessage || "Failed to exchange authorization code",
318
+ { edgeBoostError: error, statusCode: status }
479
319
  );
480
320
  }
481
- /**
482
- * Handles API errors.
483
- */
484
- async handleApiError(response, path) {
485
- const error = await response.json().catch(() => ({}));
486
- const status = response.status;
321
+ async handleApiErrorFromBody(error, status, path) {
487
322
  if (status === 401) {
488
323
  return new import_connect.EdgeAuthenticationError(
489
324
  error.message || "Access token is invalid or expired",