diff --git a/app/api/sandbox/__tests__/route.test.ts b/app/api/sandbox/__tests__/route.test.ts new file mode 100644 index 000000000..e8b7235ab --- /dev/null +++ b/app/api/sandbox/__tests__/route.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { POST } from "@/app/api/sandbox/route"; +import { createSandboxHandler } from "@/lib/sandbox/createSandboxHandler"; + +vi.mock("@/lib/sandbox/createSandboxHandler", () => ({ + createSandboxHandler: vi.fn(async () => NextResponse.json({ ok: true }, { status: 200 })), +})); + +describe("POST /api/sandbox route shell", () => { + it("delegates to createSandboxHandler", async () => { + const req = new NextRequest("http://localhost/api/sandbox", { method: "POST" }); + const res = await POST(req); + + expect(createSandboxHandler).toHaveBeenCalledWith(req); + expect(res.status).toBe(200); + }); +}); diff --git a/app/api/sandbox/route.ts b/app/api/sandbox/route.ts new file mode 100644 index 000000000..01fb45e0c --- /dev/null +++ b/app/api/sandbox/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createSandboxHandler } from "@/lib/sandbox/createSandboxHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * `POST /api/sandbox` — provision (or resume) a Sandbox bound to a session. + * + * @param request - The incoming request. + * @returns A NextResponse with `{ createdAt, timeout, currentBranch, mode, timing }` on 200, or an error. + */ +export async function POST(request: NextRequest) { + return createSandboxHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/app/api/sandbox/status/__tests__/route.test.ts b/app/api/sandbox/status/__tests__/route.test.ts new file mode 100644 index 000000000..ca9956776 --- /dev/null +++ b/app/api/sandbox/status/__tests__/route.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { GET } from "@/app/api/sandbox/status/route"; +import { getSandboxStatusHandler } from "@/lib/sandbox/getSandboxStatusHandler"; + +vi.mock("@/lib/sandbox/getSandboxStatusHandler", () => ({ + getSandboxStatusHandler: vi.fn(async () => NextResponse.json({ ok: true }, { status: 200 })), +})); + +describe("GET /api/sandbox/status route shell", () => { + it("delegates to getSandboxStatusHandler", async () => { + const req = new NextRequest("http://localhost/api/sandbox/status?sessionId=s", { + method: "GET", + }); + const res = await GET(req); + + expect(getSandboxStatusHandler).toHaveBeenCalledWith(req); + expect(res.status).toBe(200); + }); +}); diff --git a/app/api/sandbox/status/route.ts b/app/api/sandbox/status/route.ts new file mode 100644 index 000000000..4a062b663 --- /dev/null +++ b/app/api/sandbox/status/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSandboxStatusHandler } from "@/lib/sandbox/getSandboxStatusHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +/** + * `GET /api/sandbox/status?sessionId=...` — current lifecycle/runtime state for the sandbox bound to a session. + * + * @param request - The incoming request. + * @returns A NextResponse with `{ status, hasSnapshot, lifecycleVersion, lifecycle }` on 200, or an error. + */ +export async function GET(request: NextRequest) { + return getSandboxStatusHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/github/__tests__/getServiceGithubToken.test.ts b/lib/github/__tests__/getServiceGithubToken.test.ts new file mode 100644 index 000000000..5fb28265c --- /dev/null +++ b/lib/github/__tests__/getServiceGithubToken.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; + +const ORIGINAL = process.env.GITHUB_TOKEN; + +beforeEach(() => { + delete process.env.GITHUB_TOKEN; +}); + +afterEach(() => { + if (ORIGINAL === undefined) { + delete process.env.GITHUB_TOKEN; + } else { + process.env.GITHUB_TOKEN = ORIGINAL; + } +}); + +describe("getServiceGithubToken", () => { + it("returns undefined when GITHUB_TOKEN is unset", () => { + expect(getServiceGithubToken()).toBeUndefined(); + }); + + it("returns undefined when GITHUB_TOKEN is the empty string", () => { + process.env.GITHUB_TOKEN = ""; + expect(getServiceGithubToken()).toBeUndefined(); + }); + + it("returns the token when set", () => { + process.env.GITHUB_TOKEN = "ghs_secret"; + expect(getServiceGithubToken()).toBe("ghs_secret"); + }); +}); diff --git a/lib/github/getServiceGithubToken.ts b/lib/github/getServiceGithubToken.ts new file mode 100644 index 000000000..b3af7da06 --- /dev/null +++ b/lib/github/getServiceGithubToken.ts @@ -0,0 +1,12 @@ +/** + * Returns the service-account GitHub token used for cloning private + * repositories into sandboxes. Returns undefined when the env var is + * unset or empty so callers can fall back to public-repo behavior + * without crashing. + * + * @returns The token string, or undefined. + */ +export function getServiceGithubToken(): string | undefined { + const token = process.env.GITHUB_TOKEN; + return token && token.length > 0 ? token : undefined; +} diff --git a/lib/sandbox/__tests__/buildLifecycle.test.ts b/lib/sandbox/__tests__/buildLifecycle.test.ts new file mode 100644 index 000000000..63a8014ee --- /dev/null +++ b/lib/sandbox/__tests__/buildLifecycle.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { buildLifecycle } from "@/lib/sandbox/buildLifecycle"; + +const ISO = "2030-01-01T00:00:00.000Z"; +const EPOCH = Date.parse(ISO); + +describe("buildLifecycle", () => { + it("converts every ISO timestamp on the row to epoch ms and sets serverTime", () => { + const row = { + lifecycle_state: "active", + last_activity_at: ISO, + hibernate_after: ISO, + sandbox_expires_at: ISO, + } as any; + + const result = buildLifecycle(row); + + expect(result).toEqual({ + serverTime: expect.any(Number), + state: "active", + lastActivityAt: EPOCH, + hibernateAfter: EPOCH, + sandboxExpiresAt: EPOCH, + }); + }); + + it("preserves null fields and a null lifecycle_state as-is", () => { + const row = { + lifecycle_state: null, + last_activity_at: null, + hibernate_after: null, + sandbox_expires_at: null, + } as any; + + const result = buildLifecycle(row); + + expect(result.state).toBeNull(); + expect(result.lastActivityAt).toBeNull(); + expect(result.hibernateAfter).toBeNull(); + expect(result.sandboxExpiresAt).toBeNull(); + }); +}); diff --git a/lib/sandbox/__tests__/createSandboxHandler.test.ts b/lib/sandbox/__tests__/createSandboxHandler.test.ts new file mode 100644 index 000000000..b79c2b987 --- /dev/null +++ b/lib/sandbox/__tests__/createSandboxHandler.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { createSandboxHandler } from "@/lib/sandbox/createSandboxHandler"; +import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sandbox/validateCreateSandboxBody", () => ({ + validateCreateSandboxBody: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/sandbox/factory", () => ({ + connectSandbox: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/updateSession", () => ({ + updateSession: vi.fn(), +})); +vi.mock("@/lib/github/getServiceGithubToken", () => ({ + getServiceGithubToken: vi.fn(() => "ghs_test_token"), +})); + +const ACCOUNT_ID = "acc-1"; + +function makeReq(): NextRequest { + return new NextRequest("http://localhost/api/sandbox", { method: "POST" }); +} + +function fakeSandbox(overrides: Partial> = {}) { + return { + timeout: 1_800_000, + expiresAt: Date.parse("2030-01-01T00:00:00.000Z"), + currentBranch: "main", + getState: () => ({ type: "vercel", sandboxName: "session-sess-1" }), + ...overrides, + }; +} + +describe("createSandboxHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateCreateSandboxBody).mockResolvedValue({ + body: { + repoUrl: "https://github.com/o/r", + sessionId: "sess-1", + }, + auth: { accountId: ACCOUNT_ID, orgId: null, authToken: "k" }, + }); + vi.mocked(selectSessions).mockResolvedValue([{ id: "sess-1", account_id: ACCOUNT_ID } as any]); + vi.mocked(connectSandbox).mockResolvedValue( + fakeSandbox() as unknown as Awaited>, + ); + vi.mocked(updateSession).mockResolvedValue({} as any); + }); + + it("short-circuits with the validator's response on validation failure", async () => { + const fail = NextResponse.json({ status: "error", error: "bad" }, { status: 400 }); + vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce(fail); + + const res = await createSandboxHandler(makeReq()); + + expect(res).toBe(fail); + expect(connectSandbox).not.toHaveBeenCalled(); + }); + + it("returns 404 when sessionId is provided but the session does not exist", async () => { + vi.mocked(selectSessions).mockResolvedValueOnce([]); + + const res = await createSandboxHandler(makeReq()); + + expect(res.status).toBe(404); + expect(connectSandbox).not.toHaveBeenCalled(); + }); + + it("returns 403 when the session is not owned by the authenticated account", async () => { + vi.mocked(selectSessions).mockResolvedValueOnce([ + { id: "sess-1", account_id: "someone-else" } as any, + ]); + + const res = await createSandboxHandler(makeReq()); + + expect(res.status).toBe(403); + expect(connectSandbox).not.toHaveBeenCalled(); + }); + + it("returns 502 when the sandbox provider throws", async () => { + vi.mocked(connectSandbox).mockRejectedValueOnce(new Error("vercel down")); + + const res = await createSandboxHandler(makeReq()); + + expect(res.status).toBe(502); + }); + + it("returns 200 with the documented response shape on success", async () => { + const res = await createSandboxHandler(makeReq()); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toMatchObject({ + timeout: 1_800_000, + currentBranch: "main", + mode: "vercel", + }); + expect(typeof body.createdAt).toBe("number"); + expect(typeof body.timing.readyMs).toBe("number"); + }); + + it("reports currentBranch from the sandbox handle (not request input)", async () => { + vi.mocked(connectSandbox).mockResolvedValueOnce( + fakeSandbox({ currentBranch: "release/v2" }) as unknown as Awaited< + ReturnType + >, + ); + + const res = await createSandboxHandler(makeReq()); + + const body = await res.json(); + expect(body.currentBranch).toBe("release/v2"); + }); + + it("persists sandbox state and clears stale snapshot fields on the session row", async () => { + await createSandboxHandler(makeReq()); + + expect(updateSession).toHaveBeenCalledWith( + "sess-1", + expect.objectContaining({ + sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, + lifecycle_state: "active", + snapshot_url: null, + snapshot_created_at: null, + }), + ); + }); + + it("plumbs the service github token into connectSandbox options", async () => { + await createSandboxHandler(makeReq()); + + const arg = vi.mocked(connectSandbox).mock.calls[0]?.[0]; + expect(arg).toBeDefined(); + if (!arg || !("options" in arg)) throw new Error("expected new-API config shape"); + expect(arg.options?.githubToken).toBe("ghs_test_token"); + }); + + it("skips the session-row write when no sessionId is provided", async () => { + vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({ + body: { repoUrl: "https://github.com/o/r" }, + auth: { accountId: ACCOUNT_ID, orgId: null, authToken: "k" }, + }); + + const res = await createSandboxHandler(makeReq()); + + expect(res.status).toBe(200); + expect(updateSession).not.toHaveBeenCalled(); + expect(selectSessions).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts b/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts new file mode 100644 index 000000000..2bf66ea01 --- /dev/null +++ b/lib/sandbox/__tests__/getSandboxStatusHandler.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getSandboxStatusHandler } from "@/lib/sandbox/getSandboxStatusHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; + +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(), +})); + +const ACCOUNT_ID = "acc-1"; +const FAR_FUTURE = "2099-01-01T00:00:00.000Z"; +const FAR_PAST = "2000-01-01T00:00:00.000Z"; + +function makeReq(query = "?sessionId=sess-1"): NextRequest { + return new NextRequest(`http://localhost/api/sandbox/status${query}`, { + method: "GET", + }); +} + +const baseRow = { + id: "sess-1", + account_id: ACCOUNT_ID, + sandbox_state: null as unknown, + lifecycle_state: null as string | null, + lifecycle_version: 0, + sandbox_expires_at: null as string | null, + hibernate_after: null as string | null, + last_activity_at: null as string | null, + snapshot_url: null as string | null, +}; + +describe("getSandboxStatusHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_ID, + orgId: null, + authToken: "k", + }); + }); + + it("returns the auth response unchanged when auth fails", async () => { + const fail = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValueOnce(fail); + + const res = await getSandboxStatusHandler(makeReq()); + + expect(res).toBe(fail); + }); + + it("returns 400 when sessionId is missing from the query string", async () => { + const res = await getSandboxStatusHandler(makeReq("")); + + expect(res.status).toBe(400); + }); + + it("returns 404 when no session exists with the given id", async () => { + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await getSandboxStatusHandler(makeReq()); + + expect(res.status).toBe(404); + }); + + it("returns 403 when the session is not owned by the authenticated account", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { ...baseRow, account_id: "someone-else" } as any, + ]); + + const res = await getSandboxStatusHandler(makeReq()); + + expect(res.status).toBe(403); + }); + + it("returns status='active' when sandbox_state is set and not expired", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { + ...baseRow, + sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, + lifecycle_state: "active", + lifecycle_version: 3, + sandbox_expires_at: FAR_FUTURE, + } as any, + ]); + + const res = await getSandboxStatusHandler(makeReq()); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe("active"); + expect(body.lifecycleVersion).toBe(3); + expect(body.lifecycle.state).toBe("active"); + expect(typeof body.lifecycle.serverTime).toBe("number"); + expect(body.lifecycle.sandboxExpiresAt).toBe(Date.parse(FAR_FUTURE)); + }); + + it("returns status='no_sandbox' when sandbox_state is null", async () => { + vi.mocked(selectSessions).mockResolvedValue([{ ...baseRow } as any]); + + const res = await getSandboxStatusHandler(makeReq()); + + const body = await res.json(); + expect(body.status).toBe("no_sandbox"); + expect(body.hasSnapshot).toBe(false); + }); + + it("returns status='no_sandbox' when sandbox is expired", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { + ...baseRow, + sandbox_state: { type: "vercel" }, + sandbox_expires_at: FAR_PAST, + } as any, + ]); + + const res = await getSandboxStatusHandler(makeReq()); + + const body = await res.json(); + expect(body.status).toBe("no_sandbox"); + }); + + it("returns hasSnapshot=true when snapshot_url is set", async () => { + vi.mocked(selectSessions).mockResolvedValue([{ ...baseRow, snapshot_url: "snap://x" } as any]); + + const res = await getSandboxStatusHandler(makeReq()); + + const body = await res.json(); + expect(body.hasSnapshot).toBe(true); + }); + + // Regression: see PR #522 smoke-test comment. POST /api/sessions writes + // sandbox_state: { type: "vercel" } as a type stub on insert. Before the + // fix, isSandboxActive treated any truthy sandbox_state + null + // sandbox_expires_at as active, so the chat loading UX would flip to + // "ready" the moment the session was created — before any sandbox + // existed. Status must report no_sandbox until real runtime metadata + // (sandboxName) is written by POST /api/sandbox. + it("returns status='no_sandbox' for the freshly-created-session type stub (no sandboxName, no expiry)", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { + ...baseRow, + sandbox_state: { type: "vercel" }, + sandbox_expires_at: null, + lifecycle_state: "provisioning", + } as any, + ]); + + const res = await getSandboxStatusHandler(makeReq()); + + const body = await res.json(); + expect(body.status).toBe("no_sandbox"); + }); + + it("returns status='active' once sandboxName is set on the state, even without explicit expiry", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + { + ...baseRow, + sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, + sandbox_expires_at: null, + } as any, + ]); + + const res = await getSandboxStatusHandler(makeReq()); + + const body = await res.json(); + expect(body.status).toBe("active"); + }); +}); diff --git a/lib/sandbox/__tests__/getSessionSandboxName.test.ts b/lib/sandbox/__tests__/getSessionSandboxName.test.ts new file mode 100644 index 000000000..99a642edb --- /dev/null +++ b/lib/sandbox/__tests__/getSessionSandboxName.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from "vitest"; +import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; + +describe("getSessionSandboxName", () => { + it("returns a deterministic name prefixed with 'session-'", () => { + expect(getSessionSandboxName("abc123")).toBe("session-abc123"); + }); + + it("returns the same value for the same input", () => { + const input = "uuid-style-id"; + expect(getSessionSandboxName(input)).toBe(getSessionSandboxName(input)); + }); +}); diff --git a/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts b/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts new file mode 100644 index 000000000..6b4f18966 --- /dev/null +++ b/lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; + +describe("hasRuntimeSandboxState", () => { + it("returns false for null", () => { + expect(hasRuntimeSandboxState(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(hasRuntimeSandboxState(undefined)).toBe(false); + }); + + it("returns false for non-object scalars", () => { + expect(hasRuntimeSandboxState("vercel")).toBe(false); + expect(hasRuntimeSandboxState(42)).toBe(false); + expect(hasRuntimeSandboxState(true)).toBe(false); + }); + + it("returns false for the type-only stub written at session creation", () => { + expect(hasRuntimeSandboxState({ type: "vercel" })).toBe(false); + }); + + it("returns true when sandboxName is set", () => { + expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "session-x" })).toBe(true); + }); + + it("returns false when sandboxName is the empty string", () => { + expect(hasRuntimeSandboxState({ type: "vercel", sandboxName: "" })).toBe(false); + }); +}); diff --git a/lib/sandbox/__tests__/isSandboxActive.test.ts b/lib/sandbox/__tests__/isSandboxActive.test.ts new file mode 100644 index 000000000..c8f08a1f3 --- /dev/null +++ b/lib/sandbox/__tests__/isSandboxActive.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { isSandboxActive } from "@/lib/sandbox/isSandboxActive"; + +const FAR_FUTURE = "2099-01-01T00:00:00.000Z"; +const FAR_PAST = "2000-01-01T00:00:00.000Z"; + +const baseRow = { + sandbox_state: null as unknown, + sandbox_expires_at: null as string | null, +}; + +describe("isSandboxActive", () => { + it("returns false when sandbox_state has no runtime metadata", () => { + expect(isSandboxActive({ ...baseRow, sandbox_state: { type: "vercel" } } as any)).toBe(false); + }); + + it("returns false when sandbox_state is null", () => { + expect(isSandboxActive({ ...baseRow } as any)).toBe(false); + }); + + it("returns true with a runtime sandboxName and a far-future expiry", () => { + expect( + isSandboxActive({ + ...baseRow, + sandbox_state: { type: "vercel", sandboxName: "session-x" }, + sandbox_expires_at: FAR_FUTURE, + } as any), + ).toBe(true); + }); + + it("returns false when expiry is in the past", () => { + expect( + isSandboxActive({ + ...baseRow, + sandbox_state: { type: "vercel", sandboxName: "session-x" }, + sandbox_expires_at: FAR_PAST, + } as any), + ).toBe(false); + }); + + it("returns true when sandboxName is set but expiry is null (no expiry to compare against)", () => { + expect( + isSandboxActive({ + ...baseRow, + sandbox_state: { type: "vercel", sandboxName: "session-x" }, + sandbox_expires_at: null, + } as any), + ).toBe(true); + }); +}); diff --git a/lib/sandbox/__tests__/isoToEpochMs.test.ts b/lib/sandbox/__tests__/isoToEpochMs.test.ts new file mode 100644 index 000000000..a8d448ca7 --- /dev/null +++ b/lib/sandbox/__tests__/isoToEpochMs.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { isoToEpochMs } from "@/lib/sandbox/isoToEpochMs"; + +describe("isoToEpochMs", () => { + it("returns null for null input", () => { + expect(isoToEpochMs(null)).toBeNull(); + }); + + it("returns null for an empty string", () => { + expect(isoToEpochMs("")).toBeNull(); + }); + + it("returns null for an unparseable string", () => { + expect(isoToEpochMs("not-a-date")).toBeNull(); + }); + + it("converts a valid ISO string to epoch milliseconds", () => { + expect(isoToEpochMs("2030-01-01T00:00:00.000Z")).toBe(Date.parse("2030-01-01T00:00:00.000Z")); + }); +}); diff --git a/lib/sandbox/__tests__/validateCreateSandboxBody.test.ts b/lib/sandbox/__tests__/validateCreateSandboxBody.test.ts new file mode 100644 index 000000000..e3894f2ed --- /dev/null +++ b/lib/sandbox/__tests__/validateCreateSandboxBody.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +const ACCOUNT_ID = "acc-1"; + +function makeReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/sandbox", { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": "k" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +describe("validateCreateSandboxBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT_ID, + orgId: null, + authToken: "k", + }); + }); + + it("returns the auth response unchanged when auth fails", async () => { + const failure = NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValueOnce(failure); + + const result = await validateCreateSandboxBody(makeReq({ repoUrl: "x" })); + + expect(result).toBe(failure); + }); + + it("returns 400 when repoUrl is missing", async () => { + const result = await validateCreateSandboxBody(makeReq({})); + + expect(result).toBeInstanceOf(NextResponse); + const res = result as NextResponse; + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.status).toBe("error"); + expect(json.missing_fields).toEqual(["repoUrl"]); + }); + + it("returns 400 when repoUrl is empty", async () => { + const result = await validateCreateSandboxBody(makeReq({ repoUrl: "" })); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when JSON body is malformed", async () => { + const result = await validateCreateSandboxBody(makeReq("not-json")); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when repoUrl is not a valid GitHub repository URL", async () => { + const result = await validateCreateSandboxBody(makeReq({ repoUrl: "https://gitlab.com/o/r" })); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 when repoUrl is not a URL at all", async () => { + const result = await validateCreateSandboxBody(makeReq({ repoUrl: "x" })); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns the validated body + auth on a minimal happy path", async () => { + const result = await validateCreateSandboxBody(makeReq({ repoUrl: "https://github.com/o/r" })); + + expect(result).not.toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) return; + expect(result.body.repoUrl).toBe("https://github.com/o/r"); + expect(result.body.sessionId).toBeUndefined(); + expect(result.auth.accountId).toBe(ACCOUNT_ID); + }); + + it("accepts a request with sessionId", async () => { + const result = await validateCreateSandboxBody( + makeReq({ + repoUrl: "https://github.com/o/r", + sessionId: "sess-1", + }), + ); + + expect(result).not.toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) return; + expect(result.body.sessionId).toBe("sess-1"); + }); + + it("strips an unknown branch input from the validated body", async () => { + const result = await validateCreateSandboxBody( + makeReq({ + repoUrl: "https://github.com/o/r", + branch: "feat/x", + }), + ); + + expect(result).not.toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) return; + expect((result.body as Record).branch).toBeUndefined(); + }); +}); diff --git a/lib/sandbox/buildLifecycle.ts b/lib/sandbox/buildLifecycle.ts new file mode 100644 index 000000000..4f2337354 --- /dev/null +++ b/lib/sandbox/buildLifecycle.ts @@ -0,0 +1,19 @@ +import { isoToEpochMs } from "@/lib/sandbox/isoToEpochMs"; +import type { Tables } from "@/types/database.types"; + +/** + * Projects the lifecycle-relevant columns of a `sessions` row into the + * docs-spec lifecycle envelope used by GET /api/sandbox/status. + * + * @param row - The `sessions` row. + * @returns The lifecycle envelope: serverTime, state, and three epoch-ms timestamps. + */ +export function buildLifecycle(row: Tables<"sessions">) { + return { + serverTime: Date.now(), + state: row.lifecycle_state, + lastActivityAt: isoToEpochMs(row.last_activity_at), + hibernateAfter: isoToEpochMs(row.hibernate_after), + sandboxExpiresAt: isoToEpochMs(row.sandbox_expires_at), + }; +} diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts new file mode 100644 index 000000000..eb3f9be21 --- /dev/null +++ b/lib/sandbox/createSandboxHandler.ts @@ -0,0 +1,113 @@ +import ms from "ms"; +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; +import type { Json } from "@/types/database.types"; + +const DEFAULT_TIMEOUT_MS = ms("30m"); +const DEFAULT_PORTS = [3000]; +const DEFAULT_BRANCH = "main"; + +/** + * Handles `POST /api/sandbox`. Provisions a Sandbox bound to the given + * session (or a one-shot sandbox when no `sessionId` is supplied) using + * the repo's default branch — there is no input branch override; the + * chat UX always works against whatever the repo treats as default. + * + * When a session is bound, the resolved `sandbox_state`, lifecycle, and + * expiry are written back to the `sessions` row so subsequent reads via + * `GET /api/sandbox/status` can report the sandbox as active. Stale + * `snapshot_url` / `snapshot_created_at` are cleared on a fresh + * provision so the UI does not surface a snapshot that no longer + * matches the current sandbox. + */ +export async function createSandboxHandler(request: NextRequest): Promise { + const validated = await validateCreateSandboxBody(request); + if (validated instanceof NextResponse) { + return validated; + } + const { body, auth } = validated; + + const sessionId = body.sessionId; + + let currentLifecycleVersion = 0; + if (sessionId) { + const rows = await selectSessions({ id: sessionId }); + const row = rows[0]; + + 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() }, + ); + } + + currentLifecycleVersion = row.lifecycle_version; + } + + const sandboxName = sessionId ? getSessionSandboxName(sessionId) : undefined; + const startTime = Date.now(); + + let sandbox; + try { + sandbox = await connectSandbox({ + state: { + type: "vercel", + ...(sandboxName ? { sandboxName } : {}), + source: { repo: body.repoUrl }, + }, + options: { + timeout: DEFAULT_TIMEOUT_MS, + ports: DEFAULT_PORTS, + githubToken: getServiceGithubToken(), + persistent: !!sandboxName, + resume: !!sandboxName, + createIfMissing: !!sandboxName, + }, + }); + } catch (error) { + console.error("[createSandboxHandler] connectSandbox failed:", error); + return NextResponse.json( + { status: "error", error: "Failed to provision sandbox" }, + { status: 502, headers: getCorsHeaders() }, + ); + } + + if (sessionId && sandbox.getState) { + const nextState = sandbox.getState() as Json; + const expiresAt = + typeof sandbox.expiresAt === "number" ? new Date(sandbox.expiresAt).toISOString() : null; + await updateSession(sessionId, { + sandbox_state: nextState, + lifecycle_state: "active", + lifecycle_version: currentLifecycleVersion + 1, + sandbox_expires_at: expiresAt, + last_activity_at: new Date().toISOString(), + snapshot_url: null, + snapshot_created_at: null, + }); + } + + return NextResponse.json( + { + createdAt: Date.now(), + timeout: sandbox.timeout ?? DEFAULT_TIMEOUT_MS, + currentBranch: sandbox.currentBranch ?? DEFAULT_BRANCH, + mode: "vercel", + timing: { readyMs: Date.now() - startTime }, + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sandbox/getSandboxStatusHandler.ts b/lib/sandbox/getSandboxStatusHandler.ts new file mode 100644 index 000000000..8c9e1b988 --- /dev/null +++ b/lib/sandbox/getSandboxStatusHandler.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { buildLifecycle } from "@/lib/sandbox/buildLifecycle"; +import { isSandboxActive } from "@/lib/sandbox/isSandboxActive"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; + +/** + * Handles `GET /api/sandbox/status`. Returns the current lifecycle and + * runtime state for the sandbox bound to a session — DB-only read, no + * upstream probe. Status is `"active"` when the session row carries a + * non-expired `sandbox_state` (with real runtime metadata), otherwise + * `"no_sandbox"`. `hasSnapshot` is true when the row records a saved + * snapshot the UI can offer to resume. + */ +export async function getSandboxStatusHandler(request: NextRequest): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const sessionId = request.nextUrl.searchParams.get("sessionId"); + if (!sessionId) { + return NextResponse.json( + { status: "error", error: "Missing sessionId" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const rows = await selectSessions({ id: sessionId }); + const row = rows[0]; + + 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( + { + status: isSandboxActive(row) ? "active" : "no_sandbox", + hasSnapshot: !!row.snapshot_url, + lifecycleVersion: row.lifecycle_version, + lifecycle: buildLifecycle(row), + }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sandbox/getSessionSandboxName.ts b/lib/sandbox/getSessionSandboxName.ts new file mode 100644 index 000000000..5c1f24ca0 --- /dev/null +++ b/lib/sandbox/getSessionSandboxName.ts @@ -0,0 +1,14 @@ +/** + * Deterministic Vercel Sandbox name for a given session id. + * + * The sandbox provider keys persistent sandboxes by name; deriving the + * name from the session id makes resume idempotent — calling the create + * endpoint twice for the same session will reconnect to the existing + * sandbox instead of provisioning a duplicate. + * + * @param sessionId - The owning session id. + * @returns The persistent sandbox name (e.g. `session-abc123`). + */ +export function getSessionSandboxName(sessionId: string): string { + return `session-${sessionId}`; +} diff --git a/lib/sandbox/hasRuntimeSandboxState.ts b/lib/sandbox/hasRuntimeSandboxState.ts new file mode 100644 index 000000000..5dc7e4171 --- /dev/null +++ b/lib/sandbox/hasRuntimeSandboxState.ts @@ -0,0 +1,24 @@ +/** + * Returns true when `sandbox_state` carries actual runtime metadata + * (i.e. a sandbox has been provisioned and bound to the session) rather + * than the type-only stub written at session creation. + * + * `POST /api/sessions` (api PR #515) inserts `sandbox_state` as + * `{ type: "vercel" }` — a type discriminator with no runtime data. + * Callers must NOT treat this stub as evidence of a live sandbox; doing + * so causes `GET /api/sandbox/status` to report `"active"` immediately + * after session creation, which defeats the chat loading-state UX. + * + * Runtime presence is currently keyed off a non-empty `sandboxName` — + * `POST /api/sandbox` writes this via `getSessionSandboxName(sessionId)` + * and the abstraction's `connectSandbox(...).getState()` preserves it. + * + * @param state - The persisted `sandbox_state` JSON column value. + * @returns true when the state has real runtime metadata; false for + * null/undefined, scalars, the empty type stub, or empty sandboxName. + */ +export function hasRuntimeSandboxState(state: unknown): boolean { + if (!state || typeof state !== "object") return false; + const candidate = state as { sandboxName?: unknown }; + return typeof candidate.sandboxName === "string" && candidate.sandboxName.length > 0; +} diff --git a/lib/sandbox/isSandboxActive.ts b/lib/sandbox/isSandboxActive.ts new file mode 100644 index 000000000..a97c72d87 --- /dev/null +++ b/lib/sandbox/isSandboxActive.ts @@ -0,0 +1,21 @@ +import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; +import { isoToEpochMs } from "@/lib/sandbox/isoToEpochMs"; +import type { Tables } from "@/types/database.types"; + +const SANDBOX_EXPIRES_BUFFER_MS = 10_000; + +/** + * Decides whether the sandbox bound to a session row should be reported + * as `"active"` by GET /api/sandbox/status. Active iff the row carries + * real runtime metadata (not the type-only stub from session creation) + * AND the recorded expiry is at least 10s in the future. + * + * @param row - The `sessions` row. + * @returns true when the sandbox is alive and unexpired. + */ +export function isSandboxActive(row: Tables<"sessions">): boolean { + if (!hasRuntimeSandboxState(row.sandbox_state)) return false; + const expiresAt = isoToEpochMs(row.sandbox_expires_at); + if (expiresAt === null) return true; + return Date.now() < expiresAt - SANDBOX_EXPIRES_BUFFER_MS; +} diff --git a/lib/sandbox/isoToEpochMs.ts b/lib/sandbox/isoToEpochMs.ts new file mode 100644 index 000000000..7558f58d3 --- /dev/null +++ b/lib/sandbox/isoToEpochMs.ts @@ -0,0 +1,12 @@ +/** + * Converts an ISO-8601 timestamp string into epoch milliseconds. + * Returns null for null / empty / unparseable input. + * + * @param value - The ISO timestamp from Supabase. + * @returns Epoch milliseconds, or null when the value cannot be parsed. + */ +export function isoToEpochMs(value: string | null): number | null { + if (!value) return null; + const ms = Date.parse(value); + return Number.isFinite(ms) ? ms : null; +} diff --git a/lib/sandbox/validateCreateSandboxBody.ts b/lib/sandbox/validateCreateSandboxBody.ts new file mode 100644 index 000000000..c0f6d4ec0 --- /dev/null +++ b/lib/sandbox/validateCreateSandboxBody.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; +import { parseGitHubRepoUrl } from "@/lib/github/parseGitHubRepoUrl"; + +export const createSandboxBodySchema = z.object({ + repoUrl: z + .string({ message: "repoUrl is required" }) + .min(1, "repoUrl cannot be empty") + .refine(value => parseGitHubRepoUrl(value) !== null, { + message: "repoUrl must be a valid GitHub repository URL", + }), + sessionId: z.string().optional(), +}); + +export type CreateSandboxBody = z.infer; + +export interface ValidatedCreateSandboxRequest { + body: CreateSandboxBody; + auth: AuthContext; +} + +/** + * Validates a `POST /api/sandbox` request: authenticates the caller, + * tolerates malformed JSON (treated as an empty body), then enforces + * the Zod schema (including a strict GitHub URL check). Returns either + * the first 4xx response or the validated `{ body, auth }`. + */ +export async function validateCreateSandboxBody( + request: NextRequest, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rawBody = await safeParseJson(request); + const result = createSandboxBodySchema.safeParse(rawBody); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return { body: result.data, auth }; +} diff --git a/lib/supabase/sessions/__tests__/updateSession.test.ts b/lib/supabase/sessions/__tests__/updateSession.test.ts new file mode 100644 index 000000000..c1c34df12 --- /dev/null +++ b/lib/supabase/sessions/__tests__/updateSession.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; + +const updateChain = vi.fn(); +const eqChain = vi.fn(); +const selectChain = vi.fn(); +const singleChain = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: vi.fn(() => ({ update: updateChain })), + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + updateChain.mockReturnValue({ eq: eqChain }); + eqChain.mockReturnValue({ select: selectChain }); + selectChain.mockReturnValue({ single: singleChain }); +}); + +describe("updateSession", () => { + it("returns the updated row on success", async () => { + const row = { id: "sess-1", title: "renamed" }; + singleChain.mockResolvedValue({ data: row, error: null }); + + const result = await updateSession("sess-1", { title: "renamed" }); + + expect(result).toEqual(row); + expect(updateChain).toHaveBeenCalledWith({ title: "renamed" }); + expect(eqChain).toHaveBeenCalledWith("id", "sess-1"); + }); + + it("returns null when supabase reports an error", async () => { + singleChain.mockResolvedValue({ data: null, error: { message: "down" } }); + + const result = await updateSession("sess-x", { title: "x" }); + + expect(result).toBeNull(); + }); + + it("forwards the entire updates object to the .update() call", async () => { + singleChain.mockResolvedValue({ data: {}, error: null }); + + await updateSession("sess-1", { + sandbox_state: { type: "vercel", sandboxName: "session-sess-1" }, + lifecycle_state: "active", + lifecycle_version: 5, + sandbox_expires_at: "2030-01-01T00:00:00.000Z", + last_activity_at: "2030-01-01T00:00:00.000Z", + snapshot_url: null, + snapshot_created_at: null, + }); + + const payload = updateChain.mock.calls[0]?.[0]; + expect(payload).toMatchObject({ + lifecycle_state: "active", + lifecycle_version: 5, + snapshot_url: null, + snapshot_created_at: null, + }); + }); +}); diff --git a/lib/supabase/sessions/updateSession.ts b/lib/supabase/sessions/updateSession.ts new file mode 100644 index 000000000..f582cb7e5 --- /dev/null +++ b/lib/supabase/sessions/updateSession.ts @@ -0,0 +1,29 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesUpdate } from "@/types/database.types"; + +/** + * Updates a `sessions` row by id with any subset of mutable columns. + * Returns the updated row, or null on Supabase error. + * + * @param id - The session id to update. + * @param updates - Partial column updates (any TablesUpdate<"sessions"> shape). + * @returns The updated row, or null on error. + */ +export async function updateSession( + id: string, + updates: TablesUpdate<"sessions">, +): Promise | null> { + const { data, error } = await supabase + .from("sessions") + .update(updates) + .eq("id", id) + .select() + .single(); + + if (error) { + console.error("[updateSession] error:", error); + return null; + } + + return data; +}