[rooms] improve room ban/unban, history

[api] mod kick
[match] allow banned users into dorm
This commit is contained in:
Devin Zuczek
2026-09-17 10:43:31 -04:00
parent 61aa125af5
commit fd4264d982
18 changed files with 1804 additions and 260 deletions
+12
View File
@@ -1477,6 +1477,18 @@ export const InstantKickRequest = z.object({
PlayerIds: z.array(z.int()).describe('Account ids to kick out of that instance'),
})
/** `POST /api/PlayerReporting/v1/roomModKick` form body — one player out of one instance. */
export const RoomModKickRequest = z.object({
PlayerId: z.string().describe('Account id of the player to kick'),
GameSessionId: z
.string()
.describe('The room instance (game session) to eject them from — they must be standing in it'),
Reason: z
.string()
.optional()
.describe('Free text, shown to the kicked player in the kick message'),
})
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
+81
View File
@@ -467,3 +467,84 @@ export async function getBansInForce(
.all<ReportRow>()
return results
}
// ---- Block details ----------------------------------------------------------
/**
* `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.
*/
export const PERMANENT_BAN_DURATION = 2_147_483_647
/**
* 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.
*/
export const NOT_BLOCKED = {
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsHostKick: 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.
*
* Shared by the two places a player meets an account ban: `api`'s `moderationBlockDetails`
* (the screen at sign-in) and `www`'s live `ModerationKick` frame (the screen when the ban is
* handed down mid-session). Built once here so both screens describe the same ban.
*
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
* start for the duration. The start is `banned_at`, the instant the ban was handed down
* (see 0020_report_ban_audit.sql), 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.
*
* It falls back to the report's `created_at` for a row banned before that column existed.
* The two can be months apart, and using `created_at` as the start — which is what this
* did before there was anything else to use — misreports both halves of the pair: a 7-day
* ban applied to a 30-day-old report told the player their block began a month ago and
* ended three weeks ago. Every pre-migration row still reads exactly as it used to, which
* is the point of the fallback rather than a coalesce to now.
*
* 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.
*/
export function banBlockDetails(ban: ReportRow) {
const startedAtIso = ban.banned_at ?? ban.created_at
const startedAt = Date.parse(startedAtIso)
const duration =
ban.ban_expires === null
? PERMANENT_BAN_DURATION
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
return {
...NOT_BLOCKED,
ReportCategory: ban.report_category,
Duration: duration,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: startedAtIso,
}
}
+111 -80
View File
@@ -2,9 +2,11 @@ import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
canManageRoom,
canModerateRoom,
deletePresence,
getPlayerIdsInInstance,
getPresence,
getPresences,
getRoomById,
getStoredRoomInstance,
@@ -30,18 +32,18 @@ import {
JsonArray,
jsonBody,
ModerationBlockDetails,
RoomModKickRequest,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
VoteToKickReason,
VoteToKickRequest,
} from '../openapi'
import { createReport, getActiveBan } from '../reports-db'
import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } 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
@@ -131,14 +133,17 @@ async function pushInstantKick(
playerIds: number[],
gameSessionId: number,
roomName: string,
moderatorId: number
moderatorId: number,
{ reason = '', isHostKick = true }: { reason?: string; isHostKick?: boolean } = {}
): Promise<void> {
const frame: ModerationKickPayload = {
ReportCategory: KickReportCategory.Moderator,
Duration: 0,
GameSessionId: gameSessionId,
IsHostKick: true,
Message: `You have been kicked from ${roomName}.`,
IsHostKick: isHostKick,
Message: reason
? `You have been kicked from ${roomName}. Reason: ${reason}`
: `You have been kicked from ${roomName}.`,
PlayerIdReporter: moderatorId,
IsBan: false,
IsVoiceModAutoban: false,
@@ -224,81 +229,6 @@ async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Prom
}
}
/**
* `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 = 2_147_483_647
/**
* 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,
IsHostKick: 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` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
* start for the duration. The start is `banned_at`, the instant the ban was handed down
* (see 0020_report_ban_audit.sql), 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.
*
* It falls back to the report's `created_at` for a row banned before that column existed.
* The two can be months apart, and using `created_at` as the start — which is what this
* did before there was anything else to use — misreports both halves of the pair: a 7-day
* ban applied to a 30-day-old report told the player their block began a month ago and
* ended three weeks ago. Every pre-migration row still reads exactly as it used to, which
* is the point of the fallback rather than a coalesce to now.
*
* 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) {
const startedAtIso = ban.banned_at ?? ban.created_at
const startedAt = Date.parse(startedAtIso)
const duration =
ban.ban_expires === null
? PERMANENT_BAN_DURATION
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
return {
...NOT_BLOCKED,
ReportCategory: ban.report_category,
Duration: duration,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: startedAtIso,
}
}
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
@@ -700,6 +630,107 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}
)
// ONE player kicked out of ONE live instance, from the in-room moderation menu, with a
// reason. The single-player sibling of `instantKick`, but form-encoded and gated more
// narrowly: the room's owner or a co-owner, or an account holding the staff role — not
// the room's Moderator (20) tier, which `instantKick` lets through.
//
// The cross-room guard is the same and it is the point: the instance names the room, the
// room is what the caller's authority is checked against, and the target's LIVE presence
// must put them in that very instance. Naming a player standing in some other room — even
// another instance of this one — kicks nobody, so authority over one room can never reach
// a session in another.
.post(
'/api/PlayerReporting/v1/roomModKick',
describeRoute({
tags: ['Moderation'],
summary: 'Kick one player out of a room instance, with a reason',
description:
'Ejects `PlayerId` from the live room instance `GameSessionId` (a `roomInstanceId`). ' +
'Form-encoded, unlike `instantKick`’s JSON; `Reason` is optional free text shown to ' +
'the kicked player in the kick message.\n\n' +
'Gated to the instance’s room: its creator or a co-owner, or any account whose token ' +
'carries the `moderator` / `developer` role. A room Moderator (20) or Host is NOT ' +
'enough here. Anyone else with a valid token gets a 403.\n\n' +
'The player must be standing in **that** instance — their live `presence` row must ' +
'name it. Otherwise nothing happens and the call answers `success: false`, so an ' +
'account id cannot reach into a session in another room, or another instance of this ' +
'one. The caller need not be in the instance themselves. The caller cannot kick ' +
'themselves, and nobody can kick the room’s owner or a co-owner.\n\n' +
'A kick deletes the player’s presence row (they read offline at once and the instance ' +
'frees a slot, its fullness recomputed) and sends them an EPHEMERAL `ModerationKick` ' +
'frame (id 22) with `IsBan: false` — nothing stops them rejoining. `IsHostKick` is ' +
'true when the caller runs the room, false for a staff account acting in a room it ' +
'does not.\n\n' +
'Answers the lowercase `{ success, error }` envelope; the shape is unverified against ' +
'the real service.',
security: AUTHED,
requestBody: form(RoomModKickRequest, 'The session, the player and a reason'),
responses: {
200: json(
SuccessErrorEnvelope,
'`{ success: true, error: "" }`, or `success: false` when the player cannot be kicked'
),
400: json(SuccessErrorEnvelope, 'No `GameSessionId` or `PlayerId`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'The caller neither runs the room nor holds a staff role'),
404: json(SuccessErrorEnvelope, 'No such game session'),
},
}),
async (c) => {
const moderatorId = await authedId(c)
if (moderatorId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
if (gameSessionId === null) {
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
const playerId = asInt(formField(body, c, 'PlayerId'))
if (playerId === null) return c.json({ success: false, error: 'PlayerId is required' }, 400)
const reason = formField(body, c, 'Reason')?.trim() ?? ''
// The instance names the room, and the room carries the roles this is gated on.
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
if (!room) return c.json({ success: false, error: 'This game session does not exist!' }, 404)
// The room's own owners first; the token's roles are only read when that fails.
const runsRoom = canManageRoom(room, moderatorId)
if (!runsRoom) {
const roles = await authedRoles(c)
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}
}
if (playerId === moderatorId) {
return c.json({ success: false, error: 'You cannot kick yourself!' })
}
// Otherwise a co-owner, or a staffer, could throw the room's owner out of it.
if (canManageRoom(room, playerId)) {
return c.json({ success: false, error: 'You cannot kick an owner of this room!' })
}
// The cross-room guard: their LIVE presence must name this exact instance. Offline,
// expired, in another room, or in another instance of this room are all "not here".
const presence = await getPresence<{ roomInstanceId?: number }>(c.env.DB, playerId)
if (presence?.roomInstance?.roomInstanceId !== gameSessionId) {
return c.json({ success: false, error: 'That player is not in this game session!' })
}
await deletePresence(c.env.DB, playerId)
await refreshInstanceFullness(c.env.DB, gameSessionId)
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushInstantKick(c, [playerId], gameSessionId, roomName, moderatorId, {
reason,
isHostKick: runsRoom,
})
return c.json({ success: true, error: '' })
}
)
// A warning handed down by a moderator — the staff-side counterpart to a report.
// Gated on the `moderator` role in the token, not just a valid one.
.post(
+213 -1
View File
@@ -103,7 +103,8 @@ const TEST_ROOMS = [
},
{
// The instant kick's room. Owned by account 42 (the default test token); 43 holds
// Moderator (20) and 44 only Host (10) — the tier just below that gate.
// Moderator (20) and 44 only Host (10) — the tier just below that gate. 45 is a
// CoOwner (30), for `roomModKick`, which is gated on owners rather than moderators.
RoomId: 4,
Name: 'KickRoom',
IsDorm: false,
@@ -112,6 +113,7 @@ const TEST_ROOMS = [
Roles: [
{ AccountId: 43, Role: 20, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 45, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
],
},
]
@@ -4688,6 +4690,215 @@ describe('instant kick', () => {
})
})
describe('room mod kick', () => {
// A session of KickRoom (room 4, owned by 42, co-owned by 45), a second session of the
// same room, and a session of RecCenter (room 2, owned by 1) — somebody else's room.
const SESSION = 1074859
const SAME_ROOM_SESSION = 1074860
const OTHER_ROOM_SESSION = 1074861
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const seedInstance = async (
roomInstanceId: number,
roomId: number,
maxCapacity = 0,
isFull = false
) =>
env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)')
.bind(
JSON.stringify({
roomInstanceId,
ownerAccountId: 42,
roomId,
subRoomId: roomId,
maxCapacity,
isFull,
gameVersion: GAME_VERSION,
createdAt: new Date().toISOString(),
})
)
.run()
const standIn = async (accountId: number, roomInstanceId: number, roomId = 4) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId, roomId },
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
const isPresent = async (accountId: number) =>
(await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ n: number }>())!.n === 1
// The client's form body.
const modKick = async (fields: Record<string, string>, sub = '42', roles?: string[]) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
method: 'POST',
headers: {
...(await bearer(sub, roles)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
const frames = async () =>
(await (await hub().fetch('http://do/all')).json()) as Array<{
playerIds?: number[]
ephemeral?: boolean
data: Record<string, unknown>
}>
test('the room’s owner kicks a player out of the session they are in, with a reason', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// A full one-seat instance that 205 is filling.
await seedInstance(SESSION, 4, 1, true)
await standIn(205, SESSION)
// The client's body, verbatim.
const res = await modKick({ PlayerId: '205', GameSessionId: String(SESSION), Reason: 'test' })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await isPresent(205)).toBe(false)
const full = await env.DB.prepare('SELECT is_full AS f FROM room_instance WHERE id = ?1')
.bind(SESSION)
.first<{ f: number }>()
expect(full!.f).toBe(0)
expect(await frames()).toEqual([
expect.objectContaining({
playerIds: [205],
ephemeral: true,
data: expect.objectContaining({
GameSessionId: SESSION,
IsHostKick: true,
IsBan: false,
PlayerIdReporter: 42,
Message: 'You have been kicked from KickRoom. Reason: test',
}),
}),
])
})
// The whole reason for the presence check: authority over one room must never reach a
// session somewhere else.
test('a player who is not standing in that exact session is not kicked', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await seedInstance(SESSION, 4)
await seedInstance(SAME_ROOM_SESSION, 4)
await seedInstance(OTHER_ROOM_SESSION, 2)
// In another instance of the SAME room, in a different room entirely, and offline.
await standIn(206, SAME_ROOM_SESSION)
await standIn(207, OTHER_ROOM_SESSION, 2)
for (const playerId of ['206', '207', '208']) {
const res = await modKick({ PlayerId: playerId, GameSessionId: String(SESSION) }, '42')
expect(res.status, playerId).toBe(200)
expect(await res.json(), playerId).toEqual({
success: false,
error: 'That player is not in this game session!',
})
}
expect(await isPresent(206)).toBe(true)
expect(await isPresent(207)).toBe(true)
// Naming the OTHER room's session instead doesn't help: 42 has no authority there.
const cross = await modKick(
{ PlayerId: '207', GameSessionId: String(OTHER_ROOM_SESSION) },
'42'
)
expect(cross.status).toBe(403)
expect(await isPresent(207)).toBe(true)
expect(await frames()).toEqual([])
})
test('a co-owner or a staff role may kick; a room moderator, a host and a stranger may not', async () => {
await seedInstance(SESSION, 4)
// 45 co-owns the room.
await standIn(209, SESSION)
expect(
await (await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '45')).json()
).toEqual({ success: true, error: '' })
expect(await isPresent(209)).toBe(false)
// 999 has no role on the room but carries the staff `moderator` role — and is not the
// host, so the frame says so.
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(209, SESSION)
const staff = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '999', [
'gameClient',
'moderator',
])
expect(await staff.json()).toEqual({ success: true, error: '' })
expect(await isPresent(209)).toBe(false)
expect((await frames())[0].data).toMatchObject({ IsHostKick: false, PlayerIdReporter: 999 })
// 43 is the room's Moderator (20) — enough for instantKick, not for this. 44 is a Host,
// 99 holds nothing.
await standIn(209, SESSION)
for (const sub of ['43', '44', '99']) {
const res = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, sub)
expect(res.status, sub).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await isPresent(209)).toBe(true)
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `PlayerId=209&GameSessionId=${SESSION}`,
})
expect(anon.status).toBe(401)
})
test('nobody kicks themselves or an owner of the room', async () => {
await seedInstance(SESSION, 4)
await standIn(42, SESSION)
await standIn(45, SESSION)
expect(
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '42')).json()
).toEqual({ success: false, error: 'You cannot kick yourself!' })
// A co-owner cannot throw out the owner, and staff cannot throw out a co-owner.
expect(
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '45')).json()
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
expect(
await (
await modKick({ PlayerId: '45', GameSessionId: String(SESSION) }, '999', ['moderator'])
).json()
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
expect(await isPresent(42)).toBe(true)
expect(await isPresent(45)).toBe(true)
})
test('an unknown session 404s, and the body must name a session and a player', async () => {
const unknown = await modKick({ PlayerId: '205', GameSessionId: '999999' })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({
success: false,
error: 'This game session does not exist!',
})
for (const [fields, error] of [
[{ PlayerId: '205' }, 'GameSessionId is required'],
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
[{ GameSessionId: String(SESSION) }, 'PlayerId is required'],
] as Array<[Record<string, string>, string]>) {
const res = await modKick(fields)
expect(res.status, error).toBe(400)
expect(await res.json()).toEqual({ success: false, error })
}
})
})
describe('vote to kick', () => {
// Two live sessions, so a vote called in one can be checked against a player in the
// other. Nothing reads `room_instance` here — the gate is presence alone.
@@ -8474,6 +8685,7 @@ describe('openapi', () => {
'POST /api/PlayerReporting/v1/instantKick',
'POST /api/PlayerReporting/v1/moderationBlockDetails',
'POST /api/PlayerReporting/v1/referee',
'POST /api/PlayerReporting/v1/roomModKick',
'POST /api/PlayerReporting/v3/create',
'POST /api/PlayerReporting/v3/voteToKick',
'POST /api/avatar/v1/lockeditems/bulk',