[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',
+42 -43
View File
@@ -72,8 +72,8 @@ const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/**
* The `error_description` a grant is refused with when the caller's account is not itself
* banned but shares an identity with one that is (see bans-db's linked arms). A fixed
* The `error_description` a SIGNUP is refused with when it shares an identity with a banned
* account (see bans-db's linked arms). A fixed
* sentence, because `www`'s shared auth-messages table keys on this exact string to put a
* real sentence in front of a player — anything varying would fall through to the generic
* "you could not be signed in". Keep the two in sync. Deliberately vague: the account
@@ -81,7 +81,9 @@ const TOKEN_SCOPE =
* account is banned" would be a lie, and naming the account we matched them to would hand
* out somebody else's moderation record.
*
* A DIRECTLY banned account is not refused here at all — see the token grant.
* Only `create_account` is refused this way. SIGNING IN is never refused for a ban, its own
* or a linked one — see the token grant: the client needs a token to reach the block screen,
* and `match` is what holds a blocked player to their dorm.
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
@@ -651,22 +653,25 @@ const app = new Hono<App>()
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
'client reads it as a claim beside `role`, and it is absent for everyone else.',
'',
'**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
'**Bans.** A BLOCKED account still gets a token — every grant, including a refresh,',
'and whether the ban is its own or reaches it through the evasion arms.',
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
'lifts on its own when `ban_expires` passes, and never if that is null. The token',
'is what lets the client reach `api`’s `/api/PlayerReporting/v1/moderationBlockDetails`',
'and show the player the block screen that explains the ban; the ban itself is',
'enforced by `match`, which refuses every matchmake for a banned player, so a token',
'gets them as far as that screen and no further.',
'enforced by `match`, which refuses every matchmake but the caller’s own dorm, so a',
'token gets them as far as that screen and no further.',
'',
'What IS refused here (`invalid_grant`) is ban EVASION: an account that shares a',
'PROVEN platform identity (a `platform_account` link) or an IP (`signupIp`/',
'`lastLoginIp`, or the address this request came from) with a banned one, and a',
'`create_account` carrying either, which is refused BEFORE it mints anything. Such',
'an account has no ban of its own for the block screen to describe, so there is',
'nothing to let it in for. Those two arms are the operator’s `BAN_EVASION_MATCH`',
'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
'account refused may belong to a housemate of the banned player rather than to them.',
'Ban EVASION — an account sharing a PROVEN platform identity (a `platform_account`',
'link) or an IP (`signupIp`/`lastLoginIp`, or the address this request came from)',
'with a banned one — signs in the same way, and is stopped at matchmaking like any',
'other blocked account. Those two arms are the operator’s `BAN_EVASION_MATCH` knob',
'(`ip`, `platform`, or `off`).',
'',
'What IS refused here (`invalid_grant`) is a `create_account` carrying either link,',
'refused BEFORE it mints anything: making a NEW account to evade with is a different',
'act from signing into one. The description is deliberately vague — the signup',
'refused may belong to a housemate of the banned player rather than to them.',
].join('\n'),
requestBody: form(
TokenRequest,
@@ -1033,33 +1038,36 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// A DIRECTLY banned account still gets its token. The client needs one to reach
// `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
// (category, time left, "Rule violation") — refused here, they would only ever see
// a failed sign-in. The ban is enforced by matchmaking instead, which refuses every
// matchmake for a banned player, so the token gets them as far as the block screen
// and no further. Logged, so the operator can see a banned player signing in.
// EVERY account gets its token, banned or not — a ban of its own, or a link to
// somebody else's through the evasion arms. The client needs one to reach `api`'s
// moderationBlockDetails, which is where a banned player is TOLD they are banned
// (category, time left, "Rule violation"); refused here they would only ever see a
// sign-in that fails, with nothing saying why. The ban is enforced by `match`, which
// refuses every matchmake but the caller's own dorm, so a token gets them as far as
// the block screen and no further.
//
// Ban EVASION is still refused here: an account that merely shares a device or
// network with a banned one has no ban of its own for that screen to describe, so
// there is nothing to let it in for — and letting it in is exactly what the evader
// wants. Checked once here, after the grant has resolved an account, so it covers
// every grant: password, cached_login and a refresh_token redeemed by a client that
// has been running since before the ban. Deliberately AFTER the credential checks —
// a wrong password is still "invalid account_id or password", so this can't be
// used to probe whether an account exists or is banned without knowing it.
// That includes ban EVASION, which this used to refuse. An evader signing in lands
// in their dorm and can enter no room — the same place the ban leaves them — and
// refusing the token instead only ever produced a client stuck on a failed login.
// `create_account` is still refused before it mints anything (above): that is making
// a NEW account to evade with, which is a different act from signing into one.
//
// Logged either way, and the arm is logged with it: "banned" and "shares a network
// with somebody banned" are very different things to be reading in a log.
//
// Deliberately AFTER the credential checks — a wrong password is still "invalid
// account_id or password", so this can't be used to probe whether an account exists
// or is banned without knowing it.
//
// The request's own IP and proven identity are passed alongside the account, so a
// ban also reaches an old, clean account logged into from the banned player's
// device or network — the stored ips alone would only catch that on the SECOND
// login. create_account was already refused before it minted anything (above);
// this still runs for it, so a signup that raced one is refused too.
// ban also reaches an old, clean account logged into from the banned player's device
// or network — the stored ips alone would only catch that on the SECOND login.
const ban = await resolveBan(c.env.DB, Number(accountId), {
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (ban && ban.via !== 'account') {
logger.info('token refused: ban evasion', {
if (ban) {
logger.info('token issued to a blocked account', {
accountId,
grantType,
via: ban.via,
@@ -1067,15 +1075,6 @@ const app = new Hono<App>()
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
}
if (ban) {
logger.info('token issued to banned account', {
accountId,
grantType,
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
+35 -28
View File
@@ -1662,12 +1662,12 @@ describe('banned accounts', () => {
})
})
// The ban follows the player past the account it was written on: a login from an account
// that shares a proven platform identity or an IP with a banned one is refused, and a
// signup carrying either is refused before it mints anything. Unlike the banned account
// itself, such an account has no ban of its own for the block screen to describe, so
// there is nothing to let it in for. See the api worker's bans-db.ts for the arms and
// the BAN_EVASION_MATCH knob.
// The ban follows the player past the account it was written on. SIGNING IN is not where
// that is enforced: a linked account gets its token like any other, and `match` holds it to
// its own dorm — refusing the token only ever left the client stuck on a failed login. What
// IS refused here is a SIGNUP carrying either link, before it mints anything: making a new
// account to evade with is a different act from signing into one. See the api worker's
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
describe('ban evasion at the token endpoint', () => {
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
@@ -1686,38 +1686,38 @@ describe('ban evasion at the token endpoint', () => {
const login = (id: number, ip?: string) =>
postToken(`account_id=${id}&password=${LOGIN_PASSWORD}`, ip)
test('an account sharing a banned account’s platform identity cannot log in', async () => {
test('an account sharing a banned account’s platform identity still signs in', async () => {
await account(6301, 'EvaderOne')
await linkPlatformIdentity(env.DB, 6301, 0, 'steam-tokenevader')
await banAccount(6301)
await account(6302, 'EvaderTwo')
await linkPlatformIdentity(env.DB, 6302, 0, 'steam-tokenevader')
// A token, like anyone else: the client has to load in before it can be told anything,
// and `match` is what keeps this account out of every room but its dorm.
const res = await login(6302)
expect(res.status).toBe(400)
// A vaguer sentence than a direct ban: this account may belong to somebody else.
expect(res.json.error_description).toBe('this device or network is blocked')
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6302')
})
test('an account sharing a banned account’s IP cannot log in', async () => {
test('an account sharing a banned account’s IP still signs in', async () => {
await account(6303, 'SameHouseBanned', { signupIp: '203.0.113.30' })
await banAccount(6303)
await account(6304, 'SameHouseClean', { signupIp: '203.0.113.30' })
const res = await login(6304)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this device or network is blocked')
expect((await login(6304)).status).toBe(200)
})
// The address the request arrives from counts, so an account that never logged in
// from the banned network before is caught on the first attempt rather than the second.
test('the request’s own IP is matched even when the account has none stored', async () => {
// The address the request arrives from counts, so a SIGNUP from the banned network is
// caught on the first attempt rather than the second.
test('the request’s own IP is matched at signup even when the account has none stored', async () => {
await account(6305, 'BannedAtHome', { signupIp: '203.0.113.31' })
await banAccount(6305)
await account(6306, 'CleanElsewhere')
expect((await login(6306, '203.0.113.31')).status).toBe(400)
// The same account from any other network signs in normally.
expect((await postToken('grant_type=create_account', '203.0.113.31')).status).toBe(400)
// Signing in from that same network is not refused — only minting a new account is.
expect((await login(6306, '203.0.113.31')).status).toBe(200)
expect((await login(6306, '198.51.100.31')).status).toBe(200)
})
@@ -1746,7 +1746,8 @@ describe('ban evasion at the token endpoint', () => {
expect(res.status).toBe(200)
})
// The knob an operator reaches for when the IP arm locks out real players.
// The knob an operator reaches for when the IP arm locks out real players. It governs the
// linked arms wherever they are applied — at signup here, and at every matchmake in `match`.
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
const original = env.BAN_EVASION_MATCH
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
@@ -1758,16 +1759,22 @@ describe('ban evasion at the token endpoint', () => {
try {
env.BAN_EVASION_MATCH = 'platform'
expect((await login(6321)).status).toBe(200)
expect((await login(6322)).status).toBe(400)
// And signup from that network is open again.
// Signup from the banned IP is open again, since that arm is off...
expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200)
// ...while the platform arm still refuses one from the banned headset.
expect(
(await postToken('grant_type=create_account&platform=0&platform_id=steam-knobevader'))
.status
).toBe(400)
env.BAN_EVASION_MATCH = 'off'
expect((await login(6322)).status).toBe(200)
// The banned account itself signs in whatever the knob says — its ban is
// enforced at matchmake, and the knob only governs the linked arms.
expect((await login(6320)).status).toBe(200)
// Signing in is not what the knob gates: every one of these gets a token whatever it
// says — the housemate, the evader, and the banned account itself.
for (const arms of ['platform', 'off']) {
env.BAN_EVASION_MATCH = arms
for (const id of [6320, 6321, 6322]) {
expect((await login(id)).status, `${arms}/${id}`).toBe(200)
}
}
} finally {
env.BAN_EVASION_MATCH = original
}
+9 -1
View File
@@ -91,7 +91,15 @@ export type Env = SharedHonoEnv & {
}
/** Variables can be extended */
export type Variables = SharedHonoVariables
export type Variables = SharedHonoVariables & {
/**
* Set by the `/matchmake/*` ban gate when it lets a BANNED account through to its own
* dorm. Only `/matchmake/dorm` and `/matchmake/none` are let through, and `none` reads
* this to answer the dorm rather than whatever instance the caller's presence names —
* which for a stale row is a public room the ban must keep them out of.
*/
bannedToDorm?: boolean
}
export interface App extends HonoApp {
Bindings: Env
+52 -2
View File
@@ -1527,6 +1527,23 @@ async function playerDormInstance(c: Context<App>, accountId: number): Promise<R
return roomInstanceFromRoom(c.env, room, true, instance.roomInstanceId, instance.photonRoomId)
}
/**
* Whether a matchmake is one a BANNED account is still allowed: the ones that can only put
* them in their own dorm.
*
* `/matchmake/dorm` resolves the caller's dorm and nothing else. `/matchmake/none` normally
* answers whatever instance their presence names, falling back to the dorm — so it is allowed
* only because the handler sends a banned caller to the dorm outright (see `bannedToDorm`).
* Both are in the list because which one the client calls on load-in depends on the build, and
* a banned player who cannot complete either never reaches the screen telling them they are
* banned.
*
* Matched on the path rather than the route, because the check runs in middleware, before Hono
* has picked one. A trailing slash is accepted — the client sends both spellings elsewhere —
* and nothing else is: `/matchmake/dormitory` must not read as the dorm.
*/
const isDormMatchmake = (path: string): boolean => /^\/matchmake\/(dorm|none)\/?$/.test(path)
const app = new Hono<App>()
.use(
'*',
@@ -1555,6 +1572,26 @@ const app = new Hono<App>()
// only — it's the one refusal the client renders as "you are banned" instead of a room
// that mysteriously fails to load, and it's what the enum offers.
//
// ONE exception: a BLOCKED player may still matchmake into their own DORM.
// `auth` already issues them a token, because the client has to be signed in to reach
// `api`'s moderationBlockDetails — the screen that tells them they are banned, for how long
// and why. Refused every matchmake, the client never finishes loading in and never draws
// that screen, so a banned player saw nothing but a game that would not start. Their dorm
// is private to them and reachable by nobody else, so letting them sit in it costs nothing
// and is where they read the ban. Everything else — every public room, club, event,
// instance, invite and follow — is still refused, which is the ban.
//
// Only the two load-in calls, `/matchmake/dorm` and `/matchmake/none` — which one the
// client makes depends on the build. `dorm` resolves the CALLER'S dorm and nothing else;
// `none` would otherwise hand back whatever instance their presence names, so for a banned
// caller its handler answers the dorm outright rather than trusting a stale row.
//
// Ban EVASION gets the dorm too. `auth` signs those accounts in like any other, so refusing
// them the dorm would leave the client stuck on load-in — the exact failure this exception
// exists to fix — and they are held to it by the same gate. They see no block screen (that
// describes the caller's OWN ban, and they have none), but a player sitting in their dorm
// unable to enter any room is where a ban leaves them either way.
//
// Unauthenticated requests fall through untouched: the route's own `authedId` answers
// 401, which mustn't turn into "banned" just because the token was missing.
.use('/matchmake/*', async (c, next) => {
@@ -1564,7 +1601,7 @@ const app = new Hono<App>()
identity: { ip: c.req.header('cf-connecting-ip') },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (match) {
if (match && !isDormMatchmake(c.req.path)) {
logger.info('matchmake refused: player banned', {
accountId: id,
via: match.via,
@@ -1573,6 +1610,16 @@ const app = new Hono<App>()
})
return matchmakeResult(c, BANNED_FROM_ROOM, null)
}
if (match) {
// Tells `/matchmake/none` to answer the dorm rather than the caller's presence.
c.set('bannedToDorm', true)
logger.info('blocked player allowed into their own dorm', {
accountId: id,
via: match.via,
reportId: match.ban.id,
banExpires: match.ban.ban_expires,
})
}
}
await next()
})
@@ -2763,7 +2810,10 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
const presence = await getPresence<RoomInstance>(c.env.DB, id)
// A BANNED caller gets their dorm whatever their presence says. The ban gate let them
// this far so the client can finish loading in and draw the block screen; answering
// the instance a stale presence row names would walk them back into a public room.
const presence = c.get('bannedToDorm') ? null : await getPresence<RoomInstance>(c.env.DB, id)
const current = presence?.roomInstance ?? (await playerDormInstance(c, id))
await enterRoom(c, id, current)
return matchmakeResult(c, 0, current)
+89 -2
View File
@@ -3349,14 +3349,13 @@ describe('account bans', () => {
headers: await bearer(player),
})
test('every matchmake route is refused for a banned account', async () => {
test('every matchmake route but the load-in ones is refused for a banned account', async () => {
await banAccount(6001)
// One live instance of room 2 and one club membership, so each route would
// otherwise have somewhere to put them.
for (const path of [
'/matchmake/room/2',
'/matchmake/room/77/34',
'/matchmake/dorm',
'/matchmake/club/4',
'/matchmake/player/9701',
'/matchmake/instance/1',
@@ -3367,6 +3366,94 @@ describe('account bans', () => {
}
})
// The client has to finish loading in to draw the block screen that tells the player they
// are banned — `auth` issues them a token for exactly that reason. Refused their own dorm
// too, they never get that far and just see a game that will not start.
test('a banned account may still matchmake into its own dorm', async () => {
await banAccount(6008)
const res = await matchmake('/matchmake/dorm', '6008')
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { roomInstanceId: number; roomId: number } | null
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).not.toBeNull()
// Their OWN dorm, the same one every time — not a room anyone else can reach.
const again = (await (await matchmake('/matchmake/dorm', '6008')).json()) as {
roomInstance: { roomInstanceId: number } | null
}
expect(again.roomInstance?.roomInstanceId).toBe(body.roomInstance!.roomInstanceId)
// And it is only the dorm — the ban is otherwise untouched.
expect(await (await matchmake('/matchmake/room/2', '6008')).json()).toEqual(refused(55))
})
// `/matchmake/none` is the other load-in call, and which one the client makes depends on
// the build. It normally answers the instance the caller's presence names — so for a banned
// caller it must answer the DORM instead, or a stale presence row walks them straight back
// into the public room they were standing in.
test('a banned account’s /matchmake/none answers the dorm, not their stale presence', async () => {
await banAccount(6011)
// Standing in a live instance of room 2, the way a player banned mid-session is.
const joined = (await (await matchmake('/matchmake/room/2', '9701')).json()) as {
roomInstance: { roomInstanceId: number; roomId: number }
}
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 6011,
roomInstance: joined.roomInstance,
expiresAt: Math.floor(Date.now() / 1000) + 900,
})
)
.run()
const body = (await (await matchmake('/matchmake/none', '6011')).json()) as {
errorCode: number
roomInstance: { roomInstanceId: number; roomId: number } | null
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).not.toBeNull()
// NOT the room they were standing in.
expect(body.roomInstance?.roomInstanceId).not.toBe(joined.roomInstance.roomInstanceId)
expect(body.roomInstance?.roomId).not.toBe(joined.roomInstance.roomId)
// It is their dorm — the same instance `/matchmake/dorm` answers.
const dorm = (await (await matchmake('/matchmake/dorm', '6011')).json()) as {
roomInstance: { roomInstanceId: number } | null
}
expect(dorm.roomInstance?.roomInstanceId).toBe(body.roomInstance?.roomInstanceId)
})
// An evader signs in like anyone else (`auth` issues them a token), so they must be able to
// finish loading in — otherwise the client hangs on a failed login, which is the failure the
// dorm exception exists to fix. They are held to the dorm by the same gate.
test('an account caught by ban evasion gets the dorm and nothing else', async () => {
const linkTo = async (id: number, platformId: string) =>
env.DB.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, 0, ?2, ?3)`
)
.bind(id, platformId, new Date().toISOString())
.run()
await linkTo(6009, 'steam-dorm-evader')
await banAccount(6009)
// The replacement account: different id, same headset.
await linkTo(6010, 'steam-dorm-evader')
const dorm = (await (await matchmake('/matchmake/dorm', '6010')).json()) as {
errorCode: number
roomInstance: unknown
}
expect(dorm.errorCode).toBe(0)
expect(dorm.roomInstance).not.toBeNull()
// Every room is still closed to them — which is the ban.
expect(await (await matchmake('/matchmake/room/2', '6010')).json()).toEqual(refused(55))
expect(await (await matchmake('/matchmake/club/4', '6010')).json()).toEqual(refused(55))
})
// The refusal is the ban's, not the room's: nothing is entered, so no presence is
// written and the player stays where they were (nowhere).
test('a refused matchmake leaves no presence behind', async () => {
@@ -0,0 +1,13 @@
-- A room ban can now carry a reason and lapse on its own.
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- `reason` is the issuer's free text, NULL when none was given. `expires_at` is ISO-8601 UTC
-- (the same format `created_at` is written in, so the two compare as strings), and NULL is a
-- permanent ban — which is what every row written before this migration is, so no backfill.
--
-- A lapsed row is not deleted when it lapses: nothing runs at that moment. Every read that
-- asks whether a player is banned filters on `expires_at IS NULL OR expires_at > now` instead,
-- and the next ban or unban of that player overwrites or removes the stale row.
ALTER TABLE room_ban ADD COLUMN reason TEXT;
ALTER TABLE room_ban ADD COLUMN expires_at TEXT;
@@ -0,0 +1,30 @@
-- Room bans that have ENDED, so `GET /rooms/{roomId}/bans/history` has a past to report.
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- `room_ban` holds only the ban in force: one row per (room, player), DELETEd on unban and
-- overwritten on re-ban. Everything that asks "is this player banned" — `match` among them —
-- reads that table and is unchanged. This table is append-only and written at the moment a
-- ban stops being the live one:
--
-- * lifted by an unban -> status 2 (Lifted), ended_at = the unban, unbanned_by_account_id set
-- * lapsed, then cleared -> status 1 (Elapsed), ended_at = its expiry, unbanned_by_account_id NULL
-- (by an unban of the stale row, or by a new ban replacing it)
--
-- A ban still in force that is re-issued is AMENDED in place, not ended, so it writes nothing.
-- Nothing is backfilled: bans lifted before this migration were deleted and left no record.
CREATE TABLE IF NOT EXISTS room_ban_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
banned_player_id INTEGER NOT NULL,
ban_mask INTEGER NOT NULL DEFAULT 0,
banned_by_account_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
reason TEXT,
expires_at TEXT,
status INTEGER NOT NULL,
ended_at TEXT,
unbanned_by_account_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_room_ban_history_player
ON room_ban_history (room_id, banned_player_id);
+62
View File
@@ -440,6 +440,39 @@ export const IsBannedPascalEnvelope = z.object({
error_id: z.string().nullable().describe('Null. Lowercase, unlike its three siblings'),
})
/**
* One ban in `GET /rooms/{roomId}/bans/history`. Seven keys, in the client's order — derived
* members before base ones, so `AccountId` is fifth.
*/
export const RoomBanRecordDto = z.object({
Status: z.int().describe('0 Active · 1 Elapsed (ran out) · 2 Lifted (unbanned early)'),
UnbannedByAccountId: z.int().nullable().describe('Who lifted it; null unless Lifted'),
BanEndTime: z
.string()
.nullable()
.describe(
'ISO 8601 UTC. Active: scheduled expiry, null when permanent. Elapsed: its expiry. Lifted: when it was lifted'
),
Reason: z.string().describe('`""` when no reason was given — never null'),
AccountId: z.int().describe('The banned player'),
BannedByAccountId: z.int().nullable().describe('Who issued the ban'),
BanStartTime: z.string().describe('ISO 8601 UTC, when the ban was issued'),
})
/** `GET /rooms/{roomId}/bans/history` — the PascalCase envelope, `error_id` lowercase. */
export const RoomBanHistoryEnvelope = z.object({
Value: z
.object({
ActiveBan: RoomBanRecordDto.nullable().describe('The ban in force, or null'),
PreviousBans: RoomBanRecordDto.array().describe('Every ban that has ended, newest first'),
})
.nullable()
.describe('Null only when `id` is missing or not a number'),
Success: z.boolean(),
Error: z.string().nullable(),
error_id: z.string().nullable().describe('Null. Lowercase, unlike its three siblings'),
})
/** The bare JSON string the bulk lookups answer when the id list is over the cap. */
export const TooManyLookupIds = z
.string()
@@ -608,6 +641,16 @@ export const BanRequest = z.object({
.string()
.optional()
.describe('Stored verbatim; meaning unknown — the client sends `0`. Defaults to 0'),
reason: z
.string()
.optional()
.describe('Free text, stored on the ban and shown in the kick message'),
durationMinutes: z
.string()
.optional()
.describe(
'Minutes until the ban lapses. Absent, empty or `0` is permanent; anything that is not a non-negative whole number is refused'
),
})
/** A stored room ban — what `POST /rooms/{roomId}/bans` answers in `value`. */
@@ -617,6 +660,8 @@ export const RoomBanDto = z.object({
BanMask: z.int(),
BannedByAccountId: z.int().describe('Who issued the ban'),
CreatedAt: z.string(),
Reason: z.string().nullable().describe('Null when no reason was given'),
ExpiresAt: z.string().nullable().describe('ISO 8601 UTC when the ban lapses; null is permanent'),
})
/**
@@ -630,6 +675,23 @@ export const RoomBanEntryDto = z.object({
banStartTime: z.string().describe('ISO 8601 UTC, when the ban was issued'),
})
/** `DELETE /rooms/{roomId}/bans/bulk` — the players whose bans to lift. */
export const UnbanBulkRequest = z.object({
id: z
.union([z.string(), z.array(z.string())])
.describe('Account id(s) to unban — repeated `id=` fields, or one comma-separated value'),
banMask: z.string().optional().describe('Sent by the client; ignored'),
})
/** What the bulk unban answers — the ban envelope, with `value` the removed bans. */
export const RoomBansRemovedEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty on success'),
value: RoomBanDto.array()
.nullable()
.describe('The bans removed, in request order; null on a rejection'),
})
/** The envelope the ban write answers — same shape as the room writes, `value` is the ban. */
export const RoomBanEnvelope = z.object({
success: z.boolean(),
+298 -36
View File
@@ -15,6 +15,7 @@ import {
countRoomsByCreator,
createNotification,
createSubRoom,
deletePresence,
deleteRoom,
deleteRoomLeaderboard,
deleteSubRoom,
@@ -31,6 +32,7 @@ import {
getPresence,
getPublicRoomsByCreator,
getRecommendedRooms,
getRoomBanHistory,
getRoomBans,
getRoomById,
getRoomByName,
@@ -48,6 +50,7 @@ import {
MessageType,
modifySubRoom,
publishSubRoomSave,
refreshInstanceFullness,
removeCheer,
removeFavorite,
removeRoomRole,
@@ -124,6 +127,8 @@ import {
RoleRequest,
RoomBanEntryDto,
RoomBanEnvelope,
RoomBanHistoryEnvelope,
RoomBansRemovedEnvelope,
RoomDto,
RoomEnvelope,
RoomExperience,
@@ -151,11 +156,12 @@ import {
UNAUTHORIZED_EMPTY,
UNAUTHORIZED_ENVELOPE,
UNAUTHORIZED_RESPONSE,
UnbanBulkRequest,
WarningRequest,
} from './openapi'
import type { Context } from 'hono'
import type { RoomBan, RoomPermission } from '@repo/domain'
import type { RoomBan, RoomBanRecord, RoomPermission } from '@repo/domain'
import type { MessageReceivedPayload } from '../../notify/src/notification-payloads'
import type { App } from './context'
@@ -615,43 +621,109 @@ async function pushRoleInvite(
const REPORT_CATEGORY_MODERATOR = -1
/**
* Eject a player from the room they're in — a `ModerationKick` push (id 22), the frame
* the client acts on to remove someone. Sent on a ban: the row keeps them out of future
* matchmakes, this gets them out of the instance they're in right now.
* Take a freshly banned player out of the room's LIVE PRESENCE, so the ban takes effect on
* everything that reads presence right now rather than when their row next expires. The kick
* push ejects their client, but until the row goes they still count toward the instance's
* player total (keeping a full room full), still show as standing in the room to friends, and
* still pass the presence-based grants — reading the room's staged saves among them.
*
* The payload is the client's moderation shape, camelCase, in wire order:
* `reportCategory`, `duration`, `gameSessionId`, `isHostKick`, `message`,
* `playerIdReporter`, `isBan`, `isVoiceModAutoban`. `duration` is 0 (a room ban has no
* expiry — it's lifted by DELETE, not by time) and `gameSessionId` is 0 (nothing here
* tracks one).
* Only when their presence puts them in THIS room. A player banned from a room they are not
* standing in keeps their presence: a ban here is no reason to knock them offline from wherever
* they actually are. The same rule the instant kick in `api` applies to its instance.
*
* `isHostKick` says the room's HOST ejected the player, as opposed to the room
* majority vote-kicking them. There is no vote-kick path yet, so the only false case
* here is a staff moderator acting in a room they don't host. `playerIdReporter` is
* whoever caused it — the host today, and the player who started the vote once
* vote-kicks exist (those will carry `reportCategory` 10 and `isHostKick` false).
* The instance they left gets its fullness recomputed, as every other presence removal does, so
* a room they were filling opens back up. Best-effort like the push: the ban row is already
* committed, and a failure here must not turn a ban that happened into an error response.
*
* Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban row has
* already committed, so a hub hiccup must not fail the request.
* Returns the instance they were removed from, or null when they were not standing in the
* room — which is what decides whether they get a kick frame at all.
*/
async function evictBannedPlayer(c: Context<App>, ban: RoomBan): Promise<number | null> {
try {
const instance = (await getPresence<PresenceView>(c.env.DB, ban.BannedPlayerId))?.roomInstance
if (instance?.roomId !== ban.RoomId) return null
await deletePresence(c.env.DB, ban.BannedPlayerId)
if (instance.roomInstanceId == null) return null
await refreshInstanceFullness(c.env.DB, instance.roomInstanceId)
return instance.roomInstanceId
} catch (err) {
logger.error('failed to clear a banned player’s presence', {
playerId: ban.BannedPlayerId,
roomId: ban.RoomId,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}
/** An ISO timestamp as the client's ban record carries it: UTC, to the second. */
function toBanTime(iso: string): string {
return iso.replace(/\.\d+Z$/, 'Z')
}
/**
* One ban as the history route serves it. Seven keys, and the ORDER is the client's: the
* derived members before the base ones, so `AccountId` is fifth. `Reason` is a non-nullable
* string on the client, so a ban issued without one is `""`, not null.
*/
function toBanRecordDto(ban: RoomBanRecord) {
return {
Status: ban.Status,
UnbannedByAccountId: ban.UnbannedByAccountId,
BanEndTime: ban.EndedAt === null ? null : toBanTime(ban.EndedAt),
Reason: ban.Reason ?? '',
AccountId: ban.AccountId,
BannedByAccountId: ban.BannedByAccountId,
BanStartTime: toBanTime(ban.StartedAt),
}
}
/**
* Eject a room-banned player from the instance they are standing in — a `ModerationKick`
* frame (id 22), the one the client acts on to leave a room. The `room_ban` row is what keeps
* them out afterwards: `match` refuses their matchmakes into this room with `BannedFromRoom`
* (55). This frame only gets them out of the session they are in right now.
*
* It must NOT look like an account ban, and it once did. `IsBan: true` on this frame is what
* the client's moderation screen reads as a ban from the GAME — it is exactly what the staff
* account ban (`www`) sends — so a room ban sent that way showed players a game-wide ban
* screen, and with a non-zero `duration` a timed one, when nothing but one room was closed to
* them. So, like `api`'s instant kick:
*
* - `isBan` false and `duration` 0. The frame is a kick; the ban is server-side.
* - EPHEMERAL, never queued. A queued frame is delivered on the player's next connect, which
* would eject them from some unrelated session later, or greet them with it on login.
* - Sent only when they were standing in THIS room — `gameSessionId` is the instance they were
* evicted from. A player banned from a room they are not in has nothing to be ejected from
* and hears nothing; they meet the ban as a refused matchmake if they try to join.
*
* The payload is the client's moderation shape, camelCase, in wire order. `isHostKick` says the
* room's HOST ejected them rather than a room vote-kick; the only false case today is a staff
* moderator acting in a room they do not host. `playerIdReporter` is whoever caused it.
*
* Best-effort: the ban row has already committed, so a hub hiccup must not fail the request.
*/
async function pushRoomBan(
c: Context<App>,
ban: RoomBan,
gameSessionId: number,
roomName: string,
isHostKick: boolean
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
ban.BannedPlayerId,
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
[ban.BannedPlayerId],
NotificationType.ModerationKick,
{
reportCategory: REPORT_CATEGORY_MODERATOR,
duration: 0,
gameSessionId: 0,
gameSessionId,
isHostKick,
message: `You have been banned from ${roomName}.`,
message: ban.Reason
? `You have been banned from ${roomName}. Reason: ${ban.Reason}`
: `You have been banned from ${roomName}.`,
playerIdReporter: ban.BannedByAccountId,
isBan: true,
isBan: false,
isVoiceModAutoban: false,
}
)
@@ -2401,7 +2473,98 @@ const app = new Hono<App>()
}
)
// Ban a player from a room (form body `id` + `banMask`). Auth-gated (401), then
// One player's ban history in one room (`?id=` is the player). The owner's view of
// whether someone is banned now and how often they have been before — so gated like the
// ban list: auth (401), then the room's owner/co-owner or a staff token (403).
//
// Answers the PascalCase `{ Value, Success, Error, error_id }` envelope the unprefixed
// isBanned uses — `error_id` lowercase among its PascalCase siblings — with `Value` exactly
// `{ ActiveBan, PreviousBans }`. The record's seven keys are in the client's order: the
// derived members (`Status`, `UnbannedByAccountId`, `BanEndTime`, `Reason`) come BEFORE the
// base ones (`AccountId`, `BannedByAccountId`, `BanStartTime`), which is why `AccountId`
// is fifth. Built key by key in that order rather than by spreading a domain object.
.get(
'/rooms/:roomId{[0-9]+}/bans/history',
describeRoute({
tags: ['Room settings'],
summary: 'A player’s ban history in a room',
description: [
'Whether `id` is banned from the room right now, and every ban of theirs there that has',
'ended. `Value` has exactly two keys: `ActiveBan` (the ban in force, or null) and',
'`PreviousBans` (newest first).',
'',
'Each record is seven keys in this order: `Status` (0 Active, 1 Elapsed, 2 Lifted),',
'`UnbannedByAccountId` (who lifted it; null unless Lifted), `BanEndTime`, `Reason`',
'(`""` when none was given — never null), `AccountId`, `BannedByAccountId`,',
'`BanStartTime`. `BanEndTime` is the scheduled expiry of an Active ban (null for a',
'permanent one), the expiry of an Elapsed ban, and the moment a Lifted ban was lifted.',
'Timestamps are ISO 8601 UTC to the second.',
'',
'A timed ban that has run out is not active: it is reported as an Elapsed previous ban.',
'A ban re-issued while still in force is AMENDED, not ended, so it appears once, with',
'its latest reason and times. History starts with this server’s ban-history migration;',
'bans lifted before it left no record.',
'',
'Gated like the ban list: the room’s creator or a co-owner, or an account whose token',
'carries the `developer` / `moderator` role. An unknown room answers an empty history.',
'A missing or non-numeric `id` answers `Success: false` with a null `Value`.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'id',
in: 'query',
required: true,
description: 'The account whose ban history to read',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(RoomBanHistoryEnvelope, 'The player’s active ban and previous bans'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const playerId = Number.parseInt(c.req.query('id') ?? '', 10)
if (Number.isNaN(playerId)) {
return c.json({
Value: null,
Success: false,
Error: 'You must provide a valid player!',
error_id: null,
})
}
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
// No room → no bans, the same answer the ban list gives, so this is not a way to
// probe which room ids exist.
if (room && !canManageRoom(room, accountId) && !(await isStaff(c))) {
return c.body(null, 403)
}
const history = room
? await getRoomBanHistory(c.env.DB, roomId, playerId)
: { active: null, previous: [] }
return c.json({
Value: {
ActiveBan: history.active ? toBanRecordDto(history.active) : null,
PreviousBans: history.previous.map(toBanRecordDto),
},
Success: true,
Error: null,
error_id: null,
})
}
)
// Ban a player from a room (form body `id` + `banMask`, optionally `reason` and
// `durationMinutes`). Auth-gated (401), then
// gated to the room's owner/co-owner OR a staff token (403). One row per
// (room, player) — re-banning rewrites it, so the call is idempotent.
.post(
@@ -2423,16 +2586,25 @@ const app = new Hono<App>()
'`banMask` is stored verbatim and nothing interprets it — the client sends `0` and',
'what it selects is not known yet. It defaults to 0 when absent.',
'',
'The BANNED player (not the caller) gets a `ModerationKick` push (id 22) — the frame',
'the client acts on to eject someone — so a ban takes effect immediately rather than',
'only at their next matchmake. `isBan` is true, `duration` 0 (a room ban has no',
'expiry; it is lifted by DELETE, not by time) and `reportCategory` -1 (Moderator).',
'`reason` is optional free text, stored on the ban and appended to the kick message.',
'`durationMinutes` makes the ban lapse on its own; absent, empty or `0` is permanent.',
'A duration that is present but not a non-negative whole number is a rejection, never',
'read as permanent. A lapsed ban stops counting everywhere — matchmaking, `isBanned`,',
'the ban list — without anyone lifting it. Re-banning REPLACES the reason and',
'duration and restarts the clock; it does not extend the remaining time.',
'',
'`isHostKick` means the room’s HOST ejected them rather than the room majority',
'vote-kicking them; with no vote-kick path yet the only false case is a staff',
'moderator acting in a room they do not host. `playerIdReporter` is whoever caused',
'it — the host today, the player who started the vote once vote-kicks exist. The hub',
'queues the frame if they are offline.',
'If the banned player’s live presence puts them in THIS room, that presence row is',
'deleted and the instance’s fullness recomputed, so they stop counting as standing in',
'the room immediately. Presence in some other room is left alone.',
'',
'A banned player standing in the room also gets a `ModerationKick` frame (id 22),',
'the frame the client acts on to leave, with `gameSessionId` the instance they were',
'removed from. It is a KICK, not a ban, as far as the client is concerned: `isBan` is',
'false and `duration` 0, because `isBan: true` is what the client shows as a ban from',
'the whole game. The ban itself is the row, enforced when `match` refuses them the room',
'(`BannedFromRoom`, 55). The frame is ephemeral, never queued, and a player not in the',
'room gets none. `isHostKick` is false only for a staff moderator acting in a room they',
'do not host; `playerIdReporter` is whoever issued the ban.',
'',
'Answers the same lowercase `{ success, error, value }` envelope the room writes use,',
'but `value` is the BAN, not the room — a ban is not part of the room the client',
@@ -2475,10 +2647,27 @@ const app = new Hono<App>()
// Absent or unparseable → 0, the value the client sends.
const banMask = Number.parseInt(str(body.banMask), 10) || 0
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId)
// The banned player is told, not the caller — their client acts on the kick.
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushRoomBan(c, ban, roomName, isHostKick)
// Absent or empty is a PERMANENT ban. A duration that is present but is not a
// non-negative whole number is refused rather than read as permanent: a typo turning
// "30 minutes" into "forever" is the one mistake here that is worse than failing.
const rawDuration = str(body.durationMinutes).trim()
const durationMinutes = rawDuration === '' ? 0 : Number(rawDuration)
if (!Number.isInteger(durationMinutes) || durationMinutes < 0) {
return banEnvelope(c, null, 'You must provide a valid ban duration!')
}
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId, {
reason: str(body.reason),
durationMinutes,
})
// Out of the room's presence first, so by the time their client acts on the kick
// nothing server-side still counts them as standing in it. Only a player who WAS
// standing in the room is kicked — anyone else has nothing to be ejected from.
const evictedFrom = await evictBannedPlayer(c, ban)
if (evictedFrom !== null) {
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushRoomBan(c, ban, evictedFrom, roomName, isHostKick)
}
return banEnvelope(c, ban)
}
)
@@ -2581,6 +2770,79 @@ const app = new Hono<App>()
}
)
// Lift bans in BULK (form body `id`, repeated or comma-separated, plus the `banMask` the
// client sends and nothing reads). Same gate as issuing one: auth-gated (401), then the
// room's owner/co-owner OR a staff token (403). Declared before the `/:playerId` route
// only for reading order — `bulk` is not a number, so the two never compete for a path.
.delete(
'/rooms/:roomId{[0-9]+}/bans/bulk',
describeRoute({
tags: ['Room settings'],
summary: 'Unban players from a room in bulk',
description: [
'Removes the `room_ban` row of every player named by `id`, so they can matchmake into',
'the room again. `id` may repeat (`id=205&id=206`) or be comma-separated; `banMask`',
'is accepted because the client sends it, and ignored — it described the ban, not the',
'unban. The body is form-encoded, on a DELETE.',
'',
'Gated exactly like issuing a ban: the room’s creator or a co-owner, or an account',
'whose token carries the `developer` / `moderator` role.',
'',
'Unlike the single-player DELETE, a player who is not banned is SKIPPED rather than',
'failing the call — one stale id in a list must not sink the rest. Only a body naming',
'no valid id at all is a rejection. A ban that has already lapsed counts as not banned.',
'',
'Answers the ban envelope with `value` the ARRAY of bans actually removed, in the order',
'asked; empty when none of the players named were banned. No notification is pushed.',
'This shape is unverified against the real service.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(UnbanBulkRequest, 'The players to unban'),
responses: {
200: json(
RoomBansRemovedEnvelope,
'The removed bans, or a rejection with `success: false`'
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return banEnvelope(c, null, 'This room does not exist!')
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
// `all: true` keeps a repeated `id` from collapsing to its last value, which would
// unban one player out of the list and report success.
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
const ids = [
...new Set(
[body.id]
.flat()
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((v) => Number.parseInt(v.trim(), 10))
.filter((n) => !Number.isNaN(n))
),
]
if (ids.length === 0) return banEnvelope(c, null, 'You must provide a valid player to unban!')
const removed: RoomBan[] = []
for (const playerId of ids) {
const ban = await unbanPlayerFromRoom(c.env.DB, roomId, playerId, accountId)
if (ban) removed.push(ban)
}
return c.json({ success: true, error: '', value: removed })
}
)
// Lift a player's ban on a room. Same gate as issuing one: auth-gated (401), then the
// room's owner/co-owner OR a staff token (403).
.delete(
@@ -2617,7 +2879,7 @@ const app = new Hono<App>()
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId)
const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId, accountId)
if (!removed) return banEnvelope(c, null, 'This player is not banned from this room!')
return banEnvelope(c, removed)
}
+427 -12
View File
@@ -2507,8 +2507,154 @@ describe('rooms endpoints', () => {
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
})
it('POST /rooms/:id/bans kicks the banned player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown }
it('POST /rooms/:id/bans takes a reason and a duration', async () => {
type Ban = { Reason: string | null; ExpiresAt: string | null; CreatedAt: string }
type Sent = { playerIds: number[]; data: { duration: number; isBan: boolean; message: string } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
// Standing in the room, so there is a session to kick them out of.
await putInRoom(310, 2)
// The client's body, verbatim.
const res = await postForm(
'/rooms/2/bans',
{ banMask: '0', id: '310', reason: 'test', durationMinutes: '30' },
'1'
)
const body = (await envOf(res)) as unknown as { success: boolean; value: Ban }
expect(body).toMatchObject({ success: true, value: { BannedPlayerId: 310, Reason: 'test' } })
// Lapses thirty minutes after it was issued, to the second.
expect(Date.parse(body.value.ExpiresAt!) - Date.parse(body.value.CreatedAt)).toBe(30 * 60_000)
// The kick carries the reason in its message — but NOT the ban's length, and is not a
// ban frame: a duration with `isBan` is what the client shows as a timed GAME ban.
const [kick] = (await (await hub().fetch('http://do/all')).json()) as Sent[]
expect(kick.playerIds).toEqual([310])
expect(kick.data).toMatchObject({ duration: 0, isBan: false })
expect(kick.data.message).toMatch(/Reason: test$/)
// Neither field is required: no duration is a permanent ban, and it still pushes 0.
const permanent = (await envOf(
await postForm('/rooms/2/bans', { banMask: '0', id: '311' }, '1')
)) as unknown as { value: Ban }
expect(permanent.value).toMatchObject({ Reason: null, ExpiresAt: null })
for (const d of ['', '0']) {
const v = (await envOf(
await postForm('/rooms/2/bans', { id: '312', durationMinutes: d }, '1')
)) as unknown as { value: Ban }
expect(v.value.ExpiresAt, d).toBeNull()
}
// A re-ban REPLACES the duration and reason — it neither extends nor keeps the old ones.
const rebanned = (await envOf(
await postForm('/rooms/2/bans', { id: '310' }, '1')
)) as unknown as { value: Ban }
expect(rebanned.value).toMatchObject({ Reason: null, ExpiresAt: null })
})
it('POST /rooms/:id/bans deletes the banned player’s presence in that room', async () => {
const presenceOf = (accountId: number) =>
env.DB.prepare('SELECT room_id FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ room_id: number }>()
const instanceId = 1000002 // what `putInRoom` puts room 2's players in
const isFull = async () =>
(
await env.DB.prepare('SELECT is_full FROM room_instance WHERE id = ?1')
.bind(instanceId)
.first<{ is_full: number }>()
)?.is_full
// A one-seat instance of room 2 that 320 is filling.
await env.DB.prepare('INSERT INTO room_instance (data) VALUES (?1)')
.bind(
JSON.stringify({
roomInstanceId: instanceId,
roomId: 2,
subRoomId: 2,
maxCapacity: 1,
isFull: true,
})
)
.run()
await putInRoom(320, 2)
// 321 is standing in a DIFFERENT room.
await putInRoom(321, 4)
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '320' }, '1')).status).toBe(200)
// Gone from the room they were banned from, and the seat they held is free again.
expect(await presenceOf(320)).toBeNull()
expect(await isFull()).toBe(0)
// A ban from room 2 does not knock someone offline from room 4.
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '321' }, '1')).status).toBe(200)
expect(await presenceOf(321)).toEqual({ room_id: 4 })
await clearPresence(321)
await env.DB.prepare('DELETE FROM room_instance WHERE id = ?1').bind(instanceId).run()
})
it('POST /rooms/:id/bans refuses a duration it cannot read, rather than banning forever', async () => {
for (const durationMinutes of ['thirty', '-5', '1.5']) {
expect(
await envOf(await postForm('/rooms/2/bans', { id: '313', durationMinutes }, '1')),
durationMinutes
).toMatchObject({ success: false, error: 'You must provide a valid ban duration!' })
}
const row = await env.DB.prepare(
'SELECT 1 FROM room_ban WHERE room_id = 2 AND banned_player_id = 313'
).first()
expect(row).toBeNull()
})
it('a lapsed room ban stops counting everywhere without being lifted', async () => {
// Written straight to the table: an hour-long ban issued two hours ago.
const issued = new Date(Date.now() - 2 * 3_600_000)
await env.DB.prepare(
`INSERT INTO room_ban
(room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at)
VALUES (2, 314, 0, 1, ?1, 'old', ?2)`
)
.bind(issued.toISOString(), new Date(issued.getTime() + 3_600_000).toISOString())
.run()
// `isBanned` — the same check `match` refuses matchmakes on.
const isBanned = await SELF.fetch(`${ORIGIN}/rooms/2/bans/314/isBanned`, {
headers: await bearer('314'),
})
expect(await isBanned.json()).toMatchObject({ Value: false })
// Not in the ban list.
const list = (await (
await SELF.fetch(`${ORIGIN}/rooms/2/bans`, { headers: await bearer('1') })
).json()) as Array<{ accountId: number }>
expect(list.some((b) => b.accountId === 314)).toBe(false)
// Unbanning it is "not banned", like any other player who isn't.
const unban = await SELF.fetch(`${ORIGIN}/rooms/2/bans/314`, {
method: 'DELETE',
headers: await bearer('1'),
})
expect(await unban.json()).toMatchObject({ success: false })
// A ban still in force DOES count — the filter is on the expiry, not on having one.
expect(
(await postForm('/rooms/2/bans', { id: '315', durationMinutes: '30' }, '1')).status
).toBe(200)
const stillBanned = await SELF.fetch(`${ORIGIN}/rooms/2/bans/315/isBanned`, {
headers: await bearer('315'),
})
expect(await stillBanned.json()).toMatchObject({ Value: true })
})
it('POST /rooms/:id/bans kicks a banned player out of the room — without banning them from the game', async () => {
type Sent = {
playerId?: number
playerIds?: number[]
ephemeral?: boolean
notificationType: string | number
data: unknown
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sentSince = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
@@ -2516,27 +2662,30 @@ describe('rooms endpoints', () => {
// The room's current name, read rather than hardcoded — earlier tests rename it.
const { Name } = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
await putInRoom(207, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)
// A ModerationKick (id 22) to the BANNED player, not the caller — it ejects them
// from the instance they're in now; the row keeps them out of future matchmakes.
// One EPHEMERAL ModerationKick to the banned player, out of the instance they were in.
// Asserted against the enum rather than a literal: the ids are notify's to change.
expect(await sentSince()).toEqual([
{
playerId: 207,
playerIds: [207],
ephemeral: true,
notificationType: NotificationType.ModerationKick,
// The client's moderation payload, camelCase, in wire order.
data: {
reportCategory: -1, // Moderator — a person acted, not the system
duration: 0, // a room ban has no expiry
gameSessionId: 0,
// The host ejected them (as opposed to a room vote-kick, which doesn't
// exist yet). Account 1 owns RecCenter, so it hosts it.
duration: 0,
gameSessionId: 1000002, // the instance `putInRoom` stood them in
// Account 1 owns RecCenter, so it hosts it.
isHostKick: true,
message: `You have been banned from ${Name}.`,
playerIdReporter: 1,
isBan: true,
// FALSE. `isBan: true` is what the client shows as a ban from the whole game
// — the account ban's frame. A room ban is enforced by the row, when `match`
// refuses them the room; this frame only ejects them from the session.
isBan: false,
isVoiceModAutoban: false,
},
},
@@ -2544,14 +2693,26 @@ describe('rooms endpoints', () => {
// A staff moderator doesn't host the room, so it isn't a host kick — and
// `playerIdReporter` is still whoever caused it.
await putInRoom(208, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect(
(await postForm('/rooms/2/bans', { id: '208' }, '999', ['gameClient', 'moderator'])).status
).toBe(200)
expect((await sentSince())[0]).toMatchObject({
playerId: 208,
data: { isHostKick: false, playerIdReporter: 999 },
playerIds: [208],
data: { isHostKick: false, playerIdReporter: 999, isBan: false },
})
// A player who is NOT in the room has nothing to be kicked out of, and hears NOTHING —
// not now, and not queued for their next connect, which is how a room ban once greeted
// players with a ban screen on login.
await putInRoom(216, 4)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await postForm('/rooms/2/bans', { id: '216' }, '1')).status).toBe(200)
expect((await postForm('/rooms/2/bans', { id: '217' }, '1')).status).toBe(200)
expect(await sentSince()).toEqual([])
for (const id of [216]) await clearPresence(id)
})
it('GET /rooms/:id/bans lists the room’s bans, under the same gate', async () => {
@@ -2586,6 +2747,258 @@ describe('rooms endpoints', () => {
expect(await (await list('/rooms/99999/bans', '1')).json()).toEqual([])
})
it('GET /rooms/:id/bans/history serves the client’s exact shape', async () => {
const history = async (playerId: number) =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/2/bans/history?id=${playerId}`, {
headers: await bearer('1'),
})
).json()) as {
Value: {
ActiveBan: Record<string, unknown> | null
PreviousBans: Array<Record<string, unknown>>
}
Success: boolean
Error: string | null
error_id: string | null
}
expect(
(await postForm('/rooms/2/bans', { banMask: '0', id: '340', reason: 'test' }, '1')).status
).toBe(200)
const body = await history(340)
// The envelope, PascalCase with a lowercase `error_id`, and a Value of exactly two keys.
expect(Object.keys(body)).toEqual(['Value', 'Success', 'Error', 'error_id'])
expect(body).toMatchObject({ Success: true, Error: null, error_id: null })
expect(Object.keys(body.Value)).toEqual(['ActiveBan', 'PreviousBans'])
// Seven keys in the CLIENT'S order — derived members first, so AccountId is fifth.
const active = body.Value.ActiveBan!
expect(Object.keys(active)).toEqual([
'Status',
'UnbannedByAccountId',
'BanEndTime',
'Reason',
'AccountId',
'BannedByAccountId',
'BanStartTime',
])
expect(active).toMatchObject({
Status: 0, // Active
UnbannedByAccountId: null,
BanEndTime: null, // permanent
Reason: 'test',
AccountId: 340,
BannedByAccountId: 1,
})
// UTC to the second, as the client's example carries it.
expect(active.BanStartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
expect(body.Value.PreviousBans).toEqual([])
// A ban with no reason is `""`, never null; a timed one carries its scheduled end.
expect(
(await postForm('/rooms/2/bans', { id: '341', durationMinutes: '30' }, '1')).status
).toBe(200)
const timed = (await history(341)).Value.ActiveBan!
expect(timed.Reason).toBe('')
expect(Date.parse(timed.BanEndTime as string) - Date.parse(timed.BanStartTime as string)).toBe(
30 * 60_000
)
// Someone never banned has no history at all.
expect((await history(349)).Value).toEqual({ ActiveBan: null, PreviousBans: [] })
})
it('GET /rooms/:id/bans/history keeps lifted and elapsed bans, newest first', async () => {
type Rec = {
Status: number
UnbannedByAccountId: number | null
BanEndTime: string | null
Reason: string
}
const history = async (playerId: number) =>
(
(await (
await SELF.fetch(`${ORIGIN}/rooms/2/bans/history?id=${playerId}`, {
headers: await bearer('1'),
})
).json()) as { Value: { ActiveBan: Rec | null; PreviousBans: Rec[] } }
).Value
const unban = async (playerId: number, sub: string) =>
SELF.fetch(`${ORIGIN}/rooms/2/bans/${playerId}`, {
method: 'DELETE',
headers: await bearer(sub),
})
// LIFTED: banned by the owner, unbanned early by the co-owner.
expect((await postForm('/rooms/2/bans', { id: '342', reason: 'first' }, '1')).status).toBe(200)
expect((await unban(342, '2')).status).toBe(200)
let h = await history(342)
expect(h.ActiveBan).toBeNull()
expect(h.PreviousBans).toHaveLength(1)
expect(h.PreviousBans[0]).toMatchObject({ Status: 2, UnbannedByAccountId: 2, Reason: 'first' })
expect(h.PreviousBans[0].BanEndTime).not.toBeNull()
// Banned and lifted again: two previous bans, the newest first.
expect((await postForm('/rooms/2/bans', { id: '342', reason: 'second' }, '1')).status).toBe(200)
expect((await unban(342, '1')).status).toBe(200)
h = await history(342)
expect(h.PreviousBans.map((b) => b.Reason)).toEqual(['second', 'first'])
// ELAPSED: a one-hour ban issued two hours ago, never touched since. It is not active,
// and it is already reported as a previous ban before anything files it.
const issued = new Date(Date.now() - 2 * 3_600_000)
const expired = new Date(issued.getTime() + 3_600_000).toISOString()
await env.DB.prepare(
`INSERT INTO room_ban
(room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at)
VALUES (2, 343, 0, 1, ?1, 'lapsed', ?2)`
)
.bind(issued.toISOString(), expired)
.run()
h = await history(343)
expect(h.ActiveBan).toBeNull()
expect(h.PreviousBans).toEqual([
expect.objectContaining({ Status: 1, UnbannedByAccountId: null, Reason: 'lapsed' }),
])
// A new ban replaces the stale row — and FILES the lapsed one, exactly once, rather than
// losing it or counting it twice.
expect((await postForm('/rooms/2/bans', { id: '343', reason: 'again' }, '1')).status).toBe(200)
h = await history(343)
expect(h.ActiveBan).toMatchObject({ Status: 0, Reason: 'again' })
expect(h.PreviousBans).toEqual([
expect.objectContaining({ Status: 1, UnbannedByAccountId: null, Reason: 'lapsed' }),
])
// Elapsed at its expiry, not at the moment it was replaced.
expect(Date.parse(h.PreviousBans[0].BanEndTime!)).toBe(
Math.floor(Date.parse(expired) / 1000) * 1000
)
// Re-issuing a ban still in force AMENDS it: one active ban, nothing filed.
expect((await postForm('/rooms/2/bans', { id: '344', reason: 'v1' }, '1')).status).toBe(200)
expect((await postForm('/rooms/2/bans', { id: '344', reason: 'v2' }, '2')).status).toBe(200)
h = await history(344)
expect(h.ActiveBan).toMatchObject({ Reason: 'v2' })
expect(h.PreviousBans).toEqual([])
// The bulk unban files a lift the same way the single one does.
await SELF.fetch(`${ORIGIN}/rooms/2/bans/bulk`, {
method: 'DELETE',
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer('1')) },
body: 'banMask=0&id=344',
})
h = await history(344)
expect(h.ActiveBan).toBeNull()
expect(h.PreviousBans).toEqual([
expect.objectContaining({ Status: 2, UnbannedByAccountId: 1, Reason: 'v2' }),
])
})
it('GET /rooms/:id/bans/history is gated like the ban list', async () => {
const get = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub, roles) : {} })
expect((await postForm('/rooms/2/bans', { id: '345' }, '1')).status).toBe(200)
expect((await get('/rooms/2/bans/history?id=345')).status).toBe(401)
expect((await get('/rooms/2/bans/history?id=345', '999')).status).toBe(403)
// The co-owner, and a staff token with no role on the room.
for (const [sub, roles] of [
['2', undefined],
['999', ['gameClient', 'moderator']],
] as const) {
const res = await get('/rooms/2/bans/history?id=345', sub, roles as string[] | undefined)
expect(res.status, sub).toBe(200)
expect(
((await res.json()) as { Value: { ActiveBan: unknown } }).Value.ActiveBan
).not.toBeNull()
}
// An unknown room is an empty history, not an error.
expect(await (await get('/rooms/99999/bans/history?id=345', '999')).json()).toEqual({
Value: { ActiveBan: null, PreviousBans: [] },
Success: true,
Error: null,
error_id: null,
})
// A missing or non-numeric id.
for (const q of ['', '?id=nope']) {
expect(await (await get(`/rooms/2/bans/history${q}`, '1')).json(), q).toEqual({
Value: null,
Success: false,
Error: 'You must provide a valid player!',
error_id: null,
})
}
})
it('DELETE /rooms/:id/bans/bulk removes the named bans, under the same gate', async () => {
type Removed = {
success: boolean
error: string
value: Array<{ BannedPlayerId: number }> | null
}
const unban = async (body: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}/rooms/2/bans/bulk`, {
method: 'DELETE',
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(sub ? await bearer(sub, roles) : {}),
},
body,
})
const banned = async (playerId: number) =>
(await env.DB.prepare('SELECT 1 FROM room_ban WHERE room_id = 2 AND banned_player_id = ?1')
.bind(playerId)
.first()) !== null
for (const id of ['330', '331', '332', '333']) {
expect((await postForm('/rooms/2/bans', { id }, '1')).status).toBe(200)
}
// No token → 401; a valid token with no standing in the room → 403. Nothing removed.
expect((await unban('banMask=0&id=330')).status).toBe(401)
expect((await unban('banMask=0&id=330', '999')).status).toBe(403)
expect(await banned(330)).toBe(true)
// The client's body, verbatim, from the owner.
const one = await unban('banMask=0&id=330', '1')
expect(one.status).toBe(200)
expect((await one.json()) as Removed).toMatchObject({
success: true,
error: '',
value: [{ BannedPlayerId: 330 }],
})
expect(await banned(330)).toBe(false)
// Repeated `id` lifts every one of them — not just the last — from a co-owner, and a player
// who is not banned (330, already lifted) is skipped rather than failing the others.
const many = (await (await unban('banMask=0&id=331&id=330&id=332', '2')).json()) as Removed
expect(many).toMatchObject({ success: true })
expect(many.value!.map((b) => b.BannedPlayerId)).toEqual([331, 332])
expect(await banned(331)).toBe(false)
expect(await banned(332)).toBe(false)
// A staff token works in a room it has no role on, and a comma-separated id is read too.
expect(
(await (await unban('id=333,334', '999', ['gameClient', 'moderator'])).json()) as Removed
).toMatchObject({ success: true, value: [{ BannedPlayerId: 333 }] })
// Nothing banned among the names is still a success, with nothing removed.
expect((await (await unban('id=330', '1')).json()) as Removed).toMatchObject({
success: true,
value: [],
})
// A body naming no valid player at all is the one rejection.
for (const body of ['banMask=0', 'id=nope']) {
expect((await (await unban(body, '1')).json()) as Removed, body).toMatchObject({
success: false,
error: 'You must provide a valid player to unban!',
value: null,
})
}
})
it('DELETE /rooms/:id/bans/:playerId lifts a ban, under the same gate', async () => {
const del = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, {
@@ -4654,6 +5067,7 @@ describe('rooms endpoints', () => {
)
expect([...documented].sort()).toEqual([
'DELETE /rooms/{roomId}',
'DELETE /rooms/{roomId}/bans/bulk',
'DELETE /rooms/{roomId}/bans/{playerId}',
'DELETE /rooms/{roomId}/interactionby/me/cheer',
'DELETE /rooms/{roomId}/interactionby/me/favorite',
@@ -4683,6 +5097,7 @@ describe('rooms endpoints', () => {
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/bans/history',
'GET /rooms/{roomId}/bans/{playerId}/isBanned',
'GET /rooms/{roomId}/experience',
'GET /rooms/{roomId}/experience/player',
+7 -1
View File
@@ -23,7 +23,9 @@ export default defineConfig({
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
// notifyPlayer records every call so tests can assert the notifications the
// worker pushed (type + payload). GET /all for the whole list, DELETE to
// reset it between assertions.
// reset it between assertions. notifyPlayersEphemeral is recorded too, with
// `playerIds` and `ephemeral: true`: whether a frame is queued for an offline
// player is worth asserting — a queued ModerationKick lands on their next login.
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
@@ -32,6 +34,10 @@ export default defineConfig({
this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true }
}
async notifyPlayersEphemeral(playerIds, notificationType, data) {
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
return { delivered: 0 }
}
async broadcast() { return { delivered: 0 } }
async fetch(request) {
if (request.method === 'DELETE') {
+40 -27
View File
@@ -8,6 +8,7 @@ import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
// module comment below.
import { banEvasionMatch, linkedAccounts } from '../../api/src/bans-db'
import {
banBlockDetails,
banFromReport,
createReport,
getActiveBan,
@@ -20,11 +21,10 @@ import {
import { getWarningsAgainst } from '../../api/src/warnings-db'
// The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
// imported as values/types with no runtime dependencies.
import { KickReportCategory } from '../../notify/src/notification-payloads'
import { NotificationType } from '../../notify/src/notification-types'
import type { Context, MiddlewareHandler } from 'hono'
import type { ReportSearch } from '../../api/src/reports-db'
import type { ReportRow, ReportSearch } from '../../api/src/reports-db'
import type { ModerationKickPayload } from '../../notify/src/notification-payloads'
import type { App, Env } from './context'
@@ -152,46 +152,59 @@ function banExpiry(
}
/**
* Throw the banned player out of whatever instance they are standing in.
* Tell a player they have been banned from the GAME, and throw them out of wherever they are.
*
* Without this a ban only takes effect on the player's NEXT matchmake: `match` refuses a
* banned player, but nothing revisits a session already in progress, so someone banned
* mid-session keeps playing until they leave on their own.
* Without this a ban only takes effect on the player's NEXT sign-in or matchmake: `match`
* refuses a banned player and `moderationBlockDetails` blocks them at login, but nothing
* revisits a session already in progress, so someone banned mid-session keeps playing.
*
* Mirrors `api`'s instant kick — presence row deleted (they read offline at once and the
* instance frees a slot), instance fullness recomputed, and a `ModerationKick` frame (id
* 22) pushed EPHEMERALLY. `IsBan` is true here, which is what makes the client's screen
* name it as a ban rather than a kick, and an offline player needs no frame: they meet the
* ban at `moderationBlockDetails` when they next sign in.
* The frame is a `ModerationKick` (id 22) with `IsBan: true` — the frame the client's
* moderation screen shows as a ban from the game. This is the ONE place that frame belongs: a
* ROOM ban must never send it (`rooms` sends `IsBan: false` and is enforced by refusing the
* room), because the client cannot tell the two apart and shows a game-wide ban screen.
*
* Entirely best-effort. The ban row is already committed by the time this runs; a hub
* hiccup or a missing presence must not fail a ban that has been handed down.
* Its contents are {@link banBlockDetails} — the same `ReportCategory`, `Message` and the
* `Duration`/`TimeoutStartedAt` pair `moderationBlockDetails` answers for this ban — so the
* screen a player sees mid-session is the screen they see when they next sign in, not a
* generic "banned" with no length.
*
* Sent to anyone ONLINE, not only to a player standing in an instance: someone in a menu is
* as banned as someone in a room. `GameSessionId` is their instance, or 0 when they are in
* none. EPHEMERAL, because an offline player needs no frame — they meet the same screen at
* `moderationBlockDetails` when they next sign in — and a queued one would fire again then.
*
* Their presence row is deleted (they read offline at once), and the instance they were in,
* if any, has its fullness recomputed so a full room frees a slot.
*
* Entirely best-effort. The ban row is already committed by the time this runs; a hub hiccup
* or a missing presence must not fail a ban that has been handed down.
*/
async function kickBannedPlayer(c: Context<App>, playerId: number, moderatorId: number) {
async function kickBannedPlayer(c: Context<App>, report: ReportRow, moderatorId: number) {
const playerId = report.reported_player_id
try {
const presence = (await getPresences<{ roomInstanceId?: number }>(c.env.DB, [playerId])).get(
playerId
)
const gameSessionId = presence?.roomInstance?.roomInstanceId
// Not online, or online but not in an instance (the lobby) — nothing to eject them
// from. Their presence row still goes, so they read offline immediately.
if (presence) await deletePresence(c.env.DB, playerId)
if (gameSessionId === undefined) return
// Offline: nothing to eject and nobody to tell. The sign-in check has them.
if (!presence) return
await refreshInstanceFullness(c.env.DB, gameSessionId)
await deletePresence(c.env.DB, playerId)
const gameSessionId = presence.roomInstance?.roomInstanceId
if (gameSessionId !== undefined) await refreshInstanceFullness(c.env.DB, gameSessionId)
const block = banBlockDetails(report)
const frame: ModerationKickPayload = {
ReportCategory: KickReportCategory.Moderator,
Duration: 0,
GameSessionId: gameSessionId,
ReportCategory: block.ReportCategory,
Duration: block.Duration,
GameSessionId: gameSessionId ?? 0,
IsHostKick: false,
Message: 'You have been banned.',
Message: block.Message,
PlayerIdReporter: null,
IsBan: true,
IsVoiceModAutoban: false,
IsWarning: false,
VoteKickReason: '',
TimeoutStartedAt: null,
TimeoutStartedAt: block.TimeoutStartedAt,
}
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
[playerId],
@@ -199,7 +212,7 @@ async function kickBannedPlayer(c: Context<App>, playerId: number, moderatorId:
{ ...frame }
)
} catch (err) {
logger.error('failed to kick a banned player out of their instance', {
logger.error('failed to tell a banned player they are banned', {
playerId,
moderatorId,
error: err instanceof Error ? err.message : String(err),
@@ -346,7 +359,7 @@ export async function banReportHandler(c: Context<App>) {
banExpires: report.ban_expires,
})
if (banned) await kickBannedPlayer(c, report.reported_player_id, moderatorId)
if (banned) await kickBannedPlayer(c, report, moderatorId)
return c.json(report)
}
+41
View File
@@ -937,6 +937,16 @@ it('throws a banned player out of the instance they are standing in', async () =
expect(frames[0].data.IsBan).toBe(true)
expect(frames[0].data.GameSessionId).toBe(77001)
// The SAME ban the sign-in screen describes, not a generic one: its category, message and
// the Duration/TimeoutStartedAt pair, three days from when it was handed down.
const banned = await getReportById(env.DB, report.id)
expect(frames[0].data).toMatchObject({
ReportCategory: banned!.report_category,
Message: 'Rule violation',
TimeoutStartedAt: banned!.banned_at,
Duration: 3 * 86_400,
})
// And their presence row is gone, so they read offline at once and the instance frees
// a slot.
const presence = await env.DB.prepare(
@@ -945,6 +955,37 @@ it('throws a banned player out of the instance they are standing in', async () =
expect(presence?.n).toBe(0)
})
// Online but not standing in any room — in a menu — is still banned from the game, and is
// told so now rather than at their next sign-in.
it('tells a banned player who is online but in no instance', async () => {
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
await setPresence(env.DB, {
accountId: 8195,
roomInstance: null,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 4,
appVersion: 'test',
})
const report = await createReport(env.DB, { reporterPlayerId: 8196, reportedPlayerId: 8195 })
expect(
(await staffPost(`/api/staff/reports/${report.id}/ban`, 8110, { permanent: true })).status
).toBe(200)
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
playerIds?: number[]
ephemeral?: boolean
data: Record<string, unknown>
}>
expect(frames).toHaveLength(1)
expect(frames[0]).toMatchObject({ playerIds: [8195], ephemeral: true })
// No session to name, and a permanent ban carries the largest duration the field holds.
expect(frames[0].data).toMatchObject({ IsBan: true, GameSessionId: 0, Duration: 2_147_483_647 })
})
// The ban row is committed before the kick is attempted, so a player who is offline (or
// a hub that can't be reached) must not fail a ban that has been handed down.
it('bans an offline player without a kick to push', async () => {