From db003d54ef08ceb009ff0f1a3bb500e1367a0512 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 4 Aug 2026 13:26:12 -0400 Subject: [PATCH] add a non working banlist --- apps/match/src/match.app.ts | 23 ++- apps/match/src/test/integration/api.test.ts | 63 ++++++++ apps/notify/src/notification-types.ts | 2 +- apps/rooms/migrations/0010_room_ban.sql | 24 +++ apps/rooms/src/openapi.ts | 28 ++++ apps/rooms/src/rooms.app.ts | 167 +++++++++++++++++++- apps/rooms/src/test/integration/api.test.ts | 164 ++++++++++++++++++- apps/rooms/vitest.config.ts | 16 +- packages/domain/src/rooms-db.ts | 107 +++++++++++++ 9 files changed, 587 insertions(+), 7 deletions(-) create mode 100644 apps/rooms/migrations/0010_room_ban.sql diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 5c246ee..fa590f3 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -22,6 +22,7 @@ import { getRoomInstance, getRoomInstancesByRoom, isClubMember, + isPlayerBannedFromRoom, MessageType, refreshInstanceFullness, RoomInstanceType, @@ -479,7 +480,8 @@ async function inviteParty( /** * 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. + * table) or create a new one. Returns null when the room isn't found, or when the + * caller is banned from it. */ async function resolveRoomInstance( c: Context, @@ -495,6 +497,17 @@ async function resolveRoomInstance( if (!room) return null 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. + if (await isPlayerBannedFromRoom(c.env.DB, f.roomId, ownerId)) { + logger.info('matchmake refused: player banned from room', { roomId: f.roomId, ownerId }) + return null + } + // Never place the player back into the instance they're already in: the client // keys the room transition off a changing `roomInstanceId`, so re-matchmaking into // your current instance (e.g. the only public instance of a room you're already in) @@ -961,6 +974,14 @@ const app = new Hono() const instance = targetPresence?.roomInstance ?? null if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null }) + // This path hands out a Photon room id without going through + // resolveRoomInstance, so the room's bans have to be checked here too — + // 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 }) + } + // Join that same instance (same id + Photon room) and store it as the caller's // presence, so the heartbeat replays it and their own friend fan-out fires. await enterRoom(c, id, instance) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 9f1dd0d..19b7c5a 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -1241,6 +1241,69 @@ describe('auth-gated endpoints', () => { // No token → 401. expect((await follow(9801)).status).toBe(401) + + // A ban on the room blocks the follow too: this path hands out a Photon room id + // without going through resolveRoomInstance, so it carries its own ban check — + // otherwise following a friend in would be a way around a ban. + await env.DB.prepare( + `INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at) + VALUES (2, 9800, 0, 1, '2026-01-01T00:00:00.000Z')` + ).run() + try { + expect(await (await follow(9801, '9800')).json()).toEqual({ + errorCode: 20, + roomInstance: null, + }) + } finally { + await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800') + .run() + } + }) + + test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => { + const matchmake = async (sub: string) => + (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: await bearer(sub), + }) + ).json()) as { errorCode: number; roomInstance: { roomInstanceId: number } | null } + + // Not banned yet → a normal join. + expect((await matchmake('9700')).errorCode).toBe(0) + + await env.DB.prepare( + `INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at) + VALUES (2, 9701, 0, 1, '2026-01-01T00:00:00.000Z')` + ).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 }) + 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 }) + + // Refused before any instance is created, and no presence was recorded for them. + expect( + await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9701').first() + ).toBeNull() + + // The ban is per-room — another room is unaffected. + const other = (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/room/77`, { + method: 'POST', + headers: await bearer('9701'), + }) + ).json()) as { errorCode: number } + expect(other.errorCode).toBe(0) + + // Lifting the ban lets them in again. + await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9701').run() + expect((await matchmake('9701')).errorCode).toBe(0) }) test('POST /matchmake/room/:id invites AdditionalPlayerIds (party) into the instance', async () => { diff --git a/apps/notify/src/notification-types.ts b/apps/notify/src/notification-types.ts index ad3fac6..9cff255 100644 --- a/apps/notify/src/notification-types.ts +++ b/apps/notify/src/notification-types.ts @@ -24,7 +24,7 @@ export enum NotificationType { ModerationUpdateRequired = 21, ModerationKick = 22, ModerationKickAttemptFailed = 23, - ModerationRoomBan = 24, + ModerationRoomBan = "ModerationRoomBan", ServerMaintenance = 25, GiftPackageReceived = 30, GiftPackageReceivedImmediate = 31, diff --git a/apps/rooms/migrations/0010_room_ban.sql b/apps/rooms/migrations/0010_room_ban.sql new file mode 100644 index 0000000..cc0c9c5 --- /dev/null +++ b/apps/rooms/migrations/0010_room_ban.sql @@ -0,0 +1,24 @@ +-- Per-room player bans. `POST /rooms/{roomId}/bans` is how a room's owner (or a +-- staff account) bans a player from a room; one row per (room, player), so +-- re-banning someone already banned updates their row rather than appending a +-- second one. +-- +-- `ban_mask` is the client's `banMask` form field, stored verbatim. Its meaning is +-- not known yet — the client sends 0 — so nothing interprets it; it's kept so the +-- value isn't lost once we work out what it selects. +-- +-- Columnar rather than a JSON blob, and deliberately NOT part of the room's `data` +-- blob: that blob is served to the client verbatim as the room, and a room's ban +-- list is not something every reader of a room should receive. +-- +-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync. + +CREATE TABLE IF NOT EXISTS room_ban ( + 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, + PRIMARY KEY (room_id, banned_player_id) + ); +CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id); diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 5abd9f2..0272381 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -92,6 +92,9 @@ export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, /** The `:playerId` path parameter (an account id). */ export const playerIdParam = idParam('playerId', 'The account whose list to read') +/** The `:playerId` path parameter on the unban route. */ +export const bannedPlayerIdParam = idParam('playerId', 'The banned account to unban') + /** An optional string query parameter. */ export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject { return { name, in: 'query', required: false, description, schema: { type: 'string' } } @@ -441,6 +444,31 @@ export const RoleRequest = z.object({ role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'), }) +/** `POST /rooms/{roomId}/bans` — the player to ban from the room. */ +export const BanRequest = z.object({ + id: z.string().describe('Account id of the player to ban'), + banMask: z + .string() + .optional() + .describe('Stored verbatim; meaning unknown — the client sends `0`. Defaults to 0'), +}) + +/** A stored room ban — what `POST /rooms/{roomId}/bans` answers in `value`. */ +export const RoomBanDto = z.object({ + RoomId: z.int(), + BannedPlayerId: z.int(), + BanMask: z.int(), + BannedByAccountId: z.int().describe('Who issued the ban'), + CreatedAt: z.string(), +}) + +/** The envelope the ban write answers — same shape as the room writes, `value` is the ban. */ +export const RoomBanEnvelope = z.object({ + success: z.boolean(), + error: z.string().describe('Empty on success'), + value: RoomBanDto.nullable().describe('Null on a rejection'), +}) + /** `PUT /rooms/{roomId}/warning`. */ export const WarningRequest = z.object({ warningMask: z.string().describe('Content-warning bit flags, as an integer'), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 701fd22..c6870cb 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -5,6 +5,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { Accessibility, areFriends, + banPlayerFromRoom, canManageRoom, cloneRoom, cloneSubRoom, @@ -43,14 +44,20 @@ import { toggleCheer, toggleFavorite, toggleRoomTag, + unbanPlayerFromRoom, updateRoomFields, } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' -import { validateAndGetAccountId } from '@repo/jwt' +import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' +// The notification-type ids the hub carries (owned by the `notify` worker). Imported +// as a value — the enum has no runtime dependencies. +import { NotificationType } from '../../notify/src/notification-types' import { AccessibilityRequest, AUTHED, + bannedPlayerIdParam, + BanRequest, CloneRoomRequest, CloningRequest, CreateSubRoomRequest, @@ -75,6 +82,7 @@ import { PublishSaveRequest, RestrictionsRequest, RoleRequest, + RoomBanEnvelope, RoomDto, RoomEnvelope, roomIdParam, @@ -96,7 +104,7 @@ import { } from './openapi' import type { Context } from 'hono' -import type { RoomPermission } from '@repo/domain' +import type { RoomBan, RoomPermission } from '@repo/domain' import type { App } from './context' /** @@ -234,6 +242,22 @@ async function authedAccountId(c: Context): Promise { return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) } +/** + * Operator-granted elevated roles — the ones the auth worker stamps from an account's + * isDeveloper/isModerator flags (see the admin CLI). Same set the `notify` / `www` + * workers gate their admin surfaces on. + */ +const STAFF_ROLES: ReadonlySet = new Set(['developer', 'moderator']) + +/** + * Whether the caller's token carries a staff role. Used alongside the per-room owner + * check for actions staff may take in a room they don't own. + */ +async function isStaff(c: Context): Promise { + const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get()) + return roles?.some((role) => STAFF_ROLES.has(role)) ?? false +} + /** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */ function unauthorized(c: Context) { return c.json({ error: 'Unauthorized' }, 401) @@ -350,6 +374,26 @@ 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. + */ +async function pushRoomBan(c: Context, ban: RoomBan): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + ban.BannedPlayerId, + NotificationType.ModerationRoomBan, + { ...ban } + ) + } catch (err) { + logger.error('failed to push ModerationRoomBan notification', { + playerId: ban.BannedPlayerId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** * Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always * HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success. @@ -398,6 +442,12 @@ function roomEnvelope(c: Context, value: unknown, error = '') { return c.json({ success: error === '', error, value }) } +/** + * The same envelope for the ban write, whose `value` is the BAN rather than the room — + * a ban isn't part of the room the client renders, so there is no updated room to send. + */ +const banEnvelope = roomEnvelope + /** Rooms created/owned by the authed caller (shared by the createdby routes). */ async function ownedRooms(c: Context) { const accountId = await authedAccountId(c) @@ -1343,6 +1393,119 @@ const app = new Hono() } ) + // 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. + .post( + '/rooms/:roomId{[0-9]+}/bans', + describeRoute({ + tags: ['Room settings'], + 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.', + '', + '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', + 'yourself, or banning someone who can manage the room, is refused: otherwise a', + 'co-owner could ban the owner out of their own room.', + '', + '`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.', + '', + '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', + 'renders. This shape is unverified against the real service.', + ].join('\n'), + security: AUTHED, + parameters: [roomIdParam], + requestBody: form(BanRequest, 'The player to ban'), + responses: { + 200: json(RoomBanEnvelope, 'The stored ban, 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!') + + // 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) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const str = (v: unknown): string => (typeof v === 'string' ? v : '') + const bannedPlayerId = Number.parseInt(str(body.id), 10) + if (Number.isNaN(bannedPlayerId)) { + return banEnvelope(c, null, 'You must provide a valid player to ban!') + } + if (bannedPlayerId === accountId) return banEnvelope(c, null, 'You cannot ban yourself!') + // Without this a co-owner could ban the room's creator out of their own room. + if (canManageRoom(room, bannedPlayerId)) { + return banEnvelope(c, null, 'You cannot ban an owner of this room!') + } + // 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 ban. + await pushRoomBan(c, ban) + return banEnvelope(c, ban) + } + ) + + // 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( + '/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}', + describeRoute({ + tags: ['Room settings'], + summary: 'Unban a player from a room', + description: [ + 'Removes the player’s `room_ban` row, so they can matchmake into the room again.', + 'Gated exactly like issuing a ban: the room’s creator or a co-owner, or an account', + 'whose token carries the `developer` / `moderator` role.', + '', + 'Unbanning someone who is not banned is a rejection (`success: false`), not a silent', + 'success — the caller asked to undo something that was not there.', + '', + 'Answers the same envelope as the ban write, with the REMOVED ban as `value`. No', + 'notification is pushed: nothing tells a player their ban was lifted.', + ].join('\n'), + security: AUTHED, + parameters: [roomIdParam, bannedPlayerIdParam], + responses: { + 200: json(RoomBanEnvelope, 'The removed ban, 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) + + const playerId = Number.parseInt(c.req.param('playerId'), 10) + const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId) + if (!removed) return banEnvelope(c, null, 'This player is not banned from this room!') + return banEnvelope(c, removed) + } + ) + // Set a room's content warning: the `WarningMask` bit flags plus an optional // free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is // the `warningMask` form field (an integer) and an optional `customWarning` string diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index b3a41ec..e4b31e7 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -13,6 +13,7 @@ import { SUBROOM_SCHEMA_DDL, } from '@repo/domain' +import { NotificationType } from '../../../../notify/src/notification-types' import importRooms from '../../../static/ImportRooms.json' import type { Env } from '../../context' @@ -31,10 +32,12 @@ function b64url(input: ArrayBuffer | string): string { for (const byte of bytes) binary += String.fromCharCode(byte) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } -async function bearer(sub: string): Promise> { +// `roles` mints the `role` claim the auth worker stamps from an account's flags; left +// off, the token carries none — what a plain player's looks like to the role gates. +async function bearer(sub: string, roles?: string[]): Promise> { const now = Math.floor(Date.now() / 1000) const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( - JSON.stringify({ sub, exp: now + 3600 }) + JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) }) )}` const key = await crypto.subtle.importKey( 'raw', @@ -736,6 +739,21 @@ describe('rooms endpoints', () => { } }) + const postForm = async ( + path: string, + fields: Record, + sub?: string, + roles?: string[] + ) => + SELF.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { + ...(sub ? await bearer(sub, roles) : {}), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(fields).toString(), + }) + const putForm = async (path: string, fields: Record, sub?: string) => SELF.fetch(`${ORIGIN}${path}`, { method: 'PUT', @@ -923,6 +941,146 @@ describe('rooms endpoints', () => { expect(roles).toContainEqual(expect.objectContaining({ AccountId: 2, Role: 30 })) }) + it('POST /rooms/:id/bans is gated to the room’s owners or staff, and persists', async () => { + // RecCenter (room 2) is owned by account 1, with account 2 as co-owner. + const bansOf = async (roomId: number) => + ( + await env.DB.prepare( + 'SELECT banned_player_id, ban_mask, banned_by_account_id FROM room_ban WHERE room_id = ?1' + ) + .bind(roomId) + .all<{ banned_player_id: number; ban_mask: number; banned_by_account_id: number }>() + ).results + + // No token → 401 (auth gate). + expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' })).status).toBe(401) + // A valid token, no role on the room and no staff role → 403. + expect((await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '999')).status).toBe(403) + // Unknown room → failure envelope. + expect( + await envOf(await postForm('/rooms/99999/bans', { banMask: '0', id: '205' }, '1')) + ).toMatchObject({ success: false, error: 'This room does not exist!' }) + + // The owner bans player 205 — the real client body. + const ok = await postForm('/rooms/2/bans', { banMask: '0', id: '205' }, '1') + expect(ok.status).toBe(200) + expect(await envOf(ok)).toMatchObject({ + success: true, + error: '', + value: { RoomId: 2, BannedPlayerId: 205, BanMask: 0, BannedByAccountId: 1 }, + }) + expect(await bansOf(2)).toEqual([ + { banned_player_id: 205, ban_mask: 0, banned_by_account_id: 1 }, + ]) + + // Re-banning rewrites the one row rather than appending a second. + expect((await postForm('/rooms/2/bans', { banMask: '7', id: '205' }, '2')).status).toBe(200) + expect(await bansOf(2)).toEqual([ + { banned_player_id: 205, ban_mask: 7, banned_by_account_id: 2 }, + ]) + + // A staff token bans in a room they have no role on. + const byStaff = await postForm('/rooms/2/bans', { id: '206' }, '999', [ + 'gameClient', + 'moderator', + ]) + expect(byStaff.status).toBe(200) + // banMask defaults to 0 when the field is absent. + expect(await envOf(byStaff)).toMatchObject({ value: { BannedPlayerId: 206, BanMask: 0 } }) + + // Refusals: no id, yourself, and an owner of the room (a co-owner must not be + // able to ban the creator out of their own room). + expect(await envOf(await postForm('/rooms/2/bans', { id: 'nope' }, '1'))).toMatchObject({ + success: false, + value: null, + }) + expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '1'))).toMatchObject({ + success: false, + error: 'You cannot ban yourself!', + }) + expect(await envOf(await postForm('/rooms/2/bans', { id: '1' }, '2'))).toMatchObject({ + success: false, + error: 'You cannot ban an owner of this room!', + }) + // Nothing was written by any of the refusals. + 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 } } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + 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([ + { + playerId: 207, + notificationType: NotificationType.ModerationRoomBan, + data: { + RoomId: 2, + BannedPlayerId: 207, + BanMask: 0, + BannedByAccountId: 1, + CreatedAt: expect.any(String), + }, + }, + ]) + }) + + 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}`, { + method: 'DELETE', + headers: sub ? await bearer(sub, roles) : {}, + }) + const isBanned = async (roomId: number, playerId: number) => + (await env.DB.prepare( + 'SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2' + ) + .bind(roomId, playerId) + .first()) !== null + + // Two bans to lift: one removed by the owner, one by a staffer. + expect((await postForm('/rooms/2/bans', { id: '305' }, '1')).status).toBe(200) + expect((await postForm('/rooms/2/bans', { id: '306' }, '1')).status).toBe(200) + + // No token → 401; a valid token with no room role and no staff role → 403. + expect((await del('/rooms/2/bans/305')).status).toBe(401) + expect((await del('/rooms/2/bans/305', '999')).status).toBe(403) + expect(await isBanned(2, 305)).toBe(true) + + // Unknown room → failure envelope. + expect(await envOf(await del('/rooms/99999/bans/305', '1'))).toMatchObject({ + success: false, + error: 'This room does not exist!', + }) + + // The owner lifts it; the removed ban comes back as `value`. + const ok = await del('/rooms/2/bans/305', '1') + expect(ok.status).toBe(200) + expect(await envOf(ok)).toMatchObject({ + success: true, + error: '', + value: { RoomId: 2, BannedPlayerId: 305 }, + }) + expect(await isBanned(2, 305)).toBe(false) + + // Unbanning someone who isn't banned is a rejection, not a silent success. + expect(await envOf(await del('/rooms/2/bans/305', '1'))).toMatchObject({ + success: false, + error: 'This player is not banned from this room!', + value: null, + }) + + // A staff token may lift a ban in a room they have no role on. + expect((await del('/rooms/2/bans/306', '999', ['gameClient', 'developer'])).status).toBe(200) + expect(await isBanned(2, 306)).toBe(false) + }) + it('PUT /rooms/:id/warning is auth-gated, owner/co-owner-only, and persists', async () => { // No token → 401 (auth gate). expect((await putForm('/rooms/2/warning', { warningMask: '2' })).status).toBe(401) @@ -2391,6 +2549,7 @@ describe('rooms endpoints', () => { ) expect([...documented].sort()).toEqual([ 'DELETE /rooms/{roomId}', + 'DELETE /rooms/{roomId}/bans/{playerId}', 'DELETE /rooms/{roomId}/interactionby/me/cheer', 'DELETE /rooms/{roomId}/interactionby/me/favorite', 'DELETE /rooms/{roomId}/subrooms/{subRoomId}', @@ -2415,6 +2574,7 @@ describe('rooms endpoints', () => { 'GET /rooms/{roomId}/similar', 'GET /rooms/{roomId}/subrooms/{subRoomId}/saves', 'GET /roomserver/rooms/createdby/me', + 'POST /rooms/{roomId}/bans', 'POST /rooms/{roomId}/clone', 'POST /rooms/{roomId}/subrooms', 'POST /rooms/{roomId}/subrooms/{subRoomId}/clone', diff --git a/apps/rooms/vitest.config.ts b/apps/rooms/vitest.config.ts index 0ae0c7f..9cef654 100644 --- a/apps/rooms/vitest.config.ts +++ b/apps/rooms/vitest.config.ts @@ -21,11 +21,25 @@ export default defineConfig({ compatibilityDate: '2026-06-16', compatibilityFlags: ['nodejs_compat'], 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. script: ` import { DurableObject } from 'cloudflare:workers' export class NotificationsHub extends DurableObject { - async notifyPlayer() { return { delivered: 0, queued: true } } + sent = [] + async notifyPlayer(playerId, notificationType, data) { + this.sent.push({ playerId, notificationType, data }) + return { delivered: 0, queued: true } + } async broadcast() { return { delivered: 0 } } + async fetch(request) { + if (request.method === 'DELETE') { + this.sent = [] + return new Response(null, { status: 204 }) + } + return Response.json(this.sent) + } } export default { fetch() { return new Response('ok') } } `, diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index e1a92d0..c78d8e1 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -43,6 +43,23 @@ export const ROOM_SCHEMA_DDL: string[] = [ last_visited_at TEXT, PRIMARY KEY (player_id, room_id) )`, + // Per-room player bans (migrations/0010_room_ban.sql). One row per (room, player), + // so re-banning someone already banned updates their row rather than appending. + // `ban_mask` is the client's `banMask` field kept verbatim — its meaning isn't known + // yet (the client sends 0), so nothing interprets it. + // + // Deliberately NOT in the room's `data` blob: that blob is served to the client + // verbatim as the room, and a ban list is not something every reader of a room + // should receive. + `CREATE TABLE IF NOT EXISTS room_ban ( + 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, + PRIMARY KEY (room_id, banned_player_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`, ] /** @@ -136,6 +153,96 @@ export function canManageRoom(room: Room, accountId: number): boolean { return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role)) } +/** A player banned from a room (a `room_ban` row). */ +export interface RoomBan { + RoomId: number + BannedPlayerId: number + /** The client's `banMask`, stored verbatim — its meaning isn't known yet. */ + BanMask: number + BannedByAccountId: number + CreatedAt: string +} + +interface RoomBanRow { + room_id: number + banned_player_id: number + ban_mask: number + banned_by_account_id: number + created_at: string +} + +const toRoomBan = (row: RoomBanRow): RoomBan => ({ + RoomId: row.room_id, + BannedPlayerId: row.banned_player_id, + BanMask: row.ban_mask, + BannedByAccountId: row.banned_by_account_id, + CreatedAt: row.created_at, +}) + +/** + * Ban a player from a room, returning the stored ban. One row per (room, player): + * re-banning someone already banned rewrites their row with the new mask and issuer + * rather than appending a second one, so the call is idempotent. + */ +export async function banPlayerFromRoom( + db: D1Database, + roomId: number, + bannedPlayerId: number, + banMask: number, + bannedByAccountId: number +): Promise { + const row = await db + .prepare( + `INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(room_id, banned_player_id) DO UPDATE SET + ban_mask = ?3, banned_by_account_id = ?4, created_at = ?5 + RETURNING *` + ) + .bind(roomId, bannedPlayerId, banMask, bannedByAccountId, new Date().toISOString()) + .first() + // RETURNING always yields the upserted row. + return toRoomBan(row!) +} + +/** + * Lift a player's ban on a room, returning the ban that was removed — or null when + * they weren't banned, which lets the caller tell a real unban from a no-op. + */ +export async function unbanPlayerFromRoom( + db: D1Database, + roomId: number, + bannedPlayerId: number +): Promise { + const row = await db + .prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2 RETURNING *') + .bind(roomId, bannedPlayerId) + .first() + return row ? toRoomBan(row) : null +} + +/** Everyone banned from a room, most recently banned first. */ +export async function getRoomBans(db: D1Database, roomId: number): Promise { + const { results } = await db + .prepare('SELECT * FROM room_ban WHERE room_id = ?1 ORDER BY created_at DESC') + .bind(roomId) + .all() + return results.map(toRoomBan) +} + +/** Whether a player is banned from a room. */ +export async function isPlayerBannedFromRoom( + db: D1Database, + roomId: number, + playerId: number +): Promise { + const row = await db + .prepare('SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2') + .bind(roomId, playerId) + .first<{ hit: number }>() + return row !== null +} + /** * Clone an existing room into a new one owned by `accountId`. Copies the source * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given