/** * Admin API (/api/admin): header `x-dci-admin-token` must equal DCI_ADMIN_TOKEN (constant-time compare). * 60 req/min per IP. Responses use the same envelope `{ data, meta }`; errors `{ error, statusCode }`. */ import { createHash, timingSafeEqual } from "node:crypto"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { getEnv } from "../../env.js"; import { HttpError } from "../../lib/http.js"; import { connectorAdminRoutes } from "./connectors.js"; import { documentAdminRoutes } from "./documents.js"; import { matchAdminRoutes } from "./matches.js"; import { curationAdminRoutes } from "./curation.js"; import { devtoolAdminRoutes } from "./devtool.js"; import { opsAdminRoutes } from "./ops.js"; import { qualityAdminRoutes } from "./quality.js"; import { proxyAdminRoutes } from "./proxy.js"; import { claimAdminRoutes } from "./claims.js"; import { projectAdminRoutes } from "./projects.js"; export function tokenMatches(provided: string | undefined, expected: string | null): boolean { if (!expected || !provided) return false; const a = createHash("sha256").update(provided).digest(); const b = createHash("sha256").update(expected).digest(); return timingSafeEqual(a, b); } export function adminAuth(req: FastifyRequest, _reply: FastifyReply, done: (err?: Error) => void): void { const expected = getEnv().adminToken; if (!expected) return done(new HttpError(503, "admin API disabled: DCI_ADMIN_TOKEN is not configured (missing, empty or the \"change-me\" placeholder)")); const header = req.headers["x-dci-admin-token"]; const provided = Array.isArray(header) ? header[0] : header; if (!tokenMatches(provided, expected)) return done(new HttpError(401, "unauthorized")); done(); } export async function adminRoutes(app: FastifyInstance): Promise { app.addHook("onRequest", adminAuth); app.addHook("onSend", async (_req, reply) => { reply.header("cache-control", "no-store"); }); // route-level rate limit override for the admin scope app.addHook("onRoute", (route) => { route.config = { ...(route.config as Record | undefined), rateLimit: { max: getEnv().adminRateLimit, timeWindow: "1 minute" } }; route.schema = { ...(route.schema ?? {}), tags: ["admin"], security: [{ adminToken: [] }] }; }); await app.register(connectorAdminRoutes); await app.register(documentAdminRoutes); await app.register(matchAdminRoutes); await app.register(curationAdminRoutes); await app.register(devtoolAdminRoutes); await app.register(opsAdminRoutes); await app.register(qualityAdminRoutes); await app.register(proxyAdminRoutes); await app.register(claimAdminRoutes); await app.register(projectAdminRoutes); }