implement room ban

This commit is contained in:
Devin Zuczek
2026-08-04 15:41:49 -04:00
parent dbc6d15ef5
commit 73bb7c4609
6 changed files with 263 additions and 62 deletions
+11
View File
@@ -462,6 +462,17 @@ export const RoomBanDto = z.object({
CreatedAt: z.string(),
})
/**
* One entry of `GET /rooms/{roomId}/bans` — the client's ban-list shape. camelCase and
* a different field set from the {@link RoomBanDto} the write answers: no room id (the
* path already says which room) and no ban mask.
*/
export const RoomBanEntryDto = z.object({
accountId: z.int().describe('The banned player'),
bannedByAccountId: z.int().describe('Who issued the ban'),
banStartTime: z.string().describe('ISO 8601 UTC, when the ban was issued'),
})
/** The envelope the ban write answers — same shape as the room writes, `value` is the ban. */
export const RoomBanEnvelope = z.object({
success: z.boolean(),
+115 -16
View File
@@ -22,6 +22,7 @@ import {
getPresence,
getPublicRoomsByCreator,
getRecommendedRooms,
getRoomBans,
getRoomById,
getRoomByName,
getRoomsByCreator,
@@ -83,6 +84,7 @@ import {
RestrictionsRequest,
RoleRequest,
RoomBanEnvelope,
RoomBanEntryDto,
RoomDto,
RoomEnvelope,
roomIdParam,
@@ -375,19 +377,56 @@ async function pushRoomUpdate(
}
/**
* Tell a player they've been banned from a room — a `ModerationRoomBan` push carrying
* the ban. Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban
* row has already committed, so a hub hiccup must not fail the request.
* `reportCategory` on a moderation frame. -1 is "Moderator" — the category for an
* action a person took rather than one the system inferred, which is what a room ban
* is. The rest of the enum, for reference: 2 Harassment, 3 Cheating, 5 AFK, 6 Misc,
* 7 Underage, 10 VoteKick, 100104 CoC_*, 200 InappropriateClothing.
*/
async function pushRoomBan(c: Context<App>, ban: RoomBan): Promise<void> {
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.
*
* 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).
*
* `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).
*
* Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban row has
* already committed, so a hub hiccup must not fail the request.
*/
async function pushRoomBan(
c: Context<App>,
ban: RoomBan,
roomName: string,
isHostKick: boolean
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
ban.BannedPlayerId,
NotificationType.ModerationRoomBan,
{ ...ban }
NotificationType.ModerationKick,
{
reportCategory: REPORT_CATEGORY_MODERATOR,
duration: 0,
gameSessionId: 0,
isHostKick,
message: `You have been banned from ${roomName}.`,
playerIdReporter: ban.BannedByAccountId,
isBan: true,
isVoiceModAutoban: false,
}
)
} catch (err) {
logger.error('failed to push ModerationRoomBan notification', {
logger.error('failed to push ModerationKick notification', {
playerId: ban.BannedPlayerId,
error: err instanceof Error ? err.message : String(err),
})
@@ -1393,6 +1432,55 @@ const app = new Hono<App>()
}
)
// A room's ban list — the owner's view of who they've banned. Same gate as issuing a
// ban: a ban list says who a room's owner has had trouble with, so it isn't public.
// Answers a BARE array (not the room-write envelope), newest ban first.
.get(
'/rooms/:roomId{[0-9]+}/bans',
describeRoute({
tags: ['Room settings'],
summary: 'A rooms ban list',
description: [
'Everyone banned from the room, most recently banned first. Auth-gated, then gated',
'exactly like issuing a ban: the rooms creator or a co-owner, or an account whose',
'token carries the `developer` / `moderator` role. A ban list says who a rooms',
'owner has had trouble with, so it is not public.',
'',
'A bare array, NOT the `{ success, error, value }` envelope the ban write answers,',
'and the entries are camelCase with a different field set: no room id (the path',
'already says which room) and no ban mask. An unknown room is an empty list rather',
'than an error — it reads the same as a room nobody is banned from.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
responses: {
200: json(RoomBanEntryDto.array(), 'The rooms bans, newest first'),
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)
// No room → nothing banned. Same answer as a room with an empty ban list, so
// this doesn't become a way to probe which room ids exist.
if (!room) return c.json([])
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
const bans = await getRoomBans(c.env.DB, roomId)
return c.json(
bans.map((ban) => ({
accountId: ban.BannedPlayerId,
bannedByAccountId: ban.BannedByAccountId,
banStartTime: ban.CreatedAt,
}))
)
}
)
// Ban a player from a room (form body `id` + `banMask`). 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.
@@ -1403,9 +1491,9 @@ const app = new Hono<App>()
summary: 'Ban a player from a room',
description: [
'Records a ban in the `room_ban` table — one row per (room, player), so re-banning',
'someone already banned rewrites their row rather than adding a second. Nothing',
'enforces the ban yet: matchmaking does not consult this table, so a banned player',
'can still join. This is the record only.',
'someone already banned rewrites their row rather than adding a second. The row is',
'what the `match` worker checks: a banned players matchmake into this room is',
'refused with errorCode 55 and never gets a Photon room id.',
'',
'Gated to the rooms creator or a co-owner, OR to any account whose token carries the',
'`developer` / `moderator` role — a valid token from anyone else is a 403. Banning',
@@ -1415,8 +1503,16 @@ 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 `ModerationRoomBan` push carrying the ban,',
'so their client can act on it; the hub queues it if they are offline.',
'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).',
'',
'`isHostKick` means the rooms 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.',
'',
'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',
@@ -1440,8 +1536,10 @@ const app = new Hono<App>()
if (!room) return banEnvelope(c, null, 'This room does not exist!')
// The room's own owners, or a staffer acting across rooms. Roles are only
// looked up when the cheaper room check fails.
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
// looked up when the cheaper room check fails. The room's own owner IS the
// host, which is what the kick frame's `isHostKick` reports.
const isHostKick = canManageRoom(room, accountId)
if (!isHostKick && !(await isStaff(c))) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
@@ -1458,8 +1556,9 @@ const app = new Hono<App>()
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 ban.
await pushRoomBan(c, ban)
// 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)
return banEnvelope(c, ban)
}
)
+68 -13
View File
@@ -1006,29 +1006,83 @@ describe('rooms endpoints', () => {
expect(await bansOf(2)).toHaveLength(2)
})
it('POST /rooms/:id/bans notifies the banned player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: { RoomId: number } }
it('POST /rooms/:id/bans kicks the banned player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
const sentSince = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
// 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 hub().fetch('http://do/all', { method: 'DELETE' })
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
// Pushed to the BANNED player, not the caller. Asserted against the enum rather
// than a literal the hub's ids are the notify worker's to change.
expect(sent).toEqual([
// 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.
// Asserted against the enum rather than a literal: the ids are notify's to change.
expect(await sentSince()).toEqual([
{
playerId: 207,
notificationType: NotificationType.ModerationRoomBan,
notificationType: NotificationType.ModerationKick,
// The client's moderation payload, camelCase, in wire order.
data: {
RoomId: 2,
BannedPlayerId: 207,
BanMask: 0,
BannedByAccountId: 1,
CreatedAt: expect.any(String),
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.
isHostKick: true,
message: `You have been banned from ${Name}.`,
playerIdReporter: 1,
isBan: true,
isVoiceModAutoban: false,
},
},
])
// A staff moderator doesn't host the room, so it isn't a host kick — and
// `playerIdReporter` is still whoever caused it.
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 },
})
})
it('GET /rooms/:id/bans lists the rooms bans, under the same gate', async () => {
type Entry = { accountId: number; bannedByAccountId: number; banStartTime: string }
const list = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub, roles) : {} })
// Room 3 is owned by account 1 (it has no bans yet) — ban two players into it.
expect((await postForm('/rooms/3/bans', { id: '401' }, '1')).status).toBe(200)
expect((await postForm('/rooms/3/bans', { id: '402' }, '1')).status).toBe(200)
// No token → 401; a valid token with no room role and no staff role → 403.
expect((await list('/rooms/3/bans')).status).toBe(401)
expect((await list('/rooms/3/bans', '999')).status).toBe(403)
const res = await list('/rooms/3/bans', '1')
expect(res.status).toBe(200)
// A bare array in the client's camelCase shape — no room id, no ban mask.
const bans = (await res.json()) as Entry[]
expect(bans.map((b) => b.accountId).sort((a, b) => a - b)).toEqual([401, 402])
expect(bans[0]).toEqual({
accountId: expect.any(Number),
bannedByAccountId: 1,
banStartTime: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
})
// A staffer can read a list for a room they have no role on.
expect((await list('/rooms/3/bans', '999', ['gameClient', 'moderator'])).status).toBe(200)
// An unknown room reads the same as a room with nobody banned — no probing which
// room ids exist.
expect(await (await list('/rooms/99999/bans', '1')).json()).toEqual([])
})
it('DELETE /rooms/:id/bans/:playerId lifts a ban, under the same gate', async () => {
@@ -2569,6 +2623,7 @@ describe('rooms endpoints', () => {
'GET /rooms/visitedby/me',
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/interactionby/me',
'GET /rooms/{roomId}/playerdata/me',
'GET /rooms/{roomId}/similar',