diff options
| author | Adam Malczewski <[email protected]> | 2026-05-21 17:30:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-21 17:30:08 +0900 |
| commit | d6b208342edf97bafa5b1dcc986b782f9879d141 (patch) | |
| tree | c6d8f9bffa86f78e3b1369b811bef9642df788d0 /packages/core/src | |
| parent | 1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (diff) | |
| download | dispatch-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.ts | 8 | ||||
| -rw-r--r-- | packages/core/src/credentials/api-keys.ts | 71 | ||||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 116 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 17 | ||||
| -rw-r--r-- | packages/core/src/credentials/opencode.ts | 10 | ||||
| -rw-r--r-- | packages/core/src/credentials/store.ts | 177 | ||||
| -rw-r--r-- | packages/core/src/db/index.ts | 93 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 3 |
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"; |
