diff --git a/src/server/accountStore.ts b/src/server/accountStore.ts index c1b8978..84bdfa1 100644 --- a/src/server/accountStore.ts +++ b/src/server/accountStore.ts @@ -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"); @@ -180,8 +185,40 @@ async function ensureState(): Promise { 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 { + 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 { const s = await ensureState(); + await ensureExternalAccount(s); return [...s.persisted.accounts, ...s.ephemeral]; } @@ -192,6 +229,7 @@ export async function get(id: string): Promise { export async function getActive(): Promise { const s = await ensureState(); + await ensureExternalAccount(s); const activeId = s.persisted.activeId; if (activeId) { const persisted = s.persisted.accounts.find((account) => account.id === activeId); diff --git a/src/server/authProvider.ts b/src/server/authProvider.ts index aff69d1..ef0949b 100644 --- a/src/server/authProvider.ts +++ b/src/server/authProvider.ts @@ -94,6 +94,41 @@ async function loadEnvToken(): Promise { 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 { + 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 { await initAccountStore(); return getActiveAccountFromStore(); diff --git a/tests/server/accountStore.test.ts b/tests/server/accountStore.test.ts index 335e5f9..f78989b 100644 --- a/tests/server/accountStore.test.ts +++ b/tests/server/accountStore.test.ts @@ -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; const ACCOUNTS_PATH = resolve(TMP_DIR, "accounts.json"); const LEGACY_TOKEN_PATH = resolve(TMP_DIR, "auth.json"); @@ -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 () => { @@ -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({