[rooms] fix permissions update

This commit is contained in:
Devin Zuczek
2026-10-06 23:25:23 -04:00
parent d8a1c4b0da
commit 853050c2f0
3 changed files with 65 additions and 28 deletions
+3 -2
View File
@@ -855,8 +855,9 @@ export const MoveSubRoomRequest = z.object({
* A room in the PascalCase `{ Value, Success, Error, error_id }` envelope — the same mixed * A room in the PascalCase `{ Value, Success, Error, error_id }` envelope — the same mixed
* casing the unprefixed isBanned check has ({@link IsBannedPascalEnvelope}), NOT the * casing the unprefixed isBanned check has ({@link IsBannedPascalEnvelope}), NOT the
* lowercase `{ success, error, value }` most room mutations answer. The newer settings * lowercase `{ success, error, value }` most room mutations answer. The newer settings
* routes (the subroom move, the max-player calculation mode) answer this one; the client * routes (the subroom move, the max-player calculation mode, the subroom permissions)
* decodes them with a different reader, so the two are kept apart deliberately. * answer this one; the client decodes them with a different reader, so the two are kept
* apart deliberately.
*/ */
export const RoomPascalEnvelope = z.object({ export const RoomPascalEnvelope = z.object({
Value: RoomDto.nullable().describe('The room as it now stands; null on a rejection'), Value: RoomDto.nullable().describe('The room as it now stands; null on a rejection'),
+22 -10
View File
@@ -4067,7 +4067,12 @@ const app = new Hono<App>()
// creator or a co-owner (403), the same `canManageRoom` the other room-admin writes // creator or a co-owner (403), the same `canManageRoom` the other room-admin writes
// take; a creator-only check here locked out co-owners the client shows the screen to, // take; a creator-only check here locked out co-owners the client shows the screen to,
// and an owner whose room names them in `Roles` rather than `CreatorAccountId`. Answers // and an owner whose room names them in `Roles` rather than `CreatorAccountId`. Answers
// an EMPTY 200 — the client fires this and re-reads nothing, so there is no envelope. // the whole ROOM under `Value` in the PascalCase `{ Value, Success, Error, error_id }`
// envelope — the same reader the subroom move and max-player mode routes feed, and the
// same Room DTO `GET /rooms/{id}` serves (the permissions themselves aren't on it). A
// rejection is HTTP 200 with `Success: false` and the message in `Error`; only a
// missing token is a 401. This once answered an empty 200, which the client's reader
// couldn't decode.
.put( .put(
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions', '/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions',
describeRoute({ describeRoute({
@@ -4093,33 +4098,40 @@ const app = new Hono<App>()
'to the subroom the caller is standing in, resolved from presence.', 'to the subroom the caller is standing in, resolved from presence.',
'', '',
'Gated to the room’s managers — its creator or a co-owner (`canManageRoom`), as the', 'Gated to the room’s managers — its creator or a co-owner (`canManageRoom`), as the',
'other room-admin writes are. The response body is EMPTY: the client doesn’t read one.', 'other room-admin writes are. Answers the whole ROOM under `Value` in the PascalCase',
'`{ Value, Success, Error, error_id }` envelope — the same Room DTO `GET /rooms/{id}`',
'serves (the permission table itself is not part of it), and the same envelope the',
'subroom move and `max_player_calculation_mode` answer, NOT the lowercase',
'`{ success, error, value }` the other subroom mutations use. A rejection is HTTP 200',
'with `Success: false`, `Value: null` and the message in `Error`; only a missing',
'token is a 401.',
].join('\n'), ].join('\n'),
security: AUTHED, security: AUTHED,
parameters: [roomIdParam, subRoomIdParam], parameters: [roomIdParam, subRoomIdParam],
requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'), requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'),
responses: { responses: {
200: { description: 'Stored (empty body)' }, 200: json(RoomPascalEnvelope, 'The room, or a rejection with `Success: false`'),
401: UNAUTHORIZED_RESPONSE, 401: { description: 'No bearer token (empty body)' },
403: FORBIDDEN_RESPONSE,
404: { description: 'No such room or subroom' },
}, },
}), }),
async (c) => { async (c) => {
const accountId = await authedAccountId(c) const accountId = await authedAccountId(c)
if (accountId === null) return unauthorized(c) if (accountId === null) return c.body(null, 401)
const refuse = (error: string) =>
c.json({ Value: null, Success: false, Error: error, error_id: null })
const roomId = Number.parseInt(c.req.param('roomId'), 10) const roomId = Number.parseInt(c.req.param('roomId'), 10)
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10) const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
// Scoped through the room so a subroom id from another room can't be written. // Scoped through the room so a subroom id from another room can't be written.
const room = await getRoomById(c.env.DB, roomId) const room = await getRoomById(c.env.DB, roomId)
if (!room || !findSubRoom(room, subRoomId)) return c.notFound() if (!room) return refuse('This room does not exist!')
if (!canManageRoom(room, accountId)) return c.body(null, 403) if (!findSubRoom(room, subRoomId)) return refuse('This subroom does not exist!')
if (!canManageRoom(room, accountId)) return refuse('You are not the owner of this room!')
const permissions = parseRoomPermissions(await c.req.json().catch(() => null)) const permissions = parseRoomPermissions(await c.req.json().catch(() => null))
await setSubRoomPermissions(c.env.DB, subRoomId, permissions) await setSubRoomPermissions(c.env.DB, subRoomId, permissions)
return c.body(null, 200) return c.json({ Value: room, Success: true, Error: null, error_id: null })
} }
) )
+40 -16
View File
@@ -4818,42 +4818,66 @@ describe('rooms endpoints', () => {
.run() .run()
} }
type Envelope = {
Value: { RoomId: number; SubRooms: unknown[] } | null
Success: boolean
Error: string | null
error_id: null
}
const refusal = async (res: Response): Promise<string | null> => {
expect(res.status).toBe(200)
const env = (await res.json()) as Envelope
expect(env.Success).toBe(false)
expect(env.Value).toBeNull()
expect(env.error_id).toBeNull()
return env.Error
}
it('is auth-gated and open to the room’s managers only', async () => { it('is auth-gated and open to the room’s managers only', async () => {
const body = [ const body = [
{ Permission: 'CAN_SAVE_INVENTIONS', Role: 30, Override: false, Type: 0, Value: 'True' }, { Permission: 'CAN_SAVE_INVENTIONS', Role: 30, Override: false, Type: 0, Value: 'True' },
] ]
// No token → 401. // No token → 401.
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body)).status).toBe(401) expect((await putPermissions('/rooms/2/subrooms/2/permissions', body)).status).toBe(401)
// A valid token that isn't the room's creator or a co-owner → 403. // A valid token that isn't the room's creator or a co-owner → a `Success: false`
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '999')).status).toBe( // rejection, as the other PascalCase-envelope routes answer.
403 expect(
) await refusal(await putPermissions('/rooms/2/subrooms/2/permissions', body, '999'))
).toBe('You are not the owner of this room!')
// A co-owner may: account 2 holds Role 30 on the seeded rooms. (This body clears a // A co-owner may: account 2 holds Role 30 on the seeded rooms. (This body clears a
// pair that was never stored, so it changes nothing.) // pair that was never stored, so it changes nothing.)
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '2')).status).toBe(200) expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '2')).status).toBe(200)
// A Host (a lower tier) may not. // A Host (a lower tier) may not.
await setRoles() await setRoles()
expect((await putPermissions('/rooms/2/subrooms/2/permissions', body, '748')).status).toBe( expect(
403 await refusal(await putPermissions('/rooms/2/subrooms/2/permissions', body, '748'))
) ).toBe('You are not the owner of this room!')
// Unknown room / unknown subroom → 404. // Unknown room / unknown subroom.
expect((await putPermissions('/rooms/99999/subrooms/2/permissions', body, '1')).status).toBe( expect(
404 await refusal(await putPermissions('/rooms/99999/subrooms/2/permissions', body, '1'))
) ).toBe('This room does not exist!')
// A subroom id belonging to another room doesn't resolve either. // A subroom id belonging to another room doesn't resolve either.
expect((await putPermissions('/rooms/2/subrooms/9999/permissions', body, '1')).status).toBe( expect(
404 await refusal(await putPermissions('/rooms/2/subrooms/9999/permissions', body, '1'))
) ).toBe('This subroom does not exist!')
}) })
it('answers an empty 200 — the client reads no body', async () => { it('answers the whole room in the PascalCase `{ Value, Success, Error, error_id }` envelope', async () => {
const res = await putPermissions( const res = await putPermissions(
'/rooms/2/subrooms/2/permissions', '/rooms/2/subrooms/2/permissions',
[{ Permission: 'CAN_SPAWN_INVENTIONS', Role: 30, Override: true, Type: 0, Value: 'True' }], [{ Permission: 'CAN_SPAWN_INVENTIONS', Role: 30, Override: true, Type: 0, Value: 'True' }],
'1' '1'
) )
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.text()).toBe('') const env = (await res.json()) as Envelope
expect(env.Success).toBe(true)
expect(env.Error).toBeNull()
expect(env.error_id).toBeNull()
// `Value` is the same Room DTO `GET /rooms/2` serves — subrooms re-attached and all.
const room = (await (
await SELF.fetch(`${ORIGIN}/rooms/2`, { headers: await bearer('1') })
).json()) as Envelope['Value']
expect(env.Value).toEqual(room)
}) })
it('a Role 0 grant reaches a visitor; a Role 30 grant does not', async () => { it('a Role 0 grant reaches a visitor; a Role 30 grant does not', async () => {