From 8bd76a4bae2b3ca5b2a277df8ba26712a36413be Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 7 Aug 2026 12:35:50 -0400 Subject: [PATCH] allow co-owners to edit tags --- apps/rooms/src/rooms.app.ts | 23 +++++++++++---------- apps/rooms/src/test/integration/api.test.ts | 14 +++++++------ 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 95026d5..87ac682 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -1250,9 +1250,9 @@ const app = new Hono() } ) - // Toggle a tag on a room. Auth-gated (401) and owner-only. Body is the `tag` - // form field. There's no delete/patch endpoint, so this call toggles: it adds - // the tag (Type 0) if absent and removes it if present. The "main" tags + // Toggle a tag on a room. Auth-gated (401) and owner/co-owner-only (403). Body is + // the `tag` form field. There's no delete/patch endpoint, so this call toggles: it + // adds the tag (Type 0) if absent and removes it if present. The "main" tags // (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the // others. Returns the `{ success, error, value }` envelope with the updated // room as `value`; business failures are 200 with success:false. @@ -1262,11 +1262,11 @@ const app = new Hono() tags: ['Room settings'], summary: 'Toggle a tag on a room', description: [ - 'Owner-only. There is no delete/patch counterpart, so this call TOGGLES: it adds the', - 'tag (Type 0) when absent and removes it when present. The “main” tags', - '(`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio buttons — setting one clears', - 'the others. Answers the lowercase envelope with the updated room, which the client', - 're-renders from.', + 'Owner or co-owner only (403 otherwise). There is no delete/patch counterpart, so', + 'this call TOGGLES: it adds the tag (Type 0) when absent and removes it when', + 'present. The “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio', + 'buttons — setting one clears the others. Answers the lowercase envelope with the', + 'updated room, which the client re-renders from.', ].join(' '), security: AUTHED, parameters: [roomIdParam], @@ -1274,6 +1274,7 @@ const app = new Hono() responses: { 200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'), 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, }, }), async (c) => { @@ -1283,9 +1284,9 @@ const app = new Hono() const roomId = Number.parseInt(c.req.param('roomId'), 10) const room = await getRoomById(c.env.DB, roomId) if (!room) return roomEnvelope(c, null, 'This room does not exist!') - if (room.CreatorAccountId !== accountId) { - return roomEnvelope(c, null, 'You are not the owner of this room!') - } + // A valid token but not the room's owner/co-owner → 403 (the auth gate above + // already returned 401 for a missing/invalid token). + if (!canManageRoom(room, accountId)) return c.body(null, 403) const body = (await c.req.parseBody().catch(() => ({}))) as Record const tag = typeof body.tag === 'string' ? body.tag.trim() : '' diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index a117692..9c39e83 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1743,7 +1743,7 @@ describe('rooms endpoints', () => { expect(clone.CurrentSave!.SubRoomId).toBe(clone.SubRoomId) }) - it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => { + it('PUT /rooms/:id/tags is auth-gated, owner/co-owner-only, and toggles (add/remove)', async () => { // The lowercase `{ success, error, value }` envelope this endpoint returns. type TagResult = { success: boolean @@ -1755,11 +1755,8 @@ describe('rooms endpoints', () => { // No token → 401. expect((await putForm('/rooms/2/tags', { tag: 'quest' })).status).toBe(401) - // Not the owner → failure envelope. - expect(await envOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '999'))).toMatchObject({ - success: false, - error: 'You are not the owner of this room!', - }) + // A valid token but no role on the room → 403. + expect((await putForm('/rooms/2/tags', { tag: 'quest' }, '999')).status).toBe(403) // Unknown room → failure envelope. expect(await envOf(await putForm('/rooms/99999/tags', { tag: 'quest' }, '1'))).toMatchObject({ success: false, @@ -1795,6 +1792,11 @@ describe('rooms endpoints', () => { const off = await envOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '1')) expect(tagsIn(off)).not.toContain('quest') expect(tagsIn(off)).toContain('campfire') + + // The co-owner (account 2, Role 30) may edit tags too. + const byCoOwner = await envOf(await putForm('/rooms/2/tags', { tag: 'spooky' }, '2')) + expect(byCoOwner).toMatchObject({ success: true, error: '' }) + expect(tagsIn(byCoOwner)).toContain('spooky') }) it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {