diff --git a/packages/cli/src/auth/legacy-state.ts b/packages/cli/src/auth/legacy-state.ts index 48e76c59..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"; @@ -5,6 +6,77 @@ 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; + } + // 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) { + 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. + } + } + // 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 { 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..ddb1e024 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"; @@ -291,3 +295,200 @@ 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); + }); +}); + +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", + }); + }); +}); + +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, + }); + }); +}); 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")),