UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

442 lines 15 kB
/** * @beignet/core/webhooks * * Provider-neutral inbound webhook primitives for Beignet applications. */ /** * Error thrown for invalid webhook definitions and inputs. */ export class WebhookOptionsError extends Error { constructor(message) { super(message); this.name = "WebhookOptionsError"; } } /** * Error thrown when verification fails. */ export class WebhookVerificationError extends Error { webhookName; provider; code; cause; constructor(args) { 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 { webhookName; eventType; issues; constructor(args) { 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, options = {}) { if (!name) throw new WebhookOptionsError("Webhook name is required."); return { kind: "webhook", name, provider: options.provider, events: (options.events ?? {}), verifier: options.verifier, metadata: options.metadata, }; } /** * Verify a raw webhook request and validate the matching event payload schema. */ export async function verifyWebhook(webhook, input, options = {}) { validateWebhook(webhook); const verifier = options.verifier ?? webhook.verifier; if (!verifier) { throw new WebhookOptionsError(`Webhook "${webhook.name}" does not have a verifier.`); } let event; 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(webhook, event, options = {}) { validateWebhook(webhook); validateEvent(webhook, event); const schema = webhook.events[event.type]; if (!schema) { if (options.allowUnknownEvents === true) { return event; } 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, }; } /** * Create an in-memory verifier for tests. */ export function createMemoryWebhookVerifier(options = {}) { const queuedEvents = [...(options.events ?? [])]; const verifiedEvents = []; 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) { 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) { if (!webhook.name) throw new WebhookOptionsError("Webhook name is required."); } function validateEvent(webhook, event) { 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) { 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) { return issues .map((issue) => { const path = formatPath(issue.path); return path ? `${path}: ${issue.message}` : issue.message; }) .join("; "); } function normalizeHeaderName(name) { return name.toLowerCase(); } function normalizeSignature(signature, prefix) { const trimmed = signature.trim(); if (!prefix) return trimmed; return trimmed.startsWith(prefix) ? trimmed.slice(prefix.length) : trimmed; } function rawBodyBytes(rawBody) { if (typeof rawBody === "string") return new TextEncoder().encode(rawBody); if (rawBody instanceof Uint8Array) return rawBody; return new Uint8Array(rawBody); } function rawBodyText(rawBody) { if (typeof rawBody === "string") return rawBody; return new TextDecoder().decode(rawBodyBytes(rawBody)); } function hmacSignedBody(args) { 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) { 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, path) { let value = input; for (const segment of path.split(".")) { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } value = value[segment]; } return value; } function readStringPath(input, path) { const value = readPath(input, path); return typeof value === "string" && value.length > 0 ? value : undefined; } function normalizeHmacTimestampOptions(timestamp) { 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) { 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, format, provider) { const fail = () => new WebhookVerificationError({ message: "Webhook timestamp is invalid.", provider, code: "invalid_timestamp", }); let millis; 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, secret, rawBody) { 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); const signature = await crypto.subtle.sign("HMAC", key, data); return bytesToHex(new Uint8Array(signature)); } function bytesToHex(bytes) { return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } async function timingSafeStringEqual(a, b) { 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; } //# sourceMappingURL=index.js.map