diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 1bf35508b..cfb203838 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -108,6 +108,7 @@ SQLite is the engine today and Postgres is the planned second one. Code under `s - Everything else calls `readEnv()` — live, uncached, per call. - Validation is fail-fast at boot only (`boot-validate.ts`, imported by `index.ts` right after dotenv): malformed values abort startup; unset/blank defaults. Never wire validation into `buildApp()` or `ConfigModule.forRoot`, and never `cache`/snapshot a runtime-toggled value (it breaks the env-mutating tests). - Booleans go through `parseBool` (true/1/on/yes vs false/0/off/no). Everything else pins its exact legacy coercion — quirks and the ESLint exemption list are in `src/app-config/README.md`. +- **Instance settings** (`app_settings`) are named through the typed register `src/nest/common/app-settings.registry.ts`: one entry per key with its Zod shape, default, environment override and flags (admin form, masked, encrypted, managed-locked, public config). Read with `readAppSetting(repo, key)` (the stored text, exactly what `getValue` answers) or `resolveAppSetting(repo, key)` (a non-empty environment value first); ask `isOidcConfigured()` rather than spelling the issuer/client-id check. The admin form's key lists derive from the flags. ESLint refuses `getValue('')` outside the register; a key built from parts (`admin_notif_pref_*`) is a family the register does not list. ## Cross-cutting pieces diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index f0e93632d..a7b1123f2 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -117,6 +117,19 @@ const STRING_SQL_SELECTORS = [ }, ]; +// An instance setting is named through the typed register in +// src/nest/common/app-settings.registry.ts (readAppSetting/resolveAppSetting), +// which checks the key and owns the env override, never as a string handed to +// AppSettingsRepository.getValue. The register itself passes a variable, and a +// key built from parts (`admin_notif_pref_${event}_${channel}`) names a family +// the register does not list one by one. +const APP_SETTING_MESSAGE = + "Read an app setting through readAppSetting()/resolveAppSetting() from src/nest/common/app-settings.registry.ts, not getValue(''): the register types the key and owns its env override."; +const APP_SETTING_SELECTORS = [ + { selector: "CallExpression[callee.property.name='getValue'] > Literal.arguments:first-child", message: APP_SETTING_MESSAGE }, + { selector: "CallExpression[callee.property.name='getValue'] > TemplateLiteral.arguments:first-child[expressions.length=0]", message: APP_SETTING_MESSAGE }, +]; + export default tseslint.config( gitignore({ strict: false }), { @@ -231,7 +244,7 @@ export default tseslint.config( 'src/nest/plugins/host/plugin-audit.ts', ], rules: { - 'no-restricted-syntax': ['error', ...ENV_SELECTORS, ...FETCH_SELECTORS], + 'no-restricted-syntax': ['error', ...ENV_SELECTORS, ...FETCH_SELECTORS, ...APP_SETTING_SELECTORS], }, }, { @@ -356,6 +369,7 @@ export default tseslint.config( ...RAW_SQL_SELECTORS, ...FETCH_SELECTORS, ...INSERT_ID_SELECTORS, + ...APP_SETTING_SELECTORS, ], }, }, @@ -386,6 +400,7 @@ export default tseslint.config( ...FETCH_SELECTORS, ...STRING_SQL_SELECTORS, ...INSERT_ID_SELECTORS, + ...APP_SETTING_SELECTORS, ], }, }, diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json index 91ba58ac6..eba5a97ea 100644 --- a/server/scripts/size-baseline.json +++ b/server/scripts/size-baseline.json @@ -7,7 +7,7 @@ "src/db/repositories/Users.repository.ts": 1710, "src/nest/atlas/atlas-geo.ts": 1005, "src/nest/atlas/atlas.service.ts": 1182, - "src/nest/auth/auth.service.ts": 1163, + "src/nest/auth/auth.service.ts": 1156, "src/nest/budget/budget.service.ts": 1702, "src/nest/collections/collections.service.ts": 1488, "src/nest/doc-sync/doc-sync.service.ts": 1060, diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index 4b558a8ca..6f708ff46 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -17,6 +17,7 @@ import type { AppSettingsRepository } from '../../db/repositories/AppSettings.re import { Users } from '../../db/entities/Users.entity'; import type { UsersRepository } from '../../db/repositories/Users.repository'; import { UnitOfWork } from '../database/unit-of-work'; +import { readAppSetting } from '../common/app-settings.registry'; /** * Thin wrapper around the enabled-addons + photo-provider read that the legacy @@ -54,7 +55,7 @@ export class AddonsService { } async getBagTracking() { - const value = await this.appSettings.getValue('bag_tracking_enabled'); + const value = await readAppSetting(this.appSettings, 'bag_tracking_enabled'); return { enabled: value === 'true' }; } @@ -224,7 +225,7 @@ export class AddonsService { * "off" while the feature runs, which is worse than either default. */ async getPlacesEnrich() { - const value = await this.appSettings.getValue('places_enrich_enabled'); + const value = await readAppSetting(this.appSettings, 'places_enrich_enabled'); return { enabled: value !== 'false' }; } diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 2ae3f316a..8e2a3b304 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -68,6 +68,7 @@ import { import { MANAGED_FORBIDDEN_ERROR } from '../common/managed'; import { utcSuffix } from '../../db/types'; import { SessionsService } from '../sessions/sessions.service'; +import { readAppSetting } from '../common/app-settings.registry'; /** Outbound GitHub calls: hard timeout and response-size cap (server/CLAUDE.md). */ const GITHUB_TIMEOUT_MS = 10_000; @@ -559,7 +560,7 @@ export class AdminService { const result = await this.checkVersion(); if (!result.update_available) return; - const lastNotified = await this.appSettings.getValue('last_notified_version'); + const lastNotified = await readAppSetting(this.appSettings, 'last_notified_version'); if (lastNotified === result.latest) return; // INSERT OR REPLACE shape — the SAME dialect storage's `upsertOrReplace` diff --git a/server/src/nest/auth/auth.helpers.ts b/server/src/nest/auth/auth.helpers.ts index bdafd31fd..250ad556a 100644 --- a/server/src/nest/auth/auth.helpers.ts +++ b/server/src/nest/auth/auth.helpers.ts @@ -32,21 +32,6 @@ export const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', BCRY export const MFA_BACKUP_CODE_COUNT = 10; -export const ADMIN_SETTINGS_KEYS = [ - 'allow_registration', 'allowed_file_types', 'require_mfa', - 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', - 'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token', - 'notify_trip_reminder', - 'password_login', 'password_registration', 'oidc_login', 'oidc_registration', - 'passkey_login', 'webauthn_rp_id', 'webauthn_origins', - // Which places provider answers search/autocomplete/details: 'auto' (the - // default and what every install had before Amap existed), 'google', 'amap' or - // 'openstreetmap'. Validated in updateAppSettings — an unknown value is - // refused rather than stored, because the reader degrades a bad row to 'auto' - // and the admin would be left looking at a setting that does nothing. - 'places_provider', -]; - // --------------------------------------------------------------------------- // Helpers (exported for route-level use where needed) // --------------------------------------------------------------------------- diff --git a/server/src/nest/auth/auth.service.ts b/server/src/nest/auth/auth.service.ts index 716a7e3b3..d1bf2532a 100644 --- a/server/src/nest/auth/auth.service.ts +++ b/server/src/nest/auth/auth.service.ts @@ -57,7 +57,6 @@ import { MailerService } from '../notifications/mailer/mailer.service'; import { AllowedFileTypesService } from '../files/allowed-file-types.service'; import { dbNow, parseDbTimestamp } from '../../db/types'; import { - ADMIN_SETTINGS_KEYS, BCRYPT_COST, DUMMY_PASSWORD_HASH, EMAIL_REGEX, @@ -68,6 +67,17 @@ import { parseBackupCodeHashes, stripUserForClient, } from './auth.helpers'; +import { + ADMIN_FORM_SETTING_KEYS, + APP_SETTINGS, + MASKED_ADMIN_SETTING_KEYS, + PUBLIC_CONFIG_SETTING_KEYS, + isOidcConfigured, + isOidcConfiguredIn, + readAppSetting, + type AppSettingDef, + type AppSettingKey, +} from '../common/app-settings.registry'; // Mutates otplib module state; must run before any TOTP verify in either the // container singleton or the bridge instance (legacy parity — same line sat at @@ -208,7 +218,7 @@ export class AuthService { oidc_registration: boolean; passkey_login: boolean; }> { - const get = (key: string) => this.appSettings.getValue(key); + const get = (key: AppSettingKey) => readAppSetting(this.appSettings, key); // Passkey login is independent of the password/OIDC "new keys" probe, so it // must be resolved OUTSIDE the branch below — otherwise on a fresh install @@ -217,7 +227,7 @@ export class AuthService { const passkey_login = (await get('passkey_login')) === 'true'; const hasNewKeys = ( - await Promise.all(['password_login', 'password_registration', 'oidc_login', 'oidc_registration'].map((k) => get(k))) + await Promise.all((['password_login', 'password_registration', 'oidc_login', 'oidc_registration'] as const).map((k) => get(k))) ).some((v) => v !== null); if (hasNewKeys) { @@ -237,11 +247,7 @@ export class AuthService { // Legacy fallback const oidcOnlyEnabled = readEnv().oidc.only || (await get('oidc_only')) === 'true'; - const oidcConfigured = !!( - (readEnv().oidc.issuer || (await get('oidc_issuer'))) && - (readEnv().oidc.clientId || (await get('oidc_client_id'))) - ); - const oidcOnly = oidcOnlyEnabled && oidcConfigured; + const oidcOnly = oidcOnlyEnabled && (await isOidcConfigured(this.appSettings)); const allowReg = ((await get('allow_registration')) ?? 'true') === 'true'; return { @@ -292,7 +298,7 @@ export class AuthService { if (cfg.rpID !== 'localhost' || cfg.explicitOrigins) return true; const env = readEnv(); const declaredRpId = ( - env.webauthn.rpId || (await this.appSettings.getValue('webauthn_rp_id')) + env.webauthn.rpId || (await readAppSetting(this.appSettings, 'webauthn_rp_id')) )?.trim(); return !!(declaredRpId || env.app.appUrl || env.http.allowedOriginsRaw); } @@ -318,20 +324,12 @@ export class AuthService { // AU5's 14 literal `app_settings` reads collapse into one `getValues` call // (Task 5 brief ruling — the repository already has it, and the map is read // the same way a missing/NULL-valued row was before: absent from the map). - const settings = await this.appSettings.getValues([ - 'places_provider', 'oidc_display_name', 'oidc_issuer', 'oidc_client_id', 'require_mfa', - 'notification_channel', 'notify_trip_reminder', 'smtp_host', 'notification_channels', - 'places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled', - 'places_enrich_enabled', 'place_shadow_enabled', - ]); + const settings = await this.appSettings.getValues([...PUBLIC_CONFIG_SETTING_KEYS]); const placesProviderRow = settings.get('places_provider'); const placesProvider = isPlacesProviderChoice(placesProviderRow) ? placesProviderRow : 'auto'; const oidcDisplayName = readEnv().oidc.displayName || settings.get('oidc_display_name') || null; - const oidcConfigured = !!( - (readEnv().oidc.issuer || settings.get('oidc_issuer')) && - (readEnv().oidc.clientId || settings.get('oidc_client_id')) - ); + const oidcConfigured = isOidcConfiguredIn(settings); const requireMfaValue = settings.get('require_mfa'); const notifChannel = settings.get('notification_channel') || 'none'; const tripReminderSetting = settings.get('notify_trip_reminder'); @@ -707,9 +705,9 @@ export class AuthService { if (role !== 'admin') return { error: 'Admin access required', status: 403 }; const result: Record = {}; - for (const key of ADMIN_SETTINGS_KEYS) { - const value = await this.appSettings.getValue(key); - if (value !== null) result[key] = (key === 'smtp_pass' || key === 'admin_webhook_url' || key === 'admin_ntfy_token') ? '••••••••' : value; + for (const key of ADMIN_FORM_SETTING_KEYS) { + const value = await readAppSetting(this.appSettings, key); + if (value !== null) result[key] = MASKED_ADMIN_SETTING_KEYS.has(key) ? '••••••••' : value; } return { data: result }; } @@ -747,10 +745,7 @@ export class AuthService { // Lockout prevention: can't disable all login methods if (body.password_login !== undefined || body.oidc_login !== undefined) { const current = await this.resolveAuthToggles(); - const oidcConfigured = !!( - (readEnv().oidc.issuer || (await this.appSettings.getValue('oidc_issuer'))) && - (readEnv().oidc.clientId || (await this.appSettings.getValue('oidc_client_id'))) - ); + const oidcConfigured = await isOidcConfigured(this.appSettings); const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login; const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login; if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) { @@ -765,7 +760,7 @@ export class AuthService { const { blocked } = splitManagedKeys(body as Record, readEnv().managed.enabled); await this.uow.transactional(async () => { // the whole form lands, or none of it - for (const key of ADMIN_SETTINGS_KEYS) { + for (const key of ADMIN_FORM_SETTING_KEYS) { if (blocked.includes(key)) continue; if (body[key] !== undefined) { let val = String(body[key]); @@ -776,18 +771,17 @@ export class AuthService { // degrades an unrecognised row to 'auto', so writing one would show the // admin a saved setting that quietly does nothing. if (key === 'places_provider' && !isPlacesProviderChoice(val)) continue; - if (key === 'smtp_pass' && val === '••••••••') continue; - if (key === 'smtp_pass') val = encrypt_api_key(val); - if (key === 'admin_webhook_url' && val === '••••••••') continue; - if (key === 'admin_webhook_url' && val) val = maybe_encrypt_api_key(val) ?? val; - if (key === 'admin_ntfy_token' && val === '••••••••') continue; - if (key === 'admin_ntfy_token' && val) val = maybe_encrypt_api_key(val) ?? val; + // A masked key sent back unchanged keeps its stored value. + if (MASKED_ADMIN_SETTING_KEYS.has(key) && val === '••••••••') continue; + const encrypted: AppSettingDef['encrypted'] = (APP_SETTINGS[key] as AppSettingDef).encrypted; + if (encrypted === 'always') val = encrypt_api_key(val); + else if (encrypted === 'if-plain' && val) val = maybe_encrypt_api_key(val) ?? val; await this.appSettings.setValue(key, val); } } }); - const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => !blocked.includes(k) && body[k] !== undefined && !(k === 'smtp_pass' && String(body[k]) === '••••••••')); + const changedKeys = ADMIN_FORM_SETTING_KEYS.filter(k => !blocked.includes(k) && body[k] !== undefined && !(k === 'smtp_pass' && String(body[k]) === '••••••••')); const summary: Record = {}; const smtpChanged = changedKeys.some(k => k.startsWith('smtp_')); @@ -865,7 +859,7 @@ export class AuthService { if (readEnv().demo.enabled && isDemoEmail(userEmail)) { return { error: 'MFA cannot be changed in demo mode.', status: 403 }; } - const policy = await this.appSettings.getValue('require_mfa'); + const policy = await readAppSetting(this.appSettings, 'require_mfa'); if (policy === 'true') { return { error: 'Two-factor authentication cannot be disabled while it is required for all users.', status: 403 }; } diff --git a/server/src/nest/auth/mfa-policy.guard.ts b/server/src/nest/auth/mfa-policy.guard.ts index 852b2326b..26a62ced5 100644 --- a/server/src/nest/auth/mfa-policy.guard.ts +++ b/server/src/nest/auth/mfa-policy.guard.ts @@ -12,6 +12,7 @@ import { Users } from '../../db/entities/Users.entity'; import type { UsersRepository } from '../../db/repositories/Users.repository'; import { WebauthnCredentials } from '../../db/entities/WebauthnCredentials.entity'; import type { WebauthnCredentialsRepository } from '../../db/repositories/WebauthnCredentials.repository'; +import { readAppSetting } from '../common/app-settings.registry'; /** Metadata key `@MfaExempt()` writes. */ export const MFA_EXEMPT = 'trek:mfa-exempt'; @@ -68,7 +69,7 @@ export class MfaPolicyGuard implements CanActivate { // No session: whoever answers next decides, exactly as before. if (!user) return true; - const requireMfa = await this.appSettings.getValue('require_mfa'); + const requireMfa = await readAppSetting(this.appSettings, 'require_mfa'); if (requireMfa !== 'true') return true; if (this.env.isDemoMode() && user.email && DEMO_EMAILS.has(user.email)) return true; diff --git a/server/src/nest/common/app-settings.registry.ts b/server/src/nest/common/app-settings.registry.ts new file mode 100644 index 000000000..7e6dd4740 --- /dev/null +++ b/server/src/nest/common/app-settings.registry.ts @@ -0,0 +1,149 @@ +import { z } from 'zod'; +import { readEnv } from '../../app-config'; +import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; + +/** + * The instance-wide settings (`app_settings`) the server reads by name, in one + * typed register. + * + * Every entry says what the stored text means (`schema`), what a reader takes + * when the row is missing (`default`), which environment variable wins over it + * (`env`), and how it is handled: part of the admin settings form (`adminForm`, + * in the form's order), answered masked to that form (`masked`), stored + * encrypted (`encrypted`), held by the operator on a managed install + * (`managedLocked`), read for the public app config (`publicConfig`). + * + * The key lists that used to be typed out by hand (the admin form's keys, its + * masked and encrypted ones, the public config's reads) are derived from here, + * and code outside this module names a setting through {@link readAppSetting} or + * {@link resolveAppSetting}, whose key is checked against the register: ESLint + * refuses a literal `appSettings.getValue('...')` anywhere else. + * + * Stored values are text, as they have always been. A reader that needs a + * boolean or a number still converts at its own site, the way it did before; + * the register only fixes the name and documents the meaning. + */ + +/** `'true'`/`'false'`; most readers take anything but `'false'` as on. */ +const flag = z.enum(['true', 'false']); +const text = z.string(); +/** A whole number written as text. */ +const intText = z.string().regex(/^\d+$/); + +type Env = ReturnType; + +export interface AppSettingDef { + readonly schema: z.ZodType; + /** What the readers take for a missing row (documentation; each reader applies it itself). */ + readonly default: string | null; + /** The environment value that wins over the stored one when it is non-empty. */ + readonly env?: (env: Env) => string | null | undefined; + readonly adminForm?: true; + readonly masked?: true; + /** `always`: encrypted on every write. `if-plain`: encrypted unless it already is. Read back with `decrypt_api_key`. */ + readonly encrypted?: 'always' | 'if-plain'; + readonly managedLocked?: true; + readonly publicConfig?: true; +} + +// The admin form's keys come first, in the order the form has always answered them. +export const APP_SETTINGS = { + allow_registration: { schema: flag, default: 'true', adminForm: true }, + allowed_file_types: { schema: text, default: null, adminForm: true }, + require_mfa: { schema: flag, default: 'false', adminForm: true, publicConfig: true }, + smtp_host: { schema: text, default: null, env: (e) => e.smtp.host, adminForm: true, managedLocked: true, publicConfig: true }, + smtp_port: { schema: intText, default: null, env: (e) => e.smtp.port, adminForm: true, managedLocked: true }, + smtp_user: { schema: text, default: null, env: (e) => e.smtp.user, adminForm: true, managedLocked: true }, + smtp_pass: { schema: text, default: null, env: (e) => e.smtp.pass, adminForm: true, masked: true, encrypted: 'always', managedLocked: true }, + smtp_from: { schema: text, default: null, env: (e) => e.smtp.from, adminForm: true, managedLocked: true }, + smtp_skip_tls_verify: { schema: flag, default: 'false', adminForm: true, managedLocked: true }, + notification_channels: { schema: text, default: null, adminForm: true, publicConfig: true }, + admin_webhook_url: { schema: text, default: null, adminForm: true, masked: true, encrypted: 'if-plain' }, + admin_ntfy_server: { schema: text, default: null, adminForm: true }, + admin_ntfy_topic: { schema: text, default: null, adminForm: true }, + admin_ntfy_token: { schema: text, default: null, adminForm: true, masked: true, encrypted: 'if-plain' }, + notify_trip_reminder: { schema: flag, default: 'true', adminForm: true, publicConfig: true }, + password_login: { schema: flag, default: 'true', adminForm: true }, + password_registration: { schema: flag, default: 'true', adminForm: true }, + oidc_login: { schema: flag, default: 'true', adminForm: true, managedLocked: true }, + oidc_registration: { schema: flag, default: 'true', adminForm: true, managedLocked: true }, + passkey_login: { schema: flag, default: 'false', adminForm: true }, + webauthn_rp_id: { schema: text, default: null, adminForm: true, managedLocked: true }, + webauthn_origins: { schema: text, default: null, adminForm: true, managedLocked: true }, + // 'auto' (what every install had before Amap existed), 'google', 'amap' or + // 'openstreetmap'; the maps domain validates it (isPlacesProviderChoice). + places_provider: { schema: text, default: 'auto', adminForm: true, publicConfig: true }, + + // Read by name elsewhere. + oidc_issuer: { schema: text, default: null, env: (e) => e.oidc.issuer, publicConfig: true }, + oidc_client_id: { schema: text, default: null, env: (e) => e.oidc.clientId, publicConfig: true }, + oidc_client_secret: { schema: text, default: null, env: (e) => e.oidc.clientSecret, encrypted: 'if-plain' }, + oidc_display_name: { schema: text, default: 'SSO', env: (e) => e.oidc.displayName, publicConfig: true }, + oidc_discovery_url: { schema: text, default: null, env: (e) => e.oidc.discoveryUrl }, + /** The legacy single switch, read only while none of the four login toggles has a row. */ + oidc_only: { schema: flag, default: 'false' }, + notification_channel: { schema: text, default: 'none', publicConfig: true }, + notify_todo_due: { schema: flag, default: 'true' }, + places_photos_enabled: { schema: flag, default: 'true', publicConfig: true }, + places_autocomplete_enabled: { schema: flag, default: 'true', publicConfig: true }, + places_details_enabled: { schema: flag, default: 'true', publicConfig: true }, + places_enrich_enabled: { schema: flag, default: 'true', publicConfig: true }, + place_shadow_enabled: { schema: flag, default: 'false', publicConfig: true }, + bag_tracking_enabled: { schema: flag, default: 'false' }, + route_usage_enabled: { schema: flag, default: 'false' }, + airtrail_sync_enabled: { schema: flag, default: 'true' }, + airtrail_poll_interval_minutes: { schema: intText, default: null }, + dawarich_poll_interval_minutes: { schema: intText, default: null }, + /** The release the admins were last notified about (the version-check job's memory). */ + last_notified_version: { schema: text, default: null }, +} as const satisfies Record; + +export type AppSettingKey = keyof typeof APP_SETTINGS; + +const ENTRIES = Object.entries(APP_SETTINGS) as Array<[AppSettingKey, AppSettingDef]>; +const keysWhere = (pick: (def: AppSettingDef) => unknown): AppSettingKey[] => ENTRIES.filter(([, def]) => pick(def)).map(([key]) => key); + +/** The admin settings form's keys, in the order its answer has always listed them. */ +export const ADMIN_FORM_SETTING_KEYS: readonly AppSettingKey[] = keysWhere((d) => d.adminForm); +/** The form's keys answered as `••••••••` rather than their value. */ +export const MASKED_ADMIN_SETTING_KEYS: ReadonlySet = new Set(keysWhere((d) => d.masked)); +/** The keys stored encrypted. */ +export const ENCRYPTED_APP_SETTING_KEYS: ReadonlySet = new Set(keysWhere((d) => d.encrypted)); +/** The registered keys a managed install withholds from its admin (the rest of that list is user settings and profile columns). */ +export const MANAGED_LOCKED_APP_SETTING_KEYS: readonly AppSettingKey[] = keysWhere((d) => d.managedLocked); +/** The keys the public app config reads, in one query. */ +export const PUBLIC_CONFIG_SETTING_KEYS: readonly AppSettingKey[] = keysWhere((d) => d.publicConfig); + +type AppSettingsReader = Pick; + +/** The stored text of a registered setting, or null when it has no row: exactly what `getValue` answers. */ +export function readAppSetting(repo: AppSettingsReader, key: AppSettingKey): Promise { + return repo.getValue(key); +} + +/** + * The environment value when it is set and non-empty, else the stored text + * (`env || stored`, the rule every env-over-settings reader has followed). + * A setting without an environment variable reads as {@link readAppSetting}. + */ +export async function resolveAppSetting(repo: AppSettingsReader, key: AppSettingKey): Promise { + const def: AppSettingDef = APP_SETTINGS[key]; + return def.env?.(readEnv()) || (await repo.getValue(key)); +} + +/** + * Whether sign-in through OIDC is set up: an issuer and a client id, each from + * the environment or the setting, an empty value counting as unset. The client + * secret is not asked for here: the login toggles, the public config and the + * lockout guard never did. Building the provider config (OidcService) needs it + * and checks it there. + */ +export async function isOidcConfigured(repo: AppSettingsReader): Promise { + return !!((await resolveAppSetting(repo, 'oidc_issuer')) && (await resolveAppSetting(repo, 'oidc_client_id'))); +} + +/** {@link isOidcConfigured} over values already read in one batch (`AppSettingsRepository.getValues`). */ +export function isOidcConfiguredIn(values: ReadonlyMap): boolean { + const env = readEnv(); + return !!((env.oidc.issuer || values.get('oidc_issuer')) && (env.oidc.clientId || values.get('oidc_client_id'))); +} diff --git a/server/src/nest/files/allowed-file-types.service.ts b/server/src/nest/files/allowed-file-types.service.ts index c2bfae4fb..357e40fd8 100644 --- a/server/src/nest/files/allowed-file-types.service.ts +++ b/server/src/nest/files/allowed-file-types.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@mikro-orm/nestjs'; import { AppSettings } from '../../db/entities/AppSettings.entity'; import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import { DEFAULT_ALLOWED_EXTENSIONS } from './files.constants'; +import { readAppSetting } from '../common/app-settings.registry'; /** * The operator's allowed-extension list, on its own. @@ -24,7 +25,7 @@ export class AllowedFileTypesService { /** Comma-separated, as the admin panel stores it. `*` means anything. */ async get(): Promise { try { - const value = await this.appSettings.getValue('allowed_file_types'); + const value = await readAppSetting(this.appSettings, 'allowed_file_types'); return value || DEFAULT_ALLOWED_EXTENSIONS; } catch { return DEFAULT_ALLOWED_EXTENSIONS; diff --git a/server/src/nest/integrations/airtrail-link.service.ts b/server/src/nest/integrations/airtrail-link.service.ts index 362023406..13c87ed1e 100644 --- a/server/src/nest/integrations/airtrail-link.service.ts +++ b/server/src/nest/integrations/airtrail-link.service.ts @@ -18,6 +18,7 @@ import { canonicalHash } from './airtrail.mapper'; import { buildSavePayload } from './airtrail-sync.helpers'; import { RESERVATION_METADATA } from '../../db/json-columns'; import { decodeJson } from '../../utils/json-column'; +import { readAppSetting } from '../common/app-settings.registry'; /** * The AirTrail link lifecycle — the enablement gate, the detach policy, the @@ -46,7 +47,7 @@ export class AirtrailLinkService { /** Global on/off: the addon must be enabled and sync not explicitly turned off. */ async syncGloballyEnabled(): Promise { if (!(await this.addons.isAddonEnabled(ADDON_IDS.AIRTRAIL))) return false; - const value = await this.appSettings.getValue('airtrail_sync_enabled'); + const value = await readAppSetting(this.appSettings, 'airtrail_sync_enabled'); return value !== 'false'; } diff --git a/server/src/nest/integrations/airtrail-sync.job.ts b/server/src/nest/integrations/airtrail-sync.job.ts index 78bbe980c..8d6834f4e 100644 --- a/server/src/nest/integrations/airtrail-sync.job.ts +++ b/server/src/nest/integrations/airtrail-sync.job.ts @@ -5,6 +5,7 @@ import { AppSettings } from '../../db/entities/AppSettings.entity'; import { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import { CronRegistrarService } from '../scheduling/cron-registrar.service'; import { AirtrailSyncService } from './airtrail-sync.service'; +import { readAppSetting } from '../common/app-settings.registry'; /** * AirTrail sync: poll connected instances on an interval and reconcile linked @@ -32,7 +33,7 @@ export class AirtrailSyncJob implements OnApplicationBootstrap { // registering at all. let minutes = 5; await this.registrar.runOnBoot('airtrail-sync-boot', async () => { - const value = await this.appSettings.getValue('airtrail_poll_interval_minutes'); + const value = await readAppSetting(this.appSettings, 'airtrail_poll_interval_minutes'); const raw = Number.parseInt(value || '5', 10); minutes = Number.isFinite(raw) && raw >= 1 && raw <= 59 ? raw : 5; logInfo(`AirTrail sync: scheduled every ${minutes}m`); diff --git a/server/src/nest/integrations/dawarich-sync.job.ts b/server/src/nest/integrations/dawarich-sync.job.ts index aa9ef7920..855dd67e4 100644 --- a/server/src/nest/integrations/dawarich-sync.job.ts +++ b/server/src/nest/integrations/dawarich-sync.job.ts @@ -5,6 +5,7 @@ import { AppSettings } from '../../db/entities/AppSettings.entity'; import { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import { CronRegistrarService } from '../scheduling/cron-registrar.service'; import { DawarichSyncService } from './dawarich-sync.service'; +import { readAppSetting } from '../common/app-settings.registry'; /** * Polls every connected Dawarich instance for new visits (#2279). @@ -38,7 +39,7 @@ export class DawarichSyncJob implements OnApplicationBootstrap { // not registering at all. let minutes = 15; await this.registrar.runOnBoot('dawarich-sync-boot', async () => { - const value = await this.appSettings.getValue('dawarich_poll_interval_minutes'); + const value = await readAppSetting(this.appSettings, 'dawarich_poll_interval_minutes'); const raw = Number.parseInt(value || '15', 10); minutes = Number.isFinite(raw) && raw >= 5 && raw <= 59 ? raw : 15; logInfo(`Dawarich sync: scheduled every ${minutes}m`); diff --git a/server/src/nest/notifications/mailer/mailer.service.ts b/server/src/nest/notifications/mailer/mailer.service.ts index 27f88ab7e..eaf510d86 100644 --- a/server/src/nest/notifications/mailer/mailer.service.ts +++ b/server/src/nest/notifications/mailer/mailer.service.ts @@ -14,6 +14,7 @@ import { Users } from '../../../db/entities/Users.entity'; import { buildEmailHtml, buildPasswordResetHtml } from './email-html'; import { emailLogoAttachment } from './email-logo'; import { describeSmtpFailure, describeSmtpGap, parseSmtpPort, type SmtpTarget } from './smtp-diagnostics'; +import { readAppSetting, resolveAppSetting } from '../../common/app-settings.registry'; interface SmtpConfig { host: string; @@ -69,13 +70,13 @@ export class MailerService { /** Env wins over the admin panel, per field. Read fresh on every send. */ private async readSmtpSettings() { - const smtpEnv = readEnv().smtp; return { - host: smtpEnv.host || (await this.appSettings.getValue('smtp_host')), - port: smtpEnv.port || (await this.appSettings.getValue('smtp_port')), - user: smtpEnv.user || (await this.appSettings.getValue('smtp_user')), - pass: smtpEnv.pass || decrypt_api_key(await this.appSettings.getValue('smtp_pass')) || '', - from: smtpEnv.from || (await this.appSettings.getValue('smtp_from')), + host: await resolveAppSetting(this.appSettings, 'smtp_host'), + port: await resolveAppSetting(this.appSettings, 'smtp_port'), + user: await resolveAppSetting(this.appSettings, 'smtp_user'), + // The stored password is encrypted, the environment's is not. + pass: readEnv().smtp.pass || decrypt_api_key(await readAppSetting(this.appSettings, 'smtp_pass')) || '', + from: await resolveAppSetting(this.appSettings, 'smtp_from'), }; } @@ -103,7 +104,7 @@ export class MailerService { * one method is the only change, and it keeps the freshness property visible. */ private async createTransport(config: SmtpConfig, socketTimeoutMs: number = SOCKET_TIMEOUT_MS) { - const skipTls = readEnv().smtp.skipTlsVerify || (await this.appSettings.getValue('smtp_skip_tls_verify')) === 'true'; + const skipTls = readEnv().smtp.skipTlsVerify || (await readAppSetting(this.appSettings, 'smtp_skip_tls_verify')) === 'true'; if (skipTls) this.warnOnceAboutSkippedTls(config); return nodemailer.createTransport({ host: config.host, @@ -138,7 +139,7 @@ export class MailerService { /** Is SMTP configured at the instance level? (Independent of any one user's address.) */ async isSmtpConfigured(): Promise { - return !!(readEnv().smtp.host || (await this.appSettings.getValue('smtp_host'))); + return !!(await resolveAppSetting(this.appSettings, 'smtp_host')); } async getUserEmail(userId: number): Promise { diff --git a/server/src/nest/notifications/notification-preferences.service.ts b/server/src/nest/notifications/notification-preferences.service.ts index 25377e313..79fb60502 100644 --- a/server/src/nest/notifications/notification-preferences.service.ts +++ b/server/src/nest/notifications/notification-preferences.service.ts @@ -19,6 +19,7 @@ import { type NotifChannel, type NotifEventType, } from './notification-events'; +import { readAppSetting } from '../common/app-settings.registry'; export interface PreferencesMatrix { preferences: Partial>>>; @@ -96,7 +97,7 @@ export class NotificationPreferencesService { * would silently drop any that were). */ async getActiveChannels(): Promise { - const raw = (await this.appSettings.getValue('notification_channels')) || (await this.appSettings.getValue('notification_channel')) || 'none'; + const raw = (await readAppSetting(this.appSettings, 'notification_channels')) || (await readAppSetting(this.appSettings, 'notification_channel')) || 'none'; if (raw === 'none') return []; const builtins = new Set((await listChannels()).filter(c => c.source === 'builtin').map(c => c.id)); return raw.split(',').map(c => c.trim()).filter(c => builtins.has(c)); @@ -203,8 +204,8 @@ export class NotificationPreferencesService { // Admin-scoped events go out over the admin's own global credentials, which // are independent of the per-user `notification_channels` toggle. const hasSmtp = await this.mailer.isSmtpConfigured(); - const hasAdminWebhook = !!(await this.appSettings.getValue('admin_webhook_url')); - const hasAdminNtfy = !!(await this.appSettings.getValue('admin_ntfy_topic')); + const hasAdminWebhook = !!(await readAppSetting(this.appSettings, 'admin_webhook_url')); + const hasAdminNtfy = !!(await readAppSetting(this.appSettings, 'admin_ntfy_topic')); const adminActive: Record = { email: hasSmtp, webhook: hasAdminWebhook, ntfy: hasAdminNtfy }; for (const channel of await listChannels()) { // Only the channels with an admin-global copy. Plugin channels and push are @@ -290,7 +291,7 @@ export class NotificationPreferencesService { channels: await this.describeChannels(userId, scope), event_types, implemented_combos, - ...(scope === 'user' && { defaults: { ntfyServer: (await this.appSettings.getValue('admin_ntfy_server')) || null }, locked }), + ...(scope === 'user' && { defaults: { ntfyServer: (await readAppSetting(this.appSettings, 'admin_ntfy_server')) || null }, locked }), }; } diff --git a/server/src/nest/notifications/reminder-jobs.service.ts b/server/src/nest/notifications/reminder-jobs.service.ts index 91d890926..d38540d18 100644 --- a/server/src/nest/notifications/reminder-jobs.service.ts +++ b/server/src/nest/notifications/reminder-jobs.service.ts @@ -11,6 +11,7 @@ import { NotificationsService, type NotificationDelivery, type NotificationPaylo import { CronRegistrarService } from '../scheduling/cron-registrar.service'; import { addIsoDays } from '@trek/shared'; import { appClock } from '../common/timezoneService'; +import { readAppSetting } from '../common/app-settings.registry'; /** * The trip-reminder and todo-due reminder crons, in the domain that owns them @@ -62,8 +63,8 @@ export class ReminderJobsService implements OnApplicationBootstrap { // safe if this dependency graph goes repository-backed later). await this.registrar.runOnBoot('reminder-jobs-boot', async () => { try { - const reminderEnabled = (await this.appSettings.getValue('notify_trip_reminder')) !== 'false'; - const channelsRaw = (await this.appSettings.getValue('notification_channels')) || (await this.appSettings.getValue('notification_channel')) || 'none'; + const reminderEnabled = (await readAppSetting(this.appSettings, 'notify_trip_reminder')) !== 'false'; + const channelsRaw = (await readAppSetting(this.appSettings, 'notification_channels')) || (await readAppSetting(this.appSettings, 'notification_channel')) || 'none'; const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map(c => c.trim()); if (!reminderEnabled) { logInfo('Trip reminders: disabled in settings'); @@ -72,7 +73,7 @@ export class ReminderJobsService implements OnApplicationBootstrap { logInfo(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`); } - if ((await this.appSettings.getValue('notify_todo_due')) !== 'false') { + if ((await readAppSetting(this.appSettings, 'notify_todo_due')) !== 'false') { logInfo(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`); } else { logInfo('Todo due reminders: disabled in settings'); @@ -110,7 +111,7 @@ export class ReminderJobsService implements OnApplicationBootstrap { */ async tripTick(): Promise { try { - if ((await this.appSettings.getValue('notify_trip_reminder')) === 'false') return; + if ((await readAppSetting(this.appSettings, 'notify_trip_reminder')) === 'false') return; const today = appClock().date; const candidates = await this.trips.listReminderCandidates(); @@ -155,7 +156,7 @@ export class ReminderJobsService implements OnApplicationBootstrap { */ async todoTick(): Promise { try { - if ((await this.appSettings.getValue('notify_todo_due')) === 'false') return; + if ((await readAppSetting(this.appSettings, 'notify_todo_due')) === 'false') return; // `due_date` is canonical `YYYY-MM-DD` text, so a text-range bind // covers the lead window. "Today" is the date in the job's TZ. diff --git a/server/src/nest/notifications/transports/ntfy.service.ts b/server/src/nest/notifications/transports/ntfy.service.ts index cec1ff7a1..5c23c9b8a 100644 --- a/server/src/nest/notifications/transports/ntfy.service.ts +++ b/server/src/nest/notifications/transports/ntfy.service.ts @@ -8,6 +8,7 @@ import { AppSettings } from '../../../db/entities/AppSettings.entity'; import { Settings } from '../../../db/entities/Settings.entity'; import { safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard'; import type { NotifEventType } from '../notification-events'; +import { readAppSetting } from '../../common/app-settings.registry'; /** NS2's three per-user ntfy keys, read together off `SettingsRepository.getForUser`. */ const NTFY_USER_KEYS = new Set(['ntfy_topic', 'ntfy_server', 'ntfy_token']); @@ -142,9 +143,9 @@ export class NtfyService { /** NS1 — three of the plan's six identical `app_settings` reads, R4's shared `AppSettingsRepository.getValue(key)`. */ async getAdminNtfyConfig(): Promise { - const topic = (await this.appSettings.getValue('admin_ntfy_topic')) || null; - const server = (await this.appSettings.getValue('admin_ntfy_server')) || null; - const rawToken = (await this.appSettings.getValue('admin_ntfy_token')) || null; + const topic = (await readAppSetting(this.appSettings, 'admin_ntfy_topic')) || null; + const server = (await readAppSetting(this.appSettings, 'admin_ntfy_server')) || null; + const rawToken = (await readAppSetting(this.appSettings, 'admin_ntfy_token')) || null; return { topic, server, diff --git a/server/src/nest/notifications/transports/webhook.service.ts b/server/src/nest/notifications/transports/webhook.service.ts index 294d27dd9..5caab376c 100644 --- a/server/src/nest/notifications/transports/webhook.service.ts +++ b/server/src/nest/notifications/transports/webhook.service.ts @@ -7,6 +7,7 @@ import { SettingsRepository } from '../../../db/repositories/Settings.repository import { AppSettings } from '../../../db/entities/AppSettings.entity'; import { Settings } from '../../../db/entities/Settings.entity'; import { safeFetchFollow, SsrfBlockedError } from '../../../utils/ssrfGuard'; +import { readAppSetting } from '../../common/app-settings.registry'; /** * Renders the outgoing body. Discord and Slack get their native shapes; anything @@ -62,7 +63,7 @@ export class WebhookService { /** WH2 — one of the plan's six identical `app_settings` reads, R4's shared `AppSettingsRepository.getValue(key)`. */ async getAdminWebhookUrl(): Promise { - const value = (await this.appSettings.getValue('admin_webhook_url')) || null; + const value = (await readAppSetting(this.appSettings, 'admin_webhook_url')) || null; return value ? decrypt_api_key(value) : null; } diff --git a/server/src/nest/oidc/oidc.service.ts b/server/src/nest/oidc/oidc.service.ts index c403a0a24..340fff6d1 100644 --- a/server/src/nest/oidc/oidc.service.ts +++ b/server/src/nest/oidc/oidc.service.ts @@ -23,6 +23,7 @@ import type { InviteTokensRepository, InviteTokenRow } from '../../db/repositori import { AppSettings } from '../../db/entities/AppSettings.entity'; import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import { InMemoryOidcFlowStore, OidcFlowStore, type OidcPendingState } from './oidc-flow.store'; +import { readAppSetting, resolveAppSetting, type AppSettingKey } from '../common/app-settings.registry'; // --------------------------------------------------------------------------- // Types @@ -323,14 +324,15 @@ export class OidcService implements OnModuleDestroy { // ------------------------------------------------------------------------- async getOidcConfig(): Promise { - const get = (key: string) => this.appSettings.getValue(key); - - const oidcEnv = readEnv().oidc; - const issuer = oidcEnv.issuer || (await get('oidc_issuer')); - const clientId = oidcEnv.clientId || (await get('oidc_client_id')); - const clientSecret = oidcEnv.clientSecret || decrypt_api_key(await get('oidc_client_secret')); - const displayName = oidcEnv.displayName || (await get('oidc_display_name')) || 'SSO'; - const discoveryUrl = oidcEnv.discoveryUrl || (await get('oidc_discovery_url')) || null; + // Environment first, then the setting (the register's rule), for every value. + // Unlike isOidcConfigured this also wants the client secret: it builds the + // config the provider is called with. The secret is stored encrypted. + const resolve = (key: AppSettingKey) => resolveAppSetting(this.appSettings, key); + const issuer = await resolve('oidc_issuer'); + const clientId = await resolve('oidc_client_id'); + const clientSecret = readEnv().oidc.clientSecret || decrypt_api_key(await readAppSetting(this.appSettings, 'oidc_client_secret')); + const displayName = (await resolve('oidc_display_name')) || 'SSO'; + const discoveryUrl = (await resolve('oidc_discovery_url')) || null; if (!issuer || !clientId || !clientSecret) return null; // The lookbehind pins the trailing-slash strip (here and below) to the start of @@ -832,7 +834,7 @@ export class OidcService implements OnModuleDestroy { // every user out of the instance. async getOidcSettings() { - const get = async (key: string) => (await this.appSettings.getValue(key)) || ''; + const get = async (key: AppSettingKey) => (await readAppSetting(this.appSettings, key)) || ''; const secret = decrypt_api_key(await get('oidc_client_secret')); return { issuer: await get('oidc_issuer'), diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index 47753e408..859994b5f 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -28,6 +28,7 @@ import { import { buildOsmDetails, isGooglePlaceId, parseWikipediaTag, rankCommonsCandidates, toWikiLang } from '../maps/maps.helpers'; import { trekPlacesById } from '../maps/trek-places.client'; import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service'; +import { readAppSetting } from '../common/app-settings.registry'; /** * How many pictures each source may contribute. @@ -331,7 +332,7 @@ export class PlaceEnrichmentService { * working, and there is nothing here that warrants a migration. */ async enrichDisabled(): Promise { - const value = await this.appSettings.getValue('places_enrich_enabled'); + const value = await readAppSetting(this.appSettings, 'places_enrich_enabled'); return value === 'false'; } diff --git a/server/src/nest/place-shadow/place-shadow.service.ts b/server/src/nest/place-shadow/place-shadow.service.ts index 6db863bcb..53410fd05 100644 --- a/server/src/nest/place-shadow/place-shadow.service.ts +++ b/server/src/nest/place-shadow/place-shadow.service.ts @@ -10,6 +10,7 @@ import { PlaceShadowPicks } from '../../db/entities/PlaceShadowPicks.entity'; import type { PlaceShadowPicksRepository, PlaceShadowPickRow } from '../../db/repositories/PlaceShadowPicks.repository'; import { AppSettings } from '../../db/entities/AppSettings.entity'; import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; +import { readAppSetting } from '../common/app-settings.registry'; /** Rows older than this are removed nightly. */ export const RETENTION_DAYS = 180; @@ -66,7 +67,7 @@ export class PlaceShadowService { ) {} async enabled(): Promise { - const value = await this.appSettings.getValue('place_shadow_enabled'); + const value = await readAppSetting(this.appSettings, 'place_shadow_enabled'); return value === 'true'; } diff --git a/server/src/nest/realtime/realtime.gateway.ts b/server/src/nest/realtime/realtime.gateway.ts index a2ffe5cb8..6784b5748 100644 --- a/server/src/nest/realtime/realtime.gateway.ts +++ b/server/src/nest/realtime/realtime.gateway.ts @@ -32,6 +32,7 @@ import { } from './ws-state'; import { JourneyDomainService } from '../journey/journey-domain.service'; import { hostVersion } from '../plugins/install/host-compat'; +import { readAppSetting } from '../common/app-settings.registry'; const HEARTBEAT_INTERVAL = 30_000; @@ -146,7 +147,7 @@ export class RealtimeGateway // Don't leak password_version beyond the handshake. const { password_version: _pv, ...user } = row; - const requireMfa = (await this.appSettings.getValue('require_mfa')) === 'true'; + const requireMfa = (await readAppSetting(this.appSettings, 'require_mfa')) === 'true'; const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true; if (requireMfa && !mfaOk) { socket.close(4403, 'MFA required'); diff --git a/server/src/nest/route-usage/route-usage.service.ts b/server/src/nest/route-usage/route-usage.service.ts index 773e95f07..eebf2c300 100644 --- a/server/src/nest/route-usage/route-usage.service.ts +++ b/server/src/nest/route-usage/route-usage.service.ts @@ -13,6 +13,7 @@ import { RouteUsageDailyRepository } from '../../db/repositories/RouteUsageDaily import { AppSettings } from '../../db/entities/AppSettings.entity'; import { AppSettingsRepository } from '../../db/repositories/AppSettings.repository'; import { UnitOfWork } from '../database/unit-of-work'; +import { readAppSetting } from '../common/app-settings.registry'; /** Days a counted day survives. Aggregates are tiny, so this is a year and a bit. */ export const RETENTION_DAYS = 400; @@ -42,7 +43,7 @@ export class RouteUsageService { ) {} async enabled(): Promise { - const value = await this.appSettings.getValue('route_usage_enabled'); + const value = await readAppSetting(this.appSettings, 'route_usage_enabled'); // Absent means on: the counters have to be collecting before anyone thinks to // look for them, and there is nothing here to protect. return value !== 'false'; diff --git a/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts index 1ce915168..6b32c561d 100644 --- a/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts +++ b/server/tests/unit/eslint-rules/restricted-syntax-selectors.test.ts @@ -223,3 +223,23 @@ declare const query: { executeTakeFirstOrThrow(): Promise<{ insertId: bigint | u expect(insertIdHits(options, 'declare const row: { id: number };\nvoid row.id;')).toEqual([]); }); }); + +describe('app setting selectors', () => { + const APP_SETTING = + "Read an app setting through readAppSetting()/resolveAppSetting() from src/nest/common/app-settings.registry.ts, not getValue(''): the register types the key and owns its env override."; + const PROBE = 'declare const repo: { getValue(key: string): Promise };\ndeclare const key: string;\n'; + + it('flags a literal key handed to getValue, quoted or as a template, in a service and in a repository', async () => { + const service = await selectorsFor('src/nest/maps/probe.service.ts'); + expect(messages(service, `${PROBE}void repo.getValue('smtp_host');`)).toEqual([APP_SETTING]); + expect(messages(service, `${PROBE}void repo.getValue(\`smtp_host\`);`)).toEqual([APP_SETTING]); + const repo = await selectorsFor('src/db/repositories/Probe.repository.ts'); + expect(messages(repo, `${PROBE}void repo.getValue('smtp_host');`)).toEqual([APP_SETTING]); + }); + + it('leaves a key passed as a variable or built from parts alone', async () => { + const service = await selectorsFor('src/nest/common/app-settings.registry.ts'); + expect(messages(service, `${PROBE}void repo.getValue(key);`)).toEqual([]); + expect(messages(service, `${PROBE}void repo.getValue(\`pref_\${key}\`);`)).toEqual([]); + }); +}); diff --git a/server/tests/unit/nest/app-settings.registry.test.ts b/server/tests/unit/nest/app-settings.registry.test.ts new file mode 100644 index 000000000..d845a5af1 --- /dev/null +++ b/server/tests/unit/nest/app-settings.registry.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + ADMIN_FORM_SETTING_KEYS, + APP_SETTINGS, + ENCRYPTED_APP_SETTING_KEYS, + MANAGED_LOCKED_APP_SETTING_KEYS, + MASKED_ADMIN_SETTING_KEYS, + PUBLIC_CONFIG_SETTING_KEYS, + isOidcConfigured, + isOidcConfiguredIn, + readAppSetting, + resolveAppSetting, + type AppSettingDef, +} from '../../../src/nest/common/app-settings.registry'; +import { MANAGED_LOCKED_SETTING_KEYS } from '../../../src/nest/common/managed'; + +/** A stand-in for AppSettingsRepository.getValue over a fixed set of rows. */ +function repoOf(rows: Record) { + const getValue = vi.fn(async (key: string) => (key in rows ? rows[key] : null)); + return { getValue }; +} + +afterEach(() => vi.unstubAllEnvs()); + +describe('the derived key lists', () => { + it('APPSET-001: the admin form answers the keys it always has, in the same order', () => { + expect([...ADMIN_FORM_SETTING_KEYS]).toEqual([ + 'allow_registration', 'allowed_file_types', 'require_mfa', + 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', + 'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token', + 'notify_trip_reminder', + 'password_login', 'password_registration', 'oidc_login', 'oidc_registration', + 'passkey_login', 'webauthn_rp_id', 'webauthn_origins', + 'places_provider', + ]); + }); + + it('APPSET-002: the masked and the encrypted keys', () => { + expect([...MASKED_ADMIN_SETTING_KEYS].sort()).toEqual(['admin_ntfy_token', 'admin_webhook_url', 'smtp_pass']); + expect([...ENCRYPTED_APP_SETTING_KEYS].sort()).toEqual(['admin_ntfy_token', 'admin_webhook_url', 'oidc_client_secret', 'smtp_pass']); + expect((APP_SETTINGS.smtp_pass as AppSettingDef).encrypted).toBe('always'); + expect((APP_SETTINGS.admin_webhook_url as AppSettingDef).encrypted).toBe('if-plain'); + }); + + it('APPSET-003: the public config reads the keys it always read', () => { + expect([...PUBLIC_CONFIG_SETTING_KEYS].sort()).toEqual( + [ + 'places_provider', 'oidc_display_name', 'oidc_issuer', 'oidc_client_id', 'require_mfa', + 'notification_channel', 'notify_trip_reminder', 'smtp_host', 'notification_channels', + 'places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled', + 'places_enrich_enabled', 'place_shadow_enabled', + ].sort(), + ); + }); + + it('APPSET-004: a registered key is managed-locked exactly when the managed list names it', () => { + const locked = new Set(MANAGED_LOCKED_SETTING_KEYS); + const registered = Object.keys(APP_SETTINGS); + expect([...MANAGED_LOCKED_APP_SETTING_KEYS].sort()).toEqual(registered.filter((k) => locked.has(k)).sort()); + }); + + it('APPSET-005: every documented default is a value its schema accepts', () => { + for (const [key, def] of Object.entries(APP_SETTINGS) as Array<[string, AppSettingDef]>) { + if (def.default !== null) expect(def.schema.safeParse(def.default).success, key).toBe(true); + } + }); +}); + +describe('reading', () => { + it('APPSET-010: readAppSetting answers what getValue answers', async () => { + const repo = repoOf({ require_mfa: 'true' }); + expect(await readAppSetting(repo, 'require_mfa')).toBe('true'); + expect(await readAppSetting(repo, 'smtp_host')).toBeNull(); + expect(repo.getValue).toHaveBeenCalledWith('smtp_host'); + }); + + it('APPSET-011: resolveAppSetting takes a non-empty environment value over the stored one', async () => { + const repo = repoOf({ smtp_host: 'stored.example' }); + vi.stubEnv('SMTP_HOST', 'env.example'); + expect(await resolveAppSetting(repo, 'smtp_host')).toBe('env.example'); + vi.stubEnv('SMTP_HOST', ''); + expect(await resolveAppSetting(repo, 'smtp_host')).toBe('stored.example'); + // A key without an environment variable reads the row. + expect(await resolveAppSetting(repoOf({ require_mfa: 'true' }), 'require_mfa')).toBe('true'); + }); +}); + +describe('isOidcConfigured', () => { + it('APPSET-020: wants an issuer and a client id, from either side, and nothing else', async () => { + vi.stubEnv('OIDC_ISSUER', ''); + vi.stubEnv('OIDC_CLIENT_ID', ''); + expect(await isOidcConfigured(repoOf({}))).toBe(false); + expect(await isOidcConfigured(repoOf({ oidc_issuer: 'https://idp' }))).toBe(false); + expect(await isOidcConfigured(repoOf({ oidc_issuer: 'https://idp', oidc_client_id: 'trek' }))).toBe(true); + expect(await isOidcConfigured(repoOf({ oidc_issuer: '', oidc_client_id: 'trek' }))).toBe(false); + vi.stubEnv('OIDC_ISSUER', 'https://idp'); + expect(await isOidcConfigured(repoOf({ oidc_client_id: 'trek' }))).toBe(true); + }); + + it('APPSET-021: does not read the client id when there is no issuer', async () => { + vi.stubEnv('OIDC_ISSUER', ''); + const repo = repoOf({}); + await isOidcConfigured(repo); + expect(repo.getValue).not.toHaveBeenCalledWith('oidc_client_id'); + }); + + it('APPSET-022: the batch form answers the same over values already read', () => { + vi.stubEnv('OIDC_ISSUER', ''); + vi.stubEnv('OIDC_CLIENT_ID', ''); + expect(isOidcConfiguredIn(new Map())).toBe(false); + expect(isOidcConfiguredIn(new Map([['oidc_issuer', 'https://idp'], ['oidc_client_id', 'trek']]))).toBe(true); + vi.stubEnv('OIDC_CLIENT_ID', 'trek'); + expect(isOidcConfiguredIn(new Map([['oidc_issuer', 'https://idp']]))).toBe(true); + }); +}); diff --git a/server/tests/unit/nest/managed-keys.test.ts b/server/tests/unit/nest/managed-keys.test.ts index bb0996b9d..8c8b1e207 100644 --- a/server/tests/unit/nest/managed-keys.test.ts +++ b/server/tests/unit/nest/managed-keys.test.ts @@ -2,7 +2,7 @@ * 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 + * A key added to the admin form (ADMIN_FORM_SETTING_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. * @@ -11,7 +11,7 @@ * 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 { ADMIN_FORM_SETTING_KEYS } from '../../../src/nest/common/app-settings.registry'; import { DEFAULTABLE_USER_SETTING_KEYS } from '../../../src/nest/settings/settings.service'; import { MANAGED_LOCKED_SETTING_KEYS, @@ -21,7 +21,7 @@ import { splitManagedKeys, } from '../../../src/nest/common/managed'; -const SOURCE_KEYS: string[] = [...ADMIN_SETTINGS_KEYS, ...DEFAULTABLE_USER_SETTING_KEYS]; +const SOURCE_KEYS: string[] = [...ADMIN_FORM_SETTING_KEYS, ...DEFAULTABLE_USER_SETTING_KEYS]; const locked = new Set(MANAGED_LOCKED_SETTING_KEYS); const customer = new Set(MANAGED_CUSTOMER_KEYS);