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