TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import "server-only";2import { and, eq } from "drizzle-orm";3import { getDb, providerConnections, type ProviderConnection } from "@/db";4import { decryptSecret, encryptSecret, fingerprintSecret, keyHint } from "@/lib/crypto/keys";5import { ids } from "@/lib/ids";6import { writeAudit } from "@/lib/audit";7import type { ProviderId } from "@/lib/ai/core/types";8import { getAdapter } from "@/lib/ai/providers";9import { log } from "@/lib/log";1011/** What the browser is allowed to see. Never contains key material. */12export interface PublicConnection {13 id: string;14 provider: ProviderId;15 keyHint: string;16 status: string;17 lastValidatedAt: string | null;18 lastValidationError: string | null;19 lastSuccessAt: string | null;20 lastErrorAt: string | null;21 lastErrorCode: string | null;22 modelsAvailable: number | null;23 createdAt: string;24 updatedAt: string;25}2627export function toPublic(c: ProviderConnection): PublicConnection {28 return {29 id: c.id,30 provider: c.provider,31 keyHint: c.keyHint,32 status: c.status,33 lastValidatedAt: c.lastValidatedAt?.toISOString() ?? null,34 lastValidationError: c.lastValidationError,35 lastSuccessAt: c.lastSuccessAt?.toISOString() ?? null,36 lastErrorAt: c.lastErrorAt?.toISOString() ?? null,37 lastErrorCode: c.lastErrorCode,38 modelsAvailable: c.modelsAvailable,39 createdAt: c.createdAt.toISOString(),40 updatedAt: c.updatedAt.toISOString(),41 };42}4344export async function listConnections(userId: string): Promise<PublicConnection[]> {45 const rows = await getDb().select().from(providerConnections).where(eq(providerConnections.userId, userId));46 return rows.map(toPublic);47}4849export async function getConnection(userId: string, provider: ProviderId): Promise<ProviderConnection | null> {50 const [row] = await getDb()51 .select()52 .from(providerConnections)53 .where(and(eq(providerConnections.userId, userId), eq(providerConnections.provider, provider)))54 .limit(1);55 return row ?? null;56}5758/** Decrypt the user's key for `provider` — only call immediately before a provider request. */59export async function getDecryptedKey(userId: string, provider: ProviderId): Promise<string | null> {60 const row = await getConnection(userId, provider);61 if (!row) return null;62 try {63 return decryptSecret(row.encryptedKey, { userId, provider });64 } catch (e) {65 log.error("key decrypt failed", { provider, connectionId: row.id, error: (e as Error).message });66 return null;67 }68}6970export async function upsertConnection(userId: string, provider: ProviderId, plaintextKey: string, opts: { validate?: boolean; ip?: string | null } = {}) {71 const key = plaintextKey.trim();72 if (key.length < 8 || key.length > 512 || /\s/.test(key)) {73 throw new Error("That doesn't look like a valid API key.");74 }75 const db = getDb();76 const existing = await getConnection(userId, provider);77 const encryptedKey = encryptSecret(key, { userId, provider });78 const fp = fingerprintSecret(key);79 const hint = keyHint(key);80 const now = new Date();8182 let validation: { ok: boolean; modelsAvailable?: number; errorMessage?: string; errorCode?: string } | null = null;83 if (opts.validate !== false) {84 const adapter = getAdapter(provider);85 const res = await adapter.validateApiKey(key);86 validation = { ok: res.ok, modelsAvailable: res.modelsAvailable, errorMessage: res.error?.message, errorCode: res.error?.code };87 }8889 const values = {90 encryptedKey,91 keyHint: hint,92 keyFingerprint: fp,93 status: validation ? (validation.ok ? "valid" : "invalid") : "unverified",94 lastValidatedAt: validation ? now : null,95 lastValidationError: validation && !validation.ok ? validation.errorMessage ?? validation.errorCode ?? "Validation failed" : null,96 modelsAvailable: validation?.modelsAvailable ?? null,97 updatedAt: now,98 };99100 if (existing) {101 await db.update(providerConnections).set(values).where(eq(providerConnections.id, existing.id));102 await writeAudit({ userId, action: "provider.key_replaced", ipAddress: opts.ip, meta: { provider, changed: existing.keyFingerprint !== fp } });103 } else {104 await db.insert(providerConnections).values({ id: ids.connection(), userId, provider, ...values, createdAt: now });105 await writeAudit({ userId, action: "provider.key_added", ipAddress: opts.ip, meta: { provider } });106 }107 return { ok: validation ? validation.ok : true, error: validation && !validation.ok ? validation.errorMessage : undefined, modelsAvailable: validation?.modelsAvailable };108}109110export async function validateConnection(userId: string, provider: ProviderId) {111 const row = await getConnection(userId, provider);112 if (!row) throw new Error("No key configured for this provider.");113 const key = decryptSecret(row.encryptedKey, { userId, provider });114 const res = await getAdapter(provider).validateApiKey(key);115 const now = new Date();116 await getDb()117 .update(providerConnections)118 .set({119 status: res.ok ? "valid" : "invalid",120 lastValidatedAt: now,121 lastValidationError: res.ok ? null : res.error?.message ?? "Validation failed",122 modelsAvailable: res.modelsAvailable ?? row.modelsAvailable,123 updatedAt: now,124 })125 .where(eq(providerConnections.id, row.id));126 return res;127}128129export async function deleteConnection(userId: string, provider: ProviderId, ip?: string | null) {130 await getDb()131 .delete(providerConnections)132 .where(and(eq(providerConnections.userId, userId), eq(providerConnections.provider, provider)));133 await writeAudit({ userId, action: "provider.key_deleted", ipAddress: ip, meta: { provider } });134}135136/** Called after a real request to keep "last success / last error" fresh (best-effort, no throw). */137export async function recordProviderOutcome(userId: string, provider: ProviderId, outcome: { ok: true } | { ok: false; code: string }) {138 try {139 const now = new Date();140 await getDb()141 .update(providerConnections)142 .set(outcome.ok ? { lastSuccessAt: now, status: "valid", updatedAt: now } : { lastErrorAt: now, lastErrorCode: outcome.code, updatedAt: now, ...(outcome.code === "INVALID_API_KEY" ? { status: "invalid" } : {}) })143 .where(and(eq(providerConnections.userId, userId), eq(providerConnections.provider, provider)));144 } catch {145 /* best effort */146 }147}148