Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/api/projects/[id]/webhooks/[endpointId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
43 changes: 42 additions & 1 deletion app/dashboard/[[...tab]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,7 @@ function ProjectWebhooks({ project, onError }: { project: Project; onError: (m:
const [url, setUrl] = useState("");
const [provider, setProvider] = useState("");
const [secret, setSecret] = useState<string | null>(null);
const [busyEndpoint, setBusyEndpoint] = useState<string | null>(null);

const formatEvents = (raw: unknown) => {
try {
Expand Down Expand Up @@ -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 (
<section className="card2">
<h2>Webhooks — {project.name}</h2>
Expand All @@ -1222,7 +1241,29 @@ function ProjectWebhooks({ project, onError }: { project: Project; onError: (m:
<button className="btn2" disabled={!url.trim()} onClick={() => { post(`/api/projects/${project.id}/webhooks`, { url: url.trim() }, "Outbound"); setUrl(""); }}>Add outbound</button>
<button className="btn2 ghost" onClick={() => post(`/api/projects/${project.id}/webhooks/test`, {}, "").then(() => onError("Test event dispatched."))}>Send test</button>
</div>
<ul className="list">{out.map((e) => <li key={e.id}><span>↗ {e.url}</span><span className="muted">{formatEvents(e.events)}</span></li>)}
<ul className="list">{out.map((e) => <li key={e.id}>
<span>↗ {e.url} <span className="muted">· {e.active ? "active" : "paused"} · {formatEvents(e.events)}</span></span>
<span className="row-actions">
<button
className="btn2 ghost"
disabled={busyEndpoint === e.id}
onClick={() => mutateEndpoint(e.id, "PATCH", { active: !e.active })}
>
{e.active ? "Pause" : "Resume"}
</button>
<button
className="btn2 ghost"
disabled={busyEndpoint === e.id}
onClick={() => {
if (window.confirm(`Delete webhook endpoint ${e.url}?`)) {
void mutateEndpoint(e.id, "DELETE");
}
}}
>
Delete
</button>
</span>
</li>)}
{out.length === 0 && <li className="muted">No outbound endpoints yet.</li>}</ul>
<div className="row" style={{ marginTop: 14 }}>
<input className="inp" placeholder="inbound provider (e.g. github)" value={provider} onChange={(e) => setProvider(e.target.value)} />
Expand Down
32 changes: 32 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,38 @@ export async function renameProject(id: string, name: string): Promise<void> {
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<boolean> {
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<boolean> {
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<void> {
await ensureSchema();
Expand Down
102 changes: 102 additions & 0 deletions tests/project-webhook-management.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading