diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 77fdff6..5ed1b16 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1304,6 +1304,17 @@ export const CreateWarningRequest = z.object({ ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'), }) +/** + * `POST /api/PlayerReporting/v1/instantKick` JSON body — the players a room's staff are + * ejecting from one live instance. JSON, not a form, unlike its neighbours in this + * controller. `GameSessionId` is the room INSTANCE id (`roomInstanceId`); the kick is + * scoped to it, so a player named here who is standing somewhere else is left alone. + */ +export const InstantKickRequest = z.object({ + GameSessionId: z.int().describe('The room instance (game session) to eject them from'), + PlayerIds: z.array(z.int()).describe('Account ids to kick out of that instance'), +}) + /** `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'), diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 1b69f4f..4db4673 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -1,6 +1,20 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' +import { + canModerateRoom, + deletePresence, + getPresences, + getRoomById, + getStoredRoomInstance, + refreshInstanceFullness, +} from '@repo/domain' +import { logger } from '@repo/hono-helpers' + +// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a +// value — the enum has no runtime dependencies. +import { KickReportCategory } from '../../../notify/src/notification-payloads' +import { NotificationType } from '../../../notify/src/notification-types' import { authedId, authedRoles, unauthorized } from '../http' import { AUTHED, @@ -9,8 +23,10 @@ import { CreateWarningRequest, DeviceIdRequest, form, + InstantKickRequest, json, JsonArray, + jsonBody, ModerationBlockDetails, SuccessErrorEnvelope, UNAUTHORIZED_RESPONSE, @@ -20,6 +36,7 @@ import { createReport } 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' /** @@ -82,6 +99,64 @@ const VOTE_TO_KICK_REASONS = [ { Reason: 'Not following game rules', ReportCategory: 6 }, ] as const +/** The notifications hub is a single global DO instance (see the `notify` worker). */ +const HUB_INSTANCE = 'global' + +/** + * Eject players from the instance they're standing in — the `ModerationKick` frame (id 22) + * the client acts on to leave a room, the same one a room ban sends (`rooms`: + * `pushRoomBan`). This one only kicks: `IsBan` is false, so nothing keeps them from walking + * straight back in, and no ban row exists to lift. + * + * Sent EPHEMERALLY, unlike the ban's frame, and to the whole batch in one round-trip. A + * kick is only true of the moment it happened: queued and delivered on the player's next + * connect it would throw them out of some unrelated session hours later. A recipient who + * has already gone offline needs no kick anyway. + * + * `GameSessionId` is the instance they're being removed from — every recipient is in it, + * which is what the caller checked before this runs. `IsHostKick` is always true: this + * endpoint is the room's own staff acting, never the room majority vote-kicking (that path + * would carry `VoteKick` and false). Built against the client's recovered payload interface + * so a renamed key fails the build rather than vanishing on the wire. + * + * Best-effort — presence is already deleted by the time this runs, so a hub hiccup must + * not fail the request. + */ +async function pushInstantKick( + c: Context, + playerIds: number[], + gameSessionId: number, + roomName: string, + moderatorId: number +): Promise { + const frame: ModerationKickPayload = { + ReportCategory: KickReportCategory.Moderator, + Duration: 0, + GameSessionId: gameSessionId, + IsHostKick: true, + Message: `You have been kicked from ${roomName}.`, + PlayerIdReporter: moderatorId, + IsBan: false, + IsVoiceModAutoban: false, + IsWarning: false, + VoteKickReason: '', + TimeoutStartedAt: null, + } + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral( + playerIds, + NotificationType.ModerationKick, + { ...frame } + ) + } catch (err) { + logger.error('failed to push ModerationKick notification', { + playerIds, + gameSessionId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + // ---- Player reporting ------------------------------------------------------ export const moderationRoutes = new Hono({ strict: false }) // Whether the caller is currently blocked (banned / timed out / host-kicked). Bans @@ -257,6 +332,105 @@ export const moderationRoutes = new Hono({ strict: false }) } ) + // The kick a room's own staff hand out from the moderation menu: eject named players + // from ONE live instance. Two gates, and both matter — the caller must be able to + // moderate the room the instance belongs to, and each named player must actually be + // standing in that instance. Without the second, a creator could name any account id + // and kick a stranger out of somebody else's room. + .post( + '/api/PlayerReporting/v1/instantKick', + describeRoute({ + tags: ['Moderation'], + summary: 'Kick players out of a room instance', + description: + 'Ejects the named players from one live room instance. `GameSessionId` is that ' + + 'instance (`roomInstanceId`); the body is JSON, unlike the form posts elsewhere in ' + + 'this controller.\n\n' + + 'Gated to the instance’s room: the caller must be its creator or hold a role of ' + + 'Moderator (20) or above on it — anyone else with a valid token gets a 403. ' + + 'Nobody who can moderate the room can be kicked out of it, and a caller cannot ' + + 'kick themselves.\n\n' + + 'A player is only kicked if their live `presence` row puts them in **that** ' + + 'instance. Anyone else named — offline, or standing in another room — is skipped ' + + 'in silence, so naming an account id cannot reach into a session the caller has ' + + 'no authority over.\n\n' + + 'Each kicked player loses their presence row (they read offline at once and the ' + + 'instance frees a slot) and gets a `ModerationKick` frame (id 22) — the frame the ' + + 'client acts on to leave. It is the same frame a room ban sends, but `IsBan` is ' + + 'false: this only removes them from the session they are in, and nothing stops ' + + 'them rejoining. The frame is EPHEMERAL — a kick is true of the moment it ' + + 'happened, and queueing one would eject the player from an unrelated session on ' + + 'their next connect.\n\n' + + 'Answers the same lowercase `{ success, error }` envelope the report write uses, ' + + 'and says nothing about who was actually kicked — the response shape is ' + + 'unverified against the real service.', + security: AUTHED, + requestBody: jsonBody(InstantKickRequest, 'The instance and the players to eject'), + responses: { + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 400: json(SuccessErrorEnvelope, 'Unparseable body, no `GameSessionId` or no `PlayerIds`'), + 401: UNAUTHORIZED_RESPONSE, + 403: json(SuccessErrorEnvelope, 'The caller cannot moderate the instance’s room'), + 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.json().catch(() => null)) as { + GameSessionId?: unknown + PlayerIds?: unknown + } | null + if (body === null) return c.json({ success: false, error: 'Invalid request body' }, 400) + + const gameSessionId = typeof body.GameSessionId === 'number' ? body.GameSessionId : Number.NaN + if (!Number.isInteger(gameSessionId)) { + return c.json({ success: false, error: 'GameSessionId is required' }, 400) + } + const playerIds = Array.isArray(body.PlayerIds) + ? body.PlayerIds.filter((id): id is number => Number.isInteger(id)) + : [] + if (playerIds.length === 0) { + return c.json({ success: false, error: 'PlayerIds is required' }, 400) + } + + // The instance names the room, and the room carries the roles this is gated on — + // a game session with no room behind it can't authorise anything. + 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) + if (!canModerateRoom(room, moderatorId)) { + return c.json({ success: false, error: 'Forbidden' }, 403) + } + + // One read for the batch. A player is kicked only when their LIVE presence puts + // them in this very instance: offline, expired or standing elsewhere are all the + // same "not here", and are skipped rather than refused — the client sends a list + // and one stale id in it must not sink the rest. + const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, playerIds) + const kicked: number[] = [] + for (const playerId of playerIds) { + // The room's own staff are not kickable out of their room — otherwise a + // moderator could throw the creator out of it. Nor is the caller themselves. + if (playerId === moderatorId || canModerateRoom(room, playerId)) continue + if (presences.get(playerId)?.roomInstance?.roomInstanceId !== gameSessionId) continue + await deletePresence(c.env.DB, playerId) + kicked.push(playerId) + } + + if (kicked.length > 0) { + // The instance just lost players — recompute its fullness so a full room opens + // back up, exactly as the `match` worker does when someone logs out. + await refreshInstanceFullness(c.env.DB, gameSessionId) + const roomName = typeof room.Name === 'string' ? room.Name : 'this room' + await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId) + } + + 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( diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index b9004eb..184efb4 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -20,6 +20,7 @@ import { PRESENCE_TTL_SECONDS, PROGRESSION_SCHEMA_DDL, RELATIONSHIP_SCHEMA_DDL, + ROOM_INSTANCE_SCHEMA_DDL, ROOM_SCHEMA_DDL, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL, @@ -94,6 +95,19 @@ const TEST_ROOMS = [ SubRooms: [{ SubRoomId: 3 }], Roles: [{ AccountId: 42, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }], }, + { + // 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. + RoomId: 4, + Name: 'KickRoom', + IsDorm: false, + CreatorAccountId: 42, + SubRooms: [{ SubRoomId: 4 }], + Roles: [ + { AccountId: 43, Role: 20, LastChangedByAccountId: null, InvitedRole: 0 }, + { AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 }, + ], + }, ] beforeAll(async () => { @@ -132,6 +146,10 @@ beforeAll(async () => { // Presence (owned by the rooms worker) — the online-friend count joins onto it. for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Room instances (owned by the rooms worker) — the instant kick resolves the game + // session it is given to the room whose staff may kick from it. + for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0. for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -3987,6 +4005,217 @@ describe('custom avatar items', () => { }) }) +describe('instant kick', () => { + // The game session the kick names, and one belonging to the same room that must be + // left out of it. + const SESSION = 1013781 + const OTHER_SESSION = 1013782 + + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + + // Room instances are written by the `match` worker; seeded straight into the table + // here, the way the presence rows below are. + const seedInstance = async (roomInstanceId: number, maxCapacity = 0, isFull = false) => + env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)') + .bind( + JSON.stringify({ + roomInstanceId, + ownerAccountId: 42, + roomId: 4, + subRoomId: 4, + location: '', + dataBlob: '', + eventId: 0, + photonRegionId: 'us', + photonRoomId: `photon-${roomInstanceId}`, + name: '', + maxCapacity, + isFull, + isPrivate: false, + isInProgress: false, + roomCode: '', + roomInstanceType: 0, + clubId: 0, + EncryptVoiceChat: false, + matchmakingPolicy: 0, + allowNewUsers: true, + joinDisabled: false, + gameVersion: GAME_VERSION, + createdAt: new Date().toISOString(), + }) + ) + .run() + + const standIn = async (accountId: number, roomInstanceId: number) => + env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + roomInstance: { roomInstanceId, roomId: 4 }, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 0, + platform: 0, + appVersion: GAME_VERSION, + 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 + + const isFull = async (roomInstanceId: number) => + (await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1') + .bind(roomInstanceId) + .first<{ full: number }>())!.full === 1 + + const kick = async (body: unknown, sub = '42') => + exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + const frames = async () => + (await (await hub().fetch('http://do/all')).json()) as Array<{ + playerIds?: number[] + ephemeral?: boolean + notificationType: number + data: Record + }> + + test('the room’s creator kicks a player out of the session they name', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + // A full two-player instance: 205 is kicked, 206 stays. + await seedInstance(SESSION, 2, true) + await standIn(205, SESSION) + await standIn(206, SESSION) + + const res = await kick({ GameSessionId: SESSION, PlayerIds: [205] }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, error: '' }) + + // Presence is deleted, so they read offline at once — and only theirs is. + expect(await isPresent(205)).toBe(false) + expect(await isPresent(206)).toBe(true) + // The instance lost a player, so it is no longer full. + expect(await isFull(SESSION)).toBe(false) + + // One EPHEMERAL ModerationKick, addressed to the kicked player only. `IsBan` is + // false — this ejects them from the session and nothing more. + expect(await frames()).toEqual([ + { + playerIds: [205], + ephemeral: true, + notificationType: 22, // NotificationType.ModerationKick + data: { + ReportCategory: -1, // KickReportCategory.Moderator + Duration: 0, + GameSessionId: SESSION, + IsHostKick: true, + Message: 'You have been kicked from KickRoom.', + PlayerIdReporter: 42, + IsBan: false, + IsVoiceModAutoban: false, + IsWarning: false, + VoteKickReason: '', + TimeoutStartedAt: null, + }, + }, + ]) + }) + + // The gate that stops a creator kicking a stranger out of somebody else's session by + // naming their account id. + test('a player who is not in that session is skipped in silence', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + await seedInstance(SESSION) + await seedInstance(OTHER_SESSION) + await standIn(207, OTHER_SESSION) + + // 207 stands in another instance; 208 is offline entirely. + const res = await kick({ GameSessionId: SESSION, PlayerIds: [207, 208] }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, error: '' }) + expect(await isPresent(207)).toBe(true) + expect(await frames()).toEqual([]) + }) + + test('a room moderator may kick; a host, a stranger and no token may not', async () => { + await seedInstance(SESSION) + await standIn(209, SESSION) + + // 43 holds Moderator (20) on the room. + expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200) + expect(await isPresent(209)).toBe(false) + + await standIn(209, SESSION) + // 44 is only a Host (10), and 99 holds nothing at all. + for (const sub of ['44', '99']) { + const res = await kick({ GameSessionId: SESSION, PlayerIds: [209] }, 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/instantKick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ GameSessionId: SESSION, PlayerIds: [209] }), + }) + expect(anon.status).toBe(401) + }) + + // Otherwise a moderator could throw the room's own creator out of it. + test('the room’s staff — and the caller — cannot be kicked', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + await seedInstance(SESSION) + await standIn(42, SESSION) + await standIn(43, SESSION) + + // 43 (a moderator) names the creator, a fellow moderator and themselves. + const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43') + expect(res.status).toBe(200) + expect(await isPresent(42)).toBe(true) + expect(await isPresent(43)).toBe(true) + expect(await frames()).toEqual([]) + }) + + test('an unknown session 404s, and the body must name a session and players', async () => { + await seedInstance(SESSION) + const unknown = await kick({ GameSessionId: 999999, PlayerIds: [205] }) + expect(unknown.status).toBe(404) + expect(await unknown.json()).toEqual({ + success: false, + error: 'This game session does not exist!', + }) + + for (const [body, error] of [ + [{ PlayerIds: [205] }, 'GameSessionId is required'], + [{ GameSessionId: 'nope', PlayerIds: [205] }, 'GameSessionId is required'], + [{ GameSessionId: SESSION }, 'PlayerIds is required'], + [{ GameSessionId: SESSION, PlayerIds: [] }, 'PlayerIds is required'], + [{ GameSessionId: SESSION, PlayerIds: ['205'] }, 'PlayerIds is required'], + ] as Array<[unknown, string]>) { + const res = await kick(body) + expect(res.status, error).toBe(400) + expect(await res.json()).toEqual({ success: false, error }) + } + + // A body that isn't JSON at all is the same shape, not a crash. + const broken = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, { + method: 'POST', + headers: { ...(await bearer()), 'Content-Type': 'application/json' }, + body: 'not json', + }) + expect(broken.status).toBe(400) + expect(await broken.json()).toEqual({ success: false, error: 'Invalid request body' }) + }) +}) + describe('player reports', () => { const submit = async (fields: Record, headers?: Record) => exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, { @@ -7001,6 +7230,7 @@ describe('openapi', () => { 'POST /api/PlayerCheer/v1/create', 'POST /api/PlayerReporting/v1/deviceId', 'POST /api/PlayerReporting/v1/hile', + 'POST /api/PlayerReporting/v1/instantKick', 'POST /api/PlayerReporting/v1/moderationBlockDetails', 'POST /api/PlayerReporting/v1/referee', 'POST /api/PlayerReporting/v3/create', diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index c68a162..edf40b9 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -211,6 +211,22 @@ export function canManageRoom(room: Room, accountId: number): boolean { return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role)) } +/** + * Whether an account may MODERATE a room — its creator, or the holder of a role at + * Moderator (20) or above. The wider gate that {@link canManageRoom} is the narrow one + * of: a moderator polices who is in the room right now (kicking someone out of an + * instance) without being trusted to change the room itself, while everyone who can + * manage a room can obviously also police it, so CoOwner and Creator pass here too. + * + * Host (10) is deliberately below the line: it is the "runs this session" tier, which the + * client hands out freely, and a kick is a moderation power rather than a hosting one. + */ +export function canModerateRoom(room: Room, accountId: number): boolean { + if (room.CreatorAccountId === accountId) return true + const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] + return roles.some((r) => r.AccountId === accountId && r.Role >= Role.Moderator) +} + /** A player banned from a room (a `room_ban` row). */ export interface RoomBan { RoomId: number