[www] gate delete room on developer

This commit is contained in:
Devin Zuczek
2026-10-16 10:34:49 -04:00
parent c52b37037e
commit d87e1ca5ae
3 changed files with 26 additions and 13 deletions
+13 -4
View File
@@ -517,6 +517,15 @@ async function isStaff(c: Context<App>): Promise<boolean> {
return roles?.some((role) => STAFF_ROLES.has(role)) ?? false return roles?.some((role) => STAFF_ROLES.has(role)) ?? false
} }
/**
* Whether the caller's token carries the `developer` role — the narrower of the two staff
* roles, for the actions a moderator may not take (deleting a room that isn't theirs).
*/
async function isDeveloper(c: Context<App>): Promise<boolean> {
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
return roles?.includes('developer') ?? false
}
/** /**
* Whether the `:playerId` in the path is banned from the `:roomId` in it — the read behind * Whether the `:playerId` in the path is banned from the `:roomId` in it — the read behind
* both `isBanned` routes, which differ only in the envelope they wrap the answer in. * both `isBanned` routes, which differ only in the envelope they wrap the answer in.
@@ -2433,7 +2442,7 @@ const app = new Hono<App>()
} }
) )
// Delete a room. Auth-gated (401); owners and staff (developers/moderators) may delete. // Delete a room. Auth-gated (401); the owner or a developer may delete — not a moderator.
// Removes the room record (and per-player interactions with it) and the room's // Removes the room record (and per-player interactions with it) and the room's
// image object from the shared CDN bucket. Images players *took* in the room are // image object from the shared CDN bucket. Images players *took* in the room are
// left alone — they live in the api/img world and outlast the room. // left alone — they live in the api/img world and outlast the room.
@@ -2443,7 +2452,7 @@ const app = new Hono<App>()
tags: ['Room settings'], tags: ['Room settings'],
summary: 'Delete a room', summary: 'Delete a room',
description: [ description: [
'Owner or staff. Removes the room record, the per-player interactions with it, and the', 'Owner or developer (a moderator token is refused). Removes the room record, the per-player interactions with it, and the',
'room’s image object from the shared CDN bucket. Photos players TOOK in the room are', 'room’s image object from the shared CDN bucket. Photos players TOOK in the room are',
'left alone — those live in the api/img world and outlive the room.', 'left alone — those live in the api/img world and outlive the room.',
].join(' '), ].join(' '),
@@ -2468,7 +2477,7 @@ const app = new Hono<App>()
}) })
} }
const isOwner = room.CreatorAccountId === accountId const isOwner = room.CreatorAccountId === accountId
if (!isOwner && !(await isStaff(c))) { if (!isOwner && !(await isDeveloper(c))) {
return roomResult(c, { return roomResult(c, {
Success: false, Success: false,
ErrorId: 'Rooms.NotOwner', ErrorId: 'Rooms.NotOwner',
@@ -2487,7 +2496,7 @@ const app = new Hono<App>()
await c.env.CDN_ASSETS.delete(`room/${imageName}`) await c.env.CDN_ASSETS.delete(`room/${imageName}`)
} }
// A takedown — staff deleting a room that isn't theirs — goes in the audit log; an // A takedown — a developer deleting a room that isn't theirs — goes in the audit log; an
// owner deleting their own room is their business and doesn't. The row keeps the // owner deleting their own room is their business and doesn't. The row keeps the
// room's identity, since the room itself is gone and nothing else will answer "which // room's identity, since the room itself is gone and nothing else will answer "which
// room was that". Written after the fact and caught: a failed insert must not report // room was that". Written after the fact and caught: a failed insert must not report
+9 -5
View File
@@ -1759,9 +1759,9 @@ describe('rooms endpoints', () => {
expect(interactions!.n).toBe(0) expect(interactions!.n).toBe(0)
}) })
it('DELETE /rooms/:id also lets staff through — the website’s takedown — but only there', async () => { it('DELETE /rooms/:id also lets developers through — the website’s takedown — but only there', async () => {
// Somebody else's room. A moderator token may delete it; the same token may NOT // Somebody else's room. A developer token may delete it (a moderator's may not); the
// rename it, which is what a misapplied check once allowed. // same token may NOT rename it, which is what a misapplied check once allowed.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)') await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind( .bind(
JSON.stringify({ JSON.stringify({
@@ -1775,7 +1775,7 @@ describe('rooms endpoints', () => {
}) })
) )
.run() .run()
const staff = await bearer('999', ['gameClient', 'moderator']) const staff = await bearer('999', ['gameClient', 'developer'])
const roomExists = async () => const roomExists = async () =>
(await env.DB.prepare('SELECT 1 FROM room WHERE room_id = 9501').first()) !== null (await env.DB.prepare('SELECT 1 FROM room WHERE room_id = 9501').first()) !== null
@@ -1786,13 +1786,17 @@ describe('rooms endpoints', () => {
}) })
expect(await bodyOf(rename)).toMatchObject({ Success: false, ErrorId: 'Rooms.NotOwner' }) expect(await bodyOf(rename)).toMatchObject({ Success: false, ErrorId: 'Rooms.NotOwner' })
// A plain player is still refused; the moderator gets through. // A plain player and a moderator are refused; the developer gets through.
const del = async (headers: Record<string, string>) => const del = async (headers: Record<string, string>) =>
bodyOf(await SELF.fetch(`${ORIGIN}/rooms/9501`, { method: 'DELETE', headers })) bodyOf(await SELF.fetch(`${ORIGIN}/rooms/9501`, { method: 'DELETE', headers }))
expect(await del(await bearer('2', ['gameClient']))).toMatchObject({ expect(await del(await bearer('2', ['gameClient']))).toMatchObject({
Success: false, Success: false,
ErrorId: 'Rooms.NotOwner', ErrorId: 'Rooms.NotOwner',
}) })
expect(await del(await bearer('998', ['gameClient', 'moderator']))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
expect(await roomExists()).toBe(true) expect(await roomExists()).toBe(true)
expect(await del(staff)).toMatchObject({ Success: true }) expect(await del(staff)).toMatchObject({ Success: true })
expect(await roomExists()).toBe(false) expect(await roomExists()).toBe(false)
+4 -4
View File
@@ -1044,8 +1044,8 @@ const fetchRoomPhotos = (roomId: number): Promise<PublicPhoto[]> =>
) )
/** /**
* Delete a room outright — the staff takedown. `rooms` lets the owner or a developer / * Delete a room outright — the developer takedown. `rooms` lets the owner or a developer
* moderator through and refuses everyone else; it answers a refusal as a 200 carrying * through and refuses everyone else, moderators included; it answers a refusal as a 200 carrying
* `Success: false` (the game's envelope), so the check is on the body, not the status. * `Success: false` (the game's envelope), so the check is on the body, not the status.
*/ */
async function deleteRoom(roomId: number): Promise<void> { async function deleteRoom(roomId: number): Promise<void> {
@@ -2817,12 +2817,12 @@ function PublicRoomView({
</section> </section>
{/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's {/* Staff only, and cosmetic: hidden for everyone else, but `rooms` checks the token's
role itself on the DELETE and the listing PUT. */} role itself on the DELETE (developers only) and the listing PUT. */}
{isAdmin() && ( {isAdmin() && (
<> <>
{isDeveloper() && <StaffRoomTokens room={room} />} {isDeveloper() && <StaffRoomTokens room={room} />}
<StaffListing room={room} onRoomChange={onRoomChange} /> <StaffListing room={room} onRoomChange={onRoomChange} />
<StaffTakedown room={room} onTakenDown={onTakenDown} /> {isDeveloper() && <StaffTakedown room={room} onTakenDown={onTakenDown} />}
</> </>
)} )}
</> </>