diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index fa590f3..3ab019f 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -274,6 +274,14 @@ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance /** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */ const NO_SUCH_ROOM = 20 +/** + * MatchmakingErrorCode for "you are banned from this room". Unlike the opaque + * NoSuchRoom every other refusal answers, a banned player is told why: they already + * know the room exists, so there's nothing to hide, and the client can say so instead + * of showing a room that mysteriously fails to load. + */ +const BANNED_FROM_ROOM = 55 + /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -477,11 +485,20 @@ async function inviteParty( ) } +/** + * The outcome of resolving a room to join: the instance, or the `errorCode` to answer + * with. Kept as a pair rather than a bare null so callers can tell a room that isn't + * there (NoSuchRoom) from one the caller is banned from — those answer different codes. + */ +type ResolvedInstance = + | { instance: RoomInstance; errorCode: 0 } + | { instance: null; errorCode: number } + /** * Resolve a room by `:room` path segment (numeric id or name) from D1, then find a * joinable instance of it (public matchmakes reuse one via the `room_instance` - * table) or create a new one. Returns null when the room isn't found, or when the - * caller is banned from it. + * table) or create a new one. A null instance carries the error code to answer: + * NoSuchRoom when the room isn't in the DB, BannedFromRoom when the caller is banned. */ async function resolveRoomInstance( c: Context, @@ -489,23 +506,22 @@ async function resolveRoomInstance( isPrivate: boolean, ownerId: number, subRoomId?: number -): Promise { +): Promise { const id = Number.parseInt(roomKey, 10) const room = Number.isNaN(id) ? await getRoomByName(c.env.DB, roomKey) : await getRoomById(c.env.DB, id) - if (!room) return null + if (!room) return { instance: null, errorCode: NO_SUCH_ROOM } const f = instanceFieldsFromRoom(room, subRoomId) // A banned player never gets an instance. This is the whole enforcement of a room // ban: the Photon room id only ever reaches a player through a matchmake, so // refusing here means they have no coordinates to join or interact with. Handled - // before any instance is created or reused so a ban can't spawn one. The caller sees - // the same opaque NO_SUCH_ROOM every other refusal answers. + // before any instance is created or reused so a ban can't spawn one. if (await isPlayerBannedFromRoom(c.env.DB, f.roomId, ownerId)) { logger.info('matchmake refused: player banned from room', { roomId: f.roomId, ownerId }) - return null + return { instance: null, errorCode: BANNED_FROM_ROOM } } // Never place the player back into the instance they're already in: the client @@ -538,13 +554,16 @@ async function resolveRoomInstance( roomInstanceType: f.roomInstanceType, }) } - return roomInstanceFromRoom( - room, - isPrivate, - instance.roomInstanceId, - instance.photonRoomId, - f.subRoomId - ) + return { + instance: roomInstanceFromRoom( + room, + isPrivate, + instance.roomInstanceId, + instance.photonRoomId, + f.subRoomId + ), + errorCode: 0, + } } /** @@ -872,7 +891,8 @@ const app = new Hono() description: [ 'Looks the club up, checks the caller is a member of it, and places them into an', 'instance of its clubhouse room. Returns errorCode 20 with a null instance when the', - 'club is unknown, has no clubhouse set, or the caller isn’t a member.', + 'club is unknown, has no clubhouse set, or the caller isn’t a member — and errorCode', + '55 when they are banned from the clubhouse room.', ].join(' '), security: AUTHED, requestBody: form(JoinModeRequest, 'Optional JoinMode'), @@ -888,7 +908,7 @@ const app = new Hono() responses: { 200: json( MatchmakeResponse, - 'The clubhouse instance (or errorCode 20 with null when it can’t be entered)' + 'The clubhouse instance (or a null instance with errorCode 20 / 55 when it can’t be entered)' ), 401: UNAUTHORIZED_RESPONSE, }, @@ -908,13 +928,13 @@ const app = new Hono() } const joinMode = await readJoinMode(c) - const instance = await resolveRoomInstance( + const { instance, errorCode } = await resolveRoomInstance( c, String(club.clubhouseRoomId), joinMode === 2, id ) - if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + if (!instance) return c.json({ errorCode, roomInstance: null }) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) } @@ -938,7 +958,9 @@ const app = new Hono() 'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend', 'of the target (otherwise anyone could read a player’s presence and warp to them).', 'Returns errorCode 20 with a null instance when the target isn’t a friend, is the', - 'caller themselves, or isn’t currently in a room.', + 'caller themselves, or isn’t currently in a room, and errorCode 55 when the caller is', + 'banned from the room the friend is in — this path hands out join coordinates without', + 'going through the room resolver, so it carries its own ban check.', ].join(' '), security: AUTHED, parameters: [ @@ -953,7 +975,7 @@ const app = new Hono() responses: { 200: json( MatchmakeResponse, - 'The friend’s instance (or errorCode 20 with null when it can’t be joined)' + 'The friend’s instance (or a null instance with errorCode 20 / 55 when it can’t be joined)' ), 401: UNAUTHORIZED_RESPONSE, }, @@ -979,7 +1001,7 @@ const app = new Hono() // otherwise following a friend in is a way around a ban. if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) { logger.info('follow refused: player banned from room', { roomId: instance.roomId, id }) - return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null }) } // Join that same instance (same id + Photon room) and store it as the caller's @@ -1015,7 +1037,10 @@ const app = new Hono() }, ], responses: { - 200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'), + 200: json( + MatchmakeResponse, + 'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)' + ), 401: UNAUTHORIZED_RESPONSE, }, }), @@ -1024,14 +1049,14 @@ const app = new Hono() if (id === null) return unauthorized(c) const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c) const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) - const instance = await resolveRoomInstance( + const { instance, errorCode } = await resolveRoomInstance( c, c.req.param('roomId'), joinMode === 2, id, subRoomId ) - if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + if (!instance) return c.json({ errorCode, roomInstance: null }) await enterRoom(c, id, instance) // Pull the caller's party (AdditionalPlayerIds) into the instance they landed in. await inviteParty(c, id, additionalPlayerIds, instance) @@ -1054,7 +1079,10 @@ const app = new Hono() requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'), parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }], responses: { - 200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'), + 200: json( + MatchmakeResponse, + 'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)' + ), 401: UNAUTHORIZED_RESPONSE, }, }), @@ -1062,8 +1090,13 @@ const app = new Hono() const id = await authedId(c) if (id === null) return unauthorized(c) const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c) - const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id) - if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + const { instance, errorCode } = await resolveRoomInstance( + c, + c.req.param('roomId'), + joinMode === 2, + id + ) + if (!instance) return c.json({ errorCode, roomInstance: null }) await enterRoom(c, id, instance) // Pull the caller's party (AdditionalPlayerIds) into the instance they landed in. await inviteParty(c, id, additionalPlayerIds, instance) diff --git a/apps/match/src/openapi.ts b/apps/match/src/openapi.ts index c0c51a5..21ba00a 100644 --- a/apps/match/src/openapi.ts +++ b/apps/match/src/openapi.ts @@ -128,7 +128,9 @@ export const PlayerDto = z.object({ * a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`. */ export const MatchmakeResponse = z.object({ - errorCode: z.int().describe('0 = success; 20 = NoSuchRoom'), + errorCode: z + .int() + .describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'), roomInstance: RoomInstanceDto.nullable(), }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 19b7c5a..1d16b51 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1251,7 +1251,7 @@ describe('auth-gated endpoints', () => { ).run() try { expect(await (await follow(9801, '9800')).json()).toEqual({ - errorCode: 20, + errorCode: 55, roomInstance: null, }) } finally { @@ -1278,14 +1278,15 @@ describe('auth-gated endpoints', () => { ).run() // The ban is the whole enforcement: no instance means no Photon room id, so there - // is nothing for the banned player to join. Same opaque NoSuchRoom as any other - // refusal, and it applies to the subroom path as well. - expect(await matchmake('9701')).toEqual({ errorCode: 20, roomInstance: null }) + // is nothing for the banned player to join. errorCode 55 rather than the opaque + // NoSuchRoom every other refusal answers — a banned player already knows the room + // exists, so the client can say why. Applies to the subroom path as well. + expect(await matchmake('9701')).toEqual({ errorCode: 55, roomInstance: null }) const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, { method: 'POST', headers: await bearer('9701'), }) - expect(await sub.json()).toEqual({ errorCode: 20, roomInstance: null }) + expect(await sub.json()).toEqual({ errorCode: 55, roomInstance: null }) // Refused before any instance is created, and no presence was recorded for them. expect( diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 0272381..ce772aa 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -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(), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index c6870cb..b11750f 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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, 100–104 CoC_*, 200 InappropriateClothing. */ -async function pushRoomBan(c: Context, ban: RoomBan): Promise { +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, + ban: RoomBan, + roomName: string, + isHostKick: boolean +): Promise { 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() } ) + // 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 room’s ban list', + description: [ + 'Everyone banned from the room, most recently banned first. Auth-gated, then gated', + 'exactly like issuing a ban: the room’s creator or a co-owner, or an account whose', + 'token carries the `developer` / `moderator` role. A ban list says who a room’s', + '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 room’s 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() 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 player’s matchmake into this room is', + 'refused with errorCode 55 and never gets a Photon room id.', '', 'Gated to the room’s 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() '`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 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.', '', '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() 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 const str = (v: unknown): string => (typeof v === 'string' ? v : '') @@ -1458,8 +1556,9 @@ const app = new Hono() 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) } ) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index e4b31e7..4228876 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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 => + (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 room’s 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',