SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
3.7 KB · 75 lines typescript
Raw Blame History
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