Skip to content

Latest commit

 

History

History
56 lines (37 loc) · 11.2 KB

README.md

File metadata and controls

56 lines (37 loc) · 11.2 KB

src

Last updated: 2026-10-21

The whole proxy process. Wire behaviour is in the root README; each directory that owns a contract has its own README. The boundary review behind this file is #95.

Responsibility

Role. The root holds the composition root (app.mts, the process entry point), the mode selection it calls, and the pieces both modes share: the logger, graceful shutdown, request coalescing and the provider-body helpers. The directories are the two modes (modes/), the route assembly both modes mount (router/), the inbound-header helpers (express/) and client authentication (oauth/). Configuration is ../config/, outside src/.

Owns: assembling the server and the order it mounts things in; the process lifecycle (listen, drain, exit); the logger and what an error looks like in a log line; the modules both modes share.

Does not own: any mode's decision, provider client or cache (modes/*, each with its own README); header grammar (express/); client authentication at the provider (oauth/); configuration defaults and validation (config/).

Why separate. The root holds only what is assembly or shared by both modes. A mode may import the root's shared modules but never the other mode, so a module both modes need lives here rather than in either mode (#95 F6, F39).

src/router

No README of its own; this is its description.

  • Role: route assembly that is not a mode — the liveness probe and CORS, which app.mts mounts, and the stages both mode routers share: the body limit ahead of the mode, the upstream proxy stage after it, and the error handler that ends the router.
  • Owns: the /_healthcheck path and its answer; CORS (cors.mts) — whether the proxy answers it (http.cors.origin.pattern set), the preflights it answers 204, the grant — to an origin the pattern admits only; the proxy sets no Access-Control-* field on any other answer — that the grant on a proxied answer is the proxy's alone, and the boot-time warning about a pattern that likely admits too much; http.bodyLimitSize as the one byte count the body limit, the upstream proxy and the error handler enforce; building the upstream proxy from upstream.baseURL; which requests the shared stages refuse and with which status — a body over the limit is 413, an upstream that could not be reached or dropped the exchange before its answer started 502 (504 when it timed out), and one that drops it partway through its answer the caller's connection closed, anything else that reaches the end of a mode router its own status or 500 — and the ModeRefusals contract (refusal.mts) a mode says those refusals through. Whatever the mode, the upstream proxy stage sends each request on a connection of its own that carries no other request, sends the body as the bytes that arrived, frames it by Content-Length alone, drops Expect, adds the proxy's own member to Via (<version> auth.proxy, after the inbound members), tells the upstream where the request came from as this hop saw it — the peer appended to X-Forwarded-For and set as X-Real-IP, the received Host as X-Forwarded-Host, no Forwarded, and X-Forwarded-Proto as it arrived, since the proxy sees no TLS — sends the caller's Accept-Charset on unchanged, and keeps the caller's connection-scoped fields off the upstream request — the fields its Connection names, except those the proxy decides (Authorization, x-request-id, its own connection: close), the hop-by-hop fields, and Proxy-Authorization — and the upstream's off the caller's response: its Connection, the fields that names except those the proxy set before forwarding, the hop-by-hop fields, Trailer and Proxy-Authenticate; Node writes the caller's Connection itself. On a proxied answer, a field the proxy set before forwarding keeps the proxy's value, except Vary, which lists both sides' members.
  • Does not own: what else reaches upstream — each mode decides that on req.headers before the stage; how a refusal is logged and what body answers it, which are the mode's vocabulary; the mode routers themselves (modes/*/router.mts).
  • Why separate: none of it is a mode's decision (#95 F18). The body limit decides only on a declared length, and no stage decides anything about a credential.

src/modes

No README of its own; this is its description.

  • Role: the two alternative request paths. resolveRouter mounts exactly one, chosen by auth.mode at boot.
  • Owns: nothing at the directory level; modes/injection/ and modes/validation/ state their own responsibility.
  • Does not own: anything the two share; that sits at the src/ root or in router/, express/ or oauth/.
  • Why separate: each mode is a complete, alternative request path chosen once at boot, and neither imports the other.

Where shared modules live

Code both modes need sits outside modes/, because neither mode may import the other: at the src/ root (the logger, coalescing, provider-body handling), in oauth/ (how the proxy authenticates itself to the provider), in router/ (route assembly) or in express/ (inbound-header parsing).

