diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 7683e8b740..d2bc2a5bbc 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -412,8 +412,7 @@ async function remove(_opts: GlobalOptions, name: string) { } async function whoami(_opts: GlobalOptions) { - await requireLogin(_opts); - const whoamiInfo = await wmill.globalWhoami(); + const whoamiInfo = await requireLogin(_opts); log.info(JSON.stringify(whoamiInfo, null, 2)); const activeName = await getActiveWorkspaceName(_opts); const { getCurrentGitBranch, getOriginalBranchForWorkspaceForks } = await import("../../utils/git.ts"); diff --git a/cli/src/core/auth.ts b/cli/src/core/auth.ts index 037555a1a9..dcf8c40391 100644 --- a/cli/src/core/auth.ts +++ b/cli/src/core/auth.ts @@ -2,12 +2,56 @@ import { colors } from "@cliffy/ansi/colors"; import * as log from "./log.ts"; import { setClient } from "./client.ts"; import * as wmill from "../../gen/services.gen.ts"; -import { GlobalUserInfo } from "../../gen/types.gen.ts"; +import { GlobalUserInfo, User } from "../../gen/types.gen.ts"; import { loginInteractive, tryGetLoginInfo } from "./login.ts"; import { GlobalOptions } from "../types.ts"; +// Workspace-scoped tokens (tokens bound to a workspace via token.workspace_id) +// cannot call /api/users/whoami because the backend rejects any token with a +// workspace binding when the request path has no workspace in it. Fall back to +// the workspace-scoped whoami endpoint in that case so the CLI still works. +// Uses a name-based ApiError check rather than `instanceof` to match the +// pattern in cli/src/main.ts: bundling (bun build for npm, JSR dev path) can +// produce multiple module instances of gen/core/ApiError.ts, making +// `instanceof` silently return false and reintroducing the bug this fixes. +async function fetchWhoami(workspaceId: string): Promise { + try { + return await wmill.globalWhoami(); + } catch (error) { + if ( + error && typeof error === "object" && + "name" in error && (error as { name: unknown }).name === "ApiError" && + (error as { status?: number }).status === 401 + ) { + const user = await wmill.whoami({ workspace: workspaceId }); + return workspaceUserToGlobalUserInfo(user); + } + throw error; + } +} + +// Adapter for the 401 fallback path. `login_type`, `verified`, `first_time_user` +// and `role_source` are NOT derivable from the workspace-scoped User response +// and are filled with best-effort defaults — do not trust them downstream after +// a fallback whoami. Today only cli/src/commands/hub/hub.ts reads this return +// value, and only `.email`. +function workspaceUserToGlobalUserInfo(user: User): GlobalUserInfo { + return { + email: user.email, + login_type: "password", + super_admin: user.is_super_admin, + verified: true, + name: user.name, + username: user.username, + operator_only: user.operator, + first_time_user: false, + role_source: "manual", + disabled: user.disabled, + }; +} + /** * Main authentication function - moved from context.ts to break circular dependencies * This function maintains the original API signature from context.ts @@ -28,7 +72,7 @@ export async function requireLogin( setClient(token, workspace.remote.substring(0, workspace.remote.length - 1)); try { - return await wmill.globalWhoami(); + return await fetchWhoami(workspace.workspaceId); } catch (error) { // Check for network errors and provide clearer messages const errorMsg = error instanceof Error ? error.message : String(error); @@ -62,6 +106,6 @@ export async function requireLogin( newToken, workspace.remote.substring(0, workspace.remote.length - 1) ); - return await wmill.globalWhoami(); + return await fetchWhoami(workspace.workspaceId); } }