diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 9a059653..f7dd38bf 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -195,6 +195,50 @@ Current MVP commands map to patterns like this: No current MVP command uses `verify` or `inspect`, but new commands must still choose one existing pattern rather than inventing a new one casually. +### Workspace session identity + +Each `auth login` authorizes one workspace and stores one local session. Two +sessions can belong to different Prisma users. `auth workspace list` shows the +sessions authorized on this machine. It is not the full list of workspaces the +user can see in Console. + +Human output shows the user next to every workspace session: the email when it +is known, then the name, then the id. Selection prompts show the same identity. +A table renders the standard unknown-value marker when no identity is known, +and a prompt leaves it out. + +The plain stdout rows of `auth workspace list` keep their columns: workspace, +id, status. Scripts read those columns by position, and the user is optional, +so the user appears only in the table and in the structured output. + +Structured output carries a nullable `user` object on every item, and +`context.scope` is `"local-sessions"`: + +```json +{ + "context": { "scope": "local-sessions" }, + "items": [ + { + "workspaceId": "workspace_123", + "workspaceName": "Acme Inc", + "user": { "id": "usr_123", "email": "developer@example.com", "name": null }, + "current": true, + "expiresAt": "2026-08-19T09:10:49.000Z" + } + ] +} +``` + +`user` is `null` when neither stored metadata nor token claims name a user. A +user field is `null` when it is unknown. Tokens never appear in any output. + +The CLI reads the workspace name and the user from one best-effort `/v1/me` +request at login and stores them with the session. Sessions saved before this +existed get the same lookup once, from `auth workspace list` and the +`auth workspace use` picker. `auth workspace use ` is a local switch: +it looks metadata up only when the argument matches no stored session. Logout +never waits for a lookup. + ### One-Time Secret Output Commands that create one-time-view secrets print the secret bare in the human card and write the raw value to stdout. The card is the only place an interactive user ever sees the secret — when stdout and stderr render to one screen the stdout mirror is skipped, so masking the card would hide the secret from everyone including its owner (operator ruling, 2026-08-26). The stdout line is machine-readable output for pipes and redirection. diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index 75964ad0..6f3ac3cf 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -64,7 +64,7 @@ A command that needs an active workspace found an authenticated credential that ### AUTH.WORKSPACE_AMBIGUOUS -A user-typed workspace name matched more than one workspace, from two raise sites with different meta: the session-ref resolver behind `prisma auth workspace use`/`logout` when several stored sessions share the name (meta carries `workspaceIds`), and `prisma project transfer` when a `--to-workspace` reference matches several authenticated workspaces (meta carries `workspaceRef` and `matches`, each match holding `id`, `name`, `credentialWorkspaceId`). Both point the user at `prisma auth workspace list` to retry with an exact workspace id. Meta: `workspaceIds` (workspace commands) or `workspaceRef`, `matches` (project transfer). +A user-typed workspace name matched more than one workspace, from two raise sites with different meta: the session-ref resolver behind `prisma auth workspace use`/`logout` when several stored sessions share the name (meta carries `workspaceIds` and `sessions`, each session holding `workspaceId` and a nullable `user`), and `prisma project transfer` when a `--to-workspace` reference matches several authenticated workspaces (meta carries `workspaceRef` and `matches`, each match holding `id`, `name`, `credentialWorkspaceId`). Both point the user at `prisma auth workspace list` to retry with an exact workspace id. Meta: `workspaceIds`, `sessions` (workspace commands) or `workspaceRef`, `matches` (project transfer). ### AUTH.WORKSPACE_NOT_AUTHENTICATED diff --git a/packages/cli/src/auth/credential-manager.ts b/packages/cli/src/auth/credential-manager.ts index 19469272..bea40a2e 100644 --- a/packages/cli/src/auth/credential-manager.ts +++ b/packages/cli/src/auth/credential-manager.ts @@ -4,6 +4,7 @@ import type { ActiveAccessTokenOptions, ActiveCredential, Credential, + CredentialIdentity, CredentialManager, CredentialRefresher, Session, @@ -25,9 +26,11 @@ import { type DebugLog, EMPTY_STATE, makeDebugLog, + normalizeStoredSessionUser, readCredentialState, resolveStateFilePath, type StoredSession, + type StoredSessionUser, withRefreshFileLock, withStateLock, writeCredentialState, @@ -43,16 +46,63 @@ type RefreshLock = (fn: () => Promise) => Promise; * never leaves the manager, and it is never the empty string. */ const NO_WORKSPACE_CLAIMED = "(no workspace)"; -/** Looks the workspace's name up with the credential that was just - * minted. Best-effort: the manager treats any failure as "no name". */ -export type FetchWorkspaceName = ( - credential: Credential, - workspaceId: string, -) => Promise; +type SessionMetadata = { + readonly workspaceName?: string | undefined; + readonly user?: StoredSessionUser | undefined; +}; + +/** Looks up workspace and safe account metadata in one request. + * Best-effort: a failed lookup never prevents the session from being saved. */ +export type FetchSessionMetadata = ( + credential: Pick, +) => Promise; + +export type AccountSession = Session & { + readonly identity: CredentialIdentity | undefined; +}; + +export interface AccountStoredSessions { + readonly sessions: readonly AccountSession[]; + readonly selectedWorkspaceId: string | undefined; +} + +interface AccountAwareCredentialManager extends CredentialManager { + enrichSessions(): Promise; +} + +/** Session display metadata is a CLI concern, not part of the shared engine + * contract. FileCredentialManager provides it; other managers degrade to the + * standard local session shape without inventing an account identity. */ +export async function sessionsForDisplay( + manager: CredentialManager, +): Promise { + if (isAccountAwareCredentialManager(manager)) { + return manager.enrichSessions(); + } + return manager.sessions(); +} + +export function sessionIdentity( + session: Session, +): CredentialIdentity | undefined { + return isAccountSession(session) ? session.identity : undefined; +} + +function isAccountSession(session: Session): session is AccountSession { + return "identity" in session; +} + +function isAccountAwareCredentialManager( + manager: CredentialManager, +): manager is AccountAwareCredentialManager { + return ( + "enrichSessions" in manager && typeof manager.enrichSessions === "function" + ); +} export interface FileCredentialManagerOptions { readonly env: Readonly>; - readonly fetchWorkspaceName?: FetchWorkspaceName; + readonly fetchSessionMetadata?: FetchSessionMetadata; readonly refreshCredential?: CredentialRefresher; readonly debugWrite?: (text: string) => void; } @@ -114,7 +164,7 @@ export class FileCredentialManager implements CredentialManager { readonly #env: Readonly>; readonly #filePath: string; readonly #debug: DebugLog; - readonly #fetchWorkspaceName: FetchWorkspaceName | undefined; + readonly #fetchSessionMetadata: FetchSessionMetadata | undefined; readonly #refreshCredential: CredentialRefresher | undefined; #actingAs: ActingAs = { kind: "unresolved" }; /** Built for the credential the process acts as. Every mutation that @@ -128,7 +178,7 @@ export class FileCredentialManager implements CredentialManager { this.#env = options.env; this.#filePath = resolveStateFilePath(options.env).filePath; this.#debug = makeDebugLog(options.env, options.debugWrite); - this.#fetchWorkspaceName = options.fetchWorkspaceName; + this.#fetchSessionMetadata = options.fetchSessionMetadata; this.#refreshCredential = options.refreshCredential; this.#debug(`state file ${this.#filePath}`); } @@ -159,25 +209,67 @@ export class FileCredentialManager implements CredentialManager { return storedCredential(record); } - async sessions(): Promise { + async sessions(): Promise { const state = await readCredentialState(this.#filePath); - return { - sessions: state.sessions.map((record) => toSession(record)), - selectedWorkspaceId: resolvedMarker(state) ?? undefined, - }; + return storedSessions(state); + } + + async enrichSessions(): Promise { + if (this.#fetchSessionMetadata === undefined) return this.sessions(); + const state = await readCredentialState(this.#filePath); + const now = Date.now(); + const candidates = state.sessions.filter((session) => + lacksFetchableMetadata(session, now), + ); + if (candidates.length === 0) return storedSessions(state); + + const fetched = await Promise.all( + candidates.map(async (session) => ({ + workspaceId: session.workspaceId, + token: session.token, + metadata: await this.#lookUpSessionMetadata(session), + })), + ); + if (fetched.every((result) => result.metadata === undefined)) { + return this.sessions(); + } + const byWorkspaceId = new Map( + fetched.map((result) => [result.workspaceId, result]), + ); + + return this.#mutate((current) => { + let changed = false; + const sessions = current.sessions.map((session) => { + const fetchedSession = byWorkspaceId.get(session.workspaceId); + if ( + fetchedSession === undefined || + fetchedSession.token !== session.token + ) { + return session; + } + const name = session.name ?? fetchedSession.metadata?.workspaceName; + const user = session.user ?? fetchedSession.metadata?.user; + if (name === session.name && user === session.user) return session; + changed = true; + return { ...session, name, user }; + }); + if (!changed) return { result: storedSessions(current) }; + const next = { ...current, sessions }; + return { state: next, result: storedSessions(next) }; + }); } async createSession( credential: Credential, workspaceId: string, - ): Promise { + ): Promise { const environmentInForce = this.#environmentToken() !== undefined; const claimed = credentialWorkspaceId(credential.token); if (claimed !== undefined && claimed !== workspaceId) { throw credentialWorkspaceMismatchError(workspaceId); } - const created = await this.#mutate((state) => { + await this.#mutate((state) => { const existing = state.sessions.find( (session) => session.workspaceId === workspaceId, ); @@ -200,33 +292,46 @@ export class FileCredentialManager implements CredentialManager { ], currentWorkspaceId: workspaceId, }; - return { state: next, result: toSession(record) }; + return { state: next, result: undefined }; }); if (!environmentInForce) { this.#actAs({ kind: "session", workspaceId }); } - const name = await this.#lookUpWorkspaceName(credential, workspaceId); - if (name === undefined) return created; - + const { workspaceName: name, user } = + (await this.#lookUpSessionMetadata(credential)) ?? {}; return this.#mutate((state) => { const record = state.sessions.find( (session) => session.workspaceId === workspaceId, ); - if (record === undefined) return { result: created }; - const named: StoredSession = { ...record, name }; + // Lookups happen outside the lock. Do not attach their result to a + // credential that another process saved for this workspace meanwhile. + if (record === undefined) { + throw credentialsRequiredError("session-ended"); + } + if ( + record.token !== credential.token || + (name === undefined && user === undefined) + ) { + return { result: toSession(record) }; + } + const enriched: StoredSession = { + ...record, + ...(name === undefined ? {} : { name }), + ...(user === undefined ? {} : { user }), + }; const next: CredentialState = { ...state, sessions: state.sessions.map((session) => - session.workspaceId === workspaceId ? named : session, + session.workspaceId === workspaceId ? enriched : session, ), }; - return { state: next, result: toSession(named) }; + return { state: next, result: toSession(enriched) }; }); } - async selectSession(workspaceId: string): Promise { + async selectSession(workspaceId: string): Promise { const environmentInForce = this.#environmentToken() !== undefined; const selected = await this.#mutate((state) => { @@ -362,6 +467,7 @@ export class FileCredentialManager implements CredentialManager { const rotated: StoredSession = { workspaceId: record.workspaceId, ...(record.name === undefined ? {} : { name: record.name }), + user: record.user, token: tokens.accessToken, ...(tokens.refreshToken === undefined ? {} @@ -476,9 +582,6 @@ export class FileCredentialManager implements CredentialManager { return token; } - /** A blank env token is an error state everywhere the environment - * credential would be consulted, including the mutations that no - * longer care whether a valid one is set. */ /** A blank PRISMA_SERVICE_TOKEN is an error state everywhere the * environment credential would be consulted, including the two * mutations that do not otherwise read it. Reading is what raises; @@ -509,14 +612,16 @@ export class FileCredentialManager implements CredentialManager { ); } - async #lookUpWorkspaceName( - credential: Credential, - workspaceId: string, - ): Promise { - if (this.#fetchWorkspaceName === undefined) return undefined; + async #lookUpSessionMetadata( + credential: Pick, + ): Promise { + if (this.#fetchSessionMetadata === undefined) return undefined; try { - const name = await this.#fetchWorkspaceName(credential, workspaceId); - return name?.trim() ? name.trim() : undefined; + const metadata = await this.#fetchSessionMetadata(credential); + const workspaceName = metadata?.workspaceName?.trim() || undefined; + const user = normalizeStoredSessionUser(metadata?.user); + if (workspaceName === undefined && user === undefined) return undefined; + return { workspaceName, user }; } catch { return undefined; } @@ -591,10 +696,31 @@ function resolvedMarker(state: CredentialState): string | null { return null; } -function toSession(record: StoredSession): Session { +/** Whether a lookup with this session's token could add metadata. An + * expired token is rejected, and a workspace-only token has no user. */ +function lacksFetchableMetadata(session: StoredSession, now: number): boolean { + if (session.expiresAt !== undefined && Date.parse(session.expiresAt) <= now) { + return false; + } + if (session.name === undefined) return true; + return ( + session.user === undefined && + claimedIdentity(session.token)?.userId !== undefined + ); +} + +function storedSessions(state: CredentialState): AccountStoredSessions { + return { + sessions: state.sessions.map((record) => toSession(record)), + selectedWorkspaceId: resolvedMarker(state) ?? undefined, + }; +} + +function toSession(record: StoredSession): AccountSession { return { workspaceId: record.workspaceId, workspaceName: record.name, + identity: storedIdentity(record), expiresAt: record.expiresAt === undefined ? undefined : new Date(record.expiresAt), }; @@ -606,11 +732,18 @@ function storedCredential(record: StoredSession): ActiveCredential { workspaceName: record.name, expiresAt: record.expiresAt === undefined ? undefined : new Date(record.expiresAt), - identity: claimedIdentity(record.token), + identity: storedIdentity(record), origin: { source: "stored" }, }; } +function storedIdentity(record: StoredSession): CredentialIdentity | undefined { + const user = record.user; + return user === undefined + ? claimedIdentity(record.token) + : { userId: user.id, email: user.email, name: user.name }; +} + /** An environment token whose claims name no workspace reports no * workspace id — never the empty string. */ function environmentCredential(token: string): ActiveCredential { diff --git a/packages/cli/src/auth/session-metadata.ts b/packages/cli/src/auth/session-metadata.ts new file mode 100644 index 00000000..16e5d14f --- /dev/null +++ b/packages/cli/src/auth/session-metadata.ts @@ -0,0 +1,28 @@ +import { createManagementApiClient } from "@prisma/management-api-sdk"; +import type { FetchSessionMetadata } from "./credential-manager"; + +const METADATA_LOOKUP_TIMEOUT_MS = 3_000; + +export function fetchSessionMetadata(apiBaseUrl: string): FetchSessionMetadata { + return async (credential) => { + const client = createManagementApiClient({ + baseUrl: apiBaseUrl, + token: credential.token, + }); + const { data } = await client.GET("/v1/me", { + signal: AbortSignal.timeout(METADATA_LOOKUP_TIMEOUT_MS), + }); + if (!data) return undefined; + const { user, workspace } = data.data; + return { + workspaceName: workspace?.name ?? undefined, + user: user + ? { + id: user.id, + email: user.email, + ...(user.name ? { name: user.name } : {}), + } + : undefined, + }; + }; +} diff --git a/packages/cli/src/auth/state-file.ts b/packages/cli/src/auth/state-file.ts index a22f4908..f824578a 100644 --- a/packages/cli/src/auth/state-file.ts +++ b/packages/cli/src/auth/state-file.ts @@ -45,11 +45,20 @@ const REFRESH_LOCK_TIMINGS: LockTimings = { export interface StoredSession { readonly workspaceId: string; readonly name?: string; + /** Safe account metadata captured during login. Token material remains the + * source of authentication; this is only for identifying local sessions. */ + readonly user?: StoredSessionUser; readonly token: string; readonly refreshToken?: string; readonly expiresAt?: string; } +export interface StoredSessionUser { + readonly id?: string; + readonly email?: string; + readonly name?: string; +} + export interface CredentialState { readonly version: number; readonly sessions: readonly StoredSession[]; @@ -175,11 +184,13 @@ function isStoredSession(value: unknown): value is StoredSession { } function normalizeSession(session: StoredSession): StoredSession { + const user = normalizeStoredSessionUser(session.user); return { workspaceId: session.workspaceId, ...(typeof session.name === "string" && session.name.length > 0 ? { name: session.name } : {}), + ...(user === undefined ? {} : { user }), token: session.token, ...(typeof session.refreshToken === "string" && session.refreshToken.length > 0 @@ -191,6 +202,32 @@ function normalizeSession(session: StoredSession): StoredSession { }; } +export function normalizeStoredSessionUser( + value: unknown, +): StoredSessionUser | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const candidate = value as Record; + const id = normalizedString(candidate.id); + const email = normalizedString(candidate.email); + const name = normalizedString(candidate.name); + if (id === undefined && email === undefined && name === undefined) { + return undefined; + } + return { + ...(id === undefined ? {} : { id }), + ...(email === undefined ? {} : { email }), + ...(name === undefined ? {} : { name }), + }; +} + +function normalizedString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + /** Temp file in the same directory, fsync, rename, mode 0600 — a reader * only ever sees a complete state. The written file also carries the * legacy `tokens` mirror and the auth.context.json pointer stays in diff --git a/packages/cli/src/auth/workspace-name.ts b/packages/cli/src/auth/workspace-name.ts deleted file mode 100644 index 5e0aff0b..00000000 --- a/packages/cli/src/auth/workspace-name.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createManagementApiClient } from "@prisma/management-api-sdk"; -import type { FetchWorkspaceName } from "./credential-manager"; - -/** The manager's injected name lookup: a static-token client over the - * credential just minted. The manager constructs no API client and - * treats any failure here as "no name". */ -export function fetchWorkspaceName(apiBaseUrl: string): FetchWorkspaceName { - return async (credential, workspaceId) => { - const client = createManagementApiClient({ - baseUrl: apiBaseUrl, - token: credential.token, - }); - const { data } = await client.GET("/v1/workspaces/{id}", { - params: { path: { id: workspaceId } }, - }); - const name = data?.data?.name; - return typeof name === "string" && name.trim().length > 0 - ? name.trim() - : undefined; - }; -} diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 2b7b6cac..e649d3e6 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -14,13 +14,19 @@ import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { resolveAgentSetupTipCommand } from "./agent-setup-tip"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; -import { sessionLabel } from "./session-ref"; +import { + type SessionUser, + sessionLabel, + sessionUser, + sessionUserLabel, +} from "./session-ref"; const TITLE = "Starting an authenticated CLI session."; const LOGIN_STEP = "Sign in via your browser"; export interface LoginResult { readonly workspace: { readonly id: string; readonly name: string | null }; + readonly user: SessionUser | null; readonly environmentCredentialInForce: boolean; } @@ -77,8 +83,10 @@ function presentationsFor( }, result: LoginResult, ): Presentations { + const user = sessionUserLabel(spec.session); const rows = [ { label: "status", value: "signed in" }, + ...(user === undefined ? [] : [{ label: "user", value: user }]), { label: "workspace", value: sessionLabel(spec.session) }, ]; return { @@ -153,6 +161,7 @@ export const authLoginCommand = defineCommand({ id: session.workspaceId, name: session.workspaceName ?? null, }, + user: sessionUser(session), environmentCredentialInForce: environmentSession, }; return ok( diff --git a/packages/cli/src/commands/auth/session-ref.ts b/packages/cli/src/commands/auth/session-ref.ts index faf522c2..7d3ce478 100644 --- a/packages/cli/src/commands/auth/session-ref.ts +++ b/packages/cli/src/commands/auth/session-ref.ts @@ -8,6 +8,7 @@ */ import { noSessionForWorkspaceError, type Session } from "@prisma/cli-engine"; import { CliStructuredError } from "@prisma/cli-engine/protocol"; +import { sessionIdentity } from "../../auth/credential-manager"; import { CLI_NAME } from "../../cli-name"; export type SessionRefResolution = @@ -15,6 +16,37 @@ export type SessionRefResolution = | { readonly kind: "no-match" } | { readonly kind: "ambiguous"; readonly matches: readonly Session[] }; +export interface SessionUser { + readonly id: string | null; + readonly email: string | null; + readonly name: string | null; +} + +/** The safe identity fields a command may expose for a stored session. */ +export function sessionUser(session: Session): SessionUser | null { + const identity = sessionIdentity(session); + if (identity === undefined) return null; + return { + id: identity.userId ?? null, + email: identity.email ?? null, + name: identity.name ?? null, + }; +} + +/** The shortest useful human identity for a workspace session. */ +export function sessionUserLabel(session: Session): string | undefined { + const identity = sessionIdentity(session); + return identity?.email ?? identity?.name ?? identity?.userId; +} + +/** Workspace first, account second: suitable for interactive choices. */ +export function sessionChoiceLabel(session: Session): string { + const user = sessionUserLabel(session); + return user === undefined + ? sessionLabel(session) + : `${sessionLabel(session)} — ${user}`; +} + /** Exact workspace id first, then case-insensitive workspace name. */ export function resolveSessionRef( sessions: readonly Session[], @@ -47,8 +79,21 @@ export function ambiguousSessionRefError( "AUTH.WORKSPACE_AMBIGUOUS", `More than one workspace session is named '${ref}'.`, { - why: `Matching workspaces: ${matches.map((match) => match.workspaceId).join(", ")}.`, - meta: { workspaceIds: matches.map((match) => match.workspaceId) }, + why: `Matching sessions: ${matches + .map((match) => { + const user = sessionUserLabel(match); + return user === undefined + ? match.workspaceId + : `${match.workspaceId} (${user})`; + }) + .join(", ")}.`, + meta: { + workspaceIds: matches.map((match) => match.workspaceId), + sessions: matches.map((match) => ({ + workspaceId: match.workspaceId, + user: sessionUser(match), + })), + }, nextActions: [ { kind: "run-command", diff --git a/packages/cli/src/commands/auth/workspace-list.ts b/packages/cli/src/commands/auth/workspace-list.ts index 75d5792a..bcd44c42 100644 --- a/packages/cli/src/commands/auth/workspace-list.ts +++ b/packages/cli/src/commands/auth/workspace-list.ts @@ -5,14 +5,15 @@ import { type Session, } from "@prisma/cli-engine"; import { type NextAction, ok } from "@prisma/cli-engine/protocol"; +import { sessionsForDisplay } from "../../auth/credential-manager"; import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; -import { sessionLabel } from "./session-ref"; +import { sessionLabel, sessionUser, sessionUserLabel } from "./session-ref"; const LOGIN_NEXT_ACTION: NextAction = { kind: "run-command", - label: "Sign in", + label: "Authorize a workspace", command: `${CLI_NAME} auth login`, }; @@ -25,12 +26,14 @@ export interface WorkspaceListResult { export function serializeWorkspaceList(result: WorkspaceListResult) { return { context: { + scope: "local-sessions" as const, environmentCredentialInForce: result.environmentCredentialInForce, currentWorkspaceId: result.selectedWorkspaceId ?? null, }, items: result.sessions.map((session) => ({ workspaceId: session.workspaceId, workspaceName: session.workspaceName ?? null, + user: sessionUser(session), current: session.workspaceId === result.selectedWorkspaceId, expiresAt: session.expiresAt?.toISOString() ?? null, })), @@ -39,9 +42,10 @@ export function serializeWorkspaceList(result: WorkspaceListResult) { } function listPresentations(result: WorkspaceListResult): Presentations { - const columns = ["name", "id", "status"]; + const columns = ["workspace", "user", "id", "status"]; const rows = result.sessions.map((session) => [ sessionLabel(session), + sessionUserLabel(session) ?? "", session.workspaceId, session.workspaceId === result.selectedWorkspaceId ? "current" : "", ]); @@ -71,7 +75,17 @@ function listPresentations(result: WorkspaceListResult): Presentations { ] : [{ kind: "table", columns, rows } as const]), ], - stdout: () => rows.map((row) => row.join(" ").trimEnd()), + // Scripts read these columns by position, so the optional user stays out. + stdout: () => + result.sessions.map((session) => + [ + sessionLabel(session), + session.workspaceId, + session.workspaceId === result.selectedWorkspaceId ? "current" : "", + ] + .join(" ") + .trimEnd(), + ), json: () => serializeWorkspaceList(result), next: () => (result.sessions.length === 0 ? [LOGIN_NEXT_ACTION] : []), }; @@ -86,7 +100,7 @@ export const authWorkspaceListCommand = defineCommand({ examples: ["auth workspace list", "auth workspace list --json"], }, handler: async (_args, ctx) => { - const stored = await ctx.credentialManager.sessions(); + const stored = await sessionsForDisplay(ctx.credentialManager); const result: WorkspaceListResult = { sessions: stored.sessions, selectedWorkspaceId: stored.selectedWorkspaceId, diff --git a/packages/cli/src/commands/auth/workspace-logout.ts b/packages/cli/src/commands/auth/workspace-logout.ts index 90fc048f..cd4e6db3 100644 --- a/packages/cli/src/commands/auth/workspace-logout.ts +++ b/packages/cli/src/commands/auth/workspace-logout.ts @@ -8,22 +8,33 @@ import { ok } from "@prisma/cli-engine/protocol"; import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; -import { requireSession, sessionLabel } from "./session-ref"; +import { + requireSession, + type SessionUser, + sessionLabel, + sessionUser, + sessionUserLabel, +} from "./session-ref"; export interface WorkspaceLogoutResult { readonly workspace: { readonly id: string; readonly name: string | null }; + readonly user: SessionUser | null; readonly wasSelected: boolean; } function logoutPresentations( spec: { readonly label: string; + readonly user: string | undefined; readonly wasSelected: boolean; readonly environmentCredentialInForce: boolean; }, result: WorkspaceLogoutResult, ): Presentations { - const rows = [{ label: "workspace", value: spec.label }]; + const rows = [ + { label: "workspace", value: spec.label }, + ...(spec.user === undefined ? [] : [{ label: "user", value: spec.user }]), + ]; return { json: () => result, human: () => [ @@ -96,6 +107,7 @@ export const authWorkspaceLogoutCommand = defineCommand({ id: session.workspaceId, name: session.workspaceName ?? null, }, + user: sessionUser(session), wasSelected, }; return ok( @@ -104,6 +116,7 @@ export const authWorkspaceLogoutCommand = defineCommand({ logoutPresentations( { label: sessionLabel(session), + user: sessionUserLabel(session), wasSelected, environmentCredentialInForce: environmentCredentialInForce(ctx.env), }, diff --git a/packages/cli/src/commands/auth/workspace-use.ts b/packages/cli/src/commands/auth/workspace-use.ts index 4ee2b825..fa0a4ad0 100644 --- a/packages/cli/src/commands/auth/workspace-use.ts +++ b/packages/cli/src/commands/auth/workspace-use.ts @@ -1,6 +1,7 @@ /** The `auth workspace use` command: it SELECTS among the sessions you * have — it never creates one, and never opens a browser. */ import { + type CredentialManager, defineCommand, type Presentations, positional, @@ -8,13 +9,23 @@ import { type StoredSessions, } from "@prisma/cli-engine"; import { CliStructuredError, ok } from "@prisma/cli-engine/protocol"; +import { sessionsForDisplay } from "../../auth/credential-manager"; import { environmentCredentialInForce } from "../../auth/service-token"; import { CLI_NAME } from "../../cli-name"; import { ENVIRONMENT_CREDENTIAL_NOTICE } from "./credential-card"; -import { requireSession, sessionLabel } from "./session-ref"; +import { + requireSession, + resolveSessionRef, + type SessionUser, + sessionChoiceLabel, + sessionLabel, + sessionUser, + sessionUserLabel, +} from "./session-ref"; export interface WorkspaceUseResult { readonly workspace: { readonly id: string; readonly name: string | null }; + readonly user: SessionUser | null; readonly previousWorkspaceId: string | null; } @@ -42,11 +53,13 @@ function usePresentations( }, result: WorkspaceUseResult, ): Presentations { + const user = sessionUserLabel(spec.session); const rows = [ ...(spec.previous === undefined ? [] : [{ label: "previous", value: sessionLabel(spec.previous) }]), { label: "workspace", value: sessionLabel(spec.session) }, + ...(user === undefined ? [] : [{ label: "user", value: user }]), ]; return { json: () => result, @@ -105,11 +118,13 @@ export const authWorkspaceUseCommand = defineCommand({ examples: ["auth workspace use", "auth workspace use my-workspace"], }, handler: async (args, ctx) => { - const stored = await ctx.credentialManager.sessions(); + const ref = args.positionals.workspace?.trim(); + const stored = ref + ? await sessionsForRef(ctx.credentialManager, ref) + : await sessionsForDisplay(ctx.credentialManager); if (stored.sessions.length === 0) { throw noWorkspaceSessionsError(); } - const ref = args.positionals.workspace?.trim(); const chosen = ref ? requireSession(stored.sessions, ref) : await promptForSession(stored, ctx.prompt.select); @@ -125,6 +140,7 @@ export const authWorkspaceUseCommand = defineCommand({ id: session.workspaceId, name: session.workspaceName ?? null, }, + user: sessionUser(session), previousWorkspaceId: previous?.workspaceId ?? null, }; return ok( @@ -143,6 +159,20 @@ export const authWorkspaceUseCommand = defineCommand({ }, }); +/** An explicit ref is a local switch: it resolves against stored state and + * waits for no lookup. Only a ref that matches nothing looks metadata up, + * because a session saved before names were stored may be the one meant. */ +async function sessionsForRef( + manager: CredentialManager, + ref: string, +): Promise { + const stored = await manager.sessions(); + if (resolveSessionRef(stored.sessions, ref).kind !== "no-match") { + return stored; + } + return sessionsForDisplay(manager); +} + async function promptForSession( stored: StoredSessions, select: ( @@ -157,7 +187,7 @@ async function promptForSession( "Select a workspace", stored.sessions.map((session) => ({ value: session.workspaceId, - label: `${sessionLabel(session)} (${session.workspaceId})${ + label: `${sessionChoiceLabel(session)} (${session.workspaceId})${ session.workspaceId === stored.selectedWorkspaceId ? " current" : "" }`, })), diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 1c0b6b42..c214578b 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -16,12 +16,12 @@ import { } from "./auth/client"; import { FileCredentialManager } from "./auth/credential-manager"; import { makeCredentialRefresher } from "./auth/refresh"; +import { fetchSessionMetadata } from "./auth/session-metadata"; import { DEPRECATED_STATE_FILE_ENV_VAR, resolveStateFilePath, STATE_FILE_ENV_VAR, } from "./auth/state-file"; -import { fetchWorkspaceName } from "./auth/workspace-name"; import { getCliVersion } from "./lib/version"; import { runPackageManager } from "./package-manager-runner"; import { makeSpawnChild } from "./spawn"; @@ -138,7 +138,7 @@ export async function assembleRuntime(proc: HostProcess): Promise { loadConfig(proc.cwd(), configPath, getCliVersion()), credentialManager: new FileCredentialManager({ env: proc.env, - fetchWorkspaceName: fetchWorkspaceName(apiBaseUrl), + fetchSessionMetadata: fetchSessionMetadata(apiBaseUrl), refreshCredential: makeCredentialRefresher(authBaseUrl), }), managementApiClientConfig: { diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 2a07e33d..18f10c0e 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -7,11 +7,9 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { - type ActiveCredential, type Credential, defineCommand, type ManagementApiClient, - type Session, } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { @@ -20,7 +18,6 @@ import { type SessionRecord, } from "@prisma/cli-engine/testing"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - import { performLogin, storeLegacyCredential } from "../src/auth/operations"; import { authLoginCommand } from "../src/commands/auth/login"; import { authLogoutCommand } from "../src/commands/auth/logout"; @@ -28,6 +25,7 @@ import { authWhoamiCommand } from "../src/commands/auth/whoami"; import { authWorkspaceListCommand } from "../src/commands/auth/workspace-list"; import { authWorkspaceLogoutCommand } from "../src/commands/auth/workspace-logout"; import { authWorkspaceUseCommand } from "../src/commands/auth/workspace-use"; +import { attachAccountMetadata } from "./helpers/account-aware-credential-manager"; vi.mock("../src/auth/operations", async (importOriginal) => ({ ...(await importOriginal()), @@ -56,11 +54,17 @@ function tokenFor( return mintTestJwt({ workspace_id: workspaceId, ...claims }); } -function credentialFor(workspaceId: string) { +function credentialFor( + workspaceId: string, + user: { readonly id: string; readonly email: string } = { + id: "usr_456", + email: "bob@example.com", + }, +) { return { token: tokenFor(workspaceId, { - sub: "usr_456", - email: "bob@example.com", + sub: user.id, + email: user.email, }), refreshToken: `refresh_${workspaceId}`, expiresAt: undefined, @@ -70,11 +74,12 @@ function credentialFor(workspaceId: string) { function record( workspaceId: string, workspaceName: string | undefined, + user?: { readonly id: string; readonly email: string }, ): SessionRecord { return { workspaceId, workspaceName, - credential: credentialFor(workspaceId), + credential: credentialFor(workspaceId, user), }; } @@ -106,10 +111,11 @@ function makeCli(spec?: { readonly client?: ManagementApiClient; readonly openUrl?: (url: string) => void; }) { - return createTestCli({ + const sessions = spec?.sessions ?? []; + const cli = createTestCli({ commands: COMMANDS, groups: GROUPS, - sessions: spec?.sessions ?? [], + sessions, selectedWorkspaceId: spec?.selectedWorkspaceId, environmentCredential: spec?.environmentToken === undefined @@ -119,6 +125,10 @@ function makeCli(spec?: { openUrl: spec?.openUrl, now: () => new Date(0), }); + if (cli.credentialManager !== undefined) { + attachAccountMetadata(cli.credentialManager, sessions); + } + return cli; } type ResultFrame = { @@ -172,6 +182,11 @@ describe("auth login", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_1", name: null }, + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, environmentCredentialInForce: false, }); const state = cli.credentialManager?.state(); @@ -202,6 +217,7 @@ describe("auth login", () => { }); expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("user: bob@example.com"); expect(result.stderr).toContain( "PRISMA_SERVICE_TOKEN supplies the credential in force", ); @@ -456,6 +472,7 @@ describe("auth workspace list", () => { expect(resultOf(result)).toEqual({ context: { + scope: "local-sessions", environmentCredentialInForce: false, currentWorkspaceId: "ws_1", }, @@ -463,6 +480,11 @@ describe("auth workspace list", () => { { workspaceId: "ws_1", workspaceName: "Acme Inc", + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, current: true, expiresAt: null, }, @@ -471,6 +493,30 @@ describe("auth workspace list", () => { }); }); + it("uses null when a legacy session token carries no user identity", async () => { + const cli = makeCli({ + sessions: [ + { + workspaceId: "ws_legacy", + workspaceName: "Legacy workspace", + credential: { + token: tokenFor("ws_legacy"), + refreshToken: "refresh_legacy", + expiresAt: undefined, + }, + }, + ], + selectedWorkspaceId: "ws_legacy", + }); + + const result = await cli.run(["auth", "workspace", "list", "--json"]); + + expect(resultOf(result)).toMatchObject({ + items: [{ workspaceId: "ws_legacy", user: null }], + }); + expect(result.stdout).not.toContain("undefined"); + }); + it("states that the environment credential is in force", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc")], @@ -488,18 +534,85 @@ describe("auth workspace list", () => { }); }); - it("offers sign-in when there are no sessions", async () => { + it("offers workspace authorization when there are no sessions", async () => { const result = await makeCli().run(["auth", "workspace", "list", "--json"]); expect(result.exitCode).toBe(0); expect(result.presented?.presentation.next).toEqual([ { kind: "run-command", - label: "Sign in", + label: "Authorize a workspace", command: "prisma auth login", }, ]); }); + + it("distinguishes sessions from different users and offers no next action", async () => { + const cli = makeCli({ + sessions: [ + record("ws_personal", "Personal workspace", { + id: "usr_personal", + email: "personal@example.com", + }), + record("ws_work", "Prisma DevRel", { + id: "usr_work", + email: "developer@prisma.io", + }), + ], + selectedWorkspaceId: "ws_work", + }); + + const result = await cli.run(["auth", "workspace", "list", "--json"]); + + expect(resultOf(result)).toMatchObject({ + context: { scope: "local-sessions", currentWorkspaceId: "ws_work" }, + items: [ + { + workspaceId: "ws_personal", + user: { id: "usr_personal", email: "personal@example.com" }, + current: false, + }, + { + workspaceId: "ws_work", + user: { id: "usr_work", email: "developer@prisma.io" }, + current: true, + }, + ], + }); + expect(result.presented?.presentation.next).toEqual([]); + }); + + it("lists what the metadata lookup returns", async () => { + const cli = makeCli({ + sessions: [record("ws_legacy", undefined)], + selectedWorkspaceId: "ws_legacy", + }); + const enrichSessions = vi.fn(async () => ({ + sessions: [ + { + workspaceId: "ws_legacy", + workspaceName: "Acme Inc", + identity: { userId: "usr_1", email: "alice@example.com" }, + expiresAt: undefined, + }, + ], + selectedWorkspaceId: "ws_legacy", + })); + Object.assign(cli.credentialManager, { enrichSessions }); + + const result = await cli.run(["auth", "workspace", "list", "--json"]); + + expect(enrichSessions).toHaveBeenCalledTimes(1); + expect(resultOf(result)).toMatchObject({ + items: [ + { + workspaceId: "ws_legacy", + workspaceName: "Acme Inc", + user: { id: "usr_1", email: "alice@example.com", name: null }, + }, + ], + }); + }); }); describe("auth workspace use", () => { @@ -519,6 +632,11 @@ describe("auth workspace use", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_2", name: "Globex" }, + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, previousWorkspaceId: "ws_1", }); expect(cli.credentialManager?.state().selectedWorkspaceId).toBe("ws_2"); @@ -539,6 +657,59 @@ describe("auth workspace use", () => { expect(cli.credentialManager?.state().selectedWorkspaceId).toBe("ws_2"); }); + it("switches to an explicit workspace without a network lookup", async () => { + const cli = makeCli({ sessions: twoSessions, selectedWorkspaceId: "ws_1" }); + const enrichSessions = vi.fn(async () => { + throw new Error("Metadata lookup must not block a local switch"); + }); + Object.assign(cli.credentialManager, { enrichSessions }); + + const result = await cli.run([ + "auth", + "workspace", + "use", + "Globex", + "--json", + ]); + + expect(result.exitCode).toBe(0); + expect(cli.credentialManager?.state().selectedWorkspaceId).toBe("ws_2"); + expect(enrichSessions).not.toHaveBeenCalled(); + }); + + it("looks metadata up when the workspace matches no stored name", async () => { + const cli = makeCli({ + sessions: [record("ws_1", "Acme Inc"), record("ws_legacy", undefined)], + selectedWorkspaceId: "ws_1", + }); + const enrichSessions = vi.fn(async () => { + const stored = await cli.credentialManager?.sessions(); + return { + selectedWorkspaceId: stored?.selectedWorkspaceId, + sessions: (stored?.sessions ?? []).map((session) => + session.workspaceId === "ws_legacy" + ? { ...session, workspaceName: "Globex" } + : session, + ), + }; + }); + Object.assign(cli.credentialManager, { enrichSessions }); + + const result = await cli.run([ + "auth", + "workspace", + "use", + "Globex", + "--json", + ]); + + expect(result.exitCode).toBe(0); + expect(enrichSessions).toHaveBeenCalledTimes(1); + expect(cli.credentialManager?.state().selectedWorkspaceId).toBe( + "ws_legacy", + ); + }); + it("refuses an ambiguous name, listing the workspaces that matched", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc"), record("ws_9", "Acme Inc")], @@ -622,11 +793,15 @@ describe("auth workspace use", () => { }); describe("auth workspace logout", () => { - it("ends the named session and prints the workspace it ended", async () => { + it("ends the named session using local metadata without a network lookup", async () => { const cli = makeCli({ sessions: [record("ws_1", "Acme Inc"), record("ws_2", "Globex")], selectedWorkspaceId: "ws_2", }); + const enrichSessions = vi.fn(async () => { + throw new Error("Metadata lookup must not block logout"); + }); + Object.assign(cli.credentialManager, { enrichSessions }); const result = await cli.run([ "auth", @@ -639,11 +814,17 @@ describe("auth workspace logout", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_1", name: "Acme Inc" }, + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, wasSelected: false, }); expect( cli.credentialManager?.state().sessions.map((s) => s.workspaceId), ).toEqual(["ws_2"]); + expect(enrichSessions).not.toHaveBeenCalled(); }); it("clears the current marker when the ended session was current", async () => { @@ -729,6 +910,11 @@ describe("auth workspace logout", () => { expect(result.exitCode).toBe(0); expect(resultOf(result)).toEqual({ workspace: { id: "ws_1", name: "Acme Inc" }, + user: { + id: "usr_456", + email: "bob@example.com", + name: null, + }, wasSelected: true, }); expect(manager.state().sessions).toEqual([]); @@ -919,25 +1105,4 @@ describe("the shapes the commands hand back", () => { expect(run.stderr).not.toContain(secret); } }); - - it("exposes no token on the shapes the commands see", () => { - const session: Session = { - workspaceId: "ws_1", - workspaceName: "Acme Inc", - expiresAt: undefined, - }; - const active: ActiveCredential = { - workspaceId: "ws_1", - workspaceName: "Acme Inc", - expiresAt: undefined, - identity: { - userId: "usr_456", - email: "bob@example.com", - name: undefined, - }, - origin: { source: "stored" }, - }; - expect(Object.keys(session)).not.toContain("token"); - expect(Object.keys(active)).not.toContain("token"); - }); }); diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index ed15458c..aeaf41ce 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -18,7 +18,10 @@ import type { CredentialRefresher, TokenStorage } from "@prisma/cli-engine"; import { mintTestJwt } from "@prisma/cli-engine/testing"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { FileCredentialManager } from "../src/auth/credential-manager"; +import { + type FetchSessionMetadata, + FileCredentialManager, +} from "../src/auth/credential-manager"; import { readCredentialState } from "../src/auth/state-file"; import { getAuthContextFilePath } from "../src/auth/token-storage"; @@ -86,17 +89,14 @@ function credentialFor(workspaceId: string, refreshToken = "refresh-1") { function makeManager( options: { env?: Record; - fetchWorkspaceName?: ( - credential: { token: string }, - workspaceId: string, - ) => Promise; + fetchSessionMetadata?: FetchSessionMetadata; refreshCredential?: CredentialRefresher; debugWrite?: (text: string) => void; } = {}, ) { return new FileCredentialManager({ env: { PRISMA_AUTH_FILE: stateFilePath, ...options.env }, - fetchWorkspaceName: options.fetchWorkspaceName, + fetchSessionMetadata: options.fetchSessionMetadata, refreshCredential: options.refreshCredential, debugWrite: options.debugWrite, }); @@ -795,6 +795,72 @@ describe("the environment credential", () => { }); describe("createSession", () => { + it("persists and exposes the authorizing account without exposing token material", async () => { + const manager = makeManager({ + fetchSessionMetadata: async () => ({ + workspaceName: " Workspace A ", + user: { + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }, + }), + }); + const credential = { + // Real OAuth tokens identify the user but do not necessarily carry the + // email needed to distinguish accounts in workspace-session output. + token: mintToken(WORKSPACE_A, { sub: "user:opaque-subject" }), + refreshToken: "refresh-work", + expiresAt: undefined, + }; + + const created = await manager.createSession(credential, WORKSPACE_A); + const listed = (await manager.sessions()).sessions[0]; + + expect(created.workspaceName).toBe("Workspace A"); + expect(created.identity).toEqual({ + userId: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }); + expect(listed?.identity).toEqual(created.identity); + expect( + (await readCredentialState(stateFilePath)).sessions[0]?.user, + ).toEqual({ + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }); + expect(Object.keys(created)).not.toContain("token"); + expect(Object.keys(listed ?? {})).not.toContain("token"); + }); + + it("falls back to credential claims when account enrichment fails", async () => { + const manager = makeManager({ + fetchSessionMetadata: async () => { + throw new Error("offline"); + }, + }); + const session = await manager.createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:claimed" }), + refreshToken: "refresh-work", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + + expect(session.workspaceName).toBeUndefined(); + expect(session.identity).toEqual({ + userId: "user:claimed", + email: undefined, + name: undefined, + }); + expect( + (await readCredentialState(stateFilePath)).sessions[0]?.user, + ).toBeUndefined(); + }); + it("refuses a credential whose workspace_id claim names another workspace", async () => { const manager = makeManager(); await expect( @@ -803,16 +869,16 @@ describe("createSession", () => { expect(await readRawState()).toBeNull(); }); - it("holds no lock while the workspace name is fetched", async () => { + it("holds no lock while session metadata is fetched", async () => { let releaseFetch: () => void = () => {}; const fetchStarted = new Promise((resolve) => { const manager = makeManager({ - fetchWorkspaceName: async () => { + fetchSessionMetadata: async () => { resolve(); await new Promise((done) => { releaseFetch = done; }); - return "Workspace A"; + return { workspaceName: "Workspace A" }; }, }); void manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); @@ -836,46 +902,100 @@ describe("createSession", () => { ]); }); - it("keeps login working when the name lookup fails", async () => { + it.each([ + true, + false, + ])("rejects login ended during lookup (metadata returned: %s)", async (hasMetadata) => { + let releaseFetch: () => void = () => {}; + let markFetchStarted: () => void = () => {}; + const fetchStarted = new Promise((resolve) => { + markFetchStarted = resolve; + }); const manager = makeManager({ - fetchWorkspaceName: async () => { - throw new Error("offline"); + fetchSessionMetadata: async () => { + markFetchStarted(); + await new Promise((done) => { + releaseFetch = done; + }); + return hasMetadata ? { workspaceName: "Workspace A" } : undefined; }, }); - const session = await manager.createSession( + const login = manager.createSession( credentialFor(WORKSPACE_A), WORKSPACE_A, ); - expect(session.workspaceName).toBeUndefined(); + const rejected = expect(login).rejects.toMatchObject({ + code: "CLI.CREDENTIALS_REQUIRED", + message: "The workspace session this command was using has ended.", + }); + + await fetchStarted; + await makeManager().endSession(WORKSPACE_A); + releaseFetch(); + + await rejected; + expect((await readCredentialState(stateFilePath)).sessions).toEqual([]); }); - it("does not resurrect a record ended while the name was fetched", async () => { + it.each([ + true, + false, + ])("returns the concurrently replaced session (metadata returned: %s)", async (hasMetadata) => { let releaseFetch: () => void = () => {}; + let markFetchStarted: () => void = () => {}; const fetchStarted = new Promise((resolve) => { - const manager = makeManager({ - fetchWorkspaceName: async () => { - resolve(); - await new Promise((done) => { - releaseFetch = done; - }); - return "Workspace A"; - }, - }); - void manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + markFetchStarted = resolve; }); + const first = makeManager({ + fetchSessionMetadata: async () => { + markFetchStarted(); + await new Promise((resolve) => { + releaseFetch = resolve; + }); + return hasMetadata + ? { + workspaceName: "Stale Workspace", + user: { + id: "usr_first", + email: "first@example.com", + }, + } + : undefined; + }, + }); + const firstLogin = first.createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:first" }), + refreshToken: "refresh-first", + expiresAt: undefined, + }, + WORKSPACE_A, + ); await fetchStarted; - await makeManager().endSession(WORKSPACE_A); + await makeManager().createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:second" }), + refreshToken: "refresh-second", + expiresAt: undefined, + }, + WORKSPACE_A, + ); releaseFetch(); + expect((await firstLogin).identity?.userId).toBe("user:second"); - await vi.waitFor(async () => { - expect((await readCredentialState(stateFilePath)).sessions).toEqual([]); - }); + const state = await readCredentialState(stateFilePath); + expect(state.sessions[0]).toMatchObject({ refreshToken: "refresh-second" }); + expect(state.sessions[0]?.user).toBeUndefined(); + expect(state.sessions[0]?.name).toBeUndefined(); + expect((await makeManager().sessions()).sessions[0]?.identity?.userId).toBe( + "user:second", + ); }); it("upserts by workspace id, keeping the stored name and moving the marker", async () => { const manager = makeManager({ - fetchWorkspaceName: async () => "Workspace A", + fetchSessionMetadata: async () => ({ workspaceName: "Workspace A" }), }); await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); await manager.createSession(credentialFor(WORKSPACE_B), WORKSPACE_B); @@ -895,10 +1015,198 @@ describe("createSession", () => { }); }); +describe("enrichSessions", () => { + it("caches account metadata without replacing an existing workspace name", async () => { + await makeManager({ + fetchSessionMetadata: async () => ({ workspaceName: "Saved name" }), + }).createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:legacy" }), + refreshToken: "refresh-legacy", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + const fetchSessionMetadata = vi.fn(async () => ({ + workspaceName: "Different name", + user: { + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }, + })); + const manager = makeManager({ fetchSessionMetadata }); + + const first = await manager.enrichSessions(); + const second = await manager.enrichSessions(); + + expect(first.sessions[0]?.workspaceName).toBe("Saved name"); + expect(first.sessions[0]?.identity).toEqual({ + userId: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }); + expect(second).toEqual(first); + expect(fetchSessionMetadata).toHaveBeenCalledTimes(1); + expect( + (await readCredentialState(stateFilePath)).sessions[0]?.user, + ).toEqual({ + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }); + }); + + it.each([ + true, + false, + ])("persists name-only metadata (account already stored: %s)", async (hasAccount) => { + const user = { + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }; + await makeManager({ + fetchSessionMetadata: async () => ({ + user: hasAccount ? user : undefined, + }), + }).createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); + const manager = makeManager({ + fetchSessionMetadata: async () => ({ workspaceName: "Workspace A" }), + }); + + const enriched = await manager.enrichSessions(); + const stored = await readCredentialState(stateFilePath); + + expect(enriched.sessions[0]?.workspaceName).toBe("Workspace A"); + expect(stored.sessions[0]?.name).toBe("Workspace A"); + expect(stored.sessions[0]?.user).toEqual( + hasAccount + ? { + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + } + : undefined, + ); + }); + + it("discards enrichment when another process replaces or removes a session", async () => { + await seedTwoSessions(); + let markStarted: () => void = () => {}; + let release: () => void = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + const manager = makeManager({ + fetchSessionMetadata: async () => { + markStarted(); + await released; + return { + workspaceName: "Stale name", + user: { + id: "usr_stale", + email: "stale@example.com", + }, + }; + }, + }); + const pending = manager.enrichSessions(); + await started; + const other = makeManager(); + await other.createSession( + { + ...credentialFor(WORKSPACE_A), + token: mintToken(WORKSPACE_A, { sub: "user:replacement" }), + }, + WORKSPACE_A, + ); + await other.endSession(WORKSPACE_B); + const current = await other.sessions(); + const raw = await readRawState(); + release(); + + expect(await pending).toEqual(current); + expect(await readRawState()).toBe(raw); + }); + + it("returns local sessions when metadata enrichment fails", async () => { + await makeManager().createSession( + { + token: mintToken(WORKSPACE_A, { sub: "user:legacy" }), + refreshToken: "refresh-legacy", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + const manager = makeManager({ + fetchSessionMetadata: async () => { + throw new Error("offline"); + }, + }); + + const stored = await manager.enrichSessions(); + + expect(stored.sessions[0]?.identity?.userId).toBe("user:legacy"); + expect( + (await readCredentialState(stateFilePath)).sessions[0]?.user, + ).toBeUndefined(); + }); + + it("makes no request for a session whose access token has expired", async () => { + await makeManager().createSession( + { + token: mintToken(WORKSPACE_A), + refreshToken: "refresh-1", + expiresAt: new Date(Date.now() - 60_000), + }, + WORKSPACE_A, + ); + const fetchSessionMetadata = vi.fn(async () => ({ + workspaceName: "Workspace A", + })); + + const stored = await makeManager({ fetchSessionMetadata }).enrichSessions(); + + expect(fetchSessionMetadata).not.toHaveBeenCalled(); + expect(stored.sessions[0]?.workspaceName).toBeUndefined(); + }); + + it("makes no request for a named session whose token belongs to no user", async () => { + await makeManager({ + fetchSessionMetadata: async () => ({ workspaceName: "Workspace A" }), + }).createSession( + { + token: mintToken(WORKSPACE_A, { sub: `workspace:${WORKSPACE_A}` }), + refreshToken: "refresh-1", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + const fetchSessionMetadata = vi.fn(async () => ({ + workspaceName: "Workspace A", + })); + + await makeManager({ fetchSessionMetadata }).enrichSessions(); + + expect(fetchSessionMetadata).not.toHaveBeenCalled(); + }); +}); + describe("the file-backed TokenStorage", () => { it("writes only the token fields on rotation and re-derives the expiry", async () => { const manager = makeManager({ - fetchWorkspaceName: async () => "Workspace A", + fetchSessionMetadata: async () => ({ + workspaceName: "Workspace A", + user: { + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }, + }), }); await manager.createSession(credentialFor(WORKSPACE_A), WORKSPACE_A); await makeManager().createSession(credentialFor(WORKSPACE_B), WORKSPACE_B); @@ -916,6 +1224,11 @@ describe("the file-backed TokenStorage", () => { ); expect(record).toMatchObject({ name: "Workspace A", + user: { + id: "usr_work", + email: "developer@prisma.io", + name: "Prisma Developer", + }, token: rotated, refreshToken: "refresh-2", expiresAt: new Date(2_000_000_000 * 1000).toISOString(), diff --git a/packages/cli/tests/golden-rendering.test.ts b/packages/cli/tests/golden-rendering.test.ts index b31c0adf..3bd72bbf 100644 --- a/packages/cli/tests/golden-rendering.test.ts +++ b/packages/cli/tests/golden-rendering.test.ts @@ -24,13 +24,18 @@ import { authLogoutCommand } from "../src/commands/auth/logout"; import { authWorkspaceListCommand } from "../src/commands/auth/workspace-list"; import { authWorkspaceLogoutCommand } from "../src/commands/auth/workspace-logout"; import { bucketKeyCreateCommand } from "../src/commands/bucket/key-create"; +import { attachAccountMetadata } from "./helpers/account-aware-credential-manager"; function record(workspaceId: string, workspaceName: string): SessionRecord { return { workspaceId, workspaceName, credential: { - token: mintTestJwt({ workspace_id: workspaceId }), + token: mintTestJwt({ + workspace_id: workspaceId, + sub: `usr_${workspaceId}`, + email: `${workspaceId}@example.com`, + }), refreshToken: `refresh_${workspaceId}`, expiresAt: undefined, }, @@ -42,7 +47,7 @@ function makeCli( current?: string, client?: ManagementApiClient, ) { - return createTestCli({ + const cli = createTestCli({ commands: { "auth logout": authLogoutCommand, "auth workspace list": authWorkspaceListCommand, @@ -60,6 +65,10 @@ function makeCli( ...(client === undefined ? {} : { managementApi: { client } }), now: () => new Date(0), }); + if (cli.credentialManager !== undefined) { + attachAccountMetadata(cli.credentialManager, sessions); + } + return cli; } const CREATED_KEY = { @@ -112,9 +121,9 @@ describe("golden rendering", () => { expect(result.stderr).toBe( "ℹ Listing your workspace sessions on this machine.\n" + "\n" + - "Name Id Status\n" + - "Acme Inc ws_1 current\n" + - "Globex ws_2 \u2014\n", + "Workspace User Id Status\n" + + "Acme Inc ws_1@example.com ws_1 current\n" + + "Globex ws_2@example.com ws_2 \u2014\n", ); expect(result.stdout).toBe("Acme Inc ws_1 current\nGlobex ws_2\n"); }); @@ -163,7 +172,7 @@ describe("golden rendering", () => { expect(result.exitCode).toBe(2); expect(result.stderr).toBe( "✘ [AUTH.WORKSPACE_AMBIGUOUS] More than one workspace session is named 'Acme Inc'.\n" + - " why: Matching workspaces: ws_1, ws_9.\n" + + " why: Matching sessions: ws_1 (ws_1@example.com), ws_9 (ws_9@example.com).\n" + "→ List your workspace sessions and pass a workspace id: prisma auth workspace list\n", ); expect(result.stdout).toBe(""); @@ -207,9 +216,9 @@ describe("golden rendering", () => { expect(result.stderr).toBe( "\u001b[34m\u2139\u001b[39m Listing your workspace sessions on this machine.\n" + "\n" + - "\u001b[36mName \u001b[39m \u001b[36mId \u001b[39m \u001b[36mStatus\u001b[39m\n" + - "Acme Inc ws_1 current\n" + - "Globex ws_2 \u2014\n", + "\u001b[36mWorkspace\u001b[39m \u001b[36mUser \u001b[39m \u001b[36mId \u001b[39m \u001b[36mStatus\u001b[39m\n" + + "Acme Inc ws_1@example.com ws_1 current\n" + + "Globex ws_2@example.com ws_2 \u2014\n", ); }); }); diff --git a/packages/cli/tests/helpers/account-aware-credential-manager.ts b/packages/cli/tests/helpers/account-aware-credential-manager.ts new file mode 100644 index 00000000..34a1a9be --- /dev/null +++ b/packages/cli/tests/helpers/account-aware-credential-manager.ts @@ -0,0 +1,49 @@ +import { type CredentialManager, claimedIdentity } from "@prisma/cli-engine"; +import type { SessionRecord } from "@prisma/cli-engine/testing"; + +import type { AccountStoredSessions } from "../../src/auth/credential-manager"; + +/** The engine test manager deliberately models only the shared session + * contract. CLI auth tests add this package-local display capability to match + * FileCredentialManager without expanding the published engine API. */ +export function attachAccountMetadata( + manager: CredentialManager, + records: readonly SessionRecord[], +): void { + const identities = new Map( + records.map((record) => [ + record.workspaceId, + claimedIdentity(record.credential.token), + ]), + ); + const sessions = manager.sessions.bind(manager); + const createSession = manager.createSession.bind(manager); + const selectSession = manager.selectSession.bind(manager); + + Object.assign(manager, { + sessions: async (): Promise => { + const stored = await sessions(); + return { + sessions: stored.sessions.map((session) => ({ + ...session, + identity: identities.get(session.workspaceId), + })), + selectedWorkspaceId: stored.selectedWorkspaceId, + }; + }, + createSession: async ( + ...args: Parameters + ) => { + const session = await createSession(...args); + const identity = claimedIdentity(args[0].token); + identities.set(args[1], identity); + return { ...session, identity }; + }, + selectSession: async ( + ...args: Parameters + ) => { + const session = await selectSession(...args); + return { ...session, identity: identities.get(session.workspaceId) }; + }, + }); +} diff --git a/packages/cli/tests/session-metadata.test.ts b/packages/cli/tests/session-metadata.test.ts new file mode 100644 index 00000000..38f060ce --- /dev/null +++ b/packages/cli/tests/session-metadata.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { fetchSessionMetadata } from "../src/auth/session-metadata"; +import { + FAKE_WORKSPACE_API_ID, + type FakeManagementApi, + startFakeManagementApi, +} from "./helpers/fake-management-api"; + +const CREDENTIAL = { + token: "test-access-token", + refreshToken: undefined, + expiresAt: undefined, +}; + +let api: FakeManagementApi | undefined; + +afterEach(async () => { + await api?.close(); + api = undefined; +}); + +describe("login session metadata", () => { + it("resolves the workspace name and authorizing account in one request", async () => { + api = await startFakeManagementApi(); + + const metadata = await fetchSessionMetadata(api.baseUrl)(CREDENTIAL); + + expect(metadata).toEqual({ + workspaceName: "Acme Inc", + user: { + id: "usr_456", + email: "dev@example.com", + name: "Dev", + }, + }); + expect(api.requests).toEqual(["GET /v1/me"]); + }); + + it("keeps the workspace name when a service credential has no user", async () => { + api = await startFakeManagementApi({ + routes: { + "GET /v1/me": () => ({ + data: { + user: null, + workspace: { id: FAKE_WORKSPACE_API_ID, name: "Acme Inc" }, + credential: { type: "service", id: "skey_123", name: "CI" }, + }, + }), + }, + }); + + expect(await fetchSessionMetadata(api.baseUrl)(CREDENTIAL)).toEqual({ + workspaceName: "Acme Inc", + user: undefined, + }); + expect(api.requests).toEqual(["GET /v1/me"]); + }); +});