Skip to content

Commit 7cfefd1

Browse files
ralyodioclaude
andauthored
feat(webhooks): send through @profullstack/webhooks 0.2.0 (0.4.0) (#142)
Domain and project webhooks now go through the shared hub: CloudEvents bodies, Standard Webhooks signatures, a durable queue with retries and a delivery log, drained by hooks.start() at server boot (instrumentation.ts). - lib/webhook-store.ts: a WebhookStore on the app's libSQL-shaped client (Postgres via libsql-pg in prod, file: in tests), hub_ tables from the package's DDL. - Existing endpoints are copied once into the hub with their ids, URLs, subscriptions and on/off state. The old sender keyed the HMAC with the whole secret string; the stored secret becomes whsec_ + base64 of it, the same key, so receivers verify unchanged (tested). - Domain events waitlist.signup, bid.placed/won/accepted and inbound.* relays are emitted via hooks.emit; the x-moshcoding-hop header is kept. - /api/v1/webhooks?dn= mounts webhooksApi behind session or API key, scoped to a domain the account owns. - Dashboard Webhooks tab: event filter on add, per-target test, rotate, delivery log. Project webhook routes answer as before. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9feae10 commit 7cfefd1

18 files changed

Lines changed: 927 additions & 376 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,25 @@ extension outranks the pit; `moshpit` lets a registered name win anyway, see
9292
[docs/moshpit-dns.md](docs/moshpit-dns.md)). Tables are created
9393
automatically on first request.
9494

95+
### Outbound webhooks
96+
97+
Parked domains and projects send events through
98+
[@profullstack/webhooks](https://github.com/profullstack/webhooks): CloudEvents
99+
bodies, Standard Webhooks signatures (`webhook-id`, `webhook-timestamp`,
100+
`webhook-signature`), a durable queue in the database with retries over about
101+
three days, and a delivery log. Domain events: `waitlist.signup`, `bid.placed`,
102+
`bid.won`, `bid.accepted`, `inbound.*` (whatever arrived at
103+
`/api/webhooks/<domain>`, relayed). Manage targets in the dashboard
104+
(Webhooks tab) or over HTTP with an API key:
105+
106+
curl -H "Authorization: Bearer $KEY" "https://moshcoding.com/api/v1/webhooks?dn=example.com"
107+
curl -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
108+
-d '{"url":"https://example.com/hook","events":["bid.*"]}' \
109+
"https://moshcoding.com/api/v1/webhooks?dn=example.com"
110+
111+
Also `/:id` (GET/PATCH/DELETE), `/:id/secret`, `/:id/rotate`, `/:id/test`,
112+
`/:id/deliveries`, `/deliveries`, `/deliveries/:id/retry`.
113+
95114
### Deploy (Railway)
96115

97116
Builds from the **Dockerfile** (`oven/bun` → `bun run build` → `bun run start`);

‎app/api/domain-webhooks/route.ts‎

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
33
import { safeDomain } from "@/lib/config";
4+
import { accountIdForCoinpay, accountOwnsDomain, listInboundEvents } from "@/lib/db";
45
import {
5-
accountIdForCoinpay,
6-
accountOwnsDomain,
7-
listDomainWebhooks,
86
addDomainWebhook,
97
deleteDomainWebhook,
10-
setDomainWebhookActive,
11-
listInboundEvents,
128
distinctWebhookUrls,
13-
} from "@/lib/db";
14-
import { fireDomainEvent, newSecret, isInternalUrl, isSelfWebhookUrl } from "@/lib/webhooks";
9+
domainDeliveries,
10+
isInternalUrl,
11+
isSelfWebhookUrl,
12+
listDomainWebhooks,
13+
rotateDomainWebhookSecret,
14+
setDomainWebhookActive,
15+
testDomainWebhooks,
16+
WebhookError,
17+
WEBHOOK_EVENT_TYPES,
18+
} from "@/lib/webhooks";
1519
import { listAccessibleProjectIds } from "@/lib/authz";
1620

1721
export const runtime = "nodejs";
@@ -46,19 +50,21 @@ export async function GET(req: NextRequest) {
4650
try { knownUrls = await distinctWebhookUrls(await listAccessibleProjectIds(s.sub)); } catch { /* best-effort */ }
4751
}
4852

