[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
}
/**
* 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
* 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
// 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.
@@ -2443,7 +2452,7 @@ const app = new Hono<App>()
tags: ['Room settings'],
summary: 'Delete a room',
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',
'left alone — those live in the api/img world and outlive the room.',
].join(' '),
@@ -2468,7 +2477,7 @@ const app = new Hono<App>()
})
}
const isOwner = room.CreatorAccountId === accountId
if (!isOwner && !(await isStaff(c))) {
if (!isOwner && !(await isDeveloper(c))) {
return roomResult(c, {
Success: false,
ErrorId: 'Rooms.NotOwner',
@@ -2487,7 +2496,7 @@ const app = new Hono<App>()
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
// 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
+9 -5
View File
@@ -1759,9 +1759,9 @@ describe('rooms endpoints', () => {
expect(interactions!.n).toBe(0)
})
it('DELETE /rooms/:id also lets staff through — the website’s takedown — but only there', async () => {
// Somebody else's room. A moderator token may delete it; the same token may NOT
// rename it, which is what a misapplied check once allowed.
it('DELETE /rooms/:id also lets developers through — the website’s takedown — but only there', async () => {
// Somebody else's room. A developer token may delete it (a moderator's may not); the
// same token may NOT rename it, which is what a misapplied check once allowed.
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
@@ -1775,7 +1775,7 @@ describe('rooms endpoints', () => {
})
)
.run()
const staff = await bearer('999', ['gameClient', 'moderator'])
const staff = await bearer('999', ['gameClient', 'developer'])
const roomExists = async () =>
(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' })
// 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>) =>
bodyOf(await SELF.fetch(`${ORIGIN}/rooms/9501`, { method: 'DELETE', headers }))
expect(await del(await bearer('2', ['gameClient']))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
expect(await del(await bearer('998', ['gameClient', 'moderator']))).toMatchObject({
Success: false,
ErrorId: 'Rooms.NotOwner',
})
expect(await roomExists()).toBe(true)
expect(await del(staff)).toMatchObject({ Success: true })
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 /
* moderator through and refuses everyone else; it answers a refusal as a 200 carrying
* Delete a room outright — the developer takedown. `rooms` lets the owner or a developer
* 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.
*/
async function deleteRoom(roomId: number): Promise<void> {
@@ -2817,12 +2817,12 @@ function PublicRoomView({
</section>
{/* 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() && (
<>
{isDeveloper() && <StaffRoomTokens room={room} />}
<StaffListing room={room} onRoomChange={onRoomChange} />
<StaffTakedown room={room} onTakenDown={onTakenDown} />
{isDeveloper() && <StaffTakedown room={room} onTakenDown={onTakenDown} />}
</>
)}
</>