Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions app/api/sandboxes/setup/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
return setupSandboxHandler(request);
}
79 changes: 79 additions & 0 deletions lib/sandbox/__tests__/setupSandboxHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
117 changes: 117 additions & 0 deletions lib/sandbox/__tests__/validateSetupSandboxBody.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
37 changes: 37 additions & 0 deletions lib/sandbox/setupSandboxHandler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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() },
);
}
}
58 changes: 58 additions & 0 deletions lib/sandbox/validateSetupSandboxBody.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse | SetupSandboxBody> {
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,
};
}
37 changes: 37 additions & 0 deletions lib/trigger/__tests__/triggerSetupSandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
13 changes: 13 additions & 0 deletions lib/trigger/triggerSetupSandbox.ts
Original file line number Diff line number Diff line change
@@ -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;
}