diff --git a/README.md b/README.md index 894ad5211..f0d08bc04 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,7 @@ Caddy handles TLS and WebSockets automatically. | `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` | | `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` | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | | `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` | diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index ecee3d3a6..29124210c 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -387,6 +387,30 @@ describe('App — on-mount effects', () => { await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true)) }) + it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => { + server.use( + http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true })) + ) + const setManaged = vi.fn() + useAuthStore.setState({ + isLoading: false, + isAuthenticated: false, + loadUser: vi.fn().mockResolvedValue(undefined), + setManaged, + }) + renderApp('/') + await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true)) + + // An older server that does not send the field must not leave the flag + // stuck on from a previous install in the same browser profile. + setManaged.mockClear() + server.use( + http.get('/api/auth/app-config', () => HttpResponse.json({})) + ) + renderApp('/') + await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false)) + }) + it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => { const loadSettings = vi.fn().mockResolvedValue(undefined) seedAuth({ isAuthenticated: true, user: buildUser() }) diff --git a/client/src/App.tsx b/client/src/App.tsx index 18a057e8a..203edd221 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -278,7 +278,7 @@ function RouteFallback() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() const { loadPlugins } = usePluginStore() @@ -295,7 +295,8 @@ export default function App() { loadUser() } } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record }) => { + setManaged(!!config?.managed) setDemoMode(!!config?.demo_mode) if (config?.dev_mode) setDevMode(true) if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index d6adffd4b..7e8d9dc1b 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -12,6 +12,7 @@ interface AppConfig { has_users: boolean allow_registration: boolean setup_complete: boolean + managed?: boolean demo_mode: boolean oidc_configured: boolean oidc_display_name?: string diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts index 1eadbe1fa..4355d424c 100644 --- a/client/src/store/authStore.ts +++ b/client/src/store/authStore.ts @@ -38,6 +38,8 @@ interface AuthState { * ?redirect= pointing back at the page they just left. Transient. */ loggingOut: boolean error: string | null + /** The operator of this install owns its configuration, not the admin. */ + managed: boolean demoMode: boolean devMode: boolean isPrerelease: boolean @@ -63,6 +65,7 @@ interface AuthState { updateProfile: (profileData: Partial) => Promise uploadAvatar: (file: File) => Promise deleteAvatar: () => Promise + setManaged: (val: boolean) => void setDemoMode: (val: boolean) => void setDevMode: (val: boolean) => void setIsPrerelease: (val: boolean) => void @@ -103,6 +106,7 @@ export const useAuthStore = create()( authCheckFailed: false, loggingOut: false, error: null, + managed: false, demoMode: localStorage.getItem('demo_mode') === 'true', devMode: false, isPrerelease: false, @@ -327,6 +331,12 @@ export const useAuthStore = create()( set((state) => ({ user: state.user ? { ...state.user, avatar_url: null } : null })) }, + // Not persisted, unlike demoMode above: two installs can share a browser + // profile, and a stale 'this one is managed' would then take settings away + // from an admin on an install that never set the flag. Re-read on every boot + // from app-config, which is one request the app makes anyway. + setManaged: (val: boolean) => set({ managed: val }), + setDemoMode: (val: boolean) => { if (val) localStorage.setItem('demo_mode', 'true') else localStorage.removeItem('demo_mode') diff --git a/client/src/types.ts b/client/src/types.ts index 7820afdcf..9b6385630 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -244,6 +244,8 @@ export interface GeoJsonFeatureCollection { export interface AppConfig { has_users: boolean allow_registration: boolean + /** True when the operator of this install owns its configuration, not the admin */ + managed?: boolean demo_mode: boolean oidc_configured: boolean oidc_display_name?: string diff --git a/server/src/app-config/README.md b/server/src/app-config/README.md index 594bb2b36..a647820ae 100644 --- a/server/src/app-config/README.md +++ b/server/src/app-config/README.md @@ -69,7 +69,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): -DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED, +TREK_MANAGED, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED, TREK_PLUGINS_ENABLED / _DEV_LINK / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS, OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS, ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL, diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index d2c8ec2e8..645283a39 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -102,6 +102,24 @@ export function deriveSession(raw: RawEnv) { }; } +/** + * Whether this instance is centrally administered: somebody other than its + * admin user owns the configuration, the credentials and the upgrade schedule. + * + * Only a boolean, on purpose. It answers "who configures this install", and the + * surfaces that care read it themselves rather than being listed here, so adding + * one never touches this file. + * + * `=== true` so an unset, empty or unparseable value means off. A managed + * install sets it deliberately; nothing should be able to switch it on by + * accident, and a typo must not silently take settings away from an admin. + */ +export function deriveManaged(raw: RawEnv) { + return { + enabled: parseBool(raw.TREK_MANAGED) === true, + }; +} + export function deriveDemo(raw: RawEnv) { return { enabled: parseBool(raw.DEMO_MODE) === true, @@ -236,6 +254,7 @@ export function deriveAll(raw: RawEnv) { app: deriveApp(raw), http: deriveHttp(raw), session: deriveSession(raw), + managed: deriveManaged(raw), demo: deriveDemo(raw), adminBootstrap: deriveAdminBootstrap(raw), oidc: deriveOidc(raw), diff --git a/server/src/app-config/env.schema.ts b/server/src/app-config/env.schema.ts index 2d8d489ac..7bb0f2689 100644 --- a/server/src/app-config/env.schema.ts +++ b/server/src/app-config/env.schema.ts @@ -130,6 +130,7 @@ export const envSchema = z.object({ // Admin / demo ADMIN_EMAIL: anyString, ADMIN_PASSWORD: anyString, + TREK_MANAGED: boolStr, DEMO_MODE: boolStr, DEMO_ADMIN_USER: anyString, DEMO_ADMIN_EMAIL: anyString, diff --git a/server/src/app-config/index.ts b/server/src/app-config/index.ts index 5da92b321..010423d20 100644 --- a/server/src/app-config/index.ts +++ b/server/src/app-config/index.ts @@ -6,6 +6,7 @@ export { deriveApp, deriveHttp, deriveSession, + deriveManaged, deriveDemo, deriveAdminBootstrap, deriveOidc, diff --git a/server/src/nest/app-config/runtime-env.service.ts b/server/src/nest/app-config/runtime-env.service.ts index 61eee77b3..0ff01fce1 100644 --- a/server/src/nest/app-config/runtime-env.service.ts +++ b/server/src/nest/app-config/runtime-env.service.ts @@ -21,6 +21,14 @@ export class RuntimeEnvService { return readEnv().demo.enabled; } + /** + * True when somebody other than this instance's admin owns its configuration. + * Runtime-toggled like DEMO_MODE, so never cache the result in a field. + */ + isManaged(): boolean { + return readEnv().managed.enabled; + } + /** True only under exactly NODE_ENV='test' (case-sensitive, like the :memory: DB pick). */ isTest(): boolean { return readEnv().app.isTest; diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index e01b2d90d..a9dcfbb4d 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -9,6 +9,7 @@ import { PlatformModule } from './platform/platform.module'; import { McpTransportModule } from './mcp-transport/mcp-transport.module'; import { GlobalAuthGuard } from './auth/global-auth.guard'; import { MfaPolicyGuard } from './auth/mfa-policy.guard'; +import { ManagedGuard } from './common/managed.guard'; import { WeatherModule } from './weather/weather.module'; import { HelpModule } from './help/help.module'; import { AirportsModule } from './airports/airports.module'; @@ -81,6 +82,10 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; // instead of verifying the token a second time, which is what the Express // middleware it replaces did on every /api request. { provide: APP_GUARD, useClass: MfaPolicyGuard }, + // Third, and only third: a stranger gets the 401 the two above would have + // given them rather than a 403 that confirms the route exists. Inert unless + // the instance is centrally administered AND the route carries the marker. + { provide: APP_GUARD, useClass: ManagedGuard }, // Global error-envelope normaliser (DI-registered so it also catches // framework-level exceptions like the not-found handler). { provide: APP_FILTER, useClass: TrekExceptionFilter }, diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index bbbb356d1..f704e6e12 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -280,6 +280,11 @@ export class AuthService { oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, require_mfa: requireMfaRow?.value === 'true', allowed_file_types: this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'allowed_file_types'")?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv', + // Whether the configuration belongs to whoever operates this install + // rather than to its admin. The client uses it to stop offering settings + // the server would refuse anyway — it is an honesty flag for the UI, never + // the boundary itself, which is the guard and the operator's network. + managed: readEnv().managed.enabled, demo_mode: isDemo, demo_email: isDemo ? DEMO_EMAIL_PRIMARY : undefined, demo_password: isDemo ? 'demo12345' : undefined, diff --git a/server/src/nest/common/managed.guard.ts b/server/src/nest/common/managed.guard.ts new file mode 100644 index 000000000..57e6ae099 --- /dev/null +++ b/server/src/nest/common/managed.guard.ts @@ -0,0 +1,42 @@ +import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; +import { MANAGED_FORBIDDEN, MANAGED_FORBIDDEN_ERROR } from './managed'; + +/** + * Refuses the routes a centrally administered instance does not hand to its own + * admin: the ones whose value comes from whoever operates the install. + * + * Registered as the third APP_GUARD, after GlobalAuthGuard and MfaPolicyGuard. + * The order is the whole point — a stranger has to get the 401 the other two + * would have given them, not a 403 that confirms the route exists and tells them + * something about how this install is run. + * + * Marked routes only. An unmarked route is untouched in every mode, so the + * guard is inert on a self-hosted install and stays that way even if somebody + * sets the flag there. + * + * Explicitly not a path list. `MfaPolicyGuard` documents where the two literal + * path lists it replaced ended up: they stopped being maintained, and every + * endpoint added afterwards silently fell on the wrong side. A decorator lives + * on the handler it describes and cannot drift away from it. + */ +@Injectable() +export class ManagedGuard implements CanActivate { + constructor( + private readonly env: RuntimeEnvService, + private readonly reflector: Reflector, + ) {} + + canActivate(context: ExecutionContext): boolean { + if (!this.env.isManaged()) return true; + + const marked = this.reflector.getAllAndOverride(MANAGED_FORBIDDEN, [ + context.getHandler(), + context.getClass(), + ]); + if (!marked) return true; + + throw new HttpException(MANAGED_FORBIDDEN_ERROR, 403); + } +} diff --git a/server/src/nest/common/managed.ts b/server/src/nest/common/managed.ts new file mode 100644 index 000000000..b8847db41 --- /dev/null +++ b/server/src/nest/common/managed.ts @@ -0,0 +1,44 @@ +import { SetMetadata } from '@nestjs/common'; +import { RuntimeEnvService } from '../app-config/runtime-env.service'; + +/** Metadata key `@ManagedForbidden()` writes. */ +export const MANAGED_FORBIDDEN = 'trek:managed-forbidden'; + +/** + * The one 403 body a managed instance returns, shared by REST and MCP. + * + * Deliberately a single constant rather than a per-route message. DEMO_MODE grew + * two texts for the same condition — 'Uploads are disabled in demo mode. Self-host + * TREK for full functionality.' on REST and 'Write operations are disabled in demo + * mode.' on MCP — and now neither can be changed without hunting for the other. + * + * It says who owns the setting and stops there. A caller learns that the value is + * not theirs to change, which is all they can act on; the reason belongs to + * whoever runs the install, not in an API response. + */ +export const MANAGED_FORBIDDEN_ERROR = { + error: 'This is configured by the operator of this instance.', + code: 'MANAGED_FORBIDDEN', +} as const; + +/** + * Marks a route as unavailable while the instance is centrally administered. + * + * The reason is mandatory and is for the next reader of the source, not for the + * 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. + */ +export const ManagedForbidden = (reason: string) => SetMetadata(MANAGED_FORBIDDEN, { reason }); + +/** + * The handler-side flavour of the guard below. + * + * Needed because a guard throws before the multipart parser has drained the + * request body, which leaves the client with an ECONNRESET instead of the 403 + * (PROFILE-015, and the reason `isDemoWriteBlocked` is a function too). Upload + * routes call this after the interceptor instead of carrying the decorator. + */ +export function isManagedBlocked(env: RuntimeEnvService): boolean { + return env.isManaged(); +} diff --git a/server/tests/unit/nest/auth.service.test.ts b/server/tests/unit/nest/auth.service.test.ts index 1fc96769e..8f092acbd 100644 --- a/server/tests/unit/nest/auth.service.test.ts +++ b/server/tests/unit/nest/auth.service.test.ts @@ -571,6 +571,23 @@ describe('getAppConfig', () => { expect(cfg.demo_password).toBe('demo12345'); vi.unstubAllEnvs(); }); + + it('AUTH-DB-052b: managed is false unless the install says otherwise', () => { + createUser(testDb); + expect(svc.getAppConfig(null).managed).toBe(false); + }); + + it('AUTH-DB-052c: managed reaches the client, so the UI can stop offering what the server refuses', () => { + vi.stubEnv('TREK_MANAGED', 'true'); + createUser(testDb); + const cfg = svc.getAppConfig(null); + expect(cfg.managed).toBe(true); + // Additive only: the flag says who owns the configuration and changes + // nothing else about the instance. + expect(cfg.demo_mode).toBe(false); + expect(cfg.password_registration).toBe(true); + vi.unstubAllEnvs(); + }); }); describe('demoLogin', () => { diff --git a/server/tests/unit/nest/managed.guard.test.ts b/server/tests/unit/nest/managed.guard.test.ts new file mode 100644 index 000000000..ec0a716d3 --- /dev/null +++ b/server/tests/unit/nest/managed.guard.test.ts @@ -0,0 +1,82 @@ +/** + * ManagedGuard — refuses the routes a centrally administered instance does not + * hand to its own admin. + * + * Two properties carry the design and both are pinned below: it is inert unless + * the route was marked (MANAGED-GUARD-002), so a self-hosted install cannot + * notice it exists, and it keys on a decorator rather than a path list + * (MANAGED-GUARD-005). The path lists MfaPolicyGuard replaced are the reason for + * the second one: they stopped being maintained, and every endpoint added after + * they were written silently fell on the wrong side. + */ +import { describe, it, expect, vi } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +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) { + const getAllAndOverride = vi.fn((key: string) => + key === MANAGED_FORBIDDEN && marked ? { reason: 'the operator holds this' } : undefined, + ); + const guard = new ManagedGuard( + { isManaged: () => managed } as unknown as RuntimeEnvService, + { getAllAndOverride } as unknown as Reflector, + ); + return { guard, getAllAndOverride }; +} + +const ctx = { + getHandler: () => function handler() {}, + getClass: () => class Controller {}, +} as never; + +const thrown = (run: () => unknown) => { + try { + run(); + } catch (err) { + return err as HttpException; + } + return undefined; +}; + +describe('ManagedGuard', () => { + it('MANAGED-GUARD-001: lets a marked route through on a self-hosted install', () => { + const { guard } = makeGuard(false, true); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('MANAGED-GUARD-002: lets an unmarked route through on a managed install', () => { + const { guard } = makeGuard(true, false); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('MANAGED-GUARD-003: refuses a marked route on a managed install', () => { + const { guard } = makeGuard(true, true); + const err = thrown(() => guard.canActivate(ctx)); + + expect(err).toBeInstanceOf(HttpException); + expect(err?.getStatus()).toBe(403); + expect(err?.getResponse()).toEqual(MANAGED_FORBIDDEN_ERROR); + }); + + it('MANAGED-GUARD-004: does not read metadata at all while the mode is off', () => { + // The cheap check comes first because this guard runs on every request of + // every install, and all but a handful of them are self-hosted. + const { guard, getAllAndOverride } = makeGuard(false, true); + guard.canActivate(ctx); + + expect(getAllAndOverride).not.toHaveBeenCalled(); + }); + + 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); + + expect(getAllAndOverride).toHaveBeenCalledWith(MANAGED_FORBIDDEN, [ + expect.any(Function), + expect.any(Function), + ]); + }); +}); diff --git a/server/tests/unit/nest/managed.test.ts b/server/tests/unit/nest/managed.test.ts new file mode 100644 index 000000000..f30a375a9 --- /dev/null +++ b/server/tests/unit/nest/managed.test.ts @@ -0,0 +1,86 @@ +/** + * TREK_MANAGED — the switch that says the configuration belongs to whoever runs + * this install rather than to its admin. + * + * Runtime-toggled, exactly like DEMO_MODE: read live on every access, never + * snapshotted into a field or a registerAs token, because roughly sixty tests + * mutate process.env mid-lifetime and a frozen value breaks all of them. + * MANAGED-004 is the case that pins it. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { isManagedBlocked, MANAGED_FORBIDDEN, MANAGED_FORBIDDEN_ERROR, ManagedForbidden } from '../../../src/nest/common/managed'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { deriveManaged } from '../../../src/app-config/derive'; + +const env = new RuntimeEnvService(); + +afterEach(() => { + delete process.env.TREK_MANAGED; +}); + +describe('deriveManaged', () => { + it('MANAGED-001: off when the variable is unset', () => { + expect(deriveManaged({}).enabled).toBe(false); + }); + + it('MANAGED-002: on for the documented truthy spellings', () => { + for (const value of ['true', 'TRUE', '1', 'yes', 'on']) { + expect(deriveManaged({ TREK_MANAGED: value }).enabled).toBe(true); + } + }); + + it('MANAGED-003: off for an empty, unparseable or falsy value', () => { + // A typo must not take settings away from an admin who never asked for it, + // so anything that is not recognisably true stays off. + for (const value of ['', 'false', '0', 'no', 'off', 'maybe', 'TREK_MANAGED']) { + expect(deriveManaged({ TREK_MANAGED: value }).enabled).toBe(false); + } + }); +}); + +describe('RuntimeEnvService.isManaged', () => { + it('MANAGED-004: reads the env live, so a toggle takes effect without a restart', () => { + expect(env.isManaged()).toBe(false); + + process.env.TREK_MANAGED = 'true'; + expect(env.isManaged()).toBe(true); + + delete process.env.TREK_MANAGED; + expect(env.isManaged()).toBe(false); + }); +}); + +describe('isManagedBlocked', () => { + it('MANAGED-005: false on a self-hosted install', () => { + expect(isManagedBlocked(env)).toBe(false); + }); + + it('MANAGED-006: true once the instance is centrally administered', () => { + process.env.TREK_MANAGED = 'true'; + expect(isManagedBlocked(env)).toBe(true); + }); +}); + +describe('the shared 403', () => { + it('MANAGED-007: one body, and it does not name a reason', () => { + // Pinned because DEMO_MODE grew two texts for one condition (REST says + // 'Self-host TREK for full functionality.', MCP says something else) and + // neither can now be changed without hunting for the other. + expect(MANAGED_FORBIDDEN_ERROR).toEqual({ + error: 'This is configured by the operator of this instance.', + code: 'MANAGED_FORBIDDEN', + }); + }); +}); + +describe('ManagedForbidden', () => { + it('MANAGED-008: writes the reason onto the handler for the next reader', () => { + class Controller { + @ManagedForbidden('the operator holds this credential') + handler() {} + } + + const meta = Reflect.getMetadata(MANAGED_FORBIDDEN, Controller.prototype.handler); + expect(meta).toEqual({ reason: 'the operator holds this credential' }); + }); +});