From 9b8533cabcd8344b6fe1a0c65b5b42790b35b352 Mon Sep 17 00:00:00 2001 From: RhenCloud Date: Tue, 11 Aug 2026 23:37:19 +0800 Subject: [PATCH] feat(admin): role-based group members (owner/admin/viewer), invites and audit log - Group model: members[] with roles owner/admin/viewer; legacy adminIds resolve to owners (backward compatible); last-owner and self-demotion guards - Unified permission middleware (src/web/auth.ts): requireAnyAccess, requireGroup(Role), bearerAuthMiddleware replace per-route loadScope - Single-use 7-day invite links (invite:{token}); accept via /admin/invite or the OAuth callback; self-signup personal group via ALLOW_SELF_SIGNUP - D1 audit_logs (migration 0005): login/logout, group/route/member/invite changes; GET /admin/api/audit; scheduled prune after AUDIT_RETENTION_DAYS - Env: ALLOW_SELF_SIGNUP, AUDIT_RETENTION_DAYS --- migrations/0005_audit_logs.sql | 15 + src/index.ts | 8 + src/lib/audit.ts | 106 +++++++ src/types.ts | 28 +- src/web/action-routes.ts | 56 ++-- src/web/admin-routes.ts | 513 ++++++++++++++++++++++++++------- src/web/auth.ts | 112 +++++++ src/web/groups.ts | 83 +++++- src/web/invites.ts | 114 ++++++++ src/web/oauth-routes.ts | 82 +++++- 10 files changed, 964 insertions(+), 153 deletions(-) create mode 100644 migrations/0005_audit_logs.sql create mode 100644 src/lib/audit.ts create mode 100644 src/web/auth.ts create mode 100644 src/web/invites.ts diff --git a/migrations/0005_audit_logs.sql b/migrations/0005_audit_logs.sql new file mode 100644 index 0000000..721f4b2 --- /dev/null +++ b/migrations/0005_audit_logs.sql @@ -0,0 +1,15 @@ +-- Audit trail for admin operations (logins, group/route/member/invite changes). +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + actor_id TEXT, + actor_login TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + group_id TEXT, + detail TEXT, + ip TEXT +); +CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_logs (ts DESC); +CREATE INDEX IF NOT EXISTS idx_audit_group ON audit_logs (group_id); diff --git a/src/index.ts b/src/index.ts index adf3ce8..88dc19d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { createServer } from "./server"; import { syncCommands } from "./drivers/discord/commands"; import { syncTelegramWebhook } from "./drivers/telegram/commands"; +import { pruneAuditLogs } from "./lib/audit"; import type { Env } from "./types"; import { log } from "./lib/log"; @@ -22,5 +23,12 @@ export default { } catch (err) { log.error({ err }, "Telegram webhook sync from cron failed"); } + try { + const days = Math.max(Number(env.AUDIT_RETENTION_DAYS ?? 90) || 90, 1); + const removed = await pruneAuditLogs(env.DB, days); + if (removed > 0) log.info({ removed }, "Pruned audit logs"); + } catch (err) { + log.error({ err }, "Audit log prune from cron failed"); + } }, }; diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..9aac8aa --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,106 @@ +import { log } from "./log"; + +export interface AuditEntry { + id?: number; + ts: number; + actorId?: string; + actorLogin?: string; + /** Machine-readable action, e.g. "session.login", "group.update", "invite.create". */ + action: string; + targetType?: string; + targetId?: string; + groupId?: string; + /** Free-form metadata. Never include secrets or message bodies. */ + detail?: Record; + ip?: string; +} + +const COLUMNS = "id, ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip"; + +interface AuditRow { + id: number; + ts: number; + actor_id: string | null; + actor_login: string | null; + action: string; + target_type: string | null; + target_id: string | null; + group_id: string | null; + detail: string | null; + ip: string | null; +} + +function toEntry(r: AuditRow): AuditEntry { + return { + id: r.id, + ts: r.ts, + actorId: r.actor_id ?? undefined, + actorLogin: r.actor_login ?? undefined, + action: r.action, + targetType: r.target_type ?? undefined, + targetId: r.target_id ?? undefined, + groupId: r.group_id ?? undefined, + detail: r.detail ? (JSON.parse(r.detail) as Record) : undefined, + ip: r.ip ?? undefined, + }; +} + +/** Best-effort write; failures never break the business flow. */ +export async function recordAudit(db: D1Database, entry: AuditEntry): Promise { + try { + await db + .prepare( + `INSERT INTO audit_logs (ts, actor_id, actor_login, action, target_type, target_id, group_id, detail, ip) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + entry.ts, + entry.actorId ?? null, + entry.actorLogin ?? null, + entry.action, + entry.targetType ?? null, + entry.targetId ?? null, + entry.groupId ?? null, + entry.detail ? JSON.stringify(entry.detail) : null, + entry.ip ?? null, + ) + .run(); + } catch (err) { + log.warn({ err, action: entry.action }, "Failed to record audit entry"); + } +} + +export async function getAuditLog( + db: D1Database, + opts: { groupId?: string; limit?: number } = {}, +): Promise { + const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); + try { + if (opts.groupId) { + const { results } = await db + .prepare(`SELECT ${COLUMNS} FROM audit_logs WHERE group_id = ? ORDER BY ts DESC LIMIT ?`) + .bind(opts.groupId, limit) + .all(); + return results.map(toEntry); + } + const { results } = await db + .prepare(`SELECT ${COLUMNS} FROM audit_logs ORDER BY ts DESC LIMIT ?`) + .bind(limit) + .all(); + return results.map(toEntry); + } catch (err) { + log.warn({ err }, "Failed to load audit log"); + return []; + } +} + +export async function pruneAuditLogs(db: D1Database, retentionDays: number): Promise { + const cutoff = Date.now() - retentionDays * 86400_000; + try { + const { meta } = await db.prepare("DELETE FROM audit_logs WHERE ts < ?").bind(cutoff).run(); + return meta.changes; + } catch (err) { + log.warn({ err }, "Failed to prune audit logs"); + return 0; + } +} diff --git a/src/types.ts b/src/types.ts index c5a6452..57fd5bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,13 @@ export interface Env { TELEGRAM_TOKEN?: string; TELEGRAM_WEBHOOK_SECRET?: string; TELEGRAM_RICH_HEADER_HOST?: string; + /** + * When enabled ("1"/"true"), GitHub users without any group access get a + * personal group on first login instead of being blocked. + */ + ALLOW_SELF_SIGNUP?: string; + /** Audit log retention in days (default 90). */ + AUDIT_RETENTION_DAYS?: string; ASSETS?: Fetcher; KV: KVNamespace; DB: D1Database; @@ -72,14 +79,31 @@ export interface Route { discordRoleIds?: string[]; } +export type GroupRole = "owner" | "admin" | "viewer"; + +export interface GroupMember { + /** + * GitHub login (case-insensitive). Ids are matched when stored, logins + * otherwise; identityMatches handles both. + */ + login: string; + role: GroupRole; +} + export interface Group { id: string; name: string; /** - * GitHub user ids or logins (case-insensitive) allowed to manage this group. - * Super admins (ADMIN_USER_IDS) always have access regardless of this list. + * Deprecated legacy field: GitHub user ids or logins allowed to manage this + * group. Kept for backward compatibility — when `members` is absent, these + * are normalized to `members` with role "owner". New writes use `members`. */ adminIds: string[]; + /** + * Group members with roles. Roles: owner (manage group + members + invites), + * admin (manage routes), viewer (read-only). Super admins always bypass. + */ + members?: GroupMember[]; /** * GitHub organization/user logins (case-insensitive) whose webhook events are * allowed into this group's routes. Empty/omitted = no owner restriction. diff --git a/src/web/action-routes.ts b/src/web/action-routes.ts index feea0e2..e8ec295 100644 --- a/src/web/action-routes.ts +++ b/src/web/action-routes.ts @@ -1,17 +1,8 @@ import { Hono } from "hono"; import { getUserOctokit } from "../github/oauth"; -import { findUserIdByToken } from "../github/store"; -import type { Env } from "../types"; +import { bearerAuthMiddleware, type AuthEnv } from "./auth"; import { log } from "../lib/log"; -function extractBearerToken(c: { - req: { header: (name: string) => string | undefined }; -}): string | null { - const auth = c.req.header("authorization"); - if (!auth?.startsWith("Bearer ")) return null; - return auth.slice(7); -} - function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.length > 0; } @@ -30,15 +21,11 @@ async function readJson(c: { } } -export function createActionRoutes(): Hono<{ Bindings: Env }> { - const app = new Hono<{ Bindings: Env }>(); - - app.post("/api/comment", async (c) => { - const token = extractBearerToken(c); - if (!token) return c.json({ error: "Missing authorization" }, 401); - const userId = await findUserIdByToken(c.env.KV, token); - if (!userId) return c.json({ error: "Invalid or expired token" }, 401); +export function createActionRoutes(): Hono { + const app = new Hono(); + app.post("/api/comment", bearerAuthMiddleware(), async (c) => { + const userId = c.get("userId"); const body = await readJson(c); if ( !body || @@ -67,12 +54,8 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> { return c.json({ ok: true }); }); - app.post("/api/merge", async (c) => { - const token = extractBearerToken(c); - if (!token) return c.json({ error: "Missing authorization" }, 401); - const userId = await findUserIdByToken(c.env.KV, token); - if (!userId) return c.json({ error: "Invalid or expired token" }, 401); - + app.post("/api/merge", bearerAuthMiddleware(), async (c) => { + const userId = c.get("userId"); const body = await readJson(c); if ( !body || @@ -104,12 +87,8 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> { return c.json({ ok: true }); }); - app.post("/api/close", async (c) => { - const token = extractBearerToken(c); - if (!token) return c.json({ error: "Missing authorization" }, 401); - const userId = await findUserIdByToken(c.env.KV, token); - if (!userId) return c.json({ error: "Invalid or expired token" }, 401); - + app.post("/api/close", bearerAuthMiddleware(), async (c) => { + const userId = c.get("userId"); const body = await readJson(c); if ( !body || @@ -137,12 +116,8 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> { return c.json({ ok: true }); }); - app.post("/api/react", async (c) => { - const token = extractBearerToken(c); - if (!token) return c.json({ error: "Missing authorization" }, 401); - const userId = await findUserIdByToken(c.env.KV, token); - if (!userId) return c.json({ error: "Invalid or expired token" }, 401); - + app.post("/api/react", bearerAuthMiddleware(), async (c) => { + const userId = c.get("userId"); const body = await readJson(c); const reactions = [ "+1", @@ -173,7 +148,14 @@ export function createActionRoutes(): Hono<{ Bindings: Env }> { repo: body.repo, issue_number: body.issueNumber, content: body.reaction as - "+1" | "-1" | "laugh" | "confused" | "heart" | "hooray" | "rocket" | "eyes", + | "+1" + | "-1" + | "laugh" + | "confused" + | "heart" + | "hooray" + | "rocket" + | "eyes", }); } catch (err) { log.error({ err }, "Failed to create reaction"); diff --git a/src/web/admin-routes.ts b/src/web/admin-routes.ts index 6004f8a..8dc0ec7 100644 --- a/src/web/admin-routes.ts +++ b/src/web/admin-routes.ts @@ -1,14 +1,21 @@ import { Hono } from "hono"; -import type { Env, Route, Group } from "../types"; +import type { Route, Group, GroupMember, GroupRole } from "../types"; import { loadRoutes, saveRoutes } from "../config"; +import { getAdminSession, destroyAdminSession, clearAdminCookie } from "./session"; +import { saveGroups, loadGroups, identityMatches, normalizeGroupMembers } from "./groups"; import { - getAdminSession, - destroyAdminSession, - clearAdminCookie, - type AdminSession, -} from "./session"; -import { loadGroups, saveGroups, resolveScope, hasAnyAccess, type AccessScope } from "./groups"; + sessionMiddleware, + requireAnyAccess, + currentAuth, + requireGroup, + requireGroupRole, + roleAt, + clientIp, + type AuthEnv, +} from "./auth"; import { getSendLog, getSendLogById } from "../lib/send-log"; +import { getAuditLog, recordAudit } from "../lib/audit"; +import { createInvite, listInvites, revokeInvite, getInvite, acceptInvite } from "./invites"; import { log } from "../lib/log"; const VALID_FILTER_TYPES = new Set(["event", "repo", "actor", "action", "branch", "keyword"]); @@ -178,6 +185,58 @@ function validateTarget( }; } +function validateMembers( + g: Record, + gid: string, +): { ok: true; members: GroupMember[] } | { ok: false; error: string } { + const raw = g.members; + if (raw === undefined || raw === null) { + // Legacy payload without members: derive owners from adminIds. + const adminIds = g.adminIds; + if (!Array.isArray(adminIds)) { + return { ok: false, error: `group "${gid}" needs a members list` }; + } + if (!adminIds.every((a) => typeof a === "string" && a.trim().length > 0)) { + return { ok: false, error: `group "${gid}".adminIds must be a list of strings` }; + } + const members: GroupMember[] = []; + const seen = new Set(); + for (const a of adminIds as string[]) { + const login = a.trim(); + if (!login || seen.has(login.toLowerCase())) continue; + seen.add(login.toLowerCase()); + members.push({ login, role: "owner" }); + } + return { ok: true, members }; + } + if (!Array.isArray(raw)) return { ok: false, error: `group "${gid}".members must be an array` }; + const seen = new Set(); + const members: GroupMember[] = []; + for (let i = 0; i < raw.length; i++) { + const m = raw[i] as Record; + if (!m || typeof m !== "object") + return { ok: false, error: `group "${gid}".members[${i}] is not an object` }; + const login = typeof m.login === "string" ? m.login.trim() : ""; + if (!login) return { ok: false, error: `group "${gid}".members[${i}].login is required` }; + const role = m.role; + if (role !== "owner" && role !== "admin" && role !== "viewer") { + return { + ok: false, + error: `group "${gid}".members[${i}].role must be "owner" | "admin" | "viewer"`, + }; + } + if (seen.has(login.toLowerCase())) { + return { ok: false, error: `group "${gid}" has duplicate member "${login}"` }; + } + seen.add(login.toLowerCase()); + members.push({ login, role }); + } + if (members.length > 0 && !members.some((m) => m.role === "owner")) { + return { ok: false, error: `group "${gid}" needs at least one owner` }; + } + return { ok: true, members }; +} + function validateGroups( groups: unknown, ): { ok: true; groups: Group[] } | { ok: false; error: string } { @@ -196,12 +255,12 @@ function validateGroups( if (typeof g.name !== "string" || g.name.trim().length === 0) { return { ok: false, error: `group "${g.id}" needs a name` }; } - if ( - !Array.isArray(g.adminIds) || - !g.adminIds.every((a) => typeof a === "string" && a.trim().length > 0) - ) { - return { ok: false, error: `group "${g.id}".adminIds must be a list of strings` }; - } + const mres = validateMembers(g, g.id); + if (!mres.ok) return mres; + // `members` is the single source of truth; adminIds stays in sync so + // legacy consumers (isGroupAdmin, older UI) keep working. + g.members = mres.members; + g.adminIds = mres.members.filter((m) => m.role === "owner").map((m) => m.login); if ( g.owners !== undefined && (!Array.isArray(g.owners) || @@ -228,52 +287,95 @@ function validateGroups( return { ok: true, groups: groups as Group[] }; } -export function createAdminRoutes(): Hono<{ Bindings: Env }> { - const app = new Hono<{ Bindings: Env }>(); +function ownerCount(members: GroupMember[]): number { + return members.filter((m) => m.role === "owner").length; +} - async function loadScope(c: { - env: Env; - req: { header: (name: string) => string | undefined }; - }): Promise<{ session: AdminSession; scope: AccessScope; groups: Group[] } | null> { - const session = await getAdminSession(c.env.KV, c.req.header("cookie")); - if (!session) return null; - const groups = await loadGroups(c.env.KV); - const scope = resolveScope(c.env, groups, session.userId, session.login); - if (!hasAnyAccess(scope)) return null; - return { session, scope, groups }; - } +/** Route params are always present for matched paths; keeps Hono's loose typing honest. */ +function param( + c: { req: { param: (name: string) => string | undefined } }, + name: string, +): string { + return c.req.param(name) ?? ""; +} + +export function createAdminRoutes(): Hono { + const app = new Hono(); app.get("/login", (c) => { return c.redirect("/auth/github?redirect=/admin"); }); app.get("/logout", async (c) => { + const session = await getAdminSession(c.env.KV, c.req.header("cookie")); + if (session) { + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: session.userId, + actorLogin: session.login, + action: "session.logout", + ip: clientIp(c), + }); + } await destroyAdminSession(c.env.KV, c.req.header("cookie")); c.header("Set-Cookie", clearAdminCookie()); return c.redirect("/admin"); }); - app.get("/api/me", async (c) => { - const s = await loadScope(c); - if (!s) return c.json({ error: "Unauthorized" }, 401); + // Browser page that accepts a group invite. Not logged in �?OAuth first, + // carrying the same invite URL as the redirect target. + app.get("/invite", sessionMiddleware(), async (c) => { + const token = c.req.query("token"); + if (!token) return c.redirect("/admin"); + const auth = c.get("auth"); + if (!auth) { + return c.redirect(`/auth/github?redirect=${encodeURIComponent(`/admin/invite?token=${token}`)}`); + } + const result = await acceptInvite(c.env.KV, token, auth.session.userId, auth.session.login); + if (result.ok) { + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: auth.session.userId, + actorLogin: auth.session.login, + action: "invite.accept", + targetType: "group", + targetId: result.groupId, + groupId: result.groupId, + detail: { role: result.role }, + ip: clientIp(c), + }); + } + return c.redirect(`/admin?invite=${result.ok ? "ok" : result.reason}`); + }); + + app.get("/api/me", requireAnyAccess(), async (c) => { + const auth = currentAuth(c); + const roles: Record = {}; + for (const g of auth.scope.groups) { + const role = roleAt(auth.scope, g.id); + if (role) roles[g.id] = role; + } return c.json({ - login: s.session.login, - userId: s.session.userId, - isSuper: s.scope.isSuper, - groups: s.scope.groups, + login: auth.session.login, + userId: auth.session.userId, + isSuper: auth.scope.isSuper, + groups: auth.scope.groups, + roles, }); }); - app.get("/api/groups", async (c) => { - const s = await loadScope(c); - if (!s) return c.json({ error: "Unauthorized" }, 401); - return c.json({ groups: s.scope.groups, isSuper: s.scope.isSuper }); + app.get("/api/groups", requireAnyAccess(), async (c) => { + const auth = currentAuth(c); + const roles: Record = {}; + for (const g of auth.scope.groups) { + const role = roleAt(auth.scope, g.id); + if (role) roles[g.id] = role; + } + return c.json({ groups: auth.scope.groups, isSuper: auth.scope.isSuper, roles }); }); - app.put("/api/groups", async (c) => { - const s = await loadScope(c); - if (!s) return c.json({ error: "Unauthorized" }, 401); - if (!s.scope.isSuper) return c.json({ error: "Forbidden" }, 403); + app.put("/api/groups", requireAnyAccess(), async (c) => { + const auth = currentAuth(c); let body: unknown; try { body = await c.req.json(); @@ -282,57 +384,124 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> { } const result = validateGroups((body as { groups?: unknown })?.groups); if (!result.ok) return c.json({ error: result.error }, 400); + + const existing = await loadGroups(c.env.KV); + const prevById = new Map(existing.map((g) => [g.id, g])); + let nextAll: Group[]; + + if (auth.scope.isSuper) { + // Super admins see and submit every group: full replace. + nextAll = result.groups; + } else { + // Owners may only write groups they own. Preserve every other group and + // never let a submission drop the last owner of a group. + const mine = new Set(); + for (const [gid, role] of auth.scope.roles) { + if (role === "owner") mine.add(gid); + } + for (const g of result.groups) { + if (!mine.has(g.id)) { + return c.json({ error: `group "${g.id}" is outside your ownership` }, 403); + } + const members = g.members ?? normalizeGroupMembers(g); + const stillMine = members.some( + (m) => + m.role === "owner" && + identityMatches([m.login], auth.session.userId, auth.session.login), + ); + const otherOwner = ownerCount(members) > 1; + if (!stillMine && !otherOwner) { + return c.json( + { error: `group "${g.id}" would be left without an owner by you` }, + 403, + ); + } + } + nextAll = [ + ...existing.filter((g) => !mine.has(g.id)), + ...result.groups.map((g) => { + const prev = prevById.get(g.id); + if (prev && prev.owners !== undefined && g.owners === undefined) { + // Owners cannot edit the `owners` scope; keep the stored value. + return { ...g, owners: prev.owners }; + } + return g; + }), + ]; + } + + // Audit every create / update / delete. + const prevGroups = existing; + const nextById = new Map(nextAll.map((g) => [g.id, g])); + const actor = { actorId: auth.session.userId, actorLogin: auth.session.login }; + for (const g of nextAll) { + const prev = prevById.get(g.id); + if (!prev) { + await recordAudit(c.env.DB, { + ts: Date.now(), + ...actor, + action: "group.create", + targetType: "group", + targetId: g.id, + groupId: g.id, + ip: clientIp(c), + }); + continue; + } + const fields: string[] = []; + if (prev.name !== g.name) fields.push("name"); + if (prev.emoji !== g.emoji) fields.push("emoji"); + if (!deepEqual(prev.providers ?? [], g.providers ?? [])) fields.push("providers"); + if (!deepEqual(prev.owners ?? [], g.owners ?? [])) fields.push("owners"); + if (!deepEqual(prev.members ?? normalizeGroupMembers(prev), g.members)) fields.push("members"); + if (fields.length > 0) { + await recordAudit(c.env.DB, { + ts: Date.now(), + ...actor, + action: "group.update", + targetType: "group", + targetId: g.id, + groupId: g.id, + detail: { fields }, + ip: clientIp(c), + }); + } + } + for (const g of prevGroups) { + if (!nextById.has(g.id)) { + await recordAudit(c.env.DB, { + ts: Date.now(), + ...actor, + action: "group.delete", + targetType: "group", + targetId: g.id, + groupId: g.id, + ip: clientIp(c), + }); + } + } + try { - await saveGroups(c.env.KV, result.groups); + await saveGroups(c.env.KV, nextAll); } catch (err) { log.error({ err }, "Failed to save groups"); return c.json({ error: "Failed to save groups" }, 500); } - log.info({ count: result.groups.length }, "Groups updated via admin UI"); - return c.json({ ok: true, count: result.groups.length }); + log.info({ count: nextAll.length }, "Groups updated via admin UI"); + return c.json({ ok: true, count: nextAll.length }); }); - app.get("/api/routes", async (c) => { - const s = await loadScope(c); - if (!s) return c.json({ error: "Unauthorized" }, 401); + app.get("/api/routes", requireAnyAccess(), async (c) => { + const auth = currentAuth(c); const all = await loadRoutes(c.env.KV); - const routes = s.scope.isSuper + const routes = auth.scope.isSuper ? all - : all.filter((r) => r.groupId != null && s.scope.groupIds.has(r.groupId)); + : all.filter((r) => r.groupId != null && auth.scope.groupIds.has(r.groupId)); return c.json({ routes }); }); - app.get("/api/logs", async (c) => { - const s = await loadScope(c); - if (!s) return c.json({ error: "Unauthorized" }, 401); - const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100); - const filterGroupId = c.req.query("groupId") || undefined; - const allLogs = await getSendLog(c.env.DB, 200); - const allowed = s.scope.isSuper - ? allLogs - : allLogs.filter((l) => l.groupId != null && s.scope.groupIds.has(l.groupId)); - const logs = filterGroupId ? allowed.filter((l) => l.groupId === filterGroupId) : allowed; - return c.json({ logs: logs.slice(0, limit) }); - }); - - app.get("/api/logs/:id", async (c) => { - const s = await loadScope(c); - if (!s) return c.json({ error: "Unauthorized" }, 401); - const id = Number(c.req.param("id")); - if (!Number.isInteger(id) || id <= 0) return c.json({ error: "Invalid log id" }, 400); - const entry = await getSendLogById(c.env.DB, id); - if (!entry) return c.json({ error: "Log entry not found" }, 404); - if (!s.scope.isSuper) { - if (!entry.groupId || !s.scope.groupIds.has(entry.groupId)) { - return c.json({ error: "Forbidden" }, 403); - } - } - return c.json({ log: entry }); - }); - - app.put("/api/routes", async (c) => { - const s = await loadScope(c); - if (!s) return c.json({ error: "Unauthorized" }, 401); + app.put("/api/routes", requireAnyAccess(), async (c) => { + const auth = currentAuth(c); let body: unknown; try { body = await c.req.json(); @@ -346,14 +515,17 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> { let nextAll: Route[]; - if (s.scope.isSuper) { + if (auth.scope.isSuper) { // Super admins see and submit every route: full replace. nextAll = result.routes; } else { - // Group admins may only write routes inside their own groups. Reject any - // submitted route that targets a group they do not manage, then splice - // their groups' routes in place while preserving all other groups' routes. - const writable = s.scope.groupIds; + // Owners and admins may only write routes inside groups they manage. + // Reject any submitted route that targets a group they cannot edit, + // then splice their groups' routes in place while preserving all others. + const writable = new Set(); + for (const [gid, role] of auth.scope.roles) { + if (role === "owner" || role === "admin") writable.add(gid); + } for (const r of result.routes) { if (!r.groupId || !writable.has(r.groupId)) { return c.json({ error: `route "${r.id}" is outside your groups` }, 403); @@ -371,27 +543,51 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> { log.error({ err }, "Failed to save routes"); return c.json({ error: "Failed to save routes" }, 500); } + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: auth.session.userId, + actorLogin: auth.session.login, + action: "routes.update", + targetType: "routes", + targetId: "all", + detail: { count: nextAll.length }, + ip: clientIp(c), + }); log.info({ count: nextAll.length }, "Routes updated via admin UI"); return c.json({ ok: true, count: nextAll.length }); }); + app.get("/api/logs", requireAnyAccess(), async (c) => { + const auth = currentAuth(c); + const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 100); + const filterGroupId = c.req.query("groupId") || undefined; + const allLogs = await getSendLog(c.env.DB, 200); + const allowed = auth.scope.isSuper + ? allLogs + : allLogs.filter((l) => l.groupId != null && auth.scope.groupIds.has(l.groupId)); + const logs = filterGroupId ? allowed.filter((l) => l.groupId === filterGroupId) : allowed; + return c.json({ logs: logs.slice(0, limit) }); + }); + + app.get("/api/logs/:id", requireAnyAccess(), async (c) => { + const auth = currentAuth(c); + const id = Number(param(c, "id")); + if (!Number.isInteger(id) || id <= 0) return c.json({ error: "Invalid log id" }, 400); + const entry = await getSendLogById(c.env.DB, id); + if (!entry) return c.json({ error: "Log entry not found" }, 404); + if (!auth.scope.isSuper) { + if (!entry.groupId || !auth.scope.groupIds.has(entry.groupId)) { + return c.json({ error: "Forbidden" }, 403); + } + } + return c.json({ log: entry }); + }); + // Routes scoped to a single group. The group is the container: the console // enters a group and then lists / edits only that group's routes. - function groupAccess( - s: { scope: AccessScope; groups: Group[] }, - groupId: string, - ): { ok: true; group: Group } | { ok: false; status: 403 | 404 } { - const group = s.groups.find((g) => g.id === groupId); - if (!group) return { ok: false, status: 404 }; - if (!s.scope.isSuper && !s.scope.groupIds.has(groupId)) return { ok: false, status: 403 }; - return { ok: true, group }; - } - - app.get("/api/groups/:groupId/routes", async (c) => { - const s = await loadScope(c); - if (!s) return c.json({ error: "Unauthorized" }, 401); - const groupId = c.req.param("groupId"); - const access = groupAccess(s, groupId); + app.get("/api/groups/:groupId/routes", requireAnyAccess(), async (c) => { + const groupId = param(c, "groupId"); + const access = requireGroup(c, groupId); if (!access.ok) { return c.json( { error: access.status === 404 ? "Group not found" : "Forbidden" }, @@ -402,11 +598,9 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> { return c.json({ group: access.group, routes: all.filter((r) => r.groupId === groupId) }); }); - app.put("/api/groups/:groupId/routes", async (c) => { - const s = await loadScope(c); - if (!s) return c.json({ error: "Unauthorized" }, 401); - const groupId = c.req.param("groupId"); - const access = groupAccess(s, groupId); + app.put("/api/groups/:groupId/routes", requireAnyAccess(), async (c) => { + const groupId = param(c, "groupId"); + const access = requireGroupRole(c, groupId, "admin"); if (!access.ok) { return c.json( { error: access.status === 404 ? "Group not found" : "Forbidden" }, @@ -442,9 +636,118 @@ export function createAdminRoutes(): Hono<{ Bindings: Env }> { log.error({ err }, "Failed to save routes"); return c.json({ error: "Failed to save routes" }, 500); } + const auth = currentAuth(c); + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: auth.session.userId, + actorLogin: auth.session.login, + action: "group.routes.update", + targetType: "group", + targetId: groupId, + groupId, + detail: { count: result.routes.length }, + ip: clientIp(c), + }); log.info({ groupId, count: result.routes.length }, "Group routes updated via admin UI"); return c.json({ ok: true, count: result.routes.length }); }); + // ---- Group invites (owner +) ---- + app.post("/api/groups/:groupId/invites", 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, + ); + } + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + const role = (body as { role?: unknown })?.role; + if (role !== "admin" && role !== "viewer") { + return c.json({ error: 'role must be "admin" or "viewer"' }, 400); + } + const note = (body as { note?: unknown })?.note; + const auth = currentAuth(c); + const token = await createInvite(c.env.KV, { + groupId, + role, + expiresAt: Date.now() + 7 * 86400_000, + createdBy: auth.session.login, + note: typeof note === "string" && note.trim() ? note.trim() : undefined, + }); + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: auth.session.userId, + actorLogin: auth.session.login, + action: "invite.create", + targetType: "group", + targetId: groupId, + groupId, + detail: { role }, + ip: clientIp(c), + }); + return c.json({ + ok: true, + token, + url: `/admin/invite?token=${token}`, + expiresAt: Date.now() + 7 * 86400_000, + }); + }); + + app.get("/api/groups/:groupId/invites", 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 invites = await listInvites(c.env.KV, groupId); + return c.json({ invites }); + }); + + app.delete("/api/invites/:token", requireAnyAccess(), async (c) => { + const token = param(c, "token"); + const invite = await getInvite(c.env.KV, token); + if (!invite) return c.json({ error: "Invite not found" }, 404); + const access = requireGroupRole(c, invite.groupId, "owner"); + if (!access.ok) return c.json({ error: "Forbidden" }, 403); + await revokeInvite(c.env.KV, token); + const auth = currentAuth(c); + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: auth.session.userId, + actorLogin: auth.session.login, + action: "invite.revoke", + targetType: "group", + targetId: invite.groupId, + groupId: invite.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); + const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50), 1), 200); + const groupId = c.req.query("groupId") || undefined; + if (groupId && !auth.scope.isSuper && !auth.scope.groupIds.has(groupId)) { + return c.json({ error: "Forbidden" }, 403); + } + const entries = await getAuditLog(c.env.DB, { groupId, limit }); + const visible = auth.scope.isSuper + ? entries + : entries.filter((e) => e.groupId != null && auth.scope.groupIds.has(e.groupId)); + return c.json({ audit: visible }); + }); + return app; } diff --git a/src/web/auth.ts b/src/web/auth.ts new file mode 100644 index 0000000..3fb3868 --- /dev/null +++ b/src/web/auth.ts @@ -0,0 +1,112 @@ +import type { Context } from "hono"; +import type { Env, Group, GroupRole } from "../types"; +import { getAdminSession, type AdminSession } from "./session"; +import { + loadGroups, + resolveScope, + hasAnyAccess, + canEditGroup, + canEditRoutes, + roleAtLeast, + roleAt, + type AccessScope, +} from "./groups"; +import { findUserIdByToken } from "../github/store"; + +export interface AuthContext { + session: AdminSession; + scope: AccessScope; + groups: Group[]; +} + +export type AuthEnv = { + Bindings: Env; + Variables: { + auth: AuthContext; + userId: string; + }; +}; + +export const AUTH_KEY = "auth"; + +async function loadAuth(c: Context): Promise { + const session = await getAdminSession(c.env.KV, c.req.header("cookie")); + if (!session) return null; + const groups = await loadGroups(c.env.KV); + const scope = resolveScope(c.env, groups, session.userId, session.login); + return { session, scope, groups }; +} + +/** Populates `auth` whenever a valid session exists (does not reject). */ +export function sessionMiddleware() { + return async (c: Context, next: () => Promise): Promise => { + const auth = await loadAuth(c); + if (auth) c.set(AUTH_KEY, auth); + await next(); + }; +} + +/** 401 when not logged in, 403 when the account has no group access at all. */ +export function requireAnyAccess() { + return async (c: Context, next: () => Promise): Promise => { + const auth = await loadAuth(c); + if (!auth) return c.json({ error: "Unauthorized" }, 401); + if (!hasAnyAccess(auth.scope)) return c.json({ error: "Forbidden" }, 403); + c.set(AUTH_KEY, auth); + await next(); + }; +} + +/** The authenticated context; only valid behind an auth middleware. */ +export function currentAuth(c: Context): AuthContext { + return c.get(AUTH_KEY); +} + +export type GroupAccess = + | { ok: true; group: Group } + | { ok: false; status: 403 | 404 }; + +/** Resolves the group and checks the user can at least view it. */ +export function requireGroup(c: Context, groupId: string): GroupAccess { + const auth = currentAuth(c); + const group = auth.groups.find((g) => g.id === groupId); + if (!group) return { ok: false, status: 404 }; + if (!auth.scope.isSuper && !auth.scope.groupIds.has(groupId)) { + return { ok: false, status: 403 }; + } + return { ok: true, group }; +} + +/** Requires at least `min` role in the group (owner|admin|viewer). */ +export function requireGroupRole( + c: Context, + groupId: string, + min: GroupRole, +): GroupAccess { + const access = requireGroup(c, groupId); + if (!access.ok) return access; + const auth = currentAuth(c); + if (!auth.scope.isSuper && !roleAtLeast(roleAt(auth.scope, groupId), min)) { + return { ok: false, status: 403 }; + } + return access; +} + +export { canEditGroup, canEditRoutes, roleAt }; + +/** Best-effort client IP for audit entries (Cloudflare header first). */ +export function clientIp(c: { req: { header: (n: string) => string | undefined } }): string | undefined { + return c.req.header("cf-connecting-ip") ?? c.req.header("x-forwarded-for")?.split(",")[0]?.trim(); +} + +/** Bearer-token auth for machine-to-machine action endpoints. */ +export function bearerAuthMiddleware() { + return async (c: Context, next: () => Promise): Promise => { + const auth = c.req.header("authorization"); + if (!auth?.startsWith("Bearer ")) return c.json({ error: "Missing authorization" }, 401); + const userId = await findUserIdByToken(c.env.KV, auth.slice(7)); + if (!userId) return c.json({ error: "Invalid or expired token" }, 401); + c.set("userId", userId); + await next(); + }; +} diff --git a/src/web/groups.ts b/src/web/groups.ts index 478cfb6..c8bae00 100644 --- a/src/web/groups.ts +++ b/src/web/groups.ts @@ -1,9 +1,41 @@ -import type { Env, Group } from "../types"; +import type { Env, Group, GroupMember, GroupRole } from "../types"; import { isAdminUser } from "./session"; import { log } from "../lib/log"; const GROUPS_KEY = "config:groups"; +/** + * Normalizes a group's member list. Groups stored with the legacy `adminIds` + * field (or with neither field) get their admins as role "owner" members, so + * every existing group keeps full control after the member-model migration. + * Never mutates the input; returns a fresh array. + */ +export function normalizeGroupMembers(group: Group): GroupMember[] { + const members = group.members; + if (Array.isArray(members) && members.length > 0) { + const seen = new Set(); + const out: GroupMember[] = []; + for (const m of members) { + const login = String(m?.login ?? "").trim(); + if (!login || seen.has(login.toLowerCase())) continue; + const role: GroupRole = m?.role === "admin" || m?.role === "viewer" ? m.role : "owner"; + seen.add(login.toLowerCase()); + out.push({ login, role }); + } + return out; + } + // Legacy path: adminIds → owners. + const seen = new Set(); + const out: GroupMember[] = []; + for (const a of group.adminIds ?? []) { + const login = a.trim(); + if (!login || seen.has(login.toLowerCase())) continue; + seen.add(login.toLowerCase()); + out.push({ login, role: "owner" }); + } + return out; +} + export async function loadGroups(kv: KVNamespace): Promise { try { const stored = await kv.get(GROUPS_KEY, "json"); @@ -29,6 +61,19 @@ export function isGroupAdmin(group: Group, userId: string, login: string): boole return identityMatches(group.adminIds ?? [], userId, login); } +/** + * The user's role in a group, derived from normalized members. Legacy + * `adminIds` groups resolve to "owner" for every listed admin. + */ +export function memberRole(group: Group, userId: string, login: string): GroupRole | undefined { + const members = normalizeGroupMembers(group); + const wanted = members.filter((m) => { + const candidate = m.login.trim().toLowerCase(); + return candidate === login.toLowerCase() || candidate === userId; + }); + return wanted.length > 0 ? wanted[0]!.role : undefined; +} + /** * Whether an event originating from `owners` (org/user logins) is allowed into * this group. A group with no owner restriction accepts everything. @@ -53,10 +98,12 @@ export function groupAcceptsProvider(group: Group, provider?: string): boolean { export interface AccessScope { isSuper: boolean; - /** Groups the user may view/edit. When isSuper, this is every group. */ + /** Groups the user may view. When isSuper, this is every group. */ groups: Group[]; /** Ids of accessible groups, for quick membership checks. */ groupIds: Set; + /** The user's role per accessible group (absent for super admins). */ + roles: Map; } export function resolveScope( @@ -66,14 +113,44 @@ export function resolveScope( login: string, ): AccessScope { const isSuper = isAdminUser(env, userId, login); - const visible = isSuper ? groups : groups.filter((g) => isGroupAdmin(g, userId, login)); + const visible = isSuper ? groups : groups.filter((g) => memberRole(g, userId, login) != null); + const roles = new Map(); + for (const g of visible) { + const role = memberRole(g, userId, login); + if (role) roles.set(g.id, role); + } return { isSuper, groups: visible, groupIds: new Set(visible.map((g) => g.id)), + roles, }; } +/** The user's role in a group, or undefined when they have no access. */ +export function roleAt(scope: AccessScope, groupId: string): GroupRole | undefined { + if (scope.isSuper) return "owner"; + return scope.roles.get(groupId); +} + +/** Role hierarchy: viewer < admin < owner. */ +const ROLE_RANK: Record = { viewer: 1, admin: 2, owner: 3 }; + +export function roleAtLeast(role: GroupRole | undefined, min: GroupRole): boolean { + if (!role) return false; + return ROLE_RANK[role] >= ROLE_RANK[min]; +} + +/** owner | admin can edit routes; viewers are read-only. */ +export function canEditRoutes(scope: AccessScope, groupId: string): boolean { + return roleAtLeast(roleAt(scope, groupId), "admin"); +} + +/** Only owners (and super admins) may manage a group's settings and members. */ +export function canEditGroup(scope: AccessScope, groupId: string): boolean { + return roleAtLeast(roleAt(scope, groupId), "owner"); +} + /** True if the user is a super admin or manages at least one group. */ export function hasAnyAccess(scope: AccessScope): boolean { return scope.isSuper || scope.groups.length > 0; diff --git a/src/web/invites.ts b/src/web/invites.ts new file mode 100644 index 0000000..8f638c2 --- /dev/null +++ b/src/web/invites.ts @@ -0,0 +1,114 @@ +import { loadGroups, saveGroups, identityMatches, normalizeGroupMembers } from "./groups"; +import { log } from "../lib/log"; + +export interface Invite { + groupId: string; + /** Invited users can never be owners; only admins or viewers. */ + role: "admin" | "viewer"; + expiresAt: number; + createdBy: string; + note?: string; +} + +const INVITE_TTL = 7 * 24 * 3600; + +function generateInviteToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function inviteKey(token: string): string { + return `invite:${token}`; +} + +export async function createInvite(kv: KVNamespace, invite: Invite): Promise { + const token = generateInviteToken(); + await kv.put(inviteKey(token), JSON.stringify(invite), { expirationTtl: INVITE_TTL }); + return token; +} + +export async function getInvite(kv: KVNamespace, token: string): Promise { + try { + const raw = await kv.get(inviteKey(token), "json"); + if (!raw) return null; + if (Date.now() > raw.expiresAt) { + await kv.delete(inviteKey(token)); + return null; + } + return raw; + } catch (err) { + log.warn({ err }, "Failed to load invite"); + return null; + } +} + +export async function consumeInvite(kv: KVNamespace, token: string): Promise { + await kv.delete(inviteKey(token)); +} + +/** All pending (unexpired) invites of a group. */ +export async function listInvites(kv: KVNamespace, groupId: string): Promise> { + try { + const { keys } = await kv.list({ prefix: "invite:" }); + const out: Array = []; + for (const key of keys) { + const token = key.name.slice("invite:".length); + const invite = await getInvite(kv, token); + if (invite && invite.groupId === groupId) out.push({ ...invite, token }); + } + return out; + } catch (err) { + log.warn({ err }, "Failed to list invites"); + return []; + } +} + +export async function revokeInvite(kv: KVNamespace, token: string): Promise { + await kv.delete(inviteKey(token)); +} + +/** + * Adds the accepting user to the invited group (or upgrades their role when + * they are already a viewer) and consumes the invite. The invited role is + * never an owner — ownership stays with the inviter's discretion. + */ +export async function acceptInvite( + kv: KVNamespace, + token: string, + userId: string, + login: string, +): Promise< + | { ok: true; groupId: string; role: "admin" | "viewer" } + | { ok: false; reason: "invalid" | "group-missing" } +> { + const invite = await getInvite(kv, token); + if (!invite) return { ok: false, reason: "invalid" }; + const groups = await loadGroups(kv); + const group = groups.find((g) => g.id === invite.groupId); + if (!group) return { ok: false, reason: "group-missing" }; + + const members = normalizeGroupMembers(group); + const idx = members.findIndex((m) => identityMatches([m.login], userId, login)); + if (idx >= 0) { + if (invite.role === "admin" && members[idx]!.role === "viewer") { + members[idx]!.role = "admin"; + } + } else { + members.push({ login, role: invite.role }); + } + const next = groups.map((g) => + g.id === group.id + ? { + ...g, + members, + adminIds: members.filter((m) => m.role === "owner").map((m) => m.login), + } + : g, + ); + await saveGroups(kv, next); + await consumeInvite(kv, token); + return { ok: true, groupId: group.id, role: invite.role }; +} diff --git a/src/web/oauth-routes.ts b/src/web/oauth-routes.ts index a4c7949..e215855 100644 --- a/src/web/oauth-routes.ts +++ b/src/web/oauth-routes.ts @@ -2,9 +2,11 @@ import { Hono } from "hono"; import { getOAuthURL, handleOAuthCallback } from "../github/oauth"; import { removeToken, saveDiscordLink, saveTelegramLink } from "../github/store"; import { createAdminSession, adminCookie, getAdminSession } from "./session"; -import { loadGroups, resolveScope, hasAnyAccess } from "./groups"; +import { loadGroups, saveGroups, resolveScope, hasAnyAccess } from "./groups"; +import { clientIp } from "./auth"; +import { recordAudit } from "../lib/audit"; import { sendMessage } from "../drivers/telegram/rest"; -import type { Env } from "../types"; +import type { Env, Group } from "../types"; interface PendingState { redirectTo: string; @@ -34,6 +36,41 @@ function safeRedirectPath(value: string | undefined): string { return value; } +function selfSignupEnabled(env: Env): boolean { + const flag = (env.ALLOW_SELF_SIGNUP ?? "").trim().toLowerCase(); + return flag === "1" || flag === "true" || flag === "yes" || flag === "on"; +} + +/** + * Opt-in self service: users without any group access get a personal group + * they own, so they can configure their own routing without a super admin. + * The group id is deterministic (`u-{userId}`), so it is created at most once. + */ +async function ensurePersonalGroup(env: Env, userId: string, login: string): Promise { + if (!selfSignupEnabled(env)) return false; + const groups = await loadGroups(env.KV); + const gid = `u-${userId}`; + if (groups.some((g) => g.id === gid)) return true; + const personal: Group = { + id: gid, + name: `@${login}`, + members: [{ login, role: "owner" }], + adminIds: [login], + }; + await saveGroups(env.KV, [...groups, personal]); + await recordAudit(env.DB, { + ts: Date.now(), + actorId: userId, + actorLogin: login, + action: "group.create", + targetType: "group", + targetId: gid, + groupId: gid, + detail: { auto: true }, + }); + return true; +} + export function createOAuthRoutes(): Hono<{ Bindings: Env }> { const app = new Hono<{ Bindings: Env }>(); @@ -109,13 +146,36 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> { const isBrowser = (c.req.header("accept") ?? "").includes("text/html"); if (isBrowser) { - const groups = await loadGroups(c.env.KV); - const scope = resolveScope(c.env, groups, result.userId, result.login); - if (!hasAnyAccess(scope)) { + // Invite accept flow: the redirect target is the invite page, which + // processes the token after the session exists. Skip the access gate so + // non-members can get in and accept. + const isInviteFlow = + pending.redirectTo.startsWith("/admin/invite") || + pending.redirectTo.startsWith("/admin/invite?"); + + let groups = await loadGroups(c.env.KV); + let scope = resolveScope(c.env, groups, result.userId, result.login); + + if (!hasAnyAccess(scope) && !isInviteFlow) { + const created = await ensurePersonalGroup(c.env, result.userId, result.login); + if (created) { + groups = await loadGroups(c.env.KV); + scope = resolveScope(c.env, groups, result.userId, result.login); + } + } + if (!hasAnyAccess(scope) && !isInviteFlow) { return c.redirect("/admin?error=forbidden"); } + const sessionId = await createAdminSession(c.env.KV, result.userId, result.login); c.header("Set-Cookie", adminCookie(sessionId)); + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: result.userId, + actorLogin: result.login, + action: "session.login", + ip: clientIp(c), + }); return c.redirect(pending.redirectTo); } @@ -129,7 +189,17 @@ export function createOAuthRoutes(): Hono<{ Bindings: Env }> { app.delete("/token/:userId", async (c) => { const session = await getAdminSession(c.env.KV, c.req.header("Cookie")); if (!session) return c.json({ error: "Unauthorized" }, 401); - await removeToken(c.env.KV, c.req.param("userId")); + const target = c.req.param("userId"); + await removeToken(c.env.KV, target); + await recordAudit(c.env.DB, { + ts: Date.now(), + actorId: session.userId, + actorLogin: session.login, + action: "token.delete", + targetType: "token", + targetId: target, + ip: clientIp(c), + }); return c.json({ ok: true }); });