Files
TREK/server/CLAUDE.md
T
Maurice bb5cc8f795 fix(db): one account per email address, whatever its case
Login, password reset and the sign-up checks all match emails
case-insensitively, but the only constraint was the column's own
UNIQUE, which SQLite compares case-sensitively. Two accounts whose
addresses differ only in case (legacy rows, two sign-ups at once) left a
login to resolve to whichever row came first, and LOWER(email) lookups
had no index to use.

A migration adds a unique index on lower(email). An install that
already holds such a pair must still start, so the migration then
leaves the index out, logs how many addresses are shared and by which
user ids (not the addresses), and records email_case_index_pending.
Since a migration runs once, db/email-case-index.ts runs on every boot
while that flag is set: it repeats the warning until the accounts are
told apart, then creates the index and clears the flag. The app-level
checks keep guarding meanwhile.

A write that passes the up-front check and still loses the race is now
refused by the database. The users repository turns that unique
violation into UserIdentityTakenError, and sign-up, admin create and
edit, and the profile update answer it with the same 409 their checks
give, instead of a 500 or an unhandled rejection (ADMIN-SVC-094 pinned
the old behaviour and now pins the 409).

The entity generator could not see an expression index (PRAGMA
index_info names its column NULL), so PARITY-009c found a db index with
no entity declaration. Rule 14 carries such an index into the entity as
a named entry with its own statement; Users.entity.ts is regenerated.
2026-10-08 13:02:58 +02:00

14 KiB

CLAUDE.md

Scope: the @trek/server workspace. See the repo-root CLAUDE.md for the monorepo picture and the philosophy. This file holds the server's commands, invariants and pitfalls; the code and src/nest/README.md hold the inventory.

Commands (run from server/)

npm run dev               # scripts/dev.mjs: tsc -w then node --watch dist/index.js (server on :3001)
npm run build             # scripts/build.mjs — emits even on type errors (see below)
npm run typecheck         # tsc --noEmit — the real type gate
npm run typecheck:tests   # tsc over tests/ — CI runs this too; vitest green does NOT mean typed mocks compile
npm run lint              # eslint --fix
npm run lint:check        # eslint, no fix (CI)
npm run test              # vitest run; also test:unit / test:integration / test:ws / test:e2e
npm run test:coverage     # istanbul coverage; per-domain ratchet over src/nest/**
npm run gen:plugin-facts  # regenerate the plugin-protocol tables into plugin-sdk/ + shared/
npm run check:plugin-facts # CI gate — fails if the generated copies drifted
node scripts/coverage-thresholds.mjs  # after test:coverage — prints the ratchet block for vitest.config.ts
npm run db:call-graph -- --sync --tx [--domains nest/days,…]  # sync DB-touching methods + transaction sites (exit 1 if any)

Single test: npx vitest run tests/unit/nest/weather.controller.test.ts, or npx vitest run -t "returns 401 without cookie".

Build emits even with type errors (scripts/build.mjs catches tsc failures and warns). npm run build succeeding does not mean the code typechecks — run npm run typecheck.

Architecture invariants

