UNPKG

counterfact

Version:

Generate a TypeScript-based mock server from an OpenAPI spec in seconds — with stateful routes, hot reload, and REPL support.

271 lines (270 loc) 11.1 kB
import createDebug from "debug"; const debug = createDebug("counterfact:server:admin-api-middleware"); function extractBearerToken(authorization) { if (!authorization) return undefined; const [scheme, token] = authorization.trim().split(/\s+/, 2); if (!scheme || !token || scheme.toLowerCase() !== "bearer") return undefined; return token; } function normalizeIp(ip) { if (!ip) return ""; if (ip.startsWith("::ffff:")) return ip.slice("::ffff:".length); return ip; } function isLoopbackIp(ip) { const normalized = normalizeIp(ip); return (normalized === "127.0.0.1" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1"); } /** * Admin API middleware for programmatic access to Counterfact internals. * Exposes context management, proxy configuration, and route discovery * through HTTP endpoints at the given path prefix. * * This enables AI agents and external tools to interact with the mock server * in the same way the REPL does, but via HTTP requests. * * @param pathPrefix - The URL path prefix at which the admin API is mounted, * e.g. `"/_counterfact/api"`. Requests to paths that do not start with this * prefix fall through to the next middleware. * @param registry - The route registry used to list available routes. * @param contextRegistry - The context registry used to read and update * per-path context objects. * @param config - Server configuration (proxy settings, port, etc.). * @returns A Koa middleware function. */ export function adminApiMiddleware(pathPrefix, registry, contextRegistry, config) { return async (ctx, next) => { const { pathname } = ctx.URL; // Only handle admin API routes (exact prefix or paths beneath it) if (pathname !== pathPrefix && !pathname.startsWith(`${pathPrefix}/`)) { return await next(); } // ===== Admin API Access Guard ===== // If an admin API token is configured, require Authorization: Bearer <token>. // If not set, only allow loopback access. const configuredToken = (config.adminApiToken ?? process.env.COUNTERFACT_ADMIN_API_TOKEN ?? "").trim(); const authHeader = typeof ctx.get === "function" ? ctx.get("authorization") : ctx.request.headers.authorization; const providedToken = extractBearerToken(authHeader); if (configuredToken) { if (!providedToken || providedToken !== configuredToken) { debug("Admin API unauthorized request: missing/invalid bearer token"); ctx.status = 401; ctx.body = { success: false, error: "Unauthorized", message: "Admin API requires a valid bearer token.", }; return; } } else { const requestIp = normalizeIp(ctx.ip || ctx.request.ip || ctx.req.socket.remoteAddress); if (!isLoopbackIp(requestIp)) { debug("Admin API forbidden request from non-loopback IP: %s", requestIp); ctx.status = 403; ctx.body = { success: false, error: "Forbidden", message: "Admin API is restricted to localhost unless an admin token is configured.", }; return; } } debug("Admin API request: %s %s", ctx.method, pathname); // Extract the resource path after the prefix const subPath = pathname.slice(pathPrefix.length); const parts = subPath.split("/").filter(Boolean); const [resource, ...rest] = parts; try { // ===== Health Check ===== if (resource === "health" && ctx.method === "GET") { ctx.body = { status: "ok", port: config.port, uptime: process.uptime(), basePath: config.basePath, prefix: config.prefix, }; return; } // ===== List All Contexts ===== if (resource === "contexts" && rest.length === 0 && ctx.method === "GET") { const paths = contextRegistry.getAllPaths(); const contexts = contextRegistry.getAllContexts(); ctx.body = { success: true, data: { paths, contexts, }, }; return; } // ===== Get Specific Context ===== if (resource === "contexts" && rest.length > 0 && ctx.method === "GET") { const path = "/" + rest.join("/"); const context = contextRegistry.find(path); ctx.body = { success: true, data: { path, context, }, }; return; } // ===== Update Context ===== if (resource === "contexts" && rest.length > 0 && ctx.method === "POST") { const path = "/" + rest.join("/"); const newContextCandidate = ctx.request.body; if (!newContextCandidate || typeof newContextCandidate !== "object" || Array.isArray(newContextCandidate)) { ctx.status = 400; ctx.body = { success: false, error: "Request body must be a valid, non-array JSON object", }; return; } // Update the context using the registry's smart diffing const newContext = newContextCandidate; contextRegistry.update(path, newContext); ctx.body = { success: true, message: `Context updated for path: ${path}`, data: { path, context: contextRegistry.find(path), }, }; return; } // ===== Get Full Config ===== if (resource === "config" && rest.length === 0 && ctx.method === "GET") { ctx.body = { success: true, data: { alwaysFakeOptionals: config.alwaysFakeOptionals, adminApiTokenConfigured: Boolean(configuredToken), basePath: config.basePath, buildCache: config.buildCache, generate: config.generate, openApiPath: config.openApiPath, port: config.port, proxyUrl: config.proxyUrl, prefix: config.prefix, startAdminApi: config.startAdminApi ?? false, startRepl: config.startRepl, startServer: config.startServer, watch: config.watch, // Don't expose proxyPaths Map directly, convert to array proxyPaths: Array.from(config.proxyPaths.entries()), }, }; return; } // ===== Get Proxy Configuration ===== if (resource === "config" && rest[0] === "proxy" && ctx.method === "GET") { ctx.body = { success: true, data: { proxyUrl: config.proxyUrl, proxyPaths: Array.from(config.proxyPaths.entries()), }, }; return; } // ===== Update Proxy Configuration ===== if (resource === "config" && rest[0] === "proxy" && ctx.method === "PATCH") { const body = ctx.request.body; if (!body || typeof body !== "object") { ctx.status = 400; ctx.body = { success: false, error: "Request body must be a valid JSON object", }; return; } // Update proxy URL if provided if (body.proxyUrl !== undefined) { if (typeof body.proxyUrl !== "string") { ctx.status = 400; ctx.body = { success: false, error: "proxyUrl must be a string", }; return; } const proxyUrl = body.proxyUrl.trim(); config.proxyUrl = proxyUrl; debug("Updated proxy URL to: %s", config.proxyUrl); } // Update proxy paths if provided if (Array.isArray(body.proxyPaths)) { for (const [path, enabled] of body.proxyPaths) { if (typeof path === "string" && typeof enabled === "boolean") { config.proxyPaths.set(path, enabled); debug("Set proxy for %s to %s", path, enabled); } } } ctx.body = { success: true, message: "Proxy configuration updated", data: { proxyUrl: config.proxyUrl, proxyPaths: Array.from(config.proxyPaths.entries()), }, }; return; } // ===== List All Routes ===== if (resource === "routes" && ctx.method === "GET") { ctx.body = { success: true, data: { routes: registry.routes, }, }; return; } // ===== 404 for Unknown Endpoints ===== ctx.status = 404; ctx.body = { success: false, error: "Not found", path: pathname, message: `Unknown admin API endpoint: ${pathname}`, }; } catch (error) { // ===== 500 for Server Errors ===== debug("Admin API error: %O", error); ctx.status = 500; ctx.body = { success: false, error: error instanceof Error ? error.message : "Unknown error", stack: error instanceof Error && process.env.NODE_ENV !== "production" ? error.stack : undefined, }; } }; }