import "server-only"; import { and, eq } from "drizzle-orm"; import { getDb, providerConnections, type ProviderConnection } from "@/db"; import { decryptSecret, encryptSecret, fingerprintSecret, keyHint } from "@/lib/crypto/keys"; import { ids } from "@/lib/ids"; import { writeAudit } from "@/lib/audit"; import type { ProviderId } from "@/lib/ai/core/types"; import { getAdapter } from "@/lib/ai/providers"; import { log } from "@/lib/log"; /** What the browser is allowed to see. Never contains key material. */ export interface PublicConnection { id: string; provider: ProviderId; keyHint: string; status: string; lastValidatedAt: string | null; lastValidationError: string | null; lastSuccessAt: string | null; lastErrorAt: string | null; lastErrorCode: string | null; modelsAvailable: number | null; createdAt: string; updatedAt: string; } export function toPublic(c: ProviderConnection): PublicConnection { return { id: c.id, provider: c.provider, keyHint: c.keyHint, status: c.status, lastValidatedAt: c.lastValidatedAt?.toISOString() ?? null, lastValidationError: c.lastValidationError, lastSuccessAt: c.lastSuccessAt?.toISOString() ?? null, lastErrorAt: c.lastErrorAt?.toISOString() ?? null, lastErrorCode: c.lastErrorCode, modelsAvailable: c.modelsAvailable, createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), }; } export async function listConnections(userId: string): Promise { const rows = await getDb().select().from(providerConnections).where(eq(providerConnections.userId, userId)); return rows.map(toPublic); } export async function getConnection(userId: string, provider: ProviderId): Promise { const [row] = await getDb() .select() .from(providerConnections) .where(and(eq(providerConnections.userId, userId), eq(providerConnections.provider, provider))) .limit(1); return row ?? null; } /** Decrypt the user's key for `provider` — only call immediately before a provider request. */ export async function getDecryptedKey(userId: string, provider: ProviderId): Promise { const row = await getConnection(userId, provider); if (!row) return null; try { return decryptSecret(row.encryptedKey, { userId, provider }); } catch (e) { log.error("key decrypt failed", { provider, connectionId: row.id, error: (e as Error).message }); return null; } } export async function upsertConnection(userId: string, provider: ProviderId, plaintextKey: string, opts: { validate?: boolean; ip?: string | null } = {}) { const key = plaintextKey.trim(); if (key.length < 8 || key.length > 512 || /\s/.test(key)) { throw new Error("That doesn't look like a valid API key."); } const db = getDb(); const existing = await getConnection(userId, provider); const encryptedKey = encryptSecret(key, { userId, provider }); const fp = fingerprintSecret(key); const hint = keyHint(key); const now = new Date(); let validation: { ok: boolean; modelsAvailable?: number; errorMessage?: string; errorCode?: string } | null = null; if (opts.validate !== false) { const adapter = getAdapter(provider); const res = await adapter.validateApiKey(key); validation = { ok: res.ok, modelsAvailable: res.modelsAvailable, errorMessage: res.error?.message, errorCode: res.error?.code }; } const values = { encryptedKey, keyHint: hint, keyFingerprint: fp, status: validation ? (validation.ok ? "valid" : "invalid") : "unverified", lastValidatedAt: validation ? now : null, lastValidationError: validation && !validation.ok ? validation.errorMessage ?? validation.errorCode ?? "Validation failed" : null, modelsAvailable: validation?.modelsAvailable ?? null, updatedAt: now, }; if (existing) { await db.update(providerConnections).set(values).where(eq(providerConnections.id, existing.id)); await writeAudit({ userId, action: "provider.key_replaced", ipAddress: opts.ip, meta: { provider, changed: existing.keyFingerprint !== fp } }); } else { await db.insert(providerConnections).values({ id: ids.connection(), userId, provider, ...values, createdAt: now }); await writeAudit({ userId, action: "provider.key_added", ipAddress: opts.ip, meta: { provider } }); } return { ok: validation ? validation.ok : true, error: validation && !validation.ok ? validation.errorMessage : undefined, modelsAvailable: validation?.modelsAvailable }; } export async function validateConnection(userId: string, provider: ProviderId) { const row = await getConnection(userId, provider); if (!row) throw new Error("No key configured for this provider."); const key = decryptSecret(row.encryptedKey, { userId, provider }); const res = await getAdapter(provider).validateApiKey(key); const now = new Date(); await getDb() .update(providerConnections) .set({ status: res.ok ? "valid" : "invalid", lastValidatedAt: now, lastValidationError: res.ok ? null : res.error?.message ?? "Validation failed", modelsAvailable: res.modelsAvailable ?? row.modelsAvailable, updatedAt: now, }) .where(eq(providerConnections.id, row.id)); return res; } export async function deleteConnection(userId: string, provider: ProviderId, ip?: string | null) { await getDb() .delete(providerConnections) .where(and(eq(providerConnections.userId, userId), eq(providerConnections.provider, provider))); await writeAudit({ userId, action: "provider.key_deleted", ipAddress: ip, meta: { provider } }); } /** Called after a real request to keep "last success / last error" fresh (best-effort, no throw). */ export async function recordProviderOutcome(userId: string, provider: ProviderId, outcome: { ok: true } | { ok: false; code: string }) { try { const now = new Date(); await getDb() .update(providerConnections) .set(outcome.ok ? { lastSuccessAt: now, status: "valid", updatedAt: now } : { lastErrorAt: now, lastErrorCode: outcome.code, updatedAt: now, ...(outcome.code === "INVALID_API_KEY" ? { status: "invalid" } : {}) }) .where(and(eq(providerConnections.userId, userId), eq(providerConnections.provider, provider))); } catch { /* best effort */ } }