feat(api): add HMAC-signed webhook delivery system with retries#90
Conversation
|
@Ipramking Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Miracle656
left a comment
There was a problem hiding this comment.
Solid webhook delivery system. The implementation choices are right across the board:
- HMAC:
sha256=<hex>prefix inX-Wraith-Signaturematches the convention used by GitHub, Stripe, etc. Receiver-side verification: HMAC-SHA256 of the raw request body (not a re-stringified parse) using the shared secret — please document that in the response ofPOST /webhooksso subscribers don't run into JSON-canonicalization bugs. - Idempotency hint:
X-Wraith-Delivery: <id>header lets receivers safely dedupe retries on their side. Nice touch. - Backoff schedule:
[0, 30s, 5min, 30min, 2h]indexed by attempt is exactly what the PR description says. The?? 7200fallback is a belt-and-suspenders for an out-of-range index that thecanRetryguard already prevents — harmless. AbortSignal.timeout(10_000)per attempt prevents a slow receiver from blocking the worker.- Filter eval uses
BigInt(amount) < BigInt(filter.min_amount)— proper big-integer comparison, no precision loss. - Secret is never returned by GET endpoints (
selectlists every column exceptsecret), andDELETEcascades the delivery log. - POST validation: required string fields, URL scheme guard, unknown-filter-key rejection.
One concern — not blocking, but worth documenting
There's no URL allowlist or SSRF guard. A user who can call POST /webhooks can register http://localhost:6379 or an internal-network address as a target, and the worker will dutifully POST to it. For Wraith as a single-tenant operator deployment this is fine; if you ever expose POST /webhooks publicly, you'll want either an allowlist or to block private IP ranges (127/8, 169.254/16, 10/8, 172.16/12, 192.168/16, IPv6 link-local, etc.) before fetch.
Merge note
Merge will conflict with prisma/schema.prisma because #89 just landed and added AccountSummary adjacent to where your new WebhookSubscription/WebhookDelivery blocks want to live. Same quick rebase as #88:
git fetch origin
git rebase origin/main
# Conflict in prisma/schema.prisma — keep both blocks (AccountSummary from main + your two webhook models)
git add prisma/schema.prisma
git rebase --continue
git push --force-with-leaseWill merge as soon as it's pushed — the approval stays. Closes #60.
- Add WebhookSubscription and WebhookDelivery Prisma models: subscription
stores url, secret, JSON filter (contract/from/to/min_amount), active flag;
delivery log tracks every attempt with status, statusCode, error, nextRetryAt
- Add src/workers/webhooks.ts: on every new transfer, evaluates active
subscription filters and enqueues WebhookDelivery rows; POSTs payload with
X-Wraith-Signature: sha256=<hmac> header; retries up to 5x with exponential
backoff (30s, 5m, 30m, 2h); retry loop polls DB every 15s for due deliveries
- Add src/api/webhooks.ts router at /webhooks:
POST /webhooks create subscription
GET /webhooks list subscriptions (secret redacted)
DELETE /webhooks/:id delete subscription + cascade deliveries
GET /webhooks/:id/deliveries paginated delivery log with status filter
- Mount webhooks router in api.ts
- Start webhook worker in index.ts alongside the indexer
- Add comprehensive tests: HMAC signing, filter evaluation (all fields),
all four REST endpoints with mock DB, mock receiver
Closes Miracle656#60
TS2552: 'attempts' was the Prisma field name used as a shorthand but the local variable is 'attempt'. Explicitly assign attempts: attempt in both the success and failure update paths.
Rebase left <<<<<<< HEAD / ======= / >>>>>>> markers in src/api.ts
around the router mounts. Resolved by keeping both:
- app.use('/accounts', createAccountsRouter())
- app.use('/webhooks', createWebhooksRouter())
Summary
WebhookSubscriptionandWebhookDeliveryPrisma models — subscription storesurl,secret, JSONfilter,activeflag; delivery log tracks every attempt withstatus,lastStatusCode,lastError,nextRetryAt,deliveredAtsrc/workers/webhooks.ts— on every ingested transfer, evaluates active subscription filters and enqueuesWebhookDeliveryrows; POSTs JSON payload withX-Wraith-Signature: sha256=<hmac>header; retries up to 5× with exponential backoff (immediate → 30 s → 5 min → 30 min → 2 h); background retry loop polls DB every 15 scontract,from,to,min_amount— null filter receives every transfersrc/api/webhooks.tsrouter at/webhooks:POST /webhooks— create subscription (url, secret, filter?)GET /webhooks— list all (secret field always redacted)DELETE /webhooks/:id— delete + cascade delivery historyGET /webhooks/:id/deliveries— paginated delivery log, filterable by statusindex.tsalongside the indexer; listens to the existingtransferEmitterTest plan
npm test— all webhook tests passPOST /webhookscreates a subscription; secret never appears in GET responseX-Wraith-Signaturesha256=HMAC-SHA256(secret, body)GET /webhooks/:id/deliveriesshows attempt historyfailedand retries stopDELETE /webhooks/:idremoves subscription and all delivery rowscontract/from/to/min_amountfilters work independently and combinedCloses #60