Skip to content
Open
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
38 changes: 38 additions & 0 deletions src/server/accountStore.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { loadExternalAccountDraft } from "./authProvider";
import { DATA_DIR, LEGACY_DATA_DIR } from "./config";
import type { Account, AccountStoreData, ProviderConfig } from "./providers/types";

// Stable id for the ephemeral account synthesised from an external credential
// (env token / gh CLI) in non-device auth modes.
const EXTERNAL_ACCOUNT_ID = "external";

const ACCOUNTS_PATH = resolve(DATA_DIR, "accounts.json");
const ACCOUNTS_TMP_PATH = resolve(DATA_DIR, "accounts.json.tmp");
const LEGACY_TOKEN_PATH = resolve(DATA_DIR, "auth.json");
Expand Down Expand Up @@ -180,8 +185,40 @@ async function ensureState(): Promise<InternalState> {
return state;
}

/**
* Seed (or refresh) the ephemeral external account from the current auth mode.
*
* In `token` / `gh-cli` modes the credential never reaches the on-disk store,
* so without this the store stays empty and every data endpoint returns 401
* even though `/api/auth/status` reports the token as authenticated. In
* `device` mode (or when the external token is gone) this is a no-op that also
* drops any previously-seeded external account.
*/
async function ensureExternalAccount(s: InternalState): Promise<void> {
const draft = await loadExternalAccountDraft();
if (!draft) {
s.ephemeral = s.ephemeral.filter((account) => account.id !== EXTERNAL_ACCOUNT_ID);
return;
}
const existing = s.ephemeral.find((account) => account.id === EXTERNAL_ACCOUNT_ID);
const account: Account = {
id: EXTERNAL_ACCOUNT_ID,
providerKind: "github",
providerConfigId: "github.com",
label: draft.login ? `${draft.login} (${draft.source})` : `GitHub (${draft.source})`,
login: draft.login,
accessToken: draft.token,
scope: draft.scope ?? "",
obtainedAt: existing?.obtainedAt ?? new Date().toISOString(),
source: draft.source,
ephemeral: true,
};
s.ephemeral = [...s.ephemeral.filter((entry) => entry.id !== EXTERNAL_ACCOUNT_ID), account];
}

export async function list(): Promise<Account[]> {
const s = await ensureState();
await ensureExternalAccount(s);
return [...s.persisted.accounts, ...s.ephemeral];
}

Expand All @@ -192,6 +229,7 @@ export async function get(id: string): Promise<Account | null> {

export async function getActive(): Promise<Account | null> {
const s = await ensureState();
await ensureExternalAccount(s);
const activeId = s.persisted.activeId;
if (activeId) {
const persisted = s.persisted.accounts.find((account) => account.id === activeId);
Expand Down
35 changes: 35 additions & 0 deletions src/server/authProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,41 @@ async function loadEnvToken(): Promise<CachedToken> {
return envCache;
}

export interface ExternalAccountDraft {
token: string;
login: string | null;
scope: string | null;
source: "env" | "gh-cli";
}

/**
* Resolve an external account draft for the current auth mode.
*
* In `token` and `gh-cli` modes the credential lives outside the on-disk
* account store (env var / gh CLI), so the store is never populated and the
* data layer has no active account to resolve. This exposes the external
* credential as a draft the account store can seed as an ephemeral account.
*
* Returns `null` in `device` mode, or when no external token is available
* (the status endpoint surfaces the underlying error separately).
*/
export async function loadExternalAccountDraft(): Promise<ExternalAccountDraft | null> {
const mode = getAuthMode();
try {
if (mode === "token") {
const cached = await loadEnvToken();
return { token: cached.token, login: cached.login, scope: cached.scope, source: "env" };
}
if (mode === "gh-cli") {
const cached = await loadGhCliToken();
return { token: cached.token, login: cached.login, scope: cached.scope, source: "gh-cli" };
}
} catch {
return null;
}
return null;
}

export async function getActiveAccount(): Promise<Account | null> {
await initAccountStore();
return getActiveAccountFromStore();
Expand Down
48 changes: 48 additions & 0 deletions tests/server/accountStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ vi.mock("../../src/server/config", () => ({
LEGACY_DATA_DIR: LEGACY_TMP_DIR,
}));

vi.mock("../../src/server/authProvider", () => ({
loadExternalAccountDraft: vi.fn(async () => null),
}));

const store = await import("../../src/server/accountStore");
const { loadExternalAccountDraft } = await import("../../src/server/authProvider");
const mockedDraft = loadExternalAccountDraft as unknown as ReturnType<typeof vi.fn>;

const ACCOUNTS_PATH = resolve(TMP_DIR, "accounts.json");
const LEGACY_TOKEN_PATH = resolve(TMP_DIR, "auth.json");
Expand Down Expand Up @@ -50,6 +56,9 @@ describe("accountStore", () => {
beforeEach(async () => {
store.resetForTesting();
await rm(TMP_DIR, { recursive: true, force: true });
// Default to device mode (no external credential) for existing cases.
mockedDraft.mockReset();
mockedDraft.mockResolvedValue(null);
});

it("bootstraps empty when no legacy file exists", async () => {
Expand Down Expand Up @@ -135,6 +144,45 @@ describe("accountStore", () => {
expect(active?.id).toBe(a.id);
});

it("seeds an ephemeral external account in token/gh-cli mode", async () => {
// Regression: in token/gh-cli mode the store is never populated from disk,
// so getActive() returned null and every data endpoint 401'd even though
// /api/auth/status reported the token as authenticated.
mockedDraft.mockResolvedValue({
token: "ghp_regression",
login: "octocat",
scope: "repo, read:org",
source: "env",
});
await store.init();

const active = await store.getActive();
expect(active).not.toBeNull();
expect(active?.id).toBe("external");
expect(active?.accessToken).toBe("ghp_regression");
expect(active?.login).toBe("octocat");
expect(active?.scope).toBe("repo, read:org");
expect(active?.source).toBe("env");
expect(active?.ephemeral).toBe(true);
expect(active?.providerConfigId).toBe("github.com");

const all = await store.list();
expect(all).toHaveLength(1);
// The external account is ephemeral — never written to disk.
expect(await fileExists(ACCOUNTS_PATH)).toBe(false);
});

it("drops the ephemeral external account when the credential disappears", async () => {
mockedDraft.mockResolvedValue({ token: "t", login: "x", scope: "", source: "gh-cli" });
await store.init();
expect((await store.getActive())?.id).toBe("external");

// Token unset / gh logout at runtime -> no draft.
mockedDraft.mockResolvedValue(null);
expect(await store.getActive()).toBeNull();
expect(await store.list()).toEqual([]);
});

it("does not persist ephemeral accounts", async () => {
await store.init();
await store.add({
Expand Down