49-
const [webhooks, events] = await Promise.all([listDomainWebhooks(dn), listInboundEvents(dn, 25)]);
53+
const [webhooks, events, deliveries] = await Promise.all([listDomainWebhooks(dn), listInboundEvents(dn, 25), domainDeliveries(dn, 25)]);
5054
// Don't re-suggest URLs already added as targets for this domain.
5155
const have = new Set(webhooks.map((w) => w.url));
5256
return NextResponse.json({
5357
dn,
5458
inboundUrl: inboundUrl(req, dn),
55-
webhooks: webhooks.map((w) => ({ id: w.id, url: w.url, secret: w.secret, active: w.active, created_at: w.created_at })),
59+
webhooks: webhooks.map((w) => ({ id: w.id, url: w.url, secret: w.secret, events: w.events, active: w.active, failures: w.failures, disabled_reason: w.disabled_reason, created_at: w.created_at })),
5660
events,
61+
deliveries: deliveries.map(({ payload: _p, ...d }) => d),
62+
eventTypes: WEBHOOK_EVENT_TYPES,
5763
knownUrls: knownUrls.filter((u) => !have.has(u)),
5864
});
5965
}
6066

61-
// POST /api/domain-webhooks — owner: add, pause/resume, delete, or test a target.
67+
// POST /api/domain-webhooks — owner: add, pause/resume, rotate, delete, or test a target.
6268
export async function POST(req: NextRequest) {
6369
const accountId = await resolveAccountId(req);
6470
if (!accountId) return NextResponse.json({ error: "Sign in to manage webhooks." }, { status: 401 });
@@ -84,9 +90,21 @@ export async function POST(req: NextRequest) {
8490
return NextResponse.json({ ok: true, id, active: body.active });
8591
}
8692

93+
if (body.action === "rotate") {
94+
const secret = await rotateDomainWebhookSecret(String(body.id || ""), dn);
95+
if (!secret) return NextResponse.json({ error: "Webhook target not found." }, { status: 404 });
96+
return NextResponse.json({ ok: true, id: String(body.id), secret });
97+
}
98+
99+
// Sends webhook.test now: to one target (`id`) or every active one.
87100
if (body.action === "test") {
88-
await fireDomainEvent(dn, "test.ping", { message: "Test event from moshcoding", dn });
89-
return NextResponse.json({ ok: true, sent: true });
101+
try {
102+
const results = await testDomainWebhooks(dn, body.id ? String(body.id) : undefined);
103+
return NextResponse.json({ ok: true, sent: results.length > 0, results });
104+
} catch (e) {
105+
if (e instanceof WebhookError) return NextResponse.json({ error: e.message }, { status: e.status });
106+
throw e;
107+
}
90108
}
91109

92110
// Default: add an outbound target.
@@ -100,6 +118,11 @@ export async function POST(req: NextRequest) {
100118
if (isSelfWebhookUrl(url)) {
101119
return NextResponse.json({ error: "That's an inbound receiver URL — sending events there would loop back into this dashboard. Use the URL of your own server." }, { status: 400 });
102120
}
103-
const wh = await addDomainWebhook(dn, url, newSecret());
104-
return NextResponse.json({ ok: true, webhook: { id: wh.id, url: wh.url, secret: wh.secret, active: wh.active } }, { status: 201 });
121+
try {
122+
const wh = await addDomainWebhook(dn, url, body.events);
123+
return NextResponse.json({ ok: true, webhook: { id: wh.id, url: wh.url, secret: wh.secret, events: wh.events, active: wh.active } }, { status: 201 });
124+
} catch (e) {
125+
if (e instanceof WebhookError) return NextResponse.json({ error: e.message }, { status: e.status });
126+
throw e;
127+
}
105128
}

‎app/api/projects/[id]/webhooks/[endpointId]/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { requireUser, unauthorized, bad } from "@/lib/api";
33
import { authorizeProject } from "@/lib/authz";
4-
import { deleteProjectWebhook, setProjectWebhookActive } from "@/lib/db";
4+
import { deleteProjectWebhook, setProjectWebhookActive } from "@/lib/webhooks";
55

66
export const runtime = "nodejs";
77
export const dynamic = "force-dynamic";

‎app/api/projects/[id]/webhooks/route.ts‎

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { db } from "@/lib/db";
32
import { requireUser, unauthorized, bad } from "@/lib/api";
43
import { authorizeProject } from "@/lib/authz";
5-
import { newSecret, isInternalUrl } from "@/lib/webhooks";
4+
import { createProjectWebhook, isInternalUrl, listProjectWebhooks, WebhookError } from "@/lib/webhooks";
65
import { normalizeWebhookEventSubscriptions } from "@/lib/webhook-events";
76

87
export const runtime = "nodejs";
@@ -15,11 +14,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
1514
const { id: projectId } = await ctx.params;
1615
const az = await authorizeProject(u.sub, projectId, "project.read");
1716
if (!az.ok) return bad(az.error, az.status);
18-
const { rows } = await db().execute({
19-
sql: `SELECT id, url, events, active, created_at FROM webhook_endpoints WHERE project_id = ? ORDER BY created_at`,
20-
args: [projectId],
21-
});
22-
return NextResponse.json({ endpoints: rows });
17+
return NextResponse.json({ endpoints: await listProjectWebhooks(projectId) });
2318
}
2419

