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':