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
JavaScript
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,
};
}
};
}