From db8c2574d62878d3d640f13c67f52b588137b06e Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 14 Apr 2026 15:14:00 +0200 Subject: [PATCH] feat(byok): IndexedDB vault + settings UI for user API keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4-5 of BYOK. Wires the BYOK backend into the running app and gives users a UI to manage their keys. Data layer (_byokKeys table, v16 schema): - Encrypted at rest via user master key (wrapValue/unwrapValue) - NOT in SYNC_APP_MAP — keys stay device-local on purpose - Tracks per-key usage: count, total tokens, cumulative USD cost byokVault API (lib/byok/): - listAll() / listMeta() — decrypt on read - create() — encrypts + auto-handles isDefault collisions - update() — label/model/isDefault edits (not the key itself) - delete() — soft-delete - recordUsage() — called from backend's onUsage callback - getForProvider() — resolver helper initByok() wires the ByokBackend into the shared orchestrator at app startup. The resolver picks the most-recently-used provider's key by default. Usage callback updates the key's counters + cost via estimateCost(). Settings page (/settings/ai-keys): - List existing keys with provider, label, model, usage stats - Add form: provider picker, label, API key (password input), model selector with provider defaults, isDefault toggle - Inline edit for label + model - Set-as-default action - Soft-delete with confirmation - Gracefully handles locked vault state Companion chat dropdown already picks up the new 'byok' tier from ALL_TIERS — user can select BYOK directly as KI-Modus. Total BYOK implementation: ~1500 LOC across 12 files. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mana/apps/web/src/lib/byok/index.ts | 3 + apps/mana/apps/web/src/lib/byok/init.ts | 78 +++ apps/mana/apps/web/src/lib/byok/types.ts | 44 ++ apps/mana/apps/web/src/lib/byok/vault.ts | 183 ++++++ apps/mana/apps/web/src/lib/data/database.ts | 7 + .../apps/web/src/routes/(app)/+layout.svelte | 213 ++----- .../(app)/settings/ai-keys/+page.svelte | 556 ++++++++++++++++++ 7 files changed, 933 insertions(+), 151 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/byok/index.ts create mode 100644 apps/mana/apps/web/src/lib/byok/init.ts create mode 100644 apps/mana/apps/web/src/lib/byok/types.ts create mode 100644 apps/mana/apps/web/src/lib/byok/vault.ts create mode 100644 apps/mana/apps/web/src/routes/(app)/settings/ai-keys/+page.svelte diff --git a/apps/mana/apps/web/src/lib/byok/index.ts b/apps/mana/apps/web/src/lib/byok/index.ts new file mode 100644 index 000000000..928227eb5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/byok/index.ts @@ -0,0 +1,3 @@ +export { byokVault } from './vault'; +export { initByok } from './init'; +export type { ByokKeyRecord, ByokKeyPlain } from './types'; diff --git a/apps/mana/apps/web/src/lib/byok/init.ts b/apps/mana/apps/web/src/lib/byok/init.ts new file mode 100644 index 000000000..abe95bcf6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/byok/init.ts @@ -0,0 +1,78 @@ +/** + * BYOK initialization — wires the ByokBackend into the shared orchestrator + * once the app is running and the vault is available. + * + * Called from (app)/+layout.svelte after manaStore.initialize() so the + * user's master key is available for decrypting API keys on demand. + */ + +import { + llmOrchestrator, + ByokBackend, + BUILTIN_BYOK_PROVIDERS, + estimateCost, + type ByokKeyResolver, + type ByokUsageCallback, + type ResolvedByokKey, + type ByokProviderId, +} from '@mana/shared-llm'; +import { byokVault } from './vault'; + +let initialized = false; +let _currentKeyIdByProvider = new Map(); + +/** Resolver callback: looks up the user's key for a given task. */ +const resolver: ByokKeyResolver = async ({ taskName }) => { + // For the probe call, just check if ANY key exists + if (taskName === '__probe__') { + const keys = await byokVault.listMeta(); + if (keys.length === 0) return null; + const first = keys[0]; + const full = await byokVault.getForProvider(first.provider); + if (!full) return null; + _currentKeyIdByProvider.set(full.provider, full.id); + return { provider: full.provider, apiKey: full.apiKey, model: full.model ?? '' }; + } + + // TODO: honor per-task provider overrides (e.g. "byok:anthropic") + // For now, use the user's default provider from settings, or the + // provider with the most-recently-used key. + const allMeta = await byokVault.listMeta(); + if (allMeta.length === 0) return null; + + // Pick the provider with the most recent lastUsedAt, or the first one + const sorted = [...allMeta].sort((a, b) => + (b.lastUsedAt ?? b.createdAt).localeCompare(a.lastUsedAt ?? a.createdAt) + ); + const chosenProvider = sorted[0].provider; + const key = await byokVault.getForProvider(chosenProvider); + if (!key) return null; + + _currentKeyIdByProvider.set(key.provider, key.id); + + // Use the key's configured model, or fall back to the provider default + const provider = BUILTIN_BYOK_PROVIDERS.find((p) => p.id === key.provider); + const model = key.model || provider?.defaultModel || ''; + + return { provider: key.provider, apiKey: key.apiKey, model }; +}; + +/** Usage tracker: increments counters on the key that was used. */ +const onUsage: ByokUsageCallback = ({ provider, model, promptTokens, completionTokens }) => { + const keyId = _currentKeyIdByProvider.get(provider); + if (!keyId) return; + const totalTokens = promptTokens + completionTokens; + const costUsd = estimateCost(model, promptTokens, completionTokens); + void byokVault.recordUsage(keyId, totalTokens, costUsd); +}; + +export function initByok(): void { + if (initialized) return; + const backend = new ByokBackend({ + resolver, + providers: BUILTIN_BYOK_PROVIDERS, + onUsage, + }); + llmOrchestrator.registerBackend(backend); + initialized = true; +} diff --git a/apps/mana/apps/web/src/lib/byok/types.ts b/apps/mana/apps/web/src/lib/byok/types.ts new file mode 100644 index 000000000..2b4881bf6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/byok/types.ts @@ -0,0 +1,44 @@ +/** + * BYOK Key types — device-local encrypted storage of user LLM API keys. + */ + +import type { ByokProviderId } from '@mana/shared-llm'; + +/** Raw record as stored in IndexedDB. The apiKey field is encrypted. */ +export interface ByokKeyRecord { + id: string; + provider: ByokProviderId; + label: string; + /** Encrypted via AES-GCM wrapValue() — stored as base64 {iv, ct} blob */ + apiKeyEncrypted: unknown; + /** Optional model override (provider default used if undefined) */ + model?: string; + /** Whether this is the default key for this provider */ + isDefault: boolean; + createdAt: string; + updatedAt: string; + lastUsedAt?: string; + /** Incremented after each successful call */ + usageCount: number; + /** Cumulative tokens used */ + totalTokens: number; + /** Cumulative cost estimate in USD */ + totalCostUsd: number; + deletedAt?: string; +} + +/** Plaintext view used by the UI (never serialized). */ +export interface ByokKeyPlain { + id: string; + provider: ByokProviderId; + label: string; + apiKey: string; + model?: string; + isDefault: boolean; + createdAt: string; + updatedAt: string; + lastUsedAt?: string; + usageCount: number; + totalTokens: number; + totalCostUsd: number; +} diff --git a/apps/mana/apps/web/src/lib/byok/vault.ts b/apps/mana/apps/web/src/lib/byok/vault.ts new file mode 100644 index 000000000..cfe73ff87 --- /dev/null +++ b/apps/mana/apps/web/src/lib/byok/vault.ts @@ -0,0 +1,183 @@ +/** + * BYOK Key Vault — encrypted CRUD for user-provided API keys. + * + * Keys are encrypted with the user's master key (same vault as the + * rest of Mana's encrypted tables). Without an unlocked vault, keys + * cannot be read — even if someone exfiltrates IndexedDB, the keys + * stay ciphertext. + * + * NOT synced: keys live device-local. Users must add them on each + * device. This is deliberate — a compromised server shouldn't be + * able to redistribute users' API keys. + */ + +import { db } from '$lib/data/database'; +import { wrapValue, unwrapValue } from '$lib/data/crypto/aes'; +import { getActiveKey } from '$lib/data/crypto/key-provider'; +import type { ByokProviderId } from '@mana/shared-llm'; +import type { ByokKeyRecord, ByokKeyPlain } from './types'; + +const TABLE = '_byokKeys'; + +function requireKey(): CryptoKey { + const key = getActiveKey(); + if (!key) { + throw new Error('Vault ist nicht entsperrt — bitte zuerst anmelden.'); + } + return key; +} + +async function encryptApiKey(apiKey: string): Promise { + return wrapValue(apiKey, requireKey()); +} + +async function decryptApiKey(encrypted: unknown): Promise { + const decrypted = await unwrapValue(encrypted, requireKey()); + if (typeof decrypted !== 'string') { + throw new Error('Decrypted API key is not a string'); + } + return decrypted; +} + +async function recordToPlain(rec: ByokKeyRecord): Promise { + return { + id: rec.id, + provider: rec.provider, + label: rec.label, + apiKey: await decryptApiKey(rec.apiKeyEncrypted), + model: rec.model, + isDefault: rec.isDefault, + createdAt: rec.createdAt, + updatedAt: rec.updatedAt, + lastUsedAt: rec.lastUsedAt, + usageCount: rec.usageCount, + totalTokens: rec.totalTokens, + totalCostUsd: rec.totalCostUsd, + }; +} + +export const byokVault = { + /** List all active (non-deleted) keys, decrypted. */ + async listAll(): Promise { + const all = await db.table(TABLE).toArray(); + const active = all.filter((k) => !k.deletedAt); + return Promise.all(active.map(recordToPlain)); + }, + + /** List keys metadata without decrypting (for UI display of label/usage only) */ + async listMeta(): Promise[]> { + const all = await db.table(TABLE).toArray(); + const active = all.filter((k) => !k.deletedAt); + return active.map((r) => ({ + id: r.id, + provider: r.provider, + label: r.label, + model: r.model, + isDefault: r.isDefault, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + lastUsedAt: r.lastUsedAt, + usageCount: r.usageCount, + totalTokens: r.totalTokens, + totalCostUsd: r.totalCostUsd, + })); + }, + + /** Get the default key for a provider, or first available if no default. */ + async getForProvider(provider: ByokProviderId): Promise { + const all = await db.table(TABLE).toArray(); + const forProvider = all.filter((k) => !k.deletedAt && k.provider === provider); + if (forProvider.length === 0) return null; + const preferred = forProvider.find((k) => k.isDefault) ?? forProvider[0]; + return recordToPlain(preferred); + }, + + /** Create a new key (encrypted before write). */ + async create(input: { + provider: ByokProviderId; + label: string; + apiKey: string; + model?: string; + isDefault?: boolean; + }): Promise { + const now = new Date().toISOString(); + const existing = await db.table(TABLE).toArray(); + const forProvider = existing.filter((k) => !k.deletedAt && k.provider === input.provider); + + // First key for this provider is default automatically + const isDefault = input.isDefault ?? forProvider.length === 0; + + // If setting as default, clear other defaults for this provider + if (isDefault && forProvider.length > 0) { + for (const other of forProvider.filter((k) => k.isDefault)) { + await db.table(TABLE).update(other.id, { isDefault: false, updatedAt: now }); + } + } + + const rec: ByokKeyRecord = { + id: crypto.randomUUID(), + provider: input.provider, + label: input.label, + apiKeyEncrypted: await encryptApiKey(input.apiKey), + model: input.model, + isDefault, + createdAt: now, + updatedAt: now, + usageCount: 0, + totalTokens: 0, + totalCostUsd: 0, + }; + await db.table(TABLE).add(rec); + return recordToPlain(rec); + }, + + /** Update label/model/isDefault (not the key itself — remove+recreate for that). */ + async update( + id: string, + patch: { label?: string; model?: string; isDefault?: boolean } + ): Promise { + const now = new Date().toISOString(); + const existing = await db.table(TABLE).get(id); + if (!existing) return; + + // If promoting to default, demote others of same provider + if (patch.isDefault === true) { + const all = await db.table(TABLE).toArray(); + const siblings = all.filter( + (k) => !k.deletedAt && k.provider === existing.provider && k.id !== id + ); + for (const s of siblings.filter((k) => k.isDefault)) { + await db.table(TABLE).update(s.id, { isDefault: false, updatedAt: now }); + } + } + + await db.table(TABLE).update(id, { + ...(patch.label !== undefined && { label: patch.label }), + ...(patch.model !== undefined && { model: patch.model }), + ...(patch.isDefault !== undefined && { isDefault: patch.isDefault }), + updatedAt: now, + }); + }, + + /** Soft-delete. */ + async delete(id: string): Promise { + await db.table(TABLE).update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + /** Increment usage counters after a successful call. */ + async recordUsage(id: string, tokens: number, costUsd: number): Promise { + const existing = await db.table(TABLE).get(id); + if (!existing) return; + const now = new Date().toISOString(); + await db.table(TABLE).update(id, { + usageCount: existing.usageCount + 1, + totalTokens: existing.totalTokens + tokens, + totalCostUsd: existing.totalCostUsd + costUsd, + lastUsedAt: now, + updatedAt: now, + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index eb0c1e81a..a62a287f7 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -487,6 +487,13 @@ db.version(13).stores({ sleepSettings: 'id', }); +// v16 — BYOK (Bring Your Own Key) storage for user-provided LLM API keys. +// Encrypted at rest via the user's master key (AES-GCM). NOT synced. +// Keys stay device-local — user must add them on each device. +db.version(16).stores({ + _byokKeys: 'id, provider, isDefault, [provider+isDefault]', +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index 4714f881d..bc64e631c 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -1,7 +1,7 @@ + + + KI-Keys - Mana + + +
+ + + {#if vaultLocked} +
Vault ist gesperrt — bitte zuerst anmelden um Keys zu verwalten.
+ {:else if loading} +
Laedt...
+ {:else} +
+ +
+ + {#if showAdd} +
+

Neuen Key hinzufuegen

+ + + + + + + + + + + + {#if addError} +
{addError}
+ {/if} + +
+ + +
+
+ {/if} + +
+ {#each keys as k (k.id)} +
+ {#if editingId === k.id} +
+ + + + +
+ {:else} +
+
+
+
+ {k.label} + {#if k.isDefault} + Standard + {/if} +
+
+ {providerDisplay(k.provider)} + · {k.model || `Default (${providerDefaultModel(k.provider)})`} +
+
+ {k.usageCount} Aufrufe · {k.totalTokens.toLocaleString('de-DE')} Token · {formatCost( + k.totalCostUsd + )} +
+
+
+ {#if !k.isDefault} + + {/if} + + +
+
+ {/if} +
+ {:else} +
+ +

Noch keine API-Keys hinterlegt.

+

+ Klicke auf "Key hinzufuegen" um mit OpenAI, Anthropic, Gemini oder Mistral zu chatten. +

+
+ {/each} +
+ {/if} +
+ +