diff --git a/apps/rooms/src/context.ts b/apps/rooms/src/context.ts index 87e8e14..eb26fb7 100644 --- a/apps/rooms/src/context.ts +++ b/apps/rooms/src/context.ts @@ -16,6 +16,10 @@ export type Env = SharedHonoEnv & { // SignalR notifications hub (DO owned by the `notify` worker). Bound here to // push RoomUpdate notifications when a room is mutated. RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace + // Shared `recflare-cdn` R2 bucket (the `cdn`/`storage` workers own it). Room + // images/files live here under the `room/` key prefix; bound so deleting a room + // can remove its image object. + CDN_ASSETS: R2Bucket } /** Variables can be extended */ diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index abb880c..e35f327 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -5,6 +5,7 @@ import { canManageRoom, cloneRoom, cloneSubRoom, + deleteRoom, findSubRoom, getBaseRooms, getFavoritedRooms, @@ -582,6 +583,45 @@ const app = new Hono() return roomResult(c, { Success: true }) }) + // Delete a room. Auth-gated (401) and owner-only (the room's CreatorAccountId). + // 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. + .delete('/rooms/:roomId{[0-9]+}', 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 roomResult(c, { + Success: false, + ErrorId: 'Rooms.DoesntExist', + Error: 'This room does not exist!', + }) + } + if (room.CreatorAccountId !== accountId) { + return roomResult(c, { + Success: false, + ErrorId: 'Rooms.NotOwner', + Error: 'You are not the owner of this room!', + }) + } + + await deleteRoom(c.env.DB, roomId) + + // Remove the room image from the CDN bucket. The stored ImageName is the + // un-prefixed key the `cdn` worker serves back under `room/` (see storage + // upload + the `GET /room/:dataBlob` route), so the object key is `room/`. + // R2 deletes are idempotent, so a canonical/static or already-gone image is fine. + const imageName = typeof room.ImageName === 'string' ? room.ImageName : '' + if (imageName !== '') { + await c.env.CDN_ASSETS.delete(`room/${imageName}`) + } + + return roomResult(c, { Success: true }) + }) + // Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to // the room creator or a co-owner (403 otherwise) — the same owner/co-owner check // the other room-admin actions use. Body is the `role` form field (an integer role diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 7db17ba..1ab871e 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -582,6 +582,51 @@ describe('rooms endpoints', () => { expect(room.ImageName).toBe(imageName) }) + it('DELETE /rooms/:id is auth-gated, owner-only, and removes the room + its CDN image', async () => { + // Throwaway room owned by account 1, with its image object in the CDN bucket and + // a player interaction row. + const ImageName = 'test/2026-07-17/delete-me.jpg' + await env.DB.prepare('INSERT INTO room (data) VALUES (?1)') + .bind( + JSON.stringify({ + RoomId: 9500, + Name: 'DeleteMe', + CreatorAccountId: 1, + IsDorm: false, + Accessibility: 1, + ImageName, + SubRooms: [], + }) + ) + .run() + await env.CDN_ASSETS.put(`room/${ImageName}`, new Uint8Array([1, 2, 3])) + await env.DB.prepare( + 'INSERT INTO interaction (player_id, room_id, cheered, favorited) VALUES (7, 9500, 1, 1)' + ).run() + + const del = async (sub?: string) => + SELF.fetch(`${ORIGIN}/rooms/9500`, { + method: 'DELETE', + headers: sub ? await bearer(sub) : {}, + }) + const roomExists = async () => + (await env.DB.prepare('SELECT 1 FROM room WHERE room_id = 9500').first()) !== null + + // No token → 401. A non-owner → Success:false (room untouched). + expect((await del()).status).toBe(401) + expect(await bodyOf(await del('2'))).toMatchObject({ Success: false, ErrorId: 'Rooms.NotOwner' }) + expect(await roomExists()).toBe(true) + + // Owner → Success:true; the room, its interactions, and the CDN image are gone. + expect(await bodyOf(await del('1'))).toMatchObject({ Success: true }) + expect(await roomExists()).toBe(false) + expect(await env.CDN_ASSETS.get(`room/${ImageName}`)).toBeNull() + const interactions = await env.DB.prepare( + 'SELECT COUNT(*) AS n FROM interaction WHERE room_id = 9500' + ).first<{ n: number }>() + expect(interactions!.n).toBe(0) + }) + it('PUT /rooms/:id/roles/:accountId is auth-gated, owner/co-owner-only, and persists', async () => { const rolesOf = async (): Promise> => { const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { diff --git a/apps/rooms/wrangler.jsonc b/apps/rooms/wrangler.jsonc index 27e9f62..48ea6e9 100644 --- a/apps/rooms/wrangler.jsonc +++ b/apps/rooms/wrangler.jsonc @@ -19,6 +19,15 @@ "migrations_dir": "migrations" } ], + // Shared `recflare-cdn` R2 bucket (also bound by the `cdn`/`storage` workers). + // Room images/files live here under `room/`; bound so deleting a room removes its + // image object. bucket_name is a literal, so nothing is spliced at deploy time. + "r2_buckets": [ + { + "binding": "CDN_ASSETS", + "bucket_name": "recflare-cdn" + } + ], // Cross-worker binding to the SignalR notifications hub DO (owned/migrated by // the `notify` worker). We only invoke its RPC methods; no migration here. "durable_objects": { diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index c2456e4..d127d03 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -400,6 +400,20 @@ export async function getRoomById(db: D1Database, roomId: number): Promise { + await db.batch([ + db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId), + db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId), + ]) +} + /** Look up a single room by name (case-insensitive exact match). */ export async function getRoomByName(db: D1Database, name: string): Promise { return parseOne(