remove unpublished rooms from catalogs

This commit is contained in:
Devin Zuczek
2026-08-07 13:38:24 -04:00
parent 8bd76a4bae
commit 9bb43f7b9c
4 changed files with 101 additions and 7 deletions
+10 -4
View File
@@ -771,7 +771,10 @@ const app = new Hono<App>()
// Rooms created/owned by the caller. Auth-gated — no token is a 401, never // Rooms created/owned by the caller. Auth-gated — no token is a 401, never
// account 1. `ownedby/me` drops the dorm (it's not a room the player made); // account 1. `ownedby/me` drops the dorm (it's not a room the player made);
// the `createdby` variants return everything the account created. // the `createdby` variants return everything the account created. None of them
// filter on Accessibility: these are the owner's own "My Rooms" lists, so a room
// they haven't published yet (a fresh clone is Private) has to show up here.
// Only the public `ownedby/:accountId` profile list is accessibility-filtered.
.get( .get(
'/roomserver/rooms/createdby/me', '/roomserver/rooms/createdby/me',
describeRoute({ describeRoute({
@@ -795,7 +798,9 @@ const app = new Hono<App>()
description: [ description: [
'The callers own rooms with the dorm filtered out: a dorm is auto-provisioned, not a', 'The callers own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
'room the player made, so it doesnt belong in the “rooms you own” list. Use', 'room the player made, so it doesnt belong in the “rooms you own” list. Use',
'`createdby/me` for everything the account created.', '`createdby/me` for everything the account created. Accessibility is deliberately NOT',
'filtered — this is the owners own list, so unpublished (Private) rooms appear, unlike',
'the public `ownedby/{accountId}` profile list.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
responses: { responses: {
@@ -1076,8 +1081,9 @@ const app = new Hono<App>()
'Copies a rooms content (scene, subrooms, settings) into a new room owned by the', 'Copies a rooms content (scene, subrooms, settings) into a new room owned by the',
'caller. Cloning is the only way to make a room, so the per-account room cap is', 'caller. Cloning is the only way to make a room, so the per-account room cap is',
'enforced here — it counts the rooms the account created, minus their auto-provisioned', 'enforced here — it counts the rooms the account created, minus their auto-provisioned',
'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags and', 'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags,',
'`IsRRO` cleared.', '`IsRRO` cleared, and PRIVATE accessibility — a new room is unpublished until its',
'owner sets its accessibility, so it never lands in the public feeds on creation.',
'', '',
'Rejections — a blank or taken name, the cap, a source that disallows cloning — are', 'Rejections — a blank or taken name, the cap, a source that disallows cloning — are',
'HTTP 200 with `success: false` and the message the client shows.', 'HTTP 200 with `success: false` and the message the client shows.',
@@ -181,6 +181,45 @@ describe('rooms endpoints', () => {
expect(other).toEqual([]) expect(other).toEqual([])
}) })
it('GET /rooms/ownedby|createdby/me lists the callers UNPUBLISHED rooms too', async () => {
// "My Rooms" is the owner's own list, not a catalog: it must show a room that
// isn't public yet, or a freshly created room (which starts Private — see
// cloneRoom) would be invisible to the person who just made it. Only the
// PUBLIC-facing `ownedby/:accountId` profile list filters on accessibility.
const headers = {
...(await bearer('804')),
'Content-Type': 'application/x-www-form-urlencoded',
}
await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
method: 'POST',
headers,
body: new URLSearchParams({ name: 'MyUnpublishedRoom' }).toString(),
})
const listOf = async (path: string) =>
(await (await SELF.fetch(`${ORIGIN}${path}`, { headers })).json()) as Array<{
Name: string
Accessibility: number
}>
for (const path of [
'/rooms/ownedby/me',
'/rooms/createdby/me',
'/roomserver/rooms/createdby/me',
]) {
const mine = await listOf(path)
const room = mine.find((r) => r.Name === 'MyUnpublishedRoom')
expect(room, `${path} must list the caller's unpublished room`).toBeDefined()
expect(room!.Accessibility).toBe(0)
}
// The same room is absent from the account's PUBLIC profile list.
const publicList = (await (
await SELF.fetch(`${ORIGIN}/rooms/ownedby/804`)
).json()) as Array<{ Name: string }>
expect(publicList.some((r) => r.Name === 'MyUnpublishedRoom')).toBe(false)
})
it('GET /rooms/ownedby/:id returns an account public rooms (no auth)', async () => { it('GET /rooms/ownedby/:id returns an account public rooms (no auth)', async () => {
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/1`) const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/1`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -630,6 +669,7 @@ describe('rooms endpoints', () => {
CreatorAccountId: number CreatorAccountId: number
Tags?: Array<{ Tag: string }> Tags?: Array<{ Tag: string }>
IsRRO: boolean IsRRO: boolean
Accessibility: number
Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }>
} | null } | null
} }
@@ -647,6 +687,8 @@ describe('rooms endpoints', () => {
expect(ok.value!.Tags).toEqual([]) expect(ok.value!.Tags).toEqual([])
// IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone. // IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone.
expect(ok.value!.IsRRO).toBe(false) expect(ok.value!.IsRRO).toBe(false)
// A new room is unpublished: Private (0), never the source's visibility.
expect(ok.value!.Accessibility).toBe(0)
// Ownership is reset to the cloner: sole owner (Role 255), and none of the // Ownership is reset to the cloner: sole owner (Role 255), and none of the
// source base room's roles (accounts 1/2) carry over. // source base room's roles (accounts 1/2) carry over.
expect(ok.value!.Roles).toEqual([ expect(ok.value!.Roles).toEqual([
@@ -665,6 +707,40 @@ describe('rooms endpoints', () => {
expect(dup.error).toMatch(/already exists/i) expect(dup.error).toMatch(/already exists/i)
}) })
it('POST /rooms/:id/clone of a PUBLIC source stays out of the public feeds', async () => {
// Park (RoomId 25) is the one seeded base room that is itself public
// (Accessibility 1). Cloning used to inherit that, so a room appeared in
// hot/search/recommendations the instant it was created — before its owner had
// published anything.
const res = await SELF.fetch(`${ORIGIN}/rooms/25/clone`, {
method: 'POST',
headers: {
...(await bearer('802')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ name: 'ParkCloneUnpublished' }).toString(),
})
const { value } = (await res.json()) as { value: { RoomId: number; Accessibility: number } }
expect(value.Accessibility).toBe(0)
const namesIn = async (path: string) => {
const body = (await (await SELF.fetch(`${ORIGIN}${path}`)).json()) as
| { Results: Array<{ Name: string }> }
| Array<{ Name: string }>
return (Array.isArray(body) ? body : body.Results).map((r) => r.Name)
}
expect(await namesIn('/rooms/hot?take=200')).not.toContain('ParkCloneUnpublished')
expect(await namesIn('/rooms/hot?tag=new&take=200')).not.toContain('ParkCloneUnpublished')
expect(await namesIn('/rooms/recommendations?take=200')).not.toContain('ParkCloneUnpublished')
expect(await namesIn('/rooms/search?query=parkcloneunpublished')).not.toContain(
'ParkCloneUnpublished'
)
// Publishing it (owner sets Accessibility to Public) puts it in the feed.
await putForm('/rooms/' + value.RoomId + '/accessibility', { accessibility: '1' }, '802')
expect(await namesIn('/rooms/hot?take=200')).toContain('ParkCloneUnpublished')
})
it('POST /rooms/:id/clone requires auth (401, no account-1 fallback)', async () => { it('POST /rooms/:id/clone requires auth (401, no account-1 fallback)', async () => {
// No Authorization header → hard 401, and nothing is created. // No Authorization header → hard 401, and nothing is created.
const res = await SELF.fetch(`${ORIGIN}/rooms/24/clone`, { const res = await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
+6
View File
@@ -68,6 +68,12 @@ export enum MessageType {
* A room's (or image's) visibility, matching the client's `RoomAccessibility`. The * A room's (or image's) visibility, matching the client's `RoomAccessibility`. The
* client declares the enum without explicit values, so these are its ordinals — and * client declares the enum without explicit values, so these are its ordinals — and
* it sends the NAME, not the number, on the subroom accessibility route. * it sends the NAME, not the number, on the subroom accessibility route.
*
* `Unlisted` is NOT a lesser `Public`: an unlisted room is open to anyone who has a
* link or an invite, it just doesn't surface in the catalogs (hot/search/
* recommendations/featured/similar, which all key on `Public`). `Private` is the
* unpublished state — a room its owner hasn't opened up at all, which is where a
* freshly cloned room starts.
*/ */
export enum Accessibility { export enum Accessibility {
Private = 0, Private = 0,
+9 -3
View File
@@ -248,9 +248,10 @@ export async function isPlayerBannedFromRoom(
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
* name, and the new owner. The clone starts with an empty tag set — the source's * name, and the new owner. The clone starts with an empty tag set — the source's
* tags (including the `base` template tag) do not carry over, so the owner tags the * tags (including the `base` template tag) do not carry over, so the owner tags the
* clone from scratch — and `IsRRO` is cleared so the client doesn't render a virtual * clone from scratch — `IsRRO` is cleared so the client doesn't render a virtual
* "RRO" tag on it. Returns the new room, or null when the source isn't in D1 or * "RRO" tag on it, and it starts PRIVATE rather than inheriting the source's
* disallows cloning. * visibility. Returns the new room, or null when the source isn't in D1 or disallows
* cloning.
*/ */
export async function cloneRoom( export async function cloneRoom(
db: D1Database, db: D1Database,
@@ -284,6 +285,11 @@ export async function cloneRoom(
// A user clone is not a Rec Room Original — clear the inherited flag, or the // A user clone is not a Rec Room Original — clear the inherited flag, or the
// client renders a virtual "RRO" tag on the clone. // client renders a virtual "RRO" tag on the clone.
IsRRO: false, IsRRO: false,
// A brand-new room is unpublished: the owner publishes it by setting the room's
// accessibility. Inheriting the source's would put the clone straight into the
// public feeds (hot/search/recommendations/similar all key on Accessibility === 1)
// the moment it was made — every clone of a PUBLIC source, template or player room.
Accessibility: Accessibility.Private,
Roles: roles, Roles: roles,
// A fresh room has no engagement of its own — don't inherit the source's counters // A fresh room has no engagement of its own — don't inherit the source's counters
// (the derived ones are recomputed per read, but the clone is returned as-is here). // (the derived ones are recomputed per read, but the clone is returned as-is here).