• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pmcelhaney / counterfact / 23049296168

13 Mar 2026 11:43AM UTC coverage: 83.42% (+0.7%) from 82.725%
23049296168

push

github

web-flow
Merge pull request #1523 from pmcelhaney/agent-skill-admin-api

feat(server): Add an admin API and related Agent Skill

1377 of 1548 branches covered (88.95%)

Branch coverage included in aggregate %.

331 of 345 new or added lines in 5 files covered. (95.94%)

4384 of 5358 relevant lines covered (81.82%)

56.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.68
/src/server/admin-api-middleware.ts
1
import type Koa from "koa";
2✔
2
import createDebug from "debug";
2✔
3

2✔
4
import type { Config } from "./config.js";
2✔
5
import type { ContextRegistry } from "./context-registry.js";
2✔
6
import type { Registry } from "./registry.js";
2✔
7

2✔
8
const debug = createDebug("counterfact:server:admin-api-middleware");
2✔
9

2✔
10
interface AdminApiResponse {
2✔
11
  success?: boolean;
2✔
12
  data?: unknown;
2✔
13
  error?: string;
2✔
14
  message?: string;
2✔
15
}
2✔
16

2✔
17
function extractBearerToken(authorization?: string): string | undefined {
38✔
18
  if (!authorization) return undefined;
38✔
19
  const [scheme, token] = authorization.trim().split(/\s+/, 2);
4✔
20
  if (!scheme || !token || scheme.toLowerCase() !== "bearer") return undefined;
38!
21
  return token;
4✔
22
}
4✔
23

2✔
24
function normalizeIp(ip?: string): string {
68✔
25
  if (!ip) return "";
68!
26
  if (ip.startsWith("::ffff:")) return ip.slice("::ffff:".length);
68!
27
  return ip;
68✔
28
}
68✔
29

2✔
30
function isLoopbackIp(ip?: string): boolean {
34✔
31
  const normalized = normalizeIp(ip);
34✔
32
  return (
34✔
33
    normalized === "127.0.0.1" ||
34✔
34
    normalized === "::1" ||
2✔
35
    normalized === "0:0:0:0:0:0:0:1"
2✔
36
  );
34✔
37
}
34✔
38

2✔
39
/**
2✔
40
 * Admin API middleware for programmatic access to Counterfact internals.
2✔
41
 * Exposes context management, proxy configuration, and route discovery
2✔
42
 * through HTTP endpoints at /_counterfact/api/*
2✔
43
 *
2✔
44
 * This enables AI agents and external tools to interact with the mock server
2✔
45
 * in the same way the REPL does, but via HTTP requests.
2✔
46
 */
