Dual-mount /v1/* for routing intelligence + ops endpoints — closes long-standing SDK drift
2026-05-09
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 metadataGET /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,90→client.routing.getDecisions(),getCascades()packages/sdk-ts/src/resources/ops.ts:82→client.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
| Metric | Before | After |
|---|---|---|
GET /v1/routing/decisions | 404 | 200 (existing handler) |
GET /v1/routing/cascades | 404 | 200 (existing handler) |
GET /v1/ops/guardian-stats | 404 | 200 (existing handler) |
TS SDK client.routing.getDecisions() | network error / 404 | resolves with data |
Python SDK client.routing.get_decisions() | same | same |
| Lines of new logic | n/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.jsonstill
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/canaryRBAC 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 passedpnpm tsgo— exit 0, 0 errorsoxfmt --check+oxlint --type-awareon edited files — clean- Live API verification deferred until merge + ECS deploy