From d8249936a9184f3af2ad2eaafbd39eef6902a996 Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Mon, 23 Mar 2026 17:51:51 +0000 Subject: [PATCH] feat: add POST /api/sandboxes/files endpoint for uploading to GitHub org submodules - lib/github/commitFileToRepo.ts: GitHub Contents API utility to create/update files in a repo - lib/sandbox/uploadSandboxFilesHandler.ts: multipart handler that resolves submodule paths and commits files - lib/sandbox/__tests__/uploadSandboxFilesHandler.test.ts: 6 passing tests (TDD) - app/api/sandboxes/files/route.ts: POST route wired to handler Co-Authored-By: Claude Sonnet 4.6 --- app/api/sandboxes/files/route.ts | 44 ++++ lib/github/commitFileToRepo.ts | 91 +++++++++ .../uploadSandboxFilesHandler.test.ts | 193 ++++++++++++++++++ lib/sandbox/uploadSandboxFilesHandler.ts | 82 ++++++++ 4 files changed, 410 insertions(+) create mode 100644 app/api/sandboxes/files/route.ts create mode 100644 lib/github/commitFileToRepo.ts create mode 100644 lib/sandbox/__tests__/uploadSandboxFilesHandler.test.ts create mode 100644 lib/sandbox/uploadSandboxFilesHandler.ts diff --git a/app/api/sandboxes/files/route.ts b/app/api/sandboxes/files/route.ts new file mode 100644 index 00000000..c2b1bfaa --- /dev/null +++ b/app/api/sandboxes/files/route.ts @@ -0,0 +1,44 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { uploadSandboxFilesHandler } from "@/lib/sandbox/uploadSandboxFilesHandler"; + +/** + * 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/files + * + * Uploads one or more files to the authenticated account's GitHub org submodule. + * Resolves the target path through git submodules so files land in the correct repo. + * + * Authentication: x-api-key header or Authorization Bearer token required. + * + * Request body (multipart/form-data): + * - files: File[] (required) - One or more files to upload + * - folder: string (required) - Target folder path within the repo + * (e.g. ".openclaw/workspace/orgs/myorg") + * + * Response (200): + * - status: "success" + * - uploaded: [{ path, success, error? }] + * + * Error (400/401/404): + * - status: "error" + * - error: string + * + * @param request - The request object + * @returns A NextResponse with upload results or error + */ +export async function POST(request: NextRequest): Promise { + return uploadSandboxFilesHandler(request); +} diff --git a/lib/github/commitFileToRepo.ts b/lib/github/commitFileToRepo.ts new file mode 100644 index 00000000..69702e90 --- /dev/null +++ b/lib/github/commitFileToRepo.ts @@ -0,0 +1,91 @@ +import { parseGitHubRepoUrl } from "./parseGitHubRepoUrl"; + +export interface CommitFileResult { + path: string; + success: boolean; + error?: string; +} + +/** + * Commits a single file to a GitHub repository using the Contents API. + * Creates the file if it doesn't exist, or updates it if it does. + * + * @param params - The parameters object + * @param params.githubRepo - The full GitHub repository URL (e.g. "https://github.com/owner/repo") + * @param params.path - The file path within the repository (e.g. "src/index.ts") + * @param params.content - The file content as a Buffer or string + * @param params.message - The commit message + * @returns The result indicating success or failure + */ +export async function commitFileToRepo({ + githubRepo, + path, + content, + message, +}: { + githubRepo: string; + path: string; + content: Buffer | string; + message: string; +}): Promise { + const token = process.env.GITHUB_TOKEN; + if (!token) { + return { path, success: false, error: "GITHUB_TOKEN is not configured" }; + } + + const repoInfo = parseGitHubRepoUrl(githubRepo); + if (!repoInfo) { + return { path, success: false, error: "Invalid GitHub repository URL" }; + } + + const { owner, repo } = repoInfo; + const base64Content = + typeof content === "string" + ? Buffer.from(content).toString("base64") + : content.toString("base64"); + + const headers = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "Recoup-API", + "Content-Type": "application/json", + }; + + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`; + + // Check if file already exists to get its SHA (required for updates) + let existingSha: string | undefined; + const existingResponse = await fetch(apiUrl, { headers }); + if (existingResponse.ok) { + const existingData = (await existingResponse.json()) as { sha?: string }; + existingSha = existingData.sha; + } + + const body: Record = { + message, + content: base64Content, + branch: "main", + }; + if (existingSha) { + body.sha = existingSha; + } + + const response = await fetch(apiUrl, { + method: "PUT", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + message?: string; + }; + return { + path, + success: false, + error: errorData.message ?? `GitHub API error: ${response.status}`, + }; + } + + return { path, success: true }; +} diff --git a/lib/sandbox/__tests__/uploadSandboxFilesHandler.test.ts b/lib/sandbox/__tests__/uploadSandboxFilesHandler.test.ts new file mode 100644 index 00000000..980e1efb --- /dev/null +++ b/lib/sandbox/__tests__/uploadSandboxFilesHandler.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; + +import { uploadSandboxFilesHandler } from "../uploadSandboxFilesHandler"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { resolveSubmodulePath } from "@/lib/github/resolveSubmodulePath"; +import { commitFileToRepo } from "@/lib/github/commitFileToRepo"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: vi.fn(), +})); + +vi.mock("@/lib/github/resolveSubmodulePath", () => ({ + resolveSubmodulePath: vi.fn(), +})); + +vi.mock("@/lib/github/commitFileToRepo", () => ({ + commitFileToRepo: vi.fn(), +})); + +const mockAccountId = "550e8400-e29b-41d4-a716-446655440000"; +const mockGithubRepo = "https://github.com/testorg/test-repo"; +const mockFolder = ".openclaw/workspace/orgs/myorg"; + +/** + * + * @param files + * @param folder + */ +function createMockFormData(files: { name: string; content: string }[], folder: string) { + const formData = new FormData(); + for (const file of files) { + formData.append("files", new Blob([file.content], { type: "text/plain" }), file.name); + } + formData.append("folder", folder); + return formData; +} + +/** + * + * @param formData + */ +function createMockRequest(formData?: FormData) { + if (!formData) { + formData = createMockFormData([{ name: "test.txt", content: "hello" }], mockFolder); + } + return new Request("https://example.com/api/sandboxes/files", { + method: "POST", + body: formData, + }); +} + +describe("uploadSandboxFilesHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("authentication", () => { + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const request = createMockRequest(); + const response = await uploadSandboxFilesHandler(request as never); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Unauthorized"); + }); + }); + + describe("validation", () => { + it("returns 400 when folder is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "token", + }); + + const formData = new FormData(); + formData.append("files", new Blob(["hello"], { type: "text/plain" }), "test.txt"); + // no folder field + + const request = createMockRequest(formData); + const response = await uploadSandboxFilesHandler(request as never); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("folder"); + }); + + it("returns 400 when no files are provided", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "token", + }); + + const formData = new FormData(); + formData.append("folder", mockFolder); + + const request = createMockRequest(formData); + const response = await uploadSandboxFilesHandler(request as never); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toContain("file"); + }); + }); + + describe("snapshot lookup", () => { + it("returns 404 when no GitHub repo is found", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "token", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([]); + + const request = createMockRequest(); + const response = await uploadSandboxFilesHandler(request as never); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toContain("GitHub repository"); + }); + }); + + describe("successful upload", () => { + it("commits files to the resolved submodule repo", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "token", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { github_repo: mockGithubRepo } as never, + ]); + vi.mocked(resolveSubmodulePath).mockResolvedValue({ + githubRepo: "https://github.com/testorg/myorg-repo", + path: "test.txt", + }); + vi.mocked(commitFileToRepo).mockResolvedValue({ path: "test.txt", success: true }); + + const request = createMockRequest(); + const response = await uploadSandboxFilesHandler(request as never); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.status).toBe("success"); + expect(data.uploaded).toHaveLength(1); + expect(data.uploaded[0].success).toBe(true); + }); + + it("returns per-file results including errors", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: mockAccountId, + orgId: null, + authToken: "token", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { github_repo: mockGithubRepo } as never, + ]); + vi.mocked(resolveSubmodulePath).mockResolvedValue({ + githubRepo: mockGithubRepo, + path: "test.txt", + }); + vi.mocked(commitFileToRepo).mockResolvedValue({ + path: "test.txt", + success: false, + error: "Permission denied", + }); + + const request = createMockRequest(); + const response = await uploadSandboxFilesHandler(request as never); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.uploaded[0].success).toBe(false); + expect(data.uploaded[0].error).toBe("Permission denied"); + }); + }); +}); diff --git a/lib/sandbox/uploadSandboxFilesHandler.ts b/lib/sandbox/uploadSandboxFilesHandler.ts new file mode 100644 index 00000000..0f7c8a6a --- /dev/null +++ b/lib/sandbox/uploadSandboxFilesHandler.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { resolveSubmodulePath } from "@/lib/github/resolveSubmodulePath"; +import { commitFileToRepo } from "@/lib/github/commitFileToRepo"; +import type { CommitFileResult } from "@/lib/github/commitFileToRepo"; + +/** + * Handler for uploading files to the authenticated account's GitHub org submodule. + * Accepts multipart form data with one or more files and a target folder path. + * Resolves git submodules so files land in the correct repo. + * + * @param request - The request object + * @returns A NextResponse with per-file upload results or an error + */ +export async function uploadSandboxFilesHandler(request: NextRequest): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { accountId } = authResult; + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid multipart form data" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const folder = formData.get("folder"); + if (!folder || typeof folder !== "string" || folder.trim() === "") { + return NextResponse.json( + { status: "error", error: "folder is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const fileEntries = formData.getAll("files"); + const files = fileEntries.filter((entry): entry is File => entry instanceof File); + + if (files.length === 0) { + return NextResponse.json( + { status: "error", error: "At least one file is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const snapshots = await selectAccountSnapshots(accountId); + const githubRepo = snapshots[0]?.github_repo ?? null; + + if (!githubRepo) { + return NextResponse.json( + { status: "error", error: "No GitHub repository found for this account" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + const results: CommitFileResult[] = await Promise.all( + files.map(async file => { + const fullPath = `${folder.replace(/\/$/, "")}/${file.name}`; + const resolved = await resolveSubmodulePath({ githubRepo, path: fullPath }); + + const buffer = Buffer.from(await file.arrayBuffer()); + return commitFileToRepo({ + githubRepo: resolved.githubRepo, + path: resolved.path, + content: buffer, + message: `Upload ${file.name}`, + }); + }), + ); + + return NextResponse.json( + { status: "success", uploaded: results }, + { status: 200, headers: getCorsHeaders() }, + ); +}