Skip to content

Commit 0f20ec8

Browse files
feat(webhooks): add project event filters
1 parent 0241af0 commit 0f20ec8

6 files changed

Lines changed: 118 additions & 6 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { db } from "@/lib/db";
33
import { requireUser, unauthorized, bad } from "@/lib/api";
44
import { authorizeProject } from "@/lib/authz";
55
import { newSecret, isInternalUrl } from "@/lib/webhooks";
6+
import { normalizeWebhookEventSubscriptions } from "@/lib/webhook-events";
67

78
export const runtime = "nodejs";
89
export const dynamic = "force-dynamic";
@@ -33,8 +34,10 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
3334
const url = String(body?.url || "").trim();
3435
if (!/^https?:\/\//.test(url)) return bad("a valid http(s) url is required");
3536
if (isInternalUrl(url)) return bad("that url points at an internal/blocked address");
36-
let events: string[] = ["*"];
37-
if (Array.isArray(body?.events) && body.events.length) events = body.events.map((e: any) => String(e));
37+
const events = normalizeWebhookEventSubscriptions(body?.events);
38+
if (!events) {
39+
return bad("events must be an array of names using letters, digits, dot, underscore, colon, or dash");
40+
}
3841

3942
const secret = newSecret();
4043
const res = await db().execute({

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
1414
const az = await authorizeProject(u.sub, projectId, "webhook.manage");
1515
if (!az.ok) return bad(az.error, az.status);
1616

17-
const results = await dispatchEvent(projectId, "ping", { message: "🤘 moshcoding test event", at: new Date().toISOString() });
17+
const results = await dispatchEvent(
18+
projectId,
19+
"ping",
20+
{ message: "🤘 moshcoding test event", at: new Date().toISOString() },
21+
{ bypassEventFilter: true },
22+
);
1823
return NextResponse.json({ dispatched: results.length, results });
1924
}

lib/webhook-events.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,36 @@ export function normalizeInboundEventType(value: unknown): string | null {
1313
return eventType;
1414
}
1515

16+
/**
17+
* Canonical event subscriptions for one outbound project webhook.
18+
*
19+
* Blank lists mean "all events" so the creation form can leave the field
20+
* empty. Specific names use the same compact grammar as inbound event types;
21+
* duplicates are removed without changing the order shown back to the user.
22+
* A wildcard makes specific entries redundant, but every entry is still
23+
* validated before the list is collapsed to `["*"]`.
24+
*/
25+
export function normalizeWebhookEventSubscriptions(value: unknown): string[] | null {
26+
if (value === undefined || value === null) return ["*"];
27+
if (!Array.isArray(value)) return null;
28+
29+
const events: string[] = [];
30+
const seen = new Set<string>();
31+
for (const item of value) {
32+
if (typeof item !== "string") return null;
33+
const eventType = item.trim();
34+
if (!eventType) continue;
35+
if (eventType !== "*" && !EVENT_TYPE_RE.test(eventType)) return null;
36+
if (!seen.has(eventType)) {
37+
seen.add(eventType);
38+
events.push(eventType);
39+
}
40+
}
41+
42+
if (!events.length || seen.has("*")) return ["*"];
43+
return events;
44+
}
45+
1646
/**
1747
* Event type used when relaying an inbound event to a domain's outbound targets.
1848
* The `inbound.` prefix is applied at most once: a target pointed back at our own

lib/webhooks.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,13 @@ export function eventEnvelope(type: string, data: unknown, projectId: string) {
5050
return { id, type, data, created_at: new Date().toISOString(), project_id: projectId };
5151
}
5252

53-
/** Dispatch an event to every active endpoint of a project that subscribes to it. */
54-
export async function dispatchEvent(projectId: string, type: string, data: unknown) {
53+
/** Dispatch an event to active project endpoints, normally honoring subscriptions. */
54+
export async function dispatchEvent(
55+
projectId: string,
56+
type: string,
57+
data: unknown,
58+
opts: { bypassEventFilter?: boolean } = {},
59+
) {
5560
const { rows } = await db().execute({
5661
sql: `SELECT id, url, secret, events FROM webhook_endpoints WHERE project_id = ? AND active = 1`,
5762
args: [projectId],
@@ -60,7 +65,7 @@ export async function dispatchEvent(projectId: string, type: string, data: unkno
6065
for (const ep of rows as any[]) {
6166
let events: string[] = ["*"];
6267
try { events = JSON.parse(ep.events); } catch { /* default */ }
63-
if (!events.includes("*") && !events.includes(type)) continue;
68+
if (!opts.bypassEventFilter && !events.includes("*") && !events.includes(type)) continue;
6469
results.push(await deliverToEndpoint(String(ep.id), String(ep.url), String(ep.secret), type, data, projectId));
6570
}
6671
return results;

tests/project-webhook-management.test.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,38 @@ test("project webhook targets pause and resume without losing configuration", as
6464
}
6565
});
6666

67+
test("test deliveries reach active endpoints regardless of event subscriptions", async () => {
68+
const id = "endpoint-subscription-test";
69+
await db().execute({
70+
sql: `INSERT INTO webhook_endpoints (id, project_id, url, secret, events)
71+
VALUES (?, ?, ?, ?, ?)`,
72+
args: [id, "project-subscription-test", "https://hooks.example.test/subscription-test", "whsec_subscription_test", '["build.finished"]'],
73+
});
74+
const originalFetch = globalThis.fetch;
75+
const calls = [];
76+
globalThis.fetch = async (...args) => {
77+
calls.push(args);
78+
return new Response(null, { status: 204 });
79+
};
80+
81+
try {
82+
const filtered = await dispatchEvent("project-subscription-test", "ping", { ok: true });
83+
assert.equal(filtered.length, 0);
84+
assert.equal(calls.length, 0);
85+
86+
const testDeliveries = await dispatchEvent(
87+
"project-subscription-test",
88+
"ping",
89+
{ ok: true },
90+
{ bypassEventFilter: true },
91+
);
92+
assert.equal(testDeliveries.length, 1);
93+
assert.equal(calls.length, 1);
94+
} finally {
95+
globalThis.fetch = originalFetch;
96+
}
97+
});
98+
6799
test("project scoping prevents cross-project changes", async () => {
68100
const id = await insertEndpoint("project-owner", "scoped");
69101

tests/webhook-events.test.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import test from "node:test";
44
import {
55
MAX_RELAY_HOPS,
66
normalizeInboundEventType,
7+
normalizeWebhookEventSubscriptions,
78
parseRelayHop,
89
relayEventType,
910
} from "../lib/webhook-events.ts";
@@ -23,6 +24,42 @@ test("inbound webhook event types reject non-string and unsafe values", () => {
2324
assert.equal(normalizeInboundEventType("x".repeat(81)), null);
2425
});
2526

27+
test("outbound subscriptions trim and deduplicate event names", () => {
28+
assert.deepEqual(
29+
normalizeWebhookEventSubscriptions([
30+
" build.finished ",
31+
"deploy.failed",
32+
"build.finished",
33+
"",
34+
]),
35+
["build.finished", "deploy.failed"],
36+
);
37+
});
38+
39+
test("blank outbound subscriptions mean all events", () => {
40+
for (const value of [undefined, null, [], [""], [" ", ""]]) {
41+
assert.deepEqual(normalizeWebhookEventSubscriptions(value), ["*"]);
42+
}
43+
assert.deepEqual(
44+
normalizeWebhookEventSubscriptions(["build.finished", "*", "deploy.failed", "*"]),
45+
["*"],
46+
);
47+
});
48+
49+
test("outbound subscriptions reject malformed arrays and event names", () => {
50+
for (const value of [
51+
"build.finished",
52+
{ event: "build.finished" },
53+
["build finished"],
54+
[".hidden"],
55+
["x".repeat(81)],
56+
["build.finished", 42],
57+
["*", "not valid"],
58+
]) {
59+
assert.equal(normalizeWebhookEventSubscriptions(value), null);
60+
}
61+
});
62+
2663
test("relayed event types are prefixed exactly once", () => {
2764
assert.equal(relayEventType("payment.succeeded"), "inbound.payment.succeeded");
2865
assert.equal(relayEventType(null), "inbound.event");

0 commit comments

Comments
 (0)