Repository navigation
feat: signed outbound webhooks for market, trade, resolution and trace events (#128) - #168
Open
alansamdev wants to merge 8 commits into
Open
alansamdev wants to merge 8 commits into
alansamdev wants to merge 8 commits into
Conversation
Forward-only migration (generated by prisma migrate diff, so the CI schema/migration check stays green): webhook_subscriptions (filters, encrypted secret + rotation fields, status, failure streak), webhook_events (outbox, id = '<type>:<chain event id>') and webhook_deliveries (unique per event+subscription, indexed for the due-delivery claim and per-subscription ordering).
…LOW_HTTP config WEBHOOK_SECRET_KEY must be 32 bytes base64 and is required in production when webhooks are enabled (ephemeral otherwise, like JWT_SECRET). WEBHOOKS_ALLOW_HTTP is rejected in production. The key is added to the logger redact list and documented in .env.example; tests disable webhook enqueueing by default.
https-only (http only outside production when allowed), no URL credentials, ports 443/8443 in production. Rejects a host if any resolved address is private, loopback, link-local, metadata, CGNAT, multicast or reserved, including IPv4-mapped IPv6 and numeric aliases. safeFetch pins the socket to the validated IP via a custom lookup (defeats rebinding), never follows redirects, caps the response read at 64 KB and enforces a total timeout.
- enqueueEvent: idempotent outbox insert (ON CONFLICT DO NOTHING on '<type>:<chain event id>') and fan-out to matching ACTIVE subscriptions by event type, market id and category. - Indexer publishes market.created, trade.executed (same payload as TRADE_EXECUTED), market.resolved and trace.published (same payload as REASONING_PUBLISHED). - Worker leases due deliveries (FOR UPDATE SKIP LOCKED), one in flight per subscription in event order; HMAC-SHA256 signature over the exact canonical bytes sent (t=,v1=), dual signatures for 24 h after rotation; 2xx-only success; exponential backoff with jitter honouring Retry-After; DEAD after 15 attempts; auto-disable after 50 consecutive failures or 5 days failing; 30-day retention prune. - Secrets encrypted at rest with AES-256-GCM. - Worker started/stopped with the server; WEBHOOKS_ENABLED=false disables it.
POST/GET/PATCH/DELETE /webhooks, rotate-secret, delivery log and redelivery. URLs pass the SSRF guard on create/update; at most 10 subscriptions per user; plaintext secret returned only on create and rotate; PATCH status=ACTIVE re-enables and resets failure counters.
15 webhook tests plus config validation: reference verifier (tamper, stale, rotation), AES-GCM at rest, blocked ranges incl. mapped and numeric aliases, rebinding via pinned lookup, no redirects, backoff, Retry-After, DEAD/disable thresholds, enqueue dedup, canonical envelope, and an end-to-end signed delivery to a local receiver.
|
@alansamdev 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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds signed outbound webhooks for
market.created,trade.executed,market.resolvedandtrace.published:Retry-After), one delivery in flight per subscription in event order, dead-lettering, and auto-disable of failing endpoints.Changes
Schema (
prisma/migrations/20261018000000_webhooks)webhook_subscriptions: URL,eventTypes[],marketIds[],categories[], encryptedsecret/previousSecret/secretRotatedAt,status(+disabledReason),consecutiveFailures,failingSince.webhook_events: the outbox.id = "<type>:<chain_events.id>"is the dedup key.webhook_deliveries: unique per (event, subscription), indexed for the due-delivery claim and per-subscription ordering.prisma migrate difffrom the schema change, so CI's schema/migration check stays green. The schema diff is purely additive.SSRF guard (
src/lib/ssrf-guard.ts)https:only.http:is allowed only outside production withWEBHOOKS_ALLOW_HTTP=true. No credentials in the URL; ports 443/8443 only in production.169.254.169.254), CGNAT, multicast or reserved, including IPv4-mapped IPv6 (::ffff:127.0.0.1,::ffff:7f00:1) and numeric aliases (2130706433,0x7f.1, normalised by WHATWG URL parsing first). Checks run on resolved IPs, never on strings.safeFetchpins the socket to the validated IP with a customlookup(SNI/Host keep the hostname), so DNS rebinding between check and connect has no effect. It re-validates before every delivery, never follows redirects (3xx counts as a failure), caps the response read at 64 KB, and has a 10 s timeout.Service (
src/services/webhooks.service.ts)enqueueEventdoesINSERT … ON CONFLICT DO NOTHINGfor the event, thenINSERT … SELECTdeliveries for ACTIVE subscriptions matching type, market id and category. Replayed chain events insert nothing.server.tsnext to the indexer, stopped on shutdown, off whenWEBHOOKS_ENABLED=false):UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED).{ id, type, createdAt, data }as canonical bytes (canonicalBytes). The exact bytes sent are the bytes signed.OracleDesk-Signature: t=<unix>,v1=<hex>overt + "." + rawBody, plusOracleDesk-Event-Id,OracleDesk-Event-TypeandUser-Agent: OracleDesk-Webhooks/1.v1=<new>,v1=<old>.Retry-Afterwins. After 15 attempts →DEAD.DISABLEDwith a reason, plus a warning in the logs.WEBHOOK_SECRET_KEY.verifyWebhookSignature(rawBody, header, secrets, tolerance=300)is the reference verifier, used in tests.Indexer (
src/services/indexer.service.ts)Trade→trade.executed, with the payload identical toTRADE_EXECUTED; the payload is now built once and used for both the socket and the webhook.TracePublished→trace.published, identical toREASONING_PUBLISHED.MarketResolved→market.resolved{ onChainMarketId, marketId, outcome, voided, resolvedAt, txHash, ledger }. It's emitted even when the market is unknown to the backend.MarketCreated→market.created{ onChainMarketId, marketId, creator, category, closeTime, seedAmountRaw, initialYesBps, txHash, ledger }. Field names were taken from the market-core contract spec.API (
/api/v1/webhooks,requireAuth, owner-scoped)POST /webhooks→201. RunsassertSafeUrland returns the plaintext secret once. Max 10 per user.GET /webhooks,GET/PATCH/DELETE /webhooks/:id.PATCH status=ACTIVEre-enables and resets failure counters.POST /webhooks/:id/rotate-secret→{ secret }.GET /webhooks/:id/deliveries?status=&page=&limit=andPOST /webhooks/:id/deliveries/:deliveryId/redeliver.Config
WEBHOOKS_ENABLED(defaulttrue).WEBHOOK_SECRET_KEY: 32 bytes, base64. Required in production when webhooks are enabled; ephemeral otherwise, likeJWT_SECRET.WEBHOOKS_ALLOW_HTTP: rejected in production.src/config/index.ts, documented in.env.example, and the key is added to the logger redact list.Docs
docs/api.md: event types and payloads, endpoints, the request format, signature verification (Node and Python), retries/ordering/disable policy, URL rules, and config.Testing
New
tests/webhooks.test.ts: 15/15 pass.isBlockedAddressacross private, loopback, link-local, metadata, CGNAT, multicast, IPv6 ULA and link-local, and mapped forms.assertSafeUrlrejects http in production, credentials, bad ports, the metadata IP, decimal and hex aliases,[::ffff:127.0.0.1],[::1], hostnames that partly resolve to private IPs, and unresolvable hosts.127.0.0.1second. The pinned lookup still returns the validated IP, and DNS is consulted only once.302to the metadata IP is returned, not followed.Retry-Afterin seconds and as an HTTP date;DEADat 15 attempts; auto-disable at 50 failures and at 5 days.enqueueEventdedups by chain event id (a replay inserts nothing); the canonical envelope bytes are stable.deliverOnesends to a localhttp.createServerreceiver. The signature verifies, the headers are right,datamatches theTRADE_EXECUTEDshape, the delivery isSUCCEEDEDand the failure streak resets. A503withRetry-Afterschedules a retry and counts a failure.publishWebhookEventdoes nothing when disabled and never throws.tests/config.test.tsgains a webhook config validation test, and the existing ephemeral-secrets assertion now includesWEBHOOK_SECRET_KEY.Test-suite note (pre-existing on
develop)developcurrently has 14 TypeScript errors in files this PR doesn't touch:monitor.cron.ts,copy-trade.service.ts,resilience.ts,stellar/clients.ts,subscription.service.tsandtrace-audit.service.ts. Because of them,npm run buildandnpm test(which usesnoEmitOnError) already fail ondevelop. To test this PR I compiled with--noEmitOnError falseand ran the full suite:developThe 10 failures are the identical set on both (audit-constraints, constraints, copy-trade-verify, resilience, trace-audit, and 5 HTTP route tests), so this PR introduces no regressions. All 16 new tests pass, and the existing indexer and config tests still pass.
tsc --noEmitreports the same 14 errors before and after, with no new type errors.Not in this PR (follow-ups)
chain_eventsis stored and its side effect runs, not in the same DB transaction. Dedup by chain event id makes the enqueue idempotent, but a crash between storing and enqueueing could drop that one webhook. Foldingstoreand the side effects into one interactive transaction touches the whole indexer and its mocks, so it's best done separately.POST /webhooks/:id/ping).disabledReason.Closes #128
Closes #124
Closes #125
Closes #127