diff --git a/packages/void/src/cli/auth-router.ts b/packages/void/src/cli/auth-router.ts index d4ef7136b..9e1cd9531 100644 --- a/packages/void/src/cli/auth-router.ts +++ b/packages/void/src/cli/auth-router.ts @@ -1,7 +1,7 @@ import { isCancel, select } from '@voidzero-dev/vite-plus-prompts'; import type { AccountArgs } from './account-args.ts'; import type { AuthArgs } from './auth-args.ts'; -import { listPlatformConnections } from './platform-registry.ts'; +import { listPlatformConnections, normalizePlatformApiUrl } from './platform-registry.ts'; import { readDeploymentPlatform } from './project.ts'; type Destination = { platform: 'cloudflare' | 'void'; apiUrl?: string }; @@ -71,15 +71,27 @@ export async function runAuthRouter( } // Account commands resolve their origin through VOID_API_URL. Scope a // prompt-selected origin to this invocation without changing the project. - const previous = process.env.VOID_API_URL; + // VOID_TOKEN stays bound to the original VOID_API_URL: the prompt must never + // choose its recipient, so another origin falls back to its saved login. + const previous = { VOID_API_URL: process.env.VOID_API_URL, VOID_TOKEN: process.env.VOID_TOKEN }; + const tokenBound = Boolean( + previous.VOID_TOKEN && + previous.VOID_API_URL && + normalizePlatformApiUrl(previous.VOID_API_URL) === destination.apiUrl, + ); process.env.VOID_API_URL = destination.apiUrl; + if (previous.VOID_TOKEN && !tokenBound) { + delete process.env.VOID_TOKEN; + } try { await runAccountCommand(root, { subcommand }); } finally { - if (previous === undefined) { - delete process.env.VOID_API_URL; - } else { - process.env.VOID_API_URL = previous; + for (const [name, value] of Object.entries(previous)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } } } } diff --git a/packages/void/test/unit/auth-router.test.ts b/packages/void/test/unit/auth-router.test.ts index ad3621d0f..6ccd71f81 100644 --- a/packages/void/test/unit/auth-router.test.ts +++ b/packages/void/test/unit/auth-router.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { parseAuthArgs } from '../../src/cli/auth-args.ts'; import { runAuthRouter } from '../../src/cli/auth-router.ts'; +import { getToken } from '../../src/cli/auth.ts'; import { savePlatformConnection } from '../../src/cli/platform-registry.ts'; import { writeDeploymentPlatform } from '../../src/cli/project.ts'; @@ -11,6 +12,12 @@ const mocks = vi.hoisted(() => ({ account: vi.fn(), cloudflare: vi.fn(), select: vi.fn(), + keyringRead: vi.fn((): string | null => null), +})); +vi.mock('../../src/cli/keyring.ts', () => ({ + readKeyringPassword: mocks.keyringRead, + writeKeyringPassword: () => true, + deleteKeyringPassword: () => true, })); vi.mock('../../src/cli/account-cmd.ts', () => ({ runAccountCommand: mocks.account })); vi.mock('../../src/cli/cloudflare-cmd.ts', () => ({ runCloudflareCommand: mocks.cloudflare })); @@ -23,9 +30,11 @@ let root: string; beforeEach(() => { vi.clearAllMocks(); mocks.account.mockReset(); + mocks.keyringRead.mockReset(); root = mkdtempSync(join(tmpdir(), 'void-auth-router-')); vi.stubEnv('VOID_PLATFORM_REGISTRY_DIR', join(root, 'registry')); vi.stubEnv('VOID_API_URL', undefined); + vi.stubEnv('VOID_TOKEN', undefined); }); afterEach(() => { vi.unstubAllEnvs(); @@ -84,6 +93,36 @@ describe('void auth destination routing', () => { expect(mocks.account).toHaveBeenCalledWith(root, { subcommand: 'login' }); }); + it.each([ + ['another platform', 'https://a.example.com', 'https://b.example.com', 'saved-login-token'], + ['the bound platform', 'https://a.example.com/', 'https://a.example.com', 'env-token-for-a'], + ['a platform without VOID_API_URL', undefined, 'https://a.example.com', 'saved-login-token'], + ])('uses VOID_TOKEN only when the prompt selects %s', async (_, envUrl, apiUrl, expected) => { + for (const origin of ['https://a.example.com', 'https://b.example.com']) { + savePlatformConnection({ + accountId: null, + apiUrl: origin, + applicationDomain: 'apps.example.com', + displayName: origin, + installationId: null, + kind: 'connected', + proxyUrl: 'https://proxy.example.com', + }); + } + vi.stubEnv('VOID_API_URL', envUrl); + vi.stubEnv('VOID_TOKEN', 'env-token-for-a'); + mocks.keyringRead.mockReturnValue('saved-login-token'); + mocks.select.mockResolvedValue(apiUrl); + let token: string | null = null; + mocks.account.mockImplementation(() => { + token = getToken(root); + }); + await runAuthRouter(root, parseAuthArgs(['status']), true); + expect(token).toBe(expected); + expect(process.env.VOID_API_URL).toBe(envUrl); + expect(process.env.VOID_TOKEN).toBe('env-token-for-a'); + }); + it('shows the Void platform as disabled until a platform is connected', async () => { mocks.select.mockResolvedValue('cloudflare'); await runAuthRouter(root, parseAuthArgs(['login']), true);