Dependencies

  • modes/* may import the src/ root's shared modules, express/, oauth/, router/ except the healthcheck and CORS, and config/. Neither mode imports the other.
  • Nothing under express/, oauth/, router/ or config/ imports a mode, and the root's shared modules import no mode. In production code, only app-internal.mts imports a mode, to select its router.
  • express/ and oauth/ import nothing else in src/. config/ imports oauth/private-key-jwt.mts, to read a client key at boot, byte-size.mts, to read http.bodyLimitSize, and address-range.mts, to read auth.injection.trustedProxies with the grammar the injection mode builds its trust list from.
  • The test support in __tests__/ that is not itself a test is imported by tests only, never by production code: the fake provider (fake-provider.mts), a local node:http server the provider clients reach through the real fetch, and the controlled provider timeout beside it (#143); and, for the composition tests, the entry point run in a child process (app-process.mts, with the preload child-preload.mts) and a recording upstream (recording-upstream.mts) (#144); and a client that sends a chunked body (post-chunked.mts).

Invariants

  • The healthcheck is outside pathPrefix and CORS (#95 F25). It is mounted first and at the root, so /_healthcheck answers whatever the prefix is, without CORS headers and without a request id: none is read, generated or echoed. It is liveness only — the process is up — and says nothing about the provider. No test imports app.mts, which runs at import; __tests__/app-entry.test.mts runs it in a child process and pins this placement, the refusal of a bad configuration before the process listens, and CORS under the prefix. The probe's path and method are pinned by router/__tests__/Healthcheck.test.mts.
  • CORS is under the prefix, ahead of the mode router, and answers preflights only. A request outside pathPrefix gets no CORS fields, and an OPTIONS there is 404 like any other. Under it, a preflight (OPTIONS with Origin and Access-Control-Request-Method) is answered 204 and never reaches the mode router, the upstream or the log; any other OPTIONS is an ordinary request. Pinned by router/__tests__/cors.test.mts and __tests__/app-entry.test.mts.
  • The proxy announces no framework. app.mts turns Express's X-Powered-By off, so the proxy's own answers carry none and a proxied answer carries the upstream's. Pinned by __tests__/app-entry.test.mts.
  • Shutdown drains; it does not wipe. The process passes no cleanup to the shutdown, so the token caches die with the process. The shutdown guarantees are stated in the header of shutdown.mts.
  • State belongs to whoever built it. Parsed configuration is immutable after boot and passed down as an argument; caches and coalescing tables live in the closure that built them, and a router never clears state it was handed rather than built.
  • One logger. Every module logs through the Logger interface; the mode routers and CORS default to the singleton, the mode routers hand it to their decisions, and tests inject a fake through deps. Configuration comes from the HOCON parse in app.mts; the one environment read outside it is LOG_LEVEL, in logger.mts.
  • An Error reaches a log line only through an allowlist (#95 F48). Some errors carry bytes nobody chose to log — undici's HTTPParserError.data is the unparsed rest of the provider's response, which may echo the token the proxy just sent — so an Error logged under the error or the err key keeps only named fields, with URL credentials redacted; everything else on it is dropped. The validation path logs under error and shutdown.mts under err; an Error logged under any other key gets pino's handling, not the allowlist. A value under error that is not an Error passes through unchanged: injection logs String(err) there for a throw it could not classify, whatever that message holds. The mechanics are in the header of logger.mts, pinned by __tests__/logger.test.mts.
  • One log vocabulary for both modes (#134). Every line about a request — each router's incoming request and every decision line — carries the request id as requestId and an event named after its mode (injection.*, validation.*), so a query on requestId covers both modes; the event names are per mode. The error field is not shared: validation logs the Error itself, serialised through the allowlist below into an object with message, stack and cause, while injection logs a string — the error's message, or String(err) for a throw it could not classify. A query on error.message matches validation lines only. The level of a provider failure says whose it was: the provider refusing the caller's credential is info (a refusal of what the credential may do, injection's exchange_not_permitted, is warn); the proxy's own failures — its client refused, a redirect, a provider 5xx, a timeout, an invalid response, an unexpected throw — are error. One timeout is not: on the session grant, a provider 401's body is read to tell invalid_client from an expired session (#95 F47), and a timeout during that read is answered as the expired session it almost always is — 401 session_required, logged injection.session_unauthorized at info. Pinned by each mode's decision.test.mts and router.test.mts.
  • A provider failure never stops the process. A configuration error stops it before it listens; a provider failure is answered per mode; SIGTERM / SIGINT start the drain.