@edge-markets/connect-node 1.8.0 → 1.10.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 +149 -25
- package/dist/index.d.mts +306 -96
- package/dist/index.d.ts +306 -96
- package/dist/index.js +901 -287
- package/dist/index.mjs +814 -214
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,9 +12,16 @@ Server SDK for EDGE Connect token exchange and API calls.
|
|
|
12
12
|
- 🪝 **Webhook signature verification** - Constant-time HMAC-SHA256 with replay protection
|
|
13
13
|
- ✅ **Webhook parsing + validation** - Verify raw bodies and fail closed on malformed event payloads
|
|
14
14
|
- 🔁 **Webhook reconciliation** - Cursor-based sync for catching missed events
|
|
15
|
+
- 📥 **Webhook inbox orchestration** - Durable accept/process/replay control flow with partner-owned storage
|
|
15
16
|
- 🔐 **Token vault** - Versioned AES-256-GCM token encryption with key rotation
|
|
17
|
+
- 🔄 **User sessions** - Token-store callbacks with refresh, encryption, and single-flight refresh protection
|
|
16
18
|
- ⚙️ **Env config helper** - Build SDK config from standard EDGE env vars
|
|
17
19
|
|
|
20
|
+
These helpers are framework-neutral: the SDK owns repeatable Connect mechanics
|
|
21
|
+
such as token refresh, webhook validation, replay control flow, and transfer
|
|
22
|
+
intent checks, while partner applications keep ownership of persistence, wallet
|
|
23
|
+
mutation, admin authorization, and business rules.
|
|
24
|
+
|
|
18
25
|
## Installation
|
|
19
26
|
|
|
20
27
|
```bash
|
|
@@ -85,7 +92,9 @@ Supported environment variables:
|
|
|
85
92
|
| `EDGE_PARTNER_CLIENT_SECRET` | Partner machine-to-machine client secret for webhook sync |
|
|
86
93
|
| `EDGE_MTLS_CERT` / `EDGE_MTLS_CERT_PATH` | Client certificate PEM or file path |
|
|
87
94
|
| `EDGE_MTLS_KEY` / `EDGE_MTLS_KEY_PATH` | Client private key PEM or file path |
|
|
88
|
-
| `
|
|
95
|
+
| `EDGE_SERVER_CA_PEM` / `EDGE_SERVER_CA_PEM_PATH` | Optional extra server trust root PEM or file path |
|
|
96
|
+
| `EDGE_MTLS_CA` / `EDGE_MTLS_CA_PATH` | Legacy alias for optional extra server trust root PEM or file path |
|
|
97
|
+
| `EDGE_REQUIRE_MTLS` | Set to `true` to fail startup unless client cert and key are configured |
|
|
89
98
|
| `EDGE_TIMEOUT_MS` | Optional request timeout in milliseconds |
|
|
90
99
|
|
|
91
100
|
Use inline PEMs with escaped newlines (`\n`) or file paths. If both are set,
|
|
@@ -248,35 +257,46 @@ const decrypted = tokenVault.decryptTokens(connection.encryptedTokens)
|
|
|
248
257
|
const client = edge.forUser(decrypted.accessToken)
|
|
249
258
|
```
|
|
250
259
|
|
|
251
|
-
## Token Refresh
|
|
260
|
+
## Token Refresh And User Sessions
|
|
252
261
|
|
|
253
|
-
|
|
262
|
+
Use `EdgeUserSession` when you store encrypted tokens and want the SDK to
|
|
263
|
+
handle decrypt-refresh-save-client lifecycle. You still own the database table
|
|
264
|
+
and user mapping.
|
|
254
265
|
|
|
255
266
|
```typescript
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return decrypt(connection.accessToken)
|
|
277
|
-
}
|
|
267
|
+
const session = edge.createUserSession({
|
|
268
|
+
subjectId: userId,
|
|
269
|
+
tokenVault,
|
|
270
|
+
tokenStore: {
|
|
271
|
+
load: async (subjectId) => {
|
|
272
|
+
const row = await db.edgeConnections.get(subjectId)
|
|
273
|
+
return row?.encryptedTokens
|
|
274
|
+
},
|
|
275
|
+
save: async (subjectId, encryptedTokens, { tokens }) => {
|
|
276
|
+
await db.edgeConnections.update(subjectId, {
|
|
277
|
+
encryptedTokens,
|
|
278
|
+
expiresAt: new Date(tokens.expiresAt),
|
|
279
|
+
})
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
refreshSkewMs: 60_000,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
const balance = await session.withClient((client) => client.getBalance())
|
|
278
286
|
```
|
|
279
287
|
|
|
288
|
+
`EdgeUserSession` refreshes near-expiry tokens, preserves refresh tokens when
|
|
289
|
+
the token endpoint does not rotate them, saves the refreshed token envelope
|
|
290
|
+
before returning a client, and single-flights concurrent refreshes on the same
|
|
291
|
+
process. In horizontally scaled services, keep your storage callbacks
|
|
292
|
+
idempotent or add DB-level optimistic locking.
|
|
293
|
+
|
|
294
|
+
For migrations from split token columns, use `createSessionTokenRecord`,
|
|
295
|
+
`parseSessionTokenRecord`, `normalizeSessionExpiresAt`, and
|
|
296
|
+
`encryptLegacySessionTokens`. Plaintext migration requires an explicit
|
|
297
|
+
`allowPlaintext: true` option so production request paths do not silently accept
|
|
298
|
+
raw tokens.
|
|
299
|
+
|
|
280
300
|
## API Methods
|
|
281
301
|
|
|
282
302
|
All user-scoped API methods live on `EdgeUserClient`, created via `edge.forUser(accessToken)`:
|
|
@@ -526,6 +546,110 @@ calls, fetches and caches the partner `client_credentials` token internally,
|
|
|
526
546
|
retries transient sync failures with the SDK retry policy, and redacts token
|
|
527
547
|
material from observability hooks.
|
|
528
548
|
|
|
549
|
+
### Durable webhook inbox with `createWebhookInbox`
|
|
550
|
+
|
|
551
|
+
For production integrations, accept a verified webhook into durable storage
|
|
552
|
+
before returning `200`, then process it asynchronously. The SDK can own the
|
|
553
|
+
state machine while your app owns the database callbacks and wallet mutation.
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
import {
|
|
557
|
+
createWebhookInbox,
|
|
558
|
+
parseWebhookHttpRequest,
|
|
559
|
+
} from '@edge-markets/connect-node'
|
|
560
|
+
|
|
561
|
+
const inbox = createWebhookInbox({
|
|
562
|
+
store: {
|
|
563
|
+
insert: db.webhookInbox.insertIdempotently,
|
|
564
|
+
find: db.webhookInbox.find,
|
|
565
|
+
markProcessing: db.webhookInbox.markProcessing,
|
|
566
|
+
markProcessed: db.webhookInbox.markProcessed,
|
|
567
|
+
markFailed: db.webhookInbox.markFailed,
|
|
568
|
+
markPending: db.webhookInbox.markPending,
|
|
569
|
+
list: db.webhookInbox.list,
|
|
570
|
+
},
|
|
571
|
+
processEvent: async (event) => {
|
|
572
|
+
// Partner-owned business logic. Keep wallet mutation idempotent.
|
|
573
|
+
await processEdgeEvent(event)
|
|
574
|
+
},
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
app.post('/webhooks/edge', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
578
|
+
const parsed = parseWebhookHttpRequest({
|
|
579
|
+
request: { headers: req.headers, rawBody: req.body },
|
|
580
|
+
secret: process.env.EDGE_WEBHOOK_SECRET!,
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
const accepted = await inbox.acceptRaw(
|
|
584
|
+
parsed,
|
|
585
|
+
Buffer.from(req.body).toString('utf8'),
|
|
586
|
+
String(req.headers['x-edge-signature'] ?? ''),
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
res.status(200).json({
|
|
590
|
+
received: true,
|
|
591
|
+
duplicate: accepted.duplicate,
|
|
592
|
+
status: accepted.status,
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
void inbox.process(accepted.eventId).catch((error) => {
|
|
596
|
+
logger.error('EDGE webhook processing failed', { eventId: accepted.eventId, error })
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
The inbox statuses are `pending`, `processing`, `processed`, and `failed`.
|
|
602
|
+
Duplicate `event.id` deliveries are idempotent if the payload fingerprint
|
|
603
|
+
matches. A duplicate event ID with different payload fails closed.
|
|
604
|
+
|
|
605
|
+
Sync reconciliation can use the same inbox path:
|
|
606
|
+
|
|
607
|
+
```typescript
|
|
608
|
+
const reconciler = edge.createWebhookReconciler({
|
|
609
|
+
cursorStore,
|
|
610
|
+
inbox,
|
|
611
|
+
limit: 100,
|
|
612
|
+
})
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
The reconciler accepts each synced event into the inbox, processes it, and saves
|
|
616
|
+
the cursor only after processing succeeds or the event is already processed.
|
|
617
|
+
Use `inbox.replay(eventId)` for explicit admin replays and
|
|
618
|
+
`inbox.recoverStaleProcessing()` to make crashed `processing` rows replayable.
|
|
619
|
+
|
|
620
|
+
### Transfer safety helpers
|
|
621
|
+
|
|
622
|
+
Use transfer helpers to avoid decimal and direction mistakes:
|
|
623
|
+
|
|
624
|
+
```typescript
|
|
625
|
+
import {
|
|
626
|
+
assertWebhookMatchesTransfer,
|
|
627
|
+
createTransferIdempotencyKey,
|
|
628
|
+
getConnectTransferDirection,
|
|
629
|
+
mapTransferStatusToPartnerState,
|
|
630
|
+
normalizeMoneyAmount,
|
|
631
|
+
validateTransferAmount,
|
|
632
|
+
} from '@edge-markets/connect-node'
|
|
633
|
+
|
|
634
|
+
const amount = validateTransferAmount(input.amount, { min: '1.00', max: '10000.00' }, { allowNumber: false })
|
|
635
|
+
const idempotencyKey = createTransferIdempotencyKey({
|
|
636
|
+
partnerUserId: user.id,
|
|
637
|
+
type: 'debit',
|
|
638
|
+
amount,
|
|
639
|
+
category: 'sportsbook',
|
|
640
|
+
externalId: bet.id,
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
// debit = EDGE user to partner; credit = partner to EDGE user
|
|
644
|
+
const direction = getConnectTransferDirection('debit')
|
|
645
|
+
|
|
646
|
+
assertWebhookMatchesTransfer(event, {
|
|
647
|
+
transferId: localTransfer.edgeTransferId,
|
|
648
|
+
type: 'debit',
|
|
649
|
+
amount: localTransfer.amount,
|
|
650
|
+
})
|
|
651
|
+
```
|
|
652
|
+
|
|
529
653
|
## Error Handling
|
|
530
654
|
|
|
531
655
|
```typescript
|