From b9667ec3f18e44c159a32539dedd8ea405c07998 Mon Sep 17 00:00:00 2001 From: atryan Date: Thu, 23 Jul 2026 14:15:44 +0000 Subject: [PATCH 01/10] fix(web): disable dev-server host check on loopback binds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vite dev server derives server.allowedHosts from T3CODE_WEB_ALLOWED_HOSTS. A mid-session vite config re-import (e.g. a git op touching vite.config.ts triggers vite-plus's "config changed, restarting server") could drop that env-derived allow-list, so a long-running dev server started returning 403 "host not allowed" even though .env.local was correct — fixed only by a full service restart. When the dev server binds a loopback interface it is only reachable via a local reverse proxy (tailscale serve -> 127.0.0.1), so the DNS-rebinding threat allowedHosts guards against is already handled at the network layer. Set allowedHosts: true in that case so there is no env-derived list to lose on re-import. Non-loopback binds keep the explicit list. server.* only affects the dev server, so the Vercel production/preview build is unaffected. Co-Authored-By: Claude Opus 4.8 --- apps/web/vite.config.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 5153faaba2c2..976f3fea503a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -14,6 +14,18 @@ Object.assign(process.env, repoEnv); const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; +const allowedHosts = (repoEnv.T3CODE_WEB_ALLOWED_HOSTS ?? "") + .split(",") + .map((allowedHost) => allowedHost.trim()) + .filter((allowedHost) => allowedHost.length > 0); +// When the dev server binds a loopback interface it is only reachable through a +// local reverse proxy (e.g. `tailscale serve` → 127.0.0.1), so the DNS-rebinding +// threat `server.allowedHosts` guards against is already handled at the network +// layer. Disable the host check in that case so a mid-session vite config +// re-import can't drop the env-derived allow-list and start returning 403s. +// A non-loopback bind keeps the explicit list from T3CODE_WEB_ALLOWED_HOSTS. +const isLoopbackHost = ["localhost", "127.0.0.1", "::1"].includes(host); +const serverAllowedHosts: true | string[] = isLoopbackHost ? true : allowedHosts; const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; @@ -145,6 +157,7 @@ export default defineConfig(() => { host, port, strictPort: true, + allowedHosts: serverAllowedHosts, ...(devProxyTarget ? { proxy: { From 056fdaa05d13b579919290cbe6321e876f8a3f18 Mon Sep 17 00:00:00 2001 From: atryan Date: Fri, 24 Jul 2026 01:58:38 +0000 Subject: [PATCH 02/10] Add database provider settings page - Add a Supabase coming-soon entry to settings navigation - Make server watch mode explicit to keep development runs stable --- apps/server/package.json | 4 +- .../components/settings/DatabaseSettings.tsx | 62 +++++++++++++++++++ .../settings/SettingsSidebarNav.test.ts | 21 +++++++ .../settings/SettingsSidebarNav.tsx | 3 + apps/web/src/routeTree.gen.ts | 21 +++++++ apps/web/src/routes/settings.databases.tsx | 7 +++ pnpm-lock.yaml | 3 + scripts/dev-runner.test.ts | 15 +++++ 8 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/settings/DatabaseSettings.tsx create mode 100644 apps/web/src/components/settings/SettingsSidebarNav.test.ts create mode 100644 apps/web/src/routes/settings.databases.tsx diff --git a/apps/server/package.json b/apps/server/package.json index 7a634c8f599f..5df71bf415f0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,7 +15,8 @@ ], "type": "module", "scripts": { - "dev": "node --watch src/bin.ts", + "dev": "node src/bin.ts", + "dev:watch": "node --watch src/bin.ts", "build:bundle": "vp pack", "start": "node dist/bin.mjs", "typecheck": "tsgo --noEmit", @@ -29,6 +30,7 @@ "@effect/platform-node-shared": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", + "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", diff --git a/apps/web/src/components/settings/DatabaseSettings.tsx b/apps/web/src/components/settings/DatabaseSettings.tsx new file mode 100644 index 000000000000..85dd3fa6e00a --- /dev/null +++ b/apps/web/src/components/settings/DatabaseSettings.tsx @@ -0,0 +1,62 @@ +import { DatabaseZapIcon } from "lucide-react"; + +import { Badge } from "../ui/badge"; +import { Switch } from "../ui/switch"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; + +const DATABASE_PROVIDERS = [ + { + id: "supabase", + label: "Supabase", + description: + "Connect a Supabase project so agents can inspect schemas, run queries, and manage migrations.", + }, +] as const; + +export function DatabaseSettingsPanel() { + return ( + + + {DATABASE_PROVIDERS.map((provider) => ( +
+
+
+
+ + + + + + {provider.label} + + + Coming Soon + +
+

+ {provider.description} +

+

+ OAuth, project-scoped access, and read-only mode will be available here. +

+
+
+ +
+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.test.ts b/apps/web/src/components/settings/SettingsSidebarNav.test.ts new file mode 100644 index 000000000000..3d47b55a7378 --- /dev/null +++ b/apps/web/src/components/settings/SettingsSidebarNav.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { SETTINGS_NAV_ITEMS } from "./SettingsSidebarNav"; + +describe("SETTINGS_NAV_ITEMS", () => { + it("includes the databases settings section alongside developer integrations", () => { + const sourceControlIndex = SETTINGS_NAV_ITEMS.findIndex( + (item) => item.to === "/settings/source-control", + ); + const databasesIndex = SETTINGS_NAV_ITEMS.findIndex( + (item) => item.to === "/settings/databases", + ); + const connectionsIndex = SETTINGS_NAV_ITEMS.findIndex( + (item) => item.to === "/settings/connections", + ); + + expect(SETTINGS_NAV_ITEMS[databasesIndex]?.label).toBe("Databases"); + expect(databasesIndex).toBe(sourceControlIndex + 1); + expect(connectionsIndex).toBe(databasesIndex + 1); + }); +}); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 6774b6f333f9..16b55a8eb51d 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -3,6 +3,7 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, + DatabaseIcon, GitBranchIcon, KeyboardIcon, Link2Icon, @@ -27,6 +28,7 @@ export type SettingsSectionPath = | "/settings/keybindings" | "/settings/providers" | "/settings/source-control" + | "/settings/databases" | "/settings/connections" | "/settings/archived"; @@ -39,6 +41,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Providers", to: "/settings/providers", icon: BotIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, + { label: "Databases", to: "/settings/databases", icon: DatabaseIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index dfe65834d11d..a401ce1a20a8 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as SettingsProvidersRouteImport } from './routes/settings.provide import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' +import { Route as SettingsDatabasesRouteImport } from './routes/settings.databases' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' @@ -76,6 +77,11 @@ const SettingsDiagnosticsRoute = SettingsDiagnosticsRouteImport.update({ path: '/diagnostics', getParentRoute: () => SettingsRoute, } as any) +const SettingsDatabasesRoute = SettingsDatabasesRouteImport.update({ + id: '/databases', + path: '/databases', + getParentRoute: () => SettingsRoute, +} as any) const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ id: '/connections', path: '/connections', @@ -123,6 +129,7 @@ export interface FileRoutesByFullPath { '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute + '/settings/databases': typeof SettingsDatabasesRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute @@ -140,6 +147,7 @@ export interface FileRoutesByTo { '/connect/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute + '/settings/databases': typeof SettingsDatabasesRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute @@ -160,6 +168,7 @@ export interface FileRoutesById { '/connect_/callback': typeof ConnectCallbackRoute '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute + '/settings/databases': typeof SettingsDatabasesRoute '/settings/diagnostics': typeof SettingsDiagnosticsRoute '/settings/general': typeof SettingsGeneralRoute '/settings/keybindings': typeof SettingsKeybindingsRoute @@ -181,6 +190,7 @@ export interface FileRouteTypes { | '/connect/callback' | '/settings/archived' | '/settings/connections' + | '/settings/databases' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' @@ -198,6 +208,7 @@ export interface FileRouteTypes { | '/connect/callback' | '/settings/archived' | '/settings/connections' + | '/settings/databases' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' @@ -217,6 +228,7 @@ export interface FileRouteTypes { | '/connect_/callback' | '/settings/archived' | '/settings/connections' + | '/settings/databases' | '/settings/diagnostics' | '/settings/general' | '/settings/keybindings' @@ -307,6 +319,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsDiagnosticsRouteImport parentRoute: typeof SettingsRoute } + '/settings/databases': { + id: '/settings/databases' + path: '/databases' + fullPath: '/settings/databases' + preLoaderRoute: typeof SettingsDatabasesRouteImport + parentRoute: typeof SettingsRoute + } '/settings/connections': { id: '/settings/connections' path: '/connections' @@ -380,6 +399,7 @@ const ChatRouteWithChildren = ChatRoute._addFileChildren(ChatRouteChildren) interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute + SettingsDatabasesRoute: typeof SettingsDatabasesRoute SettingsDiagnosticsRoute: typeof SettingsDiagnosticsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute @@ -390,6 +410,7 @@ interface SettingsRouteChildren { const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, + SettingsDatabasesRoute: SettingsDatabasesRoute, SettingsDiagnosticsRoute: SettingsDiagnosticsRoute, SettingsGeneralRoute: SettingsGeneralRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, diff --git a/apps/web/src/routes/settings.databases.tsx b/apps/web/src/routes/settings.databases.tsx new file mode 100644 index 000000000000..22f064218bfc --- /dev/null +++ b/apps/web/src/routes/settings.databases.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { DatabaseSettingsPanel } from "../components/settings/DatabaseSettings"; + +export const Route = createFileRoute("/settings/databases")({ + component: DatabaseSettingsPanel, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4911fa56269..3e6d943a97a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -459,6 +459,9 @@ importers: '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(zod@4.4.3) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 3b79db49f5bb..44328c006add 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,3 +1,5 @@ +import * as NodeFS from "node:fs"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -63,6 +65,19 @@ const devServerInput = { it.layer(NodeServices.layer)("dev-runner", (it) => { describe("getDevRunnerModeArgs", () => { + it.effect("keeps the server stable by default and makes watch mode explicit", () => + Effect.sync(() => { + const serverPackage = JSON.parse( + NodeFS.readFileSync(new URL("../apps/server/package.json", import.meta.url), "utf8"), + ) as { + readonly scripts?: Readonly>; + }; + + assert.equal(serverPackage.scripts?.dev, "node src/bin.ts"); + assert.equal(serverPackage.scripts?.["dev:watch"], "node --watch src/bin.ts"); + }), + ); + it.effect("lets Vite+ honor the desktop dev task graph", () => Effect.sync(() => { assert.deepStrictEqual(getDevRunnerModeArgs("dev:desktop"), [ From 12ff5c2364af256b98b0041c5557b8c34653f888 Mon Sep 17 00:00:00 2001 From: atryan Date: Sat, 25 Jul 2026 18:12:13 +0000 Subject: [PATCH 03/10] feat(database): wire Supabase project connections through settings and MCP Adds a database-provider connection model that flows from the settings UI down to a project-scoped MCP toolkit, so a thread working in a given workspace can reach that project's Supabase instance without per-session manual wiring. - contracts: `database.ts` connection schema, plus `projectId` on the provider session payload so orchestration can scope a session to a project. - server: `SupabaseMcpConnector` resolves a stored connection for the invoking thread; the Supabase toolkit exposes it as MCP tools. `McpInvocationContext`/`McpSessionRegistry` carry `projectId` and `cwd` so tool resolution is project-scoped rather than global. - settings: connections are persisted with the access token held in the secret store (keyed by a base64url-encoded project ref) and redacted out of any settings read. - web: `DatabaseSettings` page for managing connections. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/database/SupabaseMcpConnector.test.ts | 110 +++++ .../src/database/SupabaseMcpConnector.ts | 187 ++++++++ apps/server/src/mcp/McpHttpServer.ts | 7 + apps/server/src/mcp/McpInvocationContext.ts | 5 + .../server/src/mcp/McpSessionRegistry.test.ts | 19 +- apps/server/src/mcp/McpSessionRegistry.ts | 6 +- .../mcp/toolkits/supabase/handlers.test.ts | 67 +++ .../src/mcp/toolkits/supabase/handlers.ts | 40 ++ .../src/mcp/toolkits/supabase/tools.test.ts | 40 ++ .../server/src/mcp/toolkits/supabase/tools.ts | 129 ++++++ .../Layers/ProviderCommandReactor.ts | 1 + .../src/provider/Layers/ProviderService.ts | 40 +- apps/server/src/server.ts | 6 +- apps/server/src/serverSettings.test.ts | 59 +++ apps/server/src/serverSettings.ts | 147 +++++- .../settings/DatabaseSettings.test.ts | 52 +++ .../components/settings/DatabaseSettings.tsx | 436 ++++++++++++++++-- packages/contracts/src/database.ts | 25 + packages/contracts/src/index.ts | 1 + packages/contracts/src/provider.ts | 2 + packages/contracts/src/settings.test.ts | 28 ++ packages/contracts/src/settings.ts | 26 +- packages/shared/src/serverSettings.test.ts | 32 ++ packages/shared/src/serverSettings.ts | 3 + 24 files changed, 1411 insertions(+), 57 deletions(-) create mode 100644 apps/server/src/database/SupabaseMcpConnector.test.ts create mode 100644 apps/server/src/database/SupabaseMcpConnector.ts create mode 100644 apps/server/src/mcp/toolkits/supabase/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/supabase/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/supabase/tools.test.ts create mode 100644 apps/server/src/mcp/toolkits/supabase/tools.ts create mode 100644 apps/web/src/components/settings/DatabaseSettings.test.ts create mode 100644 packages/contracts/src/database.ts diff --git a/apps/server/src/database/SupabaseMcpConnector.test.ts b/apps/server/src/database/SupabaseMcpConnector.test.ts new file mode 100644 index 000000000000..e7f8c156c38e --- /dev/null +++ b/apps/server/src/database/SupabaseMcpConnector.test.ts @@ -0,0 +1,110 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import { expect, it } from "@effect/vitest"; +import { DEFAULT_SERVER_SETTINGS, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { + buildSupabaseMcpUrl, + makeSupabaseMcpConnector, + resolveSupabaseConnection, + resolveSupabaseConnectionForCwd, +} from "./SupabaseMcpConnector.ts"; + +const projectA = ProjectId.make("project-a"); +const projectB = ProjectId.make("project-b"); +const connections = { + [projectA]: { + provider: "supabase" as const, + workspaceRoot: "/work/repository", + projectRef: "supabase-a", + readOnly: true, + accessToken: "sbp-secret-a", + accessTokenRedacted: true, + }, + [projectB]: { + provider: "supabase" as const, + workspaceRoot: "/work/repository/packages/nested", + projectRef: "supabase-b", + readOnly: false, + accessToken: "sbp-secret-b", + accessTokenRedacted: true, + }, +}; + +it("resolves exact project ids and the longest containing workspace without sibling escapes", () => { + expect( + resolveSupabaseConnection(connections, { projectId: projectA })?.connection.projectRef, + ).toBe("supabase-a"); + expect( + resolveSupabaseConnectionForCwd( + connections, + NodePath.join("/work/repository/packages/nested", "src"), + )?.connection.projectRef, + ).toBe("supabase-b"); + expect(resolveSupabaseConnectionForCwd(connections, "/work/repository-sibling")).toBeUndefined(); +}); + +it("builds a project-scoped read-only Supabase MCP URL", () => { + const url = buildSupabaseMcpUrl(connections[projectA]!); + expect(url.origin + url.pathname).toBe("https://mcp.supabase.com/mcp"); + expect(url.searchParams.get("project_ref")).toBe("supabase-a"); + expect(url.searchParams.get("read_only")).toBe("true"); + expect(url.searchParams.get("features")).toBe("database,debugging,development"); + expect(url.toString()).not.toContain("sbp-secret-a"); +}); + +it.effect("proxies scoped tools without returning the access token", () => + Effect.gen(function* () { + const calls: Array<{ readonly token: string; readonly tool: string }> = []; + const connector = makeSupabaseMcpConnector({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + databaseConnections: connections, + }), + remoteCall: async ({ connection, tool }) => { + calls.push({ token: connection.accessToken, tool }); + return { content: [{ type: "text", text: "ok" }] }; + }, + }); + + const result = yield* connector.callTool({ + projectId: projectA, + cwd: "/unrelated/worktree", + tool: "list_tables", + arguments: { schemas: ["public"] }, + }); + + expect(calls).toEqual([{ token: "sbp-secret-a", tool: "list_tables" }]); + expect(result.projectRef).toBe("supabase-a"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.stringify(result)).not.toContain("sbp-secret-a"); + }), +); + +it.effect("blocks migrations locally when the project connection is read-only", () => + Effect.gen(function* () { + let remoteCalled = false; + const connector = makeSupabaseMcpConnector({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + databaseConnections: connections, + }), + remoteCall: async () => { + remoteCalled = true; + return {}; + }, + }); + + const error = yield* Effect.flip( + connector.callTool({ + projectId: projectA, + tool: "apply_migration", + arguments: { name: "create_users", query: "create table users(id bigint)" }, + }), + ); + + expect(error.reason).toBe("read-only"); + expect(remoteCalled).toBe(false); + }), +); diff --git a/apps/server/src/database/SupabaseMcpConnector.ts b/apps/server/src/database/SupabaseMcpConnector.ts new file mode 100644 index 000000000000..f24ae3f25335 --- /dev/null +++ b/apps/server/src/database/SupabaseMcpConnector.ts @@ -0,0 +1,187 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { + DatabaseToolError, + type ProjectId, + type ServerSettings, + type ServerSettingsError, + type SupabaseDatabaseConnection, + type SupabaseToolProxyResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerSettingsModule from "../serverSettings.ts"; + +export type SupabaseRemoteToolName = + | "list_tables" + | "list_extensions" + | "list_migrations" + | "apply_migration" + | "execute_sql" + | "get_advisors" + | "get_project_url" + | "get_publishable_keys" + | "generate_typescript_types"; + +export interface ResolvedSupabaseConnection { + readonly projectId: string; + readonly connection: SupabaseDatabaseConnection; +} + +export interface SupabaseRemoteCallInput { + readonly connection: SupabaseDatabaseConnection; + readonly tool: SupabaseRemoteToolName; + readonly arguments: Readonly>; +} + +export type SupabaseRemoteCall = (input: SupabaseRemoteCallInput) => Promise; + +export function buildSupabaseMcpUrl(connection: SupabaseDatabaseConnection): URL { + const url = new URL("https://mcp.supabase.com/mcp"); + url.searchParams.set("project_ref", connection.projectRef); + url.searchParams.set("features", "database,debugging,development"); + if (connection.readOnly) url.searchParams.set("read_only", "true"); + return url; +} + +function pathContains(root: string, candidate: string): boolean { + const relative = NodePath.relative(NodePath.resolve(root), NodePath.resolve(candidate)); + return relative === "" || (!relative.startsWith("..") && !NodePath.isAbsolute(relative)); +} + +export function resolveSupabaseConnectionForCwd( + connections: ServerSettings["databaseConnections"], + cwd: string, +): ResolvedSupabaseConnection | undefined { + return Object.entries(connections) + .filter(([, connection]) => pathContains(connection.workspaceRoot, cwd)) + .sort( + ([, left], [, right]) => + NodePath.resolve(right.workspaceRoot).length - NodePath.resolve(left.workspaceRoot).length, + ) + .map(([projectId, connection]) => ({ projectId, connection }))[0]; +} + +export function resolveSupabaseConnection( + connections: ServerSettings["databaseConnections"], + input: { readonly projectId?: ProjectId; readonly cwd?: string }, +): ResolvedSupabaseConnection | undefined { + if (input.projectId !== undefined) { + const connection = connections[input.projectId]; + if (connection !== undefined) return { projectId: input.projectId, connection }; + } + return input.cwd === undefined + ? undefined + : resolveSupabaseConnectionForCwd(connections, input.cwd); +} + +const defaultRemoteCall: SupabaseRemoteCall = async ({ connection, tool, arguments: args }) => { + const client = new Client({ + name: "t3-code-supabase-proxy", + version: "1.0.0", + }); + const transport = new StreamableHTTPClientTransport(buildSupabaseMcpUrl(connection), { + requestInit: { + headers: { + Authorization: `Bearer ${connection.accessToken}`, + }, + }, + }); + try { + await client.connect(transport as Parameters[0]); + return await client.callTool({ name: tool, arguments: { ...args } }, undefined, { + timeout: 30_000, + }); + } finally { + await client.close().catch(() => undefined); + } +}; + +export interface SupabaseMcpConnectorShape { + readonly callTool: (input: { + readonly projectId?: ProjectId; + readonly cwd?: string; + readonly tool: SupabaseRemoteToolName; + readonly arguments: Readonly>; + }) => Effect.Effect; +} + +export function makeSupabaseMcpConnector(input: { + readonly getSettings: Effect.Effect; + readonly remoteCall?: SupabaseRemoteCall; +}): SupabaseMcpConnectorShape { + const remoteCall = input.remoteCall ?? defaultRemoteCall; + return { + callTool: Effect.fn("SupabaseMcpConnector.callTool")(function* (request) { + const settings = yield* input.getSettings.pipe( + Effect.mapError( + () => + new DatabaseToolError({ + reason: "remote-unavailable", + message: "Database settings are temporarily unavailable.", + }), + ), + ); + const resolved = resolveSupabaseConnection(settings.databaseConnections, request); + if (!resolved) { + return yield* new DatabaseToolError({ + reason: "not-configured", + message: "This thread's project is not connected to a Supabase project.", + }); + } + const { connection } = resolved; + if (connection.accessToken.length === 0) { + return yield* new DatabaseToolError({ + reason: "credential-missing", + message: "The Supabase connection does not have a configured access token.", + }); + } + if (connection.readOnly && request.tool === "apply_migration") { + return yield* new DatabaseToolError({ + reason: "read-only", + message: "This Supabase connection is read-only. Enable write access in Settings first.", + }); + } + + const result = yield* Effect.tryPromise({ + try: () => + remoteCall({ + connection, + tool: request.tool, + arguments: request.arguments, + }), + catch: () => + new DatabaseToolError({ + reason: "remote-error", + message: + "Supabase rejected the request or could not be reached. Check the project reference and access token.", + }), + }); + return { + projectRef: connection.projectRef, + readOnly: connection.readOnly, + result, + }; + }), + }; +} + +export class SupabaseMcpConnector extends Context.Service< + SupabaseMcpConnector, + SupabaseMcpConnectorShape +>()("t3/database/SupabaseMcpConnector") {} + +const make = Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + return SupabaseMcpConnector.of( + makeSupabaseMcpConnector({ + getSettings: serverSettings.getSettings, + }), + ); +}); + +export const layer = Layer.effect(SupabaseMcpConnector, make); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index f7fe340d0598..271aff0fb4ed 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -15,6 +15,8 @@ import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; import { CommandCenterToolkitHandlersLive } from "./toolkits/command-center/handlers.ts"; import { CommandCenterToolkit } from "./toolkits/command-center/tools.ts"; +import { SupabaseToolkitHandlersLive } from "./toolkits/supabase/handlers.ts"; +import { SupabaseToolkit } from "./toolkits/supabase/tools.ts"; import { PreviewSnapshotToolkitHandlersLive, PreviewStandardToolkitHandlersLive, @@ -214,9 +216,14 @@ export const CommandCenterToolkitRegistrationLive = McpServer.toolkit(CommandCen Layer.provide(CommandCenterToolkitHandlersLive), ); +export const SupabaseToolkitRegistrationLive = McpServer.toolkit(SupabaseToolkit).pipe( + Layer.provide(SupabaseToolkitHandlersLive), +); + const ToolkitRegistrationLive = Layer.mergeAll( PreviewToolkitRegistrationLive, CommandCenterToolkitRegistrationLive, + SupabaseToolkitRegistrationLive, ); const McpTransportLive = McpServer.layerHttp({ diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 7258e3620fba..044ee1bf74a2 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -2,6 +2,7 @@ import { CommandCenterMcpCapabilityUnavailableError, type EnvironmentId, PreviewAutomationUnavailableError, + type ProjectId, type ProviderInstanceId, type ThreadId, } from "@t3tools/contracts"; @@ -17,6 +18,10 @@ export interface McpInvocationScope { readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; + /** Local project owning the thread, when issued through project orchestration. */ + readonly projectId?: ProjectId; + /** Effective provider working directory used to resolve project-scoped tools. */ + readonly cwd?: string; readonly capabilities: ReadonlySet; readonly spaceId?: SpaceId; readonly repositoryId?: RepositoryId; diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 01411e2598ae..7f3ed091bfd5 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { RepositoryId, SpaceId } from "@command-center/core"; import { expect, it } from "@effect/vitest"; -import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; @@ -56,6 +56,23 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t }), ); +it.effect("binds project and working-directory scope into the issued credential", () => + Effect.gen(function* () { + const registry = yield* makeRegistry(() => 1_000); + const issued = yield* registry.issue({ + threadId: ThreadId.make("thread-project-scope"), + providerInstanceId: ProviderInstanceId.make("codex"), + projectId: ProjectId.make("project-a"), + cwd: " /work/project-a ", + }); + const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); + const resolved = yield* registry.resolve(token); + + expect(resolved?.projectId).toBe("project-a"); + expect(resolved?.cwd).toBe("/work/project-a"); + }), +); + it.effect("builds MCP endpoints from the bound server host", () => Effect.gen(function* () { const cases = [ diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index c88c5f571bf4..70efcf108fd7 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -1,5 +1,5 @@ import type { CapabilityName, RepositoryId, SpaceId } from "@command-center/core"; -import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { type ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -15,6 +15,8 @@ import * as McpProviderSession from "./McpProviderSession.ts"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; + readonly projectId?: ProjectId; + readonly cwd?: string; readonly capabilities?: ReadonlySet; readonly spaceId?: SpaceId; readonly repositoryId?: RepositoryId; @@ -136,6 +138,8 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + ...(request.projectId === undefined ? {} : { projectId: request.projectId }), + ...(request.cwd?.trim() ? { cwd: request.cwd.trim() } : {}), capabilities: request.capabilities ?? registeredScope?.capabilities ?? new Set(["preview"]), ...(spaceId === undefined ? {} : { spaceId }), ...(repositoryId === undefined ? {} : { repositoryId }), diff --git a/apps/server/src/mcp/toolkits/supabase/handlers.test.ts b/apps/server/src/mcp/toolkits/supabase/handlers.test.ts new file mode 100644 index 000000000000..5f145154472e --- /dev/null +++ b/apps/server/src/mcp/toolkits/supabase/handlers.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from "@effect/vitest"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { McpSchema, McpServer } from "effect/unstable/ai"; + +import * as SupabaseMcpConnector from "../../../database/SupabaseMcpConnector.ts"; +import * as McpHttpServer from "../../McpHttpServer.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +it.effect("routes a Supabase tool through the credential-bound project scope", () => { + const calls: Array = []; + const connector = SupabaseMcpConnector.SupabaseMcpConnector.of({ + callTool: (input) => { + calls.push(input.tool); + return Effect.succeed({ + projectRef: "supabase-a", + readOnly: true, + result: { content: [{ type: "text", text: "ok" }] }, + }); + }, + }); + const invocation = McpInvocationContext.McpInvocationContext.of({ + environmentId: EnvironmentId.make("environment-a"), + threadId: ThreadId.make("thread-a"), + providerSessionId: "provider-session-a", + providerInstanceId: ProviderInstanceId.make("codex"), + projectId: ProjectId.make("project-a"), + cwd: "/work/project-a-worktree", + capabilities: new Set(["preview"]), + issuedAt: 1, + expiresAt: Number.MAX_SAFE_INTEGER, + }); + const client = McpSchema.McpServerClient.of({ + clientId: 1, + initializePayload: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "supabase-handler-test", version: "1.0.0" }, + }, + getClient: Effect.die("unused"), + }); + const testLayer = McpHttpServer.SupabaseToolkitRegistrationLive.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(Layer.succeed(SupabaseMcpConnector.SupabaseMcpConnector, connector)), + ); + + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ + name: "supabase_list_tables", + arguments: { schemas: ["public"] }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + projectRef: "supabase-a", + readOnly: true, + }); + expect(calls).toEqual(["list_tables"]); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/mcp/toolkits/supabase/handlers.ts b/apps/server/src/mcp/toolkits/supabase/handlers.ts new file mode 100644 index 000000000000..190b892f4421 --- /dev/null +++ b/apps/server/src/mcp/toolkits/supabase/handlers.ts @@ -0,0 +1,40 @@ +import { DatabaseToolError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as SupabaseMcpConnector from "../../../database/SupabaseMcpConnector.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { SupabaseToolkit } from "./tools.ts"; + +const callSupabase = Effect.fn("SupabaseToolkit.call")(function* ( + tool: SupabaseMcpConnector.SupabaseRemoteToolName, + args: Readonly>, +) { + const invocation = yield* McpInvocationContext.McpInvocationContext; + if (invocation.projectId === undefined && invocation.cwd === undefined) { + return yield* new DatabaseToolError({ + reason: "not-configured", + message: "This thread does not have a project working directory.", + }); + } + const connector = yield* SupabaseMcpConnector.SupabaseMcpConnector; + return yield* connector.callTool({ + ...(invocation.projectId === undefined ? {} : { projectId: invocation.projectId }), + ...(invocation.cwd === undefined ? {} : { cwd: invocation.cwd }), + tool, + arguments: args, + }); +}); + +const handlers = SupabaseToolkit.of({ + supabase_list_tables: (input) => callSupabase("list_tables", input), + supabase_list_extensions: (input) => callSupabase("list_extensions", input), + supabase_list_migrations: (input) => callSupabase("list_migrations", input), + supabase_apply_migration: (input) => callSupabase("apply_migration", input), + supabase_execute_sql: (input) => callSupabase("execute_sql", input), + supabase_get_advisors: (input) => callSupabase("get_advisors", input), + supabase_get_project_url: (input) => callSupabase("get_project_url", input), + supabase_get_publishable_keys: (input) => callSupabase("get_publishable_keys", input), + supabase_generate_typescript_types: (input) => callSupabase("generate_typescript_types", input), +} satisfies Parameters[0]); + +export const SupabaseToolkitHandlersLive = SupabaseToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/supabase/tools.test.ts b/apps/server/src/mcp/toolkits/supabase/tools.test.ts new file mode 100644 index 000000000000..21aa1e770e07 --- /dev/null +++ b/apps/server/src/mcp/toolkits/supabase/tools.test.ts @@ -0,0 +1,40 @@ +import { expect, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import { Tool } from "effect/unstable/ai"; + +import { SupabaseToolkit } from "./tools.ts"; + +it("exposes project-scoped Supabase tools without credential or project selectors", () => { + const tools = Object.values(SupabaseToolkit.tools); + const names = tools.map((tool) => tool.name); + + expect(names).toEqual([ + "supabase_list_tables", + "supabase_list_extensions", + "supabase_list_migrations", + "supabase_apply_migration", + "supabase_execute_sql", + "supabase_get_advisors", + "supabase_get_project_url", + "supabase_get_publishable_keys", + "supabase_generate_typescript_types", + ]); + + for (const tool of tools) { + const schema = Tool.getJsonSchema(tool) as { + readonly properties?: Readonly>; + }; + expect(schema.properties ?? {}).not.toHaveProperty("projectRef"); + expect(schema.properties ?? {}).not.toHaveProperty("accessToken"); + expect(Context.get(tool.annotations, Tool.OpenWorld)).toBe(true); + } + + const listTables = tools.find((tool) => tool.name === "supabase_list_tables")!; + const listTablesSchema = Tool.getJsonSchema(listTables) as { + readonly type?: string; + readonly properties?: Readonly>; + }; + expect(listTablesSchema.type).toBe("object"); + expect(listTablesSchema.properties).toHaveProperty("schemas"); + expect(listTablesSchema.properties).toHaveProperty("verbose"); +}); diff --git a/apps/server/src/mcp/toolkits/supabase/tools.ts b/apps/server/src/mcp/toolkits/supabase/tools.ts new file mode 100644 index 000000000000..5103ef233f7d --- /dev/null +++ b/apps/server/src/mcp/toolkits/supabase/tools.ts @@ -0,0 +1,129 @@ +import { DatabaseToolError, SupabaseToolProxyResult } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Tool, Toolkit } from "effect/unstable/ai"; + +import * as SupabaseMcpConnector from "../../../database/SupabaseMcpConnector.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [ + McpInvocationContext.McpInvocationContext, + SupabaseMcpConnector.SupabaseMcpConnector, +]; + +const readonlyTool = (tool: T): T => + tool + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true) as T; + +const writeTool = (tool: T): T => + tool + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true) as T; + +const makeTool = ( + name: Name, + title: string, + description: string, + parameters: Parameters, + mode: "read" | "write", +) => { + const tool = Tool.make(name, { + description, + parameters, + success: SupabaseToolProxyResult, + failure: DatabaseToolError, + dependencies, + }).annotate(Tool.Title, title); + return mode === "read" ? readonlyTool(tool) : writeTool(tool); +}; + +export const SupabaseListTablesTool = makeTool( + "supabase_list_tables", + "List Supabase tables", + "List tables in the Supabase project connected to this thread's local project.", + Schema.Struct({ + schemas: Schema.optional(Schema.Array(Schema.String)), + verbose: Schema.optional(Schema.Boolean), + }), + "read", +); + +export const SupabaseListExtensionsTool = makeTool( + "supabase_list_extensions", + "List Supabase extensions", + "List Postgres extensions in the Supabase project connected to this thread.", + Schema.Struct({}), + "read", +); + +export const SupabaseListMigrationsTool = makeTool( + "supabase_list_migrations", + "List Supabase migrations", + "List database migrations in the Supabase project connected to this thread.", + Schema.Struct({}), + "read", +); + +export const SupabaseApplyMigrationTool = makeTool( + "supabase_apply_migration", + "Apply Supabase migration", + "Apply a named SQL migration to the connected Supabase project. Unavailable for read-only connections.", + Schema.Struct({ name: Schema.String, query: Schema.String }), + "write", +); + +export const SupabaseExecuteSqlTool = makeTool( + "supabase_execute_sql", + "Execute Supabase SQL", + "Execute SQL against the connected Supabase project. In read-only mode, Supabase enforces read-only SQL.", + Schema.Struct({ query: Schema.String }), + "write", +); + +export const SupabaseGetAdvisorsTool = makeTool( + "supabase_get_advisors", + "Get Supabase advisors", + "Get security or performance advisors for the connected Supabase project.", + Schema.Struct({ type: Schema.Literals(["security", "performance"]) }), + "read", +); + +export const SupabaseGetProjectUrlTool = makeTool( + "supabase_get_project_url", + "Get Supabase project URL", + "Get the API URL for the connected Supabase project.", + Schema.Struct({}), + "read", +); + +export const SupabaseGetPublishableKeysTool = makeTool( + "supabase_get_publishable_keys", + "Get Supabase publishable keys", + "Get client-safe publishable API keys for the connected Supabase project.", + Schema.Struct({}), + "read", +); + +export const SupabaseGenerateTypescriptTypesTool = makeTool( + "supabase_generate_typescript_types", + "Generate Supabase TypeScript types", + "Generate TypeScript types from the connected Supabase project's database schema.", + Schema.Struct({}), + "read", +); + +export const SupabaseToolkit = Toolkit.make( + SupabaseListTablesTool, + SupabaseListExtensionsTool, + SupabaseListMigrationsTool, + SupabaseApplyMigrationTool, + SupabaseExecuteSqlTool, + SupabaseGetAdvisorsTool, + SupabaseGetProjectUrlTool, + SupabaseGetPublishableKeysTool, + SupabaseGenerateTypescriptTypesTool, +); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bbb4d7a44577..93a6fcf00963 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -502,6 +502,7 @@ const make = Effect.gen(function* () { }) => providerService.startSession(threadId, { threadId, + projectId: thread.projectId, ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index a26511ea15fe..a2462fb26b5e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -19,6 +19,7 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + ProjectId, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, @@ -124,6 +125,7 @@ function toRuntimePayloadFromSession( session: ProviderSession, extra?: { readonly modelSelection?: unknown; + readonly projectId?: ProjectId; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -134,6 +136,7 @@ function toRuntimePayloadFromSession( activeTurnId: session.activeTurnId ?? null, lastError: session.lastError ?? null, ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), + ...(extra?.projectId !== undefined ? { projectId: extra.projectId } : {}), ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), ...(extra?.lastRuntimeEventAt !== undefined ? { lastRuntimeEventAt: extra.lastRuntimeEventAt } @@ -163,6 +166,17 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +function readPersistedProjectId( + runtimePayload: ProviderSessionDirectory.ProviderRuntimeBinding["runtimePayload"], +): ProjectId | undefined { + if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { + return undefined; + } + const rawProjectId = "projectId" in runtimePayload ? runtimePayload.projectId : undefined; + if (typeof rawProjectId !== "string" || rawProjectId.trim().length === 0) return undefined; + return ProjectId.make(rawProjectId.trim()); +} + const dieOnMissingBindingInstanceId = ( operation: string, payload: { @@ -215,8 +229,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => - McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( + const prepareMcpSession = ( + threadId: ThreadId, + providerInstanceId: ProviderInstanceId, + projectId?: ProjectId, + cwd?: string, + ) => + McpSessionRegistry.issueActiveMcpCredential({ + threadId, + providerInstanceId, + ...(projectId === undefined ? {} : { projectId }), + ...(cwd === undefined ? {} : { cwd }), + }).pipe( Effect.tap((credential) => credential ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)) @@ -262,6 +286,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( threadId: ThreadId, extra?: { readonly modelSelection?: unknown; + readonly projectId?: ProjectId; readonly lastRuntimeEvent?: string; readonly lastRuntimeEventAt?: string; }, @@ -405,9 +430,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const persistedProjectId = readPersistedProjectId(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); + yield* prepareMcpSession( + input.binding.threadId, + bindingInstanceId, + persistedProjectId, + persistedCwd, + ); const resumed = yield* adapter .startSession({ threadId: input.binding.threadId, @@ -608,7 +639,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.cwd.effective": effectiveCwd ?? "", }); const adapter = yield* registry.getByInstance(resolvedInstanceId); - yield* prepareMcpSession(threadId, resolvedInstanceId); + yield* prepareMcpSession(threadId, resolvedInstanceId, input.projectId, effectiveCwd); const session = yield* adapter .startSession({ ...input, @@ -636,6 +667,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }); yield* upsertSessionBinding(sessionWithInstance, threadId, { modelSelection: input.modelSelection, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), }); yield* analytics.record("provider.session.started", { provider: sessionWithInstance.provider, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3202975ef1a5..ecfc1f4cf216 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -36,6 +36,7 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; +import * as SupabaseMcpConnector from "./database/SupabaseMcpConnector.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -491,7 +492,10 @@ export const makeRoutesLayer = Layer.mergeAll( webhookHttpRouteLayer, websocketRpcRouteLayer, ), - McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), + McpHttpServer.layer.pipe( + Layer.provide(SupabaseMcpConnector.layer), + Layer.provide(McpSessionRegistry.layer), + ), ).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 504d99e18def..f41887eda371 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_SERVER_SETTINGS, ProviderDriverKind, ProviderInstanceId, + ProjectId, ServerSettings, ServerSettingsPatch, } from "@t3tools/contracts"; @@ -589,4 +590,62 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect( + "stores Supabase access tokens outside settings.json and preserves redacted updates", + () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const projectId = ProjectId.make("project-database"); + + const next = yield* serverSettings.updateSettings({ + databaseConnections: { + [projectId]: { + provider: "supabase", + workspaceRoot: "/work/project-database", + projectRef: "abcdefghijk", + readOnly: true, + accessToken: "sbp-database-secret", + }, + }, + }); + + assert.equal(next.databaseConnections[projectId]?.accessToken, "sbp-database-secret"); + assert.isTrue(next.databaseConnections[projectId]?.accessTokenRedacted); + assert.equal( + ServerSettingsModule.redactServerSettingsForClient(next).databaseConnections[projectId] + ?.accessToken, + "", + ); + + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "sbp-database-secret"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + assert.deepInclude(JSON.parse(raw).databaseConnections[projectId], { + projectRef: "abcdefghijk", + accessToken: "", + accessTokenRedacted: true, + }); + + const roundTripped = yield* serverSettings.updateSettings({ + databaseConnections: { + [projectId]: { + provider: "supabase", + workspaceRoot: "/work/project-database", + projectRef: "abcdefghijk", + readOnly: false, + accessToken: "", + accessTokenRedacted: true, + }, + }, + }); + assert.equal( + roundTripped.databaseConnections[projectId]?.accessToken, + "sbp-database-secret", + ); + assert.isFalse(roundTripped.databaseConnections[projectId]?.readOnly); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4119a72640fe..8d389975b10f 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -17,6 +17,7 @@ import { DEFAULT_SERVER_SETTINGS, isProviderDriverKind, type ModelSelection, + type DatabaseConnection, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, ProviderDriverKind, @@ -79,6 +80,10 @@ function providerEnvironmentSecretName(input: { return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; } +function databaseConnectionSecretName(projectId: string): string { + return `database-supabase-${Buffer.from(projectId, "utf8").toString("base64url")}`; +} + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -93,6 +98,16 @@ function redactProviderEnvironmentVariable( }; } +function redactDatabaseConnection(connection: DatabaseConnection): DatabaseConnection { + return { + ...connection, + accessToken: "", + ...(connection.accessToken.length > 0 || connection.accessTokenRedacted + ? { accessTokenRedacted: true } + : {}), + }; +} + export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings { const providerInstances = Object.fromEntries( Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ @@ -105,7 +120,17 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS : instance, ]), ); - return { ...settings, providerInstances }; + const databaseConnections = Object.fromEntries( + Object.entries(settings.databaseConnections).map(([projectId, connection]) => [ + projectId, + redactDatabaseConnection(connection), + ]), + ); + return { + ...settings, + providerInstances, + databaseConnections: databaseConnections as ServerSettings["databaseConnections"], + }; } export class ServerSettingsService extends Context.Service< @@ -464,6 +489,111 @@ const make = Effect.gen(function* () { }; }); + const materializeDatabaseConnectionSecrets = ( + settings: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const databaseConnections: Record = { + ...settings.databaseConnections, + }; + for (const [projectId, connection] of Object.entries(settings.databaseConnections)) { + if (!connection.accessTokenRedacted) continue; + const secret = yield* secretStore.get(databaseConnectionSecretName(projectId)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-secret", + databaseProjectId: projectId, + cause, + }), + ), + ); + databaseConnections[projectId] = { + ...connection, + accessToken: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", + }; + } + return { + ...settings, + databaseConnections: databaseConnections as ServerSettings["databaseConnections"], + }; + }); + + const persistDatabaseConnectionSecrets = ( + current: ServerSettings, + next: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const databaseConnections: Record = {}; + const nextSecretKeys = new Set(); + + for (const [projectId, connection] of Object.entries(next.databaseConnections)) { + const secretName = databaseConnectionSecretName(projectId); + if (connection.accessTokenRedacted) { + nextSecretKeys.add(secretName); + databaseConnections[projectId] = redactDatabaseConnection(connection); + continue; + } + + if (connection.accessToken.length > 0) { + yield* secretStore.set(secretName, textEncoder.encode(connection.accessToken)).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "write-secret", + databaseProjectId: projectId, + cause, + }), + ), + ); + nextSecretKeys.add(secretName); + databaseConnections[projectId] = { + ...connection, + accessToken: "", + accessTokenRedacted: true, + }; + continue; + } + + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-secret", + databaseProjectId: projectId, + cause, + }), + ), + ); + const { accessTokenRedacted: _omit, ...withoutRedaction } = connection; + databaseConnections[projectId] = withoutRedaction; + } + + for (const projectId of Object.keys(current.databaseConnections)) { + const secretName = databaseConnectionSecretName(projectId); + if (nextSecretKeys.has(secretName)) continue; + yield* secretStore.remove(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "remove-stale-secret", + databaseProjectId: projectId, + cause, + }), + ), + ); + } + + return { + ...next, + databaseConnections: databaseConnections as ServerSettings["databaseConnections"], + }; + }); + const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { const sparseSettingsJson = yield* encodeServerSettingsJson( @@ -561,21 +691,28 @@ const make = Effect.gen(function* () { ready: Deferred.await(startedDeferred), getSettings: getSettingsFromCache.pipe( Effect.flatMap(materializeProviderEnvironmentSecrets), + Effect.flatMap(materializeDatabaseConnectionSecrets), Effect.map(resolveTextGenerationProvider), ), updateSettings: (patch) => writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; - const nextPersisted = yield* persistProviderEnvironmentSecrets( + const nextWithProviderSecrets = yield* persistProviderEnvironmentSecrets( current, applyServerSettingsPatch(current, patch), ); + const nextPersisted = yield* persistDatabaseConnectionSecrets( + current, + nextWithProviderSecrets, + ); const next = yield* normalizeServerSettings(nextPersisted); yield* writeSettingsAtomically(next); yield* Cache.set(settingsCache, cacheKey, next); yield* emitChange(next); - const materialized = yield* materializeProviderEnvironmentSecrets(next); + const materialized = yield* materializeProviderEnvironmentSecrets(next).pipe( + Effect.flatMap(materializeDatabaseConnectionSecrets), + ); return resolveTextGenerationProvider(materialized); }), ), @@ -583,11 +720,13 @@ const make = Effect.gen(function* () { return Stream.fromPubSub(changesPubSub).pipe( Stream.mapEffect((settings) => materializeProviderEnvironmentSecrets(settings).pipe( + Effect.flatMap(materializeDatabaseConnectionSecrets), Effect.catch((error: ServerSettingsError) => - Effect.logWarning("failed to materialize provider environment secrets", { + Effect.logWarning("failed to materialize server settings secrets", { operation: error.operation, providerInstanceId: error.providerInstanceId, environmentVariable: error.environmentVariable, + databaseProjectId: error.databaseProjectId, cause: error.cause, }).pipe(Effect.as(settings)), ), diff --git a/apps/web/src/components/settings/DatabaseSettings.test.ts b/apps/web/src/components/settings/DatabaseSettings.test.ts new file mode 100644 index 000000000000..1a202b061636 --- /dev/null +++ b/apps/web/src/components/settings/DatabaseSettings.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; +import { DEFAULT_SERVER_SETTINGS, ProjectId } from "@t3tools/contracts"; + +import { removeDatabaseConnection, upsertSupabaseConnection } from "./DatabaseSettings"; + +describe("Supabase database settings helpers", () => { + it("preserves a redacted token when editing other connection fields", () => { + const projectId = ProjectId.make("project-a"); + const current = { + [projectId]: { + provider: "supabase" as const, + workspaceRoot: "/work/project-a", + projectRef: "old-ref", + readOnly: true, + accessToken: "", + accessTokenRedacted: true, + }, + }; + + expect( + upsertSupabaseConnection(current, { + projectId, + workspaceRoot: "/work/project-a", + projectRef: "new-ref", + readOnly: false, + accessToken: "", + })[projectId], + ).toEqual({ + provider: "supabase", + workspaceRoot: "/work/project-a", + projectRef: "new-ref", + readOnly: false, + accessToken: "", + accessTokenRedacted: true, + }); + }); + + it("replaces a token and removes only the selected project", () => { + const projectId = ProjectId.make("project-a"); + const next = upsertSupabaseConnection(DEFAULT_SERVER_SETTINGS.databaseConnections, { + projectId, + workspaceRoot: "/work/project-a", + projectRef: "new-ref", + readOnly: true, + accessToken: "sbp-new", + }); + + expect(next[projectId]?.accessToken).toBe("sbp-new"); + expect(next[projectId]?.accessTokenRedacted).toBeUndefined(); + expect(removeDatabaseConnection(next, projectId)).toEqual({}); + }); +}); diff --git a/apps/web/src/components/settings/DatabaseSettings.tsx b/apps/web/src/components/settings/DatabaseSettings.tsx index 85dd3fa6e00a..e88bb7bc4b09 100644 --- a/apps/web/src/components/settings/DatabaseSettings.tsx +++ b/apps/web/src/components/settings/DatabaseSettings.tsx @@ -1,62 +1,410 @@ -import { DatabaseZapIcon } from "lucide-react"; +"use client"; +import { DatabaseZapIcon, ExternalLinkIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + ProjectId, + type ServerSettings, + type SupabaseDatabaseConnection, +} from "@t3tools/contracts"; + +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { usePrimaryEnvironment } from "../../state/environments"; +import { useProjects } from "../../state/entities"; import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; -const DATABASE_PROVIDERS = [ - { - id: "supabase", - label: "Supabase", - description: - "Connect a Supabase project so agents can inspect schemas, run queries, and manage migrations.", - }, -] as const; +interface SupabaseConnectionDraft { + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly projectRef: string; + readonly accessToken: string; + readonly readOnly: boolean; +} + +export function upsertSupabaseConnection( + connections: ServerSettings["databaseConnections"], + draft: SupabaseConnectionDraft, +): ServerSettings["databaseConnections"] { + const existing = connections[draft.projectId]; + return { + ...connections, + [draft.projectId]: { + provider: "supabase", + workspaceRoot: draft.workspaceRoot, + projectRef: draft.projectRef, + readOnly: draft.readOnly, + accessToken: draft.accessToken, + ...(draft.accessToken.length === 0 && existing?.accessTokenRedacted + ? { accessTokenRedacted: true } + : {}), + }, + }; +} + +export function removeDatabaseConnection( + connections: ServerSettings["databaseConnections"], + projectId: ProjectId, +): ServerSettings["databaseConnections"] { + const next = { ...connections }; + delete next[projectId]; + return next; +} export function DatabaseSettingsPanel() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const primaryEnvironment = usePrimaryEnvironment(); + const allProjects = useProjects(); + const projects = useMemo( + () => + primaryEnvironment === null + ? [] + : allProjects + .filter((project) => project.environmentId === primaryEnvironment.environmentId) + .sort((left, right) => left.title.localeCompare(right.title)), + [allProjects, primaryEnvironment], + ); + const projectsById = useMemo( + () => new Map(projects.map((project) => [project.id, project])), + [projects], + ); + const connections = Object.entries(settings.databaseConnections); + const unconnectedProjects = projects.filter( + (project) => settings.databaseConnections[project.id] === undefined, + ); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingProjectId, setEditingProjectId] = useState(null); + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [projectRef, setProjectRef] = useState(""); + const [accessToken, setAccessToken] = useState(""); + const [readOnly, setReadOnly] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [removeProjectId, setRemoveProjectId] = useState(null); + + const openAddDialog = () => { + setEditingProjectId(null); + setSelectedProjectId(unconnectedProjects[0]?.id ?? null); + setProjectRef(""); + setAccessToken(""); + setReadOnly(true); + setDialogOpen(true); + }; + + const openEditDialog = (projectId: ProjectId, connection: SupabaseDatabaseConnection) => { + setEditingProjectId(projectId); + setSelectedProjectId(projectId); + setProjectRef(connection.projectRef); + setAccessToken(""); + setReadOnly(connection.readOnly); + setDialogOpen(true); + }; + + const selectedProject = + selectedProjectId === null ? undefined : projectsById.get(selectedProjectId); + const existingConnection = + selectedProjectId === null ? undefined : settings.databaseConnections[selectedProjectId]; + const credentialConfigured = + accessToken.trim().length > 0 || existingConnection?.accessTokenRedacted === true; + const formValid = + selectedProject !== undefined && projectRef.trim().length > 0 && credentialConfigured; + + const saveConnection = async () => { + if (!formValid || selectedProject === undefined || selectedProjectId === null) return; + setIsSaving(true); + const persisted = await updateSettings({ + databaseConnections: upsertSupabaseConnection(settings.databaseConnections, { + projectId: selectedProjectId, + workspaceRoot: selectedProject.workspaceRoot, + projectRef: projectRef.trim(), + accessToken: accessToken.trim(), + readOnly, + }), + }); + setIsSaving(false); + if (!persisted) { + toastManager.add({ + type: "error", + title: "Could not save Supabase connection", + description: "The primary environment did not accept the settings update.", + }); + return; + } + toastManager.add({ + type: "success", + title: editingProjectId === null ? "Supabase connected" : "Supabase connection updated", + description: `${selectedProject.title} can now use project-scoped Supabase tools.`, + }); + setDialogOpen(false); + }; + + const removeConnection = async () => { + if (removeProjectId === null) return; + const project = projectsById.get(removeProjectId); + const persisted = await updateSettings({ + databaseConnections: removeDatabaseConnection(settings.databaseConnections, removeProjectId), + }); + if (!persisted) { + toastManager.add({ + type: "error", + title: "Could not remove Supabase connection", + description: "The primary environment did not accept the settings update.", + }); + return; + } + toastManager.add({ + type: "success", + title: "Supabase disconnected", + description: `${project?.title ?? "The project"} no longer exposes Supabase tools to threads.`, + }); + setRemoveProjectId(null); + }; + return ( - - {DATABASE_PROVIDERS.map((provider) => ( -
-
-
-
- - - - - - {provider.label} - - - Coming Soon - -
-

- {provider.description} + + Connect + + } + > + {connections.length === 0 ? ( +

+
+ + + +
+
Connect Supabase
+

+ Bind a local project to a Supabase project. Threads in that project receive scoped + database, advisor, and type-generation tools without exposing your personal access + token to the provider process.

-

- OAuth, project-scoped access, and read-only mode will be available here. -

-
-
-
+
- ))} + ) : ( + connections.map(([rawProjectId, connection]) => { + const projectId = ProjectId.make(rawProjectId); + const project = projectsById.get(projectId); + return ( +
+
+
+ + + +
+
+ + {project?.title ?? rawProjectId} + + + Connected + + + {connection.readOnly ? "Read only" : "Write access"} + +
+

+ {connection.projectRef} +

+

+ {connection.workspaceRoot} +

+
+
+
+ + +
+
+
+ ); + }) + )} + + + + + + {editingProjectId === null ? "Connect Supabase" : "Edit Supabase connection"} + + + The access token is stored separately with restricted permissions and is never sent to + agent provider processes. + + + + + + + + + +
+
+
Read-only mode
+

