diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 87ac682..01b1600 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -771,7 +771,10 @@ const app = new Hono() // 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); - // 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( '/roomserver/rooms/createdby/me', describeRoute({ @@ -795,7 +798,9 @@ const app = new Hono() description: [ 'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a', 'room the player made, so it doesn’t 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 owner’s own list, so unpublished (Private) rooms appear, unlike', + 'the public `ownedby/{accountId}` profile list.', ].join(' '), security: AUTHED, responses: { @@ -1076,8 +1081,9 @@ const app = new Hono() 'Copies a room’s 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', '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', - '`IsRRO` cleared.', + 'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags,', + '`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', 'HTTP 200 with `success: false` and the message the client shows.', diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 9c39e83..f62120b 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -181,6 +181,45 @@ describe('rooms endpoints', () => { expect(other).toEqual([]) }) + it('GET /rooms/ownedby|createdby/me lists the caller’s 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 () => { const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/1`) expect(res.status).toBe(200) @@ -630,6 +669,7 @@ describe('rooms endpoints', () => { CreatorAccountId: number Tags?: Array<{ Tag: string }> IsRRO: boolean + Accessibility: number Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> } | null } @@ -647,6 +687,8 @@ describe('rooms endpoints', () => { expect(ok.value!.Tags).toEqual([]) // IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone. 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 // source base room's roles (accounts 1/2) carry over. expect(ok.value!.Roles).toEqual([ @@ -665,6 +707,40 @@ describe('rooms endpoints', () => { 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 () => { // No Authorization header → hard 401, and nothing is created. const res = await SELF.fetch(`${ORIGIN}/rooms/24/clone`, { diff --git a/packages/domain/src/enums.ts b/packages/domain/src/enums.ts index b309d5d..b7c96b2 100644 --- a/packages/domain/src/enums.ts +++ b/packages/domain/src/enums.ts @@ -68,6 +68,12 @@ export enum MessageType { * 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 * 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 { Private = 0, diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 1fb962c..b593d18 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -248,9 +248,10 @@ export async function isPlayerBannedFromRoom( * 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 * 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 - * "RRO" tag on it. Returns the new room, or null when the source isn't in D1 or - * disallows cloning. + * clone from scratch — `IsRRO` is cleared so the client doesn't render a virtual + * "RRO" tag on it, and it starts PRIVATE rather than inheriting the source's + * visibility. Returns the new room, or null when the source isn't in D1 or disallows + * cloning. */ export async function cloneRoom( 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 // client renders a virtual "RRO" tag on the clone. 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, // 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).