[rooms] switch owner endpoint

This commit is contained in:
Devin Zuczek
2026-09-14 13:36:41 -04:00
parent 4b4db04069
commit 295ec7bc16
4 changed files with 293 additions and 0 deletions
+5
View File
@@ -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`. */ /** `PUT /rooms/{roomId}/roles/{accountId}/invite`. */
export const InviteRoleRequest = z.object({ export const InviteRoleRequest = z.object({
role: z.string().describe('The role tier offered: 10 Host, 20 Moderator, 30 CoOwner'), role: z.string().describe('The role tier offered: 10 Host, 20 Moderator, 30 CoOwner'),
+71
View File
@@ -19,6 +19,7 @@ import {
deleteRoomLeaderboard, deleteRoomLeaderboard,
deleteSubRoom, deleteSubRoom,
findSubRoom, findSubRoom,
getAccount,
getBaseRooms, getBaseRooms,
getContributedRooms, getContributedRooms,
getFavoritedRooms, getFavoritedRooms,
@@ -62,6 +63,7 @@ import {
setSubRoomPermissions, setSubRoomPermissions,
toggleCheer, toggleCheer,
toggleFavorite, toggleFavorite,
transferRoomOwnership,
unbanPlayerFromRoom, unbanPlayerFromRoom,
updateRoomFields, updateRoomFields,
} from '@repo/domain' } from '@repo/domain'
@@ -87,6 +89,7 @@ import {
CloneRoomRequest, CloneRoomRequest,
CloningRequest, CloningRequest,
CreateSubRoomRequest, CreateSubRoomRequest,
CreatorRequest,
CuratedPlaylists, CuratedPlaylists,
DescriptionRequest, DescriptionRequest,
DormRoomId, DormRoomId,
@@ -2178,6 +2181,74 @@ const app = new Hono<App>()
} }
) )
// 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<string, unknown>
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 // 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 // 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 // co-owner may also use. Body is the `role` form field. Records the pending offer on the
+128
View File
@@ -1905,6 +1905,133 @@ describe('rooms endpoints', () => {
expect(await (await SELF.fetch(`${ORIGIN}/rooms/30602/roles`)).json()).toEqual([]) 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<Sent[]> =>
(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 () => { it('PUT /rooms/:id/roles/:accountId/invite records the offer and pushes the invite', async () => {
type Sent = { type Sent = {
playerId: number playerId: number
@@ -4446,6 +4573,7 @@ describe('rooms endpoints', () => {
'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save', 'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save',
'PUT /rooms/{roomId}/accessibility', 'PUT /rooms/{roomId}/accessibility',
'PUT /rooms/{roomId}/cloning', 'PUT /rooms/{roomId}/cloning',
'PUT /rooms/{roomId}/creator',
'PUT /rooms/{roomId}/description', 'PUT /rooms/{roomId}/description',
'PUT /rooms/{roomId}/image', 'PUT /rooms/{roomId}/image',
'PUT /rooms/{roomId}/interactionby/me/cheer', 'PUT /rooms/{roomId}/interactionby/me/cheer',
+89
View File
@@ -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)) 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<boolean | null> {
const room = parseOne(
await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`)
.bind(roomId)
.first<RoomRow>()
)
if (!room) return null
return canManageRoom(room, accountId)
}
/** /**
* Whether an account may MODERATE a room — its creator, or the holder of a role at * 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 * Moderator (20) or above. The wider gate that {@link canManageRoom} is the narrow one
@@ -605,6 +631,69 @@ export async function setRoomRole(
return updated 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<Room | null> {
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. * ANSWER a standing room-role invite — accept it, or decline it with `role` 0.
* *