@edge-markets/connect-node 1.8.0 → 1.9.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
@@ -248,35 +255,46 @@ const decrypted = tokenVault.decryptTokens(connection.encryptedTokens)
248
255
  const client = edge.forUser(decrypted.accessToken)
249
256
  ```
250
257
 
251
- ## Token Refresh
258
+ ## Token Refresh And User Sessions
252
259
 
253
- Refresh tokens before they expire:
260
+ Use `EdgeUserSession` when you store encrypted tokens and want the SDK to
261
+ handle decrypt-refresh-save-client lifecycle. You still own the database table
262
+ and user mapping.
254
263
 
255
264
  ```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
- }
265
+ const session = edge.createUserSession({
266
+ subjectId: userId,
267
+ tokenVault,
268
+ tokenStore: {
269
+ load: async (subjectId) => {
270
+ const row = await db.edgeConnections.get(subjectId)
271
+ return row?.encryptedTokens
272
+ },
273
+ save: async (subjectId, encryptedTokens, { tokens }) => {
274
+ await db.edgeConnections.update(subjectId, {
275
+ encryptedTokens,
276
+ expiresAt: new Date(tokens.expiresAt),
277
+ })
278
+ },
279
+ },
280
+ refreshSkewMs: 60_000,
281
+ })
282
+
283
+ const balance = await session.withClient((client) => client.getBalance())
278
284
  ```
279
285
 
286
+ `EdgeUserSession` refreshes near-expiry tokens, preserves refresh tokens when
287
+ the token endpoint does not rotate them, saves the refreshed token envelope
288
+ before returning a client, and single-flights concurrent refreshes on the same
289
+ process. In horizontally scaled services, keep your storage callbacks
290
+ idempotent or add DB-level optimistic locking.
291
+
292
+ For migrations from split token columns, use `createSessionTokenRecord`,
293
+ `parseSessionTokenRecord`, `normalizeSessionExpiresAt`, and
294
+ `encryptLegacySessionTokens`. Plaintext migration requires an explicit
295
+ `allowPlaintext: true` option so production request paths do not silently accept
296
+ raw tokens.
297
+
280
298
  ## API Methods
281
299
 
282
300
  All user-scoped API methods live on `EdgeUserClient`, created via `edge.forUser(accessToken)`:
@@ -526,6 +544,110 @@ calls, fetches and caches the partner `client_credentials` token internally,
526
544
  retries transient sync failures with the SDK retry policy, and redacts token
527
545
  material from observability hooks.
528
546
 
547
+ ### Durable webhook inbox with `createWebhookInbox`
548
+
549
+ For production integrations, accept a verified webhook into durable storage
550
+ before returning `200`, then process it asynchronously. The SDK can own the
551
+ state machine while your app owns the database callbacks and wallet mutation.
552
+
553
+ ```typescript
554
+ import {
555
+ createWebhookInbox,
556
+ parseWebhookHttpRequest,
557
+ } from '@edge-markets/connect-node'
558
+
559
+ const inbox = createWebhookInbox({
560
+ store: {
561
+ insert: db.webhookInbox.insertIdempotently,
562
+ find: db.webhookInbox.find,
563
+ markProcessing: db.webhookInbox.markProcessing,
564
+ markProcessed: db.webhookInbox.markProcessed,
565
+ markFailed: db.webhookInbox.markFailed,
566
+ markPending: db.webhookInbox.markPending,
567
+ list: db.webhookInbox.list,
568
+ },
569
+ processEvent: async (event) => {
570
+ // Partner-owned business logic. Keep wallet mutation idempotent.
571
+ await processEdgeEvent(event)
572
+ },
573
+ })
574
+
575
+ app.post('/webhooks/edge', express.raw({ type: 'application/json' }), async (req, res) => {
576
+ const parsed = parseWebhookHttpRequest({
577
+ request: { headers: req.headers, rawBody: req.body },
578
+ secret: process.env.EDGE_WEBHOOK_SECRET!,
579
+ })
580
+
581
+ const accepted = await inbox.acceptRaw(
582
+ parsed,
583
+ Buffer.from(req.body).toString('utf8'),
584
+ String(req.headers['x-edge-signature'] ?? ''),
585
+ )
586
+
587
+ res.status(200).json({
588
+ received: true,
589
+ duplicate: accepted.duplicate,
590
+ status: accepted.status,
591
+ })
592
+
593
+ void inbox.process(accepted.eventId).catch((error) => {
594
+ logger.error('EDGE webhook processing failed', { eventId: accepted.eventId, error })
595
+ })
596
+ })
597
+ ```
598
+
599
+ The inbox statuses are `pending`, `processing`, `processed`, and `failed`.
600
+ Duplicate `event.id` deliveries are idempotent if the payload fingerprint
601
+ matches. A duplicate event ID with different payload fails closed.
602
+
603
+ Sync reconciliation can use the same inbox path:
604
+
605
+ ```typescript
606
+ const reconciler = edge.createWebhookReconciler({
607
+ cursorStore,
608
+ inbox,
609
+ limit: 100,
610
+ })
611
+ ```
612
+
613
+ The reconciler accepts each synced event into the inbox, processes it, and saves
614
+ the cursor only after processing succeeds or the event is already processed.
615
+ Use `inbox.replay(eventId)` for explicit admin replays and
616
+ `inbox.recoverStaleProcessing()` to make crashed `processing` rows replayable.
617
+
618
+ ### Transfer safety helpers
619
+
620
+ Use transfer helpers to avoid decimal and direction mistakes:
621
+
622
+ ```typescript
623
+ import {
624
+ assertWebhookMatchesTransfer,
625
+ createTransferIdempotencyKey,
626
+ getConnectTransferDirection,
627
+ mapTransferStatusToPartnerState,
628
+ normalizeMoneyAmount,
629
+ validateTransferAmount,
630
+ } from '@edge-markets/connect-node'
631
+
632
+ const amount = validateTransferAmount(input.amount, { min: '1.00', max: '10000.00' }, { allowNumber: false })
633
+ const idempotencyKey = createTransferIdempotencyKey({
634
+ partnerUserId: user.id,
635
+ type: 'debit',
636
+ amount,
637
+ category: 'sportsbook',
638
+ externalId: bet.id,
639
+ })
640
+
641
+ // debit = EDGE user to partner; credit = partner to EDGE user
642
+ const direction = getConnectTransferDirection('debit')
643
+
644
+ assertWebhookMatchesTransfer(event, {
645
+ transferId: localTransfer.edgeTransferId,
646
+ type: 'debit',
647
+ amount: localTransfer.amount,
648
+ })
649
+ ```
650
+
529
651
  ## Error Handling
530
652
 
531
653
  ```typescript
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as node_https from 'node:https';
2
- import { EdgeEnvironment, TransferCategory, Transfer, VerificationSession, TransferType, User, VerifyIdentityOptions, VerifyIdentityResult, Balance, ListTransfersParams, TransferList, CreateVerificationSessionRequest, VerificationSessionStatusResponse, EdgeTokens, EdgeWebhookEvent, TransferStatus, EdgeWebhookEventType } from '@edge-markets/connect';
2
+ import { EdgeEnvironment, TransferCategory, Transfer, VerificationSession, TransferType, TransferStatus, User, VerifyIdentityOptions, VerifyIdentityResult, Balance, ListTransfersParams, TransferList, CreateVerificationSessionRequest, VerificationSessionStatusResponse, EdgeTokens, EdgeWebhookEvent, EdgeWebhookEventType } from '@edge-markets/connect';
3
3
  export { Balance, BaseWebhookEvent, ConsentRevokedEventData, CreateVerificationSessionRequest, EDGE_WEBHOOK_EVENT_TYPES, EdgeApiError, EdgeAuthenticationError, EdgeConsentRequiredError, EdgeEnvironment, EdgeError, EdgeIdentityVerificationError, EdgeInsufficientScopeError, EdgeNetworkError, EdgeNotFoundError, EdgeTokenExchangeError, EdgeTokens, EdgeValidationError, EdgeWebhookEvent, EdgeWebhookEventType, ListTransfersParams, SdkGeolocation, TRANSFER_CATEGORIES, Transfer, TransferCategory, TransferCompletedEventData, TransferExpiredEventData, TransferFailedEventData, TransferList, TransferListItem, TransferProcessingEventData, TransferStatus, TransferType, User, UserAddress, VerificationSession, VerificationSessionStatus, VerificationSessionStatusResponse, VerifyIdentityAddress, VerifyIdentityOptions, VerifyIdentityResult, VerifyIdentityScores, getEnvironmentConfig, isApiError, isAuthenticationError, isConsentRequiredError, isEdgeError, isIdentityVerificationError, isNetworkError, isProductionEnvironment } from '@edge-markets/connect';
