mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[rooms] switch owner endpoint
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<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
|
||||
* 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<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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user