@edge-markets/connect-node 1.0.3 → 1.3.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 +43 -9
- package/dist/index.d.mts +40 -348
- package/dist/index.d.ts +40 -348
- package/dist/index.js +230 -290
- package/dist/index.mjs +232 -291
- package/package.json +10 -12
package/dist/index.mjs
CHANGED
|
@@ -8,17 +8,110 @@ import {
|
|
|
8
8
|
EdgeInsufficientScopeError,
|
|
9
9
|
EdgeApiError,
|
|
10
10
|
EdgeNotFoundError,
|
|
11
|
-
EdgeNetworkError
|
|
11
|
+
EdgeNetworkError,
|
|
12
|
+
EdgeValidationError
|
|
12
13
|
} from "@edge-markets/connect";
|
|
14
|
+
|
|
15
|
+
// src/mle.ts
|
|
16
|
+
import { randomUUID, randomBytes, createCipheriv, createDecipheriv, publicEncrypt, privateDecrypt, constants } from "crypto";
|
|
17
|
+
function encryptMle(payload, clientId, config) {
|
|
18
|
+
const header = {
|
|
19
|
+
alg: "RSA-OAEP-256",
|
|
20
|
+
enc: "A256GCM",
|
|
21
|
+
kid: config.edgeKeyId,
|
|
22
|
+
typ: "application/json",
|
|
23
|
+
iat: Math.floor(Date.now() / 1e3),
|
|
24
|
+
jti: randomUUID(),
|
|
25
|
+
clientId
|
|
26
|
+
};
|
|
27
|
+
const protectedHeaderB64 = toBase64Url(Buffer.from(JSON.stringify(header), "utf8"));
|
|
28
|
+
const aad = Buffer.from(protectedHeaderB64, "utf8");
|
|
29
|
+
const cek = randomBytes(32);
|
|
30
|
+
const iv = randomBytes(12);
|
|
31
|
+
const cipher = createCipheriv("aes-256-gcm", cek, iv);
|
|
32
|
+
cipher.setAAD(aad);
|
|
33
|
+
const plaintext = Buffer.from(JSON.stringify(payload), "utf8");
|
|
34
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
35
|
+
const tag = cipher.getAuthTag();
|
|
36
|
+
const encryptedKey = publicEncrypt(
|
|
37
|
+
{
|
|
38
|
+
key: config.edgePublicKey,
|
|
39
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
40
|
+
oaepHash: "sha256"
|
|
41
|
+
},
|
|
42
|
+
cek
|
|
43
|
+
);
|
|
44
|
+
return [
|
|
45
|
+
protectedHeaderB64,
|
|
46
|
+
toBase64Url(encryptedKey),
|
|
47
|
+
toBase64Url(iv),
|
|
48
|
+
toBase64Url(ciphertext),
|
|
49
|
+
toBase64Url(tag)
|
|
50
|
+
].join(".");
|
|
51
|
+
}
|
|
52
|
+
function decryptMle(jwe, config) {
|
|
53
|
+
const parts = jwe.split(".");
|
|
54
|
+
if (parts.length !== 5) {
|
|
55
|
+
throw new Error("Invalid MLE payload format");
|
|
56
|
+
}
|
|
57
|
+
const [protectedHeaderB64, encryptedKeyB64, ivB64, ciphertextB64, tagB64] = parts;
|
|
58
|
+
const protectedHeader = JSON.parse(fromBase64Url(protectedHeaderB64).toString("utf8"));
|
|
59
|
+
if (protectedHeader.alg !== "RSA-OAEP-256" || protectedHeader.enc !== "A256GCM") {
|
|
60
|
+
throw new Error("Unsupported MLE algorithms");
|
|
61
|
+
}
|
|
62
|
+
if (protectedHeader.kid && protectedHeader.kid !== config.partnerKeyId) {
|
|
63
|
+
throw new Error(`Unexpected response key id: ${protectedHeader.kid}`);
|
|
64
|
+
}
|
|
65
|
+
const cek = privateDecrypt(
|
|
66
|
+
{
|
|
67
|
+
key: config.partnerPrivateKey,
|
|
68
|
+
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
|
69
|
+
oaepHash: "sha256"
|
|
70
|
+
},
|
|
71
|
+
fromBase64Url(encryptedKeyB64)
|
|
72
|
+
);
|
|
73
|
+
const decipher = createDecipheriv("aes-256-gcm", cek, fromBase64Url(ivB64));
|
|
74
|
+
decipher.setAAD(Buffer.from(protectedHeaderB64, "utf8"));
|
|
75
|
+
decipher.setAuthTag(fromBase64Url(tagB64));
|
|
76
|
+
const plaintext = Buffer.concat([
|
|
77
|
+
decipher.update(fromBase64Url(ciphertextB64)),
|
|
78
|
+
decipher.final()
|
|
79
|
+
]);
|
|
80
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
81
|
+
}
|
|
82
|
+
function toBase64Url(buffer) {
|
|
83
|
+
return buffer.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
84
|
+
}
|
|
85
|
+
function fromBase64Url(value) {
|
|
86
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
|
|
87
|
+
return Buffer.from(normalized, "base64");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/edge-connect-server.ts
|
|
13
91
|
var DEFAULT_TIMEOUT = 3e4;
|
|
14
92
|
var USER_AGENT = "@edge-markets/connect-node/1.0.0";
|
|
15
|
-
var
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
93
|
+
var DEFAULT_RETRY_CONFIG = {
|
|
94
|
+
maxRetries: 3,
|
|
95
|
+
retryOn: [408, 429, 500, 502, 503, 504],
|
|
96
|
+
backoff: "exponential",
|
|
97
|
+
baseDelayMs: 1e3
|
|
98
|
+
};
|
|
99
|
+
var instances = /* @__PURE__ */ new Map();
|
|
100
|
+
function getInstanceKey(config) {
|
|
101
|
+
return `${config.clientId}:${config.environment}`;
|
|
102
|
+
}
|
|
103
|
+
var EdgeConnectServer = class _EdgeConnectServer {
|
|
104
|
+
static getInstance(config) {
|
|
105
|
+
const key = getInstanceKey(config);
|
|
106
|
+
const existing = instances.get(key);
|
|
107
|
+
if (existing) return existing;
|
|
108
|
+
const instance = new _EdgeConnectServer(config);
|
|
109
|
+
instances.set(key, instance);
|
|
110
|
+
return instance;
|
|
111
|
+
}
|
|
112
|
+
static clearInstances() {
|
|
113
|
+
instances.clear();
|
|
114
|
+
}
|
|
22
115
|
constructor(config) {
|
|
23
116
|
if (!config.clientId) {
|
|
24
117
|
throw new Error("EdgeConnectServer: clientId is required");
|
|
@@ -32,66 +125,29 @@ var EdgeConnectServer = class {
|
|
|
32
125
|
this.config = config;
|
|
33
126
|
const envConfig = getEnvironmentConfig(config.environment);
|
|
34
127
|
this.apiBaseUrl = config.apiBaseUrl || envConfig.apiBaseUrl;
|
|
35
|
-
this.oauthBaseUrl = envConfig.oauthBaseUrl;
|
|
128
|
+
this.oauthBaseUrl = config.oauthBaseUrl || envConfig.oauthBaseUrl;
|
|
36
129
|
this.timeout = config.timeout || DEFAULT_TIMEOUT;
|
|
130
|
+
this.retryConfig = {
|
|
131
|
+
...DEFAULT_RETRY_CONFIG,
|
|
132
|
+
...config.retry
|
|
133
|
+
};
|
|
37
134
|
}
|
|
38
|
-
|
|
39
|
-
|
|
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`;
|
|
135
|
+
async exchangeCode(code, codeVerifier, redirectUri) {
|
|
136
|
+
const tokenUrl = `${this.oauthBaseUrl}/token`;
|
|
80
137
|
const body = {
|
|
81
138
|
grant_type: "authorization_code",
|
|
82
139
|
code,
|
|
83
140
|
code_verifier: codeVerifier,
|
|
84
141
|
client_id: this.config.clientId,
|
|
85
|
-
client_secret: this.config.clientSecret
|
|
142
|
+
client_secret: this.config.clientSecret,
|
|
143
|
+
redirect_uri: redirectUri || this.config.redirectUri || ""
|
|
86
144
|
};
|
|
87
145
|
try {
|
|
88
146
|
const response = await this.fetchWithTimeout(tokenUrl, {
|
|
89
147
|
method: "POST",
|
|
90
148
|
headers: {
|
|
91
149
|
"Content-Type": "application/json",
|
|
92
|
-
"User-Agent": USER_AGENT
|
|
93
|
-
// Required for ngrok tunnels in local development
|
|
94
|
-
"ngrok-skip-browser-warning": "true"
|
|
150
|
+
"User-Agent": USER_AGENT
|
|
95
151
|
},
|
|
96
152
|
body: JSON.stringify(body)
|
|
97
153
|
});
|
|
@@ -106,44 +162,8 @@ var EdgeConnectServer = class {
|
|
|
106
162
|
throw new EdgeNetworkError("Failed to exchange code", error);
|
|
107
163
|
}
|
|
108
164
|
}
|
|
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
165
|
async refreshTokens(refreshToken) {
|
|
146
|
-
const tokenUrl = `${this.oauthBaseUrl}/
|
|
166
|
+
const tokenUrl = `${this.oauthBaseUrl}/token`;
|
|
147
167
|
const body = {
|
|
148
168
|
grant_type: "refresh_token",
|
|
149
169
|
refresh_token: refreshToken,
|
|
@@ -155,15 +175,14 @@ var EdgeConnectServer = class {
|
|
|
155
175
|
method: "POST",
|
|
156
176
|
headers: {
|
|
157
177
|
"Content-Type": "application/json",
|
|
158
|
-
"User-Agent": USER_AGENT
|
|
159
|
-
"ngrok-skip-browser-warning": "true"
|
|
178
|
+
"User-Agent": USER_AGENT
|
|
160
179
|
},
|
|
161
180
|
body: JSON.stringify(body)
|
|
162
181
|
});
|
|
163
182
|
if (!response.ok) {
|
|
164
183
|
const error = await response.json().catch(() => ({}));
|
|
165
184
|
throw new EdgeAuthenticationError(
|
|
166
|
-
error.error_description || "Token refresh failed. User may need to reconnect.",
|
|
185
|
+
error.message || error.error_description || "Token refresh failed. User may need to reconnect.",
|
|
167
186
|
{ tokenError: error }
|
|
168
187
|
);
|
|
169
188
|
}
|
|
@@ -174,80 +193,14 @@ var EdgeConnectServer = class {
|
|
|
174
193
|
throw new EdgeNetworkError("Failed to refresh tokens", error);
|
|
175
194
|
}
|
|
176
195
|
}
|
|
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
196
|
async getUser(accessToken) {
|
|
195
197
|
return this.apiRequest("GET", "/user", accessToken);
|
|
196
198
|
}
|
|
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
199
|
async getBalance(accessToken) {
|
|
212
200
|
return this.apiRequest("GET", "/balance", accessToken);
|
|
213
201
|
}
|
|
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
202
|
async initiateTransfer(accessToken, options) {
|
|
203
|
+
this.validateTransferOptions(options);
|
|
251
204
|
const body = {
|
|
252
205
|
type: options.type,
|
|
253
206
|
amount: options.amount,
|
|
@@ -255,28 +208,32 @@ var EdgeConnectServer = class {
|
|
|
255
208
|
};
|
|
256
209
|
return this.apiRequest("POST", "/transfer", accessToken, body);
|
|
257
210
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
211
|
+
validateTransferOptions(options) {
|
|
212
|
+
const errors = {};
|
|
213
|
+
if (!options.type || !["debit", "credit"].includes(options.type)) {
|
|
214
|
+
errors.type = ['Must be "debit" or "credit"'];
|
|
215
|
+
}
|
|
216
|
+
if (!options.amount) {
|
|
217
|
+
errors.amount = ["Amount is required"];
|
|
218
|
+
} else {
|
|
219
|
+
const amount = parseFloat(options.amount);
|
|
220
|
+
if (isNaN(amount)) {
|
|
221
|
+
errors.amount = ["Must be a valid number"];
|
|
222
|
+
} else if (amount <= 0) {
|
|
223
|
+
errors.amount = ["Must be greater than 0"];
|
|
224
|
+
} else if (!/^\d+(\.\d{1,2})?$/.test(options.amount)) {
|
|
225
|
+
errors.amount = ["Must have at most 2 decimal places"];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!options.idempotencyKey || options.idempotencyKey.trim() === "") {
|
|
229
|
+
errors.idempotencyKey = ["idempotencyKey is required"];
|
|
230
|
+
} else if (options.idempotencyKey.length > 255) {
|
|
231
|
+
errors.idempotencyKey = ["Must be 255 characters or less"];
|
|
232
|
+
}
|
|
233
|
+
if (Object.keys(errors).length > 0) {
|
|
234
|
+
throw new EdgeValidationError("Invalid transfer options", errors);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
280
237
|
async verifyTransfer(accessToken, transferId, otp) {
|
|
281
238
|
return this.apiRequest(
|
|
282
239
|
"POST",
|
|
@@ -285,21 +242,6 @@ var EdgeConnectServer = class {
|
|
|
285
242
|
{ otp }
|
|
286
243
|
);
|
|
287
244
|
}
|
|
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
245
|
async getTransfer(accessToken, transferId) {
|
|
304
246
|
return this.apiRequest(
|
|
305
247
|
"GET",
|
|
@@ -307,27 +249,6 @@ var EdgeConnectServer = class {
|
|
|
307
249
|
accessToken
|
|
308
250
|
);
|
|
309
251
|
}
|
|
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
252
|
async listTransfers(accessToken, params) {
|
|
332
253
|
const queryParams = new URLSearchParams();
|
|
333
254
|
if (params?.limit) queryParams.set("limit", String(params.limit));
|
|
@@ -337,68 +258,93 @@ var EdgeConnectServer = class {
|
|
|
337
258
|
const path = query ? `/transfers?${query}` : "/transfers";
|
|
338
259
|
return this.apiRequest("GET", path, accessToken);
|
|
339
260
|
}
|
|
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
261
|
async revokeConsent(accessToken) {
|
|
368
262
|
return this.apiRequest("DELETE", "/consent", accessToken);
|
|
369
263
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
264
|
+
getRetryDelay(attempt) {
|
|
265
|
+
const { backoff, baseDelayMs } = this.retryConfig;
|
|
266
|
+
if (backoff === "exponential") {
|
|
267
|
+
return baseDelayMs * Math.pow(2, attempt);
|
|
268
|
+
}
|
|
269
|
+
return baseDelayMs * (attempt + 1);
|
|
270
|
+
}
|
|
271
|
+
async sleep(ms) {
|
|
272
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
273
|
+
}
|
|
376
274
|
async apiRequest(method, path, accessToken, body) {
|
|
377
275
|
const url = `${this.apiBaseUrl}${path}`;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
276
|
+
let lastError = null;
|
|
277
|
+
const mleConfig = this.config.mle;
|
|
278
|
+
const mleEnabled = !!mleConfig?.enabled;
|
|
279
|
+
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
280
|
+
const startTime = Date.now();
|
|
281
|
+
if (attempt > 0) {
|
|
282
|
+
await this.sleep(this.getRetryDelay(attempt - 1));
|
|
283
|
+
}
|
|
284
|
+
this.config.onRequest?.({ method, url, body });
|
|
285
|
+
try {
|
|
286
|
+
const requestHeaders = {
|
|
382
287
|
Authorization: `Bearer ${accessToken}`,
|
|
383
288
|
"Content-Type": "application/json",
|
|
384
|
-
"User-Agent": USER_AGENT
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
289
|
+
"User-Agent": USER_AGENT
|
|
290
|
+
};
|
|
291
|
+
let requestBody = body;
|
|
292
|
+
if (mleEnabled) {
|
|
293
|
+
requestHeaders["X-Edge-MLE"] = "v1";
|
|
294
|
+
if (body !== void 0) {
|
|
295
|
+
requestBody = { jwe: encryptMle(body, this.config.clientId, mleConfig) };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const response = await this.fetchWithTimeout(url, {
|
|
299
|
+
method,
|
|
300
|
+
headers: requestHeaders,
|
|
301
|
+
body: requestBody !== void 0 ? JSON.stringify(requestBody) : void 0
|
|
302
|
+
});
|
|
303
|
+
const rawResponseBody = await response.json().catch(() => ({}));
|
|
304
|
+
let responseBody = rawResponseBody;
|
|
305
|
+
if (mleEnabled && typeof rawResponseBody?.jwe === "string") {
|
|
306
|
+
responseBody = decryptMle(rawResponseBody.jwe, mleConfig);
|
|
307
|
+
} else if (!mleEnabled && typeof rawResponseBody?.jwe === "string") {
|
|
308
|
+
throw new EdgeApiError(
|
|
309
|
+
"mle_required",
|
|
310
|
+
"The API responded with message-level encryption. Enable MLE in SDK config.",
|
|
311
|
+
response.status,
|
|
312
|
+
rawResponseBody
|
|
313
|
+
);
|
|
314
|
+
} else if (mleEnabled && mleConfig?.strictResponseEncryption !== false && response.ok && typeof rawResponseBody?.jwe !== "string") {
|
|
315
|
+
throw new EdgeApiError(
|
|
316
|
+
"mle_response_missing",
|
|
317
|
+
"Expected encrypted response payload but received plaintext.",
|
|
318
|
+
response.status,
|
|
319
|
+
rawResponseBody
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const durationMs = Date.now() - startTime;
|
|
323
|
+
this.config.onResponse?.({ status: response.status, body: responseBody, durationMs });
|
|
324
|
+
if (!response.ok) {
|
|
325
|
+
if (this.retryConfig.retryOn.includes(response.status) && attempt < this.retryConfig.maxRetries) {
|
|
326
|
+
lastError = await this.handleApiErrorFromBody(responseBody, response.status, path);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
throw await this.handleApiErrorFromBody(responseBody, response.status, path);
|
|
330
|
+
}
|
|
331
|
+
return responseBody;
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (error instanceof EdgeError) {
|
|
334
|
+
if (!(error instanceof EdgeNetworkError) || attempt >= this.retryConfig.maxRetries) {
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
lastError = error;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
lastError = error;
|
|
341
|
+
if (attempt >= this.retryConfig.maxRetries) {
|
|
342
|
+
throw new EdgeNetworkError(`API request failed: ${method} ${path}`, lastError);
|
|
343
|
+
}
|
|
392
344
|
}
|
|
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
345
|
}
|
|
346
|
+
throw new EdgeNetworkError(`API request failed after ${this.retryConfig.maxRetries} retries: ${method} ${path}`, lastError);
|
|
398
347
|
}
|
|
399
|
-
/**
|
|
400
|
-
* Fetch with timeout support.
|
|
401
|
-
*/
|
|
402
348
|
async fetchWithTimeout(url, options) {
|
|
403
349
|
const controller = new AbortController();
|
|
404
350
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
@@ -411,9 +357,6 @@ var EdgeConnectServer = class {
|
|
|
411
357
|
clearTimeout(timeoutId);
|
|
412
358
|
}
|
|
413
359
|
}
|
|
414
|
-
/**
|
|
415
|
-
* Parses token response from Cognito.
|
|
416
|
-
*/
|
|
417
360
|
parseTokenResponse(data, existingRefreshToken) {
|
|
418
361
|
return {
|
|
419
362
|
accessToken: data.access_token,
|
|
@@ -424,35 +367,33 @@ var EdgeConnectServer = class {
|
|
|
424
367
|
scope: data.scope || ""
|
|
425
368
|
};
|
|
426
369
|
}
|
|
427
|
-
/**
|
|
428
|
-
* Handles token exchange errors.
|
|
429
|
-
*/
|
|
430
370
|
handleTokenError(error, status) {
|
|
431
371
|
const errorCode = error.error;
|
|
432
|
-
const
|
|
433
|
-
if (errorCode === "invalid_grant") {
|
|
372
|
+
const errorMessage = error.message || error.error_description;
|
|
373
|
+
if (errorCode === "invalid_grant" || errorMessage?.includes("Invalid or expired")) {
|
|
434
374
|
return new EdgeTokenExchangeError(
|
|
435
375
|
"Authorization code is invalid, expired, or already used. Please try again.",
|
|
436
|
-
{
|
|
376
|
+
{ edgeBoostError: error }
|
|
437
377
|
);
|
|
438
378
|
}
|
|
439
|
-
if (errorCode === "invalid_client") {
|
|
379
|
+
if (errorCode === "invalid_client" || errorMessage?.includes("Invalid client")) {
|
|
440
380
|
return new EdgeAuthenticationError(
|
|
441
381
|
"Invalid client credentials. Check your client ID and secret.",
|
|
442
|
-
{
|
|
382
|
+
{ edgeBoostError: error }
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
if (errorMessage?.includes("code_verifier") || errorMessage?.includes("PKCE")) {
|
|
386
|
+
return new EdgeTokenExchangeError(
|
|
387
|
+
"PKCE verification failed. Please try again.",
|
|
388
|
+
{ edgeBoostError: error }
|
|
443
389
|
);
|
|
444
390
|
}
|
|
445
391
|
return new EdgeTokenExchangeError(
|
|
446
|
-
|
|
447
|
-
{
|
|
392
|
+
errorMessage || "Failed to exchange authorization code",
|
|
393
|
+
{ edgeBoostError: error, statusCode: status }
|
|
448
394
|
);
|
|
449
395
|
}
|
|
450
|
-
|
|
451
|
-
* Handles API errors.
|
|
452
|
-
*/
|
|
453
|
-
async handleApiError(response, path) {
|
|
454
|
-
const error = await response.json().catch(() => ({}));
|
|
455
|
-
const status = response.status;
|
|
396
|
+
async handleApiErrorFromBody(error, status, path) {
|
|
456
397
|
if (status === 401) {
|
|
457
398
|
return new EdgeAuthenticationError(
|
|
458
399
|
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
|
+
"version": "1.3.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.
|
|
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
|
+
}
|