Files
CapaKraken/packages/api/src/router/webhook-support.ts
T
Hartmut 17471af7f8
CI / Architecture Guardrails (push) Successful in 3m38s
CI / Assistant Split Regression (push) Successful in 4m40s
CI / Lint (push) Successful in 5m17s
CI / Typecheck (push) Successful in 5m46s
CI / Build (push) Successful in 7m1s
CI / Unit Tests (push) Failing after 9m41s
CI / Release Images (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / E2E Tests (push) Has started running
security: bound Zod inputs, add SSE per-user cap and tRPC body limit (#51, PR #59)
Closes #51 (ESLint rule + conventions doc remain as follow-up).

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-04-18 13:53:28 +02:00

133 lines
3.8 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { WEBHOOK_EVENTS } from "../lib/webhook-dispatcher.js";
export const webhookEventEnum = z.enum(WEBHOOK_EVENTS as unknown as [string, ...string[]]);
export const createWebhookInputSchema = z.object({
name: z.string().min(1).max(200),
url: z.string().url().max(2048),
secret: z.string().min(16).max(256).optional(),
events: z.array(webhookEventEnum).min(1).max(100),
isActive: z.boolean().default(true),
});
export const updateWebhookInputSchema = z.object({
name: z.string().min(1).max(200).optional(),
url: z.string().url().max(2048).optional(),
secret: z.string().min(16).max(256).nullish(),
events: z.array(webhookEventEnum).min(1).max(100).optional(),
isActive: z.boolean().optional(),
});
type WebhookRecord = {
id: string;
name: string;
url: string;
secret?: string | null;
events?: string[];
isActive?: boolean;
};
type WebhookDb = {
webhook: {
findUnique: (args: { where: { id: string } }) => Promise<WebhookRecord | null>;
};
};
export function buildWebhookCreateData(input: z.infer<typeof createWebhookInputSchema>) {
return {
name: input.name,
url: input.url,
...(input.secret !== undefined ? { secret: input.secret } : {}),
events: input.events,
isActive: input.isActive,
};
}
export function buildWebhookUpdateData(input: z.infer<typeof updateWebhookInputSchema>) {
return {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.url !== undefined ? { url: input.url } : {}),
...(input.secret !== undefined ? { secret: input.secret } : {}),
...(input.events !== undefined ? { events: input.events } : {}),
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
};
}
export async function loadWebhookOrThrow(db: WebhookDb, id: string) {
const webhook = await db.webhook.findUnique({ where: { id } });
if (!webhook) {
throw new TRPCError({ code: "NOT_FOUND", message: "Webhook not found" });
}
return webhook;
}
export async function buildWebhookTestRequest(
webhook: Pick<WebhookRecord, "id" | "name" | "secret">,
timestamp = new Date().toISOString(),
) {
const payload = {
event: "webhook.test",
timestamp,
payload: {
webhookId: webhook.id,
webhookName: webhook.name,
message: "This is a test payload from CapaKraken.",
},
};
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Webhook-Event": "webhook.test",
};
if (webhook.secret) {
const { createHmac } = await import("node:crypto");
headers["X-Webhook-Signature"] = createHmac("sha256", webhook.secret)
.update(body)
.digest("hex");
}
return { body, headers };
}
export async function sendWebhookTestRequest(
webhook: Pick<WebhookRecord, "id" | "name" | "url" | "secret">,
options: {
fetchImpl?: typeof fetch;
timeoutMs?: number;
timestamp?: string;
} = {},
): Promise<{ success: boolean; statusCode: number; statusText: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
const timeoutMs = options.timeoutMs ?? 5_000;
const { body, headers } = await buildWebhookTestRequest(webhook, options.timestamp);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(webhook.url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
return {
success: response.ok,
statusCode: response.status,
statusText: response.statusText,
};
} catch (error) {
return {
success: false,
statusCode: 0,
statusText: error instanceof Error ? error.message : "Unknown error",
};
} finally {
clearTimeout(timeout);
}
}