From 6bbdf989b9def29fb74b7cb24c05eb9e899addb3 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 10 Aug 2026 10:29:24 -0400 Subject: [PATCH] ban hammer --- apps/api/migrations/0009_report_ban.sql | 19 +++ apps/api/src/reports-db.ts | 98 ++++++++++++++-- apps/api/src/routes/moderation.ts | 23 ++-- apps/api/src/test/integration/api.test.ts | 86 +++++++++++++- apps/auth/src/auth.app.ts | 45 +++++++- apps/auth/src/test/integration/api.test.ts | 120 +++++++++++++++++++ apps/match/src/match.app.ts | 31 ++++- apps/match/src/test/integration/api.test.ts | 122 +++++++++++++++++++- 8 files changed, 517 insertions(+), 27 deletions(-) create mode 100644 apps/api/migrations/0009_report_ban.sql diff --git a/apps/api/migrations/0009_report_ban.sql b/apps/api/migrations/0009_report_ban.sql new file mode 100644 index 0000000..793a0d2 --- /dev/null +++ b/apps/api/migrations/0009_report_ban.sql @@ -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; diff --git a/apps/api/src/reports-db.ts b/apps/api/src/reports-db.ts index 2bff269..4a16982 100644 --- a/apps/api/src/reports-db.ts +++ b/apps/api/src/reports-db.ts @@ -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() 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 { + 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() +} + +/** + * 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 { + 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 { + 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() +} diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 3a00acd..187b48c 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -59,18 +59,22 @@ const asFloat = (v: string | undefined): number | null => { // ---- Player reporting ------------------------------------------------------ export const moderationRoutes = new Hono({ 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({ 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 ' + diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index e8f3983..d810636 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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', () => { diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 7d499ba..63f0507 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -24,6 +24,9 @@ import { import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers' import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt' +// The account-wide ban lives on a `report` row, whose table the api worker owns; its db +// module is plain D1 queries with no runtime deps, so it imports cleanly here. +import { getActiveBan } from '../../api/src/reports-db' import { verifyMetaNonce } from './meta-nonce' import { CachedLogin, @@ -58,6 +61,14 @@ import type { PlatformLink } from './platform-db' const TOKEN_SCOPE = 'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage' +/** + * The `error_description` a banned account's grant is refused with. A fixed sentence, + * never interpolated with the expiry, because `www`'s shared auth-messages table keys on + * this exact string to put a real sentence in front of a player — anything varying would + * fall through to the generic "you could not be signed in". Keep the two in sync. + */ +const BANNED_DESCRIPTION = 'this account is banned' + /** * The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded * build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded @@ -546,6 +557,12 @@ const app = new Hono() '', '**Roles.** The token embeds a `role` claim from the account, so developer/moderator', 'powers refresh on every login and every refresh grant.', + '', + '**Bans.** Once the grant has resolved an account, a BANNED account is refused a', + 'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a', + '`report` row with `banned` set (the `api` worker owns that table); it lifts on its', + 'own when `ban_expires` passes, and never if that is null. The ban belongs to the', + 'account, so it does not stop the player creating a new one — only the signup caps do.', ].join('\n'), requestBody: form( TokenRequest, @@ -557,7 +574,8 @@ const app = new Hono() OAuthError, [ 'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an', - 'invalid/expired refresh token, a missing account identifier, or a signup cap reached', + 'invalid/expired refresh token, a missing account identifier, a signup cap reached,', + 'or a banned account', ].join(' ') ), 500: json( @@ -869,6 +887,31 @@ const app = new Hono() await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) } + // A banned account gets no token — and with no token every other worker is shut + // to it, so this is the outer wall of a ban; matchmaking's refusal is the inner + // one, which still has to exist because a token issued before the ban stays valid + // until it expires. + // + // Checked once here, after the grant has resolved an account, so it covers every + // grant: password, cached_login and a refresh_token redeemed by a client that has + // been running since before the ban. Deliberately AFTER the credential checks — + // a wrong password is still "invalid account_id or password", so this can't be + // used to probe whether an account exists or is banned without knowing it. + // + // It is per-account, and the ban is the account's, not the person's: nothing here + // stops a banned player creating a new account and playing on. Refusing that is a + // signup-cap/platform-identity problem, not this check's. + const ban = await getActiveBan(c.env.DB, Number(accountId)) + if (ban) { + logger.info('token refused: account banned', { + accountId, + grantType, + reportId: ban.id, + banExpires: ban.ban_expires, + }) + return c.json({ error: 'invalid_grant', error_description: BANNED_DESCRIPTION }, 400) + } + // Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing // binding) would still yield a well-formed token — but one signed with an empty // key, which every worker validates against, so anyone could forge it. Refuse to diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 0e0c1d7..ef90dd1 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -15,6 +15,11 @@ import { } from '@repo/domain' import { TOKEN_TTL_SECONDS } from '@repo/jwt' +import { + banFromReport, + createReport, + SCHEMA_DDL as REPORTS_SCHEMA_DDL, +} from '../../../../api/src/reports-db' import { getLinksForAccount, linkPlatformIdentity, @@ -81,8 +86,27 @@ beforeAll(async () => { IsDorm: false, SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }], }) + // Report table (owned by the api worker) — a banned account is refused a token, and + // a ban is a report row with `banned` set. + for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) +/** + * Ban an account the way a moderator would: file a report against it and convert that + * report into a ban. `banExpires` null is a permanent ban. + */ +async function banAccount(accountId: number, banExpires: string | null = null): Promise { + const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: accountId }) + await banFromReport(env.DB, row.id, { banExpires }) +} + +/** Seed an account with LOGIN_PASSWORD set, so it can be logged into. */ +async function seedAccount(accountId: number, username: string): Promise { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId, username, passwordHash: await hashPassword(LOGIN_PASSWORD) })) + .run() +} + /** Decode a JWT payload (no verification) for asserting claims. */ function decodePayload(token: string): Record { const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/') @@ -1147,3 +1171,99 @@ describe('CORS', () => { ) }) }) + +// A banned account is refused a token at all — the outer wall of a ban, since with no +// token every other worker is shut to it. The ban is a `report` row with `banned` set +// (the api worker owns that table); matchmaking enforces the same ban on tokens issued +// before it was handed down. +describe('banned accounts', () => { + test('POST /connect/token refuses a password grant from a banned account', async () => { + await seedAccount(6101, 'BannedPlayer') + await banAccount(6101) + + const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`) + expect(res.status).toBe(400) + expect(res.json.error).toBe('invalid_grant') + // The exact sentence www's shared auth-messages table keys on to put a real + // message in front of the player — changing it silently downgrades that to the + // generic "you could not be signed in". + expect(res.json.error_description).toBe('this account is banned') + }) + + test('POST /connect/token refuses a username login from a banned account', async () => { + await seedAccount(6102, 'BannedByName') + await banAccount(6102) + + const res = await postToken( + `grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}` + ) + expect(res.status).toBe(400) + expect(res.json.error_description).toBe('this account is banned') + }) + + // A client that was already signed in when the ban landed still holds a valid refresh + // token; redeeming it must not renew the session. + test('POST /connect/token refuses to refresh a banned account’s session', async () => { + await seedAccount(6103, 'BannedLater') + const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`) + expect(login.status).toBe(200) + const refreshToken = login.json.refresh_token as string + + await banAccount(6103) + const refreshed = await postToken( + `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}` + ) + expect(refreshed.status).toBe(400) + expect(refreshed.json.error_description).toBe('this account is banned') + }) + + // The ban check runs AFTER the credential check, so a wrong password on a banned + // account still answers the ordinary bad-credential refusal — it can't be used to + // find out whether an account exists or is banned without knowing its password. + test('a wrong password on a banned account is still a credential refusal', async () => { + await seedAccount(6104, 'BannedWrongPw') + await banAccount(6104) + + const res = await postToken('account_id=6104&password=not-the-password') + expect(res.status).toBe(400) + expect(res.json.error_description).toBe('invalid account_id or password') + }) + + // A timed ban lifts itself when its expiry passes; nothing clears the flag. + test('an expired ban lets the account sign in again', async () => { + await seedAccount(6105, 'ServedTime') + await banAccount(6105, '2020-01-01T00:00:00.000Z') + + const res = await postToken(`account_id=6105&password=${LOGIN_PASSWORD}`) + expect(res.status).toBe(200) + expect(decodePayload(res.json.access_token as string).sub).toBe('6105') + }) + + test('a ban that has not expired yet still refuses the login', async () => { + await seedAccount(6106, 'StillServing') + await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString()) + + const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`) + expect(res.status).toBe(400) + expect(res.json.error_description).toBe('this account is banned') + }) + + // A report is not a ban until a moderator converts it. + test('an unbanned report does not refuse the login', async () => { + await seedAccount(6107, 'MerelyReported') + await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6107 }) + + const res = await postToken(`account_id=6107&password=${LOGIN_PASSWORD}`) + expect(res.status).toBe(200) + }) + + // The ban is the ACCOUNT's: nothing here stops the player signing up again, which is + // the signup caps' job, not this check's. + test('a banned player can still create a new account', async () => { + await seedAccount(6108, 'BannedButNew') + await banAccount(6108) + + const created = await postToken('grant_type=create_account&platform_id=steam-after-ban') + expect(created.status).toBe(200) + }) +}) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 053dab9..a487bdf 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -38,6 +38,10 @@ import { import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +// The account-wide ban lives on a `report` row, whose table the api worker owns; its +// db module is plain D1 queries with no runtime deps, so it imports cleanly here (the +// same way econ reads api's inventions-db). +import { isPlayerBanned } from '../../api/src/reports-db' // Value import of the notify worker's NotificationType enum (its bundle has no runtime // deps), so /invite sends a typed MessageReceived frame instead of a magic number. import { NotificationType } from '../../notify/src/notification-types' @@ -696,6 +700,28 @@ const app = new Hono() })(c, next) ) + // A banned account goes nowhere. Room bans are per-room and checked per route (they + // depend on which room you're entering); an ACCOUNT ban isn't about a room at all, so + // it's enforced once here, across every matchmake — by room, by subroom, by instance, + // into a club's clubhouse, following a friend, and into their own dorm. A gate rather + // than six copies of the same check: a route added later inherits it, and there is no + // matchmake left that hands a banned player Photon coordinates. + // + // It answers the same BannedFromRoom the room bans do. The code is per-room in name + // only — it's the one refusal the client renders as "you are banned" instead of a room + // that mysteriously fails to load, and it's what the enum offers. + // + // Unauthenticated requests fall through untouched: the route's own `authedId` answers + // 401, which mustn't turn into "banned" just because the token was missing. + .use('/matchmake/*', async (c, next) => { + const id = await authedId(c) + if (id !== null && (await isPlayerBanned(c.env.DB, id))) { + logger.info('matchmake refused: account banned', { accountId: id, path: c.req.path }) + return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null }) + } + await next() + }) + .onError(withOnError()) .notFound(withNotFound()) @@ -1288,11 +1314,12 @@ const app = new Hono() description: [ 'Single-segment matchmake into the caller’s personal dorm, stored as presence. The', 'client only ever calls this with the `dorm` keyword — real rooms go through', - '`/matchmake/room/:roomId`.', + '`/matchmake/room/:roomId`. Returns errorCode 55 with a null instance when the', + 'account is banned: a ban keeps a player out of their own dorm too.', ].join(' '), security: AUTHED, responses: { - 200: json(MatchmakeResponse, 'The player’s personal dorm instance'), + 200: json(MatchmakeResponse, 'The dorm instance (or a null instance with errorCode 55)'), 401: UNAUTHORIZED_RESPONSE, }, }), diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 3276add..0b816e9 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -21,6 +21,11 @@ import { SUBROOM_SCHEMA_DDL, } from '@repo/domain' +import { + banFromReport, + createReport, + SCHEMA_DDL as REPORTS_SCHEMA_DDL, +} from '../../../../api/src/reports-db' import { scheduled } from '../../match.app' import type { Env } from '../../context' @@ -162,8 +167,21 @@ beforeAll(async () => { insertRel.bind(9702, 9700, 3), // friends (9702 requested) — friend is the requester insertRel.bind(9700, 9703, 1), // pending request out — 9703 is NOT a friend ]) + + // Report table (owned by the api worker) — an account-wide ban is a report row with + // `banned` set, and every matchmake is refused for a player who has one. + for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) +/** + * Ban a player account-wide the way a moderator would: file a report against them and + * convert it. `banExpires` null is a permanent ban. + */ +async function banAccount(playerId: number, banExpires: string | null = null): Promise { + const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: playerId }) + await banFromReport(env.DB, row.id, { banExpires }) +} + // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the // match worker's validation accepts it. Kept inline to avoid a cross-package // import. @@ -1218,8 +1236,11 @@ describe('auth-gated endpoints', () => { // No token → 401. expect( - (await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { method: 'POST' })) - .status + ( + await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { + method: 'POST', + }) + ).status ).toBe(401) // Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal, @@ -1598,8 +1619,9 @@ describe('auth-gated endpoints', () => { roomInstance: null, }) } finally { - await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800') - .run() + await env.DB.prepare( + 'DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800' + ).run() } }) @@ -1765,3 +1787,95 @@ describe('auth-gated endpoints', () => { } }) }) + +// An ACCOUNT ban (a `report` row with `banned` set, owned by the api worker) is not +// about any one room, so it is enforced across every matchmake rather than per route — +// see the /matchmake/* gate in match.app.ts. It answers the same BannedFromRoom (55) the +// per-room bans do, which is the code the client renders as "you are banned". +describe('account bans', () => { + const matchmake = async (path: string, player: string) => + exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: await bearer(player), + }) + + test('every matchmake route is refused for a banned account', async () => { + await banAccount(6001) + // One live instance of room 2 and one club membership, so each route would + // otherwise have somewhere to put them. + for (const path of [ + '/matchmake/room/2', + '/matchmake/room/77/34', + '/matchmake/dorm', + '/matchmake/club/4', + '/matchmake/player/9701', + '/matchmake/instance/1', + ]) { + const res = await matchmake(path, '6001') + expect(res.status, path).toBe(200) + expect(await res.json(), path).toEqual({ errorCode: 55, roomInstance: null }) + } + }) + + // The refusal is the ban's, not the room's: nothing is entered, so no presence is + // written and the player stays where they were (nowhere). + test('a refused matchmake leaves no presence behind', async () => { + await banAccount(6002) + expect((await matchmake('/matchmake/room/2', '6002')).status).toBe(200) + + const player = (await ( + await exports.default.fetch(`${ORIGIN}/player?id=6002`, { headers: await bearer('6002') }) + ).json()) as Array<{ isOnline: boolean; roomInstance: unknown }> + expect(player[0]?.roomInstance ?? null).toBeNull() + }) + + // A timed ban lifts itself once its expiry passes — nothing clears the flag. + test('an expired ban no longer blocks a matchmake', async () => { + await banAccount(6003, '2020-01-01T00:00:00.000Z') + const res = await matchmake('/matchmake/room/2', '6003') + const body = (await res.json()) as { errorCode: number; roomInstance: unknown } + expect(body.errorCode).toBe(0) + expect(body.roomInstance).not.toBeNull() + }) + + test('a ban that has not expired yet blocks a matchmake', async () => { + await banAccount(6004, new Date(Date.now() + 3_600_000).toISOString()) + expect(await (await matchmake('/matchmake/room/2', '6004')).json()).toEqual({ + errorCode: 55, + roomInstance: null, + }) + }) + + // A report on its own is not a ban — only a moderator converting it is. + test('an unbanned report does not block a matchmake', async () => { + await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6005 }) + const body = (await (await matchmake('/matchmake/room/2', '6005')).json()) as { + errorCode: number + } + expect(body.errorCode).toBe(0) + }) + + // Filing the report doesn't touch the reporter, so they still play. + test('the reporter is not banned by the report they filed', async () => { + await banAccount(6006) + const body = (await (await matchmake('/matchmake/room/2', '1')).json()) as { errorCode: number } + expect(body.errorCode).toBe(0) + }) + + // The gate must not turn a missing token into "banned" — that's still a 401. + test('an unauthenticated matchmake is still a 401', async () => { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { method: 'POST' }) + expect(res.status).toBe(401) + }) + + // Only the matchmakes are gated: presence and the rest of the surface keep working, + // so a banned player's client isn't left hammering a dead heartbeat. + test('the gate does not touch non-matchmake routes', async () => { + await banAccount(6007) + const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { + method: 'POST', + headers: await bearer('6007'), + }) + expect(res.status).toBe(200) + }) +})