[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',
+85 -1
View File
@@ -135,6 +135,13 @@ interface OwnedRoom {
MaxPlayers: number
/** False blocks `POST /rooms/{id}/clone` — nobody can take a copy of the room. */
CloningAllowed: boolean
/**
* The staff listing flags: out of the browse feeds, and out of search. Set together
* through the website (`PUT /rooms/{id}/exclude`); absent on a room nobody has touched,
* which reads as listed.
*/
ExcludeFromLists?: boolean
ExcludeFromSearch?: boolean
SupportsScreens: boolean
SupportsWalkVR: boolean
SupportsTeleportVR: boolean
@@ -1003,6 +1010,23 @@ async function deleteRoom(roomId: number): Promise<void> {
if (!result.Success) throw new Error(result.Error || 'The room couldn’t be deleted.')
}
/**
* Hide a room from the feeds and search, or list it again — the staff toggle. Not a game
* endpoint (the real service set these flags from an admin tool), so it is recflare's own
* on `rooms`, staff-only: the owner is refused like anyone else. Both flags move together.
* Answers the lowercase `{ success, error, value }` envelope with the updated room.
*/
async function setRoomExcluded(roomId: number, excluded: boolean): Promise<OwnedRoom> {
const result = await call<{ success: boolean; error: string; value: OwnedRoom | null }>(
`${where().rooms}/rooms/${roomId}/exclude`,
{ method: 'PUT', authed: true, form: { excluded: String(excluded) } }
)
if (!result.success || result.value === null) {
throw new Error(result.error || 'The room’s listing couldn’t be changed.')
}
return result.value
}
/** The `/u/<username>` path, or null for any other path. */
function usernameFromPath(path: string): string | null {
const match = /^\/u\/([^/]+)$/.exec(path)
@@ -2600,6 +2624,7 @@ function RoomPage({
room={room}
navigate={navigate}
onTakenDown={() => setTakenDown(room.Name)}
onRoomChange={setRoom}
/>
) : (
<p className="muted">
@@ -2621,10 +2646,13 @@ function PublicRoomView({
room,
navigate,
onTakenDown,
onRoomChange,
}: {
room: OwnedRoom
navigate: Navigate
onTakenDown: () => void
/** A staff edit answers with the updated room; the page re-renders from it. */
onRoomChange: (room: OwnedRoom) => void
}) {
const [creator, setCreator] = useState<{ username: string; displayName: string } | null>(null)
const [photos, setPhotos] = useState<PublicPhoto[] | null>(null)
@@ -2699,10 +2727,11 @@ function PublicRoomView({
</section>
{/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's
role itself on the DELETE. */}
role itself on the DELETE and the listing PUT. */}
{isAdmin() && (
<>
{isDeveloper() && <StaffRoomTokens room={room} />}
<StaffListing room={room} onRoomChange={onRoomChange} />
<StaffTakedown room={room} onTakenDown={onTakenDown} />
</>
)}
@@ -2786,6 +2815,61 @@ function StaffRoomTokens({ room }: { room: OwnedRoom }) {
)
}
/**
* The staff listing toggle: one checkbox that sets `ExcludeFromLists` and `ExcludeFromSearch`
* together, so a room moderation pulls is gone from the feeds AND unfindable by name, not one
* without the other. The lighter hand next to the takedown — the room, its saves and its
* link all survive, it just stops being advertised. Writes on change; the room on screen is
* swapped for the one the worker answers with, so the box reflects what was stored.
*/
function StaffListing({
room,
onRoomChange,
}: {
room: OwnedRoom
onRoomChange: (room: OwnedRoom) => void
}) {
const { pending, error, done, run } = useAction()
const hidden = room.ExcludeFromLists === true && room.ExcludeFromSearch === true
return (
<section className="card">
<h2>Listing</h2>
<label className="check">
<input
type="checkbox"
checked={hidden}
disabled={pending}
onChange={(e) => {
const excluded = e.target.checked
void run(async () => {
const updated = await setRoomExcluded(room.RoomId, excluded)
// Only the flags are taken: the envelope's room is the stored record,
// without the live counters the public lookup folds in.
onRoomChange({
...room,
ExcludeFromLists: updated.ExcludeFromLists === true,
ExcludeFromSearch: updated.ExcludeFromSearch === true,
})
return excluded
? `^${room.Name} is hidden from the listings and search.`
: `^${room.Name} is listed again.`
})
}}
/>
Hide from listings and search
</label>
<p className="hint">
Keeps the room out of every browse feed, search and autocomplete. It stays reachable by link
and by invite, and its creator keeps everything. Sets both the lists and search exclusions
at once.
</p>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
</section>
)
}
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
const [confirming, setConfirming] = useState(false)
const { pending, error, run } = useAction()