@beignet/core
Version:
Core framework primitives for Beignet
822 lines (742 loc) • 21.9 kB
text/typescript
/**
* @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;
}