Dual-mount /v1/* for routing intelligence + ops endpoints — closes long-standing SDK drift

2026-05-09

apisdkrbac

What We Built

createRoutingRoutes() (src/api/routes/auth-routing.ts) is now mounted at both /auth/ (Supabase JWT, dashboard) and /v1/ (API-key auth, SDKs/agents). Same handlers, two auth modes, picked at the mount layer.

Three endpoints flip from 404 to working:

  • GET /v1/routing/decisions — recent routing decisions with decisionTrace metadata
  • GET /v1/routing/cascades — cascade activations (model escalation on failure)
  • GET /v1/ops/guardian-stats — guardian overhead + cache hit rate + anomaly count (24h)

Why It Matters

Both shipped SDKs (TypeScript and Python) call these /v1/* paths in code that's been live since the routing and ops resource classes were written:

  • packages/sdk-ts/src/resources/routing.ts:84,90client.routing.getDecisions(), getCascades()
  • packages/sdk-ts/src/resources/ops.ts:82client.ops.getGuardianStats()
  • packages/sdk-py/src/brainstormrouter/resources/{routing,ops}.py — same paths

Until this ship, every one of those calls returned 404 in production. The handlers existed and worked — but only at /auth/* (mounted via tenant-config-ops.ts:615), reachable only with a Supabase JWT (i.e., dashboard browser sessions). The SDKs had no way to authenticate that mount.

This is the "SDK /v1/routing/\* drift" memory entry, finally closed. Customers running:

const decisions = await client.routing.getDecisions({ limit: 50 });

now get their routing decisions back instead of a 404.

How It Works

In register-routes.ts (registerCommonRoutes), a small adapter sub-app wraps createAuthRoutingRoutes():

const v1AuthRoutingMount = new Hono<ApiEnv>();
v1AuthRoutingMount.use("*", async (c, next) => {
  const apiKey = c.get("apiKey");
  if (apiKey?.tenantId) {
    c.set("tenantCtx", { tenantId: apiKey.tenantId, userId: apiKey.userId });
  }
  await next();
});
v1AuthRoutingMount.route("/", createAuthRoutingRoutes());
app.route("/v1", v1AuthRoutingMount);

The adapter does ONE thing: synthesize the tenantCtx shape that the existing handlers expect, sourced from the resolved API key. Handlers continue to read c.get("tenantCtx")?.tenantId exactly as they did under JWT auth — they're auth-mode-agnostic.

TenantCtx.userId (src/api/routes/auth-routing.ts:26) is relaxed to string | null | undefined because API-key auth may not have an associated user. The handlers don't read userId — only tenantId — so the relaxation is safe.

RBAC entries (src/api/middleware/rbac.ts:355-358):

{ method: "GET", path: "/v1/routing/decisions", permission: "audit.read" },
{ method: "GET", path: "/v1/routing/cascades",  permission: "audit.read" },

/v1/ops/guardian-stats is already covered by the existing /v1/ops/ prefix entry with audit.read.

The Numbers

MetricBeforeAfter
GET /v1/routing/decisions404200 (existing handler)
GET /v1/routing/cascades404200 (existing handler)
GET /v1/ops/guardian-stats404200 (existing handler)
TS SDK client.routing.getDecisions()network error / 404resolves with data
Python SDK client.routing.get_decisions()samesame
Lines of new logicn/a~20 (adapter + mount)

Lockstep Notes

No SDK changes — both SDKs already had these methods, calling the right paths, since their original routing / ops resource files. The bug was the missing /v1 mount, not missing SDK code. The SDK unit tests (packages/sdk-ts/src/__tests__/routing-ops.test.ts) assert the right paths against a mock; they were green while production was broken.

site/public/routes.json is unchanged — the snapshot scanner only sees top-level app.get(...) declarations in auth-routing.ts (bare paths), not the dual-mount that adds the /v1/ prefix. The pre-push lockstep gate doesn't fire because the contract didn't change at the manifest level. The snapshot-vs-reality drift was already documented in route-manifest-coverage.test.ts:53-68 — that comment now applies even more sharply (the bare paths are now reachable at TWO prefixes, not one).

What This Doesn't Fix

  • Snapshot scanner doesn't follow nested mounts: routes.json still

reports bare /routing/decisions paths instead of /v1/routing/decisions or /auth/routing/decisions. Pre-existing infra debt; flagged in route-manifest-coverage.test.ts. Fixing requires teaching the regex scanner to follow app.route("/prefix", subApp) chains across files.

  • Other endpoints in EXPECTED_OUTSIDE_MOUNT: /events (from

auth-events.ts) is still in the same nested-mount shape. If the SDK ever ships an events client that calls /v1/events, the same dual-mount pattern from this PR is the template.

  • /v1/routing/canary RBAC paths: untouched. They have their own RBAC

entries (config.read/config.write) and live in a different route file.

Verification

  • npx vitest run src/api/routes/auth-routing-v1.test.ts — 5/5 passed
  • pnpm tsgo — exit 0, 0 errors
  • oxfmt --check + oxlint --type-aware on edited files — clean
  • Live API verification deferred until merge + ECS deploy