profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
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