refactor(settings): instance settings are named through one typed register

app-settings.registry.ts lists every app_settings key the server reads by name with its shape, default, environment override and flags. The admin form's keys, its masked and encrypted handling and the public config's reads derive from it, isOidcConfigured() replaces the three issuer/client-id checks in AuthService, and OidcService resolves its values through the same rule while still requiring the client secret. Every literal getValue() read goes through readAppSetting()/resolveAppSetting(), and ESLint refuses a new one.
This commit is contained in:
Maurice
2026-10-10 13:06:48 +02:00
parent 42d75a2b7e
commit 3b58694eb9
26 changed files with 394 additions and 97 deletions
+1
View File
@@ -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('<literal>')` outside the register; a key built from parts (`admin_notif_pref_*`) is a family the register does not list.
## Cross-cutting pieces
+16 -1
View File
@@ -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('<key>'): 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,
],
},
},
+1 -1
View File
@@ -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,
+3 -2
View File
@@ -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' };
}
+2 -1
View File
@@ -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`
-15
View File
@@ -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)
// ---------------------------------------------------------------------------
+29 -35
View File
@@ -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<string, string> = {};
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<string, unknown>, 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<string, unknown> = {};
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 };
}
+2 -1
View File
@@ -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;
@@ -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<typeof readEnv>;
export interface AppSettingDef {
readonly schema: z.ZodType<string>;
/** 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<string, AppSettingDef>;
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<AppSettingKey> = new Set(keysWhere((d) => d.masked));
/** The keys stored encrypted. */
export const ENCRYPTED_APP_SETTING_KEYS: ReadonlySet<AppSettingKey> = 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<AppSettingsRepository, 'getValue'>;
/** 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<string | null> {
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<string | null> {
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<boolean> {
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<string, string>): boolean {
const env = readEnv();
return !!((env.oidc.issuer || values.get('oidc_issuer')) && (env.oidc.clientId || values.get('oidc_client_id')));
}
@@ -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<string> {
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;
@@ -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<boolean> {
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';
}
@@ -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`);
@@ -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`);
@@ -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<boolean> {
return !!(readEnv().smtp.host || (await this.appSettings.getValue('smtp_host')));
return !!(await resolveAppSetting(this.appSettings, 'smtp_host'));
}
async getUserEmail(userId: number): Promise<string | null> {
@@ -19,6 +19,7 @@ import {
type NotifChannel,
type NotifEventType,
} from './notification-events';
import { readAppSetting } from '../common/app-settings.registry';
export interface PreferencesMatrix {
preferences: Partial<Record<NotifEventType, Partial<Record<NotifChannel, boolean>>>>;
@@ -96,7 +97,7 @@ export class NotificationPreferencesService {
* would silently drop any that were).
*/
async getActiveChannels(): Promise<NotifChannel[]> {
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<string, boolean> = { 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 }),
};
}
@@ -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<void> {
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<void> {
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.
@@ -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<NtfyConfig> {
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,
@@ -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<string | null> {
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;
}
+11 -9
View File
@@ -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<OidcConfig | null> {
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'),
@@ -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<boolean> {
const value = await this.appSettings.getValue('places_enrich_enabled');
const value = await readAppSetting(this.appSettings, 'places_enrich_enabled');
return value === 'false';
}
@@ -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<boolean> {
const value = await this.appSettings.getValue('place_shadow_enabled');
const value = await readAppSetting(this.appSettings, 'place_shadow_enabled');
return value === 'true';
}
+2 -1
View File
@@ -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');
@@ -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<boolean> {
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';
@@ -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('<key>'): the register types the key and owns its env override.";
const PROBE = 'declare const repo: { getValue(key: string): Promise<string | null> };\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([]);
});
});
@@ -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<string, string>) {
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<string>(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);
});
});
+3 -3
View File
@@ -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<string>(MANAGED_LOCKED_SETTING_KEYS);
const customer = new Set<string>(MANAGED_CUSTOMER_KEYS);