TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { FastifyInstance, FastifyRequest } from 'fastify';2import fp from 'fastify-plugin';3import { getDb, apiKeys, apiUsage, eq, sql } from '@rareindex/database';4import { parseBearer, resolveApiKey, TIERS, type ResolvedKey } from '../lib/keys.js';5import { problem } from '../lib/envelope.js';67declare module 'fastify' {8 interface FastifyRequest {9 apiKey: ResolvedKey | null;10 }11}1213/**14 * Bearer API-key authentication. Anonymous requests are allowed on the "public" tier (low limits)15 * so the docs playground and casual use work; keyed requests get their tier's limits and usage counters.16 */17export const authPlugin = fp(async (app: FastifyInstance) => {18 app.decorateRequest('apiKey', null);19 const cache = new Map<string, { key: ResolvedKey | null; at: number }>();2021 app.addHook('onRequest', async (req, reply) => {22 const bearer = parseBearer(req.headers.authorization);23 if (!bearer) {24 req.apiKey = null;25 return;26 }27 const hit = cache.get(bearer);28 let resolved: ResolvedKey | null;29 if (hit && Date.now() - hit.at < 60_000) resolved = hit.key;30 else {31 resolved = await resolveApiKey(bearer);32 cache.set(bearer, { key: resolved, at: Date.now() });33 if (cache.size > 5000) cache.clear();34 }35 if (!resolved) return problem(reply, 401, 'Invalid API key', 'The provided API key is unknown or revoked.');36 req.apiKey = resolved;37 // daily quota38 const today = new Date().toISOString().slice(0, 10);39 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}`);40 const used = Number((row as { used?: number } | undefined)?.used ?? 0);41 if (used >= resolved.dailyQuota) return problem(reply, 429, 'Daily quota exceeded', `Tier ${resolved.tier} allows ${resolved.dailyQuota} requests per day.`, { quota: resolved.dailyQuota, used });42 });4344 app.addHook('onResponse', async (req, reply) => {45 if (!req.apiKey || !req.routeOptions.url?.startsWith('/v1')) return;46 const today = new Date().toISOString().slice(0, 10);47 const endpoint = req.routeOptions.url;48 const latency = reply.elapsedTime;49 try {50 await getDb()51 .insert(apiUsage)52 .values({ keyId: req.apiKey.id, date: today, endpoint, count: 1, latencyMsAvg: latency })53 .onConflictDoUpdate({54 target: [apiUsage.keyId, apiUsage.date, apiUsage.endpoint],55 set: { count: sql`${apiUsage.count} + 1`, latencyMsAvg: sql`(${apiUsage.latencyMsAvg} * ${apiUsage.count} + ${latency}) / (${apiUsage.count} + 1)` },56 });57 await getDb().update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, req.apiKey.id));58 } catch (err) {59 req.log.warn({ err }, 'api usage counter failed');60 }61 });62});6364export function rateLimitFor(req: FastifyRequest): number {65 return req.apiKey?.rateLimitPerMinute ?? TIERS.public.rateLimitPerMinute;66}67export function rateKeyFor(req: FastifyRequest): string {68 return req.apiKey ? `key:${req.apiKey.id}` : `ip:${req.ip}`;69}70