From 9a2c60bf30aa8cc8ffa5c92d4c82eee24f9659a4 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Thu, 12 Mar 2026 19:53:52 -0700 Subject: [PATCH] Move Foundry HTTP APIs onto /v1 --- docs/deploy/foundry-self-hosting.mdx | 4 +- foundry/CLAUDE.md | 8 ++-- foundry/docker/nginx.preview.conf | 8 ++-- .../backend/src/actors/workspace/app-shell.ts | 2 +- foundry/packages/backend/src/index.ts | 46 +++++++++---------- foundry/packages/cli/src/backend/manager.ts | 2 +- foundry/packages/cli/src/index.ts | 2 +- foundry/packages/client/src/backend-client.ts | 21 +++++++-- .../test/e2e/full-integration-e2e.test.ts | 2 +- .../client/test/e2e/github-pr-e2e.test.ts | 2 +- .../client/test/e2e/workbench-e2e.test.ts | 2 +- .../test/e2e/workbench-load-e2e.test.ts | 2 +- .../desktop/scripts/build-frontend.ts | 2 +- foundry/packages/desktop/src-tauri/src/lib.rs | 4 +- foundry/packages/frontend/src/lib/env.ts | 4 +- foundry/packages/frontend/vite.config.ts | 2 +- 16 files changed, 62 insertions(+), 51 deletions(-) diff --git a/docs/deploy/foundry-self-hosting.mdx b/docs/deploy/foundry-self-hosting.mdx index 9146a46..04b0e9f 100644 --- a/docs/deploy/foundry-self-hosting.mdx +++ b/docs/deploy/foundry-self-hosting.mdx @@ -38,7 +38,7 @@ These values can be safely defaulted for local development: - `APP_URL=http://localhost:4173` - `BETTER_AUTH_URL=http://localhost:7741` - `BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me` -- `GITHUB_REDIRECT_URI=http://localhost:7741/api/auth/github/callback` +- `GITHUB_REDIRECT_URI=http://localhost:7741/v1/auth/github/callback` These should be treated as development-only values. @@ -90,7 +90,7 @@ Recommended GitHub App permissions: - Repository `Checks: Read` - Repository `Commit statuses: Read` -Set the webhook URL to `https:///api/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`. +Set the webhook URL to `https:///v1/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`. Recommended webhook subscriptions: diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index 5e7c3f6..e538986 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -57,7 +57,7 @@ Use `pnpm` workspaces and Turborepo. - Keep a browser-friendly GUI implementation aligned with the TUI interaction model wherever possible. - Do not import `rivetkit` directly in CLI or GUI packages. RivetKit client access must stay isolated inside `packages/client`. - All backend interaction (actor calls, metadata/health checks, backend HTTP endpoint access) must go through the dedicated client library in `packages/client`. -- Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../api/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior. +- Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../v1/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior. - GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows. - Keep the mock workbench types and mock client in `packages/shared` + `packages/client` up to date with the frontend contract. The mock is the UI testing reference implementation while backend functionality catches up. - Keep frontend route/state coverage current in code and tests; there is no separate page-inventory doc to maintain. @@ -105,9 +105,9 @@ For all Rivet/RivetKit implementation: ## Rivet Routing -- Mount RivetKit directly on `/api/rivet` via `registry.handler(c.req.raw)`. +- Mount RivetKit directly on `/v1/rivet` via `registry.handler(c.req.raw)`. - Do not add an extra proxy or manager-specific route layer in the backend. -- Let RivetKit own metadata/public endpoint behavior for `/api/rivet`. +- Let RivetKit own metadata/public endpoint behavior for `/v1/rivet`. ## Workspace + Actor Rules @@ -142,7 +142,7 @@ For all Rivet/RivetKit implementation: - All external service calls (git CLI, GitHub CLI, sandbox-agent HTTP, tmux) must go through the `BackendDriver` interface on the runtime context. - Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`. - End-to-end testing must run against the dev backend started via `docker compose -f compose.dev.yaml up` (host -> container). Do not run E2E against an in-process test runtime. - - E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs. + - E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/v1/rivet`) and use real GitHub repos/PRs. - For Foundry live verification, use `rivet-dev/sandbox-agent-testing` as the default testing repo unless the task explicitly says otherwise. - Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo. - `~/misc/env.txt` and `~/misc/the-foundry.env` contain the expected local OpenAI + GitHub OAuth/App config for dev. diff --git a/foundry/docker/nginx.preview.conf b/foundry/docker/nginx.preview.conf index 33b05ae..d36b4c8 100644 --- a/foundry/docker/nginx.preview.conf +++ b/foundry/docker/nginx.preview.conf @@ -5,8 +5,8 @@ server { root /usr/share/nginx/html; index index.html; - location /api/rivet/ { - proxy_pass http://backend:7841/api/rivet/; + location /v1/ { + proxy_pass http://backend:7841/v1/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -15,8 +15,8 @@ server { proxy_set_header Connection "upgrade"; } - location = /api/rivet { - proxy_pass http://backend:7841/api/rivet; + location = /v1 { + proxy_pass http://backend:7841/v1; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/foundry/packages/backend/src/actors/workspace/app-shell.ts b/foundry/packages/backend/src/actors/workspace/app-shell.ts index 6786073..b2f259e 100644 --- a/foundry/packages/backend/src/actors/workspace/app-shell.ts +++ b/foundry/packages/backend/src/actors/workspace/app-shell.ts @@ -832,7 +832,7 @@ export const workspaceAppActions = { customerId, customerEmail: session.currentUserEmail, planId: input.planId, - successUrl: `${appShell.apiUrl}/api/billing/checkout/complete?organizationId=${encodeURIComponent( + successUrl: `${appShell.apiUrl}/v1/billing/checkout/complete?organizationId=${encodeURIComponent( input.organizationId, )}&foundrySession=${encodeURIComponent(input.sessionId)}&session_id={CHECKOUT_SESSION_ID}`, cancelUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`, diff --git a/foundry/packages/backend/src/index.ts b/foundry/packages/backend/src/index.ts index 4c3dd14..7ac1264 100644 --- a/foundry/packages/backend/src/index.ts +++ b/foundry/packages/backend/src/index.ts @@ -70,7 +70,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise origin ?? "*", credentials: true, @@ -103,7 +103,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise origin ?? "*", credentials: true, @@ -132,12 +132,12 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.get("/v1/app/snapshot", async (c) => { const sessionId = await resolveSessionId(c); return c.json(await appWorkspaceAction(async (workspace) => await workspace.getAppSnapshot({ sessionId }))); }); - app.get("/api/auth/github/start", async (c) => { + app.get("/v1/auth/github/start", async (c) => { const sessionId = await resolveSessionId(c); const result = await appWorkspaceAction(async (workspace) => await workspace.startAppGithubAuth({ sessionId })); return Response.redirect(result.url, 302); @@ -154,20 +154,20 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/app/sign-out", async (c) => { const sessionId = await resolveSessionId(c); return c.json(await appWorkspaceAction(async (workspace) => await workspace.signOutApp({ sessionId }))); }); - app.post("/api/app/onboarding/starter-repo/skip", async (c) => { + app.post("/v1/app/onboarding/starter-repo/skip", async (c) => { const sessionId = await resolveSessionId(c); return c.json(await appWorkspaceAction(async (workspace) => await workspace.skipAppStarterRepo({ sessionId }))); }); - app.post("/api/app/organizations/:organizationId/starter-repo/star", async (c) => { + app.post("/v1/app/organizations/:organizationId/starter-repo/star", async (c) => { const sessionId = await resolveSessionId(c); return c.json( await appWorkspaceAction( @@ -180,7 +180,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/app/organizations/:organizationId/select", async (c) => { const sessionId = await resolveSessionId(c); return c.json( await appWorkspaceAction( @@ -193,7 +193,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.patch("/v1/app/organizations/:organizationId/profile", async (c) => { const sessionId = await resolveSessionId(c); const body = await c.req.json(); return c.json( @@ -210,7 +210,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/app/organizations/:organizationId/import", async (c) => { const sessionId = await resolveSessionId(c); return c.json( await appWorkspaceAction( @@ -223,7 +223,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/app/organizations/:organizationId/reconnect", async (c) => { const sessionId = await resolveSessionId(c); return c.json( await appWorkspaceAction( @@ -236,7 +236,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/app/organizations/:organizationId/billing/checkout", async (c) => { const sessionId = await resolveSessionId(c); const body = await c.req.json().catch(() => ({})); const planId = body?.planId === "free" || body?.planId === "team" ? (body.planId as FoundryBillingPlanId) : "team"; @@ -249,7 +249,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.get("/v1/billing/checkout/complete", async (c) => { const organizationId = c.req.query("organizationId"); const sessionId = c.req.query("foundrySession"); const checkoutSessionId = c.req.query("session_id"); @@ -264,7 +264,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/app/organizations/:organizationId/billing/portal", async (c) => { const sessionId = await resolveSessionId(c); return c.json( await (await appWorkspace()).createAppBillingPortalSession({ @@ -274,7 +274,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/app/organizations/:organizationId/billing/cancel", async (c) => { const sessionId = await resolveSessionId(c); return c.json( await (await appWorkspace()).cancelAppScheduledRenewal({ @@ -284,7 +284,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/app/organizations/:organizationId/billing/resume", async (c) => { const sessionId = await resolveSessionId(c); return c.json( await (await appWorkspace()).resumeAppSubscription({ @@ -294,7 +294,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/app/workspaces/:workspaceId/seat-usage", async (c) => { const sessionId = await resolveSessionId(c); return c.json( await (await appWorkspace()).recordAppSeatUsage({ @@ -313,9 +313,9 @@ export async function startBackend(options: BackendStartOptions = {}): Promise { + app.post("/v1/webhooks/github", async (c) => { const payload = await c.req.text(); await (await appWorkspace()).handleAppGithubWebhook({ payload, @@ -325,8 +325,8 @@ export async function startBackend(options: BackendStartOptions = {}): Promise registry.handler(c.req.raw)); - app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); + app.all("/v1/rivet", (c) => registry.handler(c.req.raw)); + app.all("/v1/rivet/*", (c) => registry.handler(c.req.raw)); const server = Bun.serve({ fetch: app.fetch, diff --git a/foundry/packages/cli/src/backend/manager.ts b/foundry/packages/cli/src/backend/manager.ts index bed268c..d05d937 100644 --- a/foundry/packages/cli/src/backend/manager.ts +++ b/foundry/packages/cli/src/backend/manager.ts @@ -132,7 +132,7 @@ function removeStateFiles(host: string, port: number): void { async function checkHealth(host: string, port: number): Promise { return await checkBackendHealth({ - endpoint: `http://${host}:${port}/api/rivet`, + endpoint: `http://${host}:${port}/v1/rivet`, timeoutMs: HEALTH_TIMEOUT_MS, }); } diff --git a/foundry/packages/cli/src/index.ts b/foundry/packages/cli/src/index.ts index 0e054eb..a495ea8 100644 --- a/foundry/packages/cli/src/index.ts +++ b/foundry/packages/cli/src/index.ts @@ -217,7 +217,7 @@ async function handleBackend(args: string[]): Promise { if (sub === "inspect") { await ensureBackendRunning(backendConfig); const metadata = await readBackendMetadata({ - endpoint: `http://${host}:${port}/api/rivet`, + endpoint: `http://${host}:${port}/v1/rivet`, timeoutMs: 4_000, }); const managerEndpoint = metadata.clientEndpoint ?? `http://${host}:${port}`; diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index b98efbf..255c4ba 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -259,7 +259,7 @@ export interface BackendClient { } export function rivetEndpoint(config: AppConfig): string { - return `http://${config.backend.host}:${config.backend.port}/api/rivet`; + return `http://${config.backend.host}:${config.backend.port}/v1/rivet`; } export function createBackendClientFromConfig(config: AppConfig): BackendClient { @@ -273,8 +273,18 @@ function stripTrailingSlash(value: string): string { return value.replace(/\/$/, ""); } -function deriveAppApiEndpoint(endpoint: string): string { - return stripTrailingSlash(endpoint).replace(/\/api\/rivet$/, "/api"); +function deriveBackendEndpoints(endpoint: string): { appEndpoint: string; rivetEndpoint: string } { + const normalized = stripTrailingSlash(endpoint); + if (normalized.endsWith("/rivet")) { + return { + appEndpoint: normalized.slice(0, -"/rivet".length), + rivetEndpoint: normalized, + }; + } + return { + appEndpoint: normalized, + rivetEndpoint: `${normalized}/rivet`, + }; } function isLoopbackHost(hostname: string): boolean { @@ -394,8 +404,9 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return createMockBackendClient(options.defaultWorkspaceId); } - const rivetApiEndpoint = stripTrailingSlash(options.endpoint); - const appApiEndpoint = deriveAppApiEndpoint(options.endpoint); + const endpoints = deriveBackendEndpoints(options.endpoint); + const rivetApiEndpoint = endpoints.rivetEndpoint; + const appApiEndpoint = endpoints.appEndpoint; let clientPromise: Promise | null = null; let appSessionId = typeof window !== "undefined" ? window.localStorage.getItem("sandbox-agent-foundry:remote-app-session") : null; const workbenchSubscriptions = new Map< diff --git a/foundry/packages/client/test/e2e/full-integration-e2e.test.ts b/foundry/packages/client/test/e2e/full-integration-e2e.test.ts index 1697d52..bdb7c1e 100644 --- a/foundry/packages/client/test/e2e/full-integration-e2e.test.ts +++ b/foundry/packages/client/test/e2e/full-integration-e2e.test.ts @@ -107,7 +107,7 @@ async function ensureRemoteBranchExists(token: string, fullName: string, branchN describe("e2e(client): full integration stack workflow", () => { it.skipIf(!RUN_FULL_E2E)("adds repo, loads branch graph, and executes a stack restack action", { timeout: 8 * 60_000 }, async () => { - const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; + const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet"; const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); const githubToken = requiredEnv("GITHUB_TOKEN"); diff --git a/foundry/packages/client/test/e2e/github-pr-e2e.test.ts b/foundry/packages/client/test/e2e/github-pr-e2e.test.ts index cdd4557..c468717 100644 --- a/foundry/packages/client/test/e2e/github-pr-e2e.test.ts +++ b/foundry/packages/client/test/e2e/github-pr-e2e.test.ts @@ -144,7 +144,7 @@ async function githubApi(token: string, path: string, init?: RequestInit): Promi describe("e2e: backend -> sandbox-agent -> git -> PR", () => { it.skipIf(!RUN_E2E)("creates a task, waits for agent to implement, and opens a PR", { timeout: 15 * 60_000 }, async () => { - const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; + const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet"; const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); const githubToken = requiredEnv("GITHUB_TOKEN"); diff --git a/foundry/packages/client/test/e2e/workbench-e2e.test.ts b/foundry/packages/client/test/e2e/workbench-e2e.test.ts index b4e97d6..5d85125 100644 --- a/foundry/packages/client/test/e2e/workbench-e2e.test.ts +++ b/foundry/packages/client/test/e2e/workbench-e2e.test.ts @@ -145,7 +145,7 @@ function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], exp describe("e2e(client): workbench flows", () => { it.skipIf(!RUN_WORKBENCH_E2E)("creates a task, adds sessions, exchanges messages, and manages workbench state", { timeout: 20 * 60_000 }, async () => { - const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; + const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet"; const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o"); diff --git a/foundry/packages/client/test/e2e/workbench-load-e2e.test.ts b/foundry/packages/client/test/e2e/workbench-load-e2e.test.ts index fa76be7..07d6173 100644 --- a/foundry/packages/client/test/e2e/workbench-load-e2e.test.ts +++ b/foundry/packages/client/test/e2e/workbench-load-e2e.test.ts @@ -175,7 +175,7 @@ async function measureWorkbenchSnapshot( describe("e2e(client): workbench load", () => { it.skipIf(!RUN_WORKBENCH_LOAD_E2E)("runs a simple sequential load profile against the real backend", { timeout: 30 * 60_000 }, async () => { - const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; + const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet"; const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o"); diff --git a/foundry/packages/desktop/scripts/build-frontend.ts b/foundry/packages/desktop/scripts/build-frontend.ts index 555a0b1..9b9117c 100644 --- a/foundry/packages/desktop/scripts/build-frontend.ts +++ b/foundry/packages/desktop/scripts/build-frontend.ts @@ -22,7 +22,7 @@ function run(cmd: string, opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) { console.log("\n=== Building frontend for desktop ===\n"); run("pnpm --filter @sandbox-agent/foundry-frontend build", { env: { - VITE_HF_BACKEND_ENDPOINT: "http://127.0.0.1:7741/api/rivet", + VITE_HF_BACKEND_ENDPOINT: "http://127.0.0.1:7741/v1/rivet", }, }); diff --git a/foundry/packages/desktop/src-tauri/src/lib.rs b/foundry/packages/desktop/src-tauri/src/lib.rs index f8117bc..efcbd75 100644 --- a/foundry/packages/desktop/src-tauri/src/lib.rs +++ b/foundry/packages/desktop/src-tauri/src/lib.rs @@ -14,7 +14,7 @@ fn get_backend_url() -> String { #[tauri::command] async fn backend_health() -> Result { - match reqwest::get("http://127.0.0.1:7741/api/rivet/metadata").await { + match reqwest::get("http://127.0.0.1:7741/v1/rivet/metadata").await { Ok(resp) => Ok(resp.status().is_success()), Err(_) => Ok(false), } @@ -32,7 +32,7 @@ async fn wait_for_backend(timeout_secs: u64) -> Result<(), String> { )); } - match reqwest::get("http://127.0.0.1:7741/api/rivet/metadata").await { + match reqwest::get("http://127.0.0.1:7741/v1/rivet/metadata").await { Ok(resp) if resp.status().is_success() => return Ok(()), _ => {} } diff --git a/foundry/packages/frontend/src/lib/env.ts b/foundry/packages/frontend/src/lib/env.ts index 73c35ae..ea53e85 100644 --- a/foundry/packages/frontend/src/lib/env.ts +++ b/foundry/packages/frontend/src/lib/env.ts @@ -12,9 +12,9 @@ declare global { function resolveDefaultBackendEndpoint(): string { if (typeof window !== "undefined" && window.location?.origin) { - return `${window.location.origin}/api/rivet`; + return `${window.location.origin}/v1/rivet`; } - return "http://127.0.0.1:7741/api/rivet"; + return "http://127.0.0.1:7741/v1/rivet"; } type FrontendImportMetaEnv = ImportMetaEnv & { diff --git a/foundry/packages/frontend/vite.config.ts b/foundry/packages/frontend/vite.config.ts index 739f497..3002a3a 100644 --- a/foundry/packages/frontend/vite.config.ts +++ b/foundry/packages/frontend/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ server: { port: 4173, proxy: { - "/api": { + "/v1": { target: backendProxyTarget, changeOrigin: true, },