mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 23:48:52 -07:00
[rooms] improve room ban/unban, history
[api] mod kick [match] allow banned users into dorm
This commit is contained in:
@@ -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);
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user