From 77365074367ef79af5d039407127348e7451876e Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Sat, 1 Aug 2026 23:04:28 +0700 Subject: [PATCH] feat(webhooks): manage project endpoints --- .../[id]/webhooks/[endpointId]/route.ts | 37 +++++++ app/dashboard/[[...tab]]/page.tsx | 43 +++++++- lib/db.ts | 32 ++++++ tests/project-webhook-management.test.mjs | 102 ++++++++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 app/api/projects/[id]/webhooks/[endpointId]/route.ts create mode 100644 tests/project-webhook-management.test.mjs diff --git a/app/api/projects/[id]/webhooks/[endpointId]/route.ts b/app/api/projects/[id]/webhooks/[endpointId]/route.ts new file mode 100644 index 0000000..db53991 --- /dev/null +++ b/app/api/projects/[id]/webhooks/[endpointId]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireUser, unauthorized, bad } from "@/lib/api"; +import { authorizeProject } from "@/lib/authz"; +import { deleteProjectWebhook, setProjectWebhookActive } from "@/lib/db"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string; endpointId: string }> }; + +export async function PATCH(req: NextRequest, ctx: Params) { + const u = await requireUser(req); + if (!u) return unauthorized(); + const { id: projectId, endpointId } = await ctx.params; + const az = await authorizeProject(u.sub, projectId, "webhook.manage"); + if (!az.ok) return bad(az.error, az.status); + + const body = await req.json().catch(() => null); + if (!body || typeof body.active !== "boolean") { + return bad("active must be a boolean"); + } + const ok = await setProjectWebhookActive(endpointId, projectId, body.active); + if (!ok) return bad("Webhook endpoint not found", 404); + return NextResponse.json({ ok: true, id: endpointId, active: body.active }); +} + +export async function DELETE(req: NextRequest, ctx: Params) { + const u = await requireUser(req); + if (!u) return unauthorized(); + const { id: projectId, endpointId } = await ctx.params; + const az = await authorizeProject(u.sub, projectId, "webhook.manage"); + if (!az.ok) return bad(az.error, az.status); + + const ok = await deleteProjectWebhook(endpointId, projectId); + if (!ok) return bad("Webhook endpoint not found", 404); + return NextResponse.json({ ok: true, id: endpointId }); +} diff --git a/app/dashboard/[[...tab]]/page.tsx b/app/dashboard/[[...tab]]/page.tsx index c7954da..4861b3b 100644 --- a/app/dashboard/[[...tab]]/page.tsx +++ b/app/dashboard/[[...tab]]/page.tsx @@ -1183,6 +1183,7 @@ function ProjectWebhooks({ project, onError }: { project: Project; onError: (m: const [url, setUrl] = useState(""); const [provider, setProvider] = useState(""); const [secret, setSecret] = useState(null); + const [busyEndpoint, setBusyEndpoint] = useState(null); const formatEvents = (raw: unknown) => { try { @@ -1214,6 +1215,24 @@ function ProjectWebhooks({ project, onError }: { project: Project; onError: (m: } catch (e: any) { onError(e.message); } }; + const mutateEndpoint = async (endpointId: string, method: "PATCH" | "DELETE", body?: { active: boolean }) => { + setBusyEndpoint(endpointId); + try { + const res = await fetch(`/api/projects/${project.id}/webhooks/${endpointId}`, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Could not update webhook endpoint."); + await load(); + } catch (e: any) { + onError(e.message || "Could not update webhook endpoint."); + } finally { + setBusyEndpoint(null); + } + }; + return (

Webhooks — {project.name}

@@ -1222,7 +1241,29 @@ function ProjectWebhooks({ project, onError }: { project: Project; onError: (m: -
    {out.map((e) =>
  • ↗ {e.url}{formatEvents(e.events)}
  • )} +
      {out.map((e) =>
    • + ↗ {e.url} · {e.active ? "active" : "paused"} · {formatEvents(e.events)} + + + + +
    • )} {out.length === 0 &&
    • No outbound endpoints yet.
    • }
    setProvider(e.target.value)} /> diff --git a/lib/db.ts b/lib/db.ts index 4f3b4fc..ba9a2d4 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -680,6 +680,38 @@ export async function renameProject(id: string, name: string): Promise { await db().execute({ sql: `UPDATE projects SET name = ? WHERE id = ?`, args: [name, id] }); } +/** Pause or resume one project webhook without discarding its signing secret. */ +export async function setProjectWebhookActive( + id: string, + projectId: string, + active: boolean, +): Promise { + await ensureSchema(); + const r = await db().execute({ + sql: `UPDATE webhook_endpoints SET active = ? WHERE id = ? AND project_id = ?`, + args: [active ? 1 : 0, id, projectId], + }); + return Number(r.rowsAffected || 0) > 0; +} + +/** Delete one project webhook and its delivery history. */ +export async function deleteProjectWebhook(id: string, projectId: string): Promise { + await ensureSchema(); + const d = db(); + const owned = await d.execute({ + sql: `SELECT 1 FROM webhook_endpoints WHERE id = ? AND project_id = ? LIMIT 1`, + args: [id, projectId], + }); + if (!owned.rows.length) return false; + + await d.execute({ sql: `DELETE FROM webhook_deliveries WHERE endpoint_id = ?`, args: [id] }); + const r = await d.execute({ + sql: `DELETE FROM webhook_endpoints WHERE id = ? AND project_id = ?`, + args: [id, projectId], + }); + return Number(r.rowsAffected || 0) > 0; +} + /** Deletes a project and its webhook config/history (SQLite has no cascade). */ export async function deleteProject(id: string): Promise { await ensureSchema(); diff --git a/tests/project-webhook-management.test.mjs b/tests/project-webhook-management.test.mjs new file mode 100644 index 0000000..7148d57 --- /dev/null +++ b/tests/project-webhook-management.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +process.env.TURSO_DATABASE_URL = "file::memory:"; + +const { + db, + deleteProjectWebhook, + ensureSchema, + setProjectWebhookActive, +} = await import("../lib/db.ts"); +const { dispatchEvent } = await import("../lib/webhooks.ts"); + +await ensureSchema(); + +async function insertEndpoint(projectId, suffix) { + const id = `endpoint-${suffix}`; + await db().execute({ + sql: `INSERT INTO webhook_endpoints (id, project_id, url, secret, events) + VALUES (?, ?, ?, ?, '["*"]')`, + args: [id, projectId, `https://hooks.example.test/${suffix}`, `whsec_${suffix}`], + }); + return id; +} + +test("project webhook targets pause and resume without losing configuration", async () => { + const id = await insertEndpoint("project-pause", "pause"); + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (...args) => { + calls.push(args); + return new Response(null, { status: 204 }); + }; + + try { + assert.equal(await setProjectWebhookActive(id, "project-pause", false), true); + await dispatchEvent("project-pause", "build.finished", { ok: true }); + assert.equal(calls.length, 0); + + const paused = await db().execute({ + sql: `SELECT url, secret, events, active FROM webhook_endpoints WHERE id = ?`, + args: [id], + }); + assert.deepEqual( + { + url: String(paused.rows[0].url), + secret: String(paused.rows[0].secret), + events: String(paused.rows[0].events), + active: Number(paused.rows[0].active), + }, + { + url: "https://hooks.example.test/pause", + secret: "whsec_pause", + events: '["*"]', + active: 0, + }, + ); + + assert.equal(await setProjectWebhookActive(id, "project-pause", true), true); + await dispatchEvent("project-pause", "build.finished", { ok: true }); + assert.equal(calls.length, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("project scoping prevents cross-project changes", async () => { + const id = await insertEndpoint("project-owner", "scoped"); + + assert.equal(await setProjectWebhookActive(id, "project-other", false), false); + assert.equal(await deleteProjectWebhook(id, "project-other"), false); + + const endpoint = await db().execute({ + sql: `SELECT active FROM webhook_endpoints WHERE id = ?`, + args: [id], + }); + assert.equal(Number(endpoint.rows[0].active), 1); +}); + +test("deleting a project webhook also deletes its delivery history", async () => { + const id = await insertEndpoint("project-delete", "delete"); + await db().execute({ + sql: `INSERT INTO webhook_deliveries + (id, endpoint_id, event_type, payload, idempotency_key, status) + VALUES (?, ?, ?, ?, ?, 'failed')`, + args: ["delivery-delete", id, "build.failed", "{}", "event-delete"], + }); + + assert.equal(await deleteProjectWebhook(id, "project-delete"), true); + assert.equal(await deleteProjectWebhook(id, "project-delete"), false); + + const endpoints = await db().execute({ + sql: `SELECT 1 FROM webhook_endpoints WHERE id = ?`, + args: [id], + }); + const deliveries = await db().execute({ + sql: `SELECT 1 FROM webhook_deliveries WHERE endpoint_id = ?`, + args: [id], + }); + assert.equal(endpoints.rows.length, 0); + assert.equal(deliveries.rows.length, 0); +});