feat: per-group webhook ingress, custom webhooks, GitHub App tenant isolation

Add POST /webhook/{groupId} with per-group secrets (KV tenant:{groupId}), a custom provider (X-WebHooker-Signature HMAC, arbitrary JSON -> custom events through the route pipeline), and GitHub App installation isolation (Group.installationId) with automatic provisioning on installation.created (inst-{id} groups or binding matching owners groups). Includes WebhookPanel admin UI, custom route template, docs and 157 passing tests.
This commit is contained in:
RhenCloud 2026-08-13 09:24:50 +08:00
parent 0b078d938b
commit b600f02027
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
34 changed files with 1711 additions and 183 deletions

View file

@ -7,8 +7,20 @@ import {
adminCookie,
clearAdminCookie,
} from "../web/session";
import { groupAcceptsProvider } from "../web/groups";
import {
groupAcceptsProvider,
groupAcceptsInstallation,
ensureInstallationGroup,
loadGroups,
saveGroups,
} from "../web/groups";
import { validateGroups } from "../web/admin-routes";
import {
getTenantSecret,
setTenantSecret,
deleteTenantSecret,
generateTenantSecret,
} from "../web/tenants";
import { loadRoutes, saveRoutes, loadConfig } from "../config";
import type { Env, Route, Group } from "../types";
@ -199,6 +211,105 @@ describe("validateGroups logTarget", () => {
});
});
describe("validateGroups installationId", () => {
const baseGroup = {
id: "g",
name: "G",
members: [{ login: "boss", role: "owner" }],
};
it("accepts a positive integer installation id", () => {
const res = validateGroups([{ ...baseGroup, installationId: 42 }]);
expect(res.ok).toBe(true);
if (res.ok) expect(res.groups[0]!.installationId).toBe(42);
});
it("rejects non-integer installation ids", () => {
expect(validateGroups([{ ...baseGroup, installationId: "42" }]).ok).toBe(false);
expect(validateGroups([{ ...baseGroup, installationId: 42.5 }]).ok).toBe(false);
});
it("drops a null installation id", () => {
const res = validateGroups([{ ...baseGroup, installationId: null }]);
expect(res.ok).toBe(true);
if (res.ok) expect(res.groups[0]!.installationId).toBeUndefined();
});
});
describe("groupAcceptsInstallation", () => {
const base: Group = { id: "g", name: "G", adminIds: [] };
it("accepts everything when the group is not bound to an installation", () => {
expect(groupAcceptsInstallation(base, 101)).toBe(true);
expect(groupAcceptsInstallation(base, undefined)).toBe(true);
});
it("only accepts events from the bound installation", () => {
const bound = { ...base, installationId: 101 };
expect(groupAcceptsInstallation(bound, 101)).toBe(true);
expect(groupAcceptsInstallation(bound, 202)).toBe(false);
expect(groupAcceptsInstallation(bound, undefined)).toBe(false);
});
});
describe("ensureInstallationGroup", () => {
it("creates an inst-{id} group when nothing is bound", async () => {
const kv = createMockKV();
const group = await ensureInstallationGroup(kv, 555, "myorg");
expect(group?.id).toBe("inst-555");
expect(group?.installationId).toBe(555);
expect(group?.name).toBe("myorg");
const groups = await loadGroups(kv);
expect(groups).toHaveLength(1);
expect(groups[0]!.installationId).toBe(555);
});
it("binds existing groups whose owners match the installing account", async () => {
const kv = createMockKV();
await saveGroups(kv, [
{ id: "backend", name: "Backend", adminIds: [], owners: ["myorg"], members: [] },
{ id: "other", name: "Other", adminIds: [], owners: ["another-org"], members: [] },
]);
await ensureInstallationGroup(kv, 555, "MyOrg");
const groups = await loadGroups(kv);
expect(groups).toHaveLength(2);
expect(groups.find((g) => g.id === "backend")?.installationId).toBe(555);
expect(groups.find((g) => g.id === "other")?.installationId).toBeUndefined();
expect(groups.some((g) => g.id.startsWith("inst-"))).toBe(false);
});
it("is idempotent when a group is already bound", async () => {
const kv = createMockKV();
await ensureInstallationGroup(kv, 555, "myorg");
await ensureInstallationGroup(kv, 555, "myorg");
expect((await loadGroups(kv)).filter((g) => g.installationId === 555)).toHaveLength(1);
});
});
describe("tenant webhook secrets", () => {
it("generates 64-char hex secrets", () => {
const s = generateTenantSecret();
expect(s).toMatch(/^[0-9a-f]{64}$/);
});
it("creates, reads, and deletes a tenant secret", async () => {
const kv = createMockKV();
expect(await getTenantSecret(kv, "g1")).toBeNull();
const secret = await setTenantSecret(kv, "g1");
expect(await getTenantSecret(kv, "g1")).toBe(secret);
await deleteTenantSecret(kv, "g1");
expect(await getTenantSecret(kv, "g1")).toBeNull();
});
it("regenerating replaces the previous secret", async () => {
const kv = createMockKV();
const a = await setTenantSecret(kv, "g1");
const b = await setTenantSecret(kv, "g1");
expect(a).not.toBe(b);
expect(await getTenantSecret(kv, "g1")).toBe(b);
});
});
describe("config routes persistence", () => {
it("saves and loads routes from KV", async () => {
const kv = createMockKV();

View file

@ -1,10 +1,12 @@
import { describe, it, expect } from "bun:test";
import { createHmac } from "crypto";
import { detectProvider } from "../providers";
import { customProvider } from "../providers/custom";
import { verifyGiteaSignature } from "../providers/gitea/verify";
import { parseGiteaEvent } from "../providers/gitea/parse";
import { parseEvent } from "../providers/github/parse";
import { formatEvent } from "../formatters";
import type { Route } from "../types";
import type { Env, Route } from "../types";
function giteaSign(body: string, secret: string): string {
return createHmac("sha256", secret).update(body).digest("hex");
@ -151,3 +153,74 @@ describe("gitea event parsing", () => {
);
});
});
describe("custom provider", () => {
const secret = "tenant-secret";
const body = JSON.stringify({
title: "Deploy failed",
repo: "acme/widget",
color: "red",
description: "prod down",
});
function env(overrides: Record<string, unknown> = {}): Env {
return { GITHUB_WEBHOOK_SECRET: secret, ...overrides } as Env;
}
it("matches only requests with X-WebHooker-Signature and no forge headers", () => {
expect(customProvider.matches({ "x-webhooker-signature": "sha256=abc" })).toBe(true);
expect(customProvider.matches({})).toBe(false);
expect(customProvider.matches({ "x-github-event": "push" })).toBe(false);
expect(customProvider.matches({ "x-gitea-event": "push" })).toBe(false);
});
it("detectProvider finds the custom provider", () => {
expect(detectProvider({ "x-webhooker-signature": "sha256=abc" })?.id).toBe("custom");
});
it("accepts a valid sha256 HMAC signature", async () => {
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
expect(await customProvider.verify(body, { "x-webhooker-signature": sig }, env())).toBe(true);
});
it("rejects an invalid signature", async () => {
expect(
await customProvider.verify(body, { "x-webhooker-signature": "sha256=wrong" }, env()),
).toBe(false);
});
it("rejects when the secret is missing", async () => {
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
expect(
(await customProvider.verify(body, { "x-webhooker-signature": sig }, {})) as unknown as Env,
).toBe(false);
});
it("parses the body into a custom event with optional deliveryId", () => {
const event = customProvider.parse(body, {});
expect(event).not.toBeNull();
expect(event!.event).toBe("custom");
expect(event!.provider).toBe("custom");
expect((event!.payload as { title: string }).title).toBe("Deploy failed");
const withId = customProvider.parse(JSON.stringify({ title: "x", deliveryId: "alert-1" }), {});
expect(withId!.deliveryId).toBe("alert-1");
});
it("returns null for invalid JSON", () => {
expect(customProvider.parse("not json", {})).toBeNull();
});
it("extracts the GitHub App installation id on github events", () => {
const event = parseEvent(
{ "x-github-event": "push", "x-github-delivery": "d1" },
JSON.stringify({ installation: { id: 42 }, repository: { full_name: "a/b" } }),
);
expect(event!.installationId).toBe(42);
const noInstall = parseEvent(
{ "x-github-event": "push" },
JSON.stringify({ repository: { full_name: "a/b" } }),
);
expect(noInstall!.installationId).toBeUndefined();
});
});

View file

@ -0,0 +1,391 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { createHmac } from "crypto";
import { processWebhook } from "../webhook";
import { invalidateConfigCache } from "../config";
import { loadGroups } from "../web/groups";
import type { Env, Route } from "../types";
function githubSign(body: string, secret: string): string {
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
}
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string, type?: string) => {
const v = store.get(key);
if (v == null) return null;
if (type === "json") return JSON.parse(v);
return v;
},
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
function createMockDB(): D1Database {
return {
prepare: () => ({
bind: () => ({
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
}),
}),
} as unknown as D1Database;
}
function createEnv(overrides: Partial<Env> = {}): Env {
return {
GITHUB_WEBHOOK_SECRET: "global-secret",
KV: createMockKV(),
DB: createMockDB(),
...overrides,
};
}
describe("processWebhook", () => {
let fetched: Array<{ url: string; body: string }>;
const restoredFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = restoredFetch;
});
function mockFetch(): void {
fetched = [];
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
fetched.push({ url: String(input), body: String(init?.body ?? "") });
return Promise.resolve(new Response("{}", { status: 200 }));
};
}
beforeEach(() => {
invalidateConfigCache();
});
/** waitUntil collector: lets the test await the dispatched work. */
function makeWait(): {
waitUntil: (p: Promise<unknown>) => void;
flush: () => Promise<void>;
} {
const pending: Promise<unknown>[] = [];
return {
waitUntil: (p: Promise<unknown>): void => {
pending.push(p);
},
flush: (): Promise<void> => Promise.allSettled(pending).then(() => undefined),
};
}
async function setupTenant(secret: string, extraRoutes: Route[] = []): Promise<Env> {
const kv = createMockKV();
await kv.put("config:groups", JSON.stringify([{ id: "team-a", name: "Team A", adminIds: [] }]));
await kv.put("tenant:team-a", secret);
await kv.put(
"config:routes",
JSON.stringify([
{
id: "team-a-push",
name: "Team A Push",
enabled: true,
groupId: "team-a",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
{
id: "other-push",
name: "Other Push",
enabled: true,
groupId: "other",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "222" }],
},
...extraRoutes,
] as Route[]),
);
return createEnv({ KV: kv });
}
it("dispatches only the tenant group's routes on a valid signature", async () => {
mockFetch();
const secret = "tenant-secret-1";
const env = await setupTenant(secret);
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "push",
"x-hub-signature-256": githubSign(body, secret),
"x-github-delivery": "deliv-1",
},
wait.waitUntil,
"team-a",
);
expect(res.status).toBe(200);
await wait.flush();
expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true);
expect(fetched.some((f) => f.url.includes("/222/"))).toBe(false);
});
it("rejects an invalid signature with 401", async () => {
const env = await setupTenant("tenant-secret-1");
const body = JSON.stringify({ ref: "refs/heads/main" });
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "push",
"x-hub-signature-256": "sha256=wrong",
"x-github-delivery": "deliv-2",
},
wait.waitUntil,
"team-a",
);
expect(res.status).toBe(401);
});
it("returns 404 for an unknown group", async () => {
const env = await setupTenant("tenant-secret-1");
const wait = makeWait();
const res = await processWebhook(
env,
"{}",
{ "x-github-event": "push" },
wait.waitUntil,
"nope",
);
expect(res.status).toBe(404);
});
it("returns 404 when the group has no tenant secret", async () => {
const kv = createMockKV();
await kv.put("config:groups", JSON.stringify([{ id: "g", name: "G", adminIds: [] }]));
const env = createEnv({ KV: kv });
const wait = makeWait();
const res = await processWebhook(
env,
"{}",
{ "x-github-event": "push", "x-hub-signature-256": "sha256=x" },
wait.waitUntil,
"g",
);
expect(res.status).toBe(404);
});
it("dedupes delivery ids per tenant", async () => {
mockFetch();
const secret = "tenant-secret-1";
const env = await setupTenant(secret);
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
const headers = {
"x-github-event": "push",
"x-hub-signature-256": githubSign(body, secret),
"x-github-delivery": "same-deliv",
};
const first = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a");
const second = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a");
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(second.body).toEqual({ ok: true, duplicate: true });
expect(fetched).toHaveLength(1);
});
it("accepts custom webhooks signed with the tenant secret", async () => {
mockFetch();
const secret = "tenant-secret-1";
const env = await setupTenant(secret, [
{
id: "custom-alerts",
name: "Custom Alerts",
enabled: true,
groupId: "team-a",
filters: [{ type: "event", match: "custom" }],
targets: [{ channelId: "333" }],
},
]);
const body = JSON.stringify({
title: "Deploy failed",
repo: "acme/widget",
color: "red",
description: "prod down",
});
const wait = makeWait();
const res = await processWebhook(
env,
body,
{ "x-webhooker-signature": githubSign(body, secret) },
wait.waitUntil,
"team-a",
);
expect(res.status).toBe(200);
await wait.flush();
const sent = fetched.filter((f) => f.url.includes("/333/"));
expect(sent).toHaveLength(1);
const parsed = JSON.parse(sent[0]!.body) as {
embeds?: Array<{ title?: string; color?: number; description?: string }>;
};
expect(parsed.embeds?.[0]?.title).toBe("acme/widget: Deploy failed");
expect(parsed.embeds?.[0]?.color).toBe(0xf85149);
expect(parsed.embeds?.[0]?.description).toBe("prod down");
});
it("rejects custom webhooks without a signature header", async () => {
const env = await setupTenant("tenant-secret-1");
const wait = makeWait();
const res = await processWebhook(
env,
JSON.stringify({ title: "x" }),
{},
wait.waitUntil,
"team-a",
);
expect(res.status).toBe(400);
});
it("keeps the legacy global endpoint working with the global secret", async () => {
mockFetch();
const kv = createMockKV();
await kv.put(
"config:routes",
JSON.stringify([
{
id: "all",
name: "All",
enabled: true,
filters: [],
targets: [{ channelId: "111" }],
},
] as Route[]),
);
const env = createEnv({ KV: kv });
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "push",
"x-hub-signature-256": githubSign(body, "global-secret"),
"x-github-delivery": "g-1",
},
wait.waitUntil,
);
expect(res.status).toBe(200);
await wait.flush();
expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true);
});
it("keeps a group's routes isolated by GitHub App installation id", async () => {
mockFetch();
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "inst-1", name: "Install 1", adminIds: [], installationId: 101 },
{ id: "inst-2", name: "Install 2", adminIds: [], installationId: 202 },
]),
);
await kv.put(
"config:routes",
JSON.stringify([
{
id: "r1",
name: "R1",
enabled: true,
groupId: "inst-1",
filters: [],
targets: [{ channelId: "111" }],
},
{
id: "r2",
name: "R2",
enabled: true,
groupId: "inst-2",
filters: [],
targets: [{ channelId: "222" }],
},
] as Route[]),
);
const env = createEnv({ KV: kv });
// Global endpoint: installation 101's event may only reach group inst-1.
const body = JSON.stringify({
installation: { id: 101 },
repository: { full_name: "org-a/repo" },
ref: "refs/heads/main",
commits: [],
});
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "push",
"x-hub-signature-256": githubSign(body, "global-secret"),
"x-github-delivery": "inst-1-deliv",
},
wait.waitUntil,
);
expect(res.status).toBe(200);
await wait.flush();
expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true);
expect(fetched.some((f) => f.url.includes("/222/"))).toBe(false);
});
it("auto-provisions a group on installation.created", async () => {
mockFetch();
const kv = createMockKV();
await kv.put(
"config:routes",
JSON.stringify([
{
id: "install-events",
name: "Install Events",
enabled: true,
filters: [{ type: "event", match: "installation" }],
targets: [{ channelId: "111" }],
},
] as Route[]),
);
const env = createEnv({ KV: kv });
const body = JSON.stringify({
action: "created",
installation: { id: 555, account: { login: "myorg", type: "Organization" } },
repositories: [{ full_name: "myorg/repo" }],
sender: { login: "admin" },
});
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "installation",
"x-hub-signature-256": githubSign(body, "global-secret"),
"x-github-delivery": "inst-event-1",
},
wait.waitUntil,
);
expect(res.status).toBe(200);
await wait.flush();
const groups = await loadGroups(kv);
const bound = groups.find((g) => g.installationId === 555);
expect(bound).toBeDefined();
expect(bound?.id).toBe("inst-555");
expect(bound?.name).toBe("myorg");
// The auto-created group is only visible to super admins (no members).
expect(bound?.members ?? []).toHaveLength(0);
});
});

