SPB Git

spb/rareindex

Public
3.7 KB · 75 lines typescript
1import 'server-only';2import { createHmac, randomUUID } from 'node:crypto';3import { cookies, headers } from 'next/headers';4import { getCurrentUser } from '@/lib/auth/session';5import { getDb, aiQuotas, 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  const db = getDb();60  const [row] = await db.execute(sql`select count from ai_quotas where key = ${key} and feature = ${feature} and date = ${date} limit 1`);61  const used = Number((row as { count?: number } | undefined)?.count ?? 0);62  if (used >= limit) return { ok: false, used, limit };63  if (row) await db.execute(sql`update ai_quotas set count = count + 1 where key = ${key} and feature = ${feature} and date = ${date}`);64  else await db.insert(aiQuotas).values({ key, feature, date, count: 1 });65  return { ok: true, used: used + 1, 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
Files · d732f70ce5489245a263ca6bcf09fbebc0d2be77
Menu

Theme