diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index ce2e705..b93b235 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -69,6 +69,7 @@ import { transferRoomOwnership, unbanPlayerFromRoom, updateRoomFields, + writeAuditLog, } from '@repo/domain' import { intVar, @@ -2117,6 +2118,33 @@ 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 + // 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 + // a deletion that has already happened as refused. It leaves a loud line instead. + if (!isOwner) { + try { + await writeAuditLog(c.env.DB, { + playerId: accountId, + action: 'room_delete', + data: { + roomId, + name: room.Name, + creatorAccountId: room.CreatorAccountId, + accessibility: room.Accessibility, + }, + }) + } catch (err) { + logger.error('could not write an audit log row', { + action: 'room_delete', + playerId: accountId, + roomId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + return roomResult(c, { Success: true }) } ) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index c3d6f4b..6301eac 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -4,6 +4,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import '../../rooms.app' import { + AUDIT_LOG_SCHEMA_DDL, createRoomInstance, getRoomInstance, MessageType, @@ -104,6 +105,9 @@ beforeAll(async () => { // Message store (owned by the api worker) — a room-role invite is written there before // it is pushed, so the invited player can read it back from their inbox. for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // The audit log the staff takedown writes to. Owned by `api`'s migrations; built here + // directly, the way `notify`'s tests do. + for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill). for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record) @@ -1671,6 +1675,23 @@ describe('rooms endpoints', () => { expect(await roomExists()).toBe(true) expect(await del(staff)).toMatchObject({ Success: true }) expect(await roomExists()).toBe(false) + + // The takedown is the one row in the audit log: the owner's own deletion in the test + // above wrote none, and the refused attempts wrote none. It names the actor and keeps + // enough of the room to say which one it was, now that the room itself is gone. + const { results: audit } = await env.DB.prepare( + 'SELECT player_id, action, data FROM audit_log WHERE action = ?1' + ) + .bind('room_delete') + .all<{ player_id: number; action: string; data: string }>() + expect(audit).toHaveLength(1) + expect(audit[0].player_id).toBe(999) + expect(JSON.parse(audit[0].data)).toEqual({ + roomId: 9501, + name: 'TakeMeDown', + creatorAccountId: 1, + accessibility: 1, + }) }) it('PUT /rooms/:id/roles/:accountId grants a helper role outright, but never co-owner', async () => {