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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,25 @@ extension outranks the pit; `moshpit` lets a registered name win anyway, see
[docs/moshpit-dns.md](docs/moshpit-dns.md)). Tables are created
automatically on first request.

### Outbound webhooks

Parked domains and projects send events through
[@profullstack/webhooks](https://github.com/profullstack/webhooks): CloudEvents
bodies, Standard Webhooks signatures (`webhook-id`, `webhook-timestamp`,
`webhook-signature`), a durable queue in the database with retries over about
three days, and a delivery log. Domain events: `waitlist.signup`, `bid.placed`,
`bid.won`, `bid.accepted`, `inbound.*` (whatever arrived at
`/api/webhooks/<domain>`, relayed). Manage targets in the dashboard
(Webhooks tab) or over HTTP with an API key:

curl -H "Authorization: Bearer $KEY" "https://moshcoding.com/api/v1/webhooks?dn=example.com"
curl -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"url":"https://example.com/hook","events":["bid.*"]}' \
"https://moshcoding.com/api/v1/webhooks?dn=example.com"

Also `/:id` (GET/PATCH/DELETE), `/:id/secret`, `/:id/rotate`, `/:id/test`,
`/:id/deliveries`, `/deliveries`, `/deliveries/:id/retry`.

### Deploy (Railway)

Builds from the **Dockerfile** (`oven/bun` → `bun run build` → `bun run start`);
Expand Down
51 changes: 37 additions & 14 deletions app/api/domain-webhooks/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import { safeDomain } from "@/lib/config";
import { accountIdForCoinpay, accountOwnsDomain, listInboundEvents } from "@/lib/db";
import {
accountIdForCoinpay,
accountOwnsDomain,
listDomainWebhooks,
addDomainWebhook,
deleteDomainWebhook,
setDomainWebhookActive,
listInboundEvents,
distinctWebhookUrls,
} from "@/lib/db";
import { fireDomainEvent, newSecret, isInternalUrl, isSelfWebhookUrl } from "@/lib/webhooks";
domainDeliveries,
isInternalUrl,
isSelfWebhookUrl,
listDomainWebhooks,
rotateDomainWebhookSecret,
setDomainWebhookActive,
testDomainWebhooks,
WebhookError,
WEBHOOK_EVENT_TYPES,
} from "@/lib/webhooks";
import { listAccessibleProjectIds } from "@/lib/authz";

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

const [webhooks, events] = await Promise.all([listDomainWebhooks(dn), listInboundEvents(dn, 25)]);
const [webhooks, events, deliveries] = await Promise.all([listDomainWebhooks(dn), listInboundEvents(dn, 25), domainDeliveries(dn, 25)]);
// Don't re-suggest URLs already added as targets for this domain.
const have = new Set(webhooks.map((w) => w.url));
return NextResponse.json({
dn,
inboundUrl: inboundUrl(req, dn),
webhooks: webhooks.map((w) => ({ id: w.id, url: w.url, secret: w.secret, active: w.active, created_at: w.created_at })),
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 })),
events,
deliveries: deliveries.map(({ payload: _p, ...d }) => d),
eventTypes: WEBHOOK_EVENT_TYPES,
knownUrls: knownUrls.filter((u) => !have.has(u)),
});
}

// POST /api/domain-webhooks — owner: add, pause/resume, delete, or test a target.
// POST /api/domain-webhooks — owner: add, pause/resume, rotate, 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 @@ -84,9 +90,21 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: true, id, active: body.active });
}

if (body.action === "rotate") {
const secret = await rotateDomainWebhookSecret(String(body.id || ""), dn);
if (!secret) return NextResponse.json({ error: "Webhook target not found." }, { status: 404 });
return NextResponse.json({ ok: true, id: String(body.id), secret });
}

// Sends webhook.test now: to one target (`id`) or every active one.
if (body.action === "test") {
await fireDomainEvent(dn, "test.ping", { message: "Test event from moshcoding", dn });
return NextResponse.json({ ok: true, sent: true });
try {
const results = await testDomainWebhooks(dn, body.id ? String(body.id) : undefined);
return NextResponse.json({ ok: true, sent: results.length > 0, results });
} catch (e) {
if (e instanceof WebhookError) return NextResponse.json({ error: e.message }, { status: e.status });
throw e;
}
}

