Skip to content
Draft
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
55 changes: 55 additions & 0 deletions docs/product/output-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,61 @@ 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

An OAuth login authorizes one workspace and stores one local workspace session.
Running `auth login` again may add another session, including a session owned by
a different Prisma user. Therefore, `auth workspace list` describes the
workspace sessions authorized on this machine; it must not present them as the
complete list of workspaces visible in Console for the currently selected user.

At login, the CLI resolves the authorizing user through `/v1/me` and persists
only its safe id, email, and name alongside the session. This lookup is
best-effort and never prevents login. Existing state remains compatible; when
stored metadata is unavailable, the CLI falls back to identity claims in the
access token. Session-list and session-selection commands also attempt this
enrichment for older records and cache successful results. This is an explicit
best-effort operation; the credential manager's ordinary `sessions()` read
remains local-only.

Human workspace-session output shows the user email next to every workspace
when one is known. Selection prompts use the same identity so a user can
distinguish same-named workspaces and sessions belonging to different
accounts. When no email is known, output falls back to the user's name and then
id. Tables render the standard unknown-value marker when no user identity is
available, while selection prompts omit an identity they do not know.

Structured workspace-session output includes a nullable `user` object on every
item. Its `context.scope` is `"local-sessions"`, making it explicit that the
collection is not a complete remote membership list:

```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"
}
]
}
```

The `user` object is `null` when neither stored metadata nor token claims carry
a user identity. Individual user fields use `null` when unavailable. Token
material never reaches either output mode. `auth workspace list` always offers
`auth login` as the structured next action: it authorizes the first workspace
when the list is empty and another workspace when sessions already exist.

### One-Time Secret Output

