From e0f802cee5db61f6e278c204f6ecf6d98cca9ce0 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 3 Sep 2026 11:40:45 -0400 Subject: [PATCH] [api] improve moderationblock --- apps/api/src/openapi.ts | 41 ++++++++--- apps/api/src/routes/moderation.ts | 86 ++++++++++++++--------- apps/api/src/test/integration/api.test.ts | 51 +++++++++----- 3 files changed, 117 insertions(+), 61 deletions(-) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 2c23878..500cf61 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1244,13 +1244,18 @@ export const VoteToKickReason = z.object({ /** * `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an * account-wide ban in force (a `report` row with `banned` set) it describes that ban: - * `IsBan` true, the report's `ReportCategory`, `Duration` in seconds left (0 for a - * permanent ban, which has no end) and a fixed `Message` of "Rule violation". Otherwise it is the "not - * blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`: + * `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and + * its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to + * `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`: * `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and * `Message` is null — the client distinguishes "no message" from a blank one, so we send * null where the reference sends an empty string. `IsVoiceModAutoban`/`TimeoutStartedAt` * are on the DTO but unset by that stub, so they carry their C# defaults (false / null). + * + * Sixteen keys on the wire — every one the 2025 client's `ModerationBlockDetail` formatter + * reads. The seven past the stub's nine (`IsDeviceBan` … `BottomMessageOverride`) are + * block kinds and screen dressings this server never hands out, so they always carry their + * "none" value; they are sent so a decoder that wants the key present finds it. */ export const ModerationBlockDetails = z.object({ ReportCategory: z @@ -1261,18 +1266,38 @@ export const ModerationBlockDetails = z.object({ Duration: z .int() .describe( - 'Seconds left on the block; 0 for a permanent ban (no end) and when not blocked — `IsBan` marks the block' + 'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked' ), GameSessionId: z.int(), - IsBan: z.boolean().describe('True when an account-wide ban is in force'), - IsHostKick: z.boolean(), - IsVoiceModAutoban: z.boolean(), + IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'), Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'), PlayerIdReporter: z .int() .nullable() .describe('Always null — the reporter is not shown to the reported'), - TimeoutStartedAt: z.string().nullable(), + IsBan: z.boolean().describe('True when an account-wide ban is in force'), + IsVoiceModAutoban: z.boolean().describe('Always false'), + IsDeviceBan: z.boolean().describe('Always false — bans here are account-wide, not per device'), + IsWarning: z + .boolean() + .describe('Always false — warnings are delivered as notifications, not here'), + VoteKickReason: z.string().nullable().describe('Always null — no vote-kick is recorded here'), + TimeoutStartedAt: z + .string() + .nullable() + .describe( + 'When the block began — the ban’s report `created_at` (ISO-8601 UTC); `Duration` runs from it. Null when not blocked' + ), + AssociatedAccountUsername: z.string().nullable().describe('Always null'), + ShowCreatorCodeOfConduct: z.boolean().describe('Always false'), + TopMessageOverride: z + .string() + .nullable() + .describe('Always null — the client’s default block-screen text stands'), + BottomMessageOverride: z + .string() + .nullable() + .describe('Always null — the client’s default block-screen text stands'), }) /** diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 5eb37a0..21b763c 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -225,55 +225,67 @@ async function pushVoteToKick(c: Context, message: VoteToKickMessage): Prom } /** - * `Duration` on a permanent ban: 0, "no end". `IsBan` is what says the player is blocked; - * `Duration` only says for how long, and a ban with no expiry has no length to give. Not - * the int32-max sentinel E12354 uses — that reads as a 68-year countdown. + * `Duration` on a permanent ban. The client's field is a 32-bit int of seconds that PAIRS + * with `TimeoutStartedAt` — start + duration is the end of the block — so a ban with no + * end gets the largest value the field holds, 68 years past its start. */ -const PERMANENT_BAN_DURATION = 0 +const PERMANENT_BAN_DURATION = 2_147_483_647 -/** The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`. */ +/** + * The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`, + * widened to every key the client's `ModerationBlockDetail` decoder names (16 on the wire; + * the 2025 build's formatter reads them all). The ones past the stub's nine are the block + * kinds and screen dressings this server never uses — a device ban, a warning, the + * vote-kick reason, an associated account, the creator code of conduct, the top/bottom + * message overrides — so they carry their "none" values on every answer. + */ const NOT_BLOCKED = { ReportCategory: -1, Duration: 0, GameSessionId: 0, - IsBan: false, IsHostKick: false, - IsVoiceModAutoban: false, Message: null, PlayerIdReporter: null, + IsBan: false, + IsVoiceModAutoban: false, + IsDeviceBan: false, + IsWarning: false, + VoteKickReason: null, TimeoutStartedAt: null, + AssociatedAccountUsername: null, + ShowCreatorCodeOfConduct: false, + TopMessageOverride: null, + BottomMessageOverride: null, } /** * The block details for a ban in force — the `report` row a moderator set `banned` on. * - * `Duration` is the seconds left on the ban (rounded up, so a ban with a second to run - * doesn't read as over), or `PERMANENT_BAN_DURATION` (0, no end) when `ban_expires` is - * NULL — `IsBan` alone marks the block, so the "not blocked" answer and a permanent ban - * share a `Duration` of 0 without being confused. 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 reporter is not a host who kicked them, and - * naming them would tell the banned player who reported them. `IsHostKick`, - * `IsVoiceModAutoban` and `TimeoutStartedAt` describe the OTHER kinds of block, none of - * which this server hands out. + * `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 + * 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 + * reporter is not a host who kicked them, and naming them would tell the banned player who + * reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and + * screen dressings, none of which this server hands out. */ -function banBlockDetails(ban: ReportRow, now: Date) { +function banBlockDetails(ban: ReportRow) { + const startedAt = Date.parse(ban.created_at) const duration = ban.ban_expires === null ? PERMANENT_BAN_DURATION - : Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - now.getTime()) / 1000)) + : Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000)) return { + ...NOT_BLOCKED, ReportCategory: ban.report_category, Duration: duration, - GameSessionId: 0, IsBan: true, - IsHostKick: false, - IsVoiceModAutoban: false, Message: 'Rule violation', - PlayerIdReporter: null, - TimeoutStartedAt: null, + TimeoutStartedAt: ban.created_at, } } @@ -307,17 +319,24 @@ export const moderationRoutes = new Hono({ strict: false }) 'out is the account-wide ban — a `report` row with `banned` set, the same row ' + 'matchmake refuses on (login still issues a token, so the client can reach this ' + 'screen) — so a caller with one in force gets ' + - '`IsBan: true`, the `ReportCategory` the report was filed under, `Duration` as the ' + - 'seconds left (0 for a permanent ban, which has no end) and the fixed ' + - '`Message` “Rule violation”. `PlayerIdReporter` stays null: it names a kicking ' + - 'host, and the reporter is not shown to the player they reported. Only the ' + - 'caller’s own account is consulted, not the ban-evasion arms.\n\n' + + '`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 ' + + '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 ' + + 'shown to the player they reported. Only the caller’s own account is consulted, ' + + 'not the ban-evasion arms.\n\n' + 'Everyone else gets the reference server’s stub “not blocked” answer: ' + '`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' + '`Message` is null rather than the empty string that stub sends — the client ' + 'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' + '`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' + - 'defaults. Answers GET or POST: the newer client POSTs it with no body.', + 'defaults, as do the seven keys past the stub’s nine that the 2025 client’s decoder ' + + 'names (`IsDeviceBan`, `IsWarning`, `VoteKickReason`, `AssociatedAccountUsername`, ' + + '`ShowCreatorCodeOfConduct`, `TopMessageOverride`, `BottomMessageOverride`) — ' + + 'block kinds and screen dressings this server never uses. Answers GET or POST: ' + + 'the newer client POSTs it with no body.', security: AUTHED, responses: { 200: json(ModerationBlockDetails, 'The caller’s block, or “not blocked”'), @@ -327,9 +346,8 @@ export const moderationRoutes = new Hono({ strict: false }) async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - const now = new Date() - const ban = await getActiveBan(c.env.DB, id, now) - return c.json(ban ? banBlockDetails(ban, now) : NOT_BLOCKED) + const ban = await getActiveBan(c.env.DB, id) + return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED) } ) // The reasons the client offers when a player starts a vote-to-kick. Order matters — diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index a61940f..7f3d15c 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4509,16 +4509,24 @@ describe('player reports', () => { // 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', () => { + // All sixteen keys the 2025 client's decoder names, every one at its "none" value. const NOT_BLOCKED = { ReportCategory: -1, Duration: 0, GameSessionId: 0, - IsBan: false, IsHostKick: false, - IsVoiceModAutoban: false, Message: null, PlayerIdReporter: null, + IsBan: false, + IsVoiceModAutoban: false, + IsDeviceBan: false, + IsWarning: false, + VoteKickReason: null, TimeoutStartedAt: null, + AssociatedAccountUsername: null, + ShowCreatorCodeOfConduct: false, + TopMessageOverride: null, + BottomMessageOverride: null, } const details = async (method: string, sub: string) => { const res = await exports.default.fetch( @@ -4549,7 +4557,10 @@ describe('player reports', () => { expect(res.status).toBe(401) }) - // A permanent ban has no end, so Duration is 0 — IsBan is what marks the block. + // 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. test('describes a permanent ban', async () => { await submit( { PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' }, @@ -4559,32 +4570,34 @@ describe('player reports', () => { await banFromReport(env.DB, row!.id) expect(await details('POST', '221')).toEqual({ + ...NOT_BLOCKED, ReportCategory: 102, - Duration: 0, - GameSessionId: 0, + Duration: 2_147_483_647, IsBan: true, - IsHostKick: false, - IsVoiceModAutoban: false, // A fixed message — the report's `details` are the reporter's words, and - // the reporter is not shown to the player they reported, hence null. + // the reporter is not shown to the player they reported (PlayerIdReporter + // stays null). Message: 'Rule violation', - PlayerIdReporter: null, - TimeoutStartedAt: null, + TimeoutStartedAt: row!.created_at, }) }) - // A timed ban reports the seconds LEFT, not its original length. - test('describes a timed ban with the seconds remaining', async () => { + // 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 () => { await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer()) const [row] = await getReportsAgainst(env.DB, 222) - await banFromReport(env.DB, row!.id, { - banExpires: new Date(Date.now() + 3600 * 1000).toISOString(), - }) + const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000) + await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() }) - const body = await details('GET', '222') - expect(body).toMatchObject({ ReportCategory: 103, IsBan: true, Message: 'Rule violation' }) - expect(body.Duration).toBeGreaterThan(3500) - expect(body.Duration).toBeLessThanOrEqual(3600) + expect(await details('GET', '222')).toEqual({ + ...NOT_BLOCKED, + ReportCategory: 103, + Duration: 3600, + IsBan: true, + Message: 'Rule violation', + TimeoutStartedAt: row!.created_at, + }) }) // A ban that has served its time is not a block, even though the row still says