mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[api] show ban details
This commit is contained in:
+25
-12
@@ -1242,23 +1242,36 @@ export const VoteToKickReason = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
|
||||
* answer (no ban storage yet), 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).
|
||||
* `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()`:
|
||||
* `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).
|
||||
*/
|
||||
export const ModerationBlockDetails = z.object({
|
||||
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
|
||||
Duration: z.int(),
|
||||
ReportCategory: z
|
||||
.int()
|
||||
.describe(
|
||||
'The category the ban’s report was filed under; -1 = ReportCategory.Unknown when not blocked (0 is a real category)'
|
||||
),
|
||||
Duration: z
|
||||
.int()
|
||||
.describe(
|
||||
'Seconds left on the block; 0 for a permanent ban (no end) and when not blocked — `IsBan` marks the block'
|
||||
),
|
||||
GameSessionId: z.int(),
|
||||
IsBan: z.boolean(),
|
||||
IsBan: z.boolean().describe('True when an account-wide ban is in force'),
|
||||
IsHostKick: z.boolean(),
|
||||
IsVoiceModAutoban: z.boolean(),
|
||||
Message: z.string().nullable(),
|
||||
PlayerIdReporter: z.int().nullable(),
|
||||
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(),
|
||||
})
|
||||
|
||||
|
||||
+11
-11
@@ -20,14 +20,14 @@
|
||||
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
|
||||
*
|
||||
* 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.
|
||||
* on that same row (see `banFromReport`), so the ban carries the evidence for it. It is
|
||||
* ENFORCED by `match`, which refuses every matchmake for a banned player, and DESCRIBED
|
||||
* by `/api/PlayerReporting/v1/moderationBlockDetails`, which tells the banned player why
|
||||
* (via `getActiveBan`). `auth` still issues a banned account a token — that is what lets
|
||||
* the client reach the block screen — and reads this table only for ban EVASION (an
|
||||
* account sharing a device or network with a banned one; see bans-db). 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -199,9 +199,9 @@ export async function getActiveBan(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Whether a player is banned right now. The hot-path form of `getActiveBan`, for a caller
|
||||
* that has nothing to say about WHICH report did it. `moderationBlockDetails` is the
|
||||
* caller that does, and reads `getActiveBan` itself.
|
||||
*/
|
||||
export async function isPlayerBanned(
|
||||
db: D1Database,
|
||||
|
||||
@@ -35,12 +35,13 @@ import {
|
||||
VoteToKickReason,
|
||||
VoteToKickRequest,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
import { createReport, getActiveBan } from '../reports-db'
|
||||
import { createWarning } from '../warnings-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
|
||||
import type { App } from '../context'
|
||||
import type { ReportRow } from '../reports-db'
|
||||
|
||||
/**
|
||||
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
|
||||
@@ -223,16 +224,76 @@ 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.
|
||||
*/
|
||||
const PERMANENT_BAN_DURATION = 0
|
||||
|
||||
/** The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`. */
|
||||
const NOT_BLOCKED = {
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: 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.
|
||||
*/
|
||||
function banBlockDetails(ban: ReportRow, now: Date) {
|
||||
const duration =
|
||||
ban.ban_expires === null
|
||||
? PERMANENT_BAN_DURATION
|
||||
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - now.getTime()) / 1000))
|
||||
return {
|
||||
ReportCategory: ban.report_category,
|
||||
Duration: duration,
|
||||
GameSessionId: 0,
|
||||
IsBan: true,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: 'Rule violation',
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Player reporting ------------------------------------------------------
|
||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// 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 —
|
||||
// the reference server's stub `ReturnModerationBlockDetails()`.
|
||||
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
|
||||
// `Message` is null, not the empty string that stub sends — the client distinguishes
|
||||
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
|
||||
// DTO but left unset there, so they go out with their C# defaults.
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
|
||||
// kind of block this server has is the account-wide ban — a `report` row with `banned`
|
||||
// set (see `getActiveBan`), the same row matchmake refuses on — so a caller with one in
|
||||
// force gets it described here, and everyone else gets the "not blocked" answer of the
|
||||
// reference server's stub `ReturnModerationBlockDetails()`. This is the screen a banned
|
||||
// player is shown, which is why `auth` still issues them a token: without one the client
|
||||
// never gets here, and the ban reads as a failed sign-in.
|
||||
// Only the caller's OWN account is consulted, not the evasion arms `resolveBan` adds
|
||||
// at matchmake and login: this screen explains a ban handed to this account, and a
|
||||
// player blocked for sharing a network with a banned one has no report row to show.
|
||||
// In the "not blocked" answer `ReportCategory` is `Unknown` (-1) rather than 0, which
|
||||
// is a real category, and `Message` is null, not the empty string that stub sends —
|
||||
// the client distinguishes "no message" from a blank one. `IsVoiceModAutoban` /
|
||||
// `TimeoutStartedAt` are on the DTO but unset there, so they go out with their C#
|
||||
// defaults.
|
||||
// The newer client POSTs this with no body despite it being a pure read; it answers
|
||||
// GET too, so the path is reachable from either build.
|
||||
.on(
|
||||
@@ -242,29 +303,34 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Moderation'],
|
||||
summary: 'Whether the caller is blocked',
|
||||
description:
|
||||
'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, following the ' +
|
||||
'reference server’s stub: `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.',
|
||||
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
|
||||
'Ban / timeout / host-kick state for the caller. The one block this server hands ' +
|
||||
'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' +
|
||||
'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.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(ModerationBlockDetails, 'The caller’s block, or “not blocked”'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
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)
|
||||
}
|
||||
)
|
||||
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
|
||||
// the client renders them in the order they arrive — and the list is grouped by the
|
||||
@@ -350,10 +416,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Moderation'],
|
||||
summary: 'Submit a player report',
|
||||
description:
|
||||
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
|
||||
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
|
||||
'Records a player report in the `report` table; nothing dedupes the rows. 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' +
|
||||
'`banned` on the row, which is what matchmaking refuses on and what ' +
|
||||
'`moderationBlockDetails` describes to the banned player.\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 ' +
|
||||
|
||||
@@ -750,32 +750,6 @@ describe('public endpoints', () => {
|
||||
expect(charadesWordsFor(new Date('2026-04-01T23:59:59Z'))).toBe(april)
|
||||
})
|
||||
|
||||
// The client POSTs this with no body, despite it being a pure read; the route answers
|
||||
// GET as well, and both methods serve the same body.
|
||||
test.each(['GET', 'POST'])(
|
||||
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
|
||||
async (method) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||
{ method }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
|
||||
// reference stub's empty string — the client tells "no message" from a blank one.
|
||||
expect(await res.json()).toEqual({
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// A fixed list, in render order — the client shows the buttons in the order they
|
||||
// arrive, so the order is part of the contract, not just the contents.
|
||||
test('GET /api/PlayerReporting/v1/voteToKickReasons serves the reasons in order', async () => {
|
||||
@@ -4531,6 +4505,98 @@ describe('player reports', () => {
|
||||
test('banFromReport returns null for an unknown report', async () => {
|
||||
expect(await banFromReport(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', () => {
|
||||
const NOT_BLOCKED = {
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsBan: false,
|
||||
IsHostKick: false,
|
||||
IsVoiceModAutoban: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
}
|
||||
const details = async (method: string, sub: string) => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
|
||||
{ method, headers: await bearer(sub) }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// The client POSTs this with no body, despite it being a pure read; the route
|
||||
// answers GET as well, and both methods serve the same body.
|
||||
test.each(['GET', 'POST'])(
|
||||
'%s reports "not blocked" for an unbanned player',
|
||||
async (method) => {
|
||||
// A report against them that nobody acted on is not a block.
|
||||
await submit({ PlayerIdReported: '220' }, await bearer())
|
||||
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
|
||||
// reference stub's empty string — the client tells "no message" from a blank one.
|
||||
expect(await details(method, '220')).toEqual(NOT_BLOCKED)
|
||||
}
|
||||
)
|
||||
|
||||
test('401s without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
// A permanent ban has no end, so Duration is 0 — IsBan is what marks the block.
|
||||
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)
|
||||
|
||||
expect(await details('POST', '221')).toEqual({
|
||||
ReportCategory: 102,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
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.
|
||||
Message: 'Rule violation',
|
||||
PlayerIdReporter: null,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
})
|
||||
|
||||
// A timed ban reports the seconds LEFT, not its original length.
|
||||
test('describes a timed ban with the seconds remaining', 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 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)
|
||||
})
|
||||
|
||||
// A ban that has served its time is not a block, even though the row still says
|
||||
// `banned = 1` — the same rule `getActiveBan` applies for matchmake and login.
|
||||
test('reports "not blocked" once a ban has expired', async () => {
|
||||
await submit({ PlayerIdReported: '223' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 223)
|
||||
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
|
||||
|
||||
expect(await details('GET', '223')).toEqual(NOT_BLOCKED)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('player warnings', () => {
|
||||
|
||||
Reference in New Issue
Block a user