From dd908e0d068d6ea61371e1ead5c8f94625742ad7 Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:14:00 +0200 Subject: [PATCH 1/3] Sessions this CLI writes stay visible to the 3.x CLI sharing the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both CLI lines point at the same auth store (auth.json + auth.context.json), but they disagree about its shape: the 3.x CLI reads sessions from a top-level `tokens` array selected by the context file's `activeWorkspaceId`, while this CLI writes { version, sessions, currentWorkspaceId } and nothing else. Adoption of the legacy store is deliberately a pure read, so a user who logged in with 3.x keeps working — until this CLI's first mutation of the file. For a user who only runs read commands that first mutation is the background token refresh, which rewrites auth.json without the `tokens` key and silently logs the 3.x CLI out (#204): its reader does `data.tokens || []` and reports authenticated: false. The fix is a legacy mirror at the single write choke point. writeCredentialState serializes the sessions a second time in the legacy record shape under `tokens`, and keeps auth.context.json's `activeWorkspaceId` in step with `currentWorkspaceId` (preserving the remembered-workspace name map). Every mutation — login, refresh, select, logout — flows through this function, so all of them stay legacy-visible. The mirror is invisible to this CLI's own reader, which branches on `sessions` before ever looking at `tokens`, so adoption, precedence, and the migration tests are untouched. This is the behavior the code already intended: auth/operations.ts carries an unwired storeLegacyCredential helper whose comment says the legacy store 'dies with the legacy shell' — the shell is still alive on `latest`, and the docs drive users through both CLIs on one machine. Regression tests read the store exactly as the 3.x CLI does (tokens array + active pointer, refreshToken required): a refresh keeps the session visible, create/select move the pointer, and the mirror stays invisible to our own reader. Two of the three fail without the fix. Verification: pnpm --filter @prisma/cli test (981 passing), tsc --noEmit, pnpm lint. Fixes #204. Co-Authored-By: Claude Fable 5 --- packages/cli/src/auth/legacy-state.ts | 59 ++++++++++ packages/cli/src/auth/state-file.ts | 16 ++- .../credential-manager-migration.test.ts | 102 ++++++++++++++++++ 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/auth/legacy-state.ts b/packages/cli/src/auth/legacy-state.ts index 48e76c59..06aa1095 100644 --- a/packages/cli/src/auth/legacy-state.ts +++ b/packages/cli/src/auth/legacy-state.ts @@ -5,6 +5,65 @@ import { getAuthContextFilePath } from "./token-storage"; const LEGACY_PLACEHOLDER_NAME = "Unknown workspace"; +/** + * The sessions re-serialized in the legacy store's record shape. The + * 3.x CLI reads `tokens` from auth.json (`data.tokens || []`, silently + * empty for any other shape), so a write that dropped the key made + * every session invisible to `@prisma/cli@latest` on the same machine + * the moment this CLI first mutated the file (#204). Sessions without + * a refresh token still mirror; the legacy reader skips them, exactly + * as it skips its own unrefreshable records. + */ +export function legacyTokensMirror( + sessions: readonly StoredSession[], +): readonly { workspaceId: string; token: string; refreshToken?: string }[] { + return sessions.map((session) => ({ + workspaceId: session.workspaceId, + token: session.token, + ...(session.refreshToken === undefined + ? {} + : { refreshToken: session.refreshToken }), + })); +} + +/** + * Keeps auth.context.json's `activeWorkspaceId` — the pointer the 3.x + * CLI selects its session with — in step with `currentWorkspaceId`. + * The rest of the context file (the remembered-workspace name map) is + * preserved verbatim; only the pointer moves. + */ +export async function syncLegacyContext( + authFilePath: string, + currentWorkspaceId: string | null, +): Promise { + const contextFilePath = getAuthContextFilePath(authFilePath); + const context = await readLegacyContext(contextFilePath); + if (context.exists && context.activeWorkspaceId === currentWorkspaceId) { + return; + } + const raw = await fs.readFile(contextFilePath, "utf8").catch(() => null); + let workspaces: unknown = {}; + if (raw !== null) { + try { + const parsed = JSON.parse(raw) as { workspaces?: unknown }; + if ( + typeof parsed.workspaces === "object" && + parsed.workspaces !== null && + !Array.isArray(parsed.workspaces) + ) { + workspaces = parsed.workspaces; + } + } catch { + // A corrupt context file is replaced with a fresh one. + } + } + await fs.writeFile( + contextFilePath, + `${JSON.stringify({ activeWorkspaceId: currentWorkspaceId, workspaces }, null, 2)}\n`, + "utf8", + ); +} + interface LegacyContext { readonly exists: boolean; readonly activeWorkspaceId: string | null; diff --git a/packages/cli/src/auth/state-file.ts b/packages/cli/src/auth/state-file.ts index b8fcd6d0..a22f4908 100644 --- a/packages/cli/src/auth/state-file.ts +++ b/packages/cli/src/auth/state-file.ts @@ -4,7 +4,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { CliStructuredError } from "@prisma/cli-engine/protocol"; import { defaultAuthFilePath } from "./client"; -import { adoptLegacyState } from "./legacy-state"; +import { + adoptLegacyState, + legacyTokensMirror, + syncLegacyContext, +} from "./legacy-state"; export const STATE_FILE_ENV_VAR = "PRISMA_AUTH_FILE"; export const DEPRECATED_STATE_FILE_ENV_VAR = "PRISMA_COMPUTE_AUTH_FILE"; @@ -188,13 +192,18 @@ function normalizeSession(session: StoredSession): StoredSession { } /** Temp file in the same directory, fsync, rename, mode 0600 — a reader - * only ever sees a complete state. */ + * only ever sees a complete state. The written file also carries the + * legacy `tokens` mirror and the auth.context.json pointer stays in + * step, so the 3.x CLI sharing this store keeps seeing the sessions + * (#204). Our own reader branches on `sessions` before it ever looks + * at `tokens`, so the mirror is invisible to this CLI. */ export async function writeCredentialState( filePath: string, state: CredentialState, ): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${randomUUID()}.tmp`; + const payload = { ...state, tokens: legacyTokensMirror(state.sessions) }; // The temp file holds the whole state, tokens included, so no path // out of here may leave one behind: a write that fails after the // handle is open would otherwise strand a working credential copy @@ -202,7 +211,7 @@ export async function writeCredentialState( try { const handle = await fs.open(tempPath, "wx", FILE_MODE); try { - await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); + await handle.writeFile(`${JSON.stringify(payload, null, 2)}\n`, "utf8"); await handle.sync(); } finally { await handle.close(); @@ -213,6 +222,7 @@ export async function writeCredentialState( throw error; } await fs.chmod(filePath, FILE_MODE).catch(() => {}); + await syncLegacyContext(filePath, state.currentWorkspaceId); } class StateLockTimeoutError extends CliStructuredError { diff --git a/packages/cli/tests/credential-manager-migration.test.ts b/packages/cli/tests/credential-manager-migration.test.ts index 43241cb4..6d6583f4 100644 --- a/packages/cli/tests/credential-manager-migration.test.ts +++ b/packages/cli/tests/credential-manager-migration.test.ts @@ -291,3 +291,105 @@ describe("adopting the legacy store", () => { await unlink(authFilePath); }); }); + +describe("the legacy mirror", () => { + /** Reads the store exactly as the 3.x CLI does (#204): sessions come + * from auth.json's `tokens` array (`data.tokens || []`), selected by + * auth.context.json's `activeWorkspaceId`, and a record without a + * workspaceId, token, and refreshToken is skipped. */ + async function readAsLegacyCli() { + const data = JSON.parse(await readFile(authFilePath, "utf8")) as { + tokens?: unknown[]; + }; + const tokens = data.tokens || []; + const context = JSON.parse(await readFile(contextFilePath, "utf8")) as { + activeWorkspaceId?: string | null; + workspaces?: Record; + }; + const active = context.activeWorkspaceId; + if (!active) return null; + const credential = tokens.find( + (entry) => (entry as { workspaceId?: string })?.workspaceId === active, + ) as + | { workspaceId: string; token?: string; refreshToken?: string } + | undefined; + if (!credential?.token || !credential.refreshToken) return null; + return { + workspaceId: credential.workspaceId, + accessToken: credential.token, + refreshToken: credential.refreshToken, + }; + } + + it("a token refresh keeps the session visible to the 3.x reader", async () => { + await writeLegacyStore([legacyEntry(WORKSPACE_A, "legacy-refresh")]); + await writeLegacyContext({ + activeWorkspaceId: WORKSPACE_A, + workspaces: { [WORKSPACE_A]: { name: "Alpha" } }, + }); + + const manager = makeManager(); + await manager.activeCredential(); + const storage = await manager.activeCredentialStorage(); + const rotatedToken = mintToken(WORKSPACE_A); + await storage.setTokens({ + workspaceId: WORKSPACE_A, + accessToken: rotatedToken, + refreshToken: "rotated-refresh", + }); + + expect(await readAsLegacyCli()).toEqual({ + workspaceId: WORKSPACE_A, + accessToken: rotatedToken, + refreshToken: "rotated-refresh", + }); + + const context = JSON.parse(await readFile(contextFilePath, "utf8")) as { + workspaces: Record; + }; + expect(context.workspaces[WORKSPACE_A]?.name).toBe("Alpha"); + }); + + it("creating and selecting sessions moves the 3.x active pointer with them", async () => { + const manager = makeManager(); + const tokenA = mintToken(WORKSPACE_A); + const tokenB = mintToken(WORKSPACE_B); + await manager.createSession( + { token: tokenA, refreshToken: "ra", expiresAt: undefined }, + WORKSPACE_A, + ); + await manager.createSession( + { token: tokenB, refreshToken: "rb", expiresAt: undefined }, + WORKSPACE_B, + ); + + expect((await readAsLegacyCli())?.workspaceId).toBe(WORKSPACE_B); + + await manager.selectSession(WORKSPACE_A); + expect(await readAsLegacyCli()).toEqual({ + workspaceId: WORKSPACE_A, + accessToken: tokenA, + refreshToken: "ra", + }); + }); + + it("the mirror is invisible to this CLI's own reader", async () => { + const manager = makeManager(); + await manager.createSession( + { + token: mintToken(WORKSPACE_A), + refreshToken: "r", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + + const state = await readCredentialState(authFilePath); + expect(Object.keys(state)).toEqual([ + "version", + "sessions", + "currentWorkspaceId", + ]); + expect(state.sessions).toHaveLength(1); + }); +}); From 21db503566e61cb6b031eb2eceb67d8800452f80 Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:36:23 +0200 Subject: [PATCH 2/3] Review hardening: atomic context write, and no context file materializes from nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From an independent review of the mirror: - auth.context.json now goes through the same temp+rename as auth.json. A torn context file does not crash either CLI's reader, but the 3.x CLI treats an unreadable context as absent and then self-activates its latest session — a torn write could silently switch its active workspace. - When no context file exists and nothing is selected, none is created. The 3.x CLI reads an existing null pointer as 'explicitly signed out' where an absent file lets it self-activate; an rc store that never had a context should not flip that behavior. - The atomicity test now expects the trailing context rename, and new tests pin that a pointer move preserves the remembered-workspace name map and that an empty write materializes no context file. Considered and rejected: adopting legacy entries via their stored workspaceId when the token carries no claims — the adoption suite deliberately pins claims as the only trusted key ('keys on the workspace_id claim ... ignores undecodable entries'), and every rc session token is a claims-bearing platform JWT. Co-Authored-By: Claude Fable 5 --- packages/cli/src/auth/legacy-state.ts | 23 ++++++++--- .../credential-manager-migration.test.ts | 40 ++++++++++++++++++- packages/cli/tests/credential-manager.test.ts | 7 ++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/auth/legacy-state.ts b/packages/cli/src/auth/legacy-state.ts index 06aa1095..3302a2d0 100644 --- a/packages/cli/src/auth/legacy-state.ts +++ b/packages/cli/src/auth/legacy-state.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import { claimedExpiresAt, credentialWorkspaceId } from "@prisma/cli-engine"; import type { CredentialState, StoredSession } from "./state-file"; @@ -41,6 +42,12 @@ export async function syncLegacyContext( if (context.exists && context.activeWorkspaceId === currentWorkspaceId) { return; } + // No file and nothing selected stays no file: an existing context + // with a null pointer reads as "explicitly signed out" to the 3.x + // CLI, where an absent one lets it self-activate its latest session. + if (!context.exists && currentWorkspaceId === null) { + return; + } const raw = await fs.readFile(contextFilePath, "utf8").catch(() => null); let workspaces: unknown = {}; if (raw !== null) { @@ -57,11 +64,17 @@ export async function syncLegacyContext( // A corrupt context file is replaced with a fresh one. } } - await fs.writeFile( - contextFilePath, - `${JSON.stringify({ activeWorkspaceId: currentWorkspaceId, workspaces }, null, 2)}\n`, - "utf8", - ); + // Temp + rename like the auth file itself: a torn context file makes + // the 3.x CLI silently self-activate its latest session. + const tempPath = `${contextFilePath}.${randomUUID()}.tmp`; + const payload = `${JSON.stringify({ activeWorkspaceId: currentWorkspaceId, workspaces }, null, 2)}\n`; + try { + await fs.writeFile(tempPath, payload, "utf8"); + await fs.rename(tempPath, contextFilePath); + } catch (error) { + await fs.unlink(tempPath).catch(() => {}); + throw error; + } } interface LegacyContext { diff --git a/packages/cli/tests/credential-manager-migration.test.ts b/packages/cli/tests/credential-manager-migration.test.ts index 6d6583f4..2c66d973 100644 --- a/packages/cli/tests/credential-manager-migration.test.ts +++ b/packages/cli/tests/credential-manager-migration.test.ts @@ -9,7 +9,11 @@ import { mintTestJwt } from "@prisma/cli-engine/testing"; import { beforeEach, describe, expect, it } from "vitest"; import { FileCredentialManager } from "../src/auth/credential-manager"; -import { readCredentialState } from "../src/auth/state-file"; +import { + EMPTY_STATE, + readCredentialState, + writeCredentialState, +} from "../src/auth/state-file"; import { getAuthContextFilePath } from "../src/auth/token-storage"; const WORKSPACE_A = "wksp_a"; @@ -393,3 +397,37 @@ describe("the legacy mirror", () => { expect(state.sessions).toHaveLength(1); }); }); + +describe("the legacy mirror's context sync", () => { + it("a pointer move preserves the remembered-workspace name map", async () => { + await writeLegacyStore([ + legacyEntry(WORKSPACE_A, "ra"), + legacyEntry(WORKSPACE_B, "rb"), + ]); + await writeLegacyContext({ + activeWorkspaceId: WORKSPACE_A, + workspaces: { + [WORKSPACE_A]: { name: "Alpha" }, + [WORKSPACE_B]: { name: "Bravo" }, + }, + }); + + await makeManager().selectSession(WORKSPACE_B); + + const context = JSON.parse(await readFile(contextFilePath, "utf8")) as { + activeWorkspaceId: string | null; + workspaces: Record; + }; + expect(context.activeWorkspaceId).toBe(WORKSPACE_B); + expect(context.workspaces[WORKSPACE_A]?.name).toBe("Alpha"); + expect(context.workspaces[WORKSPACE_B]?.name).toBe("Bravo"); + }); + + it("writes no context file when none exists and nothing is selected", async () => { + await writeCredentialState(authFilePath, EMPTY_STATE); + + await expect(readFile(contextFilePath, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); diff --git a/packages/cli/tests/credential-manager.test.ts b/packages/cli/tests/credential-manager.test.ts index 1b3abfcf..ed15458c 100644 --- a/packages/cli/tests/credential-manager.test.ts +++ b/packages/cli/tests/credential-manager.test.ts @@ -251,6 +251,8 @@ describe("the state file", () => { } const stateDir = path.dirname(stateFilePath); + // The trailing rename is the legacy auth.context.json mirror, which + // goes through its own temp file in the same directory. expect(order).toEqual([ expect.stringMatching( new RegExp(`^open ${escapeForRegExp(stateFilePath)}\\..+\\.tmp$`), @@ -261,6 +263,11 @@ describe("the state file", () => { `^rename ${escapeForRegExp(stateFilePath)}\\..+\\.tmp -> ${escapeForRegExp(stateFilePath)}$`, ), ), + expect.stringMatching( + new RegExp( + `\\.tmp -> ${escapeForRegExp(stateDir)}.*\\.context\\.json$`, + ), + ), ]); expect( (await readdir(stateDir)).filter((entry) => entry.endsWith(".tmp")), From 95ac397ae184c28f3001e456f823268f2ce5a52c Mon Sep 17 00:00:00 2001 From: Ankur Datta <64993082+ankur-arch@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:50:40 +0200 Subject: [PATCH 3/3] endSession's legacy view gets its own coverage Ending a non-active session keeps the remaining session and pointer visible to the 3.x reader; ending the active one empties the mirror and nulls the pointer. Co-Authored-By: Claude Fable 5 --- .../credential-manager-migration.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/cli/tests/credential-manager-migration.test.ts b/packages/cli/tests/credential-manager-migration.test.ts index 2c66d973..ddb1e024 100644 --- a/packages/cli/tests/credential-manager-migration.test.ts +++ b/packages/cli/tests/credential-manager-migration.test.ts @@ -431,3 +431,64 @@ describe("the legacy mirror's context sync", () => { }); }); }); + +describe("ending sessions and the legacy mirror", () => { + async function readLegacyView() { + const data = JSON.parse(await readFile(authFilePath, "utf8")) as { + tokens?: { workspaceId: string }[]; + }; + const context = JSON.parse(await readFile(contextFilePath, "utf8")) as { + activeWorkspaceId?: string | null; + }; + return { + tokenWorkspaces: (data.tokens ?? []).map((entry) => entry.workspaceId), + activeWorkspaceId: context.activeWorkspaceId ?? null, + }; + } + + it("ending a non-active session keeps the active one visible to the 3.x reader", async () => { + const manager = makeManager(); + await manager.createSession( + { + token: mintToken(WORKSPACE_A), + refreshToken: "ra", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + await manager.createSession( + { + token: mintToken(WORKSPACE_B), + refreshToken: "rb", + expiresAt: undefined, + }, + WORKSPACE_B, + ); + + await manager.endSession(WORKSPACE_A); + + expect(await readLegacyView()).toEqual({ + tokenWorkspaces: [WORKSPACE_B], + activeWorkspaceId: WORKSPACE_B, + }); + }); + + it("ending the active session clears the 3.x pointer with it", async () => { + const manager = makeManager(); + await manager.createSession( + { + token: mintToken(WORKSPACE_A), + refreshToken: "ra", + expiresAt: undefined, + }, + WORKSPACE_A, + ); + + await manager.endSession(WORKSPACE_A); + + expect(await readLegacyView()).toEqual({ + tokenWorkspaces: [], + activeWorkspaceId: null, + }); + }); +});