Skip to content

Commit d8d1449

Browse files
feat(webhooks): manage inbound receiver lifecycle (#95)
1 parent fcc6ee3 commit d8d1449

3 files changed

Lines changed: 169 additions & 0 deletions

File tree

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

lib/db.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,39 @@ export async function deleteProjectWebhook(id: string, projectId: string): Promi
712712
return Number(r.rowsAffected || 0) > 0;
713713
}
714714

715+
/** Pause or resume one inbound receiver without rotating its shared secret. */
716+
export async function setProjectInboundWebhookActive(
717+
id: string,
718+
projectId: string,
719+
active: boolean,
720+
): Promise<boolean> {
721+
await ensureSchema();
722+
const r = await db().execute({
723+
sql: `UPDATE inbound_webhooks SET active = ? WHERE id = ? AND project_id = ?`,
724+
args: [active ? 1 : 0, id, projectId],
725+
});
726+
return Number(r.rowsAffected || 0) > 0;
727+
}
728+
729+
/** Delete one inbound receiver and its idempotency/event history. */
730+
export async function deleteProjectInboundWebhook(id: string, projectId: string): Promise<boolean> {
731+
await ensureSchema();
732+
const [, receiverDelete] = await db().batch([
733+
{
734+
sql: `DELETE FROM inbound_events
735+
WHERE inbound_id IN (
736+
SELECT id FROM inbound_webhooks WHERE id = ? AND project_id = ?
737+
)`,
738+
args: [id, projectId],
739+
},
740+
{
741+
sql: `DELETE FROM inbound_webhooks WHERE id = ? AND project_id = ?`,
742+
args: [id, projectId],
743+
},
744+
], "write");
745+
return Number(receiverDelete.rowsAffected || 0) > 0;
746+
}
747+
715748
/** Deletes a project and its webhook config/history (SQLite has no cascade). */
716749
export async function deleteProject(id: string): Promise<void> {
717750
await ensureSchema();
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
deleteProjectInboundWebhook,
9+
ensureSchema,
10+
setProjectInboundWebhookActive,
11+
} = await import("../lib/db.ts");
12+
13+
await ensureSchema();
14+
15+
async function insertReceiver(projectId, suffix) {
16+
const id = `receiver-${suffix}`;
17+
await db().execute({
18+
sql: `INSERT INTO inbound_webhooks (id, project_id, provider, secret)
19+
VALUES (?, ?, ?, ?)`,
20+
args: [id, projectId, `provider-${suffix}`, `whrcv_${suffix}`],
21+
});
22+
return id;
23+
}
24+
25+
test("inbound receivers pause and resume without losing configuration", async () => {
26+
const id = await insertReceiver("project-pause", "pause");
27+
28+
assert.equal(await setProjectInboundWebhookActive(id, "project-pause", false), true);
29+
const paused = await db().execute({
30+
sql: `SELECT provider, secret, active FROM inbound_webhooks WHERE id = ?`,
31+
args: [id],
32+
});
33+
assert.deepEqual(
34+
{
35+
provider: String(paused.rows[0].provider),
36+
secret: String(paused.rows[0].secret),
37+
active: Number(paused.rows[0].active),
38+
},
39+
{ provider: "provider-pause", secret: "whrcv_pause", active: 0 },
40+
);
41+
42+
assert.equal(await setProjectInboundWebhookActive(id, "project-pause", true), true);
43+
const resumed = await db().execute({
44+
sql: `SELECT active FROM inbound_webhooks WHERE id = ?`,
45+
args: [id],
46+
});
47+
assert.equal(Number(resumed.rows[0].active), 1);
48+
});
49+
50+
test("project scoping prevents cross-project receiver changes", async () => {
51+
const id = await insertReceiver("project-owner", "scoped");
52+
await db().execute({
53+
sql: `INSERT INTO inbound_events
54+
(id, inbound_id, provider, idempotency_key, status)
55+
VALUES (?, ?, ?, ?, 'accepted')`,
56+
args: ["inbound-event-scoped", id, "provider-scoped", "event-scoped"],
57+
});
58+
59+
assert.equal(await setProjectInboundWebhookActive(id, "project-other", false), false);
60+
assert.equal(await deleteProjectInboundWebhook(id, "project-other"), false);
61+
62+
const receiver = await db().execute({
63+
sql: `SELECT active FROM inbound_webhooks WHERE id = ?`,
64+
args: [id],
65+
});
66+
assert.equal(Number(receiver.rows[0].active), 1);
67+
const events = await db().execute({
68+
sql: `SELECT 1 FROM inbound_events WHERE inbound_id = ?`,
69+
args: [id],
70+
});
71+
assert.equal(events.rows.length, 1);
72+
});
73+
74+
test("deleting an inbound receiver also deletes its event history", async () => {
75+
const id = await insertReceiver("project-delete", "delete");
76+
await db().execute({
77+
sql: `INSERT INTO inbound_events
78+
(id, inbound_id, provider, idempotency_key, status)
79+
VALUES (?, ?, ?, ?, 'accepted')`,
80+
args: ["inbound-event-delete", id, "provider-delete", "event-delete"],
81+
});
82+
83+
assert.equal(await deleteProjectInboundWebhook(id, "project-delete"), true);
84+
assert.equal(await deleteProjectInboundWebhook(id, "project-delete"), false);
85+
86+
const receivers = await db().execute({
87+
sql: `SELECT 1 FROM inbound_webhooks WHERE id = ?`,
88+
args: [id],
89+
});
90+
const events = await db().execute({
91+
sql: `SELECT 1 FROM inbound_events WHERE inbound_id = ?`,
92+
args: [id],
93+
});
94+
assert.equal(receivers.rows.length, 0);
95+
assert.equal(events.rows.length, 0);
96+
});

0 commit comments

Comments
 (0)