diff --git a/.env.example b/.env.example index 5420fdc..d58a4dd 100644 --- a/.env.example +++ b/.env.example @@ -112,3 +112,11 @@ TRACE_AUDIT_MAX_BYTES=2097152 COPY_TRADE_TTL_MINUTES=30 COPY_TRADE_MAX_PENDING=5 +# ── Outbound webhooks ────────────────────────────────────────────────────── +# Signed webhook deliveries for market/trade/resolution/trace events. +WEBHOOKS_ENABLED=true +# 32 random bytes, base64. Encrypts webhook signing secrets at rest. +# Required in production. Generate with: openssl rand -base64 32 +WEBHOOK_SECRET_KEY= +# Allow http:// webhook URLs (local receivers). Never in production. +WEBHOOKS_ALLOW_HTTP=false diff --git a/docs/api.md b/docs/api.md index 7e58f8a..51d6c31 100644 --- a/docs/api.md +++ b/docs/api.md @@ -620,6 +620,113 @@ Emitted when the indexer stores a reasoning-registry publish event. } ``` +## Webhooks + +Signed, server-to-server push for integrators who can't hold a socket open. +Delivery is **at-least-once**: deduplicate on `OracleDesk-Event-Id`. + +### Event types + +| Type | When | `data` | +|---|---|---| +| `market.created` | market-core `MarketCreated` indexed | `{ onChainMarketId, marketId, creator, category, closeTime, seedAmountRaw, initialYesBps, txHash, ledger }` (`category` is the contract tag: `Macro`, `Geopolitics`, `Crypto`, `Sports`, `Culture`) | +| `trade.executed` | market-core `Trade` indexed | identical to the `TRADE_EXECUTED` socket payload | +| `market.resolved` | market-core `MarketResolved` indexed | `{ onChainMarketId, marketId, outcome: "Yes"\|"No"\|null, voided, resolvedAt, txHash, ledger }` | +| `trace.published` | reasoning-registry `TracePublished` indexed | identical to the `REASONING_PUBLISHED` socket payload | + +Each event is produced once per chain event (id `":"`), +so re-indexing the same ledgers never sends duplicates. + +### Endpoints + +All require `Authorization: Bearer ` and only see the caller's own subscriptions. + +| Method & path | Body / query | Response | +|---|---|---| +| `POST /webhooks` | `{ url, eventTypes[], onChainMarketIds?: string[], categories?: MarketCategory[] }` | `201` subscription **plus `secret` (shown once)**. Max 10 per user (`409 WEBHOOK_LIMIT`); unsafe URLs → `400 INVALID_WEBHOOK_URL`. | +| `GET /webhooks` | | subscriptions (never the secret) | +| `GET /webhooks/:id` | | subscription | +| `PATCH /webhooks/:id` | any of `url`, `eventTypes`, `onChainMarketIds`, `categories`, `status` | `status: "ACTIVE"` re-enables a disabled subscription and resets its failure counters | +| `DELETE /webhooks/:id` | | `{ deleted: true }` | +| `POST /webhooks/:id/rotate-secret` | | `{ id, secret }` (new secret, shown once) | +| `GET /webhooks/:id/deliveries` | `?status=PENDING\|SUCCEEDED\|DEAD&page=&limit=` | delivery log: `attempts`, `lastStatusCode`, `lastError`, `nextAttemptAt`, `eventType` | +| `POST /webhooks/:id/deliveries/:deliveryId/redeliver` | | requeues now with a fresh retry budget | + +Empty `onChainMarketIds` / `categories` match everything. A category filter +only matches events whose market is known to the backend. + +### Request + +```http +POST +Content-Type: application/json +User-Agent: OracleDesk-Webhooks/1 +OracleDesk-Event-Id: trade.executed:0004951600-0000000001 +OracleDesk-Event-Type: trade.executed +OracleDesk-Signature: t=1760000000,v1=5257a869e7ec… + +{"createdAt":"2026-10-17T12:00:00.000Z","data":{…},"id":"trade.executed:0004951600-0000000001","type":"trade.executed"} +``` + +Any `2xx` is success. Everything else (including `3xx`; redirects are never +followed) is a failure. + +### Verifying signatures + +`v1 = hex(HMAC_SHA256(secret, t + "." + rawBody))`. Verify against the **raw +request bytes** (don't re-serialise the JSON) and reject timestamps older +than 5 minutes. During the 24 h after a secret rotation the header carries two +`v1=` values (new and old secret); accept if any matches. + +```js +// Node +const crypto = require('crypto'); +function verify(rawBody, header, secret, toleranceSec = 300) { + const parts = header.split(','); + const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2)); + if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; + const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest(); + return parts.filter((p) => p.startsWith('v1=')).some((p) => { + const sig = Buffer.from(p.slice(3), 'hex'); + return sig.length === expected.length && crypto.timingSafeEqual(sig, expected); + }); +} +``` + +```python +# Python +import hashlib, hmac, time +def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool: + parts = header.split(",") + t = next((p[2:] for p in parts if p.startswith("t=")), None) + if not t or abs(time.time() - int(t)) > tolerance: + return False + expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() + return any(hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1=")) +``` + +### Retries, ordering and disabling + +- **Backoff:** 1 min, doubling per attempt, capped at 24 h, ±20% jitter. A `Retry-After` header (seconds or HTTP date) is honoured when it is longer. After **15 attempts** a delivery is dead-lettered (`DEAD`) and the next one proceeds. +- **Ordering:** per subscription, deliveries are sent one at a time in event order, so a down receiver only delays its own queue. +- **Timeouts:** 10 s per attempt; at most 64 KB of the response is read. +- **Auto-disable:** after 50 consecutive failed deliveries, or 5 days of continuous failure, the subscription is `DISABLED` with a `disabledReason`. Fix the endpoint, then `PATCH { "status": "ACTIVE" }`. +- **Retention:** delivered and dead deliveries are pruned after 30 days. + +### URL rules (SSRF protection) + +- `https://` only (`http://` only outside production with `WEBHOOKS_ALLOW_HTTP=true`); no credentials in the URL; ports 443/8443 only in production. +- The host is resolved and rejected if **any** address is private, loopback, link-local, cloud-metadata, CGNAT, multicast or reserved (IPv4, IPv6 and IPv4-mapped IPv6; numeric aliases like `2130706433` or `0x7f.1` are normalised first). +- Checked at create/update **and before every delivery**. The connection is pinned to the validated IP, so DNS rebinding can't redirect it. + +### Configuration + +| Variable | Default | Notes | +|---|---|---| +| `WEBHOOKS_ENABLED` | `true` | `false` stops enqueueing and the delivery worker | +| `WEBHOOK_SECRET_KEY` | ephemeral | 32 bytes, base64 (`openssl rand -base64 32`). Encrypts signing secrets at rest (AES-256-GCM). **Required in production** when webhooks are enabled. | +| `WEBHOOKS_ALLOW_HTTP` | `false` | Allow `http://` URLs for local receivers. Rejected in production. | + ## Frontend call map Every call in the frontend's `lib/api/*.ts` and where it lands: diff --git a/prisma/migrations/20261018000000_webhooks/migration.sql b/prisma/migrations/20261018000000_webhooks/migration.sql new file mode 100644 index 0000000..e51ae67 --- /dev/null +++ b/prisma/migrations/20261018000000_webhooks/migration.sql @@ -0,0 +1,81 @@ +-- Migration: 20261018000000_webhooks (#128) +-- Outbound webhooks: subscriptions, outbox events and per-subscription deliveries. + +-- CreateEnum +CREATE TYPE "WebhookSubscriptionStatus" AS ENUM ('ACTIVE', 'DISABLED'); + +-- CreateEnum +CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('PENDING', 'SUCCEEDED', 'DEAD'); + +-- CreateTable +CREATE TABLE "webhook_subscriptions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "eventTypes" TEXT[], + "marketIds" BIGINT[], + "categories" "MarketCategory"[], + "secret" TEXT NOT NULL, + "previousSecret" TEXT, + "secretRotatedAt" TIMESTAMP(3), + "status" "WebhookSubscriptionStatus" NOT NULL DEFAULT 'ACTIVE', + "disabledReason" TEXT, + "consecutiveFailures" INTEGER NOT NULL DEFAULT 0, + "failingSince" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "webhook_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "webhook_events" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "webhook_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "webhook_deliveries" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "subscriptionId" TEXT NOT NULL, + "status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastStatusCode" INTEGER, + "lastError" TEXT, + "deliveredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "webhook_deliveries_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "webhook_subscriptions_status_idx" ON "webhook_subscriptions"("status"); + +-- CreateIndex +CREATE INDEX "webhook_subscriptions_userId_idx" ON "webhook_subscriptions"("userId"); + +-- CreateIndex +CREATE INDEX "webhook_deliveries_status_nextAttemptAt_idx" ON "webhook_deliveries"("status", "nextAttemptAt"); + +-- CreateIndex +CREATE INDEX "webhook_deliveries_subscriptionId_status_idx" ON "webhook_deliveries"("subscriptionId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "webhook_deliveries_eventId_subscriptionId_key" ON "webhook_deliveries"("eventId", "subscriptionId"); + +-- AddForeignKey +ALTER TABLE "webhook_subscriptions" ADD CONSTRAINT "webhook_subscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "webhook_events"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "webhook_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e3c80d..dd0e1cc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,17 @@ datasource db { // ─────────────────────────── ENUMS ─────────────────────────── +enum WebhookSubscriptionStatus { + ACTIVE + DISABLED +} + +enum WebhookDeliveryStatus { + PENDING + SUCCEEDED + DEAD +} + enum MarketStatus { PENDING ACTIVE @@ -140,6 +151,7 @@ model User { copyTrades CopyTrade[] paymentEvents PaymentEvent[] spendingAllowance SpendingAllowance? + webhookSubscriptions WebhookSubscription[] @@index([walletAddress]) @@map("users") @@ -549,6 +561,70 @@ model ConstraintQuarantine { @@map("constraint_quarantine") } +// ─────────────────────────── WEBHOOKS (#128) ─────────────────────────── + +/// A user-registered outbound webhook endpoint. Empty filter arrays match all. +model WebhookSubscription { + id String @id @default(uuid()) + userId String + url String + eventTypes String[] + marketIds BigInt[] + categories MarketCategory[] + /// AES-256-GCM ciphertext (iv.tag.data, base64), keyed by WEBHOOK_SECRET_KEY. + secret String + /// Encrypted previous secret; deliveries carry both signatures for 24 h after rotation. + previousSecret String? + secretRotatedAt DateTime? + status WebhookSubscriptionStatus @default(ACTIVE) + disabledReason String? + consecutiveFailures Int @default(0) + /// Set on the first failure of a failing streak; cleared on success. + failingSince DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + deliveries WebhookDelivery[] + + @@index([status]) + @@index([userId]) + @@map("webhook_subscriptions") +} + +/// One row per domain event. id = ":" (dedup key). +model WebhookEvent { + id String @id + type String + payload Json + createdAt DateTime @default(now()) + + deliveries WebhookDelivery[] + + @@map("webhook_events") +} + +/// One row per (event, subscription). Delivered at-least-once, in order per subscription. +model WebhookDelivery { + id String @id @default(uuid()) + eventId String + subscriptionId String + status WebhookDeliveryStatus @default(PENDING) + attempts Int @default(0) + nextAttemptAt DateTime @default(now()) + lastStatusCode Int? + lastError String? + deliveredAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + event WebhookEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) + subscription WebhookSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + + @@unique([eventId, subscriptionId]) + @@index([status, nextAttemptAt]) + @@index([subscriptionId, status]) + @@map("webhook_deliveries") /// Idempotency-Key records for state-changing endpoints (#123). One row per /// (scope, method, route, key); see src/middlewares/idempotency.middleware.ts. model IdempotencyKey { diff --git a/src/config/index.ts b/src/config/index.ts index f64abc3..d34642f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -129,6 +129,16 @@ const envSchema = z // ── Copy Trading (Issue #151) ───────────────────────────────────────── COPY_TRADE_TTL_MINUTES: z.string().default('30').transform(Number), COPY_TRADE_MAX_PENDING: z.string().default('5').transform(Number), + + // ── Outbound webhooks (Issue #128) ──────────────────────────────────── + WEBHOOKS_ENABLED: z.enum(['true', 'false']).default('true').transform((v) => v === 'true'), + // 32-byte key (base64) encrypting webhook signing secrets at rest + // (AES-256-GCM). Required in production when webhooks are enabled; an + // ephemeral one is generated otherwise (stored secrets then stop + // decrypting after a restart). + WEBHOOK_SECRET_KEY: optionalString, + // Allow http:// webhook URLs. Development/test only; rejected in production. + WEBHOOKS_ALLOW_HTTP: z.enum(['true', 'false']).default('false').transform((v) => v === 'true'), }) .superRefine((env, ctx) => { if (env.JWT_SECRET && env.JWT_SECRET.length < 32) { @@ -144,6 +154,15 @@ const envSchema = z ctx.addIssue({ code: 'custom', path: [key], message: 'must be a Stellar secret seed (S…)' }); } } + if (env.WEBHOOK_SECRET_KEY && Buffer.from(env.WEBHOOK_SECRET_KEY, 'base64').length !== 32) { + ctx.addIssue({ code: 'custom', path: ['WEBHOOK_SECRET_KEY'], message: 'must be 32 bytes, base64-encoded' }); + } + if (env.NODE_ENV === 'production' && env.WEBHOOKS_ENABLED && !env.WEBHOOK_SECRET_KEY) { + ctx.addIssue({ code: 'custom', path: ['WEBHOOK_SECRET_KEY'], message: 'is required in production when WEBHOOKS_ENABLED=true' }); + } + if (env.NODE_ENV === 'production' && env.WEBHOOKS_ALLOW_HTTP) { + ctx.addIssue({ code: 'custom', path: ['WEBHOOKS_ALLOW_HTTP'], message: 'must not be true in production' }); + } if (env.CHAIN_EXECUTION_MODE === 'live') { if (!env.AGENT_SECRET_KEY) { ctx.addIssue({ code: 'custom', path: ['AGENT_SECRET_KEY'], message: 'is required when CHAIN_EXECUTION_MODE=live' }); @@ -182,6 +201,10 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { data.AUTH_SIGNING_SECRET = Keypair.random().secret(); ephemeralSecrets.push('AUTH_SIGNING_SECRET'); } + if (!data.WEBHOOK_SECRET_KEY) { + data.WEBHOOK_SECRET_KEY = crypto.randomBytes(32).toString('base64'); + if (data.WEBHOOKS_ENABLED) ephemeralSecrets.push('WEBHOOK_SECRET_KEY'); + } if (!data.AUTH_WEB_AUTH_DOMAIN) data.AUTH_WEB_AUTH_DOMAIN = data.AUTH_HOME_DOMAIN; return { ...data, ephemeralSecrets }; } diff --git a/src/controllers/webhooks.controller.ts b/src/controllers/webhooks.controller.ts new file mode 100644 index 0000000..1081e8d --- /dev/null +++ b/src/controllers/webhooks.controller.ts @@ -0,0 +1,235 @@ +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import { prisma } from '../lib/prisma'; +import { assertSafeUrl, SsrfError } from '../lib/ssrf-guard'; +import { sendError, sendSuccess, parsePagination, buildPaginationMeta } from '../utils/response.util'; +import { + WEBHOOK_EVENT_TYPES, + MAX_SUBSCRIPTIONS_PER_USER, + encryptSecret, + generateSecret, + guardOptions, +} from '../services/webhooks.service'; + +const MARKET_CATEGORIES = [ + 'FED', 'ECB', 'ELECTION', 'GEOPOLITICAL', + 'CRYPTO', 'MACRO', 'SPORTS', 'ENTERTAINMENT', 'POLITICS', +] as const; + +const filters = { + eventTypes: z.array(z.enum(WEBHOOK_EVENT_TYPES)).min(1).transform((v) => [...new Set(v)]), + onChainMarketIds: z.array(z.string().regex(/^\d+$/, 'must be a decimal market id')).max(100).default([]), + categories: z.array(z.enum(MARKET_CATEGORIES)).default([]), +}; + +const createSchema = z.object({ + url: z.string().max(2048), + ...filters, +}); + +const updateSchema = z.object({ + url: z.string().max(2048).optional(), + eventTypes: filters.eventTypes.optional(), + onChainMarketIds: z.array(z.string().regex(/^\d+$/)).max(100).optional(), + categories: z.array(z.enum(MARKET_CATEGORIES)).optional(), + status: z.enum(['ACTIVE', 'DISABLED']).optional(), +}); + +const SUB_SELECT = { + id: true, + url: true, + eventTypes: true, + marketIds: true, + categories: true, + status: true, + disabledReason: true, + consecutiveFailures: true, + failingSince: true, + secretRotatedAt: true, + createdAt: true, + updatedAt: true, +} as const; + +type SubRow = { marketIds: bigint[] } & Record; + +/** Never exposes the (encrypted) secret; market ids as decimal strings. */ +function present({ marketIds, ...rest }: SubRow) { + return { ...rest, onChainMarketIds: marketIds.map(String) }; +} + +async function validateUrl(res: Response, url: string): Promise { + try { + await assertSafeUrl(url, guardOptions()); + return true; + } catch (err) { + if (err instanceof SsrfError) { + sendError(res, 400, 'INVALID_WEBHOOK_URL', err.message); + return false; + } + throw err; + } +} + +async function findOwned(req: Request, res: Response) { + const sub = await prisma.webhookSubscription.findFirst({ + where: { id: String(req.params.id), userId: req.user!.userId }, + select: { id: true, secret: true }, + }); + if (!sub) sendError(res, 404, 'NOT_FOUND', 'Webhook subscription not found'); + return sub; +} + +/** POST /webhooks — returns the plaintext secret once. */ +export async function createWebhook(req: Request, res: Response): Promise { + const parsed = createSchema.safeParse(req.body); + if (!parsed.success) { + sendError(res, 400, 'VALIDATION_ERROR', 'Invalid webhook subscription', { issues: parsed.error.issues }); + return; + } + const userId = req.user!.userId; + const count = await prisma.webhookSubscription.count({ where: { userId } }); + if (count >= MAX_SUBSCRIPTIONS_PER_USER) { + sendError(res, 409, 'WEBHOOK_LIMIT', `At most ${MAX_SUBSCRIPTIONS_PER_USER} webhook subscriptions per user`); + return; + } + if (!(await validateUrl(res, parsed.data.url))) return; + + const secret = generateSecret(); + const sub = await prisma.webhookSubscription.create({ + data: { + userId, + url: parsed.data.url, + eventTypes: parsed.data.eventTypes, + marketIds: parsed.data.onChainMarketIds.map(BigInt), + categories: parsed.data.categories, + secret: encryptSecret(secret), + }, + select: SUB_SELECT, + }); + sendSuccess(res, { ...present(sub), secret }, 201); +} + +/** GET /webhooks */ +export async function listWebhooks(req: Request, res: Response): Promise { + const subs = await prisma.webhookSubscription.findMany({ + where: { userId: req.user!.userId }, + orderBy: { createdAt: 'desc' }, + select: SUB_SELECT, + }); + sendSuccess(res, subs.map(present)); +} + +/** GET /webhooks/:id */ +export async function getWebhook(req: Request, res: Response): Promise { + const sub = await prisma.webhookSubscription.findFirst({ + where: { id: String(req.params.id), userId: req.user!.userId }, + select: SUB_SELECT, + }); + if (!sub) { + sendError(res, 404, 'NOT_FOUND', 'Webhook subscription not found'); + return; + } + sendSuccess(res, present(sub)); +} + +/** PATCH /webhooks/:id — status=ACTIVE re-enables and resets failure counters. */ +export async function updateWebhook(req: Request, res: Response): Promise { + const parsed = updateSchema.safeParse(req.body); + if (!parsed.success) { + sendError(res, 400, 'VALIDATION_ERROR', 'Invalid webhook update', { issues: parsed.error.issues }); + return; + } + if (!(await findOwned(req, res))) return; + const { url, eventTypes, onChainMarketIds, categories, status } = parsed.data; + if (url !== undefined && !(await validateUrl(res, url))) return; + + const sub = await prisma.webhookSubscription.update({ + where: { id: String(req.params.id) }, + data: { + ...(url !== undefined ? { url } : {}), + ...(eventTypes ? { eventTypes } : {}), + ...(onChainMarketIds ? { marketIds: onChainMarketIds.map(BigInt) } : {}), + ...(categories ? { categories } : {}), + ...(status === 'ACTIVE' ? { status, disabledReason: null, consecutiveFailures: 0, failingSince: null } : {}), + ...(status === 'DISABLED' ? { status, disabledReason: 'Disabled by owner' } : {}), + }, + select: SUB_SELECT, + }); + sendSuccess(res, present(sub)); +} + +/** DELETE /webhooks/:id */ +export async function deleteWebhook(req: Request, res: Response): Promise { + if (!(await findOwned(req, res))) return; + await prisma.webhookSubscription.delete({ where: { id: String(req.params.id) } }); + sendSuccess(res, { deleted: true }); +} + +/** + * POST /webhooks/:id/rotate-secret — returns the new plaintext secret once. + * Deliveries are signed with both secrets for 24 h. + */ +export async function rotateWebhookSecret(req: Request, res: Response): Promise { + const current = await findOwned(req, res); + if (!current) return; + const secret = generateSecret(); + await prisma.webhookSubscription.update({ + where: { id: current.id }, + data: { secret: encryptSecret(secret), previousSecret: current.secret, secretRotatedAt: new Date() }, + }); + sendSuccess(res, { id: current.id, secret }); +} + +const DELIVERY_STATUSES = ['PENDING', 'SUCCEEDED', 'DEAD'] as const; + +/** GET /webhooks/:id/deliveries?status=&page=&limit= */ +export async function listDeliveries(req: Request, res: Response): Promise { + if (!(await findOwned(req, res))) return; + const status = req.query.status; + if (status !== undefined && !DELIVERY_STATUSES.includes(status as (typeof DELIVERY_STATUSES)[number])) { + sendError(res, 400, 'VALIDATION_ERROR', `status must be one of ${DELIVERY_STATUSES.join(', ')}`); + return; + } + const { page, limit, skip } = parsePagination(req.query as Record); + const where = { + subscriptionId: String(req.params.id), + ...(status ? { status: status as (typeof DELIVERY_STATUSES)[number] } : {}), + }; + const [rows, total] = await Promise.all([ + prisma.webhookDelivery.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + eventId: true, + status: true, + attempts: true, + nextAttemptAt: true, + lastStatusCode: true, + lastError: true, + deliveredAt: true, + createdAt: true, + event: { select: { type: true } }, + }, + }), + prisma.webhookDelivery.count({ where }), + ]); + const deliveries = rows.map(({ event, ...d }) => ({ ...d, eventType: event.type })); + sendSuccess(res, deliveries, 200, buildPaginationMeta(page, limit, total) as unknown as Record); +} + +/** POST /webhooks/:id/deliveries/:deliveryId/redeliver */ +export async function redeliver(req: Request, res: Response): Promise { + if (!(await findOwned(req, res))) return; + const { count } = await prisma.webhookDelivery.updateMany({ + where: { id: String(req.params.deliveryId), subscriptionId: String(req.params.id) }, + data: { status: 'PENDING', attempts: 0, nextAttemptAt: new Date(), lastError: null }, + }); + if (count === 0) { + sendError(res, 404, 'NOT_FOUND', 'Delivery not found'); + return; + } + sendSuccess(res, { id: String(req.params.deliveryId), status: 'PENDING' }); +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 77ad02a..5ab42ce 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -26,6 +26,7 @@ export const logger = pino({ '*.PINATA_SECRET_API_KEY', '*.ANTHROPIC_API_KEY', '*.GEMINI_API_KEY', + '*.WEBHOOK_SECRET_KEY', 'secret', 'token', ], diff --git a/src/lib/ssrf-guard.ts b/src/lib/ssrf-guard.ts new file mode 100644 index 0000000..425bd87 --- /dev/null +++ b/src/lib/ssrf-guard.ts @@ -0,0 +1,224 @@ +/** + * SSRF guard for outbound webhook deliveries (#128). + * + * User-registered URLs are a classic SSRF vector (cloud metadata at + * 169.254.169.254, Redis on localhost, internal services). This module: + * + * - accepts only https: (http: only outside production with + * WEBHOOKS_ALLOW_HTTP=true), no credentials in the URL, and only ports + * 443/8443 in production; + * - resolves the hostname and rejects it if ANY address falls in a blocked + * range. Checks run on resolved IPs, never on strings, so aliases such as + * 0x7f.1, 2130706433 or [::ffff:127.0.0.1] are caught after normalisation; + * - pins the connection to the validated address with a custom `lookup`, so a + * second DNS answer (rebinding) cannot redirect the socket. TLS SNI and the + * Host header keep the original hostname; + * - never follows redirects, caps the response read at 64 KB and enforces a + * total timeout. + */ +import dns from 'node:dns'; +import http from 'node:http'; +import https from 'node:https'; +import net from 'node:net'; + +export class SsrfError extends Error { + constructor(message: string) { + super(message); + this.name = 'SsrfError'; + } +} + +export type Resolver = (hostname: string) => Promise>; + +export const defaultResolver: Resolver = (hostname) => dns.promises.lookup(hostname, { all: true, verbatim: true }); + +export interface GuardOptions { + production: boolean; + allowHttp: boolean; + resolver?: Resolver; + /** Test-only escape hatch for local receivers. Never wired to env. */ + allowPrivateForTests?: boolean; +} + +const BLOCKED_V4: Array<[string, number]> = [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +]; + +const BLOCKED_V6: Array<[string, number]> = [ + ['::', 128], + ['::1', 128], + ['64:ff9b::', 96], + ['fc00::', 7], + ['fe80::', 10], + ['ff00::', 8], +]; + +const blockList = (() => { + const list = new net.BlockList(); + for (const [addr, prefix] of BLOCKED_V4) list.addSubnet(addr, prefix, 'ipv4'); + for (const [addr, prefix] of BLOCKED_V6) list.addSubnet(addr, prefix, 'ipv6'); + return list; +})(); + +/** Extracts the IPv4 address from an IPv4-mapped/compatible IPv6 address. */ +function embeddedV4(address: string): string | null { + const lower = address.toLowerCase(); + const dotted = lower.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/); + if (dotted) return dotted[1]; + const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hex) { + const hi = parseInt(hex[1], 16); + const lo = parseInt(hex[2], 16); + return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`; + } + return null; +} + +/** True if a literal IP address is private, loopback, link-local, metadata, etc. */ +export function isBlockedAddress(address: string): boolean { + const family = net.isIP(address); + if (family === 4) return blockList.check(address, 'ipv4'); + if (family === 6) { + const v4 = embeddedV4(address); + if (v4) return blockList.check(v4, 'ipv4'); + return blockList.check(address, 'ipv6'); + } + return true; // not an IP at all: refuse +} + +function parseUrl(raw: string, opts: GuardOptions): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new SsrfError('URL is not valid'); + } + const httpAllowed = !opts.production && opts.allowHttp; + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && httpAllowed)) { + throw new SsrfError(httpAllowed ? 'URL must use http or https' : 'URL must use https'); + } + if (url.username || url.password) throw new SsrfError('URL must not contain credentials'); + if (opts.production && url.port && url.port !== '443' && url.port !== '8443') { + throw new SsrfError('URL port must be 443 or 8443'); + } + return url; +} + +/** Strips IPv6 brackets: new URL('http://[::1]/').hostname === '[::1]'. */ +function bareHost(url: URL): string { + return url.hostname.replace(/^\[|\]$/g, ''); +} + +export interface ValidatedTarget { + url: URL; + address: string; + family: number; +} + +/** + * Validates a webhook URL and resolves it to a single safe address. + * Used at subscription create/update and again before every delivery. + * WHATWG URL parsing normalises 0x7f.1 / 2130706433 to 127.0.0.1 first. + */ +export async function resolveSafeTarget(raw: string, opts: GuardOptions): Promise { + const url = parseUrl(raw, opts); + const host = bareHost(url); + const resolver = opts.resolver ?? defaultResolver; + + let addresses: Array<{ address: string; family: number }>; + if (net.isIP(host)) { + addresses = [{ address: host, family: net.isIP(host) }]; + } else { + try { + addresses = await resolver(host); + } catch { + throw new SsrfError(`Could not resolve ${host}`); + } + } + if (addresses.length === 0) throw new SsrfError(`Could not resolve ${host}`); + if (!opts.allowPrivateForTests) { + const bad = addresses.find((a) => isBlockedAddress(a.address)); + if (bad) throw new SsrfError(`${host} resolves to a disallowed address (${bad.address})`); + } + return { url, address: addresses[0].address, family: addresses[0].family }; +} + +/** Convenience wrapper used by the API: validates and returns the parsed URL. */ +export async function assertSafeUrl(raw: string, opts: GuardOptions): Promise { + return (await resolveSafeTarget(raw, opts)).url; +} + +/** + * A `lookup` that always answers with the pre-validated address, whatever + * DNS says now. This is what defeats DNS rebinding between check and connect. + */ +export function pinnedLookup(address: string, family: number): net.LookupFunction { + return ((_hostname: string, options: dns.LookupOptions | undefined, callback?: unknown) => { + const cb = (typeof options === 'function' ? options : callback) as (...args: unknown[]) => void; + const all = typeof options === 'object' && options?.all; + if (all) cb(null, [{ address, family }]); + else cb(null, address, family); + }) as net.LookupFunction; +} + +const MAX_RESPONSE_BYTES = 64 * 1024; + +export interface SafeFetchResult { + status: number; + headers: http.IncomingHttpHeaders; +} + +/** + * POSTs `body` to `rawUrl` through the guard. Re-validates and re-resolves on + * every call. Redirects are returned as-is (3xx), never followed. + */ +export async function safeFetch( + rawUrl: string, + init: { body: Buffer; headers: Record; timeoutMs: number }, + opts: GuardOptions, +): Promise { + const target = await resolveSafeTarget(rawUrl, opts); + const { url } = target; + const isHttps = url.protocol === 'https:'; + const lookup = pinnedLookup(target.address, target.family); + const agent = isHttps + ? new https.Agent({ lookup, keepAlive: false }) + : new http.Agent({ lookup, keepAlive: false }); + + return new Promise((resolve, reject) => { + const signal = AbortSignal.timeout(init.timeoutMs); + const req = (isHttps ? https : http).request( + url, + { + method: 'POST', + agent, + headers: { ...init.headers, 'Content-Length': String(init.body.length) }, + signal, + ...(isHttps ? { servername: bareHost(url) } : {}), + }, + (res) => { + let read = 0; + res.on('data', (chunk: Buffer) => { + read += chunk.length; + if (read > MAX_RESPONSE_BYTES) res.destroy(); // drain at most 64 KB + }); + const done = () => resolve({ status: res.statusCode ?? 0, headers: res.headers }); + res.on('end', done); + res.on('close', done); + res.on('error', done); + }, + ); + req.on('error', (err) => reject(err)); + req.end(init.body); + }).finally(() => agent.destroy()); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 4a0365f..3d3487c 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -6,6 +6,7 @@ import portfolioRoutes from './portfolio.routes'; import tradeRoutes from './trade.routes'; import oracleRoutes from './oracle.routes'; import adminRoutes from './admin.routes'; +import webhookRoutes from './webhooks.routes'; const router = Router(); @@ -16,5 +17,6 @@ router.use('/portfolio', portfolioRoutes); router.use('/trade', tradeRoutes); router.use('/oracle', oracleRoutes); router.use('/admin', adminRoutes); +router.use('/webhooks', webhookRoutes); export default router; diff --git a/src/routes/webhooks.routes.ts b/src/routes/webhooks.routes.ts new file mode 100644 index 0000000..a27ec72 --- /dev/null +++ b/src/routes/webhooks.routes.ts @@ -0,0 +1,28 @@ +import { Router } from 'express'; +import { + createWebhook, + deleteWebhook, + getWebhook, + listDeliveries, + listWebhooks, + redeliver, + rotateWebhookSecret, + updateWebhook, +} from '../controllers/webhooks.controller'; +import { requireAuth } from '../middlewares/auth.middleware'; + +// Outbound webhook subscriptions (#128). All owner-scoped. +const router = Router(); + +router.use(requireAuth); + +router.post('/', createWebhook); +router.get('/', listWebhooks); +router.get('/:id', getWebhook); +router.patch('/:id', updateWebhook); +router.delete('/:id', deleteWebhook); +router.post('/:id/rotate-secret', rotateWebhookSecret); +router.get('/:id/deliveries', listDeliveries); +router.post('/:id/deliveries/:deliveryId/redeliver', redeliver); + +export default router; diff --git a/src/server.ts b/src/server.ts index 3e17612..a0d8217 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,9 +8,11 @@ import { startIngestionCron } from './cron/ingestion.cron'; import { startMonitorCron } from './cron/monitor.cron'; import { startTraceAuditCron } from './cron/trace-audit.cron'; import { startEventListener } from './services/indexer.service'; +import { startWebhookWorker } from './services/webhooks.service'; import { closeRedis } from './lib/redis'; let stopEventListener: (() => void) | null = null; +let stopWebhookWorker: (() => void) | null = null; async function bootstrap(): Promise { // 1. Connect to database @@ -42,6 +44,9 @@ async function bootstrap(): Promise { // 3. Index Stellar events (resumes from the stored ledger cursor) stopEventListener = startEventListener((event, payload) => io.emit(event, payload)); + // 3b. Deliver signed outbound webhooks (#128) + stopWebhookWorker = startWebhookWorker(); + // 4. Start cron jobs startIngestionCron(); startMonitorCron(); @@ -73,6 +78,7 @@ async function bootstrap(): Promise { server.close(async () => { if (stopEventListener) stopEventListener(); + if (stopWebhookWorker) stopWebhookWorker(); await prisma.$disconnect(); await closeRedis(); diff --git a/src/services/indexer.service.ts b/src/services/indexer.service.ts index 40a232f..5d557d2 100644 --- a/src/services/indexer.service.ts +++ b/src/services/indexer.service.ts @@ -1,7 +1,8 @@ /** * Stellar event indexer: polls RPC getEvents for the four OracleDesk * contracts, stores each event once (keyed by its RPC event id), applies - * side effects, and emits socket.io events. + * side effects, emits socket.io events, and enqueues signed outbound + * webhooks (#128; see webhooks.service.ts). * * - The ledger cursor is persisted in Postgres (indexer_cursors), so a * restart resumes where it stopped. @@ -22,6 +23,7 @@ import { readClients, rpcServer } from './stellar/clients'; import { printable } from './chain.service'; import { priceYesBps } from '../generated/fpmm'; import { withTimeout, deadline } from '../lib/resilience'; +import { publishWebhookEvent } from './webhooks.service'; export type EventSource = Pick; @@ -107,7 +109,7 @@ async function applySideEffects(event: DecodedEvent, emit: IndexerDeps['emit']): if (event.contractName === 'marketCore' && event.eventName === 'Trade') { const onChainMarketId = BigInt(p.market_id); const yesBps = Number(priceYesBps(BigInt(p.reserve_yes), BigInt(p.reserve_no))); - const market = await prisma.market.findUnique({ where: { onChainMarketId }, select: { id: true, question: true } }); + const market = await prisma.market.findUnique({ where: { onChainMarketId }, select: { id: true, question: true, category: true } }); if (market) { await prisma.market.update({ where: { id: market.id }, data: { currentYesProb: yesBps / 10_000 } }); } @@ -120,7 +122,7 @@ async function applySideEffects(event: DecodedEvent, emit: IndexerDeps['emit']): } } - emit('TRADE_EXECUTED', { + const tradePayload = { onChainMarketId: onChainMarketId.toString(), marketId: market?.id ?? null, marketQuestion: market?.question ?? null, @@ -134,15 +136,52 @@ async function applySideEffects(event: DecodedEvent, emit: IndexerDeps['emit']): txHash: event.txHash, ledger: event.ledger, eventId: event.id, - }); + }; + emit('TRADE_EXECUTED', tradePayload); + await publishWebhookEvent('trade.executed', event.id, tradePayload, { onChainMarketId, category: market?.category }); + return; + } + + if (event.contractName === 'marketCore' && event.eventName === 'MarketCreated') { + const onChainMarketId = BigInt(p.market_id); + const market = await prisma.market.findUnique({ where: { onChainMarketId }, select: { id: true, category: true } }); + await publishWebhookEvent( + 'market.created', + event.id, + { + onChainMarketId: onChainMarketId.toString(), + marketId: market?.id ?? null, + creator: p.creator, + // Contract category tag (Macro, Geopolitics, Crypto, Sports, Culture). + category: p.category && typeof p.category === 'object' && 'tag' in p.category ? p.category.tag : null, + closeTime: String(p.close_time), + seedAmountRaw: String(p.seed_amount), + initialYesBps: Number(p.initial_yes_bps), + txHash: event.txHash, + ledger: event.ledger, + }, + { onChainMarketId, category: market?.category }, + ); return; } if (event.contractName === 'marketCore' && event.eventName === 'MarketResolved') { const onChainMarketId = BigInt(p.market_id); const outcome = outcomeTag(p.outcome); // null: the market was voided - const market = await prisma.market.findUnique({ where: { onChainMarketId }, select: { id: true } }); - if (!market) return; + const market = await prisma.market.findUnique({ where: { onChainMarketId }, select: { id: true, category: true } }); + const resolvedPayload = { + onChainMarketId: onChainMarketId.toString(), + marketId: market?.id ?? null, + outcome, + voided: outcome === null, + resolvedAt: new Date(event.ledgerClosedAt).toISOString(), + txHash: event.txHash, + ledger: event.ledger, + }; + if (!market) { + await publishWebhookEvent('market.resolved', event.id, resolvedPayload, { onChainMarketId }); + return; + } await prisma.$transaction([ prisma.market.update({ where: { id: market.id }, @@ -157,6 +196,7 @@ async function applySideEffects(event: DecodedEvent, emit: IndexerDeps['emit']): data: { status: 'CLOSED', closedAt: new Date(event.ledgerClosedAt), closeReason: outcome ? `${outcome.toUpperCase()}_RESOLVED` : 'VOIDED' }, }), ]); + await publishWebhookEvent('market.resolved', event.id, resolvedPayload, { onChainMarketId, category: market.category }); return; } @@ -177,7 +217,7 @@ async function applySideEffects(event: DecodedEvent, emit: IndexerDeps['emit']): data: { onChainTraceId, publishTxHash: event.txHash }, }); } - emit('REASONING_PUBLISHED', { + const tracePayload = { onChainTraceId: onChainTraceId.toString(), onChainMarketId: String(p.market_id), traceId: trace?.id ?? null, @@ -187,7 +227,12 @@ async function applySideEffects(event: DecodedEvent, emit: IndexerDeps['emit']): traceHash, txHash: event.txHash, ledger: event.ledger, - }); + }; + emit('REASONING_PUBLISHED', tracePayload); + if (config.WEBHOOKS_ENABLED) { + const traceMarket = await prisma.market.findUnique({ where: { onChainMarketId }, select: { category: true } }); + await publishWebhookEvent('trace.published', event.id, tracePayload, { onChainMarketId, category: traceMarket?.category }); + } } } diff --git a/src/services/webhooks.service.ts b/src/services/webhooks.service.ts new file mode 100644 index 0000000..9611686 --- /dev/null +++ b/src/services/webhooks.service.ts @@ -0,0 +1,391 @@ +/** + * Outbound webhooks (#128): signed, durable, at-least-once delivery of + * market / trade / resolution / trace events to user-registered endpoints. + * + * Flow + * 1. The indexer calls `enqueueEvent` for each newly stored chain event. One + * `webhook_events` row per domain event, keyed ":" + * (ON CONFLICT DO NOTHING, so replays are no-ops), plus one + * `webhook_deliveries` row per matching ACTIVE subscription. + * 2. The worker claims due deliveries, at most one per subscription (the + * oldest pending by creation order), leasing them by pushing nextAttemptAt + * forward inside the claim so concurrent workers never double-send. + * 3. Each delivery is POSTed through the SSRF guard with an HMAC-SHA256 + * signature over the exact bytes sent. 2xx = success; anything else is + * retried with exponential backoff and jitter (honouring Retry-After), up + * to MAX_ATTEMPTS, then dead-lettered. + * 4. Subscriptions failing for DISABLE_AFTER_FAILURES consecutive attempts or + * DISABLE_AFTER_MS are disabled automatically; owners re-enable via PATCH. + * + * Delivery is at-least-once: receivers dedupe on `OracleDesk-Event-Id`. + */ +import crypto from 'crypto'; +import { Prisma, type MarketCategory } from '@prisma/client'; +import { config } from '../config'; +import { prisma } from '../lib/prisma'; +import { logger } from '../lib/logger'; +import { canonicalBytes, canonicalJson } from '../utils/hash.util'; +import { safeFetch, SsrfError, type GuardOptions } from '../lib/ssrf-guard'; + +// ─── Event types ──────────────────────────────────────────────────────────── + +export const WEBHOOK_EVENT_TYPES = ['market.created', 'market.resolved', 'trade.executed', 'trace.published'] as const; +export type WebhookEventType = (typeof WEBHOOK_EVENT_TYPES)[number]; + +// ─── Policy ───────────────────────────────────────────────────────────────── + +export const MAX_ATTEMPTS = 15; +export const BASE_BACKOFF_MS = 60_000; +export const MAX_BACKOFF_MS = 24 * 60 * 60_000; +export const DISABLE_AFTER_FAILURES = 50; +export const DISABLE_AFTER_MS = 5 * 24 * 60 * 60_000; +export const DELIVERY_TIMEOUT_MS = 10_000; +export const SECRET_ROTATION_OVERLAP_MS = 24 * 60 * 60_000; +export const SIGNATURE_TOLERANCE_SEC = 300; +export const MAX_SUBSCRIPTIONS_PER_USER = 10; +export const RETENTION_DAYS = 30; +const WORKER_INTERVAL_MS = 2_000; +const CLAIM_BATCH = 20; +const LEASE_SECONDS = 60; + +export function guardOptions(): GuardOptions { + return { production: config.NODE_ENV === 'production', allowHttp: config.WEBHOOKS_ALLOW_HTTP }; +} + +// ─── Secrets (AES-256-GCM at rest) ────────────────────────────────────────── + +function secretKey(): Buffer { + return Buffer.from(config.WEBHOOK_SECRET_KEY, 'base64'); +} + +/** New plaintext signing secret. Returned to the owner only on create/rotate. */ +export function generateSecret(): string { + return `whsec_${crypto.randomBytes(32).toString('base64url')}`; +} + +/** Encrypts a secret as "iv.tag.ciphertext" (base64). */ +export function encryptSecret(plain: string, key = secretKey()): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const data = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]); + return [iv, cipher.getAuthTag(), data].map((b) => b.toString('base64')).join('.'); +} + +export function decryptSecret(stored: string, key = secretKey()): string { + const [iv, tag, data] = stored.split('.').map((part) => Buffer.from(part, 'base64')); + if (!iv || !tag || !data) throw new Error('Malformed encrypted webhook secret'); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); +} + +// ─── Signing ──────────────────────────────────────────────────────────────── + +export function computeSignature(secret: string, timestamp: number, rawBody: Buffer | string): string { + return crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'); +} + +/** + * `OracleDesk-Signature: t=,v1=[,v1=]`. During a rotation + * overlap the body is signed with both the new and the previous secret. + */ +export function signatureHeader(secrets: string[], timestamp: number, rawBody: Buffer | string): string { + return [`t=${timestamp}`, ...secrets.map((s) => `v1=${computeSignature(s, timestamp, rawBody)}`)].join(','); +} + +/** + * Reference verifier for receivers (and tests). Accepts if any v1 signature + * matches any of `secrets` and the timestamp is within `toleranceSec`. + */ +export function verifyWebhookSignature( + rawBody: Buffer | string, + header: string, + secrets: string[], + toleranceSec = SIGNATURE_TOLERANCE_SEC, + nowSec = Math.floor(Date.now() / 1000), +): boolean { + const parts = header.split(',').map((p) => p.trim()); + const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2)); + if (!Number.isInteger(t) || Math.abs(nowSec - t) > toleranceSec) return false; + const provided = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3)); + return secrets.some((secret) => { + const expected = Buffer.from(computeSignature(secret, t, rawBody), 'hex'); + return provided.some((sig) => { + const given = Buffer.from(sig, 'hex'); + return given.length === expected.length && crypto.timingSafeEqual(given, expected); + }); + }); +} + +/** Secrets to sign with: current, plus previous during the rotation overlap. */ +export function signingSecrets( + sub: { secret: string; previousSecret: string | null; secretRotatedAt: Date | null }, + now = new Date(), + decrypt: (s: string) => string = decryptSecret, +): string[] { + const secrets = [decrypt(sub.secret)]; + if (sub.previousSecret && sub.secretRotatedAt && now.getTime() - sub.secretRotatedAt.getTime() < SECRET_ROTATION_OVERLAP_MS) { + secrets.push(decrypt(sub.previousSecret)); + } + return secrets; +} + +// ─── Retry policy ─────────────────────────────────────────────────────────── + +/** Backoff after the `attempts`-th failure: 1 min doubling, capped at 24 h, ±20% jitter. */ +export function backoffMs(attempts: number, rand = Math.random()): number { + const base = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** Math.max(0, attempts - 1)); + return Math.round(base * (0.8 + 0.4 * rand)); +} + +/** Parses Retry-After (seconds or HTTP date) into ms from now, capped at 24 h. */ +export function parseRetryAfter(value: string | string[] | undefined, now = Date.now()): number | null { + const v = Array.isArray(value) ? value[0] : value; + if (!v) return null; + const secs = Number(v); + const ms = Number.isFinite(secs) ? secs * 1000 : Date.parse(v) - now; + if (!Number.isFinite(ms) || ms <= 0) return null; + return Math.min(ms, MAX_BACKOFF_MS); +} + +export interface FailurePlan { + status: 'PENDING' | 'DEAD'; + attempts: number; + nextAttemptAt: Date; + consecutiveFailures: number; + failingSince: Date; + disableReason: string | null; +} + +/** Pure decision for a failed attempt. */ +export function planFailure( + delivery: { attempts: number }, + sub: { consecutiveFailures: number; failingSince: Date | null }, + opts: { now: Date; retryAfterMs?: number | null; rand?: number }, +): FailurePlan { + const attempts = delivery.attempts + 1; + const consecutiveFailures = sub.consecutiveFailures + 1; + const failingSince = sub.failingSince ?? opts.now; + const wait = Math.max(backoffMs(attempts, opts.rand), opts.retryAfterMs ?? 0); + let disableReason: string | null = null; + if (consecutiveFailures >= DISABLE_AFTER_FAILURES) { + disableReason = `Disabled after ${consecutiveFailures} consecutive failed deliveries`; + } else if (opts.now.getTime() - failingSince.getTime() >= DISABLE_AFTER_MS) { + disableReason = 'Disabled after failing for 5 days'; + } + return { + status: attempts >= MAX_ATTEMPTS ? 'DEAD' : 'PENDING', + attempts, + nextAttemptAt: new Date(opts.now.getTime() + wait), + consecutiveFailures, + failingSince, + disableReason, + }; +} + +// ─── Producing events (outbox) ────────────────────────────────────────────── + +type Db = Pick; + +export interface EventMatch { + onChainMarketId: bigint; + /** Backend category when the market is known; null matches only unfiltered subscriptions. */ + category?: MarketCategory | null; +} + +/** + * Records a domain event and fans it out to matching active subscriptions. + * Idempotent per (type, sourceEventId): a replayed chain event inserts + * nothing. Returns the number of deliveries created. + */ +export async function enqueueEvent( + db: Db, + type: WebhookEventType, + sourceEventId: string, + payload: object, + match: EventMatch, +): Promise { + const id = `${type}:${sourceEventId}`; + const inserted = await db.$executeRaw` + INSERT INTO "webhook_events" ("id", "type", "payload") + VALUES (${id}, ${type}, ${canonicalJson(payload)}::jsonb) + ON CONFLICT ("id") DO NOTHING`; + if (inserted === 0) return 0; + + const category = match.category ?? null; + return db.$executeRaw` + INSERT INTO "webhook_deliveries" ("id", "eventId", "subscriptionId", "status", "nextAttemptAt", "updatedAt") + SELECT gen_random_uuid()::text, ${id}, s."id", 'PENDING', now(), now() + FROM "webhook_subscriptions" s + WHERE s."status" = 'ACTIVE' + AND ${type} = ANY(s."eventTypes") + AND (cardinality(s."marketIds") = 0 OR ${match.onChainMarketId}::bigint = ANY(s."marketIds")) + AND (cardinality(s."categories") = 0 OR (${category}::text IS NOT NULL AND ${category}::text::"MarketCategory" = ANY(s."categories"))) + ON CONFLICT ("eventId", "subscriptionId") DO NOTHING`; +} + +/** Indexer hook: enqueue when webhooks are enabled, never failing the indexer. */ +export async function publishWebhookEvent( + type: WebhookEventType, + sourceEventId: string, + payload: object, + match: EventMatch, + db: Db = prisma, +): Promise { + if (!config.WEBHOOKS_ENABLED) return; + try { + await enqueueEvent(db, type, sourceEventId, payload, match); + } catch (err) { + logger.error({ err, type, sourceEventId }, 'Webhooks: failed to enqueue event'); + } +} + +// ─── Delivery ─────────────────────────────────────────────────────────────── + +/** Envelope bytes. These exact bytes are signed and sent; never re-serialised. */ +export function envelopeBytes(event: { id: string; type: string; createdAt: Date; payload: unknown }): Buffer { + return canonicalBytes({ id: event.id, type: event.type, createdAt: event.createdAt.toISOString(), data: event.payload }); +} + +/** Leases up to `limit` due deliveries, at most one (the oldest) per subscription. */ +async function claimDueDeliveries(limit = CLAIM_BATCH): Promise { + const rows = await prisma.$queryRaw>` + UPDATE "webhook_deliveries" d + SET "nextAttemptAt" = now() + make_interval(secs => ${LEASE_SECONDS}), "updatedAt" = now() + WHERE d."id" IN ( + SELECT c."id" FROM "webhook_deliveries" c + JOIN "webhook_subscriptions" s ON s."id" = c."subscriptionId" AND s."status" = 'ACTIVE' + WHERE c."status" = 'PENDING' AND c."nextAttemptAt" <= now() + AND NOT EXISTS ( + SELECT 1 FROM "webhook_deliveries" p + WHERE p."subscriptionId" = c."subscriptionId" AND p."status" = 'PENDING' AND p."id" <> c."id" + AND (p."createdAt", p."id") < (c."createdAt", c."id") + ) + ORDER BY c."nextAttemptAt" + LIMIT ${limit} + FOR UPDATE OF c SKIP LOCKED + ) + RETURNING d."id"`; + return rows.map((r) => r.id); +} + +export type Sender = typeof safeFetch; + +/** Sends one leased delivery and records the outcome. */ +export async function deliverOne(deliveryId: string, send: Sender = safeFetch, now = () => new Date()): Promise { + const delivery = await prisma.webhookDelivery.findUnique({ + where: { id: deliveryId }, + include: { event: true, subscription: true }, + }); + if (!delivery || delivery.status !== 'PENDING' || delivery.subscription.status !== 'ACTIVE') return; + const { event, subscription: sub } = delivery; + + let statusCode: number | null = null; + let error: string | null = null; + let retryAfterMs: number | null = null; + try { + const body = envelopeBytes(event); + const timestamp = Math.floor(now().getTime() / 1000); + const res = await send( + sub.url, + { + body, + timeoutMs: DELIVERY_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'OracleDesk-Webhooks/1', + 'OracleDesk-Event-Id': event.id, + 'OracleDesk-Event-Type': event.type, + 'OracleDesk-Signature': signatureHeader(signingSecrets(sub, now()), timestamp, body), + }, + }, + guardOptions(), + ); + statusCode = res.status; + if (res.status < 200 || res.status >= 300) { + error = `HTTP ${res.status}`; + retryAfterMs = parseRetryAfter(res.headers['retry-after']); + } + } catch (err) { + error = err instanceof SsrfError ? `Blocked: ${err.message}` : err instanceof Error ? err.message : String(err); + } + + const at = now(); + if (!error) { + await prisma.$transaction([ + prisma.webhookDelivery.update({ + where: { id: delivery.id }, + data: { status: 'SUCCEEDED', attempts: { increment: 1 }, lastStatusCode: statusCode, lastError: null, deliveredAt: at }, + }), + prisma.webhookSubscription.update({ + where: { id: sub.id }, + data: { consecutiveFailures: 0, failingSince: null }, + }), + ]); + return; + } + + const plan = planFailure(delivery, sub, { now: at, retryAfterMs }); + await prisma.$transaction([ + prisma.webhookDelivery.update({ + where: { id: delivery.id }, + data: { status: plan.status, attempts: plan.attempts, nextAttemptAt: plan.nextAttemptAt, lastStatusCode: statusCode, lastError: error.slice(0, 500) }, + }), + prisma.webhookSubscription.update({ + where: { id: sub.id }, + data: { + consecutiveFailures: plan.consecutiveFailures, + failingSince: plan.failingSince, + ...(plan.disableReason ? { status: 'DISABLED' as const, disabledReason: plan.disableReason } : {}), + }, + }), + ]); + if (plan.status === 'DEAD') { + logger.warn({ deliveryId: delivery.id, subscriptionId: sub.id, attempts: plan.attempts }, 'Webhooks: delivery dead-lettered'); + } + if (plan.disableReason) { + logger.warn({ subscriptionId: sub.id, userId: sub.userId, reason: plan.disableReason }, 'Webhooks: subscription disabled'); + } +} + +/** Deletes delivered/dead deliveries and orphaned events past retention. */ +export async function pruneOldDeliveries(days = RETENTION_DAYS): Promise { + const cutoff = new Date(Date.now() - days * 24 * 60 * 60_000); + const { count } = await prisma.webhookDelivery.deleteMany({ + where: { status: { in: ['SUCCEEDED', 'DEAD'] }, updatedAt: { lt: cutoff } }, + }); + await prisma.webhookEvent.deleteMany({ where: { createdAt: { lt: cutoff }, deliveries: { none: {} } } }); + if (count > 0) logger.info({ count, days }, 'Webhooks: pruned old deliveries'); +} + +/** Starts the delivery loop. Returns a stop function. */ +export function startWebhookWorker(): () => void { + if (!config.WEBHOOKS_ENABLED) { + logger.warn('WEBHOOKS_ENABLED=false; webhook delivery worker disabled'); + return () => undefined; + } + let stopped = false; + let timer: NodeJS.Timeout | null = null; + const tick = async () => { + try { + const ids = await claimDueDeliveries(); + await Promise.all( + ids.map((id) => deliverOne(id).catch((err) => logger.error({ err, deliveryId: id }, 'Webhooks: delivery failed unexpectedly'))), + ); + } catch (err) { + logger.warn({ err: err instanceof Error ? err.message : err }, 'Webhooks: worker tick failed; will retry'); + } + if (!stopped) timer = setTimeout(tick, WORKER_INTERVAL_MS); + }; + const prune = setInterval(() => { + pruneOldDeliveries().catch((err) => logger.warn({ err }, 'Webhooks: prune failed')); + }, 24 * 60 * 60_000); + prune.unref(); + void tick(); + logger.info({ intervalMs: WORKER_INTERVAL_MS }, 'Webhook delivery worker started'); + return () => { + stopped = true; + if (timer) clearTimeout(timer); + clearInterval(prune); + }; +} diff --git a/tests/config.test.ts b/tests/config.test.ts index da79a80..6af60d9 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -23,5 +23,18 @@ test('defaults to dry-run on testnet, and generates dev-only ephemeral secrets', const c = loadConfig({ DATABASE_URL: 'x' }); assert.equal(c.CHAIN_EXECUTION_MODE, 'dry-run'); assert.equal(c.STELLAR_NETWORK_PASSPHRASE, 'Test SDF Network ; September 2015'); - assert.deepEqual(c.ephemeralSecrets.sort(), ['AUTH_SIGNING_SECRET', 'JWT_SECRET']); + assert.deepEqual(c.ephemeralSecrets.sort(), ['AUTH_SIGNING_SECRET', 'JWT_SECRET', 'WEBHOOK_SECRET_KEY']); +}); + +test('validates webhook settings (#128)', async () => { + const { loadConfig } = await import('../src/config'); + const prod = { DATABASE_URL: 'x', NODE_ENV: 'production', JWT_SECRET: '01234567890123456789012345678901', AUTH_SIGNING_SECRET: Keypair.random().secret() }; + assert.throws(() => loadConfig(prod), /WEBHOOK_SECRET_KEY is required in production/); + assert.throws(() => loadConfig({ DATABASE_URL: 'x', WEBHOOK_SECRET_KEY: 'c2hvcnQ=' }), /WEBHOOK_SECRET_KEY must be 32 bytes/); + const key = Buffer.alloc(32, 7).toString('base64'); + assert.throws(() => loadConfig({ ...prod, WEBHOOK_SECRET_KEY: key, WEBHOOKS_ALLOW_HTTP: 'true' }), /WEBHOOKS_ALLOW_HTTP must not be true in production/); + assert.equal(loadConfig({ ...prod, WEBHOOKS_ENABLED: 'false' }).WEBHOOKS_ENABLED, false); + const ok = loadConfig({ ...prod, WEBHOOK_SECRET_KEY: key }); + assert.equal(ok.WEBHOOK_SECRET_KEY, key); + assert.ok(!ok.ephemeralSecrets.includes('WEBHOOK_SECRET_KEY')); }); diff --git a/tests/helpers/env.ts b/tests/helpers/env.ts index ff8bde5..80e1e41 100644 --- a/tests/helpers/env.ts +++ b/tests/helpers/env.ts @@ -11,4 +11,6 @@ export function setupTestEnv(): void { process.env.STELLAR_RPC_URL = 'http://127.0.0.1:1'; // tests must never reach a real RPC process.env.INDEXER_ENABLED = 'false'; process.env.ADMIN_ADDRESSES = ''; + // Webhook enqueueing is exercised explicitly in tests/webhooks.test.ts. + process.env.WEBHOOKS_ENABLED = 'false'; } diff --git a/tests/webhooks.test.ts b/tests/webhooks.test.ts new file mode 100644 index 0000000..ab93967 --- /dev/null +++ b/tests/webhooks.test.ts @@ -0,0 +1,325 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { setupTestEnv } from './helpers/env'; + +setupTestEnv(); + +const publicResolver = async () => [{ address: '93.184.216.34', family: 4 }]; +const prodOpts = { production: true, allowHttp: false, resolver: publicResolver }; +const devHttpOpts = { production: false, allowHttp: true, resolver: publicResolver }; + +// ─── Signing ──────────────────────────────────────────────────────────────── + +test('signature verifies with the reference verifier; tampered body or stale timestamp fails', async () => { + const { signatureHeader, verifyWebhookSignature } = await import('../src/services/webhooks.service'); + const body = Buffer.from('{"id":"trade.executed:1","type":"trade.executed"}'); + const now = 1_760_000_000; + const header = signatureHeader(['whsec_a'], now, body); + assert.match(header, /^t=1760000000,v1=[0-9a-f]{64}$/); + + assert.equal(verifyWebhookSignature(body, header, ['whsec_a'], 300, now), true); + assert.equal(verifyWebhookSignature(Buffer.from(body.toString() + ' '), header, ['whsec_a'], 300, now), false, 'tampered body'); + assert.equal(verifyWebhookSignature(body, header, ['whsec_b'], 300, now), false, 'wrong secret'); + assert.equal(verifyWebhookSignature(body, header, ['whsec_a'], 300, now + 301), false, 'stale timestamp'); + assert.equal(verifyWebhookSignature(body, 'v1=deadbeef', ['whsec_a'], 300, now), false, 'missing timestamp'); +}); + +test('during rotation both signatures are sent and either secret verifies', async () => { + const { signatureHeader, signingSecrets, verifyWebhookSignature, encryptSecret, decryptSecret } = await import('../src/services/webhooks.service'); + const now = new Date('2026-10-17T12:00:00Z'); + const sub = { + secret: encryptSecret('whsec_new'), + previousSecret: encryptSecret('whsec_old'), + secretRotatedAt: new Date(now.getTime() - 60 * 60_000), + }; + const secrets = signingSecrets(sub, now); + assert.deepEqual(secrets, ['whsec_new', 'whsec_old']); + const t = Math.floor(now.getTime() / 1000); + const header = signatureHeader(secrets, t, 'x'); + assert.equal(header.split(',').filter((p) => p.startsWith('v1=')).length, 2); + assert.equal(verifyWebhookSignature('x', header, ['whsec_old'], 300, t), true); + assert.equal(verifyWebhookSignature('x', header, ['whsec_new'], 300, t), true); + + const afterOverlap = new Date(now.getTime() + 24 * 60 * 60_000); + assert.deepEqual(signingSecrets(sub, afterOverlap), ['whsec_new'], 'old secret dropped after 24 h'); + assert.equal(decryptSecret(sub.secret), 'whsec_new'); +}); + +test('secrets are encrypted at rest with AES-256-GCM and tamper-evident', async () => { + const { encryptSecret, decryptSecret } = await import('../src/services/webhooks.service'); + const enc = encryptSecret('whsec_abc'); + assert.ok(!enc.includes('whsec_abc')); + assert.notEqual(enc, encryptSecret('whsec_abc'), 'random IV'); + const [iv, tag, data] = enc.split('.'); + const flipped = Buffer.from(data, 'base64'); + flipped[0] ^= 1; + assert.throws(() => decryptSecret([iv, tag, flipped.toString('base64')].join('.'))); +}); + +// ─── SSRF guard ───────────────────────────────────────────────────────────── + +test('isBlockedAddress covers private, loopback, link-local, metadata and mapped ranges', async () => { + const { isBlockedAddress } = await import('../src/lib/ssrf-guard'); + for (const ip of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '::1', 'fe80::1', 'fd00::1', '::ffff:127.0.0.1', '::ffff:7f00:1', '::ffff:a9fe:a9fe']) { + assert.equal(isBlockedAddress(ip), true, ip); + } + for (const ip of ['93.184.216.34', '8.8.8.8', '2606:4700::1111']) { + assert.equal(isBlockedAddress(ip), false, ip); + } +}); + +test('assertSafeUrl rejects unsafe URLs', async () => { + const { assertSafeUrl, SsrfError } = await import('../src/lib/ssrf-guard'); + const rejects = (url: string, opts: any, re: RegExp) => assert.rejects(assertSafeUrl(url, opts), (e: unknown) => e instanceof SsrfError && re.test(e.message)); + + await rejects('http://example.com/hook', prodOpts, /must use https/); + await rejects('http://example.com/hook', { ...devHttpOpts, allowHttp: false }, /must use https/); + await rejects('https://user:pw@example.com/hook', prodOpts, /credentials/); + await rejects('https://example.com:6379/hook', prodOpts, /port/); + await rejects('https://169.254.169.254/latest/meta-data', prodOpts, /disallowed address/); + await rejects('https://127.0.0.1/hook', prodOpts, /disallowed/); + await rejects('https://2130706433/hook', prodOpts, /disallowed/); // decimal 127.0.0.1 + await rejects('https://0x7f.1/hook', prodOpts, /disallowed/); // hex alias + await rejects('https://[::ffff:127.0.0.1]/hook', prodOpts, /disallowed/); // IPv4-mapped IPv6 + await rejects('https://[::1]/hook', prodOpts, /disallowed/); + // A public-looking hostname that resolves (even partly) to a private address. + await rejects('https://internal.example/hook', { ...prodOpts, resolver: async () => [{ address: '93.184.216.34', family: 4 }, { address: '10.0.0.5', family: 4 }] }, /disallowed address \(10\.0\.0\.5\)/); + await rejects('https://nx.example/hook', { ...prodOpts, resolver: async () => { throw new Error('ENOTFOUND'); } }, /Could not resolve/); + + const ok = await assertSafeUrl('https://hooks.example.com/oracledesk', prodOpts); + assert.equal(ok.hostname, 'hooks.example.com'); + assert.ok(await assertSafeUrl('http://localhost.example/hook', devHttpOpts), 'http allowed outside production when opted in'); +}); + +test('pinned lookup answers with the validated IP even if DNS now says otherwise (rebinding)', async () => { + const { resolveSafeTarget, pinnedLookup } = await import('../src/lib/ssrf-guard'); + let calls = 0; + const rebinding = async () => (calls++ === 0 ? [{ address: '93.184.216.34', family: 4 }] : [{ address: '127.0.0.1', family: 4 }]); + const target = await resolveSafeTarget('https://rebind.example/hook', { ...prodOpts, resolver: rebinding }); + assert.equal(target.address, '93.184.216.34'); + + // The socket's lookup never consults DNS again. + const lookup = pinnedLookup(target.address, target.family); + const single = await new Promise((resolve) => (lookup as any)('rebind.example', {}, (_e: unknown, a: string) => resolve(a))); + const all = await new Promise((resolve) => (lookup as any)('rebind.example', { all: true }, (_e: unknown, a: unknown) => resolve(a))); + assert.equal(single, '93.184.216.34'); + assert.deepEqual(all, [{ address: '93.184.216.34', family: 4 }]); + assert.equal(calls, 1, 'resolver consulted once, at validation'); +}); + +test('safeFetch does not follow redirects', async () => { + const { safeFetch } = await import('../src/lib/ssrf-guard'); + let hits = 0; + const server = http.createServer((_req, res) => { + hits++; + res.writeHead(302, { location: 'http://169.254.169.254/' }).end(); + }).listen(0); + try { + const port = (server.address() as AddressInfo).port; + const res = await safeFetch(`http://receiver.test:${port}/hook`, { body: Buffer.from('{}'), headers: {}, timeoutMs: 2000 }, { + production: false, allowHttp: true, allowPrivateForTests: true, resolver: async () => [{ address: '127.0.0.1', family: 4 }], + }); + assert.equal(res.status, 302); + assert.equal(hits, 1); + } finally { + server.close(); + } +}); + +// ─── Retry policy ─────────────────────────────────────────────────────────── + +test('backoff doubles from 1 minute, caps at 24 h, with ±20% jitter', async () => { + const { backoffMs } = await import('../src/services/webhooks.service'); + assert.equal(backoffMs(1, 0.5), 60_000); + assert.equal(backoffMs(2, 0.5), 120_000); + assert.equal(backoffMs(5, 0.5), 16 * 60_000); + assert.equal(backoffMs(20, 0.5), 24 * 60 * 60_000); + assert.equal(backoffMs(1, 0), 48_000); + assert.equal(backoffMs(1, 1), 72_000); +}); + +test('Retry-After is honoured (seconds or HTTP date)', async () => { + const { parseRetryAfter, planFailure } = await import('../src/services/webhooks.service'); + const now = Date.parse('2026-10-17T12:00:00Z'); + assert.equal(parseRetryAfter('120', now), 120_000); + assert.equal(parseRetryAfter(new Date(now + 30_000).toUTCString(), now), 30_000); + assert.equal(parseRetryAfter('garbage', now), null); + assert.equal(parseRetryAfter(undefined, now), null); + + const plan = planFailure({ attempts: 0 }, { consecutiveFailures: 0, failingSince: null }, { now: new Date(now), retryAfterMs: 600_000, rand: 0.5 }); + assert.equal(plan.nextAttemptAt.getTime() - now, 600_000, 'Retry-After beats the 1 min backoff'); +}); + +test('dead-letters after 15 attempts and disables at the failure thresholds', async () => { + const { planFailure, MAX_ATTEMPTS, DISABLE_AFTER_FAILURES } = await import('../src/services/webhooks.service'); + const now = new Date('2026-10-17T12:00:00Z'); + + const first = planFailure({ attempts: 0 }, { consecutiveFailures: 0, failingSince: null }, { now, rand: 0.5 }); + assert.equal(first.status, 'PENDING'); + assert.equal(first.attempts, 1); + assert.equal(first.failingSince, now, 'failure streak starts now'); + assert.equal(first.disableReason, null); + + assert.equal(planFailure({ attempts: MAX_ATTEMPTS - 2 }, { consecutiveFailures: 0, failingSince: now }, { now }).status, 'PENDING'); + assert.equal(planFailure({ attempts: MAX_ATTEMPTS - 1 }, { consecutiveFailures: 0, failingSince: now }, { now }).status, 'DEAD'); + + assert.match( + planFailure({ attempts: 0 }, { consecutiveFailures: DISABLE_AFTER_FAILURES - 1, failingSince: now }, { now }).disableReason ?? '', + /consecutive failed deliveries/, + ); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60_000); + assert.match(planFailure({ attempts: 0 }, { consecutiveFailures: 1, failingSince: fiveDaysAgo }, { now }).disableReason ?? '', /5 days/); +}); + +// ─── Outbox ───────────────────────────────────────────────────────────────── + +function fakeDb() { + const events = new Set(); + const sql: string[] = []; + return { + events, + sql, + async $executeRaw(strings: TemplateStringsArray, ...values: unknown[]) { + const text = strings.join('?'); + sql.push(text); + if (text.includes('INSERT INTO "webhook_events"')) { + const id = values[0] as string; + if (events.has(id)) return 0; + events.add(id); + return 1; + } + return 2; // two matching subscriptions + }, + }; +} + +test('enqueueEvent dedups by chain event id', async () => { + const { enqueueEvent } = await import('../src/services/webhooks.service'); + const db = fakeDb(); + const payload = { onChainMarketId: '0' }; + assert.equal(await enqueueEvent(db as any, 'trade.executed', '0004951600-0000000001', payload, { onChainMarketId: 0n }), 2); + assert.equal(await enqueueEvent(db as any, 'trade.executed', '0004951600-0000000001', payload, { onChainMarketId: 0n }), 0, 'replay inserts nothing'); + assert.deepEqual([...db.events], ['trade.executed:0004951600-0000000001']); + assert.equal(db.sql.filter((s) => s.includes('webhook_deliveries')).length, 1, 'deliveries fanned out once'); + assert.match(db.sql[1], /status" = 'ACTIVE'/); + assert.match(db.sql[1], /ANY\(s\."eventTypes"\)/); +}); + +test('envelope bytes are canonical and stable (signed bytes == sent bytes)', async () => { + const { envelopeBytes } = await import('../src/services/webhooks.service'); + const event = { id: 'trade.executed:1', type: 'trade.executed', createdAt: new Date('2026-10-17T12:00:00Z'), payload: { z: 1, a: 2 } }; + const bytes = envelopeBytes(event); + assert.equal(bytes.toString(), '{"createdAt":"2026-10-17T12:00:00.000Z","data":{"a":2,"z":1},"id":"trade.executed:1","type":"trade.executed"}'); + assert.deepEqual(envelopeBytes(event), bytes); +}); + +// ─── End-to-end delivery to a local receiver ──────────────────────────────── + +test('a local receiver gets a signed envelope matching the TRADE_EXECUTED shape', async () => { + const { prisma } = await import('../src/lib/prisma'); + const svc = await import('../src/services/webhooks.service'); + const { safeFetch } = await import('../src/lib/ssrf-guard'); + + const received: Array<{ headers: http.IncomingHttpHeaders; body: string }> = []; + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + received.push({ headers: req.headers, body }); + res.writeHead(204).end(); + }); + }).listen(0); + const port = (server.address() as AddressInfo).port; + + const tradePayload = { onChainMarketId: '0', marketId: 'm0', direction: 'YES', collateralRaw: '100000000', eventId: '0004951600-0000000001' }; + const delivery: any = { + id: 'd1', + status: 'PENDING', + attempts: 0, + event: { id: 'trade.executed:0004951600-0000000001', type: 'trade.executed', createdAt: new Date(), payload: tradePayload }, + subscription: { + id: 's1', userId: 'u1', status: 'ACTIVE', url: `http://receiver.test:${port}/hook`, + secret: svc.encryptSecret('whsec_test'), previousSecret: null, secretRotatedAt: null, consecutiveFailures: 3, failingSince: new Date(), + }, + }; + const updates: any[] = []; + (prisma.webhookDelivery.findUnique as any) = async () => delivery; + (prisma.webhookDelivery.update as any) = (args: any) => (updates.push(['delivery', args]), args); + (prisma.webhookSubscription.update as any) = (args: any) => (updates.push(['subscription', args]), args); + (prisma.$transaction as any) = async (ops: unknown[]) => ops; + + // Local receivers need http + loopback: allowed only via the test escape hatch. + const send: typeof safeFetch = (url, init) => + safeFetch(url, init, { production: false, allowHttp: true, allowPrivateForTests: true, resolver: async () => [{ address: '127.0.0.1', family: 4 }] }); + + try { + await svc.deliverOne('d1', send); + } finally { + server.close(); + } + + assert.equal(received.length, 1); + const [{ headers, body }] = received; + assert.equal(headers['oracledesk-event-id'], 'trade.executed:0004951600-0000000001'); + assert.equal(headers['oracledesk-event-type'], 'trade.executed'); + assert.equal(headers['user-agent'], 'OracleDesk-Webhooks/1'); + assert.ok(svc.verifyWebhookSignature(body, String(headers['oracledesk-signature']), ['whsec_test'])); + const envelope = JSON.parse(body); + assert.equal(envelope.type, 'trade.executed'); + assert.deepEqual(envelope.data, tradePayload); + + const [, d] = updates.find(([k]) => k === 'delivery'); + assert.equal(d.data.status, 'SUCCEEDED'); + assert.equal(d.data.lastStatusCode, 204); + const [, s] = updates.find(([k]) => k === 'subscription'); + assert.equal(s.data.consecutiveFailures, 0, 'success resets the failure streak'); +}); + +test('a failing receiver schedules a retry and counts the failure', async () => { + const { prisma } = await import('../src/lib/prisma'); + const svc = await import('../src/services/webhooks.service'); + const now = new Date('2026-10-17T12:00:00Z'); + const delivery: any = { + id: 'd2', status: 'PENDING', attempts: 0, + event: { id: 'market.resolved:x', type: 'market.resolved', createdAt: now, payload: {} }, + subscription: { id: 's2', userId: 'u1', status: 'ACTIVE', url: 'https://hooks.example.com/x', secret: svc.encryptSecret('k'), previousSecret: null, secretRotatedAt: null, consecutiveFailures: 0, failingSince: null }, + }; + const updates: any[] = []; + (prisma.webhookDelivery.findUnique as any) = async () => delivery; + (prisma.webhookDelivery.update as any) = (args: any) => (updates.push(['delivery', args]), args); + (prisma.webhookSubscription.update as any) = (args: any) => (updates.push(['subscription', args]), args); + (prisma.$transaction as any) = async (ops: unknown[]) => ops; + + await svc.deliverOne('d2', async () => ({ status: 503, headers: { 'retry-after': '300' } }), () => now); + + const [, d] = updates.find(([k]) => k === 'delivery'); + assert.equal(d.data.status, 'PENDING'); + assert.equal(d.data.attempts, 1); + assert.equal(d.data.lastStatusCode, 503); + assert.equal(d.data.nextAttemptAt.getTime() - now.getTime(), 300_000); + const [, s] = updates.find(([k]) => k === 'subscription'); + assert.equal(s.data.consecutiveFailures, 1); + assert.equal(s.data.status, undefined, 'not disabled yet'); +}); + +// ─── Indexer integration ──────────────────────────────────────────────────── + +test('publishWebhookEvent is a no-op when WEBHOOKS_ENABLED=false and never throws', async () => { + const { config } = await import('../src/config'); + const { publishWebhookEvent } = await import('../src/services/webhooks.service'); + const db = fakeDb(); + await publishWebhookEvent('trade.executed', 'e1', {}, { onChainMarketId: 0n }, db as any); + assert.equal(db.sql.length, 0); + + const was = config.WEBHOOKS_ENABLED; + (config as any).WEBHOOKS_ENABLED = true; + try { + await publishWebhookEvent('trade.executed', 'e1', {}, { onChainMarketId: 0n }, db as any); + assert.equal(db.sql.length, 2); + await publishWebhookEvent('trade.executed', 'e2', {}, { onChainMarketId: 0n }, { $executeRaw: async () => { throw new Error('db down'); } } as any); + } finally { + (config as any).WEBHOOKS_ENABLED = was; + } +});