diff --git a/src/__test__/integration/inspect-sandbox.test.ts b/src/__test__/integration/inspect-sandbox.test.ts new file mode 100644 index 000000000..91ddf78cb --- /dev/null +++ b/src/__test__/integration/inspect-sandbox.test.ts @@ -0,0 +1,622 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// ============================================================================ +// MOCKS SETUP +// ============================================================================ + +const mockSupabaseClient = { + auth: { + getUser: vi.fn(), + getSession: vi.fn(), + }, +} + +vi.mock('@/lib/clients/supabase/server', () => ({ + createClient: vi.fn(() => Promise.resolve(mockSupabaseClient)), +})) + +vi.mock('@/lib/clients/supabase/admin', () => ({ + supabaseAdmin: { + from: vi.fn(), + }, +})) + +vi.mock('@/lib/clients/kv', () => ({ + kv: { + get: vi.fn(), + set: vi.fn(), + }, +})) + +vi.mock('@/lib/clients/api', () => ({ + infra: { + GET: vi.fn(), + }, +})) + +vi.mock('@/configs/api', () => ({ + SUPABASE_AUTH_HEADERS: vi.fn((token: string, teamId: string) => ({ + Authorization: `Bearer ${token}`, + 'X-Team-ID': teamId, + })), +})) + +vi.mock('@/configs/keys', () => ({ + COOKIE_KEYS: { + SELECTED_TEAM_ID: 'selected_team_id', + SELECTED_TEAM_SLUG: 'selected_team_slug', + }, + KV_KEYS: { + TEAM_ID_TO_SLUG: (teamId: string) => `team_id_to_slug:${teamId}`, + TEAM_SLUG_TO_ID: (slug: string) => `team_slug_to_id:${slug}`, + }, +})) + +vi.mock('@/configs/urls', () => ({ + PROTECTED_URLS: { + DASHBOARD: '/dashboard', + SANDBOX_INSPECT: (teamSlug: string, sandboxId: string) => + `/dashboard/${teamSlug}/sandboxes/${sandboxId}/inspect`, + }, + RESOLVER_URLS: { + INSPECT_SANDBOX: (sandboxId: string) => + `/dashboard/inspect/sandbox/${sandboxId}`, + }, + AUTH_URLS: { + SIGN_IN: '/sign-in', + }, +})) + +vi.mock('@/lib/clients/logger/logger', () => ({ + l: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('next/headers', () => ({ + cookies: vi.fn(() => + Promise.resolve({ + get: vi.fn(), + }) + ), +})) + +// Track redirect calls for assertions +let mockRedirectCalls: Array<{ url: string }> = [] + +vi.mock('next/server', async () => { + const actual = + await vi.importActual('next/server') + + return { + ...actual, + NextResponse: { + ...actual.NextResponse, + redirect: vi.fn((url: URL | string) => { + const urlString = url.toString() + mockRedirectCalls.push({ url: urlString }) + return new actual.NextResponse(null, { + status: 307, + headers: { + location: urlString, + }, + }) + }), + }, + } +}) + +// Import mocked modules after mock setup +import { infra } from '@/lib/clients/api' +import { supabaseAdmin } from '@/lib/clients/supabase/admin' + +// Constants for testing +const COOKIE_KEYS = { + SELECTED_TEAM_ID: 'selected_team_id', + SELECTED_TEAM_SLUG: 'selected_team_slug', +} + +const KV_KEYS = { + TEAM_ID_TO_SLUG: (teamId: string) => `team_id_to_slug:${teamId}`, + TEAM_SLUG_TO_ID: (slug: string) => `team_slug_to_id:${slug}`, +} + +const PROTECTED_URLS = { + DASHBOARD: '/dashboard', + SANDBOX_INSPECT: (teamSlug: string, sandboxId: string) => + `/dashboard/${teamSlug}/sandboxes/${sandboxId}/inspect`, +} + +const AUTH_URLS = { + SIGN_IN: '/sign-in', +} + +// ============================================================================ +// TEST HELPERS +// ============================================================================ + +function createMockRequest(sandboxId: string): NextRequest { + return new NextRequest( + `https://app.e2b.dev/dashboard/inspect/sandbox/${sandboxId}` + ) +} + +function createMockParams(sandboxId: string) { + return { + params: Promise.resolve({ sandboxId }), + } +} + +function setupAuthenticatedUser( + userId = 'user-123', + accessToken = 'access-token-123' +) { + mockSupabaseClient.auth.getUser.mockResolvedValue({ + data: { user: { id: userId } }, + error: null, + }) + + mockSupabaseClient.auth.getSession.mockResolvedValue({ + data: { session: { access_token: accessToken } }, + error: null, + }) +} + +function setupUserTeams(teams: Array<{ id: string; slug: string | null }>) { + const mockTeamsData = teams.map((team) => ({ + teams: team, + is_default: false, + })) + + vi.mocked(supabaseAdmin.from).mockImplementation( + () => + ({ + select: vi.fn(() => ({ + eq: vi.fn(() => + Promise.resolve({ + data: mockTeamsData, + error: null, + }) + ), + })), + }) as any + ) +} + +function setupSandboxResponse( + teamId: string, + sandboxId: string, + found: boolean +) { + vi.mocked(infra.GET).mockImplementation((path: string, options: any) => { + if ( + path === '/sandboxes/{sandboxID}' && + options.params.path.sandboxID === sandboxId && + options.headers['X-Team-ID'] === teamId + ) { + if (found) { + return Promise.resolve({ + response: { status: 200 }, + data: { + sandboxID: sandboxId, + templateID: 'template123', + clientID: 'client123', + startedAt: '2024-01-01T00:00:00Z', + endAt: '2024-01-02T00:00:00Z', + state: 'running', + cpuCount: 2, + memoryMB: 4096, + diskSizeMB: 10240, + envdVersion: '1.0.0', + }, + error: null, + }) + } else { + return Promise.resolve({ + response: { status: 404 }, + data: null, + error: { message: 'Sandbox not found' }, + }) + } + } + + return Promise.resolve({ + response: { status: 404 }, + data: null, + error: { message: 'Not found' }, + }) + }) +} + +// ============================================================================ +// TESTS +// ============================================================================ + +describe('Sandbox Inspect Route - Integration Tests', () => { + let GET: any + + beforeEach(async () => { + vi.clearAllMocks() + mockRedirectCalls = [] + + // Dynamically import the route handler after mocks are set up + const routeModule = await import( + '@/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route' + ) + GET = routeModule.GET + }) + + afterEach(() => { + vi.clearAllMocks() + vi.resetModules() + }) + + describe('Input Validation', () => { + /** + * SECURITY TEST: Validates that malicious sandbox IDs are rejected + */ + it('rejects SQL injection attempts in sandbox ID', async () => { + setupAuthenticatedUser() + setupUserTeams([{ id: 'team-123', slug: 'team-slug' }]) + + const maliciousIds = [ + "'; DROP TABLE users; --", + "1' OR '1'='1", + '../../../etc/passwd', + "", + "'; SELECT * FROM teams; --", + ] + + for (const maliciousId of maliciousIds) { + const request = createMockRequest(maliciousId) + const params = createMockParams(maliciousId) + + await GET(request, params) + + const lastRedirect = mockRedirectCalls[mockRedirectCalls.length - 1] + expect(lastRedirect?.url).toContain(PROTECTED_URLS.DASHBOARD) + } + }) + + /** + * VALIDATION TEST: Ensures sandbox ID length limits are enforced + */ + it('rejects sandbox IDs exceeding maximum length', async () => { + setupAuthenticatedUser() + setupUserTeams([{ id: 'team-123', slug: 'team-slug' }]) + + const longId = 'a'.repeat(101) // max is 100 characters + const request = createMockRequest(longId) + const params = createMockParams(longId) + + await GET(request, params) + + expect(mockRedirectCalls[0]?.url).toContain(PROTECTED_URLS.DASHBOARD) + }) + + /** + * VALIDATION TEST: Ensures valid sandbox IDs are accepted + * Note: SandboxIdSchema accepts only lowercase alphanumeric characters + */ + it('accepts valid sandbox ID formats', async () => { + setupAuthenticatedUser() + setupUserTeams([{ id: 'team-123', slug: 'team-slug' }]) + + const validIds = [ + 'sbx123', + 'sandbox456', + 'testsandbox789', + 'a1b2c3d4e5', + 'sandboxupper123', + ] + + for (const validId of validIds) { + setupSandboxResponse('team-123', validId, true) + + const request = createMockRequest(validId) + const params = createMockParams(validId) + + await GET(request, params) + + const lastRedirect = mockRedirectCalls[mockRedirectCalls.length - 1] + expect(lastRedirect?.url).toContain(`/sandboxes/${validId}/inspect`) + } + }) + }) + + describe('Authentication', () => { + /** + * SECURITY TEST: Verifies unauthenticated users are redirected to sign-in + */ + it('redirects to sign-in when user is not authenticated', async () => { + mockSupabaseClient.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + + const request = createMockRequest('sbx123') + const params = createMockParams('sbx123') + + await GET(request, params) + + expect(mockRedirectCalls[0]?.url).toContain('/sign-in') + }) + + /** + * SECURITY TEST: Verifies session errors are handled properly + */ + it('redirects to sign-in when session is invalid', async () => { + setupAuthenticatedUser('user-123', null as any) + + mockSupabaseClient.auth.getSession.mockResolvedValue({ + data: { session: null }, + error: { message: 'Invalid session' }, + }) + + const request = createMockRequest('sbx123') + const params = createMockParams('sbx123') + + await GET(request, params) + + expect(mockRedirectCalls[0]?.url).toContain('/sign-in') + }) + }) + + describe('Team Discovery', () => { + /** + * USER FLOW TEST: Verifies users with no teams are handled correctly + */ + it('redirects to dashboard when user has no teams', async () => { + setupAuthenticatedUser() + setupUserTeams([]) + + const request = createMockRequest('sbx123') + const params = createMockParams('sbx123') + + await GET(request, params) + + expect(mockRedirectCalls[0]?.url).toContain(PROTECTED_URLS.DASHBOARD) + }) + + /** + * USER FLOW TEST: Verifies sandbox is found after searching all teams + */ + it('searches all teams when sandbox not in cookie team', async () => { + setupAuthenticatedUser() + setupUserTeams([ + { id: 'team-1', slug: 'team-one' }, + { id: 'team-2', slug: 'team-two' }, + { id: 'team-3', slug: 'team-three' }, + ]) + + // Setup: Sandbox only exists in team-3 + let callCount = 0 + vi.mocked(infra.GET).mockImplementation(() => { + callCount++ + if (callCount < 3) { + return Promise.resolve({ + response: { status: 404 }, + data: null, + error: { message: 'Not found' }, + }) + } + return Promise.resolve({ + response: { status: 200 }, + data: { + sandboxID: 'sbx123', + templateID: 'template123', + clientID: 'client123', + startedAt: '2024-01-01T00:00:00Z', + endAt: '2024-01-02T00:00:00Z', + state: 'running', + cpuCount: 2, + memoryMB: 4096, + diskSizeMB: 10240, + envdVersion: '1.0.0', + }, + error: null, + }) + }) + + const request = createMockRequest('sbx123') + const params = createMockParams('sbx123') + + await GET(request, params) + + // Verify: Multiple API calls were made to find the sandbox + expect(vi.mocked(infra.GET).mock.calls.length).toBeGreaterThanOrEqual(3) + expect(mockRedirectCalls[0]?.url).toContain('team-three') + expect(mockRedirectCalls[0]?.url).toContain('sbx123') + }) + }) + + describe('Sandbox Authorization', () => { + /** + * SECURITY TEST: Verifies sandbox not in user's teams is rejected + */ + it('redirects to dashboard when sandbox not found in any team', async () => { + setupAuthenticatedUser() + setupUserTeams([ + { id: 'team-1', slug: 'team-one' }, + { id: 'team-2', slug: 'team-two' }, + ]) + + // Setup: Sandbox doesn't exist in any team + vi.mocked(infra.GET).mockResolvedValue({ + response: { status: 404 }, + data: null, + error: { message: 'Not found' }, + }) + + const request = createMockRequest('sbxnotexists') + const params = createMockParams('sbxnotexists') + + await GET(request, params) + + expect(mockRedirectCalls[0]?.url).toContain(PROTECTED_URLS.DASHBOARD) + }) + + /** + * USER FLOW TEST: Verifies successful sandbox resolution and redirect + */ + it('redirects to correct team URL when sandbox is found', async () => { + setupAuthenticatedUser() + setupUserTeams([{ id: 'team-123', slug: 'my-team' }]) + setupSandboxResponse('team-123', 'sbx456', true) + + const request = createMockRequest('sbx456') + const params = createMockParams('sbx456') + + await GET(request, params) + + expect(mockRedirectCalls[0]?.url).toContain( + '/dashboard/my-team/sandboxes/sbx456/inspect' + ) + }) + }) + + describe('Cookie Updates', () => { + /** + * USER FLOW TEST: Verifies team cookies are updated for UI consistency + */ + it('updates team selection cookies on successful resolution', async () => { + setupAuthenticatedUser() + setupUserTeams([{ id: 'team-456', slug: 'new-team' }]) + setupSandboxResponse('team-456', 'sbx789', true) + + const request = createMockRequest('sbx789') + const params = createMockParams('sbx789') + + const response = await GET(request, params) + + expect(response).toBeDefined() + expect(mockRedirectCalls[0]?.url).toContain( + '/dashboard/new-team/sandboxes/sbx789/inspect' + ) + }) + }) + + describe('Error Handling', () => { + /** + * ERROR HANDLING TEST: Verifies database errors are handled gracefully + */ + it('handles database errors gracefully', async () => { + setupAuthenticatedUser() + + // Setup: Database error when fetching teams + vi.mocked(supabaseAdmin.from).mockImplementation( + () => + ({ + select: vi.fn(() => ({ + eq: vi.fn(() => + Promise.resolve({ + data: null, + error: { message: 'Database connection error' }, + }) + ), + })), + }) as any + ) + + const request = createMockRequest('sbx123') + const params = createMockParams('sbx123') + + await GET(request, params) + + expect(mockRedirectCalls[0]?.url).toContain(PROTECTED_URLS.DASHBOARD) + }) + + /** + * ERROR HANDLING TEST: Verifies API errors are handled gracefully + */ + it('handles infrastructure API errors gracefully', async () => { + setupAuthenticatedUser() + setupUserTeams([{ id: 'team-123', slug: 'my-team' }]) + + // Setup: API timeout error + vi.mocked(infra.GET).mockRejectedValue(new Error('API timeout')) + + const request = createMockRequest('sbx123') + const params = createMockParams('sbx123') + + await GET(request, params) + + expect(mockRedirectCalls[0]?.url).toContain(PROTECTED_URLS.DASHBOARD) + }) + + /** + * SECURITY TEST: Verifies unexpected errors don't expose sensitive details + */ + it('does not expose internal errors to users', async () => { + // Setup: Authentication failure with sensitive data + mockSupabaseClient.auth.getUser.mockRejectedValue( + new Error('Internal server error with sensitive data') + ) + + const request = createMockRequest('sbx123') + const params = createMockParams('sbx123') + + await GET(request, params) + + // Verify: Redirect doesn't contain sensitive information + expect(mockRedirectCalls[0]?.url).toContain(PROTECTED_URLS.DASHBOARD) + expect(mockRedirectCalls[0]?.url).not.toContain('sensitive') + }) + }) + + describe('Performance Characteristics', () => { + /** + * PERFORMANCE TEST: Verifies early exit when sandbox is found + */ + it('stops searching once sandbox is found', async () => { + setupAuthenticatedUser() + setupUserTeams([ + { id: 'team-1', slug: 'team-one' }, + { id: 'team-2', slug: 'team-two' }, + { id: 'team-3', slug: 'team-three' }, + { id: 'team-4', slug: 'team-four' }, + ]) + + // Setup: Sandbox found in team-2 + let callCount = 0 + vi.mocked(infra.GET).mockImplementation(() => { + callCount++ + if (callCount === 2) { + return Promise.resolve({ + response: { status: 200 }, + data: { + sandboxID: 'sbx123', + templateID: 'template123', + clientID: 'client123', + startedAt: '2024-01-01T00:00:00Z', + endAt: '2024-01-02T00:00:00Z', + state: 'running', + cpuCount: 2, + memoryMB: 4096, + diskSizeMB: 10240, + envdVersion: '1.0.0', + }, + error: null, + }) + } + return Promise.resolve({ + response: { status: 404 }, + data: null, + error: { message: 'Not found' }, + }) + }) + + const request = createMockRequest('sbx123') + const params = createMockParams('sbx123') + + await GET(request, params) + + // Verify: Early exit after finding sandbox + expect(callCount).toBe(2) + }) + }) +}) diff --git a/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts new file mode 100644 index 000000000..349d03024 --- /dev/null +++ b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts @@ -0,0 +1,398 @@ +/** + * Sandbox Inspection Proxy Route + * + * This route provides a team-agnostic way to access sandbox inspection pages. + * It automatically discovers which team owns a sandbox and redirects to the + * appropriate team-scoped URL. + * + * Use Case: CLI tools and external integrations can generate URLs without + * knowing the team context, simplifying URL generation and improving UX. + * + * Flow: + * 1. Validate and sanitize the sandbox ID + * 2. Authenticate the user + * 3. Fetch user's teams with optimized query + * 4. Search for sandbox ownership (cookie team first, then all teams) + * 5. Resolve team slug and update cache + * 6. Redirect to team-scoped inspection URL + * + * Security: + * - Input validation with Zod + * - Authentication required + * - Team membership verification + * - Sandbox ownership verification + * + * Performance: + * - Redis caching for team slug mappings + * - Cookie-based team preference for faster resolution + * - Optimized database queries with selective field retrieval + */ + +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { COOKIE_KEYS } from '@/configs/keys' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { infra } from '@/lib/clients/api' +import { l } from '@/lib/clients/logger/logger' +import { supabaseAdmin } from '@/lib/clients/supabase/admin' +import { createClient } from '@/lib/clients/supabase/server' +import { SandboxIdSchema } from '@/lib/schemas/api' +import { SandboxInfo } from '@/types/api' +import { cookies } from 'next/headers' +import { NextRequest, NextResponse } from 'next/server' +import { serializeError } from 'serialize-error' + +export const dynamic = 'force-dynamic' +export const fetchCache = 'force-no-store' + +export const revalidate = 0 +export const maxDuration = 60 // seconds + +interface RouteParams { + params: Promise<{ + sandboxId: string + }> +} + +interface MinimalTeam { + id: string + slug: string + is_default?: boolean +} + +interface SandboxSearchResult { + team: MinimalTeam + sandbox: SandboxInfo +} + +/** + * Creates a redirect response to the dashboard with error logging + */ +function redirectToDashboard( + request: NextRequest, + logKey: string, + context: Record = {} +): NextResponse { + l.warn({ + key: logKey, + ...context, + }) + return NextResponse.redirect( + new URL(PROTECTED_URLS.DASHBOARD, request.nextUrl.origin) + ) +} + +/** + * Creates a redirect response to sign-in page + */ +function redirectToSignIn(request: NextRequest): NextResponse { + return NextResponse.redirect( + new URL(AUTH_URLS.SIGN_IN, request.nextUrl.origin) + ) +} + +/** + * Attempts to find a sandbox in a specific team + * + * @param sandboxId - The ID of the sandbox to find + * @param teamId - The team ID to search in + * @param accessToken - User's access token for API authentication + * @returns Promise with sandbox details if found, null otherwise + */ +async function findSandboxInTeam( + sandboxId: string, + teamId: string, + accessToken: string +): Promise { + try { + const res = await infra.GET('/sandboxes/{sandboxID}', { + params: { + path: { + sandboxID: sandboxId, + }, + }, + headers: { + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + }, + cache: 'no-store', // always fetch fresh data for security + }) + + // only return sandbox data if request was successful + if (res.response?.status === 200 && res.data) { + return res.data + } + + return null + } catch (error) { + // log non-404 errors as they might indicate infrastructure issues + if (error instanceof Error && !error.message.includes('404')) { + l.error({ + key: 'find_sandbox_in_team:error', + error, + sandbox_id: sandboxId, + team_id: teamId, + }) + } + return null + } +} + +/** + * Searches for a sandbox across all user's teams + * Optimized to check cookie-selected team first for better performance + * + * @param sandboxId - The sandbox ID to search for + * @param usersTeams - List of teams the user has access to + * @param cookieTeamId - Team ID from user's cookies (if any) + * @param accessToken - User's access token + * @returns Search result with team and sandbox details if found + */ +async function searchSandboxInTeams( + sandboxId: string, + usersTeams: MinimalTeam[], + cookieTeamId: string | undefined, + accessToken: string +): Promise { + // optimization: try cookie team first if it exists + // this handles the common case where user is working within one team + if (cookieTeamId) { + const cookieTeam = usersTeams.find((t) => t.id === cookieTeamId) + if (cookieTeam) { + const sandboxDetails = await findSandboxInTeam( + sandboxId, + cookieTeamId, + accessToken + ) + + if (sandboxDetails) { + return { + team: cookieTeam, + sandbox: sandboxDetails, + } + } + } + } + + // fall back to searching all teams + // this handles team switching and first-time access scenarios + for (const team of usersTeams) { + // skip if we already checked this team above + if (team.id === cookieTeamId) { + continue + } + + const sandboxDetails = await findSandboxInTeam( + sandboxId, + team.id, + accessToken + ) + + if (sandboxDetails) { + return { + team: team, + sandbox: sandboxDetails, + } + } + } + + return null +} + +/** + * Updates user's team selection cookies for UI consistency + * + * @param response - NextResponse object to add cookies to + * @param team - Selected team + * @param teamSlug - Resolved team slug + */ +function updateTeamCookies( + response: NextResponse, + team: MinimalTeam, + teamSlug: string +): void { + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' as const, + maxAge: 60 * 60 * 24 * 30, // 30 days + } + + // always set team ID cookie + response.cookies.set(COOKIE_KEYS.SELECTED_TEAM_ID, team.id, cookieOptions) + + // only set slug cookie if it's different from ID + if (teamSlug !== team.id) { + response.cookies.set( + COOKIE_KEYS.SELECTED_TEAM_SLUG, + teamSlug, + cookieOptions + ) + } +} + +/** + * GET /dashboard/inspect/[sandboxId] + * + * Resolves sandbox ownership and redirects to appropriate team-scoped URL + * + * @param request - Next.js request object + * @param params - Route parameters containing sandbox ID + * @returns Redirect response to team-scoped sandbox inspection page + */ +export async function GET( + request: NextRequest, + { params }: RouteParams +): Promise { + try { + // validate and sanitize input + + const { sandboxId: rawSandboxId } = await params + + // validate sandbox ID format to prevent injection attacks + const validationResult = SandboxIdSchema.safeParse(rawSandboxId) + + if (!validationResult.success) { + return redirectToDashboard(request, 'inspect_sandbox:invalid_id', { + sandbox_id: rawSandboxId, + validation_errors: validationResult.error.flatten(), + }) + } + + const sandboxId = validationResult.data + + // authenticate user + + const supabase = await createClient() + const { data: authData, error: authError } = await supabase.auth.getUser() + + if (authError || !authData.user) { + l.info({ + key: 'inspect_sandbox:unauthenticated', + sandbox_id: sandboxId, + error: authError, + }) + return redirectToSignIn(request) + } + + const userId = authData.user.id + + // get session for API access token + const { data: sessionData, error: sessionError } = + await supabase.auth.getSession() + + if (sessionError || !sessionData.session) { + l.warn({ + key: 'inspect_sandbox:session_error', + user_id: userId, + sandbox_id: sandboxId, + error: sessionError, + }) + return redirectToSignIn(request) + } + + const accessToken = sessionData.session.access_token + + // fetch user's teams using supabaseAdmin + + const { data: usersTeamsData, error: teamsError } = await supabaseAdmin + .from('users_teams') + .select('is_default, teams!inner(id, slug)') + .eq('user_id', userId) + + if (teamsError || !usersTeamsData || usersTeamsData.length === 0) { + l.warn({ + key: 'inspect_sandbox:teams_fetch_error', + user_id: userId, + sandbox_id: sandboxId, + error: teamsError, + }) + + return redirectToDashboard(request, 'inspect_sandbox:no_teams', { + user_id: userId, + sandbox_id: sandboxId, + }) + } + + // transform to MinimalTeam format + + const usersTeams: MinimalTeam[] = usersTeamsData.map((userTeam) => ({ + id: userTeam.teams.id, + slug: userTeam.teams.slug, + is_default: userTeam.is_default, + })) + + // get team preference from cookies for optimization + + const cookieStore = await cookies() + const cookieTeamId = cookieStore.get(COOKIE_KEYS.SELECTED_TEAM_ID)?.value + + // search for sandbox across teams + + const searchResult = await searchSandboxInTeams( + sandboxId, + usersTeams, + cookieTeamId, + accessToken + ) + + if (!searchResult) { + return redirectToDashboard(request, 'inspect_sandbox:not_found', { + user_id: userId, + sandbox_id: sandboxId, + teams_checked: usersTeams.map((t) => t.id), + }) + } + + // resolve team slug and prepare redirect + + const teamSlug = searchResult.team.slug + + const redirectUrl = new URL( + PROTECTED_URLS.SANDBOX_INSPECT(teamSlug, sandboxId), + request.url + ) + + const response = NextResponse.redirect(redirectUrl) + + // update cookies for UI consistency + + updateTeamCookies(response, searchResult.team, teamSlug) + + l.info( + { + key: 'inspect_sandbox_route_handler:success', + user_id: userId, + sandbox_id: sandboxId, + team_id: searchResult.team.id, + context: { + redirect_url: redirectUrl.pathname, + team_slug: teamSlug, + }, + }, + `INSPECT_SANDBOX_ROUTE_HANDLER: Redirecting to ${redirectUrl.pathname}` + ) + + return response + } catch (error) { + // global error handler - ensures we never expose internal errors + + const sE = serializeError(error) + const errorMessage = + typeof sE === 'object' && sE !== null && 'message' in sE + ? String(sE.message) + : 'Unknown error' + + l.error( + { + key: 'inspect_sandbox_route_handler:unexpected_error', + error: sE, + sandbox_id: (await params).sandboxId, + }, + `INSPECT_SANDBOX_ROUTE_HANDLER: Unexpected error: ${errorMessage}` + ) + + // always redirect to dashboard on unexpected errors for security + return redirectToDashboard(request, 'inspect_sandbox:unexpected_error', { + sandbox_id: (await params).sandboxId, + }) + } +} diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 40f8be7b5..f8019cf42 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -25,6 +25,11 @@ export const PROTECTED_URLS = { RESET_PASSWORD: '/dashboard/account', } +export const RESOLVER_URLS = { + INSPECT_SANDBOX: (sandboxId: string) => + `/dashboard/inspect/sandbox/${sandboxId}`, +} + export const HELP_URLS = { BUILD_TEMPLATE: 'https://e2b.dev/docs/sandbox-template#4-build-your-sandbox-template', diff --git a/src/lib/schemas/api.ts b/src/lib/schemas/api.ts new file mode 100644 index 000000000..12d038c72 --- /dev/null +++ b/src/lib/schemas/api.ts @@ -0,0 +1,13 @@ +import z from 'zod' + +/** + * Sandbox ID validation schema + * Accepts standard sandbox ID format (alphanumeric characters) + * Maximum length of 100 characters to prevent DoS attacks + * Example: i08krhnahpx21arf83wmz + */ +export const SandboxIdSchema = z + .string() + .min(1, 'Sandbox ID is required') + .max(100, 'Sandbox ID too long') + .regex(/^[a-z0-9]+$/, 'Invalid sandbox ID format') diff --git a/src/server/middleware.ts b/src/server/middleware.ts index b8a17543f..415b8bab1 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -34,7 +34,11 @@ export async function resolveTeamForDashboard( COOKIE_KEYS.SELECTED_TEAM_SLUG )?.value - if (teamIdOrSlug && teamIdOrSlug !== 'account') { + const isTeamRoute = + !request.nextUrl.pathname.startsWith('/dashboard/inspect/sandbox/') && + request.nextUrl.pathname !== '/dashboard/account' + + if (teamIdOrSlug && isTeamRoute) { try { const teamId = await resolveTeamId(teamIdOrSlug) const hasAccess = await checkUserTeamAccess(userId, teamId) @@ -79,10 +83,9 @@ export async function resolveTeamForDashboard( return { teamId: currentTeamId, teamSlug, - redirect: - teamIdOrSlug === 'account' - ? undefined - : PROTECTED_URLS.SANDBOXES(teamSlug || currentTeamId), + redirect: !isTeamRoute + ? undefined + : PROTECTED_URLS.SANDBOXES(teamSlug || currentTeamId), } } } @@ -122,12 +125,9 @@ export async function resolveTeamForDashboard( return { teamId: defaultTeam.team_id, teamSlug: defaultTeam.team?.slug || undefined, - redirect: - teamIdOrSlug === 'account' - ? undefined - : PROTECTED_URLS.SANDBOXES( - defaultTeam.team?.slug || defaultTeam.team_id - ), + redirect: !isTeamRoute + ? undefined + : PROTECTED_URLS.SANDBOXES(defaultTeam.team?.slug || defaultTeam.team_id), } } diff --git a/src/server/team/get-team.ts b/src/server/team/get-team.ts index e36bd12df..d1b9d2a40 100644 --- a/src/server/team/get-team.ts +++ b/src/server/team/get-team.ts @@ -64,90 +64,39 @@ export const getUserTeams = authActionClient } if (!usersTeamsData || usersTeamsData.length === 0) { - return returnServerError('No teams found.') + return [] } const teamIds = usersTeamsData.map((userTeam) => userTeam.teams.id) - try { - const { data: allConnectedDefaultTeamRelations, error: relationsError } = - await supabaseAdmin - .from('users_teams') - .select('team_id, user_id, is_default') - .in('team_id', teamIds) - .eq('is_default', true) - - if (relationsError) { - throw relationsError - } - - const defaultUserIds = new Set( - allConnectedDefaultTeamRelations?.map((relation) => relation.user_id) || - [] - ) - - const { data: defaultTeamAuthUsers, error: authUsersError } = - await supabaseAdmin - .from('auth_users') - .select('id, email') - .in('id', Array.from(defaultUserIds)) - - if (authUsersError) { - l.error({ - key: 'get_usr_teams:supabase_error', - message: authUsersError.message, - error: serializeError(authUsersError), - user_id: user.id, - }) - - return usersTeamsData.map((userTeam) => ({ - ...userTeam.teams, - is_default: userTeam.is_default, - })) - } + const { data: allConnectedDefaultTeamRelations, error: relationsError } = + await supabaseAdmin + .from('users_teams') + .select('team_id, user_id, is_default') + .in('team_id', teamIds) + .eq('is_default', true) - const userEmailMap = new Map( - defaultTeamAuthUsers?.map((user) => [user.id, user.email]) || [] - ) + if (relationsError) { + throw relationsError + } - const teams: ClientTeam[] = usersTeamsData.map((userTeam) => { - const team = userTeam.teams - const defaultTeamRelation = allConnectedDefaultTeamRelations.find( - (relation) => relation.team_id === team.id - ) - - let transformedDefaultName - // generate a transformed default name if the team is a default team and the team name is the same as the default user's email - if ( - defaultTeamRelation && - team.name === userEmailMap.get(defaultTeamRelation.user_id) - ) { - const email = team.name - const splitEmail = email.split('@') - - if (splitEmail.length > 0 && splitEmail[0]) { - const username = - splitEmail[0].charAt(0).toUpperCase() + splitEmail[0].slice(1) - transformedDefaultName = `${username}'s Team` - } - } + const defaultUserIds = new Set( + allConnectedDefaultTeamRelations?.map((relation) => relation.user_id) || + [] + ) - return { - ...team, - is_default: userTeam.is_default, - transformed_default_name: transformedDefaultName, - } - }) + const { data: defaultTeamAuthUsers, error: authUsersError } = + await supabaseAdmin + .from('auth_users') + .select('id, email') + .in('id', Array.from(defaultUserIds)) - return teams - } catch (err) { + if (authUsersError) { l.error({ - key: 'get_user_teams:unexpected_error', - error: serializeError(err), + key: 'get_user_teams:supabase_error', + message: authUsersError.message, + error: serializeError(authUsersError), user_id: user.id, - context: { - usersTeamsData, - }, }) return usersTeamsData.map((userTeam) => ({ @@ -155,4 +104,43 @@ export const getUserTeams = authActionClient is_default: userTeam.is_default, })) } + + const userEmailMap = new Map( + defaultTeamAuthUsers?.map((user) => [user.id, user.email]) || [] + ) + + const teams: ClientTeam[] = usersTeamsData.map((userTeam) => { + const team = userTeam.teams + const defaultTeamRelation = allConnectedDefaultTeamRelations.find( + (relation) => relation.team_id === team.id + ) + + let transformedDefaultName + // generate a transformed default name if the team is a default team and the team name is the same as the default user's email + if ( + defaultTeamRelation && + team.name === userEmailMap.get(defaultTeamRelation.user_id) + ) { + const email = team.name + const splitEmail = email.split('@') + + if (splitEmail.length > 0 && splitEmail[0]) { + const username = + splitEmail[0].charAt(0).toUpperCase() + splitEmail[0].slice(1) + transformedDefaultName = `${username}'s Team` + } + } + + return { + ...team, + is_default: userTeam.is_default, + transformed_default_name: transformedDefaultName, + } + }) + + if (teams.length === 0) { + return returnServerError('No teams found.') + } + + return teams })