4
4
 
5
5
  type RequestEndpoint = 'connect_api' | 'oauth_token' | 'partner_webhook_sync';
@@ -107,6 +107,9 @@ interface TransferAmountLimits {
107
107
  min?: string | number;
108
108
  max?: string | number;
109
109
  }
110
+ interface MoneyAmountOptions {
111
+ allowNumber?: boolean;
112
+ }
110
113
  interface CreateTransferIdempotencyKeyOptions {
111
114
  partnerUserId: string;
112
115
  type: TransferType;
@@ -121,6 +124,14 @@ interface TransferIntent {
121
124
  amount: string | number;
122
125
  category?: TransferCategory | null;
123
126
  }
127
+ type ConnectTransferDirection = 'user_to_partner' | 'partner_to_user';
128
+ interface TransferStatusMapping<TPartnerState extends string = string> {
129
+ pending_verification: TPartnerState;
130
+ processing: TPartnerState;
131
+ completed: TPartnerState;
132
+ failed: TPartnerState;
133
+ expired: TPartnerState;
134
+ }
124
135
  interface StartTransferVerificationOptions {
125
136
  transfer: TransferOptions;
126
137
  origin: string;
@@ -129,10 +140,18 @@ interface StartTransferVerificationResult {
129
140
  transfer: Transfer;
130
141
  verificationSession: VerificationSession;
131
142
  }
132
- declare function normalizeMoneyAmount(value: string | number): string;
133
- declare function validateTransferAmount(value: string | number, limits?: TransferAmountLimits): string;
143
+ declare const CONNECT_TRANSFER_DIRECTIONS: {
144
+ readonly debit: "user_to_partner";
145
+ readonly credit: "partner_to_user";
146
+ };
147
+ declare function getConnectTransferDirection(type: TransferType): ConnectTransferDirection;
148
+ declare function normalizeMoneyAmount(value: string | number, options?: MoneyAmountOptions): string;
149
+ declare function validateTransferAmount(value: string | number, limits?: TransferAmountLimits, options?: MoneyAmountOptions): string;
134
150
  declare function createTransferIdempotencyKey(options: CreateTransferIdempotencyKeyOptions): string;
135
151
  declare function assertSameTransferIntent(existing: TransferIntent, requested: TransferIntent): void;
152
+ declare function fingerprintTransferIntent(intent: TransferIntent): string;
153
+ declare function moneyAmountsEqual(left: string | number, right: string | number, options?: MoneyAmountOptions): boolean;
154
+ declare function mapTransferStatusToPartnerState<TPartnerState extends string>(status: TransferStatus, mapping: TransferStatusMapping<TPartnerState>): TPartnerState;
136
155
  declare function startTransferVerification(client: Pick<EdgeUserClient, 'initiateTransfer' | 'createVerificationSession'>, options: StartTransferVerificationOptions): Promise<StartTransferVerificationResult>;
137
156
 
138
157
  /**
@@ -205,10 +224,22 @@ interface EdgeUserSessionOptions {
205
224
  refreshSkewMs?: number;
206
225
  now?: () => number;
207
226
  onRefresh?: (tokens: EdgeTokens) => Promise<void> | void;
227
+ onEvent?: (event: EdgeUserSessionEvent) => Promise<void> | void;
228
+ singleFlightRefresh?: boolean;
208
229
  }
209
230
  interface GetValidTokensOptions {
210
231
  forceRefresh?: boolean;
211
232
  }
233
+ type EdgeUserSessionEventType = 'session.loaded' | 'session.refresh_started' | 'session.refresh_succeeded' | 'session.refresh_failed' | 'session.saved';
234
+ interface EdgeUserSessionEvent {
235
+ type: EdgeUserSessionEventType;
236
+ subjectId: string;
237
+ refreshed?: boolean;
238
+ encrypted?: boolean;
239
+ expiresAt?: number;
240
+ scope?: string;
241
+ error?: string;
242
+ }
212
243
  declare class EdgeUserSession {
213
244
  private readonly edge;
214
245
  private readonly subjectId;
@@ -217,15 +248,225 @@ declare class EdgeUserSession {
217
248
  private readonly refreshSkewMs;
218
249
  private readonly now;
219
250
  private readonly onRefresh?;
251
+ private readonly onEvent?;
252
+ private readonly singleFlightRefresh;
253
+ private refreshPromise;
220
254
  constructor(options: EdgeUserSessionOptions);
221
255
  getValidTokens(options?: GetValidTokensOptions): Promise<EdgeTokens>;
222
256
  getClient(options?: GetValidTokensOptions): Promise<EdgeUserClient>;
223
- withClient<T>(callback: (client: EdgeUserClient, tokens: EdgeTokens) => Promise<T> | T): Promise<T>;
257
+ withClient<T>(callback: (client: EdgeUserClient, tokens: EdgeTokens) => Promise<T> | T, options?: GetValidTokensOptions): Promise<T>;
224
258
  saveTokens(tokens: EdgeTokens, refreshed?: boolean): Promise<void>;
225
259
  private loadTokens;
260
+ private refreshTokens;
261
+ private emitLoaded;
262
+ private emit;
226
263
  }
227
264
  declare function createEdgeUserSession(options: EdgeUserSessionOptions): EdgeUserSession;
228
265
 
266
+ /**
267
+ * Webhook Signature Verification (SDK-001)
268
+ *
269
+ * HMAC-SHA256 verification for EDGE Connect webhook payloads.
270
+ *
271
+ * Signature header format: `t={timestamp},v1={hex_hmac}`
272
+ * Signed message: `{timestamp}.{raw_json_body}`
273
+ *
274
+ * The timestamp prefix prevents replay attacks — events older than the
275
+ * tolerance window (default 5 minutes) are rejected. The HMAC is verified
276
+ * with constant-time comparison to prevent timing attacks.
277
+ */
278
+ /**
279
+ * Options for {@link verifyWebhookSignature}.
280
+ */
281
+ interface VerifyWebhookSignatureOptions {
282
+ /**
283
+ * Maximum age of the signature timestamp, in seconds.
284
+ *
285
+ * Signatures older than this are rejected as potential replays.
286
+ * Defaults to 300 seconds (5 minutes), matching the producer.
287
+ */
288
+ toleranceSeconds?: number;
289
+ }
290
+ /**
291
+ * Verifies an EDGE Connect webhook signature.
292
+ *
293
+ * **Always pass the raw request body bytes** — never `JSON.stringify(req.body)`.
294
+ * Re-stringifying after the framework parses JSON will reorder keys, change
295
+ * whitespace, and silently break verification with no diagnostic. Capture
296
+ * the raw body via your framework's raw-body middleware:
297
+ *
298
+ * - Express: `express.raw({ type: 'application/json' })`
299
+ * - Fastify: built-in (use `request.rawBody`)
300
+ * - NestJS: `{ rawBody: true }` on `NestFactory.create` + `RawBodyRequest<Request>`
301
+ *
302
+ * @param header - The `X-Edge-Signature` header value (`t=...,v1=...`)
303
+ * @param body - The raw request body as a UTF-8 string (NOT a parsed object)
304
+ * @param secret - The partner's webhook signing secret
305
+ * @param options - Optional overrides
306
+ * @returns `true` if the signature is valid and within the tolerance window,
307
+ * `false` otherwise. Never throws — malformed input returns `false`.
308
+ *
309
+ * @example
310
+ * ```typescript
311
+ * import { verifyWebhookSignature } from '@edge-markets/connect-node'
312
+ *
313
+ * app.post('/webhooks/edge', express.raw({ type: 'application/json' }), (req, res) => {
314
+ * const ok = verifyWebhookSignature(
315
+ * req.headers['x-edge-signature'] as string,
316
+ * req.body.toString('utf8'),
317
+ * process.env.EDGE_WEBHOOK_SECRET!,
318
+ * )
319
+ *
320
+ * if (!ok) return res.status(401).send('invalid signature')
321
+ *
322
+ * const event = JSON.parse(req.body.toString('utf8'))
323
+ * // ... process event
324
+ * res.status(200).send('ok')
325
+ * })
326
+ * ```
327
+ */
328
+ declare function verifyWebhookSignature(header: string, body: string, secret: string, options?: VerifyWebhookSignatureOptions): boolean;
329
+
330
+ type EdgeWebhookRawBody = string | Buffer | Uint8Array;
331
+ type EdgeWebhookSignatureHeader = string | string[] | undefined | null;
332
+ interface ParseAndVerifyWebhookOptions extends VerifyWebhookSignatureOptions {
333
+ rawBody: EdgeWebhookRawBody;
334
+ signatureHeader: EdgeWebhookSignatureHeader;
335
+ secret: string;
336
+ receivedAt?: Date;
337
+ }
338
+ interface ParsedEdgeWebhook {
339
+ event: EdgeWebhookEvent;
340
+ eventId: string;
341
+ eventType: EdgeWebhookEventType;
342
+ signatureTimestamp: number;
343
+ receivedAt: Date;
344
+ }
345
+ declare function parseAndVerifyWebhook(options: ParseAndVerifyWebhookOptions): ParsedEdgeWebhook;
346
+ declare function extractWebhookSignatureTimestamp(header: EdgeWebhookSignatureHeader): number | undefined;
347
+ declare function isEdgeWebhookEvent(value: unknown): value is EdgeWebhookEvent;
348
+ declare function validateEdgeWebhookEvent(value: unknown): EdgeWebhookEvent;
349
+
350
+ type WebhookInboxStatus = 'pending' | 'processing' | 'processed' | 'failed';
351
+ type WebhookInboxSource = 'live_webhook' | 'sync';
352
+ interface WebhookInboxRecord {
353
+ eventId: string;
354
+ eventType: EdgeWebhookEventType;
355
+ event: EdgeWebhookEvent;
356
+ payloadFingerprint: string;
357
+ status: WebhookInboxStatus;
358
+ source: WebhookInboxSource;
359
+ attempts: number;
360
+ receivedAt: Date;
361
+ updatedAt?: Date;
362
+ processedAt?: Date | null;
363
+ lastError?: string | null;
364
+ signatureTimestamp?: number | null;
365
+ signatureHeader?: string | null;
366
+ rawBody?: string | null;
367
+ }
368
+ interface WebhookInboxInsertRecord {
369
+ eventId: string;
370
+ eventType: EdgeWebhookEventType;
371
+ event: EdgeWebhookEvent;
372
+ payloadFingerprint: string;
373
+ source: WebhookInboxSource;
374
+ receivedAt: Date;
375
+ signatureTimestamp?: number | null;
376
+ signatureHeader?: string | null;
377
+ rawBody?: string | null;
378
+ }
379
+ type WebhookInboxInsertResult = {
380
+ inserted: true;
381
+ record: WebhookInboxRecord;
382
+ } | {
383
+ inserted: false;
384
+ record: WebhookInboxRecord;
385
+ };
386
+ interface WebhookInboxListOptions {
387
+ status?: WebhookInboxStatus;
388
+ updatedBefore?: Date;
389
+ limit?: number;
390
+ }
391
+ interface WebhookInboxStore {
392
+ insert: (record: WebhookInboxInsertRecord) => Promise<WebhookInboxInsertResult> | WebhookInboxInsertResult;
393
+ find: (eventId: string) => Promise<WebhookInboxRecord | null | undefined> | WebhookInboxRecord | null | undefined;
394
+ markProcessing: (eventId: string, context: {
395
+ now: Date;
396
+ }) => Promise<WebhookInboxRecord | null | undefined> | WebhookInboxRecord | null | undefined;
397
+ markProcessed: (eventId: string, context: {
398
+ now: Date;
399
+ }) => Promise<WebhookInboxRecord | null | undefined> | WebhookInboxRecord | null | undefined;
400
+ markFailed: (eventId: string, context: {
401
+ now: Date;
402
+ error: string;
403
+ }) => Promise<WebhookInboxRecord | null | undefined> | WebhookInboxRecord | null | undefined;
404
+ markPending?: (eventId: string, context: {
405
+ now: Date;
406
+ reason: string;
407
+ }) => Promise<WebhookInboxRecord | null | undefined> | WebhookInboxRecord | null | undefined;
408
+ list?: (options: WebhookInboxListOptions) => Promise<WebhookInboxRecord[]> | WebhookInboxRecord[];
409
+ }
410
+ interface WebhookInboxOptions {
411
+ store: WebhookInboxStore;
412
+ processEvent: (event: EdgeWebhookEvent, record: WebhookInboxRecord) => Promise<void> | void;
413
+ now?: () => Date;
414
+ storeRawBody?: boolean;
415
+ processingTimeoutMs?: number;
416
+ onEvent?: (event: WebhookInboxLifecycleEvent) => Promise<void> | void;
417
+ }
418
+ interface WebhookInboxLifecycleEvent {
419
+ type: 'webhook.accepted' | 'webhook.duplicate' | 'webhook.payload_mismatch' | 'webhook.processing_started' | 'webhook.processing_succeeded' | 'webhook.processing_failed' | 'webhook.recovered_stale';
420
+ eventId: string;
421
+ eventType?: EdgeWebhookEventType;
422
+ status?: WebhookInboxStatus;
423
+ source?: WebhookInboxSource;
424
+ attempts?: number;
425
+ error?: string;
426
+ }
427
+ interface WebhookInboxAcceptResult {
428
+ eventId: string;
429
+ eventType: EdgeWebhookEventType;
430
+ inserted: boolean;
431
+ duplicate: boolean;
432
+ status: WebhookInboxStatus;
433
+ record: WebhookInboxRecord;
434
+ }
435
+ interface WebhookInboxProcessResult {
436
+ eventId: string;
437
+ status: WebhookInboxStatus;
438
+ processed: boolean;
439
+ skipped: boolean;
440
+ reason?: 'already_processed' | 'not_found' | 'locked';
441
+ record?: WebhookInboxRecord;
442
+ }
443
+ interface WebhookInboxRecoverResult {
444
+ recovered: number;
445
+ skipped: boolean;
446
+ reason?: 'list_not_configured' | 'mark_pending_not_configured';
447
+ }
448
+ declare class EdgeWebhookInbox {
449
+ private readonly store;
450
+ private readonly processEvent;
451
+ private readonly now;
452
+ private readonly storeRawBody;
453
+ private readonly processingTimeoutMs;
454
+ private readonly onEvent?;
455
+ constructor(options: WebhookInboxOptions);
456
+ accept(parsed: ParsedEdgeWebhook): Promise<WebhookInboxAcceptResult>;
457
+ acceptRaw(parsed: ParsedEdgeWebhook, rawBody: string, signatureHeader: string): Promise<WebhookInboxAcceptResult>;
458
+ acceptSyncedEvent(event: EdgeWebhookEvent, receivedAt?: Date): Promise<WebhookInboxAcceptResult>;
459
+ process(eventId: string, options?: {
460
+ force?: boolean;
461
+ }): Promise<WebhookInboxProcessResult>;
462
+ replay(eventId: string): Promise<WebhookInboxProcessResult>;
463
+ recoverStaleProcessing(): Promise<WebhookInboxRecoverResult>;
464
+ private acceptEvent;
465
+ private emit;
466
+ }
467
+ declare function createWebhookInbox(options: WebhookInboxOptions): EdgeWebhookInbox;
468
+ declare function fingerprintWebhookEvent(event: EdgeWebhookEvent): string;
469
+
229
470
  interface WebhookReconcilerCursorStore {
230
471
  load: () => Promise<string | null | undefined> | string | null | undefined;
231
472
  save: (cursor: string) => Promise<void> | void;
@@ -233,12 +474,15 @@ interface WebhookReconcilerCursorStore {
233
474
  interface WebhookReconcilerOptions {
234
475
  edge: Pick<EdgeConnectServer, 'syncWebhookEvents'>;
235
476
  cursorStore: WebhookReconcilerCursorStore;
236
- processEvent: (event: EdgeWebhookEvent) => Promise<void> | void;
477
+ processEvent?: (event: EdgeWebhookEvent) => Promise<void> | void;
478
+ inbox?: Pick<EdgeWebhookInbox, 'acceptSyncedEvent' | 'process'>;
237
479
  limit?: SyncWebhookEventsOptions['limit'];
238
480
  maxPages?: number;
239
481
  }
240
482
  interface WebhookReconcilerRunResult {
241
483
  processed: number;
484
+ accepted?: number;
485
+ duplicates?: number;
242
486
  pages: number;
243
487
  cursor?: string;
244
488
  hasMore: boolean;
@@ -437,6 +681,19 @@ interface EdgeHttpError {
437
681
  declare function toEdgeHttpError(error: unknown): EdgeHttpError;
438
682
  declare function toEdgeProblemJson(error: unknown): EdgeProblemJson;
439
683
 
684
+ interface EdgeWebhookHttpRequestLike {
685
+ headers?: Record<string, unknown>;
686
+ rawBody?: Buffer | Uint8Array | string;
687
+ body?: unknown;
688
+ }
689
+ interface ParseWebhookHttpRequestOptions extends Omit<ParseAndVerifyWebhookOptions, 'rawBody' | 'signatureHeader'> {
690
+ request: EdgeWebhookHttpRequestLike;
691
+ signatureHeaderName?: string;
692
+ }
693
+ declare function getEdgeWebhookRawBody(request: EdgeWebhookHttpRequestLike): EdgeWebhookRawBody;
694
+ declare function getEdgeWebhookSignatureHeader(request: EdgeWebhookHttpRequestLike, headerName?: string): EdgeWebhookSignatureHeader;
695
+ declare function parseWebhookHttpRequest(options: ParseWebhookHttpRequestOptions): ParsedEdgeWebhook;
696
+
440
697
  interface PartnerIdentityAddress {
441
698
  street?: string | null;
442
699
  line1?: string | null;
@@ -467,6 +724,43 @@ interface IdentityPolicyEvaluation {
467
724
  declare function buildVerifyIdentityPayload(profile: PartnerIdentityProfile): VerifyIdentityOptions;
468
725
  declare function evaluateIdentityResult(result: VerifyIdentityResult, policy?: IdentityPolicy): IdentityPolicyEvaluation;
469
726
 
727
+ interface EdgeSessionTokenRecord {
728
+ subjectId: string;
729
+ encryptedTokens: string;
730
+ expiresAt: number;
731
+ scopes?: string[];
732
+ edgeUserId?: string;
733
+ keyId?: string;
734
+ metadata?: Record<string, unknown>;
735
+ }
736
+ interface CreateSessionTokenRecordOptions {
737
+ scopes?: string[];
738
+ edgeUserId?: string;
739
+ keyId?: string;
740
+ metadata?: Record<string, unknown>;
741
+ }
742
+ interface LegacySessionTokenRecord {
743
+ subjectId?: string;
744
+ accessToken?: string | null;
745
+ refreshToken?: string | null;
746
+ idToken?: string | null;
747
+ expiresAt?: unknown;
748
+ expiresIn?: unknown;
749
+ scope?: unknown;
750
+ scopes?: unknown;
751
+ edgeUserId?: string | null;
752
+ metadata?: Record<string, unknown>;
753
+ }
754
+ interface EncryptLegacySessionTokensOptions {
755
+ subjectId: string;
756
+ keyId?: string;
757
+ allowPlaintext?: boolean;
758
+ }
759
+ declare function createSessionTokenRecord(subjectId: string, tokens: EdgeTokens, tokenVault: Pick<EdgeTokenVault, 'encryptTokens'>, options?: CreateSessionTokenRecordOptions): EdgeSessionTokenRecord;
760
+ declare function parseSessionTokenRecord(record: unknown): EdgeSessionTokenRecord;
761
+ declare function encryptLegacySessionTokens(legacyRecord: LegacySessionTokenRecord, tokenVault: Pick<EdgeTokenVault, 'encryptTokens' | 'isEncrypted'>, options: EncryptLegacySessionTokensOptions): EdgeSessionTokenRecord;
762
+ declare function normalizeSessionExpiresAt(value: unknown): number;
763
+
470
764
  interface TransferWebhookIntent {
471
765
  transferId: string;
472
766
  type?: TransferType;
@@ -477,89 +771,6 @@ declare function isTransferWebhookEvent(event: EdgeWebhookEvent): event is Extra
477
771
  type: `transfer.${string}`;
478
772
  }>;
479
773
  declare function assertTransferEventMatchesIntent(event: EdgeWebhookEvent, intent: TransferWebhookIntent): void;
774
+ declare const assertWebhookMatchesTransfer: typeof assertTransferEventMatchesIntent;
480
775
 
481
- /**
482
- * Webhook Signature Verification (SDK-001)
483
- *
484
- * HMAC-SHA256 verification for EDGE Connect webhook payloads.
485
- *
486
- * Signature header format: `t={timestamp},v1={hex_hmac}`
487
- * Signed message: `{timestamp}.{raw_json_body}`
488
- *
489
- * The timestamp prefix prevents replay attacks — events older than the
490
- * tolerance window (default 5 minutes) are rejected. The HMAC is verified
491
- * with constant-time comparison to prevent timing attacks.
492
- */
493
- /**
494
- * Options for {@link verifyWebhookSignature}.
495
- */
496
- interface VerifyWebhookSignatureOptions {
497
- /**
498
- * Maximum age of the signature timestamp, in seconds.
499
- *
500
- * Signatures older than this are rejected as potential replays.
501
- * Defaults to 300 seconds (5 minutes), matching the producer.
502
- */
503
- toleranceSeconds?: number;
504
- }
505
- /**
506
- * Verifies an EDGE Connect webhook signature.
507
- *
508
- * **Always pass the raw request body bytes** — never `JSON.stringify(req.body)`.
509
- * Re-stringifying after the framework parses JSON will reorder keys, change
510
- * whitespace, and silently break verification with no diagnostic. Capture
511
- * the raw body via your framework's raw-body middleware:
512
- *
513
- * - Express: `express.raw({ type: 'application/json' })`
514
- * - Fastify: built-in (use `request.rawBody`)
515
- * - NestJS: `{ rawBody: true }` on `NestFactory.create` + `RawBodyRequest<Request>`
516
- *
517
- * @param header - The `X-Edge-Signature` header value (`t=...,v1=...`)
518
- * @param body - The raw request body as a UTF-8 string (NOT a parsed object)
519
- * @param secret - The partner's webhook signing secret
520
- * @param options - Optional overrides
521
- * @returns `true` if the signature is valid and within the tolerance window,
522
- * `false` otherwise. Never throws — malformed input returns `false`.
523
- *
524
- * @example
525
- * ```typescript
526
- * import { verifyWebhookSignature } from '@edge-markets/connect-node'
527
- *
528
- * app.post('/webhooks/edge', express.raw({ type: 'application/json' }), (req, res) => {
529
- * const ok = verifyWebhookSignature(
530
- * req.headers['x-edge-signature'] as string,
531
- * req.body.toString('utf8'),
532
- * process.env.EDGE_WEBHOOK_SECRET!,
533
- * )
534
- *
535
- * if (!ok) return res.status(401).send('invalid signature')
536
- *
537
- * const event = JSON.parse(req.body.toString('utf8'))
538
- * // ... process event
539
- * res.status(200).send('ok')
540
- * })
541
- * ```
542
- */
543
- declare function verifyWebhookSignature(header: string, body: string, secret: string, options?: VerifyWebhookSignatureOptions): boolean;
544
-
545
- type EdgeWebhookRawBody = string | Buffer | Uint8Array;
546
- type EdgeWebhookSignatureHeader = string | string[] | undefined | null;
547
- interface ParseAndVerifyWebhookOptions extends VerifyWebhookSignatureOptions {
548
- rawBody: EdgeWebhookRawBody;
549
- signatureHeader: EdgeWebhookSignatureHeader;
550
- secret: string;
551
- receivedAt?: Date;
552
- }
553
- interface ParsedEdgeWebhook {
554
- event: EdgeWebhookEvent;
555
- eventId: string;
556
- eventType: EdgeWebhookEventType;
557
- signatureTimestamp: number;
558
- receivedAt: Date;
559
- }
560
- declare function parseAndVerifyWebhook(options: ParseAndVerifyWebhookOptions): ParsedEdgeWebhook;
561
- declare function extractWebhookSignatureTimestamp(header: EdgeWebhookSignatureHeader): number | undefined;
562
- declare function isEdgeWebhookEvent(value: unknown): value is EdgeWebhookEvent;
563
- declare function validateEdgeWebhookEvent(value: unknown): EdgeWebhookEvent;
564
-
565
- export { type CreateEdgeConnectServerFromEnvOptions, type CreateTransferIdempotencyKeyOptions, type EdgeConnectEnv, EdgeConnectServer, type EdgeConnectServerConfig, type EdgeConnectServerFromEnvCapabilities, type EdgeConnectServerFromEnvResult, type EdgeHttpError, type EdgeProblemJson, type EdgeTokenStore, type EdgeTokenStoreSaveContext, type EdgeTokenStoreValue, EdgeTokenVault, type EdgeTokenVaultKey, type EdgeTokenVaultKeyMaterial, type EdgeTokenVaultOptions, EdgeUserClient, EdgeUserSession, type EdgeUserSessionOptions, type EdgeWebhookRawBody, EdgeWebhookReconciler, type EdgeWebhookSignatureHeader, type GetValidTokensOptions, type IdentityPolicy, type IdentityPolicyEvaluation, type MtlsConfig, type ParseAndVerifyWebhookOptions, type ParsedEdgeWebhook, type PartnerIdentityAddress, type PartnerIdentityProfile, type RequestInfo, type ResponseInfo, type RetryConfig, type StartTransferVerificationOptions, type StartTransferVerificationResult, type SyncWebhookEventsOptions, type SyncWebhookEventsResult, type TransferAmountLimits, type TransferIntent, type TransferOptions, type TransferWebhookIntent, type VerifyWebhookSignatureOptions, type WebhookReconcilerCursorStore, type WebhookReconcilerOptions, type WebhookReconcilerRunResult, assertSameTransferIntent, assertTransferEventMatchesIntent, buildVerifyIdentityPayload, createEdgeConnectServerFromEnv, createEdgeTokenVault, createEdgeUserSession, createTransferIdempotencyKey, createWebhookReconciler, evaluateIdentityResult, extractWebhookSignatureTimestamp, isEdgeTokenVaultEnvelope, isEdgeWebhookEvent, isTransferWebhookEvent, normalizeMoneyAmount, parseAndVerifyWebhook, startTransferVerification, toEdgeHttpError, toEdgeProblemJson, validateEdgeWebhookEvent, validateTransferAmount, verifyWebhookSignature };
776
+ export { CONNECT_TRANSFER_DIRECTIONS, type ConnectTransferDirection, type CreateEdgeConnectServerFromEnvOptions, type CreateSessionTokenRecordOptions, type CreateTransferIdempotencyKeyOptions, type EdgeConnectEnv, EdgeConnectServer, type EdgeConnectServerConfig, type EdgeConnectServerFromEnvCapabilities, type EdgeConnectServerFromEnvResult, type EdgeHttpError, type EdgeProblemJson, type EdgeSessionTokenRecord, type EdgeTokenStore, type EdgeTokenStoreSaveContext, type EdgeTokenStoreValue, EdgeTokenVault, type EdgeTokenVaultKey, type EdgeTokenVaultKeyMaterial, type EdgeTokenVaultOptions, EdgeUserClient, EdgeUserSession, type EdgeUserSessionEvent, type EdgeUserSessionEventType, type EdgeUserSessionOptions, type EdgeWebhookHttpRequestLike, EdgeWebhookInbox, type EdgeWebhookRawBody, EdgeWebhookReconciler, type EdgeWebhookSignatureHeader, type EncryptLegacySessionTokensOptions, type GetValidTokensOptions, type IdentityPolicy, type IdentityPolicyEvaluation, type LegacySessionTokenRecord, type MoneyAmountOptions, type MtlsConfig, type ParseAndVerifyWebhookOptions, type ParseWebhookHttpRequestOptions, type ParsedEdgeWebhook, type PartnerIdentityAddress, type PartnerIdentityProfile, type RequestInfo, type ResponseInfo, type RetryConfig, type StartTransferVerificationOptions, type StartTransferVerificationResult, type SyncWebhookEventsOptions, type SyncWebhookEventsResult, type TransferAmountLimits, type TransferIntent, type TransferOptions, type TransferStatusMapping, type TransferWebhookIntent, type VerifyWebhookSignatureOptions, type WebhookInboxAcceptResult, type WebhookInboxInsertRecord, type WebhookInboxInsertResult, type WebhookInboxLifecycleEvent, type WebhookInboxListOptions, type WebhookInboxOptions, type WebhookInboxProcessResult, type WebhookInboxRecord, type WebhookInboxRecoverResult, type WebhookInboxSource, type WebhookInboxStatus, type WebhookInboxStore, type WebhookReconcilerCursorStore, type WebhookReconcilerOptions, type WebhookReconcilerRunResult, assertSameTransferIntent, assertTransferEventMatchesIntent, assertWebhookMatchesTransfer, buildVerifyIdentityPayload, createEdgeConnectServerFromEnv, createEdgeTokenVault, createEdgeUserSession, createSessionTokenRecord, createTransferIdempotencyKey, createWebhookInbox, createWebhookReconciler, encryptLegacySessionTokens, evaluateIdentityResult, extractWebhookSignatureTimestamp, fingerprintTransferIntent, fingerprintWebhookEvent, getConnectTransferDirection, getEdgeWebhookRawBody, getEdgeWebhookSignatureHeader, isEdgeTokenVaultEnvelope, isEdgeWebhookEvent, isTransferWebhookEvent, mapTransferStatusToPartnerState, moneyAmountsEqual, normalizeMoneyAmount, normalizeSessionExpiresAt, parseAndVerifyWebhook, parseSessionTokenRecord, parseWebhookHttpRequest, startTransferVerification, toEdgeHttpError, toEdgeProblemJson, validateEdgeWebhookEvent, validateTransferAmount, verifyWebhookSignature };