Commands that create one-time-view secrets may write the raw secret value to
Expand Down
219 changes: 203 additions & 16 deletions packages/cli/src/auth/credential-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import type {
ActiveAccessTokenOptions,
ActiveCredential,
Credential,
CredentialIdentity,
CredentialManager,
CredentialRefresher,
Session,
StoredSessions,
TokenStorage,
} from "@prisma/cli-engine";
import {
Expand All @@ -28,6 +28,7 @@ import {
readCredentialState,
resolveStateFilePath,
type StoredSession,
type StoredSessionUser,
withRefreshFileLock,
withStateLock,
writeCredentialState,
Expand All @@ -50,9 +51,64 @@ export type FetchWorkspaceName = (
workspaceId: string,
) => Promise<string | undefined>;

/** Looks up safe account metadata for the credential that was just minted.
* Best-effort: a failed lookup never prevents the session from being saved. */
export type FetchSessionIdentity = (
credential: Credential,
workspaceId: string,
) => Promise<CredentialIdentity | 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<AccountStoredSessions> {
if (isAccountAwareCredentialManager(manager)) {
return manager.enrichSessions();
}
const stored = await manager.sessions();
return {
sessions: stored.sessions.map(asAccountSession),
selectedWorkspaceId: stored.selectedWorkspaceId,
};
}

export function sessionIdentity(
session: Session,
): CredentialIdentity | undefined {
return normalizedIdentity(
Reflect.get(session, "identity") as CredentialIdentity | undefined,
);
}

function isAccountAwareCredentialManager(
manager: CredentialManager,
): manager is AccountAwareCredentialManager {
return typeof Reflect.get(manager, "enrichSessions") === "function";
}

function asAccountSession(session: Session): AccountSession {
return { ...session, identity: sessionIdentity(session) };
}

export interface FileCredentialManagerOptions {
readonly env: Readonly<Record<string, string | undefined>>;
readonly fetchWorkspaceName?: FetchWorkspaceName;
readonly fetchSessionIdentity?: FetchSessionIdentity;
readonly refreshCredential?: CredentialRefresher;
readonly debugWrite?: (text: string) => void;
}
Expand Down Expand Up @@ -115,6 +171,7 @@ export class FileCredentialManager implements CredentialManager {
readonly #filePath: string;
readonly #debug: DebugLog;
readonly #fetchWorkspaceName: FetchWorkspaceName | undefined;
readonly #fetchSessionIdentity: FetchSessionIdentity | undefined;
readonly #refreshCredential: CredentialRefresher | undefined;
#actingAs: ActingAs = { kind: "unresolved" };
/** Built for the credential the process acts as. Every mutation that
Expand All @@ -129,6 +186,7 @@ export class FileCredentialManager implements CredentialManager {
this.#filePath = resolveStateFilePath(options.env).filePath;
this.#debug = makeDebugLog(options.env, options.debugWrite);
this.#fetchWorkspaceName = options.fetchWorkspaceName;
this.#fetchSessionIdentity = options.fetchSessionIdentity;
this.#refreshCredential = options.refreshCredential;
this.#debug(`state file ${this.#filePath}`);
}
Expand Down Expand Up @@ -159,18 +217,65 @@ 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.#fetchSessionIdentity === undefined) return this.sessions();
const state = await readCredentialState(this.#filePath);
const candidates = state.sessions.filter(
(session) => session.user === undefined,
);
if (candidates.length === 0) return storedSessions(state);

const fetched = await Promise.all(
candidates.map(async (session) => ({
workspaceId: session.workspaceId,
token: session.token,
identity: await this.#lookUpSessionIdentity(
storedSessionCredential(session),
session.workspaceId,
),
})),
);
const byWorkspaceId = new Map(
fetched
.filter(
(
result,
): result is typeof result & { identity: CredentialIdentity } =>
result.identity !== undefined,
)
.map((result) => [result.workspaceId, result] as const),
);
if (byWorkspaceId.size === 0) return this.sessions();

return this.#mutate((current) => {
let changed = false;
const sessions = current.sessions.map((session) => {
const fetchedSession = byWorkspaceId.get(session.workspaceId);
if (
session.user !== undefined ||
fetchedSession === undefined ||
fetchedSession.token !== session.token
) {
return session;
}
changed = true;
return { ...session, user: storedUser(fetchedSession.identity) };
});
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) {
Expand Down Expand Up @@ -207,26 +312,37 @@ export class FileCredentialManager implements CredentialManager {
this.#actAs({ kind: "session", workspaceId });
}

const name = await this.#lookUpWorkspaceName(credential, workspaceId);
if (name === undefined) return created;
const [name, identity] = await Promise.all([
this.#lookUpWorkspaceName(credential, workspaceId),
this.#lookUpSessionIdentity(credential, workspaceId),
]);
if (name === undefined && identity === undefined) return created;

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 || record.token !== credential.token) {
return { result: created };
}
const enriched: StoredSession = {
...record,
...(name === undefined ? {} : { name }),
...(identity === undefined ? {} : { user: storedUser(identity) }),
};
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 +478,7 @@ export class FileCredentialManager implements CredentialManager {
const rotated: StoredSession = {
workspaceId: record.workspaceId,
...(record.name === undefined ? {} : { name: record.name }),
...storedUserSlice(record.user),
token: tokens.accessToken,
...(tokens.refreshToken === undefined
? {}
Expand Down Expand Up @@ -522,6 +639,20 @@ export class FileCredentialManager implements CredentialManager {
}
}

async #lookUpSessionIdentity(
credential: Credential,
workspaceId: string,
): Promise<CredentialIdentity | undefined> {
if (this.#fetchSessionIdentity === undefined) return undefined;
try {
return normalizedIdentity(
await this.#fetchSessionIdentity(credential, workspaceId),
);
} catch {
return undefined;
}
}

/** One mutation: the short lock, a fresh read, one slice, one atomic
* write. A slice that returns no state writes nothing. */
async #mutate<T>(
Expand Down Expand Up @@ -578,6 +709,15 @@ function expiresAtSlice(
return expiresAt === undefined ? {} : { expiresAt: expiresAt.toISOString() };
}

function storedSessionCredential(record: StoredSession): Credential {
return {
token: record.token,
refreshToken: record.refreshToken,
expiresAt:
record.expiresAt === undefined ? undefined : new Date(record.expiresAt),
};
}

/** The selection the manager will admit to: one that names a stored
* session, or none. A dangling selection never escapes. */
function resolvedMarker(state: CredentialState): string | null {
Expand All @@ -591,10 +731,18 @@ function resolvedMarker(state: CredentialState): string | null {
return null;
}

function toSession(record: StoredSession): Session {
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 +754,50 @@ 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 };
}

function storedUser(identity: CredentialIdentity): StoredSessionUser {
return {
...(identity.userId === undefined ? {} : { id: identity.userId }),
...(identity.email === undefined ? {} : { email: identity.email }),
...(identity.name === undefined ? {} : { name: identity.name }),
};
}

function storedUserSlice(user: StoredSessionUser | undefined): {
user?: StoredSessionUser;
} {
return user === undefined ? {} : { user };
}

function normalizedIdentity(
identity: CredentialIdentity | undefined,
): CredentialIdentity | undefined {
if (identity === undefined) return undefined;
const userId = normalizedString(identity.userId);
const email = normalizedString(identity.email);
const name = normalizedString(identity.name);
if (userId === undefined && email === undefined && name === undefined) {
return undefined;
}
return { userId, email, name };
}

function normalizedString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

/** 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