TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import 'server-only';2import { createHmac, randomUUID } from 'node:crypto';3import { cookies, headers } from 'next/headers';4import { getCurrentUser } from '@/lib/auth/session';5import { getDb, sql } from '@rareindex/database';67const SECRET = () => process.env.SESSION_SECRET ?? 'dev-only';89/** Stable, non-reversible identifier for the caller's IP (quota keys, abuse review). */10export async function clientIpHash(): Promise<string> {11 const h = await headers();12 const ip = (h.get('x-forwarded-for')?.split(',')[0] ?? h.get('x-real-ip') ?? '0.0.0.0').trim();13 return createHmac('sha256', SECRET()).update(ip).digest('hex').slice(0, 32);14}1516export const ANON_COOKIE = 'ri_anon';1718/** Anonymous visitor id (httpOnly cookie) used to thread research sessions and scanner history. */19export async function getAnonId(create = true): Promise<string | null> {20 const jar = await cookies();21 const existing = jar.get(ANON_COOKIE)?.value;22 if (existing && /^[a-f0-9-]{36}$/.test(existing)) return existing;23 if (!create) return null;24 const id = randomUUID();25 try {26 jar.set(ANON_COOKIE, id, { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 24 * 365 });27 } catch {28 /* cannot set cookies during render; caller falls back to null */29 return null;30 }31 return id;32}3334/**35 * Signed-in user id, if any. Integration point for the account module: it reads the session36 * cookie (`ri_session`, falling back to `session`) and validates it against the `sessions` table.37 * If the auth module uses another cookie name or token format, adapt this one function only.38 */39export async function currentUserId(): Promise<string | null> {40 try {41 // Delegates to the account module (hashed session id, revocation, expiry) so both agree.42 const user = await getCurrentUser();43 return user?.id ?? null;44 } catch {45 return null;46 }47}4849export const QUOTAS: Record<string, { anonymous: number; user: number }> = {50 scanner: { anonymous: 20, user: 100 },51 research: { anonymous: 40, user: 300 },52};5354/** Consume one unit of a daily quota. Returns remaining (−1 when exceeded). */55export async function consumeQuota(feature: keyof typeof QUOTAS, opts: { userId: string | null; ipHash: string }): Promise<{ ok: boolean; used: number; limit: number }> {56 const limit = opts.userId ? QUOTAS[feature]!.user : QUOTAS[feature]!.anonymous;57 const key = opts.userId ? `user:${opts.userId}` : `ip:${opts.ipHash}`;58 const date = new Date().toISOString().slice(0, 10);59 // Atomic increment (unique (key, feature, date)): concurrent requests can no longer create60 // duplicate counters and slip past the limit.61 const [row] = await getDb().execute(sql`insert into ai_quotas (key, feature, date, count) values (${key}, ${feature}, ${date}, 1)62 on conflict (key, feature, date) do update set count = ai_quotas.count + 1 returning count`);63 const used = Number((row as { count?: number } | undefined)?.count ?? 1);64 if (used > limit) return { ok: false, used: used - 1, limit };65 return { ok: true, used, limit };66}6768export function aiErrorMessage(err: unknown): { status: number; message: string } {69 const e = err as { code?: string; message?: string; status?: number };70 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.' };71 if (e?.code === 'ai_refusal') return { status: 422, message: `The model declined this request${e.message ? `: ${e.message}` : ''}.` };72 if (typeof e?.status === 'number' && e.status === 429) return { status: 429, message: 'The AI provider is rate limiting requests. Try again shortly.' };73 return { status: 500, message: e?.message ?? 'Unexpected error' };74}75