import type { SubscriptionDelivery, SubscriptionFiring } from "@uptimizr/schema"; import { type SubscriptionRecord } from "@uptimizr/db"; /** * **Delivering a subscription firing** (#311, ADR 0051 §6 / sketch §F.3). * * Two targets, and they are deliberately asymmetric: * * - **SSE** is a fan-out to whoever is already connected. It costs nothing, it * leaves the process only over a connection the operator's own key opened, and * it is the default. * - **A webhook is egress.** Nothing here makes an outbound request unless a * subscription carries a `webhook` delivery with a URL — ADR 0051 §6, "no * outbound egress happens unless a self-hoster configures a webhook URL", is * enforced by {@link deliver} having nothing to do in that case. * * ## The SSRF boundary * * `parseWebhookUrl` (shared with `uptimizr agent report`) rejects anything that * is not `http(s)`, and its own doc is explicit that a surface which accepts a * URL *over HTTP* must add an allow-list on top — which is exactly this surface, * because a subscription is created through the API by an `annotate` key rather * than typed into the operator's shell. {@link checkWebhookUrl} is that * allow-list: `COLLECTOR_WEBHOOK_ALLOWED_HOSTS`, empty by default, which means * **no webhook delivery leaves the process at all until an operator names the * hosts it may reach**. A key holder can therefore not turn the collector into a * probe for the network it sits in. * * ## Retries * * Three attempts, exponential backoff, only for failures that can plausibly * succeed on a retry (a network error, a 408/429, any 5xx). A 4xx other than * those is the receiver saying "not like that", and repeating it just triples * the noise. Every attempt carries the same {@link WEBHOOK_DELIVERY_HEADER} id, * so a receiver can dedupe. */ /** Attempts per firing, including the first. */ export declare const WEBHOOK_MAX_ATTEMPTS = 3; /** Backoff before the 2nd attempt; doubled for each further one. */ export declare const WEBHOOK_BASE_BACKOFF_MS = 500; /** Per-attempt timeout. A webhook receiver that is slow is a webhook that fails. */ export declare const WEBHOOK_TIMEOUT_MS = 10000; /** The body a webhook receives. */ export interface WebhookPayload { /** Schema marker, so a receiver can branch on shape without guessing. */ type: "subscription.firing"; firing: SubscriptionFiring; /** Bounded `format=summary` of the metric over the window that fired. */ summary: Record | null; } /** Outcome of delivering one firing. */ export interface DeliveryOutcome { /** Whether every configured target accepted it (SSE always does). */ ok: boolean; /** Bounded, redacted failure text when a webhook failed, else `null`. */ error: string | null; /** How many webhook attempts were made (0 when no webhook is configured). */ attempts: number; /** True when a webhook request actually left the process. */ egress: boolean; } /** What {@link deliver} needs from its host. */ export interface DeliveryDeps { /** Push the firing to connected SSE listeners. */ broadcast: (firing: SubscriptionFiring, summary: Record | null) => void; /** The subscription's webhook secret, or `null`. */ secret: string | null; /** Hosts a webhook may target; empty forbids all webhook egress. */ allowedHosts: readonly string[]; /** Injected for tests. Defaults to the global `fetch`. */ fetchImpl?: typeof fetch; /** Injected for tests. Defaults to a real timer. */ sleep?: (ms: number) => Promise; } /** * Validate a webhook URL against the operator's allow-list. * * Returns the parsed URL, or a refusal string naming why. Matching is on the * **hostname** (case-insensitive, port-independent), because a port is not a * trust boundary: an operator who allows `hooks.slack.com` means the host, and * an operator who allows `localhost` means their own box. `*` allows every host * and is an explicit, documented opt-out for a collector on a closed network. */ export declare function checkWebhookUrl(raw: string, allowedHosts: readonly string[]): { url: URL; } | { refused: string; }; /** The one webhook target of a subscription, or `null`. */ export declare function webhookTargetOf(sub: SubscriptionRecord): Extract | null; /** Whether a subscription asks for SSE fan-out. */ export declare function wantsSse(sub: SubscriptionRecord): boolean; /** * Deliver one firing to every target the subscription declares. * * SSE first and unconditionally — it is in-process, so a broken webhook must * never cost a connected agent its event. */ export declare function deliver(sub: SubscriptionRecord, firing: SubscriptionFiring, summary: Record | null, deps: DeliveryDeps): Promise; //# sourceMappingURL=delivery.d.ts.map