[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
* 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 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` 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<App>({ 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 ' +
'callers 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 blocks span as the pair the client reads ' +
'them as: `TimeoutStartedAt` is the reports `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 callers own account is consulted, ' +
'not the ban-evasion arms.\n\n' +
'Everyone else gets the reference servers 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 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,
responses: {
200: json(ModerationBlockDetails, 'The callers block, or “not blocked”'),
@@ -327,9 +346,8 @@ export const moderationRoutes = new Hono<App>({ 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 —
+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
// 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 reports 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