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