@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 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
- | `EDGE_MTLS_CA` / `EDGE_MTLS_CA_PATH` | Optional extra server trust root PEM or file path |
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
- Refresh tokens before they expire:
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
- async function getValidAccessToken(userId: string): Promise<string> {
257
- const connection = await db.edgeConnections.get(userId)
258
-
259
- // Refresh 5 minutes before expiry
260
- const BUFFER = 5 * 60 * 1000
261
-
262
- if (Date.now() > connection.expiresAt.getTime() - BUFFER) {
263
- const newTokens = await edge.refreshTokens(
264
- decrypt(connection.refreshToken)
265
- )
266
-
267
- await db.edgeConnections.update(userId, {
268
- accessToken: encrypt(newTokens.accessToken),
269
- refreshToken: encrypt(newTokens.refreshToken),
270
- expiresAt: new Date(newTokens.expiresAt),
271
- })
272
-
273
- return newTokens.accessToken
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