fix: CLI falls back to workspace whoami for workspace-scoped tokens (#8789)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-10 05:55:53 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a7512f9034
commit d243eb31b0
2 changed files with 48 additions and 5 deletions
+1 -2
View File
@@ -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");
+47 -3
View File
@@ -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<GlobalUserInfo> {
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);
}
}