-
Notifications
You must be signed in to change notification settings - Fork 9
promote: test → main (GET /api/sessions/[sessionId]) #517
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06f0822
e2c3167
e1ac582
97724c4
4c17ee7
5f46e70
b2dd017
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
| }, | ||
| }); | ||
| }); | ||
| }); |
| 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); | ||
| } | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const fetchCache = "force-no-store"; | ||
| export const revalidate = 0; | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Return a consistent 500 JSON+CORS response on data-layer failures.
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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: recoupable/api
Length of output: 218
🏁 Script executed:
Repository: recoupable/api
Length of output: 1740
🏁 Script executed:
Repository: recoupable/api
Length of output: 98
🏁 Script executed:
Repository: recoupable/api
Length of output: 118
🏁 Script executed:
Repository: recoupable/api
Length of output: 2105
🏁 Script executed:
Repository: recoupable/api
Length of output: 1634
🏁 Script executed:
Repository: recoupable/api
Length of output: 2536
🏁 Script executed:
# Check the validation function pattern cat -n lib/accounts/validateAddArtistBody.tsRepository: recoupable/api
Length of output: 1376
🏁 Script executed:
# Check another validation function cat -n lib/accounts/validateCreateAccountBody.tsRepository: recoupable/api
Length of output: 1407
🏁 Script executed:
Repository: recoupable/api
Length of output: 5345
🏁 Script executed:
Repository: recoupable/api
Length of output: 290
🏁 Script executed:
Repository: recoupable/api
Length of output: 1149
🏁 Script executed:
Repository: recoupable/api
Length of output: 1938
🏁 Script executed:
# Check the validateGetAccountParams and validateAccountParams patterns cat -n lib/accounts/validateGetAccountParams.tsRepository: recoupable/api
Length of output: 1725
🏁 Script executed:
Repository: recoupable/api
Length of output: 1324
🏁 Script executed:
Repository: recoupable/api
Length of output: 40
🏁 Script executed:
Repository: recoupable/api
Length of output: 4885
🏁 Script executed:
Repository: recoupable/api
Length of output: 280
🏁 Script executed:
Repository: recoupable/api
Length of output: 40
🏁 Script executed:
Repository: recoupable/api
Length of output: 40
🏁 Script executed:
Repository: recoupable/api
Length of output: 1906
Add parameter validation for
sessionIdusing Zod before calling the handler.The route currently forwards the
sessionIdparameter without validation. Create a validation function following the established pattern in the codebase (similar tovalidateAccountParamsorvalidateAddArtistBody) that validates the parameter format and returns either the validated value or aNextResponseerror.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
🤖 Prompt for AI Agents