diff --git a/app/api/sandboxes/setup/route.ts b/app/api/sandboxes/setup/route.ts new file mode 100644 index 00000000..8ee33199 --- /dev/null +++ b/app/api/sandboxes/setup/route.ts @@ -0,0 +1,45 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { setupSandboxHandler } from "@/lib/sandbox/setupSandboxHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/sandboxes/setup + * + * Triggers the setup-sandbox background task to create a personal sandbox, + * provision a GitHub repo, take a snapshot, and shut down. + * For personal API keys, sets up the sandbox for the key owner's account. + * Organization API keys may specify account_id to target any account + * within their organization. + * + * Authentication: x-api-key header or Authorization Bearer token required. + * + * Request body: + * - account_id: string (optional) - UUID of the account to set up for (org keys only) + * + * Response (200): + * - status: "success" + * - runId: string - The Trigger.dev run ID for the background task + * + * Error (400/401/500): + * - status: "error" + * - error: string + * + * @param request - The request object + * @returns A NextResponse with the setup result or error + */ +export async function POST(request: NextRequest): Promise { + return setupSandboxHandler(request); +} diff --git a/lib/sandbox/__tests__/setupSandboxHandler.test.ts b/lib/sandbox/__tests__/setupSandboxHandler.test.ts new file mode 100644 index 00000000..5f21a7cd --- /dev/null +++ b/lib/sandbox/__tests__/setupSandboxHandler.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { setupSandboxHandler } from "@/lib/sandbox/setupSandboxHandler"; + +import { validateSetupSandboxBody } from "@/lib/sandbox/validateSetupSandboxBody"; +import { triggerSetupSandbox } from "@/lib/trigger/triggerSetupSandbox"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/sandbox/validateSetupSandboxBody", () => ({ + validateSetupSandboxBody: vi.fn(), +})); + +vi.mock("@/lib/trigger/triggerSetupSandbox", () => ({ + triggerSetupSandbox: vi.fn(), +})); + +describe("setupSandboxHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns validation error when validation fails", async () => { + const errorResponse = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateSetupSandboxBody).mockResolvedValue(errorResponse); + + const request = new NextRequest("http://localhost/api/sandboxes/setup", { + method: "POST", + }); + + const result = await setupSandboxHandler(request); + + expect(result).toBe(errorResponse); + }); + + it("returns success with runId when trigger succeeds", async () => { + vi.mocked(validateSetupSandboxBody).mockResolvedValue({ + accountId: "test-account-id", + }); + vi.mocked(triggerSetupSandbox).mockResolvedValue({ + id: "run_abc123", + } as never); + + const request = new NextRequest("http://localhost/api/sandboxes/setup", { + method: "POST", + }); + + const result = await setupSandboxHandler(request); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body).toEqual({ status: "success", runId: "run_abc123" }); + }); + + it("returns error when trigger fails", async () => { + vi.mocked(validateSetupSandboxBody).mockResolvedValue({ + accountId: "test-account-id", + }); + vi.mocked(triggerSetupSandbox).mockRejectedValue(new Error("Trigger.dev connection failed")); + + const request = new NextRequest("http://localhost/api/sandboxes/setup", { + method: "POST", + }); + + const result = await setupSandboxHandler(request); + const body = await result.json(); + + expect(result.status).toBe(500); + expect(body).toEqual({ + status: "error", + error: "Trigger.dev connection failed", + }); + }); +}); diff --git a/lib/sandbox/__tests__/validateSetupSandboxBody.test.ts b/lib/sandbox/__tests__/validateSetupSandboxBody.test.ts new file mode 100644 index 00000000..5a6ab035 --- /dev/null +++ b/lib/sandbox/__tests__/validateSetupSandboxBody.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateSetupSandboxBody } from "@/lib/sandbox/validateSetupSandboxBody"; + +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/networking/safeParseJson", () => ({ + safeParseJson: vi.fn(), +})); + +describe("validateSetupSandboxBody", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + const authError = NextResponse.json( + { status: "error", error: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(safeParseJson).mockResolvedValue({}); + vi.mocked(validateAuthContext).mockResolvedValue(authError); + + const request = new NextRequest("http://localhost/api/sandboxes/setup", { + method: "POST", + }); + + const result = await validateSetupSandboxBody(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns 400 when account_id is not a valid UUID", async () => { + vi.mocked(safeParseJson).mockResolvedValue({ + account_id: "not-a-uuid", + }); + + const request = new NextRequest("http://localhost/api/sandboxes/setup", { + method: "POST", + }); + + const result = await validateSetupSandboxBody(request); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns validated accountId on success with no body", async () => { + vi.mocked(safeParseJson).mockResolvedValue({}); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "resolved-account-id", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/sandboxes/setup", { + method: "POST", + }); + + const result = await validateSetupSandboxBody(request); + + expect(result).not.toBeInstanceOf(NextResponse); + expect(result).toEqual({ accountId: "resolved-account-id" }); + }); + + it("passes account_id override to validateAuthContext for org keys", async () => { + const targetAccountId = "550e8400-e29b-41d4-a716-446655440000"; + vi.mocked(safeParseJson).mockResolvedValue({ + account_id: targetAccountId, + }); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: targetAccountId, + orgId: "org-123", + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/sandboxes/setup", { + method: "POST", + }); + + const result = await validateSetupSandboxBody(request); + + expect(validateAuthContext).toHaveBeenCalledWith(request, { + accountId: targetAccountId, + }); + expect(result).toEqual({ accountId: targetAccountId }); + }); + + it("calls validateAuthContext with no accountId when body is empty", async () => { + vi.mocked(safeParseJson).mockResolvedValue({}); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "default-account-id", + orgId: null, + authToken: "test-token", + }); + + const request = new NextRequest("http://localhost/api/sandboxes/setup", { + method: "POST", + }); + + await validateSetupSandboxBody(request); + + expect(validateAuthContext).toHaveBeenCalledWith(request, { + accountId: undefined, + }); + }); +}); diff --git a/lib/sandbox/setupSandboxHandler.ts b/lib/sandbox/setupSandboxHandler.ts new file mode 100644 index 00000000..4beccd73 --- /dev/null +++ b/lib/sandbox/setupSandboxHandler.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateSetupSandboxBody } from "@/lib/sandbox/validateSetupSandboxBody"; +import { triggerSetupSandbox } from "@/lib/trigger/triggerSetupSandbox"; + +/** + * Handler for POST /api/sandboxes/setup. + * + * Triggers the setup-sandbox background task to create a personal sandbox, + * provision a GitHub repo, take a snapshot, and shut down. + * Requires authentication via x-api-key header or Authorization Bearer token. + * + * @param request - The request object + * @returns A NextResponse with the trigger result or error + */ +export async function setupSandboxHandler(request: NextRequest): Promise { + const validated = await validateSetupSandboxBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + try { + const handle = await triggerSetupSandbox(validated.accountId); + + return NextResponse.json( + { status: "success", runId: handle.id }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to trigger setup-sandbox"; + return NextResponse.json( + { status: "error", error: message }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/sandbox/validateSetupSandboxBody.ts b/lib/sandbox/validateSetupSandboxBody.ts new file mode 100644 index 00000000..15ee78ab --- /dev/null +++ b/lib/sandbox/validateSetupSandboxBody.ts @@ -0,0 +1,58 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { z } from "zod"; + +export const setupSandboxBodySchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID").optional(), +}); + +export type SetupSandboxBody = { + accountId: string; +}; + +/** + * Validates auth and request body for POST /api/sandboxes/setup. + * Handles authentication via x-api-key or Authorization bearer token, + * body validation, and optional account_id override for organization API keys. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated body with auth context. + */ +export async function validateSetupSandboxBody( + request: NextRequest, +): Promise { + const body = await safeParseJson(request); + const result = setupSandboxBodySchema.safeParse(body); + + 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(), + }, + ); + } + + const { account_id: targetAccountId } = result.data; + + const authResult = await validateAuthContext(request, { + accountId: targetAccountId, + }); + + if (authResult instanceof NextResponse) { + return authResult; + } + + return { + accountId: authResult.accountId, + }; +} diff --git a/lib/trigger/__tests__/triggerSetupSandbox.test.ts b/lib/trigger/__tests__/triggerSetupSandbox.test.ts new file mode 100644 index 00000000..9c35758f --- /dev/null +++ b/lib/trigger/__tests__/triggerSetupSandbox.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { triggerSetupSandbox } from "@/lib/trigger/triggerSetupSandbox"; + +import { tasks } from "@trigger.dev/sdk"; + +vi.mock("@trigger.dev/sdk", () => ({ + tasks: { + trigger: vi.fn(), + }, +})); + +describe("triggerSetupSandbox", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("triggers the setup-sandbox task with the accountId", async () => { + const mockHandle = { id: "run_abc123" }; + vi.mocked(tasks.trigger).mockResolvedValue(mockHandle as never); + + const result = await triggerSetupSandbox("account-uuid-123"); + + expect(tasks.trigger).toHaveBeenCalledWith("setup-sandbox", { + accountId: "account-uuid-123", + }); + expect(result).toEqual(mockHandle); + }); + + it("returns the task handle with runId", async () => { + const mockHandle = { id: "run_xyz789" }; + vi.mocked(tasks.trigger).mockResolvedValue(mockHandle as never); + + const result = await triggerSetupSandbox("another-account-id"); + + expect(result.id).toBe("run_xyz789"); + }); +}); diff --git a/lib/trigger/triggerSetupSandbox.ts b/lib/trigger/triggerSetupSandbox.ts new file mode 100644 index 00000000..dec60cb4 --- /dev/null +++ b/lib/trigger/triggerSetupSandbox.ts @@ -0,0 +1,13 @@ +import { tasks } from "@trigger.dev/sdk"; + +/** + * Triggers the setup-sandbox task to create a personal sandbox, + * provision a GitHub repo, take a snapshot, and shut down. + * + * @param accountId - The account ID to set up the sandbox for + * @returns The task handle with runId + */ +export async function triggerSetupSandbox(accountId: string) { + const handle = await tasks.trigger("setup-sandbox", { accountId }); + return handle; +}