SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
3.2 KB · 67 lines typescript
Raw Blame History
1import { createHash, timingSafeEqual } from 'node:crypto';2import { sql } from 'drizzle-orm';3import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';4import { TtlCache } from '../lib/cache.js';5import { ServiceUnavailable, Unauthorized } from '../lib/errors.js';6import type { ApiKeyInfo } from '../types.js';78export const ANONYMOUS_LIMIT_PER_MINUTE = 60;910export function hashApiKey(key: string): string {11  return createHash('sha256').update(key).digest('hex');12}1314/**15 * API key resolution (CLAUDE.md §190-191). `Authorization: Bearer <key>` → sha256 → api_keys.16 * Anonymous requests are allowed (60/min by IP); an invalid key is rejected with 401 rather than17 * silently downgraded so integrators notice misconfiguration.18 */19export function registerApiKeyAuth(app: FastifyInstance): void {20  const cache = new TtlCache<ApiKeyInfo | 'invalid'>(60_000);21  const lastTouched = new Map<number, number>();22  app.decorateRequest('apiKey', null);2324  app.addHook('onRequest', async (req: FastifyRequest) => {25    const header = req.headers.authorization;26    if (!header) return;27    const m = /^Bearer\s+(\S+)$/i.exec(header);28    if (!m) throw new Unauthorized('malformed Authorization header (expected "Bearer <key>")');29    const hash = hashApiKey(m[1]!);30    const info = await cache.getOrLoad(hash, async () => {31      const rows = await app.db.execute<{ id: number; prefix: string; tier: string; rate_limit_per_minute: number; label: string | null }>(sql`32        SELECT id, prefix, tier, rate_limit_per_minute, label FROM api_keys WHERE key_hash = ${hash} AND active LIMIT 1`);33      const r = rows[0];34      return r ? { id: Number(r.id), prefix: r.prefix, tier: r.tier, rateLimitPerMinute: Number(r.rate_limit_per_minute), label: r.label } : 'invalid';35    });36    if (info === 'invalid') throw new Unauthorized('invalid or inactive API key');37    req.apiKey = info;38    // Touch last_used_at at most every 5 minutes per key (fire-and-forget).39    const now = Date.now();40    if ((lastTouched.get(info.id) ?? 0) < now - 300_000) {41      lastTouched.set(info.id, now);42      app.db.execute(sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${info.id}`).catch((e: unknown) => app.log.warn({ err: e }, 'api_keys touch failed'));43    }44  });45}4647/** Rate-limit key: per API key when present, otherwise per client IP. */48export function rateLimitKey(req: FastifyRequest): string {49  return req.apiKey ? `key:${req.apiKey.id}` : `ip:${req.ip}`;50}5152export function rateLimitMax(req: FastifyRequest): number {53  return req.apiKey?.rateLimitPerMinute ?? ANONYMOUS_LIMIT_PER_MINUTE;54}5556/** Admin guard: constant-time comparison of `x-admin-token` with ADMIN_TOKEN. */57export async function requireAdmin(req: FastifyRequest, _reply: FastifyReply): Promise<void> {58  const expected = process.env.ADMIN_TOKEN;59  if (!expected || expected === 'change-me') throw new ServiceUnavailable('admin endpoints disabled: set ADMIN_TOKEN');60  const given = req.headers['x-admin-token'];61  const token = Array.isArray(given) ? given[0] : given;62  if (!token) throw new Unauthorized('missing x-admin-token');63  const a = Buffer.from(token);64  const b = Buffer.from(expected);65  if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Unauthorized('invalid admin token');66}67