byok.ts
2,943 bytes
| 1 | /** |
|---|---|
| 2 | * BYOK key resolution - there is no server-paid key. |
| 3 | * |
| 4 | * A request's OpenRouter key comes from one of two places, in order: |
| 5 | * 1. the authenticated user's saved key (encrypted at rest, AES-256-GCM), or |
| 6 | * 2. a session-only key the visitor supplied, held encrypted in an HttpOnly |
| 7 | * cookie (never persisted server-side). |
| 8 | * |
| 9 | * In `MOCK_LLM` mode no key is required - the mock client serves everything. |
| 10 | */ |
| 11 | import { cookies } from 'next/headers'; |
| 12 | import { currentUserId } from '@/auth'; |
| 13 | import { getEncryptedKey } from '@/db/repositories'; |
| 14 | import { decryptSecret, encryptSecret } from './crypto'; |
| 15 | import { env } from './env'; |
| 16 | |
| 17 | export const SESSION_KEY_COOKIE = 'rt_session_key'; |
| 18 | |
| 19 | const COOKIE_OPTS = { |
| 20 | httpOnly: true, |
| 21 | secure: env.NODE_ENV === 'production', |
| 22 | sameSite: 'lax' as const, |
| 23 | path: '/', |
| 24 | maxAge: 60 * 60 * 12, // 12 hours |
| 25 | }; |
| 26 | |
| 27 | export type KeySource = 'mock' | 'saved' | 'session'; |
| 28 | |
| 29 | export interface ResolvedKey { |
| 30 | ok: true; |
| 31 | apiKey: string; |
| 32 | source: KeySource; |
| 33 | } |
| 34 | export interface KeyMissing { |
| 35 | ok: false; |
| 36 | reason: string; |
| 37 | } |
| 38 | |
| 39 | /** Resolve the OpenRouter key for the current request. Never returns it to the client. */ |
| 40 | export async function resolveApiKey(): Promise<ResolvedKey | KeyMissing> { |
| 41 | if (env.MOCK_LLM) return { ok: true, apiKey: 'mock', source: 'mock' }; |
| 42 | |
| 43 | const userId = await currentUserId(); |
| 44 | if (userId) { |
| 45 | const encrypted = await getEncryptedKey(userId); |
| 46 | if (encrypted) { |
| 47 | try { |
| 48 | return { ok: true, apiKey: decryptSecret(encrypted), source: 'saved' }; |
| 49 | } catch { |
| 50 | return { ok: false, reason: 'Saved key could not be decrypted; please re-enter it.' }; |
| 51 | } |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | const jar = await cookies(); |
| 56 | const sessionEnc = jar.get(SESSION_KEY_COOKIE)?.value; |
| 57 | if (sessionEnc) { |
| 58 | try { |
| 59 | return { ok: true, apiKey: decryptSecret(sessionEnc), source: 'session' }; |
| 60 | } catch { |
| 61 | return { ok: false, reason: 'Session key is invalid; please re-enter it.' }; |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | return { ok: false, reason: 'No OpenRouter API key found. Add one to start a debate.' }; |
| 66 | } |
| 67 | |
| 68 | /** Store a session-only key (encrypted) in an HttpOnly cookie. */ |
| 69 | export async function setSessionKeyCookie(apiKey: string): Promise<void> { |
| 70 | const jar = await cookies(); |
| 71 | jar.set(SESSION_KEY_COOKIE, encryptSecret(apiKey), COOKIE_OPTS); |
| 72 | } |
| 73 | |
| 74 | export async function clearSessionKeyCookie(): Promise<void> { |
| 75 | const jar = await cookies(); |
| 76 | jar.delete(SESSION_KEY_COOKIE); |
| 77 | } |
| 78 | |
| 79 | /** Whether the current request has *some* usable key (without decrypting for use). */ |
| 80 | export async function hasAnyKey(): Promise<{ has: boolean; source: KeySource | null }> { |
| 81 | if (env.MOCK_LLM) return { has: true, source: 'mock' }; |
| 82 | const userId = await currentUserId(); |
| 83 | if (userId && (await getEncryptedKey(userId))) return { has: true, source: 'saved' }; |
| 84 | const jar = await cookies(); |
| 85 | if (jar.get(SESSION_KEY_COOKIE)?.value) return { has: true, source: 'session' }; |
| 86 | return { has: false, source: null }; |
| 87 | } |
| 88 | |