From 5f4f25a6322bf96b32ec2cb0b1f86651078ab401 Mon Sep 17 00:00:00 2001 From: horn111 Date: Sun, 28 Jun 2026 19:11:00 +0300 Subject: [PATCH] Add Arc Receipts webhook inbox replay --- CHANGELOG.md | 1 + README.md | 3 + ROADMAP.md | 1 + apps/demo/src/app/api/receipts/route.ts | 7 +- .../src/app/api/webhook-inbox/replay/route.ts | 29 ++ apps/demo/src/app/api/webhook-inbox/route.ts | 24 ++ apps/demo/src/app/api/webhook-inbox/store.ts | 10 + apps/demo/src/app/page.tsx | 297 +++++++++++++++++- docs/receipts.md | 37 ++- packages/sdk/README.md | 2 +- packages/sdk/src/index.ts | 3 + packages/sdk/src/receipts/index.ts | 8 + packages/sdk/src/receipts/types.ts | 16 + .../sdk/src/receipts/webhook-inbox.test.ts | 111 +++++++ packages/sdk/src/receipts/webhook-inbox.ts | 152 +++++++++ 15 files changed, 681 insertions(+), 20 deletions(-) create mode 100644 apps/demo/src/app/api/webhook-inbox/replay/route.ts create mode 100644 apps/demo/src/app/api/webhook-inbox/route.ts create mode 100644 apps/demo/src/app/api/webhook-inbox/store.ts create mode 100644 packages/sdk/src/receipts/webhook-inbox.test.ts create mode 100644 packages/sdk/src/receipts/webhook-inbox.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 847f6fb..9de5b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Arc Receipts MVP with invoice memos, receipt matching, in-memory ledger, and signed webhook helpers. - Arc Testnet watcher for memo-wrapped USDC payments, plus memo payment request helpers and official Arc Testnet contract constants. +- Webhook Inbox + Replay for local signed-webhook delivery verification and replay attempts. ## [0.2.0-alpha] - 2026-06-02 diff --git a/README.md b/README.md index e5abde9..5bc15ee 100644 --- a/README.md +++ b/README.md @@ -303,12 +303,15 @@ Invoice, transaction memo, watcher, receipt, and signed webhook helpers for Arc import { ArcReceiptWatcher, ReceiptLedger, + WebhookInbox, createInvoiceMemo, createMemoPaymentRequest, verifyWebhookSignature, } from '@arc-nano-kit/sdk/receipts'; ``` +`WebhookInbox` lets local apps receive a signed `invoice.paid` webhook, verify the signature, store the delivery attempt, and replay the same event while developing payment operations. + ## 🌐 Why Arc? Arc is Circle's Layer 1 blockchain — purpose-built for stablecoin-native finance. Here's why it's the ideal foundation for usage-based billing: diff --git a/ROADMAP.md b/ROADMAP.md index 1774e1d..de04e4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,6 +37,7 @@ arc-nano-kit is under active development. This roadmap outlines our planned mile - [x] Receipt matching and in-memory ledger - [x] HMAC-signed webhook events - [x] Arc Testnet payment watcher +- [x] Local webhook inbox and replay attempts - [ ] Persistent watcher cursor - [ ] SQLite/Postgres receipt store - [ ] Next.js webhook route helpers diff --git a/apps/demo/src/app/api/receipts/route.ts b/apps/demo/src/app/api/receipts/route.ts index 624b30f..97aad2d 100644 --- a/apps/demo/src/app/api/receipts/route.ts +++ b/apps/demo/src/app/api/receipts/route.ts @@ -3,13 +3,13 @@ import { createMemoPaymentRequest, signWebhookEvent, } from '@arc-nano-kit/sdk/receipts'; +import { DEMO_WEBHOOK_SECRET, DEMO_WEBHOOK_TARGET } from '../webhook-inbox/store'; export const dynamic = 'force-dynamic'; const DEMO_SELLER = '0x1111111111111111111111111111111111111111'; const DEMO_PAYER = '0x2222222222222222222222222222222222222222'; const DEMO_TX_HASH = '0x7a6d91b9f5b42e6f9a4d8d5c0a5f1a833f9f94c8b2e7d4d0a0e8c7b6a5f4d3c2'; -const WEBHOOK_SECRET = 'arc_receipts_demo_secret'; export async function GET() { const now = Date.now(); @@ -62,7 +62,7 @@ export async function GET() { ); } - const signature = signWebhookEvent(paidEvent, WEBHOOK_SECRET); + const signature = signWebhookEvent(paidEvent, DEMO_WEBHOOK_SECRET); return Response.json({ generatedAt: new Date().toISOString(), @@ -117,8 +117,9 @@ export async function GET() { webhook: { eventId: paidEvent.id, type: paidEvent.type, + event: paidEvent, signatureHeader: signature.header, - target: 'https://seller.app/webhooks/arc', + target: DEMO_WEBHOOK_TARGET, }, timeline: [ { diff --git a/apps/demo/src/app/api/webhook-inbox/replay/route.ts b/apps/demo/src/app/api/webhook-inbox/replay/route.ts new file mode 100644 index 0000000..6eebcc9 --- /dev/null +++ b/apps/demo/src/app/api/webhook-inbox/replay/route.ts @@ -0,0 +1,29 @@ +import type { WebhookEvent } from '@arc-nano-kit/sdk/receipts'; +import { DEMO_WEBHOOK_SECRET, DEMO_WEBHOOK_TARGET, webhookInbox } from '../store'; + +export const dynamic = 'force-dynamic'; + +interface ReplayRequest { + event?: WebhookEvent; + replayOf?: string; +} + +export async function POST(request: Request) { + const body = (await request.json()) as ReplayRequest; + + if (!body.event) { + return Response.json( + { error: 'Missing webhook event' }, + { status: 400 }, + ); + } + + const delivery = webhookInbox.replay({ + event: body.event, + secret: DEMO_WEBHOOK_SECRET, + target: DEMO_WEBHOOK_TARGET, + replayOf: body.replayOf, + }); + + return Response.json({ delivery }); +} diff --git a/apps/demo/src/app/api/webhook-inbox/route.ts b/apps/demo/src/app/api/webhook-inbox/route.ts new file mode 100644 index 0000000..c8767e5 --- /dev/null +++ b/apps/demo/src/app/api/webhook-inbox/route.ts @@ -0,0 +1,24 @@ +import { DEMO_WEBHOOK_SECRET, DEMO_WEBHOOK_TARGET, webhookInbox } from './store'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request) { + const payload = await request.text(); + const header = request.headers.get('x-arc-signature'); + + if (!header) { + return Response.json( + { error: 'Missing x-arc-signature header' }, + { status: 400 }, + ); + } + + const delivery = webhookInbox.receive({ + payload, + header, + secret: DEMO_WEBHOOK_SECRET, + target: DEMO_WEBHOOK_TARGET, + }); + + return Response.json({ delivery }); +} diff --git a/apps/demo/src/app/api/webhook-inbox/store.ts b/apps/demo/src/app/api/webhook-inbox/store.ts new file mode 100644 index 0000000..b804507 --- /dev/null +++ b/apps/demo/src/app/api/webhook-inbox/store.ts @@ -0,0 +1,10 @@ +import { WebhookInbox } from '@arc-nano-kit/sdk/receipts'; + +export const DEMO_WEBHOOK_SECRET = 'arc_receipts_demo_secret'; +export const DEMO_WEBHOOK_TARGET = 'https://seller.app/webhooks/arc'; + +const globalWebhookInbox = globalThis as typeof globalThis & { + __arcNanoKitWebhookInbox?: WebhookInbox; +}; + +export const webhookInbox = globalWebhookInbox.__arcNanoKitWebhookInbox ??= new WebhookInbox(); diff --git a/apps/demo/src/app/page.tsx b/apps/demo/src/app/page.tsx index 0e79717..1e913b6 100644 --- a/apps/demo/src/app/page.tsx +++ b/apps/demo/src/app/page.tsx @@ -70,12 +70,34 @@ type ReceiptDemo = { webhook: { eventId: string; type: string; + event: WebhookDemoEvent; signatureHeader: string; target: string; }; timeline: TimelineItem[]; }; +type WebhookDemoEvent = { + id: string; + type: string; + createdAt: number; + data: unknown; +}; + +type WebhookDeliveryAttempt = { + id: string; + eventId: string; + eventType: string; + attempt: number; + status: 'verified' | 'failed'; + verified: boolean; + signatureHeader: string; + receivedAt: number; + target?: string; + replayOf?: string; + error?: string; +}; + type FlowBlockId = 'invoice' | 'request' | 'watch' | 'receipt'; type FlowBlock = { @@ -113,13 +135,6 @@ const pendingTimeline: TimelineItem[] = [ { id: 'receipt', label: 'receipt.generated', detail: 'Awaiting transaction confirmations.' }, ]; -const factMap: Record = { - invoice: ['invoice', 'amount'], - request: ['contract', 'memoId', 'hash'], - watch: ['contract'], - receipt: ['invoice', 'amount', 'memoId'], -}; - export default function HomePage() { const [receiptDemo, setReceiptDemo] = useState(null); const [receiptLoading, setReceiptLoading] = useState(false); @@ -128,6 +143,9 @@ export default function HomePage() { const [selectedBlock, setSelectedBlock] = useState('invoice'); const [endpointResult, setEndpointResult] = useState(null); const [endpointLoading, setEndpointLoading] = useState(null); + const [webhookDeliveries, setWebhookDeliveries] = useState([]); + const [webhookInboxError, setWebhookInboxError] = useState(null); + const [replayLoading, setReplayLoading] = useState(false); const terminalState = receiptLoading ? 'Watching flow' @@ -145,6 +163,8 @@ export default function HomePage() { setTimeline(pendingTimeline); setCompletedSteps([]); setSelectedBlock('invoice'); + setWebhookDeliveries([]); + setWebhookInboxError(null); try { const response = await fetch('/api/receipts', { cache: 'no-store' }); @@ -172,6 +192,7 @@ export default function HomePage() { setTimeline(data.timeline); setSelectedBlock('receipt'); + await deliverWebhookToInbox(data); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown receipt demo error'; setEndpointResult({ error: message }); @@ -180,6 +201,64 @@ export default function HomePage() { } }; + const deliverWebhookToInbox = async (data: ReceiptDemo) => { + const response = await fetch('/api/webhook-inbox', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-arc-signature': data.webhook.signatureHeader, + }, + body: JSON.stringify(data.webhook.event), + }); + const result = (await response.json()) as { + delivery?: WebhookDeliveryAttempt; + error?: string; + }; + + if (!response.ok || !result.delivery) { + throw new Error(result.error ?? 'Webhook inbox delivery failed'); + } + + setWebhookDeliveries([result.delivery]); + }; + + const replayWebhook = async () => { + if (!receiptDemo) { + return; + } + + setReplayLoading(true); + setWebhookInboxError(null); + + try { + const previous = webhookDeliveries[webhookDeliveries.length - 1]; + const response = await fetch('/api/webhook-inbox/replay', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + event: receiptDemo.webhook.event, + replayOf: previous?.id, + }), + }); + const result = (await response.json()) as { + delivery?: WebhookDeliveryAttempt; + error?: string; + }; + + if (!response.ok || !result.delivery) { + throw new Error(result.error ?? 'Webhook replay failed'); + } + + const delivery = result.delivery; + setWebhookDeliveries((current) => [...current, delivery]); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown replay error'; + setWebhookInboxError(message); + } finally { + setReplayLoading(false); + } + }; + const testEndpoint = async (url: string) => { setEndpointLoading(url); setEndpointResult(null); @@ -361,10 +440,65 @@ export default function HomePage() {

