From 8608b26e6ff629440ebbe716787f3ae914efb3d5 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Wed, 8 Jul 2026 11:57:00 +0530 Subject: [PATCH 1/2] feat(admin): extract shared settings-config module for CLI and admin UI Signed-off-by: ABHAY PANDEY --- .changeset/shared-settings-config-module.md | 5 + src/cli/commands/config.ts | 16 +- src/cli/tui/menus/configure.ts | 130 +----- src/cli/utils/config.ts | 440 +------------------- src/cli/utils/paths.ts | 12 +- src/utils/settings-config.ts | 439 +++++++++++++++++++ src/utils/settings-guided-schema.ts | 121 ++++++ test/unit/utils/settings-config.spec.ts | 114 +++++ 8 files changed, 720 insertions(+), 557 deletions(-) create mode 100644 .changeset/shared-settings-config-module.md create mode 100644 src/utils/settings-config.ts create mode 100644 src/utils/settings-guided-schema.ts create mode 100644 test/unit/utils/settings-config.spec.ts diff --git a/.changeset/shared-settings-config-module.md b/.changeset/shared-settings-config-module.md new file mode 100644 index 00000000..f8743f7a --- /dev/null +++ b/.changeset/shared-settings-config-module.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +refactor: extract shared settings-config module and guided schema for admin settings editor foundation diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 0a7812fd..c47426cf 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -2,8 +2,9 @@ import ora from 'ora' import yaml from 'js-yaml' import { + formatSettingCategoryLabel, getByPath, - loadDefaults, + getTopLevelSettingCategories, loadMergedSettings, loadUserSettings, parseTypedValue, @@ -11,7 +12,7 @@ import { setByPath, validatePathAgainstDefaults, validateSettings, -} from '../utils/config' +} from '../../utils/settings-config' import { isSecretEnvKey, isSupportedEnvKey, @@ -57,13 +58,7 @@ const serialize = (value: unknown): string => { return yaml.dump(value, { lineWidth: 120 }).trimEnd() } -const formatLabel = (key: string): string => { - return key - .split(/[_\-.]/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' ') -} +const formatLabel = formatSettingCategoryLabel const restartRelay = async (): Promise => { const spinner = ora('Restarting relay...').start() @@ -257,6 +252,5 @@ export const runConfigEnvValidate = async (): Promise => { } export const getConfigTopLevelCategories = (): string[] => { - const defaults = loadDefaults() as unknown as Record - return Object.keys(defaults) + return getTopLevelSettingCategories() } diff --git a/src/cli/tui/menus/configure.ts b/src/cli/tui/menus/configure.ts index 8bd13703..201750ca 100644 --- a/src/cli/tui/menus/configure.ts +++ b/src/cli/tui/menus/configure.ts @@ -5,16 +5,14 @@ import { runConfigSet, runConfigValidate, } from '../../commands/config' -import { getByPath, loadMergedSettings } from '../../utils/config' +import { formatSettingCategoryLabel, getByPath, loadMergedSettings } from '../../../utils/settings-config' +import { + type GuidedSettingField, + guidedSettingCategories, +} from '../../../utils/settings-guided-schema' import { tuiPrompts } from '../prompts' -const toCategoryLabel = (key: string): string => { - return key - .split(/[_\-.]/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' ') -} +const toCategoryLabel = formatSettingCategoryLabel const getCategoryOptions = () => { const categories = getConfigTopLevelCategories().sort((a, b) => a.localeCompare(b)) @@ -28,116 +26,6 @@ const getCategoryOptions = () => { ] } -type GuidedSetting = { - label: string - path: string - type: 'boolean' | 'number' | 'string' | 'select' | 'stringArray' - options?: string[] - placeholder?: string - validate?: (value: string) => string | undefined -} - -type GuidedCategory = { - value: string - label: string - settings: GuidedSetting[] -} - -const requireNonEmpty = (value: string): string | undefined => { - return value.trim() ? undefined : 'Value is required' -} - -const requireSafeNonNegativeInteger = (value: string): string | undefined => { - const trimmed = value.trim() - if (!/^\d+$/.test(trimmed)) { - return 'Value must be a non-negative integer' - } - - const parsed = Number(trimmed) - if (!Number.isSafeInteger(parsed)) { - return 'Value must be a safe integer' - } - - return undefined -} - -const guidedCategories: GuidedCategory[] = [ - { - value: 'payments', - label: 'Payments', - settings: [ - { label: 'Enable payments', path: 'payments.enabled', type: 'boolean' }, - { - label: 'Payment processor', - path: 'payments.processor', - type: 'select', - options: ['zebedee', 'lnbits', 'lnurl', 'nodeless', 'opennode'], - }, - { - label: 'Admission fee enabled', - path: 'payments.feeSchedules.admission[0].enabled', - type: 'boolean', - }, - { - label: 'Admission fee amount (msats)', - path: 'payments.feeSchedules.admission[0].amount', - type: 'number', - validate: requireSafeNonNegativeInteger, - }, - ], - }, - { - value: 'network', - label: 'Network', - settings: [ - { - label: 'Relay URL', - path: 'info.relay_url', - type: 'string', - placeholder: 'wss://relay.example.com', - validate: requireNonEmpty, - }, - { - label: 'Relay name', - path: 'info.name', - type: 'string', - placeholder: 'relay.example.com', - validate: requireNonEmpty, - }, - { - label: 'Max payload size', - path: 'network.maxPayloadSize', - type: 'number', - validate: requireSafeNonNegativeInteger, - }, - ], - }, - { - value: 'limits', - label: 'Limits', - settings: [ - { - label: 'Rate limiter strategy', - path: 'limits.rateLimiter.strategy', - type: 'select', - options: ['ewma', 'sliding_window'], - }, - { - label: 'Primary event content max length', - path: 'limits.event.content[0].maxLength', - type: 'number', - validate: requireSafeNonNegativeInteger, - }, - { - label: 'Minimum pubkey balance', - path: 'limits.event.pubkey.minBalance', - type: 'number', - validate: requireSafeNonNegativeInteger, - }, - ], - }, -] - const formatCurrentValue = (value: unknown): string => { if (Array.isArray(value)) { return value.length === 0 ? '[]' : value.join(', ') @@ -162,7 +50,7 @@ const formatCurrentValue = (value: unknown): string => { return String(value) } -const getGuidedSettingValue = async (setting: GuidedSetting, currentValue: unknown) => { +const getGuidedSettingValue = async (setting: GuidedSettingField, currentValue: unknown) => { switch (setting.type) { case 'boolean': { const answer = await tuiPrompts.confirm({ @@ -248,7 +136,7 @@ const getGuidedSettingValue = async (setting: GuidedSetting, currentValue: unkno const runGuidedConfigureMenu = async (): Promise => { const category = await tuiPrompts.select({ message: 'Configuration category', - options: [...guidedCategories.map(({ value, label }) => ({ value, label })), { value: 'back', label: 'Back' }], + options: [...guidedSettingCategories.map(({ value, label }) => ({ value, label })), { value: 'back', label: 'Back' }], }) if (tuiPrompts.isCancel(category)) { @@ -259,7 +147,7 @@ const runGuidedConfigureMenu = async (): Promise => { return 0 } - const selectedCategory = guidedCategories.find((entry) => entry.value === category) + const selectedCategory = guidedSettingCategories.find((entry) => entry.value === category) if (!selectedCategory) { tuiPrompts.cancel('Unknown category') return 1 diff --git a/src/cli/utils/config.ts b/src/cli/utils/config.ts index 33cb768d..b6b58618 100644 --- a/src/cli/utils/config.ts +++ b/src/cli/utils/config.ts @@ -1,420 +1,20 @@ -import fs from 'fs' -import yaml from 'js-yaml' -import { mergeDeepRight } from 'ramda' - -import { Settings } from '../../@types/settings' -import { getConfigBaseDir, getDefaultSettingsFilePath, getSettingsFilePath } from './paths' - -export type ValidationIssue = { - path: string - message: string -} - -type PathToken = - | { - type: 'key' - key: string - } - | { - type: 'index' - index: number - } - -const isPlainObject = (value: unknown): value is Record => { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -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}`) - } - - tokens.push({ type: 'key', key: match[1] }) - - 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 (!(key in schema)) { - issues.push({ - path: formatPathTokens([...path, { type: 'key', key }]), - message: 'Unknown setting key', - }) - } - } - - for (const key of Object.keys(schema)) { - validateShape((schema as Record)[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) || !(token.key in current)) { - return false - } - current = (current as Record)[token.key] - continue - } - - if (!Array.isArray(current)) { - return false - } - - current = current[0] - } - - return true -} - -export const ensureSettingsExists = (): void => { - const configDir = getConfigBaseDir() - const settingsPath = getSettingsFilePath() - const defaultsPath = getDefaultSettingsFilePath() - - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }) - } - - if (!fs.existsSync(settingsPath)) { - fs.copyFileSync(defaultsPath, settingsPath) - } -} - -export const loadDefaults = (): Settings => { - const defaultsRaw = fs.readFileSync(getDefaultSettingsFilePath(), 'utf-8') - return yaml.load(defaultsRaw) as Settings -} - -export const loadUserSettings = (): Settings => { - ensureSettingsExists() - const raw = fs.readFileSync(getSettingsFilePath(), 'utf-8') - return (yaml.load(raw) as Settings) ?? ({} as Settings) -} - -export const loadMergedSettings = (): Settings => { - return mergeDeepRight(loadDefaults(), loadUserSettings()) as Settings -} - -export const saveSettings = (settings: Settings): void => { - ensureSettingsExists() - const serialized = yaml.dump(toSerializable(settings), { lineWidth: 120 }) - fs.writeFileSync(getSettingsFilePath(), serialized, 'utf-8') -} - -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', - }, - ] -} - -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' }) - } - - 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' }) - } - - validateShape(loadDefaults(), settings, [], issues) - - return issues -} +export { + ensureSettingsExists, + formatSettingCategoryLabel, + getByPath, + getConfigBaseDir, + getDefaultSettingsFilePath, + getSettingsFilePath, + getTopLevelSettingCategories, + loadDefaults, + loadMergedSettings, + loadUserSettings, + parseTypedValue, + parseValue, + saveSettings, + setByPath, + validatePathAgainstDefaults, + validateSettings, +} from '../../utils/settings-config' + +export type { ValidationIssue } from '../../utils/settings-config' diff --git a/src/cli/utils/paths.ts b/src/cli/utils/paths.ts index 33f2673d..d44432b0 100644 --- a/src/cli/utils/paths.ts +++ b/src/cli/utils/paths.ts @@ -1,13 +1,15 @@ import { join } from 'path' +import { + getConfigBaseDir, + getDefaultSettingsFilePath, + getSettingsFilePath, +} from '../../utils/settings-config' + export const getProjectRoot = (): string => process.cwd() export const getProjectPath = (...parts: string[]): string => join(getProjectRoot(), ...parts) -export const getConfigBaseDir = (): string => process.env.NOSTR_CONFIG_DIR ?? getProjectPath('.nostr') - -export const getSettingsFilePath = (): string => join(getConfigBaseDir(), 'settings.yaml') - -export const getDefaultSettingsFilePath = (): string => getProjectPath('resources', 'default-settings.yaml') +export { getConfigBaseDir, getDefaultSettingsFilePath, getSettingsFilePath } export const getEnvFilePath = (): string => getProjectPath('.env') diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts new file mode 100644 index 00000000..56dfc9a8 --- /dev/null +++ b/src/utils/settings-config.ts @@ -0,0 +1,439 @@ +import fs from 'fs' +import { join } from 'path' +import yaml from 'js-yaml' +import { mergeDeepRight } from 'ramda' + +import { Settings } from '../@types/settings' + +export type ValidationIssue = { + path: string + message: string +} + +type PathToken = + | { + type: 'key' + key: string + } + | { + type: 'index' + index: number + } + +export const getConfigBaseDir = (): string => process.env.NOSTR_CONFIG_DIR ?? join(process.cwd(), '.nostr') + +export const getSettingsFilePath = (): string => join(getConfigBaseDir(), 'settings.yaml') + +export const getDefaultSettingsFilePath = (): string => join(process.cwd(), 'resources', 'default-settings.yaml') + +export const formatSettingCategoryLabel = (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 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}`) + } + + tokens.push({ type: 'key', key: match[1] }) + + 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 (!(key in schema)) { + issues.push({ + path: formatPathTokens([...path, { type: 'key', key }]), + message: 'Unknown setting key', + }) + } + } + + for (const key of Object.keys(schema)) { + validateShape((schema as Record)[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) || !(token.key in current)) { + return false + } + current = (current as Record)[token.key] + continue + } + + if (!Array.isArray(current)) { + return false + } + + current = current[0] + } + + return true +} + +export const ensureSettingsExists = (): void => { + const configDir = getConfigBaseDir() + const settingsPath = getSettingsFilePath() + const defaultsPath = getDefaultSettingsFilePath() + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }) + } + + if (!fs.existsSync(settingsPath)) { + fs.copyFileSync(defaultsPath, settingsPath) + } +} + +export const loadDefaults = (): Settings => { + const defaultsRaw = fs.readFileSync(getDefaultSettingsFilePath(), 'utf-8') + return yaml.load(defaultsRaw) as Settings +} + +export const loadUserSettings = (): Settings => { + ensureSettingsExists() + const raw = fs.readFileSync(getSettingsFilePath(), 'utf-8') + return (yaml.load(raw) as Settings) ?? ({} as Settings) +} + +export const loadMergedSettings = (): Settings => { + return mergeDeepRight(loadDefaults(), loadUserSettings()) as Settings +} + +export const saveSettings = (settings: Settings): void => { + ensureSettingsExists() + const serialized = yaml.dump(toSerializable(settings), { lineWidth: 120 }) + fs.writeFileSync(getSettingsFilePath(), serialized, 'utf-8') +} + +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', + }, + ] +} + +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' }) + } + + 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' }) + } + + validateShape(loadDefaults(), settings, [], issues) + + return issues +} diff --git a/src/utils/settings-guided-schema.ts b/src/utils/settings-guided-schema.ts new file mode 100644 index 00000000..79011dc3 --- /dev/null +++ b/src/utils/settings-guided-schema.ts @@ -0,0 +1,121 @@ +export type GuidedSettingFieldType = 'boolean' | 'number' | 'string' | 'select' | 'stringArray' + +export type GuidedSettingFieldValidator = (value: string) => string | undefined + +export type GuidedSettingField = { + label: string + path: string + type: GuidedSettingFieldType + options?: string[] + placeholder?: string + validate?: GuidedSettingFieldValidator +} + +export type GuidedSettingCategory = { + value: string + label: string + settings: GuidedSettingField[] +} + +export const requireNonEmptySettingValue = (value: string): string | undefined => { + return value.trim() ? undefined : 'Value is required' +} + +export const requireSafeNonNegativeIntegerSettingValue = (value: string): string | undefined => { + const trimmed = value.trim() + if (!/^\d+$/.test(trimmed)) { + return 'Value must be a non-negative integer' + } + + const parsed = Number(trimmed) + if (!Number.isSafeInteger(parsed)) { + return 'Value must be a safe integer' + } + + return undefined +} + +export const guidedSettingCategories: GuidedSettingCategory[] = [ + { + value: 'payments', + label: 'Payments', + settings: [ + { label: 'Enable payments', path: 'payments.enabled', type: 'boolean' }, + { + label: 'Payment processor', + path: 'payments.processor', + type: 'select', + options: ['zebedee', 'lnbits', 'lnurl', 'nodeless', 'opennode'], + }, + { + label: 'Admission fee enabled', + path: 'payments.feeSchedules.admission[0].enabled', + type: 'boolean', + }, + { + label: 'Admission fee amount (msats)', + path: 'payments.feeSchedules.admission[0].amount', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + ], + }, + { + value: 'network', + label: 'Network', + settings: [ + { + label: 'Relay URL', + path: 'info.relay_url', + type: 'string', + placeholder: 'wss://relay.example.com', + validate: requireNonEmptySettingValue, + }, + { + label: 'Relay name', + path: 'info.name', + type: 'string', + placeholder: 'relay.example.com', + validate: requireNonEmptySettingValue, + }, + { + label: 'Max payload size', + path: 'network.maxPayloadSize', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + ], + }, + { + value: 'limits', + label: 'Limits', + settings: [ + { + label: 'Rate limiter strategy', + path: 'limits.rateLimiter.strategy', + type: 'select', + options: ['ewma', 'sliding_window'], + }, + { + label: 'Primary event content max length', + path: 'limits.event.content[0].maxLength', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + { + label: 'Minimum pubkey balance', + path: 'limits.event.pubkey.minBalance', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + ], + }, +] + +export const getGuidedSettingCategory = (value: string): GuidedSettingCategory | undefined => { + return guidedSettingCategories.find((entry) => entry.value === value) +} + +export const getGuidedSettingField = (categoryValue: string, path: string): GuidedSettingField | undefined => { + return getGuidedSettingCategory(categoryValue)?.settings.find((entry) => entry.path === path) +} diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts new file mode 100644 index 00000000..764198d2 --- /dev/null +++ b/test/unit/utils/settings-config.spec.ts @@ -0,0 +1,114 @@ +import { expect } from 'chai' + +import { + formatSettingCategoryLabel, + getByPath, + getTopLevelSettingCategories, + parseTypedValue, + parseValue, + setByPath, + validatePathAgainstDefaults, + validateSettings, +} from '../../../src/utils/settings-config' +import { + getGuidedSettingCategory, + getGuidedSettingField, + guidedSettingCategories, + requireNonEmptySettingValue, + requireSafeNonNegativeIntegerSettingValue, +} from '../../../src/utils/settings-guided-schema' + +describe('settings-config', () => { + it('parses primitive values', () => { + expect(parseValue('true')).to.equal(true) + expect(parseValue('false')).to.equal(false) + expect(parseValue('42')).to.equal(42) + expect(parseValue('42n')).to.equal(42n) + expect(parseValue('null')).to.equal(null) + expect(parseValue('hello')).to.equal('hello') + }) + + it('parses typed json values', () => { + expect(parseTypedValue('{"enabled":true}', 'json')).to.deep.equal({ enabled: true }) + expect(parseTypedValue('[1,2,3]', 'json')).to.deep.equal([1, 2, 3]) + expect(() => parseTypedValue('{', 'json')).to.throw('Invalid JSON value') + }) + + it('sets and gets dot-path values', () => { + const input = { + payments: { + enabled: false, + }, + } + + const updated = setByPath(input as any, 'payments.enabled', true) + + expect(getByPath(updated, 'payments.enabled')).to.equal(true) + expect(getByPath(updated, 'payments')).to.deep.equal({ enabled: true }) + expect(getByPath(updated, 'payments.processor')).to.equal(undefined) + }) + + it('supports indexed path syntax', () => { + const input = { + limits: { + event: { + content: [ + { + maxLength: 100, + }, + ], + }, + }, + } + + const updated = setByPath(input as any, 'limits.event.content[0].maxLength', 500) + + expect(getByPath(updated, 'limits.event.content[0].maxLength')).to.equal(500) + }) + + it('rejects malformed path syntax', () => { + expect(() => setByPath({} as any, 'payments[]', true)).to.throw('Invalid path segment') + }) + + it('validates known paths against defaults', () => { + expect(validatePathAgainstDefaults('payments.enabled')).to.deep.equal([]) + expect(validatePathAgainstDefaults('limits.event.content[0].maxLength')).to.deep.equal([]) + + const issues = validatePathAgainstDefaults('payments.fakeField') + expect(issues[0].message).to.include('does not exist') + }) + + it('validates basic required fields', () => { + const issues = validateSettings({} as any) + + expect(issues.some((issue) => issue.path === 'info.relay_url')).to.equal(true) + expect(issues.some((issue) => issue.path === 'network')).to.equal(true) + }) + + it('formats setting category labels', () => { + expect(formatSettingCategoryLabel('payments_processors')).to.equal('Payments Processors') + expect(formatSettingCategoryLabel('rate_limiter')).to.equal('Rate Limiter') + }) + + it('lists top-level categories from defaults', () => { + const categories = getTopLevelSettingCategories() + + expect(categories).to.include('payments') + expect(categories).to.include('network') + expect(categories).to.include('info') + }) +}) + +describe('settings-guided-schema', () => { + it('exports guided categories for admin and CLI use', () => { + expect(guidedSettingCategories.length).to.be.greaterThan(0) + expect(getGuidedSettingCategory('payments')?.settings.some((entry) => entry.path === 'payments.enabled')).to.equal(true) + expect(getGuidedSettingField('limits', 'limits.rateLimiter.strategy')?.options).to.deep.equal(['ewma', 'sliding_window']) + }) + + it('validates guided numeric fields', () => { + expect(requireSafeNonNegativeIntegerSettingValue('bad')).to.equal('Value must be a non-negative integer') + expect(requireSafeNonNegativeIntegerSettingValue('2048')).to.equal(undefined) + expect(requireNonEmptySettingValue(' ')).to.equal('Value is required') + }) +}) From 89f09b969802a5b32d65fbc476987f9627825e04 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Wed, 8 Jul 2026 16:55:37 +0530 Subject: [PATCH 2/2] fix(settings-config): harden path parsing against prototype pollution Signed-off-by: ABHAY PANDEY --- src/utils/settings-config.ts | 25 ++++++++++++++++++++----- test/unit/utils/settings-config.spec.ts | 7 +++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index 56dfc9a8..2cf400e6 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -38,6 +38,18 @@ 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}`) + } +} + +const hasOwn = (object: Record, key: string): boolean => { + return Object.prototype.hasOwnProperty.call(object, key) +} + const parsePath = (path: string): PathToken[] => { const input = path.trim() @@ -58,7 +70,10 @@ const parsePath = (path: string): PathToken[] => { throw new Error(`Invalid path segment: ${segment}`) } - tokens.push({ type: 'key', key: match[1] }) + const key = match[1] + assertSafePathKey(key, segment) + + tokens.push({ type: 'key', key }) const indexes = [...segment.matchAll(/\[(\d+)\]/g)] for (const entry of indexes) { @@ -200,7 +215,7 @@ const validateShape = (schema: unknown, candidate: unknown, path: PathToken[], i } for (const key of Object.keys(candidate)) { - if (!(key in schema)) { + if (!hasOwn(schema, key)) { issues.push({ path: formatPathTokens([...path, { type: 'key', key }]), message: 'Unknown setting key', @@ -209,7 +224,7 @@ const validateShape = (schema: unknown, candidate: unknown, path: PathToken[], i } for (const key of Object.keys(schema)) { - validateShape((schema as Record)[key], (candidate as Record)[key], [...path, { type: 'key', key }], issues) + validateShape(schema[key], (candidate as Record)[key], [...path, { type: 'key', key }], issues) } return @@ -236,10 +251,10 @@ const pathExistsInSchema = (schema: unknown, tokens: PathToken[]): boolean => { for (const token of tokens) { if (token.type === 'key') { - if (!isPlainObject(current) || !(token.key in current)) { + if (!isPlainObject(current) || !hasOwn(current, token.key)) { return false } - current = (current as Record)[token.key] + current = current[token.key] continue } diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index 764198d2..5f9da8a1 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -70,6 +70,13 @@ describe('settings-config', () => { expect(() => setByPath({} as any, 'payments[]', true)).to.throw('Invalid path segment') }) + it('rejects reserved prototype-pollution path keys', () => { + for (const path of ['__proto__.enabled', 'constructor.enabled', 'prototype.enabled']) { + expect(() => setByPath({ payments: { enabled: false } } as any, path, true)).to.throw('Invalid path segment') + expect(() => getByPath({ payments: { enabled: false } }, path)).to.throw('Invalid path segment') + } + }) + it('validates known paths against defaults', () => { expect(validatePathAgainstDefaults('payments.enabled')).to.deep.equal([]) expect(validatePathAgainstDefaults('limits.event.content[0].maxLength')).to.deep.equal([])