[rooms] unset cloning on copy

This commit is contained in:
Devin Zuczek
2026-09-22 10:25:15 -04:00
parent c46341b331
commit 2dc374ab70
3 changed files with 38 additions and 6 deletions
+1 -1
View File
@@ -1719,7 +1719,7 @@ const app = new Hono<App>()
'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,', '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', 'zeroed `Stats`, `IsRRO`/`IsDeveloperOwned`/`CloningAllowed` 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.', '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',
@@ -1309,11 +1309,20 @@ describe('rooms endpoints', () => {
CreatorAccountId: number CreatorAccountId: number
Tags?: Array<{ Tag: string }> Tags?: Array<{ Tag: string }>
IsRRO: boolean IsRRO: boolean
IsDeveloperOwned: boolean
CloningAllowed: boolean
Accessibility: number Accessibility: number
Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }>
Stats: Record<string, number>
} | null } | null
} }
// Give the source every flag and counter the clone must NOT inherit.
await env.DB.prepare(
`UPDATE room SET data = json_set(data, '$.IsDeveloperOwned', json('true'),
'$.CloningAllowed', json('true'), '$.Stats.VisitorCount', 42) WHERE room_id = 24`
).run()
// Clone MakerRoom (base, RoomId 24) → a fresh room owned by the caller (801). // Clone MakerRoom (base, RoomId 24) → a fresh room owned by the caller (801).
const ok = await post(24, 'MyMakerClone') const ok = await post(24, 'MyMakerClone')
expect(ok.success).toBe(true) expect(ok.success).toBe(true)
@@ -1327,6 +1336,16 @@ 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)
// Nor is it developer-owned, and the new owner opts it into cloning themselves.
expect(ok.value!.IsDeveloperOwned).toBe(false)
expect(ok.value!.CloningAllowed).toBe(false)
// No engagement carries over — not even the blob-stored VisitorCount.
expect(ok.value!.Stats).toEqual({
CheerCount: 0,
FavoriteCount: 0,
VisitorCount: 0,
VisitCount: 0,
})
// A new room is unpublished: Private (0), never the source's visibility. // A new room is unpublished: Private (0), never the source's visibility.
expect(ok.value!.Accessibility).toBe(0) 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
@@ -1338,8 +1357,16 @@ describe('rooms endpoints', () => {
// It persists and is fetchable by its new id. // It persists and is fetchable by its new id.
const fetched = (await (await SELF.fetch(`${ORIGIN}/rooms/${ok.value!.RoomId}`)).json()) as { const fetched = (await (await SELF.fetch(`${ORIGIN}/rooms/${ok.value!.RoomId}`)).json()) as {
Name: string Name: string
Tags: unknown[]
IsDeveloperOwned: boolean
CloningAllowed: boolean
Stats: Record<string, number>
} }
expect(fetched.Name).toBe('MyMakerClone') expect(fetched.Name).toBe('MyMakerClone')
expect(fetched.Tags).toEqual([])
expect(fetched.IsDeveloperOwned).toBe(false)
expect(fetched.CloningAllowed).toBe(false)
expect(fetched.Stats.VisitorCount).toBe(0)
// Duplicate name is rejected. // Duplicate name is rejected.
const dup = await post(24, 'MyMakerClone') const dup = await post(24, 'MyMakerClone')
+10 -5
View File
@@ -661,8 +661,8 @@ export async function deleteRoomLeaderboard(
* 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 — `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, and it starts PRIVATE rather than inheriting the source's * "RRO" tag on it, `IsDeveloperOwned`/`CloningAllowed` are cleared, its `Stats` start
* visibility. Returns the new room, or null when the source isn't in D1 or disallows * at zero, 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. * cloning.
*/ */
export async function cloneRoom( export async function cloneRoom(
@@ -702,15 +702,20 @@ 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,
// Neither is the cloner a developer, and a copy doesn't inherit the source's consent
// to be copied: the new owner opts their room into cloning themselves.
IsDeveloperOwned: false,
CloningAllowed: false,
// A brand-new room is unpublished: the owner publishes it by setting the room's // 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 // accessibility. Inheriting the source's would put the clone straight into the
// public feeds (hot/search/recommendations/similar all key on Accessibility === 1) // 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. // the moment it was made — every clone of a PUBLIC source, template or player room.
Accessibility: Accessibility.Private, 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 any of the source's
// (the derived ones are recomputed per read, but the clone is returned as-is here). // counters. `storedStats` alone would keep the blob-owned `VisitorCount`, which
Stats: storedStats(source.Stats), // `hotScore` ranks on.
Stats: { ...ZERO_STATS },
CreatedAt: new Date().toISOString(), CreatedAt: new Date().toISOString(),
} }