mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 13:06:54 +00:00
feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* 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 <domain>.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.
This commit is contained in:
@@ -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=<pkg>`, 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/<domain>/`. `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/<domain>/` (`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/<domain>/`.
|
||||
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
|
||||
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
|
||||
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
|
||||
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
|
||||
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
|
||||
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
|
||||
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
|
||||
- **`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('<action>')` 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 `<domain>.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<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
|
||||
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
|
||||
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
|
||||
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
|
||||
- **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<Page>()` 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/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
|
||||
- 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/<locale>/`, 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/<X>` ↔ `client/src/mobile/screens/.../M<X>`) 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.
|
||||
|
||||
+45
-53
@@ -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<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>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<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>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/<domain>.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/<domain>.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<Page>()` 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 `<html>` 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 `<html>` 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/<locale>')`** 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/<domain>/` (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` / `<ServerImage>`; 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/<locale>')` 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`).
|
||||
|
||||
@@ -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<string, unknown>[], entry: Record<string, unknown> | 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(<AdminPluginsPanel />)
|
||||
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(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<PluginRow[]>([])
|
||||
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() {
|
||||
<h2 className="text-lg font-semibold tracking-tight text-content">{t('admin.plugins.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted max-w-xl">{t('admin.plugins.subtitle')}</p>
|
||||
</div>
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex items-center gap-2 shrink-0 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{runtimeOn && ignoreTrekRange && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-warning bg-warning-soft px-2.5 py-1.5 rounded-full"
|
||||
title={t('admin.plugins.rangeBypass.pillHint')}>
|
||||
<AlertTriangle size={12} /> {t('admin.plugins.rangeBypass.pill')}
|
||||
</span>
|
||||
)}
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex items-center gap-2 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -942,7 +967,7 @@ export default function AdminPluginsPanel() {
|
||||
) : error ? (
|
||||
<div className="py-14 text-center text-sm text-danger">{t('admin.plugins.loadError')}</div>
|
||||
) : !runtimeOn ? null : view === 'discover' ? (
|
||||
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
|
||||
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds} ignoreTrekRange={ignoreTrekRange}
|
||||
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState t={t} onDiscover={openDiscover} />
|
||||
@@ -988,10 +1013,12 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Registry detail dialog */}
|
||||
{detailFor && (
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy} ignoreTrekRange={ignoreTrekRange}
|
||||
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
|
||||
)}
|
||||
|
||||
{bypass.copy && <RangeBypassDialog copy={bypass.copy} t={t} onConfirm={bypass.confirm} onClose={bypass.dismiss} />}
|
||||
|
||||
{/* Error-log modal */}
|
||||
{errorsFor && (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
@@ -1424,8 +1451,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
|
||||
d.blocked ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<d.icon size={12} className={d.blocked ? 'text-warning' : 'text-content-muted'} />{d.label}
|
||||
d.blocked || d.warn ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} />{d.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -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<string>
|
||||
filtered: boolean
|
||||
ignoreTrekRange: boolean
|
||||
}) {
|
||||
if (!items) return <div className="py-14 text-center text-sm text-content-faint">{t('common.loading')}</div>
|
||||
if (items.length === 0) return (
|
||||
@@ -1555,7 +1583,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
|
||||
{items.map(item => {
|
||||
const installed = installedIds.has(item.id)
|
||||
const offer = installOffer(item, t)
|
||||
const offer = installOffer(item, t, ignoreTrekRange)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => 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
|
||||
<Download size={11} /> {formatCompactCount(item.downloadCount)}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
|
||||
@@ -1620,9 +1648,54 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
|
||||
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>
|
||||
}
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null
|
||||
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
|
||||
/**
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
|
||||
* component rather than ConfirmDialog because the notice mode has no cancel button.
|
||||
*/
|
||||
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
|
||||
}) {
|
||||
return createPortal(
|
||||
<div role="presentation" className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-label={title}
|
||||
className="bg-surface-card border border-edge rounded-2xl w-full max-w-md p-6 shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="shrink-0 w-10 h-10 rounded-full bg-warning-soft grid place-items-center">
|
||||
<AlertTriangle size={20} className="text-warning" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">{title}</h3>
|
||||
<p className="mt-1.5 text-sm text-content-secondary leading-relaxed">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
{confirm ? (
|
||||
<>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg text-content-secondary border border-edge-secondary transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm font-semibold rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
|
||||
{t('admin.plugins.installAnyway')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-semibold rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
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<RegistryDetail | null>(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 (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
@@ -1667,7 +1740,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version)}
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version, offer.warn)}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { bypassChip, bypassOffer, rangeWarningCopy, useRangeBypass } from './useRangeBypass'
|
||||
|
||||
const t = (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k)
|
||||
|
||||
/**
|
||||
* The ONE logic path behind the TREK_PLUGINS_IGNORE_TREK_RANGE warning in both admin
|
||||
* shells — the desktop panel and the phone panel render their own markup over this.
|
||||
*/
|
||||
describe('rangeBypass helpers', () => {
|
||||
it('bypassChip: a warning chip (not a blocker) only for a bypassed row', () => {
|
||||
expect(bypassChip(null, t)).toBeNull()
|
||||
expect(bypassChip(undefined, t)).toBeNull()
|
||||
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }, t)).toMatchObject({
|
||||
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
|
||||
})
|
||||
|
||||
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
|
||||
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
|
||||
expect(offer).toEqual({
|
||||
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
|
||||
})
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
|
||||
const ranged = { name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0' }
|
||||
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.title',
|
||||
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
|
||||
})
|
||||
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
|
||||
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRangeBypass', () => {
|
||||
it('guard runs straight through without a warning, and parks the run behind one otherwise', () => {
|
||||
const { result } = renderHook(() => useRangeBypass())
|
||||
let ran = 0
|
||||
act(() => result.current.guard(undefined, () => { ran++ }))
|
||||
expect(ran).toBe(1)
|
||||
expect(result.current.warning).toBeNull()
|
||||
|
||||
act(() => result.current.guard({ name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
|
||||
expect(ran).toBe(1) // nothing sent until the admin accepts
|
||||
expect(result.current.copy?.confirm).toBe(true)
|
||||
|
||||
act(() => result.current.confirm())
|
||||
expect(ran).toBe(2)
|
||||
expect(result.current.warning).toBeNull()
|
||||
})
|
||||
|
||||
it('dismiss drops a parked run; notice opens a plain notice only for a real marker', () => {
|
||||
const { result } = renderHook(() => useRangeBypass())
|
||||
let ran = 0
|
||||
act(() => result.current.guard({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
|
||||
act(() => result.current.dismiss())
|
||||
expect(ran).toBe(0)
|
||||
expect(result.current.warning).toBeNull()
|
||||
|
||||
act(() => result.current.notice('trek-new', null))
|
||||
expect(result.current.warning).toBeNull()
|
||||
act(() => result.current.notice('trek-new', { trekRange: null, hostVersion: '3.3.0' }))
|
||||
expect(result.current.warning).toMatchObject({ name: 'trek-new', onConfirm: null })
|
||||
expect(result.current.copy?.confirm).toBe(false)
|
||||
act(() => result.current.confirm()) // a notice's confirm is just a close
|
||||
expect(result.current.warning).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
|
||||
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
|
||||
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
|
||||
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
|
||||
|
||||
/**
|
||||
* The chip a row carries while it runs outside its declared range on the operator's
|
||||
* say-so. Not a blocker — the toggle works — but a warning that must outlive the install
|
||||
* dialog for as long as the plugin runs here. Null when nothing was bypassed.
|
||||
*/
|
||||
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
|
||||
if (!b) return null
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
label: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown'),
|
||||
blocked: false,
|
||||
warn: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Install button for a registry entry the server flagged incompatible, once the
|
||||
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
|
||||
* regardless, so the button says exactly that — and carries what the warning has to say
|
||||
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
*/
|
||||
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
|
||||
return {
|
||||
blocked: false,
|
||||
label: t('admin.plugins.installAnyway'),
|
||||
title,
|
||||
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title + body of the warning, for either mode: a CONFIRM step before a registry install
|
||||
* the server would otherwise refuse (`onConfirm` set), or a plain NOTICE after a path that
|
||||
* could not ask first — sideload, dev-link, update, dependency download. The copy is the
|
||||
* whole point: the admin is accepting that the author never vouched for this TREK and
|
||||
* that, in rare cases, that can cost them data.
|
||||
*/
|
||||
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
|
||||
const confirm = w.onConfirm !== null
|
||||
return {
|
||||
confirm,
|
||||
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
|
||||
body: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning state behind BOTH admin shells (the desktop
|
||||
* panel and the phone panel render their own dialog/sheet over it). `guard` parks a
|
||||
* registry install behind the confirm step when the entry was already flagged
|
||||
* incompatible; `notice` shows the after-the-fact warning for a response that carries the
|
||||
* server's marker. Whether the switch is ON stays a plain `useState` in each shell: its
|
||||
* setter is read inside the mount-time refresh, and only a useState setter is something
|
||||
* the exhaustive-deps rule knows to be stable.
|
||||
*/
|
||||
export function useRangeBypass() {
|
||||
const { t } = useTranslation()
|
||||
const [warning, setWarning] = useState<RangeWarning | null>(null)
|
||||
|
||||
const notice = (name: string, b: TrekRangeBypass | null | undefined) => {
|
||||
if (b) setWarning({ name, trekRange: b.trekRange, hostVersion: b.hostVersion, onConfirm: null })
|
||||
}
|
||||
const guard = (warn: RangeWarning | undefined, run: () => void) => {
|
||||
if (warn) setWarning({ ...warn, onConfirm: run })
|
||||
else run()
|
||||
}
|
||||
const confirm = () => { const w = warning; setWarning(null); w?.onConfirm?.() }
|
||||
const dismiss = () => setWarning(null)
|
||||
|
||||
return {
|
||||
warning,
|
||||
copy: warning ? rangeWarningCopy(warning, t) : null,
|
||||
notice, guard, confirm, dismiss,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import PluginIcon from '../../../components/shared/PluginIcon'
|
||||
import { adminApi } from '../../../api/client'
|
||||
import { useInstanceSettings } from '../../../components/Admin/useInstanceSettings'
|
||||
import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from '../../../components/Admin/useRangeBypass'
|
||||
import { usePluginStore } from '../../../store/pluginStore'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useToast } from '../../../components/shared/Toast'
|
||||
@@ -65,6 +66,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
|
||||
@@ -272,7 +275,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),
|
||||
@@ -290,6 +293,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) })
|
||||
}
|
||||
@@ -309,11 +314,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 }
|
||||
}
|
||||
@@ -404,6 +410,8 @@ export default function MAdminPluginsPanel() {
|
||||
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<PluginRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -478,9 +486,10 @@ export default function MAdminPluginsPanel() {
|
||||
// (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(() => {})
|
||||
@@ -546,6 +555,7 @@ export default function MAdminPluginsPanel() {
|
||||
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 {
|
||||
@@ -621,7 +631,11 @@ export default function MAdminPluginsPanel() {
|
||||
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.
|
||||
@@ -634,6 +648,7 @@ export default function MAdminPluginsPanel() {
|
||||
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 {
|
||||
@@ -693,9 +708,10 @@ export default function MAdminPluginsPanel() {
|
||||
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
|
||||
@@ -713,9 +729,10 @@ export default function MAdminPluginsPanel() {
|
||||
const runUpdate = (p: PluginRow, version?: string) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
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)
|
||||
@@ -838,11 +855,19 @@ export default function MAdminPluginsPanel() {
|
||||
</h2>
|
||||
<p className="mt-1 font-geist text-[0.625rem] leading-relaxed text-m-muted">{t('admin.plugins.subtitle')}</p>
|
||||
</div>
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex flex-none items-center gap-1.5 rounded-full bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_14%,transparent)] px-2.5 py-1 text-[10px] font-bold text-[color:var(--m-st-confirmed)]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[color:var(--m-st-confirmed)]" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-none items-center gap-1.5">
|
||||
{runtimeOn && ignoreTrekRange && (
|
||||
<span className="inline-flex flex-none items-center gap-1 rounded-full bg-[color:color-mix(in_srgb,var(--m-st-pending)_14%,transparent)] px-2.5 py-1 text-[10px] font-bold text-[color:var(--m-st-pending)]"
|
||||
title={t('admin.plugins.rangeBypass.pillHint')}>
|
||||
<AlertTriangle size={11} /> {t('admin.plugins.rangeBypass.pill')}
|
||||
</span>
|
||||
)}
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex flex-none items-center gap-1.5 rounded-full bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_14%,transparent)] px-2.5 py-1 text-[10px] font-bold text-[color:var(--m-st-confirmed)]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[color:var(--m-st-confirmed)]" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -920,7 +945,7 @@ export default function MAdminPluginsPanel() {
|
||||
) : error ? (
|
||||
<div className="py-14 text-center text-sm text-[color:var(--m-st-danger)]">{t('admin.plugins.loadError')}</div>
|
||||
) : !runtimeOn ? null : view === 'discover' ? (
|
||||
<RegistryList items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
|
||||
<RegistryList items={shownRegistry} busy={busy} t={t} installedIds={installedIds} ignoreTrekRange={ignoreTrekRange}
|
||||
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState t={t} onDiscover={openDiscover} />
|
||||
@@ -965,10 +990,19 @@ export default function MAdminPluginsPanel() {
|
||||
|
||||
{/* Registry detail sheet */}
|
||||
{detailFor && (
|
||||
<PluginDetailSheet item={detailFor} t={t} locale={locale} busy={busy}
|
||||
<PluginDetailSheet item={detailFor} t={t} locale={locale} busy={busy} ignoreTrekRange={ignoreTrekRange}
|
||||
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
|
||||
)}
|
||||
|
||||
{/* TREK_PLUGINS_IGNORE_TREK_RANGE: confirm before a registry install the server would
|
||||
otherwise refuse (onConfirm set), or a plain notice after a path that could not
|
||||
ask first — sideload, dev-link, update, dependency download. */}
|
||||
{bypass.copy && (
|
||||
<MConfirmSheet open onClose={bypass.dismiss} onConfirm={bypass.copy.confirm ? bypass.confirm : undefined}
|
||||
title={bypass.copy.title} message={bypass.copy.body}
|
||||
confirmLabel={t('admin.plugins.installAnyway')} cancelLabel={bypass.copy.confirm ? t('common.cancel') : t('common.ok')} danger />
|
||||
)}
|
||||
|
||||
{/* Row ⋯ action sheet */}
|
||||
{rowMenuPlugin && (
|
||||
<RowActionsSheet p={rowMenuPlugin} t={t} onClose={() => setMenu(null)}
|
||||
@@ -1428,8 +1462,8 @@ function InstalledRow({ p, t, busy, hasUpdate, latestVer, newerIncompatible, blo
|
||||
{deps.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] text-[11px] font-medium ${d.blocked ? CHIP_PENDING : CHIP_NEUTRAL}`}>
|
||||
<d.icon size={12} className={d.blocked ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'} />{d.label}
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] text-[11px] font-medium ${d.blocked || d.warn ? CHIP_PENDING : CHIP_NEUTRAL}`}>
|
||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-[color:var(--m-st-pending)]' : 'text-m-faint'} />{d.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -1573,14 +1607,15 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
|
||||
)
|
||||
}
|
||||
|
||||
function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
|
||||
function RegistryList({ 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<string>
|
||||
filtered: boolean
|
||||
ignoreTrekRange: boolean
|
||||
}) {
|
||||
if (!items) return <div className="py-14 text-center text-sm text-m-faint">{t('common.loading')}</div>
|
||||
if (items.length === 0) return (
|
||||
@@ -1593,7 +1628,7 @@ function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<div className="space-y-3">
|
||||
{items.map(item => {
|
||||
const installed = installedIds.has(item.id)
|
||||
const offer = installOffer(item, t)
|
||||
const offer = installOffer(item, t, ignoreTrekRange)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
// Same press-scale opt-out as the desktop Discover card (#2158).
|
||||
@@ -1626,7 +1661,7 @@ function RegistryList({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<Download size={11} /> {formatCompactCount(item.downloadCount)}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className={`ml-auto ${ACT_PILL}`}>
|
||||
@@ -1656,9 +1691,9 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
|
||||
: <code className="rounded bg-[color:var(--m-ic)] px-1.5 py-0.5 font-mono text-[11px]">{perm}</code>
|
||||
}
|
||||
|
||||
function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, locale }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null
|
||||
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
|
||||
function PluginDetailSheet({ 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<RegistryDetail | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
@@ -1678,7 +1713,7 @@ function PluginDetailSheet({ 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)
|
||||
const linkClass = 'inline-flex items-center gap-1.5 rounded-lg border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-1.5 text-xs font-medium text-m-muted'
|
||||
const sectionH = 'text-[11px] font-semibold uppercase tracking-wider text-m-muted'
|
||||
|
||||
@@ -1702,7 +1737,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-m-faint">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version)}
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version, offer.warn)}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className={`${ACT_PILL} self-end`}>
|
||||
|
||||
@@ -1724,3 +1724,78 @@ describe('MAdminPluginsPanel — instance settings', () => {
|
||||
expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE on the phone shell — the same three surfaces as the
|
||||
* desktop panel: the header pill, the confirm-before-install sheet, and the notice after a
|
||||
* sideload that could not ask first, plus the persistent warning chip on the row.
|
||||
*/
|
||||
describe('MAdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
||||
const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null });
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-001: says so in the header', async () => {
|
||||
mockPanel([plugin()], [], { ignoreTrekRange: true });
|
||||
render(<MAdminPluginsPanel />);
|
||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-002: offers "Install anyway" and asks before installing', async () => {
|
||||
let body: unknown = null;
|
||||
mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true });
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } });
|
||||
}));
|
||||
render(<MAdminPluginsPanel />);
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
||||
|
||||
const btn = await screen.findByRole('button', { name: 'Install anyway' });
|
||||
expect(btn).toBeEnabled();
|
||||
fireEvent.click(btn);
|
||||
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Outside its supported TREK versions' });
|
||||
expect(within(sheet).getByText(/no guarantee/i)).toBeInTheDocument();
|
||||
expect(body).toBeNull();
|
||||
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Install anyway' }));
|
||||
await waitFor(() => expect(body).toEqual({ id: 'trek-gotify' }));
|
||||
});
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-003: cancelling the warning installs nothing', async () => {
|
||||
let posted = false;
|
||||
mockPanel([], [incompatibleEntry()], { ignoreTrekRange: true });
|
||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }); }));
|
||||
render(<MAdminPluginsPanel />);
|
||||
fireEvent.click(await screen.findByRole('tab', { name: /Discover/ }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Install anyway' }));
|
||||
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Outside its supported TREK versions' });
|
||||
fireEvent.click(within(sheet).getByRole('button', { name: 'Cancel' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Outside its supported TREK versions' })).not.toBeInTheDocument());
|
||||
expect(posted).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => {
|
||||
mockPanel([plugin()], [], { ignoreTrekRange: true });
|
||||
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 } = render(<MAdminPluginsPanel />);
|
||||
await screen.findByText('Gotify');
|
||||
|
||||
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [new File(['zip'], 'plugin.zip', { type: 'application/zip' })] } });
|
||||
|
||||
const sheet = await screen.findByRole('dialog', { name: 'Installed outside its supported TREK versions' });
|
||||
expect(within(sheet).getByText(/no guarantee/i)).toBeInTheDocument();
|
||||
expect(within(sheet).getByText(/trek-new/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-PLUGP-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => {
|
||||
mockPanel([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' },
|
||||
})], [], { ignoreTrekRange: true });
|
||||
render(<MAdminPluginsPanel />);
|
||||
expect(await screen.findByText('Outside its TREK range (>=3.2.0 <4.0.0) — version checks off')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+26
-27
@@ -1,59 +1,58 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this package is
|
||||
|
||||
`trek-plugin-sdk` — the author-facing SDK and CLI for building TREK plugins. It lives inside the TREK monorepo but is **not one of the root npm workspaces**: it has its own `package-lock.json`, is versioned and published to npm independently, and must ship standalone (a published copy has no access to the rest of the repo). Run all commands from `plugin-sdk/`, not the repo root. The repo root's CLAUDE.md covers the main app; its conventions (target `dev` branch, conventional commits) still apply.
|
||||
`trek-plugin-sdk` — the author-facing SDK and CLI for building TREK plugins. It lives in the TREK monorepo but is **not a root npm workspace**: own `package-lock.json`, versioned and published to npm independently, and it must ship standalone (a published copy has no access to the rest of the repo). Run all commands from `plugin-sdk/`. The root `CLAUDE.md` conventions (target `dev`, conventional commits, no attribution trailers) still apply.
|
||||
|
||||
It ships three bins from one codebase: `trek-plugin-sdk` / `trek-plugin` (same entry, `src/cli/trek-plugin.ts`) and `create-trek-plugin` (`src/cli/create.ts`).
|
||||
Three bins from one codebase: `trek-plugin-sdk` / `trek-plugin` (same entry, `src/cli/trek-plugin.ts`, one file per command under `src/cli/`) and `create-trek-plugin` (`src/cli/create.ts`). `README.md` here is the author guide; the `trek-plugin-dev` skill is the long-form authoring reference.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # tsc ESM build + tsc CJS build + scripts/finish-cjs.mjs
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm test # vitest run
|
||||
npm run build # tsc ESM build + tsc CJS build + scripts/finish-cjs.mjs
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm test # vitest run
|
||||
npm run test:coverage # what CI runs (feeds the Sonar new-code coverage gate)
|
||||
npx vitest run test/sdk.test.ts # single test file
|
||||
npx vitest run -t "name of the test" # by test-name pattern
|
||||
node scripts/gen-lucide-icon-names.mjs # regenerate src/lucide-icon-names.ts (uses repo-root lucide-react)
|
||||
```
|
||||
|
||||
Node >= 18. The package is `"type": "module"`; the dual build emits ESM to `dist/` and CJS to `dist/cjs/` (finish-cjs.mjs writes the `{"type":"commonjs"}` scope marker there — without it Node parses the CJS output as ESM).
|
||||
Node >= 18, `"type": "module"`; the dual build emits ESM to `dist/` and CJS to `dist/cjs/` (finish-cjs.mjs writes the `{"type":"commonjs"}` marker there — without it Node parses the CJS output as ESM). There is no lint script here.
|
||||
|
||||
**Release**: a pushed `plugin-sdk-v*` tag runs `npm ci && npm publish` via the root `publish-plugin-sdk.yml`; `prepublishOnly` builds and tests first. The published version is whatever `package.json` says — bump it in the same PR as the SDK change. Never push the tag yourself.
|
||||
|
||||
## The load-bearing principle: mirrored host code + parity tests
|
||||
|
||||
The SDK's core promise is that local results equal host/registry results: `dev` enforces what a real TREK enforces, and `validate` passes iff the TREK-Plugins registry CI passes. A drift in the lenient direction is the worst bug in this package — a **false green** lets an author cut an effectively immutable GitHub release (the registry pins its sha256) that the registry then rejects.
|
||||
The SDK's promise is that local results equal host/registry results: `dev` enforces what a real TREK enforces, and `validate` passes iff the TREK-Plugins registry CI passes. A drift in the lenient direction is the worst bug in this package — a **false green** lets an author cut an effectively immutable GitHub release (the registry pins its sha256) that the registry then rejects.
|
||||
|
||||
Because the package ships standalone, it cannot import server code, so several files are deliberate **copies** of host sources, each guarded by a parity test that reads the original source directly. These tests skip outside the monorepo, but in this checkout they run — keep the pairs in sync when touching either side:
|
||||
Because the package ships standalone it cannot import server code, so several files are deliberate **copies** of host sources, each guarded by a parity test that reads the original directly. Those tests skip outside the monorepo but run in this checkout — keep the pairs in sync when touching either side:
|
||||
|
||||
| SDK copy | Source of truth | Parity test |
|
||||
|---|---|---|
|
||||
| `src/permissions.ts` (`HOOK_PERMISSION`) | `server/src/nest/plugins/protocol/envelope.ts` via `server/scripts/gen-plugin-facts.ts` | `test/permissions-parity.test.ts` |
|
||||
| `src/generated/host-facts.ts` (permissions, RPC methods, hooks, event catalog) | `server/src/nest/plugins/protocol/envelope.ts` via `server/scripts/gen-plugin-facts.ts` — **machine-written, never hand-edit** | server `check:plugin-facts` CI gate |
|
||||
| `src/egress-policy.ts` (pure helpers, kept **byte-identical**) | `server/src/nest/plugins/runtime/egress-policy.ts` | `test/permissions-parity.test.ts` |
|
||||
| `src/manifest.ts` (validation rules, `KNOWN_ADDONS`) | server plugin loader / `server/src/addons.ts` | — kept in sync by hand |
|
||||
| `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; set `TREK_PLUGINS_REPO` to point at a checkout) |
|
||||
| `src/zip.ts` (ZIP writer) | server `safe-extract.ts` reader | format is frozen against the reader |
|
||||
| `src/manifest.ts` (validation rules incl. the `trek` version range, `KNOWN_ADDONS`) | `server/src/nest/plugins/install/` + `server/src/addons.ts` | kept in sync by hand |
|
||||
| `src/cli/checks/*` (registry gates) | TREK-Plugins registry repo (`scripts/validate-entry.mjs`, `check-readme.mjs`) | `test/checks-parity.test.ts` (runs the registry's real script; `TREK_PLUGINS_REPO` points at a checkout) |
|
||||
| `src/zip.ts` (ZIP writer) | server `safe-extract.ts` reader | format frozen against the reader |
|
||||
|
||||
`src/permissions.ts` also documents a subtlety worth preserving: ctx methods fail loudly at call time, but hooks/events/jobs are gated *before* the plugin is reached and fail **silently** in production — so the dev server and mock host deliberately make those failures loud (an ungranted `run(def).hook(...)` throws).
|
||||
**Direction: single-source the contract.** The hand-kept copies are the residual risk, and the parity tests' skip-outside-the-monorepo guards mean the published SDK's own CI can green-light drift in them — keep moving copies into the generated/parity-tested set, never away from it. Changing an egress rule, manifest rule or registry gate means touching **every** copy in one change; parity gates fail closed; version/compat checks fail **closed** on unknown host versions.
|
||||
|
||||
**Direction: single-source the contract.** The protocol core — permissions, RPC methods, hooks, the event catalog — is already single-sourced: `src/generated/host-facts.ts` is **machine-written** by `server/scripts/gen-plugin-facts.ts` from `server/src/nest/plugins/protocol/envelope.ts` (never hand-edit it; the server's `check:plugin-facts` CI gate catches drift). The remaining hand-kept copies (egress-policy, manifest rules, registry gates, the ZIP writer) are the residual risk, and the `skipIf(!inMonorepo)` escape hatch means the published SDK's own CI can green-light drift in those — so keep moving copies into the generated/parity-tested set, never away from it. Until then: changing an egress rule, manifest rule, or registry gate means touching **every** copy in one change; parity gates fail closed, never leniently; and version/compat checks fail **closed** on unknown host versions rather than assuming compatibility.
|
||||
A subtlety documented in `src/permissions.ts`: ctx methods fail loudly at call time, but hooks/events/jobs are gated *before* the plugin is reached and fail **silently** in production — so the dev server and mock host deliberately make those failures loud.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **`src/index.ts`** — the plugin API surface: all types, `definePlugin`, `PLUGIN_API_VERSION` (bump on any breaking API change). Pure and dependency-free; it mirrors exactly what TREK's isolated plugin runtime injects. Runtime deps for the whole package are only `@clack/prompts`, `semver`, `update-notifier` — keep it that way (e.g. Playwright for `shot` is intentionally the author's devDependency, and the ZIP writer is in-tree instead of a dependency).
|
||||
- **Two package exports**: `.` (the SDK) and `./testing` (`src/mock-host.ts`) — a mock `PluginContext` + driver enforcing the same permission model against configured fixtures.
|
||||
- **`src/cli/`** — one file per command, dispatched by `trek-plugin.ts`; `menu.ts` is the no-command guided menu. Interactive TTY sessions get prompts; non-TTY (CI, pipes) stays flag-driven — machine output (`entry` JSON, `pack --json`, PR URLs) goes to stdout, notices to stderr.
|
||||
- **`src/cli/checks/`** — one check registry, two depths: `runOffline` (synchronous on purpose, so `packPluginDir` can validate before zipping without going async) and `runAll` (adds the GitHub-dependent gates used by `preflight`/`publish`). Printing lives in `report.ts`: `status` renders the checklist and never exits non-zero; `validate` is the same checks with an exit code.
|
||||
- **`src/ui/kit.ts`** — the design kit (`TREK_UI_CSS`, `TREK_THEME_JS`) as plain strings, inlined into a plugin's `client/index.html` where the `<!-- trek:ui -->` marker sits (`dev`/`pack` expand it; the opaque-origin iframe CSP forbids external link/script). Not a security boundary.
|
||||
- **`src/lucide-icon-names.ts`** — generated snapshot (see command above); an unknown icon is a validate **warning**, never an error.
|
||||
- **`test/helpers.ts`** — `makePublishable(dir)` turns a fresh scaffold into a registry-passing plugin (README with per-permission lines, ≥400 prose chars, screenshot); the scaffold deliberately fails publish gates, so tests choose "fresh" vs "publishable" explicitly.
|
||||
- **`examples/`** — complete example plugins (`trip-doctor`, `koffi`).
|
||||
- **`src/index.ts`** — the plugin API surface: all types, `definePlugin`, `PLUGIN_API_VERSION` (bump on any breaking API change). Pure and dependency-free; it mirrors exactly what TREK's plugin runtime injects. Runtime deps for the whole package are only `@clack/prompts`, `semver`, `update-notifier` — keep it that way (Playwright for `shot` is the author's devDependency; the ZIP writer is in-tree).
|
||||
- **Two package exports**: `.` (the SDK) and `./testing` (`src/mock-host.ts`) — a mock `PluginContext` enforcing the same permission model against fixtures.
|
||||
- **`src/cli/`** — interactive TTY sessions get prompts; non-TTY stays flag-driven. Machine output (`entry` JSON, `pack --json`, PR URLs) goes to stdout, notices to stderr.
|
||||
- **`src/cli/checks/`** — one check registry, two depths: `runOffline` (synchronous on purpose, so packing can validate before zipping) and `runAll` (adds the GitHub-dependent gates used by `preflight`/`publish`).
|
||||
- **`src/ui/kit.ts`** — the design kit as plain strings, inlined where the `<!-- trek:ui -->` marker sits in a plugin's `client/index.html` (the opaque-origin iframe CSP forbids external link/script). Not a security boundary.
|
||||
- **`src/lucide-icon-names.ts`** — generated snapshot; an unknown icon is a validate **warning**, never an error.
|
||||
- **`test/helpers.ts`** — `makePublishable(dir)` turns a fresh scaffold into a registry-passing plugin; the scaffold deliberately fails publish gates, so tests choose "fresh" vs "publishable" explicitly.
|
||||
|
||||
## Behavioral contracts to not break
|
||||
|
||||
- `status` never exits non-zero; `validate` is the enforcing form.
|
||||
- `publish` order is check → pack → release → preflight → submit, and a check failure must stop anything from being tagged/released (releases are immutable in practice).
|
||||
- `publish` order is check → pack → release → preflight → submit, and a check failure must stop anything from being tagged or released.
|
||||
- Signed → unsigned is refused forever; unsigned → signed is always allowed.
|
||||
- Update notices (update-notifier) print to stderr only and are silent in CI/`NODE_ENV=test`/non-TTY, so JSON output stays pipeable.
|
||||
- Update notices print to stderr only and are silent in CI/`NODE_ENV=test`/non-TTY, so JSON output stays pipeable.
|
||||
|
||||
+44
-78
@@ -1,108 +1,74 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Scope: the **`@trek/server`** workspace. See the repo-root `CLAUDE.md` for the monorepo-wide picture (shared contracts, client, conventions). This file covers server internals only.
|
||||
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/`)
|
||||
|
||||
```bash
|
||||
npm run dev # scripts/dev.mjs: tsc -w then node --watch dist/index.js (server on :3001)
|
||||
npm run build # scripts/build.mjs — see "emit-anyway" note below
|
||||
npm run typecheck # tsc --noEmit (the real type gate; build does NOT fail on type errors)
|
||||
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 on src/apps/libs/test
|
||||
npm run lint:check # eslint, no fix
|
||||
npm run test # vitest run (all of tests/**)
|
||||
npm run test:unit # tests/unit
|
||||
npm run test:integration # tests/integration
|
||||
npm run test:ws # tests/websocket
|
||||
npm run test:e2e # tests/e2e (boots Nest modules against a temp in-memory SQLite)
|
||||
npm run test:coverage # istanbul coverage; per-domain ratchet over src/nest/** (floor ≥80%)
|
||||
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 from envelope.ts
|
||||
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
|
||||
```
|
||||
|
||||
Single test:
|
||||
Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`.
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/nest/weather.controller.test.ts
|
||||
npx vitest run -t "returns 401 without cookie"
|
||||
npx vitest watch tests/unit/nest/auth-guard.test.ts
|
||||
```
|
||||
**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`.
|
||||
|
||||
**Build emits even with type errors** (`scripts/build.mjs` catches `tsc` failures and warns). So `npm run build` succeeding does **not** mean the code typechecks — always run `npm run typecheck` to gate types.
|
||||
## Architecture invariants
|
||||
|
||||
## Architecture
|
||||
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.**
|
||||
|
||||
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11, taking the final five `*.bridge.ts` files with it (**zero bridges remain**). Legacy `src/services/` is **deleted** — `eslint.config.mjs` errors on any import of it — and every domain is a DI module under `src/nest/<domain>/`. `src/nest/README.md` is the per-module blueprint and records the migration's design decisions. What still runs pre-init is path-prefixed plumbing only: global middleware, /uploads static+guarded routes, the DI-built discovery metadata middleware, production statics, api-docs, and the named body-parser wrappers. The websocket seam is gone: `/ws` is a Nest gateway (`src/nest/realtime/`), and `src/websocket.ts` is a re-export stub. The scheduler seam is gone: every cron is a domain provider on `@nestjs/schedule` (see `src/nest/scheduling/` below).
|
||||
- **`src/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` runs `schema.ts` → `migrations.ts` → `seeds.ts`. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness).
|
||||
|
||||
- **`src/index.ts`** — process entry: creates upload/data dirs, builds the app via `bootstrap.ts`, wires the WebSocket server onto the same HTTP server, prints the startup banner. Default port **3001**.
|
||||
- **`src/bootstrap.ts` — `buildApp()`** — the single shared app builder for both prod and the test harness. **Registration order is load-bearing**: `applyGlobalMiddleware` → `applyPlatformUploads` → the discovery metadata middleware → `applyPlatformStatic` → the named body-parser wrappers all run on the raw Express instance **before `app.init()`**, because Nest throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. **`/mcp` bodies are raw by design**: the parser wrappers (`jsonParser`/`urlencodedParser` — the names suppress Nest's own init-time parsers) exempt `/mcp` so the MCP SDK reads the untouched stream. Never `@Body()` a `/mcp` route.
|
||||
- **Domain modules** live in `src/nest/<domain>/` as `controller` + `service` + `module`, registered in `src/nest/app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract is in `shared/src/<domain>/`.
|
||||
- **`src/services/` no longer exists** — deleted 2026-08-10 after the last fold; ESLint walls off any new import. Business logic lives in each domain's injectable `.service.ts`, with pure halves split into `.helpers.ts` plain modules and, in newer folds, SQL into a repository (`trek_photos` pattern).
|
||||
- **`src/db/`** — `better-sqlite3` (WAL, FK on). `database.ts` boots and runs `schema.ts` → `migrations.ts` → `seeds.ts`. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness).
|
||||
- **`src/websocket.ts`** — JWT-authed `ws` server on `/ws`, room-per-trip, heartbeat, per-connection rate limiting. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change.
|
||||
- **`src/mcp/`** — process-wide MCP state (scopes, sessionManager, tuning knobs, rate-limit map, `nest-mcp-policy.ts` boot gate). The HTTP transport is `src/nest/mcp-transport/` (a `@Public` controller + service); the tools/resources/prompts live in decorator-driven `<domain>.mcp.ts` files under `src/nest/` — `src/mcp/tools.ts` is registry-attach-only. Every tool invocation writes an `mcp.tool_call` audit row (user, OAuth client, tool name, ip) via the nest-mcp `onInvoke` seam.
|
||||
- **`src/nest-mcp/`** — the decorator/registry layer itself (`@McpController`/`@Tool`/`@Resource`/`@Prompt`, `McpRegistry`, `McpModule`, result helpers) that the `<domain>.mcp.ts` files build on. Formerly the standalone `@trek/nest-mcp` workspace, folded in 2026-08-22; see its `README.md` for the API and invariants (self-contained apart from a type-only `../mcp/scopes` import; no scope semantics of its own). Tests: `tests/unit/nest-mcp/`.
|
||||
- **`src/nest/scheduling/`** — `CronRegistrarService`, the one way TREK schedules a cron (`@nestjs/schedule` + the `cron` package). Every job is a `*.job.ts` provider in its owning domain, registered from `onApplicationBootstrap`; the registrar refuses to schedule under `NODE_ENV=test` (the shared `buildApp()` harness must never tick — `tests/integration/scheduler-gate.test.ts` pins it) and stops everything on `nestApp.close()`. Never use a `@Cron` decorator — decorator jobs bypass the gate.
|
||||
- **`src/nest/plugins/`** — sandboxed plugin runtime in three layers: `supervisor/` + `runtime/plugin-host-entry.ts` + `protocol/envelope.ts` (forked child per plugin, IPC envelopes, rate/RSS policing — **intentionally not Nest**, it's the security boundary); `host/rpc-host.ts` (enforcement: a method is registered at spawn only if the plugin holds the unlocking permission — registration = authorization); `host/plugin-host-deps.factory.ts` (dependency wiring — an `@Injectable()` factory injecting the domain services; the RPC surface is decorator-driven since 2026-08-09, with `PluginsModule` split into four and per-domain coverage gates) + `host/plugin-host-state.ts` (process-wide data-DB/budget maps, module-level on purpose). Install-time gates in `install/` (manifest, safe-extract, Ed25519 signature verify, egress). ⚠️ The protocol tables — permissions, RPC methods, hooks, and the core-event catalog — live in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts` for the event families) and are **generated** into `plugin-sdk/src/generated/host-facts.ts` and `shared/src/plugin-permissions.ts` by `npm run gen:plugin-facts`; `check:plugin-facts` is the CI gate, so run it after touching any of them and commit the regenerated files. `runtime/egress-policy.ts` is still a hand-kept **byte-identical** mirror into `plugin-sdk/`, guarded by parity tests there — keep both sides in sync when touching either.
|
||||
## Rules for new code
|
||||
|
||||
## Direction (post-migration guardrails, from the 2026 audit)
|
||||
|
||||
**Nest is the destination — and as of 2026-08-10 the code is there.** These rules now guard against regression rather than describe a migration in flight:
|
||||
|
||||
- **Follow the Nest philosophy, don't work against it.** New surfaces are real Nest modules: thin controller (HTTP shaping, guards, status codes) → injectable provider (business logic, DI-injected deps). The migration to this shape is complete — never resurrect a plain function-module service, and don't grow god services: when a domain outgrows one class, split it (the trips domain became `trips`/`trip-members`/`trip-membership`/`trip-invite`/`trip-read-model`/`calendar`; auth split into `auth` + profile + `tokens`).
|
||||
- **Inject, don't reach for module globals.** No new imports of the global `db` proxy from `nest/` layers; no new code depending on the `ws` singleton or event-sink globals. Write new code so it can move to DI (`DatabaseModule`, injectable realtime/config/scheduler) without rework. New modules must be importable without side effects (no disk/DB access at module evaluation).
|
||||
- **Don't add routes to the pre-`app.init()` Express shell** — it bypasses every global Nest guard/interceptor/filter. New routes go through Nest so cross-cutting controls actually apply.
|
||||
- **Transactions are not optional.** Any multi-statement write goes in `db.transaction()`; never hand-roll `BEGIN`/`COMMIT` via `db.exec`. Bind values with `?`; identifiers only via a literal allow-list.
|
||||
- **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 new imports of the global `db` proxy from `nest/` layers; no new code depending on the `ws` singleton or event-sink globals. Modules must be importable without side effects.
|
||||
- **Transactions are not optional.** Multi-statement writes go in `db.transaction()`; never hand-roll `BEGIN`/`COMMIT`. Bind values with `?`; identifiers only via a literal allow-list.
|
||||
- **Migrations are append-only.** Identity is positional (array index == `schema_version`) — never reorder, insert mid-list, or delete; no env interpolation in SQL.
|
||||
- **Every write endpoint validates.** `@Body()` must go through the Zod pipe with a schema from `@trek/shared` — no `Record<string, unknown>` + ad-hoc checks. Validating the caller's permission isn't enough: verify every referenced id (place/day/user) exists and belongs to the same trip.
|
||||
- **Every outbound `fetch` gets a timeout (`AbortSignal`), 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 silently fall back to another user's API key.
|
||||
- **Fail closed.** Unrecognized flag values mean OFF; sandbox/permission opt-outs must refuse in production; never select a less-safe code path based on the *absence* of a file. GET handlers must not write to the DB.
|
||||
- **Don't fork the canonical paths.** Auth verification is `verifyJwtAndLoadUser` (with the `password_version` gate) — reuse it, never reimplement. MCP tools are adapters over the same services/`canAccessTrip`/`checkPermission`; a validation or permission change on a REST route must land in the parallel MCP tool in the same change.
|
||||
- **Money rules**: integer-cent arithmetic; largest-remainder splits (`budgetService.splitEqualShares` is the reference); FX frozen at entry; explicit flags, never sentinel values (`rate === 1`, `scopes: null`); custom splits must reconcile (`Σ balances = 0`) before persisting.
|
||||
- **Reference controllers**: `collections.controller.ts` (Zod pipe on every body + enumeration-safe owner checks) alongside `weather/` for module shape.
|
||||
- **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` directly in `src/**`** — ESLint errors on it. All env
|
||||
access goes through `src/app-config/`:
|
||||
**Never read `process.env` in `src/**`** — ESLint errors on it. All env access goes through `src/app-config/`:
|
||||
|
||||
- **Nest DI classes**: inject a boot-stable namespace token
|
||||
(`@Inject(mcpConfig.KEY) private mcp: ConfigType<typeof mcpConfig>` 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 ~60 tests mutate mid-lifetime.
|
||||
- **Everything else** (services, mcp, websocket, scheduler, db, middleware):
|
||||
`readEnv()` from `src/app-config` — live, uncached, per call. Modules that
|
||||
froze env in module-top consts keep that timing (read `readEnv()` at module
|
||||
top, marked "frozen on purpose").
|
||||
- **Validation is fail-fast at boot only** (`boot-validate.ts`, imported by
|
||||
`index.ts` right after dotenv): present-but-malformed values abort startup;
|
||||
unset/blank always defaults. Never wire validation into `buildApp()` or
|
||||
`ConfigModule.forRoot` — and never enable `cache: true` or `load`-snapshot a
|
||||
runtime-toggled value (breaks the env-mutating tests).
|
||||
- **Booleans are unified**: every switch accepts true/1/on/yes vs false/0/off/no
|
||||
(any casing) via `parseBool`. Everything else pins its exact legacy coercion —
|
||||
parity quirks are documented in `src/app-config/README.md`, including the
|
||||
exemption list for the ESLint ban (plugin child process, dynamic keys,
|
||||
`config.ts` key material, scripts, tests).
|
||||
- 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 Nest pieces
|
||||
## Cross-cutting pieces
|
||||
|
||||
- **`src/nest/common/`**: `trek-exception.filter.ts` (global error envelope, `APP_FILTER`), `zod-validation.pipe.ts` (validates against `@trek/shared` schemas), `idempotency.interceptor.ts` (global `APP_INTERCEPTOR` replaying `X-Idempotency-Key` on mutations).
|
||||
- **Auth is default-deny**: `src/nest/auth/global-auth.guard.ts` + `mfa-policy.guard.ts` run as `APP_GUARD`s; routes opt out with `@Public()`/`@OptionalAuth()` (`public.decorator.ts`), and `src/nest/common/validate-route-guards.ts` refuses at boot any public route not on `PUBLIC_ROUTE_ALLOW_LIST`. The global guard stands down for routes that declare their own guard chain (still resolving `req.user` for MFA), so per-controller `@UseGuards(JwtAuthGuard, ...)` stacks keep working. Other guards: `admin.guard.ts`, `addons/addon.guard.ts` (`@RequireAddon`), `permissions/trip-access.guard.ts` (`@RequirePermission` + `@Trip()` — the standard trip-scoped shape), `permissions/trip-owner.guard.ts`. Auth is cookie-based (`trek_session`); `@CurrentUser()` decorator. **Never gate a multipart upload with a guard** — guards run before the parser, so the client sees ECONNRESET instead of your 403; check in the handler (PROFILE-015).
|
||||
- **`src/middleware/`** — only `globalMiddleware.ts` remains (helmet/CSP, CORS, HSTS, forced-HTTPS, logging, cookie-parser), applied in `bootstrap`. The old `mfaPolicy`/`tripAccess`/`idempotency`/`validate` middlewares are deleted — their jobs moved into the guards above and the Nest pipe/interceptor layer.
|
||||
- **`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_GUARD`s, 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/`, incl. `unit/nest/<domain>.controller.test.ts` and `auth-guard.test.ts`), `integration/`, `e2e/` (one `<domain>.e2e.test.ts` per module, booting the real `JwtAuthGuard` against a temp DB via `tests/e2e/harness.ts` — `createTempDb`/`seedUser`/`sessionCookie`), and `websocket/`. Shared helpers in `tests/helpers/`, fixtures in `tests/fixtures/`, global setup in `tests/setup.ts`.
|
||||
`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/`.
|
||||
|
||||
- **vitest uses the SWC plugin** (`vitest.config.ts`), not esbuild, because Nest's type-based DI needs emitted decorator metadata — esbuild drops it. Keep that config when touching test tooling. Pool is `forks` (isolated DBs per worker).
|
||||
- **Coverage gate is a per-domain ratchet over `src/nest/**`** (statements/branches/functions/lines; ≥80% floor, most domains pinned higher — see `vitest.config.ts`); legacy code is intentionally ungated.
|
||||
- The config also aliases `@modelcontextprotocol/sdk/*` to CJS dist files because the SDK's exports map uses unresolvable extension-less wildcards.
|
||||
- **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/changing routes)
|
||||
## Parity discipline (when adding or changing routes)
|
||||
|
||||
Routes must be **byte-identical** for the client: 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 (e.g. `/reorder`, `/in-app/all`) **before** `:id` param routes. Trip-scoped handlers verify trip access (404) and permission (403) per handler.
|
||||
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).
|
||||
|
||||
@@ -78,7 +78,7 @@ below.)
|
||||
**Runtime-toggled** (read live on every access via `readEnv()` /
|
||||
`RuntimeEnvService`; tests mutate these mid-lifetime):
|
||||
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
|
||||
TREK_PLUGINS_ENABLED / _DEV_LINK / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
|
||||
TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
|
||||
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
|
||||
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
|
||||
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check).
|
||||
|
||||
@@ -200,6 +200,12 @@ export function derivePlugins(raw: RawEnv) {
|
||||
/** Kill-switch is default-on: only an explicit falsy value disables (plugins/kill-switch.ts). */
|
||||
enabled: parseBool(raw.TREK_PLUGINS_ENABLED) !== false,
|
||||
devLink: parseBool(raw.TREK_PLUGINS_DEV_LINK) === true,
|
||||
/**
|
||||
* Range bypass is default-OFF: only an explicit truthy value turns the TREK-version
|
||||
* gates into warnings (plugins/install/host-compat.ts). Never inferred from any
|
||||
* other switch — an admin has to ask for "install it anyway" by name.
|
||||
*/
|
||||
ignoreTrekRange: parseBool(raw.TREK_PLUGINS_IGNORE_TREK_RANGE) === true,
|
||||
dir: raw.TREK_PLUGINS_DIR,
|
||||
dataDir: raw.TREK_PLUGINS_DATA_DIR,
|
||||
/** Permission jail is default-on: only an explicit falsy value turns it off. */
|
||||
|
||||
@@ -150,6 +150,7 @@ export const envSchema = z.object({
|
||||
TREK_API_DOCS_ENABLED: boolStr,
|
||||
TREK_PLUGINS_ENABLED: boolStr,
|
||||
TREK_PLUGINS_DEV_LINK: boolStr,
|
||||
TREK_PLUGINS_IGNORE_TREK_RANGE: boolStr,
|
||||
TREK_PLUGINS_DIR: anyString,
|
||||
TREK_PLUGINS_DATA_DIR: anyString,
|
||||
TREK_PLUGIN_PERMISSIONS: boolStr,
|
||||
|
||||
@@ -105,3 +105,46 @@ export function hostSatisfies(range: string | null | undefined, host: string | n
|
||||
if (!isValidTrekRange(range)) return false;
|
||||
return semver.satisfies(release, range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether TREK_PLUGINS_IGNORE_TREK_RANGE is set: the operator's explicit "install it
|
||||
* anyway" for a plugin whose author has not updated its `trek` range. While it is on,
|
||||
* every TREK-version gate (registry picker, post-extraction re-check, sideload, dev-link,
|
||||
* activation) WARNS instead of refusing. Nothing else changes — {@link hostSatisfies}
|
||||
* keeps telling the truth, so the admin UI can still say the plugin is running outside
|
||||
* the range its author vouched for. Read live, like the other plugin switches.
|
||||
*/
|
||||
export function trekRangeBypassed(): boolean {
|
||||
return readEnv().plugins.ignoreTrekRange;
|
||||
}
|
||||
|
||||
/** What a bypassed gate reports back to the admin: the range that was ignored and the host it was ignored on. */
|
||||
export interface TrekRangeBypass {
|
||||
/** The plugin's declared range, or null when it declared none at all. */
|
||||
trekRange: string | null;
|
||||
hostVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-null exactly when `range` would have been refused and only the bypass lets it
|
||||
* through: the marker that makes an install/activate response say "this worked, but
|
||||
* only because you told TREK to ignore the version check". Null for a normal install
|
||||
* (the range fits) and null when the bypass is off (the gate refuses instead).
|
||||
*/
|
||||
export function bypassedRange(range: string | null | undefined): TrekRangeBypass | null {
|
||||
if (hostSatisfies(range)) return null;
|
||||
if (!trekRangeBypassed()) return null;
|
||||
return { trekRange: range ?? null, hostVersion: hostVersion() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one log line every bypassed gate writes. It names the plugin, the range that was
|
||||
* ignored and the host it was ignored on, so an operator reading the log after a plugin
|
||||
* misbehaves can connect the two without the UI.
|
||||
*/
|
||||
export function warnRangeBypass(id: string, b: TrekRangeBypass): void {
|
||||
const declared = b.trekRange ? `declares TREK ${b.trekRange}` : 'declares no TREK version range';
|
||||
console.warn(
|
||||
`[plugins] ${id} ${declared} — this is TREK ${b.hostVersion}; continuing because TREK_PLUGINS_IGNORE_TREK_RANGE is set. The author has not verified it on this version: it may not work, and it could corrupt TREK data.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import semver from 'semver';
|
||||
import { isKnownPermission, PLUGIN_API_VERSION } from '../protocol/envelope';
|
||||
import { isValidTrekRange, minTrekOf } from './host-compat';
|
||||
import { isValidTrekRange, minTrekOf, trekRangeBypassed } from './host-compat';
|
||||
import {
|
||||
MCP_TOOLS_MAX,
|
||||
McpToolSchemaError,
|
||||
@@ -252,7 +252,9 @@ export function parseManifest(raw: unknown, opts?: { requireTrek?: boolean }): P
|
||||
|
||||
const trek = optStr(m.trek);
|
||||
const trekRange = isValidTrekRange(trek) ? trek : null;
|
||||
if (opts?.requireTrek && !trekRange) {
|
||||
// Under TREK_PLUGINS_IGNORE_TREK_RANGE a missing range is the same author oversight as a
|
||||
// stale one, so it is reported by the gate (as a null range) instead of refused here.
|
||||
if (opts?.requireTrek && !trekRange && !trekRangeBypassed()) {
|
||||
throw new ManifestError(
|
||||
trek
|
||||
? `invalid "trek" version range "${trek}" (expected a satisfiable semver range, e.g. ">=3.2.0 <4.0.0")`
|
||||
|
||||
@@ -26,7 +26,8 @@ import { scanForNativeBinaries } from './install/native-scan';
|
||||
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
|
||||
import { pluginCodeDir, pluginDataDir } from './paths';
|
||||
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service';
|
||||
import { hostSatisfies, hostVersion } from './install/host-compat';
|
||||
import { hostSatisfies, hostVersion, bypassedRange, warnRangeBypass } from './install/host-compat';
|
||||
import type { TrekRangeBypass } from './install/host-compat';
|
||||
import { keyFingerprint } from './signature-status';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
@@ -504,14 +505,18 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// it would not make the plugin start. It is also the gate that catches the case
|
||||
// install can't — TREK was upgraded PAST the plugin's declared upper bound, so code
|
||||
// that was legitimately installed no longer supports the host it's sitting on.
|
||||
if (!row.trek_range) {
|
||||
const bypass = bypassedRange(row.trek_range);
|
||||
if (bypass) {
|
||||
// TREK_PLUGINS_IGNORE_TREK_RANGE: the operator chose to run it anyway. Say so in the
|
||||
// log every time it starts — the plugin list carries the same marker for the UI.
|
||||
warnRangeBypass(id, bypass);
|
||||
} else if (!row.trek_range) {
|
||||
throw new PluginDependencyError(
|
||||
`plugin ${id} does not declare which TREK versions it supports`,
|
||||
'TREK_VERSION_UNKNOWN',
|
||||
{ trekRange: null, hostVersion: hostVersion() },
|
||||
);
|
||||
}
|
||||
if (!hostSatisfies(row.trek_range)) {
|
||||
} else if (!hostSatisfies(row.trek_range)) {
|
||||
throw new PluginDependencyError(
|
||||
`plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
@@ -679,7 +684,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* Install runs first so a failed download/signature/integrity check leaves the
|
||||
* currently-running child untouched (it keeps serving the old code from memory).
|
||||
*/
|
||||
async update(id: string, opts?: { version?: string; retrustKey?: string }): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
|
||||
async update(
|
||||
id: string,
|
||||
opts?: { version?: string; retrustKey?: string },
|
||||
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
const before = this.db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as
|
||||
| { enabled: number; granted_permissions: string; version: string | null }
|
||||
| undefined;
|
||||
@@ -708,11 +716,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
if (wasEnabled) await this.deactivate(id); // stop the old child now that new code is in place
|
||||
if (newGrants.length === 0 && wasEnabled) {
|
||||
await this.activate(id); // no wider rights → transparent restart on the new code
|
||||
return { version: res.version, activated: true, newPermissions, newEgress };
|
||||
return { version: res.version, activated: true, newPermissions, newEgress, trekRangeBypassed: res.trekRangeBypassed };
|
||||
}
|
||||
// New rights requested (or it was already disabled): leave it inactive until
|
||||
// an admin explicitly consents by activating it.
|
||||
return { version: res.version, activated: false, newPermissions, newEgress };
|
||||
return { version: res.version, activated: false, newPermissions, newEgress, trekRangeBypassed: res.trekRangeBypassed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -788,7 +796,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* commits it as an INACTIVE sideloaded plugin. Never auto-activates — the admin
|
||||
* re-activates (and re-consents to permissions) explicitly.
|
||||
*/
|
||||
async sideload(bytes: Buffer): Promise<{ id: string; version: string; replaced: boolean }> {
|
||||
async sideload(bytes: Buffer): Promise<{ id: string; version: string; replaced: boolean; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const staged = this.registry.stageUpload(bytes);
|
||||
try {
|
||||
@@ -800,7 +808,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// a plugin that isn't running.
|
||||
if (replaced) await this.deactivate(staged.id);
|
||||
this.registry.commitUpload(staged); // moves code + registers INACTIVE, then clears staging
|
||||
return { id: staged.id, version: staged.version, replaced };
|
||||
return { id: staged.id, version: staged.version, replaced, trekRangeBypassed: staged.trekRangeBypassed };
|
||||
} catch (e) {
|
||||
// A failure before commitUpload leaves staging behind — clean it up.
|
||||
try { fs.rmSync(staged.stagingDir, { recursive: true, force: true }); } catch {}
|
||||
@@ -816,13 +824,13 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* and starts an fs.watch that re-forks on rebuild. Gated behind TREK_PLUGINS_DEV_LINK
|
||||
* on top of the controller's admin + kill-switch gates — see dev-link.ts for why.
|
||||
*/
|
||||
async link(sourceDir: string): Promise<{ id: string; version: string; replaced: boolean }> {
|
||||
async link(sourceDir: string): Promise<{ id: string; version: string; replaced: boolean; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
if (!devLinkEnabled()) throw new Error('dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)');
|
||||
if (!path.isAbsolute(sourceDir)) throw new Error('the dev-link path must be absolute');
|
||||
const manifestPath = path.join(sourceDir, 'trek-plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json at ${sourceDir}`);
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')), { requireTrek: true });
|
||||
assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
if (!fs.existsSync(path.join(sourceDir, 'server', 'index.js'))) {
|
||||
throw new Error('no built server/index.js — build the plugin first (the loader runs the compiled artifact, not TS source)');
|
||||
}
|
||||
@@ -852,7 +860,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
WHERE id = ?`,
|
||||
).run(DEV_LINK_SOURCE, id);
|
||||
this.watchLinked(id, sourceDir);
|
||||
return { id, version: manifest.version, replaced };
|
||||
return { id, version: manifest.version, replaced, trekRangeBypassed };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,8 @@ import { safeParseConfig as safeParse } from './plugin-config-parse';
|
||||
import { isFilled, parseDefaultValue, settingDefaults } from './settings-defaults';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
|
||||
import { hostSatisfies, hostVersion } from './install/host-compat';
|
||||
import { bypassedRange, hostSatisfies, hostVersion, trekRangeBypassed } from './install/host-compat';
|
||||
import type { TrekRangeBypass } from './install/host-compat';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
import type { PluginSettingsField } from '@trek/shared';
|
||||
|
||||
@@ -90,6 +91,12 @@ export interface PluginListItem {
|
||||
trekRange: string | null;
|
||||
/** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
|
||||
hostVersion: string;
|
||||
/**
|
||||
* Non-null when the plugin is outside its declared range (or declared none) and only
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE lets it activate. The card shows it as a warning
|
||||
* rather than a blocker: the admin chose this, but must keep seeing what they chose.
|
||||
*/
|
||||
trekRangeBypassed: TrekRangeBypass | null;
|
||||
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
|
||||
dependencyIssues: { disabledAddons: string[]; missing: PluginDependency[]; versionMismatch: VersionMismatch[] };
|
||||
/**
|
||||
@@ -149,7 +156,7 @@ export class PluginsService {
|
||||
}
|
||||
}
|
||||
|
||||
list(): { enabled: boolean; devLink: boolean; plugins: PluginListItem[] } {
|
||||
list(): { enabled: boolean; devLink: boolean; ignoreTrekRange: boolean; plugins: PluginListItem[] } {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
|
||||
@@ -168,7 +175,8 @@ export class PluginsService {
|
||||
const state = resolveDependencyState(deps, installed);
|
||||
// Mirrors the order of assertActivatable's gate, so the card explains the same
|
||||
// blocker the activate call would hit rather than a second, lesser one.
|
||||
const dependencyStatus: PluginDependencyStatus = !hostSatisfies(r.trek_range)
|
||||
const trekBypass = bypassedRange(r.trek_range);
|
||||
const dependencyStatus: PluginDependencyStatus = !hostSatisfies(r.trek_range) && !trekBypass
|
||||
? 'hostIncompatible'
|
||||
: disabledAddons.length
|
||||
? 'addonDisabled'
|
||||
@@ -196,6 +204,7 @@ export class PluginsService {
|
||||
dependencyStatus,
|
||||
trekRange: trek_range,
|
||||
hostVersion: hostVersion(),
|
||||
trekRangeBypassed: trekBypass,
|
||||
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
|
||||
signed: !!author_pubkey,
|
||||
keyFingerprint: keyFingerprint(author_pubkey),
|
||||
@@ -205,7 +214,7 @@ export class PluginsService {
|
||||
updateHold: update_hold === 1,
|
||||
};
|
||||
});
|
||||
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins };
|
||||
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), ignoreTrekRange: trekRangeBypassed(), plugins };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readEnv } from '../../../app-config';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { discoverPlugins } from '../install/discovery';
|
||||
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
|
||||
import { bypassedRange, hostSatisfies, hostVersion, normalizedHost, trekRangeBypassed, warnRangeBypass } from '../install/host-compat';
|
||||
import type { TrekRangeBypass } from '../install/host-compat';
|
||||
import type { PluginDependency } from '../install/manifest';
|
||||
import { parseJsonText, parseManifest } from '../install/manifest';
|
||||
import { scanForNativeBinaries } from '../install/native-scan';
|
||||
@@ -257,7 +258,9 @@ export class PluginRegistryService {
|
||||
private hostCompat(entry: RegistryEntry): HostCompat {
|
||||
const latest = entry.versions[0] ?? null;
|
||||
const compatible = latest ? hostCompatible(latest, normalizedHost()) : false;
|
||||
const fallback = compatible ? null : this.latestCompatible(entry);
|
||||
// The verdict is the TRUTH, so it asks the strict predicate even when the picker is
|
||||
// bypassed: "latest compatible" must keep naming a version the author vouched for.
|
||||
const fallback = compatible ? null : this.latestCompatible(entry, undefined, hostCompatible);
|
||||
return {
|
||||
trek: latest?.trek ?? null,
|
||||
hostVersion: hostVersion(),
|
||||
@@ -358,12 +361,20 @@ export class PluginRegistryService {
|
||||
return v;
|
||||
}
|
||||
|
||||
/** The newest version of `entry` that satisfies `constraint` AND admits the running TREK. */
|
||||
private latestCompatible(entry: RegistryEntry, constraint?: string): RegistryVersion | null {
|
||||
/**
|
||||
* The newest version of `entry` that satisfies `constraint` AND admits the running TREK.
|
||||
* `admits` defaults to the picker's rule (which honours TREK_PLUGINS_IGNORE_TREK_RANGE);
|
||||
* the UI verdict passes the strict {@link hostCompatible} instead.
|
||||
*/
|
||||
private latestCompatible(
|
||||
entry: RegistryEntry,
|
||||
constraint?: string,
|
||||
admits: (v: RegistryVersion, host: string | null) => boolean = installable,
|
||||
): RegistryVersion | null {
|
||||
const host = normalizedHost();
|
||||
const candidates = entry.versions.filter((v) => {
|
||||
if (constraint && !semver.satisfies(v.version, constraint, { includePrerelease: true })) return false;
|
||||
return hostCompatible(v, host);
|
||||
return admits(v, host);
|
||||
});
|
||||
if (!candidates.length) return null;
|
||||
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||
@@ -380,7 +391,7 @@ export class PluginRegistryService {
|
||||
if (opts?.version) {
|
||||
const ver = entry.versions.find((v) => v.version === opts.version);
|
||||
if (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`);
|
||||
if (!hostCompatible(ver, normalizedHost())) {
|
||||
if (!installable(ver, normalizedHost())) {
|
||||
throw new RegistryError(
|
||||
`${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
@@ -419,7 +430,7 @@ export class PluginRegistryService {
|
||||
async install(
|
||||
id: string,
|
||||
opts?: { version?: string; constraint?: string; retrustKey?: string },
|
||||
): Promise<{ id: string; version: string }> {
|
||||
): Promise<{ id: string; version: string; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
const reg = await this.fetchRegistry();
|
||||
const entry = reg.plugins.find((p) => p.id === id);
|
||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
||||
@@ -459,7 +470,7 @@ export class PluginRegistryService {
|
||||
// published entries usually carry a lower bound and no upper one at all, so a
|
||||
// plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is
|
||||
// caught only here.
|
||||
assertHostCompatible(manifest.trekRange, id);
|
||||
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, id);
|
||||
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
|
||||
|
||||
// 6. atomic move into place
|
||||
@@ -487,7 +498,7 @@ export class PluginRegistryService {
|
||||
// The plugin is now on new code that passed every check — whatever refusal was
|
||||
// recorded before no longer describes reality.
|
||||
clearUpdateBlock(this.dbs.connection, id);
|
||||
return { id, version: ver.version };
|
||||
return { id, version: ver.version, trekRangeBypassed };
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
@@ -503,7 +514,8 @@ export class PluginRegistryService {
|
||||
async installWithDependencies(
|
||||
id: string,
|
||||
constraint?: string,
|
||||
): Promise<{ installed: string[]; requiredAddons: string[] }> {
|
||||
): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
let trekRangeBypassed: TrekRangeBypass | null = null;
|
||||
const installedNow = new Set(
|
||||
(this.db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id),
|
||||
);
|
||||
@@ -519,7 +531,10 @@ export class PluginRegistryService {
|
||||
// Install the version we just RESOLVED. Passing the range back (or nothing, for
|
||||
// the root) made install() re-pick on its own and land on entry.versions[0] —
|
||||
// so the compatible version resolveVersion had chosen was computed and discarded.
|
||||
await this.install(pid, { version: ver.version });
|
||||
const res = await this.install(pid, { version: ver.version });
|
||||
// The admin asked for the root; that is the bypass they are warned about (a
|
||||
// dependency's own bypass still lands in the log).
|
||||
if (pid === id) trekRangeBypassed = res.trekRangeBypassed;
|
||||
installed.push(pid);
|
||||
installedNow.add(pid);
|
||||
}
|
||||
@@ -528,7 +543,7 @@ export class PluginRegistryService {
|
||||
for (const dep of ver.pluginDependencies ?? []) await visit(dep.id, dep.version, [...stack, pid]);
|
||||
};
|
||||
await visit(id, constraint, []);
|
||||
return { installed, requiredAddons: [...requiredAddons] };
|
||||
return { installed, requiredAddons: [...requiredAddons], trekRangeBypassed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,7 +578,7 @@ export class PluginRegistryService {
|
||||
* binaries) — only the registry sha256/signature checks are absent, because a
|
||||
* sideload has no registry entry. Throws (and self-cleans staging) on failure.
|
||||
*/
|
||||
stageUpload(bytes: Buffer): { id: string; version: string; root: string; stagingDir: string } {
|
||||
stageUpload(bytes: Buffer): { id: string; version: string; root: string; stagingDir: string; trekRangeBypassed: TrekRangeBypass | null } {
|
||||
if (bytes.length > MAX_UPLOAD_BYTES) throw new RegistryError('archive exceeds the 50MB limit');
|
||||
const stagingDir = path.join(pluginsDataRoot(), '.staging', `upload-${Date.now()}`);
|
||||
try {
|
||||
@@ -573,9 +588,9 @@ export class PluginRegistryService {
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')), {
|
||||
requireTrek: true,
|
||||
});
|
||||
assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
if (scanForNativeBinaries(root).length) throw new RegistryError('artifact contains native binaries');
|
||||
return { id: manifest.id, version: manifest.version, root, stagingDir };
|
||||
return { id: manifest.id, version: manifest.version, root, stagingDir, trekRangeBypassed };
|
||||
} catch (e) {
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
throw e;
|
||||
@@ -746,13 +761,32 @@ function hostCompatible(v: RegistryVersion, host: string | null): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the registry picker may offer `v`: it fits the host, or the operator set
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE and asked for the newest published version regardless.
|
||||
* Only the PICKER uses this — the compat verdict the UI shows (hostCompat/detail) stays
|
||||
* on {@link hostCompatible}, so a bypassed install is still labelled as outside its range.
|
||||
*/
|
||||
function installable(v: RegistryVersion, host: string | null): boolean {
|
||||
return trekRangeBypassed() || hostCompatible(v, host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse an artifact whose declared TREK range doesn't admit the running host. Shared by
|
||||
* every install front door (registry, sideload, dev-link) so they all fail the same way,
|
||||
* with a code the admin UI can act on rather than prose it would have to string-match.
|
||||
*
|
||||
* Under TREK_PLUGINS_IGNORE_TREK_RANGE the refusal becomes a log warning, and the
|
||||
* returned marker is what the front door hands back so the admin is told too. Null
|
||||
* means the range fits and nothing was bypassed.
|
||||
*/
|
||||
export function assertHostCompatible(range: string | null, id: string): void {
|
||||
if (hostSatisfies(range)) return;
|
||||
export function assertHostCompatible(range: string | null, id: string): TrekRangeBypass | null {
|
||||
if (hostSatisfies(range)) return null;
|
||||
const bypass = bypassedRange(range);
|
||||
if (bypass) {
|
||||
warnRangeBypass(id, bypass);
|
||||
return bypass;
|
||||
}
|
||||
throw new RegistryError(`${id} requires TREK ${range} — this is TREK ${hostVersion()}`, 'TREK_VERSION_INCOMPATIBLE');
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,26 @@ describe('PluginRuntimeService dev-link', () => {
|
||||
await expect(runtime.link(writeSource('nativeplug', { native: true }))).rejects.toThrow(/native/);
|
||||
});
|
||||
|
||||
it('links a dir outside its TREK range when TREK_PLUGINS_IGNORE_TREK_RANGE is set, and says so', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
await expect(runtime.link(writeSource('oldplug-bypass', { trek: '>=2.0.0 <3.0.0' }))).resolves.toMatchObject({
|
||||
id: 'oldplug-bypass', trekRangeBypassed: { trekRange: '>=2.0.0 <3.0.0', hostVersion: '3.3.0' },
|
||||
});
|
||||
await expect(runtime.link(writeSource('rangeless-bypass', { trek: '' }))).resolves.toMatchObject({
|
||||
id: 'rangeless-bypass', trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' },
|
||||
});
|
||||
// A dir that fits is a plain link — no marker to alarm anyone with.
|
||||
await expect(runtime.link(writeSource('fits-bypass'))).resolves.toMatchObject({ trekRangeBypassed: null });
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
delete process.env.APP_VERSION;
|
||||
delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a local dir whose TREK range this server does not satisfy', async () => {
|
||||
// Dev-link is the third install front door, and it hands TREK code to RUN against real
|
||||
// data — the fact that the author is standing right there is not a reason to skip the
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* HTTP route works through the host→child invoke path, using its own isolated
|
||||
* db. Proves the full activate → route → deactivate loop.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -712,6 +712,36 @@ describe('TREK host-version gating', () => {
|
||||
cleanup('boot-broken');
|
||||
});
|
||||
|
||||
describe('with TREK_PLUGINS_IGNORE_TREK_RANGE set', () => {
|
||||
beforeEach(() => { process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; });
|
||||
afterEach(() => { delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE; });
|
||||
|
||||
it('activates a plugin this TREK has outgrown, warning in the log instead of refusing', async () => {
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
seedRanged('outgrown-ok', '>=3.2.0 <4.0.0');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
await rt.activate('outgrown-ok');
|
||||
expect(rt.isActive('outgrown-ok')).toBe(true);
|
||||
expect(warn.mock.calls.some((c) => /outgrown-ok.*>=3\.2\.0 <4\.0\.0.*4\.0\.0/.test(String(c[0])))).toBe(true);
|
||||
warn.mockRestore();
|
||||
await rt.deactivate('outgrown-ok');
|
||||
cleanup('outgrown-ok');
|
||||
});
|
||||
|
||||
it('activates a plugin that never declared a range', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
seedRanged('undeclared-ok', null);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
await rt.activate('undeclared-ok');
|
||||
expect(rt.isActive('undeclared-ok')).toBe(true);
|
||||
warn.mockRestore();
|
||||
await rt.deactivate('undeclared-ok');
|
||||
cleanup('undeclared-ok');
|
||||
});
|
||||
});
|
||||
|
||||
it('checks the version BEFORE permissions — an unrunnable plugin gets no consent dialog', async () => {
|
||||
// Consenting to permissions would not make it start, so offering the dialog is a lie.
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
@@ -749,6 +779,17 @@ describe('plugin-API version gating', () => {
|
||||
cleanup('future-api');
|
||||
});
|
||||
|
||||
it('still refuses a newer apiVersion when the TREK-range bypass is on — it is not a range problem', async () => {
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
try {
|
||||
seedApi('future-api-bypass', 2);
|
||||
await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('future-api-bypass')).rejects.toMatchObject({ code: 'API_VERSION_INCOMPATIBLE' });
|
||||
} finally {
|
||||
delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE;
|
||||
cleanup('future-api-bypass');
|
||||
}
|
||||
});
|
||||
|
||||
it('activates normally when apiVersion is within what this TREK supports', async () => {
|
||||
seedApi('current-api', 1);
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
|
||||
@@ -387,7 +387,7 @@ describe('PluginRegistryService', () => {
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||
|
||||
const out = await svc.install('flight-tracker');
|
||||
expect(out).toEqual({ id: 'flight-tracker', version: '1.0.0' });
|
||||
expect(out).toEqual({ id: 'flight-tracker', version: '1.0.0', trekRangeBypassed: null });
|
||||
|
||||
// moved into place + registered inactive with provenance
|
||||
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker', 'trek-plugin.json'))).toBe(true);
|
||||
@@ -496,7 +496,7 @@ describe('PluginRegistryService', () => {
|
||||
it('installs a signed plugin and pins the author key (TOFU)', async () => {
|
||||
const k = signingKey();
|
||||
stageSignedArtifact(k.pubB64, k.sign);
|
||||
await expect(svc.install('flight-tracker')).resolves.toEqual({ id: 'flight-tracker', version: '1.0.0' });
|
||||
await expect(svc.install('flight-tracker')).resolves.toEqual({ id: 'flight-tracker', version: '1.0.0', trekRangeBypassed: null });
|
||||
const row = testDb.prepare("SELECT author_pubkey FROM plugins WHERE id='flight-tracker'").get() as { author_pubkey: string };
|
||||
expect(row.author_pubkey).toBe(k.pubB64);
|
||||
});
|
||||
@@ -865,6 +865,91 @@ describe('TREK-version gating on install', () => {
|
||||
await expect(svc.install('multi')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
});
|
||||
|
||||
/**
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE: the operator's explicit "install it anyway". Every gate
|
||||
* above turns into a warning + a `trekRangeBypassed` marker in the response, so the admin
|
||||
* is told the author never vouched for this TREK. The registry's compat VERDICT stays
|
||||
* truthful — the UI needs it to say "outside its range" while still offering the button.
|
||||
*/
|
||||
describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
||||
beforeEach(() => { process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1'; });
|
||||
afterEach(() => { delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE; });
|
||||
|
||||
it('install-latest fetches the newest PUBLISHED version and reports the ignored range', async () => {
|
||||
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
||||
stageMulti('2.1.0', 0);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await expect(svc.install('multi')).resolves.toEqual({
|
||||
id: 'multi', version: '2.1.0', trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0' },
|
||||
});
|
||||
expect(warn.mock.calls.some((c) => /multi.*>=3\.4\.0 <4\.0\.0.*3\.3\.0/.test(String(c[0])))).toBe(true);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('installs an explicitly-pinned version this TREK cannot run', async () => {
|
||||
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
||||
stageMulti('2.1.0', 0);
|
||||
await expect(svc.install('multi', { version: '2.1.0' })).resolves.toMatchObject({
|
||||
version: '2.1.0', trekRangeBypassed: { trekRange: '>=3.4.0 <4.0.0', hostVersion: '3.3.0' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a version that fits is a normal install — no marker, nothing to warn about', async () => {
|
||||
process.env.APP_VERSION = '3.5.0'; stubMulti();
|
||||
stageMulti('2.1.0', 0);
|
||||
await expect(svc.install('multi')).resolves.toEqual({ id: 'multi', version: '2.1.0', trekRangeBypassed: null });
|
||||
});
|
||||
|
||||
it('browse keeps telling the truth about compatibility', async () => {
|
||||
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
||||
const [item] = await svc.browse(true);
|
||||
expect(item).toMatchObject({ compatible: false, latestCompatible: '2.0.0', hostVersion: '3.3.0' });
|
||||
});
|
||||
|
||||
it("re-gate on the ARTIFACT's range warns instead of refusing, with the artifact's range", async () => {
|
||||
process.env.APP_VERSION = '3.9.0';
|
||||
__clearRegistryCacheForTests();
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', trek: '>=3.2.0 <3.4.0' });
|
||||
const sha = createHash('sha256').update(artifact).digest('hex');
|
||||
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||
await expect(svc.install('flight-tracker')).resolves.toMatchObject({
|
||||
id: 'flight-tracker', trekRangeBypassed: { trekRange: '>=3.2.0 <3.4.0', hostVersion: '3.9.0' },
|
||||
});
|
||||
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker'))).toBe(true);
|
||||
});
|
||||
|
||||
it('installs an artifact that declares no range at all, saying so', async () => {
|
||||
process.env.APP_VERSION = '3.9.0';
|
||||
__clearRegistryCacheForTests();
|
||||
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', trek: undefined });
|
||||
const sha = createHash('sha256').update(artifact).digest('hex');
|
||||
REGISTRY.plugins[0].versions[0].sha256 = sha;
|
||||
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
|
||||
await expect(svc.install('flight-tracker')).resolves.toMatchObject({
|
||||
trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sideload: stages an incompatible or rangeless archive and reports it', () => {
|
||||
process.env.APP_VERSION = '3.9.0';
|
||||
const old = svc.stageUpload(makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: '>=3.0.0 <3.5.0' }));
|
||||
expect(old).toMatchObject({ id: 'my-upload', trekRangeBypassed: { trekRange: '>=3.0.0 <3.5.0', hostVersion: '3.9.0' } });
|
||||
fs.rmSync(old.stagingDir, { recursive: true, force: true });
|
||||
const rangeless = svc.stageUpload(makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined }));
|
||||
expect(rangeless).toMatchObject({ trekRangeBypassed: { trekRange: null, hostVersion: '3.9.0' } });
|
||||
fs.rmSync(rangeless.stagingDir, { recursive: true, force: true });
|
||||
const fits = svc.stageUpload(makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration' }));
|
||||
expect(fits.trekRangeBypassed).toBeNull();
|
||||
fs.rmSync(fits.stagingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does NOT lift the plugin-API version gate — that one is a real ABI break', () => {
|
||||
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', apiVersion: 2 });
|
||||
expect(() => svc.stageUpload(bytes)).toThrow('plugin requires plugin-API v2; this TREK supports v1');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the newest version this TREK CAN run, so the UI can offer it', async () => {
|
||||
process.env.APP_VERSION = '3.3.0'; stubMulti();
|
||||
const [item] = await svc.browse(true);
|
||||
|
||||
@@ -188,6 +188,14 @@ describe('derivePlugins', () => {
|
||||
expect(derivePlugins({}).permissionsOff).toBe(false);
|
||||
});
|
||||
|
||||
it('ignoreTrekRange is off unless explicitly truthy — the version gate must never bypass by accident', () => {
|
||||
expect(derivePlugins({}).ignoreTrekRange).toBe(false);
|
||||
expect(derivePlugins({ TREK_PLUGINS_IGNORE_TREK_RANGE: '' }).ignoreTrekRange).toBe(false);
|
||||
expect(derivePlugins({ TREK_PLUGINS_IGNORE_TREK_RANGE: 'off' }).ignoreTrekRange).toBe(false);
|
||||
expect(derivePlugins({ TREK_PLUGINS_IGNORE_TREK_RANGE: '1' }).ignoreTrekRange).toBe(true);
|
||||
expect(derivePlugins({ TREK_PLUGINS_IGNORE_TREK_RANGE: 'TRUE' }).ignoreTrekRange).toBe(true);
|
||||
});
|
||||
|
||||
it('allowPrivateEgress is a bool (supervisor normalizes it to the literal "on" for the child)', () => {
|
||||
expect(derivePlugins({ TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: 'on' }).allowPrivateEgress).toBe(true);
|
||||
expect(derivePlugins({ TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: 'true' }).allowPrivateEgress).toBe(true);
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
isValidTrekRange,
|
||||
minTrekOf,
|
||||
hostSatisfies,
|
||||
trekRangeBypassed,
|
||||
bypassedRange,
|
||||
__resetHostWarningForTests,
|
||||
} from '../../../src/nest/plugins/install/host-compat';
|
||||
|
||||
@@ -18,6 +20,7 @@ const APP_VERSION = process.env.APP_VERSION;
|
||||
afterEach(() => {
|
||||
if (APP_VERSION === undefined) delete process.env.APP_VERSION;
|
||||
else process.env.APP_VERSION = APP_VERSION;
|
||||
delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE;
|
||||
__resetHostWarningForTests();
|
||||
});
|
||||
|
||||
@@ -114,3 +117,40 @@ describe('hostSatisfies', () => {
|
||||
expect(hostVersion()).toMatch(/^\d+\.\d+\.\d+/); // package.json
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE: an operator's explicit "install it anyway" for a plugin
|
||||
* whose author has not updated its `trek` range. The gates warn instead of refusing, but
|
||||
* the truth (hostSatisfies) never changes — the UI still has to say the plugin is
|
||||
* outside its range.
|
||||
*/
|
||||
describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
||||
it('is off by default and only an explicit truthy value turns it on', () => {
|
||||
expect(trekRangeBypassed()).toBe(false);
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '0';
|
||||
expect(trekRangeBypassed()).toBe(false);
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
expect(trekRangeBypassed()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a bypassed mismatch only when the bypass is on AND the range does not admit the host', () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
expect(bypassedRange('>=4.0.0')).toBeNull(); // off → the gate refuses, nothing to report
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = 'true';
|
||||
expect(bypassedRange('>=4.0.0')).toEqual({ trekRange: '>=4.0.0', hostVersion: '3.3.0' });
|
||||
expect(bypassedRange('>=3.2.0 <4.0.0')).toBeNull(); // fits → not a bypass, a normal install
|
||||
});
|
||||
|
||||
it('covers a plugin that declared no range at all — the same author oversight', () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
expect(bypassedRange(null)).toEqual({ trekRange: null, hostVersion: '3.3.0' });
|
||||
expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0' });
|
||||
});
|
||||
|
||||
it('leaves hostSatisfies telling the truth while bypassed', () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
expect(hostSatisfies('>=4.0.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,16 +105,16 @@ describe('signature refusal codes survive to the client', () => {
|
||||
// must install exactly that one — the compat gate stays server-side in selectVersion.
|
||||
describe('POST :id/update — explicit version', () => {
|
||||
it('forwards the requested version to the runtime', async () => {
|
||||
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
|
||||
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
|
||||
|
||||
const res = await controller.update('flight-tracker', { version: '1.2.0' });
|
||||
|
||||
expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: '1.2.0' }]);
|
||||
expect(res).toEqual({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
|
||||
expect(res).toEqual({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
|
||||
});
|
||||
|
||||
it('an omitted body keeps the default newest-compatible resolution', async () => {
|
||||
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
|
||||
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
|
||||
|
||||
await controller.update('flight-tracker');
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('POST :id/update — explicit version', () => {
|
||||
// must not touch it.
|
||||
describe('update hold wiring', () => {
|
||||
it('an explicit-version update recomputes the hold as deliberate', async () => {
|
||||
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
|
||||
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
|
||||
|
||||
await controller.update('flight-tracker', { version: '1.2.0' });
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('update hold wiring', () => {
|
||||
});
|
||||
|
||||
it('a default update recomputes as non-deliberate (clears a stale hold)', async () => {
|
||||
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
|
||||
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
|
||||
|
||||
await controller.update('flight-tracker');
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('update hold wiring', () => {
|
||||
});
|
||||
|
||||
it('an explicit-version install recomputes the hold as deliberate', async () => {
|
||||
vi.mocked(registry.install).mockResolvedValue({ id: 'flight-tracker', version: '1.2.0' });
|
||||
vi.mocked(registry.install).mockResolvedValue({ id: 'flight-tracker', version: '1.2.0', trekRangeBypassed: null });
|
||||
|
||||
await controller.install({ id: 'flight-tracker', version: '1.2.0' });
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('update hold wiring', () => {
|
||||
});
|
||||
|
||||
it('a dependency-resolution install does NOT touch the hold', async () => {
|
||||
vi.mocked(registry.installWithDependencies).mockResolvedValue({ installed: ['flight-tracker'], requiredAddons: [] });
|
||||
vi.mocked(registry.installWithDependencies).mockResolvedValue({ installed: ['flight-tracker'], requiredAddons: [], trekRangeBypassed: null });
|
||||
|
||||
await controller.install({ id: 'flight-tracker', withDependencies: true });
|
||||
|
||||
|
||||
@@ -48,6 +48,47 @@ describe('PluginsService.list', () => {
|
||||
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
|
||||
});
|
||||
|
||||
describe('TREK-range bypass', () => {
|
||||
const APP_VERSION = process.env.APP_VERSION;
|
||||
afterEach(() => {
|
||||
delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE;
|
||||
if (APP_VERSION === undefined) delete process.env.APP_VERSION;
|
||||
else process.env.APP_VERSION = APP_VERSION;
|
||||
});
|
||||
const seed = () =>
|
||||
testDb
|
||||
.prepare("INSERT INTO plugins (id, name, type, status, version, trek_range) VALUES ('old','Old','widget','inactive','1.0.0','>=3.0.0 <4.0.0')")
|
||||
.run();
|
||||
|
||||
it('reports the switch off and an outgrown plugin as hostIncompatible by default', () => {
|
||||
process.env.APP_VERSION = '4.1.0';
|
||||
seed();
|
||||
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
expect(out.ignoreTrekRange).toBe(false);
|
||||
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'hostIncompatible', trekRangeBypassed: null });
|
||||
});
|
||||
|
||||
it('with the switch on, the plugin may activate but the row still says it is outside its range', () => {
|
||||
process.env.APP_VERSION = '4.1.0';
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
seed();
|
||||
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
expect(out.ignoreTrekRange).toBe(true);
|
||||
expect(out.plugins[0]).toMatchObject({
|
||||
dependencyStatus: 'ok',
|
||||
trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0' },
|
||||
});
|
||||
});
|
||||
|
||||
it('a plugin inside its range carries no marker even with the switch on', () => {
|
||||
process.env.APP_VERSION = '3.5.0';
|
||||
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
|
||||
seed();
|
||||
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
|
||||
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'ok', trekRangeBypassed: null });
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces updateHold as a boolean (held plugins leave the update banner)', () => {
|
||||
testDb
|
||||
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")
|
||||
|
||||
+24
-31
@@ -1,61 +1,54 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Scope: the **`@trek/shared`** workspace. See the repo-root `CLAUDE.md` for the monorepo picture, and `server/CLAUDE.md` / `client/CLAUDE.md` for the consumers. This package is the **single source of truth** for API contracts (Zod) and all i18n strings; both server and client import from it.
|
||||
Scope: the **`@trek/shared`** workspace — the **single source of truth** for API contracts (Zod) and all i18n strings; server and client both import from it. See the repo-root `CLAUDE.md` for the monorepo picture.
|
||||
|
||||
## Commands (run from `shared/`)
|
||||
|
||||
```bash
|
||||
npm run build # tsdown → dist/ (CJS + ESM + .d.ts). REQUIRED before server/client typecheck or run
|
||||
npm run build:watch # tsdown --watch --no-clean (the root `npm run dev` runs this)
|
||||
npm run build:watch # what the root `npm run dev` runs
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint --fix src/**/*.ts
|
||||
npm run test # vitest run
|
||||
npm run i18n:parity # audit locale drift, exits 0 (prints a per-locale report)
|
||||
npm run lint # eslint --fix (rewrites files)
|
||||
npm run test # vitest run — co-located *.spec.ts files
|
||||
npm run i18n:parity # audit locale drift, exits 0
|
||||
npm run i18n:parity:strict # CI gate — exits 1 on any drift
|
||||
```
|
||||
|
||||
Single test:
|
||||
Single test: `npx vitest run src/i18n/i18n-parity.spec.ts`, or `npx vitest run -t "rejects extra keys"`.
|
||||
|
||||
```bash
|
||||
npx vitest run src/sanitize/sanitize.spec.ts
|
||||
npx vitest run src/i18n/i18n-parity.spec.ts
|
||||
npx vitest run -t "rejects extra keys"
|
||||
```
|
||||
|
||||
**Always rebuild after changing a schema or locale** — the consumers import the compiled `dist/`, not `src/`. If a server/client typecheck fails to see a new export, you probably forgot `npm run build` here.
|
||||
**Always rebuild after changing a schema or locale** — the consumers import the compiled `dist/`, not `src/`. If a server/client typecheck can't see a new export, you forgot `npm run build` here.
|
||||
|
||||
## Build & exports
|
||||
|
||||
`tsdown.config.ts` emits **three kinds of entry**: the root barrel (`src/index.ts`), the i18n metadata barrel (`src/i18n/index.ts`), and **one entry per locale** (`src/i18n/*/index.ts`) so each locale becomes its own lazy-loadable chunk (the client dynamic-imports them). `zod` is never bundled (`deps.neverBundle`). Both CJS and ESM are emitted with `.d.ts`. The `exports` map in `package.json` exposes `@trek/shared` (contracts), `@trek/shared/i18n` (metadata/types), and `@trek/shared/i18n/<locale>` (a single locale).
|
||||
`tsdown.config.ts` emits the root barrel, the i18n metadata barrel, and **one entry per locale** so each locale is its own lazy-loadable chunk (the client dynamic-imports them). `zod` is never bundled. The `exports` map exposes `@trek/shared` (contracts), `@trek/shared/i18n` (metadata/types) and `@trek/shared/i18n/<locale>`.
|
||||
|
||||
## Contracts (`src/<domain>/<domain>.schema.ts`)
|
||||
|
||||
One folder per domain; each exports Zod schemas plus their inferred types (`export type X = z.infer<typeof xSchema>`). The root barrel `src/index.ts` re-exports every domain. Domain-agnostic primitives live in `src/common/` (`primitives.schema.ts`, `pagination.schema.ts`).
|
||||
One folder per domain exporting Zod schemas plus inferred types (`export type X = z.infer<typeof xSchema>`), re-exported from the root barrel. Domain-agnostic primitives (`idSchema`, `idParamSchema`, `nonEmptyString`, pagination) live in `src/common/`. A few pure isomorphic helpers live beside the schemas because both sides need the same answer; they follow the same rules as everything here.
|
||||
|
||||
**`src/plugin-permissions.ts` is machine-generated** (by `server/scripts/gen-plugin-facts.ts` from the server's plugin protocol) — never hand-edit it; regenerate with `npm run gen:plugin-facts` from `server/`, where `check:plugin-facts` is the CI drift gate.
|
||||
**`src/plugin-permissions.ts` is machine-generated** from the server's plugin protocol — never hand-edit it; `npm run gen:plugin-facts` from `server/` regenerates it and `check:plugin-facts` is the CI drift gate.
|
||||
|
||||
A route is considered "migrated" only once its contract lives here and **both** sides import the inferred types. Key convention seen across schemas (e.g. `weather/weather.schema.ts`): **schemas mirror the exact legacy wire behavior** — strings stay strings if the legacy route never coerced them, optional fields reflect partial response subsets, and **bespoke 4xx error strings are reproduced in the server controller, not derived from the schema**, so the error body stays byte-identical. Don't "tidy up" a schema to be stricter than the contract it documents.
|
||||
**Schemas mirror the exact wire behavior of existing routes** (`weather/weather.schema.ts` is the example): strings stay strings if the route never coerced them, optional fields reflect partial response subsets, and bespoke 4xx error strings are reproduced in the server controller, not derived from the schema. Don't "tidy up" a schema to be stricter than the contract it documents.
|
||||
|
||||
## Direction (from the 2026 audit)
|
||||
## Rules for new contracts
|
||||
|
||||
The parity rule above governs **existing** routes; it is not a license to mint new debt. For anything new:
|
||||
The parity rule governs **existing** routes; it is not a license to mint new debt:
|
||||
|
||||
- **The contract describes the wire, not the storage engine.** New boolean fields are `z.boolean()` — convert SQLite 0/1 at the service boundary, never add a new `z.union([z.boolean(), z.number()])`. New id fields use `idSchema`/`idParamSchema`, never `number | string` unions. A field's request and response types must agree (a write→read round trip must not change a type).
|
||||
- **Use the shared primitives** in `src/common/` (`idSchema`, `nonEmptyString`, pagination, …) instead of re-declaring bare `z.number()`/`z.string()` per domain. Prefer narrowing an existing open object over adding a new `z.record(...)`/passthrough body — "any object" gives zero drift protection.
|
||||
- **The contract describes the wire, not the storage engine.** New booleans are `z.boolean()` — convert SQLite 0/1 at the service boundary, never add a `z.union([z.boolean(), z.number()])`. New ids use `idSchema`/`idParamSchema`, never `number | string`. Request and response types for a field must agree.
|
||||
- **Use the shared primitives** instead of re-declaring bare `z.number()`/`z.string()` per domain. Narrow an existing open object rather than adding a new `z.record(...)`/passthrough body — "any object" gives zero drift protection.
|
||||
- **Add a schema, add a spec** — especially for lenient parsers fed by untrusted or LLM input.
|
||||
- **This package stays lean and isomorphic**: zero imports from client/server, no `node:` APIs in `src/`, and effectively no new runtime dependencies (long-term the contract layer approaches `zod`-only; i18n is a separable concern with its own release cadence — don't couple new contract code to it).
|
||||
- **Never hand-copy a locale list.** `SUPPORTED_LANGUAGES` in `languages.ts` is the one registry — derive everything (barrels, loaders, spec maps) from it; adding a locale should be one edit, not four.
|
||||
- **`shared` is the monorepo's strict-TypeScript template** (`strict` + `noUncheckedIndexedAccess`). Hold that line: no new `any`, no new suppressions.
|
||||
- **Stay lean and isomorphic**: zero imports from client/server, no `node:` APIs, effectively no new runtime dependencies (the contract layer approaches `zod`-only; i18n is a separable concern — don't couple new contract code to it).
|
||||
- **Never hand-copy a locale list.** `SUPPORTED_LANGUAGES` in `src/i18n/languages.ts` is the one registry — derive barrels, loaders and spec maps from it.
|
||||
- **This is the monorepo's strict-TypeScript template** (`strict` + `noUncheckedIndexedAccess`). No new `any`, no new suppressions.
|
||||
|
||||
## i18n (`src/i18n/`)
|
||||
|
||||
- **`languages.ts`** — `SUPPORTED_LANGUAGES` (the `{ value, label, locale }` tuples) is the canonical language registry and the source of `SupportedLanguageCode`. Adding a language starts here.
|
||||
- **`<locale>/`** — one folder per language, with one file per UI domain (`common.ts`, `admin.ts`, `budget.ts`, …) plus an `index.ts` barrel. Every file exports a flat map of **dot-namespaced string keys** typed as `TranslationStrings` (e.g. `'common.save': 'Save'`). The runtime `t(key)` only resolves these top-level keys.
|
||||
- **`en/` is canonical.** Every other locale must have the **identical file set and identical top-level keys** — this is enforced by `i18n:parity:strict` (and `i18n-parity.spec.ts`) and is a CI gate (`chore: enforce i18n parity`). When you add or rename a key, update **every** locale, or parity fails.
|
||||
- `types.ts` defines `TranslationStrings`; `index.ts` re-exports types + language metadata (not the strings themselves — those load per-locale).
|
||||
- **`languages.ts`** — `SUPPORTED_LANGUAGES` is the canonical registry and the source of `SupportedLanguageCode`. Adding a language starts here.
|
||||
- **`<locale>/`** — one folder per language, one file per UI domain plus an `index.ts` barrel; each file exports a flat map of dot-namespaced keys typed as `TranslationStrings`. The runtime `t(key)` only resolves these top-level keys.
|
||||
- **`en/` is canonical.** Every other locale must have the identical file set and top-level keys — `i18n:parity:strict` enforces it in CI. When you add or rename a key, update every locale.
|
||||
- **Every locale gets a real translation — an English placeholder is not acceptable.** Parity only checks that the key exists, so copying the `en` string passes CI while shipping English to those users. Write native phrasing with the locale's own punctuation and the same `{placeholders}` as `en`; if you genuinely cannot translate one, say so instead of filling it with English.
|
||||
- **Further i18n specs run in `npm test`** and fail changes that parity lets through: placeholder parity (every `{placeholder}` in an `en` string must appear in each translation), no `en` string may call TREK "self-hosted" (the same build runs on managed installs; exemptions live in the spec), and a wording regression test for plugin permission descriptions (currently one string — add a case there when a permission's real behavior changes). None of these are part of `i18n:parity`, which checks key sets only.
|
||||
|
||||
## Sanitization (`src/sanitize/sanitize.ts`)
|
||||
|
||||
`isomorphic-dompurify` (same code in browser and Node, tree-shakes so the client bundle doesn't pull jsdom). Minimal inline-only allow-list today; it guards the few surfaces that interpolate user strings into markup (currently the Journey banner) and is the designated home if rich-text/Markdown ships later.
|
||||
`isomorphic-dompurify` with a minimal inline-only allow-list (a rich-text variant already exists there for when rich text ships). `sanitizeInlineHtml` backs the client's HTML translation path; `escapeHtml` is used wherever the client builds markup from user strings (map popups, account screens). This is meant to be the one home for escaping. No lint rule enforces that yet and a handful of client files still carry a local `escapeHtml` — that is debt to fold in, not a pattern to copy.
|
||||
|
||||
@@ -502,6 +502,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'يتطلب TREK {range} — يعمل هذا الخادم بالإصدار {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'لا يحدد إصدارات TREK المدعومة',
|
||||
'admin.plugins.installCompatible': 'تثبيت {version}',
|
||||
'admin.plugins.installAnyway': 'التثبيت على أي حال',
|
||||
'admin.plugins.rangeBypass.pill': 'فحوصات الإصدار متوقفة',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'تم ضبط TREK_PLUGINS_IGNORE_TREK_RANGE — يمكن تثبيت الإضافات وتشغيلها خارج إصدارات TREK التي أعلنها مطوروها',
|
||||
'admin.plugins.rangeBypass.title': 'خارج إصدارات TREK المدعومة',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'تم التثبيت خارج إصدارات TREK المدعومة',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'تعلن «{name}» دعم TREK {range}، بينما يعمل هذا الخادم بالإصدار {host}. يسمح TREK بذلك فقط لأن TREK_PLUGINS_IGNORE_TREK_RANGE مضبوط. لم يحدّث المطوّر نطاق إصدارات الإضافة لهذا الإصدار من TREK، لذا لا يوجد ضمان أنها ستعمل — وفي حالات نادرة قد تُتلف إضافة غير متوافقة بيانات TREK. تابع فقط إذا كنت تقبل هذه المخاطرة.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'لا تعلن «{name}» أي إصدارات TREK تدعمها؛ يعمل هذا الخادم بالإصدار {host}. يسمح TREK بذلك فقط لأن TREK_PLUGINS_IGNORE_TREK_RANGE مضبوط. لا شيء يدل على أن المطوّر اختبرها على هذا الإصدار من TREK، لذا لا يوجد ضمان أنها ستعمل — وفي حالات نادرة قد تُتلف إضافة غير متوافقة بيانات TREK. تابع فقط إذا كنت تقبل هذه المخاطرة.',
|
||||
'admin.plugins.dep.trekBypassed': 'خارج نطاق TREK الخاص بها ({range}) — فحوصات الإصدار متوقفة',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'لا تعلن أي نطاق TREK — فحوصات الإصدار متوقفة',
|
||||
'admin.plugins.incompatible': 'غير متوافق',
|
||||
'admin.plugins.accessTitle': 'ما الذي يمكنها الوصول إليه',
|
||||
'admin.plugins.connectsTitle': 'يتصل بـ',
|
||||
|
||||
@@ -491,6 +491,20 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Requer TREK {range} — este servidor executa {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Não informa quais versões do TREK são compatíveis',
|
||||
'admin.plugins.installCompatible': 'Instalar {version}',
|
||||
'admin.plugins.installAnyway': 'Instalar mesmo assim',
|
||||
'admin.plugins.rangeBypass.pill': 'Verificações de versão desligadas',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE está definido — plugins podem ser instalados e executados fora das versões do TREK declaradas por seus autores',
|
||||
'admin.plugins.rangeBypass.title': 'Fora das versões do TREK suportadas',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Instalado fora das versões do TREK suportadas',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'“{name}” declara suporte ao TREK {range}, e este servidor executa {host}. O TREK só o deixa passar porque TREK_PLUGINS_IGNORE_TREK_RANGE está definido. O autor não atualizou o intervalo de versões do plugin para este TREK, então não há garantia de que funcione — e, em casos raros, um plugin incompatível pode corromper os dados do TREK. Continue apenas se aceitar esse risco.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'“{name}” não declara quais versões do TREK suporta; este servidor executa {host}. O TREK só o deixa passar porque TREK_PLUGINS_IGNORE_TREK_RANGE está definido. Nada indica que o autor o tenha testado neste TREK, então não há garantia de que funcione — e, em casos raros, um plugin incompatível pode corromper os dados do TREK. Continue apenas se aceitar esse risco.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Fora do seu intervalo TREK ({range}) — verificações de versão desligadas',
|
||||
'admin.plugins.dep.trekBypassedUnknown':
|
||||
'Não declara nenhum intervalo TREK — verificações de versão desligadas',
|
||||
'admin.plugins.incompatible': 'Incompatível',
|
||||
'admin.plugins.accessTitle': 'O que ele pode acessar',
|
||||
'admin.plugins.connectsTitle': 'Conecta-se a',
|
||||
|
||||
@@ -626,6 +626,20 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Necessita TREK {range} — aquest servidor executa {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'No indica quines versions de TREK admet',
|
||||
'admin.plugins.installCompatible': 'Instal·la {version}',
|
||||
'admin.plugins.installAnyway': 'Instal·la igualment',
|
||||
'admin.plugins.rangeBypass.pill': 'Comprovacions de versió desactivades',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE està definit — els connectors es poden instal·lar i executar fora de les versions de TREK declarades pels seus autors',
|
||||
'admin.plugins.rangeBypass.title': 'Fora de les versions de TREK compatibles',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Instal·lat fora de les versions de TREK compatibles',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'«{name}» declara compatibilitat amb TREK {range}, i aquest servidor executa {host}. TREK el deixa passar només perquè TREK_PLUGINS_IGNORE_TREK_RANGE està definit. L’autor no ha actualitzat el rang de versions del connector per a aquest TREK, així que no hi ha cap garantia que funcioni — i en casos rars un connector incompatible pot corrompre les dades de TREK. Continua només si acceptes aquest risc.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'«{name}» no declara quines versions de TREK admet; aquest servidor executa {host}. TREK el deixa passar només perquè TREK_PLUGINS_IGNORE_TREK_RANGE està definit. Res no indica que l’autor l’hagi provat en aquest TREK, així que no hi ha cap garantia que funcioni — i en casos rars un connector incompatible pot corrompre les dades de TREK. Continua només si acceptes aquest risc.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Fora del seu rang de TREK ({range}) — comprovacions de versió desactivades',
|
||||
'admin.plugins.dep.trekBypassedUnknown':
|
||||
'No declara cap rang de TREK — comprovacions de versió desactivades',
|
||||
'admin.plugins.incompatible': 'Incompatible',
|
||||
'admin.plugins.accessTitle': 'A què pot accedir',
|
||||
'admin.plugins.connectsTitle': 'Es connecta a',
|
||||
|
||||
@@ -489,6 +489,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Vyžaduje TREK {range} — tento server běží na {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Neuvádí, které verze TREK podporuje',
|
||||
'admin.plugins.installCompatible': 'Nainstalovat {version}',
|
||||
'admin.plugins.installAnyway': 'Přesto nainstalovat',
|
||||
'admin.plugins.rangeBypass.pill': 'Kontroly verzí vypnuty',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'Je nastaveno TREK_PLUGINS_IGNORE_TREK_RANGE — pluginy se mohou instalovat a spouštět mimo verze TREKu, které jejich autoři deklarovali',
|
||||
'admin.plugins.rangeBypass.title': 'Mimo podporované verze TREKu',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Nainstalováno mimo podporované verze TREKu',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'„{name}“ deklaruje podporu TREKu {range} a tento server běží na {host}. TREK ho propouští jen proto, že je nastaveno TREK_PLUGINS_IGNORE_TREK_RANGE. Autor neaktualizoval rozsah verzí pluginu pro tento TREK, takže není zaručeno, že bude fungovat — a ve vzácných případech může nekompatibilní plugin poškodit data TREKu. Pokračujte jen tehdy, pokud toto riziko přijímáte.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'„{name}“ neuvádí, které verze TREKu podporuje; tento server běží na {host}. TREK ho propouští jen proto, že je nastaveno TREK_PLUGINS_IGNORE_TREK_RANGE. Nic nenasvědčuje tomu, že ho autor na tomto TREKu testoval, takže není zaručeno, že bude fungovat — a ve vzácných případech může nekompatibilní plugin poškodit data TREKu. Pokračujte jen tehdy, pokud toto riziko přijímáte.',
|
||||
'admin.plugins.dep.trekBypassed': 'Mimo svůj rozsah TREKu ({range}) — kontroly verzí vypnuty',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Neuvádí žádný rozsah TREKu — kontroly verzí vypnuty',
|
||||
'admin.plugins.incompatible': 'Nekompatibilní',
|
||||
'admin.plugins.accessTitle': 'K čemu má přístup',
|
||||
'admin.plugins.connectsTitle': 'Připojuje se k',
|
||||
|
||||
@@ -498,6 +498,19 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Benötigt TREK {range} — auf diesem Server läuft {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Gibt nicht an, welche TREK-Versionen unterstützt werden',
|
||||
'admin.plugins.installCompatible': '{version} installieren',
|
||||
'admin.plugins.installAnyway': 'Trotzdem installieren',
|
||||
'admin.plugins.rangeBypass.pill': 'Versionsprüfung aus',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE ist gesetzt — Plugins dürfen außerhalb der von ihren Autoren angegebenen TREK-Versionen installiert und ausgeführt werden',
|
||||
'admin.plugins.rangeBypass.title': 'Außerhalb der unterstützten TREK-Versionen',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Außerhalb der unterstützten TREK-Versionen installiert',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'„{name}“ gibt Unterstützung für TREK {range} an, dieser Server läuft mit {host}. TREK lässt es nur durch, weil TREK_PLUGINS_IGNORE_TREK_RANGE gesetzt ist. Der Autor hat den Versionsbereich des Plugins für dieses TREK nicht aktualisiert, es gibt also keine Garantie, dass es funktioniert — und in seltenen Fällen kann ein unpassendes Plugin TREK-Daten beschädigen. Fahre nur fort, wenn du dieses Risiko akzeptierst.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'„{name}“ gibt nicht an, welche TREK-Versionen es unterstützt; dieser Server läuft mit {host}. TREK lässt es nur durch, weil TREK_PLUGINS_IGNORE_TREK_RANGE gesetzt ist. Nichts belegt, dass der Autor es auf diesem TREK getestet hat, es gibt also keine Garantie, dass es funktioniert — und in seltenen Fällen kann ein unpassendes Plugin TREK-Daten beschädigen. Fahre nur fort, wenn du dieses Risiko akzeptierst.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Außerhalb seines TREK-Bereichs ({range}) — Versionsprüfung aus',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Gibt keinen TREK-Bereich an — Versionsprüfung aus',
|
||||
'admin.plugins.incompatible': 'Nicht kompatibel',
|
||||
'admin.plugins.accessTitle': 'Worauf es zugreift',
|
||||
'admin.plugins.connectsTitle': 'Verbindet sich mit',
|
||||
|
||||
@@ -536,6 +536,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Needs TREK {range} — this server runs {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Does not say which TREK versions it supports',
|
||||
'admin.plugins.installCompatible': 'Install {version}',
|
||||
'admin.plugins.installAnyway': 'Install anyway',
|
||||
'admin.plugins.rangeBypass.pill': 'Version checks off',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE is set — plugins may install and run outside the TREK versions their authors declared',
|
||||
'admin.plugins.rangeBypass.title': 'Outside its supported TREK versions',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Installed outside its supported TREK versions',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'“{name}” declares support for TREK {range}, and this server runs {host}. TREK lets it through only because TREK_PLUGINS_IGNORE_TREK_RANGE is set. Its author has not updated the plugin’s version range for this TREK, so there is no guarantee it works — and in rare cases a mismatched plugin can corrupt TREK data. Continue only if you accept that risk.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'“{name}” does not declare which TREK versions it supports; this server runs {host}. TREK lets it through only because TREK_PLUGINS_IGNORE_TREK_RANGE is set. Nothing says its author tested it on this TREK, so there is no guarantee it works — and in rare cases a mismatched plugin can corrupt TREK data. Continue only if you accept that risk.',
|
||||
'admin.plugins.dep.trekBypassed': 'Outside its TREK range ({range}) — version checks off',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Declares no TREK range — version checks off',
|
||||
'admin.plugins.incompatible': 'Incompatible',
|
||||
'admin.plugins.accessTitle': 'What it can access',
|
||||
'admin.plugins.connectsTitle': 'Connects to',
|
||||
|
||||
@@ -500,6 +500,20 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Necesita TREK {range} — este servidor ejecuta {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'No indica qué versiones de TREK admite',
|
||||
'admin.plugins.installCompatible': 'Instalar {version}',
|
||||
'admin.plugins.installAnyway': 'Instalar de todos modos',
|
||||
'admin.plugins.rangeBypass.pill': 'Comprobaciones de versión desactivadas',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE está definido — los plugins pueden instalarse y ejecutarse fuera de las versiones de TREK declaradas por sus autores',
|
||||
'admin.plugins.rangeBypass.title': 'Fuera de las versiones de TREK compatibles',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Instalado fuera de las versiones de TREK compatibles',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'«{name}» declara compatibilidad con TREK {range}, y este servidor ejecuta {host}. TREK lo deja pasar solo porque TREK_PLUGINS_IGNORE_TREK_RANGE está definido. Su autor no ha actualizado el rango de versiones del plugin para este TREK, así que no hay garantía de que funcione — y en casos raros un plugin incompatible puede corromper los datos de TREK. Continúa solo si aceptas ese riesgo.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'«{name}» no declara qué versiones de TREK admite; este servidor ejecuta {host}. TREK lo deja pasar solo porque TREK_PLUGINS_IGNORE_TREK_RANGE está definido. Nada indica que su autor lo haya probado en este TREK, así que no hay garantía de que funcione — y en casos raros un plugin incompatible puede corromper los datos de TREK. Continúa solo si aceptas ese riesgo.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Fuera de su rango de TREK ({range}) — comprobaciones de versión desactivadas',
|
||||
'admin.plugins.dep.trekBypassedUnknown':
|
||||
'No declara ningún rango de TREK — comprobaciones de versión desactivadas',
|
||||
'admin.plugins.incompatible': 'Incompatible',
|
||||
'admin.plugins.accessTitle': 'A qué puede acceder',
|
||||
'admin.plugins.connectsTitle': 'Se conecta a',
|
||||
|
||||
@@ -503,6 +503,20 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Nécessite TREK {range} — ce serveur exécute {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'N’indique pas quelles versions de TREK sont prises en charge',
|
||||
'admin.plugins.installCompatible': 'Installer {version}',
|
||||
'admin.plugins.installAnyway': 'Installer quand même',
|
||||
'admin.plugins.rangeBypass.pill': 'Contrôles de version désactivés',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE est défini — les plugins peuvent s’installer et s’exécuter en dehors des versions de TREK déclarées par leurs auteurs',
|
||||
'admin.plugins.rangeBypass.title': 'Hors des versions de TREK prises en charge',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Installé hors des versions de TREK prises en charge',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'« {name} » déclare prendre en charge TREK {range}, et ce serveur exécute {host}. TREK le laisse passer uniquement parce que TREK_PLUGINS_IGNORE_TREK_RANGE est défini. Son auteur n’a pas mis à jour la plage de versions du plugin pour ce TREK : rien ne garantit qu’il fonctionne — et dans de rares cas, un plugin incompatible peut corrompre les données de TREK. Ne continuez que si vous acceptez ce risque.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'« {name} » n’indique pas quelles versions de TREK il prend en charge ; ce serveur exécute {host}. TREK le laisse passer uniquement parce que TREK_PLUGINS_IGNORE_TREK_RANGE est défini. Rien n’indique que son auteur l’a testé sur ce TREK : rien ne garantit qu’il fonctionne — et dans de rares cas, un plugin incompatible peut corrompre les données de TREK. Ne continuez que si vous acceptez ce risque.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Hors de sa plage TREK ({range}) — contrôles de version désactivés',
|
||||
'admin.plugins.dep.trekBypassedUnknown':
|
||||
'Ne déclare aucune plage TREK — contrôles de version désactivés',
|
||||
'admin.plugins.incompatible': 'Incompatible',
|
||||
'admin.plugins.accessTitle': 'Ce à quoi il peut accéder',
|
||||
'admin.plugins.connectsTitle': 'Se connecte à',
|
||||
|
||||
@@ -551,6 +551,19 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Απαιτεί TREK {range} — αυτός ο διακομιστής εκτελεί {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Δεν δηλώνει ποιες εκδόσεις TREK υποστηρίζει',
|
||||
'admin.plugins.installCompatible': 'Εγκατάσταση {version}',
|
||||
'admin.plugins.installAnyway': 'Εγκατάσταση ούτως ή άλλως',
|
||||
'admin.plugins.rangeBypass.pill': 'Έλεγχοι έκδοσης απενεργοποιημένοι',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'Το TREK_PLUGINS_IGNORE_TREK_RANGE είναι ορισμένο — τα πρόσθετα μπορούν να εγκατασταθούν και να εκτελεστούν εκτός των εκδόσεων TREK που δηλώνουν οι δημιουργοί τους',
|
||||
'admin.plugins.rangeBypass.title': 'Εκτός των υποστηριζόμενων εκδόσεων TREK',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Εγκαταστάθηκε εκτός των υποστηριζόμενων εκδόσεων TREK',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'Το «{name}» δηλώνει υποστήριξη για TREK {range}, ενώ αυτός ο διακομιστής εκτελεί {host}. Το TREK το επιτρέπει μόνο επειδή είναι ορισμένο το TREK_PLUGINS_IGNORE_TREK_RANGE. Ο δημιουργός δεν έχει ενημερώσει το εύρος εκδόσεων του πρόσθετου για αυτό το TREK, οπότε δεν υπάρχει εγγύηση ότι λειτουργεί — και σε σπάνιες περιπτώσεις ένα ασύμβατο πρόσθετο μπορεί να καταστρέψει δεδομένα του TREK. Συνεχίστε μόνο αν αποδέχεστε αυτόν τον κίνδυνο.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'Το «{name}» δεν δηλώνει ποιες εκδόσεις TREK υποστηρίζει· αυτός ο διακομιστής εκτελεί {host}. Το TREK το επιτρέπει μόνο επειδή είναι ορισμένο το TREK_PLUGINS_IGNORE_TREK_RANGE. Τίποτα δεν δείχνει ότι ο δημιουργός το δοκίμασε σε αυτό το TREK, οπότε δεν υπάρχει εγγύηση ότι λειτουργεί — και σε σπάνιες περιπτώσεις ένα ασύμβατο πρόσθετο μπορεί να καταστρέψει δεδομένα του TREK. Συνεχίστε μόνο αν αποδέχεστε αυτόν τον κίνδυνο.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Εκτός του εύρους TREK του ({range}) — έλεγχοι έκδοσης απενεργοποιημένοι',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Δεν δηλώνει εύρος TREK — έλεγχοι έκδοσης απενεργοποιημένοι',
|
||||
'admin.plugins.incompatible': 'Μη συμβατό',
|
||||
'admin.plugins.accessTitle': 'Σε τι έχει πρόσβαση',
|
||||
'admin.plugins.connectsTitle': 'Συνδέεται σε',
|
||||
|
||||
@@ -503,6 +503,19 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'TREK {range} szükséges — ez a kiszolgáló {host} verziót futtat',
|
||||
'admin.plugins.dep.trekUnknown': 'Nem adja meg, mely TREK-verziókat támogatja',
|
||||
'admin.plugins.installCompatible': '{version} telepítése',
|
||||
'admin.plugins.installAnyway': 'Telepítés mindenképp',
|
||||
'admin.plugins.rangeBypass.pill': 'Verzióellenőrzés kikapcsolva',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'A TREK_PLUGINS_IGNORE_TREK_RANGE be van állítva — a bővítmények a szerzőik által megadott TREK-verziókon kívül is telepíthetők és futtathatók',
|
||||
'admin.plugins.rangeBypass.title': 'A támogatott TREK-verziókon kívül',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'A támogatott TREK-verziókon kívül telepítve',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'A(z) „{name}” a TREK {range} verzióihoz készült, ez a szerver pedig {host}-t futtat. A TREK csak azért engedi át, mert a TREK_PLUGINS_IGNORE_TREK_RANGE be van állítva. A szerző nem frissítette a bővítmény verziótartományát ehhez a TREK-hez, így nincs garancia, hogy működik — és ritka esetben egy nem illeszkedő bővítmény megsértheti a TREK adatait. Csak akkor folytasd, ha vállalod ezt a kockázatot.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'A(z) „{name}” nem adja meg, mely TREK-verziókat támogatja; ez a szerver {host}-t futtat. A TREK csak azért engedi át, mert a TREK_PLUGINS_IGNORE_TREK_RANGE be van állítva. Semmi sem jelzi, hogy a szerző ezen a TREK-en tesztelte volna, így nincs garancia, hogy működik — és ritka esetben egy nem illeszkedő bővítmény megsértheti a TREK adatait. Csak akkor folytasd, ha vállalod ezt a kockázatot.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'A TREK-tartományán kívül ({range}) — verzióellenőrzés kikapcsolva',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Nem ad meg TREK-tartományt — verzióellenőrzés kikapcsolva',
|
||||
'admin.plugins.incompatible': 'Nem kompatibilis',
|
||||
'admin.plugins.accessTitle': 'Mihez fér hozzá',
|
||||
'admin.plugins.connectsTitle': 'Kapcsolódik ehhez',
|
||||
|
||||
@@ -545,6 +545,19 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Membutuhkan TREK {range} — server ini menjalankan {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Tidak menyatakan versi TREK mana yang didukung',
|
||||
'admin.plugins.installCompatible': 'Instal {version}',
|
||||
'admin.plugins.installAnyway': 'Tetap pasang',
|
||||
'admin.plugins.rangeBypass.pill': 'Pemeriksaan versi nonaktif',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE disetel — plugin dapat dipasang dan dijalankan di luar versi TREK yang dinyatakan penulisnya',
|
||||
'admin.plugins.rangeBypass.title': 'Di luar versi TREK yang didukung',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Dipasang di luar versi TREK yang didukung',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'“{name}” menyatakan dukungan untuk TREK {range}, sedangkan server ini menjalankan {host}. TREK meloloskannya hanya karena TREK_PLUGINS_IGNORE_TREK_RANGE disetel. Penulisnya belum memperbarui rentang versi plugin untuk TREK ini, jadi tidak ada jaminan plugin ini berfungsi — dan dalam kasus yang jarang, plugin yang tidak cocok dapat merusak data TREK. Lanjutkan hanya jika Anda menerima risiko itu.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'“{name}” tidak menyatakan versi TREK mana yang didukungnya; server ini menjalankan {host}. TREK meloloskannya hanya karena TREK_PLUGINS_IGNORE_TREK_RANGE disetel. Tidak ada tanda bahwa penulisnya mengujinya di TREK ini, jadi tidak ada jaminan plugin ini berfungsi — dan dalam kasus yang jarang, plugin yang tidak cocok dapat merusak data TREK. Lanjutkan hanya jika Anda menerima risiko itu.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Di luar rentang TREK-nya ({range}) — pemeriksaan versi nonaktif',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Tidak menyatakan rentang TREK — pemeriksaan versi nonaktif',
|
||||
'admin.plugins.incompatible': 'Tidak kompatibel',
|
||||
'admin.plugins.accessTitle': 'Yang bisa diaksesnya',
|
||||
'admin.plugins.connectsTitle': 'Terhubung ke',
|
||||
|
||||
@@ -498,6 +498,20 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Richiede TREK {range} — questo server esegue {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Non indica quali versioni di TREK supporta',
|
||||
'admin.plugins.installCompatible': 'Installa {version}',
|
||||
'admin.plugins.installAnyway': 'Installa comunque',
|
||||
'admin.plugins.rangeBypass.pill': 'Controlli di versione disattivati',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE è impostato — i plugin possono essere installati ed eseguiti al di fuori delle versioni di TREK dichiarate dai loro autori',
|
||||
'admin.plugins.rangeBypass.title': 'Fuori dalle versioni di TREK supportate',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Installato fuori dalle versioni di TREK supportate',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'“{name}” dichiara il supporto per TREK {range}, e questo server esegue {host}. TREK lo lascia passare solo perché TREK_PLUGINS_IGNORE_TREK_RANGE è impostato. L’autore non ha aggiornato l’intervallo di versioni del plugin per questo TREK, quindi non c’è garanzia che funzioni — e in rari casi un plugin incompatibile può corrompere i dati di TREK. Continua solo se accetti questo rischio.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'“{name}” non dichiara quali versioni di TREK supporta; questo server esegue {host}. TREK lo lascia passare solo perché TREK_PLUGINS_IGNORE_TREK_RANGE è impostato. Nulla indica che l’autore lo abbia testato su questo TREK, quindi non c’è garanzia che funzioni — e in rari casi un plugin incompatibile può corrompere i dati di TREK. Continua solo se accetti questo rischio.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Fuori dal suo intervallo TREK ({range}) — controlli di versione disattivati',
|
||||
'admin.plugins.dep.trekBypassedUnknown':
|
||||
'Non dichiara alcun intervallo TREK — controlli di versione disattivati',
|
||||
'admin.plugins.incompatible': 'Non compatibile',
|
||||
'admin.plugins.accessTitle': 'A cosa può accedere',
|
||||
'admin.plugins.connectsTitle': 'Si connette a',
|
||||
|
||||
@@ -510,6 +510,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'TREK {range} が必要です — このサーバーは {host} です',
|
||||
'admin.plugins.dep.trekUnknown': '対応する TREK バージョンが宣言されていません',
|
||||
'admin.plugins.installCompatible': '{version} をインストール',
|
||||
'admin.plugins.installAnyway': 'それでもインストール',
|
||||
'admin.plugins.rangeBypass.pill': 'バージョンチェック無効',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE が設定されています — プラグインは作者が宣言した TREK バージョンの範囲外でもインストール・実行できます',
|
||||
'admin.plugins.rangeBypass.title': 'サポート対象の TREK バージョン外',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'サポート対象の TREK バージョン外にインストールされました',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'「{name}」は TREK {range} をサポートすると宣言していますが、このサーバーは {host} で動作しています。TREK_PLUGINS_IGNORE_TREK_RANGE が設定されているため TREK はこれを許可します。作者はこの TREK 向けにプラグインのバージョン範囲を更新していないため、動作する保証はなく、まれに不適合なプラグインが TREK のデータを破損することがあります。このリスクを受け入れる場合のみ続行してください。',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'「{name}」はサポートする TREK バージョンを宣言していません。このサーバーは {host} で動作しています。TREK_PLUGINS_IGNORE_TREK_RANGE が設定されているため TREK はこれを許可します。作者がこの TREK で検証した形跡はなく、動作する保証はありません。まれに不適合なプラグインが TREK のデータを破損することがあります。このリスクを受け入れる場合のみ続行してください。',
|
||||
'admin.plugins.dep.trekBypassed': 'TREK 範囲外({range})— バージョンチェック無効',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'TREK 範囲の宣言なし — バージョンチェック無効',
|
||||
'admin.plugins.incompatible': '非対応',
|
||||
'admin.plugins.accessTitle': 'アクセスできるもの',
|
||||
'admin.plugins.connectsTitle': '接続先',
|
||||
|
||||
@@ -510,6 +510,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'TREK {range}이(가) 필요합니다 — 이 서버는 {host}입니다',
|
||||
'admin.plugins.dep.trekUnknown': '지원하는 TREK 버전을 명시하지 않았습니다',
|
||||
'admin.plugins.installCompatible': '{version} 설치',
|
||||
'admin.plugins.installAnyway': '그래도 설치',
|
||||
'admin.plugins.rangeBypass.pill': '버전 검사 꺼짐',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE가 설정되어 있습니다 — 플러그인이 작성자가 선언한 TREK 버전 범위 밖에서도 설치·실행될 수 있습니다',
|
||||
'admin.plugins.rangeBypass.title': '지원되는 TREK 버전 범위 밖',
|
||||
'admin.plugins.rangeBypass.noticeTitle': '지원되는 TREK 버전 범위 밖에 설치됨',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'“{name}”은(는) TREK {range} 지원을 선언했지만 이 서버는 {host}을(를) 실행 중입니다. TREK_PLUGINS_IGNORE_TREK_RANGE가 설정되어 있기 때문에만 TREK가 이를 허용합니다. 작성자가 이 TREK에 맞게 플러그인의 버전 범위를 갱신하지 않았으므로 정상 동작을 보장할 수 없으며, 드물게 호환되지 않는 플러그인이 TREK 데이터를 손상시킬 수 있습니다. 이 위험을 감수하는 경우에만 계속하세요.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'“{name}”은(는) 지원하는 TREK 버전을 선언하지 않았습니다. 이 서버는 {host}을(를) 실행 중입니다. TREK_PLUGINS_IGNORE_TREK_RANGE가 설정되어 있기 때문에만 TREK가 이를 허용합니다. 작성자가 이 TREK에서 테스트했다는 근거가 없으므로 정상 동작을 보장할 수 없으며, 드물게 호환되지 않는 플러그인이 TREK 데이터를 손상시킬 수 있습니다. 이 위험을 감수하는 경우에만 계속하세요.',
|
||||
'admin.plugins.dep.trekBypassed': 'TREK 범위 밖 ({range}) — 버전 검사 꺼짐',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'TREK 범위 선언 없음 — 버전 검사 꺼짐',
|
||||
'admin.plugins.incompatible': '호환되지 않음',
|
||||
'admin.plugins.accessTitle': '접근할 수 있는 항목',
|
||||
'admin.plugins.connectsTitle': '연결 대상',
|
||||
|
||||
@@ -497,6 +497,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Vereist TREK {range} — deze server draait {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Geeft niet aan welke TREK-versies worden ondersteund',
|
||||
'admin.plugins.installCompatible': '{version} installeren',
|
||||
'admin.plugins.installAnyway': 'Toch installeren',
|
||||
'admin.plugins.rangeBypass.pill': 'Versiecontroles uit',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE is ingesteld — plugins mogen worden geïnstalleerd en uitgevoerd buiten de TREK-versies die hun auteurs opgeven',
|
||||
'admin.plugins.rangeBypass.title': 'Buiten de ondersteunde TREK-versies',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Geïnstalleerd buiten de ondersteunde TREK-versies',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'“{name}” geeft ondersteuning op voor TREK {range}, en deze server draait {host}. TREK laat het alleen door omdat TREK_PLUGINS_IGNORE_TREK_RANGE is ingesteld. De auteur heeft het versiebereik van de plugin niet bijgewerkt voor deze TREK, dus er is geen garantie dat het werkt — en in zeldzame gevallen kan een niet-passende plugin TREK-gegevens beschadigen. Ga alleen verder als je dat risico accepteert.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'“{name}” geeft niet op welke TREK-versies het ondersteunt; deze server draait {host}. TREK laat het alleen door omdat TREK_PLUGINS_IGNORE_TREK_RANGE is ingesteld. Niets wijst erop dat de auteur het op deze TREK heeft getest, dus er is geen garantie dat het werkt — en in zeldzame gevallen kan een niet-passende plugin TREK-gegevens beschadigen. Ga alleen verder als je dat risico accepteert.',
|
||||
'admin.plugins.dep.trekBypassed': 'Buiten zijn TREK-bereik ({range}) — versiecontroles uit',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Geeft geen TREK-bereik op — versiecontroles uit',
|
||||
'admin.plugins.incompatible': 'Niet compatibel',
|
||||
'admin.plugins.accessTitle': 'Waartoe hij toegang heeft',
|
||||
'admin.plugins.connectsTitle': 'Maakt verbinding met',
|
||||
|
||||
@@ -495,6 +495,19 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Wymaga TREK {range} — ten serwer działa na {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Nie określa, które wersje TREK obsługuje',
|
||||
'admin.plugins.installCompatible': 'Zainstaluj {version}',
|
||||
'admin.plugins.installAnyway': 'Zainstaluj mimo to',
|
||||
'admin.plugins.rangeBypass.pill': 'Kontrola wersji wyłączona',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'Ustawiono TREK_PLUGINS_IGNORE_TREK_RANGE — wtyczki mogą być instalowane i uruchamiane poza wersjami TREK zadeklarowanymi przez ich autorów',
|
||||
'admin.plugins.rangeBypass.title': 'Poza obsługiwanymi wersjami TREK',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Zainstalowano poza obsługiwanymi wersjami TREK',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'„{name}” deklaruje obsługę TREK {range}, a ten serwer działa na {host}. TREK przepuszcza ją tylko dlatego, że ustawiono TREK_PLUGINS_IGNORE_TREK_RANGE. Autor nie zaktualizował zakresu wersji wtyczki dla tego TREK, więc nie ma gwarancji, że zadziała — a w rzadkich przypadkach niedopasowana wtyczka może uszkodzić dane TREK. Kontynuuj tylko, jeśli akceptujesz to ryzyko.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'„{name}” nie deklaruje, które wersje TREK obsługuje; ten serwer działa na {host}. TREK przepuszcza ją tylko dlatego, że ustawiono TREK_PLUGINS_IGNORE_TREK_RANGE. Nic nie wskazuje, że autor testował ją na tym TREK, więc nie ma gwarancji, że zadziała — a w rzadkich przypadkach niedopasowana wtyczka może uszkodzić dane TREK. Kontynuuj tylko, jeśli akceptujesz to ryzyko.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Poza swoim zakresem TREK ({range}) — kontrola wersji wyłączona',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Nie deklaruje zakresu TREK — kontrola wersji wyłączona',
|
||||
'admin.plugins.incompatible': 'Niezgodny',
|
||||
'admin.plugins.accessTitle': 'Do czego ma dostęp',
|
||||
'admin.plugins.connectsTitle': 'Łączy się z',
|
||||
|
||||
@@ -499,6 +499,19 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Требуется TREK {range} — на этом сервере {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Не указано, какие версии TREK поддерживаются',
|
||||
'admin.plugins.installCompatible': 'Установить {version}',
|
||||
'admin.plugins.installAnyway': 'Всё равно установить',
|
||||
'admin.plugins.rangeBypass.pill': 'Проверки версии отключены',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'Задано TREK_PLUGINS_IGNORE_TREK_RANGE — плагины можно устанавливать и запускать вне версий TREK, указанных их авторами',
|
||||
'admin.plugins.rangeBypass.title': 'Вне поддерживаемых версий TREK',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Установлен вне поддерживаемых версий TREK',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'«{name}» заявляет поддержку TREK {range}, а этот сервер работает на {host}. TREK пропускает его только потому, что задано TREK_PLUGINS_IGNORE_TREK_RANGE. Автор не обновил диапазон версий плагина для этого TREK, так что работоспособность не гарантируется — а в редких случаях несовместимый плагин может повредить данные TREK. Продолжайте, только если принимаете этот риск.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'«{name}» не указывает, какие версии TREK он поддерживает; этот сервер работает на {host}. TREK пропускает его только потому, что задано TREK_PLUGINS_IGNORE_TREK_RANGE. Ничто не говорит о том, что автор тестировал его на этом TREK, так что работоспособность не гарантируется — а в редких случаях несовместимый плагин может повредить данные TREK. Продолжайте, только если принимаете этот риск.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Вне своего диапазона TREK ({range}) — проверки версии отключены',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Диапазон TREK не указан — проверки версии отключены',
|
||||
'admin.plugins.incompatible': 'Несовместимо',
|
||||
'admin.plugins.accessTitle': 'К чему есть доступ',
|
||||
'admin.plugins.connectsTitle': 'Подключается к',
|
||||
|
||||
@@ -556,6 +556,19 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Kräver TREK {range} — den här servern kör {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Anger inte vilka TREK-versioner som stöds',
|
||||
'admin.plugins.installCompatible': 'Installera {version}',
|
||||
'admin.plugins.installAnyway': 'Installera ändå',
|
||||
'admin.plugins.rangeBypass.pill': 'Versionskontroller av',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE är satt — plugin kan installeras och köras utanför de TREK-versioner deras upphovspersoner angett',
|
||||
'admin.plugins.rangeBypass.title': 'Utanför de TREK-versioner som stöds',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Installerat utanför de TREK-versioner som stöds',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'”{name}” anger stöd för TREK {range}, och den här servern kör {host}. TREK släpper igenom det bara för att TREK_PLUGINS_IGNORE_TREK_RANGE är satt. Upphovspersonen har inte uppdaterat pluginets versionsintervall för den här TREK-versionen, så det finns ingen garanti att det fungerar — och i sällsynta fall kan ett plugin som inte passar skada TREK-data. Fortsätt bara om du accepterar den risken.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'”{name}” anger inte vilka TREK-versioner det stöder; den här servern kör {host}. TREK släpper igenom det bara för att TREK_PLUGINS_IGNORE_TREK_RANGE är satt. Inget tyder på att upphovspersonen har testat det på den här TREK-versionen, så det finns ingen garanti att det fungerar — och i sällsynta fall kan ett plugin som inte passar skada TREK-data. Fortsätt bara om du accepterar den risken.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Utanför sitt TREK-intervall ({range}) — versionskontroller av',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Anger inget TREK-intervall — versionskontroller av',
|
||||
'admin.plugins.incompatible': 'Inkompatibel',
|
||||
'admin.plugins.accessTitle': 'Vad det har åtkomst till',
|
||||
'admin.plugins.connectsTitle': 'Ansluter till',
|
||||
|
||||
@@ -542,6 +542,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'TREK {range} gerektiriyor — bu sunucu {host} çalıştırıyor',
|
||||
'admin.plugins.dep.trekUnknown': 'Hangi TREK sürümlerini desteklediğini belirtmiyor',
|
||||
'admin.plugins.installCompatible': '{version} sürümünü yükle',
|
||||
'admin.plugins.installAnyway': 'Yine de yükle',
|
||||
'admin.plugins.rangeBypass.pill': 'Sürüm denetimleri kapalı',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE ayarlı — eklentiler, yazarlarının belirttiği TREK sürümlerinin dışında yüklenip çalıştırılabilir',
|
||||
'admin.plugins.rangeBypass.title': 'Desteklenen TREK sürümlerinin dışında',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Desteklenen TREK sürümlerinin dışında yüklendi',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'“{name}” TREK {range} desteği bildiriyor, bu sunucu ise {host} çalıştırıyor. TREK yalnızca TREK_PLUGINS_IGNORE_TREK_RANGE ayarlı olduğu için izin veriyor. Yazarı eklentinin sürüm aralığını bu TREK için güncellemedi, dolayısıyla çalışacağının garantisi yok — ve nadir durumlarda uyumsuz bir eklenti TREK verilerini bozabilir. Yalnızca bu riski kabul ediyorsanız devam edin.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'“{name}” hangi TREK sürümlerini desteklediğini bildirmiyor; bu sunucu {host} çalıştırıyor. TREK yalnızca TREK_PLUGINS_IGNORE_TREK_RANGE ayarlı olduğu için izin veriyor. Yazarının bu TREK üzerinde test ettiğine dair bir işaret yok, dolayısıyla çalışacağının garantisi yok — ve nadir durumlarda uyumsuz bir eklenti TREK verilerini bozabilir. Yalnızca bu riski kabul ediyorsanız devam edin.',
|
||||
'admin.plugins.dep.trekBypassed': 'TREK aralığının dışında ({range}) — sürüm denetimleri kapalı',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'TREK aralığı bildirmiyor — sürüm denetimleri kapalı',
|
||||
'admin.plugins.incompatible': 'Uyumsuz',
|
||||
'admin.plugins.accessTitle': 'Neye erişebilir',
|
||||
'admin.plugins.connectsTitle': 'Şuraya bağlanır',
|
||||
|
||||
@@ -496,6 +496,19 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Потрібен TREK {range} — на цьому сервері {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Не вказано, які версії TREK підтримуються',
|
||||
'admin.plugins.installCompatible': 'Встановити {version}',
|
||||
'admin.plugins.installAnyway': 'Усе одно встановити',
|
||||
'admin.plugins.rangeBypass.pill': 'Перевірки версії вимкнено',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'Задано TREK_PLUGINS_IGNORE_TREK_RANGE — плагіни можна встановлювати та запускати поза версіями TREK, вказаними їхніми авторами',
|
||||
'admin.plugins.rangeBypass.title': 'Поза підтримуваними версіями TREK',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Встановлено поза підтримуваними версіями TREK',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'«{name}» заявляє підтримку TREK {range}, а цей сервер працює на {host}. TREK пропускає його лише тому, що задано TREK_PLUGINS_IGNORE_TREK_RANGE. Автор не оновив діапазон версій плагіна для цього TREK, тож працездатність не гарантується — а в рідкісних випадках несумісний плагін може пошкодити дані TREK. Продовжуйте, лише якщо приймаєте цей ризик.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'«{name}» не вказує, які версії TREK він підтримує; цей сервер працює на {host}. TREK пропускає його лише тому, що задано TREK_PLUGINS_IGNORE_TREK_RANGE. Ніщо не свідчить, що автор тестував його на цьому TREK, тож працездатність не гарантується — а в рідкісних випадках несумісний плагін може пошкодити дані TREK. Продовжуйте, лише якщо приймаєте цей ризик.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Поза своїм діапазоном TREK ({range}) — перевірки версії вимкнено',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Діапазон TREK не вказано — перевірки версії вимкнено',
|
||||
'admin.plugins.incompatible': 'Несумісно',
|
||||
'admin.plugins.accessTitle': 'До чого має доступ',
|
||||
'admin.plugins.connectsTitle': 'Підключається до',
|
||||
|
||||
@@ -498,6 +498,19 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': 'Cần TREK {range} — máy chủ này đang chạy {host}',
|
||||
'admin.plugins.dep.trekUnknown': 'Không nêu rõ hỗ trợ những phiên bản TREK nào',
|
||||
'admin.plugins.installCompatible': 'Cài đặt {version}',
|
||||
'admin.plugins.installAnyway': 'Vẫn cài đặt',
|
||||
'admin.plugins.rangeBypass.pill': 'Đã tắt kiểm tra phiên bản',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'TREK_PLUGINS_IGNORE_TREK_RANGE đã được đặt — plugin có thể được cài đặt và chạy ngoài các phiên bản TREK mà tác giả đã khai báo',
|
||||
'admin.plugins.rangeBypass.title': 'Ngoài các phiên bản TREK được hỗ trợ',
|
||||
'admin.plugins.rangeBypass.noticeTitle': 'Đã cài đặt ngoài các phiên bản TREK được hỗ trợ',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'“{name}” khai báo hỗ trợ TREK {range}, còn máy chủ này đang chạy {host}. TREK chỉ cho phép vì TREK_PLUGINS_IGNORE_TREK_RANGE đã được đặt. Tác giả chưa cập nhật phạm vi phiên bản của plugin cho TREK này, nên không có gì đảm bảo nó hoạt động — và trong một số ít trường hợp, plugin không tương thích có thể làm hỏng dữ liệu TREK. Chỉ tiếp tục nếu bạn chấp nhận rủi ro đó.',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'“{name}” không khai báo hỗ trợ phiên bản TREK nào; máy chủ này đang chạy {host}. TREK chỉ cho phép vì TREK_PLUGINS_IGNORE_TREK_RANGE đã được đặt. Không có dấu hiệu nào cho thấy tác giả đã thử nghiệm trên TREK này, nên không có gì đảm bảo nó hoạt động — và trong một số ít trường hợp, plugin không tương thích có thể làm hỏng dữ liệu TREK. Chỉ tiếp tục nếu bạn chấp nhận rủi ro đó.',
|
||||
'admin.plugins.dep.trekBypassed':
|
||||
'Ngoài phạm vi TREK của nó ({range}) — đã tắt kiểm tra phiên bản',
|
||||
'admin.plugins.dep.trekBypassedUnknown': 'Không khai báo phạm vi TREK — đã tắt kiểm tra phiên bản',
|
||||
'admin.plugins.incompatible': 'Không tương thích',
|
||||
'admin.plugins.accessTitle': 'Những gì có thể truy cập',
|
||||
'admin.plugins.connectsTitle': 'Kết nối tới',
|
||||
|
||||
@@ -492,6 +492,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': '需要 TREK {range}——此伺服器執行的是 {host}',
|
||||
'admin.plugins.dep.trekUnknown': '未宣告支援的 TREK 版本',
|
||||
'admin.plugins.installCompatible': '安裝 {version}',
|
||||
'admin.plugins.installAnyway': '仍然安裝',
|
||||
'admin.plugins.rangeBypass.pill': '版本檢查已關閉',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'已設定 TREK_PLUGINS_IGNORE_TREK_RANGE — 外掛可在作者宣告的 TREK 版本範圍之外安裝與執行',
|
||||
'admin.plugins.rangeBypass.title': '超出支援的 TREK 版本',
|
||||
'admin.plugins.rangeBypass.noticeTitle': '已安裝在支援的 TREK 版本之外',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'「{name}」宣告支援 TREK {range},而此伺服器執行的是 {host}。僅因為設定了 TREK_PLUGINS_IGNORE_TREK_RANGE,TREK 才放行。作者尚未為此 TREK 更新外掛的版本範圍,因此無法保證它能正常運作——在極少數情況下,不相容的外掛可能損毀 TREK 資料。只有在接受該風險時才繼續。',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'「{name}」未宣告支援哪些 TREK 版本;此伺服器執行的是 {host}。僅因為設定了 TREK_PLUGINS_IGNORE_TREK_RANGE,TREK 才放行。沒有跡象顯示作者在此 TREK 上測試過它,因此無法保證它能正常運作——在極少數情況下,不相容的外掛可能損毀 TREK 資料。只有在接受該風險時才繼續。',
|
||||
'admin.plugins.dep.trekBypassed': '超出其 TREK 範圍({range})— 版本檢查已關閉',
|
||||
'admin.plugins.dep.trekBypassedUnknown': '未宣告 TREK 範圍 — 版本檢查已關閉',
|
||||
'admin.plugins.incompatible': '不相容',
|
||||
'admin.plugins.accessTitle': '可存取的內容',
|
||||
'admin.plugins.connectsTitle': '連線至',
|
||||
|
||||
@@ -452,6 +452,18 @@ const admin: TranslationStrings = {
|
||||
'admin.plugins.dep.trekIncompatible': '需要 TREK {range}——此服务器运行的是 {host}',
|
||||
'admin.plugins.dep.trekUnknown': '未声明支持的 TREK 版本',
|
||||
'admin.plugins.installCompatible': '安装 {version}',
|
||||
'admin.plugins.installAnyway': '仍然安装',
|
||||
'admin.plugins.rangeBypass.pill': '版本检查已关闭',
|
||||
'admin.plugins.rangeBypass.pillHint':
|
||||
'已设置 TREK_PLUGINS_IGNORE_TREK_RANGE — 插件可在作者声明的 TREK 版本范围之外安装和运行',
|
||||
'admin.plugins.rangeBypass.title': '超出支持的 TREK 版本',
|
||||
'admin.plugins.rangeBypass.noticeTitle': '已安装在支持的 TREK 版本之外',
|
||||
'admin.plugins.rangeBypass.body':
|
||||
'“{name}”声明支持 TREK {range},而此服务器运行的是 {host}。仅因为设置了 TREK_PLUGINS_IGNORE_TREK_RANGE,TREK 才放行。作者尚未为此 TREK 更新插件的版本范围,因此无法保证它能正常工作——在极少数情况下,不匹配的插件可能损坏 TREK 数据。只有在接受该风险时才继续。',
|
||||
'admin.plugins.rangeBypass.bodyUnknown':
|
||||
'“{name}”未声明支持哪些 TREK 版本;此服务器运行的是 {host}。仅因为设置了 TREK_PLUGINS_IGNORE_TREK_RANGE,TREK 才放行。没有迹象表明作者在此 TREK 上测试过它,因此无法保证它能正常工作——在极少数情况下,不匹配的插件可能损坏 TREK 数据。只有在接受该风险时才继续。',
|
||||
'admin.plugins.dep.trekBypassed': '超出其 TREK 范围({range})— 版本检查已关闭',
|
||||
'admin.plugins.dep.trekBypassedUnknown': '未声明 TREK 范围 — 版本检查已关闭',
|
||||
'admin.plugins.incompatible': '不兼容',
|
||||
'admin.plugins.accessTitle': '可访问的内容',
|
||||
'admin.plugins.connectsTitle': '连接到',
|
||||
|
||||
@@ -62,6 +62,7 @@ Enabling can be refused for good reasons. The first three are version gates that
|
||||
|
||||
- **This TREK is outside the range the plugin declares** — the row carries an amber *Needs TREK {range} — this server runs {host}* chip and the toggle is refused. Expect this after a major upgrade: a plugin that declared `>=3.2.0 <4.0.0` stops activating on TREK 4 even though it ran fine the day before. There is no dialog to click through, it takes a release from the author that admits this TREK — and once the registry has one, the row's **Update** button offers it.
|
||||
- **The plugin does not say which TREK versions it supports** — the chip reads *Does not say which TREK versions it supports*. That is a plugin folder which predates the range field or was dropped into the plugins directory by hand: **Rescan** registers it rather than letting it vanish silently, and the gate then refuses it instead of guessing. It needs a manifest with a `trek` range.
|
||||
- **Version checks off** — the operator set `TREK_PLUGINS_IGNORE_TREK_RANGE` (see [Environment Variables](Environment-Variables)), and the header shows an amber *Version checks off* pill. The two cases above then stop being blockers: the row carries an amber *Outside its TREK range ({range}) — version checks off* (or *Declares no TREK range — version checks off*) chip for as long as the plugin runs here, but the toggle works. In Discover, an entry the registry marks incompatible gets an **Install anyway** button instead of a dead one; pressing it opens a warning first — the author has not updated the plugin's range for this TREK, nothing guarantees it works, and in rare cases a mismatched plugin can corrupt TREK data — and only **Install anyway** in that dialog sends the request. A sideload, dev-link, update or dependency download that lands outside its range cannot ask first, so the same warning appears right after it succeeds. The bypass never lifts the plugin-API version gate.
|
||||
- **The plugin needs a newer plugin API** — its manifest's `apiVersion` is higher than the plugin API this TREK implements (currently v1). Install, upload, and dev-link reject that up front, so this only shows for a plugin picked up from the plugins directory on disk — registered at the next restart, or when you press **Rescan**.
|
||||
- **A required addon is disabled** — a toast names the addon; turn it on in [Admin-Addons](Admin-Addons).
|
||||
- **A plugin dependency is missing or outdated** — a dialog lists each dependency with a one-click **Download** / **Update** that installs the newest compatible version and then retries.
|
||||
|
||||
@@ -387,6 +387,7 @@ The plugin system is **on by default**. The runtime and the Admin → Plugins pa
|
||||
| `TREK_PLUGIN_PERMISSIONS` | Set to any falsy value (`off`, `false`, `0`, `no` — any casing) to opt **out** of the Node.js OS-level permission sandbox for plugin child processes (not recommended). A truthy value, or leaving it unset, keeps the sandbox on; anything outside that set is rejected at boot. | `on` |
|
||||
| `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS`| Set to `on` to let a plugin's declared outbound hosts resolve to private/internal addresses (e.g. a service on your LAN). By default connections to private, loopback, link-local and metadata addresses are refused. | off (private egress blocked) |
|
||||
| `TREK_PLUGINS_DEV_LINK` | **Development only.** Set to any truthy value (`1`, `true`, `on`, `yes` — any casing) to enable *dev-link*: registering a plugin from a local build directory and hot-reloading it against a live instance's data. Dev-linked code bypasses the install-time signature/integrity checks and (under `npm run dev`) runs with the OS permission jail off, so it must never be reachable in production — absent, blank or an explicitly falsy value (`0`, `false`, `off`, `no`) keeps it off. Data access is still fully gated by the capability host. | off (disabled) |
|
||||
| `TREK_PLUGINS_IGNORE_TREK_RANGE` | Set to any truthy value (`1`, `true`, `on`, `yes` — any casing) to turn the plugin **TREK-version gate** into a warning. A plugin whose `trek` range does not admit the running TREK — or that declares no range at all — can then be installed (registry, sideload, dev-link), updated and activated, and "install latest" takes the newest published version rather than the newest compatible one. Every bypass is logged, the install response carries a `trekRangeBypassed` marker, and the admin panel shows a *Version checks off* pill, a warning dialog before/after each such install, and a persistent chip on the row. Use it only when a plugin author has not yet updated their range for your TREK: nothing guarantees the plugin works, and in rare cases a mismatched plugin can corrupt TREK data. The plugin-API version gate is **not** lifted. Absent, blank or falsy keeps the gate hard. | off (gate enforced) |
|
||||
| `TREK_PLUGIN_AI_PER_DAY` | Per-plugin **daily** cap on shared-LLM broker calls (`ai.complete` / `ai.extract`). Bounds how much a single plugin can spend on the admin's LLM quota per UTC day, independent of its granted permissions. Counts persist across restarts within the same day; set to `0` to disable the AI broker entirely. Generous by design — only bites a runaway plugin. | `200` |
|
||||
| `TREK_PLUGIN_NOTIFY_PER_DAY` | Per-plugin **daily** cap on user-notification broker calls (`notify.send`), so one plugin can't spam a user. Same UTC-day window and restart-safe counting as `TREK_PLUGIN_AI_PER_DAY`; set to `0` to disable the notify broker. | `100` |
|
||||
| `TREK_PLUGIN_RPC_PER_SEC` | Sustained rate limit (calls **per second**) for a plugin's host RPC calls (`ctx.*`) once its burst allowance is spent. Prevents a tight-loop plugin from starving the single-threaded host. | `20` |
|
||||
|
||||
@@ -1427,9 +1427,17 @@ The `trek` range is a **hard contract**, enforced in both directions:
|
||||
declared `<4.0.0` it now refuses to start. It stays installed and listed,
|
||||
switched off, with the reason shown (`TREK_VERSION_INCOMPATIBLE`; a plugin that
|
||||
declares no range at all reports `TREK_VERSION_UNKNOWN`).
|
||||
- **No admin override.** The range is the author's own statement that the plugin
|
||||
does not work there — unlike a rotated signing key, there is nothing an operator
|
||||
could verify out-of-band and wave through.
|
||||
- **One operator escape hatch, loudly.** The range is the author's own statement
|
||||
that the plugin does not work there, so there is no per-install override in the
|
||||
UI. An operator who is stuck on a plugin whose author has not yet updated its
|
||||
range can set `TREK_PLUGINS_IGNORE_TREK_RANGE=1` on the server: every gate above
|
||||
then warns instead of refusing (a missing range is tolerated too), "install
|
||||
latest" takes the newest published version, each bypass is logged, the response
|
||||
carries a `trekRangeBypassed` marker, and the admin sees a warning dialog plus a
|
||||
persistent chip on the row. Nothing guarantees the plugin works there, and a
|
||||
mismatched plugin can in rare cases corrupt TREK data — so ship the range bump
|
||||
rather than telling your users to flip the switch. The plugin-API version gate
|
||||
(`apiVersion`) is never bypassed.
|
||||
|
||||
Two behaviours follow. "Install latest" resolves to the newest version *this* TREK
|
||||
can run rather than the newest published, so shipping a 2.0.0 that needs TREK 4
|
||||
|
||||
@@ -76,6 +76,8 @@ Installed plugins run **untrusted third-party code**. TREK contains a plugin in
|
||||
- [ ] Review the **capability audit** if you grant plugins broad data access. Every host-mediated core-data read and broadcast a plugin makes is recorded at the RPC boundary against the real acting user (not a value the plugin supplies) in a per-plugin, hash-chained, tamper-evident log. Admins see it per plugin; each user can see "what have plugins done in my name?". Retention is capped per plugin (default 20 000 rows, `TREK_PLUGIN_AUDIT_MAX_ROWS`).
|
||||
|
||||
> The developer **dev-link** feature (`TREK_PLUGINS_DEV_LINK=1`) loads unsigned local code and, under `npm run dev`, runs with the OS jail off — keep it off on any instance that isn't a throwaway dev box you control. See [Plugins](Plugins) and [Plugin Permissions](Plugin-Permissions).
|
||||
>
|
||||
> Likewise leave `TREK_PLUGINS_IGNORE_TREK_RANGE` unset. It turns the plugin TREK-version gate into a warning so a plugin whose author has not updated its `trek` range can still be installed and activated — the admin panel warns at every step, but a plugin running on a TREK it was never tested against can misbehave and, in rare cases, corrupt data. Set it only for a specific plugin you need, and remove it once the author ships a release that admits your TREK.
|
||||
|
||||
## Backups
|
||||
|
||||
|
||||
Reference in New Issue
Block a user