diff --git a/bun.lock b/bun.lock index e8d4cc8c1..cf993b3b5 100644 --- a/bun.lock +++ b/bun.lock @@ -59,7 +59,7 @@ "cmdk": "^1.0.4", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", - "e2b": "^2.14.0", + "e2b": "^2.27.1", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "fast-xml-parser": "^5.3.5", @@ -1221,7 +1221,7 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], - "e2b": ["e2b@2.14.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.9" } }, "sha512-jNb8mFedmrUcTMCgoQT/JqG1IcCbxQvv137Tr1z2VlUZ6AMtYD/atQpExJ8/+481tcrtQCtCScuqbK+elKVDig=="], + "e2b": ["e2b@2.27.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.11", "undici": "^7.25.0" } }, "sha512-xZ1vXSl4dpWxbvan5vihE2embXzHdlpK1N0CmFUIcj5kdGLpiQXGoQYsz1Dhy8wr9VO724DyRC7Y3iblMElLPQ=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -1765,7 +1765,7 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - "tar": ["tar@7.5.10", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw=="], + "tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="], "terser": ["terser@5.44.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw=="], @@ -2157,6 +2157,8 @@ "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "e2b/undici": ["undici@7.27.1", "", {}, "sha512-UDdpiex+mzigiyrXrGbiUaF4HzTNhKbh2vRNFaTMzcqmLIPrZxaCtwo/1TMSuWoM1Xz3WiTo9KdgI3kRqYzJGg=="], + "framer-motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], diff --git a/package.json b/package.json index 9da397f66..db2e0ff78 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "cmdk": "^1.0.4", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", - "e2b": "^2.14.0", + "e2b": "^2.27.1", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "fast-xml-parser": "^5.3.5", diff --git a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx index 92722ffd7..81c953705 100644 --- a/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx +++ b/src/app/dashboard/[teamSlug]/sandboxes/[sandboxId]/filesystem/page.tsx @@ -1,14 +1,53 @@ import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' import { COOKIE_KEYS } from '@/configs/cookies' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { auth } from '@/core/server/auth' +import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' +import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server' import SandboxInspectView from '@/features/dashboard/sandbox/inspect/view' const DEFAULT_ROOT_PATH = '/home/user' -export default async function SandboxInspectPage() { - const cookieStore = await cookies() +interface SandboxInspectPageProps { + params: Promise<{ + sandboxId: string + teamSlug: string + }> +} + +export default async function SandboxInspectPage({ + params, +}: SandboxInspectPageProps) { + const [{ teamSlug }, cookieStore, authContext] = await Promise.all([ + params, + cookies(), + auth.getAuthContext(), + ]) + + if (!authContext) { + redirect(AUTH_URLS.SIGN_IN) + } + + const teamId = await getTeamIdFromSlug(teamSlug, authContext.accessToken) + if (!teamId.ok) { + throw new Error('Failed to resolve team for sandbox filesystem') + } + if (!teamId.data) { + redirect(PROTECTED_URLS.DASHBOARD) + } + const rootPath = cookieStore.get(COOKIE_KEYS.SANDBOX_INSPECT_ROOT_PATH)?.value || DEFAULT_ROOT_PATH - return + return ( + + ) } diff --git a/src/app/dashboard/[teamSlug]/team-gate.tsx b/src/app/dashboard/[teamSlug]/team-gate.tsx index 8aede757d..ea918f4aa 100644 --- a/src/app/dashboard/[teamSlug]/team-gate.tsx +++ b/src/app/dashboard/[teamSlug]/team-gate.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' -import type { AuthUser } from '@/core/server/auth' +import type { AuthUser } from '@/core/modules/auth/models' import { DashboardContextProvider } from '@/features/dashboard/context' import LoadingLayout from '@/features/dashboard/loading-layout' import { useTRPC } from '@/trpc/client' diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index a5454a625..27059d7e0 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -11,6 +11,7 @@ import { import { auth } from '@/core/server/auth' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' import { infra } from '@/core/shared/clients/api' +import { createSandboxManagementAuth } from '@/core/shared/sandbox-management-auth.server' import { SandboxIdSchema } from '@/core/shared/schemas/api' import DashboardTerminal from '@/features/dashboard/terminal/dashboard-terminal' import { normalizeTerminalTemplate } from '@/features/dashboard/terminal/template' @@ -110,7 +111,10 @@ export default async function TerminalPage({ initialCommand={command} initialSandboxId={terminalSandboxId} initialTemplate={terminalTemplate} - teamId={team.id} + sandboxManagementAuth={createSandboxManagementAuth( + authContext, + team.id + )} /> ) diff --git a/src/core/modules/auth/models.ts b/src/core/modules/auth/models.ts index 604fa8b50..f9651699c 100644 --- a/src/core/modules/auth/models.ts +++ b/src/core/modules/auth/models.ts @@ -1,6 +1,16 @@ import z from 'zod' import { httpUrlSchema } from '@/core/shared/schemas/url' +export type AuthUser = { + id: string + email: string | null + name: string | null + avatarUrl: string | null + providers: string[] + canChangeEmail: boolean + canChangePassword: boolean +} + export const OtpTypeSchema = z.enum([ 'signup', 'recovery', diff --git a/src/core/server/auth/index.ts b/src/core/server/auth/index.ts index 569e821be..a9b3fd128 100644 --- a/src/core/server/auth/index.ts +++ b/src/core/server/auth/index.ts @@ -40,5 +40,5 @@ export function createAuthForHeaders(headers: Headers): AuthProvider { : createSupabaseAuthForHeaders(headers) } +export type { AuthUser } from '@/core/modules/auth/models' export type { AuthAdmin } from './admin' -export type { AuthUser } from './types' diff --git a/src/core/server/auth/types.ts b/src/core/server/auth/types.ts index 582b5163f..38905411e 100644 --- a/src/core/server/auth/types.ts +++ b/src/core/server/auth/types.ts @@ -1,12 +1,6 @@ -export type AuthUser = { - id: string - email: string | null - name: string | null - avatarUrl: string | null - providers: string[] - canChangeEmail: boolean - canChangePassword: boolean -} +import type { AuthUser } from '@/core/modules/auth/models' + +export type { AuthUser } from '@/core/modules/auth/models' export type AuthContext = { user: AuthUser diff --git a/src/core/shared/sandbox-management-auth.server.ts b/src/core/shared/sandbox-management-auth.server.ts new file mode 100644 index 000000000..893a67398 --- /dev/null +++ b/src/core/shared/sandbox-management-auth.server.ts @@ -0,0 +1,15 @@ +import 'server-only' + +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import type { AuthContext } from '@/core/server/auth/types' +import type { SandboxManagementAuth } from './sandbox-management-auth' + +export function createSandboxManagementAuth( + authContext: AuthContext, + teamId: string +): SandboxManagementAuth { + return { + headers: SUPABASE_AUTH_HEADERS(authContext.accessToken, teamId), + userId: authContext.user.id, + } +} diff --git a/src/core/shared/sandbox-management-auth.ts b/src/core/shared/sandbox-management-auth.ts new file mode 100644 index 000000000..b60dba9dd --- /dev/null +++ b/src/core/shared/sandbox-management-auth.ts @@ -0,0 +1,4 @@ +export interface SandboxManagementAuth { + userId: string + headers: Record +} diff --git a/src/features/dashboard/context.tsx b/src/features/dashboard/context.tsx index bd0ceac1b..74f9c23db 100644 --- a/src/features/dashboard/context.tsx +++ b/src/features/dashboard/context.tsx @@ -2,8 +2,8 @@ import { createContext, type ReactNode, useContext, useEffect } from 'react' import { useDebounceCallback } from 'usehooks-ts' +import type { AuthUser } from '@/core/modules/auth/models' import type { TeamModel } from '@/core/modules/teams/models' -import type { AuthUser } from '@/core/server/auth' interface DashboardContextValue { team: TeamModel diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx index e15acc0d7..66dc1219d 100644 --- a/src/features/dashboard/sandbox/inspect/context.tsx +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -1,7 +1,6 @@ 'use client' import Sandbox from 'e2b' -import { useRouter } from 'next/navigation' import type { ReactNode } from 'react' import { createContext, @@ -11,9 +10,7 @@ import { useMemo, useRef, } from 'react' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' -import { AUTH_URLS } from '@/configs/urls' -import { supabase } from '@/core/shared/clients/supabase/client' +import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' import { getParentPath, normalizePath } from '@/lib/utils/filesystem' import { useDashboard } from '../../context' @@ -34,11 +31,13 @@ const SandboxInspectContext = createContext( interface SandboxInspectProviderProps { children: ReactNode rootPath: string + sandboxManagementAuth: SandboxManagementAuth } export default function SandboxInspectProvider({ children, rootPath, + sandboxManagementAuth, }: SandboxInspectProviderProps) { const { team } = useDashboard() const teamId = team.id @@ -47,7 +46,6 @@ export default function SandboxInspectProvider({ const storeRef = useRef(null) const sandboxManagerRef = useRef(null) - const router = useRouter() const { trackInteraction } = useSandboxInspectAnalytics() // ---------- synchronous store initialisation ---------- @@ -181,19 +179,12 @@ export default function SandboxInspectProvider({ sandboxManagerRef.current.stopWatching() } - const { data } = await supabase.auth.getSession() - - if (!data || !data.session) { - router.replace(AUTH_URLS.SIGN_IN) - return - } - const sandbox = await Sandbox.connect(sandboxInfo.sandboxID, { domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, // Keep inspect connections from extending sandbox TTL via SDK default connect timeout. timeoutMs: 1_000, headers: { - ...SUPABASE_AUTH_HEADERS(data.session.access_token, teamId), + ...sandboxManagementAuth.headers, }, }) @@ -209,7 +200,7 @@ export default function SandboxInspectProvider({ team_id: teamId, root_path: rootPath, }) - }, [sandboxInfo, teamId, rootPath, trackInteraction, router]) + }, [sandboxInfo, teamId, rootPath, trackInteraction, sandboxManagementAuth]) // handle sandbox connection / disconnection useEffect(() => { diff --git a/src/features/dashboard/sandbox/inspect/view.tsx b/src/features/dashboard/sandbox/inspect/view.tsx index 25095c8da..9a7f9d3cc 100644 --- a/src/features/dashboard/sandbox/inspect/view.tsx +++ b/src/features/dashboard/sandbox/inspect/view.tsx @@ -1,6 +1,7 @@ 'use client' import { SANDBOX_INSPECT_MINIMUM_ENVD_VERSION } from '@/configs/versioning' +import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' import SandboxInspectProvider from '@/features/dashboard/sandbox/inspect/context' import SandboxInspectFilesystem from '@/features/dashboard/sandbox/inspect/filesystem' import SandboxInspectViewer from '@/features/dashboard/sandbox/inspect/viewer' @@ -11,10 +12,12 @@ import SandboxInspectIncompatible from './incompatible' interface SandboxInspectViewProps { rootPath: string + sandboxManagementAuth: SandboxManagementAuth } export default function SandboxInspectView({ rootPath, + sandboxManagementAuth, }: SandboxInspectViewProps) { const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes/[sandboxId]'>() @@ -39,7 +42,10 @@ export default function SandboxInspectView({ } return ( - +
diff --git a/src/features/dashboard/terminal/dashboard-terminal.tsx b/src/features/dashboard/terminal/dashboard-terminal.tsx index 3269dc675..cff03d0ff 100644 --- a/src/features/dashboard/terminal/dashboard-terminal.tsx +++ b/src/features/dashboard/terminal/dashboard-terminal.tsx @@ -4,6 +4,7 @@ import { Terminal as XTerm } from '@xterm/xterm' import type Sandbox from 'e2b' import type { CommandHandle } from 'e2b' import { useCallback, useEffect, useRef, useState } from 'react' +import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' import { DEFAULT_COLS, DEFAULT_CWD, @@ -38,7 +39,7 @@ interface DashboardTerminalProps { initialCommand?: string initialSandboxId?: string initialTemplate?: string - teamId: string + sandboxManagementAuth: SandboxManagementAuth } export default function DashboardTerminal({ @@ -46,7 +47,7 @@ export default function DashboardTerminal({ initialCommand = '', initialSandboxId, initialTemplate, - teamId, + sandboxManagementAuth, }: DashboardTerminalProps) { const [status, setStatus] = useState('idle') const [activeSandboxId, setActiveSandboxId] = useState() @@ -200,8 +201,8 @@ export default function DashboardTerminal({ const { sandbox } = await openTerminalSandbox({ forceNewSandbox: options.forceNewSandbox, onStatus: appendOutput, + sandboxManagementAuth, sandboxId: options.sandboxId, - teamId, template: nextTemplate, }) @@ -270,7 +271,7 @@ export default function DashboardTerminal({ disconnectTerminal, resizeTerminal, runCommand, - teamId, + sandboxManagementAuth, template, updateTerminalUrl, ] diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index 2e6f86700..ea57015fc 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -1,6 +1,5 @@ import Sandbox from 'e2b' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' -import { supabase } from '@/core/shared/clients/supabase/client' +import type { SandboxManagementAuth } from '@/core/shared/sandbox-management-auth' import { TERMINAL_SANDBOX_TIMEOUT_MS } from './constants' import { clearStoredTerminalSession, @@ -11,26 +10,19 @@ import { interface OpenTerminalSandboxOptions { forceNewSandbox?: boolean onStatus: (message: string) => void + sandboxManagementAuth: SandboxManagementAuth sandboxId?: string - teamId: string template: string } export async function openTerminalSandbox({ forceNewSandbox = false, onStatus, + sandboxManagementAuth, sandboxId, - teamId, template, }: OpenTerminalSandboxOptions) { - const { data } = await supabase.auth.getSession() - - if (!data.session) { - throw new Error('You need to sign in before opening a terminal.') - } - - const userId = data.session.user.id - const headers = SUPABASE_AUTH_HEADERS(data.session.access_token, teamId) + const { headers, userId } = sandboxManagementAuth if (sandboxId) { onStatus(`Connecting to terminal sandbox ${sandboxId}...\r\n`) @@ -100,8 +92,6 @@ function createTerminalSandbox({ template: string userId: string }) { - // The browser SDK sends the signed-in user's Supabase token so E2B can - // authorize sandbox ownership without a dashboard proxy endpoint. return Sandbox.create(template, { domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, timeoutMs: TERMINAL_SANDBOX_TIMEOUT_MS, diff --git a/tests/unit/dashboard-terminal.test.ts b/tests/unit/dashboard-terminal.test.ts index 54675e595..9b0d683da 100644 --- a/tests/unit/dashboard-terminal.test.ts +++ b/tests/unit/dashboard-terminal.test.ts @@ -13,13 +13,10 @@ import { } from '@/features/dashboard/terminal/template' import { calculateTerminalSize } from '@/features/dashboard/terminal/terminal-size' -const { mockCreateSandbox, mockConnectSandbox, mockGetSession } = vi.hoisted( - () => ({ - mockCreateSandbox: vi.fn(), - mockConnectSandbox: vi.fn(), - mockGetSession: vi.fn(), - }) -) +const { mockCreateSandbox, mockConnectSandbox } = vi.hoisted(() => ({ + mockCreateSandbox: vi.fn(), + mockConnectSandbox: vi.fn(), +})) vi.mock('e2b', () => ({ default: { @@ -28,14 +25,6 @@ vi.mock('e2b', () => ({ }, })) -vi.mock('@/core/shared/clients/supabase/client', () => ({ - supabase: { - auth: { - getSession: mockGetSession, - }, - }, -})) - function installLocalStorage() { const values = new Map() @@ -59,19 +48,17 @@ function installLocalStorage() { } describe('dashboard terminal helpers', () => { + const sandboxManagementAuth = { + headers: { + [SUPABASE_TOKEN_HEADER]: 'supabase-token', + [SUPABASE_TEAM_HEADER]: 'team-123', + }, + userId: 'user-123', + } + beforeEach(() => { vi.clearAllMocks() installLocalStorage() - mockGetSession.mockResolvedValue({ - data: { - session: { - access_token: 'supabase-token', - user: { - id: 'user-123', - }, - }, - }, - }) mockCreateSandbox.mockResolvedValue({ sandboxId: 'created-sandbox' }) mockConnectSandbox.mockResolvedValue({ sandboxId: 'connected-sandbox' }) }) @@ -217,8 +204,8 @@ describe('dashboard terminal helpers', () => { await openTerminalSandbox({ onStatus: (message) => statuses.push(message), + sandboxManagementAuth, sandboxId: 'sandbox-from-url', - teamId: 'team-123', template: 'base', }) @@ -240,7 +227,7 @@ describe('dashboard terminal helpers', () => { it('creates and stores a terminal sandbox when no reusable session exists', async () => { await openTerminalSandbox({ onStatus: vi.fn(), - teamId: 'team-123', + sandboxManagementAuth, template: 'base', }) @@ -275,7 +262,7 @@ describe('dashboard terminal helpers', () => { await openTerminalSandbox({ onStatus: vi.fn(), - teamId: 'team-123', + sandboxManagementAuth, template: 'base', })