View file

@ -33,6 +33,11 @@ export async function saveRoutes(kv: KVNamespace, routes: Route[]): Promise<void
configCache = null;
}
/** Drop the in-memory route/config cache (used by the admin API and tests). */
export function invalidateConfigCache(): void {
configCache = null;
}
export async function loadConfig(env: Env): Promise<Config> {
if (configCache && Date.now() < configCache.expiresAt) {
return configCache.config;

View file

@ -4,7 +4,12 @@ import { matchRoute, eventOwners } from "../events/match";
import { log } from "../lib/log";
import { loadTranslations, t as translate, type Translations } from "../lib/i18n";
import { recordSend } from "../lib/send-log";
import { loadGroups, groupAcceptsOwners, groupAcceptsProvider } from "../web/groups";
import {
loadGroups,
groupAcceptsOwners,
groupAcceptsProvider,
groupAcceptsInstallation,
} from "../web/groups";
import { getDriver } from "../drivers";
import type { SendResult } from "../drivers/types";
@ -37,6 +42,7 @@ export async function dispatchEvent(config: Config, event: WebhookEvent, env: En
if (!route.groupId) return true;
const group = groupById.get(route.groupId);
if (!group) return true;
if (!groupAcceptsInstallation(group, event.installationId)) return false;
if (!groupAcceptsOwners(group, owners)) return false;
return groupAcceptsProvider(group, event.provider);
};

89
src/formatters/custom.ts Normal file
View file

@ -0,0 +1,89 @@
import type { NeutralAuthor, NeutralField, NeutralMessage } from "../types";
import { type T, buildMessage } from "./helpers";
const COLOR_WORDS: Record<string, number> = {
red: 0xf85149,
green: 0x3fb950,
yellow: 0xd29922,
blue: 0x58a6ff,
purple: 0xbc8cff,
orange: 0xdb6d28,
cyan: 0x39c5cf,
gray: 0x6e7681,
};
function parseColor(color: unknown): number | undefined {
if (typeof color !== "string") return undefined;
const key = color.trim().toLowerCase();
if (COLOR_WORDS[key]) return COLOR_WORDS[key];
const hex = /^#?([0-9a-f]{6})$/i.exec(key);
return hex ? parseInt(hex[1]!, 16) : undefined;
}
function parseFields(raw: unknown): NeutralField[] | undefined {
if (!Array.isArray(raw)) return undefined;
const fields: NeutralField[] = [];
for (const f of raw) {
if (!f || typeof f !== "object") continue;
const name = (f as Record<string, unknown>).name;
const value = (f as Record<string, unknown>).value;
if (typeof name !== "string" || typeof value !== "string") continue;
fields.push({
name: name || "\u200b",
value,
inline: (f as Record<string, unknown>).inline === true,
});
}
return fields.length > 0 ? fields : undefined;
}
/**
* Renders a `custom` webhook payload (the message schema documented in
* docs/guide/configuration.md) into a NeutralMessage. Unlike forge events the
* title is not required to start with a repo; an optional `repo` field is
* used as the `{repo}: ` prefix and footer.
*/
export function formatCustom(
payload: Record<string, unknown>,
repo: string | undefined,
author: NeutralAuthor,
t: T,
_showEmoji: boolean,
): NeutralMessage {
const title =
typeof payload.title === "string" && payload.title.trim()
? payload.title.trim()
: t("custom.title_fallback");
const payloadRepo =
typeof payload.repo === "string" && payload.repo.trim() ? payload.repo.trim() : undefined;
const effectiveRepo = payloadRepo ?? repo;
const fullTitle = effectiveRepo ? `${effectiveRepo}: ${title}` : title;
const description = typeof payload.description === "string" ? payload.description : undefined;
const url = typeof payload.url === "string" ? payload.url : undefined;
const footer = typeof payload.footer === "string" ? payload.footer : undefined;
const rawAuthor = payload.author as Record<string, unknown> | undefined;
const customAuthor: NeutralAuthor | undefined =
rawAuthor && typeof rawAuthor === "object"
? {
name: typeof rawAuthor.name === "string" && rawAuthor.name ? rawAuthor.name : author.name,
iconUrl: typeof rawAuthor.iconUrl === "string" ? rawAuthor.iconUrl : author.iconUrl,
url: typeof rawAuthor.url === "string" ? rawAuthor.url : author.url,
}
: undefined;
return buildMessage(
{
author: customAuthor,
title: fullTitle,
url,
color: parseColor(payload.color) ?? 0x6e7681,
description,
fields: parseFields(payload.fields),
// A custom message without a repo gets no `{repo}` footer at all.
footer: footer ?? (effectiveRepo ? undefined : ""),
},
t,
effectiveRepo,
);
}

View file

@ -21,6 +21,7 @@ import { formatRepository } from "./repository";
import { formatCodeScanningAlert, formatDependabotAlert } from "./security";
import { formatGeneric } from "./generic";
import { formatPing } from "./ping";
import { formatCustom } from "./custom";
export function formatEvent(
route: Route,
@ -100,6 +101,8 @@ export function formatEvent(
return formatCodeScanningAlert(payload, repo, author, t, showEmoji);
case "dependabot_alert":
return formatDependabotAlert(payload, repo, author, t, showEmoji);
case "custom":
return formatCustom(payload, repo, author, t, showEmoji);
default:
return formatGeneric(eventType, payload, repo, author, t, showEmoji);
}

View file

@ -195,6 +195,9 @@ export const en = {
title: "{repo}: {event}{action}",
},
},
custom: {
title_fallback: "Custom message",
},
log: {
title: "{repo}: {event}{action}",
routes: "Routes",

View file

@ -195,6 +195,9 @@ export const zh = {
title: "{repo}: {event}{action}",
},
},
custom: {
title_fallback: "自定义消息",
},
log: {
title: "{repo}: {event}{action}",
routes: "路由",

View file

@ -0,0 +1,44 @@
import type { Env, WebhookEvent } from "../../types";
import type { Provider } from "../types";
import { verifySignature } from "../github/verify";
/**
* Custom webhook provider: accepts arbitrary JSON posts (monitoring, CI,
* scripts, ...) that are not signed by a forge. The sender signs the raw body
* with the tenant's secret using the GitHub-style `sha256=<hex>` HMAC header
* `X-WebHooker-Signature`. Payloads become `custom` events that flow through
* the normal route matching pipeline (a route with `event: custom`).
*/
export const customProvider: Provider = {
id: "custom",
matches(headers) {
return (
headers["x-github-event"] === undefined &&
headers["x-gitea-event"] === undefined &&
headers["x-webhooker-signature"] !== undefined
);
},
async verify(body, headers, env: Env) {
// The tenant webhook handler overrides GITHUB_WEBHOOK_SECRET with the
// group's secret; on the legacy global endpoint this falls back to the
// operator's global secret.
return verifySignature(body, headers["x-webhooker-signature"], env.GITHUB_WEBHOOK_SECRET);
},
parse(body, _headers): WebhookEvent | null {
try {
const payload = JSON.parse(body) as Record<string, unknown>;
if (!payload || typeof payload !== "object") return null;
// Optional id for sender-side dedup (retries from monitoring systems).
const deliveryId =
typeof payload.deliveryId === "string" && payload.deliveryId
? payload.deliveryId
: undefined;
return { event: "custom", provider: "custom", payload, deliveryId };
} catch {
return null;
}
},
};

View file

@ -8,8 +8,12 @@ export function parseEvent(headers: Record<string, string>, body: string): Webho
if (!event) return null;
try {
const payload = JSON.parse(body);
return { provider: "github", event, payload, signature, deliveryId };
const payload = JSON.parse(body) as Record<string, unknown>;
const installationId =
typeof (payload.installation as { id?: unknown } | undefined)?.id === "number"
? (payload.installation as { id: number }).id
: undefined;
return { provider: "github", event, payload, signature, deliveryId, installationId };
} catch {
return null;
}

View file

@ -1,6 +1,7 @@
import type { Provider } from "./types";
import { githubProvider } from "./github";
import { giteaProvider } from "./gitea";
import { customProvider } from "./custom";
export type { Provider } from "./types";
export { verifySignature } from "./github/verify";
@ -9,9 +10,10 @@ export { verifySignature } from "./github/verify";
* Detection order matters: Gitea webhooks also send GitHub-compatible headers
* (`X-GitHub-Event`, `X-Hub-Signature-256`, ...), so a Gitea request would
* match the GitHub provider too. Check Gitea first real GitHub requests
* never send `X-Gitea-Event`.
* never send `X-Gitea-Event`. Custom requests carry none of the forge headers,
* only `X-WebHooker-Signature`, so they are checked last.
*/
const providers: Provider[] = [giteaProvider, githubProvider];
const providers: Provider[] = [giteaProvider, githubProvider, customProvider];
/**
* Pick the webhook provider for a request based on its headers (e.g.

View file

@ -7,7 +7,7 @@ import type { Env, WebhookEvent } from "../types";
* {@link WebhookEvent} and never knows which forge produced it.
*/
export interface Provider {
readonly id: "github" | "gitea" | "gitlab";
readonly id: "github" | "gitea" | "gitlab" | "custom";
/**
* Whether the request headers belong to this provider (e.g. checks the
* `X-Gitea-Event` header).

View file

@ -1,7 +1,6 @@
import { Hono } from "hono";
import type { Env } from "./types";
import { detectProvider } from "./providers";
import { dispatchEvent } from "./core/dispatch";
import { handleWebhook } from "./webhook";
import { handleInteractionRequest } from "./drivers/discord/interactions";
import { handleTelegramWebhookRequest } from "./drivers/telegram/updates";
import { createOAuthRoutes } from "./web/oauth-routes";
@ -10,10 +9,6 @@ import { createAdminRoutes } from "./web/admin-routes";
import { createLegalRoutes } from "./web/legal-routes";
import { createHomeRoutes } from "./web/home-routes";
import { createRichHeaderRoutes } from "./web/richheader-routes";
import { loadConfig } from "./config";
import { log } from "./lib/log";
const MAX_BODY_SIZE = 1024 * 1024;
export function createServer(): Hono<{ Bindings: Env }> {
const app = new Hono<{ Bindings: Env }>();
@ -27,52 +22,8 @@ export function createServer(): Hono<{ Bindings: Env }> {
app.route("/admin", createAdminRoutes());
app.route("/api", createRichHeaderRoutes());
app.post("/webhook", async (c) => {
const contentLength = Number(c.req.header("content-length") ?? 0);
if (contentLength > MAX_BODY_SIZE) {
return c.json({ error: "Request too large" }, 413);
}
const body = await c.req.text();
if (body.length > MAX_BODY_SIZE) {
return c.json({ error: "Request too large" }, 413);
}
const headers: Record<string, string> = {};
c.req.raw.headers.forEach((value, key) => {
headers[key] = value;
});
const provider = detectProvider(headers);
if (!provider) {
return c.json({ error: "Unknown webhook provider" }, 400);
}
if (!(await provider.verify(body, headers, c.env))) {
return c.json({ error: "Invalid signature" }, 401);
}
const event = provider.parse(body, headers);
if (!event) {
return c.json({ error: "Invalid event" }, 400);
}
if (event.deliveryId) {
const seen = await c.env.KV.get(`delivery:${event.deliveryId}`);
if (seen) {
return c.json({ ok: true, duplicate: true });
}
await c.env.KV.put(`delivery:${event.deliveryId}`, "1", { expirationTtl: 300 });
}
const config = await loadConfig(c.env);
const dispatch = dispatchEvent(config, event, c.env).catch((err) =>
log.error(err, "Dispatch failed"),
);
c.executionCtx.waitUntil(dispatch);
return c.json({ ok: true });
});
app.post("/webhook", (c) => handleWebhook(c));
app.post("/webhook/:groupId", (c) => handleWebhook(c, c.req.param("groupId")));
app.post("/discord/interactions", (c) => handleInteractionRequest(c.req.raw, c.env));
app.post("/telegram/webhook", (c) => handleTelegramWebhookRequest(c.req.raw, c.env));

View file

@ -114,6 +114,13 @@ export interface Group {
* (e.g. `["github"]`, `["gitea"]`). Empty/omitted = all providers.
*/
providers?: WebhookProvider[];
/**
* GitHub App installation id bound to this group. When set, only webhook
* events coming from that installation (org/user) are accepted into the
* group's routes hard tenant isolation on top of (or instead of) the
* `owners` list. Empty/omitted = no installation restriction.
*/
installationId?: number;
/**
* Whether to include emoji in messages sent through this group's routes.
* Defaults to true when omitted.
@ -137,7 +144,7 @@ export interface Filter {
exclude?: boolean;
}
export type WebhookProvider = "github" | "gitea" | "gitlab";
export type WebhookProvider = "github" | "gitea" | "gitlab" | "custom";
export interface WebhookEvent {
event: string;
@ -145,6 +152,11 @@ export interface WebhookEvent {
signature?: string;
deliveryId?: string;
provider?: WebhookProvider;
/**
* GitHub App installation id that produced this event (extracted from
* `payload.installation.id`). Gitea/custom events have none.
*/
installationId?: number;
}
export interface NeutralAuthor {

View file

@ -16,6 +16,7 @@ import {
import { getSendLog, getSendLogById } from "../lib/send-log";
import { getAuditLog, recordAudit } from "../lib/audit";
import { createInvite, listInvites, revokeInvite, getInvite, acceptInvite } from "./invites";
import { getTenantSecret, setTenantSecret, deleteTenantSecret } from "./tenants";
import { log } from "../lib/log";
const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]);
@ -277,6 +278,13 @@ export function validateGroups(
error: `group "${g.id}".providers must be a list of "github" | "gitea" | "gitlab"`,
};
}
if (g.installationId !== undefined && g.installationId !== null) {
if (typeof g.installationId !== "number" || !Number.isInteger(g.installationId)) {
return { ok: false, error: `group "${g.id}".installationId must be an integer` };
}
} else {
delete g.installationId;
}
if (g.emoji !== undefined && typeof g.emoji !== "boolean") {
return { ok: false, error: `group "${g.id}".emoji must be a boolean` };
}
@ -463,6 +471,7 @@ export function createAdminRoutes(): Hono<AuthEnv> {
if (prev.lang !== g.lang) fields.push("lang");
if (!deepEqual(prev.logTarget, g.logTarget)) fields.push("logTarget");
if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers");
if (prev.installationId !== g.installationId) fields.push("installationId");
if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners");
if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members))
fields.push("members");
@ -490,6 +499,7 @@ export function createAdminRoutes(): Hono<AuthEnv> {
groupId: g.id,
ip: clientIp(c),
});
await deleteTenantSecret(c.env.KV, g.id).catch(() => undefined);
}
}
@ -746,6 +756,76 @@ export function createAdminRoutes(): Hono<AuthEnv> {
return c.json({ ok: true });
});
// ---- Group webhook ingress (owner +) ----
app.get("/api/groups/:groupId/webhook", requireAnyAccess(), async (c) => {
const groupId = param(c, "groupId");
const access = requireGroupRole(c, groupId, "owner");
if (!access.ok) {
return c.json(
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
access.status,
);
}
const origin = c.env.BASE_URL ?? new URL(c.req.url).origin;
return c.json({
url: `${origin.replace(/\/$/, "")}/webhook/${groupId}`,
hasSecret: (await getTenantSecret(c.env.KV, groupId)) != null,
});
});
app.post("/api/groups/:groupId/webhook/regenerate", requireAnyAccess(), async (c) => {
const groupId = param(c, "groupId");
const access = requireGroupRole(c, groupId, "owner");
if (!access.ok) {
return c.json(
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
access.status,
);
}
const secret = await setTenantSecret(c.env.KV, groupId);
const auth = currentAuth(c);
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "webhook.secret.regenerate",
targetType: "group",
targetId: groupId,
groupId,
ip: clientIp(c),
});
const origin = c.env.BASE_URL ?? new URL(c.req.url).origin;
return c.json({
ok: true,
url: `${origin.replace(/\/$/, "")}/webhook/${groupId}`,
secret,
});
});
app.delete("/api/groups/:groupId/webhook", requireAnyAccess(), async (c) => {
const groupId = param(c, "groupId");
const access = requireGroupRole(c, groupId, "owner");
if (!access.ok) {
return c.json(
{ error: access.status === 404 ? "Group not found" : "Forbidden" },
access.status,
);
}
await deleteTenantSecret(c.env.KV, groupId);
const auth = currentAuth(c);
await recordAudit(c.env.DB, {
ts: Date.now(),
actorId: auth.session.userId,
actorLogin: auth.session.login,
action: "webhook.secret.delete",
targetType: "group",
targetId: groupId,
groupId,
ip: clientIp(c),
});
return c.json({ ok: true });
});
// ---- Audit log (any access; group admins see only their groups) ----
app.get("/api/audit", requireAnyAccess(), async (c) => {
const auth = currentAuth(c);

View file

@ -96,6 +96,62 @@ export function groupAcceptsProvider(group: Group, provider?: string): boolean {
return allowed.includes(provider ?? "github");
}
/**
* Whether an event from a GitHub App `installationId` is allowed into this
* group. A group bound to an installation only accepts events from that
* installation (hard tenant isolation). Unbound groups accept everything here
* (their access is governed by `owners`/`providers` instead).
*/
export function groupAcceptsInstallation(group: Group, installationId?: number): boolean {
if (group.installationId == null) return true;
return installationId != null && group.installationId === installationId;
}
/**
* Auto-provision a GitHub App installation on `installation.created`:
* 1. No-op when a group is already bound to this installation id.
* 2. Otherwise bind every unbound group whose `owners` match the installing
* account login (so existing org groups light up automatically).
* 3. Otherwise create a dedicated `inst-{installationId}` group bound to the
* installation. Returns the group that now owns the installation.
*/
export async function ensureInstallationGroup(
kv: KVNamespace,
installationId: number,
accountLogin: string,
): Promise<Group | null> {
const groups = await loadGroups(kv);
const existing = groups.find((g) => g.installationId === installationId);
if (existing) return existing;
const login = accountLogin.trim().toLowerCase();
const candidates = groups.filter(
(g) =>
g.installationId == null &&
login.length > 0 &&
(g.owners ?? []).some((o) => o.trim().toLowerCase() === login),
);
if (candidates.length > 0) {
const next = groups.map((g) => (candidates.includes(g) ? { ...g, installationId } : g));
await saveGroups(kv, next);
return next.find((g) => g.id === candidates[0]!.id) ?? null;
}
const gid = `inst-${installationId}`;
const dedicated = groups.find((g) => g.id === gid);
const group: Group = {
id: gid,
name: accountLogin.trim() || `Installation ${installationId}`,
adminIds: [],
installationId,
};
await saveGroups(
kv,
dedicated ? groups.map((g) => (g.id === gid ? { ...g, ...group } : g)) : [...groups, group],
);
return group;
}
export interface AccessScope {
isSuper: boolean;
/** Groups the user may view. When isSuper, this is every group. */

38
src/web/tenants.ts Normal file
View file

@ -0,0 +1,38 @@
import { log } from "../lib/log";
/**
* Per-group webhook tenant secrets. A group can opt into its own webhook
* ingress (`POST /webhook/{groupId}`) with an independent secret, so SaaS
* users can configure their own GitHub/Gitea/custom webhooks without sharing
* (or even knowing) the operator's global secrets.
*/
const TENANT_KEY = (groupId: string): string => `tenant:${groupId}`;
/** 32 random bytes → 64 hex chars. */
export function generateTenantSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function getTenantSecret(kv: KVNamespace, groupId: string): Promise<string | null> {
try {
return await kv.get(TENANT_KEY(groupId), "text");
} catch (err) {
log.warn({ err, groupId }, "Failed to read tenant webhook secret");
return null;
}
}
/** Generate (or regenerate) the group's webhook secret. */
export async function setTenantSecret(kv: KVNamespace, groupId: string): Promise<string> {
const secret = generateTenantSecret();
await kv.put(TENANT_KEY(groupId), secret);
return secret;
}
export async function deleteTenantSecret(kv: KVNamespace, groupId: string): Promise<void> {
await kv.delete(TENANT_KEY(groupId));
}

144
src/webhook.ts Normal file
View file

@ -0,0 +1,144 @@
import type { Context } from "hono";
import type { Env } from "./types";
import { detectProvider } from "./providers";
import { dispatchEvent } from "./core/dispatch";
import { loadConfig } from "./config";
import { loadGroups, ensureInstallationGroup } from "./web/groups";
import { getTenantSecret } from "./web/tenants";
import { recordAudit } from "./lib/audit";
import { log } from "./lib/log";
const MAX_BODY_SIZE = 1024 * 1024;
export interface WebhookResult {
status: 200 | 400 | 401 | 404 | 413;
body: unknown;
}
/**
* Core webhook processing. Without `tenantId` this is the legacy global
* endpoint (`POST /webhook`): events verify against the operator's global
* secrets and may dispatch into every route. With a `tenantId` (a group id,
* `POST /webhook/{groupId}`) the group's own secret is used for verification
* (GITHUB_WEBHOOK_SECRET/GITEA_WEBHOOK_SECRET are overridden per request) and
* only that group's routes are eligible.
*/
export async function processWebhook(
env: Env,
body: string,
headers: Record<string, string>,
waitUntil: (promise: Promise<unknown>) => void,
tenantId?: string,
): Promise<WebhookResult> {
let effectiveEnv = env;
if (tenantId) {
const groups = await loadGroups(env.KV);
if (!groups.some((g) => g.id === tenantId)) {
return { status: 404, body: { error: "Group not found" } };
}
const secret = await getTenantSecret(env.KV, tenantId);
if (!secret) {
return { status: 404, body: { error: "Webhook disabled for this group" } };
}
effectiveEnv = { ...env, GITHUB_WEBHOOK_SECRET: secret, GITEA_WEBHOOK_SECRET: secret };
}
const provider = detectProvider(headers);
if (!provider) {
return { status: 400, body: { error: "Unknown webhook provider" } };
}
if (!(await provider.verify(body, headers, effectiveEnv))) {
return { status: 401, body: { error: "Invalid signature" } };
}
const event = provider.parse(body, headers);
if (!event) {
return { status: 400, body: { error: "Invalid event" } };
}
// Auto-provision GitHub App installations so tenant isolation is configured
// without manual id entry: a group is created (or existing matching groups
// are bound) before the event is dispatched.
if (
provider.id === "github" &&
event.event === "installation" &&
event.payload.action === "created" &&
event.installationId != null
) {
const install = event.payload.installation as { account?: { login?: string } } | undefined;
const account = install?.account?.login ?? "";
try {
const group = await ensureInstallationGroup(env.KV, event.installationId, account);
if (group) {
await recordAudit(env.DB, {
ts: Date.now(),
actorLogin: account || undefined,
action: "installation.created",
targetType: "group",
targetId: group.id,
groupId: group.id,
});
}
} catch (err) {
log.warn(
{ err, installationId: event.installationId },
"Failed to auto-provision installation group",
);
}
}
if (event.deliveryId) {
// Tenant-scoped dedup keys: different accounts can reuse the same
// delivery id, so the global key would wrongly dedupe across tenants.
const key = tenantId
? `delivery:${tenantId}:${event.deliveryId}`
: `delivery:${event.deliveryId}`;
const seen = await env.KV.get(key);
if (seen) {
return { status: 200, body: { ok: true, duplicate: true } };
}
await env.KV.put(key, "1", { expirationTtl: 300 });
}
const config = await loadConfig(env);
if (tenantId) {
config.routes = config.routes.filter((r) => r.groupId === tenantId);
}
const dispatch = dispatchEvent(config, event, env).catch((err) =>
log.error(err, "Dispatch failed"),
);
waitUntil(dispatch);
return { status: 200, body: { ok: true } };
}
export async function handleWebhook(
c: Context<{ Bindings: Env }>,
tenantId?: string,
): Promise<Response> {
const contentLength = Number(c.req.header("content-length") ?? 0);
if (contentLength > MAX_BODY_SIZE) {
return c.json({ error: "Request too large" }, 413);
}
const body = await c.req.text();
if (body.length > MAX_BODY_SIZE) {
return c.json({ error: "Request too large" }, 413);
}
const headers: Record<string, string> = {};
c.req.raw.headers.forEach((value, key) => {
headers[key] = value;
});
const result = await processWebhook(
c.env,
body,
headers,
(p) => c.executionCtx.waitUntil(p),
tenantId,
);
return c.json(result.body, result.status);
}