From bc10fba389ab16331680db5707d0de61feba665b Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:43:06 +0200 Subject: [PATCH] feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning A plugin whose author has not updated its `trek` range for the running TREK can now be installed, updated and activated when the operator sets TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker, post-extraction re-check, sideload, dev-link, activation) warns instead of refusing; a missing range is tolerated the same way; "install latest" takes the newest published version. The plugin-API version gate is not lifted and the Discover compatibility verdict stays truthful. The admin is warned at every step: each bypass is logged, install/upload/link/ update responses carry a `trekRangeBypassed` marker, and both admin panels show a "Version checks off" pill, an "Install anyway" confirm dialog before a registry install, the same notice after a path that could not ask first, and a persistent chip on the row while the plugin runs outside its range. Nine new i18n keys in every locale; wiki pages updated (Environment-Variables, Admin-Plugins, Plugin-Development, Security-Hardening). * refactor(plugins): one useRangeBypass hook behind both admin shells SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass logic had been added to the desktop and phone plugin panels as identical copies. The marker/warning types, the row chip, the "Install anyway" offer, the dialog copy and the confirm-or-notice state now live in useRangeBypass (next to useInstanceSettings, the existing shared-logic precedent); each panel keeps only its own dialog/sheet markup. * docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3% duplication, A ratings, hotspots reviewed) was only discoverable by waiting 20-25 minutes for the workflow. The root CLAUDE.md now states the conditions, the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and how to check a PR with the sonarqube MCP first; client and server CLAUDE.md point at it. shared/CLAUDE.md says every locale needs a real translation — parity cannot tell an English placeholder from a translation. * docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy Verified every concrete claim in the five CLAUDE.md files against the code and fixed the stale ones: the websocket server is the Nest realtime gateway, MCP tools live in .mcp.ts files, the client "native shell" section described code that was never written, offline maps also prefetch vector basemaps, plus several renamed files and wrong table names. Then trimmed everything that drifts on its own: dates, migration history, counts, and inventories of stores, slices, domains, sync files and CLI commands. What stays is the commands, the invariants with the file that enforces each one, what CI actually runs, the reference implementations to copy, and the rules for new code. Each file now opens with that rule. --- CLAUDE.md | 117 +++++++++-------- client/CLAUDE.md | 98 +++++++------- .../Admin/AdminPluginsPanel.test.tsx | 82 ++++++++++++ .../components/Admin/AdminPluginsPanel.tsx | 121 +++++++++++++---- .../components/Admin/useRangeBypass.test.ts | 76 +++++++++++ client/src/components/Admin/useRangeBypass.ts | 90 +++++++++++++ .../screens/admin/MAdminPluginsPanel.tsx | 83 ++++++++---- .../mobile/admin/MAdminPluginsPanel.test.tsx | 75 +++++++++++ plugin-sdk/CLAUDE.md | 53 ++++---- server/CLAUDE.md | 122 +++++++----------- server/src/app-config/README.md | 2 +- server/src/app-config/derive.ts | 6 + server/src/app-config/env.schema.ts | 1 + .../src/nest/plugins/install/host-compat.ts | 43 ++++++ server/src/nest/plugins/install/manifest.ts | 6 +- .../nest/plugins/plugin-runtime.service.ts | 32 +++-- server/src/nest/plugins/plugins.service.ts | 17 ++- .../nest/plugins/registry/registry.service.ts | 68 +++++++--- .../integration/plugins/dev-link.test.ts | 20 +++ .../plugins/plugin-runtime.test.ts | 43 +++++- .../integration/plugins/registry.test.ts | 89 ++++++++++++- server/tests/unit/app-config/derive.test.ts | 8 ++ server/tests/unit/plugins/host-compat.test.ts | 40 ++++++ .../plugins/plugin-retrust.controller.test.ts | 14 +- .../unit/plugins/plugins-service.test.ts | 41 ++++++ shared/CLAUDE.md | 55 ++++---- shared/src/i18n/ar/admin.ts | 12 ++ shared/src/i18n/br/admin.ts | 14 ++ shared/src/i18n/ca/admin.ts | 14 ++ shared/src/i18n/cs/admin.ts | 12 ++ shared/src/i18n/de/admin.ts | 13 ++ shared/src/i18n/en/admin.ts | 12 ++ shared/src/i18n/es/admin.ts | 14 ++ shared/src/i18n/fr/admin.ts | 14 ++ shared/src/i18n/gr/admin.ts | 13 ++ shared/src/i18n/hu/admin.ts | 13 ++ shared/src/i18n/id/admin.ts | 13 ++ shared/src/i18n/it/admin.ts | 14 ++ shared/src/i18n/ja/admin.ts | 12 ++ shared/src/i18n/ko/admin.ts | 12 ++ shared/src/i18n/nl/admin.ts | 12 ++ shared/src/i18n/pl/admin.ts | 13 ++ shared/src/i18n/ru/admin.ts | 13 ++ shared/src/i18n/sv/admin.ts | 13 ++ shared/src/i18n/tr/admin.ts | 12 ++ shared/src/i18n/uk/admin.ts | 13 ++ shared/src/i18n/vi/admin.ts | 13 ++ shared/src/i18n/zh-TW/admin.ts | 12 ++ shared/src/i18n/zh/admin.ts | 12 ++ wiki/Admin-Plugins.md | 1 + wiki/Environment-Variables.md | 1 + wiki/Plugin-Development.md | 14 +- wiki/Security-Hardening.md | 2 + 53 files changed, 1378 insertions(+), 337 deletions(-) create mode 100644 client/src/components/Admin/useRangeBypass.test.ts create mode 100644 client/src/components/Admin/useRangeBypass.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9fa2d44b9..49dfc82f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,18 +1,17 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc. ## What TREK is -A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces: +A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo: -- **`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. -- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime. +- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run. +- **`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`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`. -**`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. +Each package has its own `CLAUDE.md`; this file covers the monorepo picture. ## Commands @@ -21,7 +20,7 @@ Run from the repo root unless noted. **Use `npm run ... --workspace=`, not ```bash npm run dev # build shared, then watch shared + server + client concurrently npm run build # build shared → server → client (order matters) -npm run lint # lint all three workspaces +npm run lint # lint all three workspaces (server and shared run eslint --fix and rewrite files; client is check-only) npm run format # prettier --write all three ``` @@ -29,17 +28,14 @@ Tests: ```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 (lcov), all four packages incl. shared + plugin-sdk +npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws +npm run test:e2e # server e2e (boots Nest against a temp SQLite) +npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk 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) +cd client && npm run e2e # Playwright (local only) +cd client && npm run lint:pages # enforce the Page pattern (CI gate) +cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only) +npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests) ``` Run a single test file or test: @@ -49,65 +45,84 @@ npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or c 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): +i18n parity (CI gate — every non-`en` locale must have the identical file set and 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`). +Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference. -## Server architecture +## Server invariants -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//`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`). +Nest owns everything: every domain is a DI module under `server/src/nest//` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src//`. -- **`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//`): `.controller.ts` + `.service.ts` + `.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//.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('')` 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`), 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. +- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter. +- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route. +- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list. +- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler). +- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB. +- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub. +- **MCP**: tools are declared in `.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`. +- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`. +- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring. +- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service. +- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate). -## Client architecture +## Client invariants 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()` 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. +- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use()` hook; it must not call React state/effect/memo hooks itself. +- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly. +- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment. +- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and 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//`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up. -- **i18n locale files live in `shared/src/i18n//`**, 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. +- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up. +- Locale files live in `shared/src/i18n//`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale. ## Project direction -These principles come out of a verified 2026 full-repo audit and shape how all new code should be written: +These principles come out of a full-repo audit and shape all new code: - **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 the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper. +- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). 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`. +- **DI over global mutable module state.** 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 (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents. +- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds. ## 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): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages. -- 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. +- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written. +- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features. +- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.** +- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage. +- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes. + +## Quality gates in CI + +`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. + +**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it: + +| Condition (new code) | Must be | +|---|---| +| Coverage | ≥ 80% | +| Duplicated lines | ≤ 3% | +| Security / reliability / maintainability rating | A | +| Security hotspots reviewed | 100% | + +- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/` ↔ `client/src/mobile/screens/.../M`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free. +- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way. +- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`. +- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem. ## 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). +- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide). +- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack. diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 57337de94..49fcc8518 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -1,77 +1,69 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only. +Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory. ## Commands (run from `client/`) ```bash -npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001 +npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001 npm run build # prebuild generates PWA icons, then vite build -npm run typecheck # tsc --noEmit -npm run lint # eslint . -npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs) -npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx}) -npm run test:unit # tests/unit -npm run test:integration # tests/integration + co-located src tests -npm run test:coverage # v8 coverage -npm run e2e # Playwright -npm run e2e:report # open last Playwright report -npm run shots # Playwright screenshots project (shots:promote to accept) -npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate) +npm run typecheck # tsc --noEmit (CI) +npm run lint # eslint . (lint:check in CI) +npm run lint:pages # enforce the Page pattern (CI gate) +npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage +npm run e2e # Playwright (local only; e2e:report opens the last report) +npm run shots # Playwright screenshot project (shots:promote to accept) +npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only) +npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only) ``` -Single test: +Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`. -```bash -npx vitest run src/store/slices/budgetSlice.test.ts -npx vitest run -t "optimistically adds the place" -npx vitest watch src/pages/DashboardPage.test.tsx -``` +The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both. -The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both. +## Page pattern (enforced — `lint:pages` fails otherwise) -## Page pattern (enforced — `lint:pages` will fail otherwise) - -Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use()` hook** under `src/pages//`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives. +Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use()` hook** under `src/pages//`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives. ## Data flow — offline-first -The layering is **store → repo → api | Dexie**, and writes go through a mutation queue: +The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue: -- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state. -- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly. -- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection. -- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access. -- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles. +- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state. +- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data. +- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/.ts`, not the god module. +- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache. +- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers). -## Direction (from the 2026 audit) +## Rules for new code -The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts: +The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts: -- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/.ts`, not the `client.ts` god module. -- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly. -- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`). -- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation). -- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug. -- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss. -- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects. -- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code. -- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces. -- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter. -- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate. -- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `` styling. +- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent. +- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly. +- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks. +- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference). +- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`. +- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss. +- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects. +- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code. +- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it. +- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types. +- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate. +- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand. -## Other big-picture pieces +## Big-picture pieces -- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure. -- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`. -- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens//` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated. -- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill. -- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build. -- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / ``; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`. +- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other. +- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. +- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below. +- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill. +- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there. + +## Desktop ↔ mobile shells and the SonarQube duplication gate + +Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`. ## Tests -vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`). +vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 41ce1c610..3f8b4137c 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -2459,3 +2459,85 @@ describe('AdminPluginsPanel — instance settings', () => { expect(save.className).not.toContain('text-white') }) }) + +/** + * TREK_PLUGINS_IGNORE_TREK_RANGE. The server turns its version gates into warnings; the + * panel's job is to make sure the admin actually SEES the warning — before a registry + * install, after a sideload/link that could not be asked first, and on the row for as + * long as the plugin keeps running outside the range its author vouched for. + */ +describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => { + function bypassPanel(plugins: Record[], entry: Record | null) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, ignoreTrekRange: true, plugins })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])), + ) + } + const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null }) + + it('FE-PLG-BYPASS-001: says so in the header', async () => { + bypassPanel([plugin()], null) + render() + expect(await screen.findByText('Version checks off')).toBeInTheDocument() + }) + + it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => { + let posted: unknown = null + bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry()) + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + posted = await request.json() + return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } }) + })) + withToast() + fireEvent.click(await screen.findByText('Discover')) + + const btn = await screen.findByRole('button', { name: /^install anyway$/i }) + expect(btn).toBeEnabled() + fireEvent.click(btn) + + const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i }) + expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument() + expect(within(dialog).getByText(/corrupt trek data/i)).toBeInTheDocument() + expect(posted).toBeNull() // nothing sent until the admin accepts the risk + + fireEvent.click(within(dialog).getByRole('button', { name: /^install anyway$/i })) + await waitFor(() => expect(posted).toEqual({ id: 'trek-gotify' })) + }) + + it('FE-PLG-BYPASS-003: cancelling the warning installs nothing', async () => { + let posted = false + bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry()) + server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) })) + withToast() + fireEvent.click(await screen.findByText('Discover')) + fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i })) + + const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i }) + fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: /outside its supported trek versions/i })).not.toBeInTheDocument()) + expect(posted).toBe(false) + }) + + it('FE-PLG-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => { + bypassPanel([plugin()], null) + server.use(http.post('*/api/admin/plugins/upload', () => + HttpResponse.json({ id: 'trek-new', version: '1.0.0', replaced: false, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' } }))) + const { container } = withToast() + await screen.findByText('Gotify') + + await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, new File(['zip'], 'plugin.zip', { type: 'application/zip' })) + + const dialog = await screen.findByRole('dialog', { name: /installed outside its supported trek versions/i }) + expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument() + expect(within(dialog).getByText(/trek-new/)).toBeInTheDocument() + }) + + it('FE-PLG-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => { + bypassPanel([plugin({ + dependencyStatus: 'ok', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0', + trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }, + })], null) + render() + expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 16d39a0cf..15213f89e 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -12,6 +12,7 @@ import { import PluginIcon from '../shared/PluginIcon' import { adminApi } from '../../api/client' import { useInstanceSettings } from './useInstanceSettings' +import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass' import { usePluginStore } from '../../store/pluginStore' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' @@ -64,6 +65,8 @@ interface PluginRow { trekRange?: string | null /** The TREK this server is running — the server does the semver, the client just shows it. */ hostVersion?: string + /** Non-null while the plugin runs outside its declared range on the operator's say-so. */ + trekRangeBypassed?: TrekRangeBypass | null /** The author's signature was verified and their key pinned at install. False means the * bytes matched the registry's sha256 and nothing more — one fewer guarantee. */ signed?: boolean @@ -273,7 +276,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag return out } -interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean } +interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean } // A plugin's declared dependencies as chips — a required addon (amber when that // addon is disabled), a plugin dependency (amber when missing / version-mismatched), @@ -291,6 +294,8 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] { blocked: true, }) } + const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker + if (bypassed) out.push(bypassed) for (const a of p.dependencies?.requiredAddons ?? []) { out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) }) } @@ -310,11 +315,12 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] { * has outrun this TREK but an older one still fits, offer THAT version rather than a dead * grey button. The plugin is perfectly usable, just not at its newest. */ -function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } { +function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } { if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') } const title = item.trek ? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' }) : t('admin.plugins.dep.trekUnknown') + if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway" if (item.latestCompatible) { return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title } } @@ -398,6 +404,8 @@ export default function AdminPluginsPanel() { const toast = useToast() const [runtimeOn, setRuntimeOn] = useState(false) const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK) + const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side + const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell const [linkPath, setLinkPath] = useState('') const [plugins, setPlugins] = useState([]) const [loading, setLoading] = useState(true) @@ -472,9 +480,10 @@ export default function AdminPluginsPanel() { // (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5). void usePluginStore.getState().loadPlugins() adminApi.plugins() - .then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => { + .then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => { setRuntimeOn(!!d.enabled) setDevLink(!!d.devLink) + setIgnoreTrekRange(!!d.ignoreTrekRange) setPlugins(d.plugins || []) if ((d.plugins || []).length) { adminApi.pluginBrowse().then(indexRegistry).catch(() => {}) @@ -540,6 +549,7 @@ export default function AdminPluginsPanel() { const res = await adminApi.pluginUpload(file) setView('installed') toast.success(t('admin.plugins.uploaded', { name: res.id })) + bypass.notice(res.id, res.trekRangeBypassed) } catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) } finally { @@ -610,7 +620,11 @@ export default function AdminPluginsPanel() { const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null) return range ? { version: reg.latest, range } : null } - const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed')) + // `warn` is the pre-install confirm for an entry the registry already flagged as + // incompatible; an artifact whose OWN manifest turns out to be out of range (the index + // was only a pre-download filter) is caught by the marker on the response instead. + const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () => + act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), t('admin.plugins.installed'))) const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) // Dev-link: register a plugin from a local built directory (dev only). Reuses the // same busy/toast/refresh loop as uploadPlugin; the server gates it. @@ -623,6 +637,7 @@ export default function AdminPluginsPanel() { setView('installed') setLinkPath('') toast.success(t('admin.plugins.devLinkLinked', { id: res.id })) + bypass.notice(res.id, res.trekRangeBypassed) } catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError')) } finally { @@ -682,9 +697,10 @@ export default function AdminPluginsPanel() { if (busy === parent.id) return setBusy(parent.id) adminApi.pluginInstall(depId, { constraint, withDependencies: true }) - .then((r: { installed?: string[]; requiredAddons?: string[] }) => { + .then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => { toast.success(t('admin.plugins.dep.downloaded', { id: depId })) if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') })) + bypass.notice(depId, r?.trekRangeBypassed) return attemptActivate(parent) }) // The DEPENDENCY is what's being downloaded, so a signature refusal here is about the @@ -702,9 +718,10 @@ export default function AdminPluginsPanel() { const runUpdate = (p: PluginRow, version?: string) => { setBusy(p.id); setMenu(null) return adminApi.pluginUpdate(p.id, version) - .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { + .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => { if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) + bypass.notice(p.name, r.trekRangeBypassed) }) .catch(e => { const { error, code } = errBody(e) @@ -828,11 +845,19 @@ export default function AdminPluginsPanel() {

