From c46341b3318edd20b9227881a56e16c08ff0ae27 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 21 Sep 2026 18:34:00 -0400 Subject: [PATCH] [www] extend audit log to ban/unban --- apps/www/src/staff.ts | 52 ++++++++++++++++++++++- apps/www/src/test/integration/api.test.ts | 45 ++++++++++++++++++++ apps/www/wrangler.jsonc | 5 ++- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index 5ef1786..4a2fd06 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -1,4 +1,4 @@ -import { getPresences, movePlayerToDorm } from '@repo/domain' +import { getPresences, movePlayerToDorm, writeAuditLog } from '@repo/domain' import { logger } from '@repo/hono-helpers' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' @@ -224,6 +224,54 @@ async function kickBannedPlayer(c: Context, report: ReportRow, moderatorId: } } +/** + * Record a ban or a lift on the `audit_log` table, against the moderator who made it. + * + * The report row only ever holds the ban's CURRENT state: a lift clears `banned_by` and + * `banned_at`, and a re-ban overwrites them, so without this row nothing says who lifted a + * ban, or that one was ever handed down on a report that is clean today. `ban` and `unban` + * are separate actions because they are the two questions asked of this log ("who banned + * X", "who let X back in"); the target and the report go in `data`, per the table's rule + * that `player_id` is the actor. `previous` is the row's ban state before this call, which + * is the only place a lifted ban's original moderator and expiry survive. + * + * Written after the row is committed and never throws — the ban has already happened, and a + * failed audit insert must not turn it into a 500 the panel would read as refused. + */ +async function recordBanAudit( + c: Context, + moderatorId: number, + before: ReportRow, + after: ReportRow +) { + const action = after.banned ? 'ban' : 'unban' + try { + await writeAuditLog(c.env.DB, { + playerId: moderatorId, + action, + data: { + reportId: after.id, + playerId: after.reported_player_id, + reportCategory: after.report_category, + banExpires: after.ban_expires, + previous: { + banned: before.banned === 1, + banExpires: before.ban_expires, + bannedBy: before.banned_by_player_id, + bannedAt: before.banned_at, + }, + }, + }) + } catch (err) { + logger.error('could not write an audit log row', { + action, + moderatorId, + reportId: after.id, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** The evasion arms in force, read from the same knob `api` and `match` read. */ const armsFor = (env: Env) => banEvasionMatch(env.BAN_EVASION_MATCH) @@ -363,6 +411,8 @@ export async function banReportHandler(c: Context) { banExpires: report.ban_expires, }) + await recordBanAudit(c, moderatorId, existing, report) + if (banned) await kickBannedPlayer(c, report, moderatorId) return c.json(report) } diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 7567487..0aa987e 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -2,6 +2,7 @@ import { adminSecretsStore, env, SELF } from 'cloudflare:test' import { beforeAll, expect, it } from 'vitest' import { SCHEMA_DDL as ACCOUNT_SCHEMA_DDL, updateAccount } from '@repo/domain/src/accounts-db' +import { AUDIT_LOG_SCHEMA_DDL } from '@repo/domain/src/audit-db' import { PlatformType } from '@repo/domain/src/enums' import { PRESENCE_SCHEMA_DDL, @@ -95,6 +96,8 @@ beforeAll(async () => { for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() // `stat` is written by the `match` presence cron; www only reads it, for `/stats`. for (const stmt of STAT_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // `audit_log` is owned by `api`; www files a row there for every ban and lift. + for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Web signup is open, but only behind the Turnstile check. These pin the closed door: @@ -948,6 +951,48 @@ it('lifts a ban, clearing the expiry and the audit columns but keeping the repor expect(bans.map((b) => b.id)).not.toContain(report.id) }) +// The report row keeps only the ban's CURRENT state — a lift wipes who banned and when — +// so the audit log is the one place a ban and its lift are both still on record. +it('records a ban and its lift on the audit log, against the moderator', async () => { + const report = await createReport(env.DB, { + reporterPlayerId: 8186, + reportedPlayerId: 8185, + reportCategory: 102, + }) + expect((await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { days: 2 })).status).toBe( + 200 + ) + expect( + (await staffPost(`/api/staff/reports/${report.id}/ban`, 8111, { banned: false })).status + ).toBe(200) + + const { results } = await env.DB.prepare( + `SELECT player_id, action, data FROM audit_log + WHERE action IN ('ban', 'unban') AND json_extract(data, '$.reportId') = ?1 + ORDER BY audit_log_id` + ) + .bind(report.id) + .all<{ player_id: number; action: string; data: string }>() + expect(results.map((r) => [r.player_id, r.action])).toEqual([ + [8110, 'ban'], + [8111, 'unban'], + ]) + + const ban = JSON.parse(results[0].data) + expect(ban).toMatchObject({ reportId: report.id, playerId: 8185, reportCategory: 102 }) + expect(ban.banExpires).toEqual(expect.any(String)) + expect(ban.previous).toMatchObject({ banned: false, bannedBy: null }) + + // The lift names who it undid: the report row itself no longer can. + const unban = JSON.parse(results[1].data) + expect(unban).toMatchObject({ playerId: 8185, banExpires: null }) + expect(unban.previous).toMatchObject({ + banned: true, + bannedBy: 8110, + banExpires: ban.banExpires, + }) +}) + it('answers a ban on a report that does not exist with a 404', async () => { expect((await staffPost('/api/staff/reports/99999/ban', 8110, { days: 1 })).status).toBe(404) expect((await staffGet('/api/staff/reports/99999', 8110)).status).toBe(404) diff --git a/apps/www/wrangler.jsonc b/apps/www/wrangler.jsonc index e960f7b..4501234 100644 --- a/apps/www/wrangler.jsonc +++ b/apps/www/wrangler.jsonc @@ -7,9 +7,10 @@ "routes": [], // The shared `recflare` D1. Read for the public `/server-status` head-count and the // benefits claim; WRITTEN by the claim (`hasPlus`) and by the staff moderation panel, - // which files reports and hands down bans on the `report` table (see src/staff.ts). + // which files reports and hands down bans on the `report` table (see src/staff.ts) and + // records every ban and lift on `audit_log`. // The schema is owned by the workers that write it (presence lives in - // apps/rooms/migrations, `report` in apps/api/migrations), so no migrations_dir: www + // apps/rooms/migrations, `report` and `audit_log` in apps/api/migrations), so no migrations_dir: www // never migrates. The "local" placeholder is spliced with RECFLARE_D1 at deploy time, // as it is for every other worker. "d1_databases": [