Skip to content
163 changes: 163 additions & 0 deletions app/api/sessions/[sessionId]/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
import { GET, OPTIONS } from "../route";
import type { Tables } from "@/types/database.types";

type SessionRow = Tables<"sessions">;

vi.mock("@/lib/supabase/sessions/selectSessions", () => ({
selectSessions: vi.fn(),
}));

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

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

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

function makeReq(url = "https://example.com/api/sessions/sess_1"): NextRequest {
return new NextRequest(url);
}

const mockRow: SessionRow = {
id: "sess_1",
account_id: "acc-uuid-1",
title: "Test session",
status: "running",
repo_owner: "acme",
repo_name: "demo",
branch: "main",
clone_url: "https://github.com/acme/demo.git",
is_new_branch: false,
global_skill_refs: [],
sandbox_state: { type: "vercel" },
lifecycle_state: "active",
lifecycle_version: 1,
last_activity_at: "2026-05-04T00:00:00.000Z",
sandbox_expires_at: null,
hibernate_after: null,
lifecycle_run_id: null,
lifecycle_error: null,
lines_added: 12,
lines_removed: 3,
snapshot_url: null,
snapshot_created_at: null,
snapshot_size_bytes: null,
cached_diff: null,
cached_diff_updated_at: null,
created_at: "2026-05-01T00:00:00.000Z",
updated_at: "2026-05-04T00:00:00.000Z",
};

describe("OPTIONS /api/sessions/[sessionId]", () => {
it("returns 200 with CORS headers", async () => {
const res = await OPTIONS();
expect(res.status).toBe(200);
});
});

describe("GET /api/sessions/[sessionId]", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns 401 when auth fails", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
);

const res = await GET(makeReq(), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(401);
expect(selectSessions).not.toHaveBeenCalled();
});

it("returns 404 when session does not exist", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue([]);

const res = await GET(makeReq(), {
params: Promise.resolve({ sessionId: "sess_missing" }),
});
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
status: "error",
error: "Session not found",
});
expect(selectSessions).toHaveBeenCalledWith({ id: "sess_missing" });
});

it("returns 403 when session is owned by a different account", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-OTHER",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue([mockRow]);

const res = await GET(makeReq(), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(403);
expect(await res.json()).toEqual({
status: "error",
error: "Forbidden",
});
});

it("returns 200 with camelCase session shape on happy path", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: "acc-uuid-1",
orgId: null,
authToken: "tok",
});
vi.mocked(selectSessions).mockResolvedValue([mockRow]);

const res = await GET(makeReq(), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({
session: {
id: "sess_1",
userId: "acc-uuid-1",
title: "Test session",
status: "running",
repoOwner: "acme",
repoName: "demo",
branch: "main",
cloneUrl: "https://github.com/acme/demo.git",
isNewBranch: false,
globalSkillRefs: [],
sandboxState: { type: "vercel" },
lifecycleState: "active",
lifecycleVersion: 1,
lastActivityAt: "2026-05-04T00:00:00.000Z",
sandboxExpiresAt: null,
hibernateAfter: null,
lifecycleRunId: null,
lifecycleError: null,
linesAdded: 12,
linesRemoved: 3,
snapshotUrl: null,
snapshotCreatedAt: null,
snapshotSizeBytes: null,
cachedDiff: null,
cachedDiffUpdatedAt: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-04T00:00:00.000Z",
},
});
});
});
42 changes: 42 additions & 0 deletions app/api/sessions/[sessionId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getSessionByIdHandler } from "@/lib/sessions/getSessionByIdHandler";

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

