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.0 KB · 70 lines typescript
Raw Blame History
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