2520
// create an outbound endpoint (needs webhook.manage). Returns the signing secret ONCE.
@@ -39,11 +34,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
3934
return bad("events must be an array of names using letters, digits, dot, underscore, colon, or dash");
4035
}
4136

42-
const secret = newSecret();
43-
const res = await db().execute({
44-
sql: `INSERT INTO webhook_endpoints (project_id, url, secret, events) VALUES (?, ?, ?, ?)
45-
RETURNING id, url, events, active, created_at`,
46-
args: [projectId, url, secret, JSON.stringify(events)],
47-
});
48-
return NextResponse.json({ endpoint: res.rows[0], secret }, { status: 201 });
37+
try {
38+
const { endpoint, secret } = await createProjectWebhook(projectId, url, events);
39+
return NextResponse.json({ endpoint, secret }, { status: 201 });
40+
} catch (e) {
41+
if (e instanceof WebhookError) return bad(e.message, e.status);
42+
throw e;
43+
}
4944
}
Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { requireUser, unauthorized, bad } from "@/lib/api";
33
import { authorizeProject } from "@/lib/authz";
4-
import { dispatchEvent } from "@/lib/webhooks";
4+
import { testProjectWebhooks } from "@/lib/webhooks";
55

66
export const runtime = "nodejs";
77
export const dynamic = "force-dynamic";
88

9-
// fire a test event to all of a project's active endpoints (needs webhook.manage)
9+
// send a webhook.test event now to all of a project's active endpoints,
10+
// whatever they subscribe to (needs webhook.manage)
1011
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
1112
const u = await requireUser(req);
1213
if (!u) return unauthorized();
1314
const { id: projectId } = await ctx.params;
1415
const az = await authorizeProject(u.sub, projectId, "webhook.manage");
1516
if (!az.ok) return bad(az.error, az.status);
1617

