[www] beta mod panel

This commit is contained in:
Devin Zuczek
2026-09-15 13:11:19 -04:00
parent 71bb29b8c7
commit 27ebc95109
15 changed files with 3299 additions and 248 deletions
@@ -0,0 +1,24 @@
-- Who handed a ban down, and when. A ban lives on the `report` row it rests on (see
-- 0009_report_ban.sql), which recorded the decision but not the deciding: there was no way
-- to tell which moderator set `banned`, and no way to tell WHEN they set it. Generated from
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `banned_at` is not bookkeeping. The client's block screen reads `TimeoutStartedAt` and
-- `Duration` as a PAIR — the block runs from the start for the duration — and
-- `moderationBlockDetails` had only the report's `created_at` to offer as that start. So a
-- ban applied to a month-old report told the banned player their block began a month ago,
-- and the duration (computed from there to `ban_expires`) came out a month short: a 7-day
-- ban on a 30-day-old report reads as already served. With this column the ban's own start
-- is recorded and `banBlockDetails` uses it, falling back to `created_at` so every row
-- written before this migration keeps behaving exactly as it did.
--
-- Both are NULL on an unbanned row, and are CLEARED when a ban is lifted — the report
-- itself survives, as it does for `banned`/`ban_expires`. So a non-null `banned_at` means
-- "a ban is or was in force from here", which is the same thing `banned = 1` means.
--
-- Neither column is indexed. `banned_by_player_id` is read per-row when a moderator looks
-- at a ban, never filtered on; the ban reads that exist go by player or by the ban flag
-- (idx_report_banned). Add an index alongside a query that needs one.
ALTER TABLE report ADD COLUMN banned_by_player_id INTEGER;
ALTER TABLE report ADD COLUMN banned_at TEXT;
+91
View File
@@ -206,3 +206,94 @@ export async function isPlayerBlocked(
): Promise<boolean> {
return (await resolveBan(db, accountId, options)) !== null
}
/**
* An account a ban on someone else would also reach, and what links the two.
*
* `via` is never `'account'` here: this is the answer to "who ELSE", so the account asked
* about is excluded by the query rather than returned as a match on itself.
*/
export interface LinkedAccount {
accountId: number
username: string | null
via: Exclude<BanVia, 'account'>
/**
* What matched — the shared platform id, or the shared IP address. Shown to the
* moderator so an IP match can be judged on its face: an address that reads as a
* carrier or campus range is a different proposition from a residential one.
*/
value: string
}
/**
* Which OTHER accounts a ban on `accountId` would also block — the preview a moderator
* sees before handing one down (`www`: `GET /api/staff/players/:id/linked`).
*
* This is `resolveBan` read backwards. That function asks "does any ban reach this
* caller", driving from the few banned reports inward, because it runs on every matchmake
* and every token grant. This one asks "who would this ban reach", driving from one
* account's identities outward — a question with no hot path, asked once when a moderator
* is about to act, and the only way to see the IP arm's blast radius BEFORE it lands
* rather than from a support ticket afterwards.
*
* Honours the same `arms`, so a preview on a server running `BAN_EVASION_MATCH=platform`
* doesn't list households that would not in fact be blocked. A disabled arm contributes
* nothing rather than being listed-but-flagged: the question is who gets blocked, and the
* answer under that setting is that they don't.
*
* The username comes off the account blob, which is `auth`'s — read here for the same
* reason the resolution query reads it: the moderator needs to know WHO, and an id alone
* makes the preview unreadable. A missing blob yields null rather than dropping the row;
* the block would still reach that account.
*
* An account matched by BOTH arms appears twice, once per arm. Deliberate: the arms are
* separate evidence, and "shares a Steam identity AND an address" is worth seeing as two
* lines. Platform rows sort first, being the arm that proves more.
*/
export async function linkedAccounts(
db: D1Database,
accountId: number,
arms: BanMatchArms = DEFAULT_BAN_MATCH_ARMS
): Promise<LinkedAccount[]> {
if (!arms.ip && !arms.platform) return []
const { results } = await db
.prepare(
`WITH me AS (
SELECT
NULLIF(json_extract(data, '$.signupIp'), '') AS signup_ip,
NULLIF(json_extract(data, '$.lastLoginIp'), '') AS last_login_ip
FROM account WHERE account_id = ?1
),
ips AS (
SELECT signup_ip AS ip FROM me WHERE signup_ip IS NOT NULL
UNION SELECT last_login_ip FROM me WHERE last_login_ip IS NOT NULL
),
ids AS (
SELECT platform, platform_id FROM platform_account WHERE account_id = ?1
)
SELECT p.account_id AS accountId,
json_extract(a.data, '$.username') AS username,
'platform' AS via,
p.platform_id AS value
FROM platform_account p
JOIN ids ON ids.platform = p.platform AND ids.platform_id = p.platform_id
LEFT JOIN account a ON a.account_id = p.account_id
WHERE ?2 = 1 AND p.account_id <> ?1
UNION
SELECT a.account_id AS accountId,
json_extract(a.data, '$.username') AS username,
'ip' AS via,
ips.ip AS value
FROM account a, ips
WHERE ?3 = 1 AND a.account_id <> ?1
AND ips.ip IN (
json_extract(a.data, '$.signupIp'),
json_extract(a.data, '$.lastLoginIp')
)
ORDER BY via DESC, accountId`
)
.bind(accountId, arms.platform ? 1 : 0, arms.ip ? 1 : 0)
.all<LinkedAccount>()
return results
}
+233 -9
View File
@@ -7,9 +7,16 @@
* 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,
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql and
* 0017_report_custom_avatar_item.sql, applied under its own `migrations_table` so it
* doesn't clash with the other workers' migrations that share the database).
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
* 0017_report_custom_avatar_item.sql and 0020_report_ban_audit.sql, applied under its own
* `migrations_table` so it doesn't clash with the other workers' migrations that share the
* database).
*
* The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` —
* are not called by any endpoint on this worker. They back the staff panel served by
* `www` (`/api/staff/*`), which imports them: the endpoints are a recflare addition with
* no counterpart in the real service, and those live on `www` rather than on the workers
* that reimplement the game's own API. The SQL stays here, with the table that owns it.
*
* A reported player EVENT, INVENTION or CUSTOM AVATAR ITEM lands here too, rather than in a
* table of its own: same fields, same moderation life. Such a row carries `event_id`,
@@ -32,7 +39,8 @@
/**
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql).
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql +
* 0020_report_ban_audit.sql).
*
* None of `event_id`, `invention_id` or `custom_avatar_item_id` is indexed: each is written
* on every report of its kind and read by nothing — no query here filters on any of them,
@@ -56,7 +64,9 @@ export const SCHEMA_DDL: string[] = [
ban_expires TEXT,
event_id INTEGER,
invention_id INTEGER,
custom_avatar_item_id TEXT
custom_avatar_item_id TEXT,
banned_by_player_id INTEGER,
banned_at 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)`,
@@ -103,6 +113,18 @@ export interface ReportRow {
* it does not know who made it.
*/
custom_avatar_item_id: string | null
/**
* The moderator who set `banned` on this row, or NULL when nobody has (and cleared
* again when a ban is lifted). Read per-row by the staff panel; nothing filters on it.
*/
banned_by_player_id: number | null
/**
* ISO-8601 UTC instant the ban was HANDED DOWN — distinct from `created_at`, which is
* when the report was filed, and the two can be months apart. This is what the client's
* block screen counts the ban from (see `banBlockDetails`); rows written before
* 0020_report_ban_audit.sql carry NULL and fall back to `created_at`.
*/
banned_at: string | null
}
/**
@@ -159,7 +181,11 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
return row!
}
/** Every report filed against a player, newest first. Backs a future moderation view. */
/**
* Every report filed against a player, newest first — unpaged, because it is the whole
* history a moderator reads on one account (`www`: `GET /api/staff/players/:id`), not a
* feed. `searchReports` is the paged, filtered form for looking across accounts.
*/
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
const { results } = await db
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
@@ -217,6 +243,12 @@ export async function isPlayerBanned(
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
* report itself intact.
*
* `bannedBy` is the acting moderator, and is recorded alongside `banned_at` — the instant
* the ban was handed down, which is NOT the report's `created_at` and is what the client's
* block screen counts from. A lift clears all four columns together: an unbanned row must
* not keep an audit trail saying a ban runs from somewhere, and `banned = 0` with a
* `banned_at` still set would read as a ban to anything checking the timestamp.
*
* 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).
@@ -224,11 +256,203 @@ export async function isPlayerBanned(
export async function banFromReport(
db: D1Database,
reportId: number,
options: { banned?: boolean; banExpires?: string | null } = {}
options: { banned?: boolean; banExpires?: string | null; bannedBy?: number | 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)
.prepare(
`UPDATE report
SET banned = ?2, ban_expires = ?3, banned_by_player_id = ?4, banned_at = ?5
WHERE id = ?1
RETURNING *`
)
.bind(
reportId,
banned ? 1 : 0,
banned ? (options.banExpires ?? null) : null,
banned ? (options.bannedBy ?? null) : null,
banned ? new Date().toISOString() : null
)
.first<ReportRow>()
}
/**
* A page of reports matching a moderator's filters — the staff panel's search
* (`www`: `GET /api/staff/reports`).
*
* Every filter is optional and ANDed; an empty filter set is the whole table, newest
* first. `ORDER BY id DESC` rather than by `created_at`: the id is AUTOINCREMENT, so it is
* already chronological, and it is the primary key — ordering by the timestamp column,
* which is unindexed, would sort the whole result set. The date window is applied to
* `created_at` (an ISO-8601 string, so a lexical comparison IS a chronological one) and
* `to` is EXCLUSIVE, which is what makes a day-boundary window composable.
*
* `banned` filters on the FLAG, not on whether a ban is in force: a moderator reviewing
* what has been actioned wants the expired ones too (`getBansInForce` is the other
* question). The count is a second statement rather than a window function, so the caller
* can page without re-deriving the total — it counts the same predicate, unpaged.
*/
export interface ReportSearch {
reportedPlayerId?: number | null
reporterPlayerId?: number | null
reportCategory?: number | null
/** true = banned rows only, false = unbanned only, null/undefined = both. */
banned?: boolean | null
/** ISO-8601; inclusive lower bound on `created_at`. */
from?: string | null
/** ISO-8601; EXCLUSIVE upper bound on `created_at`. */
to?: string | null
}
/** A page of search results, plus how many rows the filters match in total. */
export interface ReportPage {
reports: ReportRow[]
total: number
}
export async function searchReports(
db: D1Database,
filters: ReportSearch = {},
page: { skip?: number; take?: number } = {}
): Promise<ReportPage> {
// Built as a parallel list of clauses and binds so a filter is added in one place and
// can't drift between the page query and the count query, which share both.
const clauses: string[] = []
const binds: Array<number | string> = []
const where = (sql: string, value: number | string) => {
binds.push(value)
clauses.push(sql.replace('?', `?${binds.length}`))
}
if (filters.reportedPlayerId != null) where('reported_player_id = ?', filters.reportedPlayerId)
if (filters.reporterPlayerId != null) where('reporter_player_id = ?', filters.reporterPlayerId)
if (filters.reportCategory != null) where('report_category = ?', filters.reportCategory)
if (filters.banned != null) where('banned = ?', filters.banned ? 1 : 0)
if (filters.from) where('created_at >= ?', filters.from)
if (filters.to) where('created_at < ?', filters.to)
const predicate = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`
// Clamped rather than trusted: `take` reaches this from a query string, and an
// unbounded one would hand a moderator's browser the entire table.
const take = Math.min(Math.max(page.take ?? 50, 1), 200)
const skip = Math.max(page.skip ?? 0, 0)
const [rows, count] = await db.batch<ReportRow | { total: number }>([
db
.prepare(
`SELECT * FROM report${predicate} ORDER BY id DESC LIMIT ?${binds.length + 1} OFFSET ?${binds.length + 2}`
)
.bind(...binds, take, skip),
db.prepare(`SELECT COUNT(*) AS total FROM report${predicate}`).bind(...binds),
])
return {
reports: rows.results as ReportRow[],
total: (count.results as Array<{ total: number }>)[0]?.total ?? 0,
}
}
/** One report by id, or null. The detail behind a search row. */
export async function getReportById(db: D1Database, reportId: number): Promise<ReportRow | null> {
return db.prepare('SELECT * FROM report WHERE id = ?1').bind(reportId).first<ReportRow>()
}
/** A player who has collected reports, as the staff panel's triage list shows them. */
export interface ReportedPlayerTally {
playerId: number
/** Reports filed against them inside the window. */
reports: number
/**
* How many DIFFERENT accounts filed those reports. The number that actually means
* something: one player filing forty reports against someone they are feuding with
* should not outrank four unconnected players filing one each.
*/
distinctReporters: number
/** `created_at` of the most recent report in the window. */
lastReportAt: string
/** Whether a ban is in force against them right now — so the list can skip the done ones. */
bannedNow: boolean
}
/**
* The players with the most reports against them — the staff panel's triage list
* (`www`: `GET /api/staff/reports/top-reported`).
*
* WINDOWED by default (30 days), because the unwindowed version ossifies: an account that
* collected reports a year ago and stopped would sit at the top forever, above whoever is
* a problem this week. Pass `sinceDays: null` for all time.
*
* Ranked by distinct reporters FIRST and raw count second, for the reason
* `distinctReporters` exists at all. `minReports` keeps the long tail of single reports
* out — almost every report is a one-off, and a list of them is not a triage list.
*
* `GROUP BY reported_player_id` rides `idx_report_reported`. The in-force ban is decided
* in the same statement (a correlated EXISTS over the partial `idx_report_banned`) rather
* than with a round trip per player.
*/
export async function getTopReported(
db: D1Database,
options: { sinceDays?: number | null; minReports?: number; take?: number; now?: Date } = {}
): Promise<ReportedPlayerTally[]> {
const now = options.now ?? new Date()
const sinceDays = options.sinceDays === undefined ? 30 : options.sinceDays
// An all-time window is expressed as a floor no timestamp can precede, so the SQL keeps
// one shape rather than growing a second predicate.
const since =
sinceDays === null
? '0000-01-01T00:00:00.000Z'
: new Date(now.getTime() - sinceDays * 86_400_000).toISOString()
const minReports = Math.max(options.minReports ?? 3, 1)
const take = Math.min(Math.max(options.take ?? 50, 1), 200)
const { results } = await db
.prepare(
`SELECT
r.reported_player_id AS playerId,
COUNT(*) AS reports,
COUNT(DISTINCT r.reporter_player_id) AS distinctReporters,
MAX(r.created_at) AS lastReportAt,
EXISTS (
SELECT 1 FROM report b
WHERE b.reported_player_id = r.reported_player_id AND b.banned = 1
AND (b.ban_expires IS NULL OR b.ban_expires > ?2)
) AS bannedNow
FROM report r
WHERE r.created_at >= ?1
GROUP BY r.reported_player_id
HAVING COUNT(*) >= ?3
ORDER BY distinctReporters DESC, reports DESC, lastReportAt DESC
LIMIT ?4`
)
.bind(since, now.toISOString(), minReports, take)
.all<Omit<ReportedPlayerTally, 'bannedNow'> & { bannedNow: number }>()
return results.map((row) => ({ ...row, bannedNow: row.bannedNow === 1 }))
}
/**
* Every ban in force right now, longest-lasting first — the staff panel's standing-bans
* list (`www`: `GET /api/staff/bans`).
*
* "In force" is `getActiveBan`'s test applied to the whole table rather than to one
* player: a row whose `ban_expires` has passed has served its time and is not listed,
* though it stays as the record that it happened. A player with several in force appears
* once per ban — each is a separate report, and which report justified which ban is the
* point of the list.
*/
export async function getBansInForce(
db: D1Database,
now: Date = new Date(),
take = 200
): Promise<ReportRow[]> {
const { results } = await db
.prepare(
`SELECT * FROM report
WHERE banned = 1 AND (ban_expires IS NULL OR ban_expires > ?1)
ORDER BY ban_expires IS NOT NULL, ban_expires DESC, id DESC
LIMIT ?2`
)
.bind(now.toISOString(), Math.min(Math.max(take, 1), 500))
.all<ReportRow>()
return results
}
+19 -7
View File
@@ -262,10 +262,19 @@ const NOT_BLOCKED = {
* The block details for a ban in force — the `report` row a moderator set `banned` on.
*
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
* start for the duration. The start is the report's `created_at` — nothing records when
* the ban itself was handed down, and the report is the record the ban rests on — and the
* duration is the seconds from there to `ban_expires`, so the two sum to the expiry; or
* `PERMANENT_BAN_DURATION` when there is none. The category is the one the report was
* start for the duration. The start is `banned_at`, the instant the ban was handed down
* (see 0020_report_ban_audit.sql), and the duration is the seconds from there to
* `ban_expires`, so the two sum to the expiry; or `PERMANENT_BAN_DURATION` when there is
* none.
*
* It falls back to the report's `created_at` for a row banned before that column existed.
* The two can be months apart, and using `created_at` as the start — which is what this
* did before there was anything else to use — misreports both halves of the pair: a 7-day
* ban applied to a 30-day-old report told the player their block began a month ago and
* ended three weeks ago. Every pre-migration row still reads exactly as it used to, which
* is the point of the fallback rather than a coalesce to now.
*
* The category is the one the report was
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
* violation" rather than the report's `details` — those are the REPORTER's words, and the
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
@@ -274,7 +283,8 @@ const NOT_BLOCKED = {
* screen dressings, none of which this server hands out.
*/
function banBlockDetails(ban: ReportRow) {
const startedAt = Date.parse(ban.created_at)
const startedAtIso = ban.banned_at ?? ban.created_at
const startedAt = Date.parse(startedAtIso)
const duration =
ban.ban_expires === null
? PERMANENT_BAN_DURATION
@@ -285,7 +295,7 @@ function banBlockDetails(ban: ReportRow) {
Duration: duration,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: ban.created_at,
TimeoutStartedAt: startedAtIso,
}
}
@@ -321,7 +331,9 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'screen) — so a caller with one in force gets ' +
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
'`Message` “Rule violation”, and the block’s span as the pair the client reads ' +
'them as: `TimeoutStartedAt` is the report’s `created_at` and `Duration` the ' +
'them as: `TimeoutStartedAt` is when the ban was handed down (`banned_at`, ' +
'falling back to the report’s `created_at` for a ban set before that column ' +
'existed) and `Duration` the ' +
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
'ban). ' +
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
+249 -10
View File
@@ -47,9 +47,13 @@ import {
banFromReport,
createReport,
getActiveBan,
getBansInForce,
getReportById,
getReportsAgainst,
getTopReported,
isPlayerBanned,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
searchReports,
} from '../../reports-db'
import {
CheerCategory,
@@ -4677,6 +4681,183 @@ describe('player reports', () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull()
})
// The audit trail 0020 added: who handed the ban down, and when. `banned_at` is not
// bookkeeping — it is what the client's block screen counts the ban from (see
// `banBlockDetails`), which is why a lift has to clear it along with the flag.
test('banFromReport records the acting moderator and the instant it landed', async () => {
await submit({ PlayerIdReported: '216' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 216)
// An unactioned report carries neither.
expect(row).toMatchObject({ banned_by_player_id: null, banned_at: null })
const before = Date.now()
const banned = await banFromReport(env.DB, row!.id, { bannedBy: 9001 })
expect(banned!.banned_by_player_id).toBe(9001)
expect(Date.parse(banned!.banned_at!)).toBeGreaterThanOrEqual(before)
// Stamped by the write, so it is the ban's own time rather than the report's.
expect(banned!.banned_at).not.toBe(row!.created_at)
// A lift clears all four together. Leaving `banned_at` behind on an unbanned row
// would read as a ban to anything checking the timestamp.
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
expect(lifted).toMatchObject({
banned: 0,
ban_expires: null,
banned_by_player_id: null,
banned_at: null,
})
})
// The moderation READS — searchReports / getTopReported / getBansInForce. No endpoint
// on this worker calls them: they back the staff panel `www` serves (`/api/staff/*`),
// which imports them, because the panel is a recflare addition with no counterpart in
// the real service while the SQL belongs with the table. Tested here, where the table
// and its migrations live.
describe('moderation reads', () => {
test('searchReports filters, orders newest-first and counts the full match', async () => {
const target = 3300
await createReport(env.DB, {
reporterPlayerId: 3301,
reportedPlayerId: target,
reportCategory: 101,
details: 'one',
})
await createReport(env.DB, {
reporterPlayerId: 3302,
reportedPlayerId: target,
reportCategory: 102,
details: 'two',
})
await createReport(env.DB, { reporterPlayerId: 3301, reportedPlayerId: 3399 })
const byPlayer = await searchReports(env.DB, { reportedPlayerId: target })
expect(byPlayer.total).toBe(2)
// `ORDER BY id DESC` — the id is AUTOINCREMENT, so it is already chronological
// and is the primary key, where `created_at` is unindexed.
expect(byPlayer.reports.map((r) => r.details)).toEqual(['two', 'one'])
expect((await searchReports(env.DB, { reporterPlayerId: 3302 })).total).toBe(1)
expect(
(await searchReports(env.DB, { reportedPlayerId: target, reportCategory: 101 })).total
).toBe(1)
// `take` pages the ROWS; `total` stays the unpaged count of the same predicate, so
// a pager can say "3–4 of 9" without asking twice.
const page = await searchReports(env.DB, { reportedPlayerId: target }, { take: 1 })
expect(page.reports).toHaveLength(1)
expect(page.total).toBe(2)
})
test('searchReports filters on the ban FLAG, expired bans included', async () => {
const report = await createReport(env.DB, {
reporterPlayerId: 3311,
reportedPlayerId: 3310,
})
// Banned, and the ban has since run out — so it is not in force, but it IS a
// report that was actioned, which is what a moderator reviewing their own work
// is looking for.
await banFromReport(env.DB, report.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: true })).total).toBe(1)
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: false })).total).toBe(0)
// An omitted filter is BOTH, not false.
expect((await searchReports(env.DB, { reportedPlayerId: 3310 })).total).toBe(1)
})
test('searchReports windows on created_at, with an exclusive upper bound', async () => {
const report = await createReport(env.DB, {
reporterPlayerId: 3321,
reportedPlayerId: 3320,
})
const filed = '2024-06-15T12:00:00.000Z'
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
.bind(report.id, filed)
.run()
const inWindow = await searchReports(env.DB, {
reportedPlayerId: 3320,
from: '2024-06-01T00:00:00.000Z',
to: '2024-07-01T00:00:00.000Z',
})
expect(inWindow.total).toBe(1)
// `from` is inclusive, `to` exclusive — which is what makes day-boundary windows
// composable rather than double-counting the row on the seam.
expect((await searchReports(env.DB, { reportedPlayerId: 3320, from: filed })).total).toBe(1)
expect((await searchReports(env.DB, { reportedPlayerId: 3320, to: filed })).total).toBe(0)
})
test('getTopReported ranks distinct reporters ahead of raw count', async () => {
// Four reports from one person, against one player…
for (let i = 0; i < 4; i++) {
await createReport(env.DB, { reporterPlayerId: 3331, reportedPlayerId: 3330 })
}
// …and three reports from three different people, against another.
for (const reporter of [3341, 3342, 3343]) {
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3340 })
}
const rows = await getTopReported(env.DB, { minReports: 3 })
const feud = rows.find((r) => r.playerId === 3330)
const real = rows.find((r) => r.playerId === 3340)
expect(feud).toMatchObject({ reports: 4, distinctReporters: 1 })
expect(real).toMatchObject({ reports: 3, distinctReporters: 3 })
// Fewer reports, but from more people — so ahead. One player filing twenty
// reports about someone they are feuding with must not top the queue.
expect(rows.indexOf(real!)).toBeLessThan(rows.indexOf(feud!))
})
test('getTopReported windows, thresholds, and flags a ban in force', async () => {
for (const reporter of [3351, 3352, 3353]) {
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3350 })
}
// Backdate them all a year, so only an all-time read sees them. The default
// window is 30 days precisely so the queue is who is a problem NOW — an account
// that collected reports last year and stopped would otherwise sit at the top
// forever.
await env.DB.prepare('UPDATE report SET created_at = ?1 WHERE reported_player_id = 3350')
.bind(new Date(Date.now() - 365 * 86_400_000).toISOString())
.run()
expect(
(await getTopReported(env.DB, { minReports: 1 })).map((r) => r.playerId)
).not.toContain(3350)
const allTime = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
expect(allTime.map((r) => r.playerId)).toContain(3350)
expect(allTime.find((r) => r.playerId === 3350)?.bannedNow).toBe(false)
// A ban in force is decided in the same statement, so the queue can skip the
// ones already dealt with.
const [row] = await getReportsAgainst(env.DB, 3350)
await banFromReport(env.DB, row!.id)
const banned = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
expect(banned.find((r) => r.playerId === 3350)?.bannedNow).toBe(true)
})
test('getBansInForce lists in-force bans only, and getReportById reads one row', async () => {
const permanent = await createReport(env.DB, {
reporterPlayerId: 3361,
reportedPlayerId: 3360,
})
const expired = await createReport(env.DB, {
reporterPlayerId: 3363,
reportedPlayerId: 3362,
})
await banFromReport(env.DB, permanent.id, { bannedBy: 9002 })
await banFromReport(env.DB, expired.id, { banExpires: '2020-01-01T00:00:00.000Z' })
const ids = (await getBansInForce(env.DB)).map((b) => b.id)
expect(ids).toContain(permanent.id)
// A ban that has served its time is not in force, though the row stays as the
// record that it happened — the same rule `getActiveBan` applies.
expect(ids).not.toContain(expired.id)
const one = await getReportById(env.DB, permanent.id)
expect(one).toMatchObject({ id: permanent.id, banned: 1, banned_by_player_id: 9002 })
expect(await getReportById(env.DB, 999_999)).toBeNull()
})
})
// What the banned player is TOLD. The block screen reads this; it's the same row
// matchmake and login refuse on, described rather than merely enforced.
describe('moderationBlockDetails', () => {
@@ -4729,16 +4910,16 @@ describe('player reports', () => {
})
// Duration and TimeoutStartedAt are a pair in the client — the block runs from the
// start for the duration. The start is the report's created_at (nothing records when
// the ban itself landed), and a permanent ban runs for the largest span the int
// holds.
// start for the duration. The start is `banned_at`, the instant the ban was handed
// down (see 0020_report_ban_audit.sql), NOT the report's created_at: the two can be
// months apart. A permanent ban runs for the largest span the int holds.
test('describes a permanent ban', async () => {
await submit(
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
await bearer()
)
const [row] = await getReportsAgainst(env.DB, 221)
await banFromReport(env.DB, row!.id)
const banned = await banFromReport(env.DB, row!.id)
expect(await details('POST', '221')).toEqual({
...NOT_BLOCKED,
@@ -4749,21 +4930,79 @@ describe('player reports', () => {
// the reporter is not shown to the player they reported (PlayerIdReporter
// stays null).
Message: 'Rule violation',
TimeoutStartedAt: row!.created_at,
TimeoutStartedAt: banned!.banned_at,
})
})
// A timed ban's Duration is the seconds from the start to the expiry, so the pair
// sums to `ban_expires` — not the seconds left as of the request.
test('describes a timed ban as its report’s created_at plus the span to expiry', async () => {
test('describes a timed ban as its banned_at plus the span to expiry', async () => {
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 222)
const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000)
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
// The expiry is set relative to NOW, which is what a moderator handing down a
// one-hour ban means — and now is `banned_at`, not the report's created_at.
const banExpires = new Date(Date.now() + 3600 * 1000)
const banned = await banFromReport(env.DB, row!.id, {
banExpires: banExpires.toISOString(),
})
expect(await details('GET', '222')).toEqual({
...NOT_BLOCKED,
// `Duration` is asserted as a range below, so it is left out of the object match
// — NOT_BLOCKED carries a 0 for it, which would win over the real value.
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
const body = (await details('GET', '222')) as Record<string, unknown>
expect(body).toMatchObject({
...unblocked,
ReportCategory: 103,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: banned!.banned_at,
})
// Within a second of the hour: `banned_at` is stamped by the write, so the span
// is the hour minus however long the write took.
expect(body.Duration).toBeGreaterThan(3595)
expect(body.Duration).toBeLessThanOrEqual(3600)
})
// The whole point of `banned_at`: a ban applied to an OLD report used to be
// described as having started when the report was filed, so a 7-day ban on a
// month-old report told the player their block began a month ago — and the duration,
// measured from there, came out as already served. Both halves of the pair now
// start from the ban.
test('counts a ban from when it was handed down, not from when the report was filed', async () => {
await submit({ PlayerIdReported: '224', ReportCategory: '102' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 224)
// Backdate the report a month, as if it had sat in the queue.
const filed = new Date(Date.now() - 30 * 86_400_000).toISOString()
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
.bind(row!.id, filed)
.run()
const banned = await banFromReport(env.DB, row!.id, {
banExpires: new Date(Date.now() + 7 * 86_400_000).toISOString(),
})
const body = (await details('GET', '224')) as Record<string, unknown>
expect(body.TimeoutStartedAt).toBe(banned!.banned_at)
expect(body.TimeoutStartedAt).not.toBe(filed)
// Seven days, not the negative-then-clamped span the report's date would give.
expect(body.Duration).toBeGreaterThan(7 * 86_400 - 5)
expect(body.Duration).toBeLessThanOrEqual(7 * 86_400)
})
// A row banned BEFORE 0020 added the column carries no `banned_at`, and must keep
// reading exactly as it used to rather than falling back to "now" — which would
// silently restart every standing ban the moment this shipped.
test('falls back to created_at for a ban with no recorded banned_at', async () => {
await submit({ PlayerIdReported: '225', ReportCategory: '101' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 225)
await env.DB.prepare(
`UPDATE report SET banned = 1, ban_expires = ?2, banned_at = NULL WHERE id = ?1`
)
.bind(row!.id, new Date(Date.parse(row!.created_at) + 3600 * 1000).toISOString())
.run()
expect(await details('GET', '225')).toEqual({
...NOT_BLOCKED,
ReportCategory: 101,
Duration: 3600,
IsBan: true,
Message: 'Rule violation',