2✔
47
export function adminApiMiddleware(
2✔
48
  registry: Registry,
40✔
49
  contextRegistry: ContextRegistry,
40✔
50
  config: Config,
40✔
51
): Koa.Middleware {
40✔
52
  return async (ctx: Koa.ExtendableContext, next: Koa.Next) => {
40✔
53
    const { pathname } = ctx.URL;
40✔
54

40✔
55
    // Only handle admin API routes
40✔
56
    if (!pathname.startsWith("/_counterfact/api/")) {
40✔
57
      return await next();
2✔
58
    }
2✔
59

38✔
60
    // ===== Admin API Access Guard =====
38✔
61
    // If an admin API token is configured, require Authorization: Bearer <token>.
38✔
62
    // If not set, only allow loopback access.
38✔
63
    const configuredToken = (
38✔
64
      config.adminApiToken ??
38!
65
      process.env.COUNTERFACT_ADMIN_API_TOKEN ??
40!
66
      ""
40✔
67
    ).trim();
40✔
68
    const authHeader =
40✔
69
      typeof ctx.get === "function"
40✔
70
        ? ctx.get("authorization")
38!
71
        : (ctx.request.headers.authorization as string | undefined);
40✔
72
    const providedToken = extractBearerToken(authHeader);
40✔
73

40✔
74
    if (configuredToken) {
40✔
75
      if (!providedToken || providedToken !== configuredToken) {
4✔
76
        debug("Admin API unauthorized request: missing/invalid bearer token");
2✔
77
        ctx.status = 401;
2✔
78
        ctx.body = {
2✔
79
          success: false,
2✔
80
          error: "Unauthorized",
2✔
81
          message: "Admin API requires a valid bearer token.",
2✔
82
        } as AdminApiResponse;
2✔
83
        return;
2✔
84
      }
2✔
85
    } else {
40✔
86
      const requestIp = normalizeIp(
34✔
87
        ctx.ip || ctx.request.ip || ctx.req.socket.remoteAddress,
34!
88
      );
34✔
89

34✔
90
      if (!isLoopbackIp(requestIp)) {
34✔
91
        debug(
2✔
92
          "Admin API forbidden request from non-loopback IP: %s",
2✔
93
          requestIp,
2✔
94
        );
2✔
95
        ctx.status = 403;
2✔
96
        ctx.body = {
2✔
97
          success: false,
2✔
98
          error: "Forbidden",
2✔
99
          message:
2✔
100
            "Admin API is restricted to localhost unless an admin token is configured.",
2✔
101
        } as AdminApiResponse;
2✔
102
        return;
2✔
103
      }
2✔
104
    }
34✔
105

34✔
106
    debug("Admin API request: %s %s", ctx.method, pathname);
34✔
107

34✔
108
    // Extract route components: ["_counterfact", "api", "resource", ...rest]
34✔
109
    const parts = pathname.split("/").filter(Boolean);
34✔
110
    const [, , resource, ...rest] = parts;
34✔
111

34✔
112
    try {
34✔
113
      // ===== Health Check =====
34✔
114
      if (resource === "health" && ctx.method === "GET") {
40✔
115
        ctx.body = {
4✔
116
          status: "ok",
4✔
117
          port: config.port,
4✔
118
          uptime: process.uptime(),
4✔
119
          basePath: config.basePath,
4✔
120
          routePrefix: config.routePrefix,
4✔
121
        } as AdminApiResponse;
4✔
122
        return;
4✔
123
      }
4✔
124

30✔
125
      // ===== List All Contexts =====
30✔
126
      if (
30✔
127
        resource === "contexts" &&
30✔
128
        rest.length === 0 &&
14✔
129
        ctx.method === "GET"
4✔
130
      ) {
40✔
131
        const paths = contextRegistry.getAllPaths();
4✔
132
        const contexts = contextRegistry.getAllContexts();
4✔
133

4✔
134
        ctx.body = {
4✔
135
          success: true,
4✔
136
          data: {
4✔
137
            paths,
4✔
138
            contexts,
4✔
139
          },
4✔
140
        } as AdminApiResponse;
4✔
141
        return;
4✔
142
      }
4✔
143

26✔
144
      // ===== Get Specific Context =====
26✔
145
      if (resource === "contexts" && rest.length > 0 && ctx.method === "GET") {
40✔
146
        const path = "/" + rest.join("/");
4✔
147
        const context = contextRegistry.find(path);
4✔
148

4✔
149
        ctx.body = {
4✔
150
          success: true,
4✔
151
          data: {
4✔
152
            path,
4✔
153
            context,
4✔
154
          },
4✔
155
        } as AdminApiResponse;
4✔
156
        return;
4✔
157
      }
4✔
158

22✔
159
      // ===== Update Context =====
22✔
160
      if (resource === "contexts" && rest.length > 0 && ctx.method === "POST") {
40✔
161
        const path = "/" + rest.join("/");
6✔
162
        const newContext = ctx.request.body;
6✔
163

6✔
164
        if (
6✔
165
          !newContext ||
6✔
166
          typeof newContext !== "object" ||
6✔
167
          Array.isArray(newContext)
4✔
168
        ) {
6✔
169
          ctx.status = 400;
2✔
170
          ctx.body = {
2✔
171
            success: false,
2✔
172
            error: "Request body must be a valid, non-array JSON object",
2✔
173
          } as AdminApiResponse;
2✔
174
          return;
2✔
175
        }
2✔
176

4✔
177
        // Update the context using the registry's smart diffing
4✔
178
        contextRegistry.update(path, newContext);
4✔
179

4✔
180
        ctx.body = {
4✔
181
          success: true,
4✔
182
          message: `Context updated for path: ${path}`,
4✔
183
          data: {
4✔
184
            path,
4✔
185
            context: contextRegistry.find(path),
4✔
186
          },
4✔
187
        } as AdminApiResponse;
4✔
188
        return;
4✔
189
      }
4✔
190

16✔
191
      // ===== Get Full Config =====
16✔
192
      if (resource === "config" && rest.length === 0 && ctx.method === "GET") {
40✔
193
        ctx.body = {
2✔
194
          success: true,
2✔
195
          data: {
2✔
196
            alwaysFakeOptionals: config.alwaysFakeOptionals,
2✔
197
            adminApiTokenConfigured: Boolean(configuredToken),
2✔
198
            basePath: config.basePath,
2✔
199
            buildCache: config.buildCache,
2✔
200
            generate: config.generate,
2✔
201
            openApiPath: config.openApiPath,
2✔
202
            port: config.port,
2✔
203
            proxyUrl: config.proxyUrl,
2✔
204
            routePrefix: config.routePrefix,
2✔
205
            startAdminApi: config.startAdminApi,
2✔
206
            startRepl: config.startRepl,
2✔
207
            startServer: config.startServer,
2✔
208
            watch: config.watch,
2✔
209
            // Don't expose proxyPaths Map directly, convert to array
2✔
210
            proxyPaths: Array.from(config.proxyPaths.entries()),
2✔
211
          },
2✔
212
        } as AdminApiResponse;
2✔
213
        return;
2✔
214
      }
2✔
215

14✔
216
      // ===== Get Proxy Configuration =====
14✔
217
      if (
14✔
218
        resource === "config" &&
14✔
219
        rest[0] === "proxy" &&
10✔
220
        ctx.method === "GET"
10✔
221
      ) {
40✔
222
        ctx.body = {
2✔
223
          success: true,
2✔
224
          data: {
2✔
225
            proxyUrl: config.proxyUrl,
2✔
226
            proxyPaths: Array.from(config.proxyPaths.entries()),
2✔
227
          },
2✔
228
        } as AdminApiResponse;
2✔
229
        return;
2✔
230
      }
2✔
231

12✔
232
      // ===== Update Proxy Configuration =====
12✔
233
      if (
12✔
234
        resource === "config" &&
12✔
235
        rest[0] === "proxy" &&
8✔
236
        ctx.method === "PATCH"
8✔
237
      ) {
40✔
238
        const body = ctx.request.body as {
8✔
239
          proxyUrl?: string;
8✔
240
          proxyPaths?: Array<[string, boolean]>;
8✔
241
        };
8✔
242

8✔
243
        if (!body || typeof body !== "object") {
8✔
244
          ctx.status = 400;
2✔
245
          ctx.body = {
2✔
246
            success: false,
2✔
247
            error: "Request body must be a valid JSON object",
2✔
248
          } as AdminApiResponse;
2✔
249
          return;
2✔
250
        }
2✔
251

6✔
252
        // Update proxy URL if provided
6✔
253
        if (body.proxyUrl !== undefined) {
8✔
254
          if (typeof body.proxyUrl !== "string") {
4!
NEW
255
            ctx.status = 400;
×
NEW
256
            ctx.body = {
×
NEW
257
              success: false,
×
NEW
258
              error: "proxyUrl must be a string",
×
NEW
259
            } as AdminApiResponse;
×
NEW
260
            return;
×
NEW
261
          }
×
262
          const proxyUrl = body.proxyUrl.trim();
4✔
263
          config.proxyUrl = proxyUrl;
4✔
264
          debug("Updated proxy URL to: %s", config.proxyUrl);
4✔
265
        }
4✔
266

6✔
267
        // Update proxy paths if provided
6✔
268
        if (Array.isArray(body.proxyPaths)) {
8✔
269
          for (const [path, enabled] of body.proxyPaths) {
4✔
270
            if (typeof path === "string" && typeof enabled === "boolean") {
6✔
271
              config.proxyPaths.set(path, enabled);
6✔
272
              debug("Set proxy for %s to %s", path, enabled);
6✔
273
            }
6✔
274
          }
6✔
275
        }
4✔
276

6✔
277
        ctx.body = {
6✔
278
          success: true,
6✔
279
          message: "Proxy configuration updated",
6✔
280
          data: {
6✔
281
            proxyUrl: config.proxyUrl,
6✔
282
            proxyPaths: Array.from(config.proxyPaths.entries()),
6✔
283
          },
6✔
284
        } as AdminApiResponse;
6✔
285
        return;
6✔
286
      }
6✔
287

4✔
288
      // ===== List All Routes =====
4✔
289
      if (resource === "routes" && ctx.method === "GET") {
40✔
290
        ctx.body = {
2✔
291
          success: true,
2✔
292
          data: {
2✔
293
            routes: registry.routes,
2✔
294
          },
2✔
295
        } as AdminApiResponse;
2✔
296
        return;
2✔
297
      }
2✔
298

2✔
299
      // ===== 404 for Unknown Endpoints =====
2✔
300
      ctx.status = 404;
2✔
301
      ctx.body = {
2✔
302
        success: false,
2✔
303
        error: "Not found",
2✔
304
        path: pathname,
2✔
305
        message: `Unknown admin API endpoint: ${pathname}`,
2✔
306
      } as AdminApiResponse;
2✔
307
    } catch (error) {
2✔
308
      // ===== 500 for Server Errors =====
2✔
309
      debug("Admin API error: %O", error);
2✔
310
      ctx.status = 500;
2✔
311
      ctx.body = {
2✔
312
        success: false,
2✔
313
        error: error instanceof Error ? error.message : "Unknown error",
2!
314
        stack:
2✔
315
          error instanceof Error && process.env.NODE_ENV !== "production"
2✔
316
            ? error.stack
2✔
NEW
317
            : undefined,
×
318
      } as AdminApiResponse;
2✔
319
    }
2✔
320
  };
40✔
321
}
40✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc