Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/product/output-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workspace>` 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
207 changes: 170 additions & 37 deletions packages/cli/src/auth/credential-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ActiveAccessTokenOptions,
ActiveCredential,
Credential,
CredentialIdentity,
CredentialManager,
CredentialRefresher,
Session,
Expand All @@ -25,9 +26,11 @@ import {
type DebugLog,
EMPTY_STATE,
makeDebugLog,
normalizeStoredSessionUser,
readCredentialState,
resolveStateFilePath,
type StoredSession,
type StoredSessionUser,
withRefreshFileLock,
withStateLock,
writeCredentialState,
Expand All @@ -43,16 +46,63 @@ type RefreshLock = <T>(fn: () => Promise<T>) => Promise<T>;
* 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<string | undefined>;
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<Credential, "token">,
) => Promise<SessionMetadata | undefined>;

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<AccountStoredSessions>;
}

/** 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<StoredSessions> {
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<Record<string, string | undefined>>;
readonly fetchWorkspaceName?: FetchWorkspaceName;
readonly fetchSessionMetadata?: FetchSessionMetadata;
readonly refreshCredential?: CredentialRefresher;
readonly debugWrite?: (text: string) => void;
}
Expand Down Expand Up @@ -114,7 +164,7 @@ export class FileCredentialManager implements CredentialManager {
readonly #env: Readonly<Record<string, string | undefined>>;
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
Expand All @@ -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}`);
}
Expand Down Expand Up @@ -159,25 +209,67 @@ export class FileCredentialManager implements CredentialManager {
return storedCredential(record);
}

async sessions(): Promise<StoredSessions> {
async sessions(): Promise<AccountStoredSessions> {
const state = await readCredentialState(this.#filePath);
return {
sessions: state.sessions.map((record) => toSession(record)),
selectedWorkspaceId: resolvedMarker(state) ?? undefined,
};
return storedSessions(state);
}

async enrichSessions(): Promise<AccountStoredSessions> {
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<Session> {
): Promise<AccountSession> {
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,
);
Expand All @@ -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<Session> {
async selectSession(workspaceId: string): Promise<AccountSession> {
const environmentInForce = this.#environmentToken() !== undefined;

const selected = await this.#mutate((state) => {
Expand Down Expand Up @@ -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
? {}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -509,14 +612,16 @@ export class FileCredentialManager implements CredentialManager {
);
}

async #lookUpWorkspaceName(
credential: Credential,
workspaceId: string,
): Promise<string | undefined> {
if (this.#fetchWorkspaceName === undefined) return undefined;
async #lookUpSessionMetadata(
credential: Pick<Credential, "token">,
): Promise<SessionMetadata | undefined> {
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;
}
Expand Down Expand Up @@ -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),
};
Expand All @@ -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 {
Expand Down
Loading
Loading