mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 07:54:38 +00:00
fix(usage): read OpenCode v2 Go API keys from its database
This commit is contained in:
committed by
paseo-bot[bot]
parent
66676d49e6
commit
0ac8175015
@@ -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<string | null> {
|
||||
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<Sqlite["DatabaseSync"]>((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();
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<UsageAccount[]> {
|
||||
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<UsageAccount[]> {
|
||||
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<UsageReport> {
|
||||
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" },
|
||||
|
||||
@@ -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<typeof inputSchema>;
|
||||
|
||||
Reference in New Issue
Block a user