Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/chats/__tests__/getChatArtistHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
60 changes: 60 additions & 0 deletions lib/chats/__tests__/validateChatAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*" })),
Expand All @@ -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";
Expand All @@ -30,6 +35,7 @@ describe("validateChatAccess", () => {

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkIsAdmin).mockResolvedValue(false);
});

it("returns 400 when roomId is invalid uuid", async () => {
Expand Down Expand Up @@ -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 });
});
});
33 changes: 19 additions & 14 deletions lib/chats/validateChatAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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";
Expand All @@ -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
Expand Down Expand Up @@ -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() },
);
}
Loading