test(security): inventory board-key routes from the runtime router

Replace the source-regex board-key route inventory with an enumeration of
the real Express router stack that createApp mounts. Express 5 does not keep
the mount path string on a mounted layer, so a probe records it as each router
is mounted; the inventory then walks the built app and reconstructs every
final normalized path, including nested routers.

Runtime-only surfaces (handlers injected through createApp options, plugin
sub-apps) are declared through an explicit registration hook; anything mounted
at runtime that is not registered resolves to undeclared and stays denied.

The runtime set is compared bidirectionally with the explicit registry
inventory so a reachable route without metadata (missing) and a declared row
with no live route (stale) both fail CI. A regression registers a direct
app.post and proves the gate fails on it. Declare the top-level /_plugins and
/llms surfaces the runtime walk newly surfaces as explicit denials; deny and
concealment behavior are unchanged.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-06 15:32:23 +00:00 committed by cryppadotta
parent 8cb500359e
commit 0a0489876f
4 changed files with 467 additions and 9817 deletions

View File

@ -0,0 +1,363 @@
import { createRequire } from "node:module";
import type { Express } from "express";
import { lookupBoardKeyRoute, type BoardKeyRouteMetadata } from "./board-key-route-registry.js";
/**
* Runtime board-key route inventory.
*
* The board-key gate ({@link lookupBoardKeyRoute}) must have an explicit policy
* for every route that is actually reachable. Earlier this was checked by
* regex-scraping `server/src/routes/*.ts` and guessing mount prefixes, which
* could not see nested routers, app-level mounts, or dynamic surfaces, so a
* reachable route without metadata could ship undetected.
*
* Instead we enumerate the *real* Express router that `createApp` mounts. The
* `router` package that Express 5 delegates to does not retain the string mount
* path on a mounted layer (only opaque matcher closures), so {@link
* installRouteInventoryProbe} records the raw mount path as each router is
* mounted. {@link collectMountedRoutes} then walks the built app's layer stack
* and reconstructs every final normalized path, including nested routers.
*
* Truly runtime-generated surfaces (plugin sub-apps, handlers injected through
* `createApp` options) are not present in the statically built stack. They are
* declared through the {@link BOARD_KEY_RUNTIME_ROUTE_SURFACES} registration
* hook. Anything mounted at runtime that is *not* registered there resolves to
* an `undeclared` classification and is denied by the gate it stays denied
* until it is registered.
*
* The route-inventory test compares the runtime set against
* {@link BOARD_KEY_ROUTE_INVENTORY} bidirectionally, so a new reachable route
* without metadata (missing) and a declared row with no live route (stale) both
* fail CI.
*/
const require = createRequire(import.meta.url);
/** Stashes the raw mount path recorded by the inventory probe on a layer. */
const MOUNT_PATH = Symbol.for("paperclip.boardKey.inventoryMountPath");
/** Sample id substituted for every `:param` so a route can be classified. */
export const INVENTORY_SAMPLE_UUID = "11111111-1111-4111-8111-111111111111";
/** All concrete methods an `all(...)` registration is reachable through. */
const ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
export interface RuntimeRoute {
methods: string[];
path: string;
}
export interface InventoryTuple {
tuple: string;
metadata: BoardKeyRouteMetadata;
}
/**
* Rewrite an Express route pattern into a concrete path the registry can
* classify: every `:param` becomes a sample id and every splat becomes a fixed
* tail segment. A trailing literal such as `.txt` on a `:param` is preserved.
*/
export function normalizeInventoryPath(routePath: string): string {
return routePath
.replace(/:[A-Za-z][A-Za-z0-9_]*/g, INVENTORY_SAMPLE_UUID)
.replace(/\*[A-Za-z][A-Za-z0-9_]*/g, "inventory-tail")
.replace(/\{\*[^}]+\}/g, "inventory-tail");
}
/**
* Patch the shared `router` prototype so every mounted sub-router records the
* raw mount path it was mounted at. Must be installed *before* `createApp`
* imports and builds its routers. Returns an idempotent restore function.
*/
export function installRouteInventoryProbe(): () => void {
const RouterCtor = require("router") as { prototype: RouterProto };
const proto = RouterCtor.prototype;
const originalUse = proto.use;
proto.use = function inventoryUse(this: RouterProto, ...args: unknown[]) {
const before = this.stack.length;
const result = originalUse.apply(this, args as never[]);
const first = args[0];
const mountPath =
typeof first === "string" || (Array.isArray(first) && first.every((p) => typeof p === "string"))
? first
: "/";
for (let i = before; i < this.stack.length; i++) {
const layer = this.stack[i]!;
if (!layer.route) layer[MOUNT_PATH] = mountPath;
}
return result;
};
let restored = false;
return () => {
if (restored) return;
restored = true;
proto.use = originalUse;
};
}
interface RouterLayer {
route?: { path: string; methods: Record<string, boolean> };
handle?: { stack?: RouterLayer[] };
[MOUNT_PATH]?: string | string[];
}
interface RouterProto {
stack: RouterLayer[];
use: (...args: never[]) => unknown;
}
function joinMount(prefix: string, mount: string): string {
const clean = mount === "/" ? "" : mount;
return `${prefix}${clean}`;
}
function joinRoute(prefix: string, routePath: string): string {
if (routePath === "/") return prefix || "/";
return `${prefix}${routePath}`;
}
function routeMethods(methods: Record<string, boolean>): string[] {
const explicit = Object.keys(methods)
.filter((method) => method !== "_all")
.map((method) => method.toUpperCase());
const set = new Set(methods._all ? [...explicit, ...ALL_METHODS] : explicit);
return set.size > 0 ? [...set] : ["GET"];
}
/**
* Walk the built Express app's layer stack and return every mounted route with
* its fully-resolved path. Nested routers are followed through the mount paths
* recorded by {@link installRouteInventoryProbe}.
*/
export function collectMountedRoutes(app: Express): RuntimeRoute[] {
const root = ((app as unknown as { router?: RouterProto; _router?: RouterProto }).router
?? (app as unknown as { _router?: RouterProto })._router);
if (!root) throw new Error("Express app has no router stack to inventory");
const routes: RuntimeRoute[] = [];
const walk = (stack: RouterLayer[], prefix: string) => {
for (const layer of stack) {
if (layer.route) {
routes.push({ methods: routeMethods(layer.route.methods), path: joinRoute(prefix, layer.route.path) });
} else if (layer.handle?.stack) {
const recorded = layer[MOUNT_PATH];
const mounts = Array.isArray(recorded) ? recorded : [recorded ?? "/"];
for (const mount of mounts) {
walk(layer.handle.stack, joinMount(prefix, typeof mount === "string" ? mount : "/"));
}
}
}
};
walk(root.stack, "");
return routes;
}
/** `classification | action | routePattern`, with sample ids canonicalized. */
export function inventoryTuple(method: string, rawPath: string): InventoryTuple {
const metadata = lookupBoardKeyRoute(method, normalizeInventoryPath(rawPath));
const canonicalPattern = metadata.routePattern.split(INVENTORY_SAMPLE_UUID).join("{id}");
return { tuple: `${metadata.classification} | ${metadata.action} | ${canonicalPattern}`, metadata };
}
/**
* Expand runtime routes into per-method inventory tuples. Feeds every method of
* every route (statically mounted plus registered runtime surfaces) through the
* registry.
*/
export function inventoryTuples(routes: readonly RuntimeRoute[]): InventoryTuple[] {
const entries: InventoryTuple[] = [];
for (const route of routes) {
for (const method of route.methods) {
entries.push(inventoryTuple(method, route.path));
}
}
return entries;
}
/**
* Explicit runtime-registration hook. Surfaces that are only mounted at
* runtime not present in the statically built `createApp` stack are
* declared here so the inventory reflects them. Any runtime surface that is not
* registered here (and not a generic parameterized route already in the stack)
* classifies as `undeclared` and is denied by the gate until it is registered.
*
* - `/api/auth/{*authPath}`: the Better Auth request handler is injected through
* `createApp({ betterAuthHandler })` with `app.all(...)`, so it is absent when
* the app is built without that option. Board keys never reach auth routes;
* its policy coincides with the statically mounted `authRoutes` denial.
*/
export const BOARD_KEY_RUNTIME_ROUTE_SURFACES: readonly RuntimeRoute[] = [
{ methods: [...ALL_METHODS], path: "/api/auth/{*authPath}" },
];
/**
* The explicit board-key route registry: every `classification | action |
* routePattern` the gate is expected to produce for a reachable route. The
* route-inventory test asserts the runtime set equals this set exactly, so both
* a reachable route with no metadata and a declared row with no live route fail
* CI. Regenerate with the route-inventory test's diff output when routes change.
*/
export const BOARD_KEY_ROUTE_INVENTORY: readonly string[] = [
"board_key_denied | deny | /_plugins/{*path}",
"board_key_denied | deny | /api/agents/{id}/{*path}",
"board_key_denied | deny | /api/auth/{*path}",
"board_key_denied | deny | /api/board-api-keys",
"board_key_denied | deny | /api/board-claim/{*path}",
"board_key_denied | deny | /api/board/{*path}",
"board_key_denied | deny | /api/bootstrap/{*path}",
"board_key_denied | deny | /api/cases/{*path}",
"board_key_denied | deny | /api/cli-auth/{*path}",
"board_key_denied | deny | /api/cloud/{*path}",
"board_key_denied | deny | /api/companies/{id}/adapters/{id}/detect-model",
"board_key_denied | deny | /api/companies/{id}/adapters/{id}/model-profiles",
"board_key_denied | deny | /api/companies/{id}/adapters/{id}/models",
"board_key_denied | deny | /api/companies/{id}/adapters/{id}/test-environment",
"board_key_denied | deny | /api/companies/{id}/agent-configurations",
"board_key_denied | deny | /api/companies/{id}/agent-hires",
"board_key_denied | deny | /api/companies/{id}/built-in-agents",
"board_key_denied | deny | /api/companies/{id}/built-in-agents/{id}/provision",
"board_key_denied | deny | /api/companies/{id}/built-in-agents/{id}/reconcile",
"board_key_denied | deny | /api/companies/{id}/built-in-agents/{id}/reset",
"board_key_denied | deny | /api/companies/{id}/built-in-agents/{id}/routines/{id}/disable",
"board_key_denied | deny | /api/companies/{id}/built-in-agents/{id}/routines/{id}/enable",
"board_key_denied | deny | /api/companies/{id}/built-in-agents/{id}/routines/{id}/run",
"board_key_denied | deny | /api/companies/{id}/built-in-agents/{id}/status",
"board_key_denied | deny | /api/companies/{id}/feedback-traces",
"board_key_denied | deny | /api/companies/{id}/inbox-dismissals",
"board_key_denied | deny | /api/companies/{id}/inbox-dismissals/{id}",
"board_key_denied | deny | /api/companies/{id}/me/user-secrets",
"board_key_denied | deny | /api/companies/{id}/me/user-secrets/{id}",
"board_key_denied | deny | /api/companies/{id}/me/user-secrets/{id}/rotate",
"board_key_denied | deny | /api/companies/{id}/openclaw/invite-prompt",
"board_key_denied | deny | /api/companies/{id}/resource-memberships/me",
"board_key_denied | deny | /api/companies/{id}/resource-memberships/me/agents/{id}",
"board_key_denied | deny | /api/companies/{id}/resource-memberships/me/documents/{id}",
"board_key_denied | deny | /api/companies/{id}/resource-memberships/me/projects/{id}",
"board_key_denied | deny | /api/companies/{id}/sidebar-preferences/me",
"board_key_denied | deny | /api/companies/{id}/skill-policy",
"board_key_denied | deny | /api/companies/{id}/skill-policy/evaluate",
"board_key_denied | deny | /api/companies/{id}/skill-test-run-templates",
"board_key_denied | deny | /api/companies/{id}/skill-test-run-templates/{id}",
"board_key_denied | deny | /api/companies/{id}/teams/catalog/installed",
"board_key_denied | deny | /api/companies/{id}/teams/catalog/{id}/install",
"board_key_denied | deny | /api/companies/{id}/teams/catalog/{id}/preview",
"board_key_denied | deny | /api/companies/{id}/users/me/inbox-agent-policy",
"board_key_denied | deny | /api/companies/{id}/users/{id}/inbox-agent-policy",
"board_key_denied | deny | /api/companies/{id}/users/{id}/profile",
"board_key_denied | deny | /api/feedback-traces/{*path}",
"board_key_denied | deny | /api/health/{*path}",
"board_key_denied | deny | /api/invites/{*path}",
"board_key_denied | deny | /api/join-requests/{*path}",
"board_key_denied | deny | /api/llms/{*path}",
"board_key_denied | deny | /api/openapi.json/{*path}",
"board_key_denied | deny | /api/pipelines/{*path}",
"board_key_denied | deny | /api/plugins/{*path}",
"board_key_denied | deny | /api/routine-triggers/public/{id}/fire",
"board_key_denied | deny | /api/sidebar-preferences/{*path}",
"board_key_denied | deny | /api/tool-gateway/{*path}",
"board_key_denied | deny | /api/tools/oauth/{*path}",
"board_key_denied | deny | /llms/{*path}",
"board_key_denied | deny | /mcp/{*path}",
"company | activity:read | /api/companies/{id}/{*path}",
"company | agents:operate | /api/agents/{id}/{*path}",
"company | agents:read | /api/agents/{id}/{*path}",
"company | agents:read | /api/companies/{id}/{*path}",
"company | agents:write | /api/agents/{id}/{*path}",
"company | agents:write | /api/companies/{id}/{*path}",
"company | approvals:decide | /api/approvals/{id}/{*path}",
"company | approvals:read | /api/approvals/{id}/{*path}",
"company | approvals:read | /api/companies/{id}/{*path}",
"company | approvals:write | /api/approvals/{id}/{*path}",
"company | approvals:write | /api/companies/{id}/{*path}",
"company | artifacts:read | /api/assets/{id}/{*path}",
"company | artifacts:read | /api/attachments/{id}/{*path}",
"company | artifacts:read | /api/companies/{id}/{*path}",
"company | artifacts:write | /api/attachments/{id}/{*path}",
"company | artifacts:write | /api/companies/{id}/{*path}",
"company | artifacts:write | /api/work-products/{id}/{*path}",
"company | audit:read | /api/companies/{id}/{*path}",
"company | companies:import_export | /api/companies/{id}/{*path}",
"company | companies:read | /api/companies/{id}/{*path}",
"company | companies:write | /api/companies/{id}/{*path}",
"company | costs:read | /api/companies/{id}/{*path}",
"company | costs:write | /api/companies/{id}/{*path}",
"company | decisions:read | /api/companies/{id}/{*path}",
"company | decisions:read | /api/decision-training/{id}/{*path}",
"company | decisions:read | /api/decisions/{id}/{*path}",
"company | decisions:write | /api/companies/{id}/{*path}",
"company | decisions:write | /api/decision-training/{id}/{*path}",
"company | decisions:write | /api/decisions/{id}/{*path}",
"company | environments:manage | /api/companies/{id}/{*path}",
"company | environments:manage | /api/environment-custom-image-setup-sessions/{id}/{*path}",
"company | environments:manage | /api/environments/{id}/{*path}",
"company | environments:read | /api/companies/{id}/{*path}",
"company | environments:read | /api/environment-custom-image-setup-sessions/{id}/{*path}",
"company | environments:read | /api/environment-leases/{id}/{*path}",
"company | environments:read | /api/environments/{id}/{*path}",
"company | goals:read | /api/companies/{id}/{*path}",
"company | goals:read | /api/goals/{id}/{*path}",
"company | goals:write | /api/companies/{id}/{*path}",
"company | goals:write | /api/goals/{id}/{*path}",
"company | issues:control | /api/issues/{id}/{*path}",
"company | issues:read | /api/companies/{id}/{*path}",
"company | issues:read | /api/issues/{id}/{*path}",
"company | issues:read | /api/issues?companyId={companyId}",
"company | issues:write | /api/companies/{id}/{*path}",
"company | issues:write | /api/issues/{id}/{*path}",
"company | members:manage | /api/companies/{id}/{*path}",
"company | members:read | /api/companies/{id}/{*path}",
"company | projects:read | /api/companies/{id}/{*path}",
"company | projects:read | /api/projects/{id}/{*path}",
"company | projects:write | /api/companies/{id}/{*path}",
"company | projects:write | /api/projects/{id}/{*path}",
"company | routines:read | /api/companies/{id}/{*path}",
"company | routines:read | /api/routines/{id}/{*path}",
"company | routines:run | /api/routines/{id}/{*path}",
"company | routines:write | /api/companies/{id}/{*path}",
"company | routines:write | /api/routine-triggers/{id}/{*path}",
"company | routines:write | /api/routines/{id}/{*path}",
"company | runtime:manage | /api/heartbeat-runs/{id}/{*path}",
"company | runtime:read | /api/companies/{id}/{*path}",
"company | runtime:read | /api/heartbeat-runs/{id}/{*path}",
"company | search:read | /api/companies/{id}/{*path}",
"company | secrets:manage | /api/companies/{id}/{*path}",
"company | secrets:manage | /api/secret-provider-configs/{id}/{*path}",
"company | secrets:manage | /api/secrets/{id}/{*path}",
"company | secrets:read_metadata | /api/companies/{id}/{*path}",
"company | secrets:read_metadata | /api/secret-provider-configs/{id}/{*path}",
"company | secrets:read_metadata | /api/secrets/{id}/{*path}",
"company | settings:read | /api/companies/{id}/{*path}",
"company | settings:read | /api/status-cards/{id}/{*path}",
"company | settings:write | /api/companies/{id}/{*path}",
"company | settings:write | /api/labels/{id}/{*path}",
"company | settings:write | /api/status-cards/{id}/{*path}",
"company | skills:manage | /api/companies/{id}/{*path}",
"company | skills:read | /api/companies/{id}/{*path}",
"company | tools:manage | /api/companies/{id}/{*path}",
"company | tools:manage | /api/tool-applications/{id}/{*path}",
"company | tools:manage | /api/tool-connections/{id}/{*path}",
"company | tools:manage | /api/tool-gateway/{*path}",
"company | tools:manage | /api/tool-profile-entries/{id}/{*path}",
"company | tools:manage | /api/tool-profiles/{id}/{*path}",
"company | tools:read | /api/companies/{id}/{*path}",
"company | tools:read | /api/tool-connections/{id}/{*path}",
"company | tools:read | /api/tool-gateway/{*path}",
"company | tools:read | /api/tool-profiles/{id}/{*path}",
"company | workspaces:manage | /api/execution-workspaces/{id}/{*path}",
"company | workspaces:read | /api/companies/{id}/{*path}",
"company | workspaces:read | /api/execution-workspaces/{id}/{*path}",
"company | workspaces:read | /api/workspace-operations/{id}/{*path}",
"company_collection | companies:read | /api/companies",
"instance_global | adapters:manage | /api/adapters/{id}/{*path}",
"instance_global | adapters:read | /api/adapters/{id}/{*path}",
"instance_global | catalogs:read | /api/skills/{*path}",
"instance_global | catalogs:read | /api/teams/{*path}",
"instance_global | companies:create | /api/companies",
"instance_global | companies:import_export | /api/companies/import/{*path}",
"instance_global | instance:manage | /api/instance/{*path}",
"instance_global | instance:read | /api/instance/{*path}",
"instance_global | plugins:manage | /api/plugins/{id}/{*path}",
"instance_global | plugins:read | /api/plugins/{id}/{*path}",
"instance_global | users:manage | /api/admin/{*path}",
"instance_global | users:read | /api/admin/{*path}",
"key_self | board_api_keys:revoke_self | /api/board-api-keys/{id}",
];

