import { eq } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { karakeepConfig, karakeepAccounts } from './schema'; // Karakeep store access. Callers deal in PLAINTEXT — encryption to and from at-rest ciphertext happens // here, so neither the proxy nor the provisioner touches crypto directly. The key purpose is // `karakeep`, minted on first use by the secret store (docs/secret-store.md). // ── Instance configuration (server-wide, one row) ── export type KarakeepConfig = { url: string | null; enabled: boolean; updatedAt: Date | null }; const EMPTY_CONFIG: KarakeepConfig = { url: null, enabled: true, updatedAt: null }; /** * Where this install's Karakeep is, or `url: null` when the owner has not pointed it anywhere yet. * * Never throws and never creates the row. A missing row and an unset URL are the same answer to the only * question anyone asks here, and an install nobody has configured is an ordinary state rather than an * error. The admin token is deliberately NOT returned — see `getAdminApiKey`. */ export async function getKarakeepConfig(): Promise { const [row] = await db.select().from(karakeepConfig).where(eq(karakeepConfig.id, 1)); if (!row) return EMPTY_CONFIG; return { url: row.url, enabled: row.enabled, updatedAt: row.updatedAt }; } /** Trailing slash removed on write, so `${url}/api/v1/...` can never double the separator. */ const normaliseUrl = (raw: string): string => raw.trim().replace(/\/+$/, ''); /** * Point this install at a Karakeep, or change which one. * * The URL is stored as given (normalised) and NOT probed. Reachability is a separate question with its * own route, and a setter that refused an unreachable address would make configuring an instance that is * temporarily down impossible. */ export async function setKarakeepConfig(patch: { url?: string | null; enabled?: boolean; adminApiKey?: string | null; }): Promise { const values = { id: 1, ...(patch.url !== undefined ? { url: patch.url === null ? null : normaliseUrl(patch.url) } : {}), ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}), ...(patch.adminApiKey !== undefined ? { adminApiKey: patch.adminApiKey === null ? null : await encryptSecret('karakeep', patch.adminApiKey) } : {}), updatedAt: new Date(), }; await db .insert(karakeepConfig) .values(values) .onConflictDoUpdate({ target: karakeepConfig.id, set: values }); return getKarakeepConfig(); } /** * The admin token, decrypted. Read by provisioning and by nothing else. * * Separate from `getKarakeepConfig` on purpose: that one is handed to the settings panel and to the * sidecar, and neither has any business holding a credential that can create accounts. A single function * that returned everything would make the careful thing the one you have to remember to do. */ export async function getAdminApiKey(): Promise { const [row] = await db.select().from(karakeepConfig).where(eq(karakeepConfig.id, 1)); if (!row?.adminApiKey) return null; return decryptSecret('karakeep', row.adminApiKey); } // ── Account mapping (one row per Officer user) ── export type KarakeepAccount = { userId: number; karakeepUserId: string; email: string; /** Null when an account exists but no key has been minted for it yet. A real state, not an error. */ apiKey: string | null; apiKeyId: string | null; }; /** Who this Officer user is on Karakeep, or null when nobody has mapped them. */ export async function getAccount(userId: number): Promise { const [row] = await db.select().from(karakeepAccounts).where(eq(karakeepAccounts.userId, userId)); if (!row) return null; return { userId: row.userId, karakeepUserId: row.karakeepUserId, email: row.email, apiKey: row.apiKey ? await decryptSecret('karakeep', row.apiKey) : null, apiKeyId: row.apiKeyId, }; } /** Every mapping, for the owner's account screen. Keys are NOT included — only whether one exists. */ export async function listAccounts(): Promise< { userId: number; karakeepUserId: string; email: string; hasKey: boolean }[] > { const rows = await db.select().from(karakeepAccounts); return rows.map((row) => ({ userId: row.userId, karakeepUserId: row.karakeepUserId, email: row.email, hasKey: !!row.apiKey, })); } /** Map an Officer user onto a Karakeep account, or replace the mapping. Upsert on `userId`. */ export async function setAccount(account: { userId: number; karakeepUserId: string; email: string; apiKey?: string | null; apiKeyId?: string | null; }): Promise { const values = { userId: account.userId, karakeepUserId: account.karakeepUserId, email: account.email, ...(account.apiKey !== undefined ? { apiKey: account.apiKey === null ? null : await encryptSecret('karakeep', account.apiKey) } : {}), ...(account.apiKeyId !== undefined ? { apiKeyId: account.apiKeyId } : {}), updatedAt: new Date(), }; await db.insert(karakeepAccounts).values(values).onConflictDoUpdate({ target: karakeepAccounts.userId, set: values }); } /** * Forget the mapping. Does NOT delete the Karakeep account. * * Deliberate: Officer created it but does not own what is in it, and a person removed from an Officer * install should not silently lose a library of saved reading. Deleting it there is a separate act, done * by somebody who means it. */ export async function forgetAccount(userId: number): Promise { const rows = await db .delete(karakeepAccounts) .where(eq(karakeepAccounts.userId, userId)) .returning({ userId: karakeepAccounts.userId }); return rows.length > 0; }