ban hammer

This commit is contained in:
Devin Zuczek
2026-08-10 10:29:24 -04:00
parent 7fbaad1fd8
commit 6bbdf989b9
8 changed files with 517 additions and 27 deletions
+19
View File
@@ -0,0 +1,19 @@
-- Turn a report into a ban. A report row already names the player it is against
-- (`reported_player_id`), so a moderator acting on one flips `banned` on that same row
-- rather than duplicating it into a second table — the ban then carries the report that
-- justified it (category, details, room, who filed it) with no join.
-- Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `ban_expires` is an ISO-8601 UTC timestamp like `created_at`, and NULL means the ban
-- never expires. Kept as its own column rather than "banned until" alone so a lifted ban
-- (banned = 0) is distinguishable from an expired one, and so the row remains a report
-- once the ban is over. Rows stay append-only in every other respect.
--
-- Partial index: bans are rare next to reports, so indexing only the banned rows keeps
-- the lookup (done on every matchmake and every token grant) reading a handful of pages
-- instead of every report ever filed against that player. idx_report_reported stays —
-- it serves the "all reports against this player" moderation read, which is unfiltered.
ALTER TABLE report ADD COLUMN banned INTEGER NOT NULL DEFAULT 0;
ALTER TABLE report ADD COLUMN ban_expires TEXT;
CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1;
+88 -10
View File
@@ -3,19 +3,25 @@
*
* Like the relationship table (and unlike the JSON-blob tables here — rooms /
* accounts / image / invention), a report is genuinely columnar, so it gets a
* normal relational table. Rows are append-only: nothing updates or dedupes a
* report, so the table is a log of exactly what players submitted.
* normal relational table. Rows are append-only in the sense that nothing rewrites
* what a player submitted: the table is a log of exactly what was reported.
*
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
* applied under its own `migrations_table` so it doesn't clash with the other
* workers' migrations that share the database).
* The `api` worker owns this schema/migration (migrations/0004_report.sql and
* 0009_report_ban.sql, applied under its own `migrations_table` so it doesn't clash
* with the other workers' migrations that share the database).
*
* Nothing acts on the rows yet — `/api/PlayerReporting/v1/moderationBlockDetails`
* still answers "not blocked" unconditionally; this is the record that a future
* moderation flow would read.
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
* out of ONE room, this one out of the game.
*
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
* answers "not blocked" unconditionally.
*/
/** Schema DDL (mirror of migrations/0004_report.sql, sans seed rows). */
/** Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -27,10 +33,13 @@ export const SCHEMA_DDL: string[] = [
height_reported REAL,
room_id INTEGER,
room_instance_type TEXT,
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
banned INTEGER NOT NULL DEFAULT 0,
ban_expires TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1`,
]
/** A stored report row (snake_case columns, one row per submission). */
@@ -47,6 +56,10 @@ export interface ReportRow {
/** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */
room_instance_type: string | null
created_at: string
/** 1 when a moderator turned this report into a ban of `reported_player_id`. */
banned: number
/** ISO-8601 UTC instant the ban lifts; NULL means it never does. */
ban_expires: string | null
}
/**
@@ -101,3 +114,68 @@ export async function getReportsAgainst(db: D1Database, playerId: number): Promi
.all<ReportRow>()
return results
}
/**
* The ban currently in force against a player, or null when they aren't banned.
*
* "In force" is narrower than `banned = 1`: a row whose `ban_expires` has passed is a
* ban that has SERVED ITS TIME, and the player is let back in without anyone having to
* go and clear the flag — the row stays as the record that it happened. A permanent ban
* carries no expiry at all (NULL), which is why that arm is checked separately rather
* than by comparing against some far-future date.
*
* When several bans are in force, the longest-lasting one wins: permanent first (NULL
* sorts ahead because `ban_expires IS NOT NULL` is 0 for it), then the latest expiry. So
* a fresh short ban can never shorten a standing one.
*/
export async function getActiveBan(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<ReportRow | null> {
return db
.prepare(
`SELECT * FROM report
WHERE reported_player_id = ?1 AND banned = 1
AND (ban_expires IS NULL OR ban_expires > ?2)
ORDER BY ban_expires IS NOT NULL, ban_expires DESC
LIMIT 1`
)
.bind(playerId, now.toISOString())
.first<ReportRow>()
}
/**
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
* calls it on every matchmake and `auth` on every token grant, and neither has anything
* to say about WHICH report did it.
*/
export async function isPlayerBanned(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<boolean> {
return (await getActiveBan(db, playerId, now)) !== null
}
/**
* Turn a report into a ban of the player it was filed against — the moderator action the
* `banned` column exists for. `banExpires` is an ISO-8601 UTC instant, or null for a
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
* report itself intact.
*
* Returns the updated row, or null when there is no report with that id — so the caller
* can tell "banned" from "banned nobody" (wrangler's `d1 execute --json` reports no
* changes count, hence RETURNING).
*/
export async function banFromReport(
db: D1Database,
reportId: number,
options: { banned?: boolean; banExpires?: string | null } = {}
): Promise<ReportRow | null> {
const banned = options.banned ?? true
return db
.prepare('UPDATE report SET banned = ?2, ban_expires = ?3 WHERE id = ?1 RETURNING *')
.bind(reportId, banned ? 1 : 0, banned ? (options.banExpires ?? null) : null)
.first<ReportRow>()
}
+14 -9
View File
@@ -59,18 +59,22 @@ const asFloat = (v: string | undefined): number | null => {
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
// an empty string — the client distinguishes "no message" from a blank one.
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
// but this endpoint is not wired to them, so it's always the "not blocked" answer.
// `ReportCategory` is -1 (no category) rather than 0, which is a real category;
// `Message` is null, not an empty string — the client distinguishes "no message"
// from a blank one.
.get(
'/api/PlayerReporting/v1/moderationBlockDetails',
describeRoute({
tags: ['Moderation'],
summary: 'Whether the caller is blocked',
description:
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
'this is always the “not blocked” answer. Two details matter to the client: ' +
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
'not wired to them, so it is always the “not blocked” answer. Two details matter ' +
'to the client: ' +
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
'`Message` is null rather than an empty string — the client distinguishes “no ' +
'message” from a blank one.',
@@ -122,9 +126,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
tags: ['Moderation'],
summary: 'Submit a player report',
description:
'Records a player report in the `report` table — an append-only log; nothing ' +
'dedupes or acts on the rows yet, and `moderationBlockDetails` still answers ' +
'“not blocked unconditionally.\n\n' +
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
'`banned` on the row, which is what matchmaking and `/connect/token` refuse on.\n\n' +
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
+85 -1
View File
@@ -22,7 +22,13 @@ import {
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-db'
import {
banFromReport,
getActiveBan,
getReportsAgainst,
isPlayerBanned,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../reports-db'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { Env } from '../../context'
@@ -1331,6 +1337,84 @@ describe('player reports', () => {
// Same envelope as the success branch — the client parses only one shape.
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
})
// A report is filed unbanned; a moderator converting it into a ban is what the
// `banned` / `ban_expires` columns are for. `match` and `auth` read exactly this.
test('a report is filed unbanned', async () => {
await submit({ PlayerIdReported: '210' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 210)
expect(row).toMatchObject({ banned: 0, ban_expires: null })
expect(await isPlayerBanned(env.DB, 210)).toBe(false)
})
test('banFromReport bans the reported player, permanently by default', async () => {
await submit({ PlayerIdReported: '211', Details: 'the evidence' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 211)
const banned = await banFromReport(env.DB, row!.id)
expect(banned).toMatchObject({ banned: 1, ban_expires: null })
// The report the ban was made from is still attached to it — the point of
// banning on the row rather than in a table of its own.
expect(banned?.details).toBe('the evidence')
expect(await isPlayerBanned(env.DB, 211)).toBe(true)
// It bans the REPORTED player, not the reporter who filed it.
expect(await isPlayerBanned(env.DB, 42)).toBe(false)
})
// A timed ban lifts itself: nothing clears the flag, the expiry just passes.
test('a ban with a past expiry is no longer in force', async () => {
await submit({ PlayerIdReported: '212' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 212)
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect(await isPlayerBanned(env.DB, 212)).toBe(false)
// Still on the row, as the record that it happened.
expect((await getReportsAgainst(env.DB, 212))[0]).toMatchObject({ banned: 1 })
// And in force while it lasted.
expect(await isPlayerBanned(env.DB, 212, new Date('2019-06-01T00:00:00.000Z'))).toBe(true)
})
test('a ban with a future expiry is in force', async () => {
await submit({ PlayerIdReported: '213' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 213)
const expires = new Date(Date.now() + 86_400_000).toISOString()
await banFromReport(env.DB, row!.id, { banExpires: expires })
expect(await isPlayerBanned(env.DB, 213)).toBe(true)
expect((await getActiveBan(env.DB, 213))?.ban_expires).toBe(expires)
})
// Two bans in force: the longest-lasting one is the one reported, so a fresh short
// ban can't shorten a standing permanent one.
test('getActiveBan prefers the permanent ban', async () => {
await submit({ PlayerIdReported: '214', Details: 'timed' }, await bearer())
await submit({ PlayerIdReported: '214', Details: 'permanent' }, await bearer())
const rows = await getReportsAgainst(env.DB, 214)
const timed = rows.find((r) => r.details === 'timed')!
const permanent = rows.find((r) => r.details === 'permanent')!
await banFromReport(env.DB, timed.id, {
banExpires: new Date(Date.now() + 3_600_000).toISOString(),
})
await banFromReport(env.DB, permanent.id)
expect(await getActiveBan(env.DB, 214)).toMatchObject({ details: 'permanent' })
})
test('banFromReport with banned:false lifts the ban and clears the expiry', async () => {
await submit({ PlayerIdReported: '215' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 215)
await banFromReport(env.DB, row!.id, { banExpires: '2999-01-01T00:00:00.000Z' })
expect(await isPlayerBanned(env.DB, 215)).toBe(true)
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
expect(lifted).toMatchObject({ banned: 0, ban_expires: null })
expect(await isPlayerBanned(env.DB, 215)).toBe(false)
})
// No such report — the caller can tell that from having banned nobody.
test('banFromReport returns null for an unknown report', async () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull()
})
})
describe('player warnings', () => {