From 3a0604b3f5ed407f575c0ea6a4cdf79824ddbc76 Mon Sep 17 00:00:00 2001 From: Maurice <61554723+mauriceboe@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:42:27 +0200 Subject: [PATCH] feat(config): put the operator-owned surfaces behind TREK_MANAGED (#1946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): put the operator-owned surfaces behind TREK_MANAGED The switch landed last release with nothing behind it. This is what it turns off, plus the four fixes that came out of walking every one of these surfaces. Nothing here changes anything for an install that does not set the flag. That is not a hope, it is what managed-inert.test.ts asserts: the mode stays off for any unparseable value, no other variable can turn it on, every settings key stays writable, and splitManagedKeys hands back the same object rather than a copy so no write path can quietly diverge on self-hosted installs only. WHAT THE MODE WITHHOLDS Fourteen routes carry @ManagedForbidden: key validation, static MCP tokens, JWT rotation, the OIDC admin pair, restore and auto-backup, plugin sideload and reload, the Ollama model list and pull, and the SMTP test send. Each names the reason it is not the instance admin's, and the reason is mandatory. Eighteen settings keys are filtered instead of refused. SMTP, the OIDC toggles and the WebAuthn pair share one handler with keys the admin does own, and the llm_* family is reachable through per-user settings, the instance defaults and the addon config alike, so a route-level refusal would be both too coarse and incomplete. A locked key is skipped and reported as managed_keys; the rest of the save goes through. Refusing the request would take the registration toggles down with the SMTP block. GET /api/auth/me/settings stops returning the three API keys in the clear. It is the only endpoint that decrypts a stored credential back to the caller, which is fine when the admin pasted it in and wrong when the operator supplied it. Two multipart routes check in the handler rather than the guard: a guard throws before the parser drains the body and the client gets an ECONNRESET instead of the 403 (PROFILE-015). They still carry the marker so the boot gate sees them. WHAT KEEPS IT FROM DRIFTING validate-managed-routes.ts refuses to boot on a marking that is not on the reviewed list, a list entry whose route lost its marking, or a marking with no reason. The third case has no equivalent in the guard gate next door and is the one that matters most: a blank reason destroys the only record of why a control was taken away, and nothing recovers that later. It found two wrong entries in my own list while I was writing this. managed-keys.test.ts asserts that every key in ADMIN_SETTINGS_KEYS and DEFAULTABLE_USER_SETTING_KEYS belongs to exactly one of two sets. A key added to either list belongs to neither until somebody decides, and that is the failure this prevents: the lists grow, the mode does not. PLACES_API_BASE AND PLACES_API_KEY The nine Places endpoints spell out googleapis.com, so an install that wants those calls to leave through something of its own has no way to say so. One variable, substituted where every call already funnels through. A second one supplies the key, and when it is set the users table is not consulted at all — a per-user key would route around whatever the endpoint counts, and those columns are readable by any admin of the instance. FOUR FIXES THAT ARE NOT ABOUT THE MODE An admin setting somebody's password now bumps password_version and clears their MCP tokens. An admin sets a password for one reason, and until now verifyJwtAndLoadUser kept accepting every cookie the intruder already had: the one action taken to lock them out was the one action that did not. The four OIDC provider calls go through the SSRF guard. Three are worse than they look — the token, userinfo and JWKS URLs come out of the discovery response, so they are chosen by the other end, not by an admin. safeFetchLlm is renamed safeFetchAdminConfigured and keeps its old name as an alias, because it is the variant that allows loopback and LAN: a self-hoster with Authentik on localhost:9000 must keep working. The token exchange follows no redirect at all, since one would hand client_secret to a second host. DELETE /api/admin/users/:id/mfa clears another account's TOTP. The passkey half has existed since passkeys landed; the TOTP half never did, which left "somebody on the trip lost their phone" with no answer short of an operator in the database. Never for the caller themselves. TREK_PLUGIN_PERMISSIONS=off refuses to boot when the install is centrally administered. A warning is right for a machine its owner chose to trust; where the person who set the flag and the people whose data is in the database are not the same person, fail closed. TEXTS Four strings lose the phrase "self-hosted" because they describe somebody else's software — an Ollama endpoint, an AirTrail instance, a service a plugin talks to — and read better without it in both modes. The one string that describes TREK itself keeps it, and the mode picks a second key instead, so self-hosters lose nothing. A grep gate in shared/ keeps the canonical locale honest, because the parity check compares the file set and the top-level keys and never looks at a value: re-wording a string is invisible to CI today, and so is putting it back. * feat(maps): supply the map credential with the instance when centrally administered MAPBOX_ACCESS_TOKEN joins PLACES_API_KEY as an operator-supplied value. SettingsService injects it when the settings are read, and picks GL as the provider if the reader is still on Leaflet, since a token is always present. The key moves from the customer set to the locked set. It is the one locked key whose value is public by design — a pk.* token has to reach the browser to be useful — so this is not about secrecy: a per-user save landing on top of the instance token would only break the map for that user. Both settings fields for it go away with it. * fix(auth): count the operator key when reporting whether Google features work has_maps_key only looked at the users table, but getMapsKey prefers the PLACES_API_KEY env over anything stored there. On an install that supplies the key that way and has an empty column, every lookup worked while the client was told there was no key and hid the search, the autocomplete and the place photos. Found by walking the running instance rather than the code: the map came up, the search box did not. * feat(admin): hide the settings a centrally administered install cannot act on A control that answers 403 is worse than no control: the AI section rendered the refusal as "Request failed with status code 403", which tells an admin they broke something rather than that the decision is not theirs. So the surfaces go rather than being disabled in place: - AI parsing and AirTrail leave the addon list, and the AI addon route now refuses outright. The endpoint, the model and what a document costs come with the instance, so there is no instance decision left — not even whether it runs. - Immich and Synology Photos leave it too. Both connect to a server the reader runs at home, and there is no route from here to one. - The SMTP block, the admin webhook and ntfy targets, and the passkey RP ID and origins follow the same rule: they configure something the operator owns, and a wrong RP ID on a shared parent domain reaches past this instance entirely. - The About tab goes. It is about the project — what TREK is, where to file a bug, where to support it — and is aimed at the person running the software. The version and the link to the source move to the sidebar footer, on both the desktop page and the mobile twin, because AGPL 13 wants that offer wherever people reach the software over a network. - The update check stops asking GitHub and reports nothing available. Separately, the version footers lose the "self-hosted" suffix everywhere, not just here. It read as a label on the software rather than on the install, and the version alone says what it needs to. * feat(config): refuse to boot centrally administered without an at-rest key Measured on a running instance rather than assumed: with ENCRYPTION_KEY unset, the at-rest key falls back to a file in the data volume, and backupService bundles that file into every archive it builds. The archive is downloadable by any instance admin, so the encryption ships with its own key in the same zip. The skip in backupService already handles this — it only omits the file when the key came from the env — but nothing made sure the env actually carried one. Provisioning sets it; forgetting it was silent. validateEnvAtBoot now carries a second kind of check next to the schema: cross- field rules that only apply with the flag set, for combinations where every value is individually valid and the result is wrong. Empty without the flag, so an install that never asked to be centrally administered cannot meet it. * feat(client): add an attachment point for install-specific screens Two empty exports on the client, an empty module on the server, and the four places that read them. Nothing here adds a route, a nav entry or an endpoint — the arrays are empty and the module contributes nothing, which managed-ext.test pins so it stays that way. Read unconditionally, never behind a flag. On the server a conditional import would tie the module graph to an environment variable, and the graph is built once per app while the flag is read live; on the client an empty array costs nothing to spread, so every build walks the same path instead of one build walking a path nobody tests. Anything attached here follows the same rules as the rest of src/nest and src/components: a real module with a controller and a provider, bodies through the Zod pipe, routes under the global guards, tests. * test(shared): move the wording gate out of src so shared typechecks again CI installs shared with its own dependencies only (npm ci --workspace shared), so there is no @types/node and the `node:fs` import inside src/ failed to compile. The tests passed the whole time — vitest supplies the module — which is why this only showed up in the typecheck step. The walk moves to scripts/, next to i18n-parity.mjs, and the spec imports it the same way that one does. It also runs on its own now (`node scripts/i18n-selfhost-wording.mjs`), which is what you want when CI names a line you have to go find. * test(server): repair the call sites typecheck:tests caught tests/ sits outside the build tsconfig, so none of this showed up in a green test run — which is exactly the gap `npm run typecheck:tests` exists to close. Three kinds, all mine: - Seven `import type` paths for RuntimeEnvService carried one `../` too many. Type-only imports are erased, so vitest never resolved them and nothing failed. - VersionCheckJob and PluginsController grew a constructor parameter and eleven hand-wired `new` calls kept passing the old count. The argument arrived as undefined and no assertion happened to read it. - ConditionContext grew `managed`, and the shared context object in the conditions suite did not. The condition also had no test of its own, so it has one now: both directions, since the registry uses `is: false` to keep a notice away and a notice that only belongs on a centrally administered install would use `is: true`. --- README.md | 2 + client/src/App.tsx | 10 ++ .../Admin/DefaultUserSettingsTab.tsx | 6 +- client/src/components/Layout/BottomNav.tsx | 3 + client/src/components/Layout/Navbar.tsx | 5 +- client/src/components/Settings/AboutTab.tsx | 36 +++- .../components/Settings/IntegrationsTab.tsx | 14 +- .../Settings/LlmConnectionSection.test.tsx | 2 +- .../components/Settings/MapSettingsTab.tsx | 6 +- client/src/managed/index.tsx | 47 +++++ client/src/mobile/screens/admin/MAdmin.tsx | 8 +- .../src/mobile/screens/settings/MSettings.tsx | 22 ++- .../settings/MSettingsIntegrations.tsx | 7 +- client/src/pages/AdminPage.tsx | 11 +- client/src/pages/LoginPage.tsx | 2 +- client/src/pages/SettingsPage.tsx | 25 ++- .../src/pages/admin/AdminNotificationsTab.tsx | 14 +- client/src/pages/admin/AdminSettingsTab.tsx | 30 +++- client/src/pages/admin/useAdmin.ts | 6 +- client/src/pages/settings/useSettings.ts | 4 +- .../settings/MLlmConnectionSection.test.tsx | 2 +- server/src/app-config/README.md | 9 +- server/src/app-config/derive.ts | 18 ++ server/src/app-config/env.schema.ts | 3 + server/src/app-config/env.ts | 43 ++++- server/src/app-config/index.ts | 1 + server/src/bootstrap.ts | 6 + server/src/nest/admin/admin.controller.ts | 9 + server/src/nest/admin/admin.module.ts | 3 +- server/src/nest/admin/admin.service.ts | 115 +++++++++++- server/src/nest/admin/version-check.job.ts | 6 + server/src/nest/app.module.ts | 3 +- server/src/nest/auth/auth.controller.ts | 7 +- server/src/nest/auth/auth.service.ts | 22 ++- server/src/nest/auth/user-profile.service.ts | 49 ++++- server/src/nest/backup/backup.controller.ts | 16 ++ server/src/nest/backup/backup.module.ts | 3 +- server/src/nest/common/managed.guard.ts | 14 +- server/src/nest/common/managed.ts | 128 ++++++++++++- .../nest/common/validate-managed-routes.ts | 127 +++++++++++++ .../nest/llm-parse/llm-local.controller.ts | 3 + server/src/nest/llm-parse/llm-parse.module.ts | 3 +- .../src/nest/llm-parse/llm-parse.service.ts | 19 +- server/src/nest/managed/managed-ext.module.ts | 24 +++ server/src/nest/maps/maps.service.ts | 31 +++- .../notifications/notifications.controller.ts | 2 + server/src/nest/oidc/oidc.controller.ts | 6 + server/src/nest/oidc/oidc.service.ts | 13 +- server/src/nest/plugins/paths.ts | 11 ++ server/src/nest/plugins/plugins.controller.ts | 14 ++ server/src/nest/plugins/plugins.module.ts | 3 +- .../src/nest/settings/settings.controller.ts | 29 ++- server/src/nest/settings/settings.module.ts | 3 +- server/src/nest/settings/settings.service.ts | 15 ++ .../system-notices/system-notices.module.ts | 3 +- .../system-notices/system-notices.service.ts | 12 +- server/src/systemNotices/conditions.ts | 5 + server/src/systemNotices/registry.ts | 9 +- server/src/systemNotices/service.ts | 5 +- server/src/systemNotices/types.ts | 4 + server/src/utils/ssrfGuard.ts | 10 +- server/tests/e2e/system-notices.e2e.test.ts | 2 +- server/tests/unit/app-config/validate.test.ts | 45 +++++ ...n-default-user-settings.controller.test.ts | 3 +- server/tests/unit/nest/admin-jobs.test.ts | 10 +- server/tests/unit/nest/admin.service.test.ts | 113 +++++++++++- .../tests/unit/nest/backup.controller.test.ts | 3 +- .../nest/llm-parse/llm-parse.service.test.ts | 3 +- server/tests/unit/nest/managed-ext.test.ts | 36 ++++ server/tests/unit/nest/managed-inert.test.ts | 74 ++++++++ server/tests/unit/nest/managed-keys.test.ts | 121 +++++++++++++ server/tests/unit/nest/managed.guard.test.ts | 15 +- server/tests/unit/nest/managed.test.ts | 14 +- server/tests/unit/nest/oidc-ssrf.test.ts | 52 ++++++ server/tests/unit/nest/oidc.service.test.ts | 12 ++ .../unit/nest/settings.controller.test.ts | 11 +- .../unit/nest/system-notices.service.test.ts | 9 +- .../unit/nest/validate-managed-routes.test.ts | 169 ++++++++++++++++++ .../plugins/plugin-retrust.controller.test.ts | 3 +- .../plugins-controller-devlink.test.ts | 3 +- .../unit/plugins/plugins-service.test.ts | 16 +- .../unit/systemNotices/conditions.test.ts | 18 ++ shared/scripts/i18n-selfhost-wording.mjs | 84 +++++++++ shared/src/i18n/ar/settings.ts | 4 + shared/src/i18n/br/settings.ts | 4 + shared/src/i18n/ca/settings.ts | 4 + shared/src/i18n/cs/settings.ts | 4 + shared/src/i18n/de/settings.ts | 4 + shared/src/i18n/en/admin.ts | 4 +- shared/src/i18n/en/settings.ts | 8 +- shared/src/i18n/es/settings.ts | 4 + shared/src/i18n/fr/settings.ts | 4 + shared/src/i18n/gr/settings.ts | 4 + shared/src/i18n/hu/settings.ts | 4 + shared/src/i18n/i18n-selfhost-wording.spec.ts | 53 ++++++ shared/src/i18n/id/settings.ts | 4 + shared/src/i18n/it/settings.ts | 4 + shared/src/i18n/ja/settings.ts | 4 + shared/src/i18n/ko/settings.ts | 4 + shared/src/i18n/nl/settings.ts | 4 + shared/src/i18n/pl/settings.ts | 4 + shared/src/i18n/ru/settings.ts | 4 + shared/src/i18n/sv/settings.ts | 4 + shared/src/i18n/tr/settings.ts | 4 + shared/src/i18n/uk/settings.ts | 4 + shared/src/i18n/vi/settings.ts | 4 + shared/src/i18n/zh-TW/settings.ts | 4 + shared/src/i18n/zh/settings.ts | 4 + 108 files changed, 1926 insertions(+), 111 deletions(-) create mode 100644 client/src/managed/index.tsx create mode 100644 server/src/nest/common/validate-managed-routes.ts create mode 100644 server/src/nest/managed/managed-ext.module.ts create mode 100644 server/tests/unit/nest/managed-ext.test.ts create mode 100644 server/tests/unit/nest/managed-inert.test.ts create mode 100644 server/tests/unit/nest/managed-keys.test.ts create mode 100644 server/tests/unit/nest/oidc-ssrf.test.ts create mode 100644 server/tests/unit/nest/validate-managed-routes.test.ts create mode 100644 shared/scripts/i18n-selfhost-wording.mjs create mode 100644 shared/src/i18n/i18n-selfhost-wording.spec.ts diff --git a/README.md b/README.md index f0d08bc04..9c56fbb1c 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,8 @@ Caddy handles TLS and WebSockets automatically. | `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random | | **Other** | | | | `TREK_MANAGED` | Marks the install as centrally administered: the operator owns the configuration, credentials and upgrades, so the instance admin is not offered settings the operator sets. Leave unset when you run TREK yourself. | `false` | +| `PLACES_API_KEY` | Places credential supplied by whoever operates the install. When set, it is used for every lookup and the per-user keys in Admin > Settings are ignored. Leave unset to keep the stored keys. | — | +| `PLACES_API_BASE` | Send the Places calls to this origin instead of `https://places.googleapis.com` (an egress proxy, a cache, a gateway holding the key). Path and query are unchanged, so it has to speak the same API. | — | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | | `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` | diff --git a/client/src/App.tsx b/client/src/App.tsx index 203edd221..21fb7b6a9 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -28,6 +28,7 @@ import OfflineBanner from './components/Layout/OfflineBanner' import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' // Notice action registrations (side-effect imports): import './pages/Trips/noticeActions.js' +import { managedRoutes } from './managed' // Every page below loads on demand. The entry chunk used to carry all twenty of // them eagerly, so opening /dashboard also paid for the planner, the journal, the @@ -472,6 +473,15 @@ export default function App() { } /> + {/* Empty in this repository, and read unconditionally so the public + build walks the same path as any other. See client/src/managed. */} + {managedRoutes.map(r => ( + {r.element}} + /> + ))} ({}) const [loaded, setLoaded] = useState(false) const [mapTileUrl, setMapTileUrl] = useState('') + const managed = useAuthStore((s) => s.managed) const [mapboxToken, setMapboxToken] = useState('') const [mapboxStyle, setMapboxStyle] = useState('') @@ -379,7 +381,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement { {mapProvider !== 'leaflet' && (
- {mapProvider === 'mapbox-gl' && ( + {/* The token comes with the instance on a managed install, injected when the + settings are read. A field here would only let somebody save a worse one. */} + {mapProvider === 'mapbox-gl' && !managed && (
+ {/* Google and Unsplash come with the instance, and so does what a lookup costs; + the per-place toggles only ever traded away quota that is not the customer’s + to spend. Weather needs no key at all and has nothing to configure. */} + {!managed && (<> {/* API Keys */}
@@ -253,6 +266,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R

{t('admin.apiKeysHint')}

+ {/* The two key fields, not the toggles below them: on a centrally + administered install the operator supplies the keys, while whether + photos and lookups are offered at all stays the admin’s call. */} + {!managed && (<> {/* Google Maps Key */}

{t('admin.unsplashKeyHint')}

+ )} {/* Place Photos Toggle */}
@@ -444,6 +462,11 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
+ {/* An issuer the instance names can assert any address as verified, and the + discovery calls leave from inside the operator’s network. Sign-on is theirs + to wire, so the fields are not offered. */} + {!managed && (<> + )} {/* OIDC / SSO Configuration */}
@@ -524,6 +547,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
+ {/* Rotating the secret signs every user out and fixes nothing an instance admin + can reach: the file it writes belongs to the host. */} + {!managed && (<> + )} {/* Danger Zone */}
@@ -548,6 +575,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
+ )} ) -} +} \ No newline at end of file diff --git a/client/src/pages/admin/useAdmin.ts b/client/src/pages/admin/useAdmin.ts index 49ffdab86..ab7fcf40e 100644 --- a/client/src/pages/admin/useAdmin.ts +++ b/client/src/pages/admin/useAdmin.ts @@ -22,6 +22,7 @@ export function useAdmin() { const hour12 = useSettingsStore(s => s.settings.time_format) === '12h' const mcpEnabled = useAddonStore(s => s.isEnabled('mcp')) const devMode = useAuthStore(s => s.devMode) + const managed = useAuthStore(s => s.managed) const [activeTab, setActiveTab] = useState('users') const [users, setUsers] = useState([]) @@ -121,6 +122,9 @@ export function useAdmin() { loadAppConfig() loadApiKeys() adminApi.getOidc().then(setOidcConfig).catch(() => {}) + // The operator decides when this instance upgrades, so there is nothing to + // check and nothing the admin could act on. + if (managed) return adminApi.checkVersion().then(data => { if (data.update_available) setUpdateInfo(data) }).catch(() => {}) @@ -361,7 +365,7 @@ export function useAdmin() { return { // store-derived - demoMode, serverTimezone, hour12, mcpEnabled, devMode, currentUser, + demoMode, serverTimezone, hour12, mcpEnabled, devMode, managed, currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, logout, navigate, toast, diff --git a/client/src/pages/settings/useSettings.ts b/client/src/pages/settings/useSettings.ts index 441de700c..9f1951f67 100644 --- a/client/src/pages/settings/useSettings.ts +++ b/client/src/pages/settings/useSettings.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { useSearchParams } from 'react-router' import { authApi } from '../../api/client' import { useAddonStore } from '../../store/addonStore' +import { useAuthStore } from '../../store/authStore' /** * Settings page logic — loads addons + the app version, tracks the active tab @@ -13,6 +14,7 @@ import { useAddonStore } from '../../store/addonStore' export function useSettings() { const [searchParams] = useSearchParams() const { isEnabled: addonEnabled, loadAddons } = useAddonStore() + const managed = useAuthStore(s => s.managed) const memoriesEnabled = addonEnabled('memories') const mcpEnabled = addonEnabled('mcp') @@ -43,5 +45,5 @@ export function useSettings() { if (tab) setActiveTab(tab) }, [searchParams]) - return { hasIntegrations, appVersion, activeTab, setActiveTab } + return { hasIntegrations, appVersion, activeTab, setActiveTab, managed } } diff --git a/client/tests/unit/mobile/settings/MLlmConnectionSection.test.tsx b/client/tests/unit/mobile/settings/MLlmConnectionSection.test.tsx index b4cd75da6..056891da2 100644 --- a/client/tests/unit/mobile/settings/MLlmConnectionSection.test.tsx +++ b/client/tests/unit/mobile/settings/MLlmConnectionSection.test.tsx @@ -56,7 +56,7 @@ describe('MLlmConnectionSection', () => { // The endpoint is instance configuration since #1772, so it has no row here. expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument(); expect( - screen.getByText('A self-hosted (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'), + screen.getByText('A local (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.'), ).toBeInTheDocument(); }); diff --git a/server/src/app-config/README.md b/server/src/app-config/README.md index a647820ae..3fa08fd14 100644 --- a/server/src/app-config/README.md +++ b/server/src/app-config/README.md @@ -25,7 +25,12 @@ tokens, `RuntimeEnvService`) and consumes the SAME derive functions. 2. **Validation runs once, at boot, from the production entrypoint only.** Never wire the schema into `buildApp()`, `ConfigModule.forRoot({ validate })` or a request path. Unset/blank variables always pass (defaults apply); only - present-but-malformed values abort startup. + present-but-malformed values abort startup. The one addition on top of the + schema is `managedPreconditions()` in `env.ts`: cross-field rules that only + apply with `TREK_MANAGED`, for combinations that are individually valid and + together wrong. It is empty without the switch, so a self-hoster never meets + it. Put a rule there only if booting anyway would be a security or data + problem — not to enforce a preference. 3. **Parity is law — with one deliberate exception.** Every derived field pins the exact coercion of the call site(s) it replaced (`Number(x) || d` treating `"0"` as unset, per-site defaults for the same variable, …). Do not @@ -69,7 +74,7 @@ override these set them at file top, before the SUT import.) **Runtime-toggled** (read live on every access via `readEnv()` / `RuntimeEnvService`; tests mutate these mid-lifetime): -TREK_MANAGED, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED, +TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED, TREK_PLUGINS_ENABLED / _DEV_LINK / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS, OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS, ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL, diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index 645283a39..58f15fb42 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -120,6 +120,23 @@ export function deriveManaged(raw: RawEnv) { }; } +/** + * Where the Places calls go and which credential they carry. + * + * Both unset on a self-hosted install, which is the path the code has always + * taken: the endpoints stay Google's and the key comes from the users table. + * Set them and the calls leave through something the operator runs, with a + * credential no instance admin can read back. + */ +export function deriveMaps(raw: RawEnv) { + return { + placesApiBase: raw.PLACES_API_BASE || undefined, + placesApiKey: raw.PLACES_API_KEY || undefined, + /** Public pk.* token shipped with a managed instance; reaches the browser by design. */ + mapboxToken: raw.MAPBOX_ACCESS_TOKEN || undefined, + }; +} + export function deriveDemo(raw: RawEnv) { return { enabled: parseBool(raw.DEMO_MODE) === true, @@ -255,6 +272,7 @@ export function deriveAll(raw: RawEnv) { http: deriveHttp(raw), session: deriveSession(raw), managed: deriveManaged(raw), + maps: deriveMaps(raw), demo: deriveDemo(raw), adminBootstrap: deriveAdminBootstrap(raw), oidc: deriveOidc(raw), diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index 7bb0f2689..d2669bc41 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -131,6 +131,9 @@ export const envSchema = z.object({ ADMIN_EMAIL: anyString, ADMIN_PASSWORD: anyString, TREK_MANAGED: boolStr, + PLACES_API_BASE: url, + PLACES_API_KEY: anyString, + MAPBOX_ACCESS_TOKEN: anyString, DEMO_MODE: boolStr, DEMO_ADMIN_USER: anyString, DEMO_ADMIN_EMAIL: anyString, diff --git a/server/src/app-config/env.ts b/server/src/app-config/env.ts index b3a62540f..8e3d0f415 100644 --- a/server/src/app-config/env.ts +++ b/server/src/app-config/env.ts @@ -24,14 +24,47 @@ export function readEnv(): AppEnv { */ export function validateEnvAtBoot(raw: RawEnv = process.env as RawEnv): void { const result = envSchema.safeParse(raw); - if (result.success) return; - const lines = result.error.issues.map((issue) => { - const key = String(issue.path[0]); - return ` - ${key}=${JSON.stringify(raw[key])}: ${issue.message}`; - }); + const lines = result.success + ? [] + : result.error.issues.map((issue) => { + const key = String(issue.path[0]); + return ` - ${key}=${JSON.stringify(raw[key])}: ${issue.message}`; + }); + lines.push(...managedPreconditions(raw)); + if (lines.length === 0) return; console.error(`Invalid environment configuration:\n${lines.join('\n')}`); throw new Error( `Invalid environment configuration (${lines.length} problem${lines.length === 1 ? '' : 's'}). ` + 'Fix the variables listed above, or unset them to use their defaults.', ); } + +/** + * Cross-field rules that only apply to a centrally administered install. + * + * These are not schema problems — every value here is individually valid, and + * blank always means "use the default" everywhere else in this file. They are + * combinations that would boot happily and be wrong, which is the case the + * schema cannot express and the one worth refusing (fail closed, see + * server/CLAUDE.md). + * + * Deliberately unreachable for a self-hoster: without TREK_MANAGED the list is + * empty and nothing changes. + */ +function managedPreconditions(raw: RawEnv): string[] { + if (deriveAll(raw).managed.enabled !== true) return []; + const problems: string[] = []; + + // Without it the key is a file in the data volume, and backupService bundles + // that file into every archive it builds. The archive is downloadable by any + // instance admin, so the at-rest encryption would come with its own key in the + // same zip. Provisioning sets this; forgetting it is silent otherwise. + if (!raw.ENCRYPTION_KEY) { + problems.push( + ' - ENCRYPTION_KEY is unset: required with TREK_MANAGED, because otherwise the ' + + 'at-rest key lives in the data volume and is bundled into every backup download.', + ); + } + + return problems; +} diff --git a/server/src/app-config/index.ts b/server/src/app-config/index.ts index 010423d20..211f078a4 100644 --- a/server/src/app-config/index.ts +++ b/server/src/app-config/index.ts @@ -7,6 +7,7 @@ export { deriveHttp, deriveSession, deriveManaged, + deriveMaps, deriveDemo, deriveAdminBootstrap, deriveOidc, diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index 302a569d6..246761ee2 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -14,6 +14,7 @@ import { setupApiDocs } from './nest/platform/api-docs'; import { MCP_METADATA_MIDDLEWARE } from './nest/platform/mcp-metadata.middleware'; import { validateBodyContracts } from './nest/common/validate-body-contracts'; import { validateRouteGuards } from './nest/common/validate-route-guards'; +import { validateManagedRoutes } from './nest/common/validate-managed-routes'; import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter'; /** @@ -129,5 +130,10 @@ export async function buildApp(): Promise { // session must carry @Public() with a reason AND be on the reviewed list. // Default-deny protects what exists; this is what keeps the exemptions honest. validateRouteGuards(app); + // And on the other direction of the same question: a route that a centrally + // administered install withholds from its own admin must say why, and be on a + // list somebody reviewed. Runs in every e2e harness because they share this + // builder, so a forgotten marker fails in CI rather than at a customer. + validateManagedRoutes(app); return app; } diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index d8d24af50..5bd368f80 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -29,6 +29,7 @@ import { logInfo } from '../audit/audit-log.logger'; import { AuditService } from '../audit/audit.service'; import { NotificationsService } from '../notifications/notifications.service'; import type { User } from '../../types'; +import { ManagedForbidden } from '../common/managed'; /** Throw the legacy {error,status} envelope when a service call reports failure. */ function ok(result: T): Exclude { @@ -104,6 +105,13 @@ export class AdminController { return { success: true, deleted: result.deleted }; } + @Delete('users/:id/mfa') + resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) { + const result = ok(this.admin.resetUserMfa(id, user.id)); + this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } }); + return { success: true }; + } + // ── Stats / permissions / audit ── @Get('stats') stats() { return this.admin.getStats(); } @@ -279,6 +287,7 @@ export class AdminController { } // ── JWT rotation ── + @ManagedForbidden('rotating the secret signs every user out and fixes nothing the admin can reach') @Post('rotate-jwt-secret') @HttpCode(200) rotateJwtSecret(@CurrentUser() user: User, @Req() req: Request) { diff --git a/server/src/nest/admin/admin.module.ts b/server/src/nest/admin/admin.module.ts index 2efa9e50f..061a06ee6 100644 --- a/server/src/nest/admin/admin.module.ts +++ b/server/src/nest/admin/admin.module.ts @@ -21,9 +21,10 @@ import { PackingModule } from '../packing/packing.module'; // PermissionsModule exports PermissionsService for the permission matrix — it is // not @Global, so the import must be explicit. import { PermissionsModule } from '../permissions/permissions.module'; +import { AppConfigModule } from '../app-config/app-config.module'; @Module({ - imports: [PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule], + imports: [AppConfigModule, PluginsRuntimeModule, SettingsModule, AuditModule, AddonsModule, AuthModule, NotificationsModule, PackingModule, PermissionsModule, TokensModule, OauthModule, SchedulingModule], controllers: [AdminController], providers: [AdminService, VersionCheckJob, DemoResetJob], }) diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index d33d0a884..340a841f6 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -38,6 +38,7 @@ import { writeVersionCache, type VersionInfo, } from './admin.helpers'; +import { MANAGED_FORBIDDEN_ERROR } from '../common/managed'; /** Outbound GitHub calls: hard timeout and response-size cap (server/CLAUDE.md). */ const GITHUB_TIMEOUT_MS = 10_000; @@ -192,6 +193,13 @@ export class AdminService { } } + // An admin sets a password for exactly one reason: the account is believed + // compromised. Without bumping password_version, verifyJwtAndLoadUser keeps + // accepting every cookie the intruder already holds, so the one action taken + // to lock them out was the one action that did not. Both self-service paths + // (changePassword, resetPassword) have always done this; this one had not. + const newPv = password ? ((user as User & { password_version?: number }).password_version ?? 0) + 1 : null; + this.db.transaction(() => { this.db.run( ` @@ -200,13 +208,31 @@ export class AdminService { email = COALESCE(?, email), role = COALESCE(?, role), password_hash = COALESCE(?, password_hash), + password_version = COALESCE(?, password_version), updated_at = CURRENT_TIMESTAMP WHERE id = ? `, - username || null, email || null, role || null, passwordHash, id, + username || null, email || null, role || null, passwordHash, newPv, id, ); + + if (password) { + // The version bump only invalidates JWT cookies. These two stores carry + // their own credentials and are revoked separately, exactly as the + // self-service paths do it. + this.db.run('DELETE FROM mcp_tokens WHERE user_id = ?', id); + try { + this.db.run( + 'UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL', + id, + ); + } catch { /* very old installs predate oauth_tokens */ } + } }); + if (password) { + try { revokeUserSessions(Number(id)); } catch { /* best-effort, same as elsewhere */ } + } + const updated = this.db.get('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?', id); const changed: string[] = []; @@ -237,6 +263,40 @@ export class AdminService { resetUserPasskeys(id: string) { return this.passkeys.adminResetPasskeys(Number(id)); } + /** + * Clear another account's TOTP so its owner can enrol again. + * + * The passkey half of this has existed since passkeys landed; the TOTP half + * never did, which left one ordinary event with no answer: somebody on a trip + * loses their phone. Without this the only ways out are an operator reaching + * into the database or the account being deleted and rebuilt. + * + * Never for the caller themselves. An admin who wants their own MFA gone + * disables it through Settings, where the current password is required — + * making that reachable from here would turn a stolen admin session into a + * way to strip the second factor off the very account it came from. + */ + resetUserMfa(id: string, actingUserId: number): { error?: string; status?: number; success?: boolean; email?: string } { + const targetId = Number(id); + if (targetId === actingUserId) { + return { error: 'Use Settings to change your own two-factor setup', status: 400 }; + } + const target = this.db.get<{ id: number; email: string; mfa_enabled: number | boolean }>( + 'SELECT id, email, mfa_enabled FROM users WHERE id = ?', + targetId, + ); + if (!target) return { error: 'User not found', status: 404 }; + + // Same three columns disableMfa clears, so an admin reset and a self-service + // disable leave the account in exactly one state rather than two. + this.db.run( + 'UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + targetId, + ); + + return { success: true, email: target.email }; + } + // ── Stats ────────────────────────────────────────────────────────────────── getStats() { @@ -368,12 +428,26 @@ export class AdminService { } async checkVersion(): Promise { - const cached = readVersionCache(); - if (cached) return cached; - // Lazy require, re-anchored for nest/admin/ (was ../../package.json in services/). const currentVersion: string = readEnv().app.appVersion || require('../../../package.json').version; const isPrerelease = currentVersion.includes('-pre.'); + + // Answered rather than refused on a centrally administered install: the admin + // page calls this on open, and a 403 there would be an error message where a + // quiet surface belongs. The operator decides when to upgrade, so from inside + // the instance there is nothing available. + if (readEnv().managed.enabled) { + return { + current: currentVersion, + latest: currentVersion, + update_available: false, + is_docker: isDocker, + is_prerelease: isPrerelease, + }; + } + + const cached = readVersionCache(); + if (cached) return cached; const fallback: VersionInfo = { current: currentVersion, latest: currentVersion, @@ -462,7 +536,24 @@ export class AdminService { // ── Addons ───────────────────────────────────────────────────────────────── listAddons() { - const addons = this.db.all('SELECT * FROM addons ORDER BY sort_order, id'); + const addons = this.db + .all('SELECT * FROM addons ORDER BY sort_order, id') + // Hidden rather than shown-and-refused, because a toggle that answers 403 + // is worse than no toggle. + // + // AI parsing: the endpoint, the model and what a document costs all belong + // to the operator, so there is no instance decision left to make — not even + // whether it runs. + // + // AirTrail: the addon exists to reach a server the admin runs themselves, + // and a managed instance has no route to one. + .filter( + (a) => + !( + readEnv().managed.enabled && + (a.id === ADDON_IDS.LLM_PARSING || a.id === ADDON_IDS.AIRTRAIL) + ), + ); const providers = this.db.all<{ id: string; name: string; @@ -474,7 +565,11 @@ export class AdminService { SELECT id, name, description, icon, enabled, sort_order FROM photo_providers ORDER BY sort_order, id - `); + `) + // Immich and Synology Photos are servers the admin runs at home. A managed + // instance cannot reach one (its egress does not go there, and it should + // not), so offering the connection would only produce a timeout. + .filter(() => !readEnv().managed.enabled); const fields = this.db.all<{ provider_id: string; field_key: string; @@ -537,6 +632,14 @@ export class AdminService { const provider = this.db.get('SELECT * FROM photo_providers WHERE id = ?', id); if (!addon && !provider) return { error: 'Addon not found', status: 404 }; + // The whole addon, not just its config: on a centrally administered install + // the operator owns the endpoint, the model and the per-document cost, so + // there is nothing here for an instance admin to set — including whether it + // runs at all. listAddons hides the row; this closes the route behind it. + if (readEnv().managed.enabled && id === ADDON_IDS.LLM_PARSING) { + return { error: MANAGED_FORBIDDEN_ERROR.error, status: 403 }; + } + this.db.transaction(() => { if (addon) { if (data.enabled !== undefined) diff --git a/server/src/nest/admin/version-check.job.ts b/server/src/nest/admin/version-check.job.ts index 158d27eb0..ded6a815b 100644 --- a/server/src/nest/admin/version-check.job.ts +++ b/server/src/nest/admin/version-check.job.ts @@ -2,6 +2,7 @@ import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'; import { logError } from '../audit/audit-log.logger'; import { AdminService } from './admin.service'; import { CronRegistrarService } from '../scheduling/cron-registrar.service'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; /** * Daily version check (moved from src/scheduler.ts — it was the only consumer @@ -15,10 +16,15 @@ export class VersionCheckJob implements OnApplicationBootstrap { constructor( private readonly admin: AdminService, private readonly registrar: CronRegistrarService, + private readonly env: RuntimeEnvService, ) {} onApplicationBootstrap(): void { if (!this.registrar.isEnabled()) return; + // A centrally administered install never registers it. The upgrade schedule + // is the operator's, so the daily call to github.com would only ever produce + // a notification telling the admin to do something they cannot do. + if (this.env.isManaged()) return; this.registrar.register('version-check', '0 9 * * *', () => this.tick()); } diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index a9dcfbb4d..542fa9848 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -15,6 +15,7 @@ import { HelpModule } from './help/help.module'; import { AirportsModule } from './airports/airports.module'; import { ConfigModule } from './config/config.module'; import { SystemNoticesModule } from './system-notices/system-notices.module'; +import { ManagedExtModule } from './managed/managed-ext.module'; import { MapsModule } from './maps/maps.module'; import { GeoModule } from './geo/geo.module'; import { PlaceEnrichmentModule } from './place-enrichment/place-enrichment.module'; @@ -72,7 +73,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index 96a183611..04e0480eb 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -44,6 +44,7 @@ import { getClientIp } from '../audit/client-ip'; import { AuditService } from '../audit/audit.service'; import type { User } from '../../types'; import { MfaExempt } from './mfa-policy.guard'; +import { ManagedForbidden } from '../common/managed'; const WINDOW = 15 * 60 * 1000; const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; @@ -168,6 +169,7 @@ export class AuthController { return { users: this.profile.listUsers(user.id) }; } + @ManagedForbidden('validating a key spends the operator quota on a test click') @Get('validate-keys') async validateKeys(@CurrentUser() user: User) { const result = await this.profile.validateKeys(user.id); @@ -195,7 +197,9 @@ export class AuthController { throw new HttpException({ error: result.error }, result.status!); } this.audit.writeAudit({ userId: user.id, action: 'settings.app_update', ip: getClientIp(req), details: result.auditSummary, debugDetails: result.auditDebugDetails }); - return { success: true }; + // Named so the settings tab can say which fields the operator holds rather + // than showing a saved value that silently did not save. + return { success: true, ...(result.managedKeys?.length ? { managed_keys: result.managedKeys } : {}) }; } // GET travel-stats moved to atlas/travel-stats.controller.ts. Same path, same @@ -249,6 +253,7 @@ export class AuthController { return { tokens: this.tokens.listMcpTokens(user.id) }; } + @ManagedForbidden('a static token never expires and carries every scope; OAuth covers the same ground') @Post('mcp-tokens') @HttpCode(201) createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) { diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index f704e6e12..b6eab60b6 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -20,6 +20,7 @@ import { EphemeralTokenService } from './ephemeral-token.service'; // every consumer of this module — a nest→mcp→nest module cycle. import { revokeUserSessions } from '../../mcp/sessionManager'; import { UserCleanupService } from './user-cleanup.service'; +import { splitManagedKeys } from '../common/managed'; import { emitUserDeleted } from '../../plugin-user-lifecycle'; import { verifyJwtAndLoadUser } from './jwt-verify'; import { User } from '../../types'; @@ -230,7 +231,13 @@ export class AuthService { // One directory deeper than the legacy src/services location — the extra // '../' keeps resolving to the workspace package.json. const version: string = readEnv().app.appVersion ?? require('../../../package.json').version; - const hasGoogleKey = !!this.db.get("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1"); + // The operator's key counts too. getMapsKey prefers it over anything in the + // users table, so asking the table alone would report "no key" on an install + // where every lookup works, and the client would hide the Google features it + // is actually able to use. + const hasGoogleKey = + !!readEnv().maps.placesApiKey || + !!this.db.get("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1"); const oidcDisplayName = readEnv().oidc.displayName || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null; const oidcConfigured = !!( @@ -602,6 +609,8 @@ export class AuthService { success?: boolean; auditSummary?: Record; auditDebugDetails?: Record; + /** Names the operator holds, skipped rather than written. Empty when self-hosted. */ + managedKeys?: string[]; } { const body = rawBody as Record; const user = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId); @@ -635,7 +644,14 @@ export class AuthService { } } + // SMTP, the OIDC toggles and the WebAuthn pair share this handler with keys + // the admin does own, so a managed install skips those names and keeps the + // rest of the save working. Reported, never thrown: the settings tab sends + // the whole form in one request. + const { blocked } = splitManagedKeys(body as Record, readEnv().managed.enabled); + for (const key of ADMIN_SETTINGS_KEYS) { + if (blocked.includes(key)) continue; if (body[key] !== undefined) { let val = String(body[key]); if (key === 'require_mfa') { @@ -651,7 +667,7 @@ export class AuthService { } } - const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => body[k] !== undefined && !(k === 'smtp_pass' && String(body[k]) === '••••••••')); + const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => !blocked.includes(k) && body[k] !== undefined && !(k === 'smtp_pass' && String(body[k]) === '••••••••')); const summary: Record = {}; const smtpChanged = changedKeys.some(k => k.startsWith('smtp_')); @@ -670,7 +686,7 @@ export class AuthService { // The reminder crons read their enable gates and channels per tick, so a // notification-settings change takes effect at the next run — no restart. - return { success: true, auditSummary: summary, auditDebugDetails: debugDetails }; + return { success: true, auditSummary: summary, auditDebugDetails: debugDetails, managedKeys: blocked }; } // ------------------------------------------------------------------------- diff --git a/server/src/nest/auth/user-profile.service.ts b/server/src/nest/auth/user-profile.service.ts index b36a6ae07..0c374340a 100644 --- a/server/src/nest/auth/user-profile.service.ts +++ b/server/src/nest/auth/user-profile.service.ts @@ -6,6 +6,7 @@ import { DatabaseService } from '../database/database.service'; import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { avatarUrl } from '../common/avatarUrl'; import { EMAIL_REGEX, avatarDir, mask_stored_api_key } from './auth.helpers'; +import { splitManagedKeys, MANAGED_LOCKED_PROFILE_KEYS } from '../common/managed'; import { User } from '../../types'; /** @@ -26,8 +27,25 @@ import { User } from '../../types'; export class UserProfileService { constructor(private readonly db: DatabaseService) {} + /** + * On a centrally administered install the three key columns belong to the + * operator, and there are four ways to reach them: this method, updateApiKeys, + * updateSettings and the read in getSettings. Sealing one route would leave + * the other three open, so all four ask here. + * + * Read live rather than injected: the unit tests construct this service by + * hand with a single argument, and tests/ sits outside the tsconfig, so a new + * constructor parameter would only surface at runtime. + */ + private get managed(): boolean { + return readEnv().managed.enabled; + } + updateMapsKey(userId: number, key: unknown) { const maps_api_key = key as string | null | undefined; + if (this.managed) { + return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'] }; + } this.db.run( 'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', maybe_encrypt_api_key(maps_api_key), userId @@ -37,6 +55,8 @@ export class UserProfileService { updateApiKeys(userId: number, rawBody: unknown) { const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string }; + const { blocked } = splitManagedKeys(body, this.managed); + for (const key of blocked) delete body[key as keyof typeof body]; const current = this.db.get>('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?', userId); // `?? null` instead of the former non-null assertions: a user row deleted @@ -57,6 +77,7 @@ export class UserProfileService { const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; return { success: true, + ...(blocked.length ? { managed_keys: blocked } : {}), user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) }, }; } @@ -92,9 +113,14 @@ export class UserProfileService { const updates: string[] = []; const params: (string | number | null)[] = []; - if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); } - if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); } - if (unsplash_api_key !== undefined) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); } + // The name and email half of this body stays the user's own in every mode; + // only the three key columns answer to the operator. + const { blocked } = splitManagedKeys(body, this.managed); + const keyLocked = blocked.length > 0; + + if (maps_api_key !== undefined && !keyLocked) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); } + if (openweather_api_key !== undefined && !keyLocked) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); } + if (unsplash_api_key !== undefined && !keyLocked) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); } if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); } if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); } @@ -112,6 +138,7 @@ export class UserProfileService { const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; return { success: true, + ...(blocked.length ? { managed_keys: blocked } : {}), user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) }, }; } @@ -123,6 +150,22 @@ export class UserProfileService { ); if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; + // The one endpoint in the codebase that hands back a stored key in the + // clear. That is fine when the admin pasted it in themselves and wrong when + // the operator supplied it, so a managed install answers with the shape and + // not the values. Keys kept, values null, because the client reads them as + // `settings?.maps_api_key || ''`. + if (this.managed) { + return { + settings: { + maps_api_key: null, + openweather_api_key: null, + unsplash_api_key: null, + managed_keys: [...MANAGED_LOCKED_PROFILE_KEYS], + }, + }; + } + return { settings: { maps_api_key: decrypt_api_key(user.maps_api_key), diff --git a/server/src/nest/backup/backup.controller.ts b/server/src/nest/backup/backup.controller.ts index 981286362..61d61d09e 100644 --- a/server/src/nest/backup/backup.controller.ts +++ b/server/src/nest/backup/backup.controller.ts @@ -28,6 +28,8 @@ import { AutoBackupSettingsDto } from './backup.dto'; import { getClientIp } from '../audit/client-ip'; import { AuditService } from '../audit/audit.service'; import { getUploadTmpDir, MAX_BACKUP_UPLOAD_SIZE } from './backup.impl'; +import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; const UPLOAD = { dest: getUploadTmpDir(), @@ -54,6 +56,7 @@ export class BackupController { private readonly backup: BackupService, private readonly audit: AuditService, private readonly autoBackup: AutoBackupJob, + private readonly env: RuntimeEnvService, ) {} @Get('list') @@ -91,6 +94,7 @@ export class BackupController { res.download(this.backup.backupFilePath(filename), filename); } + @ManagedForbidden('a restore replaces database and uploads, and the operator owns the recovery point') @Post('restore/:filename') @HttpCode(200) // Express answers restore with res.json (200). async restore(@CurrentUser() user: User, @Param('filename') filename: string, @Req() req: Request) { @@ -113,10 +117,21 @@ export class BackupController { } } + @ManagedForbidden( + 'restoring from an uploaded archive replaces the database and the encryption key', + { enforcedInHandler: true }, + ) @Post('upload-restore') @HttpCode(200) // Express answers upload-restore with res.json (200). @UseInterceptors(FileInterceptor('backup', UPLOAD)) async uploadRestore(@CurrentUser() user: User, @UploadedFile() file: Express.Multer.File | undefined, @Req() req: Request) { + // Checked here rather than in the guard: a guard runs before the multipart + // parser, so throwing there leaves the body unread and the client sees an + // ECONNRESET instead of this 403 (PROFILE-015). The marker above still puts + // the route in the boot-gate inventory. + if (isManagedBlocked(this.env)) { + throw new HttpException(MANAGED_FORBIDDEN_ERROR, 403); + } if (!file) { throw new HttpException({ error: 'No file uploaded' }, 400); } @@ -147,6 +162,7 @@ export class BackupController { } } + @ManagedForbidden('the operator schedules backups off-volume; a second schedule inside it is not one') @Put('auto-settings') updateAutoSettings(@CurrentUser() user: User, @Body() body: AutoBackupSettingsDto, @Req() req: Request) { try { diff --git a/server/src/nest/backup/backup.module.ts b/server/src/nest/backup/backup.module.ts index 2e8d44cfd..432430cd1 100644 --- a/server/src/nest/backup/backup.module.ts +++ b/server/src/nest/backup/backup.module.ts @@ -4,9 +4,10 @@ import { BackupService } from './backup.service'; import { AutoBackupJob } from './auto-backup.job'; import { AuditModule } from '../audit/audit.module'; import { SchedulingModule } from '../scheduling/scheduling.module'; +import { AppConfigModule } from '../app-config/app-config.module'; @Module({ - imports: [AuditModule, SchedulingModule], + imports: [AppConfigModule, AuditModule, SchedulingModule], controllers: [BackupController], providers: [BackupService, AutoBackupJob], }) diff --git a/server/src/nest/common/managed.guard.ts b/server/src/nest/common/managed.guard.ts index 57e6ae099..d1e5bd7be 100644 --- a/server/src/nest/common/managed.guard.ts +++ b/server/src/nest/common/managed.guard.ts @@ -31,12 +31,18 @@ export class ManagedGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { if (!this.env.isManaged()) return true; - const marked = this.reflector.getAllAndOverride(MANAGED_FORBIDDEN, [ - context.getHandler(), - context.getClass(), - ]); + const marked = this.reflector.getAllAndOverride<{ enforcedInHandler?: boolean } | undefined>( + MANAGED_FORBIDDEN, + [context.getHandler(), context.getClass()], + ); if (!marked) return true; + // A multipart handler checks for itself, after the interceptor has drained + // the upload. Throwing here would leave the body unread and the client would + // see an ECONNRESET rather than the 403 (PROFILE-015). The marker still sits + // on the route so the boot gate can inventory it. + if (marked.enforcedInHandler) return true; + throw new HttpException(MANAGED_FORBIDDEN_ERROR, 403); } } diff --git a/server/src/nest/common/managed.ts b/server/src/nest/common/managed.ts index b8847db41..f39ab61a8 100644 --- a/server/src/nest/common/managed.ts +++ b/server/src/nest/common/managed.ts @@ -28,8 +28,15 @@ export const MANAGED_FORBIDDEN_ERROR = { * client: it records why this particular surface cannot be left to the instance * admin. Without it the marks turn into a list nobody can audit, which is how * the path lists this codebase has already replaced once went stale. + * + * `enforcedInHandler` is for multipart routes. A guard throws before the parser + * has drained the body and the client gets an ECONNRESET instead of the 403 + * (PROFILE-015), so those handlers call `isManagedBlocked` themselves. They + * still carry the marker, because the boot gate inventories markers and a route + * missing from that inventory is exactly what nobody notices. */ -export const ManagedForbidden = (reason: string) => SetMetadata(MANAGED_FORBIDDEN, { reason }); +export const ManagedForbidden = (reason: string, opts?: { enforcedInHandler?: true }) => + SetMetadata(MANAGED_FORBIDDEN, { reason, enforcedInHandler: opts?.enforcedInHandler === true }); /** * The handler-side flavour of the guard below. @@ -42,3 +49,122 @@ export const ManagedForbidden = (reason: string) => SetMetadata(MANAGED_FORBIDDE export function isManagedBlocked(env: RuntimeEnvService): boolean { return env.isManaged(); } + +/** + * The settings a centrally administered install does not hand to its admin, + * because the value belongs to whoever operates it. + * + * One list rather than a condition repeated at each write path. Several of + * these are reachable through more than one route — the SMTP block and the + * WebAuthn pair share a handler with keys the admin does own, and the llm_* + * family is writable through per-user settings, the instance defaults and the + * addon config alike — so a route-level refusal would be both too coarse and + * incomplete. + * + * The three users-table columns are in here too. They are not settings keys, + * but the filter matches on name and they are the same kind of value, so + * splitting them into a second mechanism would only mean two things to forget. + * + * mapbox_access_token is in here for the same reason as the rest, though it is + * the one that reaches the browser: a managed instance ships with the operator's + * public pk.* token, injected when the settings are read, and a per-user value + * saved over it would only break the map for that user. + */ +export const MANAGED_LOCKED_SETTING_KEYS = [ + 'llm_api_key', + 'llm_base_url', + 'llm_model', + 'llm_multimodal', + 'llm_provider', + 'mapbox_access_token', + 'maps_api_key', + 'oidc_login', + 'oidc_registration', + 'openweather_api_key', + 'smtp_from', + 'smtp_host', + 'smtp_pass', + 'smtp_port', + 'smtp_skip_tls_verify', + 'smtp_user', + 'unsplash_api_key', + 'webauthn_origins', + 'webauthn_rp_id', +] as const; + +/** + * The locked names that are columns on `users` rather than settings keys, so + * the assignment test can tell "deliberately outside both source lists" from + * "somebody removed a key and left the assignment behind". + */ +export const MANAGED_LOCKED_PROFILE_KEYS = [ + 'maps_api_key', + 'openweather_api_key', + 'unsplash_api_key', +] as const; + +/** + * The settings that stay with the instance admin in every mode. + * + * Listed explicitly rather than derived as "everything not locked", so that a + * key added to either source list belongs to neither set until somebody + * decides. That is what MANAGED-KEYS-001 checks, and it is the only mechanism + * here that still works a year from now. + */ +export const MANAGED_CUSTOMER_KEYS = [ + 'admin_ntfy_server', + 'admin_ntfy_token', + 'admin_ntfy_topic', + 'admin_webhook_url', + 'allow_registration', + 'allowed_file_types', + 'blur_booking_codes', + 'dark_mode', + 'default_currency', + 'distance_unit', + 'map_provider', + 'map_tile_url', + 'mapbox_3d_enabled', + 'mapbox_quality_mode', + 'mapbox_style', + 'maplibre_style', + 'notification_channels', + 'notify_trip_reminder', + 'passkey_login', + 'password_login', + 'password_registration', + 'require_mfa', + 'temperature_unit', + 'time_format', +] as const; + +const LOCKED = new Set(MANAGED_LOCKED_SETTING_KEYS); + +/** Whether this single key is the operator's to set. */ +export function isManagedLockedKey(key: string): boolean { + return LOCKED.has(key); +} + +/** + * Splits a write body into what the caller may set and what the operator owns. + * + * Reports the blocked names instead of throwing, and that is the whole point: + * the admin settings tab saves SMTP and the registration toggles in one + * request, so refusing the request would take the toggles away with the SMTP + * block. The caller keeps working, the locked values keep their stored value, + * and the response says which names were skipped. + */ +export function splitManagedKeys>( + body: T, + managed: boolean, +): { allowed: Partial; blocked: string[] } { + if (!managed) return { allowed: body, blocked: [] }; + + const allowed: Partial = {}; + const blocked: string[] = []; + for (const [key, value] of Object.entries(body)) { + if (LOCKED.has(key)) blocked.push(key); + else (allowed as Record)[key] = value; + } + return { allowed, blocked: blocked.sort() }; +} diff --git a/server/src/nest/common/validate-managed-routes.ts b/server/src/nest/common/validate-managed-routes.ts new file mode 100644 index 000000000..22f6a0b14 --- /dev/null +++ b/server/src/nest/common/validate-managed-routes.ts @@ -0,0 +1,127 @@ +import { METHOD_METADATA } from '@nestjs/common/constants'; +import { ModulesContainer } from '@nestjs/core'; +import type { INestApplication } from '@nestjs/common'; +import { MANAGED_FORBIDDEN } from './managed'; + +export interface ManagedRouteEntry { + /** `ControllerClass.methodName` */ + id: string; + /** Why this surface is not the instance admin's, for the next reader. */ + reason: string; + /** Multipart routes refuse inside the handler; the guard steps aside for them. */ + enforcedInHandler: boolean; +} + +/** + * Collects every route marked with `@ManagedForbidden`. + * + * Same walk as `collectRouteGuards`, and deliberately so: a route is anything + * carrying an HTTP method decorator, and metadata is read from the handler with + * the class as fallback. + */ +export function collectManagedRoutes(app: INestApplication): ManagedRouteEntry[] { + const container = app.get(ModulesContainer, { strict: false }); + const entries: ManagedRouteEntry[] = []; + + for (const module of container.values()) { + for (const wrapper of module.controllers.values()) { + const ctor = wrapper.metatype as (new (...args: never[]) => object) | undefined; + if (!ctor?.prototype) continue; + + const classMark = Reflect.getMetadata(MANAGED_FORBIDDEN, ctor) as + | { reason?: string; enforcedInHandler?: boolean } + | undefined; + + for (const name of Object.getOwnPropertyNames(ctor.prototype)) { + if (name === 'constructor') continue; + const handler = (ctor.prototype as Record)[name]; + if (typeof handler !== 'function') continue; + if (Reflect.getMetadata(METHOD_METADATA, handler) === undefined) continue; + + const mark = + (Reflect.getMetadata(MANAGED_FORBIDDEN, handler) as typeof classMark) ?? classMark; + if (!mark) continue; + + entries.push({ + id: `${ctor.name}.${name}`, + reason: mark.reason ?? '', + enforcedInHandler: mark.enforcedInHandler === true, + }); + } + } + } + + return entries.sort((a, b) => a.id.localeCompare(b.id)); +} + +/** + * The surfaces a centrally administered install withholds from its own admin. + * + * Reviewed as a list on purpose, exactly like the anonymous-route list next + * door. Each entry takes a control away from somebody who would have it on a + * self-hosted install, so a diff that grows this list is a diff that narrows + * what a customer may do, and that should never happen by accident. + * + * Entries are `ControllerClass.methodName`. Sorted, no duplicates. + */ +export const MANAGED_ROUTE_ALLOW_LIST: string[] = [ + 'AdminController.rotateJwtSecret', + 'AdminOidcController.get', + 'AdminOidcController.update', + 'AuthController.createMcpToken', + 'AuthController.validateKeys', + 'BackupController.restore', + 'BackupController.updateAutoSettings', + 'BackupController.uploadRestore', + 'LlmLocalController.models', + 'LlmLocalController.pull', + 'NotificationsController.testSmtp', + 'PluginsController.link', + 'PluginsController.reload', + 'PluginsController.upload', +]; + +/** + * Throws when a route carries `@ManagedForbidden` without being listed above, + * when a listed route no longer carries it, or when a marker has no reason. + * + * The third case has no equivalent in the guard gate and is the one that keeps + * this list worth reading: a marker whose reason is blank tells the next person + * nothing about why the control was taken away, and there is no way to recover + * that intent afterwards. + */ +export function validateManagedRoutes( + app: INestApplication, + allowList: string[] = MANAGED_ROUTE_ALLOW_LIST, +): void { + const entries = collectManagedRoutes(app); + const ids = entries.map((e) => e.id); + const allowed = new Set(allowList); + + const undeclared = ids.filter((id) => !allowed.has(id)); + const stale = allowList.filter((id) => !ids.includes(id)); + const unexplained = entries.filter((e) => e.reason.trim() === '').map((e) => e.id); + + const problems: string[] = []; + if (undeclared.length > 0) { + problems.push( + `route(s) marked @ManagedForbidden() but not in MANAGED_ROUTE_ALLOW_LIST:\n ${undeclared.join('\n ')}`, + ); + } + if (stale.length > 0) { + problems.push( + `MANAGED_ROUTE_ALLOW_LIST entries that are no longer marked:\n ${stale.join('\n ')}`, + ); + } + if (unexplained.length > 0) { + problems.push(`@ManagedForbidden() without a reason:\n ${unexplained.join('\n ')}`); + } + if (problems.length > 0) { + throw new Error( + `Managed surface changed.\n\n${problems.join('\n\n')}\n\n` + + 'Every entry is a control a centrally administered install withholds from its ' + + 'own admin. Add it deliberately, with the reason the decorator requires, or ' + + 'remove the stale line.', + ); + } +} diff --git a/server/src/nest/llm-parse/llm-local.controller.ts b/server/src/nest/llm-parse/llm-local.controller.ts index df65828a6..044dd77d1 100644 --- a/server/src/nest/llm-parse/llm-local.controller.ts +++ b/server/src/nest/llm-parse/llm-local.controller.ts @@ -4,6 +4,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdminGuard } from '../auth/admin.guard'; import { LlmLocalService } from './llm-local.service'; import { LlmLocalPullDto } from './llm-local.dto'; +import { ManagedForbidden } from '../common/managed'; /** * Admin-only management of a local LLM server (Ollama): list installed models and @@ -14,6 +15,7 @@ import { LlmLocalPullDto } from './llm-local.dto'; export class LlmLocalController { constructor(private readonly local: LlmLocalService) {} + @ManagedForbidden('the model list belongs to the operator runtime, not to one instance') @Get('models') models(@Query('baseUrl') baseUrl?: string) { return this.local.listModels(baseUrl); @@ -24,6 +26,7 @@ export class LlmLocalController { * ({ status, total?, completed? }) straight to the client, which reads the * response body to render a progress bar. Uses @Res() to stream manually. */ + @ManagedForbidden('pulling a model spends the operator disk and GPU from inside a customer instance') @Post('pull') async pull(@Body() body: LlmLocalPullDto, @Res() res: Response): Promise { const stream = await this.local.pull(body?.baseUrl, body?.model ?? ''); diff --git a/server/src/nest/llm-parse/llm-parse.module.ts b/server/src/nest/llm-parse/llm-parse.module.ts index 36f119cf1..0b6ecfb8f 100644 --- a/server/src/nest/llm-parse/llm-parse.module.ts +++ b/server/src/nest/llm-parse/llm-parse.module.ts @@ -5,13 +5,14 @@ import { LlmLocalController } from './llm-local.controller'; import { LlmConfigResolver } from './llm-config.resolver'; import { SettingsModule } from '../settings/settings.module'; import { AddonsModule } from '../addons/addons.module'; +import { AppConfigModule } from '../app-config/app-config.module'; /** * Provides the LLM booking-import fallback; imported by BookingImportModule. * Exports LlmConfigResolver for the plugin host surface (HostSurfaceRpc). */ @Module({ - imports: [SettingsModule, AddonsModule], + imports: [AppConfigModule, SettingsModule, AddonsModule], controllers: [LlmLocalController], providers: [LlmParseService, LlmLocalService, LlmConfigResolver], exports: [LlmParseService, LlmConfigResolver], diff --git a/server/src/nest/llm-parse/llm-parse.service.ts b/server/src/nest/llm-parse/llm-parse.service.ts index 7954da183..00bb7f0d7 100644 --- a/server/src/nest/llm-parse/llm-parse.service.ts +++ b/server/src/nest/llm-parse/llm-parse.service.ts @@ -7,6 +7,7 @@ import { isPdf, extractText } from './text-extract'; import { routeExtraction, detectFlightNumbers } from './router/extraction-router'; import { Injectable } from '@nestjs/common'; import { kiReservationSchema } from '@trek/shared'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; const MIME_BY_EXT: Record = { '.pdf': 'application/pdf', @@ -26,7 +27,10 @@ export interface LlmParseResult { */ @Injectable() export class LlmParseService { - constructor(private readonly llmConfig: LlmConfigResolver) {} + constructor( + private readonly llmConfig: LlmConfigResolver, + private readonly env: RuntimeEnvService, + ) {} /** True when the addon is enabled AND a usable config resolves for this user. */ isAvailable(userId: number): boolean { @@ -64,7 +68,14 @@ export class LlmParseService { const MAX_EXTRACT_CHARS = config.provider !== 'local' ? 4000 : detectFlightNumbers(input.text).length > 0 ? 16000 : 6000; if (input.text.length > MAX_EXTRACT_CHARS) input.text = input.text.slice(0, MAX_EXTRACT_CHARS); - console.debug(`[DEBUG] Extracted text from ${file.originalName} (${input.text.length} chars):\n`, input.text); + // The extracted text IS the booking: traveller name, address, booking + // reference. On a centrally administered install that would land in the + // operator's log, which is a processing nobody asked for and nobody needs. + if (this.env.isManaged()) { + console.debug(`[DEBUG] Extracted text from ${file.originalName} (${input.text.length} chars)`); + } else { + console.debug(`[DEBUG] Extracted text from ${file.originalName} (${input.text.length} chars):\n`, input.text); + } if (!input.text.trim()) { return { kiItems: [], @@ -104,7 +115,9 @@ export class LlmParseService { let raw: Record[]; try { raw = await createLlmClient(config).extract(input); - console.debug('[DEBUG] Raw LLM Response: ', raw); + // Same reason: the model answers with the fields it read out of the document. + if (this.env.isManaged()) console.debug(`[DEBUG] LLM response: ${raw.length} item(s)`); + else console.debug('[DEBUG] Raw LLM Response: ', raw); } catch (err) { console.error(`[llm-parse] AI parsing failed for "${file.originalName}" (provider=${config.provider}):`, err instanceof Error ? err.message : err); return { diff --git a/server/src/nest/managed/managed-ext.module.ts b/server/src/nest/managed/managed-ext.module.ts new file mode 100644 index 000000000..a781beb80 --- /dev/null +++ b/server/src/nest/managed/managed-ext.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; + +/** + * The attachment point for server surfaces that only exist on a centrally + * administered install. + * + * Empty here, and empty in every build of this repository: no controllers, no + * providers, no routes. An installation whose operator ships additional + * endpoints replaces this file at build time. + * + * app.module.ts imports it unconditionally, which is the point. A conditional + * import would make the module graph depend on an environment variable, and the + * graph is built once per app while TREK_MANAGED is read live — the two do not + * belong in the same expression (see app-config/README.md on the boot-stable vs + * runtime-toggled split). An empty module costs nothing to import, so the flag + * never has to reach this far. + * + * Anything added here follows the same rules as the rest of src/nest: a real + * module with a controller and a provider, bodies through the Zod pipe, routes + * covered by the global guards, and its own tests. Being outside this repository + * is not a reason to be outside its standards. + */ +@Module({}) +export class ManagedExtModule {} diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index efff21f4a..764071cf0 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -39,7 +39,28 @@ import { let googleApiCallCount = 0; -function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise { +/** The upstream every Places call is written against. */ +const PLACES_UPSTREAM = 'https://places.googleapis.com'; + +/** + * Sends the call somewhere else when PLACES_API_BASE is set. + * + * The nine Places endpoints below all spell out the upstream host, so an install + * that wants these calls to leave through something of its own — an egress proxy, + * a cache, a gateway holding the key — has no way to say so today. One variable, + * substituted at the one place every call funnels through. + * + * Path and query are untouched, so the replacement has to speak the same API. + * Unset, which is every install today, the string is returned as it came in. + */ +function placesEndpoint(endpoint: string): string { + const base = readEnv().maps.placesApiBase; + if (!base || !endpoint.startsWith(PLACES_UPSTREAM)) return endpoint; + return base.replace(/\/+$/, '') + endpoint.slice(PLACES_UPSTREAM.length); +} + +function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise { + const endpoint = placesEndpoint(rawEndpoint); googleApiCallCount++; console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`); const referer = readEnv().app.appUrl ? getAppUrl() : undefined; @@ -528,6 +549,14 @@ export class MapsService { // ── API key retrieval ────────────────────────────────────────────────────── getMapsKey(userId: number): string | null { + // When the operator supplies the credential, the database is not asked at + // all. Two reasons, and the second is the load-bearing one: a per-user key + // would route around whatever the operator's endpoint counts, and the key + // columns are readable by any admin of this instance, so a shared key stored + // there would be a key handed out. Unset, this branch never runs. + const operatorKey = readEnv().maps.placesApiKey; + if (operatorKey) return operatorKey; + const user = this.database.get<{ maps_api_key: string | null }>( 'SELECT maps_api_key FROM users WHERE id = ?', userId, diff --git a/server/src/nest/notifications/notifications.controller.ts b/server/src/nest/notifications/notifications.controller.ts index 4c5942dac..5cdad0e55 100644 --- a/server/src/nest/notifications/notifications.controller.ts +++ b/server/src/nest/notifications/notifications.controller.ts @@ -26,6 +26,7 @@ import { AdminGuard } from '../auth/admin.guard'; import { NotificationPreferencesService } from './notification-preferences.service'; import { AdminNotificationPreferencesDto } from '../admin/admin.dto'; import { CurrentUser } from '../auth/current-user.decorator'; +import { ManagedForbidden } from '../common/managed'; // The masked placeholder the client sends instead of a stored secret (8× U+2022). const MASKED = '••••••••'; @@ -62,6 +63,7 @@ export class NotificationsController { return this.notifications.getPreferences(user.id, user.role); } + @ManagedForbidden('the relay is the operator credential; a test send would use their reputation') @Post('test-smtp') @HttpCode(200) async testSmtp(@CurrentUser() user: User, @Body() body: TestSmtpDto): Promise { diff --git a/server/src/nest/oidc/oidc.controller.ts b/server/src/nest/oidc/oidc.controller.ts index 1f2de56e1..47a31f8f5 100644 --- a/server/src/nest/oidc/oidc.controller.ts +++ b/server/src/nest/oidc/oidc.controller.ts @@ -11,6 +11,7 @@ import { AuditService } from '../audit/audit.service'; import { getClientIp } from '../audit/client-ip'; import { AdminOidcUpdateDto } from '../admin/admin.dto'; import { Public } from '../auth/public.decorator'; +import { ManagedForbidden } from '../common/managed'; const OIDC_STATE_COOKIE = 'trek_oidc_state'; @@ -193,11 +194,16 @@ export class AdminOidcController { private readonly audit: AuditService, ) {} + // Both halves, not just the write: reading back the issuer, client id and the + // discovery URL tells a caller how the identity of this install is wired, and + // on a managed install that wiring is not theirs. + @ManagedForbidden('the identity provider is part of what the operator runs') @Get() get() { return this.oidc.getOidcSettings(); } + @ManagedForbidden('an instance-supplied issuer could assert any address as verified') @Put() update(@CurrentUser() user: User, @Body() body: AdminOidcUpdateDto, @Req() req: Request) { const result = this.oidc.updateOidcSettings(body); diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index b1f0d4550..86d076f9c 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -13,6 +13,7 @@ import { TripMembershipService } from '../trip-membership/trip-membership.servic import { setAuthCookie } from '../common/cookie'; import { AuthService } from '../auth/auth.service'; import { DatabaseService } from '../database/database.service'; +import { safeFetchAdminConfigured } from '../../utils/ssrfGuard'; // --------------------------------------------------------------------------- // Types @@ -271,7 +272,7 @@ export class OidcService implements OnModuleDestroy { if (cached && Date.now() - cached.fetchedAt < DISCOVERY_TTL) { return cached.doc; } - const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + const res = await safeFetchAdminConfigured(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) throw new Error('Failed to fetch OIDC discovery document'); assertResponseSize(res); const parsed: unknown = await res.json(); @@ -355,12 +356,14 @@ export class OidcService implements OnModuleDestroy { client_secret: clientSecret, }); if (codeVerifier) body.set('code_verifier', codeVerifier); - const tokenRes = await fetch(doc.token_endpoint, { + // maxRedirects 0: following one would hand client_secret to a second host, + // and the platform default of 'follow' does exactly that today. + const tokenRes = await safeFetchAdminConfigured(doc.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); + }, 0); assertResponseSize(tokenRes); // Error responses are still parsed on purpose — callers branch on _ok/_status. const parsed: unknown = await tokenRes.json(); @@ -375,7 +378,7 @@ export class OidcService implements OnModuleDestroy { // ------------------------------------------------------------------------- async getUserInfo(userinfoEndpoint: string, accessToken: string): Promise { - const res = await fetch(userinfoEndpoint, { + const res = await safeFetchAdminConfigured(userinfoEndpoint, { headers: { Authorization: `Bearer ${accessToken}` }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); @@ -393,7 +396,7 @@ export class OidcService implements OnModuleDestroy { private async fetchJwks(jwksUri: string): Promise>> { const cached = this.jwksCache.get(jwksUri); if (cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys; - const res = await fetch(jwksUri, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + const res = await safeFetchAdminConfigured(jwksUri, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) throw new Error(`JWKS fetch failed: HTTP ${res.status}`); assertResponseSize(res); const json: unknown = await res.json(); diff --git a/server/src/nest/plugins/paths.ts b/server/src/nest/plugins/paths.ts index 9fa8ac517..276a3343c 100644 --- a/server/src/nest/plugins/paths.ts +++ b/server/src/nest/plugins/paths.ts @@ -114,6 +114,17 @@ export function serverCodeRoot(): string { let warnedPermissionsOff = false; export function pluginPermissionArgs(pluginId: string): string[] { if (readEnv().plugins.permissionsOff) { + // Refuse outright when somebody other than this install's admin runs it. A + // warning is the right answer for a machine its owner chose to trust; here + // the person who set the flag and the people whose data is in the database + // are not the same person, and "fail closed" is the repo rule for exactly + // that case. Nothing spawns rather than spawning without the jail. + if (readEnv().managed.enabled) { + throw new Error( + 'TREK_PLUGIN_PERMISSIONS=off is refused on a centrally administered install: ' + + 'the OS permission jail is what keeps an installed plugin out of trek.db and the secret files.', + ); + } // The OS permission jail is the boundary that stops an installed plugin from // reading trek.db / the secret files / shelling out. Turning it off makes plugin // isolation crash-only — surface that loudly once so an operator can't leave it diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index 94a3fc134..2ccb1d11c 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -12,6 +12,8 @@ import { getClientIp } from '../audit/client-ip'; import { pluginsEnabled } from './kill-switch'; import { devLinkEnabled } from './dev-link'; import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto } from './plugins.dto'; +import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; /** * Flatten a registry/install failure into the error envelope — CARRYING THE CODE. @@ -48,6 +50,7 @@ export class PluginsController { private readonly plugins: PluginsService, private readonly runtime: PluginRuntimeService, private readonly registry: PluginRegistryService, + private readonly env: RuntimeEnvService, ) {} @Get() @@ -86,10 +89,19 @@ export class PluginsController { } /** Sideload a plugin from an uploaded .zip/.tar.gz (registers INACTIVE). */ + @ManagedForbidden( + 'a sideloaded archive skips the signature check every registry install performs', + { enforcedInHandler: true }, + ) @Post('upload') @HttpCode(200) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 + 4096 } })) async upload(@UploadedFile() file?: Express.Multer.File) { + // In the handler, not the guard: guards run before the multipart parser and + // the client would get an ECONNRESET rather than this 403 (PROFILE-015). + if (isManagedBlocked(this.env)) { + throw new HttpException(MANAGED_FORBIDDEN_ERROR, 403); + } if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); if (!file?.buffer?.length) throw new HttpException({ error: 'no file uploaded' }, 400); try { @@ -103,6 +115,7 @@ export class PluginsController { * DEV-ONLY: register a plugin from a LOCAL built directory and hot-reload it * against real data. Gated by TREK_PLUGINS_DEV_LINK on top of admin + kill-switch. */ + @ManagedForbidden('a linked directory skips the signature check the registry install performs') @Post('link') @HttpCode(200) async link(@Body() body: PluginLinkDto) { @@ -184,6 +197,7 @@ export class PluginsController { } /** DEV-ONLY: re-fork a dev-linked plugin so it picks up rebuilt code. */ + @ManagedForbidden('reloading from disk reintroduces whatever a sideload put there') @Post(':id/reload') @HttpCode(200) async reload(@Param('id') id: string) { diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 029338534..8d4c338e1 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -8,6 +8,7 @@ import { PluginUserSettingsController } from './plugin-user-settings.controller' import { PluginsRuntimeModule } from './plugins-runtime.module'; import { PluginOAuthModule } from './oauth/plugin-oauth.module'; import { PluginContributionsModule } from './contributions/plugin-contributions.module'; +import { AppConfigModule } from '../app-config/app-config.module'; /** * Plugin system (#plugins), composition root. @@ -27,7 +28,7 @@ import { PluginContributionsModule } from './contributions/plugin-contributions. * that serves page and widget assets. */ @Module({ - imports: [PluginsRuntimeModule, PluginOAuthModule, PluginContributionsModule], + imports: [AppConfigModule, PluginsRuntimeModule, PluginOAuthModule, PluginContributionsModule], controllers: [ PluginsController, PluginsFeedController, diff --git a/server/src/nest/settings/settings.controller.ts b/server/src/nest/settings/settings.controller.ts index cc95563d8..a86b64296 100644 --- a/server/src/nest/settings/settings.controller.ts +++ b/server/src/nest/settings/settings.controller.ts @@ -10,6 +10,8 @@ import { AdminGuard } from '../auth/admin.guard'; import { AuditService } from '../audit/audit.service'; import { getClientIp } from '../audit/client-ip'; import { CurrentUser } from '../auth/current-user.decorator'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { isManagedLockedKey, splitManagedKeys } from '../common/managed'; /** * /api/settings — per-user key/value preferences: get-all, single upsert @@ -26,7 +28,10 @@ import { CurrentUser } from '../auth/current-user.decorator'; @Controller('api/settings') @UseGuards(JwtAuthGuard) export class SettingsController { - constructor(private readonly settings: SettingsService) {} + constructor( + private readonly settings: SettingsService, + private readonly env: RuntimeEnvService, + ) {} /** * #1772: the write half of the rule that the endpoint is instance @@ -57,6 +62,12 @@ export class SettingsController { @Put() upsert(@CurrentUser() user: User, @Body() body: SettingUpsertDto) { this.assertMayWriteLlmEndpoint({ [body.key]: body.value }); + // assertMayWriteLlmEndpoint only covers llm_base_url and provider 'local'. + // llm_api_key and llm_model are writable by every user, and on a managed + // install both cost the operator money, so the key list decides here. + if (isManagedLockedKey(body.key) && this.env.isManaged()) { + return { success: true, key: body.key, managed: true }; + } // The client echoes a redacted secret back unchanged — treat as a no-op. if (body.value === MASKED_SETTING_VALUE) { return { success: true, key: body.key, unchanged: true }; @@ -69,9 +80,10 @@ export class SettingsController { @HttpCode(200) // Express answers bulk with res.json (200), not the POST-default 201. bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto) { this.assertMayWriteLlmEndpoint(body.settings); + const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged()); try { - const updated = this.settings.bulkUpsertSettings(user.id, body.settings); - return { success: true, updated }; + const updated = this.settings.bulkUpsertSettings(user.id, allowed); + return { success: true, updated, ...(blocked.length ? { managed_keys: blocked } : {}) }; } catch (err) { console.error('Error saving settings:', err); throw new HttpException({ error: 'Error saving settings' }, 500); @@ -92,6 +104,7 @@ export class AdminDefaultUserSettingsController { constructor( private readonly settings: SettingsService, private readonly audit: AuditService, + private readonly env: RuntimeEnvService, ) {} @Get() @@ -102,7 +115,15 @@ export class AdminDefaultUserSettingsController { @Put() update(@CurrentUser() user: User, @Body() body: AdminDefaultUserSettingsDto, @Req() req: Request) { try { - this.settings.setAdminUserDefaults(body as unknown as Record); + // Deliberately no managed_keys in the response here: the route answers + // with the raw defaults map the admin panel renders from, so an extra + // field would sit inside the key namespace. The audit row below and the + // client's capability filter carry the message instead. + const { allowed } = splitManagedKeys( + body as unknown as Record, + this.env.isManaged(), + ); + this.settings.setAdminUserDefaults(allowed); this.audit.writeAudit({ userId: user.id, action: 'admin.default_user_settings_update', diff --git a/server/src/nest/settings/settings.module.ts b/server/src/nest/settings/settings.module.ts index ce4a92e9e..777af30da 100644 --- a/server/src/nest/settings/settings.module.ts +++ b/server/src/nest/settings/settings.module.ts @@ -3,11 +3,12 @@ import { AdminDefaultUserSettingsController, SettingsController } from './settin import { AuthModule } from '../auth/auth.module'; import { AuditModule } from '../audit/audit.module'; import { SettingsService } from './settings.service'; +import { AppConfigModule } from '../app-config/app-config.module'; /** Exports SettingsService for in-container consumers (admin, share, llm-parse). */ @Module({ // AuthModule for the admin gate on the defaults routes. - imports: [AuthModule, AuditModule], + imports: [AppConfigModule, AuthModule, AuditModule], controllers: [SettingsController, AdminDefaultUserSettingsController], providers: [SettingsService], exports: [SettingsService], diff --git a/server/src/nest/settings/settings.service.ts b/server/src/nest/settings/settings.service.ts index 0700c3148..fa887957e 100644 --- a/server/src/nest/settings/settings.service.ts +++ b/server/src/nest/settings/settings.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { MASKED_SETTING_VALUE, normalizeAppearance } from '@trek/shared'; +import { readEnv } from '../../app-config'; const ENCRYPTED_SETTING_KEYS = new Set(['webhook_url', 'ntfy_token', 'mapbox_access_token', 'llm_api_key']); // Encrypted keys that are masked (••••••••) when returned to the client. @@ -202,6 +203,20 @@ export class SettingsService { } merged[key] = value; } + + // On a centrally administered install the map credential comes with the + // instance, so it is injected here rather than stored per user: the token is + // the operator's, it is a public pk.* that has to reach the browser anyway, + // and nobody should be able to save a different one over it. GL is the + // default there too, since a token is always present. + const managedMaps = readEnv().maps; + if (readEnv().managed.enabled && managedMaps.mapboxToken) { + merged.mapbox_access_token = managedMaps.mapboxToken; + if (!merged.map_provider || merged.map_provider === 'leaflet') { + merged.map_provider = 'mapbox-gl'; + } + } + return merged; } diff --git a/server/src/nest/system-notices/system-notices.module.ts b/server/src/nest/system-notices/system-notices.module.ts index 1255285a8..067760ffa 100644 --- a/server/src/nest/system-notices/system-notices.module.ts +++ b/server/src/nest/system-notices/system-notices.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { AddonsModule } from '../addons/addons.module'; import { SystemNoticesController } from './system-notices.controller'; import { SystemNoticesService } from './system-notices.service'; +import { AppConfigModule } from '../app-config/app-config.module'; /** System-notices domain. Registered in AppModule. */ @Module({ - imports: [AddonsModule], + imports: [AppConfigModule, AddonsModule], controllers: [SystemNoticesController], providers: [SystemNoticesService], }) diff --git a/server/src/nest/system-notices/system-notices.service.ts b/server/src/nest/system-notices/system-notices.service.ts index f50dd64da..28cf0c97b 100644 --- a/server/src/nest/system-notices/system-notices.service.ts +++ b/server/src/nest/system-notices/system-notices.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import type { SystemNoticeDto } from '@trek/shared'; import { getActiveNoticesFor, dismissNotice } from '../../systemNotices/service'; import { AddonsService } from '../addons/addons.service'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; /** * Thin Nest wrapper around the existing system-notices service. The condition @@ -12,10 +13,17 @@ import { AddonsService } from '../addons/addons.service'; */ @Injectable() export class SystemNoticesService { - constructor(private readonly addons: AddonsService) {} + constructor( + private readonly addons: AddonsService, + private readonly env: RuntimeEnvService, + ) {} getActiveFor(userId: number): SystemNoticeDto[] { - return getActiveNoticesFor(userId, (addonId) => this.addons.isAddonEnabled(addonId)) as SystemNoticeDto[]; + return getActiveNoticesFor( + userId, + (addonId) => this.addons.isAddonEnabled(addonId), + this.env.isManaged(), + ) as SystemNoticeDto[]; } dismiss(userId: number, noticeId: string): boolean { diff --git a/server/src/systemNotices/conditions.ts b/server/src/systemNotices/conditions.ts index 2dbff5ff8..dc3b8b50e 100644 --- a/server/src/systemNotices/conditions.ts +++ b/server/src/systemNotices/conditions.ts @@ -10,6 +10,8 @@ interface ConditionContext { * its injected AddonsService) so this plain module carries no bridge import. */ addonEnabled: (addonId: string) => boolean; + /** True when somebody other than this install's admin owns its configuration. */ + managed: boolean; } // Custom predicate registry — extensible without modifying this file @@ -54,6 +56,9 @@ function evaluateOne(condition: NoticeCondition, ctx: ConditionContext): boolean case 'addonEnabled': return ctx.addonEnabled(condition.addonId); + case 'managed': + return ctx.managed === condition.is; + case 'custom': { const fn = customPredicates.get(condition.id); if (!fn) { diff --git a/server/src/systemNotices/registry.ts b/server/src/systemNotices/registry.ts index 2eb0986b6..ce75cd83b 100644 --- a/server/src/systemNotices/registry.ts +++ b/server/src/systemNotices/registry.ts @@ -61,7 +61,11 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ dismissible: true, // Desktop-only: the support modal is suppressed on small/mobile viewports. desktopOnly: true, - conditions: [], + // Not on a centrally administered install. The body thanks the reader for + // installing TREK and asks them to fund it, and there the reader installed + // nothing and already pays whoever runs it. Gated rather than reworded: the + // text is right for everyone it was written for. + conditions: [{ kind: 'managed', is: false }], publishedAt: '2026-06-27T00:00:00Z', priority: 100, recurring: 'per-version', @@ -82,6 +86,9 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ { kind: 'existingUserBeforeVersion', version: '3.0.14' }, { kind: 'role', roles: ['admin'] }, { kind: 'custom', id: 'whitespace-collision-detected' }, + // The body says to check the server logs. On a managed install the reader + // has none, and the operator sees the same collision in theirs. + { kind: 'managed', is: false }, ], publishedAt: '2026-05-03T00:00:00Z', priority: 85, diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts index 16f10f458..4637497c9 100644 --- a/server/src/systemNotices/service.ts +++ b/server/src/systemNotices/service.ts @@ -38,7 +38,8 @@ function severityWeight(s: string): number { export function getActiveNoticesFor( userId: number, - addonEnabled: (addonId: string) => boolean + addonEnabled: (addonId: string) => boolean, + managed = false ): SystemNoticeDTO[] { const user = db.prepare( 'SELECT login_count, first_seen_version, role FROM users WHERE id = ?' @@ -59,7 +60,7 @@ export function getActiveNoticesFor( const now = new Date(); const currentAppVersion = getCurrentAppVersion(); - const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now, addonEnabled }; + const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now, addonEnabled, managed }; const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0'; const isStillDismissed = (n: SystemNotice): boolean => { diff --git a/server/src/systemNotices/types.ts b/server/src/systemNotices/types.ts index 3c611fa64..64ccf9bcd 100644 --- a/server/src/systemNotices/types.ts +++ b/server/src/systemNotices/types.ts @@ -9,6 +9,10 @@ export type NoticeCondition = | { kind: 'dateWindow'; startsAt: string; endsAt?: string } | { kind: 'role'; roles: Array<'admin' | 'user'> } | { kind: 'addonEnabled'; addonId: string } + // Whether the operator of this install owns its configuration. A notice that + // asks the reader to update, to check a log or to fund the project assumes + // they run it themselves. + | { kind: 'managed'; is: boolean } | { kind: 'custom'; id: string }; export interface NoticeMedia { diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index a51cf1e61..b52e00adc 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -151,7 +151,7 @@ function isLinkLocal(ip: string): boolean { * and re-pinned. An http→https upgrade or a proxy redirect between LAN hosts still * works because the check re-runs per hop rather than locking to the first IP. */ -export async function safeFetchLlm(url: string, init?: RequestInit, maxRedirects = 5): Promise { +export async function safeFetchAdminConfigured(url: string, init?: RequestInit, maxRedirects = 5): Promise { let currentUrl = url; for (let hop = 0; ; hop++) { @@ -201,6 +201,14 @@ export async function safeFetchLlm(url: string, init?: RequestInit, maxRedirects } } +/** + * The original name. Kept so no LLM call site has to change, and because the + * shape of the problem is identical: an endpoint an admin configured, which may + * legitimately live on loopback or the LAN, and must still never reach + * link-local or a cloud metadata service. + */ +export const safeFetchLlm = safeFetchAdminConfigured; + /** * Thrown by safeFetch() when the URL is blocked by the SSRF guard. */ diff --git a/server/tests/e2e/system-notices.e2e.test.ts b/server/tests/e2e/system-notices.e2e.test.ts index cdbca93c5..b9c41ae93 100644 --- a/server/tests/e2e/system-notices.e2e.test.ts +++ b/server/tests/e2e/system-notices.e2e.test.ts @@ -75,7 +75,7 @@ describe('System-notices e2e (real auth guard + temp SQLite)', () => { const res = await request(server).get('/api/system-notices/active').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual([notice]); - expect(mockGetActive).toHaveBeenCalledWith(1, expect.any(Function)); + expect(mockGetActive).toHaveBeenCalledWith(1, expect.any(Function), false); }); it('204 with no body on a successful dismiss', async () => { diff --git a/server/tests/unit/app-config/validate.test.ts b/server/tests/unit/app-config/validate.test.ts index 11ffa90c4..d009f268a 100644 --- a/server/tests/unit/app-config/validate.test.ts +++ b/server/tests/unit/app-config/validate.test.ts @@ -111,6 +111,51 @@ describe('validateEnvAtBoot', () => { }); }); +describe('validateEnvAtBoot — centrally administered preconditions', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('MANAGED-BOOT-001: refuses TREK_MANAGED without ENCRYPTION_KEY', () => { + // The combination boots fine and is wrong: the at-rest key falls back to a + // file in the data volume, and backupService puts that file in every archive + // an instance admin can download. + vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => validateEnvAtBoot({ TREK_MANAGED: '1' })).toThrow(/1 problem/); + }); + + it('MANAGED-BOOT-002: names the variable, so the fix does not need the source', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => validateEnvAtBoot({ TREK_MANAGED: 'true' })).toThrow(); + expect(err.mock.calls[0][0]).toMatch(/ENCRYPTION_KEY/); + }); + + it('MANAGED-BOOT-003: passes once the key is supplied', () => { + expect(() => + validateEnvAtBoot({ TREK_MANAGED: '1', ENCRYPTION_KEY: 'a'.repeat(64) }), + ).not.toThrow(); + }); + + it('MANAGED-BOOT-004: inert without the switch — a self-hoster keeps the file-based key', () => { + // The whole point of the check is that it cannot reach anyone who did not + // ask to be centrally administered. + expect(() => validateEnvAtBoot({})).not.toThrow(); + expect(() => validateEnvAtBoot({ TREK_MANAGED: 'off' })).not.toThrow(); + expect(() => validateEnvAtBoot({ TREK_MANAGED: 'false' })).not.toThrow(); + }); + + it('MANAGED-BOOT-005: reports alongside schema problems rather than instead of them', () => { + // Both kinds land in one report: fixing the env should take one pass, not a + // restart per problem. + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => validateEnvAtBoot({ TREK_MANAGED: '1', PORT: 'not-a-port' })).toThrow( + /2 problems/, + ); + expect(err.mock.calls[0][0]).toMatch(/PORT/); + expect(err.mock.calls[0][0]).toMatch(/ENCRYPTION_KEY/); + }); +}); + describe('readEnv', () => { it('reads process.env live — a runtime mutation is visible on the next call', () => { const before = process.env.DEMO_MODE; diff --git a/server/tests/unit/nest/admin-default-user-settings.controller.test.ts b/server/tests/unit/nest/admin-default-user-settings.controller.test.ts index 36afba007..c4e17a243 100644 --- a/server/tests/unit/nest/admin-default-user-settings.controller.test.ts +++ b/server/tests/unit/nest/admin-default-user-settings.controller.test.ts @@ -11,6 +11,7 @@ import type { SettingsService } from '../../../src/nest/settings/settings.servic import type { AuditService } from '../../../src/nest/audit/audit.service'; import type { User } from '../../../src/types'; import { expectRegisteredController } from '../../helpers/module-providers'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; const user = { id: 1, role: 'admin' } as User; const req = { headers: {}, socket: {} } as never; @@ -22,7 +23,7 @@ function controller(over: Partial = {}) { setAdminUserDefaults: vi.fn(), ...over, } as unknown as SettingsService; - return { c: new AdminDefaultUserSettingsController(settings, { writeAudit } as unknown as AuditService), settings }; + return { c: new AdminDefaultUserSettingsController(settings, { writeAudit } as unknown as AuditService, { isManaged: () => false } as unknown as RuntimeEnvService), settings }; } const thrown = (run: () => unknown) => { diff --git a/server/tests/unit/nest/admin-jobs.test.ts b/server/tests/unit/nest/admin-jobs.test.ts index 0df3fae62..067660a21 100644 --- a/server/tests/unit/nest/admin-jobs.test.ts +++ b/server/tests/unit/nest/admin-jobs.test.ts @@ -31,20 +31,24 @@ beforeEach(() => vi.clearAllMocks()); describe('VersionCheckJob', () => { it('AJOB-001 — registers the daily 9 AM cron, with no boot log (parity)', () => { const registrar = registrarStub(); - new VersionCheckJob({} as AdminService, registrar as unknown as CronRegistrarService).onApplicationBootstrap(); + new VersionCheckJob({} as AdminService, registrar as unknown as CronRegistrarService, { isManaged: () => false } as unknown as RuntimeEnvService).onApplicationBootstrap(); expect(registrar.register).toHaveBeenCalledWith('version-check', '0 9 * * *', expect.any(Function)); expect(logMock.logInfo).not.toHaveBeenCalled(); }); it('AJOB-002 — does not register under the test gate', () => { const registrar = registrarStub(false); - new VersionCheckJob({} as AdminService, registrar as unknown as CronRegistrarService).onApplicationBootstrap(); + new VersionCheckJob({} as AdminService, registrar as unknown as CronRegistrarService, { isManaged: () => false } as unknown as RuntimeEnvService).onApplicationBootstrap(); expect(registrar.register).not.toHaveBeenCalled(); }); it('AJOB-003 — a throwing check is contained to the Version check log line', async () => { const admin = { checkAndNotifyVersion: vi.fn().mockRejectedValue(new Error('github down')) } as unknown as AdminService; - const job = new VersionCheckJob(admin, registrarStub() as unknown as CronRegistrarService); + const job = new VersionCheckJob( + admin, + registrarStub() as unknown as CronRegistrarService, + { isManaged: () => false } as unknown as RuntimeEnvService, + ); await expect(job.tick()).resolves.toBeUndefined(); expect(logMock.logError).toHaveBeenCalledWith('Version check: github down'); }); diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts index 5409da503..e0e2139cf 100644 --- a/server/tests/unit/nest/admin.service.test.ts +++ b/server/tests/unit/nest/admin.service.test.ts @@ -56,7 +56,7 @@ vi.mock('../../../src/demo/demo-reset', () => ({ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createAdmin, createInviteToken } from '../../helpers/factories'; +import { createUser, createUserWithMfa, createAdmin, createInviteToken } from '../../helpers/factories'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; import { AddonsService } from '../../../src/nest/addons/addons.service'; @@ -76,6 +76,7 @@ import { NotificationsService } from '../../../src/nest/notifications/notificati import { AdminService } from '../../../src/nest/admin/admin.service'; import { VersionCheckJob } from '../../../src/nest/admin/version-check.job'; import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; import { __clearVersionCacheForTests } from '../../../src/nest/admin/admin.helpers'; import { makeNotificationsService, makeNotificationPreferencesService } from '../../helpers/notifications'; import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; @@ -485,6 +486,7 @@ describe('updateAddon', () => { describe('version-check job', () => { const registrarStub = { isEnabled: () => true, register: vi.fn(() => true), unregister: vi.fn() } as unknown as CronRegistrarService; + const envStub = { isManaged: () => false } as unknown as RuntimeEnvService; it('VCJOB-001 — the cron tick notifies and shares the module-scoped version cache with the route', async () => { createAdmin(testDb); @@ -496,7 +498,7 @@ describe('version-check job', () => { }); vi.stubGlobal('fetch', fetchMock); - await new VersionCheckJob(svc, registrarStub).tick(); + await new VersionCheckJob(svc, registrarStub, envStub).tick(); const notified = testDb .prepare('SELECT value FROM app_settings WHERE key = ?') @@ -527,3 +529,110 @@ describe('admin quirk fixes (post-fold)', () => { }); + +// ── What an admin password reset ends, and what an ordinary edit must not ───── + +const pv = (id: number): number => + (testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(id) as { password_version: number | null }) + ?.password_version ?? 0; + +const mcpTokenCount = (id: number): number => + (testDb.prepare('SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ?').get(id) as { n: number }).n; + +describe('admin password reset revokes what an intruder already holds', () => { + it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', () => { + // An admin sets somebody else's password for one reason: the account is + // believed compromised. Without the bump, verifyJwtAndLoadUser keeps + // accepting every cookie the intruder holds, and the one action taken to + // lock them out is the one action that did not. + const { user } = createUser(testDb); + const before = pv(user.id); + + updateUser(String(user.id), { password: 'ANewStrongPass123!' }); + + expect(pv(user.id)).toBe(before + 1); + }); + + it('ADMIN-SVC-081 — and clears the MCP tokens, which the version bump does not reach', () => { + const { user } = createUser(testDb); + testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id); + + updateUser(String(user.id), { password: 'ANewStrongPass123!' }); + + expect(mcpTokenCount(user.id)).toBe(0); + }); + + it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', () => { + const { user } = createUser(testDb); + const before = pv(user.id); + testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id); + + updateUser(String(user.id), { username: 'renamed' }); + + expect(pv(user.id)).toBe(before); + expect(mcpTokenCount(user.id)).toBe(1); + }); +}); + +describe('resetUserMfa', () => { + it('ADMIN-SVC-083 — clears the three columns disableMfa clears, so both paths leave one state', () => { + // The passkey half has existed since passkeys landed; TOTP never had an + // answer, which left "somebody on the trip lost their phone" with no way out + // short of an operator reaching into the database. + const admin = createAdmin(testDb); + const { user } = createUserWithMfa(testDb); + + const result = svc.resetUserMfa(String(user.id), admin.user.id) as { success?: boolean; email?: string }; + + expect(result.success).toBe(true); + expect(result.email).toBe(user.email); + const row = testDb + .prepare('SELECT mfa_enabled, mfa_secret, mfa_backup_codes FROM users WHERE id = ?') + .get(user.id) as { mfa_enabled: number; mfa_secret: string | null; mfa_backup_codes: string | null }; + expect(row.mfa_enabled).toBe(0); + expect(row.mfa_secret).toBeNull(); + expect(row.mfa_backup_codes).toBeNull(); + }); + + it('ADMIN-SVC-084 — refuses to strip the callers own second factor', () => { + // Reachable from a stolen admin session otherwise, and it would take the + // second factor off the very account that session came from. The + // self-service path in Settings asks for the current password. + const admin = createAdmin(testDb); + + const result = svc.resetUserMfa(String(admin.user.id), admin.user.id) as { error?: string; status?: number }; + + expect(result.status).toBe(400); + expect(result.error).toMatch(/your own/i); + }); + + it('ADMIN-SVC-085 — 404 for a user that is not there', () => { + const admin = createAdmin(testDb); + + expect(svc.resetUserMfa('99999', admin.user.id) as { status?: number }).toMatchObject({ status: 404 }); + }); +}); + +describe('checkVersion on a centrally administered install', () => { + afterEach(() => { + vi.unstubAllEnvs(); + __clearVersionCacheForTests(); + }); + + it('ADMIN-SVC-086 — answers "up to date" without asking GitHub', () => { + // Answered rather than refused: the admin page calls this on open, and a 403 + // would put an error where a quiet surface belongs. The operator decides when + // this instance upgrades, so from inside it there is nothing available. + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + vi.stubEnv('TREK_MANAGED', 'true'); + vi.stubEnv('APP_VERSION', '3.4.1'); + + return checkVersion().then((info) => { + expect(info.update_available).toBe(false); + expect(info.latest).toBe('3.4.1'); + expect(info.current).toBe('3.4.1'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/tests/unit/nest/backup.controller.test.ts b/server/tests/unit/nest/backup.controller.test.ts index 47733fe5e..4bd65f136 100644 --- a/server/tests/unit/nest/backup.controller.test.ts +++ b/server/tests/unit/nest/backup.controller.test.ts @@ -29,6 +29,7 @@ import type { AutoBackupJob } from '../../../src/nest/backup/auto-backup.job'; import type { AuditService } from '../../../src/nest/audit/audit.service'; import * as backupSvc from '../../../src/nest/backup/backup.impl'; import type { User } from '../../../src/types'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; const user = { id: 1, role: 'admin', email: 'a@example.test' } as User; const req = { ip: '1.2.3.4', headers: {} } as Request; @@ -42,7 +43,7 @@ const audit = { writeAudit } as unknown as AuditService; function job(o: Partial = {}): AutoBackupJob { return { getAutoSettings: vi.fn(), updateAutoSettings: vi.fn(), start: vi.fn(), ...o } as unknown as AutoBackupJob; } -const bc = (s: BackupService, j: AutoBackupJob = job()) => new BackupController(s, audit, j); +const bc = (s: BackupService, j: AutoBackupJob = job()) => new BackupController(s, audit, j, { isManaged: () => false } as unknown as RuntimeEnvService); function svc(o: Partial = {}): BackupService { return { diff --git a/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts b/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts index a2339b59d..c7eefd84b 100644 --- a/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts +++ b/server/tests/unit/nest/llm-parse/llm-parse.service.test.ts @@ -24,10 +24,11 @@ vi.mock('../../../../src/nest/llm-parse/router/extraction-router', () => ({ rout import { LlmParseService } from '../../../../src/nest/llm-parse/llm-parse.service'; import type { LlmConfigResolver } from '../../../../src/nest/llm-parse/llm-config.resolver'; +import type { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service'; const cfg = (over: Record = {}) => ({ provider: 'openai', model: 'm', multimodal: false, ...over }); const llmConfigStub = { resolve: resolveLlmConfig } as unknown as LlmConfigResolver; -const svc = () => new LlmParseService(llmConfigStub); +const svc = () => new LlmParseService(llmConfigStub, { isManaged: () => false } as unknown as RuntimeEnvService); const file = (name: string, body = 'Flight AB123') => ({ buffer: Buffer.from(body), originalName: name }); beforeEach(() => { diff --git a/server/tests/unit/nest/managed-ext.test.ts b/server/tests/unit/nest/managed-ext.test.ts new file mode 100644 index 000000000..4eb05addb --- /dev/null +++ b/server/tests/unit/nest/managed-ext.test.ts @@ -0,0 +1,36 @@ +/** + * The extension point is empty here, and adds nothing to the app. + * + * The value of this test is not that an empty module is empty. It is that the + * emptiness is checked at all: this is the seam where an install can attach its + * own screens, and the promise made to everyone else is that the public build + * carries none of them. A stray controller or provider slipping in would be + * invisible in review and obvious only to whoever it shipped to. + */ +import { describe, it, expect } from 'vitest'; +import { MODULE_METADATA } from '@nestjs/common/constants'; +import { Test } from '@nestjs/testing'; +import { ManagedExtModule } from '../../../src/nest/managed/managed-ext.module'; + +const meta = (key: string): unknown[] => (Reflect.getMetadata(key, ManagedExtModule) as unknown[]) ?? []; + +describe('ManagedExtModule', () => { + it('MANAGED-EXT-001: contributes no controllers, providers, imports or exports', () => { + expect({ + controllers: meta(MODULE_METADATA.CONTROLLERS), + providers: meta(MODULE_METADATA.PROVIDERS), + imports: meta(MODULE_METADATA.IMPORTS), + exports: meta(MODULE_METADATA.EXPORTS), + }).toEqual({ controllers: [], providers: [], imports: [], exports: [] }); + }); + + it('MANAGED-EXT-002: compiles as a module, so app.module can import it unconditionally', async () => { + // Unconditionally on purpose: a conditional import would tie the module graph + // to an env var, and the graph is built once per app while TREK_MANAGED is + // read live. An empty module costs nothing, so the flag never reaches here. + const built = await Test.createTestingModule({ imports: [ManagedExtModule] }).compile(); + + expect(built.get(ManagedExtModule, { strict: false })).toBeInstanceOf(ManagedExtModule); + await built.close(); + }); +}); diff --git a/server/tests/unit/nest/managed-inert.test.ts b/server/tests/unit/nest/managed-inert.test.ts new file mode 100644 index 000000000..1bcbef9f1 --- /dev/null +++ b/server/tests/unit/nest/managed-inert.test.ts @@ -0,0 +1,74 @@ +/** + * With TREK_MANAGED unset, nothing in this feature does anything. + * + * That is the promise the whole design rests on: the same image runs on a + * self-hosted install and on a centrally administered one, and the first must + * not be able to tell the second exists. Every other test here checks that the + * mode works; this one checks that its absence is total. + * + * Written as its own file rather than a case in each suite on purpose. Spread + * across eight files the question "does this change anything for a self-hoster" + * has eight partial answers; here it has one. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { + splitManagedKeys, + isManagedBlocked, + MANAGED_LOCKED_SETTING_KEYS, +} from '../../../src/nest/common/managed'; +import { deriveMaps, deriveManaged } from '../../../src/app-config/derive'; + +const env = new RuntimeEnvService(); + +afterEach(() => { + delete process.env.TREK_MANAGED; + delete process.env.PLACES_API_BASE; + delete process.env.PLACES_API_KEY; +}); + +describe('a self-hosted install', () => { + it('MANAGED-INERT-001: is not managed, and no other variable can make it so', () => { + // Only TREK_MANAGED decides. Setting the operator plumbing without it must + // not flip the mode, or an operator half-way through a migration would take + // settings away from admins without meaning to. + process.env.PLACES_API_BASE = 'https://places.example.test'; + process.env.PLACES_API_KEY = 'operator-key'; + + expect(env.isManaged()).toBe(false); + expect(isManagedBlocked(env)).toBe(false); + }); + + it('MANAGED-INERT-002: keeps every settings key writable', () => { + const body = Object.fromEntries(MANAGED_LOCKED_SETTING_KEYS.map((k) => [k, 'x'])); + + const { allowed, blocked } = splitManagedKeys(body, env.isManaged()); + + expect(blocked).toEqual([]); + expect(allowed).toEqual(body); + }); + + it('MANAGED-INERT-003: is the same object, not a copy, so no write path can diverge', () => { + // splitManagedKeys returns the body itself when the mode is off. If it ever + // started cloning, a caller mutating `allowed` would silently stop working + // on self-hosted installs only, which is the hardest kind of bug to find. + const body = { smtp_host: 'mail.example.test' }; + + expect(splitManagedKeys(body, false).allowed).toBe(body); + }); + + it('MANAGED-INERT-004: leaves the Places calls pointing at Google with the stored key', () => { + const maps = deriveMaps({}); + + expect(maps.placesApiBase).toBeUndefined(); + expect(maps.placesApiKey).toBeUndefined(); + }); + + it('MANAGED-INERT-005: treats an unparseable flag as off, never as on', () => { + // Fail closed in the direction that matters here: the failure mode of a typo + // must be "the admin keeps their settings", not "the admin quietly loses them". + for (const value of ['', ' ', 'maybe', 'TRUE_ISH', '2', 'null', 'undefined']) { + expect(deriveManaged({ TREK_MANAGED: value }).enabled, `TREK_MANAGED=${value}`).toBe(false); + } + }); +}); diff --git a/server/tests/unit/nest/managed-keys.test.ts b/server/tests/unit/nest/managed-keys.test.ts new file mode 100644 index 000000000..6318afd92 --- /dev/null +++ b/server/tests/unit/nest/managed-keys.test.ts @@ -0,0 +1,121 @@ +/** + * Who owns which setting, pinned. + * + * The lists grow and the mode does not: that is how this kind of switch drifts. + * A key added to ADMIN_SETTINGS_KEYS or DEFAULTABLE_USER_SETTING_KEYS belongs to + * neither set until somebody decides, and MANAGED-KEYS-001 is what makes that + * decision unavoidable rather than optional. + * + * Deliberately an assignment test, not an equality test. Pinning the locked list + * alone would pass forever while new keys quietly defaulted to "the admin may + * set it", which is the failure this is here to prevent. + */ +import { describe, it, expect } from 'vitest'; +import { ADMIN_SETTINGS_KEYS } from '../../../src/nest/auth/auth.helpers'; +import { DEFAULTABLE_USER_SETTING_KEYS } from '../../../src/nest/settings/settings.service'; +import { + MANAGED_LOCKED_SETTING_KEYS, + MANAGED_LOCKED_PROFILE_KEYS, + MANAGED_CUSTOMER_KEYS, + isManagedLockedKey, + splitManagedKeys, +} from '../../../src/nest/common/managed'; + +const SOURCE_KEYS: string[] = [...ADMIN_SETTINGS_KEYS, ...DEFAULTABLE_USER_SETTING_KEYS]; +const locked = new Set(MANAGED_LOCKED_SETTING_KEYS); +const customer = new Set(MANAGED_CUSTOMER_KEYS); + +describe('managed key assignment', () => { + it('MANAGED-KEYS-001: every key from both source lists is assigned to exactly one set', () => { + const unassigned = SOURCE_KEYS.filter((k) => !locked.has(k) && !customer.has(k)); + const both = SOURCE_KEYS.filter((k) => locked.has(k) && customer.has(k)); + + // Both in one assertion so the failure message names the offending key. + expect({ unassigned, both }).toEqual({ unassigned: [], both: [] }); + }); + + it('MANAGED-KEYS-002: no customer entry outlives the key it describes', () => { + expect(MANAGED_CUSTOMER_KEYS.filter((k) => !SOURCE_KEYS.includes(k))).toEqual([]); + }); + + it('MANAGED-KEYS-003: the only locked names outside both source lists are the users columns', () => { + expect(MANAGED_LOCKED_SETTING_KEYS.filter((k) => !SOURCE_KEYS.includes(k)).slice().sort()).toEqual( + [...MANAGED_LOCKED_PROFILE_KEYS].sort(), + ); + }); + + it('MANAGED-KEYS-004: neither set repeats itself', () => { + expect(locked.size).toBe(MANAGED_LOCKED_SETTING_KEYS.length); + expect(customer.size).toBe(MANAGED_CUSTOMER_KEYS.length); + }); + + it('MANAGED-KEYS-005: the locked list is pinned verbatim', () => { + expect([...MANAGED_LOCKED_SETTING_KEYS]).toEqual([ + 'llm_api_key', + 'llm_base_url', + 'llm_model', + 'llm_multimodal', + 'llm_provider', + 'mapbox_access_token', + 'maps_api_key', + 'oidc_login', + 'oidc_registration', + 'openweather_api_key', + 'smtp_from', + 'smtp_host', + 'smtp_pass', + 'smtp_port', + 'smtp_skip_tls_verify', + 'smtp_user', + 'unsplash_api_key', + 'webauthn_origins', + 'webauthn_rp_id', + ]); + }); + + it("MANAGED-KEYS-006: mapbox_access_token is the operator's, and reaches the browser by design", () => { + // The one locked key whose value is meant to be public: a managed instance + // ships the operator's pk.* token, injected when settings are read. Locked so + // a per-user save cannot land on top of it and break that user's map. + expect(isManagedLockedKey('mapbox_access_token')).toBe(true); + expect(customer.has('mapbox_access_token')).toBe(false); + }); +}); + +describe('splitManagedKeys', () => { + it('MANAGED-KEYS-007: hands the body back untouched on a self-hosted install', () => { + const body = { smtp_host: 'mail.example.test', allow_registration: 'true' }; + + expect(splitManagedKeys(body, false)).toEqual({ allowed: body, blocked: [] }); + }); + + it('MANAGED-KEYS-008: keeps the customer keys and reports the locked ones', () => { + // The admin settings tab saves SMTP and the registration toggles in one + // request. Refusing the request would take the toggles down with the SMTP. + const { allowed, blocked } = splitManagedKeys( + { smtp_host: 'mail.example.test', smtp_pass: 'hunter2', allow_registration: 'true' }, + true, + ); + + expect(allowed).toEqual({ allow_registration: 'true' }); + expect(blocked).toEqual(['smtp_host', 'smtp_pass']); + }); + + it('MANAGED-KEYS-009: reports blocked names sorted, so a response body is stable', () => { + const { blocked } = splitManagedKeys( + { webauthn_rp_id: 'x', llm_api_key: 'y', smtp_from: 'z' }, + true, + ); + + expect(blocked).toEqual(['llm_api_key', 'smtp_from', 'webauthn_rp_id']); + }); + + it('MANAGED-KEYS-010: an unknown key is not the filter’s business', () => { + // Whatever rejects unknown keys today keeps rejecting them; this only ever + // removes names it was told to remove. + const { allowed, blocked } = splitManagedKeys({ not_a_setting: '1' }, true); + + expect(allowed).toEqual({ not_a_setting: '1' }); + expect(blocked).toEqual([]); + }); +}); diff --git a/server/tests/unit/nest/managed.guard.test.ts b/server/tests/unit/nest/managed.guard.test.ts index ec0a716d3..671cfa089 100644 --- a/server/tests/unit/nest/managed.guard.test.ts +++ b/server/tests/unit/nest/managed.guard.test.ts @@ -16,9 +16,11 @@ import { ManagedGuard } from '../../../src/nest/common/managed.guard'; import { MANAGED_FORBIDDEN, MANAGED_FORBIDDEN_ERROR } from '../../../src/nest/common/managed'; import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; -function makeGuard(managed: boolean, marked: boolean) { +function makeGuard(managed: boolean, marked: boolean, enforcedInHandler = false) { const getAllAndOverride = vi.fn((key: string) => - key === MANAGED_FORBIDDEN && marked ? { reason: 'the operator holds this' } : undefined, + key === MANAGED_FORBIDDEN && marked + ? { reason: 'the operator holds this', enforcedInHandler } + : undefined, ); const guard = new ManagedGuard( { isManaged: () => managed } as unknown as RuntimeEnvService, @@ -70,6 +72,15 @@ describe('ManagedGuard', () => { expect(getAllAndOverride).not.toHaveBeenCalled(); }); + it('MANAGED-GUARD-006: lets a handler-enforced marking through, so multipart still parses', () => { + // The route is marked and the mode is on, and it still passes: a guard that + // threw here would leave the upload body unread and the client would see an + // ECONNRESET instead of the 403 (PROFILE-015). The handler does the refusing. + const { guard } = makeGuard(true, true, true); + + expect(guard.canActivate(ctx)).toBe(true); + }); + it('MANAGED-GUARD-005: asks for the decorator on handler and controller, not for a path', () => { const { guard, getAllAndOverride } = makeGuard(true, false); guard.canActivate(ctx); diff --git a/server/tests/unit/nest/managed.test.ts b/server/tests/unit/nest/managed.test.ts index f30a375a9..5b8e5ce43 100644 --- a/server/tests/unit/nest/managed.test.ts +++ b/server/tests/unit/nest/managed.test.ts @@ -81,6 +81,18 @@ describe('ManagedForbidden', () => { } const meta = Reflect.getMetadata(MANAGED_FORBIDDEN, Controller.prototype.handler); - expect(meta).toEqual({ reason: 'the operator holds this credential' }); + expect(meta).toEqual({ reason: 'the operator holds this credential', enforcedInHandler: false }); + }); + + it('MANAGED-009: a multipart route stays in the inventory while refusing in its handler', () => { + class Controller { + @ManagedForbidden('restoring a backup replaces state the operator owns', { enforcedInHandler: true }) + upload() {} + } + + const meta = Reflect.getMetadata(MANAGED_FORBIDDEN, Controller.prototype.upload); + expect(meta.enforcedInHandler).toBe(true); + // The reason is still mandatory: the boot gate refuses an empty one. + expect(meta.reason).not.toBe(''); }); }); diff --git a/server/tests/unit/nest/oidc-ssrf.test.ts b/server/tests/unit/nest/oidc-ssrf.test.ts new file mode 100644 index 000000000..8f905db08 --- /dev/null +++ b/server/tests/unit/nest/oidc-ssrf.test.ts @@ -0,0 +1,52 @@ +/** + * The four OIDC provider calls go through the SSRF guard. + * + * Three of them are worse than they look: `discover` fetches a URL an admin + * configured, but the token, userinfo and JWKS URLs come out of the RESPONSE to + * that first call. They are chosen by the other end, not by anyone here, and + * until now all four went to a bare `fetch`. + * + * Deliberately a separate file. oidc.service.test.ts stubs global fetch and + * mocks the guard away so it can test OIDC logic; that is the right trade there + * and it is also exactly why the guard needs pinning somewhere it is not mocked. + * + * safeFetchAdminConfigured rather than safeFetch: a self-hoster running Authentik + * on http://localhost:9000 must keep working, so loopback and LAN stay allowed + * while link-local and the cloud-metadata range never are. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const SOURCE = readFileSync( + join(__dirname, '../../../src/nest/oidc/oidc.service.ts'), + 'utf8', +); + +describe('OIDC outbound calls', () => { + it('OIDC-SSRF-001: no bare fetch( is left in the service', () => { + // A grep, and that is the point: the failure this prevents is somebody adding + // a fifth provider call next year with the same `await fetch(...)` the other + // four used to have. A behavioural test would only cover the four that exist. + const bare = SOURCE.split('\n') + .map((line, i) => ({ line: line.trim(), n: i + 1 })) + .filter(({ line }) => /\bawait fetch\(|[^.\w]fetch\(/.test(line) && !line.startsWith('*')); + + expect(bare.map((b) => `${b.n}: ${b.line.slice(0, 70)}`)).toEqual([]); + }); + + it('OIDC-SSRF-002: all four calls name the guard', () => { + const guarded = SOURCE.match(/safeFetchAdminConfigured\(/g) ?? []; + + // discover, exchangeCodeForToken, getUserInfo, fetchJwks. + expect(guarded).toHaveLength(4); + }); + + it('OIDC-SSRF-003: the token exchange follows no redirect at all', () => { + // A redirect there would hand client_secret to a second host, and the + // platform default is to follow. The third argument is the hop budget. + const tokenCall = /safeFetchAdminConfigured\(\s*doc\.token_endpoint[\s\S]*?\}\s*,\s*0\s*\)/.test(SOURCE); + + expect(tokenCall).toBe(true); + }); +}); diff --git a/server/tests/unit/nest/oidc.service.test.ts b/server/tests/unit/nest/oidc.service.test.ts index 060b5423c..e9f9d6203 100644 --- a/server/tests/unit/nest/oidc.service.test.ts +++ b/server/tests/unit/nest/oidc.service.test.ts @@ -63,6 +63,18 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ })); vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() })); vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); +// The four provider calls go through the SSRF guard now (link-local and the +// cloud-metadata range are refused, every redirect hop re-checked). These tests +// exercise OIDC logic, not the guard, so the guard delegates to whatever fetch +// the case stubbed — and OIDC-SVC-SSRF-001 below pins that it is really there. +vi.mock('../../../src/utils/ssrfGuard', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + safeFetchAdminConfigured: vi.fn((url: string, init?: RequestInit) => fetch(url, init)), + }; +}); + const { getAppUrlMock } = vi.hoisted(() => ({ getAppUrlMock: vi.fn() })); vi.mock('../../../src/app-config', async (importOriginal) => { diff --git a/server/tests/unit/nest/settings.controller.test.ts b/server/tests/unit/nest/settings.controller.test.ts index 654f9d0e0..030cf495c 100644 --- a/server/tests/unit/nest/settings.controller.test.ts +++ b/server/tests/unit/nest/settings.controller.test.ts @@ -4,6 +4,7 @@ import { HttpException } from '@nestjs/common'; import { SettingsController } from '../../../src/nest/settings/settings.controller'; import type { SettingsService } from '../../../src/nest/settings/settings.service'; import type { User } from '../../../src/types'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; const user = { id: 1, role: 'user', email: 'u@example.test' } as User; @@ -24,7 +25,7 @@ beforeEach(() => vi.clearAllMocks()); describe('SettingsController', () => { it('GET / returns the settings', () => { - expect(new SettingsController(svc({ getUserSettings: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial)).list(user)).toEqual({ settings: { theme: 'dark' } }); + expect(new SettingsController(svc({ getUserSettings: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial), { isManaged: () => false } as unknown as RuntimeEnvService).list(user)).toEqual({ settings: { theme: 'dark' } }); }); // The 400-without-a-key path moved to the global ZodValidationPipe (via @@ -32,14 +33,14 @@ describe('SettingsController', () => { it('PUT / no-ops on the masked sentinel without writing', () => { const upsertSetting = vi.fn(); - const c = new SettingsController(svc({ upsertSetting } as Partial)); + const c = new SettingsController(svc({ upsertSetting } as Partial), { isManaged: () => false } as unknown as RuntimeEnvService); expect(c.upsert(user, { key: 'immich_api_key', value: '••••••••' })).toEqual({ success: true, key: 'immich_api_key', unchanged: true }); expect(upsertSetting).not.toHaveBeenCalled(); }); it('PUT / writes a real value', () => { const upsertSetting = vi.fn(); - const c = new SettingsController(svc({ upsertSetting } as Partial)); + const c = new SettingsController(svc({ upsertSetting } as Partial), { isManaged: () => false } as unknown as RuntimeEnvService); expect(c.upsert(user, { key: 'theme', value: 'dark' })).toEqual({ success: true, key: 'theme', value: 'dark' }); expect(upsertSetting).toHaveBeenCalledWith(1, 'theme', 'dark'); }); @@ -48,7 +49,7 @@ describe('SettingsController', () => { // The 400-without-an-object path moved to the global ZodValidationPipe // (via SettingsBulkDto) — pinned by the settings e2e, not reachable here. vi.spyOn(console, 'error').mockImplementation(() => {}); - expect(thrown(() => new SettingsController(svc({ bulkUpsertSettings: vi.fn(() => { throw new Error('db'); }) } as Partial)).bulk(user, { settings: { a: 1 } }))).toEqual({ status: 500, body: { error: 'Error saving settings' } }); - expect(new SettingsController(svc({ bulkUpsertSettings: vi.fn().mockReturnValue(3) } as Partial)).bulk(user, { settings: { a: 1, b: 2, c: 3 } })).toEqual({ success: true, updated: 3 }); + expect(thrown(() => new SettingsController(svc({ bulkUpsertSettings: vi.fn(() => { throw new Error('db'); }) } as Partial), { isManaged: () => false } as unknown as RuntimeEnvService).bulk(user, { settings: { a: 1 } }))).toEqual({ status: 500, body: { error: 'Error saving settings' } }); + expect(new SettingsController(svc({ bulkUpsertSettings: vi.fn().mockReturnValue(3) } as Partial), { isManaged: () => false } as unknown as RuntimeEnvService).bulk(user, { settings: { a: 1, b: 2, c: 3 } })).toEqual({ success: true, updated: 3 }); }); }); diff --git a/server/tests/unit/nest/system-notices.service.test.ts b/server/tests/unit/nest/system-notices.service.test.ts index aef80e72c..9f4ed3943 100644 --- a/server/tests/unit/nest/system-notices.service.test.ts +++ b/server/tests/unit/nest/system-notices.service.test.ts @@ -16,9 +16,10 @@ vi.mock('../../../src/systemNotices/service', () => ({ import { SystemNoticesService } from '../../../src/nest/system-notices/system-notices.service'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; const isAddonEnabled = vi.fn((id: string) => id === 'journey'); -const svc = new SystemNoticesService({ isAddonEnabled } as unknown as AddonsService); +const svc = new SystemNoticesService({ isAddonEnabled } as unknown as AddonsService, { isManaged: () => false } as unknown as RuntimeEnvService); beforeEach(() => { mockGetActive.mockReset(); @@ -30,7 +31,7 @@ describe('SystemNoticesService', () => { it('getActiveFor threads a live addonEnabled check over the injected AddonsService', () => { mockGetActive.mockReturnValue([]); svc.getActiveFor(7); - expect(mockGetActive).toHaveBeenCalledWith(7, expect.any(Function)); + expect(mockGetActive).toHaveBeenCalledWith(7, expect.any(Function), false); const addonEnabled = mockGetActive.mock.calls[0][1] as (id: string) => boolean; expect(addonEnabled('journey')).toBe(true); expect(addonEnabled('vacay')).toBe(false); @@ -51,10 +52,10 @@ describe('SystemNoticesService', () => { vi.resetModules(); vi.doMock('../../../src/nest/addons/addons.service', () => ({ AddonsService: undefined })); const { SystemNoticesService: Reloaded } = await import('../../../src/nest/system-notices/system-notices.service'); - const inst = new Reloaded({ isAddonEnabled } as unknown as AddonsService); + const inst = new Reloaded({ isAddonEnabled } as unknown as AddonsService, { isManaged: () => false } as unknown as RuntimeEnvService); mockGetActive.mockReturnValue([]); inst.getActiveFor(1); - expect(mockGetActive).toHaveBeenCalledWith(1, expect.any(Function)); + expect(mockGetActive).toHaveBeenCalledWith(1, expect.any(Function), false); vi.doUnmock('../../../src/nest/addons/addons.service'); }); }); diff --git a/server/tests/unit/nest/validate-managed-routes.test.ts b/server/tests/unit/nest/validate-managed-routes.test.ts new file mode 100644 index 000000000..c7cfa8f66 --- /dev/null +++ b/server/tests/unit/nest/validate-managed-routes.test.ts @@ -0,0 +1,169 @@ +/** + * The boot gate for the managed surface + * (src/nest/common/validate-managed-routes.ts). + * + * Same ratchet shape as the anonymous-route gate next door, with one case it + * does not have: a marker without a reason. That one matters most. An unlisted + * or stale entry is a bookkeeping error somebody notices at the next boot, but a + * blank reason destroys the only record of WHY a control was taken away from a + * customer, and nothing recovers that afterwards. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { Controller, Get, Post } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import type { INestApplication } from '@nestjs/common'; +import { ManagedForbidden } from '../../../src/nest/common/managed'; +import { + collectManagedRoutes, + validateManagedRoutes, +} from '../../../src/nest/common/validate-managed-routes'; + +@Controller('managed-gate/plain') +class PlainController { + @Get() + list() { + return []; + } +} + +@Controller('managed-gate/marked') +class MarkedController { + @ManagedForbidden('the operator supplies this credential') + @Get('key') + key() { + return {}; + } + + @ManagedForbidden('replacing state the operator owns', { enforcedInHandler: true }) + @Post('upload') + upload() { + return {}; + } + + @Get('untouched') + untouched() { + return {}; + } +} + +@Controller('managed-gate/blank') +class BlankReasonController { + @ManagedForbidden(' ') + @Get() + vague() { + return {}; + } +} + +/** A whole controller marked at class level: every route below inherits it. */ +@ManagedForbidden('this entire surface belongs to the operator') +@Controller('managed-gate/whole') +class WholeController { + @Get('a') + a() { + return {}; + } + + @Get('b') + b() { + return {}; + } +} + +async function buildApp(controllers: unknown[]): Promise { + const moduleRef = await Test.createTestingModule({ + controllers: controllers as never[], + }).compile(); + return moduleRef.createNestApplication(); +} + +describe('collectManagedRoutes', () => { + let markedApp: INestApplication; + let wholeApp: INestApplication; + + beforeAll(async () => { + markedApp = await buildApp([PlainController, MarkedController]); + wholeApp = await buildApp([WholeController]); + }); + + afterAll(async () => { + await Promise.all([markedApp.close(), wholeApp.close()]); + }); + + it('MANAGED-BOOT-001: inventories the marked routes and leaves the rest alone', () => { + const entries = collectManagedRoutes(markedApp); + + expect(entries.map((e) => e.id)).toEqual([ + 'MarkedController.key', + 'MarkedController.upload', + ]); + }); + + it('MANAGED-BOOT-002: carries the reason and the handler-enforced flag through', () => { + const entries = collectManagedRoutes(markedApp); + + expect(entries[0]).toEqual({ + id: 'MarkedController.key', + reason: 'the operator supplies this credential', + enforcedInHandler: false, + }); + expect(entries[1].enforcedInHandler).toBe(true); + }); + + it('MANAGED-BOOT-003: a class-level marking covers every route under it', () => { + const entries = collectManagedRoutes(wholeApp); + + expect(entries.map((e) => e.id)).toEqual(['WholeController.a', 'WholeController.b']); + expect(entries.every((e) => e.reason === 'this entire surface belongs to the operator')).toBe(true); + }); +}); + +describe('validateManagedRoutes (fail-closed boot gate)', () => { + let markedApp: INestApplication; + let blankApp: INestApplication; + let plainApp: INestApplication; + + beforeAll(async () => { + markedApp = await buildApp([PlainController, MarkedController]); + blankApp = await buildApp([BlankReasonController]); + plainApp = await buildApp([PlainController]); + }); + + afterAll(async () => { + await Promise.all([markedApp.close(), blankApp.close(), plainApp.close()]); + }); + + it('MANAGED-BOOT-004: passes when the list matches the markings', () => { + expect(() => + validateManagedRoutes(markedApp, ['MarkedController.key', 'MarkedController.upload']), + ).not.toThrow(); + }); + + it('MANAGED-BOOT-005: throws naming a marking nobody put on the list', () => { + expect(() => validateManagedRoutes(markedApp, ['MarkedController.key'])).toThrow( + /MarkedController\.upload/, + ); + }); + + it('MANAGED-BOOT-006: throws on a stale entry, so the list cannot rot', () => { + expect(() => validateManagedRoutes(plainApp, ['MarkedController.key'])).toThrow( + /no longer marked/, + ); + }); + + it('MANAGED-BOOT-007: throws when a marking has no reason', () => { + expect(() => validateManagedRoutes(blankApp, ['BlankReasonController.vague'])).toThrow( + /without a reason/, + ); + }); + + it('MANAGED-BOOT-008: an install with no markings and an empty list is fine', () => { + expect(() => validateManagedRoutes(plainApp, [])).not.toThrow(); + }); + + it('MANAGED-BOOT-009: the failure explains what an entry costs a customer', () => { + // The message is the only thing a reviewer sees at 2am, so it says what the + // list means rather than just which line is wrong. + expect(() => validateManagedRoutes(markedApp, [])).toThrow(/withholds from its own admin/); + }); +}); diff --git a/server/tests/unit/plugins/plugin-retrust.controller.test.ts b/server/tests/unit/plugins/plugin-retrust.controller.test.ts index 1767439d7..f644ef3b3 100644 --- a/server/tests/unit/plugins/plugin-retrust.controller.test.ts +++ b/server/tests/unit/plugins/plugin-retrust.controller.test.ts @@ -20,6 +20,7 @@ import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.s import { PluginRegistryService, RegistryError } from '../../../src/nest/plugins/registry/registry.service'; import { PluginsService } from '../../../src/nest/plugins/plugins.service'; import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; const ADMIN = { id: 1 } as { id: number }; const REQ = { headers: {}, socket: {} } as never; @@ -52,7 +53,7 @@ beforeEach(() => { process.env.TREK_PLUGINS_ENABLED = 'true'; runtime = { update: vi.fn(), retrust: vi.fn() } as unknown as PluginRuntimeService; registry = { install: vi.fn() } as unknown as PluginRegistryService; - controller = new PluginsController({} as PluginsService, runtime, registry); + controller = new PluginsController({} as PluginsService, runtime, registry, { isManaged: () => false } as unknown as RuntimeEnvService); }); describe('signature refusal codes survive to the client', () => { diff --git a/server/tests/unit/plugins/plugins-controller-devlink.test.ts b/server/tests/unit/plugins/plugins-controller-devlink.test.ts index 27940f676..9d9314948 100644 --- a/server/tests/unit/plugins/plugins-controller-devlink.test.ts +++ b/server/tests/unit/plugins/plugins-controller-devlink.test.ts @@ -8,9 +8,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; import { PluginsController } from '../../../src/nest/plugins/plugins.controller'; import { PluginConsentRequired } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -const ctrl = (runtime: any) => new PluginsController({} as any, runtime, {} as any); +const ctrl = (runtime: any) => new PluginsController({} as any, runtime, {} as any, { isManaged: () => false } as unknown as RuntimeEnvService); // eslint-disable-next-line @typescript-eslint/no-explicit-any const status = async (p: Promise): Promise => p.then(() => -1, (e) => (e instanceof HttpException ? e.getStatus() : 0)); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 6c6528e46..45f10baa5 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -24,6 +24,7 @@ import { AddonsService } from '../../../src/nest/addons/addons.service'; import { PluginsService } from '../../../src/nest/plugins/plugins.service'; import { PluginsController } from '../../../src/nest/plugins/plugins.controller'; import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; beforeEach(() => { testDb.exec('DELETE FROM plugins'); @@ -130,7 +131,7 @@ describe('PluginsService.list', () => { it('controller delegates to the service', () => { const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService; const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService; - const res = new PluginsController(svc, runtime, {} as never).list(); + const res = new PluginsController(svc, runtime, {} as never, { isManaged: () => false } as unknown as RuntimeEnvService).list(); expect(svc.list).toHaveBeenCalled(); expect(res).toEqual({ enabled: false, plugins: [] }); }); @@ -199,6 +200,9 @@ describe('PluginsController M2 endpoints', () => { getInstanceConfig: vi.fn(() => ({ a: 1 })), updateInstanceConfig: vi.fn(() => ({ a: 2 })), } as unknown as PluginsService; + // None of the endpoints below carry the marker, so the ordinary install is the + // whole story here; the refusals have their own tests. + const envStub = { isManaged: () => false } as unknown as RuntimeEnvService; beforeEach(() => { (svc.getInstanceConfig as ReturnType).mockClear(); @@ -208,32 +212,32 @@ describe('PluginsController M2 endpoints', () => { it('get/update config delegate to the service', () => { const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never; - const c = new PluginsController(svc, rt, {} as never); + const c = new PluginsController(svc, rt, {} as never, envStub); expect(c.getConfig('x')).toEqual({ config: { a: 1 } }); expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } }); }); it('activate spawns via the runtime when enabled', async () => { const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never; - const out = await new PluginsController(svc, rt, {} as never).activate('x', {}); + const out = await new PluginsController(svc, rt, {} as never, envStub).activate('x', {}); expect(out).toEqual({ status: 'active' }); }); it('activate is 503 when the runtime is disabled', async () => { process.env.TREK_PLUGINS_ENABLED = 'false'; const rt = { activate: vi.fn(), isActive: vi.fn() } as never; - await expect(new PluginsController(svc, rt, {} as never).activate('x', {})).rejects.toMatchObject({ status: 503 }); + await expect(new PluginsController(svc, rt, {} as never, envStub).activate('x', {})).rejects.toMatchObject({ status: 503 }); }); it('activate surfaces an activation error as 400', async () => { const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never; - await expect(new PluginsController(svc, rt, {} as never).activate('x', {})).rejects.toMatchObject({ status: 400 }); + await expect(new PluginsController(svc, rt, {} as never, envStub).activate('x', {})).rejects.toMatchObject({ status: 400 }); }); it('deactivate stops the plugin (and cascades to dependents)', async () => { const deactivateWithDependents = vi.fn(async () => ['x']); const rt = { deactivateWithDependents } as never; - expect(await new PluginsController(svc, rt, {} as never).deactivate('x')).toEqual({ status: 'inactive' }); + expect(await new PluginsController(svc, rt, {} as never, envStub).deactivate('x')).toEqual({ status: 'inactive' }); expect(deactivateWithDependents).toHaveBeenCalledWith('x'); }); }); diff --git a/server/tests/unit/systemNotices/conditions.test.ts b/server/tests/unit/systemNotices/conditions.test.ts index 2a9d108f6..e6188c2df 100644 --- a/server/tests/unit/systemNotices/conditions.test.ts +++ b/server/tests/unit/systemNotices/conditions.test.ts @@ -28,6 +28,9 @@ const baseCtx = { // Threaded in by the caller since the addons.bridge import left this module; // false keeps every case below reading as it did. addonEnabled: () => false, + // The ordinary install, so every case below is unaffected by the flag; the + // managed condition itself is exercised in its own describe block at the end. + managed: false, }; describe('firstLogin', () => { @@ -112,3 +115,18 @@ describe('addonEnabled', () => { expect(evaluate(notice, baseCtx)).toBe(false); }); }); + +describe('managed', () => { + it('matches on the value asked for, in both directions', () => { + // Both directions on purpose: the registry uses `is: false` to keep a notice + // away from a centrally administered install, and a notice that only ever + // shows there would use `is: true`. + const wantsSelfRun = noticeWith({ kind: 'managed', is: false }); + const wantsManaged = noticeWith({ kind: 'managed', is: true }); + + expect(evaluate(wantsSelfRun, { ...baseCtx, managed: false })).toBe(true); + expect(evaluate(wantsSelfRun, { ...baseCtx, managed: true })).toBe(false); + expect(evaluate(wantsManaged, { ...baseCtx, managed: true })).toBe(true); + expect(evaluate(wantsManaged, { ...baseCtx, managed: false })).toBe(false); + }); +}); diff --git a/shared/scripts/i18n-selfhost-wording.mjs b/shared/scripts/i18n-selfhost-wording.mjs new file mode 100644 index 000000000..1d882ae03 --- /dev/null +++ b/shared/scripts/i18n-selfhost-wording.mjs @@ -0,0 +1,84 @@ +/** + * Reads the canonical locale and reports every line that calls TREK self-hosted. + * + * Plain .mjs and outside `src/` for the same reason as i18n-parity.mjs next to + * it: shared's typecheck runs with only shared's own dependencies installed + * (`npm ci --workspace shared`), so there is no @types/node and a `node:fs` + * import inside `src/` fails to compile. The spec imports this the same way the + * parity spec imports its script. + * + * Runnable on its own — `node scripts/i18n-selfhost-wording.mjs` prints the hits + * — which is what you want when a CI failure names a line you have to find. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const EN_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'i18n', 'en'); + +/** Case-insensitive, and covers the hyphenated, spaced and joined spellings. */ +export const FORBIDDEN = /self[-\s]?hosted/i; + +/** + * The strings that keep the phrase, each with the reason it is right where it is. + * + * Keyed on the translation key rather than the file, so an exemption covers one + * sentence instead of everything that happens to live beside it. Keep the list + * short: it is the only way this rule can be worn down. + */ +export const ALLOWED = [ + { + key: 'settings.about.description', + because: + 'the self-description shown to somebody who set TREK up themselves, and it is ' + + 'accurate for them. A centrally administered install renders ' + + 'settings.about.descriptionManaged instead, so neither reader is told the ' + + 'wrong thing and self-hosters lose nothing.', + }, + { + key: 'system_notice.v3_thankyou.body', + because: + 'a dated message from the author about the project, pinned to one release, ' + + 'and shown only where the managed condition is false', + }, +]; + +/** + * Walks the canonical locale and reports every line carrying the phrase, + * attributed to the key it belongs to. + * + * A value can span several lines, so the key is the last one seen at or above + * the hit rather than something parsed out of the hit itself. + */ +export function collectSelfhostHits(dir = EN_DIR) { + const hits = []; + for (const name of fs.readdirSync(dir)) { + if (!name.endsWith('.ts')) continue; + const lines = fs.readFileSync(path.join(dir, name), 'utf8').split('\n'); + let key = ''; + lines.forEach((text, i) => { + const declared = /^\s*'([^']+)'\s*:/.exec(text); + if (declared) key = declared[1]; + if (FORBIDDEN.test(text)) hits.push({ file: name, line: i + 1, key, text: text.trim() }); + }); + } + return hits; +} + +/** Hits that no exemption covers — the ones that fail the build. */ +export function findOffenders(dir = EN_DIR) { + const allowed = new Set(ALLOWED.map((a) => a.key)); + return collectSelfhostHits(dir).filter((h) => !allowed.has(h.key)); +} + +// pathToFileURL rather than a `file://` template: on Windows argv[1] arrives with +// backslashes and a drive letter, so the plain comparison never matches and the +// script silently does nothing when run by hand. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const offenders = findOffenders(); + for (const h of offenders) { + console.log(`${h.file}:${h.line} [${h.key}] ${h.text.slice(0, 80)}`); + } + console.log(offenders.length === 0 ? 'Wording: OK' : `Wording: ${offenders.length} offender(s)`); + process.exit(offenders.length === 0 ? 0 : 1); +} diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 553265c01..a3269385a 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -195,6 +195,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'اقتراح ميزة', 'settings.about.featureRequestHint': 'اقترح ميزة جديدة', 'settings.about.wikiHint': 'التوثيق والأدلة', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'الداعمون الشهريون', 'settings.about.supporters.title': 'رفاق رحلة TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 8132af344..1c33ca5ab 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -97,6 +97,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Solicitar recurso', 'settings.about.featureRequestHint': 'Sugira um novo recurso', 'settings.about.wikiHint': 'Documentação e guias', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Apoiadores Mensais', 'settings.about.supporters.title': 'Companheiros de viagem do TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 701736b04..7b16a8368 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -166,6 +166,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Sol·licita una funció', 'settings.about.featureRequestHint': 'Suggereix una funció nova', 'settings.about.wikiHint': 'Documentació i guies', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Patrocinadors Mensuals', 'settings.about.supporters.title': 'Companyia de viatge per a TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 85b37b020..bb6a14574 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -180,6 +180,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Navrhnout funkci', 'settings.about.featureRequestHint': 'Navrhněte novou funkci', 'settings.about.wikiHint': 'Dokumentace a návody', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Měsíční podporovatelé', 'settings.about.supporters.title': 'Společníci na cestě s TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index 566cbeb29..d6c1cdcd9 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -184,6 +184,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Feature vorschlagen', 'settings.about.featureRequestHint': 'Schlage ein neues Feature vor', 'settings.about.wikiHint': 'Dokumentation & Anleitungen', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Monatliche Unterstützer', 'settings.about.supporters.title': 'Reisebegleitung für TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 8f561f8c2..b5d4696ca 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -245,7 +245,7 @@ const admin: TranslationStrings = { 'admin.plugins.errorLog': 'Error log', 'admin.plugins.allowedHosts': 'Allowed hosts', 'admin.plugins.allowedHosts.hint': - 'This plugin talks to a service only you can name (a self-hosted server). Add the hosts it may reach — it can reach no others.', + 'This plugin talks to a service only you can name. Add the hosts it may reach — it can reach no others.', 'admin.plugins.allowedHosts.none': 'No hosts added yet.', 'admin.plugins.allowedHosts.unsupported': 'This plugin does not use operator-supplied hosts. Its allowed hosts are fixed in its manifest.', @@ -254,7 +254,7 @@ const admin: TranslationStrings = { 'admin.plugins.allowedHosts.count': '{n} allowed host(s)', 'admin.plugins.operatorEgressPill': '+ hosts you add', 'admin.plugins.operatorEgressHint': - 'This plugin talks to a service only you can name (a self-hosted server). After installing, add the hosts it may reach under ⋯ → Allowed hosts. It can reach no others.', + 'This plugin talks to a service only you can name. After installing, add the hosts it may reach under ⋯ → Allowed hosts. It can reach no others.', 'admin.plugins.noErrors': 'No errors logged.', 'admin.plugins.uninstalled': 'Plugin uninstalled', 'admin.plugins.uninstallTitle': 'Uninstall plugin?', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index e22e42418..eef9a0df3 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -227,6 +227,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Feature Request', 'settings.about.featureRequestHint': 'Suggest a new feature', 'settings.about.wikiHint': 'Documentation & guides', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Monthly Supporters', 'settings.about.supporters.title': 'Travel companions for TREK', 'settings.about.supporters.subtitle': @@ -334,7 +338,7 @@ const settings: TranslationStrings = { 'settings.passkey.neverUsed': 'Never used', 'settings.airtrail.title': 'AirTrail', 'settings.airtrail.hint': - 'Connect your self-hosted AirTrail to import and sync flights. Create an API key in AirTrail under Settings → Security.', + 'Connect your AirTrail instance to import and sync flights. Create an API key in AirTrail under Settings → Security.', 'settings.airtrail.url': 'Instance URL', 'settings.airtrail.apiKey': 'API key', 'settings.airtrail.apiKeyPlaceholder': 'Bearer API key', @@ -359,7 +363,7 @@ const settings: TranslationStrings = { 'settings.aiParsing.providerOpenai': 'OpenAI', 'settings.aiParsing.providerAnthropic': 'Anthropic', 'settings.aiParsing.localAdminOnly': - 'A self-hosted (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.', + 'A local (Ollama) endpoint is set up once for the whole instance in the admin settings. You can still use your own OpenAI or Anthropic key here.', 'settings.aiParsing.model': 'Model', 'settings.aiParsing.baseUrl': 'Base URL', 'settings.aiParsing.baseUrlHint': 'Where the model runs — a local Ollama server or an OpenAI-compatible endpoint.', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 7ab8cf2ea..8f6dc2fe3 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -183,6 +183,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Solicitar función', 'settings.about.featureRequestHint': 'Sugiere una nueva función', 'settings.about.wikiHint': 'Documentación y guías', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Patrocinadores Mensuales', 'settings.about.supporters.title': 'Compañía de viaje para TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 261f36b8f..3b0a82326 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -184,6 +184,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Proposer une fonctionnalité', 'settings.about.featureRequestHint': 'Suggérez une nouvelle fonctionnalité', 'settings.about.wikiHint': 'Documentation et guides', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Soutiens Mensuels', 'settings.about.supporters.title': 'Compagnons de voyage pour TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 849be4dcf..ee5747dea 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -227,6 +227,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Αίτημα Λειτουργίας', 'settings.about.featureRequestHint': 'Προτείνετε ένα νέο χαρακτηριστικό', 'settings.about.wikiHint': 'Τεκμηρίωση & οδηγοί', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Μηνιαίοι Υποστηρικτές', 'settings.about.supporters.title': 'Συνταξιδιώτες για το TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 06212b52f..939080548 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -182,6 +182,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Funkció javaslat', 'settings.about.featureRequestHint': 'Javasolj egy új funkciót', 'settings.about.wikiHint': 'Dokumentáció és útmutatók', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Havi támogatók', 'settings.about.supporters.title': 'Útitársak a TREK mellett', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/i18n-selfhost-wording.spec.ts b/shared/src/i18n/i18n-selfhost-wording.spec.ts new file mode 100644 index 000000000..d54aa8858 --- /dev/null +++ b/shared/src/i18n/i18n-selfhost-wording.spec.ts @@ -0,0 +1,53 @@ +/** + * The canonical locale must not describe TREK as self-hosted. + * + * Not a style rule. The same build runs on an install its admin set up and on + * one somebody else operates, and a string that assumes the first is simply + * wrong on the second: it tells a reader to use their own server, check their + * own logs, or update something they cannot reach. + * + * This exists because the parity check cannot catch it. That one compares the + * file set and the top-level keys across all 23 locales and never looks at a + * value, so re-wording a string is invisible to CI and so is putting the old + * wording back. + * + * Scoped to `en/` on purpose: it is the canonical source every translation is + * made from. Ordering the other 22 into line with a regex would mean editing + * languages nobody here can read, and a translation still carrying the older + * phrasing is a translation lagging behind, not a broken build. + * + * The walk itself lives in scripts/, like the parity check next door — shared + * typechecks with only its own dependencies installed, so `node:fs` cannot be + * imported from `src/`. + */ +// @ts-expect-error — plain .mjs script with no .d.ts; import as JS module. +import { ALLOWED, collectSelfhostHits, findOffenders } from '../../scripts/i18n-selfhost-wording.mjs'; + +import { describe, it, expect } from 'vitest'; + +interface Hit { + file: string; + line: number; + key: string; + text: string; +} + +describe('canonical locale wording', () => { + it('I18N-SELFHOST-001: no en/ string calls TREK self-hosted', () => { + const offenders = (findOffenders() as Hit[]).map((h) => `${h.file}:${h.line} [${h.key}] ${h.text.slice(0, 80)}`); + + expect(offenders).toEqual([]); + }); + + it('I18N-SELFHOST-002: every exemption still covers a string that exists', () => { + // A stale exemption is worse than none: it reads as a reviewed decision long + // after the string it covered was rewritten. + const hits = collectSelfhostHits() as Hit[]; + for (const { key } of ALLOWED as Array<{ key: string }>) { + expect( + hits.some((h) => h.key === key), + `${key} is exempted but no longer contains the phrase`, + ).toBe(true); + } + }); +}); diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index dbe6703c4..4030048da 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -216,6 +216,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Permintaan Fitur', 'settings.about.featureRequestHint': 'Sarankan fitur baru', 'settings.about.wikiHint': 'Dokumentasi & panduan', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Pendukung Bulanan', 'settings.about.supporters.title': 'Rekan perjalanan untuk TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 5b8d51a18..14c8b7869 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -183,6 +183,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Richiedi funzionalità', 'settings.about.featureRequestHint': 'Suggerisci una nuova funzionalità', 'settings.about.wikiHint': 'Documentazione e guide', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Sostenitori Mensili', 'settings.about.supporters.title': 'Compagni di viaggio per TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index a4df34790..e80fcff29 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -197,6 +197,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': '機能リクエスト', 'settings.about.featureRequestHint': '新機能を提案', 'settings.about.wikiHint': 'ドキュメント・ガイド', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': '月額サポーター', 'settings.about.supporters.title': 'TREKの旅仲間', 'settings.about.supporters.subtitle': '皆さんの支援がTREKの未来を支えています。', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index e822b1c4c..b57e391e1 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -209,6 +209,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': '기능 요청', 'settings.about.featureRequestHint': '새로운 기능을 제안하세요', 'settings.about.wikiHint': '문서 및 가이드', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': '월간 후원자', 'settings.about.supporters.title': 'TREK의 여행 동반자', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 5fee61e72..08b432989 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -182,6 +182,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Feature aanvragen', 'settings.about.featureRequestHint': 'Stel een nieuwe functie voor', 'settings.about.wikiHint': 'Documentatie en handleidingen', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Maandelijkse Steuners', 'settings.about.supporters.title': 'Reisgezelschap voor TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index ea9e53e20..739cb3b49 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -178,6 +178,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Zaproponuj funkcję', 'settings.about.featureRequestHint': 'Zaproponuj nową funkcję', 'settings.about.wikiHint': 'Dokumentacja i poradniki', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Miesięczni Patroni', 'settings.about.supporters.title': 'Towarzystwo podróży dla TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 76fcde2dd..d27cf4940 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -180,6 +180,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Предложить функцию', 'settings.about.featureRequestHint': 'Предложите новую функцию', 'settings.about.wikiHint': 'Документация и руководства', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Ежемесячные спонсоры', 'settings.about.supporters.title': 'Спутники TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index b4939118d..043d4d887 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -224,6 +224,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Förslag på ny funktion', 'settings.about.featureRequestHint': 'Föreslå en ny funktion', 'settings.about.wikiHint': 'Dokumentation och handledningar', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Månatliga bidragsgivare', 'settings.about.supporters.title': 'Resekompisar för TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 6769bfe74..514045868 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -211,6 +211,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Özellik İsteği', 'settings.about.featureRequestHint': 'Yeni bir özellik önerin', 'settings.about.wikiHint': 'Belgeler ve kılavuzlar', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Aylık Destekçiler', 'settings.about.supporters.title': 'TREK için seyahat Arkadaşları', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index ce40dad3b..56c187931 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -176,6 +176,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Запропонувати функцію', 'settings.about.featureRequestHint': 'Запропонуйте нову функцію', 'settings.about.wikiHint': 'Документація та керівництва', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Щомісячні спонсори', 'settings.about.supporters.title': 'Спутники TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index aed962070..c3f76fffd 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -225,6 +225,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': 'Yêu cầu tính năng', 'settings.about.featureRequestHint': 'Đề xuất tính năng mới', 'settings.about.wikiHint': 'Tài liệu & hướng dẫn', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': 'Người ủng hộ hàng tháng', 'settings.about.supporters.title': 'Bạn đồng hành cho TREK', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 12545d9c9..7dada78e2 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -206,6 +206,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': '功能建議', 'settings.about.featureRequestHint': '建議新功能', 'settings.about.wikiHint': '文件與指南', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': '月度支持者', 'settings.about.supporters.title': '與 TREK 同行的夥伴', 'settings.about.supporters.subtitle': diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index 1ec506176..ecc12ad5f 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -171,6 +171,10 @@ const settings: TranslationStrings = { 'settings.about.featureRequest': '功能建议', 'settings.about.featureRequestHint': '建议一个新功能', 'settings.about.wikiHint': '文档和指南', + 'settings.about.descriptionManaged': + 'TREK helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place.', + 'settings.about.sourceTitle': 'Source code', + 'settings.about.sourceHint': 'TREK is open source, licensed AGPL-3.0', 'settings.about.supporters.badge': '月度支持者', 'settings.about.supporters.title': '与 TREK 同行的伙伴', 'settings.about.supporters.subtitle':