diff --git a/cli/src/commands/catalogs.ts b/cli/src/commands/catalogs.ts index 6ac917b..2b097bd 100644 --- a/cli/src/commands/catalogs.ts +++ b/cli/src/commands/catalogs.ts @@ -34,14 +34,14 @@ const catalogsCreateCommand = defineHttpCommand({ }, name: { type: "string", description: "Catalog name", required: true }, }, - parse({ args }): ManagementCatalogCreateInput { + parse({ args, execution }): ManagementCatalogCreateInput { if (args.engine !== "altertable") { throw new CliError( `Only the 'altertable' engine is supported (got '${String(args.engine)}').`, ); } - const env = requireManagementEnv(); + const env = requireManagementEnv(execution.profile); const name = String(args.name); return { env, @@ -77,7 +77,7 @@ const catalogsListCommand = defineOperationCommand({ examples: ["altertable catalogs list", "altertable --json catalogs list"], }, run(_input, context) { - const env = requireManagementEnv(); + const env = requireManagementEnv(context.execution.profile); return localPlan(() => fetchManagementCatalogRows(env, context)); }, present(rows) { diff --git a/cli/src/commands/duckdb.ts b/cli/src/commands/duckdb.ts index 1de43c5..d88636f 100644 --- a/cli/src/commands/duckdb.ts +++ b/cli/src/commands/duckdb.ts @@ -76,12 +76,15 @@ async function runDuckdb(input: DuckdbInput, context: OperationContext): Promise throw new ConfigurationError(LOGIN_PROMPT); } - const credentials = getLoginLakehouseCredentials(); + const credentials = getLoginLakehouseCredentials(context.execution.profile); if (!credentials) { throw new ConfigurationError(LOGIN_PROMPT); } - const rows = await fetchManagementCatalogRows(requireManagementEnv(), context); + const rows = await fetchManagementCatalogRows( + requireManagementEnv(context.execution.profile), + context, + ); const catalogs = selectCatalogsToAttach(rows, input.catalog); const snippet = buildDuckdbAttachSnippet(credentials, catalogs); diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index ae4f65d..7b879a6 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -2,7 +2,10 @@ import { defineLocalCommand } from "@/lib/operation-command-builders.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { getCliContext, isJsonOutput, setCliContext } from "@/context.ts"; import { assertAllowedApiBase } from "@/lib/url-policy.ts"; -import { getCliRuntime, refreshCliRuntimeContext, type OutputSink } from "@/lib/runtime.ts"; +import { refreshCliRuntimeContext, type OutputSink } from "@/lib/runtime.ts"; +import { httpSend } from "@/lib/http.ts"; +import { resolveManagementApiBase, resolveOAuthBase } from "@/lib/config.ts"; +import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; import { managementRequest } from "@/lib/management-transport.ts"; import { runLoginFlow, type TokenResponse } from "@/lib/oauth-flow.ts"; import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; @@ -15,12 +18,11 @@ import { deriveProfileName, profileExists, profileHasAnyAuthConfigured, - resolveProfileName, + resolveWorkingProfile, setActiveProfile, updateProfile, } from "@/features/profile/model.ts"; import { terminalSuccess } from "@/ui/terminal/styles.ts"; -import { createExecutionContext } from "@/lib/execution-context.ts"; function isInteractiveTerminal(): boolean { return process.stdin.isTTY; @@ -56,7 +58,7 @@ function selectLoginProfile( environment: string, replaceCurrentProfile: boolean, ): Pick { - const currentProfile = resolveProfileName(getCliContext().profile); + const currentProfile = resolveWorkingProfile(getCliContext().profile); if (replaceCurrentProfile) { return { profileName: currentProfile, profileAction: "replaced" }; } @@ -157,12 +159,33 @@ export function applyControlPlaneOverride(args: LoginArgs): void { process.env.ALTERTABLE_MANAGEMENT_API_BASE = url; } -async function fetchLoginWhoami(oauthResponse: TokenResponse): Promise { - const execution = createExecutionContext(getCliRuntime()); - execution.auth.management = `Authorization: Bearer ${oauthResponse.access_token}`; - return JSON.parse( - await managementRequest("GET", "/whoami", undefined, execution), - ) as WhoamiResponse; +// Profile-free on purpose: the minted token — not the current profile's stored +// auth, which may still be a different org's session — decides who we are. +export async function fetchLoginWhoami( + oauthResponse: TokenResponse, + managementApiBase: string, +): Promise { + const body = await httpSend({ + method: "GET", + url: `${managementApiBase}${encodeManagementEndpoint("/whoami")}`, + authHeader: `Authorization: Bearer ${oauthResponse.access_token}`, + authPlane: "management", + }); + return JSON.parse(body) as WhoamiResponse; +} + +async function fetchCurrentProfileWhoami(): Promise { + return JSON.parse(await managementRequest("GET", "/whoami")) as WhoamiResponse; +} + +export function sameWhoamiContext(a: WhoamiResponse, b: WhoamiResponse): boolean { + return ( + a.principal?.type === b.principal?.type && + a.principal?.slug === b.principal?.slug && + a.principal?.email === b.principal?.email && + a.organization?.slug === b.organization?.slug && + a.environment_slug === b.environment_slug + ); } async function runLogin(args: LoginArgs, sink: OutputSink): Promise { @@ -170,15 +193,27 @@ async function runLogin(args: LoginArgs, sink: OutputSink): Promise { assertInteractiveLogin(); applyControlPlaneOverride(args); - const oauthResponse = await runLoginFlow(sink); - const whoami = await fetchLoginWhoami(oauthResponse); - // Login succeeded — now persist whoami metadata and any control-plane override - // to the profile so later commands target it. The override is kept session-only - // until here so a failed login against a bad URL never writes it. + const currentProfile = resolveWorkingProfile(getCliContext().profile); + const oauthBase = resolveOAuthBase(currentProfile); + const managementApiBase = resolveManagementApiBase(currentProfile); + + // Past this point the flow is profile-free so it can't accidentally read another org's stored session. + const oauthResponse = await runLoginFlow(sink, oauthBase); + const whoami = await fetchLoginWhoami(oauthResponse, managementApiBase); + + // Login succeeded and we can now persist whoami metadata and any control-plane override to the profile so later commands target it. const { environment, profileName, profileAction } = storeLoginProfileMetadata(whoami, args); - storeOAuthTokens(oauthResponse); + storeOAuthTokens(oauthResponse, profileName); refreshCliRuntimeContext(getCliContext()); + // Refresh whoami from the profile and check if the identity is the same as the one we just authenticated as. + const refreshedWhoami = await fetchCurrentProfileWhoami(); + if (!sameWhoamiContext(whoami, refreshedWhoami)) { + throw new Error( + "Login failed: the identity before and after profile persistence do not match.", + ); + } + const identity = formatWhoamiPrincipalLine(whoami); const profileMessages = { created: `created profile "${profileName}"`, diff --git a/cli/src/commands/profile.ts b/cli/src/commands/profile.ts index 6214eab..3d83224 100644 --- a/cli/src/commands/profile.ts +++ b/cli/src/commands/profile.ts @@ -1,7 +1,6 @@ import { asCliArgString } from "@/lib/cli-args.ts"; import { getCliContext, isJsonOutput, setCliContext } from "@/context.ts"; import { CliError, ConfigurationError } from "@/lib/errors.ts"; -import { withConfigureProfileContext } from "@/lib/profile-configure-core.ts"; import type { ProfileInspect } from "@/features/profile/model.ts"; import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; import { configureVerify } from "@/lib/profile-status.ts"; @@ -132,9 +131,7 @@ const profileCreateCommand = defineLocalCommand({ }, async local(input, context) { createEmptyProfile(input.name); - await withConfigureProfileContext(input.name, () => - runProfileConfigure(input.configure, context.sink), - ); + await runProfileConfigure(input.configure, context.sink, input.name); setActiveProfile(input.name); return inspectProfile(input.name); }, diff --git a/cli/src/features/profile/model.ts b/cli/src/features/profile/model.ts index 52d67ec..0b76783 100644 --- a/cli/src/features/profile/model.ts +++ b/cli/src/features/profile/model.ts @@ -1,6 +1,12 @@ import { renameSync, rmSync } from "node:fs"; -import { configDir, configGet, resolveApiBase, resolveManagementApiBase } from "@/lib/config.ts"; -import { getCliContext, setCliContext } from "@/context.ts"; +import { + configDir, + configGet, + configSet, + resolveApiBase, + resolveManagementApiBase, +} from "@/lib/config.ts"; +import { getCliContext } from "@/context.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import type { WhoamiResponse } from "@/features/management/model.ts"; import { @@ -22,9 +28,8 @@ import { profileDir, profileExists, readProfileConfigRecord, - resolveProfileName, + resolveWorkingProfile, setActiveProfile, - writeProfileConfig, } from "@/lib/profile-store.ts"; export { @@ -37,10 +42,9 @@ export { profileConfigFile, profileExists, profilesDir, - readProfileConfig, - resolveProfileName, + readProfileConfigRecord, + resolveWorkingProfile, setActiveProfile, - writeProfileConfig, } from "@/lib/profile-store.ts"; const PROFILE_SECRET_ACCOUNTS = [ @@ -179,7 +183,7 @@ function writeProfileUpdate(name: string, update: ProfileUpdate): void { >) { const value = update[profileField]; if (value !== undefined) { - writeProfileConfig(name, PROFILE_UPDATE_CONFIG_KEYS[profileField], value); + configSet(PROFILE_UPDATE_CONFIG_KEYS[profileField], value, name); } } } @@ -324,8 +328,8 @@ export function createEmptyProfile(name: string): ProfileInspect { } ensureProfileExists(name); const createdAt = nowIso(); - writeProfileConfig(name, "created_at", createdAt); - writeProfileConfig(name, "updated_at", createdAt); + configSet("created_at", createdAt, name); + configSet("updated_at", createdAt, name); return inspectProfile(name); } @@ -336,7 +340,7 @@ export function updateProfile(name: string, update: ProfileUpdate): ProfileInspe throw new ConfigurationError(`Profile not found: ${name}`); } writeProfileUpdate(name, update); - writeProfileConfig(name, "updated_at", nowIso()); + configSet("updated_at", nowIso(), name); return inspectProfile(name); } @@ -473,12 +477,12 @@ function hasEnvManagementCredentials(): boolean { return Boolean(process.env.ALTERTABLE_API_KEY); } -function hasStoredManagementCredentials(): boolean { - return secretExists("api-key"); +function hasStoredManagementCredentials(profileName: string): boolean { + return secretExists("api-key", profileName); } -function hasOAuthLogin(): boolean { - return secretExists("oauth/access-token"); +function hasOAuthLogin(profileName: string): boolean { + return secretExists("oauth/access-token", profileName); } function hasEnvLakehouseCredentials(): boolean { @@ -490,8 +494,11 @@ function hasEnvLakehouseCredentials(): boolean { ); } -function hasStoredLakehouseCredentials(): boolean { - return secretExists("lakehouse/basic-token") || secretExists("lakehouse/password"); +function hasStoredLakehouseCredentials(profileName: string): boolean { + return ( + secretExists("lakehouse/basic-token", profileName) || + secretExists("lakehouse/password", profileName) + ); } export function hasCredentialsThroughEnv(): boolean { @@ -506,7 +513,7 @@ export function hasCredentialsThroughEnv(): boolean { export function resolveActiveProfileName(): string { return hasCredentialsThroughEnv() ? FROM_ENV_PSEUDOPROFILE_NAME - : resolveProfileName(getCliContext().profile); + : resolveWorkingProfile(getCliContext().profile); } /** @@ -522,62 +529,64 @@ export function assertProfileHasNoEnvCredentials(action: string): void { } } -export function configureCredentialStatus(): ConfigureCredentialStatus { +export function configureCredentialStatus(profileName: string): ConfigureCredentialStatus { return { hasManagement: - hasStoredManagementCredentials() || hasEnvManagementCredentials() || hasOAuthLogin(), - hasLakehouse: hasStoredLakehouseCredentials() || hasEnvLakehouseCredentials(), + hasStoredManagementCredentials(profileName) || + hasEnvManagementCredentials() || + hasOAuthLogin(profileName), + hasLakehouse: hasStoredLakehouseCredentials(profileName) || hasEnvLakehouseCredentials(), }; } -function buildManagementCredential(): ConfigureManagementCredential { +function buildManagementCredential(profileName: string): ConfigureManagementCredential { // Precedence mirrors getManagementAuthHeader: env key → OAuth login → stored key. if (hasEnvManagementCredentials()) { return { configured: true, mechanism: "management_api_key", source: "environment", - environment: process.env.ALTERTABLE_ENV ?? configGet("api_key_env") ?? undefined, + environment: process.env.ALTERTABLE_ENV ?? configGet("api_key_env", profileName) ?? undefined, }; } - if (hasOAuthLogin()) { + if (hasOAuthLogin(profileName)) { return { configured: true, mechanism: "management_oauth", source: "stored", - environment: configGet("api_key_env") || undefined, + environment: configGet("api_key_env", profileName) || undefined, oauth: "set", - expires: configGet("oauth_expiry") || undefined, + expires: configGet("oauth_expiry", profileName) || undefined, }; } - if (hasStoredManagementCredentials()) { + if (hasStoredManagementCredentials(profileName)) { return { configured: true, mechanism: "management_api_key", source: "stored", - environment: configGet("api_key_env") || undefined, + environment: configGet("api_key_env", profileName) || undefined, api_key: "set", }; } return { configured: false }; } -function buildLakehouseCredential(): ConfigureLakehouseCredential { - if (hasStoredLakehouseCredentials()) { - if (secretExists("lakehouse/basic-token")) { +function buildLakehouseCredential(profileName: string): ConfigureLakehouseCredential { + if (hasStoredLakehouseCredentials(profileName)) { + if (secretExists("lakehouse/basic-token", profileName)) { return { configured: true, mechanism: "lakehouse_basic_token", source: "stored", basic_token: "set", - expires: configGet("lakehouse_credential_expiry") || undefined, + expires: configGet("lakehouse_credential_expiry", profileName) || undefined, }; } return { configured: true, mechanism: "lakehouse_username_password", source: "stored", - user: configGet("user") || undefined, + user: configGet("user", profileName) || undefined, password: "set", }; } @@ -600,59 +609,56 @@ function buildLakehouseCredential(): ConfigureLakehouseCredential { return { configured: false }; } -function buildConfigureShowOverrides(): ConfigureShowOverrides { +function buildConfigureShowOverrides(profileName: string): ConfigureShowOverrides { const overrides: ConfigureShowOverrides = {}; - const storedApiKeyEnv = configGet("api_key_env"); + const storedApiKeyEnv = configGet("api_key_env", profileName); const envOverride = process.env.ALTERTABLE_ENV; if (envOverride && envOverride !== storedApiKeyEnv) { overrides.environment = envOverride; overrides.stored_environment = storedApiKeyEnv || null; } - if (process.env.ALTERTABLE_API_KEY && hasStoredManagementCredentials()) { + if (process.env.ALTERTABLE_API_KEY && hasStoredManagementCredentials(profileName)) { overrides.api_key = true; } return overrides; } -export function buildConfigureShowData(profileOverride?: string): ConfigureShowData { - const activeProfile = getActiveProfileName(); - const displayProfile = profileOverride ?? getCliContext().profile ?? activeProfile; - +export function buildConfigureShowData(profileName: string): ConfigureShowData { return { - profile: displayProfile, + profile: profileName, config_dir: configDir(), - config_file: profileConfigFile(displayProfile), + config_file: profileConfigFile(profileName), secret_store: secretStoreDisplay(), organization: { - slug: configGet("organization_slug") || undefined, - name: configGet("organization_name") || undefined, + slug: configGet("organization_slug", profileName) || undefined, + name: configGet("organization_name", profileName) || undefined, }, - data_plane: resolveApiBase(), - control_plane: resolveManagementApiBase(), + data_plane: resolveApiBase(profileName), + control_plane: resolveManagementApiBase(profileName), credentials: { - management: buildManagementCredential(), - lakehouse: buildLakehouseCredential(), + management: buildManagementCredential(profileName), + lakehouse: buildLakehouseCredential(profileName), }, - overrides: buildConfigureShowOverrides(), + overrides: buildConfigureShowOverrides(profileName), }; } -export function managementPlaneStatusDetail(): string | null { +export function managementPlaneStatusDetail(profileName: string): string | null { if (hasEnvManagementCredentials()) { return "via ALTERTABLE_API_KEY"; } - if (hasOAuthLogin()) { - const env = configGet("api_key_env"); + if (hasOAuthLogin(profileName)) { + const env = configGet("api_key_env", profileName); return env ? `OAuth login (${env})` : "OAuth login"; } - if (!hasStoredManagementCredentials()) { + if (!hasStoredManagementCredentials(profileName)) { return null; } - return configGet("api_key_env") || "unknown"; + return configGet("api_key_env", profileName) || "unknown"; } -export function lakehousePlaneStatusDetail(): string | null { +export function lakehousePlaneStatusDetail(profileName: string): string | null { if (process.env.ALTERTABLE_BASIC_AUTH_TOKEN) { return "via ALTERTABLE_BASIC_AUTH_TOKEN"; } @@ -660,27 +666,13 @@ export function lakehousePlaneStatusDetail(): string | null { if (envUser && process.env.ALTERTABLE_LAKEHOUSE_PASSWORD) { return envUser; } - if (!hasStoredLakehouseCredentials()) { + if (!hasStoredLakehouseCredentials(profileName)) { return null; } - if (secretExists("lakehouse/basic-token")) { + if (secretExists("lakehouse/basic-token", profileName)) { return "basic token"; } - return configGet("user") || "unknown"; -} - -function withProfileContextSync(profileName: string | undefined, run: () => T): T { - if (!profileName) { - return run(); - } - ensureProfileExists(profileName); - const previous = getCliContext(); - setCliContext({ ...previous, profile: profileName }); - try { - return run(); - } finally { - setCliContext(previous); - } + return configGet("user", profileName) || "unknown"; } export type ActiveContext = { @@ -706,25 +698,22 @@ function resolveEnvironment(showData: ConfigureShowData): string | undefined { return managementCredential.configured ? managementCredential.environment : undefined; } -export function buildActiveContext(profileOverride?: string): ActiveContext { - const profile = profileOverride ?? getCliContext().profile; - - return withProfileContextSync(profile, () => { - const showData = buildConfigureShowData(profileOverride); - const credentialStatus = configureCredentialStatus(); +export function buildActiveContext(profileName: string): ActiveContext { + ensureProfileExists(profileName); + const showData = buildConfigureShowData(profileName); + const credentialStatus = configureCredentialStatus(profileName); - return { - profile: showData.profile, - environment: resolveEnvironment(showData), - data_plane: showData.data_plane, - control_plane: showData.control_plane, - management: managementPlaneStatusDetail(), - lakehouse: lakehousePlaneStatusDetail(), - credentialStatus, - credentials: showData.credentials, - overrides: showData.overrides, - }; - }); + return { + profile: showData.profile, + environment: resolveEnvironment(showData), + data_plane: showData.data_plane, + control_plane: showData.control_plane, + management: managementPlaneStatusDetail(profileName), + lakehouse: lakehousePlaneStatusDetail(profileName), + credentialStatus, + credentials: showData.credentials, + overrides: showData.overrides, + }; } export function withAuthenticatedIdentity( diff --git a/cli/src/features/profile/render.ts b/cli/src/features/profile/render.ts index 1461c75..0c8fdf3 100644 --- a/cli/src/features/profile/render.ts +++ b/cli/src/features/profile/render.ts @@ -57,16 +57,20 @@ function renderConfigureRows( } export function formatConfigureAuthenticationLines( + profileName: string, options: FormatConfigureAuthenticationOptions = {}, ): string[] { return renderConfigureRows( - configureAuthenticationRows(buildConfigureShowData(), options.planes), + configureAuthenticationRows(buildConfigureShowData(profileName), options.planes), options, ); } -export function formatConfigureSessionSummary(configuredPlanes: ConfigureAuthPlane[]): string[] { - return formatConfigureAuthenticationLines({ planes: configuredPlanes }); +export function formatConfigureSessionSummary( + profileName: string, + configuredPlanes: ConfigureAuthPlane[], +): string[] { + return formatConfigureAuthenticationLines(profileName, { planes: configuredPlanes }); } export function formatActiveContextSummary(context: ActiveContext): string { @@ -82,9 +86,9 @@ export function formatActiveContextDetails(context: ActiveContext): string { return formatTerminalSection(lines); } -export function tryFormatActiveContextSummary(profileOverride?: string): string { +export function tryFormatActiveContextSummary(profileName: string): string { try { - return formatActiveContextSummary(buildActiveContext(profileOverride)); + return formatActiveContextSummary(buildActiveContext(profileName)); } catch (error) { if (error instanceof ConfigurationError) { return ""; diff --git a/cli/src/features/profile/views.ts b/cli/src/features/profile/views.ts index 26b450f..50c6c00 100644 --- a/cli/src/features/profile/views.ts +++ b/cli/src/features/profile/views.ts @@ -202,7 +202,7 @@ function verificationRows(result: ConfigureVerifyResult): DisplayRow[] { export function buildProfileStatusView(result: ProfileStatusResult): DisplayDocument { const errors = result.verification.errors.map( (error) => - ` ${error.plane}: ${error.message}\n ${formatConfigureVerifyRemediation(error.plane)}`, + ` ${error.plane}: ${error.message}\n ${formatConfigureVerifyRemediation(error.plane, result.verification.profile)}`, ); return document( ...buildProfileInspectView(result.profile).sections, diff --git a/cli/src/lib/auth.ts b/cli/src/lib/auth.ts index e713167..c33f128 100644 --- a/cli/src/lib/auth.ts +++ b/cli/src/lib/auth.ts @@ -10,8 +10,8 @@ export function basicAuthHeader(token: string): string { return `Authorization: Basic ${token}`; } -function storedLakehouseCredentialsExpired(): boolean { - const raw = configGet("lakehouse_credential_expiry"); +function storedLakehouseCredentialsExpired(profileName: string): boolean { + const raw = configGet("lakehouse_credential_expiry", profileName); if (!raw) { return false; } @@ -46,20 +46,20 @@ export function hasLakehouseEnvCredentials(): boolean { return lakehouseEnvAuthHeader() !== undefined; } -export function getLakehouseAuthHeader(): string { +export function getLakehouseAuthHeader(profileName: string): string { const envHeader = lakehouseEnvAuthHeader(); if (envHeader) { return envHeader; } - if (!storedLakehouseCredentialsExpired()) { - const storedToken = secretGet("lakehouse/basic-token"); + if (!storedLakehouseCredentialsExpired(profileName)) { + const storedToken = secretGet("lakehouse/basic-token", profileName); if (storedToken) { return basicAuthHeader(storedToken); } - const user = configGet("user"); - const password = secretGet("lakehouse/password"); + const user = configGet("user", profileName); + const password = secretGet("lakehouse/password", profileName); if (user && password) { return basicAuthHeader(basicAuthToken(user, password)); } @@ -81,26 +81,28 @@ function decodeBasicToken(token: string): LakehouseCredentials | undefined { return { user, password }; } -export function getLoginLakehouseCredentials(): LakehouseCredentials | undefined { - if (storedLakehouseCredentialsExpired()) { +export function getLoginLakehouseCredentials( + profileName: string, +): LakehouseCredentials | undefined { + if (storedLakehouseCredentialsExpired(profileName)) { return undefined; } - const token = secretGet("lakehouse/basic-token"); + const token = secretGet("lakehouse/basic-token", profileName); return token ? decodeBasicToken(token) : undefined; } -export function getManagementAuthHeader(): string { +export function getManagementAuthHeader(profileName: string): string { const envKey = process.env.ALTERTABLE_API_KEY ?? ""; if (envKey) { return `Authorization: Bearer ${envKey}`; } - const oauthToken = secretGet("oauth/access-token"); + const oauthToken = secretGet("oauth/access-token", profileName); if (oauthToken) { return `Authorization: Bearer ${oauthToken}`; } - const key = secretGet("api-key"); + const key = secretGet("api-key", profileName); if (!key) { throw new ConfigurationError( "No management credentials. Run 'altertable login', 'altertable profile --configure --api-key atm_xxx --env ', or set ALTERTABLE_API_KEY.", @@ -109,12 +111,12 @@ export function getManagementAuthHeader(): string { return `Authorization: Bearer ${key}`; } -function managementEnv(): string { - return process.env.ALTERTABLE_ENV ?? configGet("api_key_env"); +function managementEnv(profileName: string): string { + return process.env.ALTERTABLE_ENV ?? configGet("api_key_env", profileName); } -export function requireManagementEnv(): string { - const env = managementEnv(); +export function requireManagementEnv(profileName: string): string { + const env = managementEnv(profileName); if (!env) { throw new ConfigurationError( "No environment set. Run 'altertable profile --configure --api-key atm_xxx --env ' or set ALTERTABLE_ENV.", diff --git a/cli/src/lib/cli-session.ts b/cli/src/lib/cli-session.ts index f29b479..2a62ca3 100644 --- a/cli/src/lib/cli-session.ts +++ b/cli/src/lib/cli-session.ts @@ -1,43 +1,14 @@ import type { CliContext } from "@/context.ts"; -import { ConfigurationError } from "@/lib/errors.ts"; -import { getLakehouseAuthHeader, getManagementAuthHeader } from "@/lib/auth.ts"; -import { configGet, resolveApiBase, resolveManagementApiBase } from "@/lib/config.ts"; -import { resolveProfileName } from "@/lib/profile-store.ts"; +import { resolveWorkingProfile } from "@/lib/profile-store.ts"; +// Endpoints and auth are intentionally NOT cached here — they resolve from this +// profile at each request, so they can never desync from it. export type CliSession = { profile: string; - apiBase: string; - managementApiBase: string; - lakehouseAuthHeader?: string; - managementAuthHeader?: string; - managementEnv?: string; }; -function tryAuthHeader(resolve: () => string): string | undefined { - try { - return resolve(); - } catch (error) { - if (error instanceof ConfigurationError) { - return undefined; - } - throw error; - } -} - -function resolveOptionalManagementEnv(): string | undefined { - const env = process.env.ALTERTABLE_ENV ?? configGet("api_key_env"); - return env.length > 0 ? env : undefined; -} - export function createCliSession(context: CliContext): CliSession { - const profile = resolveProfileName(context.profile ?? process.env.ALTERTABLE_PROFILE); - return { - profile, - apiBase: resolveApiBase(), - managementApiBase: resolveManagementApiBase(), - lakehouseAuthHeader: tryAuthHeader(getLakehouseAuthHeader), - managementAuthHeader: tryAuthHeader(getManagementAuthHeader), - managementEnv: resolveOptionalManagementEnv(), + profile: resolveWorkingProfile(context.profile), }; } diff --git a/cli/src/lib/config.ts b/cli/src/lib/config.ts index c9962b1..50917dd 100644 --- a/cli/src/lib/config.ts +++ b/cli/src/lib/config.ts @@ -1,13 +1,4 @@ -import { getCliContext } from "@/context.ts"; -import { - ensureProfilesLayout, - isProfileScopedConfigKey, - profileConfigFile, - readProfileConfig, - resolveProfileName, - unsetProfileConfig, - writeProfileConfig, -} from "@/lib/profile-store.ts"; +import { configGet, configSet } from "@/lib/profile-store.ts"; import { chmodConfigFile, configDir, @@ -21,39 +12,15 @@ import { assertAllowedApiBase } from "@/lib/url-policy.ts"; import { isQueryLayout, type QueryLayout } from "@/ui/layouts/query.ts"; export { configDir, configFile, credentialsFile, kvGet, kvSet, kvUnset }; +export { configGet, configSet }; -function resolveConfigProfile(override?: string): string { - return resolveProfileName(override ?? getCliContext().profile ?? process.env.ALTERTABLE_PROFILE); -} - -export function configGet(key: string): string { - ensureProfilesLayout(); - if (isProfileScopedConfigKey(key)) { - return readProfileConfig(resolveConfigProfile(), key); - } +export function configGetGlobal(key: string): string { return kvGet(configFile(), key); } -export function configSet(key: string, value: string): void { - ensureProfilesLayout(); - if (isProfileScopedConfigKey(key)) { - writeProfileConfig(resolveConfigProfile(), key, value); - chmodConfigFile(profileConfigFile(resolveConfigProfile())); - return; - } - - const filePath = configFile(); - kvSet(filePath, key, value); - chmodConfigFile(filePath); -} - -export function configUnset(key: string): void { - ensureProfilesLayout(); - if (isProfileScopedConfigKey(key)) { - unsetProfileConfig(resolveConfigProfile(), key); - return; - } - kvUnset(configFile(), key); +export function configSetGlobal(key: string, value: string): void { + kvSet(configFile(), key, value); + chmodConfigFile(configFile()); } function resolveAllowInsecureHttp(): boolean { @@ -61,10 +28,10 @@ function resolveAllowInsecureHttp(): boolean { return envFlag === "true" || envFlag === "1"; } -export function resolveApiBase(): string { +export function resolveApiBase(profileName: string): string { let base = process.env.ALTERTABLE_API_BASE ?? ""; if (!base) { - base = configGet("api_base"); + base = configGet("api_base", profileName); } if (!base) { base = "https://api.altertable.ai"; @@ -74,10 +41,10 @@ export function resolveApiBase(): string { return normalized; } -function resolveManagementApiRoot(): string { +function resolveManagementApiRoot(profileName: string): string { let root = process.env.ALTERTABLE_MANAGEMENT_API_BASE ?? ""; if (!root) { - root = configGet("management_api_base"); + root = configGet("management_api_base", profileName); } if (!root) { root = "https://app.altertable.ai"; @@ -87,19 +54,19 @@ function resolveManagementApiRoot(): string { return normalized; } -export function resolveManagementApiBase(): string { - return `${resolveManagementApiRoot()}/rest/v1`; +export function resolveManagementApiBase(profileName: string): string { + return `${resolveManagementApiRoot(profileName)}/rest/v1`; } -export function resolveOAuthBase(): string { - return `${resolveManagementApiRoot()}/oauth`; +export function resolveOAuthBase(profileName: string): string { + return `${resolveManagementApiRoot(profileName)}/oauth`; } const QUERY_PAGER_VALUES = new Set(["auto", "always", "never"]); const MIN_QUERY_MAX_COL_WIDTH = 8; export function getQueryDefaultMaxColumnWidth(): number | undefined { - const value = configGet("query_max_width"); + const value = configGetGlobal("query_max_width"); if (value.length === 0) { return undefined; } @@ -111,7 +78,7 @@ export function getQueryDefaultMaxColumnWidth(): number | undefined { } export function getQueryDefaultLayout(): QueryLayout | undefined { - const value = configGet("query_layout"); + const value = configGetGlobal("query_layout"); if (value.length === 0) { return undefined; } @@ -122,7 +89,7 @@ export function getQueryDefaultLayout(): QueryLayout | undefined { } export function getQueryDefaultPager(): "auto" | "always" | "never" | undefined { - const value = configGet("query_pager"); + const value = configGetGlobal("query_pager"); if (value.length === 0) { return undefined; } diff --git a/cli/src/lib/execution-context.ts b/cli/src/lib/execution-context.ts index b053d1b..af86c79 100644 --- a/cli/src/lib/execution-context.ts +++ b/cli/src/lib/execution-context.ts @@ -1,33 +1,13 @@ import type { CliContext } from "@/context.ts"; -import { getLakehouseAuthHeader, getManagementAuthHeader } from "@/lib/auth.ts"; -import { resolveApiBase, resolveManagementApiBase } from "@/lib/config.ts"; -import type { AuthPlane } from "@/lib/errors.ts"; +import { resolveWorkingProfile } from "@/lib/profile-store.ts"; import type { CliRuntime, OutputSink } from "@/lib/runtime.ts"; -export type PlaneEndpoints = { - lakehouse: string; - management: string; -}; - -export type PlaneAuthHeaders = { - lakehouse?: string; - management?: string; -}; - export type ExecutionContext = { cli: CliContext; output: OutputSink; - profile?: string; - endpoints: PlaneEndpoints; - auth: PlaneAuthHeaders; - managementEnv?: string; + profile: string; }; -const PLANE_AUTH_RESOLVERS = { - lakehouse: getLakehouseAuthHeader, - management: getManagementAuthHeader, -} satisfies Record string>; - export function optionalAuth(resolve: () => string): string | undefined { try { return resolve(); @@ -43,19 +23,6 @@ export function createExecutionContext(runtime: CliRuntime): ExecutionContext { return { cli: runtime.context, output: runtime.output, - profile: runtime.session?.profile, - endpoints: { - lakehouse: runtime.session?.apiBase ?? resolveApiBase(), - management: runtime.session?.managementApiBase ?? resolveManagementApiBase(), - }, - auth: { - lakehouse: runtime.session?.lakehouseAuthHeader ?? optionalAuth(getLakehouseAuthHeader), - management: runtime.session?.managementAuthHeader ?? optionalAuth(getManagementAuthHeader), - }, - managementEnv: runtime.session?.managementEnv, + profile: runtime.session?.profile ?? resolveWorkingProfile(runtime.context.profile), }; } - -export function requirePlaneAuth(context: ExecutionContext, plane: AuthPlane): string { - return context.auth[plane] ?? PLANE_AUTH_RESOLVERS[plane](); -} diff --git a/cli/src/lib/lakehouse-provision.ts b/cli/src/lib/lakehouse-provision.ts index 04a0c25..ea5cb2c 100644 --- a/cli/src/lib/lakehouse-provision.ts +++ b/cli/src/lib/lakehouse-provision.ts @@ -6,7 +6,7 @@ import { hasLakehouseEnvCredentials, requireManagementEnv, } from "@/lib/auth.ts"; -import { configGet, configSet } from "@/lib/config.ts"; +import { configGet, configSet, resolveManagementApiBase } from "@/lib/config.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { optionalAuth, type ExecutionContext } from "@/lib/execution-context.ts"; import { httpSend } from "@/lib/http.ts"; @@ -20,10 +20,10 @@ import { USER_AGENT } from "@/version.ts"; const CREDENTIAL_LABEL = USER_AGENT; const CREDENTIAL_TTL_MS = 2 * 60 * 60 * 1000; -export function hasManagementCredentials(): boolean { +export function hasManagementCredentials(profileName: string): boolean { // optionalAuth only swallows ConfigurationError ("not configured"); // real failures (e.g. keychain errors) propagate to the user. - return optionalAuth(getManagementAuthHeader) !== undefined; + return optionalAuth(() => getManagementAuthHeader(profileName)) !== undefined; } /** @@ -31,11 +31,11 @@ export function hasManagementCredentials(): boolean { * provisioned (never env vars or manually configured credentials — a 401 on * those must surface) and management credentials exist to mint a new one. */ -export function canRecoverLakehouseAuth(): boolean { +export function canRecoverLakehouseAuth(profileName: string): boolean { return ( !hasLakehouseEnvCredentials() && - configGet("lakehouse_credential_expiry") !== "" && - hasManagementCredentials() + configGet("lakehouse_credential_expiry", profileName) !== "" && + hasManagementCredentials(profileName) ); } @@ -45,13 +45,13 @@ async function sendManagementRequest( endpoint: string, body?: string, ): Promise { - if (hasOAuthSession()) { - await ensureFreshAccessToken(); + if (hasOAuthSession(context.profile)) { + await ensureFreshAccessToken(context.profile); } const response = await httpSend({ method, - url: `${context.endpoints.management}${encodeManagementEndpoint(endpoint)}`, - authHeader: getManagementAuthHeader(), + url: `${resolveManagementApiBase(context.profile)}${encodeManagementEndpoint(endpoint)}`, + authHeader: getManagementAuthHeader(context.profile), body, contentType: body === undefined ? undefined : "application/json", authPlane: "management", @@ -61,7 +61,7 @@ async function sendManagementRequest( export async function provisionLakehouseCredential(context: ExecutionContext): Promise { context.output.writeMetadata([terminalMuted("Refreshing lakehouse credentials...")]); - const env = context.managementEnv ?? requireManagementEnv(); + const env = requireManagementEnv(context.profile); const whoami = (await sendManagementRequest( context, "GET", @@ -96,7 +96,7 @@ export async function provisionLakehouseCredential(context: ExecutionContext): P } const token = basicAuthToken(username, password); - secretSet("lakehouse/basic-token", token); - configSet("lakehouse_credential_expiry", String(expiryMs)); + secretSet("lakehouse/basic-token", token, context.profile); + configSet("lakehouse_credential_expiry", String(expiryMs), context.profile); return basicAuthHeader(token); } diff --git a/cli/src/lib/oauth-flow.ts b/cli/src/lib/oauth-flow.ts index bd56a51..65be398 100644 --- a/cli/src/lib/oauth-flow.ts +++ b/cli/src/lib/oauth-flow.ts @@ -1,7 +1,6 @@ import * as oauth from "oauth4webapi"; import { createServer } from "node:http"; import { spawn } from "node:child_process"; -import { resolveOAuthBase } from "@/lib/config.ts"; import { httpSend } from "@/lib/http.ts"; import { ConfigurationError } from "@/lib/errors.ts"; import { terminalMetadata } from "@/ui/terminal/styles.ts"; @@ -46,11 +45,14 @@ export function parseCallback(requestUrl: string, expectedState: string): Callba return { ok: true, code }; } -export function buildAuthorizeUrl(params: { - redirectUri: string; - challenge: string; - state: string; -}): string { +export function buildAuthorizeUrl( + params: { + redirectUri: string; + challenge: string; + state: string; + }, + oauthBase: string, +): string { const query = new URLSearchParams({ response_type: "code", client_id: oauthClientId(), @@ -63,18 +65,17 @@ export function buildAuthorizeUrl(params: { if (scope) { query.set("scope", scope); } - return `${resolveOAuthBase()}/authorize?${query.toString()}`; + return `${oauthBase}/authorize?${query.toString()}`; } type OAuthFetchOptions = oauth.CustomFetchOptions<"POST", URLSearchParams>; -function resolveAuthServer(): oauth.AuthorizationServer { - const base = resolveOAuthBase(); +function resolveAuthServer(oauthBase: string): oauth.AuthorizationServer { return { // Discovery advertises the issuer as the server root (base without the /oauth suffix). - issuer: base.replace(/\/oauth$/, ""), - authorization_endpoint: `${base}/authorize`, - token_endpoint: `${base}/token`, + issuer: oauthBase.replace(/\/oauth$/, ""), + authorization_endpoint: `${oauthBase}/authorize`, + token_endpoint: `${oauthBase}/token`, }; } @@ -104,9 +105,9 @@ async function httpSendFetch(url: string, options: OAuthFetchOptions): Promise { - const authServer = resolveAuthServer(); +export async function exchangeCode( + params: { + code: string; + redirectUri: string; + verifier: string; + }, + oauthBase: string, +): Promise { + const authServer = resolveAuthServer(oauthBase); const client = resolveOauthClient(); // parseCallback already verified `state` against the loopback's expected value, so // skip the re-check here; validateAuthResponse still brands the params object that @@ -146,22 +150,25 @@ export async function exchangeCode(params: { callbackParameters, params.redirectUri, params.verifier, - tokenRequestOptions(), + tokenRequestOptions(oauthBase), ); return toTokenResponse( await oauth.processAuthorizationCodeResponse(authServer, client, response), ); } -export async function refreshAccessToken(refreshToken: string): Promise { - const authServer = resolveAuthServer(); +export async function refreshAccessToken( + refreshToken: string, + oauthBase: string, +): Promise { + const authServer = resolveAuthServer(oauthBase); const client = resolveOauthClient(); const response = await oauth.refreshTokenGrantRequest( authServer, client, oauth.None(), refreshToken, - tokenRequestOptions(), + tokenRequestOptions(oauthBase), ); return toTokenResponse(await oauth.processRefreshTokenResponse(authServer, client, response)); } @@ -242,18 +249,21 @@ export function openBrowser(url: string): void { } } -export async function runLoginFlow(sink: OutputSink): Promise { +export async function runLoginFlow(sink: OutputSink, oauthBase: string): Promise { const verifier = oauth.generateRandomCodeVerifier(); const challenge = await oauth.calculatePKCECodeChallenge(verifier); const state = oauth.generateRandomState(); const server = await startLoopbackServer(state); try { - const authorizeUrl = buildAuthorizeUrl({ redirectUri: server.redirectUri, challenge, state }); + const authorizeUrl = buildAuthorizeUrl( + { redirectUri: server.redirectUri, challenge, state }, + oauthBase, + ); sink.writeMetadata([terminalMetadata("Opening your browser to sign in…")]); sink.writeMetadata([terminalMetadata(`If it doesn't open, visit: ${authorizeUrl}`)]); openBrowser(authorizeUrl); const code = await server.waitForCode(); - return await exchangeCode({ code, redirectUri: server.redirectUri, verifier }); + return await exchangeCode({ code, redirectUri: server.redirectUri, verifier }, oauthBase); } finally { server.close(); } diff --git a/cli/src/lib/oauth-profile.ts b/cli/src/lib/oauth-profile.ts index feb0809..27e5709 100644 --- a/cli/src/lib/oauth-profile.ts +++ b/cli/src/lib/oauth-profile.ts @@ -1,4 +1,4 @@ -import { configGet, configSet, configUnset } from "@/lib/config.ts"; +import { configGet, configSet, resolveOAuthBase } from "@/lib/config.ts"; import { secretDelete, secretGet, secretSet } from "@/lib/secrets.ts"; import { ConfigurationError, HttpError } from "@/lib/errors.ts"; import { refreshAccessToken, type TokenResponse } from "@/lib/oauth-flow.ts"; @@ -8,34 +8,34 @@ const REFRESH_TOKEN_ACCOUNT = "oauth/refresh-token"; const OAUTH_EXPIRY_KEY = "oauth_expiry"; const REFRESH_SKEW_MS = 60_000; -export function storeOAuthTokens(oauthResponse: TokenResponse): void { - secretSet(ACCESS_TOKEN_ACCOUNT, oauthResponse.access_token); +export function storeOAuthTokens(oauthResponse: TokenResponse, profileName: string): void { + secretSet(ACCESS_TOKEN_ACCOUNT, oauthResponse.access_token, profileName); if (oauthResponse.refresh_token) { - secretSet(REFRESH_TOKEN_ACCOUNT, oauthResponse.refresh_token); + secretSet(REFRESH_TOKEN_ACCOUNT, oauthResponse.refresh_token, profileName); } const expiresInMs = (oauthResponse.expires_in ?? 0) * 1000; - configSet(OAUTH_EXPIRY_KEY, String(Date.now() + expiresInMs)); + configSet(OAUTH_EXPIRY_KEY, String(Date.now() + expiresInMs), profileName); } -export function getStoredAccessToken(): string { - return secretGet(ACCESS_TOKEN_ACCOUNT); +export function getStoredAccessToken(profileName: string): string { + return secretGet(ACCESS_TOKEN_ACCOUNT, profileName); } -export function clearOAuthTokens(): void { - secretDelete(ACCESS_TOKEN_ACCOUNT); - secretDelete(REFRESH_TOKEN_ACCOUNT); - configUnset(OAUTH_EXPIRY_KEY); +export function clearOAuthTokens(profileName: string): void { + secretDelete(ACCESS_TOKEN_ACCOUNT, profileName); + secretDelete(REFRESH_TOKEN_ACCOUNT, profileName); + configSet(OAUTH_EXPIRY_KEY, "", profileName); } -export function hasOAuthSession(): boolean { - return configGet(OAUTH_EXPIRY_KEY) !== ""; +export function hasOAuthSession(profileName: string): boolean { + return configGet(OAUTH_EXPIRY_KEY, profileName) !== ""; } -export async function ensureFreshAccessToken(): Promise { +export async function ensureFreshAccessToken(profileName: string): Promise { if (process.env.ALTERTABLE_API_KEY) { return; // env API key takes precedence; don't refresh or clear the OAuth session } - const expiryRaw = configGet(OAUTH_EXPIRY_KEY); + const expiryRaw = configGet(OAUTH_EXPIRY_KEY, profileName); if (!expiryRaw) { return; // not logged in via OAuth } @@ -43,13 +43,13 @@ export async function ensureFreshAccessToken(): Promise { if (Number.isNaN(expiry) || Date.now() < expiry - REFRESH_SKEW_MS) { return; // still fresh } - const refreshToken = secretGet(REFRESH_TOKEN_ACCOUNT); + const refreshToken = secretGet(REFRESH_TOKEN_ACCOUNT, profileName); if (!refreshToken) { return; // nothing to refresh with; the expired token will 401 } let oauthResponse: TokenResponse; try { - oauthResponse = await refreshAccessToken(refreshToken); + oauthResponse = await refreshAccessToken(refreshToken, resolveOAuthBase(profileName)); } catch (error) { // Only a rejected grant means the session is gone: the token endpoint returns // 400 (invalid_grant — expired/revoked refresh token, RFC 6749 §5.2) or 401 @@ -58,12 +58,12 @@ export async function ensureFreshAccessToken(): Promise { // network/timeout — keeps the tokens and surfaces as-is so we don't wipe a // valid session and print a misleading "expired" message. if (error instanceof HttpError && (error.status === 400 || error.status === 401)) { - clearOAuthTokens(); + clearOAuthTokens(profileName); throw new ConfigurationError( "Your login session expired or was revoked. Run 'altertable login' to sign in again.", ); } throw error; } - storeOAuthTokens(oauthResponse); + storeOAuthTokens(oauthResponse, profileName); } diff --git a/cli/src/lib/operation-transport.ts b/cli/src/lib/operation-transport.ts index 20a9505..0794506 100644 --- a/cli/src/lib/operation-transport.ts +++ b/cli/src/lib/operation-transport.ts @@ -1,8 +1,9 @@ import { HttpError, type AuthPlane } from "@/lib/errors.ts"; import { httpSend, httpSendStream, type HttpSendOptions } from "@/lib/http.ts"; import { encodeManagementEndpoint } from "@/lib/management-endpoint.ts"; -import { requirePlaneAuth, type ExecutionContext } from "@/lib/execution-context.ts"; -import { getManagementAuthHeader } from "@/lib/auth.ts"; +import { optionalAuth, type ExecutionContext } from "@/lib/execution-context.ts"; +import { getLakehouseAuthHeader, getManagementAuthHeader } from "@/lib/auth.ts"; +import { resolveApiBase, resolveManagementApiBase } from "@/lib/config.ts"; import { ensureFreshAccessToken, hasOAuthSession } from "@/lib/oauth-profile.ts"; import { canRecoverLakehouseAuth, @@ -26,9 +27,9 @@ export type OperationHttpRequest = { }; const PLANE_URL_BUILDERS = { - lakehouse: (endpoint, context) => `${context.endpoints.lakehouse}${endpoint}`, + lakehouse: (endpoint, context) => `${resolveApiBase(context.profile)}${endpoint}`, management: (endpoint, context) => - `${context.endpoints.management}${encodeManagementEndpoint(endpoint)}`, + `${resolveManagementApiBase(context.profile)}${encodeManagementEndpoint(endpoint)}`, } satisfies Record; function resolvePlaneUrl(request: OperationHttpRequest, context: ExecutionContext): string { @@ -39,16 +40,20 @@ async function resolveRequestAuthHeader( request: OperationHttpRequest, context: ExecutionContext, ): Promise { - if (request.plane === "management" && hasOAuthSession()) { - await ensureFreshAccessToken(); - return getManagementAuthHeader(); + if (request.plane === "management") { + if (hasOAuthSession(context.profile)) { + await ensureFreshAccessToken(context.profile); + } + return getManagementAuthHeader(context.profile); + } + const existing = optionalAuth(() => getLakehouseAuthHeader(context.profile)); + if (existing) { + return existing; } - if (request.plane === "lakehouse" && !context.auth.lakehouse && hasManagementCredentials()) { - const header = await provisionLakehouseCredential(context); - context.auth.lakehouse = header; - return header; + if (hasManagementCredentials(context.profile)) { + return provisionLakehouseCredential(context); } - return requirePlaneAuth(context, request.plane); + return getLakehouseAuthHeader(context.profile); } async function toHttpSendOptions( @@ -70,14 +75,18 @@ async function toHttpSendOptions( }; } -function isRecoverableLakehouseAuthFailure(request: OperationHttpRequest, error: unknown): boolean { +function isRecoverableLakehouseAuthFailure( + request: OperationHttpRequest, + error: unknown, + profileName: string, +): boolean { return ( error instanceof HttpError && error.status === 401 && request.plane === "lakehouse" && // A stream body was consumed by the failed attempt and cannot be resent. !(request.body instanceof ReadableStream) && - canRecoverLakehouseAuth() + canRecoverLakehouseAuth(profileName) ); } @@ -90,13 +99,12 @@ async function sendWithAuthRecovery( try { return await send(options); } catch (error) { - if (!isRecoverableLakehouseAuthFailure(request, error)) { + if (!isRecoverableLakehouseAuthFailure(request, error, context.profile)) { throw error; } // The server can invalidate a provisioned credential (revocation, restart) // before the locally stored expiry: mint a fresh one and retry once. const authHeader = await provisionLakehouseCredential(context); - context.auth.lakehouse = authHeader; return send({ ...options, authHeader }); } } diff --git a/cli/src/lib/profile-configure-core.ts b/cli/src/lib/profile-configure-core.ts index f211c27..f4c1437 100644 --- a/cli/src/lib/profile-configure-core.ts +++ b/cli/src/lib/profile-configure-core.ts @@ -1,9 +1,14 @@ import { unlinkSync, rmSync, existsSync } from "node:fs"; -import { configFile, configGet, configSet, configUnset, credentialsFile } from "@/lib/config.ts"; +import { configFile, configGet, configSet, credentialsFile, kvUnset } from "@/lib/config.ts"; import { CliError } from "@/lib/errors.ts"; import { deriveProfileName, listProfiles } from "@/features/profile/model.ts"; -import { ensureProfileExists, profilesDir } from "@/lib/profile-store.ts"; -import { getCliContext, setCliContext } from "@/context.ts"; +import { + ensureProfileExists, + profileConfigFile, + profilesDir, + resolveWorkingProfile, +} from "@/lib/profile-store.ts"; +import { getCliContext } from "@/context.ts"; import { secretDelete, secretSet } from "@/lib/secrets.ts"; import { assertAllowedApiBase } from "@/lib/url-policy.ts"; import { getCliRuntime, getOutputSink, type OutputSink } from "@/lib/runtime.ts"; @@ -32,15 +37,19 @@ export type ConfigureOptions = { const AUTO_PROFILE_NAME = "auto"; -function markCurrentProfileUpdated(): void { +function markCurrentProfileUpdated(profileName: string): void { const timestamp = new Date().toISOString(); - if (!configGet("created_at")) { - configSet("created_at", timestamp); + if (!configGet("created_at", profileName)) { + configSet("created_at", timestamp, profileName); } - configSet("updated_at", timestamp); + configSet("updated_at", timestamp, profileName); } -function resolveConfigureProfile(options: ConfigureOptions, org: string): string | undefined { +function clearProfileEndpoint(key: "api_base" | "management_api_base", profileName: string): void { + kvUnset(profileConfigFile(profileName), key); +} + +function resolveConfigureProfile(options: ConfigureOptions, org: string): string { const explicitProfile = options.profile; const env = options.env ?? ""; @@ -53,46 +62,37 @@ function resolveConfigureProfile(options: ConfigureOptions, org: string): string if (explicitProfile === AUTO_PROFILE_NAME) { throw new CliError("--profile auto is only supported by interactive configure."); } - return undefined; + return resolveWorkingProfile(getCliContext().profile); } export async function withConfigureProfileContext( - profileName: string | undefined, - run: () => Promise | T, + profileName: string, + run: (profileName: string) => Promise | T, ): Promise { - if (!profileName) { - return await run(); - } ensureProfileExists(profileName); - const previous = getCliContext(); - setCliContext({ ...previous, profile: profileName }); - try { - return await run(); - } finally { - setCliContext(previous); - } + return await run(profileName); } -function configureClearLakehouseCredentials(profileName?: string): void { +function configureClearLakehouseCredentials(profileName: string): void { secretDelete("lakehouse/password", profileName); secretDelete("lakehouse/basic-token", profileName); - configUnset("user"); - configUnset("lakehouse_credential_expiry"); + configSet("user", "", profileName); + configSet("lakehouse_credential_expiry", "", profileName); } -function configureClearManagementCredentials(profileName?: string): void { +function configureClearManagementCredentials(profileName: string): void { secretDelete("api-key", profileName); secretDelete("oauth/access-token", profileName); secretDelete("oauth/refresh-token", profileName); - configUnset("api_key_env"); - configUnset("oauth_expiry"); + configSet("api_key_env", "", profileName); + configSet("oauth_expiry", "", profileName); } -export function configureClearAll(): void { - configureClearLakehouseCredentials(); - configureClearManagementCredentials(); - configUnset("api_base"); - configUnset("management_api_base"); +export function configureClearAll(profileName: string): void { + configureClearLakehouseCredentials(profileName); + configureClearManagementCredentials(profileName); + clearProfileEndpoint("api_base", profileName); + clearProfileEndpoint("management_api_base", profileName); } export async function configureRunSet( @@ -100,7 +100,7 @@ export async function configureRunSet( sink: OutputSink = getOutputSink(), org = "", ): Promise { - return withConfigureProfileContext(resolveConfigureProfile(options, org), async () => { + return withConfigureProfileContext(resolveConfigureProfile(options, org), async (profileName) => { let user = options.user ?? ""; let password = options.password ?? ""; let apiKey = options.apiKey ?? ""; @@ -177,33 +177,33 @@ export async function configureRunSet( } if (hasApiKey) { - configureClearManagementCredentials(); - secretSet("api-key", apiKey, undefined, { fromArgv: apiKeyFromArgv }); - configSet("api_key_env", env); + configureClearManagementCredentials(profileName); + secretSet("api-key", apiKey, profileName, { fromArgv: apiKeyFromArgv }); + configSet("api_key_env", env, profileName); if (org) { - configSet("organization_slug", org); + configSet("organization_slug", org, profileName); } } if (hasLakehouseBasicToken) { - configureClearLakehouseCredentials(); - secretSet("lakehouse/basic-token", basicToken); + configureClearLakehouseCredentials(profileName); + secretSet("lakehouse/basic-token", basicToken, profileName); } else if (hasLakehouseCredentials) { - configureClearLakehouseCredentials(); - configSet("user", user); - secretSet("lakehouse/password", password, undefined, { fromArgv: passwordFromArgv }); + configureClearLakehouseCredentials(profileName); + configSet("user", user, profileName); + secretSet("lakehouse/password", password, profileName, { fromArgv: passwordFromArgv }); } if (dataPlaneUrl) { assertAllowedApiBase(dataPlaneUrl, { allowInsecureHttp }); - configSet("api_base", dataPlaneUrl); + configSet("api_base", dataPlaneUrl, profileName); } else if (hasAnyCredential) { - configUnset("api_base"); + clearProfileEndpoint("api_base", profileName); } if (controlPlaneUrl) { assertAllowedApiBase(controlPlaneUrl, { allowInsecureHttp }); - configSet("management_api_base", controlPlaneUrl); + configSet("management_api_base", controlPlaneUrl, profileName); } else if (hasAnyCredential) { - configUnset("management_api_base"); + clearProfileEndpoint("management_api_base", profileName); } if (hasApiKey) { @@ -215,7 +215,7 @@ export async function configureRunSet( } else if (dataPlaneUrl) { sink.writeMetadata([terminalMetadata(`Saved data plane URL ${dataPlaneUrl}.`)]); } - markCurrentProfileUpdated(); + markCurrentProfileUpdated(profileName); }); } diff --git a/cli/src/lib/profile-configure-interactive.ts b/cli/src/lib/profile-configure-interactive.ts index d5e171a..30b6c00 100644 --- a/cli/src/lib/profile-configure-interactive.ts +++ b/cli/src/lib/profile-configure-interactive.ts @@ -221,31 +221,40 @@ type ScopePromptModel = { options: ConfigureSelectOption[]; }; -function formatScopeSelectLabel(plane: "management" | "lakehouse"): string { +function formatScopeSelectLabel(plane: "management" | "lakehouse", profileName: string): string { const name = plane === "management" ? "Management" : "Lakehouse"; const detail = - plane === "management" ? managementPlaneStatusDetail() : lakehousePlaneStatusDetail(); + plane === "management" + ? managementPlaneStatusDetail(profileName) + : lakehousePlaneStatusDetail(profileName); if (!detail) { return `${terminalStrong(name)} ${terminalNotConfiguredStatus()}`; } return `${terminalStrong(name)} ${terminalSubtle(detail)}`; } -function toScopePromptOption(value: PromptScopeSelection): ConfigureSelectOption { +function toScopePromptOption( + value: PromptScopeSelection, + profileName: string, +): ConfigureSelectOption { if (value === "both") { return { value, label: terminalStrong("Both") }; } - return { value, label: formatScopeSelectLabel(value) }; + return { value, label: formatScopeSelectLabel(value, profileName) }; } -function buildScopePromptModel(hasManagement: boolean, hasLakehouse: boolean): ScopePromptModel { +function buildScopePromptModel( + hasManagement: boolean, + hasLakehouse: boolean, + profileName: string, +): ScopePromptModel { if (hasManagement && hasLakehouse) { return { defaultValue: "both", options: [ - toScopePromptOption("management"), - toScopePromptOption("lakehouse"), - toScopePromptOption("both"), + toScopePromptOption("management", profileName), + toScopePromptOption("lakehouse", profileName), + toScopePromptOption("both", profileName), ], }; } @@ -253,23 +262,29 @@ function buildScopePromptModel(hasManagement: boolean, hasLakehouse: boolean): S if (hasManagement) { return { defaultValue: "lakehouse", - options: [toScopePromptOption("lakehouse"), toScopePromptOption("management")], + options: [ + toScopePromptOption("lakehouse", profileName), + toScopePromptOption("management", profileName), + ], }; } if (hasLakehouse) { return { defaultValue: "management", - options: [toScopePromptOption("management"), toScopePromptOption("lakehouse")], + options: [ + toScopePromptOption("management", profileName), + toScopePromptOption("lakehouse", profileName), + ], }; } return { defaultValue: "both", options: [ - toScopePromptOption("both"), - toScopePromptOption("management"), - toScopePromptOption("lakehouse"), + toScopePromptOption("both", profileName), + toScopePromptOption("management", profileName), + toScopePromptOption("lakehouse", profileName), ], }; } @@ -284,14 +299,15 @@ function parseScopeSelection(selection: string): ConfigureWizardScope { export async function promptConfigureScope( prompts: ConfigurePrompts, requestedScope: ConfigureWizardScope, + profileName: string, ): Promise { - const { hasManagement, hasLakehouse } = configureCredentialStatus(); + const { hasManagement, hasLakehouse } = configureCredentialStatus(profileName); if (requestedScope !== DEFAULT_SCOPE) { return requestedScope; } - const scopePromptModel = buildScopePromptModel(hasManagement, hasLakehouse); + const scopePromptModel = buildScopePromptModel(hasManagement, hasLakehouse, profileName); const selectedScope = await prompts.readSelect( SCOPE_SELECT_TITLE, scopePromptModel.options, @@ -342,12 +358,15 @@ type ReadSecretWithOptionalReuseArgs = { requiredMessage: string; }; -async function readSecretWithOptionalReuse(args: ReadSecretWithOptionalReuseArgs): Promise { +async function readSecretWithOptionalReuse( + args: ReadSecretWithOptionalReuseArgs, + profileName: string, +): Promise { const { prompts, secretKey, keepPrompt, passwordPrompt, requiredMessage } = args; - if (secretExists(secretKey)) { + if (secretExists(secretKey, profileName)) { const keepExistingValue = await prompts.readConfirm(keepPrompt, true); if (keepExistingValue) { - return secretGet(secretKey); + return secretGet(secretKey, profileName); } } @@ -357,16 +376,17 @@ async function readSecretWithOptionalReuse(args: ReadSecretWithOptionalReuseArgs export async function collectManagementCredentials( prompts: ConfigurePrompts, options: ConfigureWizardOptions, + profileName: string, ): Promise<{ options: ConfigureOptions; org: string }> { prompts.writePrompt(`\n${terminalAccent("Management")}\n`); - const currentEnv = configGet("api_key_env"); + const currentEnv = configGet("api_key_env", profileName); const envDefault = currentEnv || "production"; const envPrompt = `Environment ${terminalDefaultHint(envDefault)}: `; const env = await readLineWithDefault(prompts, envPrompt, envDefault); let org = options.org ?? ""; if (!org && options.profile === AUTO_PROFILE_NAME) { - const currentOrg = configGet("organization_slug"); + const currentOrg = configGet("organization_slug", profileName); const orgPrompt = currentOrg ? `Organization slug ${terminalDefaultHint(currentOrg)}: ` : `${terminalAccent("Organization slug")}: `; @@ -377,13 +397,16 @@ export async function collectManagementCredentials( throw new CliError("Organization slug is required for --profile auto."); } } - const apiKey = await readSecretWithOptionalReuse({ - prompts, - secretKey: "api-key", - keepPrompt: "Keep existing API key?", - passwordPrompt: `${terminalAccent("API key")} ${HIDDEN_PROMPT_HINT}: `, - requiredMessage: "API key is required.", - }); + const apiKey = await readSecretWithOptionalReuse( + { + prompts, + secretKey: "api-key", + keepPrompt: "Keep existing API key?", + passwordPrompt: `${terminalAccent("API key")} ${HIDDEN_PROMPT_HINT}: `, + requiredMessage: "API key is required.", + }, + profileName, + ); const useDefaultControlPlane = await prompts.readConfirm( `Default control plane ${formatTerminalUrls(DEFAULT_CONTROL_PLANE_URL)}?`, @@ -410,6 +433,7 @@ export async function collectManagementCredentials( export async function collectLakehouseCredentials( prompts: ConfigurePrompts, options: ConfigureWizardOptions, + profileName: string, ): Promise { const method = await prompts.readSelect( `${terminalAccent("Lakehouse")} ${terminalSubtle("auth")}`, @@ -435,7 +459,7 @@ export async function collectLakehouseCredentials( return configureOptions; } - const currentUser = configGet("user"); + const currentUser = configGet("user", profileName); const userPrompt = currentUser ? `Username ${terminalDefaultHint(currentUser)}: ` : `${terminalAccent("Username")}: `; @@ -446,13 +470,16 @@ export async function collectLakehouseCredentials( throw new CliError("Username is required."); } - const password = await readSecretWithOptionalReuse({ - prompts, - secretKey: "lakehouse/password", - keepPrompt: "Keep existing password?", - passwordPrompt: `${terminalAccent("Password")} ${HIDDEN_PROMPT_HINT}: `, - requiredMessage: "Password is required.", - }); + const password = await readSecretWithOptionalReuse( + { + prompts, + secretKey: "lakehouse/password", + keepPrompt: "Keep existing password?", + passwordPrompt: `${terminalAccent("Password")} ${HIDDEN_PROMPT_HINT}: `, + requiredMessage: "Password is required.", + }, + profileName, + ); const useDefaultDataPlane = await prompts.readConfirm( `Default data plane ${formatTerminalUrls(DEFAULT_DATA_PLANE_URL)}?`, diff --git a/cli/src/lib/profile-configure.ts b/cli/src/lib/profile-configure.ts index cc37e71..4fb417f 100644 --- a/cli/src/lib/profile-configure.ts +++ b/cli/src/lib/profile-configure.ts @@ -8,6 +8,7 @@ import { formatConfigureSessionSummary } from "@/features/profile/render.ts"; import type { ConfigureAuthPlane } from "@/lib/profile-status.ts"; import { ConfigurationError, CliError } from "@/lib/errors.ts"; import { getCliContext, isJsonOutput } from "@/context.ts"; +import { resolveWorkingProfile } from "@/lib/profile-store.ts"; import { getOutputSink, type OutputSink } from "@/lib/runtime.ts"; import { collectLakehouseCredentials, @@ -58,8 +59,12 @@ function formatNextCommandsLine(commands: string[]): string { ); } -function writeOutro(sink: OutputSink, configuredPlanes: ConfigureAuthPlane[]): void { - const summaryLines = formatConfigureSessionSummary(configuredPlanes); +function writeOutro( + sink: OutputSink, + configuredPlanes: ConfigureAuthPlane[], + profileName: string, +): void { + const summaryLines = formatConfigureSessionSummary(profileName, configuredPlanes); const nextCommands: string[] = []; if (configuredPlanes.includes("management")) { @@ -82,7 +87,8 @@ export function configureNonTtyErrorMessage(): string { } async function runConfigureWizardInCurrentProfile( - options: ConfigureWizardOptions = {}, + options: ConfigureWizardOptions, + profileName: string, ): Promise { const sink = options.sink ?? getOutputSink(); const prompts = options.prompts ?? defaultConfigurePrompts; @@ -98,10 +104,13 @@ async function runConfigureWizardInCurrentProfile( } try { - const scope = await promptConfigureScope(prompts, options.scope ?? DEFAULT_SCOPE); + const scope = await promptConfigureScope(prompts, options.scope ?? DEFAULT_SCOPE, profileName); const planes = planesForConfigureScope(scope); const configuredPlanes: ConfigureAuthPlane[] = []; let activeProfile = options.profile; + // The profile actually written to; for `--profile auto` it is only known once + // the org/env are collected, so it starts as the read profile and is updated. + let targetProfile = profileName; if (activeProfile === AUTO_PROFILE_NAME && !planes.includes("management")) { throw new CliError( @@ -110,27 +119,30 @@ async function runConfigureWizardInCurrentProfile( } if (planes.includes("management")) { - const { options: managementOptions, org } = await collectManagementCredentials(prompts, { - ...options, - profile: activeProfile, - }); + const { options: managementOptions, org } = await collectManagementCredentials( + prompts, + { ...options, profile: activeProfile }, + targetProfile, + ); await saveCredentials(managementOptions, sink, org); if (activeProfile === AUTO_PROFILE_NAME && org && managementOptions.env) { activeProfile = deriveProfileName(org, managementOptions.env); + targetProfile = activeProfile; } configuredPlanes.push("management"); } if (planes.includes("lakehouse")) { - const lakehouseOptions = await collectLakehouseCredentials(prompts, { - ...options, - profile: activeProfile, - }); + const lakehouseOptions = await collectLakehouseCredentials( + prompts, + { ...options, profile: activeProfile }, + targetProfile, + ); await saveCredentials(lakehouseOptions, sink); configuredPlanes.push("lakehouse"); } - writeOutro(sink, configuredPlanes); + writeOutro(sink, configuredPlanes, targetProfile); } catch (error) { if (error instanceof ConfigurePromptCancelled) { sink.writeMetadata([terminalMetadata("Configuration cancelled.")]); @@ -141,9 +153,12 @@ async function runConfigureWizardInCurrentProfile( } export async function runConfigureWizard(options: ConfigureWizardOptions = {}): Promise { - const contextProfile = options.profile === AUTO_PROFILE_NAME ? undefined : options.profile; - await withConfigureProfileContext(contextProfile, async () => { - await runConfigureWizardInCurrentProfile(options); + const contextProfile = + options.profile && options.profile !== AUTO_PROFILE_NAME + ? options.profile + : resolveWorkingProfile(getCliContext().profile); + await withConfigureProfileContext(contextProfile, async (profileName) => { + await runConfigureWizardInCurrentProfile(options, profileName); }); } @@ -241,8 +256,12 @@ function resolveWizardScope(scope: string | undefined): ConfigureWizardScope { export async function runProfileConfigure( args: ConfigureCommandArgs, sink: OutputSink, + profileName?: string, ): Promise { const options = buildConfigureOptions(args); + if (profileName) { + options.profile = profileName; + } if (hasConfigureCredentialFlags(options)) { if (args.scope) { @@ -257,6 +276,7 @@ export async function runProfileConfigure( await runConfigureWizard({ scope: resolveWizardScope(args.scope), allowInsecureHttp: options.allowInsecureHttp, + profile: profileName, sink, }); } diff --git a/cli/src/lib/profile-status.ts b/cli/src/lib/profile-status.ts index 82ec905..3836d94 100644 --- a/cli/src/lib/profile-status.ts +++ b/cli/src/lib/profile-status.ts @@ -10,7 +10,7 @@ import type { OperationContext } from "@/lib/operation-command.ts"; import { runOperationPlan } from "@/lib/operation-effect.ts"; import { formatProgressStatus, startProgress } from "@/lib/progress.ts"; import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; -import { resolveProfileName } from "@/lib/profile-store.ts"; +import { resolveWorkingProfile } from "@/lib/profile-store.ts"; export { configureCredentialStatus } from "@/features/profile/model.ts"; @@ -99,7 +99,7 @@ export async function configureVerify( refreshCliRuntimeContext(getCliContext()); const result: ConfigureVerifyResult = { - profile: resolveProfileName(getCliContext().profile), + profile: resolveWorkingProfile(getCliContext().profile), configured: [...planes], verified: { management: false, lakehouse: false }, errors: [], @@ -121,9 +121,12 @@ export async function configureVerify( return result; } -export function formatConfigureVerifyRemediation(plane: ConfigureAuthPlane): string { +export function formatConfigureVerifyRemediation( + plane: ConfigureAuthPlane, + profileName: string, +): string { if (plane === "management") { - const env = configGet("api_key_env") || ""; + const env = configGet("api_key_env", profileName) || ""; return `Check your API key and environment. Run: altertable profile --configure --scope management or altertable profile --configure --api-key atm_xxx --env ${env}`; } return "Check your lakehouse username and password. Run: altertable profile --configure --scope lakehouse or altertable profile --configure --user --password

"; diff --git a/cli/src/lib/profile-store.ts b/cli/src/lib/profile-store.ts index a813730..6496755 100644 --- a/cli/src/lib/profile-store.ts +++ b/cli/src/lib/profile-store.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readdirSync } from "node:fs"; import { join, resolve, sep } from "node:path"; -import { configDir, configFile, kvGet, kvSet, kvUnset } from "@/lib/config-files.ts"; +import { configDir, configFile, kvGet, kvSet } from "@/lib/config-files.ts"; import { ConfigurationError } from "@/lib/errors.ts"; export const DEFAULT_PROFILE_NAME = "default"; @@ -31,12 +31,6 @@ export const PROFILE_CONFIG_KEYS = [ export type ProfileConfigKey = (typeof PROFILE_CONFIG_KEYS)[number]; -const PROFILE_SCOPED_KEYS = new Set(PROFILE_CONFIG_KEYS); - -export function isProfileScopedConfigKey(key: string): key is ProfileConfigKey { - return PROFILE_SCOPED_KEYS.has(key); -} - const SAFE_PROFILE_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; export function profilesDir(): string { @@ -115,7 +109,7 @@ export function setActiveProfile(name: string): void { kvSet(configFile(), "active_profile", name); } -export function resolveProfileName(override?: string): string { +export function resolveWorkingProfile(override?: string): string { ensureProfilesLayout(); const explicit = override ?? process.env.ALTERTABLE_PROFILE ?? ""; @@ -150,20 +144,16 @@ export function ensureProfileExists(name: string): void { } } -export function readProfileConfig(profileName: string, key: string): string { +export function configGet(key: string, profileName: string): string { return kvGet(profileConfigFile(profileName), key); } -export function writeProfileConfig(profileName: string, key: string, value: string): void { +export function configSet(key: string, value: string, profileName: string): void { assertSafeProfileName(profileName); mkdirSync(profileDir(profileName), { recursive: true }); kvSet(profileConfigFile(profileName), key, value); } -export function unsetProfileConfig(profileName: string, key: string): void { - kvUnset(profileConfigFile(profileName), key); -} - export function readProfileConfigRecord( name: string, ): Record { diff --git a/cli/src/lib/secrets.ts b/cli/src/lib/secrets.ts index 13de267..70bfb00 100644 --- a/cli/src/lib/secrets.ts +++ b/cli/src/lib/secrets.ts @@ -1,11 +1,10 @@ import type { SpawnSyncOptions, SpawnSyncReturns } from "node:child_process"; import { chmodSync, statSync } from "node:fs"; import { spawnSync } from "node:child_process"; -import { getCliContext } from "@/context.ts"; import { credentialsFile, kvGet, kvSet, kvUnset } from "@/lib/config.ts"; import { CliError } from "@/lib/errors.ts"; import { logWarn } from "@/lib/log.ts"; -import { assertSafeProfileName, resolveProfileName } from "@/lib/profile-store.ts"; +import { assertSafeProfileName } from "@/lib/profile-store.ts"; type SecretBackend = "macos" | "file"; @@ -72,13 +71,9 @@ function secretBackend(): SecretBackend { return "file"; } -function resolveSecretAccount(account: string, profileName?: string): string { - if (profileName) { - assertSafeProfileName(profileName); - return `profile/${profileName}/${account}`; - } - const profile = resolveProfileName(getCliContext().profile ?? process.env.ALTERTABLE_PROFILE); - return `profile/${profile}/${account}`; +function resolveSecretKey(key: string, profileName: string): string { + assertSafeProfileName(profileName); + return `profile/${profileName}/${key}`; } export type SecretSetOptions = { @@ -120,10 +115,10 @@ function secretSetMacos(storageAccount: string, value: string): void { export function secretSet( account: string, value: string, - profileName?: string, + profileName: string, options?: SecretSetOptions, ): void { - const storageAccount = resolveSecretAccount(account, profileName); + const storageAccount = resolveSecretKey(account, profileName); const backend = secretBackend(); if (backend === "macos" && !options?.fromArgv) { secretSetMacos(storageAccount, value); @@ -138,13 +133,13 @@ export function secretSet( } } -export function secretGet(account: string, profileName?: string): string { - const storageAccount = resolveSecretAccount(account, profileName); +export function secretGet(key: string, profileName: string): string { + const secretKey = resolveSecretKey(key, profileName); const backend = secretBackend(); if (backend === "macos") { const result = runSpawnSync( "security", - ["find-generic-password", "-s", "altertable", "-a", storageAccount, "-w"], + ["find-generic-password", "-s", "altertable", "-a", secretKey, "-w"], { encoding: "utf8" }, ); const stdout = result.stdout; @@ -153,77 +148,73 @@ export function secretGet(account: string, profileName?: string): string { return keychainValue; } requireSafeCredentialsFile(); - return kvGet(credentialsFile(), storageAccount); + return kvGet(credentialsFile(), secretKey); } requireSafeCredentialsFile(); - return kvGet(credentialsFile(), storageAccount); + return kvGet(credentialsFile(), secretKey); } -export function secretExists(account: string, profileName?: string): boolean { - const storageAccount = resolveSecretAccount(account, profileName); +export function secretExists(key: string, profileName: string): boolean { + const secretKey = resolveSecretKey(key, profileName); const backend = secretBackend(); if (backend === "macos") { const result = runSpawnSync( "security", - ["find-generic-password", "-s", "altertable", "-a", storageAccount], + ["find-generic-password", "-s", "altertable", "-a", secretKey], { stdio: "ignore" }, ); if (result.status === 0) { return true; } requireSafeCredentialsFile(); - return kvGet(credentialsFile(), storageAccount) !== ""; + return kvGet(credentialsFile(), secretKey) !== ""; } requireSafeCredentialsFile(); - return kvGet(credentialsFile(), storageAccount) !== ""; + return kvGet(credentialsFile(), secretKey) !== ""; } -export function secretDelete(account: string, profileName?: string): void { - const storageAccount = resolveSecretAccount(account, profileName); +export function secretDelete(key: string, profileName: string): void { + const secretKey = resolveSecretKey(key, profileName); const backend = secretBackend(); if (backend === "macos") { - runSpawnSync( - "security", - ["delete-generic-password", "-s", "altertable", "-a", storageAccount], - { - stdio: "ignore", - }, - ); - kvUnset(credentialsFile(), storageAccount); + runSpawnSync("security", ["delete-generic-password", "-s", "altertable", "-a", secretKey], { + stdio: "ignore", + }); + kvUnset(credentialsFile(), secretKey); return; } - kvUnset(credentialsFile(), storageAccount); + kvUnset(credentialsFile(), secretKey); } export function moveProfileSecrets( sourceProfile: string, targetProfile: string, - accounts: readonly string[], + keys: readonly string[], ): void { - const prepared: Array<{ account: string; targetValue: string }> = []; + const prepared: Array<{ key: string; targetValue: string }> = []; try { - for (const account of accounts) { - const sourceValue = secretGet(account, sourceProfile); + for (const key of keys) { + const sourceValue = secretGet(key, sourceProfile); if (sourceValue.length === 0) { continue; } prepared.push({ - account, - targetValue: secretGet(account, targetProfile), + key, + targetValue: secretGet(key, targetProfile), }); - secretSet(account, sourceValue, targetProfile); + secretSet(key, sourceValue, targetProfile); } } catch (error) { for (const entry of prepared.toReversed()) { try { if (entry.targetValue.length > 0) { - secretSet(entry.account, entry.targetValue, targetProfile); + secretSet(entry.key, entry.targetValue, targetProfile); } else { - secretDelete(entry.account, targetProfile); + secretDelete(entry.key, targetProfile); } } catch { // Best-effort rollback; preserve the original failure for the caller. @@ -232,8 +223,8 @@ export function moveProfileSecrets( throw error; } - for (const { account } of prepared) { - secretDelete(account, sourceProfile); + for (const { key } of prepared) { + secretDelete(key, sourceProfile); } } diff --git a/cli/src/lib/updater.ts b/cli/src/lib/updater.ts index c9f93e0..9177cd0 100644 --- a/cli/src/lib/updater.ts +++ b/cli/src/lib/updater.ts @@ -7,7 +7,7 @@ import { spawnSync } from "node:child_process"; import type { CliContext } from "@/context.ts"; import { isJsonOutput } from "@/context.ts"; import { USER_AGENT, VERSION } from "@/version.ts"; -import { configDir, configGet, configSet } from "@/lib/config.ts"; +import { configDir, configGetGlobal, configSetGlobal } from "@/lib/config.ts"; import { urlencode } from "@/lib/encode.ts"; import { CliError, HttpError, NetworkError } from "@/lib/errors.ts"; import { hasObjectKey } from "@/lib/object.ts"; @@ -286,12 +286,14 @@ export function parseUpdateCheckInterval(value: string): UpdateCheckInterval | u } export function getUpdateCheckInterval(): UpdateCheckInterval { - const fromConfig = parseUpdateCheckInterval(configGet(UpdaterConfig.configKeys.checkInterval)); + const fromConfig = parseUpdateCheckInterval( + configGetGlobal(UpdaterConfig.configKeys.checkInterval), + ); return fromConfig ?? UpdaterConfig.defaults.checkInterval; } export function setUpdateCheckInterval(interval: UpdateCheckInterval): void { - configSet(UpdaterConfig.configKeys.checkInterval, interval); + configSetGlobal(UpdaterConfig.configKeys.checkInterval, interval); } function parseUpdateSource(value: string | undefined): UpdateSource | undefined { diff --git a/cli/tests/active-context.test.ts b/cli/tests/active-context.test.ts index 1141724..5bc8d1e 100644 --- a/cli/tests/active-context.test.ts +++ b/cli/tests/active-context.test.ts @@ -20,6 +20,8 @@ import { import { configureClearAll, configureRunSet } from "@/lib/profile-configure-core.ts"; import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; +const profileName = "default"; + let testHome = ""; function runInTestHome(run: () => T | Promise): T | Promise { @@ -41,7 +43,7 @@ beforeEach(() => { afterEach(async () => { await runInTestHome(async () => { - configureClearAll(); + configureClearAll(profileName); }); rmSync(testHome, { recursive: true, force: true }); delete process.env.ALTERTABLE_CONFIG_HOME; @@ -51,8 +53,8 @@ afterEach(async () => { describe("active context formatters", () => { test("summary shows profile and credential gaps when unconfigured", async () => { await runInTestHome(async () => { - configureClearAll(); - const summary = formatActiveContextSummary(buildActiveContext()); + configureClearAll(profileName); + const summary = formatActiveContextSummary(buildActiveContext(profileName)); expect(summary).not.toContain("CONTEXT"); expect(summary).toContain("PROFILE"); expect(summary).toMatch(/\n PROFILE/); @@ -64,7 +66,7 @@ describe("active context formatters", () => { test("details include authenticated identity when present", async () => { await runInTestHome(async () => { await configureRunSet({ apiKey: "atm_test", env: "production" }); - const context = buildActiveContext(); + const context = buildActiveContext(profileName); const details = formatActiveContextDetails( withAuthenticatedIdentity(context, { principal: { type: "User", name: "Jane Doe", email: "jane@x.io" }, @@ -84,7 +86,7 @@ describe("active context formatters", () => { await runInTestHome(async () => { await configureRunSet({ apiKey: "atm_test", env: "production" }); - const view = buildActiveContextSummaryView(buildActiveContext()); + const view = buildActiveContextSummaryView(buildActiveContext(profileName)); const [summarySection] = view.sections; const [summaryBlock] = summarySection?.blocks ?? []; @@ -112,7 +114,7 @@ describe("active context formatters", () => { await runInTestHome(async () => { await configureRunSet({ apiKey: "atm_test", env: "production" }); const view = buildActiveContextDetailsView( - withAuthenticatedIdentity(buildActiveContext(), { + withAuthenticatedIdentity(buildActiveContext(profileName), { principal: { type: "User", name: "Alex Doe", email: "alex@example.com" }, organization: { name: "Acme", slug: "acme" }, }), @@ -136,7 +138,7 @@ describe("active context formatters", () => { test("json output keeps principal for scripting compatibility", async () => { await runInTestHome(async () => { await configureRunSet({ apiKey: "atm_test", env: "production" }); - const context = withAuthenticatedIdentity(buildActiveContext(), { + const context = withAuthenticatedIdentity(buildActiveContext(profileName), { principal: { type: "User", name: "Jane Doe", email: "jane@x.io" }, organization: { name: "Acme", slug: "acme" }, }); @@ -149,7 +151,7 @@ describe("active context formatters", () => { test("tryFormatActiveContextSummary renders an empty profile", async () => { await runInTestHome(async () => { - configureClearAll(); + configureClearAll(profileName); const summary = tryFormatActiveContextSummary("staging"); expect(summary).toContain("PROFILE"); expect(summary).toContain("staging"); diff --git a/cli/tests/auth.test.ts b/cli/tests/auth.test.ts index 02e6668..910a9e1 100644 --- a/cli/tests/auth.test.ts +++ b/cli/tests/auth.test.ts @@ -12,6 +12,7 @@ import { ConfigurationError } from "@/lib/errors.ts"; import { resetSecretWarningsForTests, secretSet } from "@/lib/secrets.ts"; let testHome = ""; +const profileName = "default"; beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "altertable-auth-test-")); @@ -33,34 +34,34 @@ afterEach(() => { describe("auth", () => { test("getLakehouseAuthHeader throws ConfigurationError when credentials are missing", () => { - expect(() => getLakehouseAuthHeader()).toThrow(ConfigurationError); - expect(() => getLakehouseAuthHeader()).toThrow( + expect(() => getLakehouseAuthHeader(profileName)).toThrow(ConfigurationError); + expect(() => getLakehouseAuthHeader(profileName)).toThrow( "No credentials. Run 'altertable login', 'altertable profile --configure', or set ALTERTABLE_LAKEHOUSE_USERNAME/PASSWORD (or ALTERTABLE_BASIC_AUTH_TOKEN).", ); }); test("getManagementAuthHeader throws ConfigurationError when API key is missing", () => { - expect(() => getManagementAuthHeader()).toThrow(ConfigurationError); - expect(() => getManagementAuthHeader()).toThrow( + expect(() => getManagementAuthHeader(profileName)).toThrow(ConfigurationError); + expect(() => getManagementAuthHeader(profileName)).toThrow( "No management credentials. Run 'altertable login', 'altertable profile --configure --api-key atm_xxx --env ', or set ALTERTABLE_API_KEY.", ); }); test("requireManagementEnv throws ConfigurationError when environment is missing", () => { - expect(() => requireManagementEnv()).toThrow(ConfigurationError); - expect(() => requireManagementEnv()).toThrow( + expect(() => requireManagementEnv(profileName)).toThrow(ConfigurationError); + expect(() => requireManagementEnv(profileName)).toThrow( "No environment set. Run 'altertable profile --configure --api-key atm_xxx --env ' or set ALTERTABLE_ENV.", ); }); test("resolves lakehouse and management credentials independently", () => { - configSet("user", "alice"); - secretSet("lakehouse/password", "lakehouse-secret"); - secretSet("api-key", "atm_test"); + configSet("user", "alice", profileName); + secretSet("lakehouse/password", "lakehouse-secret", profileName); + secretSet("api-key", "atm_test", profileName); const lakehouseToken = Buffer.from("alice:lakehouse-secret").toString("base64"); - expect(getLakehouseAuthHeader()).toBe(`Authorization: Basic ${lakehouseToken}`); - expect(getManagementAuthHeader()).toBe("Authorization: Bearer atm_test"); + expect(getLakehouseAuthHeader(profileName)).toBe(`Authorization: Basic ${lakehouseToken}`); + expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer atm_test"); }); test("resolves lakehouse username/password from environment variables", () => { @@ -68,26 +69,26 @@ describe("auth", () => { process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; const lakehouseToken = Buffer.from("env-user:env-pass").toString("base64"); - expect(getLakehouseAuthHeader()).toBe(`Authorization: Basic ${lakehouseToken}`); + expect(getLakehouseAuthHeader(profileName)).toBe(`Authorization: Basic ${lakehouseToken}`); }); test("prefers environment lakehouse credentials over stored credentials", () => { - configSet("user", "alice"); - secretSet("lakehouse/password", "lakehouse-secret"); + configSet("user", "alice", profileName); + secretSet("lakehouse/password", "lakehouse-secret", profileName); process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; const lakehouseToken = Buffer.from("env-user:env-pass").toString("base64"); - expect(getLakehouseAuthHeader()).toBe(`Authorization: Basic ${lakehouseToken}`); + expect(getLakehouseAuthHeader(profileName)).toBe(`Authorization: Basic ${lakehouseToken}`); }); test("prefers environment Basic token over username/password credentials", () => { process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "env-basic-token"; process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; - configSet("user", "alice"); - secretSet("lakehouse/password", "lakehouse-secret"); + configSet("user", "alice", profileName); + secretSet("lakehouse/password", "lakehouse-secret", profileName); - expect(getLakehouseAuthHeader()).toBe("Authorization: Basic env-basic-token"); + expect(getLakehouseAuthHeader(profileName)).toBe("Authorization: Basic env-basic-token"); }); }); diff --git a/cli/tests/citty-usage.test.ts b/cli/tests/citty-usage.test.ts index 2526444..66fb0c2 100644 --- a/cli/tests/citty-usage.test.ts +++ b/cli/tests/citty-usage.test.ts @@ -88,7 +88,7 @@ describe("renderAltertableUsage active context", () => { runWithCliRuntime(createCliRuntime(getBootstrapCliContext()), () => { process.env.ALTERTABLE_CONFIG_HOME = testHome; process.env.ALTERTABLE_SECRET_BACKEND = "file"; - configureClearAll(); + configureClearAll("default"); }); rmSync(testHome, { recursive: true, force: true }); delete process.env.ALTERTABLE_CONFIG_HOME; diff --git a/cli/tests/cli-session.test.ts b/cli/tests/cli-session.test.ts index ad8266e..888daa2 100644 --- a/cli/tests/cli-session.test.ts +++ b/cli/tests/cli-session.test.ts @@ -25,7 +25,7 @@ afterEach(() => { }); describe("createCliSession", () => { - test("builds profile and API bases from config", async () => { + test("pins the resolved working profile", async () => { await configureRunSet({ apiKey: "atm_test", env: "staging", @@ -37,17 +37,5 @@ describe("createCliSession", () => { const session = runWithCliRuntime(runtime, () => createCliSession(runtime.context)); expect(session.profile).toBe("default"); - expect(session.apiBase).toBe("http://localhost:15000"); - expect(session.managementApiBase).toBe("http://localhost:13000/rest/v1"); - expect(session.managementEnv).toBe("staging"); - expect(session.managementAuthHeader).toBe("Authorization: Bearer atm_test"); - }); - - test("omits auth headers when credentials are missing", () => { - const runtime = createCliRuntime({ debug: false, json: false, agent: false }); - const session = runWithCliRuntime(runtime, () => createCliSession(runtime.context)); - - expect(session.lakehouseAuthHeader).toBeUndefined(); - expect(session.managementAuthHeader).toBeUndefined(); }); }); diff --git a/cli/tests/config.test.ts b/cli/tests/config.test.ts index 5ba6e20..e79871d 100644 --- a/cli/tests/config.test.ts +++ b/cli/tests/config.test.ts @@ -6,7 +6,7 @@ import { configDir, configGet, configSet, - configUnset, + configSetGlobal, getQueryDefaultMaxColumnWidth, getQueryDefaultLayout, getQueryDefaultPager, @@ -29,6 +29,7 @@ import { setCliContext } from "@/context.ts"; import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; let testHome = ""; +const profileName = "default"; beforeEach(() => { testHome = mkdtempSync(join(tmpdir(), "altertable-cli-test-")); @@ -54,8 +55,7 @@ describe("config", () => { expect(kvGet(filePath, "user")).toBe("alice"); kvSet(filePath, "user", "bob"); expect(kvGet(filePath, "user")).toBe("bob"); - configUnset("missing"); - expect(configGet("missing")).toBe(""); + expect(configGet("missing", profileName)).toBe(""); }); test("kvSet writes temp files with mode 0600", () => { @@ -66,69 +66,69 @@ describe("config", () => { }); test("resolve api bases with defaults", () => { - expect(resolveApiBase()).toBe("https://api.altertable.ai"); - expect(resolveManagementApiBase()).toBe("https://app.altertable.ai/rest/v1"); - configSet("api_base", "http://localhost:1111/"); - configSet("management_api_base", "http://localhost:13000/"); - expect(resolveApiBase()).toBe("http://localhost:1111"); - expect(resolveManagementApiBase()).toBe("http://localhost:13000/rest/v1"); + expect(resolveApiBase(profileName)).toBe("https://api.altertable.ai"); + expect(resolveManagementApiBase(profileName)).toBe("https://app.altertable.ai/rest/v1"); + configSet("api_base", "http://localhost:1111/", profileName); + configSet("management_api_base", "http://localhost:13000/", profileName); + expect(resolveApiBase(profileName)).toBe("http://localhost:1111"); + expect(resolveManagementApiBase(profileName)).toBe("http://localhost:13000/rest/v1"); }); test("env vars beat stored config", () => { - configSet("api_base", "http://localhost:1111"); + configSet("api_base", "http://localhost:1111", profileName); process.env.ALTERTABLE_API_BASE = "http://localhost:2222"; - expect(resolveApiBase()).toBe("http://localhost:2222"); + expect(resolveApiBase(profileName)).toBe("http://localhost:2222"); }); test("resolveApiBase rejects insecure env override without allow flag", () => { process.env.ALTERTABLE_API_BASE = "http://192.168.1.5"; - expect(() => resolveApiBase()).toThrow(CliError); + expect(() => resolveApiBase(profileName)).toThrow(CliError); delete process.env.ALTERTABLE_API_BASE; }); test("resolveApiBase allows insecure env override with ALTERTABLE_ALLOW_INSECURE_HTTP", () => { process.env.ALTERTABLE_API_BASE = "http://192.168.1.5"; process.env.ALTERTABLE_ALLOW_INSECURE_HTTP = "true"; - expect(resolveApiBase()).toBe("http://192.168.1.5"); + expect(resolveApiBase(profileName)).toBe("http://192.168.1.5"); delete process.env.ALTERTABLE_API_BASE; delete process.env.ALTERTABLE_ALLOW_INSECURE_HTTP; }); test("reads query display defaults from config", () => { - configSet("query_max_width", "48"); - configSet("query_layout", "line"); + configSetGlobal("query_max_width", "48"); + configSetGlobal("query_layout", "line"); expect(getQueryDefaultMaxColumnWidth()).toBe(48); expect(getQueryDefaultLayout()).toBe("line"); }); test("ignores invalid query config values", () => { - configSet("query_max_width", "4"); - configSet("query_layout", "invalid"); + configSetGlobal("query_max_width", "4"); + configSetGlobal("query_layout", "invalid"); expect(getQueryDefaultMaxColumnWidth()).toBeUndefined(); expect(getQueryDefaultLayout()).toBeUndefined(); }); test("reads query_pager config", () => { - configSet("query_pager", "always"); + configSetGlobal("query_pager", "always"); expect(getQueryDefaultPager()).toBe("always"); }); test("ignores unknown query_pager values", () => { - configSet("query_pager", "sometimes"); + configSetGlobal("query_pager", "sometimes"); expect(getQueryDefaultPager()).toBeUndefined(); }); test("query_pager respects ALTERTABLE_CONFIG_HOME", () => { - configSet("query_pager", "never"); + configSetGlobal("query_pager", "never"); expect(getQueryDefaultPager()).toBe("never"); }); }); describe("secrets", () => { test("stores and reads file-backed secrets", () => { - secretSet("api-key", "atm_test"); - expect(secretGet("api-key")).toBe("atm_test"); - expect(secretExists("api-key")).toBe(true); + secretSet("api-key", "atm_test", profileName); + expect(secretGet("api-key", profileName)).toBe("atm_test"); + expect(secretExists("api-key", profileName)).toBe(true); }); }); @@ -137,10 +137,10 @@ describe("profile --configure credential accumulation", () => { await configureRunSet({ apiKey: "atm_test", env: "development" }); await configureRunSet({ user: "alice", password: "lakehouse-secret" }); - expect(secretGet("api-key")).toBe("atm_test"); - expect(configGet("api_key_env")).toBe("development"); - expect(secretGet("lakehouse/password")).toBe("lakehouse-secret"); - expect(configGet("user")).toBe("alice"); + expect(secretGet("api-key", profileName)).toBe("atm_test"); + expect(configGet("api_key_env", profileName)).toBe("development"); + expect(secretGet("lakehouse/password", profileName)).toBe("lakehouse-secret"); + expect(configGet("user", profileName)).toBe("alice"); }); test("accumulates both planes across separate configure invocations", async () => { @@ -153,10 +153,10 @@ describe("profile --configure credential accumulation", () => { env: "development", }); - expect(secretGet("api-key")).toBe("atm_test"); - expect(configGet("api_key_env")).toBe("development"); - expect(secretGet("lakehouse/password")).toBe("lakehouse-secret"); - expect(configGet("user")).toBe("alice"); + expect(secretGet("api-key", profileName)).toBe("atm_test"); + expect(configGet("api_key_env", profileName)).toBe("development"); + expect(secretGet("lakehouse/password", profileName)).toBe("lakehouse-secret"); + expect(configGet("user", profileName)).toBe("alice"); }); }); @@ -214,7 +214,7 @@ describe("configureRunClear", () => { describe("config dir", () => { test("uses ALTERTABLE_CONFIG_HOME", () => { expect(configDir()).toBe(testHome); - configSet("user", "x"); + configSet("user", "x", profileName); expect(existsSync(join(testHome, "profiles", "default", "config"))).toBe(true); expect(readFileSync(join(testHome, "profiles", "default", "config"), "utf8")).toContain( "user=x", @@ -277,10 +277,10 @@ describe("profile --configure argv secrets", () => { try { process.env.ALTERTABLE_SECRET_BACKEND = "keychain"; - secretSet("lakehouse/password", "test-password-value", undefined, { fromArgv: true }); + secretSet("lakehouse/password", "test-password-value", profileName, { fromArgv: true }); const keychainWrites = spawnCalls.filter((args) => args.includes("add-generic-password")); expect(keychainWrites).toHaveLength(0); - expect(secretGet("lakehouse/password")).toBe("test-password-value"); + expect(secretGet("lakehouse/password", profileName)).toBe("test-password-value"); } finally { if (platformDescriptor) { Object.defineProperty(process, "platform", platformDescriptor); diff --git a/cli/tests/configure-credential-status.test.ts b/cli/tests/configure-credential-status.test.ts index aaf1d26..fbb41ee 100644 --- a/cli/tests/configure-credential-status.test.ts +++ b/cli/tests/configure-credential-status.test.ts @@ -16,6 +16,8 @@ import { import { secretSet } from "@/lib/secrets.ts"; import { configSet } from "@/lib/config.ts"; +const profileName = "default"; + let testHome = ""; beforeEach(() => { @@ -37,36 +39,42 @@ afterEach(() => { describe("configureCredentialStatus", () => { test("detects stored and environment credentials", () => { - expect(configureCredentialStatus()).toEqual({ hasManagement: false, hasLakehouse: false }); + expect(configureCredentialStatus(profileName)).toEqual({ + hasManagement: false, + hasLakehouse: false, + }); - secretSet("api-key", "atm_test"); - expect(configureCredentialStatus().hasManagement).toBe(true); + secretSet("api-key", "atm_test", profileName); + expect(configureCredentialStatus(profileName).hasManagement).toBe(true); process.env.ALTERTABLE_API_KEY = "atm_env"; - expect(configureCredentialStatus().hasManagement).toBe(true); + expect(configureCredentialStatus(profileName).hasManagement).toBe(true); - secretSet("lakehouse/password", "secret"); - configSet("user", "alice"); - expect(configureCredentialStatus().hasLakehouse).toBe(true); + secretSet("lakehouse/password", "secret", profileName); + configSet("user", "alice", profileName); + expect(configureCredentialStatus(profileName).hasLakehouse).toBe(true); }); test("detects environment lakehouse credentials without stored secrets", () => { process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; - expect(configureCredentialStatus()).toEqual({ hasManagement: false, hasLakehouse: true }); + expect(configureCredentialStatus(profileName)).toEqual({ + hasManagement: false, + hasLakehouse: true, + }); }); test("plane status detail reflects environment credentials", () => { process.env.ALTERTABLE_API_KEY = "atm_env"; - expect(managementPlaneStatusDetail()).toBe("via ALTERTABLE_API_KEY"); + expect(managementPlaneStatusDetail(profileName)).toBe("via ALTERTABLE_API_KEY"); process.env.ALTERTABLE_BASIC_AUTH_TOKEN = "dG9rZW4="; - expect(lakehousePlaneStatusDetail()).toBe("via ALTERTABLE_BASIC_AUTH_TOKEN"); + expect(lakehousePlaneStatusDetail(profileName)).toBe("via ALTERTABLE_BASIC_AUTH_TOKEN"); delete process.env.ALTERTABLE_BASIC_AUTH_TOKEN; process.env.ALTERTABLE_LAKEHOUSE_USERNAME = "env-user"; process.env.ALTERTABLE_LAKEHOUSE_PASSWORD = "env-pass"; - expect(lakehousePlaneStatusDetail()).toBe("env-user"); + expect(lakehousePlaneStatusDetail(profileName)).toBe("env-user"); }); }); @@ -75,18 +83,18 @@ describe("formatConfigureAuthenticationLines", () => { process.env.ALTERTABLE_API_KEY = "atm_env"; process.env.ALTERTABLE_ENV = "staging"; - const lines = formatConfigureAuthenticationLines({ planes: ["management"] }); + const lines = formatConfigureAuthenticationLines(profileName, { planes: ["management"] }); expect(lines.join("\n")).toContain("ALTERTABLE_API_KEY"); expect(lines.join("\n")).toContain("staging"); }); test("session summary includes only configured planes", () => { - secretSet("api-key", "atm_test"); - configSet("api_key_env", "production"); - secretSet("lakehouse/password", "secret"); - configSet("user", "alice"); + secretSet("api-key", "atm_test", profileName); + configSet("api_key_env", "production", profileName); + secretSet("lakehouse/password", "secret", profileName); + configSet("user", "alice", profileName); - const summary = formatConfigureSessionSummary(["management"]); + const summary = formatConfigureSessionSummary(profileName, ["management"]); expect(summary.join("\n")).toContain("management API key"); expect(summary.join("\n")).not.toContain("lakehouse"); }); @@ -94,21 +102,23 @@ describe("formatConfigureAuthenticationLines", () => { describe("OAuth login status", () => { function loginViaOAuth(): void { - secretSet("oauth/access-token", "tok"); - configSet("oauth_expiry", String(Date.now() + 3_600_000)); - configSet("api_key_env", "production"); + secretSet("oauth/access-token", "tok", profileName); + configSet("oauth_expiry", String(Date.now() + 3_600_000), profileName); + configSet("api_key_env", "production", profileName); } test("counts as a management credential", () => { loginViaOAuth(); - expect(configureCredentialStatus().hasManagement).toBe(true); + expect(configureCredentialStatus(profileName).hasManagement).toBe(true); }); test("shows browser login (OAuth), not the api-key lines", () => { loginViaOAuth(); - configSet("organization_slug", "altertable"); - configSet("organization_name", "Altertable"); - const text = formatConfigureAuthenticationLines({ planes: ["management"] }).join("\n"); + configSet("organization_slug", "altertable", profileName); + configSet("organization_name", "Altertable", profileName); + const text = formatConfigureAuthenticationLines(profileName, { planes: ["management"] }).join( + "\n", + ); expect(text).toContain("browser login (OAuth)"); expect(text).toContain("production"); expect(text).toContain("Organization:"); @@ -118,13 +128,15 @@ describe("OAuth login status", () => { }); test("OAuth login shadows a leftover stored api key", () => { - secretSet("api-key", "atm_leftover"); + secretSet("api-key", "atm_leftover", profileName); loginViaOAuth(); - const text = formatConfigureAuthenticationLines({ planes: ["management"] }).join("\n"); + const text = formatConfigureAuthenticationLines(profileName, { planes: ["management"] }).join( + "\n", + ); expect(text).toContain("browser login (OAuth)"); expect(text).not.toContain("api key:"); - expect(managementPlaneStatusDetail()).toBe("OAuth login (production)"); - expect(buildConfigureShowData().credentials.management).toMatchObject({ + expect(managementPlaneStatusDetail(profileName)).toBe("OAuth login (production)"); + expect(buildConfigureShowData(profileName).credentials.management).toMatchObject({ mechanism: "management_oauth", oauth: "set", }); @@ -133,21 +145,23 @@ describe("OAuth login status", () => { test("ALTERTABLE_API_KEY env still takes precedence over OAuth", () => { loginViaOAuth(); process.env.ALTERTABLE_API_KEY = "atm_env"; - const text = formatConfigureAuthenticationLines({ planes: ["management"] }).join("\n"); + const text = formatConfigureAuthenticationLines(profileName, { planes: ["management"] }).join( + "\n", + ); expect(text).toContain("ALTERTABLE_API_KEY"); expect(text).not.toContain("browser login (OAuth)"); - expect(managementPlaneStatusDetail()).toBe("via ALTERTABLE_API_KEY"); + expect(managementPlaneStatusDetail(profileName)).toBe("via ALTERTABLE_API_KEY"); }); }); describe("buildConfigureShowData", () => { test("returns structured credential status without secrets", () => { - secretSet("api-key", "atm_test"); - configSet("api_key_env", "production"); - secretSet("lakehouse/password", "lakehouse-pass"); - configSet("user", "alice"); + secretSet("api-key", "atm_test", profileName); + configSet("api_key_env", "production", profileName); + secretSet("lakehouse/password", "lakehouse-pass", profileName); + configSet("user", "alice", profileName); - const data = buildConfigureShowData(); + const data = buildConfigureShowData(profileName); expect(data.credentials.management).toEqual({ configured: true, mechanism: "management_api_key", @@ -167,12 +181,12 @@ describe("buildConfigureShowData", () => { }); test("includes environment overrides", () => { - secretSet("api-key", "atm_test"); - configSet("api_key_env", "production"); + secretSet("api-key", "atm_test", profileName); + configSet("api_key_env", "production", profileName); process.env.ALTERTABLE_ENV = "staging"; process.env.ALTERTABLE_API_KEY = "atm_override"; - const data = buildConfigureShowData(); + const data = buildConfigureShowData(profileName); expect(data.overrides).toEqual({ environment: "staging", stored_environment: "production", @@ -181,11 +195,11 @@ describe("buildConfigureShowData", () => { expect(JSON.stringify(data)).not.toContain("atm_override"); }); - test("buildConfigureShowView separates summary and authentication sections", () => { - secretSet("api-key", "atm_test"); - configSet("api_key_env", "production"); + test("configure show view separates summary and authentication sections", () => { + secretSet("api-key", "atm_test", profileName); + configSet("api_key_env", "production", profileName); - const view = buildConfigureShowView(buildConfigureShowData()); + const view = buildConfigureShowView(buildConfigureShowData(profileName)); const [summary, authentication] = view.sections; const [summaryRows] = summary?.blocks ?? []; const [authenticationRows] = authentication?.blocks ?? []; diff --git a/cli/tests/configure-data-plane-url.test.ts b/cli/tests/configure-data-plane-url.test.ts index 2314cd3..93f7655 100644 --- a/cli/tests/configure-data-plane-url.test.ts +++ b/cli/tests/configure-data-plane-url.test.ts @@ -9,6 +9,7 @@ import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; import { secretGet } from "@/lib/secrets.ts"; let testHome = ""; +const profileName = "default"; function runInTestHome(run: () => T | Promise): T | Promise { process.env.ALTERTABLE_CONFIG_HOME = testHome; @@ -23,7 +24,7 @@ beforeEach(() => { afterEach(async () => { await runInTestHome(async () => { - configureClearAll(); + configureClearAll(profileName); }); rmSync(testHome, { recursive: true, force: true }); delete process.env.ALTERTABLE_CONFIG_HOME; @@ -37,10 +38,10 @@ describe("profile --configure --data-plane-url alone", () => { await configureRunSet({ dataPlaneUrl: "https://data.example.com" }); - expect(configGet("api_base")).toBe("https://data.example.com"); - expect(secretGet("api-key")).toBe("atm_test"); - expect(configGet("api_key_env")).toBe("staging"); - expect(configGet("management_api_base")).toBe(""); + expect(configGet("api_base", profileName)).toBe("https://data.example.com"); + expect(secretGet("api-key", profileName)).toBe("atm_test"); + expect(configGet("api_key_env", profileName)).toBe("staging"); + expect(configGet("management_api_base", profileName)).toBe(""); }); }); diff --git a/cli/tests/lakehouse-provision.test.ts b/cli/tests/lakehouse-provision.test.ts index 552a5fc..dd52937 100644 --- a/cli/tests/lakehouse-provision.test.ts +++ b/cli/tests/lakehouse-provision.test.ts @@ -10,6 +10,8 @@ import { getCliRuntime, refreshCliRuntimeContext } from "@/lib/runtime.ts"; import { secretGet, secretSet } from "@/lib/secrets.ts"; import { USER_AGENT } from "@/version.ts"; +const profileName = "default"; + let testHome = ""; let mockFile = ""; let logFile = ""; @@ -89,8 +91,8 @@ describe("lakehouse credential auto-provisioning", () => { expect(response).toBe("ok"); const expectedToken = Buffer.from("cli-user:cli-pass").toString("base64"); - expect(secretGet("lakehouse/basic-token")).toBe(expectedToken); - expect(configGet("lakehouse_credential_expiry")).toBe( + expect(secretGet("lakehouse/basic-token", profileName)).toBe(expectedToken); + expect(configGet("lakehouse_credential_expiry", profileName)).toBe( String(Date.parse("2100-01-01T00:00:00Z")), ); const logContent = readFileSync(logFile, "utf8"); @@ -102,21 +104,29 @@ describe("lakehouse credential auto-provisioning", () => { test("re-provisions when the stored credential is expired", async () => { writeMocks(); - secretSet("lakehouse/basic-token", Buffer.from("old-user:old-pass").toString("base64")); - configSet("lakehouse_credential_expiry", String(Date.now() - 1000)); + secretSet( + "lakehouse/basic-token", + Buffer.from("old-user:old-pass").toString("base64"), + profileName, + ); + configSet("lakehouse_credential_expiry", String(Date.now() - 1000), profileName); const response = await sendLakehouseRequest(); expect(response).toBe("ok"); - expect(secretGet("lakehouse/basic-token")).toBe( + expect(secretGet("lakehouse/basic-token", profileName)).toBe( Buffer.from("cli-user:cli-pass").toString("base64"), ); }); test("aborts on a corrupt stored expiry without provisioning or sending anything", async () => { writeMocks(); - secretSet("lakehouse/basic-token", Buffer.from("old-user:old-pass").toString("base64")); - configSet("lakehouse_credential_expiry", "not-a-number"); + secretSet( + "lakehouse/basic-token", + Buffer.from("old-user:old-pass").toString("base64"), + profileName, + ); + configSet("lakehouse_credential_expiry", "not-a-number", profileName); await expectRejection( sendLakehouseRequest(), @@ -135,12 +145,16 @@ describe("lakehouse credential auto-provisioning", () => { test("uses valid stored credentials without touching the management plane", async () => { writeMocks(); - secretSet("lakehouse/basic-token", Buffer.from("old-user:old-pass").toString("base64")); + secretSet( + "lakehouse/basic-token", + Buffer.from("old-user:old-pass").toString("base64"), + profileName, + ); const response = await sendLakehouseRequest(); expect(response).toBe("ok"); - expect(secretGet("lakehouse/basic-token")).toBe( + expect(secretGet("lakehouse/basic-token", profileName)).toBe( Buffer.from("old-user:old-pass").toString("base64"), ); expect(readFileSync(logFile, "utf8")).not.toContain("/whoami"); @@ -151,8 +165,8 @@ describe("lakehouse credential recovery after 401", () => { const OLD_TOKEN = Buffer.from("old-user:old-pass").toString("base64"); function storeProvisionedCredential(): void { - secretSet("lakehouse/basic-token", OLD_TOKEN); - configSet("lakehouse_credential_expiry", String(Date.now() + 60 * 60 * 1000)); + secretSet("lakehouse/basic-token", OLD_TOKEN, profileName); + configSet("lakehouse_credential_expiry", String(Date.now() + 60 * 60 * 1000), profileName); } test("re-provisions and retries when the server rejects a provisioned credential", async () => { @@ -180,7 +194,7 @@ describe("lakehouse credential recovery after 401", () => { const response = await sendLakehouseRequest(); expect(response).toBe("ok"); - expect(secretGet("lakehouse/basic-token")).toBe( + expect(secretGet("lakehouse/basic-token", profileName)).toBe( Buffer.from("cli-user:cli-pass").toString("base64"), ); }); @@ -224,7 +238,7 @@ describe("lakehouse credential recovery after 401", () => { }); test("does not re-provision on 401 for manually configured credentials", async () => { - secretSet("lakehouse/basic-token", OLD_TOKEN); + secretSet("lakehouse/basic-token", OLD_TOKEN, profileName); writeFileSync( mockFile, diff --git a/cli/tests/management-auth.test.ts b/cli/tests/management-auth.test.ts index 38d02a4..15a61e3 100644 --- a/cli/tests/management-auth.test.ts +++ b/cli/tests/management-auth.test.ts @@ -8,6 +8,8 @@ import { storeOAuthTokens } from "@/lib/oauth-profile.ts"; import { setCliContext } from "@/context.ts"; import { ConfigurationError } from "@/lib/errors.ts"; +const profileName = "default"; + let testHome = ""; beforeEach(() => { @@ -27,24 +29,27 @@ afterEach(() => { describe("getManagementAuthHeader precedence", () => { test("throws when nothing is configured", () => { - expect(() => getManagementAuthHeader()).toThrow(ConfigurationError); + expect(() => getManagementAuthHeader(profileName)).toThrow(ConfigurationError); }); test("uses the stored api-key when it is the only credential", () => { - secretSet("api-key", "atm_stored"); - expect(getManagementAuthHeader()).toBe("Authorization: Bearer atm_stored"); + secretSet("api-key", "atm_stored", profileName); + expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer atm_stored"); }); test("OAuth access token beats the stored api-key", () => { - secretSet("api-key", "atm_stored"); - storeOAuthTokens({ access_token: "oauth_tok", refresh_token: "r", expires_in: 3600 }); - expect(getManagementAuthHeader()).toBe("Authorization: Bearer oauth_tok"); + secretSet("api-key", "atm_stored", profileName); + storeOAuthTokens( + { access_token: "oauth_tok", refresh_token: "r", expires_in: 3600 }, + profileName, + ); + expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer oauth_tok"); }); test("ALTERTABLE_API_KEY env beats everything", () => { - secretSet("api-key", "atm_stored"); - storeOAuthTokens({ access_token: "oauth_tok", expires_in: 3600 }); + secretSet("api-key", "atm_stored", profileName); + storeOAuthTokens({ access_token: "oauth_tok", expires_in: 3600 }, profileName); process.env.ALTERTABLE_API_KEY = "atm_env"; - expect(getManagementAuthHeader()).toBe("Authorization: Bearer atm_env"); + expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer atm_env"); }); }); diff --git a/cli/tests/oauth-refresh.test.ts b/cli/tests/oauth-refresh.test.ts index aa27dee..f4b70fb 100644 --- a/cli/tests/oauth-refresh.test.ts +++ b/cli/tests/oauth-refresh.test.ts @@ -9,13 +9,15 @@ import { getStoredAccessToken, } from "@/lib/oauth-profile.ts"; import { getManagementAuthHeader } from "@/lib/auth.ts"; -import { configGet, configSet } from "@/lib/config.ts"; +import { configGet, configSet, resolveManagementApiBase, resolveOAuthBase } from "@/lib/config.ts"; import { secretGet, secretSet } from "@/lib/secrets.ts"; import { setCliContext, getCliContext } from "@/context.ts"; import { ConfigurationError, HttpError, NetworkError } from "@/lib/errors.ts"; import { managementRequest } from "@/lib/management-transport.ts"; import { createCliRuntime, refreshCliRuntimeContext, runWithCliRuntime } from "@/lib/runtime.ts"; +import { fetchLoginWhoami, storeLoginProfileMetadata } from "@/commands/login.ts"; +const profileName = "default"; let testHome = ""; let mockFile = ""; @@ -50,11 +52,14 @@ describe("exchangeCode", () => { }, ]), ); - const tokens = await exchangeCode({ - code: "c", - redirectUri: "http://127.0.0.1:5000/callback", - verifier: "v", - }); + const tokens = await exchangeCode( + { + code: "c", + redirectUri: "http://127.0.0.1:5000/callback", + verifier: "v", + }, + resolveOAuthBase(profileName), + ); expect(tokens.access_token).toBe("acc"); expect(tokens.refresh_token).toBe("ref"); }); @@ -62,25 +67,25 @@ describe("exchangeCode", () => { describe("ensureFreshAccessToken", () => { test("no-op when not logged in via OAuth", async () => { - await ensureFreshAccessToken(); - expect(getStoredAccessToken()).toBe(""); // nothing stored, nothing refreshed + await ensureFreshAccessToken(profileName); + expect(getStoredAccessToken(profileName)).toBe(""); // nothing stored, nothing refreshed }); test("no-op when the token is still fresh", async () => { - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }); - await ensureFreshAccessToken(); - expect(getStoredAccessToken()).toBe("acc"); // unchanged — no refresh + storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); + await ensureFreshAccessToken(profileName); + expect(getStoredAccessToken(profileName)).toBe("acc"); // unchanged — no refresh }); test("no-op when ALTERTABLE_API_KEY is set (env key short-circuits refresh)", async () => { // Expired OAuth session, but no /oauth/token mock — a refresh attempt would throw. - secretSet("oauth/refresh-token", "ref"); - configSet("oauth_expiry", String(Date.now() - 1000)); - const tokenBefore = getStoredAccessToken(); + secretSet("oauth/refresh-token", "ref", profileName); + configSet("oauth_expiry", String(Date.now() - 1000), profileName); + const tokenBefore = getStoredAccessToken(profileName); process.env.ALTERTABLE_API_KEY = "atm_env"; - await ensureFreshAccessToken(); - expect(getStoredAccessToken()).toBe(tokenBefore); // tokens must not be cleared + await ensureFreshAccessToken(profileName); + expect(getStoredAccessToken(profileName)).toBe(tokenBefore); // tokens must not be cleared }); test("refreshes and persists when expired", async () => { @@ -94,12 +99,12 @@ describe("ensureFreshAccessToken", () => { }, ]), ); - secretSet("oauth/refresh-token", "ref"); - configSet("oauth_expiry", String(Date.now() - 1000)); + secretSet("oauth/refresh-token", "ref", profileName); + configSet("oauth_expiry", String(Date.now() - 1000), profileName); - await ensureFreshAccessToken(); - expect(getStoredAccessToken()).toBe("acc2"); - expect(Number.parseInt(configGet("oauth_expiry"), 10)).toBeGreaterThan(Date.now()); + await ensureFreshAccessToken(profileName); + expect(getStoredAccessToken(profileName)).toBe("acc2"); + expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThan(Date.now()); }); test("clears tokens and throws when refresh fails", async () => { @@ -114,18 +119,18 @@ describe("ensureFreshAccessToken", () => { }, ]), ); - secretSet("oauth/access-token", "acc"); - secretSet("oauth/refresh-token", "ref"); - configSet("oauth_expiry", String(Date.now() - 1000)); + secretSet("oauth/access-token", "acc", profileName); + secretSet("oauth/refresh-token", "ref", profileName); + configSet("oauth_expiry", String(Date.now() - 1000), profileName); let caught: unknown; try { - await ensureFreshAccessToken(); + await ensureFreshAccessToken(profileName); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(ConfigurationError); - expect(getStoredAccessToken()).toBe(""); + expect(getStoredAccessToken(profileName)).toBe(""); }); test("keeps tokens and rethrows when refresh returns 404 (wrong URL, not a dead session)", async () => { @@ -140,38 +145,38 @@ describe("ensureFreshAccessToken", () => { }, ]), ); - secretSet("oauth/access-token", "acc"); - secretSet("oauth/refresh-token", "ref"); - configSet("oauth_expiry", String(Date.now() - 1000)); + secretSet("oauth/access-token", "acc", profileName); + secretSet("oauth/refresh-token", "ref", profileName); + configSet("oauth_expiry", String(Date.now() - 1000), profileName); let caught: unknown; try { - await ensureFreshAccessToken(); + await ensureFreshAccessToken(profileName); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(HttpError); - expect(getStoredAccessToken()).toBe("acc"); // session preserved - expect(secretGet("oauth/refresh-token")).toBe("ref"); + expect(getStoredAccessToken(profileName)).toBe("acc"); // session preserved + expect(secretGet("oauth/refresh-token", profileName)).toBe("ref"); }); test("keeps tokens and rethrows when the refresh fails with a network error", async () => { // Bypass the mock harness so the refresh hits a real closed port. delete process.env.ALTERTABLE_MOCK_HTTP_FILE; process.env.ALTERTABLE_MANAGEMENT_API_BASE = "http://127.0.0.1:1"; - secretSet("oauth/access-token", "acc"); - secretSet("oauth/refresh-token", "ref"); - configSet("oauth_expiry", String(Date.now() - 1000)); + secretSet("oauth/access-token", "acc", profileName); + secretSet("oauth/refresh-token", "ref", profileName); + configSet("oauth_expiry", String(Date.now() - 1000), profileName); let caught: unknown; try { - await ensureFreshAccessToken(); + await ensureFreshAccessToken(profileName); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(NetworkError); - expect(getStoredAccessToken()).toBe("acc"); - expect(secretGet("oauth/refresh-token")).toBe("ref"); + expect(getStoredAccessToken(profileName)).toBe("acc"); + expect(secretGet("oauth/refresh-token", profileName)).toBe("ref"); }); }); @@ -188,9 +193,9 @@ describe("management request auth resolution", () => { { urlPattern: "/whoami", method: "GET", body: '{"principal":{},"organization":{}}' }, ]), ); - secretSet("oauth/access-token", "stale"); - secretSet("oauth/refresh-token", "ref"); - configSet("oauth_expiry", String(Date.now() - 1000)); + secretSet("oauth/access-token", "stale", profileName); + secretSet("oauth/refresh-token", "ref", profileName); + configSet("oauth_expiry", String(Date.now() - 1000), profileName); const runtime = createCliRuntime({ debug: false, json: false, agent: false }); await runWithCliRuntime(runtime, async () => { @@ -198,12 +203,58 @@ describe("management request auth resolution", () => { await managementRequest("GET", "/whoami"); }); - expect(getStoredAccessToken()).toBe("acc3"); - expect(Number.parseInt(configGet("oauth_expiry"), 10)).toBeGreaterThan(Date.now()); + expect(getStoredAccessToken(profileName)).toBe("acc3"); + expect(Number.parseInt(configGet("oauth_expiry", profileName), 10)).toBeGreaterThan(Date.now()); + }); + + // Regression: logging into org B while org A's session lives in `default`. + // whoami during login must use the freshly minted org B token — not org A's + // stored session — otherwise it identifies org A and the login wrongly derives + // an `org-a_*` profile instead of branching to org B. + test("whoami during login honors the freshly minted token, not the stored session", async () => { + // "Org A login" already landed in the default profile. + storeOAuthTokens( + { access_token: "org_a_token", refresh_token: "ref_a", expires_in: 3600 }, + profileName, + ); + configSet("organization_slug", "org-a", profileName); + + // The management server identifies the caller from its bearer token. + writeFileSync( + mockFile, + JSON.stringify([ + { + urlPattern: "/whoami", + method: "GET", + authPattern: "org_a_token", + body: '{"principal":{},"organization":{"slug":"org-a","name":"Org A"},"environment_slug":"production"}', + }, + { + urlPattern: "/whoami", + method: "GET", + authPattern: "org_b_token", + body: '{"principal":{},"organization":{"slug":"org-b","name":"Org B"},"environment_slug":"production"}', + }, + ]), + ); + + const runtime = createCliRuntime({ debug: false, json: false, agent: false }); + const metadata = await runWithCliRuntime(runtime, async () => { + refreshCliRuntimeContext(getCliContext()); + // The org B browser flow just minted this token. + const whoami = await fetchLoginWhoami( + { access_token: "org_b_token", refresh_token: "ref_b", expires_in: 3600 }, + resolveManagementApiBase(profileName), + ); + return storeLoginProfileMetadata(whoami, {}); + }); + + // Logging into org B must branch to org B's profile, not org A's. + expect(metadata.profileName).toBe("org-b_production"); }); test("resolves the header live from the store, not a bootstrap cache", async () => { - storeOAuthTokens({ access_token: "tok_a", refresh_token: "r", expires_in: 3600 }); + storeOAuthTokens({ access_token: "tok_a", refresh_token: "r", expires_in: 3600 }, profileName); writeFileSync( mockFile, @@ -216,11 +267,11 @@ describe("management request auth resolution", () => { await runWithCliRuntime(runtime, async () => { refreshCliRuntimeContext(getCliContext()); // Simulate an out-of-band token rotation (e.g. a prior refresh in this process). - secretSet("oauth/access-token", "tok_b"); + secretSet("oauth/access-token", "tok_b", profileName); await managementRequest("GET", "/whoami"); }); // The header reflects the rotated token, resolved live at send time. - expect(getManagementAuthHeader()).toBe("Authorization: Bearer tok_b"); + expect(getManagementAuthHeader(profileName)).toBe("Authorization: Bearer tok_b"); }); }); diff --git a/cli/tests/oauth.test.ts b/cli/tests/oauth.test.ts index 8b87a01..49d97f0 100644 --- a/cli/tests/oauth.test.ts +++ b/cli/tests/oauth.test.ts @@ -11,6 +11,7 @@ import { getActiveProfileName, profileExists, setActiveProfile } from "@/lib/pro import { createEmptyProfile } from "@/features/profile/model.ts"; import { secretSet } from "@/lib/secrets.ts"; +const profileName = "default"; let testHome = ""; const TEST_PRINCIPAL = { type: "User", @@ -39,12 +40,12 @@ afterEach(() => { describe("resolveOAuthBase", () => { test("defaults to the public app root + /oauth", () => { - expect(resolveOAuthBase()).toBe("https://app.altertable.ai/oauth"); + expect(resolveOAuthBase(profileName)).toBe("https://app.altertable.ai/oauth"); }); test("respects ALTERTABLE_MANAGEMENT_API_BASE", () => { process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; - expect(resolveOAuthBase()).toBe("https://app.example.com/oauth"); + expect(resolveOAuthBase(profileName)).toBe("https://app.example.com/oauth"); }); }); @@ -72,11 +73,14 @@ describe("buildAuthorizeUrl", () => { test("includes PKCE, state and client id", () => { process.env.ALTERTABLE_MANAGEMENT_API_BASE = "https://app.example.com"; const url = new URL( - buildAuthorizeUrl({ - redirectUri: "http://127.0.0.1:5000/callback", - challenge: "chal", - state: "st", - }), + buildAuthorizeUrl( + { + redirectUri: "http://127.0.0.1:5000/callback", + challenge: "chal", + state: "st", + }, + resolveOAuthBase(profileName), + ), ); expect(url.origin + url.pathname).toBe("https://app.example.com/oauth/authorize"); expect(url.searchParams.get("response_type")).toBe("code"); @@ -137,17 +141,17 @@ describe("loopback server", () => { describe("token storage", () => { test("round-trips tokens and stamps expiry", () => { const before = Date.now(); - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }); - expect(getStoredAccessToken()).toBe("acc"); - const expiry = Number.parseInt(configGet("oauth_expiry"), 10); + storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); + expect(getStoredAccessToken(profileName)).toBe("acc"); + const expiry = Number.parseInt(configGet("oauth_expiry", profileName), 10); expect(expiry).toBeGreaterThanOrEqual(before + 3600 * 1000); }); test("clear removes tokens and expiry", () => { - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }); - clearOAuthTokens(); - expect(getStoredAccessToken()).toBe(""); - expect(configGet("oauth_expiry")).toBe(""); + storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); + clearOAuthTokens(profileName); + expect(getStoredAccessToken(profileName)).toBe(""); + expect(configGet("oauth_expiry", profileName)).toBe(""); }); }); @@ -155,6 +159,7 @@ import { assertInteractiveLogin, resolveWhoamiEnvironmentSlug, applyControlPlaneOverride, + sameWhoamiContext, storeLoginProfileMetadata, } from "@/commands/login.ts"; import { @@ -182,6 +187,39 @@ describe("resolveWhoamiEnvironment", () => { }); }); +describe("sameWhoamiContext", () => { + const WHOAMI = { + principal: { type: "User", name: "Sylvain", email: "sylvain@altertable.ai", slug: "sylvain" }, + organization: { name: "Altertable", slug: "altertable" }, + authentication_scope: "environment", + environment_slug: "production", + } as const; + + test("true for the same identity and context", () => { + // A cosmetic display-name change is not an identity change. + expect( + sameWhoamiContext(WHOAMI, { ...WHOAMI, principal: { ...WHOAMI.principal, name: "S." } }), + ).toBe(true); + }); + + test("false when the principal changes", () => { + expect( + sameWhoamiContext(WHOAMI, { + ...WHOAMI, + principal: { ...WHOAMI.principal, slug: "mallory", email: "mallory@altertable.ai" }, + }), + ).toBe(false); + }); + + test("false when the organization changes", () => { + expect(sameWhoamiContext(WHOAMI, { ...WHOAMI, organization: { slug: "other" } })).toBe(false); + }); + + test("false when the environment changes", () => { + expect(sameWhoamiContext(WHOAMI, { ...WHOAMI, environment_slug: "staging" })).toBe(false); + }); +}); + describe("login profile metadata", () => { const WHOAMI = { principal: TEST_PRINCIPAL, @@ -194,7 +232,7 @@ describe("login profile metadata", () => { // profile and stores the whoami identity there, rather than deriving a new one. test("lands in the current default profile when it is unauthenticated", () => { const metadata = storeLoginProfileMetadata(WHOAMI, {}); - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }); + storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); expect(metadata).toEqual({ environment: "production", @@ -203,19 +241,19 @@ describe("login profile metadata", () => { }); expect(getActiveProfileName()).toBe("default"); expect(profileExists("altertable_production")).toBe(false); - expect(configGet("api_key_env")).toBe("production"); - expect(configGet("organization_slug")).toBe("altertable"); - expect(configGet("organization_name")).toBe("Altertable"); - expect(configGet("principal_name")).toBe("Test User"); - expect(configGet("principal_email")).toBe("test.user@altertable.test"); - expect(getStoredAccessToken()).toBe("acc"); + expect(configGet("api_key_env", profileName)).toBe("production"); + expect(configGet("organization_slug", profileName)).toBe("altertable"); + expect(configGet("organization_name", profileName)).toBe("Altertable"); + expect(configGet("principal_name", profileName)).toBe("Test User"); + expect(configGet("principal_email", profileName)).toBe("test.user@altertable.test"); + expect(getStoredAccessToken(profileName)).toBe("acc"); }); // A second `altertable login` must not clobber the now-authenticated `default`; // it derives and switches to a fresh profile instead. test("creates a separate profile when the current one is already authenticated", () => { storeLoginProfileMetadata(WHOAMI, {}); - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }); + storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); const metadata = storeLoginProfileMetadata(WHOAMI, {}); @@ -249,7 +287,7 @@ describe("login profile metadata", () => { // Lakehouse-only credentials still count as "authenticated" — a login must not // overwrite them, so it branches to a new profile. test("treats a profile with only lakehouse credentials as authenticated", () => { - secretSet("lakehouse/password", "s_lake"); + secretSet("lakehouse/password", "s_lake", profileName); const metadata = storeLoginProfileMetadata(WHOAMI, {}); @@ -311,8 +349,8 @@ describe("login profile metadata", () => { expect(profileExists("default")).toBe(true); expect(profileExists("altertable_production")).toBe(false); expect(getActiveProfileName()).toBe("default"); - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }); - expect(getStoredAccessToken()).toBe("acc"); + storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); + expect(getStoredAccessToken(profileName)).toBe("acc"); }); test("stores endpoint overrides after successful login metadata is available", () => { @@ -328,20 +366,20 @@ describe("login profile metadata", () => { }, ); - expect(configGet("management_api_base")).toBe("https://app.altertable.test"); - expect(configGet("api_base")).toBe("https://api.altertable.test"); + expect(configGet("management_api_base", profileName)).toBe("https://app.altertable.test"); + expect(configGet("api_base", profileName)).toBe("https://api.altertable.test"); }); }); describe("login --control-plane-url", () => { test("stores the control-plane root so OAuth targets it", () => { applyControlPlaneOverride({ "control-plane-url": "https://app.altertable.test" }); - expect(resolveOAuthBase()).toBe("https://app.altertable.test/oauth"); + expect(resolveOAuthBase(profileName)).toBe("https://app.altertable.test/oauth"); }); test("no-op without the flag (keeps the default)", () => { applyControlPlaneOverride({}); - expect(resolveOAuthBase()).toBe("https://app.altertable.ai/oauth"); + expect(resolveOAuthBase(profileName)).toBe("https://app.altertable.ai/oauth"); }); test("rejects non-localhost http without --allow-insecure-http", () => { @@ -355,7 +393,7 @@ describe("login --control-plane-url", () => { "control-plane-url": "http://app.altertable.test", "allow-insecure-http": true, }); - expect(resolveOAuthBase()).toBe("http://app.altertable.test/oauth"); + expect(resolveOAuthBase(profileName)).toBe("http://app.altertable.test/oauth"); }); test("errors when ALTERTABLE_MANAGEMENT_API_BASE conflicts with the flag", () => { @@ -383,9 +421,9 @@ describe("login interactivity guard", () => { describe("clear removes OAuth credentials", () => { test("configureRunClear wipes stored OAuth tokens", () => { - storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }); - expect(getStoredAccessToken()).toBe("acc"); + storeOAuthTokens({ access_token: "acc", refresh_token: "ref", expires_in: 3600 }, profileName); + expect(getStoredAccessToken(profileName)).toBe("acc"); configureRunClear(getOutputSink()); - expect(getStoredAccessToken()).toBe(""); + expect(getStoredAccessToken(profileName)).toBe(""); }); }); diff --git a/cli/tests/pager.test.ts b/cli/tests/pager.test.ts index 33e437a..2b870ce 100644 --- a/cli/tests/pager.test.ts +++ b/cli/tests/pager.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { configSet } from "@/lib/config.ts"; +import { configSetGlobal } from "@/lib/config.ts"; import { buildPagerEnv, resolvePagerOptions, shouldUsePager } from "@/lib/pager.ts"; describe("resolvePagerOptions", () => { @@ -19,17 +19,17 @@ describe("resolvePagerOptions", () => { }); test("config always forces pager when CLI flags unset", () => { - configSet("query_pager", "always"); + configSetGlobal("query_pager", "always"); expect(resolvePagerOptions()).toEqual({ mode: "always" }); }); test("CLI pager mode wins over config always", () => { - configSet("query_pager", "always"); + configSetGlobal("query_pager", "always"); expect(resolvePagerOptions("never")).toEqual({ mode: "never" }); }); test("config always triggers pager for short text on TTY", () => { - configSet("query_pager", "always"); + configSetGlobal("query_pager", "always"); const originalIsTTY = process.stdout.isTTY; Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); expect(shouldUsePager("short", resolvePagerOptions())).toBe(true); diff --git a/cli/tests/profile-configure.test.ts b/cli/tests/profile-configure.test.ts index dc93e35..75de0d2 100644 --- a/cli/tests/profile-configure.test.ts +++ b/cli/tests/profile-configure.test.ts @@ -11,6 +11,8 @@ import { setCliContext } from "@/context.ts"; import { createCliRuntime, runWithCliRuntime } from "@/lib/runtime.ts"; import { ConfigurationError } from "@/lib/errors.ts"; +const profileName = "default"; + let testHome = ""; function createMockPrompts(responses: { @@ -91,8 +93,8 @@ describe("runConfigureWizard", () => { }); }); - expect(secretGet("api-key")).toBe("atm_test_key"); - expect(configGet("api_key_env")).toBe("staging"); + expect(secretGet("api-key", profileName)).toBe("atm_test_key"); + expect(configGet("api_key_env", profileName)).toBe("staging"); } finally { Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); } @@ -119,8 +121,8 @@ describe("runConfigureWizard", () => { }); }); - expect(configGet("user")).toBe("alice"); - expect(secretGet("lakehouse/password")).toBe("lake-secret"); + expect(configGet("user", profileName)).toBe("alice"); + expect(secretGet("lakehouse/password", profileName)).toBe("lake-secret"); } finally { Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); } @@ -154,9 +156,9 @@ describe("runConfigureWizard", () => { }); }); - await withConfigureProfileContext("target", () => { - expect(configGet("user")).toBe("target-user"); - expect(secretGet("lakehouse/password")).toBe("target-secret"); + await withConfigureProfileContext("target", (profileName) => { + expect(configGet("user", profileName)).toBe("target-user"); + expect(secretGet("lakehouse/password", profileName)).toBe("target-secret"); }); } finally { Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); @@ -185,12 +187,12 @@ describe("runConfigureWizard", () => { }); }); - await withConfigureProfileContext("acme_production", () => { - expect(secretGet("api-key")).toBe("atm_test_key"); - expect(configGet("api_key_env")).toBe("production"); - expect(configGet("organization_slug")).toBe("Acme"); - expect(configGet("user")).toBe("alice"); - expect(secretGet("lakehouse/password")).toBe("lake-secret"); + await withConfigureProfileContext("acme_production", (profileName) => { + expect(secretGet("api-key", profileName)).toBe("atm_test_key"); + expect(configGet("api_key_env", profileName)).toBe("production"); + expect(configGet("organization_slug", profileName)).toBe("Acme"); + expect(configGet("user", profileName)).toBe("alice"); + expect(secretGet("lakehouse/password", profileName)).toBe("lake-secret"); }); } finally { Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); @@ -250,8 +252,8 @@ describe("runConfigureWizard", () => { }); }); - expect(secretGet("api-key")).toBe("atm_existing"); - expect(configGet("api_key_env")).toBe("production"); + expect(secretGet("api-key", profileName)).toBe("atm_existing"); + expect(configGet("api_key_env", profileName)).toBe("production"); } finally { Object.defineProperty(process.stdin, "isTTY", { value: originalIsTTY, configurable: true }); } diff --git a/cli/tests/profile.test.ts b/cli/tests/profile.test.ts index 3a559c5..c0d44a0 100644 --- a/cli/tests/profile.test.ts +++ b/cli/tests/profile.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { configGet, configSet, kvGet } from "@/lib/config.ts"; +import { configGet, configSet, configSetGlobal, kvGet } from "@/lib/config.ts"; import { configureRunSet } from "@/lib/profile-configure-core.ts"; import { resetSecretWarningsForTests, @@ -22,7 +22,7 @@ import { profileConfigFile, profileExists, renameProfile, - resolveProfileName, + resolveWorkingProfile, setActiveProfile, updateProfile, } from "@/features/profile/model.ts"; @@ -42,6 +42,8 @@ import { setCliContext, getCliContext } from "@/context.ts"; import { createCliTestRuntime, runCommandWithTestRuntime } from "@tests/cli-test-runtime.ts"; import { renderShellExportView } from "@/ui/shell/render.ts"; +const profileName = "default"; + let testHome = ""; beforeEach(() => { @@ -69,14 +71,14 @@ describe("profiles layout", () => { }); }); -describe("resolveProfileName", () => { +describe("resolveWorkingProfile", () => { test("prefers CLI context profile over active profile", async () => { await configureRunSet({ apiKey: "atm_a", env: "prod" }); await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); setActiveProfile("default"); setCliContext({ debug: false, json: false, agent: false, profile: "staging" }); - expect(resolveProfileName(getCliContext().profile)).toBe("staging"); + expect(resolveWorkingProfile(getCliContext().profile)).toBe("staging"); }); test("prefers ALTERTABLE_PROFILE over active profile", async () => { @@ -84,12 +86,12 @@ describe("resolveProfileName", () => { await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); setActiveProfile("default"); process.env.ALTERTABLE_PROFILE = "staging"; - expect(resolveProfileName()).toBe("staging"); + expect(resolveWorkingProfile()).toBe("staging"); }); test("returns active profile by default", async () => { await configureRunSet({ apiKey: "atm_a", env: "prod" }); - expect(resolveProfileName()).toBe(DEFAULT_PROFILE_NAME); + expect(resolveWorkingProfile()).toBe(DEFAULT_PROFILE_NAME); expect(getActiveProfileName()).toBe(DEFAULT_PROFILE_NAME); }); }); @@ -105,8 +107,8 @@ describe("profile storage", () => { expect(profileExists("acme_production")).toBe(true); setCliContext({ debug: false, json: false, agent: false, profile: "acme_production" }); - expect(configGet("api_key_env")).toBe("Production"); - expect(secretGet("api-key")).toBe("atm_prod"); + expect(configGet("api_key_env", "acme_production")).toBe("Production"); + expect(secretGet("api-key", "acme_production")).toBe("atm_prod"); expect( listProfiles().find((profile) => profile.name === "acme_production")?.management_auth, ).toBe("api-key"); @@ -152,7 +154,7 @@ describe("profile storage", () => { await configureRunSet({ profile: "acme_prod", user: "alice", password: "secret" }); const lakehouseExpiry = Date.now() + 60 * 60 * 1000; setCliContext({ debug: false, json: false, agent: false, profile: "acme_prod" }); - configSet("lakehouse_credential_expiry", String(lakehouseExpiry)); + configSet("lakehouse_credential_expiry", String(lakehouseExpiry), "acme_prod"); const profile = inspectProfile("acme_prod"); expect(profile.status).toBe("configured"); @@ -166,8 +168,8 @@ describe("profile storage", () => { await configureRunSet({ profile: "staging", apiKey: "atm_staging", env: "staging" }); expect(profileExists("staging")).toBe(true); setCliContext({ debug: false, json: false, agent: false, profile: "staging" }); - expect(configGet("api_key_env")).toBe("staging"); - expect(secretGet("api-key")).toBe("atm_staging"); + expect(configGet("api_key_env", "staging")).toBe("staging"); + expect(secretGet("api-key", "staging")).toBe("atm_staging"); }); test("listProfiles marks the active profile", async () => { @@ -244,7 +246,7 @@ describe("profile storage", () => { }); test("global query defaults stay in root config across profiles", async () => { - configSet("query_layout", "line"); + configSetGlobal("query_layout", "line"); await configureRunSet({ profile: "staging", apiKey: "atm_b", env: "staging" }); expect(kvGet(join(testHome, "config"), "query_layout")).toBe("line"); expect(kvGet(profileConfigFile("staging"), "query_layout")).toBe(""); @@ -415,7 +417,7 @@ describe("profile storage", () => { }); test("rejects path traversal profile names", () => { - expect(() => resolveProfileName("../../outside")).toThrow(ConfigurationError); + expect(() => resolveWorkingProfile("../../outside")).toThrow(ConfigurationError); expect(() => setActiveProfile("..")).toThrow(ConfigurationError); expect(() => deleteProfile("foo/bar")).toThrow(ConfigurationError); }); @@ -457,8 +459,8 @@ describe("profile --configure dispatch", () => { await runProfileConfigure({ "api-key": "atm_flagkey", env: "staging" }, sink); - expect(secretGet("api-key")).toBe("atm_flagkey"); - expect(configGet("api_key_env")).toBe("staging"); + expect(secretGet("api-key", profileName)).toBe("atm_flagkey"); + expect(configGet("api_key_env", profileName)).toBe("staging"); }); test("rejects --scope combined with credential flags", async () => { @@ -472,7 +474,7 @@ describe("profile --configure dispatch", () => { } expect(caught).toBeInstanceOf(CliError); - expect(secretGet("api-key")).toBe(""); + expect(secretGet("api-key", profileName)).toBe(""); }); }); diff --git a/cli/tests/query-format.test.ts b/cli/tests/query-format.test.ts index ae26ca8..877f33c 100644 --- a/cli/tests/query-format.test.ts +++ b/cli/tests/query-format.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { configSet } from "@/lib/config.ts"; +import { configSetGlobal } from "@/lib/config.ts"; import { parseLakehouseQueryResponse } from "@/lib/lakehouse-client.ts"; import { defaultDisplayOptions, @@ -607,8 +607,8 @@ describe("defaultDisplayOptions", () => { }); test("merges config defaults when present", () => { - configSet("query_max_width", "24"); - configSet("query_layout", "table"); + configSetGlobal("query_max_width", "24"); + configSetGlobal("query_layout", "table"); const options = defaultDisplayOptions(); expect(options.maxColumnWidth).toBe(24); expect(options.layout).toBe("table");