diff --git a/plugins/opencode-go-usage-source/server/stores.ts b/plugins/opencode-go-usage-source/server/stores.ts new file mode 100644 index 0000000000..c3108055a7 --- /dev/null +++ b/plugins/opencode-go-usage-source/server/stores.ts @@ -0,0 +1,78 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { z } from "zod"; + +interface Database { + prepare(sql: string): { all(...args: string[]): unknown[] }; + close(): void; +} +interface Sqlite { + DatabaseSync: new (path: string, options: { readOnly: boolean }) => Database; +} +interface DatabaseLogin { + key: string | null; +} +export interface StorePaths { + file: string; + database: string; +} + +const fileSchema = z.object({ + "opencode-go": z.object({ type: z.literal("api"), key: z.string().min(1) }).optional(), +}); +const rowSchema = z.object({ value: z.string() }); +const keySchema = z.object({ type: z.literal("key"), key: z.string().min(1) }); + +export function defaultStorePaths(env: NodeJS.ProcessEnv = process.env): StorePaths { + const dataHome = env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local", "share"); + const directory = join(dataHome, "opencode"); + const database = env.OPENCODE_DB ? resolve(env.OPENCODE_DB) : join(directory, "opencode.db"); + return { file: join(directory, "auth.json"), database }; +} + +export async function readFileKey(path: string): Promise { + try { + const auth = fileSchema.parse(JSON.parse(await readFile(path, "utf8"))); + return auth["opencode-go"]?.key ?? null; + } catch { + return null; + } +} + +export function readDatabaseLogin(path: string): DatabaseLogin | null { + // Keep the legacy source loadable on Node versions without node:sqlite. + const sqlite = z + .object({ + DatabaseSync: z.custom((value) => typeof value === "function"), + }) + .safeParse(process.getBuiltinModule?.("node:sqlite")); + if (!sqlite.success || !existsSync(path)) return null; + const db = new sqlite.data.DatabaseSync(path, { readOnly: true }); + try { + // V1 also has opencode.db, but only v2 has the credential table. + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credential'") + .all(); + if (!tables.length) return null; + const [value] = db + .prepare( + "SELECT value FROM credential WHERE integration_id = ? AND active = 1 ORDER BY time_updated DESC, id DESC LIMIT 1", + ) + .all("opencode-go"); + const row = rowSchema.safeParse(value); + if (!row.success) return { key: null }; + let credential: unknown; + try { + credential = JSON.parse(row.data.value); + } catch (error) { + if (error instanceof SyntaxError) return { key: null }; + throw error; + } + const parsed = keySchema.safeParse(credential); + return { key: parsed.success ? parsed.data.key : null }; + } finally { + db.close(); + } +} diff --git a/plugins/opencode-go-usage-source/server/usage.test.ts b/plugins/opencode-go-usage-source/server/usage.test.ts index 72fbe0ee13..022b150c17 100644 --- a/plugins/opencode-go-usage-source/server/usage.test.ts +++ b/plugins/opencode-go-usage-source/server/usage.test.ts @@ -1,13 +1,25 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; import { discover, fetchUsage } from "./usage.js"; +import { defaultStorePaths } from "./stores.js"; import { inputSchema } from "../shared/input.js"; let fixtureDirectory: string; let fixturePath: string; +function stores(path: string) { + return { file: path, database: join(dirname(path), "opencode.db") }; +} +async function database() { + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(join(fixtureDirectory, "opencode.db")); + db.exec( + "CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, value TEXT, active INTEGER, time_updated INTEGER)", + ); + return db; +} beforeEach(async () => { fixtureDirectory = await mkdtemp(join(tmpdir(), "go-default-")); fixturePath = join(fixtureDirectory, "auth.json"); @@ -22,6 +34,9 @@ afterEach(async () => { test("discovery input rejects an explicit API key", () => { expect(inputSchema.safeParse({ apiKey: "unused" }).success).toBe(false); + expect( + inputSchema.safeParse({ store: "database", path: "opencode.db", apiKey: "unused" }).success, + ).toBe(false); expect(() => inputSchema.parse({})).toThrow(); }); @@ -33,14 +48,147 @@ const upstreamResponse = { }, }; +test("uses the active v2 Go key instead of a stale legacy key without writing either store", async () => { + const databasePath = join(fixtureDirectory, "opencode.db"); + const db = await database(); + const insert = db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?)"); + insert.run("inactive", "opencode-go", JSON.stringify({ type: "key", key: "inactive-key" }), 0, 3); + insert.run("active", "opencode-go", JSON.stringify({ type: "key", key: "active-key" }), 1, 1); + insert.run("zen", "opencode", JSON.stringify({ type: "key", key: "zen-key" }), 1, 4); + db.close(); + const beforeDatabase = await readFile(databasePath); + const beforeFile = await readFile(fixturePath); + const accounts = await discover(stores(fixturePath)); + expect(accounts).toEqual([ + { + key: expect.stringMatching(/^[a-f0-9]{64}$/), + input: { store: "database", path: databasePath }, + }, + ]); + const input = inputSchema.parse(accounts[0]!.input); + const report = await fetchUsage(input, async (_url, init) => { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer active-key"); + return Response.json(upstreamResponse); + }); + expect(report.status).toBe("available"); + expect(report.windows.map((window) => window.usedPct)).toEqual([21, 42, 100]); + expect(await readFile(databasePath)).toEqual(beforeDatabase); + expect(await readFile(fixturePath)).toEqual(beforeFile); +}); + +test("discovers v2 Go usage without a legacy auth.json", async () => { + const db = await database(); + db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?)").run( + "go", + "opencode-go", + JSON.stringify({ type: "key", key: "database-key" }), + 1, + 1, + ); + db.close(); + await rm(fixturePath); + const accounts = await discover(stores(fixturePath)); + expect(accounts).toEqual([ + { + key: expect.stringMatching(/^[a-f0-9]{64}$/), + input: { store: "database", path: join(fixtureDirectory, "opencode.db") }, + }, + ]); + expect(JSON.stringify(accounts)).not.toContain("database-key"); +}); + +test("refresh follows the active Go account and re-reads its rotated key", async () => { + const db = await database(); + const insert = db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?)"); + insert.run("first", "opencode-go", JSON.stringify({ type: "key", key: "first-key" }), 1, 1); + insert.run("second", "opencode-go", JSON.stringify({ type: "key", key: "second-key" }), 0, 2); + db.close(); + const paths = stores(fixturePath); + const accounts = await discover(paths); + const input = inputSchema.parse(accounts[0]!.input); + const { DatabaseSync } = await import("node:sqlite"); + const writer = new DatabaseSync(paths.database); + writer.exec("UPDATE credential SET active = CASE id WHEN 'second' THEN 1 ELSE 0 END"); + writer + .prepare("UPDATE credential SET value = ? WHERE id = 'second'") + .run(JSON.stringify({ type: "key", key: "rotated-key" })); + writer.close(); + expect(await discover(paths)).toEqual(accounts); + const before = await readFile(paths.database); + const report = await fetchUsage(input, async (_url, init) => { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer rotated-key"); + return Response.json(upstreamResponse); + }); + expect(report.status).toBe("available"); + expect(JSON.stringify(report)).not.toContain("rotated-key"); + expect(await readFile(paths.database)).toEqual(before); +}); + +test.each([ + ["inactive Go key", "opencode-go", 0, JSON.stringify({ type: "key", key: "inactive-key" })], + ["other provider", "opencode", 1, JSON.stringify({ type: "key", key: "zen-key" })], + ["Console OAuth", "opencode-go", 1, JSON.stringify({ type: "oauth", access: "oauth-token" })], + ["malformed credential", "opencode-go", 1, "invalid JSON"], + ["empty key", "opencode-go", 1, JSON.stringify({ type: "key", key: "" })], +])( + "does not revive a legacy key for a v2 store with %s", + async (_name, provider, active, value) => { + const db = await database(); + db.prepare("INSERT INTO credential VALUES (?, ?, ?, ?, ?)").run( + "credential", + provider, + value, + active, + 1, + ); + db.close(); + expect(await discover(stores(fixturePath))).toEqual([]); + }, +); + +test("keeps legacy discovery when the v1 session database has no credential table", async () => { + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(join(fixtureDirectory, "opencode.db")); + db.exec("CREATE TABLE session (id TEXT PRIMARY KEY)"); + db.close(); + expect(await discover(stores(fixturePath))).toEqual([ + { + key: expect.stringMatching(/^[a-f0-9]{64}$/), + input: { store: "file", path: fixturePath }, + }, + ]); +}); + +test("fails before fetching when the selected database disappears", async () => { + await expect( + fetchUsage({ store: "database", path: join(fixtureDirectory, "missing.db") }, async () => { + throw new Error("must not fetch"); + }), + ).rejects.toThrow("login store no longer exists"); +}); + +test("resolves default and overridden OpenCode store paths", () => { + expect(defaultStorePaths({ HOME: fixtureDirectory })).toEqual({ + file: join(fixtureDirectory, ".local", "share", "opencode", "auth.json"), + database: join(fixtureDirectory, ".local", "share", "opencode", "opencode.db"), + }); + const customDatabase = join(fixtureDirectory, "custom.db"); + expect( + defaultStorePaths({ XDG_DATA_HOME: fixtureDirectory, OPENCODE_DB: customDatabase }), + ).toEqual({ + file: join(fixtureDirectory, "opencode", "auth.json"), + database: customDatabase, + }); +}); + test("discovers and fetches the default key from read-only auth.json", async () => { const directory = await mkdtemp(join(tmpdir(), "paseo-go-usage-")); const path = join(directory, "auth.json"); const content = JSON.stringify({ "opencode-go": { type: "api", key: "fixture-key" } }); try { await writeFile(path, content); - expect(await discover(path)).toEqual([ - { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { path } }, + expect(await discover(stores(path))).toEqual([ + { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { store: "file", path } }, ]); const requests: Array<{ url: string; authorization: string | null }> = []; const fetchApi = async (url: string | URL | Request, init?: RequestInit) => { @@ -50,7 +198,7 @@ test("discovers and fetches the default key from read-only auth.json", async () }); return Response.json(upstreamResponse); }; - const report = await fetchUsage({ path }, fetchApi as typeof fetch); + const report = await fetchUsage({ store: "file", path }, fetchApi); expect(requests).toEqual([ { url: "https://opencode.ai/zen/go/v1/usage", authorization: "Bearer fixture-key" }, ]); @@ -75,9 +223,9 @@ test("omits default discovery when auth.json lacks an OpenCode Go API key", asyn const path = join(directory, "auth.json"); try { await writeFile(path, JSON.stringify({ openai: { type: "oauth", access: "other-token" } })); - expect(await discover(path)).toEqual([]); + expect(await discover(stores(path))).toEqual([]); await expect( - fetchUsage({ path }, async () => { + fetchUsage({ store: "file", path }, async () => { throw new Error("must not fetch"); }), ).rejects.toThrow("login store no longer exists"); @@ -88,7 +236,7 @@ test("omits default discovery when auth.json lacks an OpenCode Go API key", asyn test.each([401, 403])("maps HTTP %i to unavailable", async (status) => { const report = await fetchUsage( - { path: fixturePath }, + { store: "file", path: fixturePath }, async () => new Response(null, { status }), ); expect(report).toEqual({ @@ -100,5 +248,5 @@ test.each([401, 403])("maps HTTP %i to unavailable", async (status) => { test.each(["missing file", "unrelated file"])("discovers no accounts for %s", async (scenario) => { const path = join(fixtureDirectory, "missing.json"); if (scenario === "unrelated file") await writeFile(path, "{}"); - expect(await discover(path)).toEqual([]); + expect(await discover(stores(path))).toEqual([]); }); diff --git a/plugins/opencode-go-usage-source/server/usage.ts b/plugins/opencode-go-usage-source/server/usage.ts index cd2905ca2e..8292ad8b91 100644 --- a/plugins/opencode-go-usage-source/server/usage.ts +++ b/plugins/opencode-go-usage-source/server/usage.ts @@ -1,6 +1,3 @@ -import { readFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; import { z } from "zod"; import { hashAccountKey, @@ -11,15 +8,7 @@ import { type UsageReport, } from "@getpaseo/plugin/server/usage"; import type { Input } from "../shared/input.js"; - -const authSchema = z - .object({ - "opencode-go": z - .object({ type: z.literal("api"), key: z.string().min(1) }) - .passthrough() - .optional(), - }) - .passthrough(); +import { defaultStorePaths, readDatabaseLogin, readFileKey } from "./stores.js"; const windowSchema = z.object({ status: z.enum(["ok", "rate-limited"]), percent: z.number().finite(), @@ -29,32 +18,30 @@ const responseSchema = z.object({ usage: z.object({ rolling: windowSchema, weekly: windowSchema, monthly: windowSchema }), }); -function authPath(env: NodeJS.ProcessEnv = process.env): string { - return join( - env.XDG_DATA_HOME || join(env.HOME || homedir(), ".local", "share"), - "opencode", - "auth.json", - ); -} - -export async function readDefaultKey(path = authPath()): Promise { - try { - const auth = authSchema.parse(JSON.parse(await readFile(path, "utf8"))); - return auth["opencode-go"]?.key ?? null; - } catch { - return null; +export async function discover(paths = defaultStorePaths()): Promise { + const login = readDatabaseLogin(paths.database); + if (login) { + return login.key + ? [ + { + key: hashAccountKey(paths.database), + input: { store: "database", path: paths.database }, + }, + ] + : []; } -} - -export async function discover(path = authPath()): Promise { - return (await readDefaultKey(path)) ? [{ key: hashAccountKey(path), input: { path } }] : []; + const key = await readFileKey(paths.file); + return key + ? [{ key: hashAccountKey(paths.file), input: { store: "file", path: paths.file } }] + : []; } export async function fetchUsage( input: Input, fetchApi: typeof fetch = fetch, ): Promise { - const apiKey = await readDefaultKey(input.path); + const apiKey = + input.store === "database" ? readDatabaseLogin(input.path)?.key : await readFileKey(input.path); if (!apiKey) throw new Error("OpenCode Go login store no longer exists"); const response = await fetchApi("https://opencode.ai/zen/go/v1/usage", { headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, diff --git a/plugins/opencode-go-usage-source/shared/input.ts b/plugins/opencode-go-usage-source/shared/input.ts index 6e3ffd1719..888e63b57c 100644 --- a/plugins/opencode-go-usage-source/shared/input.ts +++ b/plugins/opencode-go-usage-source/shared/input.ts @@ -1,3 +1,5 @@ import { z } from "zod"; -export const inputSchema = z.object({ path: z.string().min(1) }).strict(); +export const inputSchema = z + .object({ store: z.enum(["file", "database"]), path: z.string().min(1) }) + .strict(); export type Input = z.infer;