@edge-markets/connect-node 1.0.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 ADDED
@@ -0,0 +1,528 @@
1
+ // src/edge-connect-server.ts
2
+ import {
3
+ getEnvironmentConfig,
4
+ EdgeError,
5
+ EdgeAuthenticationError,
6
+ EdgeTokenExchangeError,
7
+ EdgeConsentRequiredError,
8
+ EdgeInsufficientScopeError,
9
+ EdgeApiError,
10
+ EdgeNotFoundError,
11
+ EdgeNetworkError
12
+ } from "@edge-markets/connect";
13
+ var DEFAULT_TIMEOUT = 3e4;
14
+ 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
+ */
22
+ constructor(config) {
23
+ if (!config.clientId) {
24
+ throw new Error("EdgeConnectServer: clientId is required");
25
+ }
26
+ if (!config.clientSecret) {
27
+ throw new Error("EdgeConnectServer: clientSecret is required");
28
+ }
29
+ if (!config.environment) {
30
+ throw new Error("EdgeConnectServer: environment is required");
31
+ }
32
+ this.config = config;
33
+ const envConfig = getEnvironmentConfig(config.environment);
34
+ this.apiBaseUrl = config.apiBaseUrl || envConfig.apiBaseUrl;
35
+ this.oauthBaseUrl = envConfig.oauthBaseUrl;
36
+ this.timeout = config.timeout || DEFAULT_TIMEOUT;
37
+ }
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`;
80
+ const body = {
81
+ grant_type: "authorization_code",
82
+ code,
83
+ code_verifier: codeVerifier,
84
+ client_id: this.config.clientId,
85
+ client_secret: this.config.clientSecret
86
+ };
87
+ try {
88
+ const response = await this.fetchWithTimeout(tokenUrl, {
89
+ method: "POST",
90
+ headers: {
91
+ "Content-Type": "application/json",
92
+ "User-Agent": USER_AGENT,
93
+ // Required for ngrok tunnels in local development
94
+ "ngrok-skip-browser-warning": "true"
95
+ },
96
+ body: JSON.stringify(body)
97
+ });
98
+ if (!response.ok) {
99
+ const error = await response.json().catch(() => ({}));
100
+ throw this.handleTokenError(error, response.status);
101
+ }
102
+ const data = await response.json();
103
+ return this.parseTokenResponse(data);
104
+ } catch (error) {
105
+ if (error instanceof EdgeError) throw error;
106
+ throw new EdgeNetworkError("Failed to exchange code", error);
107
+ }
108
+ }
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
+ async refreshTokens(refreshToken) {
146
+ const tokenUrl = `${this.oauthBaseUrl}/oauth/token`;
147
+ const body = {
148
+ grant_type: "refresh_token",
149
+ refresh_token: refreshToken,
150
+ client_id: this.config.clientId,
151
+ client_secret: this.config.clientSecret
152
+ };
153
+ try {
154
+ const response = await this.fetchWithTimeout(tokenUrl, {
155
+ method: "POST",
156
+ headers: {
157
+ "Content-Type": "application/json",
158
+ "User-Agent": USER_AGENT,
159
+ "ngrok-skip-browser-warning": "true"
160
+ },
161
+ body: JSON.stringify(body)
162
+ });
163
+ if (!response.ok) {
164
+ const error = await response.json().catch(() => ({}));
165
+ throw new EdgeAuthenticationError(
166
+ error.error_description || "Token refresh failed. User may need to reconnect.",
167
+ { tokenError: error }
168
+ );
169
+ }
170
+ const data = await response.json();
171
+ return this.parseTokenResponse(data, refreshToken);
172
+ } catch (error) {
173
+ if (error instanceof EdgeError) throw error;
174
+ throw new EdgeNetworkError("Failed to refresh tokens", error);
175
+ }
176
+ }
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
+ async getUser(accessToken) {
195
+ return this.apiRequest("GET", "/user", accessToken);
196
+ }
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
+ async getBalance(accessToken) {
212
+ return this.apiRequest("GET", "/balance", accessToken);
213
+ }
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
+ async initiateTransfer(accessToken, options) {
251
+ const body = {
252
+ type: options.type,
253
+ amount: options.amount,
254
+ idempotencyKey: options.idempotencyKey
255
+ };
256
+ return this.apiRequest("POST", "/transfer", accessToken, body);
257
+ }
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
+ */
280
+ async verifyTransfer(accessToken, transferId, otp) {
281
+ return this.apiRequest(
282
+ "POST",
283
+ `/transfer/${encodeURIComponent(transferId)}/verify`,
284
+ accessToken,
285
+ { otp }
286
+ );
287
+ }
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
+ async getTransfer(accessToken, transferId) {
304
+ return this.apiRequest(
305
+ "GET",
306
+ `/transfer/${encodeURIComponent(transferId)}`,
307
+ accessToken
308
+ );
309
+ }
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
+ async listTransfers(accessToken, params) {
332
+ const queryParams = new URLSearchParams();
333
+ if (params?.limit) queryParams.set("limit", String(params.limit));
334
+ if (params?.offset) queryParams.set("offset", String(params.offset));
335
+ if (params?.status) queryParams.set("status", params.status);
336
+ const query = queryParams.toString();
337
+ const path = query ? `/transfers?${query}` : "/transfers";
338
+ return this.apiRequest("GET", path, accessToken);
339
+ }
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
+ async revokeConsent(accessToken) {
368
+ return this.apiRequest("DELETE", "/consent", accessToken);
369
+ }
370
+ // ===========================================================================
371
+ // PRIVATE HELPERS
372
+ // ===========================================================================
373
+ /**
374
+ * Makes an authenticated API request.
375
+ */
376
+ async apiRequest(method, path, accessToken, body) {
377
+ 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);
392
+ }
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
+ }
398
+ }
399
+ /**
400
+ * Fetch with timeout support.
401
+ */
402
+ async fetchWithTimeout(url, options) {
403
+ const controller = new AbortController();
404
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
405
+ try {
406
+ return await fetch(url, {
407
+ ...options,
408
+ signal: controller.signal
409
+ });
410
+ } finally {
411
+ clearTimeout(timeoutId);
412
+ }
413
+ }
414
+ /**
415
+ * Parses token response from Cognito.
416
+ */
417
+ parseTokenResponse(data, existingRefreshToken) {
418
+ return {
419
+ accessToken: data.access_token,
420
+ refreshToken: data.refresh_token || existingRefreshToken || "",
421
+ idToken: data.id_token,
422
+ expiresIn: data.expires_in,
423
+ expiresAt: Date.now() + data.expires_in * 1e3,
424
+ scope: data.scope || ""
425
+ };
426
+ }
427
+ /**
428
+ * Handles token exchange errors.
429
+ */
430
+ handleTokenError(error, status) {
431
+ const errorCode = error.error;
432
+ const errorDescription = error.error_description;
433
+ if (errorCode === "invalid_grant") {
434
+ return new EdgeTokenExchangeError(
435
+ "Authorization code is invalid, expired, or already used. Please try again.",
436
+ { cognitoError: error }
437
+ );
438
+ }
439
+ if (errorCode === "invalid_client") {
440
+ return new EdgeAuthenticationError(
441
+ "Invalid client credentials. Check your client ID and secret.",
442
+ { cognitoError: error }
443
+ );
444
+ }
445
+ return new EdgeTokenExchangeError(
446
+ errorDescription || "Failed to exchange authorization code",
447
+ { cognitoError: error, statusCode: status }
448
+ );
449
+ }
450
+ /**
451
+ * Handles API errors.
452
+ */
453
+ async handleApiError(response, path) {
454
+ const error = await response.json().catch(() => ({}));
455
+ const status = response.status;
456
+ if (status === 401) {
457
+ return new EdgeAuthenticationError(
458
+ error.message || "Access token is invalid or expired",
459
+ error
460
+ );
461
+ }
462
+ if (status === 403) {
463
+ if (error.error === "consent_required") {
464
+ return new EdgeConsentRequiredError(
465
+ this.config.clientId,
466
+ error.consentUrl,
467
+ error.message
468
+ );
469
+ }
470
+ if (error.error === "insufficient_scope" || error.error === "insufficient_consent") {
471
+ return new EdgeInsufficientScopeError(
472
+ error.missing_scopes || error.missingScopes || [],
473
+ error.message
474
+ );
475
+ }
476
+ }
477
+ if (status === 404) {
478
+ const resourceType = path.includes("/transfer") ? "Transfer" : "Resource";
479
+ const resourceId = path.split("/").pop() || "unknown";
480
+ return new EdgeNotFoundError(resourceType, resourceId);
481
+ }
482
+ return new EdgeApiError(
483
+ error.error || "api_error",
484
+ error.message || error.error_description || `Request failed with status ${status}`,
485
+ status,
486
+ error
487
+ );
488
+ }
489
+ };
490
+
491
+ // src/index.ts
492
+ import {
493
+ EdgeError as EdgeError2,
494
+ EdgeAuthenticationError as EdgeAuthenticationError2,
495
+ EdgeTokenExchangeError as EdgeTokenExchangeError2,
496
+ EdgeConsentRequiredError as EdgeConsentRequiredError2,
497
+ EdgeInsufficientScopeError as EdgeInsufficientScopeError2,
498
+ EdgeApiError as EdgeApiError2,
499
+ EdgeNotFoundError as EdgeNotFoundError2,
500
+ EdgeNetworkError as EdgeNetworkError2,
501
+ isEdgeError,
502
+ isAuthenticationError,
503
+ isConsentRequiredError,
504
+ isApiError,
505
+ isNetworkError
506
+ } from "@edge-markets/connect";
507
+ import {
508
+ getEnvironmentConfig as getEnvironmentConfig2,
509
+ isProductionEnvironment
510
+ } from "@edge-markets/connect";
511
+ export {
512
+ EdgeApiError2 as EdgeApiError,
513
+ EdgeAuthenticationError2 as EdgeAuthenticationError,
514
+ EdgeConnectServer,
515
+ EdgeConsentRequiredError2 as EdgeConsentRequiredError,
516
+ EdgeError2 as EdgeError,
517
+ EdgeInsufficientScopeError2 as EdgeInsufficientScopeError,
518
+ EdgeNetworkError2 as EdgeNetworkError,
519
+ EdgeNotFoundError2 as EdgeNotFoundError,
520
+ EdgeTokenExchangeError2 as EdgeTokenExchangeError,
521
+ getEnvironmentConfig2 as getEnvironmentConfig,
522
+ isApiError,
523
+ isAuthenticationError,
524
+ isConsentRequiredError,
525
+ isEdgeError,
526
+ isNetworkError,
527
+ isProductionEnvironment
528
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@edge-markets/connect-node",
3
+ "version": "1.0.0",
4
+ "description": "Server SDK for EDGE Connect token exchange and API calls",
5
+ "author": "Edge Markets",
6
+ "license": "MIT",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./dist/index.d.ts",
10
+ "sideEffects": false,
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.mts",
15
+ "default": "./dist/index.mjs"
16
+ },
17
+ "require": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ }
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",
28
+ "test:run": "vitest run"
29
+ },
30
+ "dependencies": {
31
+ "@edge-markets/connect": "workspace:^"
32
+ },
33
+ "devDependencies": {
34
+ "tsup": "^8.0.0",
35
+ "typescript": "^5.3.0",
36
+ "vitest": "^1.0.0"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "README.md"
41
+ ],
42
+ "keywords": [
43
+ "edgeboost",
44
+ "edge-connect",
45
+ "sdk",
46
+ "server",
47
+ "oauth",
48
+ "token-exchange",
49
+ "api-client"
50
+ ],
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "https://github.com/edgeboost/edge-connect-sdk.git",
54
+ "directory": "packages/server"
55
+ },
56
+ "engines": {
57
+ "node": ">=18"
58
+ }
59
+ }
60
+
61
+