import { Hash, Hex } from 'ox' import type * as Webhooks from './Webhooks.js' import * as core_betterStack from './webhookDestinations/betterStack.js' import * as core_slack from './webhookDestinations/slack.js' import * as core_url from './webhookDestinations/url.js' export { assertBetterstackUrl, betterStack } from './webhookDestinations/betterStack.js' export { assertSlackUrl, redactSlackUrl, slack } from './webhookDestinations/slack.js' export { assertDeliverableUrl, url } from './webhookDestinations/url.js' /** * Where a subscription's matched events are delivered. * * - `url` — a subscriber HTTPS endpoint; the signed envelope is POSTed verbatim * (HMAC in `tempo-signature`). * - `slack` — a subscriber-supplied Slack incoming-webhook URL (`hooks.slack.com`). * The envelope is formatted into a Slack Block Kit message and POSTed there * with no signature (the URL is itself the shared secret). No OAuth. * - `betterstack` — a Better Stack Telemetry source: an ingest host URL * (`*.betterstackdata.com`) plus a source token. The envelope is POSTed as a * structured log event authenticated with `Authorization: Bearer `. * * A `Destination` is plain, serializable data — it is what a `Subscription` * persists. The builders (`from`, `url`, `slack`, `betterStack`) return an * {@link Instance}: the same data plus a bound {@link Instance.deliver}. `deliver` * is a function property, so an instance serializes and structurally compares as * the plain destination it is. */ export type Destination = BetterStack | Slack | Url /** A `url` delivery destination (a signed event POST to an HTTPS endpoint). */ export type Url = { /** Destination kind. */ type: 'url' /** Subscriber callback URL (validated `https`). */ url: string } /** A `slack` delivery destination (a formatted message to an incoming webhook). */ export type Slack = { /** Destination kind. */ type: 'slack' /** Slack incoming-webhook URL (`https://hooks.slack.com/services/…`). */ url: string } /** A `betterstack` delivery destination (a log event to a Better Stack source). */ export type BetterStack = { /** Better Stack source token, sent as `Authorization: Bearer …`. */ token: string /** Destination kind. */ type: 'betterstack' /** Better Stack ingest host URL (`https://.betterstackdata.com`). */ url: string } /** * A built destination: the plain {@link Destination} data plus a `deliver` * method that owns this transport's delivery logic (formatting, headers, * signing, redirect policy). `deliver` is a function property, so it drops out of * `JSON.stringify` — an instance serializes to just its data. * * There is no central delivery router: each provider carries its own transport. * To deliver a persisted (plain) `Subscription.destination`, rebuild an instance * with {@link from} and call `.deliver(...)`. */ export type Instance = destination & { /** Delivers an envelope to this destination using its own transport. */ deliver: (options: deliver.Options) => Promise } export declare namespace deliver { /** Options for {@link Instance.deliver}. */ type Options = { /** The envelope to deliver. */ envelope: Webhooks.Envelope /** `fetch` implementation; defaults to the global. Injectable for tests. */ fetch?: typeof globalThis.fetch | undefined /** Monotonic clock in ms; defaults to `Date.now`. Injectable for tests. */ now?: (() => number) | undefined /** Signing secret; required by the `url` transport, ignored by others. */ secret?: string | undefined /** Abort the request after this many ms (default 10000). */ timeoutMs?: number | undefined /** Unix timestamp (seconds) embedded in the signature; defaults to now. */ timestamp?: number | undefined } } /** Outcome of a single delivery attempt, shared by every transport. */ export type Result = { /** Wall-clock duration of the attempt in ms, when the request was made. */ durationMs?: number | undefined /** Failure reason when `ok` is false. */ error?: string | undefined /** Whether the destination accepted the delivery (2xx). */ ok: boolean /** HTTP response status, when a response was received. */ status?: number | undefined } /** Returns whether another delivery attempt could plausibly succeed. */ export function isRetryable(result: Result): boolean { if (result.ok) return false if (result.status !== undefined) return result.status === 408 || result.status === 429 || result.status >= 500 // Workers exposes rejected redirects as an opaque response without a status. if (result.error && /redirect/i.test(result.error)) return false return true } /** * Canonical constructor: normalizes a value into a validated {@link Instance} by * delegating to the matching builder (`url`, `slack`, `betterStack`), each of * which owns its own transport. This is also how you deliver a persisted (plain) * destination: `from(subscription.destination).deliver(...)`. * * @example * ```ts * WebhookDestination.from('https://example.com/hook') * WebhookDestination.from({ type: 'slack', url: 'https://hooks.slack.com/…' }) * ``` */ export function from(value: from.Value): Instance { if (typeof value === 'string') return core_url.url(value) if (value.type === 'slack') return core_slack.slack(value.url) if (value.type === 'betterstack') return core_betterStack.betterStack(value) return core_url.url(value.url) } export declare namespace from { /** A destination object, or a bare HTTPS URL string (shorthand for `url`). */ type Value = Destination | string } /** Validates a destination's URL according to its transport-specific rules. */ export function assertDestination(destination: Destination): void { if (destination.type === 'url') core_url.assertDeliverableUrl(destination.url) else if (destination.type === 'slack') core_slack.assertSlackUrl(destination.url) else core_betterStack.assertBetterstackUrl(destination.url) } /** * A non-secret label for a destination, used in delivery-log `requestUrl`. Slack * incoming-webhook URLs are bearer secrets, so `slack` destinations log a * token-redacted form (see {@link core_slack.redactSlackUrl}) rather than the raw * URL. Better Stack keeps its secret in a separate `token`, so its ingest URL * logs verbatim. */ export function destinationLabel(destination: Destination): string { return destination.type === 'slack' ? core_slack.redactSlackUrl(destination.url) : destination.url } /** * Low-level HTTP send shared by every transport: POSTs a prebuilt body/headers * with a timeout and classifies the response into a {@link Result}. This is the * network mechanic only — each provider owns its own body formatting, headers, * signing, and redirect policy. Never throws. */ export async function send(options: send.Options): Promise { const fetch_ = options.fetch ?? globalThis.fetch const clock = options.now ?? (() => Date.now()) const timeoutMs = options.timeoutMs ?? 10_000 const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) const start = clock() try { const response = await fetch_(options.url, { body: options.body, headers: { 'content-type': 'application/json', ...options.headers }, method: 'POST', // `manual` yields an opaque redirect (status 0) in Workers/browsers, or the // raw 3xx status in Node — both are treated as a failed delivery. redirect: 'manual', signal: controller.signal, }) const durationMs = clock() - start // The body is never read; an unreleased stream holds its buffer and // connection in the isolate until GC, which stacks up during drains. response.body?.cancel().catch(() => {}) if ( options.rejectRedirects && (response.status === 0 || (response.status >= 300 && response.status < 400)) ) return { durationMs, error: `redirect rejected (${response.status})`, ok: false, status: response.status || undefined, } if (response.status >= 200 && response.status < 300) return { durationMs, ok: true, status: response.status } return { durationMs, error: `${options.errorPrefix ?? ''}non-2xx response (${response.status})`, ok: false, status: response.status || undefined, } } catch (cause) { const durationMs = clock() - start const timedOut = cause instanceof Error && cause.name === 'AbortError' return { durationMs, error: timedOut ? `timed out after ${timeoutMs}ms` : String(cause), ok: false, } } finally { clearTimeout(timer) } } export declare namespace send { /** Options for {@link send}. */ type Options = { /** Serialized request body. */ body: string /** Prefix for the non-2xx error message (e.g. `slack `). */ errorPrefix?: string | undefined /** `fetch` implementation; defaults to the global. */ fetch?: typeof globalThis.fetch | undefined /** Extra request headers merged over `content-type: application/json`. */ headers?: Record | undefined /** Monotonic clock in ms; defaults to `Date.now`. */ now?: (() => number) | undefined /** Treat any 3xx/opaque redirect as a failed delivery (SSRF guard). */ rejectRedirects?: boolean | undefined /** Abort the request after this many ms (default 10000). */ timeoutMs?: number | undefined /** Destination URL. */ url: string } } /** Header carrying the HMAC signature (`t=…,v1=…`). */ export const signatureHeader = 'tempo-signature' /** Header carrying the idempotent event id (`evt_…`). */ export const eventIdHeader = 'tempo-event-id' /** Header carrying the event type. */ export const eventTypeHeader = 'tempo-event-type' /** * Signs a delivery body with a per-subscription secret. Returns a Stripe-style * header value `t=,v1=` where the timestamp guards against replay. */ export function sign(options: sign.Options): string { const timestamp = options.timestamp ?? Math.floor(Date.now() / 1_000) const v1 = Signature.hmac(options.secret, `${timestamp}.${options.body}`) return `t=${timestamp},v1=${v1}` } export declare namespace sign { /** Options for {@link sign}. */ type Options = { /** Raw request body to sign. */ body: string /** Per-subscription signing secret. */ secret: string /** Unix timestamp (seconds) to embed; defaults to now. */ timestamp?: number | undefined } } /** * Verifies a signature produced by {@link sign}: recomputes the HMAC and * rejects signatures older than `tolerance` seconds (default 300). */ export function verify(options: verify.Options): boolean { const parsed = Signature.parse(options.signature) if (!parsed) return false const tolerance = options.tolerance ?? 300 const now = options.now ?? Math.floor(Date.now() / 1_000) if (Math.abs(now - parsed.timestamp) > tolerance) return false const expected = Signature.hmac(options.secret, `${parsed.timestamp}.${options.body}`) return Signature.timingSafeEqual(expected, parsed.v1) } export declare namespace verify { /** Options for {@link verify}. */ type Options = { /** Raw request body that was signed. */ body: string /** Current Unix timestamp (seconds); injectable for tests. */ now?: number | undefined /** Per-subscription signing secret. */ secret: string /** Signature header value (`t=…,v1=…`). */ signature: string /** Maximum age in seconds before a signature is rejected. */ tolerance?: number | undefined } } namespace Signature { export function hmac(secret: string, message: string): string { return Hash.hmac256(Hex.fromString(secret), Hex.fromString(message)).slice(2) } export function parse(signature: string): { timestamp: number; v1: string } | null { let timestamp: number | undefined let v1: string | undefined for (const part of signature.split(',')) { const [key, value] = part.split('=', 2) if (key === 't' && value) timestamp = Number(value) else if (key === 'v1' && value) v1 = value } if (timestamp === undefined || Number.isNaN(timestamp) || !v1) return null return { timestamp, v1 } } export function timingSafeEqual(left: string, right: string): boolean { if (left.length !== right.length) return false let mismatch = 0 for (let index = 0; index < left.length; index++) mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index) return mismatch === 0 } } /** Thrown when a subscriber URL fails SSRF/format validation. */ export class InvalidUrlError extends Error { /** Why the URL was rejected. */ reason: InvalidUrlError.Reason /** The rejected URL. */ url: string constructor(reason: InvalidUrlError.Reason, url: string) { super(`Webhook URL rejected (${reason}): ${url}`) this.name = 'Webhooks.InvalidUrlError' this.reason = reason this.url = url } } export declare namespace InvalidUrlError { /** Reason a URL was rejected. */ type Reason = | 'betterstack_host' | 'blocked_host' | 'credentials' | 'malformed' | 'protocol' | 'slack_host' }