// Default: add an outbound target.
Expand All @@ -100,6 +118,11 @@ export async function POST(req: NextRequest) {
if (isSelfWebhookUrl(url)) {
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 });
}
const wh = await addDomainWebhook(dn, url, newSecret());
return NextResponse.json({ ok: true, webhook: { id: wh.id, url: wh.url, secret: wh.secret, active: wh.active } }, { status: 201 });
try {
const wh = await addDomainWebhook(dn, url, body.events);
return NextResponse.json({ ok: true, webhook: { id: wh.id, url: wh.url, secret: wh.secret, events: wh.events, active: wh.active } }, { status: 201 });
} catch (e) {
if (e instanceof WebhookError) return NextResponse.json({ error: e.message }, { status: e.status });
throw e;
}
}
2 changes: 1 addition & 1 deletion app/api/projects/[id]/webhooks/[endpointId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { requireUser, unauthorized, bad } from "@/lib/api";
import { authorizeProject } from "@/lib/authz";
import { deleteProjectWebhook, setProjectWebhookActive } from "@/lib/db";
import { deleteProjectWebhook, setProjectWebhookActive } from "@/lib/webhooks";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand Down
23 changes: 9 additions & 14 deletions app/api/projects/[id]/webhooks/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { requireUser, unauthorized, bad } from "@/lib/api";
import { authorizeProject } from "@/lib/authz";
import { newSecret, isInternalUrl } from "@/lib/webhooks";
import { createProjectWebhook, isInternalUrl, listProjectWebhooks, WebhookError } from "@/lib/webhooks";
import { normalizeWebhookEventSubscriptions } from "@/lib/webhook-events";

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

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

const secret = newSecret();
const res = await db().execute({
sql: `INSERT INTO webhook_endpoints (project_id, url, secret, events) VALUES (?, ?, ?, ?)
RETURNING id, url, events, active, created_at`,
args: [projectId, url, secret, JSON.stringify(events)],
});
return NextResponse.json({ endpoint: res.rows[0], secret }, { status: 201 });
try {
const { endpoint, secret } = await createProjectWebhook(projectId, url, events);
return NextResponse.json({ endpoint, secret }, { status: 201 });
} catch (e) {
if (e instanceof WebhookError) return bad(e.message, e.status);
throw e;
}
}
12 changes: 4 additions & 8 deletions app/api/projects/[id]/webhooks/test/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { requireUser, unauthorized, bad } from "@/lib/api";
import { authorizeProject } from "@/lib/authz";
import { dispatchEvent } from "@/lib/webhooks";
import { testProjectWebhooks } from "@/lib/webhooks";

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

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

const results = await dispatchEvent(
projectId,
"ping",
{ message: "🤘 moshcoding test event", at: new Date().toISOString() },
{ bypassEventFilter: true },
);
const results = await testProjectWebhooks(projectId);
return NextResponse.json({ dispatched: results.length, results });
}
35 changes: 35 additions & 0 deletions app/api/v1/webhooks/[[...path]]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { webhooksApi } from "@profullstack/webhooks";
import { resolveAccountIdOrToken } from "@/lib/api";
import { safeDomain } from "@/lib/config";
import { accountOwnsDomain } from "@/lib/db";
import { domainOwner, hooks } from "@/lib/webhooks";

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

// The @profullstack/webhooks API for a parked domain's outbound targets:
// GET/POST /api/v1/webhooks?dn=<domain>, GET/PATCH/DELETE /:id,
// GET /:id/secret, POST /:id/rotate, POST /:id/test, GET /:id/deliveries,
// GET /deliveries, POST /deliveries/:id/retry
// Auth: the dashboard session or `Authorization: Bearer <API key>`; the
// account must own the domain (pass it as ?dn= or an x-domain header).
let api: ReturnType<typeof webhooksApi> | undefined;
const owners = new WeakMap<Request, string>();

