1import 'server-only';2import { createHmac, randomUUID } from 'node:crypto';3import { cookies, headers } from 'next/headers';4import { getDb, aiQuotas, sql } from '@rareindex/database';56const SECRET = () => process.env.SESSION_SECRET ?? 'dev-only';78/** Stable, non-reversible identifier for the caller's IP (quota keys, abuse review). */9export async function clientIpHash(): Promise<string> {10 const h = await headers();11 const ip = (h.get('x-forwarded-for')?.split(',')[0] ?? h.get('x-real-ip') ?? '0.0.0.0').trim();12 return createHmac('sha256', SECRET()).update(ip).digest('hex').slice(0, 32);13}1415export const ANON_COOKIE = 'ri_anon';1617/** Anonymous visitor id (httpOnly cookie) used to thread research sessions and scanner history. */18export async function getAnonId(create = true): Promise<string | null> {19 const jar = await cookies();20 const existing = jar.get(ANON_COOKIE)?.value;21 if (existing && /^[a-f0-9-]{36}$/.test(existing)) return existing;22 if (!create) return null;23 const id = randomUUID();24 try {25 jar.set(ANON_COOKIE, id, { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 24 * 365 });26 } catch {27 /* cannot set cookies during render; caller falls back to null */28 return null;29 }30 return id;31}3233/**34 * Signed-in user id, if any. Integration point for the account module: it reads the session35 * cookie (`ri_session`, falling back to `session`) and validates it against the `sessions` table.36 * If the auth module uses another cookie name or token format, adapt this one function only.37 */38export async function currentUserId(): Promise<string | null> {39 try {40 const jar = await cookies();41 const token = jar.get('ri_session')?.value ?? jar.get('session')?.value;42 if (!token || token.length > 200) return null;43 const [row] = await getDb().execute(sql`select user_id from sessions where id = ${token} and expires_at > now() limit 1`);44 return ((row as { user_id?: string } | undefined)?.user_id) ?? null;45 } catch {46 return null;47 }48}4950export const QUOTAS: Record<string, { anonymous: number; user: number }> = {51 scanner: { anonymous: 20, user: 100 },52 research: { anonymous: 40, user: 300 },53};5455/** Consume one unit of a daily quota. Returns remaining (−1 when exceeded). */56export async function consumeQuota(feature: keyof typeof QUOTAS, opts: { userId: string | null; ipHash: string }): Promise<{ ok: boolean; used: number; limit: number }> {57 const limit = opts.userId ? QUOTAS[feature]!.user : QUOTAS[feature]!.anonymous;58 const key = opts.userId ? `user:${opts.userId}` : `ip:${opts.ipHash}`;59 const date = new Date().toISOString().slice(0, 10);60 const db = getDb();61 const [row] = await db.execute(sql`select count from ai_quotas where key = ${key} and feature = ${feature} and date = ${date} limit 1`);62 const used = Number((row as { count?: number } | undefined)?.count ?? 0);63 if (used >= limit) return { ok: false, used, limit };64 if (row) await db.execute(sql`update ai_quotas set count = count + 1 where key = ${key} and feature = ${feature} and date = ${date}`);65 else await db.insert(aiQuotas).values({ key, feature, date, count: 1 });66 return { ok: true, used: used + 1, limit };67}6869export function aiErrorMessage(err: unknown): { status: number; message: string } {70 const e = err as { code?: string; message?: string; status?: number };71 if (e?.code === 'ai_not_configured') return { status: 503, message: 'AI provider not configured. Set ANTHROPIC_API_KEY (or OPENAI_API_KEY) on the server.' };72 if (e?.code === 'ai_refusal') return { status: 422, message: `The model declined this request${e.message ? `: ${e.message}` : ''}.` };73 if (typeof e?.status === 'number' && e.status === 429) return { status: 429, message: 'The AI provider is rate limiting requests. Try again shortly.' };74 return { status: 500, message: e?.message ?? 'Unexpected error' };75}76