[rooms] admin switch to exclude rooms from lists

This commit is contained in:
Devin Zuczek
2026-10-10 14:55:45 -04:00
parent c049c8f14d
commit 9a3ab3e50c
4 changed files with 271 additions and 1 deletions
+14
View File
@@ -812,6 +812,20 @@ export const AccessibilityRequest = z.object({
accessibility: z.string().describe('0 = Private, 1 = Public, 2 = Unlisted'),
})
/**
* `PUT /rooms/{roomId}/exclude` — the staff listing toggle. Not a game endpoint: the real
* service set `ExcludeFromLists` / `ExcludeFromSearch` from an admin tool the client never
* had a screen for, so this is recflare's own, called from the website's room page.
*/
export const ExcludeRequest = z.object({
excluded: z
.string()
.describe(
'`true` hides the room from every browse feed AND from search; `false` lists it again. ' +
'Both flags are always set together.'
),
})
/**
* `PUT /rooms/{roomId}/subrooms/{subRoomId}/accessibility`. Unlike the room-level route
* above, the client sends the enum NAME here (`accessibility=Private`), so both the name
+96
View File
@@ -113,6 +113,7 @@ import {
CuratedPlaylists,
DescriptionRequest,
DormRoomId,
ExcludeRequest,
ExperienceIncrementRequest,
ExperienceRequest,
FeaturedRoomGroupDto,
@@ -3579,6 +3580,91 @@ const app = new Hono<App>()
}
)
// Hide a room from the feeds and from search, or list it again — the staff listing
// toggle on the website's room page. NOT a game endpoint: the real service set
// `ExcludeFromLists` / `ExcludeFromSearch` from an admin tool, and the client has no
// screen for either, so this is recflare's own and is staff-only — the owner has
// `Accessibility` for hiding their own room. The two flags are independent in the reads
// (see LISTABLE_WHERE / SEARCHABLE_WHERE) but are set TOGETHER here: a room moderation
// pulls from the listings is meant to be unfindable, not merely un-browsable.
.put(
'/rooms/:roomId{[0-9]+}/exclude',
describeRoute({
tags: ['Custom', 'Room settings'],
summary: 'Hide a room from the listings and search (staff)',
description: [
'Sets `ExcludeFromLists` AND `ExcludeFromSearch` together to the posted `excluded`.',
'Staff only — a token carrying the `developer` / `moderator` role; the room’s owner',
'is refused (403) like anyone else, since this is a moderation action rather than a',
'room setting. The room stays reachable by link and by matchmake; it simply stops',
'appearing in the browse feeds, search and autocomplete. Not an endpoint the game',
'client calls — the website’s room page uses it. Written to the audit log.',
].join(' '),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(ExcludeRequest, 'Whether the room is hidden'),
responses: {
200: json(RoomEnvelope, 'The updated room, 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)
if (!(await isStaff(c))) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const excluded =
body.excluded === 'true' || body.excluded === '1'
? true
: body.excluded === 'false' || body.excluded === '0'
? false
: null
if (excluded === null) {
return roomEnvelope(c, null, 'You must provide a valid excluded!')
}
const roomId = Number.parseInt(c.req.param('roomId'), 10)
const room = await getRoomById(c.env.DB, roomId)
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
const updated = await updateRoomFields(c.env.DB, roomId, room, {
ExcludeFromLists: excluded,
ExcludeFromSearch: excluded,
})
// Always a staff action on somebody's room, so always audited — the owner can't
// get here. Written after the fact and caught, like the takedown's row: a failed
// insert must not report a change that has already happened as refused.
try {
await writeAuditLog(c.env.DB, {
playerId: accountId,
action: 'room_exclude',
data: {
roomId,
name: room.Name,
creatorAccountId: room.CreatorAccountId,
excluded,
},
})
} catch (err) {
logger.error('could not write an audit log row', {
action: 'room_exclude',
playerId: accountId,
roomId,
error: err instanceof Error ? err.message : String(err),
})
}
// The owner's client, if it's open, re-renders the room from this.
if (typeof room.CreatorAccountId === 'number') {
await pushRoomUpdate(c, room.CreatorAccountId, updated)
}
return roomEnvelope(c, updated)
}
)
// A subroom's saved-data versions — the room-history / "restore a save" list. Every
// save is its own `subroom_save` row (nothing is overwritten), so this is real
// history, newest first, paged by skip/take. Auth-gated (401), and readable by the
@@ -4934,6 +5020,16 @@ app.get(
].join('\n'),
},
servers: [{ url: 'https://rooms.recflare.net', description: 'Production' }],
tags: [
{
name: 'Custom',
description: [
'Endpoints the game client never calls. Everything else here reimplements a',
'call the Rec Room client makes; these are recflare’s own additions, used by the',
'website (`www`) for things the real service did from an admin tool.',
].join(' '),
},
],
components: {
securitySchemes: {
bearerAuth: {
@@ -1798,6 +1798,81 @@ describe('rooms endpoints', () => {
})
})
it('PUT /rooms/:id/exclude is the staff listing toggle — both flags, together, staff only', async () => {
// A public room its owner lists. Not a game endpoint: the real service set these two
// flags from an admin tool, so the website's room page calls this with a staff token.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: 9502,
Name: 'Delisted',
CreatorAccountId: 1,
IsDorm: false,
Accessibility: 1,
ImageName: '',
SubRooms: [],
})
)
.run()
const put = async (excluded: string, headers: Record<string, string>) =>
SELF.fetch(`${ORIGIN}/rooms/9502/exclude`, {
method: 'PUT',
headers: { ...headers, 'content-type': 'application/x-www-form-urlencoded' },
body: `excluded=${excluded}`,
})
const flags = async () => {
const row = await env.DB.prepare('SELECT data FROM room WHERE room_id = 9502').first<{
data: string
}>()
const room = JSON.parse(row!.data) as Record<string, unknown>
return { lists: room.ExcludeFromLists, search: room.ExcludeFromSearch }
}
// No token is a 401; the OWNER is refused like any other player — this is moderation,
// not a room setting they have.
expect((await put('true', {})).status).toBe(401)
expect((await put('true', await bearer('1', ['gameClient']))).status).toBe(403)
expect(await flags()).toEqual({ lists: undefined, search: undefined })
const staff = await bearer('999', ['gameClient', 'moderator'])
const hidden = await put('true', staff)
expect(hidden.status).toBe(200)
expect(await hidden.json()).toMatchObject({
success: true,
error: '',
value: { RoomId: 9502, ExcludeFromLists: true, ExcludeFromSearch: true },
})
// Proper JSON booleans, not 1/0 — the virtual columns and the feeds read them as such.
expect(await flags()).toEqual({ lists: true, search: true })
// Hidden means hidden from search too, not just the feeds.
const search = await SELF.fetch(`${ORIGIN}/rooms/search?query=Delisted`)
const found = ((await search.json()) as { Results: Array<{ RoomId: number }> }).Results
expect(found.find((r) => r.RoomId === 9502)).toBeUndefined()
// And it flips back, both at once.
expect(await (await put('false', staff)).json()).toMatchObject({
success: true,
value: { ExcludeFromLists: false, ExcludeFromSearch: false },
})
expect(await flags()).toEqual({ lists: false, search: false })
// Anything but true/false is a rejection in the envelope, and writes nothing.
expect(await (await put('maybe', staff)).json()).toMatchObject({ success: false })
expect(await flags()).toEqual({ lists: false, search: false })
// Each staff write is audited against the moderator, with what it did.
const { results: audit } = await env.DB.prepare(
'SELECT player_id, data FROM audit_log WHERE action = ?1 ORDER BY audit_log_id'
)
.bind('room_exclude')
.all<{ player_id: number; data: string }>()
expect(audit.map((row) => ({ by: row.player_id, ...JSON.parse(row.data) }))).toEqual([
{ by: 999, roomId: 9502, name: 'Delisted', creatorAccountId: 1, excluded: true },
{ by: 999, roomId: 9502, name: 'Delisted', creatorAccountId: 1, excluded: false },
])
})
it('PUT /rooms/:id/roles/:accountId grants a helper role outright, but never co-owner', async () => {
type Role = {
AccountId: number
@@ -5999,6 +6074,7 @@ describe('rooms endpoints', () => {
'PUT /rooms/{roomId}/cloning',
'PUT /rooms/{roomId}/creator',
'PUT /rooms/{roomId}/description',
'PUT /rooms/{roomId}/exclude',
'PUT /rooms/{roomId}/image',
'PUT /rooms/{roomId}/interactionby/me/cheer',
'PUT /rooms/{roomId}/interactionby/me/favorite',