/* Notices to the outside (stage 8): when HUB_WEBHOOK_URL is set, the hub POSTs a small JSON body `{event, at, hub, items}` on a change of drift (`drift.found`, `drift.cleared`) and when an audited write failed (`write.failed`). With HUB_WEBHOOK_SECRET the exact bytes of the body are signed: `X-Hub-Signature: sha256=`. A delivery never blocks or fails the request that caused it: it runs on its own with a timeout and at most one retry. Errors go to the server log with the HOST only (the URL may carry a token in its query string) and never the error text. Items carry names and field names, never values. */ import { createHmac } from 'node:crypto' import type { HubConfig } from './config.ts' export type WebhookEvent = 'drift.found' | 'drift.cleared' | 'write.failed' | 'webhook.test' export interface Delivery { event: WebhookEvent at: string ok: boolean /** `HTTP 500`, or an error code or name. Never a URL. */ error?: string } export interface WebhookStatus { configured: boolean /** Host and port only. */ host: string | null signed: boolean last: Delivery | null } export interface WebhookOptions { fetch?: typeof fetch timeoutMs?: number retryDelayMs?: number /** How long write.failed items are collected into one notice. */ batchMs?: number now?: () => number } export const sign = (secret: string, body: string): string => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}` export class Webhook { #url: string | null #secret: string | null #domain: string #host: string | null #fetch: typeof fetch #timeoutMs: number #retryDelayMs: number #batchMs: number #now: () => number #last: Delivery | null = null #pending = new Set>() #queued: Record[] = [] #timer: NodeJS.Timeout | null = null constructor(config: Pick, options: WebhookOptions = {}) { this.#url = config.webhookUrl this.#secret = config.webhookSecret this.#domain = config.domain this.#host = config.webhookUrl === null ? null : new URL(config.webhookUrl).host this.#fetch = options.fetch ?? fetch this.#timeoutMs = options.timeoutMs ?? 5000 this.#retryDelayMs = options.retryDelayMs ?? 1000 this.#batchMs = options.batchMs ?? 250 this.#now = options.now ?? Date.now } get configured(): boolean { return this.#url !== null } status(): WebhookStatus { return { configured: this.#url !== null, host: this.#host, signed: this.#secret !== null, last: this.#last } } /** Resolves when every delivery started so far has ended (for the tests). */ async settled(): Promise { if (this.#timer !== null) this.#flush() while (this.#pending.size > 0) await Promise.allSettled([...this.#pending]) } #body(event: WebhookEvent, items: Record[]): string { return JSON.stringify({ event, at: new Date(this.#now()).toISOString(), hub: this.#domain, items }) } async #attempt(body: string): Promise { const headers: Record = { 'content-type': 'application/json' } if (this.#secret !== null) headers['x-hub-signature'] = sign(this.#secret, body) try { const res = await this.#fetch(this.#url as string, { method: 'POST', headers, body, redirect: 'manual', signal: AbortSignal.timeout(this.#timeoutMs) }) await res.body?.cancel().catch(() => undefined) return res.status >= 200 && res.status < 300 ? null : `HTTP ${res.status}` } catch (e) { const code = (e as { cause?: { code?: unknown } })?.cause?.code ?? (e as { code?: unknown })?.code return typeof code === 'string' ? code : e instanceof Error ? e.name : 'failed' } } /* Delivers one notice and records the result. Returns it. */ async deliver(event: WebhookEvent, items: Record[]): Promise { if (this.#url === null) return { event, at: new Date(this.#now()).toISOString(), ok: false, error: 'not configured' } const body = this.#body(event, items) let error = await this.#attempt(body) if (error !== null) { if (this.#retryDelayMs > 0) await new Promise((r) => setTimeout(r, this.#retryDelayMs)) error = await this.#attempt(body) } const result: Delivery = { event, at: new Date(this.#now()).toISOString(), ok: error === null, ...(error === null ? {} : { error }) } this.#last = result if (error !== null) console.error(`hub: webhook delivery to ${this.#host} failed (${error}).`) return result } /* Fire and forget: never throws, never waits. */ send(event: WebhookEvent, items: Record[]): void { if (this.#url === null || items.length === 0) return const run = this.deliver(event, items).catch(() => undefined) this.#pending.add(run) void run.finally(() => this.#pending.delete(run)) } /* Collects `write.failed` items for a moment, so ten failed servers make one notice. */ queueFailed(item: Record): void { if (this.#url === null) return this.#queued.push(item) if (this.#timer === null) { this.#timer = setTimeout(() => this.#flush(), this.#batchMs) this.#timer.unref() } } #flush(): void { if (this.#timer !== null) clearTimeout(this.#timer) this.#timer = null const items = this.#queued.splice(0, 50) this.#queued = [] this.send('write.failed', items) } }