From 295ec7bc161e22f38f7c218672fa730910c4899a Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 14 Sep 2026 13:36:41 -0400 Subject: [PATCH] [rooms] switch owner endpoint --- apps/rooms/src/openapi.ts | 5 + apps/rooms/src/rooms.app.ts | 71 +++++++++++ apps/rooms/src/test/integration/api.test.ts | 128 ++++++++++++++++++++ packages/domain/src/rooms-db.ts | 89 ++++++++++++++ 4 files changed, 293 insertions(+) diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index eeb29d9..b47547a 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -591,6 +591,11 @@ export const RoleRequest = z.object({ ), }) +/** `PUT /rooms/{roomId}/creator` — the account taking the room over. */ +export const CreatorRequest = z.object({ + accountId: z.string().describe('The player who becomes the room’s owner'), +}) + /** `PUT /rooms/{roomId}/roles/{accountId}/invite`. */ export const InviteRoleRequest = z.object({ role: z.string().describe('The role tier offered: 10 Host, 20 Moderator, 30 CoOwner'), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index d363658..180fd5f 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -19,6 +19,7 @@ import { deleteRoomLeaderboard, deleteSubRoom, findSubRoom, + getAccount, getBaseRooms, getContributedRooms, getFavoritedRooms, @@ -62,6 +63,7 @@ import { setSubRoomPermissions, toggleCheer, toggleFavorite, + transferRoomOwnership, unbanPlayerFromRoom, updateRoomFields, } from '@repo/domain' @@ -87,6 +89,7 @@ import { CloneRoomRequest, CloningRequest, CreateSubRoomRequest, + CreatorRequest, CuratedPlaylists, DescriptionRequest, DormRoomId, @@ -2178,6 +2181,74 @@ const app = new Hono() } ) + // Hand the room to a new owner (form body `accountId`). Auth-gated (401) and gated to + // the room's OWNER alone (403) — giving a room away is the one thing only its owner may + // do. The recipient becomes Creator and the outgoing owner is left CoOwner, and + // `CreatorAccountId` moves with them. + .put( + '/rooms/:roomId{[0-9]+}/creator', + describeRoute({ + tags: ['Room settings'], + summary: 'Transfer a room to a new owner', + description: [ + 'Makes `accountId` the room’s owner: their `Roles` entry becomes `Role` 255 (Creator)', + 'and the outgoing owner’s becomes 30 (CoOwner), so whoever built the room keeps their', + 'access to it without keeping the room. `CreatorAccountId` moves too — it is the', + 'room’s real owner field, which every gate short-circuits on and the "rooms I made"', + 'lists select on, so leaving it behind would give the room two owners.', + '', + 'Gated to the room’s OWNER — its creator, or the holder of the Creator role. A valid', + 'token from anyone else, a co-owner included, is a 403. This is the one place', + '`Role` 255 is handed over without the recipient accepting it: unlike a co-owner', + 'invite, it is not a grant made about someone by a third party but the only person', + 'who could already do anything to this room giving it away.', + '', + 'A DORM cannot be transferred. A dorm is found by its owner, so handing one over', + 'would give away somebody’s personal room and mint them a fresh empty one on next', + 'access, their build gone.', + '', + 'Everyone standing in the room gets the `RoomUpdate` push, and so do both parties', + 'wherever they are — the room they are looking at just changed hands.', + ].join('\n'), + security: AUTHED, + parameters: [roomIdParam], + requestBody: form(CreatorRequest, 'The account taking the room over'), + 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) + + 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!') + if (!isRoomOwner(room, accountId)) return c.body(null, 403) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const toAccountId = + typeof body.accountId === 'string' ? Number.parseInt(body.accountId, 10) : Number.NaN + if (Number.isNaN(toAccountId)) return roomEnvelope(c, null, 'You must provide an account!') + if (toAccountId === accountId) return roomEnvelope(c, null, 'You already own this room!') + // A room handed to an account that doesn't exist is orphaned for good — nobody is + // left who can transfer it back. + if (!(await getAccount(c.env.DB, toAccountId))) { + return roomEnvelope(c, null, 'That player does not exist!') + } + + const updated = await transferRoomOwnership(c.env.DB, roomId, accountId, toAccountId, room) + if (!updated) return roomEnvelope(c, null, 'A dorm cannot be given away!') + + // Both parties hear about it wherever they are — the outgoing owner's client has to + // stop offering them the owner's tools, and the new owner's to start. + await pushRoomUpdateToRoom(c, roomId, updated, [accountId, toAccountId]) + return roomEnvelope(c, updated) + } + ) + // Invite a player to a room role (`Roles[].InvitedRole`). Auth-gated (401) and gated to // the room's OWNER alone (403 otherwise) — narrower than the role WRITE above, which a // co-owner may also use. Body is the `role` form field. Records the pending offer on the diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index c031e8c..269438f 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1905,6 +1905,133 @@ describe('rooms endpoints', () => { expect(await (await SELF.fetch(`${ORIGIN}/rooms/30602/roles`)).json()).toEqual([]) }) + it('PUT /rooms/:id/creator hands the room over, and only the owner may', async () => { + type Role = { AccountId: number; Role: number; LastChangedByAccountId: number | null } + const roomOf = async (roomId: number) => + (await (await SELF.fetch(`${ORIGIN}/rooms/${roomId}`)).json()) as { + CreatorAccountId: number + Roles: Role[] + } + + // A room of account 1's to give away, and two accounts to try it with. + await seedRoomWithSubRooms(env.DB, { + RoomId: 30701, + Name: 'HandMeDown', + CreatorAccountId: 1, + Accessibility: 1, + SubRooms: [], + Roles: [{ AccountId: 1, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }], + }) + for (const accountId of [880, 881]) { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId, username: `Taker${accountId}` })) + .run() + } + + // No token → 401; a valid token that doesn't own the room → 403. + expect((await putForm('/rooms/30701/creator', { accountId: '880' })).status).toBe(401) + expect((await putForm('/rooms/30701/creator', { accountId: '880' }, '999')).status).toBe(403) + // Unknown room → failure envelope. + expect( + await envOf(await putForm('/rooms/99999/creator', { accountId: '880' }, '1')) + ).toMatchObject({ success: false, error: 'This room does not exist!' }) + // No account, yourself, and a player who doesn't exist are each refused — a room + // handed to a nonexistent account would be orphaned for good. + expect(await envOf(await putForm('/rooms/30701/creator', {}, '1'))).toMatchObject({ + success: false, + }) + expect( + await envOf(await putForm('/rooms/30701/creator', { accountId: '1' }, '1')) + ).toMatchObject({ success: false, error: 'You already own this room!' }) + expect( + await envOf(await putForm('/rooms/30701/creator', { accountId: '99998' }, '1')) + ).toMatchObject({ success: false, error: 'That player does not exist!' }) + expect((await roomOf(30701)).CreatorAccountId).toBe(1) + + // The owner hands it to 880: they become Creator, the outgoing owner is left CoOwner, + // and `CreatorAccountId` moves with them. + const ok = await putForm('/rooms/30701/creator', { accountId: '880' }, '1') + expect(ok.status).toBe(200) + expect(await envOf(ok)).toMatchObject({ success: true, error: '' }) + + const after = await roomOf(30701) + expect(after.CreatorAccountId).toBe(880) + expect(after.Roles).toContainEqual( + expect.objectContaining({ AccountId: 880, Role: 255, LastChangedByAccountId: 1 }) + ) + expect(after.Roles).toContainEqual( + expect.objectContaining({ AccountId: 1, Role: 30, LastChangedByAccountId: 1 }) + ) + + // The old owner is a co-owner now, not the owner: they can no longer give the room + // away — otherwise the transfer would have left it with two owners. + expect((await putForm('/rooms/30701/creator', { accountId: '881' }, '1')).status).toBe(403) + // The new owner can, and the room only ever has one Creator entry. + expect((await putForm('/rooms/30701/creator', { accountId: '881' }, '880')).status).toBe(200) + const handedOn = await roomOf(30701) + expect(handedOn.CreatorAccountId).toBe(881) + expect(handedOn.Roles.filter((r) => r.Role === 255)).toEqual([ + expect.objectContaining({ AccountId: 881 }), + ]) + expect(handedOn.Roles).toContainEqual(expect.objectContaining({ AccountId: 880, Role: 30 })) + }) + + it('PUT /rooms/:id/creator refuses a dorm, and pushes the room on a transfer', async () => { + type Sent = { playerId: number; notificationType: string | number; data: { RoomId: number } } + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + const sent = async (): Promise => + (await (await hub().fetch('http://do/all')).json()) as Sent[] + + // A dorm is found by its owner, so handing one over would give away somebody's + // personal room and mint them a fresh empty one on next access. + await seedRoomWithSubRooms(env.DB, { + RoomId: 30702, + Name: 'SomeonesDorm', + CreatorAccountId: 890, + IsDorm: true, + Accessibility: 2, + SubRooms: [], + Roles: [{ AccountId: 890, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }], + }) + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId: 891, username: 'NotYourDorm' })) + .run() + + expect( + await envOf(await putForm('/rooms/30702/creator', { accountId: '891' }, '890')) + ).toMatchObject({ success: false, error: 'A dorm cannot be given away!' }) + const dorm = (await (await SELF.fetch(`${ORIGIN}/rooms/30702`)).json()) as { + CreatorAccountId: number + } + expect(dorm.CreatorAccountId).toBe(890) + + // A real transfer reaches everyone in the room plus both parties, wherever they are. + await seedRoomWithSubRooms(env.DB, { + RoomId: 30703, + Name: 'WatchedHandover', + CreatorAccountId: 892, + Accessibility: 1, + SubRooms: [], + Roles: [{ AccountId: 892, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }], + }) + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId: 893, username: 'NewOwner' })) + .run() + await putInRoom(894, 30703) + await hub().fetch('http://do/all', { method: 'DELETE' }) + + expect((await putForm('/rooms/30703/creator', { accountId: '893' }, '892')).status).toBe(200) + + const pushed = await sent() + expect(pushed.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([892, 893, 894]) + expect( + pushed.every((n) => n.notificationType === NotificationType.SubscriptionUpdateRoom) + ).toBe(true) + expect(pushed.every((n) => n.data.RoomId === 30703)).toBe(true) + + await clearPresence(894) + }) + it('PUT /rooms/:id/roles/:accountId/invite records the offer and pushes the invite', async () => { type Sent = { playerId: number @@ -4446,6 +4573,7 @@ describe('rooms endpoints', () => { 'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save', 'PUT /rooms/{roomId}/accessibility', 'PUT /rooms/{roomId}/cloning', + 'PUT /rooms/{roomId}/creator', 'PUT /rooms/{roomId}/description', 'PUT /rooms/{roomId}/image', 'PUT /rooms/{roomId}/interactionby/me/cheer', diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 6d2c8f1..15d2262 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -227,6 +227,32 @@ export function canManageRoom(room: Room, accountId: number): boolean { return roomRoles(room).some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role)) } +/** + * Whether an account may manage the room with this id — {@link canManageRoom} against a room + * this reads for itself. Null when there is no such room, which a caller answers differently + * (a 404) from a room somebody else runs (a 403). + * + * Reads the room BLOB only, with none of {@link getRoomById}'s hydration. Ownership lives in + * `CreatorAccountId` and `Roles`, both of which are in the blob; the subrooms, tags and stats + * that hydration attaches are three more tables a caller would otherwise have to have just to + * ask who runs a room. That matters for the workers outside `rooms` that gate on room + * ownership — `econ` minting a room currency does not know what a subroom is. + */ +export async function canManageRoomById( + db: D1Database, + roomId: number, + accountId: number +): Promise { + const room = parseOne( + await db + .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`) + .bind(roomId) + .first() + ) + if (!room) return null + return canManageRoom(room, accountId) +} + /** * Whether an account may MODERATE a room — its creator, or the holder of a role at * Moderator (20) or above. The wider gate that {@link canManageRoom} is the narrow one @@ -605,6 +631,69 @@ export async function setRoomRole( return updated } +/** + * TRANSFER a room to a new owner: the recipient becomes {@link Role.Creator} and the + * outgoing owner is left as {@link Role.CoOwner}, keeping their access to a room they built + * without keeping the room. + * + * `CreatorAccountId` moves too, and it has to. It is the room's real owner field — every + * gate short-circuits on it ({@link canManageRoom}, {@link isRoomOwner}), the "rooms I made" + * lists select on it, and it is what the client shows. Moving only the `Roles` entries would + * give the room two owners: the new one by role, the old one by `CreatorAccountId`, still + * able to hand the room on again. + * + * Exempt from {@link roleRequiresInvite}, which otherwise means nobody is handed + * {@link Role.Creator} without accepting it. This is not a grant made ABOUT someone by a + * third party; it is the sole person who could already do anything at all to this room + * giving it away. The recipient gets a room, not a liability, and requiring them to accept + * would leave the room in a half-transferred state in the meantime. + * + * Returns null, writing nothing, for a DORM. A dorm is found by its owner + * ({@link getDormRoom} selects on `creator_account_id AND is_dorm`), so transferring one + * would hand somebody else's dorm to the recipient — who already has their own — and mint + * the original owner a fresh empty one on next access, their build gone. + * + * The caller supplies the already-loaded room (after its owner check) to avoid a re-read; + * the whole room JSON is rewritten. Pending invites on either entry are left standing. + */ +export async function transferRoomOwnership( + db: D1Database, + roomId: number, + fromAccountId: number, + toAccountId: number, + room: Room +): Promise { + if (room.IsDorm === true) return null + + const roles = roomRoles(room) + // Both sides get an entry whether or not they had one: the outgoing owner may never have + // been in `Roles` at all (the older rooms carry no entry for their creator), and the + // recipient is usually a stranger to the room. + const setRole = (accountId: number, role: number): void => { + const existing = roles.find((r) => r.AccountId === accountId) + if (existing) { + existing.Role = role + existing.LastChangedByAccountId = fromAccountId + } else { + roles.push({ + AccountId: accountId, + Role: role, + LastChangedByAccountId: fromAccountId, + InvitedRole: Role.None, + }) + } + } + setRole(toAccountId, Role.Creator) + setRole(fromAccountId, Role.CoOwner) + + const updated: Room = { ...room, CreatorAccountId: toAccountId, Roles: roles } + await db + .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') + .bind(roomId, serializeRoom(updated)) + .run() + return updated +} + /** * ANSWER a standing room-role invite — accept it, or decline it with `role` 0. *