From 58a8fde9524c2482de10c30210c816627dde9d75 Mon Sep 17 00:00:00 2001 From: Salmatcre8 Date: Mon, 1 Jun 2026 11:42:16 +0100 Subject: [PATCH] feat(api): add API key auth and per-key rate quotas --- .env.example | 7 + package.json | 1 + prisma/schema.prisma | 12 ++ scripts/issue-api-key.ts | 68 ++++++++++ sql/schema.sql | 14 ++ src/__tests__/auth.test.ts | 262 +++++++++++++++++++++++++++++++++++++ src/api/admin.ts | 108 +++++++++++++++ src/api/auth.ts | 97 ++++++++++++++ src/api/rest.ts | 4 +- src/index.ts | 28 +++- 10 files changed, 596 insertions(+), 5 deletions(-) create mode 100644 scripts/issue-api-key.ts create mode 100644 src/__tests__/auth.test.ts create mode 100644 src/api/admin.ts create mode 100644 src/api/auth.ts diff --git a/.env.example b/.env.example index 74a9dce2..8b092689 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,13 @@ AMM_PAGE_SIZE=200 # Secret key required for admin endpoints (e.g., adding/removing pairs) ADMIN_API_KEY=super-secret-admin-key +# Shared secret guarding the API-key admin endpoints (POST/DELETE /admin/keys) +# and the `npm run key:issue` CLI. Send as the X-Admin-Token header. +ADMIN_TOKEN=super-secret-admin-token +# Set to "false" to disable API-key auth entirely (e.g. local dev). Any other +# value (or unset) leaves auth enabled, so every non-public route needs a key. +REQUIRE_API_KEY=true + # --- App Logic --- # Comma-separated list of asset pairs to watch. # Format: "CODE:ISSUER/CODE:ISSUER". Use "native" for XLM. diff --git a/package.json b/package.json index 04beaf78..fe987eac 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "version-packages": "changeset version", "release": "changeset tag", "oracle:relay": "tsx examples/oracle-relay/relay.ts", + "key:issue": "tsx scripts/issue-api-key.ts", "db:push": "prisma db push", "db:generate": "prisma generate" }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f47498db..a2c8f2b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -101,3 +101,15 @@ model Webhook { @@index([assetA, assetB]) @@map("webhooks") } + +model ApiKey { + id String @id @default(uuid()) + hash String @unique + label String + ratePerMin Int @default(60) @map("rate_per_min") + ratePerDay Int @default(10000) @map("rate_per_day") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + + @@map("api_keys") +} diff --git a/scripts/issue-api-key.ts b/scripts/issue-api-key.ts new file mode 100644 index 00000000..699d9eba --- /dev/null +++ b/scripts/issue-api-key.ts @@ -0,0 +1,68 @@ +/** + * CLI to mint the first (or any) Lens API key. + * + * The plaintext key is printed exactly once — only its SHA-256 hash is stored. + * Run with: + * npm run key:issue -- --label "Acme integrator" --per-min 120 --per-day 50000 + * + * Requires DATABASE_URL to be set (same as the server). + */ +import 'dotenv/config' +import { randomBytes, createHash } from 'crypto' +import { PrismaClient } from '@prisma/client' + +function parseArgs(argv: string[]): Record { + const args: Record = {} + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const next = argv[i + 1] + if (next && !next.startsWith('--')) { + args[key] = next + i++ + } else { + args[key] = 'true' + } + } + } + return args +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + const label = args.label ?? 'default' + const ratePerMin = args['per-min'] ? parseInt(args['per-min'], 10) : undefined + const ratePerDay = args['per-day'] ? parseInt(args['per-day'], 10) : undefined + + const plaintext = `lens_${randomBytes(24).toString('hex')}` + const hash = createHash('sha256').update(plaintext, 'utf8').digest('hex') + + const prisma = new PrismaClient() + try { + const record = await prisma.apiKey.create({ + data: { + hash, + label, + ...(ratePerMin !== undefined ? { ratePerMin } : {}), + ...(ratePerDay !== undefined ? { ratePerDay } : {}), + }, + }) + + console.log('✅ API key created.') + console.log(` id: ${record.id}`) + console.log(` label: ${record.label}`) + console.log(` ratePerMin: ${record.ratePerMin}`) + console.log(` ratePerDay: ${record.ratePerDay}`) + console.log('') + console.log(' Plaintext key (store it now — it will not be shown again):') + console.log(` ${plaintext}`) + } finally { + await prisma.$disconnect() + } +} + +main().catch((err) => { + console.error('Failed to create API key:', err) + process.exit(1) +}) diff --git a/sql/schema.sql b/sql/schema.sql index 5742774b..ecce9704 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -63,3 +63,17 @@ CREATE TABLE IF NOT EXISTS indexer_state ( last_processed_at TIMESTAMPTZ, updated_at TIMESTAMPTZ DEFAULT NOW() ); + +-- API keys for authenticated, rate-quota'd access. +-- Only the SHA-256 hash of each key is ever stored — never the plaintext. +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + rate_per_min INTEGER NOT NULL DEFAULT 60, + rate_per_day INTEGER NOT NULL DEFAULT 10000, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys (hash); diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts new file mode 100644 index 00000000..19d83518 --- /dev/null +++ b/src/__tests__/auth.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createHash } from 'crypto' +import Fastify from 'fastify' +import rateLimit from '@fastify/rate-limit' + +// ── Mock Prisma (mirrors the pattern used by webhooks.test.ts) ───────────────── +vi.mock('../db', () => ({ + prisma: { + apiKey: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})) + +import { prisma } from '../db' +import { + registerApiKeyAuth, + hashApiKey, + extractBearer, + lookupApiKey, +} from '../api/auth' +import { registerAdminRoutes, generateApiKey } from '../api/admin' + +const mockFindUnique = prisma.apiKey.findUnique as unknown as ReturnType +const mockCreate = prisma.apiKey.create as unknown as ReturnType +const mockUpdate = prisma.apiKey.update as unknown as ReturnType + +function sha256(s: string): string { + return createHash('sha256').update(s, 'utf8').digest('hex') +} + +/** Builds an app with auth enabled + one protected and one public route. */ +async function buildAuthedApp() { + const app = Fastify() + // Auth must register before rate-limit: both run in `onRequest`, and the + // limiter reads req.apiKey, which the auth hook populates. + await app.register(registerApiKeyAuth) + await app.register(rateLimit, { + max: (req) => req.apiKey?.ratePerMin ?? 100, + timeWindow: '1 minute', + keyGenerator: (req) => req.apiKey?.id ?? req.ip, + }) + app.get('/price/test', async () => ({ ok: true })) + app.get('/status', { config: { public: true } }, async () => ({ ok: true })) + await app.ready() + return app +} + +beforeEach(() => { + mockFindUnique.mockReset() + mockCreate.mockReset() + mockUpdate.mockReset() +}) + +describe('hashApiKey / extractBearer', () => { + it('hashes keys with SHA-256 (never stores plaintext)', () => { + expect(hashApiKey('lens_secret')).toBe(sha256('lens_secret')) + // 64 hex chars = SHA-256 + expect(hashApiKey('x')).toMatch(/^[a-f0-9]{64}$/) + }) + + it('extracts the bearer token, case-insensitively', () => { + expect(extractBearer('Bearer abc123')).toBe('abc123') + expect(extractBearer('bearer spaced ')).toBe('spaced') + expect(extractBearer(undefined)).toBeNull() + expect(extractBearer('Basic abc')).toBeNull() + }) +}) + +describe('lookupApiKey', () => { + it('looks up by hash and returns context for a valid key', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, + }) + const ctx = await lookupApiKey('lens_plain') + expect(mockFindUnique).toHaveBeenCalledWith({ where: { hash: sha256('lens_plain') } }) + expect(ctx).toEqual({ id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000 }) + }) + + it('returns null for an unknown key', async () => { + mockFindUnique.mockResolvedValue(null) + expect(await lookupApiKey('nope')).toBeNull() + }) + + it('returns null for a revoked key', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 60, ratePerDay: 1000, revokedAt: new Date(), + }) + expect(await lookupApiKey('lens_plain')).toBeNull() + }) +}) + +describe('API key auth hook', () => { + it('returns 401 when no key is provided', async () => { + const app = await buildAuthedApp() + const res = await app.inject({ method: 'GET', url: '/price/test' }) + expect(res.statusCode).toBe(401) + expect(res.json()).toMatchObject({ error: 'Unauthorized' }) + expect(mockFindUnique).not.toHaveBeenCalled() + }) + + it('returns 401 for an invalid key', async () => { + mockFindUnique.mockResolvedValue(null) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer wrong' }, + }) + expect(res.statusCode).toBe(401) + }) + + it('returns 401 for a revoked key', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 60, ratePerDay: 1000, revokedAt: new Date(), + }) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer revoked' }, + }) + expect(res.statusCode).toBe(401) + }) + + it('allows a valid key through and attaches metadata', async () => { + mockFindUnique.mockResolvedValue({ + id: 'key-1', label: 'acme', ratePerMin: 120, ratePerDay: 50000, revokedAt: null, + }) + const app = await buildAuthedApp() + const res = await app.inject({ + method: 'GET', + url: '/price/test', + headers: { authorization: 'Bearer good' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) + + it('lets public routes through without a key', async () => { + const app = await buildAuthedApp() + const res = await app.inject({ method: 'GET', url: '/status' }) + expect(res.statusCode).toBe(200) + expect(mockFindUnique).not.toHaveBeenCalled() + }) +}) + +describe('per-key rate quotas', () => { + it('honors each key’s own ratePerMin (429 after the quota)', async () => { + // A key allowed only 2 requests/min. + mockFindUnique.mockResolvedValue({ + id: 'key-limited', label: 'small', ratePerMin: 2, ratePerDay: 1000, revokedAt: null, + }) + const app = await buildAuthedApp() + const headers = { authorization: 'Bearer limited' } + + const r1 = await app.inject({ method: 'GET', url: '/price/test', headers }) + const r2 = await app.inject({ method: 'GET', url: '/price/test', headers }) + const r3 = await app.inject({ method: 'GET', url: '/price/test', headers }) + + expect(r1.statusCode).toBe(200) + expect(r2.statusCode).toBe(200) + expect(r3.statusCode).toBe(429) // quota exhausted + }) + + it('tracks quotas independently per key', async () => { + const app = await buildAuthedApp() + // First key: limited to 1/min. + mockFindUnique.mockResolvedValue({ + id: 'key-a', label: 'a', ratePerMin: 1, ratePerDay: 100, revokedAt: null, + }) + const a1 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer a' } }) + const a2 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer a' } }) + expect(a1.statusCode).toBe(200) + expect(a2.statusCode).toBe(429) + + // Second, distinct key gets its own fresh bucket. + mockFindUnique.mockResolvedValue({ + id: 'key-b', label: 'b', ratePerMin: 1, ratePerDay: 100, revokedAt: null, + }) + const b1 = await app.inject({ method: 'GET', url: '/price/test', headers: { authorization: 'Bearer b' } }) + expect(b1.statusCode).toBe(200) + }) +}) + +describe('admin endpoints', () => { + const ORIGINAL = process.env.ADMIN_TOKEN + beforeEach(() => { process.env.ADMIN_TOKEN = 'admin-secret' }) + afterEach(() => { process.env.ADMIN_TOKEN = ORIGINAL }) + + async function buildAdminApp() { + const app = Fastify() + await registerAdminRoutes(app) + await app.ready() + return app + } + + it('generateApiKey produces a prefixed opaque key', () => { + const k = generateApiKey() + expect(k).toMatch(/^lens_[a-f0-9]{48}$/) + }) + + it('rejects minting without the admin token', async () => { + const app = await buildAdminApp() + const res = await app.inject({ + method: 'POST', url: '/admin/keys', payload: { label: 'x' }, + }) + expect(res.statusCode).toBe(401) + expect(mockCreate).not.toHaveBeenCalled() + }) + + it('mints a key and returns the plaintext once (stored as hash only)', async () => { + mockCreate.mockImplementation(async ({ data }: any) => ({ + id: 'new-id', createdAt: new Date(), ratePerMin: 60, ratePerDay: 10000, ...data, + })) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'POST', + url: '/admin/keys', + headers: { 'x-admin-token': 'admin-secret' }, + payload: { label: 'acme', ratePerMin: 120, ratePerDay: 50000 }, + }) + expect(res.statusCode).toBe(201) + const body = res.json() + expect(body.key).toMatch(/^lens_[a-f0-9]{48}$/) + expect(body.label).toBe('acme') + // What got persisted is the HASH of the returned key, not the key itself. + const stored = mockCreate.mock.calls[0][0].data + expect(stored.hash).toBe(sha256(body.key)) + expect(stored.hash).not.toBe(body.key) + }) + + it('revokes a key by id', async () => { + mockFindUnique.mockResolvedValue({ id: 'key-1', revokedAt: null }) + mockUpdate.mockResolvedValue({ id: 'key-1', revokedAt: new Date() }) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'DELETE', + url: '/admin/keys/key-1', + headers: { 'x-admin-token': 'admin-secret' }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ id: 'key-1', revoked: true }) + expect(mockUpdate).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { revokedAt: expect.any(Date) }, + }) + }) + + it('returns 404 revoking a non-existent key', async () => { + mockFindUnique.mockResolvedValue(null) + const app = await buildAdminApp() + const res = await app.inject({ + method: 'DELETE', + url: '/admin/keys/missing', + headers: { 'x-admin-token': 'admin-secret' }, + }) + expect(res.statusCode).toBe(404) + }) +}) diff --git a/src/api/admin.ts b/src/api/admin.ts new file mode 100644 index 00000000..b65133db --- /dev/null +++ b/src/api/admin.ts @@ -0,0 +1,108 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { randomBytes } from 'crypto' +import { prisma } from '../db' +import { hashApiKey } from './auth' + +/** Generates a new opaque API key with a recognizable `lens_` prefix. */ +export function generateApiKey(): string { + return `lens_${randomBytes(24).toString('hex')}` +} + +/** + * Guards admin routes with a shared secret supplied via the `ADMIN_TOKEN` + * env var, sent as `X-Admin-Token` (or `Authorization: Bearer `). + * Returns true if the caller is authorized. + */ +function isAdminAuthorized(req: FastifyRequest): boolean { + const adminToken = process.env.ADMIN_TOKEN + if (!adminToken) return false + const supplied = + (req.headers['x-admin-token'] as string | undefined) ?? + req.headers['authorization']?.replace(/^Bearer\s+/i, '') + return supplied === adminToken +} + +interface CreateKeyBody { + label?: string + ratePerMin?: number + ratePerDay?: number +} + +/** + * Registers admin-only endpoints for minting and revoking API keys. + * + * - `POST /admin/keys` — mint a new key (returns the plaintext key ONCE). + * - `DELETE /admin/keys/:id` — revoke an existing key. + * + * All routes are marked `config.public = true` so the API-key auth hook skips + * them; they enforce their own `ADMIN_TOKEN` check instead. + */ +export async function registerAdminRoutes(app: FastifyInstance) { + app.post<{ Body: CreateKeyBody }>( + '/admin/keys', + { config: { public: true } }, + async (req: FastifyRequest<{ Body: CreateKeyBody }>, reply: FastifyReply) => { + if (!isAdminAuthorized(req)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Provide a valid X-Admin-Token header.', + }) + } + + const { label, ratePerMin, ratePerDay } = req.body ?? {} + if (!label || typeof label !== 'string' || label.trim().length === 0) { + return reply.status(400).send({ error: 'label is required' }) + } + + // Generate the key, store only its hash, and return the plaintext once. + const plaintext = generateApiKey() + const record = await prisma.apiKey.create({ + data: { + hash: hashApiKey(plaintext), + label: label.trim(), + ...(ratePerMin !== undefined ? { ratePerMin } : {}), + ...(ratePerDay !== undefined ? { ratePerDay } : {}), + }, + }) + + return reply.status(201).send({ + id: record.id, + label: record.label, + ratePerMin: record.ratePerMin, + ratePerDay: record.ratePerDay, + // The plaintext key is shown only at creation time and never stored. + key: plaintext, + createdAt: record.createdAt, + }) + }, + ) + + app.delete<{ Params: { id: string } }>( + '/admin/keys/:id', + { config: { public: true } }, + async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { + if (!isAdminAuthorized(req)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Provide a valid X-Admin-Token header.', + }) + } + + const { id } = req.params + const existing = await prisma.apiKey.findUnique({ where: { id } }) + if (!existing) { + return reply.status(404).send({ error: 'API key not found' }) + } + if (existing.revokedAt) { + return reply.status(200).send({ id, revoked: true, alreadyRevoked: true }) + } + + await prisma.apiKey.update({ + where: { id }, + data: { revokedAt: new Date() }, + }) + + return reply.status(200).send({ id, revoked: true }) + }, + ) +} diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 00000000..44cf0d12 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,97 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { createHash } from 'crypto' +import fp from 'fastify-plugin' +import { prisma } from '../db' + +/** + * Metadata for an authenticated API key, attached to each request as + * `req.apiKey` once the key has been validated. + */ +export interface ApiKeyContext { + id: string + label: string + ratePerMin: number + ratePerDay: number +} + +declare module 'fastify' { + interface FastifyRequest { + apiKey?: ApiKeyContext + } + interface FastifyContextConfig { + /** When true, the API-key auth hook skips this route (e.g. health, admin). */ + public?: boolean + } +} + +/** SHA-256 hex digest of a plaintext API key. We only ever store/compare hashes. */ +export function hashApiKey(key: string): string { + return createHash('sha256').update(key, 'utf8').digest('hex') +} + +/** Extracts the bearer token from an Authorization header, or null. */ +export function extractBearer(header: string | undefined): string | null { + if (!header) return null + const match = /^Bearer\s+(.+)$/i.exec(header.trim()) + return match ? match[1].trim() : null +} + +/** + * Looks up an API key by its plaintext value, returning the key context only + * when the key exists and has not been revoked. + */ +export async function lookupApiKey(plaintext: string): Promise { + const hash = hashApiKey(plaintext) + const record = await prisma.apiKey.findUnique({ where: { hash } }) + if (!record || record.revokedAt) return null + return { + id: record.id, + label: record.label, + ratePerMin: record.ratePerMin, + ratePerDay: record.ratePerDay, + } +} + +/** + * Fastify plugin that authenticates requests via `Authorization: Bearer `. + * + * A request without a valid, non-revoked key gets 401. Valid requests have + * their key metadata attached to `req.apiKey` so downstream rate limiting can + * read per-key quotas. + * + * Runs as an `onRequest` hook (and must be registered BEFORE @fastify/rate-limit) + * so that `req.apiKey` is populated before the rate limiter — which also runs in + * `onRequest` — evaluates its per-key `max`/`keyGenerator`. + * + * Routes can opt out of auth (e.g. health/metrics) by setting + * `config.public = true` on the route definition. + */ +async function apiKeyAuthPlugin(app: FastifyInstance) { + app.decorateRequest('apiKey', undefined) + + app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => { + // Allow routes to mark themselves public (no auth required). + const routeConfig = (req.routeOptions?.config ?? {}) as { public?: boolean } + if (routeConfig.public) return + + const token = extractBearer(req.headers['authorization']) + if (!token) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Missing API key. Provide an Authorization: Bearer header.', + }) + } + + const keyContext = await lookupApiKey(token) + if (!keyContext) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Invalid or revoked API key.', + }) + } + + req.apiKey = keyContext + }) +} + +export const registerApiKeyAuth = fp(apiKeyAuthPlugin, { name: 'api-key-auth' }) diff --git a/src/api/rest.ts b/src/api/rest.ts index 43ea20cc..30737d1d 100644 --- a/src/api/rest.ts +++ b/src/api/rest.ts @@ -22,8 +22,8 @@ function findPair(assetA: string, assetB: string) { } export async function registerRESTRoutes(app: FastifyInstance) { - // GET /status - app.get('/status', async () => { + // GET /status — public health/monitoring endpoint (no API key required) + app.get('/status', { config: { public: true } }, async () => { const result = await pgPool.query( `SELECT last_ledger, last_processed_at FROM indexer_state ORDER BY updated_at DESC LIMIT 1` ) diff --git a/src/index.ts b/src/index.ts index 74baf628..718f7b10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,8 @@ import { registerCandleRoutes } from './routes/candles' import { registerPairsRoutes } from './routes/pairs' import { registerX402 } from './middleware/x402' import { registerWebSocket } from './api/websocket' +import { registerApiKeyAuth } from './api/auth' +import { registerAdminRoutes } from './api/admin' import { startSDEXIngester } from './ingesters/sdex' import { startAMMIngester } from './ingesters/amm' @@ -42,9 +44,25 @@ async function main() { const app = Fastify({ logger: { level: 'warn' } }) await app.register(cors, { origin: true }) await app.register(compress) + + // API-key authentication — validates Authorization: Bearer and attaches + // per-key quota metadata to req.apiKey. Registered BEFORE the rate limiter so + // that req.apiKey is populated when the limiter evaluates its per-key quota + // (both run in the onRequest phase, in registration order). Disabled when + // REQUIRE_API_KEY=false. Routes marked `config.public` bypass auth. + if (process.env.REQUIRE_API_KEY !== 'false') { + await app.register(registerApiKeyAuth) + } else { + app.log.warn('[auth] REQUIRE_API_KEY=false — API key authentication disabled') + } + + // Per-key rate quotas: the limit is derived from the authenticated key's + // metadata (req.apiKey, set by the auth hook above). Unauthenticated/public + // requests fall back to a conservative shared limit keyed by IP. await app.register(rateLimit, { - max: 100, + max: (req) => req.apiKey?.ratePerMin ?? 100, timeWindow: '1 minute', + keyGenerator: (req) => req.apiKey?.id ?? req.ip, allowList: (req) => req.url === '/status', errorResponseBuilder: (req, context) => ({ statusCode: 429, @@ -67,6 +85,10 @@ async function main() { } }) + // Admin endpoints (key issuance/revocation) — gated by ADMIN_TOKEN. Marked + // `config.public` so the API-key auth hook skips them. + await registerAdminRoutes(app) + await app.register(registerX402) await registerRESTRoutes(app) await registerWebhookRoutes(app) @@ -75,8 +97,8 @@ async function main() { await registerGraphQL(app) await registerWebSocket(app) - // Prometheus metrics endpoint (un-gated) - app.get('/metrics', async (req, reply) => { + // Prometheus metrics endpoint (un-gated, public — no API key required) + app.get('/metrics', { config: { public: true } }, async (req, reply) => { reply.type('text/plain; version=0.0.4; charset=utf-8') return await getMetrics() })