[api] improve moderationblock

This commit is contained in:
Devin Zuczek
2026-09-03 11:40:45 -04:00
parent 3d7ea3cf27
commit e0f802cee5
3 changed files with 117 additions and 61 deletions
+33 -8
View File
@@ -1244,13 +1244,18 @@ export const VoteToKickReason = z.object({
/** /**
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an * `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: * 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 * `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
* permanent ban, which has no end) and a fixed `Message` of "Rule violation". Otherwise it is the "not * its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
* blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`: * `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 * `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 * `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` * 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). * 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({ export const ModerationBlockDetails = z.object({
ReportCategory: z ReportCategory: z
@@ -1261,18 +1266,38 @@ export const ModerationBlockDetails = z.object({
Duration: z Duration: z
.int() .int()
.describe( .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(), GameSessionId: z.int(),
IsBan: z.boolean().describe('True when an account-wide ban is in force'), IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
IsHostKick: z.boolean(),
IsVoiceModAutoban: z.boolean(),
Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'), Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
PlayerIdReporter: z PlayerIdReporter: z
.int() .int()
.nullable() .nullable()
.describe('Always null — the reporter is not shown to the reported'), .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 bans 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 clients default block-screen text stands'),
BottomMessageOverride: z
.string()
.nullable()
.describe('Always null — the clients default block-screen text stands'),
}) })
/** /**
+52 -34
View File
@@ -225,55 +225,67 @@ async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Prom
} }
/** /**
* `Duration` on a permanent ban: 0, "no end". `IsBan` is what says the player is blocked; * `Duration` on a permanent ban. The client's field is a 32-bit int of seconds that PAIRS
* `Duration` only says for how long, and a ban with no expiry has no length to give. Not * with `TimeoutStartedAt` — start + duration is the end of the block — so a ban with no
* the int32-max sentinel E12354 uses — that reads as a 68-year countdown. * 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 = { const NOT_BLOCKED = {
ReportCategory: -1, ReportCategory: -1,
Duration: 0, Duration: 0,
GameSessionId: 0, GameSessionId: 0,
IsBan: false,
IsHostKick: false, IsHostKick: false,
IsVoiceModAutoban: false,
Message: null, Message: null,
PlayerIdReporter: null, PlayerIdReporter: null,
IsBan: false,
IsVoiceModAutoban: false,
IsDeviceBan: false,
IsWarning: false,
VoteKickReason: null,
TimeoutStartedAt: 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. * 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 * `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
* doesn't read as over), or `PERMANENT_BAN_DURATION` (0, no end) when `ban_expires` is * start for the duration. The start is the report's `created_at` — nothing records when
* NULL — `IsBan` alone marks the block, so the "not blocked" answer and a permanent ban * the ban itself was handed down, and the report is the record the ban rests on — and the
* share a `Duration` of 0 without being confused. The * duration is the seconds from there to `ban_expires`, so the two sum to the expiry; or
* category is the one the report was filed under, so the client's ban screen names the * `PERMANENT_BAN_DURATION` when there is none. The category is the one the report was
* reason. `Message` is a fixed "Rule violation" rather than the report's `details` — * filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
* those are the REPORTER's words, and the banned player isn't shown them, for the same * violation" rather than the report's `details` — those are the REPORTER's words, and the
* reason `PlayerIdReporter` stays null: the reporter is not a host who kicked them, and * banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
* naming them would tell the banned player who reported them. `IsHostKick`, * reporter is not a host who kicked them, and naming them would tell the banned player who
* `IsVoiceModAutoban` and `TimeoutStartedAt` describe the OTHER kinds of block, none of * reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
* which this server hands out. * 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 = const duration =
ban.ban_expires === null ban.ban_expires === null
? PERMANENT_BAN_DURATION ? 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 { return {
...NOT_BLOCKED,
ReportCategory: ban.report_category, ReportCategory: ban.report_category,
Duration: duration, Duration: duration,
GameSessionId: 0,
IsBan: true, IsBan: true,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: 'Rule violation', Message: 'Rule violation',
PlayerIdReporter: null, TimeoutStartedAt: ban.created_at,
TimeoutStartedAt: null,
} }
} }
@@ -307,17 +319,24 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'out is the account-wide ban — a `report` row with `banned` set, the same row ' + '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 ' + 'matchmake refuses on (login still issues a token, so the client can reach this ' +
'screen) — so a caller with one in force gets ' + 'screen) — so a caller with one in force gets ' +
'`IsBan: true`, the `ReportCategory` the report was filed under, `Duration` as the ' + '`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
'seconds left (0 for a permanent ban, which has no end) and the fixed ' + '`Message` “Rule violation”, and the blocks span as the pair the client reads ' +
'`Message` “Rule violation”. `PlayerIdReporter` stays null: it names a kicking ' + 'them as: `TimeoutStartedAt` is the reports `created_at` and `Duration` the ' +
'host, and the reporter is not shown to the player they reported. Only the ' + 'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
'callers own account is consulted, not the ban-evasion arms.\n\n' + 'ban). ' +
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
'shown to the player they reported. Only the callers own account is consulted, ' +
'not the ban-evasion arms.\n\n' +
'Everyone else gets the reference servers stub “not blocked” answer: ' + 'Everyone else gets the reference servers stub “not blocked” answer: ' +
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' + '`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 ' + '`Message` is null rather than the empty string that stub sends — the client ' +
'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' + 'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' +
'`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' + '`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 stubs nine that the 2025 clients 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, security: AUTHED,
responses: { responses: {
200: json(ModerationBlockDetails, 'The callers block, or “not blocked”'), 200: json(ModerationBlockDetails, 'The callers block, or “not blocked”'),
@@ -327,9 +346,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
async (c) => { async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const now = new Date() const ban = await getActiveBan(c.env.DB, id)
const ban = await getActiveBan(c.env.DB, id, now) return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
return c.json(ban ? banBlockDetails(ban, now) : NOT_BLOCKED)
} }
) )
// The reasons the client offers when a player starts a vote-to-kick. Order matters — // The reasons the client offers when a player starts a vote-to-kick. Order matters —
+32 -19
View File
@@ -4509,16 +4509,24 @@ describe('player reports', () => {
// What the banned player is TOLD. The block screen reads this; it's the same row // 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. // matchmake and login refuse on, described rather than merely enforced.
describe('moderationBlockDetails', () => { describe('moderationBlockDetails', () => {
// All sixteen keys the 2025 client's decoder names, every one at its "none" value.
const NOT_BLOCKED = { const NOT_BLOCKED = {
ReportCategory: -1, ReportCategory: -1,
Duration: 0, Duration: 0,
GameSessionId: 0, GameSessionId: 0,
IsBan: false,
IsHostKick: false, IsHostKick: false,
IsVoiceModAutoban: false,
Message: null, Message: null,
PlayerIdReporter: null, PlayerIdReporter: null,
IsBan: false,
IsVoiceModAutoban: false,
IsDeviceBan: false,
IsWarning: false,
VoteKickReason: null,
TimeoutStartedAt: null, TimeoutStartedAt: null,
AssociatedAccountUsername: null,
ShowCreatorCodeOfConduct: false,
TopMessageOverride: null,
BottomMessageOverride: null,
} }
const details = async (method: string, sub: string) => { const details = async (method: string, sub: string) => {
const res = await exports.default.fetch( const res = await exports.default.fetch(
@@ -4549,7 +4557,10 @@ describe('player reports', () => {
expect(res.status).toBe(401) 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 () => { test('describes a permanent ban', async () => {
await submit( await submit(
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' }, { PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
@@ -4559,32 +4570,34 @@ describe('player reports', () => {
await banFromReport(env.DB, row!.id) await banFromReport(env.DB, row!.id)
expect(await details('POST', '221')).toEqual({ expect(await details('POST', '221')).toEqual({
...NOT_BLOCKED,
ReportCategory: 102, ReportCategory: 102,
Duration: 0, Duration: 2_147_483_647,
GameSessionId: 0,
IsBan: true, IsBan: true,
IsHostKick: false,
IsVoiceModAutoban: false,
// A fixed message — the report's `details` are the reporter's words, and // 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', Message: 'Rule violation',
PlayerIdReporter: null, TimeoutStartedAt: row!.created_at,
TimeoutStartedAt: null,
}) })
}) })
// A timed ban reports the seconds LEFT, not its original length. // A timed ban's Duration is the seconds from the start to the expiry, so the pair
test('describes a timed ban with the seconds remaining', async () => { // sums to `ban_expires` — not the seconds left as of the request.
test('describes a timed ban as its reports created_at plus the span to expiry', async () => {
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer()) await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 222) const [row] = await getReportsAgainst(env.DB, 222)
await banFromReport(env.DB, row!.id, { const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000)
banExpires: new Date(Date.now() + 3600 * 1000).toISOString(), await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
})
const body = await details('GET', '222') expect(await details('GET', '222')).toEqual({
expect(body).toMatchObject({ ReportCategory: 103, IsBan: true, Message: 'Rule violation' }) ...NOT_BLOCKED,
expect(body.Duration).toBeGreaterThan(3500) ReportCategory: 103,
expect(body.Duration).toBeLessThanOrEqual(3600) 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 // A ban that has served its time is not a block, even though the row still says