17-
const results = await dispatchEvent(
18-
projectId,
19-
"ping",
20-
{ message: "🤘 moshcoding test event", at: new Date().toISOString() },
21-
{ bypassEventFilter: true },
22-
);
18+
const results = await testProjectWebhooks(projectId);
2319
return NextResponse.json({ dispatched: results.length, results });
2420
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { webhooksApi } from "@profullstack/webhooks";
3+
import { resolveAccountIdOrToken } from "@/lib/api";
4+
import { safeDomain } from "@/lib/config";
5+
import { accountOwnsDomain } from "@/lib/db";
6+
import { domainOwner, hooks } from "@/lib/webhooks";
7+
8+
export const runtime = "nodejs";
9+
export const dynamic = "force-dynamic";
10+
11+
// The @profullstack/webhooks API for a parked domain's outbound targets:
12+
// GET/POST /api/v1/webhooks?dn=<domain>, GET/PATCH/DELETE /:id,
13+
// GET /:id/secret, POST /:id/rotate, POST /:id/test, GET /:id/deliveries,
14+
// GET /deliveries, POST /deliveries/:id/retry
15+
// Auth: the dashboard session or `Authorization: Bearer <API key>`; the
16+
// account must own the domain (pass it as ?dn= or an x-domain header).
17+
let api: ReturnType<typeof webhooksApi> | undefined;
18+
const owners = new WeakMap<Request, string>();
19+
20+
async function handle(req: NextRequest): Promise<Response> {
21+
const accountId = await resolveAccountIdOrToken(req);
22+
if (!accountId) return NextResponse.json({ error: "sign in or send an API key" }, { status: 401 });
23+
const dn = safeDomain(req.nextUrl.searchParams.get("dn") || req.headers.get("x-domain"));
24+
if (!dn) return NextResponse.json({ error: "name the domain: ?dn=<domain> or an x-domain header" }, { status: 400 });
25+
if (!(await accountOwnsDomain(accountId, dn))) return NextResponse.json({ error: "You don't own this domain." }, { status: 403 });
26+
owners.set(req, domainOwner(dn));
27+
api ??= webhooksApi(hooks(), { basePath: "/api/v1/webhooks", owner: (r) => owners.get(r) ?? null });
28+
return (await api(req)) ?? NextResponse.json({ error: "not found" }, { status: 404 });
29+
}
30+
31+
export const GET = handle;
32+
export const POST = handle;
33+
export const PATCH = handle;
34+
export const PUT = handle;
35+
export const DELETE = handle;

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

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -963,10 +963,13 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
963963
const [active, setActive] = useState<string | null>(null);
964964
const [data, setData] = useState<any | null>(null);
965965
const [url, setUrl] = useState("");
966+
const [events, setEvents] = useState("");
966967
const [busy, setBusy] = useState(false);
968+
// A new or rotated secret, shown once with a copy button.
969+
const [fresh, setFresh] = useState<{ id: string; secret: string } | null>(null);
967970

968971
const load = async (dn: string) => {
969-
setActive(dn); setData(null);
972+
setActive(dn); setData(null); setFresh(null);
970973
try {
971974
const r = await fetch(`/api/domain-webhooks?dn=${encodeURIComponent(dn)}`);
972975
const d = await r.json();
@@ -995,7 +998,14 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
995998
});
996999
const d = await r.json();
9971000
if (!r.ok) throw new Error(d.error);
1001+
if (payload.action === "test" && Array.isArray(d.results)) {
1002+
const bad = d.results.filter((x: any) => !x.ok);
1003+
if (!d.results.length) ok = "No active targets to test.";
1004+
else if (bad.length) ok = `Test sent: ${d.results.length - bad.length}/${d.results.length} answered 2xx (${bad[0].error || `HTTP ${bad[0].status}`}).`;
1005+
}
9981006
onOk(ok); await load(active);
1007+
const secret = d.secret || d.webhook?.secret;
1008+
if (secret) setFresh({ id: d.id || d.webhook?.id, secret });
9991009
} catch (e: any) { onError(e.message || "Failed."); } finally { setBusy(false); }
10001010
};
10011011