+ Recommended. Disables migrations and restricts SQL to read-only operations. +

+
+ +
+
+ + + + +
+
+ + { + if (!open) setRemoveProjectId(null); + }} + > + + + Disconnect Supabase? + + Threads in this project will immediately lose access to Supabase tools. The stored + personal access token will be removed from the server. + + + + + + + + ); } diff --git a/packages/contracts/src/database.ts b/packages/contracts/src/database.ts new file mode 100644 index 000000000000..82605f1db49e --- /dev/null +++ b/packages/contracts/src/database.ts @@ -0,0 +1,25 @@ +import * as Schema from "effect/Schema"; + +export const DatabaseToolErrorReason = Schema.Literals([ + "not-configured", + "credential-missing", + "read-only", + "remote-unavailable", + "remote-error", +]); +export type DatabaseToolErrorReason = typeof DatabaseToolErrorReason.Type; + +export class DatabaseToolError extends Schema.TaggedErrorClass()( + "DatabaseToolError", + { + reason: DatabaseToolErrorReason, + message: Schema.String, + }, +) {} + +export const SupabaseToolProxyResult = Schema.Struct({ + projectRef: Schema.String, + readOnly: Schema.Boolean, + result: Schema.Unknown, +}); +export type SupabaseToolProxyResult = typeof SupabaseToolProxyResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 7c20b19c4754..3611ae8060f2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -16,6 +16,7 @@ export * from "./model.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; +export * from "./database.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a7bc2..8767b7705ada 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -4,6 +4,7 @@ import { ApprovalRequestId, EventId, IsoDateTime, + ProjectId, ProviderItemId, ThreadId, TurnId, @@ -52,6 +53,7 @@ export type ProviderSession = typeof ProviderSession.Type; export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, + projectId: Schema.optional(ProjectId), provider: Schema.optional(ProviderDriverKind), // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ac2d47ca3365..4d4f36c072b3 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; +import { ProjectId } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, @@ -99,6 +100,33 @@ describe("ServerSettings worktree defaults", () => { }); }); +describe("ServerSettings.databaseConnections", () => { + it("defaults legacy settings to no connections", () => { + expect(decodeServerSettings({}).databaseConnections).toEqual({}); + }); + + it("decodes and trims a project-scoped Supabase connection", () => { + const projectId = ProjectId.make("project-a"); + const decoded = decodeServerSettings({ + databaseConnections: { + [projectId]: { + provider: "supabase", + workspaceRoot: " /work/project-a ", + projectRef: " abcdefghijk ", + accessToken: " sbp-token ", + }, + }, + }); + expect(decoded.databaseConnections[projectId]).toEqual({ + provider: "supabase", + workspaceRoot: "/work/project-a", + projectRef: "abcdefghijk", + readOnly: true, + accessToken: "sbp-token", + }); + }); +}); + describe("ServerSettingsPatch.providerInstances", () => { it("treats providerInstances as an optional whole-map replacement", () => { const patch = decodeServerSettingsPatch({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b05f397bf5ca..c5a12e79900e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { ProjectId, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { DEFAULT_GIT_TEXT_GENERATION_MODEL, ProviderOptionSelections } from "./model.ts"; import { ModelSelection } from "./orchestration.ts"; import { ProviderInstanceConfig, ProviderInstanceId } from "./providerInstance.ts"; @@ -361,6 +361,19 @@ export const ObservabilitySettings = Schema.Struct({ }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; +export const SupabaseDatabaseConnection = Schema.Struct({ + provider: Schema.Literal("supabase"), + workspaceRoot: TrimmedNonEmptyString, + projectRef: TrimmedNonEmptyString, + readOnly: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + accessToken: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + accessTokenRedacted: Schema.optional(Schema.Boolean), +}); +export type SupabaseDatabaseConnection = typeof SupabaseDatabaseConnection.Type; + +export const DatabaseConnection = SupabaseDatabaseConnection; +export type DatabaseConnection = typeof DatabaseConnection.Type; + export const DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL = Duration.seconds(30); export const ServerSettings = Schema.Struct({ @@ -408,6 +421,9 @@ export const ServerSettings = Schema.Struct({ providerInstances: Schema.Record(ProviderInstanceId, ProviderInstanceConfig).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + databaseConnections: Schema.Record(ProjectId, DatabaseConnection).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -434,6 +450,7 @@ export class ServerSettingsError extends Schema.TaggedErrorClass { config: { homePath: "~/.codex" }, }); }); + + it("replaces database connection maps as a whole", () => { + const projectA = ProjectId.make("project-a"); + const projectB = ProjectId.make("project-b"); + const current = { + ...DEFAULT_SERVER_SETTINGS, + databaseConnections: { + [projectA]: { + provider: "supabase" as const, + workspaceRoot: "/work/a", + projectRef: "supabase-a", + readOnly: true, + accessToken: "", + accessTokenRedacted: true, + }, + }, + }; + const replacement = { + [projectB]: { + provider: "supabase" as const, + workspaceRoot: "/work/b", + projectRef: "supabase-b", + readOnly: false, + accessToken: "replacement", + }, + }; + + expect( + applyServerSettingsPatch(current, { databaseConnections: replacement }).databaseConnections, + ).toEqual(replacement); + }); }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 1bbf466f60b8..da90d664947f 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -83,6 +83,9 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(patch.databaseConnections !== undefined + ? { databaseConnections: patch.databaseConnections } + : {}), ...(automaticGitFetchInterval !== undefined ? { automaticGitFetchInterval } : {}), }; if (!selectionPatch) { From 4028bf31c12747d812e7b6f7a86ec8f9e4e60c72 Mon Sep 17 00:00:00 2001 From: atryan Date: Sat, 25 Jul 2026 18:18:13 +0000 Subject: [PATCH 04/10] feat(preview): authenticated preview gateway, serve mode, and stability fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements slices 0-4 of the command-center stability/speed/remote-access plan. Every claim below is backed by a measurement or an executed test, not by inspection. Slice 0 — loopback origin handling `resolveHttpRequestBaseUrl`/`resolvePrimaryEnvironmentWebSocketBaseUrl` compared the current origin to `VITE_DEV_SERVER_URL` by exact string, so browsing 127.0.0.1 instead of localhost missed the vite proxy entirely and fell into a 3s discovery retry storm (measured: 8.6s of pure retry for work that takes ~30ms on the matching origin). Now compared by loopback-ness plus port. Slice 1/2 — turn lifecycle Stalled-turn watchdog threshold 20min/2min-sweep -> 10min/60s-sweep. Derived from data, not picked: across 60 healthy completed turns in the local provider logs the longest legitimate in-turn silence was p95 68.0s with a single 7.2m outlier, so 10min keeps ~40% headroom. Two new tests build the layer with no options so the production defaults are actually exercised — every pre-existing test injected a threshold and so could not catch a default regression. Tool activity payloads are now truncated before they reach the event store (32KB per string, depth 8), on both the `tool.updated` and `tool.completed` paths. Unbounded provider payloads were the main driver of SQLite growth. The resumable-subscription cursor was captured once and never advanced, so every reconnect replayed from the original sequence. `subscribeRpcStream` now accepts a thunk. Slice 3 — serve mode `pnpm serve` / `pnpm serve:fast` build the web app once and let the server serve `apps/web/dist`, making one origin carry app + /api + /ws. Measured against dev on the same screen: 250 requests / 19.15 MB / 971 ms DCL becomes 32 requests / 2.38 MB / 174 ms DCL. Documented in docs/getting-started/dev-vs-serve.md, including the VITE_HTTP_URL bake-in footgun and T3CODE_WEB_SOURCEMAP=hidden (16.7 MB JS vs 37.2 MB sourcemaps). Slice 4 — preview gateway Adds a loopback-bound proxy on its own port that forwards to 127.0.0.1: behind the existing browser session auth, so dev servers stay loopback-bound and no per-session SSH tunnel is needed. The target port travels in a cookie rather than the path because the gateway is mounted at `/` — dev servers emit absolute asset URLs that would 404 under a path prefix. Security boundary is in `preview/gatewayTarget.ts`: host is never caller-supplied, ports are parsed with a strict /^\d+$/ (rejecting "8080/../", "+8080", "0x1f"), privileged ports and the gateway's own ports are refused, and the post-selection redirect is path-normalised so it cannot be turned into an open redirect. Verified end-to-end against a real loopback-only upstream: 10/10 checks including unauthenticated 401, port-selection 303, absolute-path asset fetch, missing-cookie 421, WebSocket roundtrip, out-of-range/privileged port 400, and open-redirect neutralisation. Also confirmed in a real Chrome session at the gateway origin — page rendered, an absolute-path asset resolved, and a full HMR-style WebSocket exchange completed. Also fixes a pre-existing flake in ProviderRegistry.test.ts: it polled a real filesystem write for a fixed 50 attempts while advancing a TestClock, so the budget was bounded by fiber scheduling rather than simulated time and lost races under a loaded parallel run. Budget raised to 2000 attempts; mutation-checked by replacing the write with a no-op, which fails the test. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/bin.test.ts | 3 + apps/server/src/cli/config.test.ts | 45 ++ apps/server/src/cli/config.ts | 79 ++ apps/server/src/config.ts | 20 + .../src/environment/ServerEnvironment.test.ts | 3 + .../Layers/ProviderRuntimeIngestion.test.ts | 106 +++ .../Layers/ProviderRuntimeIngestion.ts | 58 +- .../Layers/StalledTurnWatchdog.test.ts | 76 +- .../Layers/StalledTurnWatchdog.ts | 18 +- .../src/preview/gatewayPortCookie.test.ts | 146 ++++ apps/server/src/preview/gatewayPortCookie.ts | 150 ++++ apps/server/src/preview/gatewayRoute.test.ts | 689 ++++++++++++++++++ apps/server/src/preview/gatewayRoute.ts | 356 +++++++++ .../src/preview/gatewayServedLayer.test.ts | 196 +++++ apps/server/src/preview/gatewayTarget.test.ts | 293 ++++++++ apps/server/src/preview/gatewayTarget.ts | 259 +++++++ .../provider/Layers/ProviderRegistry.test.ts | 9 +- apps/server/src/server.test.ts | 3 + apps/server/src/server.ts | 103 ++- apps/server/src/ws.ts | 15 + apps/web/src/browser/WebPreviewFrame.tsx | 89 +++ .../src/browser/browserTargetResolver.test.ts | 149 +++- apps/web/src/browser/browserTargetResolver.ts | 101 ++- apps/web/src/browser/webPreviewFrame.test.ts | 78 ++ apps/web/src/browser/webPreviewFrame.ts | 36 + apps/web/src/components/ChatView.tsx | 4 + .../chat/MessagesTimeline.logic.test.ts | 217 ++++++ .../components/chat/MessagesTimeline.logic.ts | 148 +++- .../components/chat/MessagesTimeline.test.tsx | 82 +++ .../src/components/chat/MessagesTimeline.tsx | 143 +++- .../components/preview/PreviewEmptyState.tsx | 8 +- .../src/components/preview/PreviewView.tsx | 50 +- .../settings/ConnectionsSettings.tsx | 16 +- .../environments/primary/bootstrap.test.ts | 32 + apps/web/src/environments/primary/target.ts | 44 +- apps/web/src/state/server.ts | 11 + docs/README.md | 1 + docs/getting-started/dev-vs-serve.md | 101 +++ docs/getting-started/quick-start.md | 8 + package.json | 2 + .../client-runtime/src/rpc/client.test.ts | 10 +- packages/client-runtime/src/rpc/client.ts | 19 +- .../src/state/shell-sync.test.ts | 89 +++ packages/client-runtime/src/state/shell.ts | 17 +- .../src/state/threads-sync.test.ts | 49 ++ packages/client-runtime/src/state/threads.ts | 16 +- packages/contracts/src/previewAutomation.ts | 19 +- packages/contracts/src/server.ts | 27 + packages/shared/package.json | 4 + packages/shared/src/previewGateway.test.ts | 84 +++ packages/shared/src/previewGateway.ts | 58 ++ scripts/dev-runner.test.ts | 147 ++++ scripts/dev-runner.ts | 194 +++-- 53 files changed, 4540 insertions(+), 140 deletions(-) create mode 100644 apps/server/src/preview/gatewayPortCookie.test.ts create mode 100644 apps/server/src/preview/gatewayPortCookie.ts create mode 100644 apps/server/src/preview/gatewayRoute.test.ts create mode 100644 apps/server/src/preview/gatewayRoute.ts create mode 100644 apps/server/src/preview/gatewayServedLayer.test.ts create mode 100644 apps/server/src/preview/gatewayTarget.test.ts create mode 100644 apps/server/src/preview/gatewayTarget.ts create mode 100644 apps/web/src/browser/WebPreviewFrame.tsx create mode 100644 apps/web/src/browser/webPreviewFrame.test.ts create mode 100644 apps/web/src/browser/webPreviewFrame.ts create mode 100644 docs/getting-started/dev-vs-serve.md create mode 100644 packages/shared/src/previewGateway.test.ts create mode 100644 packages/shared/src/previewGateway.ts diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 4e7ad6b63e8f..512030c8c388 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -84,6 +84,9 @@ const makeCliTestServerConfig = (baseDir: string) => logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: 8445, } satisfies ServerConfig.ServerConfig["Service"]; }); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index ebd8e6f29a27..99b1eea515c5 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -81,6 +81,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + previewGatewayEnabled: Option.none(), + previewGatewayPort: Option.none(), + previewGatewayServePort: Option.none(), }, Option.none(), ).pipe( @@ -125,6 +128,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: 8445, }); assert.equal(resolved.stateDir, join(baseDir, "userdata")); }), @@ -152,6 +158,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.some(true), tailscaleServeEnabled: Option.some(true), tailscaleServePort: Option.some(8443), + previewGatewayEnabled: Option.some(true), + previewGatewayPort: Option.some(8446), + previewGatewayServePort: Option.some(8447), }, Option.some("Debug"), ).pipe( @@ -195,6 +204,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: true, tailscaleServePort: 8443, + previewGatewayEnabled: true, + previewGatewayPort: 8446, + previewGatewayServePort: 8447, }); assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite")); }), @@ -230,6 +242,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.some(false), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + previewGatewayEnabled: Option.none(), + previewGatewayPort: Option.none(), + previewGatewayServePort: Option.none(), }, Option.none(), ).pipe( @@ -268,6 +283,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: 8445, }); }), ); @@ -305,6 +323,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + previewGatewayEnabled: Option.none(), + previewGatewayPort: Option.none(), + previewGatewayServePort: Option.none(), }, Option.none(), ).pipe( @@ -342,6 +363,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: 8445, }); assert.equal(join(baseDir, "userdata"), resolved.stateDir); }), @@ -368,6 +392,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + previewGatewayEnabled: Option.none(), + previewGatewayPort: Option.none(), + previewGatewayServePort: Option.none(), }, Option.none(), ).pipe( @@ -430,6 +457,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + previewGatewayEnabled: Option.none(), + previewGatewayPort: Option.none(), + previewGatewayServePort: Option.none(), }, Option.some("Debug"), ).pipe( @@ -470,6 +500,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: 8445, }); }), ); @@ -506,6 +539,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + previewGatewayEnabled: Option.none(), + previewGatewayPort: Option.none(), + previewGatewayServePort: Option.none(), }, Option.none(), ).pipe( @@ -539,6 +575,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: 8445, }); }), ); @@ -563,6 +602,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + previewGatewayEnabled: Option.none(), + previewGatewayPort: Option.none(), + previewGatewayServePort: Option.none(), }, Option.none(), { @@ -602,6 +644,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: 8445, }); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index ce52d9d2bda8..71dea3e3a859 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -74,6 +74,22 @@ export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +export const previewGatewayFlag = Flag.boolean("preview-gateway").pipe( + Flag.withDescription( + "Run the authenticated preview gateway, which proxies loopback dev servers behind this environment's session auth.", + ), + Flag.optional, +); +export const previewGatewayPortFlag = Flag.integer("preview-gateway-port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("Loopback port for the preview gateway listener."), + Flag.optional, +); +export const previewGatewayServePortFlag = Flag.integer("preview-gateway-serve-port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("HTTPS port Tailscale Serve publishes the preview gateway on."), + Flag.optional, +); const EnvServerConfig = Config.all({ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), @@ -134,6 +150,18 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + previewGatewayEnabled: Config.boolean("T3CODE_PREVIEW_GATEWAY").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + previewGatewayPort: Config.port("T3CODE_PREVIEW_GATEWAY_PORT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + previewGatewayServePort: Config.port("T3CODE_PREVIEW_GATEWAY_SERVE_PORT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), }); export interface CliServerFlags { @@ -149,6 +177,9 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly previewGatewayEnabled: Option.Option; + readonly previewGatewayPort: Option.Option; + readonly previewGatewayServePort: Option.Option; } export interface CliAuthLocationFlags { @@ -183,6 +214,9 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + previewGatewayEnabled: previewGatewayFlag, + previewGatewayPort: previewGatewayPortFlag, + previewGatewayServePort: previewGatewayServePortFlag, } as const; export const authLocationFlags = sharedServerLocationFlags; @@ -228,6 +262,9 @@ export const resolveServerConfig = ( logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + previewGatewayEnabled: flags.previewGatewayEnabled ?? Option.none(), + previewGatewayPort: flags.previewGatewayPort ?? Option.none(), + previewGatewayServePort: flags.previewGatewayServePort ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -331,6 +368,42 @@ export const resolveServerConfig = ( ), () => 443, ); + const previewGatewayEnabled = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.previewGatewayEnabled, + Option.fromUndefinedOr(env.previewGatewayEnabled), + ), + // The gateway only earns its keep once the environment is reachable from + // another machine, and that is exactly what Tailscale Serve means here. + () => tailscaleServeEnabled, + ); + const previewGatewayPort = previewGatewayEnabled + ? yield* Option.match( + resolveOptionPrecedence( + normalizedFlags.previewGatewayPort, + Option.fromUndefinedOr(env.previewGatewayPort), + ), + { + onSome: (value) => Effect.succeed(value), + onNone: () => + findAvailablePort( + // Never hand back the port the backend already claimed: the + // gateway refuses to forward to its own ports, so a collision + // would be a listener conflict rather than a routing loop. + port === ServerConfig.DEFAULT_PREVIEW_GATEWAY_PORT + ? ServerConfig.DEFAULT_PREVIEW_GATEWAY_PORT + 1 + : ServerConfig.DEFAULT_PREVIEW_GATEWAY_PORT, + ), + }, + ) + : 0; + const previewGatewayServePort = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.previewGatewayServePort, + Option.fromUndefinedOr(env.previewGatewayServePort), + ), + () => ServerConfig.DEFAULT_PREVIEW_GATEWAY_SERVE_PORT, + ); const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( @@ -375,6 +448,9 @@ export const resolveServerConfig = ( logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + previewGatewayEnabled, + previewGatewayPort, + previewGatewayServePort, }; return config; @@ -398,6 +474,9 @@ export const resolveCliAuthConfig = ( logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + previewGatewayEnabled: Option.none(), + previewGatewayPort: Option.none(), + previewGatewayServePort: Option.none(), }, cliLogLevel, ); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 3b081c95c349..61bec53014d3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -16,6 +16,18 @@ import * as Schema from "effect/Schema"; export const DEFAULT_PORT = 3773; +/** + * HTTPS port Tailscale Serve publishes the preview gateway on. + * + * The gateway is mounted at the root of its *own* port rather than under a path + * prefix on the main one, because dev servers emit absolute URLs that would 404 + * under a prefix. That costs one extra Tailscale mapping, and this is it. + */ +export const DEFAULT_PREVIEW_GATEWAY_SERVE_PORT = 8445; + +/** Preferred loopback port for the preview gateway listener. */ +export const DEFAULT_PREVIEW_GATEWAY_PORT = 3774; + export const RuntimeMode = Schema.Literals(["web", "desktop"]); export type RuntimeMode = typeof RuntimeMode.Type; @@ -79,6 +91,11 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + readonly previewGatewayEnabled: boolean; + /** Loopback port the preview gateway listens on; 0 asks the OS for one. */ + readonly previewGatewayPort: number; + /** HTTPS port Tailscale Serve publishes the gateway on, when Serve is enabled. */ + readonly previewGatewayServePort: number; } >()("t3/config/ServerConfig") { /** @deprecated Import and use `layerTest` from this module. */ @@ -182,6 +199,9 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: DEFAULT_PREVIEW_GATEWAY_SERVE_PORT, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 6b3290246fea..a418956695d3 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -38,6 +38,9 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: 8445, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 001ba3889496..588e82f9f28c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -849,6 +849,112 @@ describe("ProviderRuntimeIngestion", () => { expect(payload?.detail).toBe("bun run lint"); }); + it("caps oversized command output stored in tool activity data", async () => { + const harness = await createHarness(); + // 1 MiB is the provider's own ceiling for aggregatedOutput, and rows that + // size are what drove the event store to 33.7 MB of command output here. + const hugeOutput = "x".repeat(1_048_576); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-huge-output"), + provider: ProviderDriverKind.make("cursor"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-huge-output"), + itemId: asItemId("item-huge-output"), + payload: { + itemType: "command_execution", + status: "completed", + title: "Ran command", + detail: "pnpm build", + data: { + completedAtMs: 1, + item: { + aggregatedOutput: hugeOutput, + command: "pnpm build", + exitCode: 0, + type: "command_execution", + }, + }, + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-huge-output", + ), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-huge-output", + ); + const payload = activity?.payload as Record | undefined; + const data = payload?.["data"] as Record | undefined; + const item = data?.["item"] as Record | undefined; + const stored = item?.["aggregatedOutput"]; + + // Bounded, and by a wide margin over the 1 MiB input. + expect(typeof stored).toBe("string"); + expect((stored as string).length).toBeLessThan(40_000); + expect(stored).toContain("… [truncated"); + // The head of the output survives — this is a cap, not a drop. + expect((stored as string).startsWith("x".repeat(1000))).toBe(true); + // Structure and sibling fields are untouched, so consumers still resolve. + expect(item?.["command"]).toBe("pnpm build"); + expect(item?.["exitCode"]).toBe(0); + expect(data?.["completedAtMs"]).toBe(1); + expect(payload?.["detail"]).toBe("pnpm build"); + }); + + it("leaves normal-sized tool activity data untouched", async () => { + const harness = await createHarness(); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-small-output"), + provider: ProviderDriverKind.make("cursor"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-small-output"), + itemId: asItemId("item-small-output"), + payload: { + itemType: "command_execution", + status: "completed", + title: "Ran command", + detail: "pnpm lint", + data: { + item: { + aggregatedOutput: "all files pass\n", + commandActions: [{ command: "pnpm lint" }], + exitCode: 0, + }, + }, + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-small-output", + ), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-small-output", + ); + const data = (activity?.payload as Record | undefined)?.["data"] as + | Record + | undefined; + const item = data?.["item"] as Record | undefined; + + expect(item?.["aggregatedOutput"]).toBe("all files pass\n"); + expect(item?.["exitCode"]).toBe(0); + // Arrays keep their shape rather than being flattened into objects. + const commandActions = item?.["commandActions"]; + expect(Array.isArray(commandActions)).toBe(true); + expect((commandActions as ReadonlyArray>)[0]?.["command"]).toBe( + "pnpm lint", + ); + }); + it("uses structured read-file paths when available", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3e5978f4846d..32f44e9f5cbd 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -166,6 +166,56 @@ function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; } +/** + * Cap on any single string inside a tool activity's opaque `data` blob. + * + * Tool payloads are the dominant consumer of the event store: on this instance + * `item.aggregatedOutput` alone accounted for 33.7 MB — 87% of all text across + * 9,295 tool activities — with individual rows hitting the provider's own 1 MiB + * ceiling. Every event is also projected into `projection_thread_activities`, + * so an uncapped payload is written twice and replayed on every reconnect. + * + * 32 KiB keeps a command's output readable (the p99 command here is far under + * it) while turning a pathological 1 MiB row into a bounded one. Truncation is + * marked inline so a reader can tell the difference between "the command + * printed nothing more" and "we stopped recording". + */ +const MAX_ACTIVITY_DATA_STRING_CHARS = 32_768; +const MAX_ACTIVITY_DATA_DEPTH = 8; + +const truncationNotice = (originalLength: number): string => + `\n… [truncated ${(originalLength - MAX_ACTIVITY_DATA_STRING_CHARS).toLocaleString("en-US")} more characters]`; + +/** + * Bound every string in a tool payload, structure preserved. + * + * `payload` is `Schema.Unknown` in the contract and the shapes come straight + * from provider SDKs, so this walks generically rather than naming fields: + * capping `aggregatedOutput` by name would miss the next provider's equivalent. + * Non-string leaves, object keys, and array positions are all left intact, so + * consumers that read a specific path still find it. + */ +function truncateActivityData(value: unknown, depth = 0): unknown { + if (typeof value === "string") { + return value.length > MAX_ACTIVITY_DATA_STRING_CHARS + ? `${value.slice(0, MAX_ACTIVITY_DATA_STRING_CHARS)}${truncationNotice(value.length)}` + : value; + } + // Depth guard: provider payloads are attacker-adjacent (a tool prints what it + // likes) and a cyclic or absurdly nested blob must not take the ingester down. + if (depth >= MAX_ACTIVITY_DATA_DEPTH || value === null || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map((entry) => truncateActivityData(entry, depth + 1)); + } + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + result[key] = truncateActivityData(entry, depth + 1); + } + return result; +} + function normalizeProposedPlanMarkdown(planMarkdown: string | undefined): string | undefined { const trimmed = planMarkdown?.trim(); if (!trimmed) { @@ -569,7 +619,9 @@ function runtimeEventToActivities( itemType: event.payload.itemType, ...(event.payload.status ? { status: event.payload.status } : {}), ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), - ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + ...(event.payload.data !== undefined + ? { data: truncateActivityData(event.payload.data) } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -591,7 +643,9 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), - ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), + ...(event.payload.data !== undefined + ? { data: truncateActivityData(event.payload.data) } + : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/orchestration/Layers/StalledTurnWatchdog.test.ts b/apps/server/src/orchestration/Layers/StalledTurnWatchdog.test.ts index e10da79ba655..9c027f031727 100644 --- a/apps/server/src/orchestration/Layers/StalledTurnWatchdog.test.ts +++ b/apps/server/src/orchestration/Layers/StalledTurnWatchdog.test.ts @@ -144,6 +144,9 @@ describe("StalledTurnWatchdog", () => { readonly snapshot: OrchestrationShellSnapshot; readonly stallThresholdMs?: number; readonly interruptTurnImplementation?: ProviderServiceShape["interruptTurn"]; + // When true, build the layer with no options at all so the assertions + // exercise the production defaults rather than injected test values. + readonly useProductionDefaults?: boolean; }) { const dispatched: DispatchedCommand[] = []; const dispatch = vi.fn((command) => @@ -174,11 +177,15 @@ describe("StalledTurnWatchdog", () => { streamEvents: Stream.empty, }; - const layer = makeStalledTurnWatchdogLive({ - // Large sweep interval so exactly one sweep runs during the test window. - stallThresholdMs: input.stallThresholdMs ?? 1_000, - sweepIntervalMs: 60_000, - }).pipe( + const layer = makeStalledTurnWatchdogLive( + input.useProductionDefaults === true + ? undefined + : { + // Large sweep interval so exactly one sweep runs during the test window. + stallThresholdMs: input.stallThresholdMs ?? 1_000, + sweepIntervalMs: 60_000, + }, + ).pipe( Layer.provideMerge(Layer.succeed(ProviderService, providerService)), Layer.provideMerge( Layer.succeed(OrchestrationEngineService, { @@ -258,7 +265,7 @@ describe("StalledTurnWatchdog", () => { it("does not touch a running turn whose updatedAt is fresh", async () => { const threadId = ThreadId.make("thread-watchdog-fresh"); const turnId = TurnId.make("turn-watchdog-fresh"); - const freshNow = DateTime.formatIso(await Effect.runPromise(DateTime.now)); + const freshNow = DateTime.formatIso(DateTime.nowUnsafe()); const harness = createHarness({ // 20-minute threshold; the turn was just active → healthy long turn. stallThresholdMs: 20 * 60 * 1000, @@ -336,6 +343,63 @@ describe("StalledTurnWatchdog", () => { expect(harness.dispatched).toHaveLength(0); }); + // The suite above always injects a threshold, so it cannot catch a regression + // in the production defaults. These two pin them from both sides. The 10m value + // is derived from measured provider logs (see the comment on + // DEFAULT_STALL_THRESHOLD_MS): the worst legitimate intra-turn silence observed + // across 60 healthy turns was 7.2m, so 9m must survive and 11m must not. + it("leaves a turn alone at 9m of silence under production defaults", async () => { + const threadId = ThreadId.make("thread-watchdog-defaults-under"); + const turnId = TurnId.make("turn-watchdog-defaults-under"); + const silentFor9m = DateTime.formatIso(DateTime.subtract(DateTime.nowUnsafe(), { minutes: 9 })); + const harness = createHarness({ + useProductionDefaults: true, + snapshot: makeShellSnapshot([ + makeShell({ + id: threadId, + session: makeRunningSession(threadId, turnId), + latestTurn: makeRunningTurn(turnId), + updatedAt: silentFor9m, + }), + ]), + }); + + await startWatchdog(); + await Effect.runPromise(drainFibers); + + expect(harness.interruptTurn).not.toHaveBeenCalled(); + expect(harness.dispatched).toHaveLength(0); + }); + + it("auto-fails a turn at 11m of silence under production defaults", async () => { + const threadId = ThreadId.make("thread-watchdog-defaults-over"); + const turnId = TurnId.make("turn-watchdog-defaults-over"); + const silentFor11m = DateTime.formatIso( + DateTime.subtract(DateTime.nowUnsafe(), { minutes: 11 }), + ); + const harness = createHarness({ + useProductionDefaults: true, + snapshot: makeShellSnapshot([ + makeShell({ + id: threadId, + session: makeRunningSession(threadId, turnId), + latestTurn: makeRunningTurn(turnId), + updatedAt: silentFor11m, + }), + ]), + }); + + await startWatchdog(); + await waitFor(() => harness.interruptTurn.mock.calls.length === 1); + + const activity = harness.dispatched.find((c) => c.type === "thread.activity.append"); + expect(activity).toBeDefined(); + if (activity?.type === "thread.activity.append") { + // Message is rendered from the threshold, so it also pins the 10m value. + expect(activity.activity.summary).toContain("10m"); + } + }); + it("does not auto-fail a turn parked on a pending approval", async () => { const threadId = ThreadId.make("thread-watchdog-pending-approval"); const turnId = TurnId.make("turn-watchdog-pending-approval"); diff --git a/apps/server/src/orchestration/Layers/StalledTurnWatchdog.ts b/apps/server/src/orchestration/Layers/StalledTurnWatchdog.ts index c3b9f4d1dbd9..2308b4fa14e0 100644 --- a/apps/server/src/orchestration/Layers/StalledTurnWatchdog.ts +++ b/apps/server/src/orchestration/Layers/StalledTurnWatchdog.ts @@ -21,8 +21,22 @@ import { // bumps — it tracks the provider stream precisely and freezes the instant the // stream goes silent (unlike `provider_session_runtime.last_seen_at`, which only // bumps on session-lifecycle changes and reads stale during a healthy long turn). -const DEFAULT_STALL_THRESHOLD_MS = 20 * 60 * 1000; -const DEFAULT_SWEEP_INTERVAL_MS = 2 * 60 * 1000; +// +// The watchdog is the *only* recovery path for a turn that stalls while its +// provider session stays alive: a session that exits emits `session.exited`, +// which settles the turn within ~1s, but a live session that simply stops +// emitting `turn.completed` produces no signal at all. Measured over 4 days of +// local provider logs, 8 such turns hung behind a live codex session — one for +// 20m11s, ending only when this sweep fired. +// +// Threshold derived from the same logs rather than guessed. Across 60 healthy +// completed turns, the longest silence *within* a turn was: p50 12.4s, p90 +// 52.4s, p95 68.0s, and a single 430s (7.2m) outlier — the next longest was +// 75.9s. 10 minutes leaves ~40% headroom over the worst legitimate silence +// observed while halving the window a user spends watching a false spinner. +// Worst case to recovery is threshold + sweep interval. +const DEFAULT_STALL_THRESHOLD_MS = 10 * 60 * 1000; +const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000; export interface StalledTurnWatchdogLiveOptions { readonly stallThresholdMs?: number; diff --git a/apps/server/src/preview/gatewayPortCookie.test.ts b/apps/server/src/preview/gatewayPortCookie.test.ts new file mode 100644 index 000000000000..d2c18c89460c --- /dev/null +++ b/apps/server/src/preview/gatewayPortCookie.test.ts @@ -0,0 +1,146 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { signPayload } from "../auth/utils.ts"; +import { + describePreviewPortCookieRejection, + resolvePreviewPortCookieName, + signPreviewPortCookie, + verifyPreviewPortCookie, +} from "./gatewayPortCookie.ts"; + +const secret = new Uint8Array(32).fill(7); +const otherSecret = new Uint8Array(32).fill(9); +const now = 1_800_000_000_000; +const later = now + 60_000; + +const mint = (port: number, expiresAtMillis = later) => + signPreviewPortCookie({ port, expiresAtMillis, secret }); + +describe("resolvePreviewPortCookieName", () => { + it("uses one name in web mode and a per-port name in desktop mode", () => { + assert.equal(resolvePreviewPortCookieName({ mode: "web", port: 13_773 }), "t3_preview_port"); + assert.equal( + resolvePreviewPortCookieName({ mode: "desktop", port: 13_773 }), + "t3_preview_port_13773", + ); + // Two desktop servers on one machine must not overwrite each other's selection. + assert.notEqual( + resolvePreviewPortCookieName({ mode: "desktop", port: 13_773 }), + resolvePreviewPortCookieName({ mode: "desktop", port: 13_791 }), + ); + }); +}); + +describe("preview port cookie round trip", () => { + it("recovers the port it was signed with", () => { + const result = verifyPreviewPortCookie({ value: mint(5173), secret, nowMillis: now }); + assert.deepStrictEqual(result, { ok: true, port: 5173, expiresAtMillis: later }); + }); + + it("produces a different value per port", () => { + assert.notEqual(mint(5173), mint(5174)); + }); +}); + +describe("verifyPreviewPortCookie", () => { + // The whole point of signing: a browser-editable cookie would otherwise be a + // "pick your own upstream" control on the gateway. + it("rejects a value signed with a different secret", () => { + const forged = signPreviewPortCookie({ + port: 5173, + expiresAtMillis: later, + secret: otherSecret, + }); + assert.deepStrictEqual(verifyPreviewPortCookie({ value: forged, secret, nowMillis: now }), { + ok: false, + reason: "bad-signature", + }); + }); + + it("rejects a tampered payload even though the signature is well-formed", () => { + const [, signature] = mint(5173).split("."); + const swapped = mint(9999).split(".")[0]; + assert.deepStrictEqual( + verifyPreviewPortCookie({ value: `${swapped}.${signature}`, secret, nowMillis: now }), + { ok: false, reason: "bad-signature" }, + ); + }); + + it("rejects structurally broken values", () => { + for (const value of [undefined, "", "nodot", "too.many.dots", ".", "payload.", ".signature"]) { + const result = verifyPreviewPortCookie({ value, secret, nowMillis: now }); + assert.equal(result.ok, false, `${JSON.stringify(value)} must be rejected`); + } + }); + + // Signed but corrupt: the bytes are ours, so the failure has to be a rejection + // rather than a thrown decode error surfacing as a 500. + it("rejects a correctly signed payload that is not the expected shape", () => { + for (const payload of [ + "not-json", + '{"port":5173}', + '{"exp":123}', + '{"port":"5173","exp":1}', + '{"port":5173.5,"exp":1}', + "[]", + ]) { + const encoded = Buffer.from(payload, "utf8").toString("base64url"); + const signed = `${encoded}.${signPayload(encoded, secret)}`; + assert.deepStrictEqual( + verifyPreviewPortCookie({ value: signed, secret, nowMillis: now }), + { ok: false, reason: "malformed" }, + `${payload} must be rejected as malformed`, + ); + } + }); + + it("rejects an expired selection", () => { + assert.deepStrictEqual( + verifyPreviewPortCookie({ value: mint(5173, now - 1), secret, nowMillis: now }), + { ok: false, reason: "expired" }, + ); + // Boundary: expiry is exclusive, so exp === now is already expired. + assert.deepStrictEqual( + verifyPreviewPortCookie({ value: mint(5173, now), secret, nowMillis: now }), + { ok: false, reason: "expired" }, + ); + assert.equal( + verifyPreviewPortCookie({ value: mint(5173, now + 1), secret, nowMillis: now }).ok, + true, + ); + }); + + // A validly signed cookie is still not permission to reach any port: the + // range rules stay authoritative, including for cookies minted before the + // server knew which ports were its own. + it("still applies the gateway port rules to a validly signed port", () => { + assert.deepStrictEqual(verifyPreviewPortCookie({ value: mint(22), secret, nowMillis: now }), { + ok: false, + reason: "unusable-port", + }); + assert.deepStrictEqual( + verifyPreviewPortCookie({ + value: mint(13_773), + secret, + nowMillis: now, + selfPorts: [13_773, 8445], + }), + { ok: false, reason: "unusable-port" }, + ); + assert.deepStrictEqual( + verifyPreviewPortCookie({ + value: mint(5173), + secret, + nowMillis: now, + selfPorts: [13_773, 8445], + }), + { ok: true, port: 5173, expiresAtMillis: later }, + ); + }); + + it("explains every rejection", () => { + for (const reason of ["malformed", "bad-signature", "expired", "unusable-port"] as const) { + assert.ok(describePreviewPortCookieRejection(reason).length > 0); + } + }); +}); diff --git a/apps/server/src/preview/gatewayPortCookie.ts b/apps/server/src/preview/gatewayPortCookie.ts new file mode 100644 index 000000000000..b88f834eea21 --- /dev/null +++ b/apps/server/src/preview/gatewayPortCookie.ts @@ -0,0 +1,150 @@ +/** + * The signed cookie that names which loopback port the preview gateway forwards to. + * + * The gateway is mounted at the *root* of its own port rather than under a + * `/preview//` prefix, because dev servers emit absolute URLs + * (`/@vite/client`, `/src/main.tsx`, the HMR socket) that would resolve against + * the gateway origin and 404 under a prefix. Root mounting means the request + * path can no longer carry the target port, so the port travels in a cookie. + * + * A cookie the browser can edit would turn the gateway into a "pick your own + * upstream" control, so the value is HMAC-signed with the same server secret + * machinery the session cookie uses and carries its own expiry. Verification is + * pure and lives here so it can be tested exhaustively; the port range and + * loopback rules stay in {@link ./gatewayTarget.ts}. + */ + +import * as Encoding from "effect/Encoding"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { base64UrlEncode, signPayload, timingSafeEqualBase64Url } from "../auth/utils.ts"; + +import { resolveGatewayPort } from "./gatewayTarget.ts"; + +const PREVIEW_PORT_COOKIE_NAME = "t3_preview_port"; + +/** Name of the server secret that signs preview port cookies. */ +export const PREVIEW_PORT_SIGNING_SECRET_NAME = "preview-gateway-key"; + +/** Bytes of entropy in the preview port signing secret. */ +export const PREVIEW_PORT_SIGNING_SECRET_BYTES = 32; + +/** How long a port selection stays valid before the client must re-select. */ +export const PREVIEW_PORT_COOKIE_TTL_MILLIS = 12 * 60 * 60 * 1000; + +/** + * Desktop mode runs several servers on one machine, each with its own signing + * secret; a shared cookie name would make them fight over the same slot the way + * the session cookie would. Mirrors `resolveSessionCookieName`. + */ +export function resolvePreviewPortCookieName(input: { + readonly mode: "web" | "desktop"; + readonly port: number; +}): string { + if (input.mode !== "desktop") { + return PREVIEW_PORT_COOKIE_NAME; + } + return `${PREVIEW_PORT_COOKIE_NAME}_${input.port}`; +} + +export type PreviewPortCookieRejection = + | "malformed" + | "bad-signature" + | "expired" + | "unusable-port"; + +export type PreviewPortCookieVerification = + | { readonly ok: true; readonly port: number; readonly expiresAtMillis: number } + | { readonly ok: false; readonly reason: PreviewPortCookieRejection }; + +const PreviewPortClaims = Schema.Struct({ + port: Schema.Int, + exp: Schema.Int, +}); + +const encodeClaims = Schema.encodeSync(Schema.fromJsonString(PreviewPortClaims)); +const decodeClaims = Schema.decodeUnknownResult(Schema.fromJsonString(PreviewPortClaims)); + +/** + * Mint a signed cookie value naming `port` as the gateway's upstream. + * + * The port is not re-validated here: callers mint from a port they have already + * resolved, and signing is the wrong place to discover a bad one — by the time + * verification rejects it, the caller can no longer be told what it did wrong. + */ +export function signPreviewPortCookie(input: { + readonly port: number; + readonly expiresAtMillis: number; + readonly secret: Uint8Array; +}): string { + const encoded = base64UrlEncode( + encodeClaims({ port: input.port, exp: Math.floor(input.expiresAtMillis) }), + ); + return `${encoded}.${signPayload(encoded, input.secret)}`; +} + +/** + * Verify a cookie value and recover the port it names. + * + * `selfPorts` are the server's own listening ports, forwarded to + * {@link resolveGatewayPort} so a cookie can never aim the gateway at itself — + * including a cookie that was validly signed before those ports were known. + */ +export function verifyPreviewPortCookie(input: { + readonly value: string | undefined; + readonly secret: Uint8Array; + readonly nowMillis: number; + readonly selfPorts?: ReadonlyArray; +}): PreviewPortCookieVerification { + if (typeof input.value !== "string" || input.value.length === 0) { + return { ok: false, reason: "malformed" }; + } + + const parts = input.value.split("."); + if (parts.length !== 2) { + return { ok: false, reason: "malformed" }; + } + const [encoded, signature] = parts; + if (!encoded || !signature) { + return { ok: false, reason: "malformed" }; + } + + // Signature first: everything below this line parses attacker-supplied bytes. + if (!timingSafeEqualBase64Url(signPayload(encoded, input.secret), signature)) { + return { ok: false, reason: "bad-signature" }; + } + + const decoded = Result.getOrUndefined(Encoding.decodeBase64UrlString(encoded)); + if (decoded === undefined) { + return { ok: false, reason: "malformed" }; + } + const claims = Result.getOrUndefined(decodeClaims(decoded)); + if (claims === undefined) { + return { ok: false, reason: "malformed" }; + } + if (claims.exp <= input.nowMillis) { + return { ok: false, reason: "expired" }; + } + + const resolved = resolveGatewayPort(claims.port, input.selfPorts ?? []); + if (!resolved.ok) { + return { ok: false, reason: "unusable-port" }; + } + + return { ok: true, port: resolved.port, expiresAtMillis: claims.exp }; +} + +/** Human-readable explanation for a rejected cookie, safe to return in a response body. */ +export function describePreviewPortCookieRejection(reason: PreviewPortCookieRejection): string { + switch (reason) { + case "malformed": + return "Preview port selection is unreadable. Select a preview port again."; + case "bad-signature": + return "Preview port selection was not issued by this server."; + case "expired": + return "Preview port selection has expired. Select a preview port again."; + case "unusable-port": + return "Preview port selection names a port the gateway will not forward to."; + } +} diff --git a/apps/server/src/preview/gatewayRoute.test.ts b/apps/server/src/preview/gatewayRoute.test.ts new file mode 100644 index 000000000000..4fb4af05372e --- /dev/null +++ b/apps/server/src/preview/gatewayRoute.test.ts @@ -0,0 +1,689 @@ +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeSocket from "@effect/platform-node/NodeSocket"; + +import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; +import { + PREVIEW_GATEWAY_PORT_PARAM, + PREVIEW_GATEWAY_REDIRECT_PARAM, + PREVIEW_GATEWAY_SELECT_PATH, +} from "@t3tools/shared/previewGateway"; +import { assert, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + type HttpClientResponse, + HttpRouter, + HttpServer, +} from "effect/unstable/http"; +import * as Cookies from "effect/unstable/http/Cookies"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; + +import { makePreviewGatewayRoutesLayer } from "./gatewayRoute.ts"; + +/** + * A stand-in for the dev server behind the gateway. + * + * It records what it actually received, which is how the "the credential never + * reaches the dev server" claims below are checked — asserting on the gateway's + * own view would only prove that the test and the code agree. + */ +interface UpstreamRequestRecord { + readonly method: string; + readonly url: string; + readonly headers: Readonly>; + readonly body: string; +} + +interface UpstreamHandlerInput { + readonly method: string; + readonly url: string; + readonly body: string; +} + +interface UpstreamResponse { + readonly status?: number; + /** + * Array values become genuinely repeated headers on the wire. A comma-joined + * string does NOT: `Set-Cookie` is the one header where the difference is + * load-bearing, and a joined string would let a broken relay pass. + */ + readonly headers?: Readonly>>; + readonly body?: string; +} + +const startUpstream = (respond: (request: UpstreamHandlerInput) => UpstreamResponse) => + Effect.gen(function* () { + const received: UpstreamRequestRecord[] = []; + const server = yield* Effect.acquireRelease( + Effect.promise(async () => { + const NodeHttp = await import("node:http"); + const instance = NodeHttp.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + received.push({ + method: request.method ?? "", + url: request.url ?? "", + headers: request.headers as Readonly>, + body, + }); + const result = respond({ method: request.method ?? "", url: request.url ?? "", body }); + response.writeHead( + result.status ?? 200, + (result.headers ?? {}) as Record>, + ); + response.end(result.body); + }); + }); + await new Promise((resolve, reject) => { + instance.on("error", reject); + instance.listen(0, "127.0.0.1", resolve); + }); + return instance; + }), + (instance) => + Effect.promise( + () => + new Promise((resolve) => { + instance.closeAllConnections(); + instance.close(() => resolve()); + }), + ), + ); + + const address = server.address(); + if (address === null || typeof address === "string") { + return yield* Effect.die(new Error("Expected a TCP address for the upstream test server.")); + } + return { port: address.port, received } as const; + }); + +/** A port nothing is listening on: bound to learn the number, then released. */ +const reserveClosedPort = Effect.promise(async () => { + const NodeNet = await import("node:net"); + const server = NodeNet.createServer(); + const port = await new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("Expected a TCP address for the reserved port.")); + return; + } + resolve(address.port); + }); + }); + await new Promise((resolve) => server.close(() => resolve())); + return port; +}); + +/** + * Count `Set-Cookie` headers as they actually appear on the wire. + * + * Every parsed view of a response loses this: `Cookies` is keyed by cookie name + * and the Effect header map is a `Record`, so a cookie sent twice looks identical + * to a cookie sent once in both. + */ +const countRawSetCookieHeaders = (input: { readonly port: number; readonly cookie: string }) => + Effect.promise(async () => { + const NodeHttp = await import("node:http"); + return await new Promise((resolve, reject) => { + const request = NodeHttp.request( + { host: "127.0.0.1", port: input.port, path: "/", headers: { cookie: input.cookie } }, + (response) => { + const raw = response.headersDistinct["set-cookie"] ?? []; + response.resume(); + response.on("end", () => resolve(raw.length)); + }, + ); + request.on("error", reject); + request.end(); + }); + }); + +const buildGatewayUnderTest = Effect.fnUntraced(function* (options?: { + readonly config?: Partial; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-preview-gateway-" }); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + yield* ServerConfig.ensureServerDirectories(derivedPaths); + + const config: ServerConfig.ServerConfig["Service"] = { + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + // Zero keeps the backend out of `selfPorts`, so the only port the gateway + // refuses as "its own" is the ephemeral one the test server actually bound. + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + startupPresentation: "browser", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + previewGatewayEnabled: true, + previewGatewayPort: 0, + previewGatewayServePort: 8445, + ...options?.config, + }; + + const dependenciesLayer = Layer.mergeAll( + EnvironmentAuth.layer.pipe(Layer.provide(SqlitePersistenceMemory)), + // The gateway's own outbound client. It must not be the test client, which + // is pinned to the gateway's base URL and would rewrite upstream URLs. + FetchHttpClient.layer, + ).pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(ServerConfig.layer(config)), + ); + + const servedLayer = HttpRouter.serve(makePreviewGatewayRoutesLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.provideMerge(dependenciesLayer)); + + const context = yield* Layer.build(servedLayer); + const sessions = Context.get(context, SessionStore.SessionStore); + + const server = yield* HttpServer.HttpServer; + const address = server.address as HttpServer.TcpAddress; + + const issueSessionCookie = ( + scopes?: ReadonlyArray< + typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope + >, + ) => + sessions + .issue({ + scopes: scopes ?? [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], + }) + .pipe(Effect.map((session) => `${sessions.cookieName}=${session.token}`)); + + return { + config, + gatewayPort: address.port, + sessionCookieName: sessions.cookieName, + issueSessionCookie, + } as const; +}); + +const gatewayRequest = ( + path: string, + options?: { + readonly method?: "GET" | "POST" | "HEAD"; + readonly cookie?: string; + readonly headers?: Readonly>; + readonly body?: string; + readonly followRedirects?: boolean; + }, +) => { + const request = HttpClientRequest.make(options?.method ?? "GET")(path, { + headers: { + ...(options?.cookie === undefined ? {} : { cookie: options.cookie }), + ...options?.headers, + }, + }).pipe( + options?.body === undefined + ? (self) => self + : HttpClientRequest.bodyText(options.body, "text/plain"), + ); + const executed = HttpClient.execute(request); + return options?.followRedirects === true + ? executed + : executed.pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); +}; + +/** Run the select route and return a cookie header carrying both credentials. */ +const selectPreviewPort = Effect.fnUntraced(function* (input: { + readonly sessionCookie: string; + readonly port: number; +}) { + const response = yield* gatewayRequest( + `${PREVIEW_GATEWAY_SELECT_PATH}?${PREVIEW_GATEWAY_PORT_PARAM}=${input.port}`, + { cookie: input.sessionCookie }, + ); + assert.equal(response.status, 303); + const previewCookies = Cookies.toCookieHeader(response.cookies); + assert.notEqual(previewCookies, ""); + return `${input.sessionCookie}; ${previewCookies}`; +}); + +const responseText = (response: HttpClientResponse.HttpClientResponse) => response.text; + +it.layer(NodeServices.layer)("preview gateway", (it) => { + it.effect("rejects a proxy request with no session credential", () => + Effect.gen(function* () { + yield* buildGatewayUnderTest(); + + const response = yield* gatewayRequest("/"); + + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects a select request with no session credential", () => + Effect.gen(function* () { + yield* buildGatewayUnderTest(); + + const response = yield* gatewayRequest( + `${PREVIEW_GATEWAY_SELECT_PATH}?${PREVIEW_GATEWAY_PORT_PARAM}=45678`, + ); + + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects a select request from a session without the operate scope", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie([AuthOrchestrationReadScope]); + + const response = yield* gatewayRequest( + `${PREVIEW_GATEWAY_SELECT_PATH}?${PREVIEW_GATEWAY_PORT_PARAM}=45678`, + { cookie: sessionCookie }, + ); + + assert.equal(response.status, 403); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("answers 421 when an authenticated request has selected no port", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + + const response = yield* gatewayRequest("/", { cookie: sessionCookie }); + + assert.equal(response.status, 421); + assert.include(yield* responseText(response), "Select a preview port"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects a forged port cookie rather than forwarding to it", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const upstream = yield* startUpstream(() => ({ body: "should-not-be-reached" })); + + const nowMillis = yield* Clock.currentTimeMillis; + const forged = `${sessionCookie}; t3_preview_port=${Buffer.from( + `{"port":${upstream.port},"exp":${nowMillis + 60_000}}`, + ).toString("base64url")}.not-a-real-signature`; + const response = yield* gatewayRequest("/", { cookie: forged }); + + assert.equal(response.status, 421); + assert.include(yield* responseText(response), "not issued by this server"); + assert.equal(upstream.received.length, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("refuses to select the gateway's own port", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + + const response = yield* gatewayRequest( + `${PREVIEW_GATEWAY_SELECT_PATH}?${PREVIEW_GATEWAY_PORT_PARAM}=${gateway.gatewayPort}`, + { cookie: sessionCookie }, + ); + + assert.equal(response.status, 400); + assert.include(yield* responseText(response), "its own port"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("refuses to select a privileged port", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + + const response = yield* gatewayRequest( + `${PREVIEW_GATEWAY_SELECT_PATH}?${PREVIEW_GATEWAY_PORT_PARAM}=80`, + { cookie: sessionCookie }, + ); + + assert.equal(response.status, 400); + assert.include(yield* responseText(response), "not reachable through the gateway"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("redirects to the requested same-origin path after selecting a port", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + + const response = yield* gatewayRequest( + `${PREVIEW_GATEWAY_SELECT_PATH}?${PREVIEW_GATEWAY_PORT_PARAM}=45678&${PREVIEW_GATEWAY_REDIRECT_PARAM}=%2Fdashboard`, + { cookie: sessionCookie }, + ); + + assert.equal(response.status, 303); + assert.equal(response.headers.location, "/dashboard"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("ignores an off-origin redirect target instead of honouring it", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + + const response = yield* gatewayRequest( + `${PREVIEW_GATEWAY_SELECT_PATH}?${PREVIEW_GATEWAY_PORT_PARAM}=45678&${PREVIEW_GATEWAY_REDIRECT_PARAM}=%2F%2Fevil.example.com%2F`, + { cookie: sessionCookie }, + ); + + assert.equal(response.status, 303); + assert.equal(response.headers.location, "/"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("forwards path, query, and body to the selected upstream", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const upstream = yield* startUpstream((request) => ({ + status: 201, + headers: { "content-type": "text/plain", "x-upstream": "yes" }, + body: `saw ${request.method} ${request.url} body=${request.body}`, + })); + const cookie = yield* selectPreviewPort({ sessionCookie, port: upstream.port }); + + const response = yield* gatewayRequest("/api/thing?a=1&b=2", { + method: "POST", + cookie, + body: "payload", + }); + + assert.equal(response.status, 201); + assert.equal(response.headers["x-upstream"], "yes"); + assert.equal(yield* responseText(response), "saw POST /api/thing?a=1&b=2 body=payload"); + + const record = upstream.received[0]; + assert.isDefined(record); + assert.equal(record.url, "/api/thing?a=1&b=2"); + assert.equal(record.headers.host, `127.0.0.1:${upstream.port}`); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("strips both gateway credentials from what the upstream receives", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const upstream = yield* startUpstream(() => ({ body: "ok" })); + const cookie = yield* selectPreviewPort({ sessionCookie, port: upstream.port }); + + yield* gatewayRequest("/", { cookie: `${cookie}; app_pref=dark` }); + + const record = upstream.received[0]; + assert.isDefined(record); + const forwardedCookie = record.headers.cookie ?? ""; + assert.notInclude(forwardedCookie, gateway.sessionCookieName); + assert.notInclude(forwardedCookie, "t3_preview_port"); + // The dev server's own cookies still have to survive the trip. + assert.include(forwardedCookie, "app_pref=dark"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("drops the cookie header entirely when only gateway cookies were sent", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const upstream = yield* startUpstream(() => ({ body: "ok" })); + const cookie = yield* selectPreviewPort({ sessionCookie, port: upstream.port }); + + yield* gatewayRequest("/", { cookie }); + + const record = upstream.received[0]; + assert.isDefined(record); + assert.isUndefined(record.headers.cookie); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("relays an upstream redirect verbatim instead of following it", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const upstream = yield* startUpstream((request) => + request.url === "/moved" + ? { status: 302, headers: { location: "/destination" } } + : { body: "followed-the-redirect" }, + ); + const cookie = yield* selectPreviewPort({ sessionCookie, port: upstream.port }); + + const response = yield* gatewayRequest("/moved", { cookie }); + + assert.equal(response.status, 302); + assert.equal(response.headers.location, "/destination"); + assert.equal(upstream.received.length, 1); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("relays every upstream Set-Cookie, not just the last one", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const upstream = yield* startUpstream(() => ({ + headers: { "set-cookie": ["first=1; Path=/", "second=2; Path=/"] }, + body: "ok", + })); + const cookie = yield* selectPreviewPort({ sessionCookie, port: upstream.port }); + + const response = yield* gatewayRequest("/", { cookie }); + + // Read from the raw wire, not Effect's parsed view: the failure this + // guards against is two Set-Cookie headers collapsing into one. + // `response.cookies` is built from the raw multi-value `Set-Cookie` + // headers, so it is the channel that proves nothing collapsed. The header + // map cannot: it is a Record, and only ever holds the last value. + const relayed = Cookies.toSetCookieHeaders(response.cookies); + assert.deepEqual([...relayed], ["first=1; Path=/", "second=2; Path=/"]); + // Counted on the raw wire, because a duplicate would be invisible in any + // parsed view: `Cookies` is keyed by name and the header map is a Record. + const rawSetCookieCount = yield* countRawSetCookieHeaders({ + port: gateway.gatewayPort, + cookie, + }); + assert.equal(rawSetCookieCount, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + /** + * These two characterise the wire behaviour of a bodyless upstream reply — + * status and headers relayed, body empty. They do NOT cover `emptyOnAbsentBody` + * itself: removing that guard leaves these passing, because the Node server has + * already written the head by the time the empty stream fails. The guard stays + * as defence (it is what Effect's own `fromClientResponse` does), not because a + * test pins it. + */ + it.effect("relays a bodyless upstream response without failing on the empty body", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const upstream = yield* startUpstream(() => ({ + status: 304, + headers: { etag: '"abc123"' }, + })); + const cookie = yield* selectPreviewPort({ sessionCookie, port: upstream.port }); + + const response = yield* gatewayRequest("/", { cookie }); + + assert.equal(response.status, 304); + // The headers a conditional-GET reply exists to carry must survive the + // bodyless path, not just the status line. + assert.equal(response.headers.etag, '"abc123"'); + assert.equal(yield* responseText(response), ""); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("relays a HEAD response without a body", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const upstream = yield* startUpstream(() => ({ + status: 200, + headers: { "content-type": "text/html" }, + body: "body", + })); + const cookie = yield* selectPreviewPort({ sessionCookie, port: upstream.port }); + + const response = yield* gatewayRequest("/", { cookie, method: "HEAD" }); + + assert.equal(response.status, 200); + assert.equal(response.headers["content-type"], "text/html"); + assert.equal(yield* responseText(response), ""); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("relays a HEAD response with headers but no body", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const upstream = yield* startUpstream(() => ({ + status: 200, + headers: { "content-type": "text/html", "x-marker": "head" }, + body: "body", + })); + const cookie = yield* selectPreviewPort({ sessionCookie, port: upstream.port }); + + const response = yield* gatewayRequest("/", { cookie, method: "HEAD" }); + + assert.equal(response.status, 200); + assert.equal(response.headers["x-marker"], "head"); + assert.equal(yield* responseText(response), ""); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("answers 502 when nothing is listening on the selected port", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + const closedPort = yield* reserveClosedPort; + const cookie = yield* selectPreviewPort({ sessionCookie, port: closedPort }); + + const response = yield* gatewayRequest("/", { cookie }); + + assert.equal(response.status, 502); + assert.include(yield* responseText(response), `127.0.0.1:${closedPort}`); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("relays a websocket upgrade to the upstream, preserving the subprotocol", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + const sessionCookie = yield* gateway.issueSessionCookie(); + + const upstreamSockets = yield* Effect.acquireRelease( + Effect.promise(async () => { + const server = new NodeSocket.NodeWS.WebSocketServer({ + port: 0, + host: "127.0.0.1", + handleProtocols: (protocols: Set) => [...protocols][0] ?? false, + }); + await new Promise((resolve) => server.once("listening", resolve)); + server.on("connection", (socket, request) => { + socket.send(`hello ${request.url ?? ""}`); + socket.on("message", (data: unknown) => { + socket.send(`echo:${String(data)}`); + }); + }); + return server; + }), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + for (const client of server.clients) client.terminate(); + server.close(() => resolve()); + }), + ), + ); + const upstreamAddress = upstreamSockets.address(); + if (upstreamAddress === null || typeof upstreamAddress === "string") { + return yield* Effect.die(new Error("Expected a TCP address for the upstream socket.")); + } + const cookie = yield* selectPreviewPort({ sessionCookie, port: upstreamAddress.port }); + + const exchange = yield* Effect.promise( + () => + new Promise<{ readonly protocol: string; readonly messages: ReadonlyArray }>( + (resolve, reject) => { + const messages: string[] = []; + const client = new NodeSocket.NodeWS.WebSocket( + `ws://127.0.0.1:${gateway.gatewayPort}/hmr?token=x`, + ["vite-hmr"], + { headers: { cookie } }, + ); + client.on("error", reject); + client.on("message", (data: unknown) => { + messages.push(String(data)); + if (messages.length === 1) { + client.send("ping"); + return; + } + const protocol = client.protocol; + client.close(); + resolve({ protocol, messages }); + }); + }, + ), + ).pipe(Effect.timeout(Duration.seconds(10))); + + assert.equal(exchange.protocol, "vite-hmr"); + assert.deepEqual([...exchange.messages], ["hello /hmr?token=x", "echo:ping"]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("refuses a websocket upgrade from an unauthenticated client", () => + Effect.gen(function* () { + const gateway = yield* buildGatewayUnderTest(); + + const outcome = yield* Effect.promise( + () => + new Promise((resolve) => { + const client = new NodeSocket.NodeWS.WebSocket( + `ws://127.0.0.1:${gateway.gatewayPort}/hmr`, + ); + client.on("error", () => resolve("rejected")); + client.on("open", () => { + client.close(); + resolve("opened"); + }); + }), + ).pipe(Effect.timeout(Duration.seconds(10))); + + assert.equal(outcome, "rejected"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); +}); diff --git a/apps/server/src/preview/gatewayRoute.ts b/apps/server/src/preview/gatewayRoute.ts new file mode 100644 index 000000000000..4af96ae25a12 --- /dev/null +++ b/apps/server/src/preview/gatewayRoute.ts @@ -0,0 +1,356 @@ +/** + * The authenticated preview gateway: an HTTP + WebSocket reverse proxy that + * puts a loopback-bound dev server behind the environment's existing session + * auth, so previewing a dev server from another machine needs no new tunnel. + * + * The gateway is mounted at the **root** of its own port rather than under a + * `/preview//` prefix. Dev servers emit absolute URLs (`/@vite/client`, + * `/src/main.tsx`, the HMR socket) that resolve against the gateway origin and + * would 404 under a prefix. Root mounting means the path can no longer name the + * upstream, so the port travels in the signed cookie from + * {@link ./gatewayPortCookie.ts} and is selected via {@link PREVIEW_GATEWAY_SELECT_PATH}. + * + * Security shape: + * - Every request is authenticated with the *same* `EnvironmentAuth` session + * cookie as the rest of the server. There is no gateway-specific auth path. + * - Upstreams are loopback-only and port-bounded ({@link ./gatewayTarget.ts}); + * this is deliberately not a general forward proxy. + * - The session cookie is stripped before the request reaches the dev server — + * arbitrary user code on a loopback port has no use for the credential. + * + * Known residual risk (documented, not solved here): cookies are scoped by host, + * not by port, so a page served *through* the gateway is same-site with the main + * app and its credentialed requests to the main origin will carry the session + * cookie. CORS keeps responses unreadable, but state-changing same-site requests + * are not blocked by `sameSite: "lax"`. Fully closing this needs a separate + * hostname for previews, which is out of scope for this slice. + */ + +import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; +// The select path and its query parameters are shared with the clients that +// build the URL; a second copy here would be a silent mismatch waiting to happen. +import { + PREVIEW_GATEWAY_PORT_PARAM, + PREVIEW_GATEWAY_REDIRECT_PARAM, + PREVIEW_GATEWAY_SELECT_PATH, +} from "@t3tools/shared/previewGateway"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { + FetchHttpClient, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpMethod, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, + HttpServerRespondable, +} from "effect/unstable/http"; +import * as Cookies from "effect/unstable/http/Cookies"; +import * as Socket from "effect/unstable/socket/Socket"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { + failEnvironmentAuthInvalid, + failEnvironmentInternal, + failEnvironmentScopeRequired, +} from "../auth/http.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; +import * as ServerConfig from "../config.ts"; + +import { + describePreviewPortCookieRejection, + PREVIEW_PORT_COOKIE_TTL_MILLIS, + PREVIEW_PORT_SIGNING_SECRET_BYTES, + PREVIEW_PORT_SIGNING_SECRET_NAME, + resolvePreviewPortCookieName, + signPreviewPortCookie, + verifyPreviewPortCookie, +} from "./gatewayPortCookie.ts"; +import { + buildGatewayRequestHeaders, + buildGatewayResponseHeaders, + buildGatewayUpstreamUrl, + buildGatewayUpstreamWebSocketUrl, + describeGatewayPortRejection, + isWebSocketUpgrade, + resolveGatewayPort, + resolveRequestedSubprotocols, + stripCookie, +} from "./gatewayTarget.ts"; + +/** + * A bodyless upstream response is routine, not an edge case: a dev server emits + * `304`s constantly once the browser has a warm cache, and `HEAD`/`204` responses + * have no body either. Reading the client's stream in those cases fails with an + * `EmptyBodyError`, so it is caught and turned into an empty body — the same + * treatment Effect's own `HttpServerResponse.fromClientResponse` applies. + * + * Catching the error beats enumerating the bodyless statuses: it needs no list to + * keep in sync with what dev servers actually send. + */ +const emptyOnAbsentBody = ( + stream: Stream.Stream, +): Stream.Stream => + Stream.catchIf( + stream, + (error: HttpClientError.HttpClientError) => + HttpClientError.isHttpClientError(error) && error.reason._tag === "EmptyBodyError", + () => Stream.empty, + ); + +/** + * Authenticate against the environment session exactly as the rest of the + * server does. Mirrors `authenticateRawRouteWithScope` in `../http.ts`; the + * gateway deliberately has no auth path of its own. + */ +const authenticateGatewayRequest = ( + scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, +) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* serverAuth.authenticateHttpRequest(request).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), + ); + if (!session.scopes.includes(scope)) { + return yield* failEnvironmentScopeRequired(scope); + } + }); + +const environmentErrorResponses = { + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, +} as const; + +const firstSearchParam = ( + params: Readonly>>, + name: string, +): string | undefined => { + const value = params[name]; + if (value === undefined) return undefined; + return typeof value === "string" ? value : value[0]; +}; + +/** + * Only same-origin, absolute-path redirects are honoured after a selection, so + * the select route cannot be used as an open redirect. + */ +const resolveRedirectTarget = (raw: string | undefined): string => { + if (raw === undefined || !raw.startsWith("/") || raw.startsWith("//")) return "/"; + return raw; +}; + +/** + * Build the gateway's route table. + * + * `selfPorts` are ports the gateway must refuse to forward to — at minimum its + * own, which would otherwise recurse. + */ +export const makePreviewGatewayRoutesLayer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const gatewayServer = yield* HttpServer.HttpServer; + const gatewayAddress = gatewayServer.address; + const gatewayPort = + typeof gatewayAddress === "string" || !("port" in gatewayAddress) + ? undefined + : gatewayAddress.port; + + // The gateway's own port would recurse; the backend's own port would loop a + // preview back into the app. `config.port` is the configured port, which is + // the real one in every non-ephemeral configuration. + const selfPorts = [gatewayPort, config.port].filter( + (port): port is number => typeof port === "number" && port > 0, + ); + + const previewCookieName = resolvePreviewPortCookieName({ + mode: config.mode, + port: gatewayPort ?? config.port, + }); + + /** Resolve the upstream port for the current request, or explain why not. */ + const resolveUpstreamPort = Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const secret = yield* secretStore + .getOrCreateRandom(PREVIEW_PORT_SIGNING_SECRET_NAME, PREVIEW_PORT_SIGNING_SECRET_BYTES) + .pipe(Effect.catch((cause) => failEnvironmentInternal("internal_error", cause))); + const nowMillis = yield* Clock.currentTimeMillis; + return verifyPreviewPortCookie({ + value: request.cookies[previewCookieName], + secret, + nowMillis, + selfPorts, + }); + }); + + const selectRouteLayer = HttpRouter.add( + "GET", + PREVIEW_GATEWAY_SELECT_PATH, + Effect.gen(function* () { + yield* authenticateGatewayRequest(AuthOrchestrationOperateScope); + const params = yield* HttpServerRequest.ParsedSearchParams; + const resolved = resolveGatewayPort( + firstSearchParam(params, PREVIEW_GATEWAY_PORT_PARAM), + selfPorts, + ); + if (!resolved.ok) { + return HttpServerResponse.text(describeGatewayPortRejection(resolved.reason), { + status: 400, + }); + } + + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const secret = yield* secretStore + .getOrCreateRandom(PREVIEW_PORT_SIGNING_SECRET_NAME, PREVIEW_PORT_SIGNING_SECRET_BYTES) + .pipe(Effect.catch((cause) => failEnvironmentInternal("internal_error", cause))); + const nowMillis = yield* Clock.currentTimeMillis; + const expiresAtMillis = nowMillis + PREVIEW_PORT_COOKIE_TTL_MILLIS; + const value = signPreviewPortCookie({ port: resolved.port, expiresAtMillis, secret }); + + const cookies = yield* Effect.fromResult( + // No `secure`: the gateway is reached over plain HTTP on loopback as + // well as over HTTPS through Tailscale, and a `secure` cookie would + // silently never be set on the former. + Cookies.set(Cookies.empty, previewCookieName, value, { + httpOnly: true, + path: "/", + sameSite: "lax", + maxAge: PREVIEW_PORT_COOKIE_TTL_MILLIS, + }), + ).pipe(Effect.catch((cause) => failEnvironmentInternal("internal_error", cause))); + + yield* Effect.logDebug("Preview gateway upstream selected", { port: resolved.port }); + + return HttpServerResponse.redirect( + resolveRedirectTarget(firstSearchParam(params, PREVIEW_GATEWAY_REDIRECT_PARAM)), + { status: 303, cookies }, + ); + }).pipe(Effect.catchTags(environmentErrorResponses)), + ); + + const proxyRouteLayer = HttpRouter.add( + "*", + "/*", + Effect.gen(function* () { + yield* authenticateGatewayRequest(AuthOrchestrationReadScope); + const request = yield* HttpServerRequest.HttpServerRequest; + const sessions = yield* SessionStore.SessionStore; + + const verification = yield* resolveUpstreamPort; + if (!verification.ok) { + return HttpServerResponse.text(describePreviewPortCookieRejection(verification.reason), { + status: 421, + }); + } + const port = verification.port; + + // Neither credential is the dev server's business: the session cookie is + // a live credential for this environment, and the port cookie is the + // gateway's own control channel. + const headers = buildGatewayRequestHeaders(request.headers, port, sessions.cookieName); + if (headers.cookie !== undefined) { + const remaining = stripCookie(headers.cookie, previewCookieName); + if (remaining) { + headers.cookie = remaining; + } else { + delete headers.cookie; + } + } + + if (isWebSocketUpgrade(request.headers)) { + return yield* proxyWebSocket(port); + } + + const httpClient = yield* HttpClient.HttpClient; + const upstreamRequest = HttpClientRequest.make(request.method)( + buildGatewayUpstreamUrl(port, request.url), + { headers }, + ).pipe( + HttpMethod.hasBody(request.method) + ? HttpClientRequest.bodyStream(request.stream) + : (self) => self, + ); + + const response = yield* httpClient.execute(upstreamRequest).pipe( + // A dev server's redirect must reach the browser verbatim; following + // it here would resolve it against the *upstream* origin and hand back + // the wrong document. (Measured: `fetch` follows by default.) + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.catch((cause) => + Effect.logWarning("Preview gateway upstream request failed", { cause, port }).pipe( + Effect.as(undefined), + ), + ), + ); + if (response === undefined) { + return HttpServerResponse.text(`No dev server is answering on 127.0.0.1:${port}.`, { + status: 502, + }); + } + + const responseOptions = { + status: response.status, + headers: buildGatewayResponseHeaders(response.headers), + // Relayed through the cookie channel rather than the header map: the + // header map is a `Record`, so multiple `Set-Cookie` values collapse + // to the last one and every other cookie is lost. + cookies: response.cookies, + }; + + return HttpServerResponse.stream(emptyOnAbsentBody(response.stream), responseOptions); + }).pipe(Effect.catchTags(environmentErrorResponses)), + ); + + return Layer.mergeAll(selectRouteLayer, proxyRouteLayer); + }), +); + +/** + * Relay a WebSocket upgrade to the upstream dev server. + * + * Both halves are Effect `Socket`s: the downstream one comes from the server's + * upgrade handler, the upstream one from a client WebSocket. Each is pumped into + * the other's writer, and the first to end tears the other down. + */ +const proxyWebSocket = Effect.fnUntraced(function* (port: number) { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = buildGatewayUpstreamWebSocketUrl(port, request.url); + // Vite's HMR client connects with the `vite-hmr` subprotocol and expects it + // echoed. The downstream server echoes the client's first requested protocol + // on its own, so forwarding the same list keeps both halves in agreement. + const protocols = resolveRequestedSubprotocols(request.headers); + + const downstream = yield* Effect.orDie(request.upgrade); + const upstream = yield* Socket.makeWebSocket( + url, + protocols.length > 0 ? { protocols: [...protocols] } : {}, + ).pipe(Effect.provide(Socket.layerWebSocketConstructorGlobal)); + + const writeUpstream = yield* upstream.writer; + const writeDownstream = yield* downstream.writer; + + yield* Effect.raceFirst( + upstream.runRaw((chunk) => writeDownstream(chunk)), + downstream.runRaw((chunk) => writeUpstream(chunk)), + ).pipe( + // A socket closing is how this ends, not a failure to report. + Effect.catchTag("SocketError", (error) => + Effect.logDebug("Preview gateway websocket closed", { port, reason: error.reason._tag }), + ), + ); + + return HttpServerResponse.empty(); +}); diff --git a/apps/server/src/preview/gatewayServedLayer.test.ts b/apps/server/src/preview/gatewayServedLayer.test.ts new file mode 100644 index 000000000000..f201f718d265 --- /dev/null +++ b/apps/server/src/preview/gatewayServedLayer.test.ts @@ -0,0 +1,196 @@ +/** + * Regression coverage for the preview gateway's router isolation. + * + * A live boot with `--preview-gateway` died at startup with + * `Method 'GET' already declared for route '/*'`. `HttpRouter.serve` builds its + * router from the module-level `HttpRouter.layer`, layers are memoized by + * identity within a single build, and so the gateway's catch-all was registered + * into the *main app's* router next to the app's own catch-all. A control boot + * without the flag started clean, which is what pinned it on the gateway. + * + * `./gatewayRoute.test.ts` could not catch this and still cannot: it builds the + * gateway on its own, and with only one router in the build there is nothing to + * collide with. The property here needs two routers in one build, which is what + * `makeServerLayer` does and what these tests reproduce. + */ + +// @effect-diagnostics nodeBuiltinImport:off - this test binds real listeners to +// prove two routers can coexist, which needs the Node server the app itself uses. +import * as NodeHttp from "node:http"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PREVIEW_GATEWAY_SELECT_PATH } from "@t3tools/shared/previewGateway"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { + FetchHttpClient, + HttpClient, + HttpRouter, + HttpServer, + HttpServerResponse, +} from "effect/unstable/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { makePreviewGatewayServedLayer } from "../server.ts"; + +/** + * A stand-in for the main app's router: one catch-all, which is the only thing + * about `makeRoutesLayer` that participates in the collision. Building the real + * one needs a dozen mocked services (`server.test.ts`'s `buildAppUnderTest`), + * none of which would make the assertion stronger. + */ +const APP_RESPONSE_BODY = "main-app-router"; +const appRouterLayer = HttpRouter.add("*", "/*", HttpServerResponse.text(APP_RESPONSE_BODY)); + +/** + * A distinct listener layer per call. + * + * Reusing one layer *value* for both servers would reintroduce the same + * memoization sharing at the listener instead of the router — and production + * has two distinct values (`HttpServerLive`, `PreviewGatewayHttpServerLive`), + * so a factory is what actually mirrors it. + */ +const makeEphemeralHttpServerLayer = () => + NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", port: 0 }); + +/** The same listener, plus a hook to read back the port it bound. */ +const makePortCapturingHttpServerLayer = (capture: (port: number) => void) => + Layer.effectDiscard( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + capture((server.address as HttpServer.TcpAddress).port); + }), + ).pipe(Layer.provideMerge(makeEphemeralHttpServerLayer())); + +const buildBothRoutersUnderTest = Effect.fnUntraced(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-gateway-served-" }); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + yield* ServerConfig.ensureServerDirectories(derivedPaths); + + const config: ServerConfig.ServerConfig["Service"] = { + logLevel: "Error", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + noBrowser: true, + startupPresentation: "browser", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + previewGatewayEnabled: true, + previewGatewayPort: 0, + previewGatewayServePort: 8445, + }; + + const dependenciesLayer = Layer.mergeAll( + EnvironmentAuth.layer.pipe(Layer.provide(SqlitePersistenceMemory)), + FetchHttpClient.layer, + ).pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(ServerConfig.layer(config)), + ); + + let appPort = 0; + let gatewayPort = 0; + + // The shape under test. `Layer.mergeAll` of two served routers over shared + // dependencies is exactly `serverApplicationLayer` in `server.ts`, minus the + // services that have no bearing on routing. + const combined = Layer.mergeAll( + HttpRouter.serve(appRouterLayer, { disableListenLog: true, disableLogger: true }), + makePreviewGatewayServedLayer( + makePortCapturingHttpServerLayer((port) => { + gatewayPort = port; + }), + ), + ).pipe( + Layer.provideMerge( + makePortCapturingHttpServerLayer((port) => { + appPort = port; + }), + ), + Layer.provideMerge(dependenciesLayer), + ); + + // Building is itself part of the assertion: before `Layer.fresh` this failed + // here with `Method 'GET' already declared for route '/*'`. + yield* Layer.build(combined); + + assert.notEqual(appPort, 0); + assert.notEqual(gatewayPort, 0); + assert.notEqual(appPort, gatewayPort); + + return { appPort, gatewayPort } as const; +}); + +/** + * Absolute-URL GET against one of the two listeners. + * + * Redirects stay unfollowed so a 3xx from the gateway's select route is visible + * as itself rather than as whatever it points at. + */ +const get = Effect.fnUntraced(function* (port: number, path: string) { + const response = yield* HttpClient.get(`http://127.0.0.1:${port}${path}`).pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + ); + return { status: response.status, body: yield* response.text } as const; +}); + +it.layer(NodeServices.layer)("preview gateway served layer", (it) => { + it.effect("builds alongside the main app's router instead of colliding with it", () => + Effect.gen(function* () { + const { appPort, gatewayPort } = yield* buildBothRoutersUnderTest(); + + // Each listener answers `/` with its own router's handler. Same path, two + // different answers, so neither router swallowed the other. + const app = yield* get(appPort, "/"); + assert.equal(app.status, 200); + assert.equal(app.body, APP_RESPONSE_BODY); + + // 401 rather than 200: the gateway's proxy route ran and rejected an + // unauthenticated request, which the app's catch-all would never do. + const gateway = yield* get(gatewayPort, "/"); + assert.equal(gateway.status, 401); + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)), + ); + + it.effect("keeps the gateway's routes off the main app's listener", () => + Effect.gen(function* () { + const { appPort, gatewayPort } = yield* buildBothRoutersUnderTest(); + + // The inverse of the bug: if the gateway registered into the app's router, + // its select route would answer here — with a 401, not the app's body. + const leaked = yield* get(appPort, `${PREVIEW_GATEWAY_SELECT_PATH}?port=45678`); + assert.equal(leaked.status, 200); + assert.equal(leaked.body, APP_RESPONSE_BODY); + + // And the route does exist on the gateway, so the assertion above is about + // where it is mounted rather than about it having been dropped entirely. + const served = yield* get(gatewayPort, `${PREVIEW_GATEWAY_SELECT_PATH}?port=45678`); + assert.equal(served.status, 401); + }).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer)), + ); +}); diff --git a/apps/server/src/preview/gatewayTarget.test.ts b/apps/server/src/preview/gatewayTarget.test.ts new file mode 100644 index 000000000000..6b90ee5e8f08 --- /dev/null +++ b/apps/server/src/preview/gatewayTarget.test.ts @@ -0,0 +1,293 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + buildGatewayRequestHeaders, + buildGatewayResponseHeaders, + buildGatewayUpstreamUrl, + buildGatewayUpstreamWebSocketUrl, + describeGatewayPortRejection, + isWebSocketUpgrade, + resolveGatewayPort, + resolveRequestedSubprotocols, + stripCookie, + GATEWAY_TARGET_HOST, + MAX_GATEWAY_PORT, + MIN_GATEWAY_PORT, +} from "./gatewayTarget.ts"; + +describe("resolveGatewayPort", () => { + it("accepts an ordinary dev server port", () => { + assert.deepStrictEqual(resolveGatewayPort("5173"), { ok: true, port: 5173 }); + assert.deepStrictEqual(resolveGatewayPort(3000), { ok: true, port: 3000 }); + assert.deepStrictEqual(resolveGatewayPort(MIN_GATEWAY_PORT), { + ok: true, + port: MIN_GATEWAY_PORT, + }); + assert.deepStrictEqual(resolveGatewayPort(MAX_GATEWAY_PORT), { + ok: true, + port: MAX_GATEWAY_PORT, + }); + }); + + // Privileged ports are never dev servers, and forwarding to them is how a + // proxy bug turns into "reach the SSH/SMTP daemon through the gateway". + it("rejects privileged ports", () => { + for (const port of [0, 22, 80, 443, MIN_GATEWAY_PORT - 1]) { + assert.deepStrictEqual( + resolveGatewayPort(port), + { ok: false, reason: "reserved-privileged" }, + `port ${port} must be rejected`, + ); + } + }); + + it("rejects ports outside the valid TCP range", () => { + assert.deepStrictEqual(resolveGatewayPort(MAX_GATEWAY_PORT + 1), { + ok: false, + reason: "out-of-range", + }); + assert.deepStrictEqual(resolveGatewayPort(-1), { ok: false, reason: "out-of-range" }); + }); + + // `Number()` is far too permissive for this: it maps "", " 12 ", "0x1f", + // "1e3", and "+8080" to numbers, several of which would smuggle a different + // port than the string suggests. + it("rejects anything that is not a plain run of digits", () => { + for (const raw of ["", " ", "0x1f", "1e3", "+8080", " 8080 ", "8080/../", "80.5", "eighty"]) { + const result = resolveGatewayPort(raw); + assert.equal(result.ok, false, `${JSON.stringify(raw)} must be rejected`); + } + assert.deepStrictEqual(resolveGatewayPort(undefined), { ok: false, reason: "not-a-number" }); + assert.deepStrictEqual(resolveGatewayPort(80.5), { ok: false, reason: "not-a-number" }); + }); + + // A gateway pointed at itself proxies itself: every hop consumes another + // connection until the server runs out. + it("refuses to forward to the server's own ports", () => { + assert.deepStrictEqual(resolveGatewayPort("13773", [13_773, 8445]), { + ok: false, + reason: "gateway-self", + }); + assert.deepStrictEqual(resolveGatewayPort("8445", [13_773, 8445]), { + ok: false, + reason: "gateway-self", + }); + assert.deepStrictEqual(resolveGatewayPort("5173", [13_773, 8445]), { ok: true, port: 5173 }); + }); + + it("explains every rejection", () => { + for (const reason of [ + "not-a-number", + "out-of-range", + "reserved-privileged", + "gateway-self", + ] as const) { + assert.ok(describeGatewayPortRejection(reason).length > 0); + } + }); +}); + +describe("buildGatewayUpstreamUrl", () => { + // The host is a constant, never caller-supplied — this is the check that the + // route cannot be turned into an open forward proxy. + it("always targets loopback", () => { + assert.equal(buildGatewayUpstreamUrl(5173, "/"), "http://127.0.0.1:5173/"); + assert.ok(buildGatewayUpstreamUrl(5173, "/x").startsWith(`http://${GATEWAY_TARGET_HOST}:`)); + }); + + it("passes the path and query through untouched", () => { + assert.equal( + buildGatewayUpstreamUrl(5173, "/src/main.tsx?t=17849&x=a%2Fb"), + "http://127.0.0.1:5173/src/main.tsx?t=17849&x=a%2Fb", + ); + }); + + it("normalizes a missing leading slash", () => { + assert.equal( + buildGatewayUpstreamUrl(5173, "assets/app.js"), + "http://127.0.0.1:5173/assets/app.js", + ); + }); +}); + +describe("buildGatewayRequestHeaders", () => { + it("rewrites host to the upstream authority", () => { + const headers = buildGatewayRequestHeaders( + { host: "example-tailnet.ts.net", accept: "text/html" }, + 5173, + "t3_session", + ); + assert.equal(headers.host, "127.0.0.1:5173"); + assert.equal(headers.accept, "text/html"); + }); + + // Hop-by-hop headers are connection-scoped; relaying them corrupts keep-alive + // and upgrade negotiation on the upstream connection. + it("drops hop-by-hop headers and accept-encoding", () => { + const headers = buildGatewayRequestHeaders( + { + connection: "keep-alive", + "keep-alive": "timeout=5", + "transfer-encoding": "chunked", + upgrade: "websocket", + te: "trailers", + "accept-encoding": "gzip, br", + "x-real-header": "kept", + }, + 5173, + "t3_session", + ); + for (const dropped of [ + "connection", + "keep-alive", + "transfer-encoding", + "upgrade", + "te", + "accept-encoding", + ]) { + assert.equal(headers[dropped], undefined, `${dropped} must not be forwarded`); + } + assert.equal(headers["x-real-header"], "kept"); + }); + + // The dev server is arbitrary user code. Handing it the session credential + // that authenticates against this server would be a needless way to lose it. + it("strips the session cookie but keeps the dev server's own cookies", () => { + const headers = buildGatewayRequestHeaders( + { cookie: "t3_session=SECRET-TOKEN; vite_theme=dark; other=1" }, + 5173, + "t3_session", + ); + assert.ok(!(headers.cookie ?? "").includes("SECRET-TOKEN")); + assert.ok(!(headers.cookie ?? "").includes("t3_session")); + assert.equal(headers.cookie, "vite_theme=dark; other=1"); + }); + + it("omits the cookie header entirely when only the session cookie was present", () => { + const headers = buildGatewayRequestHeaders({ cookie: "t3_session=SECRET" }, 5173, "t3_session"); + assert.equal(headers.cookie, undefined); + }); + + it("is case-insensitive about header names", () => { + const headers = buildGatewayRequestHeaders( + { Host: "example", "Accept-Encoding": "gzip", "X-Keep": "yes" }, + 5173, + "t3_session", + ); + assert.equal(headers.host, "127.0.0.1:5173"); + assert.equal(headers["accept-encoding"], undefined); + assert.equal(headers["x-keep"], "yes"); + }); +}); + +describe("buildGatewayResponseHeaders", () => { + it("keeps ordinary response headers", () => { + const headers = buildGatewayResponseHeaders({ + "content-type": "text/html", + etag: 'W/"abc"', + }); + assert.equal(headers["content-type"], "text/html"); + assert.equal(headers.etag, 'W/"abc"'); + }); + + // `set-cookie` travels in the response's cookie channel instead. This map is a + // `Record` and holds only the last of several values, so keeping it here would + // send that one cookie twice alongside the complete set. + it("drops set-cookie, which is relayed through the cookie channel", () => { + const headers = buildGatewayResponseHeaders({ + "content-type": "text/html", + "set-cookie": "vite=1", + }); + assert.isUndefined(headers["set-cookie"]); + assert.equal(headers["content-type"], "text/html"); + }); + + // The body is relayed as a stream, so the upstream's length no longer + // describes what we send; leaving it in truncates or hangs the response. + it("drops connection-scoped headers and content-length", () => { + const headers = buildGatewayResponseHeaders({ + connection: "keep-alive", + "keep-alive": "timeout=5", + "transfer-encoding": "chunked", + upgrade: "h2c", + "content-length": "1234", + }); + assert.deepStrictEqual(headers, {}); + }); + + // The HTTP client decodes the body before we ever see it but leaves the + // upstream's `content-encoding` on the headers. Relaying it tells the browser + // to gunzip plaintext, and every response from a compressing dev server fails. + it("drops content-encoding, because the relayed body is already decoded", () => { + const headers = buildGatewayResponseHeaders({ + "content-type": "application/javascript", + "content-encoding": "gzip", + }); + assert.equal(headers["content-encoding"], undefined); + assert.equal(headers["content-type"], "application/javascript"); + }); +}); + +describe("isWebSocketUpgrade", () => { + it("recognizes an upgrade request", () => { + assert.equal(isWebSocketUpgrade({ upgrade: "websocket", connection: "Upgrade" }), true); + // Browsers really do send "keep-alive, Upgrade". + assert.equal( + isWebSocketUpgrade({ Upgrade: "WebSocket", Connection: "keep-alive, Upgrade" }), + true, + ); + }); + + it("does not mistake an ordinary request for one", () => { + assert.equal(isWebSocketUpgrade({}), false); + assert.equal(isWebSocketUpgrade({ connection: "keep-alive" }), false); + assert.equal(isWebSocketUpgrade({ upgrade: "websocket" }), false); + assert.equal(isWebSocketUpgrade({ upgrade: "h2c", connection: "Upgrade" }), false); + }); +}); + +describe("resolveRequestedSubprotocols", () => { + // Vite's HMR client connects with `vite-hmr`; open the upstream socket without + // it and the dev server answers on the wrong protocol, so HMR never connects. + it("preserves the client's subprotocol list in order", () => { + assert.deepStrictEqual(resolveRequestedSubprotocols({ "sec-websocket-protocol": "vite-hmr" }), [ + "vite-hmr", + ]); + assert.deepStrictEqual( + resolveRequestedSubprotocols({ "Sec-WebSocket-Protocol": "vite-hmr, other" }), + ["vite-hmr", "other"], + ); + }); + + it("is empty when the client asked for none", () => { + assert.deepStrictEqual(resolveRequestedSubprotocols({}), []); + assert.deepStrictEqual(resolveRequestedSubprotocols({ "sec-websocket-protocol": " " }), []); + }); +}); + +describe("buildGatewayUpstreamWebSocketUrl", () => { + it("targets loopback over ws://", () => { + assert.equal( + buildGatewayUpstreamWebSocketUrl(5173, "/?token=abc"), + "ws://127.0.0.1:5173/?token=abc", + ); + assert.equal(buildGatewayUpstreamWebSocketUrl(5173, "hmr"), "ws://127.0.0.1:5173/hmr"); + }); +}); + +describe("stripCookie", () => { + it("removes only the named cookie", () => { + assert.equal(stripCookie("a=1; b=2; c=3", "b"), "a=1; c=3"); + assert.equal(stripCookie("a=1", "b"), "a=1"); + assert.equal(stripCookie("b=1", "b"), ""); + }); + + // A cookie whose name merely *contains* the session name must survive, or + // the dev server silently loses state. + it("does not match on prefix", () => { + assert.equal( + stripCookie("t3_session_theme=dark; t3_session=X", "t3_session"), + "t3_session_theme=dark", + ); + }); +}); diff --git a/apps/server/src/preview/gatewayTarget.ts b/apps/server/src/preview/gatewayTarget.ts new file mode 100644 index 000000000000..2cadd2608144 --- /dev/null +++ b/apps/server/src/preview/gatewayTarget.ts @@ -0,0 +1,259 @@ +/** + * Pure target resolution for the authenticated preview gateway. + * + * The gateway forwards requests to a dev server bound on loopback inside this + * environment. That makes it the one route in the server that can be pointed at + * an arbitrary address, so everything it will accept is decided here, in pure + * code that is cheap to test exhaustively. + * + * Two rules, both non-negotiable: + * + * 1. **Loopback only.** The forward target host is never taken from the request. + * It is always `127.0.0.1`; only the *port* is caller-supplied. Without this + * the route is an open forward proxy sitting behind the user's Tailscale + * identity — anything on the tailnet could reach anything the server can. + * 2. **Port-bounded.** Only unprivileged ports (>= 1024) are reachable, and the + * server's own port is excluded so the gateway can never be aimed back at + * itself (a request loop that would consume a connection slot per hop). + */ + +/** Lowest port the gateway will forward to. Privileged ports are never dev servers. */ +export const MIN_GATEWAY_PORT = 1024; +/** Highest valid TCP port. */ +export const MAX_GATEWAY_PORT = 65_535; + +/** The only host the gateway ever connects to. Deliberately not caller-supplied. */ +export const GATEWAY_TARGET_HOST = "127.0.0.1"; + +export type GatewayPortRejection = + | "not-a-number" + | "out-of-range" + | "reserved-privileged" + | "gateway-self"; + +export type GatewayPortResolution = + | { readonly ok: true; readonly port: number } + | { readonly ok: false; readonly reason: GatewayPortRejection }; + +/** + * Validate a caller-supplied preview port. + * + * `selfPorts` are ports this server itself listens on (its HTTP port, and the + * gateway port when they differ). Forwarding to one of those would make the + * gateway proxy itself. + */ +export function resolveGatewayPort( + rawPort: string | number | undefined, + selfPorts: ReadonlyArray = [], +): GatewayPortResolution { + // `Number("")` is 0 and `Number(" 12 ")` is 12, so parse strictly: only a + // run of digits is a port. This also rejects "8080/../", "+8080", and "0x1f". + const port = + typeof rawPort === "number" + ? rawPort + : typeof rawPort === "string" && /^\d+$/.test(rawPort) + ? Number(rawPort) + : Number.NaN; + + if (!Number.isInteger(port)) { + return { ok: false, reason: "not-a-number" }; + } + if (port < 0 || port > MAX_GATEWAY_PORT) { + return { ok: false, reason: "out-of-range" }; + } + if (port < MIN_GATEWAY_PORT) { + return { ok: false, reason: "reserved-privileged" }; + } + if (selfPorts.includes(port)) { + return { ok: false, reason: "gateway-self" }; + } + return { ok: true, port }; +} + +/** Human-readable explanation for a rejected port, safe to return in a response body. */ +export function describeGatewayPortRejection(reason: GatewayPortRejection): string { + switch (reason) { + case "not-a-number": + return "Preview port must be a number."; + case "out-of-range": + return `Preview port must be between ${MIN_GATEWAY_PORT} and ${MAX_GATEWAY_PORT}.`; + case "reserved-privileged": + return `Preview ports below ${MIN_GATEWAY_PORT} are not reachable through the gateway.`; + case "gateway-self": + return "The gateway cannot forward to its own port."; + } +} + +/** + * Build the upstream URL for a forwarded request. + * + * The path and query come from the incoming request untouched — the dev server + * behind the gateway sees itself at the origin root, which is the whole point of + * mounting the gateway at a root rather than under a `/preview//` prefix. + * A prefix would break every absolute URL the dev server emits (`/@vite/client`, + * `/src/main.tsx`) and its HMR socket along with them. + * + * `pathAndQuery` is used verbatim rather than parsed and re-serialised so that + * the upstream receives byte-identical encoding; re-encoding would corrupt + * requests that rely on a specific escaping of `%2F` and friends. + */ +export function buildGatewayUpstreamUrl(port: number, pathAndQuery: string): string { + return `http://${GATEWAY_TARGET_HOST}:${port}${normalizePathAndQuery(pathAndQuery)}`; +} + +/** + * Build the upstream URL for a forwarded WebSocket upgrade. + * + * Same rules as {@link buildGatewayUpstreamUrl}; only the scheme differs, since + * `Socket.makeWebSocket` takes a `ws://` URL. + */ +export function buildGatewayUpstreamWebSocketUrl(port: number, pathAndQuery: string): string { + return `ws://${GATEWAY_TARGET_HOST}:${port}${normalizePathAndQuery(pathAndQuery)}`; +} + +function normalizePathAndQuery(pathAndQuery: string): string { + return pathAndQuery.startsWith("/") ? pathAndQuery : `/${pathAndQuery}`; +} + +/** + * Headers that must not be copied verbatim between the client and the upstream. + * + * `host` is rewritten to the upstream authority; hop-by-hop headers are + * connection-scoped per RFC 9110 and forwarding them corrupts keep-alive and + * upgrade handling. `accept-encoding` is dropped on the way up so the upstream + * responds uncompressed and we never have to re-encode a body we are streaming. + */ +export const GATEWAY_STRIPPED_REQUEST_HEADERS: ReadonlySet = new Set([ + "host", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "accept-encoding", +]); + +/** Response headers that are connection-scoped and must not be relayed downstream. */ +export const GATEWAY_STRIPPED_RESPONSE_HEADERS: ReadonlySet = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "transfer-encoding", + "upgrade", +]); + +/** + * Copy request headers for the upstream, dropping the ones that must not travel + * and rewriting `host` to the upstream authority. + * + * The session cookie is deliberately *not* forwarded: the dev server has no use + * for it, and handing a credential to arbitrary user code on a loopback port is + * a needless way to lose it. + */ +export function buildGatewayRequestHeaders( + incoming: Readonly>, + port: number, + sessionCookieName: string, +): Record { + const headers: Record = {}; + for (const [rawName, value] of Object.entries(incoming)) { + if (value === undefined) continue; + const name = rawName.toLowerCase(); + if (GATEWAY_STRIPPED_REQUEST_HEADERS.has(name)) continue; + if (name === "cookie") { + const filtered = stripCookie(value, sessionCookieName); + if (filtered) headers[name] = filtered; + continue; + } + headers[name] = value; + } + headers.host = `${GATEWAY_TARGET_HOST}:${port}`; + return headers; +} + +/** + * Copy response headers back downstream, dropping the connection-scoped ones. + * + * `content-length` goes too: the body is relayed as a stream and the framing is + * decided by the downstream server, so a stale length would truncate or hang the + * response. + * + * `content-encoding` goes for a subtler reason. The HTTP client negotiates its + * own `accept-encoding` and hands back a *decoded* body stream, but leaves the + * upstream's `content-encoding: gzip` on the response headers. Relaying that + * header would tell the browser to gunzip bytes that are already plain, and + * every response from a compressing dev server would fail to decode. (Measured: + * a gzipping loopback server read back through `fetch` yields + * `content-encoding: gzip` alongside a 16-byte plaintext stream.) + * + * `set-cookie` goes because it is relayed through the response's cookie channel + * instead. This map is a `Record`, so it only ever holds the *last* of several + * `Set-Cookie` values; leaving it in would emit that one cookie a second time + * alongside the complete set. (Effect's own `fromClientResponse` removes it here + * for the same reason.) + */ +export function buildGatewayResponseHeaders( + incoming: Readonly>, +): Record { + const headers: Record = {}; + for (const [rawName, value] of Object.entries(incoming)) { + if (value === undefined) continue; + const name = rawName.toLowerCase(); + if (GATEWAY_STRIPPED_RESPONSE_HEADERS.has(name)) continue; + if (name === "content-length" || name === "content-encoding") continue; + if (name === "set-cookie") continue; + headers[name] = value; + } + return headers; +} + +/** + * Whether a request is a WebSocket upgrade, which the gateway must relay as a + * socket rather than as a request/response pair — dev-server HMR depends on it. + */ +export function isWebSocketUpgrade(headers: Readonly>): boolean { + const upgrade = headerValue(headers, "upgrade"); + const connection = headerValue(headers, "connection"); + if (upgrade?.toLowerCase() !== "websocket") return false; + // `Connection` is a comma-separated list ("keep-alive, Upgrade" in the wild). + return (connection ?? "").split(",").some((token) => token.trim().toLowerCase() === "upgrade"); +} + +/** + * Subprotocols the client asked for, in order. + * + * Vite's HMR client connects with the `vite-hmr` subprotocol and the dev server + * echoes it back; if the gateway opens its upstream socket without it, the dev + * server answers on the wrong protocol and HMR never connects. + */ +export function resolveRequestedSubprotocols( + headers: Readonly>, +): ReadonlyArray { + return (headerValue(headers, "sec-websocket-protocol") ?? "") + .split(",") + .map((token) => token.trim()) + .filter((token) => token.length > 0); +} + +function headerValue( + headers: Readonly>, + name: string, +): string | undefined { + for (const [rawName, value] of Object.entries(headers)) { + if (rawName.toLowerCase() === name) return value; + } + return undefined; +} + +/** Remove one named cookie from a `Cookie` header value, preserving the rest. */ +export function stripCookie(cookieHeader: string, cookieName: string): string { + return cookieHeader + .split(";") + .filter((pair) => pair.trim().split("=", 1)[0]?.trim() !== cookieName) + .map((pair) => pair.trim()) + .filter((pair) => pair.length > 0) + .join("; "); +} diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 6bfe25de8827..6c0a2a742911 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -829,10 +829,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); yield* PubSub.publish(changes, refreshedProvider); + // The snapshot is written to the real filesystem while this loop + // advances a TestClock, so what bounds the wait is fiber scheduling, + // not simulated time — under a loaded parallel run the write can + // need far more than 50 turns to land. The budget is generous + // rather than tight because it only decides how long a *broken* + // implementation takes to fail: a write that never happens still + // fails the assertion below. let cachedProvider = yield* readProviderStatusCache(filePath); for ( let attempt = 0; - attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; + attempt < 2_000 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt; attempt += 1 ) { yield* TestClock.adjust("10 millis"); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 440f75df97d6..0d726063d738 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -390,6 +390,9 @@ const buildAppUnderTest = (options?: { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + previewGatewayEnabled: false, + previewGatewayPort: 0, + previewGatewayServePort: 8445, ...options?.config, }; const layerConfig = ServerConfig.layer(config); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ecfc1f4cf216..6709ece33bb1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -36,11 +36,12 @@ import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; -import * as SupabaseMcpConnector from "./database/SupabaseMcpConnector.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; +import * as SupabaseMcpConnector from "./database/SupabaseMcpConnector.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; +import { makePreviewGatewayRoutesLayer } from "./preview/gatewayRoute.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -166,6 +167,60 @@ const HttpServerLive = Layer.unwrap( }), ); +/** + * The preview gateway's own listener. + * + * It cannot share the main server: the gateway is mounted at `/` (dev servers + * emit absolute URLs that would 404 under a path prefix), and `/` on the main + * server is already claimed by the static/dev catch-all route. Always loopback — + * reachability from another machine is Tailscale Serve's job, not the socket's. + */ +const PreviewGatewayHttpServerLive = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + if (typeof Bun !== "undefined") { + const BunHttpServer = yield* Effect.promise( + () => import("@effect/platform-bun/BunHttpServer"), + ); + return BunHttpServer.layer({ + port: config.previewGatewayPort, + hostname: "127.0.0.1", + gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, + }); + } + const [NodeHttpServer, NodeHttp] = yield* Effect.all([ + Effect.promise(() => import("@effect/platform-node/NodeHttpServer")), + Effect.promise(() => import("node:http")), + ]); + return NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: config.previewGatewayPort, + gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, + }); + }), +); + +/** + * Serve the preview gateway's routes on their own listener. + * + * `Layer.fresh` is the load-bearing part. `HttpRouter.serve` builds its router + * from the module-level `HttpRouter.layer`, and layers are memoized by identity + * within a single build — so without it the gateway registers its routes into + * the *main app's* router. Both mount a catch-all at `/`, and startup dies with + * `Method 'GET' already declared for route '/*'`. (Observed on a live boot with + * `--preview-gateway`; a control boot without the flag started clean.) + * + * The listener is a parameter so a test can supply an ephemeral one and still + * exercise this exact composition rather than a copy of it. + */ +export const makePreviewGatewayServedLayer = ( + httpServerLayer: Layer.Layer, +) => + HttpRouter.serve(makePreviewGatewayRoutesLayer, { disableLogger: true }).pipe( + Layer.fresh, + Layer.provide(httpServerLayer), + ); + const PlatformServicesLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { @@ -583,6 +638,50 @@ export const makeServerLayer = Layer.unwrap( ), ) : Layer.empty; + // The gateway is a second listener, so it needs its own Serve mapping on its + // own HTTPS port. It is published from the configured port rather than the + // bound address because the gateway server lives in a sibling layer scope. + const previewGatewayTailscaleServeLayer = + config.tailscaleServeEnabled && config.previewGatewayEnabled && config.previewGatewayPort > 0 + ? Layer.effectDiscard( + Effect.acquireRelease( + ensureTailscaleServe({ + localPort: config.previewGatewayPort, + servePort: config.previewGatewayServePort, + localHost: "127.0.0.1", + }).pipe( + Effect.as({ servePort: config.previewGatewayServePort }), + Effect.tap(() => + Effect.logInfo("Tailscale Serve configured for preview gateway", { + localPort: config.previewGatewayPort, + servePort: config.previewGatewayServePort, + }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to configure Tailscale Serve for preview gateway", { + cause, + localPort: config.previewGatewayPort, + servePort: config.previewGatewayServePort, + }).pipe(Effect.as(null)), + ), + ), + (configured) => + configured + ? disableTailscaleServe({ servePort: configured.servePort }).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to disable Tailscale Serve for preview gateway", { + cause, + servePort: configured.servePort, + }), + ), + ) + : Effect.void, + ), + ) + : Layer.empty; + const previewGatewayLayer = config.previewGatewayEnabled + ? makePreviewGatewayServedLayer(PreviewGatewayHttpServerLive) + : Layer.empty; const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { if (!hasCloudPublicConfig) return; @@ -612,6 +711,8 @@ export const makeServerLayer = Layer.unwrap( httpListeningLayer, runtimeStateLayer, tailscaleServeLayer, + previewGatewayLayer, + previewGatewayTailscaleServeLayer, cloudDesiredLinkReconcileLayer, ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 29b5a655c1cf..ed46d025bc4e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -727,6 +727,21 @@ const makeWsRpcLayer = ( : {}), otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, }, + // Only advertised once the gateway is actually listening; a client + // that sees this field will route previews through it, so announcing + // a port nothing answers would break previews that work today. + ...(config.previewGatewayEnabled && config.previewGatewayPort > 0 + ? { + previewGateway: { + loopbackPort: config.previewGatewayPort, + // Tailscale Serve is what makes the gateway reachable from + // another machine; without it there is no public port to name. + ...(config.tailscaleServeEnabled + ? { publicHttpsPort: config.previewGatewayServePort } + : {}), + }, + } + : {}), settings, }; }); diff --git a/apps/web/src/browser/WebPreviewFrame.tsx b/apps/web/src/browser/WebPreviewFrame.tsx new file mode 100644 index 000000000000..dc88aca3c2af --- /dev/null +++ b/apps/web/src/browser/WebPreviewFrame.tsx @@ -0,0 +1,89 @@ +/* + * The preview iframe below is deliberately unsandboxed; see the comment at the + * element for why a sandbox permissive enough to be useful here would also be + * equivalent to none. This file contains exactly one iframe. + */ +/* oxlint-disable react/iframe-missing-sandbox */ +"use client"; + +import type { PreviewNavStatus, ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback, useRef } from "react"; + +import { previewEnvironment } from "~/state/preview"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { resolveWebPreviewFrameState } from "./webPreviewFrame"; + +/** + * The preview surface for a plain browser, where there is no Electron + * `` to position over the panel. + * + * This is an iframe rather than a popped-out tab because the preview panel is + * the point: the user wants the running app beside the thread that is changing + * it. Framing only works because the gateway is mounted at the root of its own + * origin, so the dev server inside the frame sees itself at `/`. + * + * What the desktop surface has and this one does not, honestly: no in-frame URL + * tracking, no history, no screenshot/element-pick, no device viewport. All of + * those need to read or drive the guest document, which the same-origin policy + * forbids for a cross-origin frame. The affordances that depend on them are + * already gated on the desktop bridge at their call sites. + */ +export function WebPreviewFrame(props: { + readonly threadRef: ScopedThreadRef; + readonly tabId: string; + readonly navStatus: PreviewNavStatus; + readonly reloadNonce: number; + readonly className?: string; +}) { + const { threadRef, tabId, navStatus, reloadNonce, className } = props; + const reportStatus = useAtomCommand(previewEnvironment.reportStatus, "preview status report"); + const frame = resolveWebPreviewFrameState({ navStatus, reloadNonce }); + const reportedRef = useRef(null); + + const handleLoad = useCallback(() => { + if (!frame) return; + // `load` is the only navigation signal a cross-origin frame gives us, and + // it fires again on every reload, so dedupe on the URL we last reported. + // Without this the server's snapshot would stay `Loading` forever and the + // chrome row would show a permanent progress bar. + if (reportedRef.current === frame.key) return; + reportedRef.current = frame.key; + void reportStatus({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + tabId, + // The title lives in the guest document, which is cross-origin. The + // server keeps the previous title when this is empty. + navStatus: { _tag: "Success", url: frame.src, title: "" }, + canGoBack: false, + canGoForward: false, + }, + }); + }, [frame, reportStatus, tabId, threadRef]); + + if (!frame) return null; + + return ( +