Notification Hub

-

Webhook Payload

+

Webhook Inbox

+
+ +
+ +
+
0 ? 'ok' : ''}`}> + Received + {webhookDeliveries.length > 0 ? 'yes' : 'pending'} +
+
+ Verified + {webhookDeliveries[0]?.verified ? 'yes' : 'pending'} +
+
+ Signature + {webhookDeliveries[0]?.status === 'verified' ? 'OK' : 'pending'} +
+
0 ? 'ok' : ''}`}> + Attempts + {webhookDeliveries.length || 'pending'}
- + + {webhookInboxError ?

{webhookInboxError}

: null} +
+ {webhookDeliveries.length === 0 ? ( +
+
+ Delivery attempt #1 + waiting for signed webhook +
+ signature pending +
+ ) : ( + webhookDeliveries.map((delivery) => ( +
+
+ Delivery attempt #{delivery.attempt} + {delivery.eventType} +
+
+ {delivery.verified ? 'Signature OK' : 'Signature failed'} + {formatSignatureTimestamp(delivery.signatureHeader)} +
+ {delivery.replayOf ? ( + replay of {formatAddress(delivery.replayOf, 10, 6)} + ) : null} +
+ )) + )} +
+ @@ -612,6 +746,12 @@ export default function HomePage() { opacity: 0.68; } + .replay-button { + min-height: 34px; + padding: 0 12px; + font-size: 13px; + } + .pipeline-container, .json-wrapper, .response-console, @@ -964,6 +1104,114 @@ export default function HomePage() { color: var(--pale-red-text); } + .inbox-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-bottom: 16px; + } + + .inbox-status { + min-width: 0; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-canvas); + } + + .inbox-status.ok { + border-color: rgba(129, 201, 149, 0.52); + background: var(--pale-green); + } + + .inbox-status span, + .inbox-status strong { + display: block; + font-family: JetBrains Mono, Geist Mono, SFMono-Regular, Consolas, monospace; + } + + .inbox-status span { + margin-bottom: 3px; + color: var(--text-muted); + font-size: 10px; + text-transform: uppercase; + } + + .inbox-status strong { + color: var(--text-main); + font-size: 12px; + font-weight: 500; + } + + .inbox-status.ok strong { + color: var(--pale-green-text); + } + + .inbox-error { + margin-bottom: 12px; + color: var(--pale-red-text); + font-size: 13px; + } + + .delivery-attempts { + display: grid; + gap: 8px; + margin-bottom: 16px; + } + + .delivery-attempt { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-canvas); + } + + .delivery-attempt.verified { + border-color: rgba(129, 201, 149, 0.52); + background: rgba(129, 201, 149, 0.08); + } + + .delivery-attempt.failed { + border-color: rgba(255, 105, 105, 0.45); + background: rgba(255, 105, 105, 0.08); + } + + .delivery-attempt strong, + .delivery-attempt span { + display: block; + font-family: JetBrains Mono, Geist Mono, SFMono-Regular, Consolas, monospace; + } + + .delivery-attempt strong { + margin-bottom: 4px; + color: var(--text-main); + font-size: 12px; + font-weight: 600; + } + + .delivery-attempt span { + color: var(--text-muted); + font-size: 11px; + } + + .delivery-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .delivery-meta span, + .delivery-signature, + .delivery-replay { + width: fit-content; + padding: 4px 7px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: rgba(255, 255, 255, 0.03); + } + .endpoint-panel { margin-bottom: 0; } @@ -1122,6 +1370,10 @@ export default function HomePage() { width: 100%; } + .inbox-status-grid { + grid-template-columns: 1fr; + } + .btn-endpoint { align-items: flex-start; flex-direction: column; @@ -1179,16 +1431,22 @@ function getReceiptPayload(data: ReceiptDemo | null) { }; } -function getWebhookPayload(data: ReceiptDemo | null) { +function getWebhookInboxPayload(data: ReceiptDemo | null, deliveries: WebhookDeliveryAttempt[]) { if (!data) { - return { status: 'pending' }; + return { + event: { status: 'pending' }, + deliveries: [], + }; } return { - eventId: data.webhook.eventId, - type: data.webhook.type, - target: data.webhook.target, - signature: data.webhook.signatureHeader, + event: { + id: data.webhook.eventId, + type: data.webhook.type, + target: data.webhook.target, + signature: data.webhook.signatureHeader, + }, + deliveries, }; } @@ -1232,6 +1490,15 @@ function formatAddress(value?: string, start = 10, end = 8) { return `${value.slice(0, start)}...${value.slice(-end)}`; } +function formatSignatureTimestamp(header: string) { + const timestamp = header + .split(',') + .map((part) => part.trim()) + .find((part) => part.startsWith('t=')); + + return timestamp ?? 't=pending'; +} + function delay(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); diff --git a/docs/receipts.md b/docs/receipts.md index 20a8306..c190327 100644 --- a/docs/receipts.md +++ b/docs/receipts.md @@ -98,6 +98,40 @@ The watcher polls Arc Testnet `Memo` events, fetches the transaction receipt, ve It intentionally watches the ERC-20 USDC interface at `0x3600000000000000000000000000000000000000` and ignores the native USDC system event emitter at `0xfffffffffffffffffffffffffffffffffffffffe` to avoid double-counting the same ERC-20 transfer. +## Webhook Inbox and Replay + +Use `WebhookInbox` when you want a local app to receive, verify, store, and replay Arc Receipts webhook deliveries. + +```typescript +import { + WebhookInbox, + serializeWebhookPayload, + signWebhookEvent, +} from '@arc-nano-kit/sdk/receipts'; + +const inbox = new WebhookInbox(); +const event = ledger.listWebhookEvents().at(-1)!; +const signature = signWebhookEvent(event, process.env.ARC_WEBHOOK_SECRET!); + +const delivery = inbox.receive({ + payload: serializeWebhookPayload(event), + header: signature.header, + secret: process.env.ARC_WEBHOOK_SECRET!, + target: 'https://seller.app/webhooks/arc', +}); + +const replay = inbox.replay({ + event, + secret: process.env.ARC_WEBHOOK_SECRET!, + replayOf: delivery.id, +}); + +console.log(delivery.status); // verified +console.log(replay.attempt); // 2 +``` + +The inbox is intentionally in-memory for local payment-ops workflows. A production app should persist delivery attempts in its own database. + ## What ships in the MVP - `createInvoice()` for invoice ids, stablecoin minor units, Arc payment URIs, memo ids, and invoice memos. @@ -107,6 +141,7 @@ It intentionally watches the ERC-20 USDC interface at `0x36000000000000000000000 - `matchPaymentToInvoice()` to validate amount, recipient, currency, network, memo, memo id, and expiry. - `ReceiptLedger` for an in-memory invoice/receipt/event store with duplicate tx protection. - `signWebhookEvent()` and `verifyWebhookSignature()` for HMAC-signed webhooks. +- `WebhookInbox` for local signed-webhook verification, delivery attempts, and replay. ## Current Limits @@ -119,4 +154,4 @@ The watcher is intentionally local-first and polling-based. It does not yet pers - Next.js webhook route helpers. - Refund receipts and partial refund accounting. - Unified Balance readiness states. -- Demo dashboard for invoice state transitions. \ No newline at end of file +- Demo dashboard for invoice state transitions. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 546679a..b9a63ac 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -63,7 +63,7 @@ const receipt = ledger.recordPayment(invoice.id, { | Client | `@arc-nano-kit/sdk/client` | Buyer SDK for automated x402 payments | | Billing | `@arc-nano-kit/sdk/billing` | Usage metering & billing plans | | Gateway | `@arc-nano-kit/sdk/gateway` | Circle Gateway balance management | -| Receipts | `@arc-nano-kit/sdk/receipts` | Invoices, memos, Arc Testnet watcher, receipts, signed webhooks | +| Receipts | `@arc-nano-kit/sdk/receipts` | Invoices, memos, Arc Testnet watcher, receipts, signed webhooks, webhook inbox replay | ## License diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index fcf7e9a..388658a 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -33,6 +33,7 @@ export { GatewayClient, type GatewayClientConfig } from './gateway/client.js'; export { ArcReceiptWatcher, ReceiptLedger, + WebhookInbox, createInvoice, createInvoiceMemo, createMemoPaymentRequest, @@ -45,6 +46,8 @@ export { type CreateInvoiceInput, type MemoPaymentRequest, type ObservedPayment, + type WebhookDeliveryAttempt, + type WebhookDeliveryStatus, type WebhookEvent, } from './receipts/index.js'; diff --git a/packages/sdk/src/receipts/index.ts b/packages/sdk/src/receipts/index.ts index 00fd7cb..7489195 100644 --- a/packages/sdk/src/receipts/index.ts +++ b/packages/sdk/src/receipts/index.ts @@ -38,6 +38,12 @@ export { verifyWebhookSignature, type WebhookSignature, } from './webhooks.js'; +export { + WebhookInbox, + type ReceiveWebhookInput, + type ReplayWebhookInput, + type WebhookDeliveryFilter, +} from './webhook-inbox.js'; export type { ArcInvoice, ArcReceipt, @@ -48,6 +54,8 @@ export type { PaymentMatchResult, ReceiptStatus, StablecoinSymbol, + WebhookDeliveryAttempt, + WebhookDeliveryStatus, WebhookEvent, WebhookEventType, } from './types.js'; diff --git a/packages/sdk/src/receipts/types.ts b/packages/sdk/src/receipts/types.ts index 93eb2a6..4f0ee1e 100644 --- a/packages/sdk/src/receipts/types.ts +++ b/packages/sdk/src/receipts/types.ts @@ -119,3 +119,19 @@ export interface WebhookEvent { createdAt: number; data: TData; } + +export type WebhookDeliveryStatus = 'verified' | 'failed'; + +export interface WebhookDeliveryAttempt { + id: string; + eventId: string; + eventType: WebhookEventType | 'unknown'; + attempt: number; + status: WebhookDeliveryStatus; + verified: boolean; + signatureHeader: string; + receivedAt: number; + target?: string; + replayOf?: string; + error?: string; +} diff --git a/packages/sdk/src/receipts/webhook-inbox.test.ts b/packages/sdk/src/receipts/webhook-inbox.test.ts new file mode 100644 index 0000000..64220b9 --- /dev/null +++ b/packages/sdk/src/receipts/webhook-inbox.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { WebhookInbox } from './webhook-inbox.js'; +import { + createWebhookEvent, + serializeWebhookPayload, + signWebhookEvent, +} from './webhooks.js'; + +describe('WebhookInbox', () => { + it('records a verified delivery for a valid signed webhook', () => { + const inbox = new WebhookInbox(); + const event = createWebhookEvent('invoice.paid', { invoiceId: 'inv_123' }, 1_700_000_000_000); + const signature = signWebhookEvent(event, 'secret', 1_700_000_000); + + const delivery = inbox.receive({ + payload: serializeWebhookPayload(event), + header: signature.header, + secret: 'secret', + now: 1_700_000_000, + receivedAt: 1_700_000_001_000, + target: 'https://seller.app/webhooks/arc', + }); + + expect(delivery.status).toBe('verified'); + expect(delivery.verified).toBe(true); + expect(delivery.eventId).toBe(event.id); + expect(delivery.eventType).toBe('invoice.paid'); + expect(delivery.attempt).toBe(1); + expect(delivery.target).toBe('https://seller.app/webhooks/arc'); + expect(inbox.getDelivery(delivery.id)).toEqual(delivery); + }); + + it('records failed deliveries for tampered and stale payloads', () => { + const inbox = new WebhookInbox(); + const event = createWebhookEvent('invoice.paid', { invoiceId: 'inv_123' }, 1_700_000_000_000); + const signature = signWebhookEvent(event, 'secret', 1_700_000_000); + + const tampered = inbox.receive({ + payload: serializeWebhookPayload({ + ...event, + data: { invoiceId: 'inv_other' }, + }), + header: signature.header, + secret: 'secret', + now: 1_700_000_000, + }); + + const stale = inbox.receive({ + payload: event, + header: signature.header, + secret: 'secret', + now: 1_700_000_999, + }); + + expect(tampered.status).toBe('failed'); + expect(tampered.verified).toBe(false); + expect(tampered.error).toMatch(/verification failed/i); + expect(stale.status).toBe('failed'); + expect(stale.attempt).toBe(2); + }); + + it('replays a webhook with a new verified delivery attempt', () => { + const inbox = new WebhookInbox(); + const event = createWebhookEvent('invoice.paid', { invoiceId: 'inv_123' }, 1_700_000_000_000); + const signature = signWebhookEvent(event, 'secret', 1_700_000_000); + + const first = inbox.receive({ + payload: event, + header: signature.header, + secret: 'secret', + now: 1_700_000_000, + }); + + const replay = inbox.replay({ + event, + secret: 'secret', + replayOf: first.id, + timestamp: 1_700_000_100, + receivedAt: 1_700_000_101_000, + }); + + expect(replay.status).toBe('verified'); + expect(replay.verified).toBe(true); + expect(replay.attempt).toBe(2); + expect(replay.replayOf).toBe(first.id); + expect(replay.signatureHeader).toContain('t=1700000100'); + }); + + it('filters delivery attempts by event id and status', () => { + const inbox = new WebhookInbox(); + const paid = createWebhookEvent('invoice.paid', { invoiceId: 'inv_paid' }, 1_700_000_000_000); + const expired = createWebhookEvent('invoice.expired', { invoiceId: 'inv_expired' }, 1_700_000_000_000); + + inbox.receive({ + payload: paid, + header: signWebhookEvent(paid, 'secret', 1_700_000_000).header, + secret: 'secret', + now: 1_700_000_000, + }); + inbox.receive({ + payload: expired, + header: signWebhookEvent(expired, 'secret', 1_700_000_000).header, + secret: 'wrong', + now: 1_700_000_000, + }); + + expect(inbox.listDeliveries({ eventId: paid.id })).toHaveLength(1); + expect(inbox.listDeliveries({ status: 'verified' })).toHaveLength(1); + expect(inbox.listDeliveries({ status: 'failed' })).toHaveLength(1); + }); +}); diff --git a/packages/sdk/src/receipts/webhook-inbox.ts b/packages/sdk/src/receipts/webhook-inbox.ts new file mode 100644 index 0000000..54da4d4 --- /dev/null +++ b/packages/sdk/src/receipts/webhook-inbox.ts @@ -0,0 +1,152 @@ +/** + * Local in-memory inbox for signed Arc Receipts webhooks. + */ + +import { randomBytes } from 'node:crypto'; +import type { + WebhookDeliveryAttempt, + WebhookDeliveryStatus, + WebhookEvent, + WebhookEventType, +} from './types.js'; +import { signWebhookEvent, verifyWebhookSignature } from './webhooks.js'; + +export interface ReceiveWebhookInput { + payload: WebhookEvent | string; + header: string; + secret: string; + target?: string; + now?: number; + receivedAt?: number; + toleranceSeconds?: number; +} + +export interface ReplayWebhookInput { + event: WebhookEvent; + secret: string; + target?: string; + replayOf?: string; + timestamp?: number; + receivedAt?: number; +} + +export interface WebhookDeliveryFilter { + eventId?: string; + status?: WebhookDeliveryStatus; +} + +export class WebhookInbox { + private readonly deliveries: WebhookDeliveryAttempt[] = []; + + receive(input: ReceiveWebhookInput): WebhookDeliveryAttempt { + const event = parseWebhookPayload(input.payload); + const verified = verifyWebhookSignature({ + payload: input.payload, + header: input.header, + secret: input.secret, + now: input.now, + toleranceSeconds: input.toleranceSeconds, + }); + + const delivery = this.createDelivery({ + eventId: event.eventId, + eventType: event.eventType, + status: verified ? 'verified' : 'failed', + verified, + signatureHeader: input.header, + receivedAt: input.receivedAt ?? Date.now(), + target: input.target, + error: verified ? undefined : 'Webhook signature verification failed', + }); + + this.deliveries.push(delivery); + return delivery; + } + + replay(input: ReplayWebhookInput): WebhookDeliveryAttempt { + const signature = signWebhookEvent(input.event, input.secret, input.timestamp); + + const delivery = this.createDelivery({ + eventId: input.event.id, + eventType: input.event.type, + status: 'verified', + verified: true, + signatureHeader: signature.header, + receivedAt: input.receivedAt ?? Date.now(), + target: input.target, + replayOf: input.replayOf, + }); + + this.deliveries.push(delivery); + return delivery; + } + + listDeliveries(filter: WebhookDeliveryFilter = {}): WebhookDeliveryAttempt[] { + return this.deliveries.filter((delivery) => { + if (filter.eventId && delivery.eventId !== filter.eventId) { + return false; + } + + if (filter.status && delivery.status !== filter.status) { + return false; + } + + return true; + }); + } + + getDelivery(id: string): WebhookDeliveryAttempt | undefined { + return this.deliveries.find((delivery) => delivery.id === id); + } + + private createDelivery(input: Omit): WebhookDeliveryAttempt { + const attempt = this.deliveries.filter((delivery) => delivery.eventId === input.eventId).length + 1; + + return { + id: `dlv_${randomBytes(12).toString('hex')}`, + attempt, + ...input, + }; + } +} + +function parseWebhookPayload(payload: WebhookEvent | string): { + eventId: string; + eventType: WebhookEventType | 'unknown'; +} { + const event = typeof payload === 'string' + ? parseJson(payload) + : payload; + + if (!isWebhookEventLike(event)) { + return { + eventId: 'unknown', + eventType: 'unknown', + }; + } + + return { + eventId: event.id, + eventType: event.type, + }; +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +function isWebhookEventLike(value: unknown): value is WebhookEvent { + if (!value || typeof value !== 'object') { + return false; + } + + const event = value as Partial; + return typeof event.id === 'string' + && typeof event.type === 'string' + && typeof event.createdAt === 'number' + && 'data' in event; +}