View File

@ -1,69 +1,52 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { Express } from "express";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { BOARD_API_KEY_PERMISSION_KEYS } from "@paperclipai/shared";
import { lookupBoardKeyRoute } from "./board-key-route-registry.js";
import {
BOARD_KEY_ROUTE_INVENTORY,
BOARD_KEY_RUNTIME_ROUTE_SURFACES,
collectMountedRoutes,
installRouteInventoryProbe,
inventoryTuple,
inventoryTuples,
type RuntimeRoute,
} from "./board-key-route-inventory.js";
const ROUTES_DIR = path.resolve(import.meta.dirname, "../routes");
const ROUTE_REGISTRATION = /\b(?:router|routes)\.(get|post|put|patch|delete)\s*\(\s*(["'`])([^"'`]+)\2/g;
const ROUTE_CALL = /\b(?:router|routes)\.(?:get|post|put|patch|delete)\s*\(/g;
const SAMPLE_UUID = "11111111-1111-4111-8111-111111111111";
// The inventory is derived from the real Express router `createApp` mounts, not
// from source regexes. The probe must be installed before `createApp` builds
// its routers so Express 5 mount prefixes are recoverable, so the app is built
// once here and shared across the inventory assertions.
let app: Express;
let restoreProbe: () => void;
type InventoriedRoute = { file: string; method: string; path: string };
beforeAll(async () => {
restoreProbe = installRouteInventoryProbe();
const { createApp } = await import("../app.js");
app = (await createApp({} as never, {
uiMode: "none",
serverPort: 0,
storageService: {} as never,
deploymentMode: "local_trusted",
deploymentExposure: "private",
allowedHostnames: [],
bindHost: "127.0.0.1",
authReady: true,
companyDeletionEnabled: false,
// Mount every optionally-gated router so the inventory reflects production.
// The Better Auth handler is injected with `app.all(...)`; it is covered by
// BOARD_KEY_RUNTIME_ROUTE_SURFACES rather than mounted here.
databaseBackupService: { runManualBackup: async () => ({}) },
decisionServiceOptions: {} as never,
} as never)) as unknown as Express;
}, 120_000);
function mountedPath(file: string, routePath: string) {
const mount = file === "companies.ts"
? "/api/companies"
: file === "auth.ts"
? "/api/auth"
: file === "health.ts"
? "/api/health"
: file === "cloud.ts"
? "/api/cloud"
: routePath.startsWith("/mcp")
? ""
: "/api";
const joined = `${mount}${routePath === "/" ? "" : routePath}` || "/";
return joined
.replace(/:[A-Za-z][A-Za-z0-9_]*/g, SAMPLE_UUID)
.replace(/\{\*[^}]+\}/g, "inventory-tail");
}
afterAll(() => {
restoreProbe?.();
});
function inventoryRoutes() {
const inventory: InventoriedRoute[] = [];
const unsupported: string[] = [];
for (const file of fs.readdirSync(ROUTES_DIR).filter((name) => name.endsWith(".ts") && !name.includes(".test."))) {
const source = fs.readFileSync(path.join(ROUTES_DIR, file), "utf8");
const calls = source.match(ROUTE_CALL)?.length ?? 0;
let captured = 0;
for (const match of source.matchAll(ROUTE_REGISTRATION)) {
captured += 1;
const routePath = match[3]!;
if (routePath.includes("${")) {
unsupported.push(`${file}: dynamic template route ${routePath}`);
continue;
}
inventory.push({
file,
method: match[1]!.toUpperCase(),
path: mountedPath(file, routePath),
});
}
// companies.ts has one frozen constant route: COMPANY_IMPORT_ROUTE_PATH.
const allowedNonLiteralCalls = file === "companies.ts" ? 1 : 0;
if (calls - captured !== allowedNonLiteralCalls) {
unsupported.push(`${file}: ${calls - captured} route registration(s) do not use a literal path`);
}
}
inventory.push({ file: "companies.ts", method: "POST", path: "/api/companies/import" });
const unique = new Map<string, InventoriedRoute>();
const duplicates: InventoriedRoute[] = [];
for (const route of inventory) {
const key = `${route.method} ${route.path}`;
if (unique.has(key)) duplicates.push(route);
else unique.set(key, route);
}
return { inventory: [...unique.values()], unsupported, duplicates };
function runtimeInventory(extra: readonly RuntimeRoute[] = []): string[] {
const routes = [...collectMountedRoutes(app), ...BOARD_KEY_RUNTIME_ROUTE_SURFACES, ...extra];
return [...new Set(inventoryTuples(routes).map((entry) => entry.tuple))].sort();
}
describe("board-key route registry", () => {
@ -78,28 +61,62 @@ describe("board-key route registry", () => {
expect(lookupBoardKeyRoute(method, routePath)).toMatchObject({ action, classification });
});
it("inventories every checked-in route registration with an explicit policy", () => {
const { inventory, unsupported, duplicates } = inventoryRoutes();
const undeclared = inventory
.map((route) => ({ ...route, metadata: lookupBoardKeyRoute(route.method, route.path) }))
.filter((route) => route.metadata.classification === "undeclared");
const unsafeDuplicates = duplicates.filter(
(route) => lookupBoardKeyRoute(route.method, route.path).classification !== "board_key_denied",
);
it("enumerates the real createApp route stack, including nested routers", () => {
const routes = collectMountedRoutes(app);
// Sanity: this is the whole mounted API surface, not a handful of files.
expect(routes.length).toBeGreaterThan(500);
// Nested routers resolve to fully-qualified paths, not bare sub-paths.
const paths = routes.map((route) => route.path);
expect(paths).toContain("/api/companies/:companyId/issues");
expect(paths.some((path) => path.startsWith("/api/companies/:companyId/skills/:skillId/"))).toBe(true);
expect(paths.every((path) => path.startsWith("/api/") || path.startsWith("/_plugins/")
|| path.startsWith("/llms/") || path.startsWith("/mcp/"))).toBe(true);
});
expect(inventory.length).toBeGreaterThan(500);
expect(unsupported).toEqual([]);
// cases.ts and pipelines.ts intentionally overlap while both experimental
// surfaces are hard-denied. A duplicate on an allowed route is unsafe.
expect(unsafeDuplicates).toEqual([]);
expect(undeclared).toEqual([]);
expect(inventory.map((route) => ({
...route,
metadata: lookupBoardKeyRoute(route.method, route.path),
}))).toMatchSnapshot();
for (const route of inventory) {
const { action } = lookupBoardKeyRoute(route.method, route.path);
it("declares an explicit policy for every reachable route", () => {
const entries = inventoryTuples([
...collectMountedRoutes(app),
...BOARD_KEY_RUNTIME_ROUTE_SURFACES,
]);
// Missing metadata: no reachable route may fall through to `undeclared`.
const undeclared = entries.filter((entry) => entry.metadata.classification === "undeclared");
expect(undeclared.map((entry) => entry.metadata.routePattern)).toEqual([]);
// Every action is either an explicit deny or a real board-key permission.
for (const entry of entries) {
const { action } = entry.metadata;
expect(action === "deny" || BOARD_API_KEY_PERMISSION_KEYS.includes(action)).toBe(true);
}
});
it("matches the explicit registry inventory bidirectionally", () => {
const runtime = runtimeInventory();
const declared = [...new Set(BOARD_KEY_ROUTE_INVENTORY)].sort();
// Missing metadata (runtime rows absent from the registry) and stale
// metadata (registry rows with no live route) both surface as a set diff.
const missing = runtime.filter((tuple) => !declared.includes(tuple));
const stale = declared.filter((tuple) => !runtime.includes(tuple));
expect({ missing, stale }).toEqual({ missing: [], stale: [] });
});
it("fails when a direct app.post is registered without metadata", () => {
// Regression for the PoC: a route added straight onto the app (bypassing the
// route modules the old regex scanned) must be seen by the inventory and
// must fail the gate as undeclared until it is classified.
const before = runtimeInventory();
expect(before).toEqual([...new Set(BOARD_KEY_ROUTE_INVENTORY)].sort());
(app as unknown as { post: (path: string, handler: () => void) => void })
.post("/api/security-review-poc", () => {});
const pocRoute = collectMountedRoutes(app).find((route) => route.path === "/api/security-review-poc");
expect(pocRoute).toBeDefined();
expect(inventoryTuple("POST", "/api/security-review-poc").metadata.classification).toBe("undeclared");
const after = runtimeInventory();
// The gate now fails: an undeclared row appears that the registry never declared.
expect(after).not.toEqual(before);
expect(after).toContain("undeclared | deny | /api/security-review-poc");
expect(after.filter((tuple) => !BOARD_KEY_ROUTE_INVENTORY.includes(tuple)))
.toEqual(["undeclared | deny | /api/security-review-poc"]);
});
});

View File

@ -298,6 +298,13 @@ export function lookupBoardKeyRoute(methodInput: string, rawPath: string): Board
const segments = path.split("/").filter(Boolean);
if (segments[0] === "mcp") return denied(method, "/mcp/{*path}");
// Top-level (non-/api) surfaces mounted directly on the app. These are
// reachable routes, so they are explicitly denied rather than left
// undeclared: board keys never reach the MCP gateway protocol, the plugin
// UI static asset server, or the public LLM discovery files. Any other
// non-/api path stays undeclared and fails closed.
if (segments[0] === "_plugins") return denied(method, "/_plugins/{*path}");
if (segments[0] === "llms") return denied(method, "/llms/{*path}");
if (segments[0] !== "api") {
return declared(method, path, "deny", "undeclared", "none", { concealment: "forbidden" });
}