diff --git a/.changeset/settings-db-overrides.md b/.changeset/settings-db-overrides.md new file mode 100644 index 000000000..2398b0772 --- /dev/null +++ b/.changeset/settings-db-overrides.md @@ -0,0 +1,7 @@ +--- +"nostream": minor +--- + +feat: store relay settings overrides in PostgreSQL when SETTINGS_BACKEND=db + +Serialize settings writes, fan out DB override reloads from workers through the primary, close CLI database pools after config commands, bootstrap the settings store for info/invite/configure CLI paths, route moderation and admin PATCH saves through the DB override store, forward SETTINGS_BACKEND in Compose and the env CLI, and fix dotted settings path round-trips for database rows. diff --git a/.env.example b/.env.example index 64191de5f..6c6bfc7cf 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,7 @@ REDIS_PORT=6379 RELAY_PORT=8008 WORKER_COUNT=2 # Defaults to CPU count. Use 1 or 2 for local testing. # NOSTR_CONFIG_DIR=.nostr # Where settings.yaml lives +# SETTINGS_BACKEND=file # Operator overrides in settings.yaml; use db to store overrides in PostgreSQL # --- DEBUGGING --- # Useful namespaces: maintenance-worker, database-client:*, cache-client, etc. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 787d10aa3..cede2e280 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -66,6 +66,7 @@ The following environment variables can be set: | ADMIN_DEPENDENCY_PING_TIMEOUT_MS | Timeout for admin DB/Redis dependency pings (ms) | 3000 | | GRAFANA_URL | Grafana base URL for admin dashboard embeds | http://127.0.0.1:7777 | | NOSTR_CONFIG_DIR | Configuration directory | /.nostr/ | +| SETTINGS_BACKEND | Where operator overrides are stored: `file` (default) or `db` | file | | MONITOR_PRIVATE_KEY | Hex-encoded private key for the NIP-66 monitor identity that will sign kind 30166/10166 events. Configure via environment variable, not settings.yaml. | | | DEBUG | Debugging filter | | | ZEBEDEE_API_KEY | Zebedee Project API Key | | diff --git a/deploy/docker-compose.haproxy.yml b/deploy/docker-compose.haproxy.yml index ca7ca668a..6d7264a66 100644 --- a/deploy/docker-compose.haproxy.yml +++ b/deploy/docker-compose.haproxy.yml @@ -16,6 +16,7 @@ x-nostream-relay: &nostream-relay environment: RELAY_PORT: 8008 NOSTR_CONFIG_DIR: /home/node/.nostr + SETTINGS_BACKEND: ${SETTINGS_BACKEND:-file} DB_HOST: nostream-db DB_PORT: 5432 DB_USER: ${DB_USER} diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index f86501c6e..f802c2cd9 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -7,6 +7,7 @@ services: environment: RELAY_PORT: 8008 NOSTR_CONFIG_DIR: /home/node/.nostr + SETTINGS_BACKEND: ${SETTINGS_BACKEND:-file} DB_HOST: nostream-db DB_PORT: 5432 DB_USER: ${DB_USER} diff --git a/docker-compose.yml b/docker-compose.yml index d640b0232..281c506ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: RELAY_PORT: 8008 # Master NOSTR_CONFIG_DIR: /home/node/.nostr + SETTINGS_BACKEND: ${SETTINGS_BACKEND:-file} DB_HOST: nostream-db DB_PORT: 5432 DB_USER: nostr_ts_relay diff --git a/migrations/20260905_120000_create_settings_overrides_table.js b/migrations/20260905_120000_create_settings_overrides_table.js new file mode 100644 index 000000000..e91fe7711 --- /dev/null +++ b/migrations/20260905_120000_create_settings_overrides_table.js @@ -0,0 +1,11 @@ +exports.up = async function (knex) { + await knex.schema.createTable('settings_overrides', (table) => { + table.text('path').primary() + table.jsonb('value').notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + }) +} + +exports.down = async function (knex) { + await knex.schema.dropTableIfExists('settings_overrides') +} diff --git a/src/app/app.ts b/src/app/app.ts index c11cb7939..f7b81b4e3 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -26,6 +26,10 @@ import { } from '../utils/relay-broadcast-message' import { setRelayBroadcastFanoutReady } from '../utils/relay-broadcast-state' import { getPrimaryShutdownDeadlineMs } from '../utils/shutdown-state' +import { + handleSettingsOverridesClusterSyncFromWorker, + isSettingsOverridesClusterSyncMessage, +} from '../utils/settings-store' const logger = createLogger('app-primary') @@ -215,6 +219,13 @@ export class App implements IRunnable { private onClusterMessage(source: Worker, message: Serializable) { logger('message received from worker %s: %o', source.process.pid, message) + if (isSettingsOverridesClusterSyncMessage(message)) { + void handleSettingsOverridesClusterSyncFromWorker(source).catch((error) => { + logger.error('settings overrides cluster sync failed: %o', error) + }) + return + } + if (isRelayBroadcastMessage(message)) { this.relayBroadcastDeduplicator.mark(message.event.id) const publishPromise = this.relayBroadcastFanout?.publish(message) diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index c851ff7ce..2932a469b 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -4,9 +4,7 @@ import yaml from 'js-yaml' import { getByPath, loadMergedSettings, - loadUserSettings, parseTypedValue, - saveSettings, setByPath, toCategoryLabel, validatePathAgainstDefaults, @@ -21,12 +19,25 @@ import { validateEnvPair, validateEnvValues, } from '../utils/env-config' +import { + bootstrapSettingsStore, + shutdownSettingsStore, + updateUserSettingsOverrides, +} from '../../utils/settings-store' import { logError, logInfo } from '../utils/output' import { runStart } from './start' import { runStop } from './stop' type ValueType = 'inferred' | 'json' +const withSettingsCliSession = async (operation: () => Promise): Promise => { + try { + return await operation() + } finally { + await shutdownSettingsStore() + } +} + const toJson = (value: unknown): string => { return JSON.stringify( value, @@ -76,40 +87,44 @@ const restartRelay = async (): Promise => { return 0 } -export const runConfigList = async (options: { json?: boolean } = {}): Promise => { - const settings = loadMergedSettings() - - if (options.json) { - logInfo(toJson(settings)) - return 0 - } +export const runConfigList = async (options: { json?: boolean } = {}): Promise => + withSettingsCliSession(async () => { + await bootstrapSettingsStore() + const settings = loadMergedSettings() - logInfo(yaml.dump(settings, { lineWidth: 120 })) - return 0 -} + if (options.json) { + logInfo(toJson(settings)) + return 0 + } -export const runConfigGet = async (path: string, options: { json?: boolean } = {}): Promise => { - const settings = loadMergedSettings() as unknown as Record - const value = getByPath(settings, path) + logInfo(yaml.dump(settings, { lineWidth: 120 })) + return 0 + }) + +export const runConfigGet = async (path: string, options: { json?: boolean } = {}): Promise => + withSettingsCliSession(async () => { + await bootstrapSettingsStore() + const settings = loadMergedSettings() as unknown as Record + const value = getByPath(settings, path) + + if (value === undefined) { + if (options.json) { + process.stderr.write(`${JSON.stringify({ error: { message: `Path not found: ${path}`, code: 1 } })}\n`) + return 1 + } - if (value === undefined) { - if (options.json) { - process.stderr.write(`${JSON.stringify({ error: { message: `Path not found: ${path}`, code: 1 } })}\n`) + logError(`Path not found: ${path}`) return 1 } - logError(`Path not found: ${path}`) - return 1 - } + if (options.json) { + logInfo(toJson(value)) + return 0 + } - if (options.json) { - logInfo(toJson(value)) + logInfo(serialize(value)) return 0 - } - - logInfo(serialize(value)) - return 0 -} + }) export const runConfigSet = async ( path: string, @@ -119,60 +134,67 @@ export const runConfigSet = async ( validate?: boolean valueType?: ValueType } = {}, -): Promise => { - const valueType = options.valueType ?? 'inferred' +): Promise => + withSettingsCliSession(async () => { + const valueType = options.valueType ?? 'inferred' - const pathIssues = validatePathAgainstDefaults(path) - if (pathIssues.length > 0) { - logError(pathIssues[0].message) - return 1 - } + await bootstrapSettingsStore() + + const pathIssues = validatePathAgainstDefaults(path) + if (pathIssues.length > 0) { + logError(pathIssues[0].message) + return 1 + } - const settings = loadUserSettings() as unknown as Record - const next = setByPath(settings, path, parseTypedValue(rawValue, valueType)) + const parsedValue = parseTypedValue(rawValue, valueType) - if (options.validate !== false) { - const merged = loadMergedSettings() as unknown as Record - const mergedNext = setByPath(merged, path, getByPath(next, path)) - const validationIssues = validateSettings(mergedNext as any) + if (options.validate !== false) { + const merged = loadMergedSettings() as unknown as Record + const mergedNext = setByPath(merged, path, parsedValue) + const validationIssues = validateSettings(mergedNext as any) - if (validationIssues.length > 0) { - logError('Config update rejected by validation:') - for (const issue of validationIssues) { - logError(`- ${issue.path}: ${issue.message}`) - } + if (validationIssues.length > 0) { + logError('Config update rejected by validation:') + for (const issue of validationIssues) { + logError(`- ${issue.path}: ${issue.message}`) + } - return 1 + return 1 + } } - } - saveSettings(next as any) + await updateUserSettingsOverrides((userSettings) => + setByPath(userSettings as unknown as Record, path, parsedValue) as unknown as typeof userSettings, + ) - logInfo(`Updated ${path}`) + logInfo(`Updated ${path}`) - if (options.restart) { - return restartRelay() - } + if (options.restart) { + return restartRelay() + } - return 0 -} + return 0 + }) -export const runConfigValidate = async (): Promise => { - const settings = loadMergedSettings() - const issues = validateSettings(settings) +export const runConfigValidate = async (): Promise => + withSettingsCliSession(async () => { + await bootstrapSettingsStore() + const settings = loadMergedSettings() + const issues = validateSettings(settings) - if (issues.length === 0) { - logInfo('Settings are valid') - return 0 - } + if (issues.length === 0) { + logInfo('Settings are valid') + return 0 + } - logError('Settings validation failed:') - for (const issue of issues) { - logError(`- ${issue.path}: ${issue.message}`) - } + logError('Settings validation failed:') + for (const issue of issues) { + logError(`- ${issue.path}: ${issue.message}`) + } + + return 1 + }) - return 1 -} export const runConfigEnvList = async (options: { showSecrets?: boolean } = {}): Promise => { const values = readEnvValues() const entries = Object.entries(values).sort(([a], [b]) => a.localeCompare(b)) diff --git a/src/cli/commands/info.ts b/src/cli/commands/info.ts index d70cafefe..763373da6 100644 --- a/src/cli/commands/info.ts +++ b/src/cli/commands/info.ts @@ -2,6 +2,7 @@ import fs from 'fs' import { Client } from 'pg' import packageJson from '../../../package.json' +import { bootstrapSettingsStore, shutdownSettingsStore } from '../../utils/settings-store' import { loadMergedSettings } from '../utils/config' import { tryGetRelayNip43Pubkey } from '../../utils/nip43' import { logError, logInfo } from '../utils/output' @@ -152,6 +153,15 @@ export const getInfoPayload = async () => { } export const runInfo = async (options: InfoOptions): Promise => { + try { + await bootstrapSettingsStore() + return await runInfoWithBootstrappedSettings(options) + } finally { + await shutdownSettingsStore() + } +} + +const runInfoWithBootstrappedSettings = async (options: InfoOptions): Promise => { const payload = await getInfoPayload() if (options.torHostname) { diff --git a/src/cli/commands/invite.ts b/src/cli/commands/invite.ts index bd345d00f..e87b517c2 100644 --- a/src/cli/commands/invite.ts +++ b/src/cli/commands/invite.ts @@ -6,6 +6,7 @@ import { IInviteCodeRepository } from '../../@types/repositories' import { Settings } from '../../@types/settings' import { InviteCodeRepository } from '../../repositories/invite-code-repository' import { issueInviteCode, parseRelayPubkey } from '../../utils/nip43-invites' +import { bootstrapSettingsStore, shutdownSettingsStore } from '../../utils/settings-store' import { loadMergedSettings } from '../utils/config' import { readEnvValues } from '../utils/env-config' import { logInfo } from '../utils/output' @@ -146,6 +147,13 @@ export const runInviteCreate = async ( const loadSettings = deps.loadSettings ?? loadMergedSettings const issue = deps.issue ?? issueInviteCode const now = deps.now ?? Date.now + + const shouldBootstrapSettings = !deps.loadSettings + + if (shouldBootstrapSettings) { + await bootstrapSettingsStore() + } + const settings = loadSettings() const overrides: Parameters[2] = {} @@ -186,5 +194,9 @@ export const runInviteCreate = async ( if (dbClient && typeof (dbClient as Knex).destroy === 'function') { await (dbClient as Knex).destroy() } + + if (shouldBootstrapSettings) { + await shutdownSettingsStore() + } } } diff --git a/src/cli/tui/menus/configure.ts b/src/cli/tui/menus/configure.ts index ae2f3d8ec..12913cdfb 100644 --- a/src/cli/tui/menus/configure.ts +++ b/src/cli/tui/menus/configure.ts @@ -4,6 +4,7 @@ import { runConfigSet, runConfigValidate, } from '../../commands/config' +import { bootstrapSettingsStore, shutdownSettingsStore } from '../../../utils/settings-store' import { getByPath, getTopLevelSettingCategories, loadMergedSettings, toCategoryLabel } from '../../../utils/settings-config' import { type GuidedSettingField, @@ -209,6 +210,15 @@ const runGuidedConfigureMenu = async (): Promise => { } export const runConfigureMenu = async (): Promise => { + try { + await bootstrapSettingsStore() + return await runConfigureMenuWithStore() + } finally { + await shutdownSettingsStore() + } +} + +const runConfigureMenuWithStore = async (): Promise => { const action = await tuiPrompts.select({ message: 'Configuration action', options: [ diff --git a/src/cli/utils/env-config.ts b/src/cli/utils/env-config.ts index bc9817e1f..2f713dd8e 100644 --- a/src/cli/utils/env-config.ts +++ b/src/cli/utils/env-config.ts @@ -42,6 +42,7 @@ const SUPPORTED_ENV_KEYS = new Set([ 'REDIS_USER', 'REDIS_PASSWORD', 'NOSTR_CONFIG_DIR', + 'SETTINGS_BACKEND', 'DEBUG', 'ZEBEDEE_API_KEY', 'NODELESS_API_KEY', @@ -68,6 +69,10 @@ const INTEGER_KEYS = new Set([ const BOOLEAN_KEYS = new Set(['READ_REPLICA_ENABLED']) +const ENUM_KEYS: Record> = { + SETTINGS_BACKEND: new Set(['file', 'db']), +} + const RR_INTEGER_KEY_REGEX = /^RR\d+_DB_(PORT|MIN_POOL_SIZE|MAX_POOL_SIZE|ACQUIRE_CONNECTION_TIMEOUT)$/ const SECRET_KEY_REGEX = /(SECRET|PASSWORD|API_KEY|PRIVATE_KEY)/i @@ -148,6 +153,11 @@ export const validateEnvPair = (key: string, value: string): string | undefined return validateBoolean(key, value) } + const allowedValues = ENUM_KEYS[key] + if (allowedValues && !allowedValues.has(value.trim())) { + return `${key} must be one of: ${[...allowedValues].join(', ')}` + } + return undefined } diff --git a/src/controllers/admin/patch-notifications-controller.ts b/src/controllers/admin/patch-notifications-controller.ts index 2e3545d49..fdfbd83a1 100644 --- a/src/controllers/admin/patch-notifications-controller.ts +++ b/src/controllers/admin/patch-notifications-controller.ts @@ -17,9 +17,9 @@ import { loadDefaults, loadMergedSettings, loadUserSettings, - saveSettings, validateSettings, } from '../../utils/settings-config' +import { saveUserSettingsOverrides } from '../../utils/settings-store' import { validateSchema } from '../../utils/validation' import { recordAdminAuditFromRequest } from '../../utils/record-admin-audit' @@ -55,7 +55,7 @@ export class PatchAdminNotificationsController implements IController { return } - saveSettings(nextUserSettings) + await saveUserSettingsOverrides(nextUserSettings) appendSettingsAuditLog({ action: 'settings.updated', changes: [{ path: 'admin.notifications', reload: 'hot-reload' }], diff --git a/src/controllers/admin/patch-settings-controller.ts b/src/controllers/admin/patch-settings-controller.ts index f67eb099e..3abd8c58a 100644 --- a/src/controllers/admin/patch-settings-controller.ts +++ b/src/controllers/admin/patch-settings-controller.ts @@ -7,12 +7,11 @@ import { appendSettingsAuditLog, getByPath, loadMergedSettings, - loadUserSettings, - saveSettings, setByPath, validatePathAgainstDefaults, validateSettings, } from '../../utils/settings-config' +import { updateUserSettingsOverrides } from '../../utils/settings-store' import { isWriteProtectedSettingsPath, redactSettingsValue, @@ -50,17 +49,12 @@ export class PatchAdminSettingsController implements IController { return } - const userSettings = loadUserSettings() as unknown as Record - const nextUserSettings = changes.reduce( - (settings, change) => setByPath(settings, change.path, change.value), - userSettings, - ) const merged = loadMergedSettings() as unknown as Record - const mergedNext = changes.reduce( - (settings, change) => setByPath(settings, change.path, getByPath(nextUserSettings, change.path)), + const mergedPreview = changes.reduce( + (settings, change) => setByPath(settings, change.path, change.value), merged, ) - const validationIssues = validateSettings(mergedNext as unknown as Settings) + const validationIssues = validateSettings(mergedPreview as unknown as Settings) if (validationIssues.length > 0) { response @@ -70,7 +64,14 @@ export class PatchAdminSettingsController implements IController { return } - saveSettings(nextUserSettings as unknown as Settings) + let nextUserSettings: Record = {} + await updateUserSettingsOverrides((currentUserSettings) => { + nextUserSettings = changes.reduce( + (settings, change) => setByPath(settings as Record, change.path, change.value), + currentUserSettings as unknown as Record, + ) + return nextUserSettings as unknown as Settings + }) const updatedChanges = changes.map(({ path }) => ({ path, value: redactSettingsValue(path, getByPath(nextUserSettings, path)), diff --git a/src/controllers/admin/post-settings-restore-controller.ts b/src/controllers/admin/post-settings-restore-controller.ts index aded18e40..1bb3c65e9 100644 --- a/src/controllers/admin/post-settings-restore-controller.ts +++ b/src/controllers/admin/post-settings-restore-controller.ts @@ -15,7 +15,7 @@ export class PostAdminSettingsRestoreController implements IController { } try { - restoreSettingsBackup(validation.value.filename) + await restoreSettingsBackup(validation.value.filename) appendSettingsAuditLog({ action: 'settings.restored', filename: validation.value.filename, diff --git a/src/database/client.ts b/src/database/client.ts index 86cbdd7ff..f39f05f22 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -107,3 +107,17 @@ export const getReadReplicaDbClient = () => { return readClient } + +export const destroyMasterDbClient = async (): Promise => { + if (writeClient) { + await writeClient.destroy() + writeClient = undefined as unknown as Knex + } +} + +export const destroyReadReplicaDbClient = async (): Promise => { + if (readClient) { + await readClient.destroy() + readClient = undefined as unknown as Knex + } +} diff --git a/src/index.ts b/src/index.ts index b909447ca..4adb7c62d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,15 @@ import cluster from 'cluster' import { appFactory } from './factories/app-factory' +import { createLogger } from './factories/logger-factory' import { dvmOrchestratorWorkerFactory } from './factories/dvm-orchestrator-worker-factory' import { maintenanceWorkerFactory } from './factories/maintenance-worker-factory' import { relayMonitorWorkerFactory } from './factories/relay-monitor-worker-factory' import { workerFactory } from './factories/worker-factory' import { initializeMetricsTelemetry } from './telemetry/metrics' +import { bootstrapSettingsStore } from './utils/settings-store' + +const logger = createLogger('index') export const getRunner = () => { if (cluster.isPrimary) { @@ -29,5 +33,12 @@ export const getRunner = () => { if (require.main === module) { initializeMetricsTelemetry() - getRunner().run() + void bootstrapSettingsStore() + .then(() => { + getRunner().run() + }) + .catch((error) => { + logger.error('failed to bootstrap settings store:', error) + process.exit(1) + }) } diff --git a/src/repositories/settings-override-repository.ts b/src/repositories/settings-override-repository.ts new file mode 100644 index 000000000..a52016e20 --- /dev/null +++ b/src/repositories/settings-override-repository.ts @@ -0,0 +1,44 @@ +import { DatabaseClient } from '../@types/base' +import { Settings } from '../@types/settings' +import { createLogger } from '../factories/logger-factory' +import { flattenSettingsToPaths, setByPath } from '../utils/settings-config' + +const logger = createLogger('settings-override-repository') + +export class SettingsOverrideRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async loadOverrides(): Promise { + const rows = await this.dbClient('settings_overrides').select('path', 'value') + + let settings: Record = {} + for (const row of rows) { + settings = setByPath(settings, row.path, row.value) + } + + return settings as unknown as Settings + } + + public async replaceOverrides(overrides: Settings): Promise { + const entries = flattenSettingsToPaths(overrides) + + await this.dbClient.transaction(async (transaction) => { + await transaction('settings_overrides').delete() + + if (entries.length === 0) { + return + } + + const now = new Date() + await transaction('settings_overrides').insert( + entries.map((entry) => ({ + path: entry.path, + value: JSON.stringify(entry.value), + updated_at: now, + })), + ) + }) + + logger('replaced %d settings override paths', entries.length) + } +} diff --git a/src/utils/admin-moderation-persist.ts b/src/utils/admin-moderation-persist.ts index ddfcc3dc5..e971fdcdb 100644 --- a/src/utils/admin-moderation-persist.ts +++ b/src/utils/admin-moderation-persist.ts @@ -26,7 +26,7 @@ export const saveModerationLimitsFromRequest = async ( auditDetail?: Record, ): Promise => { try { - persistAdminModerationLimits(limits) + await persistAdminModerationLimits(limits) } catch (error) { if (error instanceof ValidationError) { throw error diff --git a/src/utils/admin-moderation.ts b/src/utils/admin-moderation.ts index 9020cb8fd..c4d29a731 100644 --- a/src/utils/admin-moderation.ts +++ b/src/utils/admin-moderation.ts @@ -7,9 +7,9 @@ import { loadDefaults, loadMergedSettings, loadUserSettings, - saveSettings, validateSettings, } from './settings-config' +import { updateUserSettingsOverrides } from './settings-store' export interface AdminModerationLimits { pubkey: { @@ -119,7 +119,7 @@ const applyLimitsToUserSettings = (userSettings: Settings, limits: AdminModerati } } -export const persistAdminModerationLimits = (next: AdminModerationLimits): void => { +export const persistAdminModerationLimits = async (next: AdminModerationLimits): Promise => { const normalized: AdminModerationLimits = { pubkey: { blacklist: normalizePubkeyList(next.pubkey.blacklist, 'pubkey blacklist'), @@ -135,16 +135,17 @@ export const persistAdminModerationLimits = (next: AdminModerationLimits): void }, } - const userSettings = loadUserSettings() as Settings - const nextUserSettings = applyLimitsToUserSettings(userSettings, normalized) - const mergedNext = mergeDeepRight(loadDefaults(), nextUserSettings) as Settings + await updateUserSettingsOverrides((userSettings) => { + const nextUserSettings = applyLimitsToUserSettings(userSettings as Settings, normalized) + const mergedNext = mergeDeepRight(loadDefaults(), nextUserSettings) as Settings - const issues = validateSettings(mergedNext) - if (issues.length > 0) { - throw new ValidationError('Validation failed', issues) - } + const issues = validateSettings(mergedNext) + if (issues.length > 0) { + throw new ValidationError('Validation failed', issues) + } - saveSettings(nextUserSettings) + return nextUserSettings + }) } export class ValidationError extends Error { diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index d80201792..e1c758da8 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -72,6 +72,101 @@ const hasOwn = (object: Record, key: string): boolean => { return Object.prototype.hasOwnProperty.call(object, key) } +const QUOTED_PATH_KEY = /^\["((?:\\.|[^"\\])*)"\]$/ + +export const formatSettingsPathKey = (key: string): string => { + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + return key + } + + return `["${key.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]` +} + +const readQuotedBracketSegment = (input: string, start: number): number => { + if (input[start] !== '[' || input[start + 1] !== '"') { + throw new Error(`Invalid path segment in: ${input}`) + } + + let index = start + 2 + while (index < input.length) { + if (input[index] === '\\') { + index += 2 + continue + } + + if (input[index] === '"' && input[index + 1] === ']') { + return index + 2 + } + + index += 1 + } + + throw new Error(`Invalid path segment in: ${input}`) +} + +const tokenizePathSegments = (input: string): string[] => { + const segments: string[] = [] + let index = 0 + + while (index < input.length) { + if (input[index] === '.') { + index += 1 + continue + } + + const start = index + + if (input[index] === '[') { + if (input[index + 1] === '"') { + index = readQuotedBracketSegment(input, index) + } else { + const closing = input.indexOf(']', index) + if (closing < 0) { + throw new Error(`Invalid path segment in: ${input}`) + } + index = closing + 1 + } + + while (index < input.length && input[index] === '[') { + if (input[index + 1] === '"') { + index = readQuotedBracketSegment(input, index) + continue + } + + const closing = input.indexOf(']', index) + if (closing < 0) { + throw new Error(`Invalid path segment in: ${input}`) + } + index = closing + 1 + } + + segments.push(input.slice(start, index)) + continue + } + + while (index < input.length && input[index] !== '.' && input[index] !== '[') { + index += 1 + } + + while (index < input.length && input[index] === '[') { + if (input[index + 1] === '"') { + index = readQuotedBracketSegment(input, index) + continue + } + + const closing = input.indexOf(']', index) + if (closing < 0) { + throw new Error(`Invalid path segment in: ${input}`) + } + index = closing + 1 + } + + segments.push(input.slice(start, index)) + } + + return segments.filter(Boolean) +} + const parsePath = (path: string): PathToken[] => { const input = path.trim() @@ -80,11 +175,50 @@ const parsePath = (path: string): PathToken[] => { } const tokens: PathToken[] = [] - const segments = input.split('.').map((part) => part.trim()) - for (const segment of segments) { - if (!segment) { - throw new Error(`Invalid path segment in: ${path}`) + for (const segment of tokenizePathSegments(input)) { + const indexOnly = segment.match(/^(\[\d+\])+$/) + if (indexOnly) { + for (const entry of segment.matchAll(/\[(\d+)\]/g)) { + tokens.push({ + type: 'index', + index: Number(entry[1]), + }) + } + continue + } + + const quoted = segment.match(QUOTED_PATH_KEY) + if (quoted) { + const key = quoted[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') + assertSafePathKey(key, segment) + tokens.push({ type: 'key', key }) + + for (const entry of segment.matchAll(/\[(\d+)\]/g)) { + tokens.push({ + type: 'index', + index: Number(entry[1]), + }) + } + continue + } + + const identifierQuoted = segment.match(/^([A-Za-z_][A-Za-z0-9_]*)\["((?:\\.|[^"\\])*)"\](.*)$/) + if (identifierQuoted) { + assertSafePathKey(identifierQuoted[1], segment) + tokens.push({ type: 'key', key: identifierQuoted[1] }) + + const nestedKey = identifierQuoted[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\') + assertSafePathKey(nestedKey, segment) + tokens.push({ type: 'key', key: nestedKey }) + + for (const entry of identifierQuoted[3].matchAll(/\[(\d+)\]/g)) { + tokens.push({ + type: 'index', + index: Number(entry[1]), + }) + } + continue } const match = segment.match(/^([A-Za-z_][A-Za-z0-9_]*)(\[(\d+)\])*$/) @@ -114,7 +248,13 @@ const formatPathTokens = (tokens: PathToken[]): string => { for (const token of tokens) { if (token.type === 'key') { - out = out ? `${out}.${token.key}` : token.key + const formatted = formatSettingsPathKey(token.key) + if (!out) { + out = formatted + continue + } + + out = formatted.startsWith('[') ? `${out}${formatted}` : `${out}.${formatted}` continue } @@ -316,7 +456,7 @@ export const loadDefaults = (): Settings => { return yaml.load(defaultsRaw) as Settings } -export const loadUserSettings = (): Settings => { +export const loadUserSettingsFromFiles = (): Settings => { ensureSettingsExists() const settingsPath = getSettingsFilePath() @@ -336,6 +476,17 @@ export const loadUserSettings = (): Settings => { return {} as Settings } +export const loadUserSettings = (): Settings => { + if (process.env.SETTINGS_BACKEND === 'db') { + // Lazy import avoids a circular dependency with settings-store bootstrap. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getDbOverridesCache } = require('./settings-store') as typeof import('./settings-store') + return getDbOverridesCache() + } + + return loadUserSettingsFromFiles() +} + export const loadMergedSettings = (): Settings => { return mergeDeepRight(loadDefaults(), loadUserSettings()) as Settings } @@ -379,6 +530,49 @@ export const filterSettingsAgainstDefaults = (settings: unknown, defaults: unkno return settings } +export const flattenSettingsToPaths = ( + value: unknown, + prefix = '', +): Array<{ path: string; value: unknown }> => { + if (Array.isArray(value)) { + if (value.length === 0 && prefix) { + return [{ path: prefix, value: [] }] + } + + return value.flatMap((entry, index) => { + const nextPrefix = prefix ? `${prefix}[${index}]` : `[${index}]` + return flattenSettingsToPaths(entry, nextPrefix) + }) + } + + if (isPlainObject(value)) { + const entries = Object.entries(value) + if (entries.length === 0 && prefix) { + return [{ path: prefix, value: {} }] + } + + return entries.flatMap(([key, entry]) => { + const keySegment = formatSettingsPathKey(key) + const nextPrefix = prefix + ? keySegment.startsWith('[') + ? `${prefix}${keySegment}` + : `${prefix}.${keySegment}` + : keySegment + if (isPlainObject(entry) || Array.isArray(entry)) { + return flattenSettingsToPaths(entry, nextPrefix) + } + + return [{ path: nextPrefix, value: entry }] + }) + } + + if (prefix) { + return [{ path: prefix, value }] + } + + return [] +} + export const saveSettings = (settings: Settings): void => { ensureSettingsExists() const serialized = yaml.dump(toSerializable(settings), { lineWidth: 120 }) @@ -418,7 +612,23 @@ export type SettingsBackupInfo = { } const isSafeBackupFilename = (filename: string): boolean => { - return /^settings\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.yaml$/.test(filename) + return /^settings(?:-db)?\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.yaml$/.test(filename) +} + +export const backupUserSettingsOverrides = (overrides: Settings): string => { + ensureSettingsExists() + const backupDirectory = getSettingsBackupDir() + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const filename = `settings-db.${timestamp}.yaml` + + fs.mkdirSync(backupDirectory, { recursive: true }) + fs.writeFileSync( + join(backupDirectory, filename), + yaml.dump(toSerializable(overrides), { lineWidth: 120 }), + { encoding: 'utf-8', mode: 0o600 }, + ) + + return filename } export const listSettingsBackups = (): SettingsBackupInfo[] => { @@ -441,7 +651,7 @@ export const listSettingsBackups = (): SettingsBackupInfo[] => { .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) } -export const restoreSettingsBackup = (filename: string): Settings => { +const loadValidatedSettingsBackup = (filename: string): Settings => { if (!isSafeBackupFilename(filename)) { throw new Error('Invalid backup filename') } @@ -457,7 +667,15 @@ export const restoreSettingsBackup = (filename: string): Settings => { throw new Error(`Backup failed validation: ${issues.map((issue) => issue.path).join(', ')}`) } - saveSettings(restored) + return restored +} + +export const restoreSettingsBackup = async (filename: string): Promise => { + const restored = loadValidatedSettingsBackup(filename) + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { saveUserSettingsOverrides } = require('./settings-store') as typeof import('./settings-store') + await saveUserSettingsOverrides(restored) return restored } diff --git a/src/utils/settings-paths.ts b/src/utils/settings-paths.ts index d55743195..6bc154f75 100644 --- a/src/utils/settings-paths.ts +++ b/src/utils/settings-paths.ts @@ -12,3 +12,5 @@ export const getDefaultSettingsFilePath = (): string => join(process.cwd(), 'res export const getSettingsBackupDir = (): string => join(getConfigBaseDir(), 'backups') export const getSettingsAuditLogPath = (): string => join(getConfigBaseDir(), 'settings-audit.jsonl') + +export const getSettingsDbImportMarkerPath = (): string => join(getConfigBaseDir(), '.settings-db-import-complete') diff --git a/src/utils/settings-store.ts b/src/utils/settings-store.ts new file mode 100644 index 000000000..c296ce899 --- /dev/null +++ b/src/utils/settings-store.ts @@ -0,0 +1,216 @@ +import fs from 'fs' + +import cluster, { Worker } from 'cluster' + +import { Settings } from '../@types/settings' +import { destroyMasterDbClient, getMasterDbClient } from '../database/client' +import { createLogger } from '../factories/logger-factory' +import { SettingsOverrideRepository } from '../repositories/settings-override-repository' +import { SettingsStatic } from './settings' +import { + backupUserSettingsOverrides, + loadUserSettingsFromFiles, + saveSettings, +} from './settings-config' +import { getSettingsDbImportMarkerPath } from './settings-paths' + +const logger = createLogger('settings-store') + +export const SETTINGS_OVERRIDES_RELOAD_EVENT = 'settings-overrides-reload' +export const SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT = 'settings-overrides-cluster-sync' + +let dbOverridesCache: Settings = {} as Settings +let bootstrapComplete = false +let reloadListenerRegistered = false +let writeChain: Promise = Promise.resolve() + +export type SettingsBackend = 'file' | 'db' + +export type SettingsOverridesClusterSyncMessage = { + eventName: typeof SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT +} + +export const isSettingsOverridesClusterSyncMessage = ( + message: unknown, +): message is SettingsOverridesClusterSyncMessage => + typeof message === 'object' && + message !== null && + (message as SettingsOverridesClusterSyncMessage).eventName === SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT + +export const getSettingsBackend = (): SettingsBackend => { + return process.env.SETTINGS_BACKEND === 'db' ? 'db' : 'file' +} + +export const getDbOverridesCache = (): Settings => structuredClone(dbOverridesCache) + +const isEmptyOverrides = (settings: Settings): boolean => { + return Object.keys(settings as object).length === 0 +} + +const markSettingsFileImportComplete = (): void => { + fs.writeFileSync(getSettingsDbImportMarkerPath(), `${new Date().toISOString()}\n`, { encoding: 'utf-8', mode: 0o600 }) +} + +const hasCompletedSettingsFileImport = (): boolean => { + return fs.existsSync(getSettingsDbImportMarkerPath()) +} + +export const reloadDbOverridesFromDatabase = async (): Promise => { + if (getSettingsBackend() !== 'db') { + return + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + dbOverridesCache = await repository.loadOverrides() + SettingsStatic.invalidateCache() +} + +const registerSettingsOverridesReloadListener = (): void => { + if (reloadListenerRegistered || getSettingsBackend() !== 'db') { + return + } + + reloadListenerRegistered = true + process.on('message', (message: { eventName?: string }) => { + if (message?.eventName !== SETTINGS_OVERRIDES_RELOAD_EVENT) { + return + } + + void reloadDbOverridesFromDatabase().catch((error) => { + logger.error('failed to reload database settings overrides: %o', error) + }) + }) +} + +const broadcastSettingsOverridesReload = (): void => { + if (getSettingsBackend() !== 'db') { + return + } + + if (cluster.isWorker && typeof process.send === 'function') { + process.send({ eventName: SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT }) + return + } + + if (cluster.isPrimary && cluster.workers) { + for (const worker of Object.values(cluster.workers)) { + worker?.send({ eventName: SETTINGS_OVERRIDES_RELOAD_EVENT }) + } + } +} + +export const handleSettingsOverridesClusterSyncFromWorker = async (source: Worker): Promise => { + if (getSettingsBackend() !== 'db' || !cluster.isPrimary) { + return + } + + await reloadDbOverridesFromDatabase() + + for (const worker of Object.values(cluster.workers ?? {})) { + if (worker?.id === source.id) { + continue + } + + worker?.send({ eventName: SETTINGS_OVERRIDES_RELOAD_EVENT }) + } +} + +const withSettingsOverridesWriteLock = async (operation: () => Promise): Promise => { + let release: () => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + + const previous = writeChain + writeChain = previous.then(() => gate) + await previous + + try { + return await operation() + } finally { + release() + } +} + +const commitUserSettingsOverrides = async (settings: Settings): Promise => { + if (getSettingsBackend() === 'db') { + if (!bootstrapComplete) { + throw new Error('settings store has not been bootstrapped') + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + const previous = await repository.loadOverrides() + if (!isEmptyOverrides(previous)) { + backupUserSettingsOverrides(previous) + } + + await repository.replaceOverrides(settings) + dbOverridesCache = structuredClone(settings) + SettingsStatic.invalidateCache() + broadcastSettingsOverridesReload() + return + } + + saveSettings(settings) +} + +export const bootstrapSettingsStore = async (): Promise => { + registerSettingsOverridesReloadListener() + + if (getSettingsBackend() !== 'db') { + bootstrapComplete = true + return + } + + const repository = new SettingsOverrideRepository(getMasterDbClient()) + let overrides = await repository.loadOverrides() + + if (!hasCompletedSettingsFileImport() && isEmptyOverrides(overrides)) { + const fileOverrides = loadUserSettingsFromFiles() + if (!isEmptyOverrides(fileOverrides)) { + logger.info('importing settings overrides from file into database') + await repository.replaceOverrides(fileOverrides) + overrides = fileOverrides + } + markSettingsFileImportComplete() + } + + dbOverridesCache = overrides + bootstrapComplete = true + SettingsStatic.invalidateCache() +} + +export const updateUserSettingsOverrides = async ( + updater: (current: Settings) => Settings | Promise, +): Promise => { + await withSettingsOverridesWriteLock(async () => { + if (getSettingsBackend() === 'db') { + if (!bootstrapComplete) { + throw new Error('settings store has not been bootstrapped') + } + + await reloadDbOverridesFromDatabase() + } + + const current = + getSettingsBackend() === 'db' + ? structuredClone(dbOverridesCache) + : (loadUserSettingsFromFiles() as Settings) + const next = await updater(current) + await commitUserSettingsOverrides(next) + }) +} + +export const saveUserSettingsOverrides = async (settings: Settings): Promise => { + await updateUserSettingsOverrides(() => settings) +} + +export const shutdownSettingsStore = async (): Promise => { + if (getSettingsBackend() === 'db') { + await destroyMasterDbClient() + } + + bootstrapComplete = false + dbOverridesCache = {} as Settings + writeChain = Promise.resolve() +} diff --git a/src/utils/settings.ts b/src/utils/settings.ts index c47aa321a..665a6ff4a 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -95,6 +95,10 @@ export class SettingsStatic { } } + public static invalidateCache(): void { + SettingsStatic._settings = undefined + } + public static saveSettings(path: string, settings: Settings) { logger('saving settings to %s: %o', path, settings) return fs.writeFileSync(join(path, 'settings.yaml'), yaml.dump(settings), { encoding: 'utf-8' }) diff --git a/test/unit/cli/commands.spec.ts b/test/unit/cli/commands.spec.ts index adc4dfaf7..bf552aeb7 100644 --- a/test/unit/cli/commands.spec.ts +++ b/test/unit/cli/commands.spec.ts @@ -14,5 +14,8 @@ describe('cli env config helpers', () => { expect(validateEnvPair('RELAY_PORT', 'bad')).to.include('must be an integer') expect(validateEnvPair('READ_REPLICA_ENABLED', 'true')).to.equal(undefined) expect(validateEnvPair('READ_REPLICA_ENABLED', 'yes')).to.include('must be true or false') + expect(isSupportedEnvKey('SETTINGS_BACKEND')).to.equal(true) + expect(validateEnvPair('SETTINGS_BACKEND', 'db')).to.equal(undefined) + expect(validateEnvPair('SETTINGS_BACKEND', 'files')).to.include('must be one of') }) }) diff --git a/test/unit/controllers/admin/patch-notifications-controller.spec.ts b/test/unit/controllers/admin/patch-notifications-controller.spec.ts index 9698bcfae..70926bb6e 100644 --- a/test/unit/controllers/admin/patch-notifications-controller.spec.ts +++ b/test/unit/controllers/admin/patch-notifications-controller.spec.ts @@ -5,6 +5,7 @@ import sinonChai from 'sinon-chai' import { OperatorNotificationEventType } from '../../../../src/@types/operator-notifications' import * as adminNotificationsSettings from '../../../../src/utils/admin-notifications-settings' import * as settingsConfig from '../../../../src/utils/settings-config' +import * as settingsStore from '../../../../src/utils/settings-store' // eslint-disable-next-line @typescript-eslint/no-var-requires const { PatchAdminNotificationsController } = require('../../../../src/controllers/admin/patch-notifications-controller') @@ -49,7 +50,7 @@ describe('PatchAdminNotificationsController', () => { sandbox.stub(settingsConfig, 'loadMergedSettings').returns(baseMergedSettings as any) sandbox.stub(settingsConfig, 'loadUserSettings').returns({ admin: { notifications: baseNotifications } } as any) - sandbox.stub(settingsConfig, 'saveSettings') + sandbox.stub(settingsStore, 'saveUserSettingsOverrides').resolves() sandbox.stub(settingsConfig, 'appendSettingsAuditLog') sandbox.stub(adminNotificationsSettings, 'getRedactedAdminNotifications').returns({ ...baseNotifications, @@ -93,7 +94,7 @@ describe('PatchAdminNotificationsController', () => { expect(response.status.calledOnceWithExactly(400)).to.equal(true) expect(response.send.firstCall.args[0].error).to.equal('Validation failed') expect(response.send.firstCall.args[0].issues).to.be.an('array').that.is.not.empty - expect(settingsConfig.saveSettings).to.not.have.been.called + expect(settingsStore.saveUserSettingsOverrides).to.not.have.been.called expect(outboxRepository.enqueue).to.not.have.been.called }) @@ -118,8 +119,8 @@ describe('PatchAdminNotificationsController', () => { ) expect(response.status.calledOnceWithExactly(200)).to.equal(true) - expect(settingsConfig.saveSettings).to.have.been.calledOnce - const saved = (settingsConfig.saveSettings as Sinon.SinonStub).firstCall.args[0] + expect(settingsStore.saveUserSettingsOverrides).to.have.been.calledOnce + const saved = (settingsStore.saveUserSettingsOverrides as Sinon.SinonStub).firstCall.args[0] expect(saved.admin.notifications.enabled).to.equal(true) expect(saved.admin.notifications.targets[0].enabled).to.equal(false) expect(saved.admin.notifications.targets[0].url).to.equal('https://discord.com/api/webhooks/secret') @@ -137,6 +138,6 @@ describe('PatchAdminNotificationsController', () => { await controller.handleRequest({ body: { enabled: true }, ip: '127.0.0.1' } as any, response as any) expect(response.status.calledOnceWithExactly(200)).to.equal(true) - expect(settingsConfig.saveSettings).to.have.been.calledOnce + expect(settingsStore.saveUserSettingsOverrides).to.have.been.calledOnce }) }) diff --git a/test/unit/migrations/settings-overrides-migration.spec.ts b/test/unit/migrations/settings-overrides-migration.spec.ts new file mode 100644 index 000000000..bd197646f --- /dev/null +++ b/test/unit/migrations/settings-overrides-migration.spec.ts @@ -0,0 +1,13 @@ +import { createRequire } from 'node:module' + +import { expect } from 'chai' + +const requireFromHere = createRequire(__filename) +const migration = requireFromHere('../../../migrations/20260905_120000_create_settings_overrides_table.js') + +describe('migrations/20260905_120000_create_settings_overrides_table', () => { + it('exports up and down', () => { + expect(migration.up).to.be.a('function') + expect(migration.down).to.be.a('function') + }) +}) diff --git a/test/unit/repositories/settings-override-repository.spec.ts b/test/unit/repositories/settings-override-repository.spec.ts new file mode 100644 index 000000000..6f96fde3b --- /dev/null +++ b/test/unit/repositories/settings-override-repository.spec.ts @@ -0,0 +1,116 @@ +import * as chai from 'chai' +import * as sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import { SettingsOverrideRepository } from '../../../src/repositories/settings-override-repository' + +chai.use(sinonChai) + +const { expect } = chai + +describe('SettingsOverrideRepository', () => { + let queryBuilder: { + select: sinon.SinonStub + delete: sinon.SinonStub + insert: sinon.SinonStub + } + let transactionBuilder: { + delete: sinon.SinonStub + insert: sinon.SinonStub + } + let dbClient: any + let repository: SettingsOverrideRepository + + beforeEach(() => { + queryBuilder = { + select: sinon.stub().resolves([ + { path: 'info.name', value: 'Test Relay' }, + { path: 'nip66.enabled', value: true }, + ]), + delete: sinon.stub(), + insert: sinon.stub(), + } + + transactionBuilder = { + delete: sinon.stub().resolves(), + insert: sinon.stub().resolves(), + } + + const transactionFn = sinon.stub() + transactionFn.withArgs('settings_overrides').returns(transactionBuilder) + + dbClient = sinon.stub() + dbClient.callsFake((table: string) => { + if (table === 'settings_overrides') { + return queryBuilder + } + + throw new Error(`unexpected table ${table}`) + }) + dbClient.transaction = sinon.stub().callsFake(async (callback: (trx: typeof transactionFn) => Promise) => { + await callback(transactionFn) + }) + + repository = new SettingsOverrideRepository(dbClient) + }) + + it('loads overrides into a nested settings object', async () => { + const overrides = await repository.loadOverrides() + + expect(overrides).to.deep.equal({ + info: { name: 'Test Relay' }, + nip66: { enabled: true }, + }) + }) + + it('replaces all override rows from a nested settings object', async () => { + await repository.replaceOverrides({ + info: { name: 'Updated Relay' }, + admin: { enabled: true }, + } as any) + + expect(dbClient.transaction).to.have.been.calledOnce + expect(transactionBuilder.delete).to.have.been.calledOnce + expect(transactionBuilder.insert).to.have.been.calledOnce + + const insertedRows = transactionBuilder.insert.firstCall.args[0] + expect(insertedRows).to.have.length(2) + expect(insertedRows[0]).to.deep.include({ + path: 'info.name', + value: JSON.stringify('Updated Relay'), + }) + expect(insertedRows[0].updated_at).to.be.instanceOf(Date) + expect(insertedRows[1]).to.deep.include({ + path: 'admin.enabled', + value: JSON.stringify(true), + }) + expect(insertedRows[1].updated_at).to.be.instanceOf(Date) + }) + + it('clears all rows when overrides are empty', async () => { + await repository.replaceOverrides({} as any) + + expect(transactionBuilder.delete).to.have.been.calledOnce + expect(transactionBuilder.insert).not.to.have.been.called + }) + + it('loads overrides whose paths contain dotted notification keys', async () => { + queryBuilder.select.resolves([ + { path: 'admin.notifications.events["settings.changed"]', value: true }, + { path: 'admin.notifications.events["mirror.disconnected"]', value: false }, + ]) + + const overrides = await repository.loadOverrides() + + expect(overrides).to.deep.equal({ + admin: { + notifications: { + events: { + 'settings.changed': true, + 'mirror.disconnected': false, + }, + }, + }, + }) + }) +}) diff --git a/test/unit/utils/admin-moderation.spec.ts b/test/unit/utils/admin-moderation.spec.ts index ee202b0f9..4aa984408 100644 --- a/test/unit/utils/admin-moderation.spec.ts +++ b/test/unit/utils/admin-moderation.spec.ts @@ -105,9 +105,9 @@ describe('admin-moderation', () => { expect(withoutIp.rateLimitBypass.ipAddresses).to.not.include('203.0.113.10') }) - it('persists normalized moderation limits to settings', () => { + it('persists normalized moderation limits to settings', async () => { const pubkey = 'c'.repeat(64) - persistAdminModerationLimits({ + await persistAdminModerationLimits({ ...baseLimits, pubkey: { blacklist: [pubkey], whitelist: [] }, }) @@ -116,16 +116,14 @@ describe('admin-moderation', () => { expect(limits.pubkey.blacklist).to.include(pubkey) }) - it('persists when user settings only override part of info', () => { + it('persists when user settings only override part of info', async () => { settingsConfig.saveSettings({ info: { relay_url: 'wss://relay.example' } } as any) const pubkey = 'f'.repeat(64) - expect(() => - persistAdminModerationLimits({ - ...baseLimits, - pubkey: { blacklist: [pubkey], whitelist: [] }, - }), - ).to.not.throw() + await persistAdminModerationLimits({ + ...baseLimits, + pubkey: { blacklist: [pubkey], whitelist: [] }, + }) expect(readAdminModerationLimits().pubkey.blacklist).to.include(pubkey) }) diff --git a/test/unit/utils/settings-flatten.spec.ts b/test/unit/utils/settings-flatten.spec.ts new file mode 100644 index 000000000..229ee7565 --- /dev/null +++ b/test/unit/utils/settings-flatten.spec.ts @@ -0,0 +1,75 @@ +import { expect } from 'chai' + +import { flattenSettingsToPaths, getByPath, setByPath } from '../../../src/utils/settings-config' + +describe('flattenSettingsToPaths', () => { + it('flattens nested objects into dot paths', () => { + expect( + flattenSettingsToPaths({ + info: { name: 'Relay', relay_url: 'wss://relay.example.com' }, + nip66: { enabled: true }, + }), + ).to.deep.equal([ + { path: 'info.name', value: 'Relay' }, + { path: 'info.relay_url', value: 'wss://relay.example.com' }, + { path: 'nip66.enabled', value: true }, + ]) + }) + + it('preserves notification event keys that contain dots', () => { + expect( + flattenSettingsToPaths({ + admin: { + notifications: { + events: { 'settings.changed': true, 'mirror.disconnected': false }, + }, + }, + }), + ).to.deep.equal([ + { path: 'admin.notifications.events["settings.changed"]', value: true }, + { path: 'admin.notifications.events["mirror.disconnected"]', value: false }, + ]) + }) + + it('stores empty arrays as explicit override rows', () => { + expect( + flattenSettingsToPaths({ + limits: { + message: { + ipWhitelist: [], + }, + }, + }), + ).to.deep.equal([{ path: 'limits.message.ipWhitelist', value: [] }]) + }) + + it('flattens indexed array paths', () => { + expect( + flattenSettingsToPaths({ + nip66: { + targets: ['wss://relay.example.com'], + }, + }), + ).to.deep.equal([{ path: 'nip66.targets[0]', value: 'wss://relay.example.com' }]) + }) + + it('round-trips dotted notification keys through setByPath', () => { + const source = { + admin: { + notifications: { + events: { 'settings.changed': true, 'mirror.disconnected': false }, + targets: [{ id: 'ops', type: 'http', url: 'https://example.com/hook' }], + }, + }, + } + + let rebuilt: Record = {} + for (const entry of flattenSettingsToPaths(source)) { + rebuilt = setByPath(rebuilt, entry.path, entry.value) + } + + expect(getByPath(rebuilt, 'admin.notifications.events["settings.changed"]')).to.equal(true) + expect(getByPath(rebuilt, 'admin.notifications.targets[0].id')).to.equal('ops') + expect(rebuilt).to.deep.equal(source) + }) +}) diff --git a/test/unit/utils/settings-store.spec.ts b/test/unit/utils/settings-store.spec.ts new file mode 100644 index 000000000..bd03ef51f --- /dev/null +++ b/test/unit/utils/settings-store.spec.ts @@ -0,0 +1,117 @@ +import { expect } from 'chai' +import cluster, { Worker } from 'cluster' +import Sinon from 'sinon' + +import * as settingsConfig from '../../../src/utils/settings-config' +import * as settingsStore from '../../../src/utils/settings-store' + +describe('settings-store backend selection', () => { + const originalBackend = process.env.SETTINGS_BACKEND + + afterEach(() => { + if (originalBackend === undefined) { + delete process.env.SETTINGS_BACKEND + } else { + process.env.SETTINGS_BACKEND = originalBackend + } + }) + + it('defaults to file backend', () => { + delete process.env.SETTINGS_BACKEND + + expect(settingsStore.getSettingsBackend()).to.equal('file') + }) + + it('selects db backend when SETTINGS_BACKEND=db', () => { + process.env.SETTINGS_BACKEND = 'db' + + expect(settingsStore.getSettingsBackend()).to.equal('db') + }) +}) + +describe('settings-store cluster sync messages', () => { + it('recognizes cluster sync messages from workers', () => { + expect( + settingsStore.isSettingsOverridesClusterSyncMessage({ + eventName: settingsStore.SETTINGS_OVERRIDES_CLUSTER_SYNC_EVENT, + }), + ).to.equal(true) + expect(settingsStore.isSettingsOverridesClusterSyncMessage({ eventName: 'other' })).to.equal(false) + }) +}) + +describe('settings-store updateUserSettingsOverrides', () => { + const originalBackend = process.env.SETTINGS_BACKEND + + afterEach(async () => { + Sinon.restore() + await settingsStore.shutdownSettingsStore() + + if (originalBackend === undefined) { + delete process.env.SETTINGS_BACKEND + } else { + process.env.SETTINGS_BACKEND = originalBackend + } + }) + + it('serializes overlapping file-backend updates', async () => { + delete process.env.SETTINGS_BACKEND + const saveSettings = Sinon.stub(settingsConfig, 'saveSettings') + + await Promise.all([ + settingsStore.updateUserSettingsOverrides((current) => + ({ + ...current, + info: { relay_url: 'wss://a.example', name: 'A' }, + }) as any, + ), + settingsStore.updateUserSettingsOverrides((current) => + ({ + ...current, + info: { relay_url: 'wss://b.example', name: 'B' }, + }) as any, + ), + ]) + + expect(saveSettings.callCount).to.equal(2) + const secondSaved = saveSettings.secondCall.args[0] as { info?: { name?: string } } + expect(secondSaved.info?.name).to.equal('B') + }) +}) + +describe('handleSettingsOverridesClusterSyncFromWorker', () => { + const originalBackend = process.env.SETTINGS_BACKEND + + afterEach(() => { + Sinon.restore() + if (originalBackend === undefined) { + delete process.env.SETTINGS_BACKEND + } else { + process.env.SETTINGS_BACKEND = originalBackend + } + }) + + it('reloads on the primary and notifies other workers', async () => { + process.env.SETTINGS_BACKEND = 'db' + Sinon.stub(cluster, 'isPrimary').value(true) + + const reload = Sinon.stub(settingsStore, 'reloadDbOverridesFromDatabase').resolves() + const source = { id: 1 } as Worker + const notifiedWorker = { id: 2, send: Sinon.stub() } + const sourceWorker = { id: 1, send: Sinon.stub() } + Sinon.stub(cluster, 'workers').value({ + 1: sourceWorker, + 2: notifiedWorker, + }) + + await settingsStore.handleSettingsOverridesClusterSyncFromWorker(source) + + expect(reload.calledOnce).to.equal(true) + expect( + notifiedWorker.send.calledOnceWith({ + eventName: settingsStore.SETTINGS_OVERRIDES_RELOAD_EVENT, + }), + ).to.equal(true) + expect(sourceWorker.send.called).to.equal(false) + }) +})