summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 17:30:08 +0900
committerAdam Malczewski <[email protected]>2026-05-21 17:30:08 +0900
commitd6b208342edf97bafa5b1dcc986b782f9879d141 (patch)
treec6d8f9bffa86f78e3b1369b811bef9642df788d0 /packages/core/src
parent1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (diff)
downloaddispatch-d6b208342edf97bafa5b1dcc986b782f9879d141.tar.gz
dispatch-d6b208342edf97bafa5b1dcc986b782f9879d141.zip
feat: SQLite database for all credentials, keys, wake schedule, and usage cache
- Add SQLite database at ~/.local/share/dispatch/dispatch.db with tables: credentials, api_keys, wake_schedule, usage_cache - Store Claude OAuth credentials in DB with import button in Model Status UI - Store OpenCode/Copilot API keys in DB with paste-to-import modal - Store OpenCode cookie and workspace IDs in DB - Migrate wake schedule from .wake-schedule.json to DB - Migrate usage cache from in-memory Map + localStorage to DB - Remove all env var and file fallbacks — DB is the single source of truth - Add seed scripts: bin/import-credentials.ts, bin/seed-opencode-keys.ts - Docker: container runs as host UID/GID with matching home directory - Clean up dispatch.toml: remove env fields, update comments - Progress bar time markers for usage cycle tracking
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/config/schema.ts8
-rw-r--r--packages/core/src/credentials/api-keys.ts71
-rw-r--r--packages/core/src/credentials/claude.ts116
-rw-r--r--packages/core/src/credentials/index.ts17
-rw-r--r--packages/core/src/credentials/opencode.ts10
-rw-r--r--packages/core/src/credentials/store.ts177
-rw-r--r--packages/core/src/db/index.ts93
-rw-r--r--packages/core/src/index.ts3
8 files changed, 472 insertions, 23 deletions
diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts
index 57d0b7b..83e2695 100644
--- a/packages/core/src/config/schema.ts
+++ b/packages/core/src/config/schema.ts
@@ -149,16 +149,12 @@ function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefi
};
}
- // Other providers require env
- if (typeof raw["env"] !== "string") {
- errors.push({ path: `${path}.env`, message: "must be a string" });
- return null;
- }
+ // Other providers: env is optional (keys can be stored in DB)
return {
id: raw["id"] as string,
provider: raw["provider"] as string,
- env: raw["env"] as string,
base_url: raw["base_url"] as string,
+ ...(typeof raw["env"] === "string" ? { env: raw["env"] } : {}),
};
}
diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts
new file mode 100644
index 0000000..5f92ffa
--- /dev/null
+++ b/packages/core/src/credentials/api-keys.ts
@@ -0,0 +1,71 @@
+import { getDatabase } from "../db/index.js";
+
+export interface StoredApiKey {
+ keyId: string;
+ provider: string;
+ apiKey: string;
+ importedAt: number;
+ updatedAt: number;
+}
+
+/**
+ * Store or update an API key in the database.
+ */
+export function setApiKey(keyId: string, provider: string, apiKey: string): void {
+ const db = getDatabase();
+ const now = Date.now();
+ db.query(
+ `INSERT INTO api_keys (key_id, provider, api_key, imported_at, updated_at)
+ VALUES ($keyId, $provider, $apiKey, $now, $now)
+ ON CONFLICT(key_id) DO UPDATE SET
+ api_key = $apiKey,
+ updated_at = $now`,
+ ).run({
+ $keyId: keyId,
+ $provider: provider,
+ $apiKey: apiKey,
+ $now: now,
+ });
+}
+
+/**
+ * Get a stored API key by key ID. Returns the key string or null.
+ */
+export function getApiKey(keyId: string): string | null {
+ const db = getDatabase();
+ const row = db.query(
+ "SELECT api_key FROM api_keys WHERE key_id = $keyId",
+ ).get({ $keyId: keyId }) as { api_key: string } | null;
+ return row?.api_key ?? null;
+}
+
+/**
+ * Resolve an API key from the database. Returns null if not found.
+ */
+export function resolveApiKey(keyId: string): string | null {
+ return getApiKey(keyId);
+}
+
+/**
+ * Delete a stored API key.
+ */
+export function deleteApiKey(keyId: string): void {
+ const db = getDatabase();
+ db.query("DELETE FROM api_keys WHERE key_id = $keyId").run({ $keyId: keyId });
+}
+
+/**
+ * List all stored API keys with metadata (key value excluded for security).
+ */
+export function listApiKeys(): Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> {
+ const db = getDatabase();
+ const rows = db.query(
+ "SELECT key_id, provider, imported_at, updated_at FROM api_keys ORDER BY key_id",
+ ).all() as Array<Record<string, unknown>>;
+ return rows.map((row) => ({
+ keyId: row.key_id as string,
+ provider: row.provider as string,
+ importedAt: row.imported_at as number,
+ updatedAt: row.updated_at as number,
+ }));
+}
diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts
index 6639afc..1b9d148 100644
--- a/packages/core/src/credentials/claude.ts
+++ b/packages/core/src/credentials/claude.ts
@@ -1,4 +1,6 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, readdirSync } from "node:fs";
+import { getStoredCredentials, updateStoredTokens, listStoredCredentials } from "./store.js";
+import { getDatabase } from "../db/index.js";
import { dirname, join, basename } from "node:path";
import { homedir } from "node:os";
import { createHash } from "node:crypto";
@@ -144,6 +146,34 @@ function buildAccountLabels(accounts: ClaudeAccount[]): void {
}
}
+/**
+ * Load Claude accounts from the SQLite database.
+ * Returns accounts for all stored anthropic credentials.
+ * This is the preferred path — file-based discovery is the fallback.
+ */
+export function getClaudeAccountsFromDB(): ClaudeAccount[] {
+ const stored = listStoredCredentials();
+ const accounts: ClaudeAccount[] = [];
+
+ for (const cred of stored) {
+ if (cred.provider !== "anthropic") continue;
+ accounts.push({
+ id: cred.keyId,
+ label: "",
+ source: `db:${cred.keyId}`,
+ credentials: {
+ accessToken: cred.accessToken,
+ refreshToken: cred.refreshToken,
+ expiresAt: cred.expiresAt,
+ subscriptionType: cred.subscriptionType ?? undefined,
+ },
+ });
+ }
+
+ buildAccountLabels(accounts);
+ return accounts;
+}
+
export function discoverClaudeAccounts(): ClaudeAccount[] {
const accounts: ClaudeAccount[] = [];
@@ -194,10 +224,22 @@ export function refreshAccountCredentials(account: ClaudeAccount): ClaudeCredent
return cached.creds;
}
- // Re-read from file to pick up external updates
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
+ // Re-read credentials: from DB for DB-backed accounts, from file otherwise
+ if (account.source.startsWith("db:")) {
+ const stored = getStoredCredentials(account.id);
+ if (stored) {
+ account.credentials = {
+ accessToken: stored.accessToken,
+ refreshToken: stored.refreshToken,
+ expiresAt: stored.expiresAt,
+ subscriptionType: stored.subscriptionType ?? undefined,
+ };
+ }
+ } else {
+ const onDisk = readCredentialsFile(account.source);
+ if (onDisk) {
+ account.credentials = onDisk;
+ }
}
if (account.credentials.expiresAt > now + 60_000) {
@@ -222,10 +264,22 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr
return cached.creds;
}
- // Re-read from file
- const onDisk = readCredentialsFile(account.source);
- if (onDisk) {
- account.credentials = onDisk;
+ // Re-read credentials: from DB for DB-backed accounts, from file otherwise
+ if (account.source.startsWith("db:")) {
+ const stored = getStoredCredentials(account.id);
+ if (stored) {
+ account.credentials = {
+ accessToken: stored.accessToken,
+ refreshToken: stored.refreshToken,
+ expiresAt: stored.expiresAt,
+ subscriptionType: stored.subscriptionType ?? undefined,
+ };
+ }
+ } else {
+ const onDisk = readCredentialsFile(account.source);
+ if (onDisk) {
+ account.credentials = onDisk;
+ }
}
if (account.credentials.expiresAt > now + 60_000) {
@@ -238,7 +292,12 @@ export async function refreshAccountCredentialsAsync(account: ClaudeAccount): Pr
const refreshed = await refreshViaOAuth(account.credentials.refreshToken);
if (refreshed && refreshed.expiresAt > now + 60_000) {
account.credentials = refreshed;
- writeCredentialsFile(account.source, refreshed);
+ // Update DB if this is a DB-backed account, otherwise write to file
+ if (account.source.startsWith("db:")) {
+ updateStoredTokens(account.id, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
+ } else {
+ writeCredentialsFile(account.source, refreshed);
+ }
accountCacheMap.set(account.id, { creds: refreshed, cachedAt: now });
return refreshed;
}
@@ -458,15 +517,46 @@ async function fetchClaudeUsage(accessToken: string): Promise<ClaudeUsageReport
}
}
-const usageCacheMap = new Map<string, ClaudeUsageReport>();
+function getCachedUsage(keyId: string): ClaudeUsageReport | null {
+ try {
+ const db = getDatabase();
+ const row = db.query(
+ "SELECT report_json FROM usage_cache WHERE key_id = $keyId",
+ ).get({ $keyId: keyId }) as { report_json: string } | null;
+ if (!row) return null;
+ return JSON.parse(row.report_json) as ClaudeUsageReport;
+ } catch {
+ return null;
+ }
+}
+
+function setCachedUsage(keyId: string, provider: string, report: ClaudeUsageReport): void {
+ try {
+ const db = getDatabase();
+ db.query(
+ `INSERT INTO usage_cache (key_id, provider, cached_at, report_json)
+ VALUES ($keyId, $provider, $cachedAt, $reportJson)
+ ON CONFLICT(key_id) DO UPDATE SET
+ cached_at = $cachedAt,
+ report_json = $reportJson`,
+ ).run({
+ $keyId: keyId,
+ $provider: provider,
+ $cachedAt: Date.now(),
+ $reportJson: JSON.stringify(report),
+ });
+ } catch {
+ // Ignore DB errors
+ }
+}
export async function getAccountUsage(account: ClaudeAccount): Promise<ClaudeUsageReport | null> {
const creds = await refreshAccountCredentialsAsync(account);
- if (!creds) return usageCacheMap.get(account.id) ?? null;
+ if (!creds) return getCachedUsage(account.id);
const report = await fetchClaudeUsage(creds.accessToken);
if (report) {
- usageCacheMap.set(account.id, report);
+ setCachedUsage(account.id, "anthropic", report);
return report;
}
- return usageCacheMap.get(account.id) ?? null;
+ return getCachedUsage(account.id);
} \ No newline at end of file
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
index 0ae4edb..8cbedd9 100644
--- a/packages/core/src/credentials/index.ts
+++ b/packages/core/src/credentials/index.ts
@@ -7,6 +7,7 @@ export {
type ClaudeUsageBucket,
type ClaudeUsageReport,
discoverClaudeAccounts,
+ getClaudeAccountsFromDB,
fetchAnthropicModels,
getAccountUsage,
getAnthropicBetas,
@@ -25,3 +26,19 @@ export {
type OpencodeUsageBucket,
type OpencodeUsageReport,
} from "./opencode.js";
+export {
+ type StoredCredential,
+ importCredentialsFromFile,
+ getStoredCredentials,
+ updateStoredTokens,
+ deleteStoredCredentials,
+ listStoredCredentials,
+} from "./store.js";
+export {
+ type StoredApiKey,
+ setApiKey,
+ getApiKey,
+ resolveApiKey,
+ deleteApiKey,
+ listApiKeys,
+} from "./api-keys.js";
diff --git a/packages/core/src/credentials/opencode.ts b/packages/core/src/credentials/opencode.ts
index 7a74486..b8a9d4d 100644
--- a/packages/core/src/credentials/opencode.ts
+++ b/packages/core/src/credentials/opencode.ts
@@ -1,3 +1,5 @@
+import { resolveApiKey } from "./api-keys.js";
+
// ─── OpenCode Usage Tracking ──────────────────────────────────
// OpenCode has no public usage API. We scrape usage from the
// SolidStart SSR-rendered workspace page using a session cookie.
@@ -16,14 +18,14 @@ export interface OpencodeUsageReport {
}
function getWorkspaceId(keyId: string): string | undefined {
- // Match ai-usage convention: opencode-1 → OPENCODE_WS1_ID, opencode-2 → OPENCODE_WS2_ID
+ // Check DB for workspace ID: stored as "opencode-ws1", "opencode-ws2", or "opencode-ws"
const match = keyId.match(/opencode-(\d+)$/i);
if (match) {
const num = match[1];
- const specific = process.env[`OPENCODE_WS${num}_ID`];
+ const specific = resolveApiKey(`opencode-ws${num}`);
if (specific) return specific;
}
- return process.env.OPENCODE_WS_ID;
+ return resolveApiKey("opencode-ws") ?? undefined;
}
function parseOcDouble(html: string, key: string): number {
@@ -71,7 +73,7 @@ function parseOcBucket(
export async function fetchOpencodeUsage(
keyId: string,
): Promise<OpencodeUsageReport | null> {
- const cookie = process.env.OPENCODE_COOKIE;
+ const cookie = resolveApiKey("opencode-cookie");
const wsId = getWorkspaceId(keyId);
if (!cookie || !wsId) {
diff --git a/packages/core/src/credentials/store.ts b/packages/core/src/credentials/store.ts
new file mode 100644
index 0000000..6c814f9
--- /dev/null
+++ b/packages/core/src/credentials/store.ts
@@ -0,0 +1,177 @@
+import { getDatabase } from "../db/index.js";
+import type { ClaudeCredentials } from "./claude.js";
+import { existsSync, readFileSync } from "node:fs";
+
+export interface StoredCredential {
+ keyId: string;
+ provider: string;
+ accessToken: string;
+ refreshToken: string;
+ expiresAt: number;
+ subscriptionType: string | null;
+ sourceFile: string | null;
+ importedAt: number;
+ updatedAt: number;
+}
+
+function parseCredentialsFile(raw: string): ClaudeCredentials | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
+
+ const data = (parsed as Record<string, unknown>).claudeAiOauth ?? parsed;
+ const creds = data as Record<string, unknown>;
+
+ if (creds.mcpOAuth && !creds.accessToken) return null;
+
+ if (
+ typeof creds.accessToken !== "string" ||
+ typeof creds.refreshToken !== "string" ||
+ typeof creds.expiresAt !== "number"
+ ) {
+ return null;
+ }
+
+ return {
+ accessToken: creds.accessToken as string,
+ refreshToken: creds.refreshToken as string,
+ expiresAt: creds.expiresAt as number,
+ subscriptionType: typeof creds.subscriptionType === "string" ? creds.subscriptionType : undefined,
+ };
+}
+
+/**
+ * Import credentials from a file into the database for a specific key.
+ * Reads the credential file, parses it, and upserts into the credentials table.
+ */
+export function importCredentialsFromFile(
+ keyId: string,
+ provider: string,
+ filePath: string,
+): { success: boolean; error?: string } {
+ if (!existsSync(filePath)) {
+ return { success: false, error: `File not found: ${filePath}` };
+ }
+
+ let raw: string;
+ try {
+ raw = readFileSync(filePath, "utf-8").trim();
+ } catch (e) {
+ return { success: false, error: `Failed to read file: ${e instanceof Error ? e.message : String(e)}` };
+ }
+
+ if (!raw) {
+ return { success: false, error: "File is empty" };
+ }
+
+ const creds = parseCredentialsFile(raw);
+ if (!creds) {
+ return { success: false, error: "Invalid credentials format" };
+ }
+
+ const db = getDatabase();
+ const now = Date.now();
+
+ db.query(
+ `INSERT INTO credentials (key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at)
+ VALUES ($keyId, $provider, $accessToken, $refreshToken, $expiresAt, $subscriptionType, $sourceFile, $now, $now)
+ ON CONFLICT(key_id) DO UPDATE SET
+ access_token = $accessToken,
+ refresh_token = $refreshToken,
+ expires_at = $expiresAt,
+ subscription_type = $subscriptionType,
+ source_file = $sourceFile,
+ updated_at = $now`,
+ ).run({
+ $keyId: keyId,
+ $provider: provider,
+ $accessToken: creds.accessToken,
+ $refreshToken: creds.refreshToken,
+ $expiresAt: creds.expiresAt,
+ $subscriptionType: creds.subscriptionType ?? null,
+ $sourceFile: filePath,
+ $now: now,
+ });
+
+ return { success: true };
+}
+
+/**
+ * Get stored credentials for a specific key from the database.
+ */
+export function getStoredCredentials(keyId: string): StoredCredential | null {
+ const db = getDatabase();
+ const row = db.query(
+ "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials WHERE key_id = $keyId",
+ ).get({ $keyId: keyId }) as Record<string, unknown> | null;
+
+ if (!row) return null;
+
+ return {
+ keyId: row.key_id as string,
+ provider: row.provider as string,
+ accessToken: row.access_token as string,
+ refreshToken: row.refresh_token as string,
+ expiresAt: row.expires_at as number,
+ subscriptionType: row.subscription_type as string | null,
+ sourceFile: row.source_file as string | null,
+ importedAt: row.imported_at as number,
+ updatedAt: row.updated_at as number,
+ };
+}
+
+/**
+ * Update tokens in the database after a refresh.
+ */
+export function updateStoredTokens(
+ keyId: string,
+ accessToken: string,
+ refreshToken: string,
+ expiresAt: number,
+): void {
+ const db = getDatabase();
+ db.query(
+ `UPDATE credentials SET access_token = $accessToken, refresh_token = $refreshToken, expires_at = $expiresAt, updated_at = $now WHERE key_id = $keyId`,
+ ).run({
+ $keyId: keyId,
+ $accessToken: accessToken,
+ $refreshToken: refreshToken,
+ $expiresAt: expiresAt,
+ $now: Date.now(),
+ });
+}
+
+/**
+ * Delete stored credentials for a key.
+ */
+export function deleteStoredCredentials(keyId: string): void {
+ const db = getDatabase();
+ db.query("DELETE FROM credentials WHERE key_id = $keyId").run({ $keyId: keyId });
+}
+
+/**
+ * List all keys that have imported credentials, with their status.
+ */
+export function listStoredCredentials(): StoredCredential[] {
+ const db = getDatabase();
+ const rows = db.query(
+ "SELECT key_id, provider, access_token, refresh_token, expires_at, subscription_type, source_file, imported_at, updated_at FROM credentials ORDER BY key_id",
+ ).all() as Array<Record<string, unknown>>;
+
+ return rows.map((row) => ({
+ keyId: row.key_id as string,
+ provider: row.provider as string,
+ accessToken: row.access_token as string,
+ refreshToken: row.refresh_token as string,
+ expiresAt: row.expires_at as number,
+ subscriptionType: row.subscription_type as string | null,
+ sourceFile: row.source_file as string | null,
+ importedAt: row.imported_at as number,
+ updatedAt: row.updated_at as number,
+ }));
+}
diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts
new file mode 100644
index 0000000..818aff0
--- /dev/null
+++ b/packages/core/src/db/index.ts
@@ -0,0 +1,93 @@
+import { Database } from "bun:sqlite";
+import { existsSync, mkdirSync } from "node:fs";
+import { isAbsolute, join } from "node:path";
+import { homedir } from "node:os";
+
+/**
+ * Returns the directory for persistent Dispatch data, following XDG Base
+ * Directory spec on Linux: `$XDG_DATA_HOME/dispatch` (defaults to
+ * `~/.local/share/dispatch`).
+ */
+function getDataDir(): string {
+ const xdg = process.env.XDG_DATA_HOME;
+ const base = xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "share");
+ return join(base, "dispatch");
+}
+
+let _db: Database | null = null;
+
+/**
+ * Get (or create) the singleton SQLite database.
+ *
+ * - Creates the data directory if it doesn't exist.
+ * - Creates `dispatch.db` if it doesn't exist.
+ * - Enables WAL journal mode for concurrent read performance.
+ */
+export function getDatabase(): Database {
+ if (_db) return _db;
+
+ const dir = getDataDir();
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
+
+ const dbPath = join(dir, "dispatch.db");
+ _db = new Database(dbPath, { create: true });
+
+ // WAL mode: better concurrent read performance, safe for single-writer
+ _db.run("PRAGMA journal_mode = WAL;");
+ // Recommended for WAL: normal synchronous is safe and faster
+ _db.run("PRAGMA synchronous = NORMAL;");
+ // Enable foreign keys
+ _db.run("PRAGMA foreign_keys = ON;");
+
+ // Create tables
+ _db.run(`CREATE TABLE IF NOT EXISTS credentials (
+ key_id TEXT PRIMARY KEY,
+ provider TEXT NOT NULL,
+ access_token TEXT NOT NULL,
+ refresh_token TEXT NOT NULL,
+ expires_at INTEGER NOT NULL,
+ subscription_type TEXT,
+ source_file TEXT,
+ imported_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )`);
+
+ _db.run(`CREATE TABLE IF NOT EXISTS wake_schedule (
+ hour INTEGER PRIMARY KEY CHECK (hour BETWEEN 0 AND 23),
+ next_wake_at INTEGER NOT NULL
+ )`);
+
+ _db.run(`CREATE TABLE IF NOT EXISTS usage_cache (
+ key_id TEXT PRIMARY KEY,
+ provider TEXT NOT NULL,
+ cached_at INTEGER NOT NULL,
+ report_json TEXT NOT NULL
+ )`);
+
+ _db.run(`CREATE TABLE IF NOT EXISTS api_keys (
+ key_id TEXT PRIMARY KEY,
+ provider TEXT NOT NULL,
+ api_key TEXT NOT NULL,
+ imported_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ )`);
+
+ return _db;
+}
+
+/** Close the database connection (e.g. on shutdown). */
+export function closeDatabase(): void {
+ if (_db) {
+ _db.close();
+ _db = null;
+ }
+}
+
+/** Returns the path where the database file lives (or will live). */
+export function getDatabasePath(): string {
+ if (_db) return _db.filename;
+ const dir = getDataDir();
+ return join(dir, "dispatch.db");
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ae826dc..90cafbe 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -29,3 +29,6 @@ export { ModelRegistry, ModelResolver } from "./models/index.js";
// Credentials
export * from "./credentials/index.js";
+
+// Database
+export { getDatabase, closeDatabase, getDatabasePath } from "./db/index.js";