{t('admin.plugins.title')}

{t('admin.plugins.subtitle')}

- {runtimeOn && ( - - {t('admin.plugins.runtimeOn')} - - )} +
+ {runtimeOn && ignoreTrekRange && ( + + {t('admin.plugins.rangeBypass.pill')} + + )} + {runtimeOn && ( + + {t('admin.plugins.runtimeOn')} + + )} +
@@ -942,7 +967,7 @@ export default function AdminPluginsPanel() { ) : error ? (
{t('admin.plugins.loadError')}
) : !runtimeOn ? null : view === 'discover' ? ( - ) : plugins.length === 0 ? ( @@ -988,10 +1013,12 @@ export default function AdminPluginsPanel() { {/* Registry detail dialog */} {detailFor && ( - setDetailFor(null)} /> )} + {bypass.copy && } + {/* Error-log modal */} {errorsFor && (
setErrorsFor(null)}> @@ -1424,8 +1451,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
{deps.map((d, i) => ( - {d.label} + d.blocked || d.warn ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}> + {d.label} ))}
@@ -1535,14 +1562,15 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla ) } -function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: { +function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: { items: RegistryItem[] | null - onInstall: (id: string, version?: string) => void + onInstall: (id: string, version?: string, warn?: RangeWarning) => void onOpenDetail: (item: RegistryItem) => void busy: string | null t: T installedIds: Set filtered: boolean + ignoreTrekRange: boolean }) { if (!items) return
{t('common.loading')}
if (items.length === 0) return ( @@ -1555,7 +1583,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
{items.map(item => { const installed = installedIds.has(item.id) - const offer = installOffer(item, t) + const offer = installOffer(item, t, ignoreTrekRange) return (
onOpenDetail(item)} // No press-scale on the card: shrinking it mid-click slides the Install @@ -1590,7 +1618,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f {formatCompactCount(item.downloadCount)} )} - + + + ) : ( + + )} +
+
+
, + document.body, + ) +} + +function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange }: { + item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean + onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string }) { const [detail, setDetail] = useState(null) const [failed, setFailed] = useState(false) @@ -1645,7 +1718,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null // The detail fetch carries the same compat verdict as the browse list; prefer it once // it lands (it is keyed to the same entry) and fall back to the grid item until then. - const offer = installOffer(detail ?? item, t) + const offer = installOffer(detail ?? item, t, ignoreTrekRange) return (
@@ -1667,7 +1740,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local

{item.author}{item.latest ? ` · v${item.latest}` : ''}

-