/**
* GET /api/sessions/{sessionId}
*
* Reads a single agent session by id. Authenticates via Privy Bearer
* token or x-api-key header. Returns 404 if the session does not exist
* and 403 if it exists but is not owned by the authenticated account.
*
* Response shape mirrors open-agents' /api/sessions/[sessionId] so the
* existing frontend can cut over to api without code changes.
*
* @param request - The request object
* @param options - Route options containing the async params
* @param options.params - Route params containing the session id
* @returns A NextResponse with `{ session }` on 200, or an error.
*/
export async function GET(
request: NextRequest,
options: { params: Promise<{ sessionId: string }> },
) {
const { sessionId } = await options.params;
return getSessionByIdHandler(request, sessionId);
Comment on lines +32 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

find app/api -name "route.ts" -type f | head -5

Repository: recoupable/api

Length of output: 218


🏁 Script executed:

cat -n app/api/sessions/\[sessionId\]/route.ts

Repository: recoupable/api

Length of output: 1740


🏁 Script executed:

# Check if getSessionByIdHandler exists and review its signature
find . -name "getSessionByIdHandler*" -type f

Repository: recoupable/api

Length of output: 98


🏁 Script executed:

# Check other API routes to see validation patterns
grep -r "safeParse\|z\.object" app/api --include="*.ts" -l | head -10

Repository: recoupable/api

Length of output: 118


🏁 Script executed:

cat -n ./lib/sessions/getSessionByIdHandler.ts

Repository: recoupable/api

Length of output: 2105


🏁 Script executed:

cat -n app/api/accounts/artists/route.ts

Repository: recoupable/api

Length of output: 1634


🏁 Script executed:

cat -n app/api/accounts/route.ts

Repository: recoupable/api

Length of output: 2536


🏁 Script executed:

# Check the validation function pattern
cat -n lib/accounts/validateAddArtistBody.ts

Repository: recoupable/api

Length of output: 1376


🏁 Script executed:

# Check another validation function
cat -n lib/accounts/validateCreateAccountBody.ts

Repository: recoupable/api

Length of output: 1407


🏁 Script executed:

# Search for other route parameters that might need validation
grep -r "options.params" app/api --include="*.ts" -B 2 -A 2

Repository: recoupable/api

Length of output: 5345


🏁 Script executed:

# Check if there are any validation functions for path parameters
find lib -name "validate*" -type f | xargs grep -l "params\|Params" | head -5

Repository: recoupable/api

Length of output: 290


🏁 Script executed:

# Check the accounts/[id]/catalogs route to see how it handles params
cat -n app/api/accounts/\[id\]/catalogs/route.ts

Repository: recoupable/api

Length of output: 1149


🏁 Script executed:

# Check another parameterized route with handler
cat -n app/api/artists/\[id\]/route.ts | head -50

Repository: recoupable/api

Length of output: 1938


🏁 Script executed:

# Check the validateGetAccountParams and validateAccountParams patterns
cat -n lib/accounts/validateGetAccountParams.ts

Repository: recoupable/api

Length of output: 1725


🏁 Script executed:

cat -n lib/accounts/validateAccountParams.ts

Repository: recoupable/api

Length of output: 1324


🏁 Script executed:

# Search for how other routes with IDs validate their params
grep -r "validateGetAccountParams\|validateAccountParams" app/api --include="*.ts" -B 2 -A 2

Repository: recoupable/api

Length of output: 40


🏁 Script executed:

# Search for usage of these validation functions in routes
grep -r "validateGetAccountParams\|validateAccountParams" . --include="*.ts" | grep -v node_modules

Repository: recoupable/api

Length of output: 4885


🏁 Script executed:

# Look for session parameter validation patterns
grep -r "sessionId" lib --include="*.ts" -l | head -5

Repository: recoupable/api

Length of output: 280


🏁 Script executed:

# Check if there's a pattern for validating route params across the API
grep -r "safeParse.*params\|validateGet.*Params" app/api --include="*.ts" -B 3 -A 3

Repository: recoupable/api

Length of output: 40


🏁 Script executed:

# Check if there are tests for the sessions route
find . -name "*sessions*" -type f | grep -i test

Repository: recoupable/api

Length of output: 40


🏁 Script executed:

# Look at the getAccountHandler to see the pattern
cat -n lib/accounts/getAccountHandler.ts | head -50

Repository: recoupable/api

Length of output: 1906


Add parameter validation for sessionId using Zod before calling the handler.

The route currently forwards the sessionId parameter without validation. Create a validation function following the established pattern in the codebase (similar to validateAccountParams or validateAddArtistBody) that validates the parameter format and returns either the validated value or a NextResponse error.

Proposed validation pattern
 import { NextRequest, NextResponse } from "next/server";
 import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
 import { getSessionByIdHandler } from "@/lib/sessions/getSessionByIdHandler";
+import { z } from "zod";
+
+const sessionIdParamsSchema = z.object({
+  sessionId: z.string().min(1, "sessionId cannot be empty"),
+});

 export async function GET(
   request: NextRequest,
   options: { params: Promise<{ sessionId: string }> },
 ) {
-  const { sessionId } = await options.params;
-  return getSessionByIdHandler(request, sessionId);
+  const parsed = sessionIdParamsSchema.safeParse(await options.params);
+  if (!parsed.success) {
+    const firstError = parsed.error.issues[0];
+    return NextResponse.json(
+      {
+        status: "error",
+        missing_fields: firstError.path,
+        error: firstError.message,
+      },
+      { status: 400, headers: getCorsHeaders() },
+    );
+  }
+  return getSessionByIdHandler(request, parsed.data.sessionId);
+}

This follows the coding guideline: "All API endpoints should use a validate function for input parsing using Zod for schema validation."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function GET(
request: NextRequest,
options: { params: Promise<{ sessionId: string }> },
) {
const { sessionId } = await options.params;
return getSessionByIdHandler(request, sessionId);
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { getSessionByIdHandler } from "@/lib/sessions/getSessionByIdHandler";
import { z } from "zod";
const sessionIdParamsSchema = z.object({
sessionId: z.string().min(1, "sessionId cannot be empty"),
});
export async function GET(
request: NextRequest,
options: { params: Promise<{ sessionId: string }> },
) {
const parsed = sessionIdParamsSchema.safeParse(await options.params);
if (!parsed.success) {
const firstError = parsed.error.issues[0];
return NextResponse.json(
{
status: "error",
missing_fields: firstError.path,
error: firstError.message,
},
{ status: 400, headers: getCorsHeaders() },
);
}
return getSessionByIdHandler(request, parsed.data.sessionId);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/api/sessions/`[sessionId]/route.ts around lines 32 - 37, Add a Zod-based
parameter validator for sessionId and call it from the GET route before invoking
getSessionByIdHandler: create a function (e.g., validateSessionParams) modeled
after validateAccountParams / validateAddArtistBody that accepts the raw params
object, defines a Zod schema for { sessionId: string } (with any required format
checks), returns the parsed value on success or a NextResponse with a 400 error
on failure, then update the GET handler to await
validateSessionParams(options.params) and only call
getSessionByIdHandler(request, sessionId) when validation passes.

}

export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export const revalidate = 0;
51 changes: 51 additions & 0 deletions lib/sessions/getSessionByIdHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
import { toSessionResponse } from "@/lib/sessions/toSessionResponse";

/**
* Handles GET /api/sessions/{sessionId}.
*
* Reads a single agent session by id. Authenticates via Privy Bearer
* token or x-api-key header. Returns 404 if the session does not exist
* and 403 if it exists but is not owned by the authenticated account.
*
* Response shape mirrors open-agents' /api/sessions/[sessionId] so the
* existing frontend can cut over to api without code changes.
*
* @param request - The incoming request.
* @param sessionId - The id of the session to fetch.
* @returns A NextResponse with `{ session }` on 200, or an error.
*/
export async function getSessionByIdHandler(
request: NextRequest,
sessionId: string,
): Promise<NextResponse> {
const auth = await validateAuthContext(request);
if (auth instanceof NextResponse) {
return auth;
}

const rows = await selectSessions({ id: sessionId });
const row = rows[0] ?? null;

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

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

return NextResponse.json(
{ session: toSessionResponse(row) },
{ status: 200, headers: getCorsHeaders() },
);
Comment on lines +30 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return a consistent 500 JSON+CORS response on data-layer failures.

selectSessions() errors currently bubble out and can break your consistent envelope/CORS contract for this endpoint.

Proposed fix
 export async function getSessionByIdHandler(
   request: NextRequest,
   sessionId: string,
 ): Promise<NextResponse> {
   const auth = await validateAuthContext(request);
   if (auth instanceof NextResponse) {
     return auth;
   }

-  const rows = await selectSessions({ id: sessionId });
+  let rows;
+  try {
+    rows = await selectSessions({ id: sessionId });
+  } catch {
+    return NextResponse.json(
+      { status: "error", error: "Internal server error" },
+      { status: 500, headers: getCorsHeaders() },
+    );
+  }
   const row = rows[0] ?? null;

As per coding guidelines, “Handle errors gracefully” and for API logic “Proper error handling and validation.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const rows = await selectSessions({ id: sessionId });
const row = rows[0] ?? null;
if (!row) {
return NextResponse.json(
{ status: "error", error: "Session not found" },
{ status: 404, headers: getCorsHeaders() },
);
}
if (row.account_id !== auth.accountId) {
return NextResponse.json(
{ status: "error", error: "Forbidden" },
{ status: 403, headers: getCorsHeaders() },
);
}
return NextResponse.json(
{ session: toSessionResponse(row) },
{ status: 200, headers: getCorsHeaders() },
);
export async function getSessionByIdHandler(
request: NextRequest,
sessionId: string,
): Promise<NextResponse> {
const auth = await validateAuthContext(request);
if (auth instanceof NextResponse) {
return auth;
}
let rows;
try {
rows = await selectSessions({ id: sessionId });
} catch {
return NextResponse.json(
{ status: "error", error: "Internal server error" },
{ status: 500, headers: getCorsHeaders() },
);
}
const row = rows[0] ?? null;
if (!row) {
return NextResponse.json(
{ status: "error", error: "Session not found" },
{ status: 404, headers: getCorsHeaders() },
);
}
if (row.account_id !== auth.accountId) {
return NextResponse.json(
{ status: "error", error: "Forbidden" },
{ status: 403, headers: getCorsHeaders() },
);
}
return NextResponse.json(
{ session: toSessionResponse(row) },
{ status: 200, headers: getCorsHeaders() },
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/sessions/getSessionByIdHandler.ts` around lines 30 - 50, selectSessions
can throw and currently bubbles out, breaking the endpoint's JSON+CORS contract;
wrap the call to selectSessions({ id: sessionId }) in a try/catch inside
getSessionByIdHandler (or the handler function in this file), and on any caught
error return a NextResponse.json({ status: "error", error: "Internal server
error" }, { status: 500, headers: getCorsHeaders() }); optionally log the caught
error before returning; keep the rest of the control flow (not-found, forbidden,
success using toSessionResponse(row)) unchanged.

}
Loading