[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`. */
export const InviteRoleRequest = z.object({
role: z.string().describe('The role tier offered: 10 Host, 20 Moderator, 30 CoOwner'),
+71
View File
@@ -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<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
// 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
+128
View File
@@ -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<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 () => {
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',