import fs from 'fs' import { setTimeout as delay } from 'node:timers/promises' import { join } from 'path' import yaml from 'js-yaml' import { isNil, mergeDeepRight } from 'ramda' import { createLogger } from '../factories/logger-factory' import { EVENT_STORE_BACKENDS, isEventStoreBackend } from './event-store' import { GRANDFATHERED_PLAN_ID } from '../@types/user-subscription' import { Settings } from '../@types/settings' import { getConfigBaseDir, getDefaultSettingsFilePath, getLegacySettingsFilePath, getSettingsAuditLogPath, getSettingsBackupDir, getSettingsFilePath, getSettingsWriteLockPath, } from './settings-paths' export { getConfigBaseDir, getDefaultSettingsFilePath, getLegacySettingsFilePath, getSettingsAuditLogPath, getSettingsBackupDir, getSettingsFilePath, getSettingsWriteLockPath, } from './settings-paths' const SETTINGS_WRITE_LOCK_STALE_MS = 60_000 const SETTINGS_WRITE_LOCK_RETRY_MS = 25 const SETTINGS_WRITE_LOCK_MAX_WAIT_MS = 30_000 type SettingsWriteLockMetadata = { pid: number acquiredAt: number } const readSettingsWriteLockMetadata = (lockPath: string): SettingsWriteLockMetadata | undefined => { try { const raw = fs.readFileSync(lockPath, 'utf-8') const [pidLine, acquiredLine] = raw.split('\n') const pid = Number(pidLine) const acquiredAt = Number(acquiredLine) if (!Number.isFinite(pid) || !Number.isFinite(acquiredAt)) { return undefined } return { pid, acquiredAt } } catch { return undefined } } const isSettingsWriteLockStale = (metadata: SettingsWriteLockMetadata): boolean => { if (Date.now() - metadata.acquiredAt > SETTINGS_WRITE_LOCK_STALE_MS) { return true } try { process.kill(metadata.pid, 0) return false } catch (error) { // Only ESRCH proves the owner is gone. EPERM means the process exists under // another user, and any other error is treated as alive so a failed check // can never steal a live lock; the age rule above remains the fallback. return (error as NodeJS.ErrnoException).code === 'ESRCH' } } const reclaimSettingsWriteLock = (lockPath: string): void => { const reclaimPath = `${lockPath}.reclaim.${process.pid}.${Date.now()}` fs.renameSync(lockPath, reclaimPath) fs.rmSync(reclaimPath, { force: true }) } const attemptStealStaleSettingsWriteLock = ( lockPath: string, expected: SettingsWriteLockMetadata, ): boolean => { try { const current = readSettingsWriteLockMetadata(lockPath) if ( !current || current.pid !== expected.pid || current.acquiredAt !== expected.acquiredAt || !isSettingsWriteLockStale(current) ) { return false } reclaimSettingsWriteLock(lockPath) return true } catch { return false } } // A writer holds the lock between openSync and writeFileSync, so a lock with // missing or unparseable metadata is only abandoned once it has been untouched // for the same grace period as a stale lock. Reclaiming it immediately would // race a live writer. const attemptStealAbandonedSettingsWriteLock = (lockPath: string): boolean => { try { const stats = fs.statSync(lockPath) if (Date.now() - stats.mtimeMs <= SETTINGS_WRITE_LOCK_STALE_MS) { return false } reclaimSettingsWriteLock(lockPath) return true } catch { return false } } export const withSettingsWriteLock = async (operation: () => T): Promise => { ensureSettingsExists() const lockPath = getSettingsWriteLockPath() const deadline = Date.now() + SETTINGS_WRITE_LOCK_MAX_WAIT_MS let lockFd: number | undefined while (Date.now() < deadline) { try { const lockFdLocal = fs.openSync(lockPath, 'wx') try { fs.writeFileSync(lockFdLocal, `${process.pid}\n${Date.now()}\n`) } catch (error) { // The lock exists before it has contents; leaving that empty file // behind would block every later writer until someone removes it. try { fs.closeSync(lockFdLocal) } finally { fs.rmSync(lockPath, { force: true }) } throw error } lockFd = lockFdLocal break } catch (error) { const errno = error as NodeJS.ErrnoException if (errno.code !== 'EEXIST') { throw error } const metadata = readSettingsWriteLockMetadata(lockPath) if (metadata) { if (isSettingsWriteLockStale(metadata)) { attemptStealStaleSettingsWriteLock(lockPath, metadata) } } else { attemptStealAbandonedSettingsWriteLock(lockPath) } await delay(SETTINGS_WRITE_LOCK_RETRY_MS) } } if (lockFd === undefined) { throw new Error('Timed out waiting for settings write lock') } try { return operation() } finally { fs.closeSync(lockFd) fs.rmSync(lockPath, { force: true }) } } const logger = createLogger('settings-config') export type ValidationIssue = { path: string message: string } type PathToken = | { type: 'key' key: string } | { type: 'index' index: number } export const toCategoryLabel = (key: string): string => { return key .split(/[_\-.]/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(' ') } const isPlainObject = (value: unknown): value is Record => { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } const RESERVED_PATH_KEYS = new Set(['__proto__', 'prototype', 'constructor']) const assertSafePathKey = (key: string, segment: string): void => { if (RESERVED_PATH_KEYS.has(key)) { throw new Error(`Invalid path segment: ${segment}`) } } /** Settings that no longer do anything because the static mirroring worker was removed. */ const REMOVED_SETTINGS = [ { path: '$', key: 'mirroring' }, { path: 'admin.notifications.events', key: 'mirror.disconnected' }, ] const hasOwn = (object: Record, key: string): boolean => { return Object.prototype.hasOwnProperty.call(object, key) } const parsePath = (path: string): PathToken[] => { const input = path.trim() if (!input) { throw new Error('Path is required') } 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}`) } const match = segment.match(/^([A-Za-z_][A-Za-z0-9_]*)(\[(\d+)\])*$/) if (!match) { throw new Error(`Invalid path segment: ${segment}`) } const key = match[1] assertSafePathKey(key, segment) tokens.push({ type: 'key', key }) const indexes = [...segment.matchAll(/\[(\d+)\]/g)] for (const entry of indexes) { tokens.push({ type: 'index', index: Number(entry[1]), }) } } return tokens } const formatPathTokens = (tokens: PathToken[]): string => { let out = '' for (const token of tokens) { if (token.type === 'key') { out = out ? `${out}.${token.key}` : token.key continue } out = `${out}[${token.index}]` } return out } export const parseValue = (raw: string): unknown => { const trimmed = raw.trim() if (trimmed === 'true') { return true } if (trimmed === 'false') { return false } if (trimmed === 'null') { return null } if (/^-?\d+$/.test(trimmed)) { const asNumber = Number(trimmed) if (Number.isSafeInteger(asNumber)) { return asNumber } } if (/^-?\d+n$/.test(trimmed)) { return BigInt(trimmed.slice(0, -1)) } if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { return JSON.parse(trimmed) } catch { return raw } } return raw } export const parseTypedValue = (raw: string, type: 'inferred' | 'json' = 'inferred'): unknown => { if (type === 'json') { try { return JSON.parse(raw) } catch (error) { const message = error instanceof Error ? error.message : String(error) throw new Error(`Invalid JSON value: ${message}`) } } return parseValue(raw) } const toSerializable = (value: unknown): unknown => { if (typeof value === 'bigint') { return value.toString() } if (Array.isArray(value)) { return value.map((entry) => toSerializable(entry)) } if (isPlainObject(value)) { return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, toSerializable(entry)])) } return value } const validateShape = (schema: unknown, candidate: unknown, path: PathToken[], issues: ValidationIssue[]): void => { if (schema === undefined || candidate === undefined) { return } const renderedPath = formatPathTokens(path) || '$' if (Array.isArray(schema)) { if (!Array.isArray(candidate)) { issues.push({ path: renderedPath, message: `Expected array, got ${typeof candidate}`, }) return } if (schema.length === 0) { return } candidate.forEach((entry, index) => { const matchesAny = schema.some((schemaEntry) => { const localIssues: ValidationIssue[] = [] validateShape(schemaEntry, entry, [...path, { type: 'index', index }], localIssues) return localIssues.length === 0 }) if (!matchesAny) { issues.push({ path: formatPathTokens([...path, { type: 'index', index }]), message: 'Array element does not match expected schema shape', }) } }) return } if (isPlainObject(schema)) { if (!isPlainObject(candidate)) { issues.push({ path: renderedPath, message: `Expected object, got ${typeof candidate}`, }) return } for (const key of Object.keys(candidate)) { if (renderedPath === 'admin' && key === 'passwordHash') { continue } // Settings that were removed stay accepted, so a settings file that still has one can be saved. if (REMOVED_SETTINGS.some((removed) => removed.path === renderedPath && removed.key === key)) { continue } if (!hasOwn(schema, key)) { issues.push({ path: formatPathTokens([...path, { type: 'key', key }]), message: 'Unknown setting key', }) } } for (const key of Object.keys(schema)) { validateShape(schema[key], (candidate as Record)[key], [...path, { type: 'key', key }], issues) } return } if (candidate === null && schema !== null) { issues.push({ path: renderedPath, message: `Expected ${typeof schema}, got null`, }) return } if (schema !== null && typeof schema !== typeof candidate) { issues.push({ path: renderedPath, message: `Expected ${typeof schema}, got ${typeof candidate}`, }) } } const pathExistsInSchema = (schema: unknown, tokens: PathToken[]): boolean => { let current: unknown = schema for (const token of tokens) { if (token.type === 'key') { if (!isPlainObject(current) || !hasOwn(current, token.key)) { return false } current = current[token.key] continue } if (!Array.isArray(current)) { return false } if (current.length === 0) { return true } current = current[0] } return true } export const ensureSettingsExists = (): void => { const configDir = getConfigBaseDir() if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }) } } export const loadDefaults = (): Settings => { const defaultsRaw = fs.readFileSync(getDefaultSettingsFilePath(), 'utf-8') return yaml.load(defaultsRaw) as Settings } export const loadUserSettings = (): Settings => { ensureSettingsExists() const settingsPath = getSettingsFilePath() if (fs.existsSync(settingsPath)) { const raw = fs.readFileSync(settingsPath, 'utf-8') return (yaml.load(raw) as Settings) ?? ({} as Settings) } // Deployments predating the yaml migration still hold their overrides in // settings.json. Ignoring it would silently reset them to image defaults. const legacyPath = getLegacySettingsFilePath() if (fs.existsSync(legacyPath)) { logger.warn('settings.json is deprecated, please migrate your overrides to %s', settingsPath) return (JSON.parse(fs.readFileSync(legacyPath, 'utf-8')) as Settings) ?? ({} as Settings) } return {} as Settings } export const loadMergedSettings = (): Settings => { return mergeDeepRight(loadDefaults(), loadUserSettings()) as Settings } export const filterSettingsAgainstDefaults = (settings: unknown, defaults: unknown): unknown => { if (Array.isArray(settings)) { if (!Array.isArray(defaults)) { return [] } if (defaults.length === 0) { return [...settings] } const itemSchema = defaults[0] return settings.map((item) => filterSettingsAgainstDefaults(item, itemSchema)) } if (isPlainObject(settings)) { if (!isPlainObject(defaults)) { return {} } const filtered: Record = {} for (const key of Object.keys(settings)) { if (hasOwn(defaults, key) || key === 'passwordHash') { // If it's a known non-schema key like passwordHash, we don't have a default schema for its children. // We can pass {} to allow it to be preserved. const defaultSubSchema = hasOwn(defaults, key) ? (defaults as Record)[key] : {} filtered[key] = filterSettingsAgainstDefaults( (settings as Record)[key], defaultSubSchema ) } } return filtered } return settings } export const saveSettings = (settings: Settings): void => { ensureSettingsExists() const serialized = yaml.dump(toSerializable(settings), { lineWidth: 120 }) const settingsPath = getSettingsFilePath() const backupDirectory = getSettingsBackupDir() const timestamp = new Date().toISOString().replace(/[:.]/g, '-') fs.mkdirSync(backupDirectory, { recursive: true }) if (fs.existsSync(settingsPath)) { fs.copyFileSync(settingsPath, join(backupDirectory, `settings.${timestamp}.yaml`)) } const temporaryPath = join(getConfigBaseDir(), `.settings.${process.pid}.${Date.now()}.tmp`) try { fs.writeFileSync(temporaryPath, serialized, { encoding: 'utf-8', mode: 0o600 }) fs.renameSync(temporaryPath, settingsPath) } finally { if (fs.existsSync(temporaryPath)) { fs.rmSync(temporaryPath, { force: true }) } } } export const appendSettingsAuditLog = (entry: Record): void => { ensureSettingsExists() fs.appendFileSync( getSettingsAuditLogPath(), `${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}\n`, { encoding: 'utf-8', mode: 0o600 }, ) } export type SettingsBackupInfo = { filename: string createdAt: string sizeBytes: number } 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) } export const listSettingsBackups = (): SettingsBackupInfo[] => { const backupDirectory = getSettingsBackupDir() if (!fs.existsSync(backupDirectory)) { return [] } return fs .readdirSync(backupDirectory) .filter((filename) => isSafeBackupFilename(filename)) .map((filename) => { const stats = fs.statSync(join(backupDirectory, filename)) return { filename, createdAt: stats.mtime.toISOString(), sizeBytes: stats.size, } }) .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) } export const restoreSettingsBackup = (filename: string): Settings => { if (!isSafeBackupFilename(filename)) { throw new Error('Invalid backup filename') } const backupPath = join(getSettingsBackupDir(), filename) if (!fs.existsSync(backupPath)) { throw new Error('Backup not found') } const restored = yaml.load(fs.readFileSync(backupPath, 'utf-8')) as Settings const issues = validateSettings(mergeDeepRight(loadDefaults(), restored) as Settings) if (issues.length > 0) { throw new Error(`Backup failed validation: ${issues.map((issue) => issue.path).join(', ')}`) } saveSettings(restored) return restored } export const getTopLevelSettingCategories = (): string[] => { const defaults = loadDefaults() as unknown as Record return Object.keys(defaults) } export const getByPath = (settings: unknown, path: string): unknown => { const tokens = parsePath(path) let current: unknown = settings for (const token of tokens) { if (token.type === 'key') { if (!isPlainObject(current)) { return undefined } current = current[token.key] continue } if (!Array.isArray(current)) { return undefined } current = current[token.index] } return current } const ensureArrayLength = (target: unknown[], minimumLength: number): void => { while (target.length <= minimumLength) { target.push(undefined) } } export const setByPath = (settings: Record, path: string, value: unknown): Record => { const tokens = parsePath(path) const clone: Record = structuredClone(settings) if (tokens.length === 0) { throw new Error('Path is required') } let current: unknown = clone for (let i = 0; i < tokens.length - 1; i++) { const token = tokens[i] const nextToken = tokens[i + 1] if (token.type === 'key') { if (!isPlainObject(current)) { throw new Error(`Cannot set key ${token.key} on non-object path`) } const existing = current[token.key] if (existing === undefined) { current[token.key] = nextToken.type === 'index' ? [] : {} } else if (nextToken.type === 'index' && !Array.isArray(existing)) { current[token.key] = [] } else if (nextToken.type === 'key' && !isPlainObject(existing)) { current[token.key] = {} } current = current[token.key] continue } if (!Array.isArray(current)) { throw new Error(`Cannot index non-array path at [${token.index}]`) } ensureArrayLength(current, token.index) const existing = current[token.index] if (existing === undefined) { current[token.index] = nextToken.type === 'index' ? [] : {} } else if (nextToken.type === 'index' && !Array.isArray(existing)) { current[token.index] = [] } else if (nextToken.type === 'key' && !isPlainObject(existing)) { current[token.index] = {} } current = current[token.index] } const last = tokens[tokens.length - 1] if (last.type === 'key') { if (!isPlainObject(current)) { throw new Error(`Cannot set key ${last.key} on non-object path`) } current[last.key] = value return clone } if (!Array.isArray(current)) { throw new Error(`Cannot index non-array path at [${last.index}]`) } ensureArrayLength(current, last.index) current[last.index] = value return clone } export const validatePathAgainstDefaults = (path: string): ValidationIssue[] => { const defaults = loadDefaults() as unknown const tokens = parsePath(path) if (pathExistsInSchema(defaults, tokens)) { return [] } return [ { path, message: 'Path does not exist in default settings schema', }, ] } const validateRateLimitPeriods = ( rateLimits: { period: number }[] | undefined, pathPrefix: string, issues: ValidationIssue[], ): void => { if (!Array.isArray(rateLimits)) { return } rateLimits.forEach((entry, index) => { if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { issues.push({ path: `${pathPrefix}[${index}]`, message: 'rateLimits entry must be an object' }) return } // The window is in milliseconds, and 60 is as valid a number as 60000, so // the unit cannot be inferred here: it is stated on RateLimit and on each // block in resources/default-settings.yaml. What this can refuse is a value // that is not a whole count of milliseconds at all. if (typeof entry.period !== 'number' || !Number.isFinite(entry.period)) { issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be a number of milliseconds' }) } else if (!Number.isInteger(entry.period)) { issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be a whole number of milliseconds' }) } else if (entry.period < 1) { issues.push({ path: `${pathPrefix}[${index}].period`, message: 'period must be at least 1 millisecond' }) } }) } const isPositiveInteger = (value: unknown): boolean => (typeof value === 'number' || typeof value === 'bigint') && Number.isInteger(Number(value)) && Number(value) > 0 // Unlike the global limits, a plan's limits are not covered by the settings schema while the plan list // is empty by default, so each entry is checked here. const validatePlanRateLimits = (rateLimits: unknown, path: string, issues: ValidationIssue[]): void => { if (rateLimits === undefined || rateLimits === null) { return } if (!Array.isArray(rateLimits)) { issues.push({ path, message: 'rateLimits must be a list' }) return } rateLimits.forEach((entry, index) => { if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { issues.push({ path: `${path}[${index}]`, message: 'rateLimits entry must be an object' }) return } for (const key of ['period', 'rate']) { if (!isPositiveInteger(entry[key])) { issues.push({ path: `${path}[${index}].${key}`, message: `${key} must be a positive integer` }) } } }) } const validateSubscriptionPlans = (plans: unknown, issues: ValidationIssue[]): void => { // A value that is not a list is already reported against the default settings schema. if (!Array.isArray(plans)) { return } const ids = new Set() const amounts = new Set() plans.forEach((plan, index) => { const prefix = `payments.subscriptionPlans[${index}]` if (plan === null || typeof plan !== 'object' || Array.isArray(plan)) { issues.push({ path: prefix, message: 'plan must be an object' }) return } if (typeof plan.id !== 'string' || !plan.id.trim()) { issues.push({ path: `${prefix}.id`, message: 'id is required' }) } else if (plan.id === GRANDFATHERED_PLAN_ID) { issues.push({ path: `${prefix}.id`, message: `id "${GRANDFATHERED_PLAN_ID}" is reserved` }) } else if (ids.has(plan.id)) { issues.push({ path: `${prefix}.id`, message: `id "${plan.id}" is used by more than one plan` }) } else { ids.add(plan.id) } // The amount paid selects the plan, so two plans with one price could not be told apart. if (!isPositiveInteger(plan.amount)) { issues.push({ path: `${prefix}.amount`, message: 'amount must be a positive integer (msats)' }) } else if (amounts.has(String(plan.amount))) { issues.push({ path: `${prefix}.amount`, message: `amount ${plan.amount} is used by more than one plan` }) } else { amounts.add(String(plan.amount)) } if (!isPositiveInteger(plan.intervalSeconds)) { issues.push({ path: `${prefix}.intervalSeconds`, message: 'intervalSeconds must be a positive integer' }) } if (!(Number.isInteger(plan.graceSeconds) && plan.graceSeconds >= 0)) { issues.push({ path: `${prefix}.graceSeconds`, message: 'graceSeconds must be a non-negative integer' }) } const limits = plan.limits if (limits !== undefined && limits !== null) { if (typeof limits !== 'object' || Array.isArray(limits)) { issues.push({ path: `${prefix}.limits`, message: 'limits must be an object' }) return } validatePlanRateLimits(limits.rateLimits, `${prefix}.limits.rateLimits`, issues) if (limits.retentionMaxDays !== undefined && !isPositiveInteger(limits.retentionMaxDays)) { issues.push({ path: `${prefix}.limits.retentionMaxDays`, message: 'retentionMaxDays must be a positive integer', }) } } }) } const PEERING_KIND_MAX = 65535 const PEERING_POW_MAX_BITS = 256 const PEERING_SCOPE_KEYS = new Set(['kinds', 'since', 'until', 'windowSeconds']) const PEERING_TERM_KEYS = new Set(['amount', 'unit', 'period']) const isValidPeeringKindValue = (value: unknown): boolean => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= PEERING_KIND_MAX const validatePeeringKinds = (kinds: unknown, path: string, issues: ValidationIssue[]): void => { if (!Array.isArray(kinds) || kinds.length === 0) { issues.push({ path, message: `must be a non-empty list of event kinds or [min, max] ranges`, }) return } kinds.forEach((kind, index) => { if (Array.isArray(kind)) { if (kind.length !== 2 || !isValidPeeringKindValue(kind[0]) || !isValidPeeringKindValue(kind[1])) { issues.push({ path: `${path}[${index}]`, message: `range must contain two integers between 0 and ${PEERING_KIND_MAX}`, }) return } if ((kind[0] as number) > (kind[1] as number)) { issues.push({ path: `${path}[${index}]`, message: 'range start must be less than or equal to range end' }) } return } if (!isValidPeeringKindValue(kind)) { issues.push({ path: `${path}[${index}]`, message: `must be an integer between 0 and ${PEERING_KIND_MAX}` }) } }) } // Scopes and terms default to empty lists, so the default settings schema has no // entry shape to check them against. Validate each entry here instead. const validatePeeringScopes = (scopes: unknown, issues: ValidationIssue[]): void => { if (!Array.isArray(scopes)) { return } scopes.forEach((scope, index) => { const prefix = `peering.scopes[${index}]` if (!isPlainObject(scope)) { issues.push({ path: prefix, message: 'scope must be an object' }) return } for (const key of Object.keys(scope)) { if (!PEERING_SCOPE_KEYS.has(key)) { issues.push({ path: `${prefix}.${key}`, message: 'Unknown setting key' }) } } validatePeeringKinds(scope.kinds, `${prefix}.kinds`, issues) const { since, until, windowSeconds } = scope if (since !== undefined && !(typeof since === 'number' && Number.isSafeInteger(since) && since >= 0)) { issues.push({ path: `${prefix}.since`, message: 'since must be a non-negative integer (unix seconds)' }) } if (until !== undefined && !(typeof until === 'number' && Number.isSafeInteger(until) && until >= 0)) { issues.push({ path: `${prefix}.until`, message: 'until must be a non-negative integer (unix seconds)' }) } if ( windowSeconds !== undefined && !(typeof windowSeconds === 'number' && Number.isSafeInteger(windowSeconds) && windowSeconds > 0) ) { issues.push({ path: `${prefix}.windowSeconds`, message: 'windowSeconds must be a positive integer (seconds)' }) } else if (windowSeconds !== undefined && (since !== undefined || until !== undefined)) { issues.push({ path: prefix, message: 'windowSeconds cannot be combined with since or until' }) } if ( typeof since === 'number' && typeof until === 'number' && Number.isSafeInteger(since) && Number.isSafeInteger(until) && since > until ) { issues.push({ path: prefix, message: 'since must be less than or equal to until' }) } }) } const validatePeeringTerms = (terms: unknown, issues: ValidationIssue[]): void => { if (!Array.isArray(terms)) { return } terms.forEach((term, index) => { const prefix = `peering.terms[${index}]` if (!isPlainObject(term)) { issues.push({ path: prefix, message: 'term must be an object' }) return } for (const key of Object.keys(term)) { if (!PEERING_TERM_KEYS.has(key)) { issues.push({ path: `${prefix}.${key}`, message: 'Unknown setting key' }) } } if (!(typeof term.amount === 'number' && Number.isSafeInteger(term.amount) && term.amount >= 0)) { issues.push({ path: `${prefix}.amount`, message: 'amount must be a non-negative integer' }) } if (typeof term.unit !== 'string' || !term.unit.trim()) { issues.push({ path: `${prefix}.unit`, message: 'unit must be a non-empty string' }) } if (!(typeof term.period === 'number' && Number.isSafeInteger(term.period) && term.period > 0)) { issues.push({ path: `${prefix}.period`, message: 'period must be a positive integer (seconds)' }) } }) } const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => { const peering = settings.peering if (peering === undefined || peering === null) { return } // A non-object value is already reported against the default settings schema. if (!isPlainObject(peering)) { return } const enabled = peering.enabled === true if (peering.minRequestPowDifficulty !== undefined) { const bits = peering.minRequestPowDifficulty if ( typeof bits !== 'number' || !Number.isInteger(bits) || bits < 0 || bits > PEERING_POW_MAX_BITS ) { issues.push({ path: 'peering.minRequestPowDifficulty', message: `must be an integer between 0 and ${PEERING_POW_MAX_BITS}`, }) } } const endpoint = typeof peering.endpoint === 'string' ? peering.endpoint.trim() : '' if (endpoint) { try { const parsed = new URL(endpoint) if (!['ws:', 'wss:'].includes(parsed.protocol) || !parsed.host) { issues.push({ path: 'peering.endpoint', message: 'must be a ws:// or wss:// URL' }) } } catch { issues.push({ path: 'peering.endpoint', message: 'must be a valid ws:// or wss:// URL' }) } } // NIP-11 calls `contact` a URI. Reject free text so a relay does not publish // an address other relays cannot use to reach the operator. const contact = typeof peering.contact === 'string' ? peering.contact.trim() : '' if (contact) { try { new URL(contact) } catch { issues.push({ path: 'peering.contact', message: 'must be a valid URI (for example mailto: or https:)', }) } } validatePeeringScopes(peering.scopes, issues) validatePeeringTerms(peering.terms, issues) if (!enabled) { return } if (!Array.isArray(peering.scopes) || peering.scopes.length === 0) { issues.push({ path: 'peering.scopes', message: 'at least one scope is required when peering is enabled' }) } if (!contact && !endpoint) { issues.push({ path: 'peering.contact', message: 'contact or endpoint is required when peering is enabled' }) } } export const validateSettings = (settings: Settings): ValidationIssue[] => { const issues: ValidationIssue[] = [] if (!settings.info?.relay_url) { issues.push({ path: 'info.relay_url', message: 'relay_url is required' }) } else if (typeof settings.info.relay_url === 'string') { try { const parsed = new URL(settings.info.relay_url) if (!parsed.host) { issues.push({ path: 'info.relay_url', message: 'relay_url must include a valid host' }) } else if (!['ws:', 'wss:', 'http:', 'https:'].includes(parsed.protocol)) { issues.push({ path: 'info.relay_url', message: 'relay_url must use ws, wss, http, or https protocol', }) } } catch { issues.push({ path: 'info.relay_url', message: 'relay_url must be a valid absolute URL' }) } } if (!settings.info?.name) { issues.push({ path: 'info.name', message: 'name is required' }) } if (!settings.network) { issues.push({ path: 'network', message: 'network section is required' }) } if (settings.payments?.enabled && !settings.payments.processor) { issues.push({ path: 'payments.processor', message: 'processor is required when payments are enabled' }) } const strategy = settings.limits?.rateLimiter?.strategy if (strategy && strategy !== 'ewma' && strategy !== 'sliding_window') { issues.push({ path: 'limits.rateLimiter.strategy', message: 'strategy must be ewma or sliding_window' }) } const eventStoreBackend = settings.eventStore?.backend if (!isNil(eventStoreBackend) && !isEventStoreBackend(eventStoreBackend)) { issues.push({ path: 'eventStore.backend', message: `backend must be one of: ${EVENT_STORE_BACKENDS.join(', ')}`, }) } const nip66 = settings.nip66 if (nip66?.historyRetentionRuns !== undefined) { const runs = nip66.historyRetentionRuns if (!Number.isFinite(runs) || !(runs >= 1)) { issues.push({ path: 'nip66.historyRetentionRuns', message: 'historyRetentionRuns must be at least 1' }) } } if ( nip66?.historyRetentionHours !== undefined && !(nip66.historyRetentionHours === 0 || nip66.historyRetentionHours >= 1) ) { issues.push({ path: 'nip66.historyRetentionHours', message: 'historyRetentionHours must be 0 (disabled) or at least 1', }) } const nip77 = settings.nip77 if (nip77?.enabled) { for (const key of ['maxEventsPerSession', 'maxSessionsPerConnection', 'sessionIdleTimeoutMs'] as const) { const value = nip77[key] // Wrong types (e.g. a string) are already reported against the default settings schema. if (typeof value === 'number' && !(Number.isInteger(value) && value > 0)) { issues.push({ path: `nip77.${key}`, message: `${key} must be a positive integer` }) } } } validateSubscriptionPlans(settings.payments?.subscriptionPlans, issues) validatePeering(settings, issues) const pow = settings.limits?.event?.pow if (pow?.enabled) { if (!(pow.floorBits >= 0) || !(pow.floorBits <= pow.ceilingBits)) { issues.push({ path: 'limits.event.pow.floorBits', message: 'floorBits must be >= 0 and <= ceilingBits' }) } if (!(pow.ceilingBits <= 256)) { issues.push({ path: 'limits.event.pow.ceilingBits', message: 'ceilingBits must be <= 256' }) } if (!(pow.periodMs > 0)) { issues.push({ path: 'limits.event.pow.periodMs', message: 'periodMs must be greater than 0' }) } if (!(pow.targetEventsPerSecond > 0)) { issues.push({ path: 'limits.event.pow.targetEventsPerSecond', message: 'targetEventsPerSecond must be greater than 0', }) } if (Array.isArray(pow.wotThresholds)) { pow.wotThresholds.forEach((threshold, index) => { if (threshold === null || typeof threshold !== 'object') { issues.push({ path: `limits.event.pow.wotThresholds[${index}]`, message: 'wotThresholds entry must be an object', }) return } if (!(threshold.maxDistance >= 0)) { issues.push({ path: `limits.event.pow.wotThresholds[${index}].maxDistance`, message: 'maxDistance must be >= 0', }) } if (!(threshold.difficultyFactor >= 0) || !(threshold.difficultyFactor <= 1)) { issues.push({ path: `limits.event.pow.wotThresholds[${index}].difficultyFactor`, message: 'difficultyFactor must be between 0 and 1', }) } }) } } const limits = settings.limits if (limits) { validateRateLimitPeriods(limits.event?.rateLimits, 'limits.event.rateLimits', issues) validateRateLimitPeriods(limits.message?.rateLimits, 'limits.message.rateLimits', issues) validateRateLimitPeriods(limits.connection?.rateLimits, 'limits.connection.rateLimits', issues) validateRateLimitPeriods(limits.invoice?.rateLimits, 'limits.invoice.rateLimits', issues) validateRateLimitPeriods(limits.admissionCheck?.rateLimits, 'limits.admissionCheck.rateLimits', issues) validateRateLimitPeriods(limits.invite?.rateLimits, 'limits.invite.rateLimits', issues) validateRateLimitPeriods(limits.admin?.rateLimits, 'limits.admin.rateLimits', issues) validateRateLimitPeriods(limits.admin?.loginRateLimits, 'limits.admin.loginRateLimits', issues) } validateShape(loadDefaults(), settings, [], issues) issues.push(...validateAdminNotifications(settings)) return issues } const validateAdminNotifications = (settings: Settings): ValidationIssue[] => { const issues: ValidationIssue[] = [] const notifications = settings.admin?.notifications if (!notifications) { return issues } if (notifications.retry?.maxAttempts !== undefined && notifications.retry.maxAttempts < 1) { issues.push({ path: 'admin.notifications.retry.maxAttempts', message: 'maxAttempts must be at least 1' }) } if (notifications.retry?.baseDelayMs !== undefined && notifications.retry.baseDelayMs < 0) { issues.push({ path: 'admin.notifications.retry.baseDelayMs', message: 'baseDelayMs must be >= 0' }) } if (notifications.deliveryLogRetentionDays !== undefined && notifications.deliveryLogRetentionDays < 1) { issues.push({ path: 'admin.notifications.deliveryLogRetentionDays', message: 'deliveryLogRetentionDays must be at least 1', }) } const rawTargets = notifications.targets if (rawTargets !== undefined && !Array.isArray(rawTargets)) { issues.push({ path: 'admin.notifications.targets', message: 'targets must be an array' }) return issues } const targetIds = new Set() for (const [index, target] of (rawTargets ?? []).entries()) { const prefix = `admin.notifications.targets[${index}]` if (!target || typeof target !== 'object' || Array.isArray(target)) { issues.push({ path: prefix, message: 'target must be an object' }) continue } if (typeof target.id !== 'string' || !target.id.trim()) { issues.push({ path: `${prefix}.id`, message: 'target id is required' }) } else if (targetIds.has(target.id)) { issues.push({ path: `${prefix}.id`, message: 'target id must be unique' }) } else { targetIds.add(target.id) } if ( typeof target.type !== 'string' || !['http', 'discord', 'slack', 'telegram'].includes(target.type) ) { issues.push({ path: `${prefix}.type`, message: 'type must be http, discord, slack, or telegram' }) } if (target.enabled !== undefined && typeof target.enabled !== 'boolean') { issues.push({ path: `${prefix}.enabled`, message: 'enabled must be a boolean' }) } if (target.type === 'telegram') { if (typeof target.botToken !== 'string' || !target.botToken.trim()) { issues.push({ path: `${prefix}.botToken`, message: 'botToken is required for telegram targets' }) } if (typeof target.chatId !== 'string' || !target.chatId.trim()) { issues.push({ path: `${prefix}.chatId`, message: 'chatId is required for telegram targets' }) } } else if (target.type === 'http' || target.type === 'discord' || target.type === 'slack') { if (typeof target.url !== 'string' || !target.url.trim()) { issues.push({ path: `${prefix}.url`, message: 'url is required for webhook targets' }) } else { try { const parsed = new URL(target.url) if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { issues.push({ path: `${prefix}.url`, message: 'url must use http or https' }) } } catch { issues.push({ path: `${prefix}.url`, message: 'url must be a valid URL' }) } } } } return issues }