import { env } from "cloudflare:workers"; import { z } from "zod"; import { all, first, run } from "../db"; import { randomId } from "../crypto"; import { notFoundError } from "../errors"; import { isForgeWebhookUrl } from "../events/consumer"; import { assertWebhookTarget, deliverWebhook, MAX_WEBHOOK_SECRET, MIN_WEBHOOK_SECRET, replayableBody, sealWebhookSecret, } from "../webhooks/legacy"; import type { WebhookEvent } from "../events/emit"; import { assertBelowCap, CAPS, limitWrite } from "../limits"; import { noContent, readJson, requireScopedRepo } from "./http"; import type { ApiContext } from "./http"; import { WEBHOOK_EVENTS } from "~/lib/webhook-events"; const DELIVERY_LIMIT = 100; type WebhookRow = { id: string; url: string; secret: string | null; events: string; active: number; created_at: number; }; function hookJson(hook: WebhookRow) { return { id: hook.id, url: hook.url, has_secret: Boolean(hook.secret), events: JSON.parse(hook.events) as string[], active: hook.active === 1, created_at: hook.created_at, }; } /** Repo webhooks need an admin-area grant, matching the settings page's requireRepoAccess(..., "admin"). */ async function adminRepo(ctx: ApiContext, owner: string, name: string) { return (await requireScopedRepo(ctx, owner, name, "admin", "admin")).repo; } async function loadHook(repoId: string, id: string): Promise { const hook = await first("SELECT * FROM webhooks WHERE id = ? AND repo_id = ?", id, repoId); if (!hook) throw notFoundError("Webhook not found"); return hook; } export async function listWebhooksApi(ctx: ApiContext, owner: string, name: string): Promise { const repo = await adminRepo(ctx, owner, name); const hooks = await all("SELECT * FROM webhooks WHERE repo_id = ? ORDER BY created_at", repo.id); return Response.json(hooks.map(hookJson)); } const webhookEvent = z.custom( (value) => value === "*" || WEBHOOK_EVENTS.some((event) => event === value), "Unknown webhook event", ); const hookInput = z.object({ url: z .url() .refine((value) => /^https?:\/\//.test(value), "Webhook URLs must be http(s)") .refine((value) => !isForgeWebhookUrl(value), "Webhook URLs may not point at this forge"), secret: z.string().min(MIN_WEBHOOK_SECRET).max(MAX_WEBHOOK_SECRET).nullable().default(null), events: z.array(webhookEvent).min(1).default(["*"]), active: z.boolean().default(true), }); /** Same insert and initial ping as the settings page's saveWebhook server function. */ export async function createWebhookApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, hookInput); const repo = await adminRepo(ctx, owner, name); await limitWrite(ctx.viewer); await assertWebhookTarget(data.url); assertBelowCap( (await first<{ count: number }>("SELECT COUNT(*) AS count FROM webhooks WHERE repo_id = ?", repo.id))?.count ?? 0, CAPS.webhooksPerRepo, "webhooks", ); const id = randomId(); await run( "INSERT INTO webhooks (id, repo_id, url, secret, events, active, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", id, repo.id, data.url, await sealWebhookSecret(data.secret), JSON.stringify(data.events), data.active ? 1 : 0, Date.now(), ); await env.EVENTS.send({ kind: "webhook.deliver", webhookId: id, event: "ping", deliveryId: randomId(), payload: { zen: "Keep it logically awesome.", hook_id: id }, }); return Response.json(hookJson(await loadHook(repo.id, id)), { status: 201 }); } export async function deleteWebhookApi(ctx: ApiContext, owner: string, name: string, id: string): Promise { const repo = await adminRepo(ctx, owner, name); await limitWrite(ctx.viewer); await loadHook(repo.id, id); await run("DELETE FROM webhooks WHERE id = ? AND repo_id = ?", id, repo.id); return noContent(); } export async function listWebhookDeliveriesApi( ctx: ApiContext, owner: string, name: string, id: string, ): Promise { const repo = await adminRepo(ctx, owner, name); await loadHook(repo.id, id); const rows = await all<{ id: string; event: string; status_code: number | null; error: string | null; duration_ms: number | null; attempt: number; created_at: number; }>( `SELECT id, event, status_code, error, duration_ms, attempt, created_at FROM webhook_deliveries WHERE webhook_id = ? ORDER BY created_at DESC LIMIT ?`, id, DELIVERY_LIMIT, ); return Response.json(rows); } /** Same lookup and resend as the settings page's redeliverWebhook server function. */ export async function redeliverWebhookApi( ctx: ApiContext, owner: string, name: string, id: string, deliveryId: string, ): Promise { const repo = await adminRepo(ctx, owner, name); await limitWrite(ctx.viewer); const delivery = await first<{ webhook_id: string; event: WebhookEvent; request_body: string; url: string; secret: string | null; }>( `SELECT d.webhook_id, d.event, d.request_body, w.url, w.secret FROM webhook_deliveries d JOIN webhooks w ON w.id = d.webhook_id WHERE d.id = ? AND w.id = ? AND w.repo_id = ?`, deliveryId, id, repo.id, ); if (!delivery) throw notFoundError("Delivery not found"); const ok = await deliverWebhook( { id: delivery.webhook_id, url: delivery.url, secret: delivery.secret }, delivery.event, randomId(), replayableBody(delivery.request_body), 1, ); return Response.json({ ok }); }