13 KiB
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 viabetter-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>.
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):
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:
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):
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 beforeapp.init(), because Nest's router throwsNotFoundExceptionon 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 SPAindex.htmlfallback and the global error envelope areAPP_FILTERs (SpaFallbackFilter,TrekExceptionFilter)./mcpbodies are raw by design — bootstrap's parser wrappers exempt/mcpso the MCP SDK reads the untouched stream; never@Body()a/mcproute.- Per-domain module shape (one folder under
server/src/nest/<domain>/):<domain>.controller.ts+<domain>.service.ts+<domain>.module.ts, registered inapp.module.ts.weather/is the reference implementation — copy its shape. The Zod contract for the domain lives inshared/src/<domain>/<domain>.schema.ts. - Auth is default-deny:
GlobalAuthGuard+MfaPolicyGuardrun asAPP_GUARDs; 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 aservices/*-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; seeroadmap.md) — and forwardX-Socket-Idto 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.tsinitializes;schema.tscreates tables;migrations.ts+seeds.tsrun on boot. InNODE_ENV=testeach vitest worker gets an isolated:memory:DB; the Playwright harness usesTREK_DB_FILEfor 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 intools/, resources, scopes, session manager). SeeMCP.md. - Addons are admin-toggleable feature modules keyed by
ADDON_IDSinserver/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.tsregisters a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (install/,runtime/egress-policy.ts). Author tooling lives inplugin-sdk/; thetrek-plugin-devskill covers plugin authoring.
Client architecture
Offline-first, with a layered data flow. A Page never owns state directly:
- Page pattern (enforced by
lint:pages, seeclient/src/pages/PATTERN.md): a*Page.tsxdefault export is a wiring container — it composes a co-locateduse<Page>()hook into JSX and must not calluseState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRefin its own body. Stateful logic lives in the hook. - State: Zustand stores in
client/src/store/. The big one istripStore.ts, assembled from slices instore/slices/(places, packing, todo, budget, reservations, files, …).slices/remoteEventHandler.tsapplies 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 checknavigator.onLine: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go throughsync/mutationQueue.ts— optimistic Dexie write, then on reconnectflush()replays REST with anX-Idempotency-Keyheader so retried writes don't double-apply.sync/tripSyncManager.tsandsync/syncTriggers.tsdrive reconnection sync;sync/tilePrefetcher.tscaches map tiles. - Styling: use the appearance tokens (
var(--token)inline orbg-[var(--...)]Tailwind classes), never hardcoded color literals or numericfontSize—theme:lintflags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with atheme-lint-disableline comment. - PWA:
vite-plugin-pwa+ Workbox caches tiles/API/uploads.prebuildgenerates 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 inshared/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 ori18n:parity:strictfails CI. The client resolves keys viaclient/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
dbproxy,wssingleton, and event-sink globals are what forcerequire()-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoidswindow-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 neweslint-disable, no downgrading rules towarn.
Conventions (from CONTRIBUTING.md)
- Target the
devbranch for PRs, notmain(exception:wiki/-only changes). - Discuss first: outside contributions must be pitched in the
#github-prDiscord channel before any code is written — undiscussed PRs are closed. - PRs follow
.github/PULL_REQUEST_TEMPLATE.mdand 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:idparam 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).