Skip to content

Commit 16b665e

Browse files
ankur-archclaude
andauthored
Sessions this CLI writes stay visible to the 3.x CLI sharing the store (#212)
## What this PR does Sessions written by this CLI stay visible to the 3.x CLI that shares the same auth store. Today our first write silently logs the 3.x CLI out. Merging this closes #204. ## The bug Both CLI lines point at one auth store (`auth.json` plus `auth.context.json`) but disagree about its shape: - The 3.x CLI (`@prisma/cli@latest`, 3.0.0-beta.30) reads sessions from a top-level `tokens` array (`data.tokens || []`), selected by the context file's `activeWorkspaceId`. - This CLI writes `{ version, sessions, currentWorkspaceId }`, and it writes the whole file. Adoption of the legacy store is deliberately a pure read, so 3.x sessions keep working until this CLI's first mutation. For someone who only runs read commands, that first mutation is the background token refresh. After it, 3.x reports `authenticated: false` with no error anywhere: its reader simply finds an empty array. ## The fix One write choke point gains a legacy mirror. `writeCredentialState`: 1. also serializes the sessions in the legacy record shape under `tokens` (`{ workspaceId, token, refreshToken? }`), and 2. keeps `auth.context.json`'s `activeWorkspaceId` in step with `currentWorkspaceId`, preserving the remembered-workspace name map. Every mutation flows through this function (login, refresh, select, end session, logout), 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`. This is also the behavior the code already intended: `auth/operations.ts` carries an unwired `storeLegacyCredential` helper written for exactly this purpose. Hardening from the review rounds: the context file writes via temp plus rename (a torn context makes 3.x silently self-activate its latest session), and an empty state with no pre-existing context file does not materialize one (an existing null pointer reads as "signed out" to 3.x, an absent file does not). ## Proof End-to-end against the real published 3.x binary, using a store refreshed through `activeCredentialStorage().setTokens` (the exact write the background refresh performs): ``` store written by main: auth whoami -> authenticated: false store written by this branch: auth whoami -> authenticated: true, workspace wksp_e2e ``` Regression tests read the store exactly as the 3.x CLI does: refresh keeps the session visible, create and select move the pointer, endSession preserves or clears the legacy view, and the mirror stays invisible to our own reader. The core two fail without the fix. Full suite: 985 passing, `tsc --noEmit` and `pnpm lint` clean. ## Scope Two small functions in `legacy-state.ts` (the module that owns legacy-format knowledge) plus two lines in `writeCredentialState`. No changes to the credential manager, the lock protocol, or the schema this CLI reads. Known pre-existing gaps (the `project transfer` path writing through `@prisma/credentials-store`, dead `performLogout` code) are documented in the PR comments as follow-ups. Fixes #204. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9287a64 commit 16b665e

4 files changed

Lines changed: 294 additions & 4 deletions

File tree

packages/cli/src/auth/legacy-state.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,82 @@
1+
import { randomUUID } from "node:crypto";
12
import fs from "node:fs/promises";
23
import { claimedExpiresAt, credentialWorkspaceId } from "@prisma/cli-engine";
34
import type { CredentialState, StoredSession } from "./state-file";
45
import { getAuthContextFilePath } from "./token-storage";
56

67
const LEGACY_PLACEHOLDER_NAME = "Unknown workspace";
78

9+
/**
10+
* The sessions re-serialized in the legacy store's record shape. The
11+
* 3.x CLI reads `tokens` from auth.json (`data.tokens || []`, silently
12+
* empty for any other shape), so a write that dropped the key made
13+
* every session invisible to `@prisma/cli@latest` on the same machine
14+
* the moment this CLI first mutated the file (#204). Sessions without
15+
* a refresh token still mirror; the legacy reader skips them, exactly
16+
* as it skips its own unrefreshable records.
17+
*/
18+
export function legacyTokensMirror(
19+
sessions: readonly StoredSession[],
20+
): readonly { workspaceId: string; token: string; refreshToken?: string }[] {
21+
return sessions.map((session) => ({
22+
workspaceId: session.workspaceId,
23+
token: session.token,
24+
...(session.refreshToken === undefined
25+
? {}
26+
: { refreshToken: session.refreshToken }),
27+
}));
28+
}
29+
30+
/**
31+
* Keeps auth.context.json's `activeWorkspaceId` — the pointer the 3.x
32+
* CLI selects its session with — in step with `currentWorkspaceId`.
33+
* The rest of the context file (the remembered-workspace name map) is
34+
* preserved verbatim; only the pointer moves.
35+
*/
36+
export async function syncLegacyContext(
37+
authFilePath: string,
38+
currentWorkspaceId: string | null,
39+
): Promise<void> {
40+
const contextFilePath = getAuthContextFilePath(authFilePath);
41+
const context = await readLegacyContext(contextFilePath);
42+
if (context.exists && context.activeWorkspaceId === currentWorkspaceId) {
43+
return;
44+
}
45+
// No file and nothing selected stays no file: an existing context
46+
// with a null pointer reads as "explicitly signed out" to the 3.x
47+
// CLI, where an absent one lets it self-activate its latest session.
48+
if (!context.exists && currentWorkspaceId === null) {
49+
return;
50+
}
51+
const raw = await fs.readFile(contextFilePath, "utf8").catch(() => null);
52+
let workspaces: unknown = {};
53+
if (raw !== null) {
54+
try {
55+
const parsed = JSON.parse(raw) as { workspaces?: unknown };
56+
if (
57+
typeof parsed.workspaces === "object" &&
58+
parsed.workspaces !== null &&
59+
!Array.isArray(parsed.workspaces)
60+
) {
61+
workspaces = parsed.workspaces;
62+
}
63+
} catch {
64+
// A corrupt context file is replaced with a fresh one.
65+
}
66+
}
67+
// Temp + rename like the auth file itself: a torn context file makes
68+
// the 3.x CLI silently self-activate its latest session.
69+
const tempPath = `${contextFilePath}.${randomUUID()}.tmp`;
70+
const payload = `${JSON.stringify({ activeWorkspaceId: currentWorkspaceId, workspaces }, null, 2)}\n`;
71+
try {
72+
await fs.writeFile(tempPath, payload, "utf8");
73+
await fs.rename(tempPath, contextFilePath);
74+
} catch (error) {
75+
await fs.unlink(tempPath).catch(() => {});
76+
throw error;
77+
}
78+
}
79+
880
interface LegacyContext {
981
readonly exists: boolean;
1082
readonly activeWorkspaceId: string | null;

packages/cli/src/auth/state-file.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import fs from "node:fs/promises";
44
import path from "node:path";
55
import { CliStructuredError } from "@prisma/cli-engine/protocol";
66
import { defaultAuthFilePath } from "./client";
7-
import { adoptLegacyState } from "./legacy-state";
7+
import {
8+
adoptLegacyState,
9+
legacyTokensMirror,
10+
syncLegacyContext,
11+
} from "./legacy-state";
812

913
export const STATE_FILE_ENV_VAR = "PRISMA_AUTH_FILE";
1014
export const DEPRECATED_STATE_FILE_ENV_VAR = "PRISMA_COMPUTE_AUTH_FILE";
@@ -188,21 +192,26 @@ function normalizeSession(session: StoredSession): StoredSession {
188192
}
189193

190194
/** Temp file in the same directory, fsync, rename, mode 0600 — a reader
191-
* only ever sees a complete state. */
195+
* only ever sees a complete state. The written file also carries the
196+
* legacy `tokens` mirror and the auth.context.json pointer stays in
197+
* step, so the 3.x CLI sharing this store keeps seeing the sessions
198+
* (#204). Our own reader branches on `sessions` before it ever looks
199+
* at `tokens`, so the mirror is invisible to this CLI. */
192200
export async function writeCredentialState(
193201
filePath: string,
194202
state: CredentialState,
195203
): Promise<void> {
196204
await fs.mkdir(path.dirname(filePath), { recursive: true });
197205
const tempPath = `${filePath}.${randomUUID()}.tmp`;
206+
const payload = { ...state, tokens: legacyTokensMirror(state.sessions) };
198207
// The temp file holds the whole state, tokens included, so no path
199208
// out of here may leave one behind: a write that fails after the
200209
// handle is open would otherwise strand a working credential copy
201210
// under a name nothing later looks for.
202211
try {
203212
const handle = await fs.open(tempPath, "wx", FILE_MODE);
204213
try {
205-
await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8");
214+
await handle.writeFile(`${JSON.stringify(payload, null, 2)}\n`, "utf8");
206215
await handle.sync();
207216
} finally {
208217
await handle.close();
@@ -213,6 +222,7 @@ export async function writeCredentialState(
213222
throw error;
214223
}
215224
await fs.chmod(filePath, FILE_MODE).catch(() => {});
225+
await syncLegacyContext(filePath, state.currentWorkspaceId);
216226
}
217227

218228
class StateLockTimeoutError extends CliStructuredError {

packages/cli/tests/credential-manager-migration.test.ts

Lines changed: 202 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { mintTestJwt } from "@prisma/cli-engine/testing";
99
import { beforeEach, describe, expect, it } from "vitest";
1010

1111
import { FileCredentialManager } from "../src/auth/credential-manager";
12-
import { readCredentialState } from "../src/auth/state-file";
12+
import {
13+
EMPTY_STATE,
14+
readCredentialState,
15+
writeCredentialState,
16+
} from "../src/auth/state-file";
1317
import { getAuthContextFilePath } from "../src/auth/token-storage";
1418

1519
const WORKSPACE_A = "wksp_a";
@@ -291,3 +295,200 @@ describe("adopting the legacy store", () => {
291295
await unlink(authFilePath);
292296
});
293297
});
298+
299+
describe("the legacy mirror", () => {
300+
/** Reads the store exactly as the 3.x CLI does (#204): sessions come
301+
* from auth.json's `tokens` array (`data.tokens || []`), selected by
302+
* auth.context.json's `activeWorkspaceId`, and a record without a
303+
* workspaceId, token, and refreshToken is skipped. */
304+
async function readAsLegacyCli() {
305+
const data = JSON.parse(await readFile(authFilePath, "utf8")) as {
306+
tokens?: unknown[];
307+
};
308+
const tokens = data.tokens || [];
309+
const context = JSON.parse(await readFile(contextFilePath, "utf8")) as {
310+
activeWorkspaceId?: string | null;
311+
workspaces?: Record<string, { name?: string }>;
312+
};
313+
const active = context.activeWorkspaceId;
314+
if (!active) return null;
315+
const credential = tokens.find(
316+
(entry) => (entry as { workspaceId?: string })?.workspaceId === active,
317+
) as
318+
| { workspaceId: string; token?: string; refreshToken?: string }
319+
| undefined;
320+
if (!credential?.token || !credential.refreshToken) return null;
321+
return {
322+
workspaceId: credential.workspaceId,
323+
accessToken: credential.token,
324+
refreshToken: credential.refreshToken,
325+
};
326+
}
327+
328+
it("a token refresh keeps the session visible to the 3.x reader", async () => {
329+
await writeLegacyStore([legacyEntry(WORKSPACE_A, "legacy-refresh")]);
330+
await writeLegacyContext({
331+
activeWorkspaceId: WORKSPACE_A,
332+
workspaces: { [WORKSPACE_A]: { name: "Alpha" } },
333+
});
334+
335+
const manager = makeManager();
336+
await manager.activeCredential();
337+
const storage = await manager.activeCredentialStorage();
338+
const rotatedToken = mintToken(WORKSPACE_A);
339+
await storage.setTokens({
340+
workspaceId: WORKSPACE_A,
341+
accessToken: rotatedToken,
342+
refreshToken: "rotated-refresh",
343+
});
344+
345+
expect(await readAsLegacyCli()).toEqual({
346+
workspaceId: WORKSPACE_A,
347+
accessToken: rotatedToken,
348+
refreshToken: "rotated-refresh",
349+
});
350+
351+
const context = JSON.parse(await readFile(contextFilePath, "utf8")) as {
352+
workspaces: Record<string, { name?: string }>;
353+
};
354+
expect(context.workspaces[WORKSPACE_A]?.name).toBe("Alpha");
355+
});
356+
357+
it("creating and selecting sessions moves the 3.x active pointer with them", async () => {
358+
const manager = makeManager();
359+
const tokenA = mintToken(WORKSPACE_A);
360+
const tokenB = mintToken(WORKSPACE_B);
361+
await manager.createSession(
362+
{ token: tokenA, refreshToken: "ra", expiresAt: undefined },
363+
WORKSPACE_A,
364+
);
365+
await manager.createSession(
366+
{ token: tokenB, refreshToken: "rb", expiresAt: undefined },
367+
WORKSPACE_B,
368+
);
369+
370+
expect((await readAsLegacyCli())?.workspaceId).toBe(WORKSPACE_B);
371+
372+
await manager.selectSession(WORKSPACE_A);
373+
expect(await readAsLegacyCli()).toEqual({
374+
workspaceId: WORKSPACE_A,
375+
accessToken: tokenA,
376+
refreshToken: "ra",
377+
});
378+
});
379+
380+
it("the mirror is invisible to this CLI's own reader", async () => {
381+
const manager = makeManager();
382+
await manager.createSession(
383+
{
384+
token: mintToken(WORKSPACE_A),
385+
refreshToken: "r",
386+
expiresAt: undefined,
387+
},
388+
WORKSPACE_A,
389+
);
390+
391+
const state = await readCredentialState(authFilePath);
392+
expect(Object.keys(state)).toEqual([
393+
"version",
394+
"sessions",
395+
"currentWorkspaceId",
396+
]);
397+
expect(state.sessions).toHaveLength(1);
398+
});
399+
});
400+
401+
describe("the legacy mirror's context sync", () => {
402+
it("a pointer move preserves the remembered-workspace name map", async () => {
403+
await writeLegacyStore([
404+
legacyEntry(WORKSPACE_A, "ra"),
405+
legacyEntry(WORKSPACE_B, "rb"),
406+
]);
407+
await writeLegacyContext({
408+
activeWorkspaceId: WORKSPACE_A,
409+
workspaces: {
410+
[WORKSPACE_A]: { name: "Alpha" },
411+
[WORKSPACE_B]: { name: "Bravo" },
412+
},
413+
});
414+
415+
await makeManager().selectSession(WORKSPACE_B);
416+
417+
const context = JSON.parse(await readFile(contextFilePath, "utf8")) as {
418+
activeWorkspaceId: string | null;
419+
workspaces: Record<string, { name?: string }>;
420+
};
421+
expect(context.activeWorkspaceId).toBe(WORKSPACE_B);
422+
expect(context.workspaces[WORKSPACE_A]?.name).toBe("Alpha");
423+
expect(context.workspaces[WORKSPACE_B]?.name).toBe("Bravo");
424+
});
425+
426+
it("writes no context file when none exists and nothing is selected", async () => {
427+
await writeCredentialState(authFilePath, EMPTY_STATE);
428+
429+
await expect(readFile(contextFilePath, "utf8")).rejects.toMatchObject({
430+
code: "ENOENT",
431+
});
432+
});
433+
});
434+
435+
describe("ending sessions and the legacy mirror", () => {
436+
async function readLegacyView() {
437+
const data = JSON.parse(await readFile(authFilePath, "utf8")) as {
438+
tokens?: { workspaceId: string }[];
439+
};
440+
const context = JSON.parse(await readFile(contextFilePath, "utf8")) as {
441+
activeWorkspaceId?: string | null;
442+
};
443+
return {
444+
tokenWorkspaces: (data.tokens ?? []).map((entry) => entry.workspaceId),
445+
activeWorkspaceId: context.activeWorkspaceId ?? null,
446+
};
447+
}
448+
449+
it("ending a non-active session keeps the active one visible to the 3.x reader", async () => {
450+
const manager = makeManager();
451+
await manager.createSession(
452+
{
453+
token: mintToken(WORKSPACE_A),
454+
refreshToken: "ra",
455+
expiresAt: undefined,
456+
},
457+
WORKSPACE_A,
458+
);
459+
await manager.createSession(
460+
{
461+
token: mintToken(WORKSPACE_B),
462+
refreshToken: "rb",
463+
expiresAt: undefined,
464+
},
465+
WORKSPACE_B,
466+
);
467+
468+
await manager.endSession(WORKSPACE_A);
469+
470+
expect(await readLegacyView()).toEqual({
471+
tokenWorkspaces: [WORKSPACE_B],
472+
activeWorkspaceId: WORKSPACE_B,
473+
});
474+
});
475+
476+
it("ending the active session clears the 3.x pointer with it", async () => {
477+
const manager = makeManager();
478+
await manager.createSession(
479+
{
480+
token: mintToken(WORKSPACE_A),
481+
refreshToken: "ra",
482+
expiresAt: undefined,
483+
},
484+
WORKSPACE_A,
485+
);
486+
487+
await manager.endSession(WORKSPACE_A);
488+
489+
expect(await readLegacyView()).toEqual({
490+
tokenWorkspaces: [],
491+
activeWorkspaceId: null,
492+
});
493+
});
494+
});

packages/cli/tests/credential-manager.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ describe("the state file", () => {
251251
}
252252

253253
const stateDir = path.dirname(stateFilePath);
254+
// The trailing rename is the legacy auth.context.json mirror, which
255+
// goes through its own temp file in the same directory.
254256
expect(order).toEqual([
255257
expect.stringMatching(
256258
new RegExp(`^open ${escapeForRegExp(stateFilePath)}\\..+\\.tmp$`),
@@ -261,6 +263,11 @@ describe("the state file", () => {
261263
`^rename ${escapeForRegExp(stateFilePath)}\\..+\\.tmp -> ${escapeForRegExp(stateFilePath)}$`,
262264
),
263265
),
266+
expect.stringMatching(
267+
new RegExp(
268+
`\\.tmp -> ${escapeForRegExp(stateDir)}.*\\.context\\.json$`,
269+
),
270+
),
264271
]);
265272
expect(
266273
(await readdir(stateDir)).filter((entry) => entry.endsWith(".tmp")),

0 commit comments

Comments
 (0)