feat(config): put the operator-owned surfaces behind TREK_MANAGED (#1946)

* feat(config): put the operator-owned surfaces behind TREK_MANAGED

The switch landed last release with nothing behind it. This is what it turns
off, plus the four fixes that came out of walking every one of these surfaces.

Nothing here changes anything for an install that does not set the flag. That
is not a hope, it is what managed-inert.test.ts asserts: the mode stays off for
any unparseable value, no other variable can turn it on, every settings key
stays writable, and splitManagedKeys hands back the same object rather than a
copy so no write path can quietly diverge on self-hosted installs only.

WHAT THE MODE WITHHOLDS

Fourteen routes carry @ManagedForbidden: key validation, static MCP tokens, JWT
rotation, the OIDC admin pair, restore and auto-backup, plugin sideload and
reload, the Ollama model list and pull, and the SMTP test send. Each names the
reason it is not the instance admin's, and the reason is mandatory.

Eighteen settings keys are filtered instead of refused. SMTP, the OIDC toggles
and the WebAuthn pair share one handler with keys the admin does own, and the
llm_* family is reachable through per-user settings, the instance defaults and
the addon config alike, so a route-level refusal would be both too coarse and
incomplete. A locked key is skipped and reported as managed_keys; the rest of
the save goes through. Refusing the request would take the registration toggles
down with the SMTP block.

GET /api/auth/me/settings stops returning the three API keys in the clear. It
is the only endpoint that decrypts a stored credential back to the caller, which
is fine when the admin pasted it in and wrong when the operator supplied it.

Two multipart routes check in the handler rather than the guard: a guard throws
before the parser drains the body and the client gets an ECONNRESET instead of
the 403 (PROFILE-015). They still carry the marker so the boot gate sees them.

WHAT KEEPS IT FROM DRIFTING

validate-managed-routes.ts refuses to boot on a marking that is not on the
reviewed list, a list entry whose route lost its marking, or a marking with no
reason. The third case has no equivalent in the guard gate next door and is the
one that matters most: a blank reason destroys the only record of why a control
was taken away, and nothing recovers that later. It found two wrong entries in
my own list while I was writing this.

managed-keys.test.ts asserts that every key in ADMIN_SETTINGS_KEYS and
DEFAULTABLE_USER_SETTING_KEYS belongs to exactly one of two sets. A key added to
either list belongs to neither until somebody decides, and that is the failure
this prevents: the lists grow, the mode does not.

PLACES_API_BASE AND PLACES_API_KEY

The nine Places endpoints spell out googleapis.com, so an install that wants
those calls to leave through something of its own has no way to say so. One
variable, substituted where every call already funnels through. A second one
supplies the key, and when it is set the users table is not consulted at all —
a per-user key would route around whatever the endpoint counts, and those
columns are readable by any admin of the instance.

FOUR FIXES THAT ARE NOT ABOUT THE MODE

An admin setting somebody's password now bumps password_version and clears
their MCP tokens. An admin sets a password for one reason, and until now
verifyJwtAndLoadUser kept accepting every cookie the intruder already had: the
one action taken to lock them out was the one action that did not.

The four OIDC provider calls go through the SSRF guard. Three are worse than
they look — the token, userinfo and JWKS URLs come out of the discovery
response, so they are chosen by the other end, not by an admin. safeFetchLlm is
renamed safeFetchAdminConfigured and keeps its old name as an alias, because it
is the variant that allows loopback and LAN: a self-hoster with Authentik on
localhost:9000 must keep working. The token exchange follows no redirect at all,
since one would hand client_secret to a second host.

DELETE /api/admin/users/:id/mfa clears another account's TOTP. The passkey half
has existed since passkeys landed; the TOTP half never did, which left
"somebody on the trip lost their phone" with no answer short of an operator in
the database. Never for the caller themselves.

TREK_PLUGIN_PERMISSIONS=off refuses to boot when the install is centrally
administered. A warning is right for a machine its owner chose to trust; where
the person who set the flag and the people whose data is in the database are
not the same person, fail closed.

TEXTS

Four strings lose the phrase "self-hosted" because they describe somebody
else's software — an Ollama endpoint, an AirTrail instance, a service a plugin
talks to — and read better without it in both modes. The one string that
describes TREK itself keeps it, and the mode picks a second key instead, so
self-hosters lose nothing.

A grep gate in shared/ keeps the canonical locale honest, because the parity
check compares the file set and the top-level keys and never looks at a value:
re-wording a string is invisible to CI today, and so is putting it back.

* feat(maps): supply the map credential with the instance when centrally administered

MAPBOX_ACCESS_TOKEN joins PLACES_API_KEY as an operator-supplied value.
SettingsService injects it when the settings are read, and picks GL as the
provider if the reader is still on Leaflet, since a token is always present.

The key moves from the customer set to the locked set. It is the one locked key
whose value is public by design — a pk.* token has to reach the browser to be
useful — so this is not about secrecy: a per-user save landing on top of the
instance token would only break the map for that user. Both settings fields for
it go away with it.

* fix(auth): count the operator key when reporting whether Google features work

has_maps_key only looked at the users table, but getMapsKey prefers the
PLACES_API_KEY env over anything stored there. On an install that supplies the
key that way and has an empty column, every lookup worked while the client was
told there was no key and hid the search, the autocomplete and the place photos.

Found by walking the running instance rather than the code: the map came up,
the search box did not.

* feat(admin): hide the settings a centrally administered install cannot act on

A control that answers 403 is worse than no control: the AI section rendered the
refusal as "Request failed with status code 403", which tells an admin they broke
something rather than that the decision is not theirs.

So the surfaces go rather than being disabled in place:

- AI parsing and AirTrail leave the addon list, and the AI addon route now
  refuses outright. The endpoint, the model and what a document costs come with
  the instance, so there is no instance decision left — not even whether it runs.
- Immich and Synology Photos leave it too. Both connect to a server the reader
  runs at home, and there is no route from here to one.
- The SMTP block, the admin webhook and ntfy targets, and the passkey RP ID and
  origins follow the same rule: they configure something the operator owns, and
  a wrong RP ID on a shared parent domain reaches past this instance entirely.
- The About tab goes. It is about the project — what TREK is, where to file a
  bug, where to support it — and is aimed at the person running the software.
  The version and the link to the source move to the sidebar footer, on both the
  desktop page and the mobile twin, because AGPL 13 wants that offer wherever
  people reach the software over a network.
- The update check stops asking GitHub and reports nothing available.

Separately, the version footers lose the "self-hosted" suffix everywhere, not
just here. It read as a label on the software rather than on the install, and
the version alone says what it needs to.

* feat(config): refuse to boot centrally administered without an at-rest key

Measured on a running instance rather than assumed: with ENCRYPTION_KEY unset,
the at-rest key falls back to a file in the data volume, and backupService
bundles that file into every archive it builds. The archive is downloadable by
any instance admin, so the encryption ships with its own key in the same zip.

The skip in backupService already handles this — it only omits the file when the
key came from the env — but nothing made sure the env actually carried one.
Provisioning sets it; forgetting it was silent.

validateEnvAtBoot now carries a second kind of check next to the schema: cross-
field rules that only apply with the flag set, for combinations where every value
is individually valid and the result is wrong. Empty without the flag, so an
install that never asked to be centrally administered cannot meet it.

* feat(client): add an attachment point for install-specific screens

Two empty exports on the client, an empty module on the server, and the four
places that read them. Nothing here adds a route, a nav entry or an endpoint —
the arrays are empty and the module contributes nothing, which managed-ext.test
pins so it stays that way.

Read unconditionally, never behind a flag. On the server a conditional import
would tie the module graph to an environment variable, and the graph is built
once per app while the flag is read live; on the client an empty array costs
nothing to spread, so every build walks the same path instead of one build
walking a path nobody tests.

Anything attached here follows the same rules as the rest of src/nest and
src/components: a real module with a controller and a provider, bodies through
the Zod pipe, routes under the global guards, tests.

* test(shared): move the wording gate out of src so shared typechecks again

CI installs shared with its own dependencies only (npm ci --workspace shared),
so there is no @types/node and the `node:fs` import inside src/ failed to
compile. The tests passed the whole time — vitest supplies the module — which is
why this only showed up in the typecheck step.

The walk moves to scripts/, next to i18n-parity.mjs, and the spec imports it the
same way that one does. It also runs on its own now
(`node scripts/i18n-selfhost-wording.mjs`), which is what you want when CI names
a line you have to go find.

* test(server): repair the call sites typecheck:tests caught

tests/ sits outside the build tsconfig, so none of this showed up in a green
test run — which is exactly the gap `npm run typecheck:tests` exists to close.
Three kinds, all mine:

- Seven `import type` paths for RuntimeEnvService carried one `../` too many.
  Type-only imports are erased, so vitest never resolved them and nothing failed.
- VersionCheckJob and PluginsController grew a constructor parameter and eleven
  hand-wired `new` calls kept passing the old count. The argument arrived as
  undefined and no assertion happened to read it.
- ConditionContext grew `managed`, and the shared context object in the
  conditions suite did not.

The condition also had no test of its own, so it has one now: both directions,
since the registry uses `is: false` to keep a notice away and a notice that only
belongs on a centrally administered install would use `is: true`.
This commit is contained in:
Maurice
2026-08-15 16:42:27 +02:00
committed by GitHub
parent bfb3bcd63c
commit 3a0604b3f5
108 changed files with 1926 additions and 111 deletions
+2
View File
@@ -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` |
+10
View File
@@ -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() {
</ProtectedRoute>
}
/>
{/* 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 => (
<Route
key={r.path}
path={r.path}
element={<ProtectedRoute>{r.element}</ProtectedRoute>}
/>
))}
<Route
path="/vacay"
element={
@@ -18,6 +18,7 @@ import {
styleSettingKey,
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
@@ -108,6 +109,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
const [defaults, setDefaults] = useState<Defaults>({})
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' && (
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
{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 && (
<div>
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
{t('admin.defaultSettings.mapboxToken')}
@@ -6,6 +6,7 @@ import { useTranslation } from '../../i18n'
import { LayoutGrid, CalendarDays, Globe, Compass, Bookmark, Plus } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { managedNavItems } from '../../managed'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
@@ -67,6 +68,8 @@ export default function BottomNav() {
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
}),
...pagePlugins.map(p => ({ to: `/plugins/${p.id}`, label: p.name, icon: resolvePluginIcon(p.icon) })),
// Empty in this repository — see client/src/managed.
...managedNavItems.map(m => ({ to: m.path, label: m.label, icon: m.Icon })),
]
// Split the items so the raised "+" sits dead centre.
const splitAt = Math.ceil(items.length / 2)
+4 -1
View File
@@ -10,6 +10,7 @@ import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, S
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { managedNavItems } from '../../managed'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
@@ -151,7 +152,9 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
>
{[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) }))
...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })),
// Empty in this repository — see client/src/managed.
...managedNavItems.map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
].map(tab => {
const isActive = location.pathname === tab.path
return (
+35 -1
View File
@@ -2,6 +2,7 @@ import { BookOpen, Bug, Coffee, ExternalLink, Heart, Info, Lightbulb } from 'luc
import React from 'react';
import { useTranslation } from '../../i18n';
import Section from './Section';
import { useAuthStore } from '../../store/authStore';
interface Props {
appVersion: string;
@@ -9,6 +10,7 @@ interface Props {
export default function AboutTab({ appVersion }: Props): React.ReactElement {
const { t, locale } = useTranslation();
const managed = useAuthStore((s) => s.managed);
return (
<Section title={t('settings.about')} icon={Info}>
@@ -22,7 +24,11 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
className="text-content-secondary"
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 6, marginTop: -4 }}
>
{t('settings.about.description')}
{/* The stock line calls TREK self-hosted and points at 'your own server'.
Both are true for the reader who set it up and neither is for a customer
of a hosted instance, so the mode picks the sentence rather than the
wording being watered down for everybody. */}
{t(managed ? 'settings.about.descriptionManaged' : 'settings.about.description')}
</p>
<p
className="text-content-faint"
@@ -52,6 +58,14 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
</span>
</p>
{/* Ko-fi, Buy Me a Coffee, Discord, and the issue/discussion links assume
the reader runs this install and can act on it. On a centrally
administered one they support somebody they are not the customer of,
and file bugs against an instance they do not operate. The version and
the source link below stay in both modes: AGPL §13 wants the source
offered prominently to the people using it over a network, and that is
not the part being trimmed here. */}
{!managed && (<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<a
href="https://ko-fi.com/mauriceboe"
@@ -263,6 +277,26 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
</div>
</>)}
{/* What replaces the grids above. AGPL §13 asks for the source to be
offered prominently to whoever uses the software over a network, and a
customer of a hosted instance is exactly that reader. The support and
bug-report links go; this does not. */}
{managed && (
<a
href="https://github.com/liketrek/TREK"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline"
>
<div>
<div className="text-sm font-semibold text-content">{t('settings.about.sourceTitle')}</div>
<div className="text-xs text-content-faint">{t('settings.about.sourceHint')}</div>
</div>
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
</a>
)}
</Section>
);
}
@@ -10,6 +10,7 @@ import AirTrailConnectionSection from './AirTrailConnectionSection'
import LlmConnectionSection from './LlmConnectionSection'
import { ALL_SCOPES } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
import { useAuthStore } from '../../store/authStore'
interface OAuthPreset {
id: string
@@ -96,11 +97,18 @@ interface McpToken {
export default function IntegrationsTab(): React.ReactElement {
const S = useIntegrations()
const managed = useAuthStore((s) => s.managed)
return (
<>
<PhotoProvidersSection />
{S.airtrailEnabled && <AirTrailConnectionSection />}
{S.llmEnabled && <LlmConnectionSection />}
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
runs themselves. On a managed install there is none, and every one of
these would ask for an address that cannot be reached from here. */}
{!managed && <PhotoProvidersSection />}
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
{S.llmEnabled && !managed && <LlmConnectionSection />}
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
<McpTokenModals {...S} />
<OAuthClientModals {...S} />
@@ -66,7 +66,7 @@ describe('LlmConnectionSection', () => {
// The endpoint is instance configuration since #1772, so it has no field 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();
});
@@ -20,6 +20,7 @@ import {
normalizeStyleForProvider,
type GlMapProvider,
} from '../Map/glProviders'
import { useAuthStore } from '../../store/authStore'
interface MapPreset {
name: string
@@ -161,6 +162,7 @@ export default function MapSettingsTab(): React.ReactElement {
const [saving, setSaving] = useState(false)
const [provider, setProvider] = useState<Provider>(initialProvider)
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
const managed = useAuthStore((s) => s.managed)
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
const [mapboxStyle, setMapboxStyle] = useState<string>(styleForProvider(initialProvider, slotStyle(initialProvider, settings)))
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
@@ -322,7 +324,9 @@ export default function MapSettingsTab(): React.ReactElement {
{/* GL settings */}
{provider !== 'leaflet' && (
<div className="space-y-3">
{provider === '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. */}
{provider === 'mapbox-gl' && !managed && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
<input
+47
View File
@@ -0,0 +1,47 @@
import type { ReactElement } from 'react'
import type { LucideIcon } from 'lucide-react'
/**
* The attachment point for surfaces that only exist on a centrally administered
* install.
*
* Empty here, and empty in every build of this repository. An installation whose
* operator ships additional screens replaces this file at build time; nothing
* else in the client changes, because everything below reads from these two
* exports rather than naming a screen directly.
*
* Why it is a file and not a plugin: what belongs here is not a third-party
* extension living behind a sandbox boundary. It is part of the product for the
* people who get that product operated for them, and it needs the same
* components, the same tokens and the same overlays as everything around it. A
* frame cannot open a dialog past its own edge.
*
* Two rules keep this honest:
* - Never import from here conditionally at a call site. The arrays are always
* read, and they are simply empty, so the public build takes exactly the same
* code path as any other.
* - Nothing here may be required for TREK to work. Everything below is
* additive: routes that are not registered, nav entries that are not shown.
*/
export interface ManagedRoute {
/** Router path, e.g. `/subscription`. Must not collide with an existing one. */
path: string
/** Rendered inside the app's ProtectedRoute wrapper, like every other screen. */
element: ReactElement
}
export interface ManagedNavItem {
/** Stable id for React keys; prefixed by the consumer, so keep it bare. */
id: string
path: string
label: string
/** lucide-react, like every other icon in the app — see the theme README. */
Icon: LucideIcon
}
/** Extra protected routes. Spread into the router next to the built-in ones. */
export const managedRoutes: ManagedRoute[] = []
/** Extra entries for the desktop navbar and the mobile tab bar. */
export const managedNavItems: ManagedNavItem[] = []
+5 -3
View File
@@ -68,7 +68,7 @@ export default function MAdmin() {
const { t, locale } = useTranslation()
const admin = useAdmin()
const {
demoMode, mcpEnabled, devMode, toast, navigate,
demoMode, mcpEnabled, devMode, managed, toast, navigate,
activeTab, setActiveTab, stats, serverTimezone,
bagTrackingEnabled, setBagTrackingEnabled,
collabFeatures, setCollabFeatures,
@@ -88,8 +88,10 @@ export default function MAdmin() {
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle },
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: Plug }] : []),
{ id: 'github', label: t('admin.tabs.github'), icon: Github },
{ id: 'backup', label: t('admin.tabs.backup'), icon: Database },
// Same two the desktop list drops: releases and backup schedule belong to
// whoever operates the install.
...(managed ? [] : [{ id: 'github', label: t('admin.tabs.github'), icon: Github }]),
...(managed ? [] : [{ id: 'backup', label: t('admin.tabs.backup'), icon: Database }]),
{ id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText },
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug }] : []),
]
@@ -17,6 +17,7 @@ import {
import type { LucideIcon } from 'lucide-react'
import { useTranslation } from '../../../i18n'
import { useSettings } from '../../../pages/settings/useSettings'
import { useAuthStore } from '../../../store/authStore'
import { usePluginStore } from '../../../store/pluginStore'
import MSettingsPlugins from './MSettingsPlugins'
import MSettingsOffline from './MSettingsOffline'
@@ -44,6 +45,7 @@ export default function MSettings() {
const { t } = useTranslation()
const navigate = useNavigate()
const { hasIntegrations, appVersion, activeTab, setActiveTab } = useSettings()
const managed = useAuthStore((s) => s.managed)
const hasPlugins = usePluginStore((s) => s.plugins.length > 0)
const [dropOpen, setDropOpen] = useState(false)
@@ -56,7 +58,12 @@ export default function MSettings() {
...(hasPlugins ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
{ id: 'account', label: t('settings.tabs.account'), icon: User },
...(appVersion ? [{ id: 'about', label: t('settings.tabs.about'), icon: Info }] : []),
// Same call as the desktop page: About is about the project — what TREK is,
// where to file a bug, where to support it — and a customer of a hosted
// instance is the audience for none of it. The version and the source link
// move to the footer below, because AGPL §13 wants those offered wherever
// people reach the software over a network.
...(appVersion && !managed ? [{ id: 'about', label: t('settings.tabs.about'), icon: Info }] : []),
]
const active = tabs.find((tab) => tab.id === activeTab) || tabs[0]
@@ -117,6 +124,19 @@ export default function MSettings() {
{active.id === 'offline' && <MSettingsOffline />}
{active.id === 'account' && <MSettingsAccount />}
{active.id === 'about' && appVersion && <MSettingsAbout appVersion={appVersion} />}
{managed && appVersion && (
<p className="mt-8 text-center text-caption text-m-muted">
<a
href="https://github.com/liketrek/TREK"
target="_blank"
rel="noopener noreferrer"
className="no-underline"
>
v{appVersion}
</a>
</p>
)}
</div>
)
}
@@ -4,6 +4,7 @@ import MPhotoProvidersSection from './MPhotoProvidersSection'
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
import MLlmConnectionSection from './MLlmConnectionSection'
import MSettingsMcp from './MSettingsMcp'
import { useAuthStore } from '../../../store/authStore'
/**
* "Integrations" section. The photo-provider / AirTrail / LLM connection forms
@@ -16,6 +17,7 @@ export default function MSettingsIntegrations() {
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
const managed = useAuthStore((s) => s.managed)
useEffect(() => {
loadAddons()
@@ -25,7 +27,10 @@ export default function MSettingsIntegrations() {
<>
<MPhotoProvidersSection />
{airtrailEnabled && <MAirTrailConnectionSection />}
{llmEnabled && <MLlmConnectionSection />}
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
{llmEnabled && !managed && <MLlmConnectionSection />}
{mcpEnabled && <MSettingsMcp />}
</>
)
+7 -4
View File
@@ -34,7 +34,7 @@ function AdminPageDesktop(): React.ReactElement {
// each tab/section renders from a dedicated sub-component.
const admin = useAdmin()
const {
demoMode, mcpEnabled, devMode, toast,
demoMode, mcpEnabled, devMode, managed, toast,
activeTab, setActiveTab, stats,
bagTrackingEnabled, setBagTrackingEnabled,
collabFeatures, setCollabFeatures,
@@ -55,8 +55,11 @@ function AdminPageDesktop(): React.ReactElement {
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig },
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell, group: gIntegration },
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: KeyRound, group: gIntegration }] : []),
{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch, group: gIntegration },
{ id: 'backup', label: t('admin.tabs.backup'), icon: Database, group: gMaintenance },
// Releases and update cadence belong to whoever operates the install.
...(managed ? [] : [{ id: 'github', label: t('admin.tabs.github'), icon: GitBranch, group: gIntegration }]),
// Backups run off-volume on a managed install; the tab would offer a
// schedule that competes with the real one.
...(managed ? [] : [{ id: 'backup', label: t('admin.tabs.backup'), icon: Database, group: gMaintenance }]),
{ id: 'audit', label: t('admin.tabs.audit'), icon: ScrollText, group: gMaintenance },
...(devMode ? [{ id: 'dev-notifications', label: 'Dev: Notifications', icon: Bug, group: gMaintenance }] : []),
]
@@ -123,7 +126,7 @@ function AdminPageDesktop(): React.ReactElement {
tabs={TABS}
activeTab={activeTab}
onTabChange={setActiveTab}
footer="admin · self-hosted"
footer=""
>
{/* Tab content */}
{activeTab === 'users' && (
+1 -1
View File
@@ -570,7 +570,7 @@ export default function LoginPage(): React.ReactElement {
</div>
)}
{insecureCookie && (
{insecureCookie && !appConfig?.managed && (
<div
style={{
padding: '12px 14px',
+22 -3
View File
@@ -24,7 +24,7 @@ export default function SettingsPage(): React.ReactElement {
function SettingsPageDesktop(): React.ReactElement {
const { t } = useTranslation()
// Page = wiring container: addon/version loading + active-tab state in the hook.
const { hasIntegrations, appVersion, activeTab, setActiveTab } = useSettings()
const { hasIntegrations, appVersion, activeTab, setActiveTab, managed } = useSettings()
const hasPlugins = usePluginStore(s => s.plugins.length > 0)
const tabs: PageSidebarTab[] = [
@@ -40,7 +40,11 @@ function SettingsPageDesktop(): React.ReactElement {
: []),
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
{ id: 'account', label: t('settings.tabs.account'), icon: User },
...(appVersion
// About is where the project lives: what TREK is, where to report a bug,
// where to support it. A customer of a hosted instance is the audience for
// none of that, so the tab goes and the sidebar footer below carries the one
// thing that has to stay — the link to the source (AGPL §13).
...(appVersion && !managed
? [{ id: 'about', label: t('settings.tabs.about'), icon: Info }]
: []),
]
@@ -65,7 +69,22 @@ function SettingsPageDesktop(): React.ReactElement {
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={appVersion ? `v${appVersion} · self-hosted` : 'self-hosted'}
footer={
appVersion
? managed
// No About tab here, so this is the prominent source offer
// AGPL §13 asks for when people use it over a network.
? <a
href="https://github.com/liketrek/TREK"
target="_blank"
rel="noopener noreferrer"
className="no-underline text-content-faint hover:text-content-secondary"
>
v{appVersion}
</a>
: `v${appVersion}`
: ''
}
>
{activeTab === 'display' && <DisplaySettingsTab />}
{activeTab === 'appearance' && <AppearanceSettingsTab />}
@@ -14,7 +14,7 @@ interface AdminNotificationsTabProps {
// trip reminders, admin webhook + ntfy targets, and the per-event preference matrix.
// Derives channel state from smtpValues exactly as the original inline IIFE did.
export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTabProps): React.ReactElement {
const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled } = admin
const { toast, smtpValues, setSmtpValues, smtpLoaded, setTripRemindersEnabled, managed } = admin
// Derive active channels from smtpValues.notification_channels (plural)
// with fallback to notification_channel (singular) for existing installs
@@ -58,6 +58,10 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
return (<>
<div className="space-y-4">
{/* The relay is the operator's: their host, their credential, their sending
reputation. An instance that could point it elsewhere would send under a
domain it does not own. */}
{!managed && (<>
{/* Email Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between">
@@ -132,6 +136,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
</div>
</div>
</>)}
{/* Webhook Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 flex items-center justify-between">
@@ -211,6 +216,9 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
</div>
</div>
{/* Admin alerts are about running the instance (version notices, and what else
lands there later). On a managed install those go to whoever runs it. */}
{!managed && (<>
{/* Admin Webhook Panel */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
@@ -262,7 +270,8 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
</div>
</div>
{/* Admin Ntfy Panel */}
{/* Admin Ntfy Panel — same audience as the webhook above, and inside the
same wrapper. */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.notifications.adminNtfyPanel.title')}</h2>
@@ -361,6 +370,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
</button>
</div>
</div>
</>)}
</div>
<div className="mt-6">
+29 -1
View File
@@ -29,6 +29,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, showKeys, savingKeys, validating, validation,
managed,
setShowRotateJwtModal,
handleToggleAuthSetting, handleToggleRequireMfa,
toggleKey, handleSaveApiKeys, handleValidateKey,
@@ -154,6 +155,13 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</p>
)}
{/* The domain passkeys bind to and the origins that may present them
follow from the address the instance is served on, which the operator
owns. Getting either wrong invalidates every enrolled passkey, and on
a shared parent domain a wrong RP ID reaches past this instance
entirely — so they are pinned per container, not offered here. The
switch above stays: whether to offer passkeys at all is a house rule. */}
{!managed && (<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">{t('admin.passkey.rpId')}</label>
<p className="text-xs text-slate-400 mb-1.5">{t('admin.passkey.rpIdHint')}</p>
@@ -185,6 +193,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
{savingWebauthn ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{t('common.save')}
</button>
</>)}
</div>
</div>
@@ -246,6 +255,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div>
</div>
{/* 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 */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
@@ -253,6 +266,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<p className="text-xs text-slate-400 mt-1">{t('admin.apiKeysHint')}</p>
</div>
<div className="p-6 space-y-4">
{/* 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 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
@@ -329,6 +346,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div>
<p className="text-xs text-slate-400 mt-1">{t('admin.unsplashKeyHint')}</p>
</div>
</>)}
{/* Place Photos Toggle */}
<div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100">
@@ -444,6 +462,11 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div>
</div>
{/* 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 */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
@@ -524,6 +547,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</button>
</div>
</div>
{/* 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 */}
<div className="bg-white rounded-xl border border-red-200 overflow-hidden">
<div className="px-6 py-4 border-b border-red-100 bg-red-50">
@@ -548,6 +575,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div>
</div>
</div>
</>)}
</div>
)
}
}
+5 -1
View File
@@ -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<string>('users')
const [users, setUsers] = useState<AdminUser[]>([])
@@ -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,
+3 -1
View File
@@ -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 }
}
@@ -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();
});
+7 -2
View File
@@ -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,
+18
View File
@@ -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),
+3
View File
@@ -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,
+38 -5
View File
@@ -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;
}
+1
View File
@@ -7,6 +7,7 @@ export {
deriveHttp,
deriveSession,
deriveManaged,
deriveMaps,
deriveDemo,
deriveAdminBootstrap,
deriveOidc,
+6
View File
@@ -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<INestApplication> {
// 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;
}
@@ -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<T>(result: T): Exclude<T, { error: string }> {
@@ -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) {
+2 -1
View File
@@ -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],
})
+109 -6
View File
@@ -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<VersionInfo> {
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<Addon>('SELECT * FROM addons ORDER BY sort_order, id');
const addons = this.db
.all<Addon>('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<ProviderRow>('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)
@@ -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());
}
+2 -1
View File
@@ -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
+6 -1
View File
@@ -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) {
+19 -3
View File
@@ -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<string, unknown>;
auditDebugDetails?: Record<string, unknown>;
/** Names the operator holds, skipped rather than written. Empty when self-hosted. */
managedKeys?: string[];
} {
const body = rawBody as Record<string, unknown>;
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<string, unknown>, 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<string, unknown> = {};
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 };
}
// -------------------------------------------------------------------------
+46 -3
View File
@@ -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<Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>('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),
@@ -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 {
+2 -1
View File
@@ -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],
})
+10 -4
View File
@@ -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);
}
}
+127 -1
View File
@@ -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<string>(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<T extends Record<string, unknown>>(
body: T,
managed: boolean,
): { allowed: Partial<T>; blocked: string[] } {
if (!managed) return { allowed: body, blocked: [] };
const allowed: Partial<T> = {};
const blocked: string[] = [];
for (const [key, value] of Object.entries(body)) {
if (LOCKED.has(key)) blocked.push(key);
else (allowed as Record<string, unknown>)[key] = value;
}
return { allowed, blocked: blocked.sort() };
}
@@ -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<string, unknown>)[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.',
);
}
}
@@ -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<void> {
const stream = await this.local.pull(body?.baseUrl, body?.model ?? '');
@@ -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],
+16 -3
View File
@@ -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<string, string> = {
'.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<string, unknown>[];
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 {
@@ -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 {}
+30 -1
View File
@@ -39,7 +39,28 @@ import {
let googleApiCallCount = 0;
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
/** 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<Response> {
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,
@@ -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<ChannelTestResult> {
+6
View File
@@ -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);
+8 -5
View File
@@ -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<OidcUserInfo> {
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<Array<Record<string, unknown>>> {
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();
+11
View File
@@ -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
@@ -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) {
+2 -1
View File
@@ -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,
@@ -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<string, unknown>);
// 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<string, unknown>,
this.env.isManaged(),
);
this.settings.setAdminUserDefaults(allowed);
this.audit.writeAudit({
userId: user.id,
action: 'admin.default_user_settings_update',
+2 -1
View File
@@ -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],
@@ -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;
}
@@ -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],
})
@@ -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 {
+5
View File
@@ -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) {
+8 -1
View File
@@ -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,
+3 -2
View File
@@ -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 => {
+4
View File
@@ -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 {
+9 -1
View File
@@ -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<Response> {
export async function safeFetchAdminConfigured(url: string, init?: RequestInit, maxRedirects = 5): Promise<Response> {
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.
*/
+1 -1
View File
@@ -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 () => {
@@ -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;
@@ -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<SettingsService> = {}) {
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) => {
+7 -3
View File
@@ -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');
});
+111 -2
View File
@@ -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();
});
});
});
@@ -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> = {}): 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> = {}): BackupService {
return {
@@ -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<string, unknown> = {}) => ({ 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(() => {
@@ -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();
});
});
@@ -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);
}
});
});
+121
View File
@@ -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<string>(MANAGED_LOCKED_SETTING_KEYS);
const customer = new Set<string>(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([]);
});
});
+13 -2
View File
@@ -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);
+13 -1
View File
@@ -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('');
});
});
+52
View File
@@ -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);
});
});
@@ -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<typeof import('../../../src/utils/ssrfGuard')>();
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) => {
@@ -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<SettingsService>)).list(user)).toEqual({ settings: { theme: 'dark' } });
expect(new SettingsController(svc({ getUserSettings: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<SettingsService>), { 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<SettingsService>));
const c = new SettingsController(svc({ upsertSetting } as Partial<SettingsService>), { 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<SettingsService>));
const c = new SettingsController(svc({ upsertSetting } as Partial<SettingsService>), { 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<SettingsService>)).bulk(user, { settings: { a: 1 } }))).toEqual({ status: 500, body: { error: 'Error saving settings' } });
expect(new SettingsController(svc({ bulkUpsertSettings: vi.fn().mockReturnValue(3) } as Partial<SettingsService>)).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<SettingsService>), { 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<SettingsService>), { isManaged: () => false } as unknown as RuntimeEnvService).bulk(user, { settings: { a: 1, b: 2, c: 3 } })).toEqual({ success: true, updated: 3 });
});
});
@@ -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');
});
});
@@ -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<INestApplication> {
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/);
});
});
@@ -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', () => {
@@ -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<any>): Promise<number> =>
p.then(() => -1, (e) => (e instanceof HttpException ? e.getStatus() : 0));
@@ -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<typeof vi.fn>).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');
});
});
@@ -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);
});
});
+84
View File
@@ -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);
}
+4
View File
@@ -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':
+4
View File
@@ -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':
+4
View File
@@ -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':
+4
View File
@@ -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':
+4
View File
@@ -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':
+2 -2
View File
@@ -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?',
+6 -2
View File
@@ -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.',
+4
View File
@@ -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':
+4
View File
@@ -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':
+4
View File
@@ -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':
+4
View File
@@ -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':
@@ -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);
}
});
});
+4
View File
@@ -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':
+4
View File
@@ -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':
+4
View File
@@ -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の未来を支えています。',
+4
View File
@@ -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':
+4
View File
@@ -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':

Some files were not shown because too many files have changed in this diff Show More