[rooms] banned endpoint, event index

This commit is contained in:
Devin Zuczek
2026-08-24 18:16:27 -04:00
parent 6e8b91d503
commit f1e960f950
4 changed files with 183 additions and 10 deletions
+20 -1
View File
@@ -401,6 +401,23 @@ export const IsBannedEnvelope = z.object({
value: z.boolean().describe('Whether that player is banned from that room'), value: z.boolean().describe('Whether that player is banned from that room'),
}) })
/**
* `GET /rooms/{roomId}/bans/{playerId}/isBanned` — the same check, in the shape the client
* reads on the UNPREFIXED path.
*
* PascalCase, and deliberately not unified with {@link IsBannedEnvelope}: the two paths are
* two calls the client makes with two different decoders, and its decoder drops members it
* does not recognise silently, so a `value` served where it wants `Value` reads as `false` —
* a banned player looking unbanned — rather than as an error. `error_id` stays lowercase
* even here; that is how it comes off the wire, not a slip.
*/
export const IsBannedPascalEnvelope = z.object({
Value: z.boolean().describe('Whether that player is banned from that room'),
Success: z.literal(true).describe('The check ran; whether the player is banned is `Value`'),
Error: z.string().nullable().describe('Null — the check itself does not fail'),
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. */ /** The bare JSON string the bulk lookups answer when the id list is over the cap. */
export const TooManyLookupIds = z export const TooManyLookupIds = z
.string() .string()
@@ -437,7 +454,9 @@ export const FeaturedRoomGroupDto = z.object({
name: z.string(), name: z.string(),
StartAt: z.string(), StartAt: z.string(),
EndAt: z.string(), EndAt: z.string(),
Rooms: z.array(FeaturedRoomDto).describe('Randomly ordered — no editorial curation yet'), Rooms: z
.array(FeaturedRoomDto)
.describe('Randomly ordered, at most 10 — no editorial curation yet'),
}) })
// ---- Envelopes ------------------------------------------------------------- // ---- Envelopes -------------------------------------------------------------
+65 -9
View File
@@ -85,6 +85,7 @@ import {
InteractionDto, InteractionDto,
intQuery, intQuery,
IsBannedEnvelope, IsBannedEnvelope,
IsBannedPascalEnvelope,
json, json,
jsonBody, jsonBody,
LoadScreenRequest, LoadScreenRequest,
@@ -361,6 +362,21 @@ async function isStaff(c: Context<App>): Promise<boolean> {
return roles?.some((role) => STAFF_ROLES.has(role)) ?? false return roles?.some((role) => STAFF_ROLES.has(role)) ?? false
} }
/**
* Whether the `:playerId` in the path is banned from the `:roomId` in it — the read behind
* both `isBanned` routes, which differ only in the envelope they wrap the answer in.
*
* Reads the same `room_ban` rows the ban writes make and `match` refuses matchmakes on, so
* the answer is what would actually happen. A room that does not exist simply has no ban
* rows and comes back false: the question is about the ban, not about the room.
*/
async function pathBan(c: Context<App>): Promise<boolean> {
// Both routes constrain these to `[0-9]+`, so neither is ever missing — the fallback is
// only here because a helper is typed against the whole app rather than one route.
const id = (name: string) => Number.parseInt(c.req.param(name) ?? '', 10)
return isPlayerBannedFromRoom(c.env.DB, id('roomId'), id('playerId'))
}
/** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */ /** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */
function unauthorized(c: Context<App>) { function unauthorized(c: Context<App>) {
return c.json({ error: 'Unauthorized' }, 401) return c.json({ error: 'Unauthorized' }, 401)
@@ -899,8 +915,8 @@ const app = new Hono<App>()
) )
// Featured rooms — a single always-active group whose `Rooms` are a randomly // Featured rooms — a single always-active group whose `Rooms` are a randomly
// ordered set of public, non-dorm rooms. No real curation yet, so `current` // ordered set of public, non-dorm rooms, at most ten of them. No real curation yet, so
// just returns a shuffled list of eligible rooms in the featured-group shape. // `current` just returns a shuffled sample of eligible rooms in the featured-group shape.
// //
// Gated on the caller's BUILD, not just their token: this payload breaks the 2023 // Gated on the caller's BUILD, not just their token: this payload breaks the 2023
// client — its other room listings start failing with NREs, apparently because the // client — its other room listings start failing with NREs, apparently because the
@@ -917,7 +933,9 @@ const app = new Hono<App>()
summary: 'Featured rooms', summary: 'Featured rooms',
description: [ description: [
'A single always-active group of featured rooms: a random shuffle of eligible public', 'A single always-active group of featured rooms: a random shuffle of eligible public',
'rooms, since there is no editorial curation yet.', 'rooms, since there is no editorial curation yet. Capped at 10 rooms — a featured',
'group is a short selection, not the whole room list — and the cap is applied after',
'the shuffle, so each request serves a different sample.',
'', '',
'Restricted by CLIENT BUILD. Serving this to the 2023 client breaks its other room', 'Restricted by CLIENT BUILD. Serving this to the 2023 client breaks its other room',
'listings (NREs, apparently from the featured-room load corrupting its room cache), so', 'listings (NREs, apparently from the featured-room load corrupting its room cache), so',
@@ -2008,12 +2026,50 @@ const app = new Hono<App>()
const accountId = await authedAccountId(c) const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c) if (accountId === null) return unauthorized(c)
const banned = await isPlayerBannedFromRoom( return c.json({ success: true, error: null, error_id: null, value: await pathBan(c) })
c.env.DB, }
Number.parseInt(c.req.param('roomId'), 10), )
Number.parseInt(c.req.param('playerId'), 10)
) // The SAME check on the unprefixed path (`GET /rooms/112/bans/1/isBanned`), which is the
return c.json({ success: true, error: null, error_id: null, value: banned }) // spelling the client uses on the rooms host itself. Registered as its own route rather
// than as an alias because the answer is not the same bytes: this one is PascalCase
// (`Value`/`Success`/`Error`, `error_id` still lowercase), and the client's decoder drops
// members it doesn't know silently — a lowercase `value` here would read as `false` and
// show a banned player as unbanned.
//
// Same gate as its sibling: auth-gated, not owner-gated. A ban is not a secret from the
// player it stops, and the client asks this before offering a room action so it can grey
// it out rather than let the attempt fail.
.get(
'/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}/isBanned',
describeRoute({
tags: ['Room settings'],
summary: 'Whether a player is banned from a room (unprefixed path)',
description: [
'The same `room_ban` check as the `/Room_server/` route, on the path the client uses',
'against the rooms host directly — and in the PascalCase envelope it reads there:',
'`{ Value, Success, Error, error_id }`, with `error_id` lowercase.',
'',
'The two envelopes are NOT unified. The clients decoder drops members it does not',
'recognise, so serving the other routes lowercase `value` here would decode as',
'`false` — a banned player shown as unbanned — rather than fail.',
'',
'Auth-gated, but any authenticated caller may ask: a ban is not a secret from the',
'player it stops. A room that does not exist has no bans, so it answers',
'`Value: false` rather than 404ing — the check is about the ban row, not the room.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam, bannedPlayerIdParam],
responses: {
200: json(IsBannedPascalEnvelope, 'Whether that player is banned from that room'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c)
return c.json({ Value: await pathBan(c), Success: true, Error: null, error_id: null })
} }
) )
@@ -1125,6 +1125,38 @@ describe('rooms endpoints', () => {
expect(body.Rooms.some((r) => r.RoomId === 1)).toBe(false) expect(body.Rooms.some((r) => r.RoomId === 1)).toBe(false)
}) })
it('GET /featuredrooms/current serves at most 10 rooms', async () => {
// Seed more eligible rooms than the cap, so the length is decided by the cap rather
// than by how many rooms the suite happens to have.
const ids = Array.from({ length: 14 }, (_, i) => 30900 + i)
for (const RoomId of ids) {
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId,
Name: `Featurable${RoomId}`,
CreatorAccountId: 831,
Accessibility: 1,
IsDorm: false,
SubRooms: [],
})
)
.run()
}
const res = await SELF.fetch(`${ORIGIN}/featuredrooms/current`, {
headers: await bearer('1', undefined, '20250718.01'),
})
const body = (await res.json()) as { Rooms: Array<{ RoomId: number }> }
// A featured group is a short selection, not the whole room list.
expect(body.Rooms).toHaveLength(10)
// The cap TRIMS the shuffled list rather than sampling with replacement, so no room
// can appear twice in one group.
expect(new Set(body.Rooms.map((r) => r.RoomId)).size).toBe(10)
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids.join(', ')})`).run()
})
it('GET /featuredrooms/current withholds the group from other client builds', async () => { it('GET /featuredrooms/current withholds the group from other client builds', async () => {
// The 2023 build gets the 404 it got while the route was parked — the state in which // The 2023 build gets the 404 it got while the route was parked — the state in which
// its room listings work. Same for a token with no `rn.ver` at all. // its room listings work. Same for a token with no `rn.ver` at all.
@@ -1667,6 +1699,62 @@ describe('rooms endpoints', () => {
.run() .run()
}) })
it('GET /rooms/:id/bans/:playerId/isBanned answers the same check, PascalCase', async () => {
const isBanned = async (roomId: number, playerId: number) =>
SELF.fetch(`${ORIGIN}/rooms/${roomId}/bans/${playerId}/isBanned`, {
headers: await bearer('300'),
})
// Same gate as the `/Room_server/` spelling: a token is needed, any token will do.
expect((await SELF.fetch(`${ORIGIN}/rooms/112/bans/1/isBanned`)).status).toBe(401)
const clean = await isBanned(112, 1)
expect(clean.status).toBe(200)
// PascalCase — `Value`/`Success`/`Error`, and `error_id` lowercase all the same. The
// client's decoder drops members it doesn't recognise, so the other route's lowercase
// keys here would decode as `false` rather than fail. Room 112 does not exist and the
// answer is still a clean `false`: the question is about the ban row, not the room.
expect(await clean.json()).toEqual({
Value: false,
Success: true,
Error: null,
error_id: null,
})
// The same `room_ban` rows the other route and `match` read.
await env.DB.prepare(
'INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)' +
" VALUES (?1, ?2, 0, 1, '2026-01-01T00:00:00Z')"
)
.bind(112, 1)
.run()
expect(await (await isBanned(112, 1)).json()).toEqual({
Value: true,
Success: true,
Error: null,
error_id: null,
})
// Per (room, player), like every other read of these rows.
expect(await (await isBanned(113, 1)).json()).toMatchObject({ Value: false })
expect(await (await isBanned(112, 2)).json()).toMatchObject({ Value: false })
// …and the prefixed route sees the same ban, in its own lowercase envelope.
const prefixed = await SELF.fetch(`${ORIGIN}/Room_server/rooms/112/bans/1/isBanned`, {
headers: await bearer('300'),
})
expect(await prefixed.json()).toEqual({
success: true,
error: null,
error_id: null,
value: true,
})
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2')
.bind(112, 1)
.run()
})
it('POST /rooms/:id/bans kicks the banned player', async () => { it('POST /rooms/:id/bans kicks the banned player', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown } 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')
@@ -3609,6 +3697,7 @@ describe('rooms endpoints', () => {
'GET /rooms/visitedby/{playerId}', 'GET /rooms/visitedby/{playerId}',
'GET /rooms/{roomId}', 'GET /rooms/{roomId}',
'GET /rooms/{roomId}/bans', 'GET /rooms/{roomId}/bans',
'GET /rooms/{roomId}/bans/{playerId}/isBanned',
'GET /rooms/{roomId}/experience', 'GET /rooms/{roomId}/experience',
'GET /rooms/{roomId}/experience/player', 'GET /rooms/{roomId}/experience/player',
'GET /rooms/{roomId}/interactionby/me', 'GET /rooms/{roomId}/interactionby/me',
+9
View File
@@ -2470,11 +2470,19 @@ export interface FeaturedRoomGroup {
Rooms: FeaturedRoom[] Rooms: FeaturedRoom[]
} }
/** How many rooms one featured group carries. See {@link getFeaturedRooms}. */
export const FEATURED_ROOM_LIMIT = 10
/** /**
* Featured rooms group: public, non-dorm rooms not excluded from lists, in random * Featured rooms group: public, non-dorm rooms not excluded from lists, in random
* order. There's no editorial curation behind this yet, so "featured" is just a * order. There's no editorial curation behind this yet, so "featured" is just a
* random shuffle of the eligible rooms wrapped in a single always-active group. * random shuffle of the eligible rooms wrapped in a single always-active group.
* Eligibility is filtered in SQL ({@link LISTABLE_WHERE}); the rest is in memory. * Eligibility is filtered in SQL ({@link LISTABLE_WHERE}); the rest is in memory.
*
* At most {@link FEATURED_ROOM_LIMIT} rooms — a featured group is a short editorial
* selection, not the whole room list. The cap is applied AFTER the shuffle, so it is a
* random SAMPLE that varies between requests; a `LIMIT` in the SQL would instead pin the
* same handful of rooms forever and make the shuffle cosmetic.
*/ */
export async function getFeaturedRooms(db: D1Database): Promise<FeaturedRoomGroup> { export async function getFeaturedRooms(db: D1Database): Promise<FeaturedRoomGroup> {
const { results } = await db const { results } = await db
@@ -2486,6 +2494,7 @@ export async function getFeaturedRooms(db: D1Database): Promise<FeaturedRoomGrou
const j = Math.floor(Math.random() * (i + 1)) const j = Math.floor(Math.random() * (i + 1))
;[rooms[i], rooms[j]] = [rooms[j], rooms[i]] ;[rooms[i], rooms[j]] = [rooms[j], rooms[i]]
} }
rooms.length = Math.min(rooms.length, FEATURED_ROOM_LIMIT)
const str = (v: unknown): string => (typeof v === 'string' ? v : '') const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const num = (v: unknown): number => (typeof v === 'number' ? v : 0) const num = (v: unknown): number => (typeof v === 'number' ? v : 0)