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
13 changes: 12 additions & 1 deletion app/api/domain-webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
listDomainWebhooks,
addDomainWebhook,
deleteDomainWebhook,
setDomainWebhookActive,
listInboundEvents,
distinctWebhookUrls,
} from "@/lib/db";
Expand Down Expand Up @@ -58,7 +59,7 @@ export async function GET(req: NextRequest) {
});
}

// POST /api/domain-webhooks — owner: add a target, delete one, or send a test.
// POST /api/domain-webhooks — owner: add, pause/resume, delete, or test a target.
export async function POST(req: NextRequest) {
const accountId = await resolveAccountId(req);
if (!accountId) return NextResponse.json({ error: "Sign in to manage webhooks." }, { status: 401 });
Expand All @@ -74,6 +75,16 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok }, { status: ok ? 200 : 404 });
}

if (body.action === "setActive") {
const id = String(body.id || "");
if (!id || typeof body.active !== "boolean") {
return NextResponse.json({ error: "Webhook id and active state are required." }, { status: 400 });
}
const ok = await setDomainWebhookActive(id, dn, body.active);
if (!ok) return NextResponse.json({ error: "Webhook target not found." }, { status: 404 });
return NextResponse.json({ ok: true, id, active: body.active });
}

if (body.action === "test") {
await fireDomainEvent(dn, "test.ping", { message: "Test event from moshcoding", dn });
return NextResponse.json({ ok: true, sent: true });
Expand Down
13 changes: 12 additions & 1 deletion app/dashboard/[[...tab]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,7 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
<div className="row"><input className="inp" readOnly value={data.inboundUrl || ""} /><button className="btn2 ghost" onClick={() => copyText(data.inboundUrl || "")}>Copy</button></div>

<h3 className="ed-h">Outbound targets{data.knownUrls?.length ? <span className="muted"> · pre-filled from your project webhooks</span> : null}</h3>
<p className="sub">Pause a target to stop deliveries without deleting its signing secret.</p>
<div className="row">
<input className="inp" list="known-webhook-urls" placeholder="https://your-app.com/hook or a Discord/Slack/Zapier URL" value={url} onChange={(e) => setUrl(e.target.value)} />
{data.knownUrls?.length ? (
Expand All @@ -917,9 +918,19 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
{(!data.webhooks || data.webhooks.length === 0) && <li className="muted">No outbound targets yet.</li>}
{data.webhooks?.map((w: any) => (
<li key={w.id}>
<span>{w.url} <span className="muted">· secret {String(w.secret).slice(0, 12)}…</span></span>
<span>{w.url} <span className="muted">· {w.active ? "active" : "paused"} · secret {String(w.secret).slice(0, 12)}…</span></span>
<span className="row-actions">
<button className="btn2 ghost" onClick={() => copyText(w.secret)}>Copy secret</button>
<button
className="btn2 ghost"
disabled={busy}
onClick={() => act(
{ action: "setActive", id: w.id, active: !w.active },
w.active ? "Webhook paused." : "Webhook resumed.",
)}
>
{w.active ? "Pause" : "Resume"}
</button>
<button className="btn2 ghost" disabled={busy} onClick={() => act({ action: "delete", id: w.id }, "Removed.")}>Delete</button>
</span>
</li>
Expand Down
10 changes: 10 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,16 @@ export async function deleteDomainWebhook(id: string, dn: string): Promise<boole
return Number(r.rowsAffected || 0) > 0;
}

/** Pause or resume one outbound target without discarding its signing secret. */
export async function setDomainWebhookActive(id: string, dn: string, active: boolean): Promise<boolean> {
await ensureSchema();
const r = await db().execute({
sql: `UPDATE domain_webhooks SET active = ? WHERE id = ? AND dn = ?`,
args: [active ? 1 : 0, id, dn.toLowerCase()],
});
return Number(r.rowsAffected || 0) > 0;
}

/** Stores an inbound event, keeping only the most recent 200 per domain. */
export async function recordInboundEvent(opts: { dn: string; source?: string | null; eventType?: string | null; payload: string }): Promise<void> {
await ensureSchema();
Expand Down
39 changes: 39 additions & 0 deletions tests/domain-webhook-active.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";

process.env.TURSO_DATABASE_URL = "file::memory:";

const {
activeDomainWebhooks,
addDomainWebhook,
listDomainWebhooks,
setDomainWebhookActive,
} = await import("../lib/db.ts");

test("domain webhook targets can pause and resume without losing their configuration", async () => {
const webhook = await addDomainWebhook("example.test", "https://hooks.example.test/events", "whsec_keep_me");

assert.equal(webhook.active, true);
assert.deepEqual(await activeDomainWebhooks("example.test"), [
{ url: "https://hooks.example.test/events", secret: "whsec_keep_me" },
]);

assert.equal(await setDomainWebhookActive(webhook.id, "example.test", false), true);
assert.deepEqual(await activeDomainWebhooks("example.test"), []);
assert.deepEqual(
(await listDomainWebhooks("example.test")).map(({ url, secret, active }) => ({ url, secret, active })),
[{ url: "https://hooks.example.test/events", secret: "whsec_keep_me", active: false }],
);

assert.equal(await setDomainWebhookActive(webhook.id, "example.test", true), true);
assert.deepEqual(await activeDomainWebhooks("example.test"), [
{ url: "https://hooks.example.test/events", secret: "whsec_keep_me" },
]);
});

test("a target cannot be changed through another domain", async () => {
const webhook = await addDomainWebhook("owner.test", "https://hooks.example.test/owner", "whsec_owner");

assert.equal(await setDomainWebhookActive(webhook.id, "other.test", false), false);
assert.equal((await listDomainWebhooks("owner.test"))[0].active, true);
});
Loading