import 'server-only'; import { createHmac, randomUUID } from 'node:crypto'; import { cookies, headers } from 'next/headers'; import { getCurrentUser } from '@/lib/auth/session'; import { getDb, sql } from '@rareindex/database'; const SECRET = () => process.env.SESSION_SECRET ?? 'dev-only'; /** Stable, non-reversible identifier for the caller's IP (quota keys, abuse review). */ export async function clientIpHash(): Promise { const h = await headers(); const ip = (h.get('x-forwarded-for')?.split(',')[0] ?? h.get('x-real-ip') ?? '0.0.0.0').trim(); return createHmac('sha256', SECRET()).update(ip).digest('hex').slice(0, 32); } export const ANON_COOKIE = 'ri_anon'; /** Anonymous visitor id (httpOnly cookie) used to thread research sessions and scanner history. */ export async function getAnonId(create = true): Promise { const jar = await cookies(); const existing = jar.get(ANON_COOKIE)?.value; if (existing && /^[a-f0-9-]{36}$/.test(existing)) return existing; if (!create) return null; const id = randomUUID(); try { jar.set(ANON_COOKIE, id, { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 24 * 365 }); } catch { /* cannot set cookies during render; caller falls back to null */ return null; } return id; } /** * Signed-in user id, if any. Integration point for the account module: it reads the session * cookie (`ri_session`, falling back to `session`) and validates it against the `sessions` table. * If the auth module uses another cookie name or token format, adapt this one function only. */ export async function currentUserId(): Promise { try { // Delegates to the account module (hashed session id, revocation, expiry) so both agree. const user = await getCurrentUser(); return user?.id ?? null; } catch { return null; } } export const QUOTAS: Record = { scanner: { anonymous: 20, user: 100 }, research: { anonymous: 40, user: 300 }, }; /** Consume one unit of a daily quota. Returns remaining (−1 when exceeded). */ export async function consumeQuota(feature: keyof typeof QUOTAS, opts: { userId: string | null; ipHash: string }): Promise<{ ok: boolean; used: number; limit: number }> { const limit = opts.userId ? QUOTAS[feature]!.user : QUOTAS[feature]!.anonymous; const key = opts.userId ? `user:${opts.userId}` : `ip:${opts.ipHash}`; const date = new Date().toISOString().slice(0, 10); // Atomic increment (unique (key, feature, date)): concurrent requests can no longer create // duplicate counters and slip past the limit. const [row] = await getDb().execute(sql`insert into ai_quotas (key, feature, date, count) values (${key}, ${feature}, ${date}, 1) on conflict (key, feature, date) do update set count = ai_quotas.count + 1 returning count`); const used = Number((row as { count?: number } | undefined)?.count ?? 1); if (used > limit) return { ok: false, used: used - 1, limit }; return { ok: true, used, limit }; } export function aiErrorMessage(err: unknown): { status: number; message: string } { const e = err as { code?: string; message?: string; status?: number }; 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.' }; if (e?.code === 'ai_refusal') return { status: 422, message: `The model declined this request${e.message ? `: ${e.message}` : ''}.` }; if (typeof e?.status === 'number' && e.status === 429) return { status: 429, message: 'The AI provider is rate limiting requests. Try again shortly.' }; return { status: 500, message: e?.message ?? 'Unexpected error' }; }