diff --git a/lib/chats/__tests__/getChatArtistHandler.test.ts b/lib/chats/__tests__/getChatArtistHandler.test.ts index c88ce650..7bbf0d38 100644 --- a/lib/chats/__tests__/getChatArtistHandler.test.ts +++ b/lib/chats/__tests__/getChatArtistHandler.test.ts @@ -17,6 +17,10 @@ vi.mock("@/lib/chats/buildGetChatsParams", () => ({ buildGetChatsParams: vi.fn(), })); +vi.mock("@/lib/admins/checkIsAdmin", () => ({ + checkIsAdmin: vi.fn(() => Promise.resolve(false)), +})); + const createRequest = () => new NextRequest("http://localhost/api/chats/chat-id/artist"); describe("getChatArtistHandler", () => { diff --git a/lib/chats/__tests__/validateChatAccess.test.ts b/lib/chats/__tests__/validateChatAccess.test.ts index 4c925544..cac17ccf 100644 --- a/lib/chats/__tests__/validateChatAccess.test.ts +++ b/lib/chats/__tests__/validateChatAccess.test.ts @@ -4,6 +4,7 @@ import { validateChatAccess } from "../validateChatAccess"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import selectRoom from "@/lib/supabase/rooms/selectRoom"; import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; +import { checkIsAdmin } from "@/lib/admins/checkIsAdmin"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -21,6 +22,10 @@ vi.mock("@/lib/chats/buildGetChatsParams", () => ({ buildGetChatsParams: vi.fn(), })); +vi.mock("@/lib/admins/checkIsAdmin", () => ({ + checkIsAdmin: vi.fn(), +})); + describe("validateChatAccess", () => { const roomId = "123e4567-e89b-12d3-a456-426614174000"; const accountId = "123e4567-e89b-12d3-a456-426614174001"; @@ -30,6 +35,7 @@ describe("validateChatAccess", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(checkIsAdmin).mockResolvedValue(false); }); it("returns 400 when roomId is invalid uuid", async () => { @@ -159,4 +165,58 @@ describe("validateChatAccess", () => { const result = await validateChatAccess(request, roomId); expect(result).toEqual({ roomId, room, accountId }); }); + + it("grants a Recoup admin access to a room they don't own (admin checked after ownership fails)", async () => { + const room = { + id: roomId, + account_id: "another-account", + artist_id: null, + topic: "Topic", + updated_at: "2026-03-30T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + vi.mocked(selectRoom).mockResolvedValue(room); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + vi.mocked(checkIsAdmin).mockResolvedValue(true); + + const result = await validateChatAccess(request, roomId); + + expect(buildGetChatsParams).toHaveBeenCalled(); + expect(checkIsAdmin).toHaveBeenCalledWith(accountId); + expect(result).toEqual({ roomId, room, accountId: "another-account" }); + }); + + it("does not consult admin status when the caller owns the room", async () => { + const room = { + id: roomId, + account_id: accountId, + artist_id: null, + topic: "Topic", + updated_at: "2026-03-30T00:00:00Z", + }; + + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-key", + }); + vi.mocked(selectRoom).mockResolvedValue(room); + vi.mocked(buildGetChatsParams).mockResolvedValue({ + params: { account_ids: [accountId] }, + error: null, + }); + + const result = await validateChatAccess(request, roomId); + + expect(checkIsAdmin).not.toHaveBeenCalled(); + expect(result).toEqual({ roomId, room, accountId }); + }); }); diff --git a/lib/chats/validateChatAccess.ts b/lib/chats/validateChatAccess.ts index 30f89e5d..de663fea 100644 --- a/lib/chats/validateChatAccess.ts +++ b/lib/chats/validateChatAccess.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkIsAdmin } from "@/lib/admins/checkIsAdmin"; import selectRoom from "@/lib/supabase/rooms/selectRoom"; import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; import type { Tables } from "@/types/database.types"; @@ -18,6 +19,11 @@ const chatIdSchema = z.string().uuid("id must be a valid UUID"); /** * Validates that the authenticated caller can access a chat room. * + * The room is fully identified by `roomId`, so no account override is accepted. + * Access is granted to the room's owner; a Recoup admin (`RECOUP_ORG_ID` member) + * may access any room. The admin check runs only when the ownership check fails, + * so the common owner path never pays the extra lookup. + * * @param request - The incoming request (used for auth context) * @param roomId - The room/chat UUID to validate access for * @returns NextResponse on auth/access failure, or validated access data @@ -49,23 +55,22 @@ export async function validateChatAccess( ); } - const { params, error } = await buildGetChatsParams({ - account_id: accountId, - }); + const { params } = await buildGetChatsParams({ account_id: accountId }); - if (!params) { - return NextResponse.json( - { status: "error", error: error ?? "Access denied" }, - { status: 403, headers: getCorsHeaders() }, - ); + const isOwner = !!params && !!room.account_id && params.account_ids.includes(room.account_id); + if (isOwner) { + return { roomId: room.id, room, accountId }; } - if (!room.account_id || !params.account_ids.includes(room.account_id)) { - return NextResponse.json( - { status: "error", error: "Access denied to this chat" }, - { status: 403, headers: getCorsHeaders() }, - ); + // Non-owner: a Recoup admin may access any chat (read or write). The owner is + // resolved server-side, so no account_id input is needed. Checked only here so + // the owner path above never pays the lookup. + if (await checkIsAdmin(accountId)) { + return { roomId: room.id, room, accountId: room.account_id ?? accountId }; } - return { roomId: room.id, room, accountId }; + return NextResponse.json( + { status: "error", error: "Access denied to this chat" }, + { status: 403, headers: getCorsHeaders() }, + ); }