Skip to content

refactor(proxy): table-driven route forwarding - #7

Merged
Sebas1705 merged 2 commits into
mainfrom
refactor/gateway-route-table
Nov 12, 2026
Merged

Sebas1705 merged 2 commits into
mainfrom
refactor/gateway-route-table

Conversation

@Sebas1705

@Sebas1705 Sebas1705 commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What

routes/proxy.ts registered 62 routes by hand: 59 near-identical buildUrl -> forwardRequest wrappers. This replaces the 56 plain forwards with a declarative table (routes/routeTable.ts, data only) and one generic handler. Adding a BFF route is now one table line.

Kept explicit, unchanged in behaviour:

  • the 3 query-validating routes (GET /v1/mobile/games/{quiz,wordpass}/random, GET /v1/backoffice/users/leaderboard), still forwarded in json mode;
  • the 3 /internal/admin/ai-engine/target routes (admin token via isGatewayAdminAuthorized, body validation, applyAiEngineTarget with the protocol and routing-allowlist checks, runtime target store);
  • the raw passthrough from perf(proxy): raw passthrough on plain routes, shared PUT path, bounded rate limiter #6 (forwardRequest, the raw JSON body parser), CORS, rate limiting, circuit breakers, and the runtime-switchable ai-engine target (base URL still resolved per request).

No external contract change: same paths, methods, status codes, auth, query handling, timeouts.

Rework on top of current main (#3 and #6 merged)

The PR was first written before #3 (auth fail-closed, /internal/* token, TRUST_PROXY, routing allowlist) and #6 landed. It is now rebuilt from origin/main (two commits, base is main) and carries main's security behaviour:

  • checkEdgeAuth(request, reply, config) keeps main's fail-closed behaviour (503 when EDGE_API_TOKEN is unset outside development/test, 401 on a bad bearer, constant-time compare). The edge entry of authGuard calls it.
  • New "internal" auth kind in the Auth type, guarded by checkInternalAuth (x-internal-token, 503 when INTERNAL_SERVICE_TOKEN is unset outside development/test, 401 on mismatch). The two /internal/ai-engine sections of the table (7 routes) moved from none to internal, replacing the 7 per-route checkInternalAuth blocks; effective protection is identical.
  • Admin routes (isGatewayAdminAuthorized, applyAiEngineTarget, security.js import) are main's code, untouched, and are not in the table.
  • The bff-backoffice routing routes that bff-backoffice #9 (on hold) will delete are kept in the table; they go away with a one-line-per-route table edit when that lands.
  • The fixture was regenerated from main's proxy.ts with a script (not by hand). Compared with the pre-rework fixture, the only differences are auth: none -> internal on the 7 /internal/ai-engine/* routes.

How equivalence is checked

Commit 1 adds tests/fixtures/gatewayRoutes.json (method, path, upstream, upstream path, query policy, timeout, forward mode, auth, validated; 62 routes: 3 admin, 3 validated, 56 plain; 44 edge, 7 internal, 8 none) and tests/proxyRouteEquivalence.test.ts, and passes against main's hand-written proxy.ts. Commit 2 is the refactor and leaves the test untouched. For every fixture entry the test asserts:

  • the registered route set equals the fixture exactly, and the number of routes per auth kind is pinned (44 / 7 / 8) so no route can silently lose its auth;
  • edge routes: 401 for a missing or wrong bearer, and for the internal token alone; 503 when no edge token is configured with NODE_ENV=production; never forwarded in either case;
  • internal routes: 401 for a missing or wrong x-internal-token, and for the edge bearer alone; 503 when INTERNAL_SERVICE_TOKEN is unset in production; never forwarded;
  • none routes: reachable without credentials, also in production with no tokens;
  • upstream URL (including encodeURIComponent re-encoding of path params, tested with a%2Fb), method, timeout, forward mode (raw vs json) and query handling match;
  • validating routes still answer the 400 envelope for unknown params;
  • the ai-engine runtime override still redirects api and stats traffic;
  • admin routes: 401 without the admin token, 401 in production when the token is unset, and the routing allowlist rejects an unlisted host;
  • unknown paths, extra segments, trailing slash and wrong methods still 404.

Mutation check: flipping one internal section back to none makes 12 tests fail. The existing proxy.test.ts, proxyRaw.test.ts, authFailClosed.test.ts pass untouched.

Size

proxy.ts 953 -> 400 LOC, plus routeTable.ts 140 LOC (540 total, -413). The equivalence test (359 LOC) and fixture are not counted.

Also: vitest.config.ts now excludes dist/**. tsc emits compiled tests there and vitest was collecting them (running every test twice after a build; the new test's fixture does not exist in dist).

Verification

Run in src/: npm ci, npm run build, npm run lint, npm run test:coverage all pass locally. Vitest: 12 files, 301 tests, green. Coverage: statements 98.33%, branches 91.57%, functions 97.87%, lines 98.3% (thresholds 90%).

Not verified: no run against live BFFs or the real docker-compose stack, and no load or latency comparison (the table adds one object lookup per request). Behaviour equivalence rests on the fixture-based test.

Comment thread src/app/routes/proxy.ts
Comment on lines +327 to +336
handler: async (request, reply) => {
if (!authGuard[route.auth](request, reply)) {
return;
}

const base = baseUrl[route.upstream]();
const query = applyQueryPolicy(route.query, (request.query as Record<string, unknown>) ?? {});
const url = buildUrl(base, fillPathParams(route.upstreamPath, request.params), query);
await forwardRequest(request, reply, url, route.method, timeoutMs[route.timeout], getBreakerForUrl(base));
},
Comment thread src/app/routes/proxy.ts
Comment on lines +348 to +361
app.get(path, async (request, reply) => {
if (!authGuard[auth](request, reply)) {
return;
}

const parsedQuery = schema.safeParse(request.query ?? {});
if (!parsedQuery.success) {
return sendValidationError(reply, parsedQuery.error);
}

const base = baseUrl[upstream]();
const url = buildUrl(base, path, query(parsedQuery.data));
await forwardRequest(request, reply, url, "GET", upstreamTimeoutMs, getBreakerForUrl(base), "json");
});
@Sebas1705
Sebas1705 force-pushed the perf/phase2-raw-proxy branch from 56ec2ad to 66ec41a Compare November 12, 2026 17:53
Base automatically changed from perf/phase2-raw-proxy to main November 12, 2026 17:55
Fixture extracted mechanically from main's hand-written proxy.ts (62
routes, incl. fail-closed edge auth and the /internal token rules).
Also excludes dist/** from vitest.
Replace the 59 near-identical forward handlers with routeTable.ts plus one
generic handler. Auth kinds: none, edge (fail-closed) and internal
(x-internal-token). Admin routes and the validated query routes stay explicit.
@Sebas1705
Sebas1705 force-pushed the refactor/gateway-route-table branch from 633fec7 to c6a5f90 Compare November 12, 2026 18:41
@Sebas1705 Sebas1705 changed the title refactor(proxy): table-driven route forwarding (stacked on #6) refactor(proxy): table-driven route forwarding Nov 12, 2026
@codecov

codecov Bot commented Nov 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Sebas1705
Sebas1705 marked this pull request as ready for review November 12, 2026 19:01
@Sebas1705
Sebas1705 merged commit 165dc80 into main Nov 12, 2026
5 of 6 checks passed
@Sebas1705
Sebas1705 deleted the refactor/gateway-route-table branch November 12, 2026 19:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants