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
72 changes: 72 additions & 0 deletions packages/cli/src/auth/legacy-state.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,82 @@
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";
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<void> {
const contextFilePath = getAuthContextFilePath(authFilePath);
const context = await readLegacyContext(contextFilePath);
if (context.exists && context.activeWorkspaceId === currentWorkspaceId) {
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
// 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;
Expand Down
16 changes: 13 additions & 3 deletions packages/cli/src/auth/state-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -188,21 +192,26 @@ 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<void> {
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
// under a name nothing later looks for.
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();
Expand All @@ -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 {
Expand Down
203 changes: 202 additions & 1 deletion packages/cli/tests/credential-manager-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, { name?: string }>;
};
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<string, { name?: string }>;
};
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",
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<string, { name?: string }>;
};
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,
});
});
});
7 changes: 7 additions & 0 deletions packages/cli/tests/credential-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$`),
Expand All @@ -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")),
Expand Down
Loading