/** * Receiver-side verification — combines signature + replay checks * into a single decision the HTTP handler can act on. * * Pure: takes the (already-parsed) envelope, the receiver's shared * secret, and time-of-check `now` (injectable for tests). Returns * one of three outcomes: * * { ok: true } — accept + emit * { ok: false, reason: 'signature-invalid', … } — drop + 401 * { ok: false, reason: 'stale', … } — drop + 401 * * Dedup is NOT done here — the spec models dedup as a downstream * concern (e.g. via the local-bus's `dedupKey` mechanism, which * silently merges duplicate inserts). Keeping it out of this layer * lets the receiver-server decide the dedup storage strategy * without changing the verification contract. */ import { isFresh, verifyEvent } from './signing'; import type { WebhookEnvelope } from './types'; export type VerifyOutcome = | { ok: true; eventId: string } | { ok: false; reason: 'signature-invalid' | 'stale' | 'malformed'; detail?: string }; export interface VerifyOptions { /** Per-receiver shared secret. The publisher used this to sign. */ secret: string; /** "Now" for the freshness check. Defaults to Date.now(). */ now?: number; /** * Tolerated event-age window in milliseconds, either direction. * Defaults to the signing-layer default (6 hours). */ maxAgeMs?: number; } /** * Verify a (sender-signed) webhook envelope is genuine and fresh. * * Returns `{ ok: true, eventId }` for accept, or a discriminated * `{ ok: false, reason }` for the receiver-server to translate into * HTTP 4xx + log line. `reason: 'malformed'` covers payload shape * surprises (no event, missing fields); the others map to the * signing-layer outcomes 1-to-1. */ export function verifyEnvelope(envelope: WebhookEnvelope, opts: VerifyOptions): VerifyOutcome { if (!envelope || typeof envelope !== 'object') { return { ok: false, reason: 'malformed', detail: 'envelope is not an object' }; } const { event, signature } = envelope; if (!event || typeof event !== 'object') { return { ok: false, reason: 'malformed', detail: 'event missing or not an object' }; } if (typeof signature !== 'string' || signature.length === 0) { return { ok: false, reason: 'malformed', detail: 'signature missing' }; } if (typeof event.eventId !== 'string' || typeof event.timestamp !== 'string') { return { ok: false, reason: 'malformed', detail: 'eventId or timestamp missing' }; } if (!verifyEvent(event, signature, opts.secret)) { return { ok: false, reason: 'signature-invalid' }; } if (!isFresh(event, { now: opts.now, maxAgeMs: opts.maxAgeMs })) { return { ok: false, reason: 'stale' }; } return { ok: true, eventId: event.eventId }; }