[rooms] improve room ban/unban, history

[api] mod kick
[match] allow banned users into dorm
This commit is contained in:
Devin Zuczek
2026-09-17 10:43:31 -04:00
parent 61aa125af5
commit fd4264d982
18 changed files with 1804 additions and 260 deletions
@@ -0,0 +1,13 @@
-- A room ban can now carry a reason and lapse on its own.
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- `reason` is the issuer's free text, NULL when none was given. `expires_at` is ISO-8601 UTC
-- (the same format `created_at` is written in, so the two compare as strings), and NULL is a
-- permanent ban — which is what every row written before this migration is, so no backfill.
--
-- A lapsed row is not deleted when it lapses: nothing runs at that moment. Every read that
-- asks whether a player is banned filters on `expires_at IS NULL OR expires_at > now` instead,
-- and the next ban or unban of that player overwrites or removes the stale row.
ALTER TABLE room_ban ADD COLUMN reason TEXT;
ALTER TABLE room_ban ADD COLUMN expires_at TEXT;
@@ -0,0 +1,30 @@
-- Room bans that have ENDED, so `GET /rooms/{roomId}/bans/history` has a past to report.
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- `room_ban` holds only the ban in force: one row per (room, player), DELETEd on unban and
-- overwritten on re-ban. Everything that asks "is this player banned" — `match` among them —
-- reads that table and is unchanged. This table is append-only and written at the moment a
-- ban stops being the live one:
--
-- * lifted by an unban -> status 2 (Lifted), ended_at = the unban, unbanned_by_account_id set
-- * lapsed, then cleared -> status 1 (Elapsed), ended_at = its expiry, unbanned_by_account_id NULL
-- (by an unban of the stale row, or by a new ban replacing it)
--
-- A ban still in force that is re-issued is AMENDED in place, not ended, so it writes nothing.
-- Nothing is backfilled: bans lifted before this migration were deleted and left no record.
CREATE TABLE IF NOT EXISTS room_ban_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
banned_player_id INTEGER NOT NULL,
ban_mask INTEGER NOT NULL DEFAULT 0,
banned_by_account_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
reason TEXT,
expires_at TEXT,
status INTEGER NOT NULL,
ended_at TEXT,
unbanned_by_account_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_room_ban_history_player
ON room_ban_history (room_id, banned_player_id);
+62
View File
@@ -440,6 +440,39 @@ export const IsBannedPascalEnvelope = z.object({
error_id: z.string().nullable().describe('Null. Lowercase, unlike its three siblings'),
})
/**
* One ban in `GET /rooms/{roomId}/bans/history`. Seven keys, in the client's order — derived
* members before base ones, so `AccountId` is fifth.
*/
export const RoomBanRecordDto = z.object({
Status: z.int().describe('0 Active · 1 Elapsed (ran out) · 2 Lifted (unbanned early)'),
UnbannedByAccountId: z.int().nullable().describe('Who lifted it; null unless Lifted'),
BanEndTime: z
.string()
.nullable()
.describe(
'ISO 8601 UTC. Active: scheduled expiry, null when permanent. Elapsed: its expiry. Lifted: when it was lifted'
),
Reason: z.string().describe('`""` when no reason was given — never null'),
AccountId: z.int().describe('The banned player'),
BannedByAccountId: z.int().nullable().describe('Who issued the ban'),
BanStartTime: z.string().describe('ISO 8601 UTC, when the ban was issued'),
})
/** `GET /rooms/{roomId}/bans/history` — the PascalCase envelope, `error_id` lowercase. */
export const RoomBanHistoryEnvelope = z.object({
Value: z
.object({
ActiveBan: RoomBanRecordDto.nullable().describe('The ban in force, or null'),
PreviousBans: RoomBanRecordDto.array().describe('Every ban that has ended, newest first'),
})
.nullable()
.describe('Null only when `id` is missing or not a number'),
Success: z.boolean(),
Error: z.string().nullable(),
error_id: z.string().nullable().describe('Null. Lowercase, unlike its three siblings'),
})
/** The bare JSON string the bulk lookups answer when the id list is over the cap. */
export const TooManyLookupIds = z
.string()
@@ -608,6 +641,16 @@ export const BanRequest = z.object({
.string()
.optional()
.describe('Stored verbatim; meaning unknown — the client sends `0`. Defaults to 0'),
reason: z
.string()
.optional()
.describe('Free text, stored on the ban and shown in the kick message'),
durationMinutes: z
.string()
.optional()
.describe(
'Minutes until the ban lapses. Absent, empty or `0` is permanent; anything that is not a non-negative whole number is refused'
),
})
/** A stored room ban — what `POST /rooms/{roomId}/bans` answers in `value`. */
@@ -617,6 +660,8 @@ export const RoomBanDto = z.object({
BanMask: z.int(),
BannedByAccountId: z.int().describe('Who issued the ban'),
CreatedAt: z.string(),
Reason: z.string().nullable().describe('Null when no reason was given'),
ExpiresAt: z.string().nullable().describe('ISO 8601 UTC when the ban lapses; null is permanent'),
})
/**
@@ -630,6 +675,23 @@ export const RoomBanEntryDto = z.object({
banStartTime: z.string().describe('ISO 8601 UTC, when the ban was issued'),
})
/** `DELETE /rooms/{roomId}/bans/bulk` — the players whose bans to lift. */
export const UnbanBulkRequest = z.object({
id: z
.union([z.string(), z.array(z.string())])
.describe('Account id(s) to unban — repeated `id=` fields, or one comma-separated value'),
banMask: z.string().optional().describe('Sent by the client; ignored'),
})
/** What the bulk unban answers — the ban envelope, with `value` the removed bans. */
export const RoomBansRemovedEnvelope = z.object({
success: z.boolean(),
error: z.string().describe('Empty on success'),
value: RoomBanDto.array()
.nullable()
.describe('The bans removed, in request order; null on a rejection'),
})
/** The envelope the ban write answers — same shape as the room writes, `value` is the ban. */
export const RoomBanEnvelope = z.object({
success: z.boolean(),
+298 -36
View File
@@ -15,6 +15,7 @@ import {
countRoomsByCreator,
createNotification,
createSubRoom,
deletePresence,
deleteRoom,
deleteRoomLeaderboard,
deleteSubRoom,
@@ -31,6 +32,7 @@ import {
getPresence,
getPublicRoomsByCreator,
getRecommendedRooms,
getRoomBanHistory,
getRoomBans,
getRoomById,
getRoomByName,
@@ -48,6 +50,7 @@ import {
MessageType,
modifySubRoom,
publishSubRoomSave,
refreshInstanceFullness,
removeCheer,
removeFavorite,
removeRoomRole,
@@ -124,6 +127,8 @@ import {
RoleRequest,
RoomBanEntryDto,
RoomBanEnvelope,
RoomBanHistoryEnvelope,
RoomBansRemovedEnvelope,
RoomDto,
RoomEnvelope,
RoomExperience,
@@ -151,11 +156,12 @@ import {
UNAUTHORIZED_EMPTY,
UNAUTHORIZED_ENVELOPE,
UNAUTHORIZED_RESPONSE,
UnbanBulkRequest,
WarningRequest,
} from './openapi'
import type { Context } from 'hono'
import type { RoomBan, RoomPermission } from '@repo/domain'
import type { RoomBan, RoomBanRecord, RoomPermission } from '@repo/domain'
import type { MessageReceivedPayload } from '../../notify/src/notification-payloads'
import type { App } from './context'
@@ -615,43 +621,109 @@ async function pushRoleInvite(
const REPORT_CATEGORY_MODERATOR = -1
/**
* Eject a player from the room they're in — a `ModerationKick` push (id 22), the frame
* the client acts on to remove someone. Sent on a ban: the row keeps them out of future
* matchmakes, this gets them out of the instance they're in right now.
* Take a freshly banned player out of the room's LIVE PRESENCE, so the ban takes effect on
* everything that reads presence right now rather than when their row next expires. The kick
* push ejects their client, but until the row goes they still count toward the instance's
* player total (keeping a full room full), still show as standing in the room to friends, and
* still pass the presence-based grants — reading the room's staged saves among them.
*
* The payload is the client's moderation shape, camelCase, in wire order:
* `reportCategory`, `duration`, `gameSessionId`, `isHostKick`, `message`,
* `playerIdReporter`, `isBan`, `isVoiceModAutoban`. `duration` is 0 (a room ban has no
* expiry — it's lifted by DELETE, not by time) and `gameSessionId` is 0 (nothing here
* tracks one).
* Only when their presence puts them in THIS room. A player banned from a room they are not
* standing in keeps their presence: a ban here is no reason to knock them offline from wherever
* they actually are. The same rule the instant kick in `api` applies to its instance.
*
* `isHostKick` says the room's HOST ejected the player, as opposed to the room
* majority vote-kicking them. There is no vote-kick path yet, so the only false case
* here is a staff moderator acting in a room they don't host. `playerIdReporter` is
* whoever caused it — the host today, and the player who started the vote once
* vote-kicks exist (those will carry `reportCategory` 10 and `isHostKick` false).
* The instance they left gets its fullness recomputed, as every other presence removal does, so
* a room they were filling opens back up. Best-effort like the push: the ban row is already
* committed, and a failure here must not turn a ban that happened into an error response.
*
* Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban row has
* already committed, so a hub hiccup must not fail the request.
* Returns the instance they were removed from, or null when they were not standing in the
* room — which is what decides whether they get a kick frame at all.
*/
async function evictBannedPlayer(c: Context<App>, ban: RoomBan): Promise<number | null> {
try {
const instance = (await getPresence<PresenceView>(c.env.DB, ban.BannedPlayerId))?.roomInstance
if (instance?.roomId !== ban.RoomId) return null
await deletePresence(c.env.DB, ban.BannedPlayerId)
if (instance.roomInstanceId == null) return null
await refreshInstanceFullness(c.env.DB, instance.roomInstanceId)
return instance.roomInstanceId
} catch (err) {
logger.error('failed to clear a banned player’s presence', {
playerId: ban.BannedPlayerId,
roomId: ban.RoomId,
error: err instanceof Error ? err.message : String(err),
})
return null
}
}
/** An ISO timestamp as the client's ban record carries it: UTC, to the second. */
function toBanTime(iso: string): string {
return iso.replace(/\.\d+Z$/, 'Z')
}
/**
* One ban as the history route serves it. Seven keys, and the ORDER is the client's: the
* derived members before the base ones, so `AccountId` is fifth. `Reason` is a non-nullable
* string on the client, so a ban issued without one is `""`, not null.
*/
function toBanRecordDto(ban: RoomBanRecord) {
return {
Status: ban.Status,
UnbannedByAccountId: ban.UnbannedByAccountId,
BanEndTime: ban.EndedAt === null ? null : toBanTime(ban.EndedAt),
Reason: ban.Reason ?? '',
AccountId: ban.AccountId,
BannedByAccountId: ban.BannedByAccountId,
BanStartTime: toBanTime(ban.StartedAt),
}
}
/**
* Eject a room-banned player from the instance they are standing in — a `ModerationKick`
* frame (id 22), the one the client acts on to leave a room. The `room_ban` row is what keeps
* them out afterwards: `match` refuses their matchmakes into this room with `BannedFromRoom`
* (55). This frame only gets them out of the session they are in right now.
*
* It must NOT look like an account ban, and it once did. `IsBan: true` on this frame is what
* the client's moderation screen reads as a ban from the GAME — it is exactly what the staff
* account ban (`www`) sends — so a room ban sent that way showed players a game-wide ban
* screen, and with a non-zero `duration` a timed one, when nothing but one room was closed to
* them. So, like `api`'s instant kick:
*
* - `isBan` false and `duration` 0. The frame is a kick; the ban is server-side.
* - EPHEMERAL, never queued. A queued frame is delivered on the player's next connect, which
* would eject them from some unrelated session later, or greet them with it on login.
* - Sent only when they were standing in THIS room — `gameSessionId` is the instance they were
* evicted from. A player banned from a room they are not in has nothing to be ejected from
* and hears nothing; they meet the ban as a refused matchmake if they try to join.
*
* The payload is the client's moderation shape, camelCase, in wire order. `isHostKick` says the
* room's HOST ejected them rather than a room vote-kick; the only false case today is a staff
* moderator acting in a room they do not host. `playerIdReporter` is whoever caused it.
*
* Best-effort: the ban row has already committed, so a hub hiccup must not fail the request.
*/
async function pushRoomBan(
c: Context<App>,
ban: RoomBan,
gameSessionId: number,
roomName: string,
isHostKick: boolean
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
ban.BannedPlayerId,
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
[ban.BannedPlayerId],
NotificationType.ModerationKick,
{
reportCategory: REPORT_CATEGORY_MODERATOR,
duration: 0,
gameSessionId: 0,
gameSessionId,
isHostKick,
message: `You have been banned from ${roomName}.`,
message: ban.Reason
? `You have been banned from ${roomName}. Reason: ${ban.Reason}`
: `You have been banned from ${roomName}.`,
playerIdReporter: ban.BannedByAccountId,
isBan: true,
isBan: false,
isVoiceModAutoban: false,
}
)
@@ -2401,7 +2473,98 @@ const app = new Hono<App>()
}
)
// Ban a player from a room (form body `id` + `banMask`). Auth-gated (401), then
// One player's ban history in one room (`?id=` is the player). The owner's view of
// whether someone is banned now and how often they have been before — so gated like the
// ban list: auth (401), then the room's owner/co-owner or a staff token (403).
//
// Answers the PascalCase `{ Value, Success, Error, error_id }` envelope the unprefixed
// isBanned uses — `error_id` lowercase among its PascalCase siblings — with `Value` exactly
// `{ ActiveBan, PreviousBans }`. The record's seven keys are in the client's order: the
// derived members (`Status`, `UnbannedByAccountId`, `BanEndTime`, `Reason`) come BEFORE the
// base ones (`AccountId`, `BannedByAccountId`, `BanStartTime`), which is why `AccountId`
// is fifth. Built key by key in that order rather than by spreading a domain object.
.get(
'/rooms/:roomId{[0-9]+}/bans/history',
describeRoute({
tags: ['Room settings'],
summary: 'A player’s ban history in a room',
description: [
'Whether `id` is banned from the room right now, and every ban of theirs there that has',
'ended. `Value` has exactly two keys: `ActiveBan` (the ban in force, or null) and',
'`PreviousBans` (newest first).',
'',
'Each record is seven keys in this order: `Status` (0 Active, 1 Elapsed, 2 Lifted),',
'`UnbannedByAccountId` (who lifted it; null unless Lifted), `BanEndTime`, `Reason`',
'(`""` when none was given — never null), `AccountId`, `BannedByAccountId`,',
'`BanStartTime`. `BanEndTime` is the scheduled expiry of an Active ban (null for a',
'permanent one), the expiry of an Elapsed ban, and the moment a Lifted ban was lifted.',
'Timestamps are ISO 8601 UTC to the second.',
'',
'A timed ban that has run out is not active: it is reported as an Elapsed previous ban.',
'A ban re-issued while still in force is AMENDED, not ended, so it appears once, with',
'its latest reason and times. History starts with this server’s ban-history migration;',
'bans lifted before it left no record.',
'',
'Gated like the ban list: the room’s creator or a co-owner, or an account whose token',
'carries the `developer` / `moderator` role. An unknown room answers an empty history.',
'A missing or non-numeric `id` answers `Success: false` with a null `Value`.',
].join('\n'),
security: AUTHED,
parameters: [
roomIdParam,
{
name: 'id',
in: 'query',
required: true,
description: 'The account whose ban history to read',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(RoomBanHistoryEnvelope, 'The player’s active ban and previous bans'),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const playerId = Number.parseInt(c.req.query('id') ?? '', 10)
if (Number.isNaN(playerId)) {
return c.json({
Value: null,
Success: false,
Error: 'You must provide a valid player!',
error_id: null,
})
}
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
// No room → no bans, the same answer the ban list gives, so this is not a way to
// probe which room ids exist.
if (room && !canManageRoom(room, accountId) && !(await isStaff(c))) {
return c.body(null, 403)
}
const history = room
? await getRoomBanHistory(c.env.DB, roomId, playerId)
: { active: null, previous: [] }
return c.json({
Value: {
ActiveBan: history.active ? toBanRecordDto(history.active) : null,
PreviousBans: history.previous.map(toBanRecordDto),
},
Success: true,
Error: null,
error_id: null,
})
}
)
// Ban a player from a room (form body `id` + `banMask`, optionally `reason` and
// `durationMinutes`). Auth-gated (401), then
// gated to the room's owner/co-owner OR a staff token (403). One row per
// (room, player) — re-banning rewrites it, so the call is idempotent.
.post(
@@ -2423,16 +2586,25 @@ const app = new Hono<App>()
'`banMask` is stored verbatim and nothing interprets it — the client sends `0` and',
'what it selects is not known yet. It defaults to 0 when absent.',
'',
'The BANNED player (not the caller) gets a `ModerationKick` push (id 22) — the frame',
'the client acts on to eject someone — so a ban takes effect immediately rather than',
'only at their next matchmake. `isBan` is true, `duration` 0 (a room ban has no',
'expiry; it is lifted by DELETE, not by time) and `reportCategory` -1 (Moderator).',
'`reason` is optional free text, stored on the ban and appended to the kick message.',
'`durationMinutes` makes the ban lapse on its own; absent, empty or `0` is permanent.',
'A duration that is present but not a non-negative whole number is a rejection, never',
'read as permanent. A lapsed ban stops counting everywhere — matchmaking, `isBanned`,',
'the ban list — without anyone lifting it. Re-banning REPLACES the reason and',
'duration and restarts the clock; it does not extend the remaining time.',
'',
'`isHostKick` means the room’s HOST ejected them rather than the room majority',
'vote-kicking them; with no vote-kick path yet the only false case is a staff',
'moderator acting in a room they do not host. `playerIdReporter` is whoever caused',
'it — the host today, the player who started the vote once vote-kicks exist. The hub',
'queues the frame if they are offline.',
'If the banned player’s live presence puts them in THIS room, that presence row is',
'deleted and the instance’s fullness recomputed, so they stop counting as standing in',
'the room immediately. Presence in some other room is left alone.',
'',
'A banned player standing in the room also gets a `ModerationKick` frame (id 22),',
'the frame the client acts on to leave, with `gameSessionId` the instance they were',
'removed from. It is a KICK, not a ban, as far as the client is concerned: `isBan` is',
'false and `duration` 0, because `isBan: true` is what the client shows as a ban from',
'the whole game. The ban itself is the row, enforced when `match` refuses them the room',
'(`BannedFromRoom`, 55). The frame is ephemeral, never queued, and a player not in the',
'room gets none. `isHostKick` is false only for a staff moderator acting in a room they',
'do not host; `playerIdReporter` is whoever issued the ban.',
'',
'Answers the same lowercase `{ success, error, value }` envelope the room writes use,',
'but `value` is the BAN, not the room — a ban is not part of the room the client',
@@ -2475,10 +2647,27 @@ const app = new Hono<App>()
// Absent or unparseable → 0, the value the client sends.
const banMask = Number.parseInt(str(body.banMask), 10) || 0
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId)
// The banned player is told, not the caller — their client acts on the kick.
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushRoomBan(c, ban, roomName, isHostKick)
// Absent or empty is a PERMANENT ban. A duration that is present but is not a
// non-negative whole number is refused rather than read as permanent: a typo turning
// "30 minutes" into "forever" is the one mistake here that is worse than failing.
const rawDuration = str(body.durationMinutes).trim()
const durationMinutes = rawDuration === '' ? 0 : Number(rawDuration)
if (!Number.isInteger(durationMinutes) || durationMinutes < 0) {
return banEnvelope(c, null, 'You must provide a valid ban duration!')
}
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId, {
reason: str(body.reason),
durationMinutes,
})
// Out of the room's presence first, so by the time their client acts on the kick
// nothing server-side still counts them as standing in it. Only a player who WAS
// standing in the room is kicked — anyone else has nothing to be ejected from.
const evictedFrom = await evictBannedPlayer(c, ban)
if (evictedFrom !== null) {
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushRoomBan(c, ban, evictedFrom, roomName, isHostKick)
}
return banEnvelope(c, ban)
}
)
@@ -2581,6 +2770,79 @@ const app = new Hono<App>()
}
)
// Lift bans in BULK (form body `id`, repeated or comma-separated, plus the `banMask` the
// client sends and nothing reads). Same gate as issuing one: auth-gated (401), then the
// room's owner/co-owner OR a staff token (403). Declared before the `/:playerId` route
// only for reading order — `bulk` is not a number, so the two never compete for a path.
.delete(
'/rooms/:roomId{[0-9]+}/bans/bulk',
describeRoute({
tags: ['Room settings'],
summary: 'Unban players from a room in bulk',
description: [
'Removes the `room_ban` row of every player named by `id`, so they can matchmake into',
'the room again. `id` may repeat (`id=205&id=206`) or be comma-separated; `banMask`',
'is accepted because the client sends it, and ignored — it described the ban, not the',
'unban. The body is form-encoded, on a DELETE.',
'',
'Gated exactly like issuing a ban: the room’s creator or a co-owner, or an account',
'whose token carries the `developer` / `moderator` role.',
'',
'Unlike the single-player DELETE, a player who is not banned is SKIPPED rather than',
'failing the call — one stale id in a list must not sink the rest. Only a body naming',
'no valid id at all is a rejection. A ban that has already lapsed counts as not banned.',
'',
'Answers the ban envelope with `value` the ARRAY of bans actually removed, in the order',
'asked; empty when none of the players named were banned. No notification is pushed.',
'This shape is unverified against the real service.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(UnbanBulkRequest, 'The players to unban'),
responses: {
200: json(
RoomBansRemovedEnvelope,
'The removed bans, or a rejection with `success: false`'
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return banEnvelope(c, null, 'This room does not exist!')
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
// `all: true` keeps a repeated `id` from collapsing to its last value, which would
// unban one player out of the list and report success.
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
const ids = [
...new Set(
[body.id]
.flat()
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((v) => Number.parseInt(v.trim(), 10))
.filter((n) => !Number.isNaN(n))
),
]
if (ids.length === 0) return banEnvelope(c, null, 'You must provide a valid player to unban!')
const removed: RoomBan[] = []
for (const playerId of ids) {
const ban = await unbanPlayerFromRoom(c.env.DB, roomId, playerId, accountId)
if (ban) removed.push(ban)
}
return c.json({ success: true, error: '', value: removed })
}
)
// Lift a player's ban on a room. Same gate as issuing one: auth-gated (401), then the
// room's owner/co-owner OR a staff token (403).
.delete(
@@ -2617,7 +2879,7 @@ const app = new Hono<App>()
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
const playerId = Number.parseInt(c.req.param('playerId'), 10)
const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId)
const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId, accountId)
if (!removed) return banEnvelope(c, null, 'This player is not banned from this room!')
return banEnvelope(c, removed)
}
+427 -12
View File
@@ -2507,8 +2507,154 @@ describe('rooms endpoints', () => {
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
})
it('POST /rooms/:id/bans kicks the banned player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown }
it('POST /rooms/:id/bans takes a reason and a duration', async () => {
type Ban = { Reason: string | null; ExpiresAt: string | null; CreatedAt: string }
type Sent = { playerIds: number[]; data: { duration: number; isBan: boolean; message: string } }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
// Standing in the room, so there is a session to kick them out of.
await putInRoom(310, 2)
// The client's body, verbatim.
const res = await postForm(
'/rooms/2/bans',
{ banMask: '0', id: '310', reason: 'test', durationMinutes: '30' },
'1'
)
const body = (await envOf(res)) as unknown as { success: boolean; value: Ban }
expect(body).toMatchObject({ success: true, value: { BannedPlayerId: 310, Reason: 'test' } })
// Lapses thirty minutes after it was issued, to the second.
expect(Date.parse(body.value.ExpiresAt!) - Date.parse(body.value.CreatedAt)).toBe(30 * 60_000)
// The kick carries the reason in its message — but NOT the ban's length, and is not a
// ban frame: a duration with `isBan` is what the client shows as a timed GAME ban.
const [kick] = (await (await hub().fetch('http://do/all')).json()) as Sent[]
expect(kick.playerIds).toEqual([310])
expect(kick.data).toMatchObject({ duration: 0, isBan: false })
expect(kick.data.message).toMatch(/Reason: test$/)
// Neither field is required: no duration is a permanent ban, and it still pushes 0.
const permanent = (await envOf(
await postForm('/rooms/2/bans', { banMask: '0', id: '311' }, '1')
)) as unknown as { value: Ban }
expect(permanent.value).toMatchObject({ Reason: null, ExpiresAt: null })
for (const d of ['', '0']) {
const v = (await envOf(
await postForm('/rooms/2/bans', { id: '312', durationMinutes: d }, '1')
)) as unknown as { value: Ban }
expect(v.value.ExpiresAt, d).toBeNull()
}
// A re-ban REPLACES the duration and reason — it neither extends nor keeps the old ones.
const rebanned = (await envOf(
await postForm('/rooms/2/bans', { id: '310' }, '1')
)) as unknown as { value: Ban }
expect(rebanned.value).toMatchObject({ Reason: null, ExpiresAt: null })
})
it('POST /rooms/:id/bans deletes the banned player’s presence in that room', async () => {
const presenceOf = (accountId: number) =>
env.DB.prepare('SELECT room_id FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ room_id: number }>()
const instanceId = 1000002 // what `putInRoom` puts room 2's players in
const isFull = async () =>
(
await env.DB.prepare('SELECT is_full FROM room_instance WHERE id = ?1')
.bind(instanceId)
.first<{ is_full: number }>()
)?.is_full
// A one-seat instance of room 2 that 320 is filling.
await env.DB.prepare('INSERT INTO room_instance (data) VALUES (?1)')
.bind(
JSON.stringify({
roomInstanceId: instanceId,
roomId: 2,
subRoomId: 2,
maxCapacity: 1,
isFull: true,
})
)
.run()
await putInRoom(320, 2)
// 321 is standing in a DIFFERENT room.
await putInRoom(321, 4)
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '320' }, '1')).status).toBe(200)
// Gone from the room they were banned from, and the seat they held is free again.
expect(await presenceOf(320)).toBeNull()
expect(await isFull()).toBe(0)
// A ban from room 2 does not knock someone offline from room 4.
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '321' }, '1')).status).toBe(200)
expect(await presenceOf(321)).toEqual({ room_id: 4 })
await clearPresence(321)
await env.DB.prepare('DELETE FROM room_instance WHERE id = ?1').bind(instanceId).run()
})
it('POST /rooms/:id/bans refuses a duration it cannot read, rather than banning forever', async () => {
for (const durationMinutes of ['thirty', '-5', '1.5']) {
expect(
await envOf(await postForm('/rooms/2/bans', { id: '313', durationMinutes }, '1')),
durationMinutes
).toMatchObject({ success: false, error: 'You must provide a valid ban duration!' })
}
const row = await env.DB.prepare(
'SELECT 1 FROM room_ban WHERE room_id = 2 AND banned_player_id = 313'
).first()
expect(row).toBeNull()
})
it('a lapsed room ban stops counting everywhere without being lifted', async () => {
// Written straight to the table: an hour-long ban issued two hours ago.
const issued = new Date(Date.now() - 2 * 3_600_000)
await env.DB.prepare(
`INSERT INTO room_ban
(room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at)
VALUES (2, 314, 0, 1, ?1, 'old', ?2)`
)
.bind(issued.toISOString(), new Date(issued.getTime() + 3_600_000).toISOString())
.run()
// `isBanned` — the same check `match` refuses matchmakes on.
const isBanned = await SELF.fetch(`${ORIGIN}/rooms/2/bans/314/isBanned`, {
headers: await bearer('314'),
})
expect(await isBanned.json()).toMatchObject({ Value: false })
// Not in the ban list.
const list = (await (
await SELF.fetch(`${ORIGIN}/rooms/2/bans`, { headers: await bearer('1') })
).json()) as Array<{ accountId: number }>
expect(list.some((b) => b.accountId === 314)).toBe(false)
// Unbanning it is "not banned", like any other player who isn't.
const unban = await SELF.fetch(`${ORIGIN}/rooms/2/bans/314`, {
method: 'DELETE',
headers: await bearer('1'),
})
expect(await unban.json()).toMatchObject({ success: false })
// A ban still in force DOES count — the filter is on the expiry, not on having one.
expect(
(await postForm('/rooms/2/bans', { id: '315', durationMinutes: '30' }, '1')).status
).toBe(200)
const stillBanned = await SELF.fetch(`${ORIGIN}/rooms/2/bans/315/isBanned`, {
headers: await bearer('315'),
})
expect(await stillBanned.json()).toMatchObject({ Value: true })
})
it('POST /rooms/:id/bans kicks a banned player out of the room — without banning them from the game', async () => {
type Sent = {
playerId?: number
playerIds?: number[]
ephemeral?: boolean
notificationType: string | number
data: unknown
}
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const sentSince = async (): Promise<Sent[]> =>
(await (await hub().fetch('http://do/all')).json()) as Sent[]
@@ -2516,27 +2662,30 @@ describe('rooms endpoints', () => {
// The room's current name, read rather than hardcoded — earlier tests rename it.
const { Name } = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
await putInRoom(207, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)
// A ModerationKick (id 22) to the BANNED player, not the caller — it ejects them
// from the instance they're in now; the row keeps them out of future matchmakes.
// One EPHEMERAL ModerationKick to the banned player, out of the instance they were in.
// Asserted against the enum rather than a literal: the ids are notify's to change.
expect(await sentSince()).toEqual([
{
playerId: 207,
playerIds: [207],
ephemeral: true,
notificationType: NotificationType.ModerationKick,
// The client's moderation payload, camelCase, in wire order.
data: {
reportCategory: -1, // Moderator — a person acted, not the system
duration: 0, // a room ban has no expiry
gameSessionId: 0,
// The host ejected them (as opposed to a room vote-kick, which doesn't
// exist yet). Account 1 owns RecCenter, so it hosts it.
duration: 0,
gameSessionId: 1000002, // the instance `putInRoom` stood them in
// Account 1 owns RecCenter, so it hosts it.
isHostKick: true,
message: `You have been banned from ${Name}.`,
playerIdReporter: 1,
isBan: true,
// FALSE. `isBan: true` is what the client shows as a ban from the whole game
// — the account ban's frame. A room ban is enforced by the row, when `match`
// refuses them the room; this frame only ejects them from the session.
isBan: false,
isVoiceModAutoban: false,
},
},
@@ -2544,14 +2693,26 @@ describe('rooms endpoints', () => {
// A staff moderator doesn't host the room, so it isn't a host kick — and
// `playerIdReporter` is still whoever caused it.
await putInRoom(208, 2)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect(
(await postForm('/rooms/2/bans', { id: '208' }, '999', ['gameClient', 'moderator'])).status
).toBe(200)
expect((await sentSince())[0]).toMatchObject({
playerId: 208,
data: { isHostKick: false, playerIdReporter: 999 },
playerIds: [208],
data: { isHostKick: false, playerIdReporter: 999, isBan: false },
})
// A player who is NOT in the room has nothing to be kicked out of, and hears NOTHING —
// not now, and not queued for their next connect, which is how a room ban once greeted
// players with a ban screen on login.
await putInRoom(216, 4)
await hub().fetch('http://do/all', { method: 'DELETE' })
expect((await postForm('/rooms/2/bans', { id: '216' }, '1')).status).toBe(200)
expect((await postForm('/rooms/2/bans', { id: '217' }, '1')).status).toBe(200)
expect(await sentSince()).toEqual([])
for (const id of [216]) await clearPresence(id)
})
it('GET /rooms/:id/bans lists the room’s bans, under the same gate', async () => {
@@ -2586,6 +2747,258 @@ describe('rooms endpoints', () => {
expect(await (await list('/rooms/99999/bans', '1')).json()).toEqual([])
})
it('GET /rooms/:id/bans/history serves the client’s exact shape', async () => {
const history = async (playerId: number) =>
(await (
await SELF.fetch(`${ORIGIN}/rooms/2/bans/history?id=${playerId}`, {
headers: await bearer('1'),
})
).json()) as {
Value: {
ActiveBan: Record<string, unknown> | null
PreviousBans: Array<Record<string, unknown>>
}
Success: boolean
Error: string | null
error_id: string | null
}
expect(
(await postForm('/rooms/2/bans', { banMask: '0', id: '340', reason: 'test' }, '1')).status
).toBe(200)
const body = await history(340)
// The envelope, PascalCase with a lowercase `error_id`, and a Value of exactly two keys.
expect(Object.keys(body)).toEqual(['Value', 'Success', 'Error', 'error_id'])
expect(body).toMatchObject({ Success: true, Error: null, error_id: null })
expect(Object.keys(body.Value)).toEqual(['ActiveBan', 'PreviousBans'])
// Seven keys in the CLIENT'S order — derived members first, so AccountId is fifth.
const active = body.Value.ActiveBan!
expect(Object.keys(active)).toEqual([
'Status',
'UnbannedByAccountId',
'BanEndTime',
'Reason',
'AccountId',
'BannedByAccountId',
'BanStartTime',
])
expect(active).toMatchObject({
Status: 0, // Active
UnbannedByAccountId: null,
BanEndTime: null, // permanent
Reason: 'test',
AccountId: 340,
BannedByAccountId: 1,
})
// UTC to the second, as the client's example carries it.
expect(active.BanStartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
expect(body.Value.PreviousBans).toEqual([])
// A ban with no reason is `""`, never null; a timed one carries its scheduled end.
expect(
(await postForm('/rooms/2/bans', { id: '341', durationMinutes: '30' }, '1')).status
).toBe(200)
const timed = (await history(341)).Value.ActiveBan!
expect(timed.Reason).toBe('')
expect(Date.parse(timed.BanEndTime as string) - Date.parse(timed.BanStartTime as string)).toBe(
30 * 60_000
)
// Someone never banned has no history at all.
expect((await history(349)).Value).toEqual({ ActiveBan: null, PreviousBans: [] })
})
it('GET /rooms/:id/bans/history keeps lifted and elapsed bans, newest first', async () => {
type Rec = {
Status: number
UnbannedByAccountId: number | null
BanEndTime: string | null
Reason: string
}
const history = async (playerId: number) =>
(
(await (
await SELF.fetch(`${ORIGIN}/rooms/2/bans/history?id=${playerId}`, {
headers: await bearer('1'),
})
).json()) as { Value: { ActiveBan: Rec | null; PreviousBans: Rec[] } }
).Value
const unban = async (playerId: number, sub: string) =>
SELF.fetch(`${ORIGIN}/rooms/2/bans/${playerId}`, {
method: 'DELETE',
headers: await bearer(sub),
})
// LIFTED: banned by the owner, unbanned early by the co-owner.
expect((await postForm('/rooms/2/bans', { id: '342', reason: 'first' }, '1')).status).toBe(200)
expect((await unban(342, '2')).status).toBe(200)
let h = await history(342)
expect(h.ActiveBan).toBeNull()
expect(h.PreviousBans).toHaveLength(1)
expect(h.PreviousBans[0]).toMatchObject({ Status: 2, UnbannedByAccountId: 2, Reason: 'first' })
expect(h.PreviousBans[0].BanEndTime).not.toBeNull()
// Banned and lifted again: two previous bans, the newest first.
expect((await postForm('/rooms/2/bans', { id: '342', reason: 'second' }, '1')).status).toBe(200)
expect((await unban(342, '1')).status).toBe(200)
h = await history(342)
expect(h.PreviousBans.map((b) => b.Reason)).toEqual(['second', 'first'])
// ELAPSED: a one-hour ban issued two hours ago, never touched since. It is not active,
// and it is already reported as a previous ban before anything files it.
const issued = new Date(Date.now() - 2 * 3_600_000)
const expired = new Date(issued.getTime() + 3_600_000).toISOString()
await env.DB.prepare(
`INSERT INTO room_ban
(room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at)
VALUES (2, 343, 0, 1, ?1, 'lapsed', ?2)`
)
.bind(issued.toISOString(), expired)
.run()
h = await history(343)
expect(h.ActiveBan).toBeNull()
expect(h.PreviousBans).toEqual([
expect.objectContaining({ Status: 1, UnbannedByAccountId: null, Reason: 'lapsed' }),
])
// A new ban replaces the stale row — and FILES the lapsed one, exactly once, rather than
// losing it or counting it twice.
expect((await postForm('/rooms/2/bans', { id: '343', reason: 'again' }, '1')).status).toBe(200)
h = await history(343)
expect(h.ActiveBan).toMatchObject({ Status: 0, Reason: 'again' })
expect(h.PreviousBans).toEqual([
expect.objectContaining({ Status: 1, UnbannedByAccountId: null, Reason: 'lapsed' }),
])
// Elapsed at its expiry, not at the moment it was replaced.
expect(Date.parse(h.PreviousBans[0].BanEndTime!)).toBe(
Math.floor(Date.parse(expired) / 1000) * 1000
)
// Re-issuing a ban still in force AMENDS it: one active ban, nothing filed.
expect((await postForm('/rooms/2/bans', { id: '344', reason: 'v1' }, '1')).status).toBe(200)
expect((await postForm('/rooms/2/bans', { id: '344', reason: 'v2' }, '2')).status).toBe(200)
h = await history(344)
expect(h.ActiveBan).toMatchObject({ Reason: 'v2' })
expect(h.PreviousBans).toEqual([])
// The bulk unban files a lift the same way the single one does.
await SELF.fetch(`${ORIGIN}/rooms/2/bans/bulk`, {
method: 'DELETE',
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer('1')) },
body: 'banMask=0&id=344',
})
h = await history(344)
expect(h.ActiveBan).toBeNull()
expect(h.PreviousBans).toEqual([
expect.objectContaining({ Status: 2, UnbannedByAccountId: 1, Reason: 'v2' }),
])
})
it('GET /rooms/:id/bans/history is gated like the ban list', async () => {
const get = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub, roles) : {} })
expect((await postForm('/rooms/2/bans', { id: '345' }, '1')).status).toBe(200)
expect((await get('/rooms/2/bans/history?id=345')).status).toBe(401)
expect((await get('/rooms/2/bans/history?id=345', '999')).status).toBe(403)
// The co-owner, and a staff token with no role on the room.
for (const [sub, roles] of [
['2', undefined],
['999', ['gameClient', 'moderator']],
] as const) {
const res = await get('/rooms/2/bans/history?id=345', sub, roles as string[] | undefined)
expect(res.status, sub).toBe(200)
expect(
((await res.json()) as { Value: { ActiveBan: unknown } }).Value.ActiveBan
).not.toBeNull()
}
// An unknown room is an empty history, not an error.
expect(await (await get('/rooms/99999/bans/history?id=345', '999')).json()).toEqual({
Value: { ActiveBan: null, PreviousBans: [] },
Success: true,
Error: null,
error_id: null,
})
// A missing or non-numeric id.
for (const q of ['', '?id=nope']) {
expect(await (await get(`/rooms/2/bans/history${q}`, '1')).json(), q).toEqual({
Value: null,
Success: false,
Error: 'You must provide a valid player!',
error_id: null,
})
}
})
it('DELETE /rooms/:id/bans/bulk removes the named bans, under the same gate', async () => {
type Removed = {
success: boolean
error: string
value: Array<{ BannedPlayerId: number }> | null
}
const unban = async (body: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}/rooms/2/bans/bulk`, {
method: 'DELETE',
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(sub ? await bearer(sub, roles) : {}),
},
body,
})
const banned = async (playerId: number) =>
(await env.DB.prepare('SELECT 1 FROM room_ban WHERE room_id = 2 AND banned_player_id = ?1')
.bind(playerId)
.first()) !== null
for (const id of ['330', '331', '332', '333']) {
expect((await postForm('/rooms/2/bans', { id }, '1')).status).toBe(200)
}
// No token → 401; a valid token with no standing in the room → 403. Nothing removed.
expect((await unban('banMask=0&id=330')).status).toBe(401)
expect((await unban('banMask=0&id=330', '999')).status).toBe(403)
expect(await banned(330)).toBe(true)
// The client's body, verbatim, from the owner.
const one = await unban('banMask=0&id=330', '1')
expect(one.status).toBe(200)
expect((await one.json()) as Removed).toMatchObject({
success: true,
error: '',
value: [{ BannedPlayerId: 330 }],
})
expect(await banned(330)).toBe(false)
// Repeated `id` lifts every one of them — not just the last — from a co-owner, and a player
// who is not banned (330, already lifted) is skipped rather than failing the others.
const many = (await (await unban('banMask=0&id=331&id=330&id=332', '2')).json()) as Removed
expect(many).toMatchObject({ success: true })
expect(many.value!.map((b) => b.BannedPlayerId)).toEqual([331, 332])
expect(await banned(331)).toBe(false)
expect(await banned(332)).toBe(false)
// A staff token works in a room it has no role on, and a comma-separated id is read too.
expect(
(await (await unban('id=333,334', '999', ['gameClient', 'moderator'])).json()) as Removed
).toMatchObject({ success: true, value: [{ BannedPlayerId: 333 }] })
// Nothing banned among the names is still a success, with nothing removed.
expect((await (await unban('id=330', '1')).json()) as Removed).toMatchObject({
success: true,
value: [],
})
// A body naming no valid player at all is the one rejection.
for (const body of ['banMask=0', 'id=nope']) {
expect((await (await unban(body, '1')).json()) as Removed, body).toMatchObject({
success: false,
error: 'You must provide a valid player to unban!',
value: null,
})
}
})
it('DELETE /rooms/:id/bans/:playerId lifts a ban, under the same gate', async () => {
const del = async (path: string, sub?: string, roles?: string[]) =>
SELF.fetch(`${ORIGIN}${path}`, {
@@ -4654,6 +5067,7 @@ describe('rooms endpoints', () => {
)
expect([...documented].sort()).toEqual([
'DELETE /rooms/{roomId}',
'DELETE /rooms/{roomId}/bans/bulk',
'DELETE /rooms/{roomId}/bans/{playerId}',
'DELETE /rooms/{roomId}/interactionby/me/cheer',
'DELETE /rooms/{roomId}/interactionby/me/favorite',
@@ -4683,6 +5097,7 @@ describe('rooms endpoints', () => {
'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}',
'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/bans/history',
'GET /rooms/{roomId}/bans/{playerId}/isBanned',
'GET /rooms/{roomId}/experience',
'GET /rooms/{roomId}/experience/player',
+7 -1
View File
@@ -23,7 +23,9 @@ export default defineConfig({
durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' },
// notifyPlayer records every call so tests can assert the notifications the
// worker pushed (type + payload). GET /all for the whole list, DELETE to
// reset it between assertions.
// reset it between assertions. notifyPlayersEphemeral is recorded too, with
// `playerIds` and `ephemeral: true`: whether a frame is queued for an offline
// player is worth asserting — a queued ModerationKick lands on their next login.
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
@@ -32,6 +34,10 @@ export default defineConfig({
this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true }
}
async notifyPlayersEphemeral(playerIds, notificationType, data) {
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
return { delivered: 0 }
}
async broadcast() { return { delivered: 0 } }
async fetch(request) {
if (request.method === 'DELETE') {