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
+114
View File
@@ -0,0 +1,114 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What TREK is
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with four workspaces:
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
- **`nest-mcp/`** (`@trek/nest-mcp`) — decorator-driven MCP tool/resource/prompt registration for NestJS, consumed by the server. Deliberately **extraction-clean**: no imports from `@trek/server`/`@trek/shared`, no TREK semantics.
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
## Commands
Run from the repo root unless noted. Per the user's preference, **use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
```bash
npm run dev # build shared + nest-mcp, then watch shared + server + client concurrently
npm run build # build shared → nest-mcp → server → client (order matters)
npm run lint # lint all four workspaces
npm run format # prettier --write all four
```
Tests (the user runs client tests as `cd client && npm run test`):
```bash
npm run test # all workspaces
npm run test --workspace=server # vitest run (server)
npm run test:unit --workspace=server # tests/unit only
npm run test:integration --workspace=server
npm run test:ws --workspace=server # websocket tests
npm run test:e2e # server e2e (boots Nest against temp SQLite)
npm run test:cov # coverage, server + client
cd client && npm run test # client vitest run
cd client && npm run e2e # Playwright
cd client && npm run lint:pages # enforce the Page pattern (see below)
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
```
Run a single test file or test:
```bash
npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or client/)
npx vitest run -t "name of the test" # by test-name pattern
```
i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys):
```bash
npm run i18n:parity --workspace=shared # audit (exit 0)
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
```
Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
## Server architecture
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
## Client architecture
Offline-first, with a layered data flow. A Page never owns state directly:
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
## Shared contracts & i18n
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
## Project direction (from the 2026 full-repo audit)
A verified audit of the whole repo lives in `audit_report/TREK_audit_reports/` (`00_MASTER_SUMMARY.md` is the entrypoint, `01_STEERING_ROADMAP.md` the plan). Its verdict shapes how all new code should be written:
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's feature components calling the raw API past the offline core, still ~190 files). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
## Conventions (from CONTRIBUTING.md)
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). Per user preference, **do not add Co-Authored-By Claude attribution**.
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
## Reference docs
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
+61
View File
@@ -0,0 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this package is
`@trek/nest-mcp` — decorator-driven MCP tool/resource/prompt registration for NestJS, a workspace package of the TREK monorepo (see the repo-root `CLAUDE.md` for monorepo-wide context). Domains declare MCP entries as decorated methods on ordinary Nest providers; a discovery-backed registry collects them at boot and attaches them, filtered by a host-defined access policy, onto each per-session `McpServer`.
Two invariants shape everything here:
- **Extraction-clean**: imports nothing from `@trek/server` or `@trek/shared`; peer-depends only on `@nestjs/common`, `@nestjs/core`, `@modelcontextprotocol/sdk`, and `zod`. Do not add TREK-specific imports or semantics.
- **No scope semantics of its own**: `access: { group, mode }` markers are opaque here; their meaning comes entirely from the host's `accessPolicy` passed to `McpModule.forRoot(...)`. Host-specific behavior (TREK's scope rules, canned permission errors) belongs in `server/`, not here.
## Commands
From the repo root (or drop `--workspace=nest-mcp` when running inside this directory):
```bash
npm run build --workspace=nest-mcp # tsc -p tsconfig.build.json → dist/ (CJS + d.ts)
npm run typecheck --workspace=nest-mcp # tsc --noEmit (covers src + tests)
npm run test --workspace=nest-mcp # vitest run
npm run test:coverage --workspace=nest-mcp # istanbul coverage, 80% thresholds on src/**
npm run lint:check --workspace=nest-mcp # eslint (lint runs with --fix)
```
Single test file / single test (from `nest-mcp/`):
```bash
npx vitest run tests/registry.test.ts
npx vitest run -t "name of the test"
```
Build order matters at the monorepo level: root `build`/`dev` build `shared` → `nest-mcp` → `server` → `client`, because `server` consumes this package's `dist/`. After changing this package, rebuild it before running server tests that exercise the Nest MCP layer.
## Architecture
Source is small and flat (`src/`, one concern per file):
- **`metadata.ts`** — package-private metadata store: a `WeakSet`/`WeakMap` keyed by class constructor, deliberately **not** reflect-metadata (no polyfill import-order hazards; works in bare vitest workers and `createTestRegistry` without a Nest app). `getEntry` walks the constructor prototype chain so inherited decorated methods resolve on subclasses.
- **`decorators.ts`** — `@McpController()` (implies `@Injectable()`; discovery scans only marked classes) plus `@Tool` / `@Resource` / `@ResourceTemplate` / `@Prompt`, which just record entries into the metadata store.
- **`registry.ts`** — `McpRegistry`, a plain no-DI class holding decorated entries bound to provider instances. `attach(server, ctx, opts?)` registers each entry passing its access check onto a per-session `McpServer`, normalizing SDK callback shapes so handlers always receive `ctx` as the last argument (the SDK `extra` slot) and tools always receive `(args, ctx)` even with no `inputSchema`. `opts.onInvoke` (see `McpAttachOptions`) fires with `{ kind, name }` immediately before every attached handler runs — the host's observability seam (TREK's tool-call audit trail); it must not throw, and nest-mcp does not catch. `validate()` fails fast on duplicate names (fixed resources: duplicate URIs) and on declarative `access` without a configured policy.
- **`mcp-registry.service.ts`** — `McpRegistryService`, the DI subclass: on `onModuleInit` it scans providers via Nest's `DiscoveryService`/`MetadataScanner`, dedupes by instance (a provider can be wrapped once per module listing it), registers them, then runs `validate()` so misconfiguration breaks app boot rather than MCP session creation.
- **`mcp.module.ts`** — `McpModule.forRoot({ accessPolicy })`, a global dynamic module exporting the registry service.
- **`testing.ts`** — `createTestRegistry(instances, options)`: builds and validates a registry from hand-constructed controller instances, no Nest app.
- **`helpers.ts`** — handler-side result helpers (`ok`, `errorResult`, `demoDenied`) and the six `TOOL_ANNOTATIONS_*` presets.
- **`types.ts`** — options types and the empty `McpContext` interface, which hosts augment via `declare module '@trek/nest-mcp'`.
Access evaluation order in `allowed()`: `when` gate first, then `access` (predicate bypasses the policy; declarative goes through it; omitted `access` = always registered). Preserve this order — hosts rely on `when` for addon toggles so scope markers stay declarative.
Everything public is re-exported through `src/index.ts`; new exports go there.
## Testing setup quirks
- Vitest uses the **SWC transform** (`unplugin-swc` in `vitest.config.ts`) because esbuild doesn't emit decorator metadata — without it, type-based Nest DI in tests breaks silently.
- Coverage uses the **istanbul** provider (v8 under-reports on decorator output), thresholds 80% across the board on `src/**`.
- `tests/setup.ts` imports `reflect-metadata` for `@nestjs/testing` only — the package's own decorators/registry deliberately don't need it. Keep it that way.
- `tests/harness.ts` provides `createAttachHarness` (real `McpServer` + `Client` over an `InMemoryTransport`) and `asCtx` for the test context shape. Prefer exercising behavior through a real client round-trip rather than poking registry internals.
## MCP SDK exports-map workaround
The SDK's `exports` map uses extension-less wildcard targets that TypeScript and Vite cannot resolve. Both `tsconfig.json` (`paths`) and `vitest.config.ts` (`resolve.alias`) redirect deep imports (`server/mcp`, `client/index`, `inMemory`, `types`) to the CJS dist files under the **repo-root** `node_modules` (packages are hoisted). If you add a new deep SDK import, add it to **both** files or typecheck/tests will fail with unresolvable-module errors.
+26 -10
View File
@@ -375,12 +375,24 @@ gate-verifiable, worth re-checking after every phase:
cron a domain provider on `CronRegistrarService` (`src/nest/scheduling/`),
`node-cron` out of `package.json`. `index.ts` no longer boots any cron and its
`shutdown()` rides `nestApp.close()`. The db-boot airport backfill was already
gone (`9c46f31c`); ws/MCP wiring remain its hand-booted seams.
- 🟡 (re-checked 2026-08-11, after the bridge fold) **5** `*.bridge.ts` files
remain — `oauth`, `audit`, `addons`, `auth`, `permissions` — each shrunk to
the exports its consumers import, all pinned by the ONE remaining seam (the
pre-`app.init()` MCP/OAuth mount) and all dying together if that mount ever
moves behind the container. The 2026-08-10 endgame executed and then some:
gone (`9c46f31c`). (2026-08-11: the ws gateway binds inside `buildApp()` and
/mcp is a Nest controller — `index.ts`'s only MCP touch left is the
`closeMcpSessions()` housekeeping call in `shutdown()`.)
- ✅ (2026-08-11, MCP/OAuth mount migration) **Zero** `*.bridge.ts` files
remain. The one remaining seam died: /api/health, OAuth discovery, the
consent COOP override, /oauth/authorize + /oauth/register (SDK routers via
`OauthModule.configure`) and /mcp (`nest/mcp-transport/`) all moved behind
the container, so `oauth`/`audit`/`addons`/`auth`/`permissions` bridges
became ordinary injections and were deleted, along with
`mcp/registry-handoff.ts` (the transport injects `McpRegistryService`).
`_shared.ts`'s impure guards became the injectable `McpToolGuardsService`
(`nest/mcp-shared/`). Pre-init Express now carries only global middleware,
uploads, the DI-built discovery metadata middleware, static assets, api-docs
and the named body-parser wrappers (which keep `/mcp` bodies raw for the
SDK).
- 🟡 (historical — the 2026-08-11 morning state this replaced) 5 bridges
remained after the bridge fold, all pinned by the pre-`app.init()` mount.
The 2026-08-10 endgame executed and then some:
the 4 dead ones deleted (`8e4261ed`); `journey.bridge` +
`src/mcp/resources.ts` retired with the journey-resource migration;
`budget.bridge` deleted (UserCleanupService injects BudgetService);
@@ -394,10 +406,14 @@ gate-verifiable, worth re-checking after every phase:
them.
- ✅ (2026-08-11) `src/mcp/resources.ts` gone — the 4 journey resources are
decorator-registered on `journey.mcp.ts`; `journey.bridge` died with it.
- ❌ The `db` proxy is still imported outside DatabaseModule:
`mcp/oauthProvider.ts`, `mcp/tools/_shared.ts`, and the surviving bridges —
the MCP/OAuth seam above (`websocket.ts` is a re-export stub with no `db`
import since the gateway move; the scheduler's cron-path imports are gone).
- 🟡 (re-checked 2026-08-11, after the mount migration) The `db` proxy is
imported outside DatabaseModule by exactly four files: `backup.impl.ts`
(the backup lifecycle the DoD explicitly allows), `auth/jwt-verify.ts`
(the canonical pre-init token verifier the uploads route shares),
`platform.routes.ts` (the guarded /uploads/photos route) and
`places/place-image.ts`. Nothing under `src/mcp/` touches it anymore —
`oauthProvider.ts` is the injectable `oauth-sdk.provider.ts`, `_shared.ts`
is pure, and every bridge is gone.
## Keep in mind — ORM / data-layer swap (not scheduled, just recorded)
+3 -3
View File
@@ -32,15 +32,15 @@ npx vitest watch tests/unit/nest/auth-guard.test.ts
## Architecture
Nest owns everything: the strangler migration **completed 2026-08-10**. Legacy `src/services/` is **deleted** — `eslint.config.mjs` errors on any import of it — and every domain is a DI module under `src/nest/<domain>/`. `src/nest/README.md` is the per-module blueprint and records the migration's design decisions. What still runs outside the container (tracked in `roadmap.md`): the MCP/OAuth transport mounts before `app.init()` — that seam is why five `*.bridge.ts` files survive (`addons`, `audit`, `auth`, `oauth`, `permissions`; each header states the pin, and all die together if that mount migrates). The cycle-dodge bridges are gone — folded via leaf-module splits (`AssignmentsDomainModule`, `ReservationsReadRepository`/`AirtrailCoreModule`, the `TripMembershipService` reads). The websocket seam is gone too: `/ws` is a Nest gateway (`src/nest/realtime/`), and `src/websocket.ts` is a re-export stub. The scheduler seam is gone: every cron is a domain provider on `@nestjs/schedule` (see `src/nest/scheduling/` below).
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11, taking the final five `*.bridge.ts` files with it (**zero bridges remain**). Legacy `src/services/` is **deleted** — `eslint.config.mjs` errors on any import of it — and every domain is a DI module under `src/nest/<domain>/`. `src/nest/README.md` is the per-module blueprint and records the migration's design decisions. What still runs pre-init is path-prefixed plumbing only: global middleware, /uploads static+guarded routes, the DI-built discovery metadata middleware, production statics, api-docs, and the named body-parser wrappers. The websocket seam is gone: `/ws` is a Nest gateway (`src/nest/realtime/`), and `src/websocket.ts` is a re-export stub. The scheduler seam is gone: every cron is a domain provider on `@nestjs/schedule` (see `src/nest/scheduling/` below).
- **`src/index.ts`** — process entry: creates upload/data dirs, builds the app via `bootstrap.ts`, wires the WebSocket server onto the same HTTP server, prints the startup banner. Default port **3001**.
- **`src/bootstrap.ts` — `buildApp()`** — the single shared app builder for both prod and the test harness. **Registration order is load-bearing**: `applyGlobalMiddleware` → `applyPlatformUploads` → `applyPlatformTransport` → `applyPlatformStatic` all run on the raw Express instance **before `app.init()`**, because Nest throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. `bodyParser: false` keeps the raw `/mcp` body unparsed for the MCP SDK.
- **`src/bootstrap.ts` — `buildApp()`** — the single shared app builder for both prod and the test harness. **Registration order is load-bearing**: `applyGlobalMiddleware` → `applyPlatformUploads` → the discovery metadata middleware → `applyPlatformStatic` → the named body-parser wrappers all run on the raw Express instance **before `app.init()`**, because Nest throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. **`/mcp` bodies are raw by design**: the parser wrappers (`jsonParser`/`urlencodedParser` — the names suppress Nest's own init-time parsers) exempt `/mcp` so the MCP SDK reads the untouched stream. Never `@Body()` a `/mcp` route.
- **Domain modules** live in `src/nest/<domain>/` as `controller` + `service` + `module`, registered in `src/nest/app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract is in `shared/src/<domain>/`.
- **`src/services/` no longer exists** — deleted 2026-08-10 after the last fold; ESLint walls off any new import. Business logic lives in each domain's injectable `.service.ts`, with pure halves split into `.helpers.ts` plain modules and, in newer folds, SQL into a repository (`trek_photos` pattern).
- **`src/db/`** — `better-sqlite3` (WAL, FK on). `database.ts` boots and runs `schema.ts` → `migrations.ts` → `seeds.ts`. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness).
- **`src/websocket.ts`** — JWT-authed `ws` server on `/ws`, room-per-trip, heartbeat, per-connection rate limiting. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change.
- **`src/mcp/`** — OAuth 2.1 MCP server transport (scopes, sessionManager, `nest-mcp-policy.ts` boot gate). The tools/resources/prompts themselves live in decorator-driven `<domain>.mcp.ts` files under `src/nest/` — `src/mcp/tools.ts` is registry-attach-only.
- **`src/mcp/`** — process-wide MCP state (scopes, sessionManager, tuning knobs, rate-limit map, `nest-mcp-policy.ts` boot gate). The HTTP transport is `src/nest/mcp-transport/` (a `@Public` controller + service); the tools/resources/prompts live in decorator-driven `<domain>.mcp.ts` files under `src/nest/` — `src/mcp/tools.ts` is registry-attach-only. Every tool invocation writes an `mcp.tool_call` audit row (user, OAuth client, tool name, ip) via the nest-mcp `onInvoke` seam.
- **`src/nest/scheduling/`** — `CronRegistrarService`, the one way TREK schedules a cron (`@nestjs/schedule` + the `cron` package). Every job is a `*.job.ts` provider in its owning domain, registered from `onApplicationBootstrap`; the registrar refuses to schedule under `NODE_ENV=test` (the shared `buildApp()` harness must never tick — `tests/integration/scheduler-gate.test.ts` pins it) and stops everything on `nestApp.close()`. Never use a `@Cron` decorator — decorator jobs bypass the gate.
- **`src/nest/plugins/`** — sandboxed plugin runtime in three layers: `supervisor/` + `runtime/plugin-host-entry.ts` + `protocol/envelope.ts` (forked child per plugin, IPC envelopes, rate/RSS policing — **intentionally not Nest**, it's the security boundary); `host/rpc-host.ts` (enforcement: a method is registered at spawn only if the plugin holds the unlocking permission — registration = authorization); `host/plugin-host-deps.factory.ts` (dependency wiring — an `@Injectable()` factory injecting the domain services; the RPC surface is decorator-driven since 2026-08-09, with `PluginsModule` split into four and per-domain coverage gates) + `host/plugin-host-state.ts` (process-wide data-DB/budget maps, module-level on purpose). Install-time gates in `install/` (manifest, safe-extract, Ed25519 signature verify, egress). ⚠️ `runtime/egress-policy.ts` and `HOOK_PERMISSION` in `supervisor/plugin-supervisor.ts` are mirrored (egress-policy **byte-identical**) into `plugin-sdk/` and guarded by parity tests there — keep both sides in sync when touching either.
+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.