@edge-markets/connect-node 1.7.0 → 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 +176 -46
- package/dist/index.d.mts +266 -4
- package/dist/index.d.ts +266 -4
- package/dist/index.js +1318 -85
- package/dist/index.mjs +1279 -57
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -10,7 +10,10 @@ Server SDK for EDGE Connect token exchange and API calls.
|
|
|
10
10
|
- 🛡️ **Typed errors** - Specific error classes for each scenario
|
|
11
11
|
- 📝 **TypeScript first** - Complete type definitions
|
|
12
12
|
- 🪝 **Webhook signature verification** - Constant-time HMAC-SHA256 with replay protection
|
|
13
|
+
- ✅ **Webhook parsing + validation** - Verify raw bodies and fail closed on malformed event payloads
|
|
13
14
|
- 🔁 **Webhook reconciliation** - Cursor-based sync for catching missed events
|
|
15
|
+
- 🔐 **Token vault** - Versioned AES-256-GCM token encryption with key rotation
|
|
16
|
+
- ⚙️ **Env config helper** - Build SDK config from standard EDGE env vars
|
|
14
17
|
|
|
15
18
|
## Installation
|
|
16
19
|
|
|
@@ -25,14 +28,11 @@ yarn add @edge-markets/connect-node
|
|
|
25
28
|
## Quick Start
|
|
26
29
|
|
|
27
30
|
```typescript
|
|
28
|
-
import {
|
|
31
|
+
import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
|
|
29
32
|
|
|
30
|
-
// Create instance once (reuse for all requests)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
clientSecret: process.env.EDGE_CLIENT_SECRET!,
|
|
34
|
-
environment: 'staging',
|
|
35
|
-
})
|
|
33
|
+
// Create instance once (reuse for all requests). Reads EDGE_CLIENT_ID,
|
|
34
|
+
// EDGE_CLIENT_SECRET, EDGE_ENVIRONMENT, optional mTLS and partner sync env vars.
|
|
35
|
+
const { server: edge } = createEdgeConnectServerFromEnv()
|
|
36
36
|
|
|
37
37
|
// Exchange code from EdgeLink for tokens
|
|
38
38
|
const tokens = await edge.exchangeCode(code, codeVerifier)
|
|
@@ -51,6 +51,48 @@ Never expose your client secret to browsers or client-side code.
|
|
|
51
51
|
|
|
52
52
|
## Configuration
|
|
53
53
|
|
|
54
|
+
### From environment variables
|
|
55
|
+
|
|
56
|
+
Most partner backends should use `createEdgeConnectServerFromEnv()` so mTLS
|
|
57
|
+
PEM handling, partner sync credentials, URL overrides, and timeout parsing are
|
|
58
|
+
consistent across integrations.
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
|
|
62
|
+
|
|
63
|
+
const { server: edge, capabilities, warnings } = createEdgeConnectServerFromEnv({
|
|
64
|
+
requireMtls: process.env.NODE_ENV === 'production',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
warnings.forEach((warning) => logger.warn(warning))
|
|
68
|
+
|
|
69
|
+
if (!capabilities.webhookSyncConfigured) {
|
|
70
|
+
logger.warn('EDGE webhook sync is not configured; set EDGE_PARTNER_CLIENT_ID and EDGE_PARTNER_CLIENT_SECRET')
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Supported environment variables:
|
|
75
|
+
|
|
76
|
+
| Variable | Description |
|
|
77
|
+
|----------|-------------|
|
|
78
|
+
| `EDGE_CLIENT_ID` | User-facing OAuth client ID |
|
|
79
|
+
| `EDGE_CLIENT_SECRET` | User-facing OAuth client secret |
|
|
80
|
+
| `EDGE_ENVIRONMENT` | `production`, `staging`, `sandbox`, or `development` |
|
|
81
|
+
| `EDGE_API_BASE_URL` | Optional Connect API override |
|
|
82
|
+
| `EDGE_OAUTH_BASE_URL` | Optional OAuth/token URL override |
|
|
83
|
+
| `EDGE_PARTNER_API_BASE_URL` | Optional partner dashboard API override |
|
|
84
|
+
| `EDGE_PARTNER_CLIENT_ID` | Partner machine-to-machine client ID for webhook sync |
|
|
85
|
+
| `EDGE_PARTNER_CLIENT_SECRET` | Partner machine-to-machine client secret for webhook sync |
|
|
86
|
+
| `EDGE_MTLS_CERT` / `EDGE_MTLS_CERT_PATH` | Client certificate PEM or file path |
|
|
87
|
+
| `EDGE_MTLS_KEY` / `EDGE_MTLS_KEY_PATH` | Client private key PEM or file path |
|
|
88
|
+
| `EDGE_MTLS_CA` / `EDGE_MTLS_CA_PATH` | Optional extra server trust root PEM or file path |
|
|
89
|
+
| `EDGE_TIMEOUT_MS` | Optional request timeout in milliseconds |
|
|
90
|
+
|
|
91
|
+
Use inline PEMs with escaped newlines (`\n`) or file paths. If both are set,
|
|
92
|
+
the inline value wins and the helper returns a warning.
|
|
93
|
+
|
|
94
|
+
### Manual configuration
|
|
95
|
+
|
|
54
96
|
```typescript
|
|
55
97
|
interface EdgeConnectServerConfig {
|
|
56
98
|
clientId: string // Your user-facing OAuth client ID
|
|
@@ -59,6 +101,7 @@ interface EdgeConnectServerConfig {
|
|
|
59
101
|
|
|
60
102
|
// Optional
|
|
61
103
|
apiBaseUrl?: string // Custom API URL (dev only)
|
|
104
|
+
partnerApiBaseUrl?: string // Custom partner dashboard API URL (dev only)
|
|
62
105
|
oauthBaseUrl?: string // Custom OAuth URL (dev only)
|
|
63
106
|
timeout?: number // Request timeout (default: 30000ms)
|
|
64
107
|
retry?: RetryConfig // Retry configuration
|
|
@@ -72,6 +115,11 @@ interface EdgeConnectServerConfig {
|
|
|
72
115
|
partnerClientId?: string
|
|
73
116
|
partnerClientSecret?: string
|
|
74
117
|
|
|
118
|
+
// By default the SDK derives partnerApiBaseUrl from apiBaseUrl by replacing
|
|
119
|
+
// /connect/v1 with /v1. For example:
|
|
120
|
+
// https://connect-staging.edgeboost.io/connect/v1
|
|
121
|
+
// -> https://connect-staging.edgeboost.io/v1
|
|
122
|
+
|
|
75
123
|
// Optional: Message Level Encryption (Connect endpoints only)
|
|
76
124
|
mle?: {
|
|
77
125
|
enabled: boolean
|
|
@@ -81,6 +129,14 @@ interface EdgeConnectServerConfig {
|
|
|
81
129
|
partnerKeyId: string // Your key ID (kid) expected in response headers
|
|
82
130
|
strictResponseEncryption?: boolean // default true
|
|
83
131
|
}
|
|
132
|
+
|
|
133
|
+
// Optional: Mutual TLS client authentication
|
|
134
|
+
mtls?: {
|
|
135
|
+
enabled: boolean
|
|
136
|
+
cert: string // Your client certificate PEM
|
|
137
|
+
key: string // Your client private key PEM
|
|
138
|
+
ca?: string | string[] // Additional server trust roots, if EDGE provides them
|
|
139
|
+
}
|
|
84
140
|
}
|
|
85
141
|
```
|
|
86
142
|
|
|
@@ -103,6 +159,34 @@ const edge = new EdgeConnectServer({
|
|
|
103
159
|
|
|
104
160
|
When enabled, the SDK sends `X-Edge-MLE: v1`, encrypts request bodies, and decrypts encrypted Connect responses.
|
|
105
161
|
|
|
162
|
+
### Mutual TLS (mTLS)
|
|
163
|
+
|
|
164
|
+
Use `mtls` when EDGE has issued your partner backend a client certificate and key:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
const edge = new EdgeConnectServer({
|
|
168
|
+
clientId: process.env.EDGE_CLIENT_ID!,
|
|
169
|
+
clientSecret: process.env.EDGE_CLIENT_SECRET!,
|
|
170
|
+
environment: 'staging',
|
|
171
|
+
mtls: {
|
|
172
|
+
enabled: true,
|
|
173
|
+
cert: process.env.EDGE_MTLS_CERT!,
|
|
174
|
+
key: process.env.EDGE_MTLS_KEY!,
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`cert` and `key` authenticate your backend to EDGE Connect. They are client
|
|
180
|
+
authentication material and must stay on your server.
|
|
181
|
+
|
|
182
|
+
`ca` is optional server trust material. Public EDGE Connect gateways such as
|
|
183
|
+
`https://connect.edgeboost.io` and `https://connect-staging.edgeboost.io` use
|
|
184
|
+
public certificates, so most partners should omit `ca`. If EDGE provides a
|
|
185
|
+
private server CA for a non-public endpoint, pass it as `ca`; the SDK appends it
|
|
186
|
+
to Node's default public trust roots instead of replacing them.
|
|
187
|
+
|
|
188
|
+
Do not disable TLS verification and do not set `NODE_TLS_REJECT_UNAUTHORIZED=0`.
|
|
189
|
+
|
|
106
190
|
## Token Exchange
|
|
107
191
|
|
|
108
192
|
After EdgeLink completes, exchange the code for tokens:
|
|
@@ -134,6 +218,36 @@ export async function POST(req: Request) {
|
|
|
134
218
|
}
|
|
135
219
|
```
|
|
136
220
|
|
|
221
|
+
### Encrypting stored tokens
|
|
222
|
+
|
|
223
|
+
Store access and refresh tokens encrypted at rest. `EdgeTokenVault` gives
|
|
224
|
+
partners a stable AES-256-GCM envelope with key IDs and key rotation support;
|
|
225
|
+
you still choose the database table and user mapping.
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
import { createEdgeTokenVault } from '@edge-markets/connect-node'
|
|
229
|
+
|
|
230
|
+
const tokenVault = createEdgeTokenVault({
|
|
231
|
+
currentKey: {
|
|
232
|
+
id: process.env.EDGE_TOKEN_ENCRYPTION_KEY_ID ?? '2026-06',
|
|
233
|
+
key: process.env.EDGE_TOKEN_ENCRYPTION_KEY!, // 32 bytes as base64, base64url, or hex
|
|
234
|
+
},
|
|
235
|
+
previousKeys: [
|
|
236
|
+
// { id: '2026-05', key: process.env.EDGE_TOKEN_ENCRYPTION_KEY_2026_05! },
|
|
237
|
+
],
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const tokens = await edge.exchangeCode(code, codeVerifier)
|
|
241
|
+
await db.edgeConnections.upsert({
|
|
242
|
+
userId,
|
|
243
|
+
encryptedTokens: tokenVault.encryptTokens(tokens),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
const connection = await db.edgeConnections.get(userId)
|
|
247
|
+
const decrypted = tokenVault.decryptTokens(connection.encryptedTokens)
|
|
248
|
+
const client = edge.forUser(decrypted.accessToken)
|
|
249
|
+
```
|
|
250
|
+
|
|
137
251
|
## Token Refresh
|
|
138
252
|
|
|
139
253
|
Refresh tokens before they expire:
|
|
@@ -237,13 +351,13 @@ fail, expire, or when a user revokes consent. The SDK provides two helpers:
|
|
|
237
351
|
**signature verification** for the live HTTP delivery channel, and a
|
|
238
352
|
**reconciliation sync method** for the cron-driven safety net.
|
|
239
353
|
|
|
240
|
-
###
|
|
354
|
+
### Parse and verify webhook deliveries
|
|
241
355
|
|
|
242
356
|
Every webhook arrives with an `X-Edge-Signature` header in `t=...,v1=...`
|
|
243
357
|
format — HMAC-SHA256 over `${timestamp}.${rawBody}` keyed by your webhook
|
|
244
|
-
secret. Use `
|
|
245
|
-
|
|
246
|
-
to prevent replay attacks.
|
|
358
|
+
secret. Use `parseAndVerifyWebhook` to verify the signature, parse JSON, and
|
|
359
|
+
runtime-validate the typed event envelope in one step. The helper rejects
|
|
360
|
+
events older than 5 minutes by default to prevent replay attacks.
|
|
247
361
|
|
|
248
362
|
> ⚠️ **The `body` argument MUST be the raw request bytes**, not a parsed
|
|
249
363
|
> object that you re-stringified. `JSON.stringify(req.body)` reorders keys
|
|
@@ -251,7 +365,7 @@ to prevent replay attacks.
|
|
|
251
365
|
> raw body via your framework's raw-body middleware.
|
|
252
366
|
|
|
253
367
|
```typescript
|
|
254
|
-
import {
|
|
368
|
+
import { parseAndVerifyWebhook } from '@edge-markets/connect-node'
|
|
255
369
|
import express from 'express'
|
|
256
370
|
|
|
257
371
|
const app = express()
|
|
@@ -261,14 +375,13 @@ app.post(
|
|
|
261
375
|
// 👇 Critical: this gives you req.body as a Buffer, not a parsed object
|
|
262
376
|
express.raw({ type: 'application/json' }),
|
|
263
377
|
(req, res) => {
|
|
264
|
-
const
|
|
265
|
-
req.
|
|
266
|
-
req.
|
|
267
|
-
process.env.EDGE_WEBHOOK_SECRET!,
|
|
268
|
-
)
|
|
269
|
-
if (!ok) return res.status(401).send('invalid signature')
|
|
378
|
+
const { event, eventId, signatureTimestamp } = parseAndVerifyWebhook({
|
|
379
|
+
rawBody: req.body,
|
|
380
|
+
signatureHeader: req.headers['x-edge-signature'],
|
|
381
|
+
secret: process.env.EDGE_WEBHOOK_SECRET!,
|
|
382
|
+
})
|
|
270
383
|
|
|
271
|
-
|
|
384
|
+
// Store eventId + signatureTimestamp in your durable inbox if you use one.
|
|
272
385
|
// Process asynchronously and return 200 immediately — see "Best Practices"
|
|
273
386
|
void processEdgeEvent(event)
|
|
274
387
|
res.status(200).send('ok')
|
|
@@ -283,26 +396,36 @@ app.post(
|
|
|
283
396
|
const app = await NestFactory.create(AppModule, { rawBody: true })
|
|
284
397
|
|
|
285
398
|
// edge-webhook.controller.ts
|
|
286
|
-
import { Controller, Post, Req, HttpCode, type RawBodyRequest } from '@nestjs/common'
|
|
399
|
+
import { Controller, Post, Req, HttpCode, UnauthorizedException, type RawBodyRequest } from '@nestjs/common'
|
|
287
400
|
import type { Request } from 'express'
|
|
288
|
-
import {
|
|
401
|
+
import { EdgeAuthenticationError, parseAndVerifyWebhook } from '@edge-markets/connect-node'
|
|
289
402
|
|
|
290
403
|
@Controller('webhooks')
|
|
291
404
|
export class EdgeWebhookController {
|
|
292
405
|
@Post('edge')
|
|
293
406
|
@HttpCode(200)
|
|
294
407
|
handle(@Req() req: RawBodyRequest<Request>) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
408
|
+
try {
|
|
409
|
+
const { event } = parseAndVerifyWebhook({
|
|
410
|
+
rawBody: req.rawBody ?? Buffer.from(''),
|
|
411
|
+
signatureHeader: req.headers['x-edge-signature'],
|
|
412
|
+
secret: process.env.EDGE_WEBHOOK_SECRET!,
|
|
413
|
+
})
|
|
414
|
+
void processEdgeEvent(event)
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (error instanceof EdgeAuthenticationError) {
|
|
417
|
+
throw new UnauthorizedException('invalid signature')
|
|
418
|
+
}
|
|
419
|
+
throw error
|
|
420
|
+
}
|
|
302
421
|
}
|
|
303
422
|
}
|
|
304
423
|
```
|
|
305
424
|
|
|
425
|
+
`verifyWebhookSignature` remains exported for advanced integrations, but
|
|
426
|
+
`parseAndVerifyWebhook` is the recommended default because it fails closed on
|
|
427
|
+
unknown event types, malformed payloads, and parsed-body mistakes.
|
|
428
|
+
|
|
306
429
|
### Type-safe event handling with `EdgeWebhookEvent`
|
|
307
430
|
|
|
308
431
|
`EdgeWebhookEvent` is a discriminated union over `event.type`. Switching
|
|
@@ -342,7 +465,7 @@ function processEdgeEvent(event: EdgeWebhookEvent) {
|
|
|
342
465
|
number, to preserve decimal precision — parse it with a decimal-aware
|
|
343
466
|
library on your side.
|
|
344
467
|
|
|
345
|
-
### Reconcile missed events with `
|
|
468
|
+
### Reconcile missed events with `createWebhookReconciler`
|
|
346
469
|
|
|
347
470
|
Even with retries and a dead-letter queue, network or partner outages can
|
|
348
471
|
drop webhook events. Run `syncWebhookEvents` from a cron (every ~5 minutes
|
|
@@ -354,32 +477,34 @@ This method requires the partner-level credentials (`partnerClientId` /
|
|
|
354
477
|
handles a 401 by refreshing the token once and retrying.
|
|
355
478
|
|
|
356
479
|
```typescript
|
|
357
|
-
import {
|
|
480
|
+
import { createEdgeConnectServerFromEnv } from '@edge-markets/connect-node'
|
|
358
481
|
|
|
359
|
-
const edge =
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
482
|
+
const { server: edge } = createEdgeConnectServerFromEnv()
|
|
483
|
+
|
|
484
|
+
const reconciler = edge.createWebhookReconciler({
|
|
485
|
+
cursorStore: {
|
|
486
|
+
load: () => db.cursors.get('edge_webhook_sync'),
|
|
487
|
+
save: (cursor) => db.cursors.set('edge_webhook_sync', cursor),
|
|
488
|
+
},
|
|
489
|
+
processEvent: processEdgeEvent, // same idempotent handler as live webhooks
|
|
490
|
+
limit: 100,
|
|
491
|
+
maxPages: 10,
|
|
365
492
|
})
|
|
366
493
|
|
|
367
494
|
async function reconcile() {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const { events, hasMore } = await edge.syncWebhookEvents({ afterEventId: cursor })
|
|
371
|
-
for (const event of events) {
|
|
372
|
-
await processEdgeEvent(event) // same idempotent handler as live webhooks
|
|
373
|
-
cursor = event.id
|
|
374
|
-
}
|
|
375
|
-
if (events.length > 0) await db.cursors.set('edge', cursor!)
|
|
376
|
-
if (!hasMore) break
|
|
377
|
-
}
|
|
495
|
+
const result = await reconciler.run()
|
|
496
|
+
logger.info('EDGE webhook reconciliation complete', result)
|
|
378
497
|
}
|
|
379
498
|
|
|
380
499
|
setInterval(reconcile, 5 * 60 * 1000)
|
|
381
500
|
```
|
|
382
501
|
|
|
502
|
+
The reconciler saves the cursor only after `processEvent(event)` succeeds and
|
|
503
|
+
skips overlapping runs on the same instance. You provide cursor storage and
|
|
504
|
+
the idempotent event handler; the SDK owns pagination and cursor advancement.
|
|
505
|
+
|
|
506
|
+
If you need full control, `edge.syncWebhookEvents()` remains available.
|
|
507
|
+
|
|
383
508
|
The server enforces a **30-day lookback**. If your cursor is older than 30
|
|
384
509
|
days, you will receive events from 30 days ago — re-bootstrap from a known
|
|
385
510
|
good state via `getTransfer` if you have been offline for longer.
|
|
@@ -396,6 +521,11 @@ good state via `getTransfer` if you have been offline for longer.
|
|
|
396
521
|
4. **Run reconciliation as a backstop** — webhooks are the fast path,
|
|
397
522
|
`syncWebhookEvents` is the safety net. They share the same handler.
|
|
398
523
|
|
|
524
|
+
`syncWebhookEvents` uses the same SDK mTLS transport as user-scoped API
|
|
525
|
+
calls, fetches and caches the partner `client_credentials` token internally,
|
|
526
|
+
retries transient sync failures with the SDK retry policy, and redacts token
|
|
527
|
+
material from observability hooks.
|
|
528
|
+
|
|
399
529
|
## Error Handling
|
|
400
530
|
|
|
401
531
|
```typescript
|