import type { FastifyInstance, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; import { getDb, apiKeys, apiUsage, eq, sql } from '@rareindex/database'; import { parseBearer, resolveApiKey, TIERS, type ResolvedKey } from '../lib/keys.js'; import { problem } from '../lib/envelope.js'; declare module 'fastify' { interface FastifyRequest { apiKey: ResolvedKey | null; } } /** * Bearer API-key authentication. Anonymous requests are allowed on the "public" tier (low limits) * so the docs playground and casual use work; keyed requests get their tier's limits and usage counters. */ export const authPlugin = fp(async (app: FastifyInstance) => { app.decorateRequest('apiKey', null); const cache = new Map(); app.addHook('onRequest', async (req, reply) => { const bearer = parseBearer(req.headers.authorization); if (!bearer) { req.apiKey = null; return; } const hit = cache.get(bearer); let resolved: ResolvedKey | null; if (hit && Date.now() - hit.at < 60_000) resolved = hit.key; else { resolved = await resolveApiKey(bearer); cache.set(bearer, { key: resolved, at: Date.now() }); if (cache.size > 5000) cache.clear(); } if (!resolved) return problem(reply, 401, 'Invalid API key', 'The provided API key is unknown or revoked.'); req.apiKey = resolved; // daily quota const today = new Date().toISOString().slice(0, 10); const [row] = await getDb().execute(sql`select coalesce(sum(count),0)::int as used from api_usage where key_id = ${resolved.id} and date = ${today}`); const used = Number((row as { used?: number } | undefined)?.used ?? 0); if (used >= resolved.dailyQuota) return problem(reply, 429, 'Daily quota exceeded', `Tier ${resolved.tier} allows ${resolved.dailyQuota} requests per day.`, { quota: resolved.dailyQuota, used }); }); app.addHook('onResponse', async (req, reply) => { if (!req.apiKey || !req.routeOptions.url?.startsWith('/v1')) return; const today = new Date().toISOString().slice(0, 10); const endpoint = req.routeOptions.url; const latency = reply.elapsedTime; try { await getDb() .insert(apiUsage) .values({ keyId: req.apiKey.id, date: today, endpoint, count: 1, latencyMsAvg: latency }) .onConflictDoUpdate({ target: [apiUsage.keyId, apiUsage.date, apiUsage.endpoint], set: { count: sql`${apiUsage.count} + 1`, latencyMsAvg: sql`(${apiUsage.latencyMsAvg} * ${apiUsage.count} + ${latency}) / (${apiUsage.count} + 1)` }, }); await getDb().update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, req.apiKey.id)); } catch (err) { req.log.warn({ err }, 'api usage counter failed'); } }); }); export function rateLimitFor(req: FastifyRequest): number { return req.apiKey?.rateLimitPerMinute ?? TIERS.public.rateLimitPerMinute; } export function rateKeyFor(req: FastifyRequest): string { return req.apiKey ? `key:${req.apiKey.id}` : `ip:${req.ip}`; }