@@ -1007,7 +1017,7 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
10071017
return (
10081018
<section className="card2">
10091019
<h2>Webhooks</h2>
1010-
<p className="sub">Every parked domain gets hosted webhooks — no server to run. <b>Inbound:</b> POST events to the URL below. <b>Outbound:</b> we POST domain events (waitlist.signup, bid.placed/won/accepted) to your URLs, signed (Standard Webhooks).</p>
1020+
<p className="sub">Every parked domain gets hosted webhooks — no server to run. <b>Inbound:</b> POST events to the URL below. <b>Outbound:</b> we POST domain events (waitlist.signup, bid.placed/won/accepted, inbound.*) to your URLs as CloudEvents, signed (Standard Webhooks), retried with backoff. Also scriptable: <code>/api/v1/webhooks?dn=&lt;domain&gt;</code> with an API key.</p>
10111021
<div className="tabs" style={{ flexWrap: "wrap" }}>
10121022
{domains.map((d) => (
10131023
<button key={d.domain} className={`tab${active === d.domain ? " on" : ""}`} onClick={() => load(d.domain)}>{d.domain}</button>
@@ -1025,16 +1035,32 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
10251035
{data.knownUrls?.length ? (
10261036
<datalist id="known-webhook-urls">{data.knownUrls.map((u: string) => <option key={u} value={u} />)}</datalist>
10271037
) : null}
1028-
<button className="btn2" disabled={busy || !url.trim()} onClick={() => { act({ url: url.trim() }, "Webhook added."); setUrl(""); }}>Add</button>
1029-
<button className="btn2 ghost" disabled={busy} onClick={() => act({ action: "test" }, "Test event dispatched.")}>Send test</button>
1038+
<input className="inp" style={{ maxWidth: 220 }} placeholder="events: all, or e.g. bid.*, waitlist.signup" title={`Comma-separated. Available: ${(data.eventTypes || []).join(", ")}`} value={events} onChange={(e) => setEvents(e.target.value)} />
1039+
<button className="btn2" disabled={busy || !url.trim()} onClick={() => { act({ url: url.trim(), ...(events.trim() ? { events: events.trim() } : {}) }, "Webhook added. Copy its secret now."); setUrl(""); setEvents(""); }}>Add</button>
1040+
<button className="btn2 ghost" disabled={busy} onClick={() => act({ action: "test" }, "Test event delivered.")}>Send test</button>
10301041
</div>
1042+
{fresh && (
1043+
<div className="row">
1044+
<input className="inp" readOnly value={fresh.secret} />
1045+
<button className="btn2 ghost" onClick={() => copyText(fresh.secret)}>Copy secret</button>
1046+
<button className="btn2 ghost" onClick={() => setFresh(null)}>Done</button>
1047+
</div>
1048+
)}
10311049
<ul className="list">
10321050
{(!data.webhooks || data.webhooks.length === 0) && <li className="muted">No outbound targets yet.</li>}
10331051
{data.webhooks?.map((w: any) => (
10341052
<li key={w.id}>
1035-
<span>{w.url} <span className="muted">· {w.active ? "active" : "paused"} · secret {String(w.secret).slice(0, 12)}…</span></span>
1053+
<span>{w.url} <span className="muted">· {(w.events || ["*"]).join(", ") === "*" ? "all events" : (w.events || []).join(", ")} · {w.active ? "active" : w.disabled_reason ? `off: ${w.disabled_reason}` : "paused"} · secret {String(w.secret).slice(0, 12)}…</span></span>
10361054
<span className="row-actions">
10371055
<button className="btn2 ghost" onClick={() => copyText(w.secret)}>Copy secret</button>
1056+
<button className="btn2 ghost" disabled={busy || !w.active} onClick={() => act({ action: "test", id: w.id }, "Test event delivered.")}>Test</button>
1057+
<button
1058+
className="btn2 ghost"
1059+
disabled={busy}
1060+
onClick={() => { if (window.confirm("Rotate the signing secret? The old one stops working at once.")) act({ action: "rotate", id: w.id }, "Secret rotated. Copy the new one now."); }}
1061+
>
1062+
Rotate
1063+
</button>
10381064
<button
10391065
className="btn2 ghost"
10401066
disabled={busy}
@@ -1051,6 +1077,17 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
10511077
))}
10521078
</ul>
10531079

1080+
<h3 className="ed-h">Recent deliveries ({data.deliveries?.length || 0})</h3>
1081+
<ul className="list">
1082+
{(!data.deliveries || data.deliveries.length === 0) && <li className="muted">Nothing sent yet.</li>}
1083+
{data.deliveries?.map((d: any) => (
1084+
<li key={d.id}>
1085+
<span><b>{d.event_type}</b> <span className="muted">· {d.status} · {d.attempts} attempt{d.attempts === 1 ? "" : "s"}{d.last_status ? ` · HTTP ${d.last_status}` : ""}{d.last_error ? ` · ${String(d.last_error).slice(0, 60)}` : ""}</span></span>
1086+
<span className="muted">{d.created_at}</span>
1087+
</li>
1088+
))}
1089+
</ul>
1090+
10541091
<h3 className="ed-h">Recent inbound events ({data.events?.length || 0})</h3>
10551092
<ul className="list">
10561093
{(!data.events || data.events.length === 0) && <li className="muted">Nothing received yet.</li>}

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎instrumentation.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Runs once when the Next server boots. Starts the webhook drainer: queued
2+
// deliveries are sent every few seconds and retried with backoff, and they
3+
// survive restarts because the queue is in the database.
4+
export async function register() {
5+
if (process.env.NEXT_RUNTIME !== "nodejs") return;
6+
const env = (name: string) => process.env[name] ?? "";
7+
if (!env("DATABASE_URL") && !env("TURSO_DATABASE_URL")) return;
8+
const { hooks } = await import("./lib/webhooks");
9+
hooks().start();
10+
}

0 commit comments

Comments
 (0)