Skip to content
Closed
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
36 changes: 36 additions & 0 deletions app/api/chats/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getChatHandler } from "@/lib/chats/getChatHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns Empty response with CORS headers.
*/
export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* GET /api/chats/[id]
*
* Returns the chat row in camelCase wire format (incl. `sessionId`) for
* the authenticated owner of its parent session.
*
* @param request - Incoming request.
* @param context - Next.js route context.
* @param context.params - Async route params containing the chat id.
* @returns JSON response with `{ chat }`, or a 401/403/404 error.
*/
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> },
): Promise<NextResponse> {
const { params } = context;
const { id } = await params;
return getChatHandler(request, id);
}
42 changes: 42 additions & 0 deletions lib/chats/__tests__/getChatHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/chats/validateGetChatRequest", () => ({
validateGetChatRequest: vi.fn(),
}));

const { validateGetChatRequest } = await import("@/lib/chats/validateGetChatRequest");
const { getChatHandler } = await import("@/lib/chats/getChatHandler");

function makeReq(): NextRequest {
return new NextRequest("https://example.com/api/chats/chat_1");
}

describe("getChatHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards the validation NextResponse on failure", async () => {
const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 });
vi.mocked(validateGetChatRequest).mockResolvedValue(failure);

const res = await getChatHandler(makeReq(), "chat_1");
expect(res).toBe(failure);
});

it("returns 200 with the camelCase chat (incl. sessionId) on success", async () => {
vi.mocked(validateGetChatRequest).mockResolvedValue(
baseChatRow({ id: "chat_1", session_id: "sess_1", title: "My chat" }),
);

const res = await getChatHandler(makeReq(), "chat_1");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.chat).toMatchObject({ id: "chat_1", sessionId: "sess_1", title: "My chat" });
});
});
96 changes: 96 additions & 0 deletions lib/chats/__tests__/validateGetChatRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow";
import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
}));
vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));
vi.mock("@/lib/supabase/sessions/selectSessions", () => ({
selectSessions: vi.fn(),
}));
vi.mock("@/lib/supabase/chats/selectChats", () => ({
selectChats: vi.fn(),
}));

const { validateAuthContext } = await import("@/lib/auth/validateAuthContext");
const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions");
const { selectChats } = await import("@/lib/supabase/chats/selectChats");
const { validateGetChatRequest } = await import("@/lib/chats/validateGetChatRequest");

const accountId = "acc-uuid-1";

function makeReq(): NextRequest {
return new NextRequest("https://example.com/api/chats/chat_1");
}

describe("validateGetChatRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("forwards the auth NextResponse when validateAuthContext rejects", async () => {
const failure = NextResponse.json({ error: "Unauthorized" }, { status: 401 });
vi.mocked(validateAuthContext).mockResolvedValue(failure);

const res = await validateGetChatRequest(makeReq(), "chat_1");
expect(res).toBe(failure);
expect(selectChats).not.toHaveBeenCalled();
expect(selectSessions).not.toHaveBeenCalled();
});

it("returns 404 when the chat is missing", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "tok" });
vi.mocked(selectChats).mockResolvedValue([]);

const res = await validateGetChatRequest(makeReq(), "chat_missing");
expect(res).toBeInstanceOf(NextResponse);
if (res instanceof NextResponse) {
expect(res.status).toBe(404);
}
expect(selectSessions).not.toHaveBeenCalled();
});

it("returns 403 when the chat's session belongs to another account", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "tok" });
vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]);
vi.mocked(selectSessions).mockResolvedValue([
baseSessionRow({ id: "sess_1", account_id: "someone-else" }),
]);

const res = await validateGetChatRequest(makeReq(), "chat_1");
expect(res).toBeInstanceOf(NextResponse);
if (res instanceof NextResponse) {
expect(res.status).toBe(403);
}
});

it("returns 403 when the chat's session is missing (orphan)", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "tok" });
vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]);
vi.mocked(selectSessions).mockResolvedValue([]);

const res = await validateGetChatRequest(makeReq(), "chat_1");
expect(res).toBeInstanceOf(NextResponse);
if (res instanceof NextResponse) {
expect(res.status).toBe(403);
}
});

it("returns the chat row when the caller owns its session", async () => {
const chat = baseChatRow({ id: "chat_1", session_id: "sess_1" });
vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "tok" });
vi.mocked(selectChats).mockResolvedValue([chat]);
vi.mocked(selectSessions).mockResolvedValue([
baseSessionRow({ id: "sess_1", account_id: accountId }),
]);

const res = await validateGetChatRequest(makeReq(), "chat_1");
expect(res).toEqual(chat);
expect(selectChats).toHaveBeenCalledWith({ id: "chat_1" });
expect(selectSessions).toHaveBeenCalledWith({ id: "sess_1" });
});
});
31 changes: 31 additions & 0 deletions lib/chats/getChatHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateGetChatRequest } from "@/lib/chats/validateGetChatRequest";
import { toChatResponse } from "@/lib/sessions/toChatResponse";

export interface ChatResponse {
chat: ReturnType<typeof toChatResponse>;
}

/**
* Handles `GET /api/chats/{chatId}`. Returns the chat row in camelCase
* wire format (incl. `sessionId`), so a client holding only a
* `/chat/[roomId]` URL can recover the `sessionId` the workflow
* transport and `chat_messages` history read require.
*
* @param request - The incoming request (carries auth).
* @param chatId - The chat id from the route params.
* @returns A NextResponse with `{ chat }` on 200, or a 401/403/404 error.
*/
export async function getChatHandler(request: NextRequest, chatId: string): Promise<NextResponse> {
const chat = await validateGetChatRequest(request, chatId);
if (chat instanceof NextResponse) {
return chat;
}

return NextResponse.json({ chat: toChatResponse(chat) } satisfies ChatResponse, {
status: 200,
headers: getCorsHeaders(),
});
}
55 changes: 55 additions & 0 deletions lib/chats/validateGetChatRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectChats } from "@/lib/supabase/chats/selectChats";
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
import type { Tables } from "@/types/database.types";

/**
* Validates a `GET /api/chats/{chatId}` request end-to-end:
* 1. Authenticates the caller via Privy Bearer / x-api-key
* 2. Loads the chat by id
* 3. Confirms the caller owns the chat's parent session
*
* Mirrors `validateGetSessionChatRequest`'s ownership check, but resolves
* the session from the chat row instead of requiring `sessionId` in the
* path — so a client holding only a `/chat/[roomId]` URL can recover the
* `sessionId` it needs for the workflow transport.
*
* Returns a 401/403/404 NextResponse for the first failure, or the chat row.
*
* @param request - The incoming request.
* @param chatId - The id of the chat being fetched.
* @returns A NextResponse on failure, or the chat row on success.
*/
export async function validateGetChatRequest(
request: NextRequest,
chatId: string,
): Promise<NextResponse | Tables<"chats">> {
const auth = await validateAuthContext(request);
if (auth instanceof NextResponse) {
return auth;
}

const chatRows = await selectChats({ id: chatId });
const chat = chatRows[0] ?? null;

if (!chat) {
return NextResponse.json(
{ status: "error", error: "Chat not found" },
{ status: 404, headers: getCorsHeaders() },
);
}

const sessionRows = await selectSessions({ id: chat.session_id });
const session = sessionRows[0] ?? null;

if (!session || session.account_id !== auth.accountId) {
return NextResponse.json(
{ status: "error", error: "Forbidden" },
{ status: 403, headers: getCorsHeaders() },
);
}

return chat;
}
Loading