async function handle(req: NextRequest): Promise<Response> {
const accountId = await resolveAccountIdOrToken(req);
if (!accountId) return NextResponse.json({ error: "sign in or send an API key" }, { status: 401 });
const dn = safeDomain(req.nextUrl.searchParams.get("dn") || req.headers.get("x-domain"));
if (!dn) return NextResponse.json({ error: "name the domain: ?dn=<domain> or an x-domain header" }, { status: 400 });
if (!(await accountOwnsDomain(accountId, dn))) return NextResponse.json({ error: "You don't own this domain." }, { status: 403 });
owners.set(req, domainOwner(dn));
api ??= webhooksApi(hooks(), { basePath: "/api/v1/webhooks", owner: (r) => owners.get(r) ?? null });
return (await api(req)) ?? NextResponse.json({ error: "not found" }, { status: 404 });
}

export const GET = handle;
export const POST = handle;
export const PATCH = handle;
export const PUT = handle;
export const DELETE = handle;
47 changes: 42 additions & 5 deletions app/dashboard/[[...tab]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -963,10 +963,13 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
const [active, setActive] = useState<string | null>(null);
const [data, setData] = useState<any | null>(null);
const [url, setUrl] = useState("");
const [events, setEvents] = useState("");
const [busy, setBusy] = useState(false);
// A new or rotated secret, shown once with a copy button.
const [fresh, setFresh] = useState<{ id: string; secret: string } | null>(null);

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

Expand All @@ -1007,7 +1017,7 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
return (
<section className="card2">
<h2>Webhooks</h2>
<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>
<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>
<div className="tabs" style={{ flexWrap: "wrap" }}>
{domains.map((d) => (
<button key={d.domain} className={`tab${active === d.domain ? " on" : ""}`} onClick={() => load(d.domain)}>{d.domain}</button>
Expand All @@ -1025,16 +1035,32 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
{data.knownUrls?.length ? (
<datalist id="known-webhook-urls">{data.knownUrls.map((u: string) => <option key={u} value={u} />)}</datalist>
) : null}
<button className="btn2" disabled={busy || !url.trim()} onClick={() => { act({ url: url.trim() }, "Webhook added."); setUrl(""); }}>Add</button>
<button className="btn2 ghost" disabled={busy} onClick={() => act({ action: "test" }, "Test event dispatched.")}>Send test</button>
<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)} />
<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>
<button className="btn2 ghost" disabled={busy} onClick={() => act({ action: "test" }, "Test event delivered.")}>Send test</button>
</div>
{fresh && (
<div className="row">
<input className="inp" readOnly value={fresh.secret} />
<button className="btn2 ghost" onClick={() => copyText(fresh.secret)}>Copy secret</button>
<button className="btn2 ghost" onClick={() => setFresh(null)}>Done</button>
</div>
)}
<ul className="list">
{(!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">· {w.active ? "active" : "paused"} · secret {String(w.secret).slice(0, 12)}…</span></span>
<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>
<span className="row-actions">
<button className="btn2 ghost" onClick={() => copyText(w.secret)}>Copy secret</button>
<button className="btn2 ghost" disabled={busy || !w.active} onClick={() => act({ action: "test", id: w.id }, "Test event delivered.")}>Test</button>
<button
className="btn2 ghost"
disabled={busy}
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."); }}
>
Rotate
</button>
<button
className="btn2 ghost"
disabled={busy}
Expand All @@ -1051,6 +1077,17 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
))}
</ul>

<h3 className="ed-h">Recent deliveries ({data.deliveries?.length || 0})</h3>
<ul className="list">
{(!data.deliveries || data.deliveries.length === 0) && <li className="muted">Nothing sent yet.</li>}
{data.deliveries?.map((d: any) => (
<li key={d.id}>
<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>
<span className="muted">{d.created_at}</span>
</li>
))}
</ul>

<h3 className="ed-h">Recent inbound events ({data.events?.length || 0})</h3>
<ul className="list">
{(!data.events || data.events.length === 0) && <li className="muted">Nothing received yet.</li>}
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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