spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
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