@beignet/core
Version:
Core framework primitives for Beignet
442 lines • 15 kB
JavaScript
/**
* @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