mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
implement room ban
This commit is contained in:
+60
-27
@@ -274,6 +274,14 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
|||||||
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
|
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
|
||||||
const NO_SUCH_ROOM = 20
|
const NO_SUCH_ROOM = 20
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MatchmakingErrorCode for "you are banned from this room". Unlike the opaque
|
||||||
|
* NoSuchRoom every other refusal answers, a banned player is told why: they already
|
||||||
|
* know the room exists, so there's nothing to hide, and the client can say so instead
|
||||||
|
* of showing a room that mysteriously fails to load.
|
||||||
|
*/
|
||||||
|
const BANNED_FROM_ROOM = 55
|
||||||
|
|
||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
@@ -477,11 +485,20 @@ async function inviteParty(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outcome of resolving a room to join: the instance, or the `errorCode` to answer
|
||||||
|
* with. Kept as a pair rather than a bare null so callers can tell a room that isn't
|
||||||
|
* there (NoSuchRoom) from one the caller is banned from — those answer different codes.
|
||||||
|
*/
|
||||||
|
type ResolvedInstance =
|
||||||
|
| { instance: RoomInstance; errorCode: 0 }
|
||||||
|
| { instance: null; errorCode: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
|
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
|
||||||
* joinable instance of it (public matchmakes reuse one via the `room_instance`
|
* joinable instance of it (public matchmakes reuse one via the `room_instance`
|
||||||
* table) or create a new one. Returns null when the room isn't found, or when the
|
* table) or create a new one. A null instance carries the error code to answer:
|
||||||
* caller is banned from it.
|
* NoSuchRoom when the room isn't in the DB, BannedFromRoom when the caller is banned.
|
||||||
*/
|
*/
|
||||||
async function resolveRoomInstance(
|
async function resolveRoomInstance(
|
||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
@@ -489,23 +506,22 @@ async function resolveRoomInstance(
|
|||||||
isPrivate: boolean,
|
isPrivate: boolean,
|
||||||
ownerId: number,
|
ownerId: number,
|
||||||
subRoomId?: number
|
subRoomId?: number
|
||||||
): Promise<RoomInstance | null> {
|
): Promise<ResolvedInstance> {
|
||||||
const id = Number.parseInt(roomKey, 10)
|
const id = Number.parseInt(roomKey, 10)
|
||||||
const room = Number.isNaN(id)
|
const room = Number.isNaN(id)
|
||||||
? await getRoomByName(c.env.DB, roomKey)
|
? await getRoomByName(c.env.DB, roomKey)
|
||||||
: await getRoomById(c.env.DB, id)
|
: await getRoomById(c.env.DB, id)
|
||||||
if (!room) return null
|
if (!room) return { instance: null, errorCode: NO_SUCH_ROOM }
|
||||||
|
|
||||||
const f = instanceFieldsFromRoom(room, subRoomId)
|
const f = instanceFieldsFromRoom(room, subRoomId)
|
||||||
|
|
||||||
// A banned player never gets an instance. This is the whole enforcement of a room
|
// A banned player never gets an instance. This is the whole enforcement of a room
|
||||||
// ban: the Photon room id only ever reaches a player through a matchmake, so
|
// ban: the Photon room id only ever reaches a player through a matchmake, so
|
||||||
// refusing here means they have no coordinates to join or interact with. Handled
|
// refusing here means they have no coordinates to join or interact with. Handled
|
||||||
// before any instance is created or reused so a ban can't spawn one. The caller sees
|
// before any instance is created or reused so a ban can't spawn one.
|
||||||
// the same opaque NO_SUCH_ROOM every other refusal answers.
|
|
||||||
if (await isPlayerBannedFromRoom(c.env.DB, f.roomId, ownerId)) {
|
if (await isPlayerBannedFromRoom(c.env.DB, f.roomId, ownerId)) {
|
||||||
logger.info('matchmake refused: player banned from room', { roomId: f.roomId, ownerId })
|
logger.info('matchmake refused: player banned from room', { roomId: f.roomId, ownerId })
|
||||||
return null
|
return { instance: null, errorCode: BANNED_FROM_ROOM }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never place the player back into the instance they're already in: the client
|
// Never place the player back into the instance they're already in: the client
|
||||||
@@ -538,13 +554,16 @@ async function resolveRoomInstance(
|
|||||||
roomInstanceType: f.roomInstanceType,
|
roomInstanceType: f.roomInstanceType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return roomInstanceFromRoom(
|
return {
|
||||||
room,
|
instance: roomInstanceFromRoom(
|
||||||
isPrivate,
|
room,
|
||||||
instance.roomInstanceId,
|
isPrivate,
|
||||||
instance.photonRoomId,
|
instance.roomInstanceId,
|
||||||
f.subRoomId
|
instance.photonRoomId,
|
||||||
)
|
f.subRoomId
|
||||||
|
),
|
||||||
|
errorCode: 0,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -872,7 +891,8 @@ const app = new Hono<App>()
|
|||||||
description: [
|
description: [
|
||||||
'Looks the club up, checks the caller is a member of it, and places them into an',
|
'Looks the club up, checks the caller is a member of it, and places them into an',
|
||||||
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
|
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
|
||||||
'club is unknown, has no clubhouse set, or the caller isn’t a member.',
|
'club is unknown, has no clubhouse set, or the caller isn’t a member — and errorCode',
|
||||||
|
'55 when they are banned from the clubhouse room.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
||||||
@@ -888,7 +908,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeResponse,
|
MatchmakeResponse,
|
||||||
'The clubhouse instance (or errorCode 20 with null when it can’t be entered)'
|
'The clubhouse instance (or a null instance with errorCode 20 / 55 when it can’t be entered)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -908,13 +928,13 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
const joinMode = await readJoinMode(c)
|
const joinMode = await readJoinMode(c)
|
||||||
const instance = await resolveRoomInstance(
|
const { instance, errorCode } = await resolveRoomInstance(
|
||||||
c,
|
c,
|
||||||
String(club.clubhouseRoomId),
|
String(club.clubhouseRoomId),
|
||||||
joinMode === 2,
|
joinMode === 2,
|
||||||
id
|
id
|
||||||
)
|
)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
}
|
}
|
||||||
@@ -938,7 +958,9 @@ const app = new Hono<App>()
|
|||||||
'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend',
|
'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend',
|
||||||
'of the target (otherwise anyone could read a player’s presence and warp to them).',
|
'of the target (otherwise anyone could read a player’s presence and warp to them).',
|
||||||
'Returns errorCode 20 with a null instance when the target isn’t a friend, is the',
|
'Returns errorCode 20 with a null instance when the target isn’t a friend, is the',
|
||||||
'caller themselves, or isn’t currently in a room.',
|
'caller themselves, or isn’t currently in a room, and errorCode 55 when the caller is',
|
||||||
|
'banned from the room the friend is in — this path hands out join coordinates without',
|
||||||
|
'going through the room resolver, so it carries its own ban check.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -953,7 +975,7 @@ const app = new Hono<App>()
|
|||||||
responses: {
|
responses: {
|
||||||
200: json(
|
200: json(
|
||||||
MatchmakeResponse,
|
MatchmakeResponse,
|
||||||
'The friend’s instance (or errorCode 20 with null when it can’t be joined)'
|
'The friend’s instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
||||||
),
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
@@ -979,7 +1001,7 @@ const app = new Hono<App>()
|
|||||||
// otherwise following a friend in is a way around a ban.
|
// otherwise following a friend in is a way around a ban.
|
||||||
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
|
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
|
||||||
logger.info('follow refused: player banned from room', { roomId: instance.roomId, id })
|
logger.info('follow refused: player banned from room', { roomId: instance.roomId, id })
|
||||||
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Join that same instance (same id + Photon room) and store it as the caller's
|
// Join that same instance (same id + Photon room) and store it as the caller's
|
||||||
@@ -1015,7 +1037,10 @@ const app = new Hono<App>()
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
200: json(
|
||||||
|
MatchmakeResponse,
|
||||||
|
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
||||||
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1024,14 +1049,14 @@ const app = new Hono<App>()
|
|||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
||||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
const instance = await resolveRoomInstance(
|
const { instance, errorCode } = await resolveRoomInstance(
|
||||||
c,
|
c,
|
||||||
c.req.param('roomId'),
|
c.req.param('roomId'),
|
||||||
joinMode === 2,
|
joinMode === 2,
|
||||||
id,
|
id,
|
||||||
subRoomId
|
subRoomId
|
||||||
)
|
)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
||||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
await inviteParty(c, id, additionalPlayerIds, instance)
|
||||||
@@ -1054,7 +1079,10 @@ const app = new Hono<App>()
|
|||||||
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
||||||
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
200: json(
|
||||||
|
MatchmakeResponse,
|
||||||
|
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
||||||
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1062,8 +1090,13 @@ const app = new Hono<App>()
|
|||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
||||||
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id)
|
const { instance, errorCode } = await resolveRoomInstance(
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
c,
|
||||||
|
c.req.param('roomId'),
|
||||||
|
joinMode === 2,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
||||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
await inviteParty(c, id, additionalPlayerIds, instance)
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ export const PlayerDto = z.object({
|
|||||||
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
|
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
|
||||||
*/
|
*/
|
||||||
export const MatchmakeResponse = z.object({
|
export const MatchmakeResponse = z.object({
|
||||||
errorCode: z.int().describe('0 = success; 20 = NoSuchRoom'),
|
errorCode: z
|
||||||
|
.int()
|
||||||
|
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
|
||||||
roomInstance: RoomInstanceDto.nullable(),
|
roomInstance: RoomInstanceDto.nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1251,7 +1251,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
).run()
|
).run()
|
||||||
try {
|
try {
|
||||||
expect(await (await follow(9801, '9800')).json()).toEqual({
|
expect(await (await follow(9801, '9800')).json()).toEqual({
|
||||||
errorCode: 20,
|
errorCode: 55,
|
||||||
roomInstance: null,
|
roomInstance: null,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1278,14 +1278,15 @@ describe('auth-gated endpoints', () => {
|
|||||||
).run()
|
).run()
|
||||||
|
|
||||||
// The ban is the whole enforcement: no instance means no Photon room id, so there
|
// The ban is the whole enforcement: no instance means no Photon room id, so there
|
||||||
// is nothing for the banned player to join. Same opaque NoSuchRoom as any other
|
// is nothing for the banned player to join. errorCode 55 rather than the opaque
|
||||||
// refusal, and it applies to the subroom path as well.
|
// NoSuchRoom every other refusal answers — a banned player already knows the room
|
||||||
expect(await matchmake('9701')).toEqual({ errorCode: 20, roomInstance: null })
|
// exists, so the client can say why. Applies to the subroom path as well.
|
||||||
|
expect(await matchmake('9701')).toEqual({ errorCode: 55, roomInstance: null })
|
||||||
const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, {
|
const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: await bearer('9701'),
|
headers: await bearer('9701'),
|
||||||
})
|
})
|
||||||
expect(await sub.json()).toEqual({ errorCode: 20, roomInstance: null })
|
expect(await sub.json()).toEqual({ errorCode: 55, roomInstance: null })
|
||||||
|
|
||||||
// Refused before any instance is created, and no presence was recorded for them.
|
// Refused before any instance is created, and no presence was recorded for them.
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -462,6 +462,17 @@ export const RoomBanDto = z.object({
|
|||||||
CreatedAt: z.string(),
|
CreatedAt: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of `GET /rooms/{roomId}/bans` — the client's ban-list shape. camelCase and
|
||||||
|
* a different field set from the {@link RoomBanDto} the write answers: no room id (the
|
||||||
|
* path already says which room) and no ban mask.
|
||||||
|
*/
|
||||||
|
export const RoomBanEntryDto = z.object({
|
||||||
|
accountId: z.int().describe('The banned player'),
|
||||||
|
bannedByAccountId: z.int().describe('Who issued the ban'),
|
||||||
|
banStartTime: z.string().describe('ISO 8601 UTC, when the ban was issued'),
|
||||||
|
})
|
||||||
|
|
||||||
/** The envelope the ban write answers — same shape as the room writes, `value` is the ban. */
|
/** The envelope the ban write answers — same shape as the room writes, `value` is the ban. */
|
||||||
export const RoomBanEnvelope = z.object({
|
export const RoomBanEnvelope = z.object({
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
|
|||||||
+115
-16
@@ -22,6 +22,7 @@ import {
|
|||||||
getPresence,
|
getPresence,
|
||||||
getPublicRoomsByCreator,
|
getPublicRoomsByCreator,
|
||||||
getRecommendedRooms,
|
getRecommendedRooms,
|
||||||
|
getRoomBans,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
getRoomByName,
|
getRoomByName,
|
||||||
getRoomsByCreator,
|
getRoomsByCreator,
|
||||||
@@ -83,6 +84,7 @@ import {
|
|||||||
RestrictionsRequest,
|
RestrictionsRequest,
|
||||||
RoleRequest,
|
RoleRequest,
|
||||||
RoomBanEnvelope,
|
RoomBanEnvelope,
|
||||||
|
RoomBanEntryDto,
|
||||||
RoomDto,
|
RoomDto,
|
||||||
RoomEnvelope,
|
RoomEnvelope,
|
||||||
roomIdParam,
|
roomIdParam,
|
||||||
@@ -375,19 +377,56 @@ async function pushRoomUpdate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tell a player they've been banned from a room — a `ModerationRoomBan` push carrying
|
* `reportCategory` on a moderation frame. -1 is "Moderator" — the category for an
|
||||||
* the ban. Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban
|
* action a person took rather than one the system inferred, which is what a room ban
|
||||||
* row has already committed, so a hub hiccup must not fail the request.
|
* is. The rest of the enum, for reference: 2 Harassment, 3 Cheating, 5 AFK, 6 Misc,
|
||||||
|
* 7 Underage, 10 VoteKick, 100–104 CoC_*, 200 InappropriateClothing.
|
||||||
*/
|
*/
|
||||||
async function pushRoomBan(c: Context<App>, ban: RoomBan): Promise<void> {
|
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.
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* `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).
|
||||||
|
*
|
||||||
|
* Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban row has
|
||||||
|
* already committed, so a hub hiccup must not fail the request.
|
||||||
|
*/
|
||||||
|
async function pushRoomBan(
|
||||||
|
c: Context<App>,
|
||||||
|
ban: RoomBan,
|
||||||
|
roomName: string,
|
||||||
|
isHostKick: boolean
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
ban.BannedPlayerId,
|
ban.BannedPlayerId,
|
||||||
NotificationType.ModerationRoomBan,
|
NotificationType.ModerationKick,
|
||||||
{ ...ban }
|
{
|
||||||
|
reportCategory: REPORT_CATEGORY_MODERATOR,
|
||||||
|
duration: 0,
|
||||||
|
gameSessionId: 0,
|
||||||
|
isHostKick,
|
||||||
|
message: `You have been banned from ${roomName}.`,
|
||||||
|
playerIdReporter: ban.BannedByAccountId,
|
||||||
|
isBan: true,
|
||||||
|
isVoiceModAutoban: false,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('failed to push ModerationRoomBan notification', {
|
logger.error('failed to push ModerationKick notification', {
|
||||||
playerId: ban.BannedPlayerId,
|
playerId: ban.BannedPlayerId,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
@@ -1393,6 +1432,55 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A room's ban list — the owner's view of who they've banned. Same gate as issuing a
|
||||||
|
// ban: a ban list says who a room's owner has had trouble with, so it isn't public.
|
||||||
|
// Answers a BARE array (not the room-write envelope), newest ban first.
|
||||||
|
.get(
|
||||||
|
'/rooms/:roomId{[0-9]+}/bans',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Room settings'],
|
||||||
|
summary: 'A room’s ban list',
|
||||||
|
description: [
|
||||||
|
'Everyone banned from the room, most recently banned first. Auth-gated, then gated',
|
||||||
|
'exactly like issuing a ban: the room’s creator or a co-owner, or an account whose',
|
||||||
|
'token carries the `developer` / `moderator` role. A ban list says who a room’s',
|
||||||
|
'owner has had trouble with, so it is not public.',
|
||||||
|
'',
|
||||||
|
'A bare array, NOT the `{ success, error, value }` envelope the ban write answers,',
|
||||||
|
'and the entries are camelCase with a different field set: no room id (the path',
|
||||||
|
'already says which room) and no ban mask. An unknown room is an empty list rather',
|
||||||
|
'than an error — it reads the same as a room nobody is banned from.',
|
||||||
|
].join('\n'),
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [roomIdParam],
|
||||||
|
responses: {
|
||||||
|
200: json(RoomBanEntryDto.array(), 'The room’s bans, newest first'),
|
||||||
|
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)
|
||||||
|
// No room → nothing banned. Same answer as a room with an empty ban list, so
|
||||||
|
// this doesn't become a way to probe which room ids exist.
|
||||||
|
if (!room) return c.json([])
|
||||||
|
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
|
||||||
|
|
||||||
|
const bans = await getRoomBans(c.env.DB, roomId)
|
||||||
|
return c.json(
|
||||||
|
bans.map((ban) => ({
|
||||||
|
accountId: ban.BannedPlayerId,
|
||||||
|
bannedByAccountId: ban.BannedByAccountId,
|
||||||
|
banStartTime: ban.CreatedAt,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Ban a player from a room (form body `id` + `banMask`). Auth-gated (401), then
|
// Ban a player from a room (form body `id` + `banMask`). Auth-gated (401), then
|
||||||
// gated to the room's owner/co-owner OR a staff token (403). One row per
|
// 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.
|
// (room, player) — re-banning rewrites it, so the call is idempotent.
|
||||||
@@ -1403,9 +1491,9 @@ const app = new Hono<App>()
|
|||||||
summary: 'Ban a player from a room',
|
summary: 'Ban a player from a room',
|
||||||
description: [
|
description: [
|
||||||
'Records a ban in the `room_ban` table — one row per (room, player), so re-banning',
|
'Records a ban in the `room_ban` table — one row per (room, player), so re-banning',
|
||||||
'someone already banned rewrites their row rather than adding a second. Nothing',
|
'someone already banned rewrites their row rather than adding a second. The row is',
|
||||||
'enforces the ban yet: matchmaking does not consult this table, so a banned player',
|
'what the `match` worker checks: a banned player’s matchmake into this room is',
|
||||||
'can still join. This is the record only.',
|
'refused with errorCode 55 and never gets a Photon room id.',
|
||||||
'',
|
'',
|
||||||
'Gated to the room’s creator or a co-owner, OR to any account whose token carries the',
|
'Gated to the room’s creator or a co-owner, OR to any account whose token carries the',
|
||||||
'`developer` / `moderator` role — a valid token from anyone else is a 403. Banning',
|
'`developer` / `moderator` role — a valid token from anyone else is a 403. Banning',
|
||||||
@@ -1415,8 +1503,16 @@ const app = new Hono<App>()
|
|||||||
'`banMask` is stored verbatim and nothing interprets it — the client sends `0` and',
|
'`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.',
|
'what it selects is not known yet. It defaults to 0 when absent.',
|
||||||
'',
|
'',
|
||||||
'The BANNED player (not the caller) gets a `ModerationRoomBan` push carrying the ban,',
|
'The BANNED player (not the caller) gets a `ModerationKick` push (id 22) — the frame',
|
||||||
'so their client can act on it; the hub queues it if they are offline.',
|
'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).',
|
||||||
|
'',
|
||||||
|
'`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.',
|
||||||
'',
|
'',
|
||||||
'Answers the same lowercase `{ success, error, value }` envelope the room writes use,',
|
'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',
|
'but `value` is the BAN, not the room — a ban is not part of the room the client',
|
||||||
@@ -1440,8 +1536,10 @@ const app = new Hono<App>()
|
|||||||
if (!room) return banEnvelope(c, null, 'This room does not exist!')
|
if (!room) return banEnvelope(c, null, 'This room does not exist!')
|
||||||
|
|
||||||
// The room's own owners, or a staffer acting across rooms. Roles are only
|
// The room's own owners, or a staffer acting across rooms. Roles are only
|
||||||
// looked up when the cheaper room check fails.
|
// looked up when the cheaper room check fails. The room's own owner IS the
|
||||||
if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)
|
// host, which is what the kick frame's `isHostKick` reports.
|
||||||
|
const isHostKick = canManageRoom(room, accountId)
|
||||||
|
if (!isHostKick && !(await isStaff(c))) return c.body(null, 403)
|
||||||
|
|
||||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||||
@@ -1458,8 +1556,9 @@ const app = new Hono<App>()
|
|||||||
const banMask = Number.parseInt(str(body.banMask), 10) || 0
|
const banMask = Number.parseInt(str(body.banMask), 10) || 0
|
||||||
|
|
||||||
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId)
|
const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId)
|
||||||
// The banned player is told, not the caller — their client acts on the ban.
|
// The banned player is told, not the caller — their client acts on the kick.
|
||||||
await pushRoomBan(c, ban)
|
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||||
|
await pushRoomBan(c, ban, roomName, isHostKick)
|
||||||
return banEnvelope(c, ban)
|
return banEnvelope(c, ban)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1006,29 +1006,83 @@ describe('rooms endpoints', () => {
|
|||||||
expect(await bansOf(2)).toHaveLength(2)
|
expect(await bansOf(2)).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('POST /rooms/:id/bans notifies the banned player', async () => {
|
it('POST /rooms/:id/bans kicks the banned player', async () => {
|
||||||
type Sent = { playerId: number; notificationType: string | number; data: { RoomId: number } }
|
type Sent = { playerId: number; notificationType: string | number; data: unknown }
|
||||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
const sentSince = async (): Promise<Sent[]> =>
|
||||||
|
(await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||||
|
|
||||||
|
// 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 hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)
|
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '207' }, '1')).status).toBe(200)
|
||||||
|
|
||||||
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
|
// A ModerationKick (id 22) to the BANNED player, not the caller — it ejects them
|
||||||
// Pushed to the BANNED player, not the caller. Asserted against the enum rather
|
// from the instance they're in now; the row keeps them out of future matchmakes.
|
||||||
// than a literal — the hub's ids are the notify worker's to change.
|
// Asserted against the enum rather than a literal: the ids are notify's to change.
|
||||||
expect(sent).toEqual([
|
expect(await sentSince()).toEqual([
|
||||||
{
|
{
|
||||||
playerId: 207,
|
playerId: 207,
|
||||||
notificationType: NotificationType.ModerationRoomBan,
|
notificationType: NotificationType.ModerationKick,
|
||||||
|
// The client's moderation payload, camelCase, in wire order.
|
||||||
data: {
|
data: {
|
||||||
RoomId: 2,
|
reportCategory: -1, // Moderator — a person acted, not the system
|
||||||
BannedPlayerId: 207,
|
duration: 0, // a room ban has no expiry
|
||||||
BanMask: 0,
|
gameSessionId: 0,
|
||||||
BannedByAccountId: 1,
|
// The host ejected them (as opposed to a room vote-kick, which doesn't
|
||||||
CreatedAt: expect.any(String),
|
// exist yet). Account 1 owns RecCenter, so it hosts it.
|
||||||
|
isHostKick: true,
|
||||||
|
message: `You have been banned from ${Name}.`,
|
||||||
|
playerIdReporter: 1,
|
||||||
|
isBan: true,
|
||||||
|
isVoiceModAutoban: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// A staff moderator doesn't host the room, so it isn't a host kick — and
|
||||||
|
// `playerIdReporter` is still whoever caused it.
|
||||||
|
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 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /rooms/:id/bans lists the room’s bans, under the same gate', async () => {
|
||||||
|
type Entry = { accountId: number; bannedByAccountId: number; banStartTime: string }
|
||||||
|
const list = async (path: string, sub?: string, roles?: string[]) =>
|
||||||
|
SELF.fetch(`${ORIGIN}${path}`, { headers: sub ? await bearer(sub, roles) : {} })
|
||||||
|
|
||||||
|
// Room 3 is owned by account 1 (it has no bans yet) — ban two players into it.
|
||||||
|
expect((await postForm('/rooms/3/bans', { id: '401' }, '1')).status).toBe(200)
|
||||||
|
expect((await postForm('/rooms/3/bans', { id: '402' }, '1')).status).toBe(200)
|
||||||
|
|
||||||
|
// No token → 401; a valid token with no room role and no staff role → 403.
|
||||||
|
expect((await list('/rooms/3/bans')).status).toBe(401)
|
||||||
|
expect((await list('/rooms/3/bans', '999')).status).toBe(403)
|
||||||
|
|
||||||
|
const res = await list('/rooms/3/bans', '1')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
// A bare array in the client's camelCase shape — no room id, no ban mask.
|
||||||
|
const bans = (await res.json()) as Entry[]
|
||||||
|
expect(bans.map((b) => b.accountId).sort((a, b) => a - b)).toEqual([401, 402])
|
||||||
|
expect(bans[0]).toEqual({
|
||||||
|
accountId: expect.any(Number),
|
||||||
|
bannedByAccountId: 1,
|
||||||
|
banStartTime: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
|
||||||
|
})
|
||||||
|
|
||||||
|
// A staffer can read a list for a room they have no role on.
|
||||||
|
expect((await list('/rooms/3/bans', '999', ['gameClient', 'moderator'])).status).toBe(200)
|
||||||
|
|
||||||
|
// An unknown room reads the same as a room with nobody banned — no probing which
|
||||||
|
// room ids exist.
|
||||||
|
expect(await (await list('/rooms/99999/bans', '1')).json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('DELETE /rooms/:id/bans/:playerId lifts a ban, under the same gate', async () => {
|
it('DELETE /rooms/:id/bans/:playerId lifts a ban, under the same gate', async () => {
|
||||||
@@ -2569,6 +2623,7 @@ describe('rooms endpoints', () => {
|
|||||||
'GET /rooms/visitedby/me',
|
'GET /rooms/visitedby/me',
|
||||||
'GET /rooms/visitedby/{playerId}',
|
'GET /rooms/visitedby/{playerId}',
|
||||||
'GET /rooms/{roomId}',
|
'GET /rooms/{roomId}',
|
||||||
|
'GET /rooms/{roomId}/bans',
|
||||||
'GET /rooms/{roomId}/interactionby/me',
|
'GET /rooms/{roomId}/interactionby/me',
|
||||||
'GET /rooms/{roomId}/playerdata/me',
|
'GET /rooms/{roomId}/playerdata/me',
|
||||||
'GET /rooms/{roomId}/similar',
|
'GET /rooms/{roomId}/similar',
|
||||||
|
|||||||
Reference in New Issue
Block a user