UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

822 lines (742 loc) 21.9 kB
/** * @beignet/core/webhooks * * Provider-neutral inbound webhook primitives for Beignet applications. */ import type { StandardSchemaV1 } from "@standard-schema/spec"; /** * Raw webhook payload. Signature verification must use this unparsed body. */ export type WebhookRawBody = string | Uint8Array | ArrayBuffer; /** * Request headers normalized to lowercase keys. */ export type WebhookHeaders = Record<string, string | undefined>; /** * Input passed to webhook verifiers. */ export interface VerifyWebhookInput { rawBody: WebhookRawBody; headers?: WebhookHeaders; signature?: string; receivedAt?: Date; } /** * Provider-neutral inbound webhook event. */ export interface WebhookEvent<TPayload = unknown> { id: string; type: string; provider?: string; createdAt?: Date; payload: TPayload; raw?: unknown; metadata?: Record<string, unknown>; } /** * Verifies an inbound webhook and converts it into a provider-neutral event. */ export interface WebhookVerifier<TEvent extends WebhookEvent = WebhookEvent> { verify(input: VerifyWebhookInput): Promise<TEvent>; } /** * Standard Schema payload catalog keyed by provider event type. */ export type WebhookEventSchemas = Record<string, StandardSchemaV1>; /** * Infer the parsed output type from a Standard Schema. */ export type InferSchemaOutput<T extends StandardSchemaV1> = StandardSchemaV1.InferOutput<T>; /** * Infer a typed webhook event from an event catalog entry. */ export type WebhookEventForSchema< Events extends WebhookEventSchemas, Type extends keyof Events & string, > = WebhookEvent<InferSchemaOutput<Events[Type]>> & { type: Type }; /** * Infer any typed event from a webhook definition's event catalog. */ export type InferWebhookEvent<TWebhook> = TWebhook extends WebhookDef<string, infer Events> ? { [Type in keyof Events & string]: WebhookEventForSchema<Events, Type>; }[keyof Events & string] : WebhookEvent; /** * Provider-neutral webhook definition. */ export interface WebhookDef< Name extends string = string, Events extends WebhookEventSchemas = WebhookEventSchemas, > { kind: "webhook"; name: Name; provider?: string; events: Events; verifier?: WebhookVerifier; metadata?: Record<string, unknown>; } /** * Options accepted by `defineWebhook(...)`. */ export interface DefineWebhookOptions< Events extends WebhookEventSchemas = WebhookEventSchemas, > { provider?: string; events?: Events; verifier?: WebhookVerifier; metadata?: Record<string, unknown>; } /** * Options accepted by `verifyWebhook(...)`. */ export interface VerifyWebhookOptions< AllowUnknownEvents extends boolean = boolean, > { verifier?: WebhookVerifier; allowUnknownEvents?: AllowUnknownEvents; } type InferWebhookVerificationResult< TWebhook extends WebhookDef<string, WebhookEventSchemas>, AllowUnknownEvents extends boolean, > = true extends AllowUnknownEvents ? InferWebhookEvent<TWebhook> | WebhookEvent : InferWebhookEvent<TWebhook>; /** * Timestamp formats accepted by the generic HMAC webhook verifier. */ export type HmacWebhookTimestampFormat = | "unix-seconds" | "unix-milliseconds" | "iso8601"; /** * Timestamp source and tolerance for generic HMAC replay protection. */ export type HmacWebhookTimestampOptions = | { /** * Header that carries the provider event timestamp. Header timestamps * are authenticated as `<timestamp>.<rawBody>`. */ header: string; payloadPath?: never; /** * Timestamp format. * * @default "unix-seconds" */ format?: HmacWebhookTimestampFormat; /** * Maximum absolute clock skew between the event timestamp and receipt. * * @default 300 */ toleranceSec?: number; } | { header?: never; /** * Dot path used to read the provider event timestamp from the JSON * payload. */ payloadPath: string; /** * Timestamp format. * * @default "unix-seconds" */ format?: HmacWebhookTimestampFormat; /** * Maximum absolute clock skew between the event timestamp and receipt. * * @default 300 */ toleranceSec?: number; }; /** * Options for the in-memory webhook verifier. */ export interface CreateMemoryWebhookVerifierOptions { events?: readonly WebhookEvent[]; } /** * In-memory verifier exposed for tests. */ export interface MemoryWebhookVerifier extends WebhookVerifier { readonly verifiedEvents: readonly WebhookEvent[]; queue(event: WebhookEvent): void; reset(): void; } /** * Options for the generic HMAC webhook verifier. */ export interface CreateHmacWebhookVerifierOptions { secret: string; /** * Header that carries the provider signature. * * @default "x-webhook-signature" */ signatureHeader?: string; /** * Web Crypto HMAC hash algorithm. * * @default "SHA-256" */ algorithm?: "SHA-256" | "SHA-384" | "SHA-512"; /** * Optional signature prefix, such as "sha256=". */ signaturePrefix?: string; /** * Provider name attached to verified events. */ provider?: string; /** * Dot path used to read the event ID from a JSON payload. * * @default "id" */ eventIdPath?: string; /** * Dot path used to read the event type from a JSON payload. * * @default "type" */ eventTypePath?: string; /** * Optional timestamp source used to reject replayed generic HMAC webhooks. */ timestamp?: HmacWebhookTimestampOptions; } /** * Error thrown for invalid webhook definitions and inputs. */ export class WebhookOptionsError extends Error { constructor(message: string) { super(message); this.name = "WebhookOptionsError"; } } /** * Error thrown when verification fails. */ export class WebhookVerificationError extends Error { readonly webhookName?: string; readonly provider?: string; readonly code: string; readonly cause?: unknown; constructor(args: { message: string; webhookName?: string; provider?: string; code: string; cause?: unknown; }) { super(args.message); this.name = "WebhookVerificationError"; this.webhookName = args.webhookName; this.provider = args.provider; this.code = args.code; this.cause = args.cause; } } /** * Error thrown when a verified event fails payload validation. */ export class WebhookValidationError extends Error { readonly webhookName: string; readonly eventType: string; readonly issues: readonly StandardSchemaV1.Issue[]; constructor(args: { webhookName: string; eventType: string; issues: readonly StandardSchemaV1.Issue[]; }) { super( `Webhook "${args.webhookName}" event "${args.eventType}" payload validation failed: ${formatIssues(args.issues)}`, ); this.name = "WebhookValidationError"; this.webhookName = args.webhookName; this.eventType = args.eventType; this.issues = args.issues; } } /** * Define a typed inbound webhook surface. */ export function defineWebhook< Name extends string, Events extends WebhookEventSchemas = WebhookEventSchemas, >( name: Name, options: DefineWebhookOptions<Events> = {}, ): WebhookDef<Name, Events> { if (!name) throw new WebhookOptionsError("Webhook name is required."); return { kind: "webhook", name, provider: options.provider, events: (options.events ?? {}) as Events, verifier: options.verifier, metadata: options.metadata, }; } /** * Verify a raw webhook request and validate the matching event payload schema. */ export async function verifyWebhook< TWebhook extends WebhookDef<string, WebhookEventSchemas>, AllowUnknownEvents extends boolean = false, >( webhook: TWebhook, input: VerifyWebhookInput, options: VerifyWebhookOptions<AllowUnknownEvents> = {}, ): Promise<InferWebhookVerificationResult<TWebhook, AllowUnknownEvents>> { validateWebhook(webhook); const verifier = options.verifier ?? webhook.verifier; if (!verifier) { throw new WebhookOptionsError( `Webhook "${webhook.name}" does not have a verifier.`, ); } let event: WebhookEvent; try { event = await verifier.verify(input); } catch (error) { if (error instanceof WebhookVerificationError) throw error; throw new WebhookVerificationError({ message: `Webhook "${webhook.name}" verification failed.`, webhookName: webhook.name, provider: webhook.provider, code: "verification_failed", cause: error, }); } return parseWebhookEvent(webhook, event, options); } /** * Validate a verified webhook event against its catalog entry. */ export async function parseWebhookEvent< TWebhook extends WebhookDef<string, WebhookEventSchemas>, AllowUnknownEvents extends boolean = false, >( webhook: TWebhook, event: WebhookEvent, options: Pick< VerifyWebhookOptions<AllowUnknownEvents>, "allowUnknownEvents" > = {}, ): Promise<InferWebhookVerificationResult<TWebhook, AllowUnknownEvents>> { validateWebhook(webhook); validateEvent(webhook, event); const schema = webhook.events[event.type]; if (!schema) { if (options.allowUnknownEvents === true) { return event as InferWebhookVerificationResult< TWebhook, AllowUnknownEvents >; } throw new WebhookVerificationError({ message: `Webhook "${webhook.name}" received unknown event type "${event.type}".`, webhookName: webhook.name, provider: webhook.provider, code: "unknown_event_type", }); } const result = await schema["~standard"].validate(event.payload); if (result.issues?.length) { throw new WebhookValidationError({ webhookName: webhook.name, eventType: event.type, issues: result.issues, }); } if (!("value" in result)) { throw new Error("Invalid Standard Schema result: missing value"); } return { ...event, payload: result.value, } as InferWebhookVerificationResult<TWebhook, AllowUnknownEvents>; } /** * Create an in-memory verifier for tests. */ export function createMemoryWebhookVerifier( options: CreateMemoryWebhookVerifierOptions = {}, ): MemoryWebhookVerifier { const queuedEvents = [...(options.events ?? [])]; const verifiedEvents: WebhookEvent[] = []; return { get verifiedEvents() { return verifiedEvents; }, async verify() { const event = queuedEvents.shift(); if (!event) { throw new WebhookVerificationError({ message: "No memory webhook event is queued.", code: "missing_memory_event", }); } verifiedEvents.push(event); return event; }, queue(event) { queuedEvents.push(event); }, reset() { queuedEvents.length = 0; verifiedEvents.length = 0; }, }; } /** * Create a generic JSON + HMAC verifier. */ export function createHmacWebhookVerifier( options: CreateHmacWebhookVerifierOptions, ): WebhookVerifier { if (!options.secret) { throw new WebhookOptionsError("Webhook HMAC secret is required."); } const signatureHeader = normalizeHeaderName( options.signatureHeader ?? "x-webhook-signature", ); const algorithm = options.algorithm ?? "SHA-256"; const eventIdPath = options.eventIdPath ?? "id"; const eventTypePath = options.eventTypePath ?? "type"; const timestamp = normalizeHmacTimestampOptions(options.timestamp); return { async verify(input) { const signature = input.signature ?? input.headers?.[signatureHeader]; if (!signature) { throw new WebhookVerificationError({ message: `Missing ${signatureHeader} header.`, provider: options.provider, code: "missing_signature", }); } const signedBody = hmacSignedBody({ input, provider: options.provider, timestamp, }); const expected = await hmacHex(algorithm, options.secret, signedBody); const actual = normalizeSignature( signature, options.signaturePrefix, ).toLowerCase(); if (!(await timingSafeStringEqual(actual, expected))) { throw new WebhookVerificationError({ message: "Webhook signature is invalid.", provider: options.provider, code: "invalid_signature", }); } const payload = parseJsonBody(input.rawBody); const createdAt = validateHmacTimestamp({ input, payload, provider: options.provider, timestamp, }); const id = readStringPath(payload, eventIdPath); const type = readStringPath(payload, eventTypePath); if (!id) { throw new WebhookVerificationError({ message: `Webhook payload is missing string event ID at "${eventIdPath}".`, provider: options.provider, code: "missing_event_id", }); } if (!type) { throw new WebhookVerificationError({ message: `Webhook payload is missing string event type at "${eventTypePath}".`, provider: options.provider, code: "missing_event_type", }); } return { id, type, provider: options.provider, ...(createdAt ? { createdAt } : {}), payload, raw: payload, }; }, }; } function validateWebhook(webhook: WebhookDef) { if (!webhook.name) throw new WebhookOptionsError("Webhook name is required."); } function validateEvent(webhook: WebhookDef, event: WebhookEvent) { if (!event.id) { throw new WebhookVerificationError({ message: `Webhook "${webhook.name}" event is missing an ID.`, webhookName: webhook.name, provider: webhook.provider, code: "missing_event_id", }); } if (!event.type) { throw new WebhookVerificationError({ message: `Webhook "${webhook.name}" event is missing a type.`, webhookName: webhook.name, provider: webhook.provider, code: "missing_event_type", }); } } function formatPath(path: StandardSchemaV1.Issue["path"]): string { if (!path?.length) return ""; return path .map((segment) => typeof segment === "object" && segment !== null && "key" in segment ? String(segment.key) : String(segment), ) .join("."); } function formatIssues(issues: readonly StandardSchemaV1.Issue[]): string { return issues .map((issue) => { const path = formatPath(issue.path); return path ? `${path}: ${issue.message}` : issue.message; }) .join("; "); } function normalizeHeaderName(name: string): string { return name.toLowerCase(); } function normalizeSignature(signature: string, prefix: string | undefined) { const trimmed = signature.trim(); if (!prefix) return trimmed; return trimmed.startsWith(prefix) ? trimmed.slice(prefix.length) : trimmed; } function rawBodyBytes(rawBody: WebhookRawBody): Uint8Array { if (typeof rawBody === "string") return new TextEncoder().encode(rawBody); if (rawBody instanceof Uint8Array) return rawBody; return new Uint8Array(rawBody); } function rawBodyText(rawBody: WebhookRawBody): string { if (typeof rawBody === "string") return rawBody; return new TextDecoder().decode(rawBodyBytes(rawBody)); } function hmacSignedBody(args: { input: VerifyWebhookInput; provider: string | undefined; timestamp: NormalizedHmacTimestampOptions | undefined; }): WebhookRawBody { if (!args.timestamp || args.timestamp.source === "payload") { return args.input.rawBody; } const value = args.input.headers?.[args.timestamp.key]; if (value === undefined || value === null || value === "") { throw new WebhookVerificationError({ message: `Missing ${args.timestamp.key} header.`, provider: args.provider, code: "missing_timestamp", }); } const prefix = new TextEncoder().encode(`${value}.`); const body = rawBodyBytes(args.input.rawBody); const signed = new Uint8Array(prefix.byteLength + body.byteLength); signed.set(prefix); signed.set(body, prefix.byteLength); return signed; } function parseJsonBody(rawBody: WebhookRawBody): unknown { try { return JSON.parse(rawBodyText(rawBody)); } catch (error) { throw new WebhookVerificationError({ message: "Webhook payload must be valid JSON.", code: "invalid_json", cause: error, }); } } function readPath(input: unknown, path: string): unknown { let value = input; for (const segment of path.split(".")) { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } value = (value as Record<string, unknown>)[segment]; } return value; } function readStringPath(input: unknown, path: string): string | undefined { const value = readPath(input, path); return typeof value === "string" && value.length > 0 ? value : undefined; } type NormalizedHmacTimestampOptions = | { source: "header"; key: string; format: HmacWebhookTimestampFormat; toleranceSec: number; } | { source: "payload"; path: string; format: HmacWebhookTimestampFormat; toleranceSec: number; }; function normalizeHmacTimestampOptions( timestamp: HmacWebhookTimestampOptions | undefined, ): NormalizedHmacTimestampOptions | undefined { if (!timestamp) return undefined; const format = timestamp.format ?? "unix-seconds"; if ( format !== "unix-seconds" && format !== "unix-milliseconds" && format !== "iso8601" ) { throw new WebhookOptionsError( "Webhook HMAC timestamp format must be unix-seconds, unix-milliseconds, or iso8601.", ); } const toleranceSec = timestamp.toleranceSec ?? 300; if (!Number.isInteger(toleranceSec) || toleranceSec <= 0) { throw new WebhookOptionsError( "Webhook HMAC timestamp toleranceSec must be a positive integer.", ); } if ("header" in timestamp) { if (!timestamp.header) { throw new WebhookOptionsError( "Webhook HMAC timestamp header is required.", ); } return { source: "header", key: normalizeHeaderName(timestamp.header), format, toleranceSec, }; } if (!timestamp.payloadPath) { throw new WebhookOptionsError( "Webhook HMAC timestamp payloadPath is required.", ); } return { source: "payload", path: timestamp.payloadPath, format, toleranceSec, }; } function validateHmacTimestamp(args: { input: VerifyWebhookInput; payload: unknown; provider: string | undefined; timestamp: NormalizedHmacTimestampOptions | undefined; }): Date | undefined { const { timestamp } = args; if (!timestamp) return undefined; const value = timestamp.source === "header" ? args.input.headers?.[timestamp.key] : readPath(args.payload, timestamp.path); if (value === undefined || value === null || value === "") { throw new WebhookVerificationError({ message: timestamp.source === "header" ? `Missing ${timestamp.key} header.` : `Webhook payload is missing timestamp at "${timestamp.path}".`, provider: args.provider, code: "missing_timestamp", }); } const createdAt = parseHmacTimestampValue( value, timestamp.format, args.provider, ); const receivedAt = args.input.receivedAt ?? new Date(); const skewMs = Math.abs(receivedAt.getTime() - createdAt.getTime()); if (skewMs > timestamp.toleranceSec * 1000) { throw new WebhookVerificationError({ message: `Webhook timestamp is outside the ${timestamp.toleranceSec}s tolerance.`, provider: args.provider, code: "timestamp_outside_tolerance", }); } return createdAt; } function parseHmacTimestampValue( value: unknown, format: HmacWebhookTimestampFormat, provider: string | undefined, ): Date { const fail = () => new WebhookVerificationError({ message: "Webhook timestamp is invalid.", provider, code: "invalid_timestamp", }); let millis: number; if (format === "iso8601") { if (typeof value !== "string") throw fail(); millis = Date.parse(value); } else { const numeric = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN; if (!Number.isFinite(numeric)) throw fail(); millis = format === "unix-seconds" ? numeric * 1000 : numeric; } if (!Number.isFinite(millis)) throw fail(); const date = new Date(millis); if (Number.isNaN(date.getTime())) throw fail(); return date; } async function hmacHex( algorithm: CreateHmacWebhookVerifierOptions["algorithm"], secret: string, rawBody: WebhookRawBody, ): Promise<string> { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: algorithm ?? "SHA-256" }, false, ["sign"], ); const bytes = rawBodyBytes(rawBody); const data = bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength, ) as ArrayBuffer; const signature = await crypto.subtle.sign("HMAC", key, data); return bytesToHex(new Uint8Array(signature)); } function bytesToHex(bytes: Uint8Array): string { return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } async function timingSafeStringEqual(a: string, b: string): Promise<boolean> { const encoder = new TextEncoder(); const [digestA, digestB] = await Promise.all([ crypto.subtle.digest("SHA-256", encoder.encode(a)), crypto.subtle.digest("SHA-256", encoder.encode(b)), ]); const bytesA = new Uint8Array(digestA); const bytesB = new Uint8Array(digestB); let mismatch = 0; for (let index = 0; index < bytesA.length; index++) { mismatch |= (bytesA[index] ?? 0) ^ (bytesB[index] ?? 0); } return mismatch === 0; }