docs: close the bridge chapter

This commit is contained in:
jubnl
2026-08-11 23:25:22 +02:00
parent a01476f445
commit 04812702ec
12 changed files with 272 additions and 63 deletions
+20 -18
View File
@@ -21,26 +21,28 @@ import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter';
* former Express app is gone. One builder is shared by the production bootstrap
* (index.ts) and the integration-test harness so the two can never drift.
*
* Composition order is load-bearing. Everything except the SPA index.html fallback
* is registered on the underlying Express instance BEFORE `app.init()`, because
* Nest's router terminates an unmatched request by throwing NotFoundException — it
* does NOT fall through to a route registered after init, so a post-init Express
* route is unreachable. The platform routes are all specific paths (/uploads/*,
* /api/health, /mcp, /.well-known/*, /oauth/{authorize,register,consent}) so they
* match their own requests and `next()` everything else through to the Nest
* controllers registered during init.
* Composition order is load-bearing. The few remaining pre-`app.init()` pieces
* are registered on the underlying Express instance first, because Nest's
* router terminates an unmatched request by throwing NotFoundException — it
* does NOT fall through to a route registered after init, so a post-init
* Express route is unreachable. Each pre-init piece matches its own requests
* and `next()`s everything else through to the Nest controllers:
*
* 1. applyGlobalMiddleware — helmet/CSP, CORS, HSTS, forced-HTTPS, the global MFA
* policy, request logging + cookie-parser. `bodyParser: false` so Nest does its
* own parsing and the raw /mcp body reaches the MCP handler unparsed.
* 1. applyGlobalMiddleware — helmet/CSP, CORS, HSTS, forced-HTTPS, request
* logging + cookie-parser.
* 2. applyPlatformUploads — the static + guarded /uploads/* routes.
* 3. applyPlatformTransport — /api/health, the OAuth/MCP SDK + /.well-known
* metadata, the /mcp routes, the /oauth/consent COOP header.
* 4. applyPlatformStatic — the production built-client static assets (so a real
* asset request returns the file before the Nest router 404s it).
* 4b. setupApiDocs — Swagger UI/spec at /api/docs* when TREK_API_DOCS_ENABLED;
* also Express-level, so it must precede init for the same reason.
* 5. app.init() — registers every migrated /api domain (the Nest controllers).
* 3. MCP_METADATA_MIDDLEWARE — the SDK's OAuth discovery router + addon
* gate, container-built (DI) but mounted pathless here (see below).
* 4. applyPlatformStatic — the production built-client static assets (so a
* real asset request returns the file before the Nest router 404s it).
* 4b. setupApiDocs — Swagger UI/spec at /api/docs* when TREK_API_DOCS_ENABLED.
* 4c. the named body-parser wrappers — the global 100kb JSON/urlencoded
* parsers, exempting /mcp so the MCP SDK reads the raw stream.
* 5. app.init() — every domain controller, including the transport surface
* that used to live pre-init: /api/health (FeaturesController), OAuth
* discovery documents (DiscoveryController), the /oauth/consent COOP
* override (ConsentCoopMiddleware), /oauth/authorize + /oauth/register
* (SDK routers via OauthModule.configure) and /mcp (McpTransportModule).
*
* The SPA index.html fallback (unmatched GET → index.html in production) is the
* SpaFallbackFilter (APP_FILTER in AppModule); the global error envelope is the
+29 -13
View File
@@ -152,10 +152,11 @@ tools, which never pass through an HTTP guard. In the five domains piloted for
It replaced `setupWebSocket(server)`, which index.ts kicked off with a
dynamic import after listen().
- `ws-state.ts` holds the socket registry, and that is **module state on
purpose**. The out-of-container bridges and `notifications.instance.ts`
build `new RealtimeService()` outside the container; rooms on a provider
instance would leave their broadcasts going to an empty map, with no error
and no log. Same reasoning as the geo throttle cursor.
purpose**. The no-Nest test harnesses build `new RealtimeService()`
outside the container and 115 test files `vi.mock` the `src/websocket`
stub; rooms on a provider instance would leave their broadcasts going to
an empty map, with no error and no log. Same reasoning as the geo
throttle cursor.
- `trek-ws.adapter.ts` exists because the stock `WsAdapter` dispatches on
`{ event, data }` and every deployed client sends `{ type, tripId }`. It
also builds the ws server itself rather than delegating: the base's port-0
@@ -190,9 +191,9 @@ tools, which never pass through an HTTP guard. In the five domains piloted for
politely waited and OSM saw a single instance ignoring its rate limit. Both
domains now go through `nominatimFetch`.
- The throttle cursor and the cache are **module state, not provider state**,
for the permissions-cache reason: the bridges build their collaborators with
`new`, outside the container, and a second copy of the cursor is the very bug
the fold removes. `GeoModule` is registered straight in AppModule and neither
for the permissions-cache reason: the no-Nest test harnesses build their
collaborators with `new`, outside the container, and a second copy of the
cursor is the very bug the fold removes. `GeoModule` is registered straight in AppModule and neither
`MapsModule` nor `AtlasModule` imports it — an import would claim an
injection edge that does not exist.
- `GeocodingService` owns exactly one thing: the cache-cleanup timer's
@@ -226,12 +227,27 @@ was the fullest example — a structural `SchedulerDeps` interface filled by
`index.ts` — until every cron became an in-container `*.job.ts` provider and
the seam disappeared entirely, which is the real endgame for this shape.)
**`<domain>.bridge.ts` is the older shape** and still correct for the MCP
registrars that reach a domain at import time. Its cost is real, though: a
bridge rebuilds its services with `new`, so adding one constructor parameter to
a service means hand-editing every bridge that constructs it — five of them in
one PR, most recently. `app.get()` costs nothing there and returns the container
singleton rather than a second instance.
**`<domain>.bridge.ts` is a retired shape** — zero bridge files remain since
the MCP/OAuth mount moved behind the container (2026-08-11). It served code
that ran before `app.init()` and so could not inject; its cost was real: a
bridge rebuilt its services with `new`, so adding one constructor parameter to
a service meant hand-editing every bridge that constructed it — five of them in
one PR, at the worst. If a pre-init consumer ever reappears, prefer the
`bootstrap.ts` pattern instead: resolve the container singleton with
`app.get(...)` before init (the `httpConfig.KEY` / discovery-metadata
precedent) rather than minting a second instance.
**Express middleware with DI has two honest mounts** (first used by the mount
migration): `consumer.apply(...).forRoutes('<concrete path>')` in a module's
`configure()` — an Express prefix mount, exactly the legacy `app.use('/path')`
semantics, which is how `OauthModule` mounts the MCP SDK's authorize/register
routers and `PlatformModule` the consent-COOP override — and, for middleware
that must see the ORIGINAL `req.url` (a wildcard `forRoutes()` is a pattern
mount that strips the matched prefix), a pathless `app.use` in `bootstrap.ts`
over a container-resolved factory provider (`MCP_METADATA_MIDDLEWARE`).
`nest/mcp-transport/` (the /mcp controller + service over injected services)
and `nest/mcp-shared/` (`McpToolGuardsService` for the `*.mcp.ts` classes) are
the module shapes that replaced the last bridges.
**A bridge imported from INSIDE the container hides an edge from the module
graph**, which is a different problem from serving a genuine outside consumer.
+1 -2
View File
@@ -14,8 +14,7 @@ import { getPhotoProviderConfig } from '../memories/memories.helpers';
* bag-tracking and collab-features flags. The boolean polarities differ on
* purpose — bag tracking is opt-in (`=== 'true'`, default OFF), the collab
* sub-features are opt-out (`!== 'false'`, default ON). Reads are uncached
* per-call queries so admin toggles stay immediately visible; out-of-container
* consumers go through addons.bridge.ts.
* per-call queries so admin toggles stay immediately visible.
*/
@Injectable()
export class AddonsService {
+5 -5
View File
@@ -6,11 +6,11 @@ import path from 'path';
import { ADDON_IDS } from '../../addons';
import { readEnv } from '../../app-config';
import { updateJwtSecret } from '../../config';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
// the whole tools fan-out (and via the domain bridges, the Nest services) into
// every consumer of this module — a nest→mcp→nest module cycle. The
// invalidateMcpSessions barrel import below predates the fold and is deliberately
// separate: it is only reached from the controller, never from the cron path.
// Import from sessionManager directly, NOT the ../../mcp barrel — the direct
// path keeps this module's graph minimal, and the split predates the barrel's
// shrink to process-wide state. The invalidateMcpSessions barrel import below
// is deliberately separate: it is only reached from the controller, never from
// the cron path.
import { revokeUserSessions, revokeUserSessionsForClient } from '../../mcp/sessionManager';
import { invalidateMcpSessions } from '../../mcp';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
+3 -2
View File
@@ -34,8 +34,9 @@ const HEARTBEAT_INTERVAL = 30_000;
* than an interval nobody stops.
*
* What did NOT move is the socket registry. It stays module-scoped in
* ws-state.ts because out-of-container code (the MCP seam's safeBroadcast,
* auth.bridge's graph) must see the same rooms; see the note there.
* ws-state.ts because out-of-container code (the no-Nest test harnesses,
* the vi.mock'd src/websocket seam) must see the same rooms; see the note
* there.
*
* The wire protocol is unchanged, down to the frame names. TrekWsAdapter maps
* `{ type }` onto @SubscribeMessage, because the stock adapter dispatches on
+1 -1
View File
@@ -4,7 +4,7 @@ import { RealtimeService } from './realtime.service';
/**
* Global so every migrated module can inject RealtimeService without
* re-importing (same rationale as DatabaseModule). The service stays
* stateless and dependency-free so the out-of-container bridges can build it
* stateless and dependency-free so the no-Nest test harnesses can build it
* with a bare `new`.
*
* The transport itself lives in RealtimeGatewayModule, which AppModule imports
+2 -2
View File
@@ -108,8 +108,8 @@ export class TrekWsAdapter extends WsAdapter {
// failures; this one keeps the transport's own errors visible.
server.on('error', (err) => logError(`ws server error: ${err instanceof Error ? err.message : String(err)}`));
// The fan-out primitives reach the live server through here, and so do the
// out-of-container bridges.
// The fan-out primitives reach the live server through here, and so does
// every out-of-container RealtimeService the test harnesses build.
setServer(server);
return server;
}
+7 -7
View File
@@ -6,13 +6,13 @@ import { User } from '../../types';
* The socket registry: rooms, per-socket identity, and the three fan-out
* primitives.
*
* MODULE state, not provider state, and that is load-bearing. Out-of-container
* code still reaches the rooms: the MCP seam's safeBroadcast imports
* src/websocket directly, auth.bridge hand-builds a RealtimeService, and the
* no-Nest test harnesses construct more. If the rooms lived on a provider
* instance, those broadcasts would go to an empty map: no error, no log, just
* a client that stops updating. Same reasoning as the geo throttle cursor and
* the permissions cache.
* MODULE state, not provider state, and that is load-bearing. The no-Nest test
* harnesses hand-build RealtimeService instances (mcp-test-controllers.ts and
* ~60 unit suites), and the 115 vi.mock('src/websocket') seams assert on these
* exact exports. If the rooms lived on a provider instance, an out-of-container
* broadcast would go to an empty map: no error, no log, just a client that
* stops updating. Same reasoning as the geo throttle cursor and the
* permissions cache.
*
* The gateway next door owns the connection lifecycle and the handshake and
* writes into here. This file deliberately knows nothing about auth.