From d243eb31b014781a249f903b2a467aa58909ddd6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 10 Apr 2026 01:55:53 -0400 Subject: [PATCH] fix: CLI falls back to workspace whoami for workspace-scoped tokens (#8789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: CLI falls back to workspace whoami when global whoami is 401 Workspace-scoped tokens (token.workspace_id set) cannot call /api/users/whoami — the backend's token lookup filters by workspace_id which is NULL on global paths, so auth returns 401 before the handler runs. This breaks the CLI entirely: requireLogin calls globalWhoami at the start of every command, so no command works with a workspace-scoped token, not even `wmill workspace whoami`. Fix it CLI-side: if the global whoami returns 401, fall back to the workspace-scoped /api/w/{w}/users/whoami using the workspace already known from the CLI profile, and adapt the response shape to GlobalUserInfo. Also drop the redundant second globalWhoami call in `wmill workspace whoami` — use requireLogin's return value instead. No backend changes: the workspace_id binding on the token stays strictly enforced for every global endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use name-based ApiError check in whoami fallback Review feedback from PR #8789: `instanceof ApiError` can silently return false when bundling produces multiple module instances of `gen/core/ApiError.ts` (bun build for npm, JSR dev path), which would skip the workspace-whoami fallback and reintroduce the exact bug this PR fixes. Match the name-based check already used at `cli/src/main.ts:232` and drop the `ApiError` import. Also add a comment on `workspaceUserToGlobalUserInfo` listing the fields that aren't derivable from the workspace-scoped User response and are filled with placeholder values, so future callers don't trust them downstream. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/commands/workspace/workspace.ts | 3 +- cli/src/core/auth.ts | 50 +++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) 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); } }