Skip to content

Commit 0241af0

Browse files
feat(webhooks): manage project endpoints (#93)
1 parent c0689e7 commit 0241af0

4 files changed

Lines changed: 213 additions & 1 deletion

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { requireUser, unauthorized, bad } from "@/lib/api";
3+
import { authorizeProject } from "@/lib/authz";
4+
import { deleteProjectWebhook, setProjectWebhookActive } from "@/lib/db";
5+
6+
export const runtime = "nodejs";
7+
export const dynamic = "force-dynamic";
8+
9+
type Params = { params: Promise<{ id: string; endpointId: string }> };
10+
11+
export async function PATCH(req: NextRequest, ctx: Params) {
12+
const u = await requireUser(req);
13+
if (!u) return unauthorized();
14+
const { id: projectId, endpointId } = await ctx.params;
15+
const az = await authorizeProject(u.sub, projectId, "webhook.manage");
16+
if (!az.ok) return bad(az.error, az.status);
17+
18+
const body = await req.json().catch(() => null);
19+
if (!body || typeof body.active !== "boolean") {
20+
return bad("active must be a boolean");
21+
}
22+
const ok = await setProjectWebhookActive(endpointId, projectId, body.active);
23+
if (!ok) return bad("Webhook endpoint not found", 404);
24+
return NextResponse.json({ ok: true, id: endpointId, active: body.active });
25+
}
26+
27+
export async function DELETE(req: NextRequest, ctx: Params) {
28+
const u = await requireUser(req);
29+
if (!u) return unauthorized();
30+
const { id: projectId, endpointId } = await ctx.params;
31+
const az = await authorizeProject(u.sub, projectId, "webhook.manage");
32+
if (!az.ok) return bad(az.error, az.status);
33+
34+
const ok = await deleteProjectWebhook(endpointId, projectId);
35+
if (!ok) return bad("Webhook endpoint not found", 404);
36+
return NextResponse.json({ ok: true, id: endpointId });
37+
}

app/dashboard/[[...tab]]/page.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,7 @@ function ProjectWebhooks({ project, onError }: { project: Project; onError: (m:
11831183
const [url, setUrl] = useState("");
11841184
const [provider, setProvider] = useState("");
11851185
const [secret, setSecret] = useState<string | null>(null);
1186+
const [busyEndpoint, setBusyEndpoint] = useState<string | null>(null);
11861187

11871188
const formatEvents = (raw: unknown) => {
11881189
try {
@@ -1214,6 +1215,24 @@ function ProjectWebhooks({ project, onError }: { project: Project; onError: (m:
12141215
} catch (e: any) { onError(e.message); }
12151216
};
12161217

1218+
const mutateEndpoint = async (endpointId: string, method: "PATCH" | "DELETE", body?: { active: boolean }) => {
1219+
setBusyEndpoint(endpointId);
1220+
try {
1221+
const res = await fetch(`/api/projects/${project.id}/webhooks/${endpointId}`, {
1222+
method,
1223+
headers: body ? { "content-type": "application/json" } : undefined,
1224+
body: body ? JSON.stringify(body) : undefined,
1225+
});
1226+
const data = await res.json();
1227+
if (!res.ok) throw new Error(data.error || "Could not update webhook endpoint.");
1228+
await load();
1229+
} catch (e: any) {
1230+
onError(e.message || "Could not update webhook endpoint.");
1231+
} finally {
1232+
setBusyEndpoint(null);
1233+
}
1234+
};
1235+
12171236
return (
12181237
<section className="card2">
12191238
<h2>Webhooks — {project.name}</h2>
@@ -1222,7 +1241,29 @@ function ProjectWebhooks({ project, onError }: { project: Project; onError: (m:
12221241
<button className="btn2" disabled={!url.trim()} onClick={() => { post(`/api/projects/${project.id}/webhooks`, { url: url.trim() }, "Outbound"); setUrl(""); }}>Add outbound</button>
12231242
<button className="btn2 ghost" onClick={() => post(`/api/projects/${project.id}/webhooks/test`, {}, "").then(() => onError("Test event dispatched."))}>Send test</button>
12241243
</div>
1225-
<ul className="list">{out.map((e) => <li key={e.id}><span>{e.url}</span><span className="muted">{formatEvents(e.events)}</span></li>)}
1244+
<ul className="list">{out.map((e) => <li key={e.id}>
1245+
<span>{e.url} <span className="muted">· {e.active ? "active" : "paused"} · {formatEvents(e.events)}</span></span>
1246+
<span className="row-actions">
1247+
<button
1248+
className="btn2 ghost"
1249+
disabled={busyEndpoint === e.id}
1250+
onClick={() => mutateEndpoint(e.id, "PATCH", { active: !e.active })}
1251+
>
1252+
{e.active ? "Pause" : "Resume"}
1253+
</button>
1254+
<button
1255+
className="btn2 ghost"
1256+
disabled={busyEndpoint === e.id}
1257+
onClick={() => {
1258+
if (window.confirm(`Delete webhook endpoint ${e.url}?`)) {
1259+
void mutateEndpoint(e.id, "DELETE");
1260+
}
1261+
}}
1262+
>
1263+
Delete
1264+
</button>
1265+
</span>
1266+
</li>)}
12261267
{out.length === 0 && <li className="muted">No outbound endpoints yet.</li>}</ul>
12271268
<div className="row" style={{ marginTop: 14 }}>
12281269
<input className="inp" placeholder="inbound provider (e.g. github)" value={provider} onChange={(e) => setProvider(e.target.value)} />

lib/db.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,38 @@ export async function renameProject(id: string, name: string): Promise<void> {
680680
await db().execute({ sql: `UPDATE projects SET name = ? WHERE id = ?`, args: [name, id] });
681681
}
682682

683+
/** Pause or resume one project webhook without discarding its signing secret. */
684+
export async function setProjectWebhookActive(
685+
id: string,
686+
projectId: string,
687+
active: boolean,
688+
): Promise<boolean> {
689+
await ensureSchema();
690+
const r = await db().execute({
691+
sql: `UPDATE webhook_endpoints SET active = ? WHERE id = ? AND project_id = ?`,
692+
args: [active ? 1 : 0, id, projectId],
693+
});
694+
return Number(r.rowsAffected || 0) > 0;
695+
}
696+
697+
/** Delete one project webhook and its delivery history. */
698+
export async function deleteProjectWebhook(id: string, projectId: string): Promise<boolean> {
699+
await ensureSchema();
700+
const d = db();
701+
const owned = await d.execute({
702+
sql: `SELECT 1 FROM webhook_endpoints WHERE id = ? AND project_id = ? LIMIT 1`,
703+
args: [id, projectId],
704+
});
705+
if (!owned.rows.length) return false;
706+
707+
await d.execute({ sql: `DELETE FROM webhook_deliveries WHERE endpoint_id = ?`, args: [id] });
708+
const r = await d.execute({
709+
sql: `DELETE FROM webhook_endpoints WHERE id = ? AND project_id = ?`,
710+
args: [id, projectId],
711+
});
712+
return Number(r.rowsAffected || 0) > 0;
713+
}
714+
683715
/** Deletes a project and its webhook config/history (SQLite has no cascade). */
684716
export async function deleteProject(id: string): Promise<void> {
685717
await ensureSchema();
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
process.env.TURSO_DATABASE_URL = "file::memory:";
5+
6+
const {
7+
db,
8+
deleteProjectWebhook,
9+
ensureSchema,
10+
setProjectWebhookActive,
11+
} = await import("../lib/db.ts");
12+
const { dispatchEvent } = await import("../lib/webhooks.ts");
13+
14+
await ensureSchema();
15+
16+
async function insertEndpoint(projectId, suffix) {
17+
const id = `endpoint-${suffix}`;
18+
await db().execute({
19+
sql: `INSERT INTO webhook_endpoints (id, project_id, url, secret, events)
20+
VALUES (?, ?, ?, ?, '["*"]')`,
21+
args: [id, projectId, `https://hooks.example.test/${suffix}`, `whsec_${suffix}`],
22+
});
23+
return id;
24+
}
25+
26+
test("project webhook targets pause and resume without losing configuration", async () => {
27+
const id = await insertEndpoint("project-pause", "pause");
28+
const originalFetch = globalThis.fetch;
29+
const calls = [];
30+
globalThis.fetch = async (...args) => {
31+
calls.push(args);
32+
return new Response(null, { status: 204 });
33+
};
34+
35+
try {
36+
assert.equal(await setProjectWebhookActive(id, "project-pause", false), true);
37+
await dispatchEvent("project-pause", "build.finished", { ok: true });
38+
assert.equal(calls.length, 0);
39+
40+
const paused = await db().execute({
41+
sql: `SELECT url, secret, events, active FROM webhook_endpoints WHERE id = ?`,
42+
args: [id],
43+
});
44+
assert.deepEqual(
45+
{
46+
url: String(paused.rows[0].url),
47+
secret: String(paused.rows[0].secret),
48+
events: String(paused.rows[0].events),
49+
active: Number(paused.rows[0].active),
50+
},
51+
{
52+
url: "https://hooks.example.test/pause",
53+
secret: "whsec_pause",
54+
events: '["*"]',
55+
active: 0,
56+
},
57+
);
58+
59+
assert.equal(await setProjectWebhookActive(id, "project-pause", true), true);
60+
await dispatchEvent("project-pause", "build.finished", { ok: true });
61+
assert.equal(calls.length, 1);
62+
} finally {
63+
globalThis.fetch = originalFetch;
64+
}
65+
});
66+
67+
test("project scoping prevents cross-project changes", async () => {
68+
const id = await insertEndpoint("project-owner", "scoped");
69+
70+
assert.equal(await setProjectWebhookActive(id, "project-other", false), false);
71+
assert.equal(await deleteProjectWebhook(id, "project-other"), false);
72+
73+
const endpoint = await db().execute({
74+
sql: `SELECT active FROM webhook_endpoints WHERE id = ?`,
75+
args: [id],
76+
});
77+
assert.equal(Number(endpoint.rows[0].active), 1);
78+
});
79+
80+
test("deleting a project webhook also deletes its delivery history", async () => {
81+
const id = await insertEndpoint("project-delete", "delete");
82+
await db().execute({
83+
sql: `INSERT INTO webhook_deliveries
84+
(id, endpoint_id, event_type, payload, idempotency_key, status)
85+
VALUES (?, ?, ?, ?, ?, 'failed')`,
86+
args: ["delivery-delete", id, "build.failed", "{}", "event-delete"],
87+
});
88+
89+
assert.equal(await deleteProjectWebhook(id, "project-delete"), true);
90+
assert.equal(await deleteProjectWebhook(id, "project-delete"), false);
91+
92+
const endpoints = await db().execute({
93+
sql: `SELECT 1 FROM webhook_endpoints WHERE id = ?`,
94+
args: [id],
95+
});
96+
const deliveries = await db().execute({
97+
sql: `SELECT 1 FROM webhook_deliveries WHERE endpoint_id = ?`,
98+
args: [id],
99+
});
100+
assert.equal(endpoints.rows.length, 0);
101+
assert.equal(deliveries.rows.length, 0);
102+
});

0 commit comments

Comments
 (0)