diff --git a/src/lib/copilot-preflight.ts b/src/lib/copilot-preflight.ts new file mode 100644 index 0000000..55e0f0d --- /dev/null +++ b/src/lib/copilot-preflight.ts @@ -0,0 +1,36 @@ +/** + * Pre-flight a GitHub token against Copilot BEFORE adopting it as the active + * account — used by both the gh-reuse adopt path and the multi-account switch + * path, so it lives here rather than in either route module. + * + * Mirrors what boot does (the live identity check): a stale/revoked token → + * GitHub rejects it (401); an account with no Copilot entitlement → + * /copilot_internal/user 403/404. Returns a specific, user-facing message so + * the UI can say WHY synchronously — instead of writing the token, rebooting, + * and surfacing a generic "came back unauthenticated" 20s later. Returns null + * when the token is usable. The `usage` lookup is injectable for tests. + */ + +import { getCopilotUsage } from "~/services/github/get-copilot-usage" + +import { HTTPError } from "./error" + +export async function preflightCopilotError( + token: string, + login: string, + usage: (token: string) => Promise = getCopilotUsage, +): Promise { + try { + await usage(token) + return null + } catch (error) { + const status = error instanceof HTTPError ? error.response.status : 0 + if (status === 401) { + return `GitHub rejected ${login}'s token — it may be expired or revoked. Run \`gh auth login\` and try again, or sign in with a code.` + } + if (status === 403 || status === 404) { + return `${login} doesn't have access to GitHub Copilot. Pick another account, or sign in with a code.` + } + return `Couldn't verify ${login} with GitHub${status ? ` (HTTP ${status})` : ""}. Check your connection and try again.` + } +} diff --git a/src/lib/settings-types.ts b/src/lib/settings-types.ts index 36eb214..81f5bad 100644 --- a/src/lib/settings-types.ts +++ b/src/lib/settings-types.ts @@ -108,6 +108,33 @@ export const AuthStatus = z.object({ }) export type AuthStatus = z.infer +// --------------------------------------------------------------------------- +// Multi-account roster — Settings → Account quick-switch (slice 3). +// +// The persisted accounts maximal can switch between. Tokens are NEVER +// included — only identity + provenance, like the auth status above redacts +// the credential. +// --------------------------------------------------------------------------- + +export const AccountSummary = z.object({ + /** Stable identity key, `login@host`. */ + key: z.string(), + login: z.string(), + host: z.string(), + /** How this account entered the registry. */ + added_via: z.enum(["device-code", "gh-cli", "migration"]), + obtained_at: z.string(), + /** Whether this is the account the proxy is (or will boot) signed in as. */ + active: z.boolean(), +}) +export type AccountSummary = z.infer + +export const AccountsListResponse = z.object({ + accounts: z.array(AccountSummary), + active_key: z.string().nullable(), +}) +export type AccountsListResponse = z.infer + /** * An API-key entry as managed by Settings → API clients. The key value * is returned in full to the local Settings UI — the endpoint is diff --git a/src/routes/settings/accounts.ts b/src/routes/settings/accounts.ts new file mode 100644 index 0000000..47f0b32 --- /dev/null +++ b/src/routes/settings/accounts.ts @@ -0,0 +1,108 @@ +/** + * Multi-account roster endpoints — /settings/api/accounts/*. + * + * Quick-switch over maximal's PERSISTED accounts: list them, set the active + * one, or forget one. Inherits the /settings/api auth gate. Switching and + * removing only edit maximal's own on-disk registry — they never touch `gh`, + * the keyring, or any GitHub session (the HARD ISOLATION INVARIANT). After a + * switch/remove the shell reboots the sidecar so it boots into the new active + * config; we don't mutate the running auth state here. + * + * Token values are never returned by any endpoint here. + */ + +import { Hono } from "hono" + +import { preflightCopilotError } from "~/lib/copilot-preflight" +import { forwardError } from "~/lib/error" +import { + listAccounts, + readDefaultRegistry, + removeAccount, + setActive, + writeDefaultRegistry, +} from "~/lib/github-token-store" + +export const accountsRoutes = new Hono() + +/** Read `{ key }` from the JSON body, or null if it isn't a non-empty string. */ +async function readKey(c: { + req: { json: () => Promise } +}): Promise { + const body = (await c.req.json().catch(() => null)) as { + key?: unknown + } | null + const key = body?.key + return typeof key === "string" && key ? key : null +} + +accountsRoutes.get("/", async (c) => { + try { + const reg = await readDefaultRegistry() + const accounts = listAccounts(reg).map((a) => ({ + key: a.key, + login: a.login, + host: a.host, + added_via: a.addedVia, + obtained_at: a.obtainedAt, + active: a.active, + })) + return c.json({ accounts, active_key: reg.activeKey }) + } catch (error) { + return await forwardError(c, error) + } +}) + +/** + * Set the active account. Pre-flights the target token against Copilot BEFORE + * flipping the pointer (the token is already in the registry, so a bad switch + * would otherwise cost a full reboot to discover) — returns a specific 422 if + * the account is no longer usable. The shell reboots on a 2xx. + */ +accountsRoutes.post("/switch", async (c) => { + try { + const key = await readKey(c) + if (!key) { + return c.json({ error: { message: "Expected { key } string." } }, 400) + } + const reg = await readDefaultRegistry() + if (!(key in reg.accounts)) { + return c.json({ error: { message: `No account ${key}.` } }, 404) + } + const target = reg.accounts[key] + const preflightError = await preflightCopilotError( + target.token, + target.login, + ) + if (preflightError) { + return c.json({ error: { message: preflightError } }, 422) + } + await writeDefaultRegistry(setActive(reg, key)) + return c.json({ ok: true, key }) + } catch (error) { + return await forwardError(c, error) + } +}) + +/** + * Forget an account — deletes maximal's OWN copy of its token from the + * registry. gh is untouched. If the removed account was active, `activeKey` + * falls to null and the shell reboots into unauthenticated. + */ +accountsRoutes.post("/remove", async (c) => { + try { + const key = await readKey(c) + if (!key) { + return c.json({ error: { message: "Expected { key } string." } }, 400) + } + const reg = await readDefaultRegistry() + if (!(key in reg.accounts)) { + return c.json({ error: { message: `No account ${key}.` } }, 404) + } + const wasActive = reg.activeKey === key + await writeDefaultRegistry(removeAccount(reg, key)) + return c.json({ ok: true, key, was_active: wasActive }) + } catch (error) { + return await forwardError(c, error) + } +}) diff --git a/src/routes/settings/api.ts b/src/routes/settings/api.ts index e4e119f..9e4bb9f 100644 --- a/src/routes/settings/api.ts +++ b/src/routes/settings/api.ts @@ -27,6 +27,7 @@ import { import { state } from "~/lib/state" import { getGitVersion, shortSha } from "~/lib/version" +import { accountsRoutes } from "./accounts" import { apiKeysRoutes } from "./api-keys" import { appsRoutes } from "./apps" import { authRoutes } from "./auth" @@ -89,6 +90,7 @@ settingsApiRoutes.get("/diagnostics", (c) => { settingsApiRoutes.route("/auth/github", authRoutes) settingsApiRoutes.route("/gh", ghRoutes) +settingsApiRoutes.route("/accounts", accountsRoutes) settingsApiRoutes.route("/api-keys", apiKeysRoutes) settingsApiRoutes.route("/clients", clientsRoutes) settingsApiRoutes.route("/apps", appsRoutes) diff --git a/src/routes/settings/gh.ts b/src/routes/settings/gh.ts index 8fd9402..7c0cae7 100644 --- a/src/routes/settings/gh.ts +++ b/src/routes/settings/gh.ts @@ -8,42 +8,13 @@ import { Hono } from "hono" -import { forwardError, HTTPError } from "~/lib/error" +import { preflightCopilotError } from "~/lib/copilot-preflight" +import { forwardError } from "~/lib/error" import { addAccountToDefaultRegistry, makeAccountRecord, } from "~/lib/github-token-store" import { detectGhCli, getGhAccountToken } from "~/services/gh-cli" -import { getCopilotUsage } from "~/services/github/get-copilot-usage" - -/** - * Verify a gh account's token actually works for Copilot BEFORE we adopt it. - * Mirrors what boot does (the live identity check that was failing silently): - * a stale/revoked token → GitHub rejects it; an account with no Copilot → - * /copilot_internal/user 403/404. Returns a specific, user-facing message so - * the UI can say WHY synchronously, instead of writing the token, rebooting, - * and surfacing a generic "came back unauthenticated" 20s later. Returns null - * when the token is usable. - */ -export async function preflightCopilotError( - token: string, - login: string, - usage: (token: string) => Promise = getCopilotUsage, -): Promise { - try { - await usage(token) - return null - } catch (error) { - const status = error instanceof HTTPError ? error.response.status : 0 - if (status === 401) { - return `GitHub rejected ${login}'s token — it may be expired or revoked. Run \`gh auth login\` and try again, or sign in with a code.` - } - if (status === 403 || status === 404) { - return `${login} doesn't have access to GitHub Copilot. Pick another account, or sign in with a code.` - } - return `Couldn't verify ${login} with GitHub${status ? ` (HTTP ${status})` : ""}. Check your connection and try again.` - } -} export const ghRoutes = new Hono() diff --git a/tests/accounts-route.test.ts b/tests/accounts-route.test.ts new file mode 100644 index 0000000..9b6cb64 --- /dev/null +++ b/tests/accounts-route.test.ts @@ -0,0 +1,195 @@ +/** + * Route tests for /settings/api/accounts/* (list / switch / remove). + * + * The registry storage boundary (`readDefaultRegistry`/`writeDefaultRegistry`) + * and the Copilot pre-flight (`preflightCopilotError`) are mocked to in-memory + * values so the routes are exercised without touching disk or the network. + * Following api-keys-route.test.ts: spread the real modules so process-wide + * `mock.module` doesn't strip helpers a sibling test file imports. + */ + +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" +import { Hono } from "hono" + +import type { AccountRegistry } from "~/lib/github-token-store" + +import { HTTPError } from "~/lib/error" + +const actualStore = await import("~/lib/github-token-store") + +let fakeRegistry: AccountRegistry +// Drives the switch route's pre-flight: getCopilotUsage either resolves (token +// usable) or throws an HTTPError (mirrors the real Copilot rejection). We mock +// this leaf — NOT the whole gh module — so the REAL preflightCopilotError runs +// and gh-preflight.test.ts's coverage of it isn't clobbered. +let usageImpl: (token: string) => Promise + +void mock.module("~/lib/github-token-store", () => ({ + ...actualStore, + readDefaultRegistry: () => Promise.resolve(fakeRegistry), + writeDefaultRegistry: (reg: AccountRegistry) => { + fakeRegistry = reg + return Promise.resolve() + }, +})) + +const actualUsage = await import("~/services/github/get-copilot-usage") + +void mock.module("~/services/github/get-copilot-usage", () => ({ + ...actualUsage, + getCopilotUsage: (token: string) => usageImpl(token), +})) + +const { accountsRoutes } = await import("~/routes/settings/accounts") +const { addAndActivate, emptyRegistry, makeAccountRecord } = actualStore + +afterAll(() => { + void mock.module("~/lib/github-token-store", () => actualStore) + void mock.module("~/services/github/get-copilot-usage", () => actualUsage) +}) + +/** An HTTPError carrying the given status, for driving the pre-flight. */ +function httpError(status: number): HTTPError { + return new HTTPError("upstream", new Response(null, { status })) +} + +function makeApp(): Hono { + const app = new Hono() + app.route("/accounts", accountsRoutes) + return app +} + +const postJson = (path: string, body: unknown) => + makeApp().request(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + +function seedTwoAccounts(): void { + // alice (gh-cli), then bob (device-code, active) + let reg = addAndActivate( + emptyRegistry(), + makeAccountRecord({ + login: "alice", + host: "github.com", + token: "ghu_alice", + addedVia: "gh-cli", + }), + ) + reg = addAndActivate( + reg, + makeAccountRecord({ + login: "bob", + host: "github.com", + token: "ghu_bob", + addedVia: "device-code", + }), + ) + fakeRegistry = reg +} + +beforeEach(() => { + fakeRegistry = emptyRegistry() + // Default: the token is usable (pre-flight passes). + usageImpl = () => Promise.resolve({}) +}) + +describe("GET /accounts", () => { + test("lists accounts with active flag and never returns tokens", async () => { + seedTwoAccounts() + const res = await makeApp().request("/accounts") + expect(res.status).toBe(200) + const body = (await res.json()) as { + accounts: Array> + active_key: string | null + } + expect(body.active_key).toBe("bob@github.com") + expect(body.accounts).toHaveLength(2) + const bob = body.accounts.find((a) => a.login === "bob") + expect(bob).toMatchObject({ + key: "bob@github.com", + host: "github.com", + added_via: "device-code", + active: true, + }) + expect(body.accounts.find((a) => a.login === "alice")?.active).toBe(false) + // No token leaks into the response. + for (const a of body.accounts) { + expect(a.token).toBeUndefined() + } + }) + + test("empty registry → empty list, null active", async () => { + const res = await makeApp().request("/accounts") + const body = (await res.json()) as { + accounts: Array + active_key: null + } + expect(body.accounts).toEqual([]) + expect(body.active_key).toBeNull() + }) +}) + +describe("POST /accounts/switch", () => { + test("400 on a missing/blank key", async () => { + expect((await postJson("/accounts/switch", {})).status).toBe(400) + expect((await postJson("/accounts/switch", { key: "" })).status).toBe(400) + }) + + test("404 on an unknown key", async () => { + seedTwoAccounts() + const res = await postJson("/accounts/switch", { key: "ghost@github.com" }) + expect(res.status).toBe(404) + }) + + test("422 when the pre-flight rejects the target token", async () => { + seedTwoAccounts() + usageImpl = () => Promise.reject(httpError(403)) // no Copilot subscription + const res = await postJson("/accounts/switch", { key: "alice@github.com" }) + expect(res.status).toBe(422) + const body = (await res.json()) as { error: { message: string } } + expect(body.error.message).toContain("Copilot") + // Active pointer unchanged on failure. + expect(fakeRegistry.activeKey).toBe("bob@github.com") + }) + + test("200 sets the active account when pre-flight passes", async () => { + seedTwoAccounts() + usageImpl = () => Promise.resolve({}) // token usable + const res = await postJson("/accounts/switch", { key: "alice@github.com" }) + expect(res.status).toBe(200) + expect(fakeRegistry.activeKey).toBe("alice@github.com") + }) +}) + +describe("POST /accounts/remove", () => { + test("400 on a missing key", async () => { + expect((await postJson("/accounts/remove", {})).status).toBe(400) + }) + + test("404 on an unknown key", async () => { + seedTwoAccounts() + const res = await postJson("/accounts/remove", { key: "ghost@github.com" }) + expect(res.status).toBe(404) + }) + + test("removes a non-active account, keeps the active pointer", async () => { + seedTwoAccounts() + const res = await postJson("/accounts/remove", { key: "alice@github.com" }) + expect(res.status).toBe(200) + const body = (await res.json()) as { was_active: boolean } + expect(body.was_active).toBe(false) + expect("alice@github.com" in fakeRegistry.accounts).toBe(false) + expect(fakeRegistry.activeKey).toBe("bob@github.com") + }) + + test("removing the active account clears activeKey", async () => { + seedTwoAccounts() + const res = await postJson("/accounts/remove", { key: "bob@github.com" }) + expect(res.status).toBe(200) + const body = (await res.json()) as { was_active: boolean } + expect(body.was_active).toBe(true) + expect(fakeRegistry.activeKey).toBeNull() + }) +}) diff --git a/tests/gh-preflight.test.ts b/tests/gh-preflight.test.ts index 0d294e7..7f2b8dc 100644 --- a/tests/gh-preflight.test.ts +++ b/tests/gh-preflight.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" +import { preflightCopilotError } from "~/lib/copilot-preflight" import { HTTPError } from "~/lib/error" -import { preflightCopilotError } from "~/routes/settings/gh" const ok = () => Promise.resolve({ copilot_plan: "enterprise" }) const throwsHttp = (status: number) => () =>