diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 331f84a..470cc86 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -517,6 +517,15 @@ async function isStaff(c: Context): Promise { 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): Promise { + 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() } ) - // 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() 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() }) } 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() 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 diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 749b177..53a452b 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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) => 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) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 96a0526..7e9e274 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -1044,8 +1044,8 @@ const fetchRoomPhotos = (roomId: number): Promise => ) /** - * 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 { @@ -2817,12 +2817,12 @@ function PublicRoomView({ {/* 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() && } - + {isDeveloper() && } )}