Nest owns everything. Every domain is a DI module under src/nest/<domain>/ (controller + service + module, registered in src/nest/app.module.ts); business logic lives in the injectable .service.ts, pure halves in .helpers.ts, SQL in a .repository.ts where one exists. There is no legacy service layer — ESLint errors on any services/ import. weather/ is the reference module shape; collections.controller.ts is the reference for body validation and enumeration-safe access checks.

  • src/bootstrap.ts — buildApp() is the single builder for production and the test harness. Everything on the raw Express instance (global middleware, uploads, discovery metadata, statics, the named jsonParser/urlencodedParser wrappers) is registered before app.init(), because Nest throws NotFoundException on unmatched routes and nothing after init is reachable. The wrappers exempt /mcp so the MCP SDK reads the raw stream — never @Body() a /mcp route. Don't add routes to that pre-init shell; it bypasses every global guard, interceptor and filter.
  • Realtime: /ws is a Nest gateway in src/nest/realtime/. Inject RealtimeService; src/websocket.ts is a re-export stub for old call sites. Trip mutations forward X-Socket-Id so the originating client doesn't echo its own change.
  • MCP: src/nest-mcp/ is the decorator/registry layer (@McpController/@Tool/@Resource/@Prompt; see its README.md); tools live in <domain>.mcp.ts files beside each module; the HTTP transport is src/nest/mcp-transport/; src/mcp/ holds process-wide state only (scopes, session manager, boot policy). Every tool invocation writes an audit row through the registry's onInvoke seam. MCP tools are adapters over the same services and permission checks as REST — a change on one side lands on the other in the same change.
  • Scheduling: src/nest/scheduling/ (CronRegistrarService) is the one way to schedule a cron. Jobs are *.job.ts providers in their owning domain. The registrar refuses to schedule under NODE_ENV=test (an integration test pins it). Never use a @Cron decorator — it bypasses that gate.
  • Plugins (src/nest/plugins/): the child-process runtime (supervisor/, runtime/plugin-host-entry.ts, protocol/envelope.ts) is intentionally not Nest — it is the security boundary. host/rpc-host.ts registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in install/; the manifest's trek version range fails closed, and TREK_PLUGINS_IGNORE_TREK_RANGE downgrades that to a warning. The protocol tables in protocol/envelope.ts (+ src/plugin-event-sink.ts) are generated into plugin-sdk/ and shared/ — run gen:plugin-facts after touching them and commit the output. runtime/egress-policy.ts is a hand-kept mirror guarded by a parity test in plugin-sdk/; change both sides together.
  • Storage: src/nest/storage/ is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly.
  • DB (src/db/): better-sqlite3, WAL, FK on. database.ts only opens the connection and applies pragmas — it does not build a schema. MikroORM's migrations (db/migrations/) are the only schema source, including for tests: the hand-kept schema.ts/migrations.ts/seeds.ts builders and the parity test that guarded them are gone. Seeding is the MikroORM seeders in db/seeders/, applied by db/orm.ts from buildApp() before anything reads. An install the retired positional runner migrated (a schema_version row, no mikro_orm_migrations rows) is baselined by db/legacy-baseline.ts: the migrations whose Legacy migration step N docstring is ≤ its version are recorded as executed (never replayed) in the same transaction as the migrator run, while the baseline migration itself still runs first, as the legacy runner's createTables() did on every boot; anything it cannot map refuses the boot. Before a boot applies pending migrations to a file database that holds anything, db/pre-migrate-snapshot.ts writes a VACUUM INTO copy next to it (pre-migrate-<schema>-<time>.db, <schema> being legacy-<schema_version> or the last recorded migration's timestamp; the newest TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP stay) and the newer-database refusal names the matching copy; a copy that cannot be written refuses the boot unless TREK_DB_PRE_MIGRATE_SNAPSHOT=false. It is off under NODE_ENV=test unless a suite sets that variable to true, and a restore's re-bootstrap skips it (the archive is the copy). After the migrator, db/email-case-index.ts creates the case-insensitive unique index on users.email that its migration had to defer on an install holding two accounts whose emails differ only in case (flag email_case_index_pending). db/orm-driver.ts binds the ORM to that same connection instead of opening a second one — with :memory: under test, a second connection is a second, empty database. Every read and write goes through a repository in db/repositories/ (each extends the shared TrekRepository base in db/repositories/_shared/, which validates the request context before delegating to MikroORM) and, for multi-statement writes, UnitOfWork.transactional(...) (nest/database/unit-of-work.ts, provided by the global OrmModule) — there is no DatabaseService/DatabaseModule to inject any more. Entrypoints outside an HTTP request — an MCP tool call, a plugin RPC dispatch, a WebSocket message, a cron job, boot-time seeding, a backup restore — open one explicitly with withRequestContext() (nest/database/request-context.ts), since only the pathless mikroOrmRequestContext middleware buildApp() mounts (with @mikro-orm/nestjs's own registerRequestContext turned off in app.module.ts, UPLOADS-P16) forks an EntityManager per HTTP request. NODE_ENV=test ⇒ isolated :memory: DB per vitest worker, opened from a snapshot tests/global-setup.ts migrates once per run; TREK_DB_FILE ⇒ explicit file DB (Playwright harness).

Rules for new code

  • Thin controller → injectable provider. Never resurrect a plain function-module service; don't grow god services — split a domain into several modules when it outgrows one class (trips and auth already are).
  • Inject, don't reach for module globals. No imports of the global db proxy from nest/ layers at all any more — the last production importer converted onto a repository, and the export now survives only so ~190 test files' vi.mock('…/db/database', …) factories keep typechecking; no new code depending on the ws singleton or event-sink globals. There is no DatabaseService to inject either — take the repository(ies) you need plus UnitOfWork for transactions. Modules must be importable without side effects.
  • Transactions are not optional. Multi-statement writes go in await this.uow.transactional(async () => …) (UnitOfWork from nest/database, provided by the global OrmModule); never hand-roll BEGIN/COMMIT and never call db.transaction() (the Phase 1 async sweep removes it). Every DB-touching method is async; @typescript-eslint/no-floating-promises and no-misused-promises are errors, so an unawaited call or a Promise in a condition fails lint. Bind values with ?; identifiers only via a literal allow-list.
  • Migrations are append-only. A migration is identified by its class name in mikro_orm_migrations, and files sort by the timestamp in that name — never rename or delete one that has shipped, and never edit its body; add a new one. No env interpolation in SQL. (The old positional schema_version array and the hand-kept db/migrations.ts test builder are both gone; the numbered migrations are the only schema history, for tests too.)
  • Every write endpoint validates through the Zod pipe with a schema from @trek/shared — no Record<string, unknown> + ad-hoc checks. Also verify every referenced id exists and belongs to the same trip.
  • Every outbound fetch gets a timeout, a response-size cap and boundary validation — no as-casting provider responses, no silent catch → null. User-influenced URLs go through the SSRF guard. Never fall back to another user's API key.
  • Fail closed. Unrecognized flag values mean OFF; sandbox/permission opt-outs refuse in production; never pick a less-safe path based on the absence of a file. GET handlers must not write.
  • Don't fork canonical paths. Auth verification is verifyJwtAndLoadUser (with the password_version gate) — reuse it.
  • Money: integer cents; largest-remainder splits (BudgetService.splitEqualShares is the reference); FX frozen at entry; explicit flags, never sentinel values; custom splits must reconcile to zero before persisting.

Configuration (app-config)

Never read process.env in src/** — ESLint errors on it. All env access goes through src/app-config/:

  • Nest classes inject a boot-stable namespace token from src/nest/app-config/ for values frozen per app build, or use RuntimeEnvService / readEnv() for runtime-toggled values (DEMO_MODE, NODE_ENV, OIDC_*, …) that tests mutate mid-lifetime.
  • Everything else calls readEnv() — live, uncached, per call.
  • Validation is fail-fast at boot only (boot-validate.ts, imported by index.ts right after dotenv): malformed values abort startup; unset/blank defaults. Never wire validation into buildApp() or ConfigModule.forRoot, and never cache/snapshot a runtime-toggled value (it breaks the env-mutating tests).
  • Booleans go through parseBool (true/1/on/yes vs false/0/off/no). Everything else pins its exact legacy coercion — quirks and the ESLint exemption list are in src/app-config/README.md.

Cross-cutting pieces

  • src/nest/common/: the global exception filter (error envelope), the Zod validation pipe, the idempotency interceptor (X-Idempotency-Key replay), and validate-route-guards.ts (boot-time ratchet refusing any public route not on its allow-list).
  • Auth is default-deny: the global auth guard and the MFA policy guard run as APP_GUARDs, followed by the managed-install guard (managed.guard.ts), which refuses the routes a centrally administered install withholds from its own admin — it runs last so strangers still get a 401, never a route-revealing 403. Routes opt out with @Public()/@OptionalAuth(). The global guard stands down for routes declaring their own guard chain, so @UseGuards(JwtAuthGuard, TripAccessGuard) + @RequirePermission stacks work. Session is a cookie (trek_session); @CurrentUser() reads the user. Never gate a multipart upload with a guard — guards run before the parser and the client sees ECONNRESET instead of your 403; check in the handler.
  • src/middleware/globalMiddleware.ts is the only Express middleware left (helmet/CSP, CORS, HSTS, forced-HTTPS, logging, cookies).

Tests

tests/ is split into unit/ (mirrors src/), integration/, e2e/ (one <domain>.e2e.test.ts per module, booting the real guards against a temp DB via tests/e2e/harness.ts) and websocket/; helpers in tests/helpers/, fixtures in tests/fixtures/.

  • Upgrade fixtures: tests/fixtures/legacy/legacy-v*.sql are databases the retired positional runner built; tests/integration/legacy-upgrade-v*.test.ts boot buildApp() on each. legacy-v244 is the v4.3.3 release with rows across the main tables, rebuilt reproducibly by node scripts/build-legacy-fixture-v244.mjs (archives the tag into a temp dir, boots that release's runner, seeds, pins timestamps, dumps).

  • vitest uses the SWC plugin, not esbuild, because Nest's DI needs emitted decorator metadata. Keep that in vitest.config.ts. Pool is forks (isolated DB per worker). The config also aliases @modelcontextprotocol/sdk/* to its CJS dist because the SDK's exports map is unresolvable.

  • Coverage is a per-domain ratchet over src/nest/** (≥80% floor, most domains pinned higher). Regenerate the block with scripts/coverage-thresholds.mjs when coverage rises; never lower a threshold to land a change.

  • CI also runs SonarCloud on the PR's new lines — see the root CLAUDE.md.

Parity discipline (when adding or changing routes)

Routes are byte-identical for the client: same URL, method, query/body, status, Set-Cookie and JSON body, including bespoke error strings reproduced in the controller. Nest defaults POST to 201 — add @HttpCode(200) where the contract returns 200. Declare static sub-routes (/reorder, /in-app/all) before :id param routes. Trip-scoped handlers verify trip access (404) and permission (403).