From 8608b26e6ff629440ebbe716787f3ae914efb3d5 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Wed, 8 Jul 2026 11:57:00 +0530 Subject: [PATCH 1/4] 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/4] 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([]) From cc5c7b38e251d10a44f003f2b67c4cb97ab85536 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Thu, 16 Jul 2026 11:34:52 +0530 Subject: [PATCH 3/4] feat(admin): add authenticated settings API endpoints Signed-off-by: ABHAY PANDEY --- .changeset/admin-settings-api.md | 5 + .../admin/get-settings-controller.ts | 13 + .../admin/get-settings-schema-controller.ts | 10 + .../admin/patch-settings-controller.ts | 76 +++++ .../post-settings-validate-controller.ts | 17 ++ .../get-admin-settings-controller-factory.ts | 6 + ...dmin-settings-schema-controller-factory.ts | 6 + ...patch-admin-settings-controller-factory.ts | 6 + ...in-settings-validate-controller-factory.ts | 6 + src/routes/admin/index.ts | 18 ++ src/schemas/admin-settings-schema.ts | 8 + src/utils/settings-redaction.ts | 52 ++++ test/unit/routes/admin-settings.spec.ts | 266 ++++++++++++++++++ test/unit/utils/settings-redaction.spec.ts | 60 ++++ 14 files changed, 549 insertions(+) create mode 100644 .changeset/admin-settings-api.md create mode 100644 src/controllers/admin/get-settings-controller.ts create mode 100644 src/controllers/admin/get-settings-schema-controller.ts create mode 100644 src/controllers/admin/patch-settings-controller.ts create mode 100644 src/controllers/admin/post-settings-validate-controller.ts create mode 100644 src/factories/controllers/get-admin-settings-controller-factory.ts create mode 100644 src/factories/controllers/get-admin-settings-schema-controller-factory.ts create mode 100644 src/factories/controllers/patch-admin-settings-controller-factory.ts create mode 100644 src/factories/controllers/post-admin-settings-validate-controller-factory.ts create mode 100644 src/schemas/admin-settings-schema.ts create mode 100644 src/utils/settings-redaction.ts create mode 100644 test/unit/routes/admin-settings.spec.ts create mode 100644 test/unit/utils/settings-redaction.spec.ts diff --git a/.changeset/admin-settings-api.md b/.changeset/admin-settings-api.md new file mode 100644 index 00000000..9fbb142a --- /dev/null +++ b/.changeset/admin-settings-api.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: add authenticated admin settings API endpoints diff --git a/src/controllers/admin/get-settings-controller.ts b/src/controllers/admin/get-settings-controller.ts new file mode 100644 index 00000000..2684842d --- /dev/null +++ b/src/controllers/admin/get-settings-controller.ts @@ -0,0 +1,13 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { loadMergedSettings } from '../../utils/settings-config' +import { redactSettingsSecrets } from '../../utils/settings-redaction' + +export class GetAdminSettingsController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + const settings = redactSettingsSecrets(loadMergedSettings()) + + response.status(200).setHeader('content-type', 'application/json').send({ settings }) + } +} diff --git a/src/controllers/admin/get-settings-schema-controller.ts b/src/controllers/admin/get-settings-schema-controller.ts new file mode 100644 index 00000000..a12d7be0 --- /dev/null +++ b/src/controllers/admin/get-settings-schema-controller.ts @@ -0,0 +1,10 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { guidedSettingCategories } from '../../utils/settings-guided-schema' + +export class GetAdminSettingsSchemaController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + response.status(200).setHeader('content-type', 'application/json').send({ categories: guidedSettingCategories }) + } +} diff --git a/src/controllers/admin/patch-settings-controller.ts b/src/controllers/admin/patch-settings-controller.ts new file mode 100644 index 00000000..0f409980 --- /dev/null +++ b/src/controllers/admin/patch-settings-controller.ts @@ -0,0 +1,76 @@ +import { Request, Response } from 'express' + +import { Settings } from '../../@types/settings' +import { IController } from '../../@types/controllers' +import { adminSettingsPatchBodySchema } from '../../schemas/admin-settings-schema' +import { + getByPath, + loadMergedSettings, + loadUserSettings, + saveSettings, + setByPath, + validatePathAgainstDefaults, + validateSettings, +} from '../../utils/settings-config' +import { + isWriteProtectedSettingsPath, + redactSettingsValue, +} from '../../utils/settings-redaction' +import { validateSchema } from '../../utils/validation' + +export class PatchAdminSettingsController implements IController { + public async handleRequest(request: Request, response: Response): Promise { + const validation = validateSchema(adminSettingsPatchBodySchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) + return + } + + const { path, value } = validation.value + + if (isWriteProtectedSettingsPath(path)) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ + error: 'Validation failed', + issues: [{ path, message: 'Path is write-protected' }], + }) + return + } + + const pathIssues = validatePathAgainstDefaults(path) + if (pathIssues.length > 0) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: 'Validation failed', issues: pathIssues }) + return + } + + const userSettings = loadUserSettings() as unknown as Record + const nextUserSettings = setByPath(userSettings, path, value) + + const merged = loadMergedSettings() as unknown as Record + const mergedNext = setByPath(merged, path, getByPath(nextUserSettings, path)) + const validationIssues = validateSettings(mergedNext as unknown as Settings) + + if (validationIssues.length > 0) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: 'Validation failed', issues: validationIssues }) + return + } + + saveSettings(nextUserSettings as unknown as Settings) + + const updatedValue = redactSettingsValue(path, getByPath(nextUserSettings, path)) + + response.status(200).setHeader('content-type', 'application/json').send({ + ok: true, + path, + value: updatedValue, + }) + } +} diff --git a/src/controllers/admin/post-settings-validate-controller.ts b/src/controllers/admin/post-settings-validate-controller.ts new file mode 100644 index 00000000..3f486746 --- /dev/null +++ b/src/controllers/admin/post-settings-validate-controller.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { loadMergedSettings, validateSettings } from '../../utils/settings-config' + +export class PostAdminSettingsValidateController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + const issues = validateSettings(loadMergedSettings()) + + if (issues.length === 0) { + response.status(200).setHeader('content-type', 'application/json').send({ valid: true, issues: [] }) + return + } + + response.status(200).setHeader('content-type', 'application/json').send({ valid: false, issues }) + } +} diff --git a/src/factories/controllers/get-admin-settings-controller-factory.ts b/src/factories/controllers/get-admin-settings-controller-factory.ts new file mode 100644 index 00000000..344370f4 --- /dev/null +++ b/src/factories/controllers/get-admin-settings-controller-factory.ts @@ -0,0 +1,6 @@ +import { GetAdminSettingsController } from '../../controllers/admin/get-settings-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminSettingsController = (): IController => { + return new GetAdminSettingsController() +} diff --git a/src/factories/controllers/get-admin-settings-schema-controller-factory.ts b/src/factories/controllers/get-admin-settings-schema-controller-factory.ts new file mode 100644 index 00000000..8bb1afd5 --- /dev/null +++ b/src/factories/controllers/get-admin-settings-schema-controller-factory.ts @@ -0,0 +1,6 @@ +import { GetAdminSettingsSchemaController } from '../../controllers/admin/get-settings-schema-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminSettingsSchemaController = (): IController => { + return new GetAdminSettingsSchemaController() +} diff --git a/src/factories/controllers/patch-admin-settings-controller-factory.ts b/src/factories/controllers/patch-admin-settings-controller-factory.ts new file mode 100644 index 00000000..09838d5b --- /dev/null +++ b/src/factories/controllers/patch-admin-settings-controller-factory.ts @@ -0,0 +1,6 @@ +import { PatchAdminSettingsController } from '../../controllers/admin/patch-settings-controller' +import { IController } from '../../@types/controllers' + +export const createPatchAdminSettingsController = (): IController => { + return new PatchAdminSettingsController() +} diff --git a/src/factories/controllers/post-admin-settings-validate-controller-factory.ts b/src/factories/controllers/post-admin-settings-validate-controller-factory.ts new file mode 100644 index 00000000..3d3d6999 --- /dev/null +++ b/src/factories/controllers/post-admin-settings-validate-controller-factory.ts @@ -0,0 +1,6 @@ +import { PostAdminSettingsValidateController } from '../../controllers/admin/post-settings-validate-controller' +import { IController } from '../../@types/controllers' + +export const createPostAdminSettingsValidateController = (): IController => { + return new PostAdminSettingsValidateController() +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 8349906a..f00ef280 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -3,7 +3,11 @@ import { json, Router } from 'express' import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory' import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory' +import { createGetAdminSettingsController } from '../../factories/controllers/get-admin-settings-controller-factory' +import { createGetAdminSettingsSchemaController } from '../../factories/controllers/get-admin-settings-schema-controller-factory' +import { createPatchAdminSettingsController } from '../../factories/controllers/patch-admin-settings-controller-factory' import { createPostAdminLoginController } from '../../factories/controllers/post-admin-login-controller-factory' +import { createPostAdminSettingsValidateController } from '../../factories/controllers/post-admin-settings-validate-controller-factory' import { adminAuthMiddleware } from '../../handlers/request-handlers/admin-auth-middleware' import { adminEnabledMiddleware } from '../../handlers/request-handlers/admin-enabled-middleware' import { @@ -23,5 +27,19 @@ router.post('/login', adminLoginRateLimitMiddleware, json(), withAdminController router.get('/session', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSessionController)) router.get('/health', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminHealthController)) router.get('/metrics', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminMetricsController)) +router.get('/settings', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSettingsController)) +router.get( + '/settings/schema', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminSettingsSchemaController), +) +router.patch('/settings', adminRateLimitMiddleware, adminAuthMiddleware, json(), withAdminController(createPatchAdminSettingsController)) +router.post( + '/settings/validate', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createPostAdminSettingsValidateController), +) export default router diff --git a/src/schemas/admin-settings-schema.ts b/src/schemas/admin-settings-schema.ts new file mode 100644 index 00000000..b6c28d28 --- /dev/null +++ b/src/schemas/admin-settings-schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const adminSettingsPatchBodySchema = z + .object({ + path: z.string().min(1), + value: z.custom((input) => input !== undefined, { message: 'value is required' }), + }) + .strict() diff --git a/src/utils/settings-redaction.ts b/src/utils/settings-redaction.ts new file mode 100644 index 00000000..3546786d --- /dev/null +++ b/src/utils/settings-redaction.ts @@ -0,0 +1,52 @@ +const SENSITIVE_SETTING_KEYS = new Set(['passwordHash', 'secret']) + +const isPlainObject = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export const isSensitiveSettingsPath = (path: string): boolean => { + const segments = path.split('.') + const lastSegment = segments[segments.length - 1] ?? '' + const key = lastSegment.replace(/\[\d+\]$/, '') + + return SENSITIVE_SETTING_KEYS.has(key) +} + +export const isWriteProtectedSettingsPath = (path: string): boolean => { + return path === 'admin.passwordHash' || path.endsWith('.passwordHash') +} + +export const redactSettingsValue = (path: string, value: unknown): unknown => { + if (isSensitiveSettingsPath(path) && typeof value === 'string' && value.length > 0) { + return '***' + } + + return value +} + +export const redactSettingsSecrets = (settings: T): T => { + const redactWalk = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(redactWalk) + } + + if (!isPlainObject(value)) { + return value + } + + const result: Record = {} + + for (const [key, entry] of Object.entries(value)) { + if (SENSITIVE_SETTING_KEYS.has(key) && typeof entry === 'string' && entry.length > 0) { + result[key] = '***' + continue + } + + result[key] = redactWalk(entry) + } + + return result + } + + return redactWalk(settings) as T +} diff --git a/test/unit/routes/admin-settings.spec.ts b/test/unit/routes/admin-settings.spec.ts new file mode 100644 index 00000000..16284dc0 --- /dev/null +++ b/test/unit/routes/admin-settings.spec.ts @@ -0,0 +1,266 @@ +import axios from 'axios' +import { expect } from 'chai' +import express from 'express' +import fs from 'fs' +import os from 'os' +import path from 'path' +import Sinon from 'sinon' + +import { hashAdminPassword } from '../../../src/utils/admin-password' +import * as adminRateLimitMiddleware from '../../../src/handlers/request-handlers/admin-rate-limit-middleware' +import * as rateLimiterMiddleware from '../../../src/handlers/request-handlers/rate-limiter-middleware' +import * as settingsFactory from '../../../src/factories/settings-factory' +import { getSettingsFilePath, loadDefaults, saveSettings } from '../../../src/utils/settings-config' + +describe('admin settings API', () => { + const originalSecret = process.env.SECRET + const originalAdminPassword = process.env.ADMIN_PASSWORD + const originalConfigDir = process.env.NOSTR_CONFIG_DIR + let configDir: string + let createSettingsStub: Sinon.SinonStub + let rateLimiterMiddlewareStub: Sinon.SinonStub + let adminRateLimitMiddlewareStub: Sinon.SinonStub + let adminLoginRateLimitMiddlewareStub: Sinon.SinonStub + let server: any + + const loadAdminRouter = () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + delete require.cache[require.resolve('../../../src/routes/admin/index')] + // eslint-disable-next-line @typescript-eslint/no-var-requires + delete require.cache[require.resolve('../../../src/routes/admin')] + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('../../../src/routes/admin').default + } + + const startServer = async (settings: Record) => { + createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns(settings as any) + const passthrough = async (_request: any, _response: any, next: any) => { + next() + } + rateLimiterMiddlewareStub = Sinon.stub(rateLimiterMiddleware, 'rateLimiterMiddleware').callsFake(passthrough) + adminRateLimitMiddlewareStub = Sinon.stub(adminRateLimitMiddleware, 'adminRateLimitMiddleware').callsFake( + passthrough, + ) + adminLoginRateLimitMiddlewareStub = Sinon.stub(adminRateLimitMiddleware, 'adminLoginRateLimitMiddleware').callsFake( + passthrough, + ) + const router = loadAdminRouter() + const app = express() + app.use('/admin', router) + + server = await new Promise((resolve) => { + const listeningServer = app.listen(0, () => resolve(listeningServer)) + }) + + return `http://127.0.0.1:${server.address().port}/admin` + } + + const stopServer = async () => { + createSettingsStub?.restore() + rateLimiterMiddlewareStub?.restore() + adminRateLimitMiddlewareStub?.restore() + adminLoginRateLimitMiddlewareStub?.restore() + delete require.cache[require.resolve('../../../src/routes/admin/index')] + delete require.cache[require.resolve('../../../src/routes/admin')] + + if (server) { + await new Promise((resolve, reject) => { + server.close((error: Error | undefined) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + server = undefined + } + } + + const login = async (baseUrl: string): Promise => { + const loginResponse = await axios.post( + `${baseUrl}/login`, + { password: 'settings-password' }, + { + headers: { 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(loginResponse.status).to.equal(200) + return loginResponse.headers['set-cookie']?.[0]?.split(';')[0] ?? '' + } + + before(() => { + process.env.SECRET = 'test-admin-secret-value' + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-admin-settings-')) + process.env.NOSTR_CONFIG_DIR = configDir + }) + + after(() => { + if (originalSecret === undefined) { + delete process.env.SECRET + } else { + process.env.SECRET = originalSecret + } + + if (originalAdminPassword === undefined) { + delete process.env.ADMIN_PASSWORD + } else { + process.env.ADMIN_PASSWORD = originalAdminPassword + } + + if (originalConfigDir === undefined) { + delete process.env.NOSTR_CONFIG_DIR + } else { + process.env.NOSTR_CONFIG_DIR = originalConfigDir + } + + fs.rmSync(configDir, { recursive: true, force: true }) + }) + + afterEach(async () => { + delete process.env.ADMIN_PASSWORD + fs.rmSync(getSettingsFilePath(), { force: true }) + await stopServer() + }) + + it('requires authentication for settings routes', async () => { + const baseUrl = await startServer({ admin: { enabled: true } }) + + const settingsResponse = await axios.get(`${baseUrl}/settings`, { validateStatus: () => true }) + const schemaResponse = await axios.get(`${baseUrl}/settings/schema`, { validateStatus: () => true }) + const validateResponse = await axios.post(`${baseUrl}/settings/validate`, {}, { validateStatus: () => true }) + const patchResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'payments.enabled', value: true }, + { + headers: { 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(settingsResponse.status).to.equal(401) + expect(schemaResponse.status).to.equal(401) + expect(validateResponse.status).to.equal(401) + expect(patchResponse.status).to.equal(401) + }) + + it('returns redacted merged settings and guided schema', async () => { + const passwordHash = hashAdminPassword('settings-password') + saveSettings({ + ...loadDefaults(), + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + mirroring: { + static: [{ address: 'wss://mirror.example', secret: 'mirror-secret-value' }], + }, + }) + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + const settingsResponse = await axios.get(`${baseUrl}/settings`, { + headers: { cookie }, + validateStatus: () => true, + }) + const schemaResponse = await axios.get(`${baseUrl}/settings/schema`, { + headers: { cookie }, + validateStatus: () => true, + }) + + expect(settingsResponse.status).to.equal(200) + expect(settingsResponse.data.settings.admin.passwordHash).to.equal('***') + expect(settingsResponse.data.settings.mirroring.static[0].secret).to.equal('***') + expect(settingsResponse.data.settings.info.name).to.be.a('string') + + expect(schemaResponse.status).to.equal(200) + expect(schemaResponse.data.categories.some((entry: { value: string }) => entry.value === 'payments')).to.equal(true) + }) + + it('patches a setting path with validation and persists to settings.yaml', async () => { + const passwordHash = hashAdminPassword('settings-password') + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + const patchResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'payments.enabled', value: true }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(patchResponse.status).to.equal(200) + expect(patchResponse.data).to.deep.equal({ ok: true, path: 'payments.enabled', value: true }) + + const settingsRaw = fs.readFileSync(getSettingsFilePath(), 'utf-8') + expect(settingsRaw).to.include('payments:') + expect(settingsRaw).to.include('enabled: true') + }) + + it('rejects invalid paths and write-protected settings', async () => { + const passwordHash = hashAdminPassword('settings-password') + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + const unknownPathResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'payments.fakeField', value: true }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + const protectedPathResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'admin.passwordHash', value: 'new-hash' }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + const invalidBodyResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'payments.enabled', unexpected: true }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(unknownPathResponse.status).to.equal(400) + expect(unknownPathResponse.data.error).to.equal('Validation failed') + expect(protectedPathResponse.status).to.equal(400) + expect(protectedPathResponse.data.issues[0].message).to.equal('Path is write-protected') + expect(invalidBodyResponse.status).to.equal(400) + expect(invalidBodyResponse.data.error).to.equal('Invalid request') + }) + + it('validates merged settings', async () => { + const passwordHash = hashAdminPassword('settings-password') + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + const validateResponse = await axios.post( + `${baseUrl}/settings/validate`, + {}, + { + headers: { cookie }, + validateStatus: () => true, + }, + ) + + expect(validateResponse.status).to.equal(200) + expect(validateResponse.data.valid).to.equal(true) + expect(validateResponse.data.issues).to.deep.equal([]) + }) +}) diff --git a/test/unit/utils/settings-redaction.spec.ts b/test/unit/utils/settings-redaction.spec.ts new file mode 100644 index 00000000..e140f903 --- /dev/null +++ b/test/unit/utils/settings-redaction.spec.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai' + +import { + isSensitiveSettingsPath, + isWriteProtectedSettingsPath, + redactSettingsSecrets, + redactSettingsValue, +} from '../../../src/utils/settings-redaction' + +describe('settings-redaction', () => { + it('redacts passwordHash and secret values in nested settings', () => { + const input = { + admin: { + enabled: true, + passwordHash: 'hashed-password-value', + }, + mirroring: { + static: [ + { + address: 'wss://mirror.example', + secret: 'mirror-secret-value', + }, + ], + }, + payments: { + enabled: false, + }, + } + + const redacted = redactSettingsSecrets(input) + + expect(redacted.admin.passwordHash).to.equal('***') + expect(redacted.mirroring.static[0].secret).to.equal('***') + expect(redacted.mirroring.static[0].address).to.equal('wss://mirror.example') + expect(redacted.payments.enabled).to.equal(false) + }) + + it('leaves empty secret values unchanged', () => { + const input = { + mirroring: { + static: [{ address: 'wss://mirror.example', secret: '' }], + }, + } + + expect(redactSettingsSecrets(input).mirroring.static[0].secret).to.equal('') + }) + + it('identifies sensitive and write-protected paths', () => { + expect(isSensitiveSettingsPath('admin.passwordHash')).to.equal(true) + expect(isSensitiveSettingsPath('mirroring.static[0].secret')).to.equal(true) + expect(isSensitiveSettingsPath('payments.enabled')).to.equal(false) + expect(isWriteProtectedSettingsPath('admin.passwordHash')).to.equal(true) + expect(isWriteProtectedSettingsPath('mirroring.static[0].secret')).to.equal(false) + }) + + it('redacts single values by path', () => { + expect(redactSettingsValue('mirroring.static[0].secret', 'top-secret')).to.equal('***') + expect(redactSettingsValue('payments.enabled', true)).to.equal(true) + }) +}) From 6bb00b8ada0896aceebc23cfedb77848d256e7c2 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Fri, 17 Jul 2026 20:52:00 +0530 Subject: [PATCH 4/4] feat: add admin settings editor with diff preview, atomic writes, and backups Signed-off-by: ABHAY PANDEY --- .changeset/admin-dashboard-ui.md | 5 + .changeset/admin-settings-ui.md | 5 + .env.example | 10 + docker-compose.yml | 24 + grafana/dashboards/nostream-overview.json | 763 +++++++++ grafana/provisioning/dashboards/default.yaml | 12 + .../provisioning/datasources/prometheus.yaml | 10 + resources/admin/assets/dashboard.css | 765 +++++++++ resources/admin/assets/dashboard.js | 1380 +++++++++++++++++ resources/admin/dashboard.html | 241 +++ src/@types/admin.ts | 1 + src/admin/password-admin-auth-provider.ts | 11 + src/app/app.ts | 2 +- .../admin/get-settings-backups-controller.ts | 10 + .../admin/patch-settings-controller.ts | 68 +- .../admin/post-logout-controller.ts | 12 + .../admin/post-settings-restore-controller.ts | 30 + ...min-settings-backups-controller-factory.ts | 6 + .../post-admin-logout-controller-factory.ts | 7 + ...min-settings-restore-controller-factory.ts | 6 + src/factories/web-app-factory.ts | 4 +- src/handlers/event-message-handler.ts | 9 +- .../get-admin-dashboard-request-handler.ts | 31 + src/routes/admin/index.ts | 25 +- src/schemas/admin-settings-schema.ts | 17 +- src/utils/admin-grafana.ts | 48 + src/utils/admin-health.ts | 32 +- src/utils/admin-session.ts | 7 + src/utils/settings-config.ts | 86 +- src/utils/settings-guided-schema.ts | 177 ++- src/utils/settings.ts | 9 +- test/unit/routes/admin-settings.spec.ts | 88 +- 32 files changed, 3845 insertions(+), 56 deletions(-) create mode 100644 .changeset/admin-dashboard-ui.md create mode 100644 .changeset/admin-settings-ui.md create mode 100644 grafana/dashboards/nostream-overview.json create mode 100644 grafana/provisioning/dashboards/default.yaml create mode 100644 grafana/provisioning/datasources/prometheus.yaml create mode 100644 resources/admin/assets/dashboard.css create mode 100644 resources/admin/assets/dashboard.js create mode 100644 resources/admin/dashboard.html create mode 100644 src/controllers/admin/get-settings-backups-controller.ts create mode 100644 src/controllers/admin/post-logout-controller.ts create mode 100644 src/controllers/admin/post-settings-restore-controller.ts create mode 100644 src/factories/controllers/get-admin-settings-backups-controller-factory.ts create mode 100644 src/factories/controllers/post-admin-logout-controller-factory.ts create mode 100644 src/factories/controllers/post-admin-settings-restore-controller-factory.ts create mode 100644 src/handlers/request-handlers/get-admin-dashboard-request-handler.ts create mode 100644 src/utils/admin-grafana.ts diff --git a/.changeset/admin-dashboard-ui.md b/.changeset/admin-dashboard-ui.md new file mode 100644 index 00000000..c6c8b2e0 --- /dev/null +++ b/.changeset/admin-dashboard-ui.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: add admin observability dashboard with Grafana embed and provisioned metrics panels diff --git a/.changeset/admin-settings-ui.md b/.changeset/admin-settings-ui.md new file mode 100644 index 00000000..d5368d88 --- /dev/null +++ b/.changeset/admin-settings-ui.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: add settings editor tab to admin dashboard UI diff --git a/.env.example b/.env.example index 060b4b69..509e2f02 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,16 @@ WORKER_COUNT=2 # Defaults to CPU count. Use 1 or 2 for local testing. # PROMETHEUS_URL=http://127.0.0.1:9090 # PROMETHEUS_QUERY_TIMEOUT_MS=5000 # ADMIN_METRICS_SSE_INTERVAL_MS=5000 +# ADMIN_METRICS_SNAPSHOT_TIMEOUT_MS=10000 +# ADMIN_DEPENDENCY_PING_TIMEOUT_MS=3000 + +# --- ADMIN CONSOLE (opt-in; disabled by default in default-settings.yaml) --- +# ADMIN_PASSWORD=dev-admin-password +# Set admin.enabled: true in .nostr/settings.yaml to enable the console +# GRAFANA_URL=http://127.0.0.1:3000 +# GRAFANA_DASHBOARD_UID=nostream-overview +# GRAFANA_ADMIN_USER=admin +# GRAFANA_ADMIN_PASSWORD=admin # --- RELAY PRIVATE KEY (Optional) --- # RELAY_PRIVATE_KEY=your_hex_private_key diff --git a/docker-compose.yml b/docker-compose.yml index ca0c1177..1939bd63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -179,6 +179,29 @@ services: networks: default: + grafana: + image: grafana/grafana:11.2.0 + container_name: grafana + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-admin} + GF_AUTH_ANONYMOUS_ENABLED: 'true' + GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer + GF_SECURITY_ALLOW_EMBEDDING: 'true' + GF_SERVER_ROOT_URL: ${GRAFANA_URL:-http://127.0.0.1:3000} + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana-data:/var/lib/grafana + ports: + - 127.0.0.1:3000:3000 + depends_on: + prometheus: + condition: service_started + restart: always + networks: + default: + networks: default: name: nostream @@ -190,3 +213,4 @@ networks: volumes: cache: prometheus-data: + grafana-data: diff --git a/grafana/dashboards/nostream-overview.json b/grafana/dashboards/nostream-overview.json new file mode 100644 index 00000000..6210c65e --- /dev/null +++ b/grafana/dashboards/nostream-overview.json @@ -0,0 +1,763 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(rate(nostream_events_accepted_total[1m]))", + "refId": "A" + } + ], + "title": "Events / sec", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 4, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(rate(nostream_events_rejected_total[1m]))", + "refId": "A" + } + ], + "title": "Rejected / sec", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 8, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(nostream_websocket_connections)", + "refId": "A" + } + ], + "title": "Active connections", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 12, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "avg(nostream_process_cpu_load_percent)", + "refId": "A" + } + ], + "title": "CPU load %", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 16, + "y": 0 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(nostream_events_accepted_total)", + "refId": "A" + } + ], + "title": "Accepted total", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 4, + "x": 20, + "y": 0 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(nostream_events_rejected_total)", + "refId": "A" + } + ], + "title": "Rejected total", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "rejected" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 4 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(rate(nostream_events_accepted_total[1m]))", + "legendFormat": "accepted", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(rate(nostream_events_rejected_total[1m]))", + "legendFormat": "rejected", + "refId": "B" + } + ], + "title": "Events / sec (15m)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 4 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(nostream_websocket_connections)", + "legendFormat": "connections", + "refId": "A" + } + ], + "title": "WebSocket connections (15m)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "decmbytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 9, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum by (worker_type) (nostream_process_memory_used_mb)", + "legendFormat": "{{worker_type}}", + "refId": "A" + } + ], + "title": "Memory by worker", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 10, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "avg by (worker_type) (nostream_process_cpu_load_percent)", + "legendFormat": "{{worker_type}}", + "refId": "A" + } + ], + "title": "CPU by worker", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": ["nostream", "admin"], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Nostream Overview", + "uid": "nostream-overview", + "version": 1, + "weekStart": "" +} diff --git a/grafana/provisioning/dashboards/default.yaml b/grafana/provisioning/dashboards/default.yaml new file mode 100644 index 00000000..022ad542 --- /dev/null +++ b/grafana/provisioning/dashboards/default.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: nostream + orgId: 1 + folder: Nostream + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards diff --git a/grafana/provisioning/datasources/prometheus.yaml b/grafana/provisioning/datasources/prometheus.yaml new file mode 100644 index 00000000..9e3c7b4e --- /dev/null +++ b/grafana/provisioning/datasources/prometheus.yaml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/resources/admin/assets/dashboard.css b/resources/admin/assets/dashboard.css new file mode 100644 index 00000000..d5782f57 --- /dev/null +++ b/resources/admin/assets/dashboard.css @@ -0,0 +1,765 @@ +.admin-dashboard.console-theme { + --bg: #ece6dc; + --panel: #f6f2ec; + --panel-border: #b8aa98; + --text: #2c241c; + --text-muted: #6b5f52; + --label: #5a4f44; + --accent: #6b5344; + --prompt: #8a5a12; + --ok: #2f6b4f; + --warn: #8a5a12; + --danger: #8f3a3a; + --no-data: #5a6270; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: "IBM Plex Mono", "Courier New", monospace; + font-size: 0.875rem; +} + +.admin-dashboard.console-theme[data-theme="dark"] { + --bg: #0e120e; + --panel: #141a14; + --panel-border: #2e3e2e; + --text: #b8d8b8; + --text-muted: #6a906a; + --label: #8cb08c; + --accent: #7ecf7e; + --prompt: #9ecf6a; + --ok: #6ecf8a; + --warn: #d4b04a; + --danger: #e07a7a; + --no-data: #8aa4c8; + background-color: var(--bg); + background-image: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(110, 207, 138, 0.02) 2px, + rgba(110, 207, 138, 0.02) 3px + ); +} + +.prompt-char { + color: var(--prompt); + font-weight: 700; + margin-right: 0.25rem; +} + +.module-code { + color: var(--text-muted); + font-size: 0.62rem; + font-weight: 600; + letter-spacing: 0.06em; + margin-right: 0.35rem; +} + +.admin-header { + border-bottom: 1px solid var(--panel-border); + padding-bottom: 1rem; +} + +.admin-dashboard .admin-kicker { + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.admin-dashboard .admin-title { + color: var(--text); + font-size: 1.1rem; + font-weight: 600; +} + +.admin-dashboard .admin-muted { + color: var(--text-muted); +} + +.terminal-line { + color: var(--text-muted); + font-size: 0.75rem; +} + +.admin-dashboard .field-label, +.admin-dashboard .metric-label { + color: var(--label); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.04em; + margin-bottom: 0.35rem; + text-transform: uppercase; +} + +.clock-wrap { + border: 1px solid var(--panel-border); + padding: 0.25rem 0.5rem; + text-align: right; +} + +.clock-value { + color: var(--text); + font-size: 0.9rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + line-height: 1.1; +} + +.clock-label { + color: var(--text-muted); + font-size: 0.65rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.system-alert { + border: 1px solid var(--warn); + color: var(--warn); + font-size: 0.75rem; + margin-bottom: 0.75rem; + padding: 0.45rem 0.75rem; +} + +.system-alert.degraded { + background: color-mix(in srgb, var(--warn) 10%, var(--panel)); + border-color: var(--warn); + color: var(--warn); +} + +.system-alert.unavailable { + background: color-mix(in srgb, var(--danger) 10%, var(--panel)); + border-color: var(--danger); + color: var(--danger); +} + +.dashboard-section { + margin-bottom: 1.5rem; +} + +.section-title::before { + color: var(--prompt); + content: "> "; + font-weight: 700; +} + +.section-title { + border-bottom: 1px solid var(--panel-border); + color: var(--label); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + margin-bottom: 0.75rem; + padding-bottom: 0.35rem; + text-transform: uppercase; +} + +.section-title .sync-cursor { + margin-left: 0.2rem; + vertical-align: baseline; +} + +.panel-card, +.metric-card { + background: var(--panel); + border: 1px solid var(--panel-border); + padding: 0.85rem 1rem; + transition: border-color 180ms ease, background-color 180ms ease; +} + +.metric-card.updated { + border-color: var(--accent); +} + +.panel-title { + color: var(--text); + font-size: 0.95rem; + font-weight: 600; +} + +.metric-card { + min-height: 4.75rem; +} + +.status-led { + background: var(--no-data); + border-radius: 50%; + display: inline-block; + height: 0.45rem; + margin-right: 0.4rem; + vertical-align: middle; + width: 0.45rem; +} + +.status-ok .status-led, +.status-badge.live .status-led { + animation: led-pulse 2s ease-in-out infinite; + background: var(--ok); + box-shadow: 0 0 4px color-mix(in srgb, var(--ok) 60%, transparent); +} + +.status-degraded .status-led, +.status-badge.warn .status-led { + background: var(--warn); +} + +.status-unavailable .status-led, +.status-down .status-led, +.status-badge.error .status-led { + background: var(--danger); +} + +.status-no-data .status-led, +.status-badge:not(.live):not(.warn):not(.error) .status-led { + background: var(--no-data); +} + +@keyframes led-pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.55; + } +} + +.admin-dashboard .metric-value { + color: var(--text); + font-size: 1.15rem; + font-variant-numeric: tabular-nums; + font-weight: 600; + line-height: 1.3; +} + +.admin-dashboard .metric-number { + font-size: inherit; +} + +.admin-dashboard .metric-unit { + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 400; +} + +.admin-dashboard .status-ok { + color: var(--ok); +} + +.admin-dashboard .status-degraded { + color: var(--warn); +} + +.admin-dashboard .status-unavailable, +.admin-dashboard .status-down { + color: var(--danger); +} + +.admin-dashboard .status-no-data { + color: var(--no-data); +} + +.sync-line { + color: var(--text-muted); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} + +.sync-cursor { + animation: cursor-blink 1s step-end infinite; + background: var(--accent); + display: inline-block; + height: 0.85em; + margin-left: 0.15rem; + vertical-align: text-bottom; + width: 0.45rem; +} + +@keyframes cursor-blink { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +.status-badge { + align-items: center; + border: 1px solid var(--panel-border); + color: var(--text-muted); + display: inline-flex; + font-size: 0.68rem; + font-weight: 500; + gap: 0.15rem; + letter-spacing: 0.04em; + padding: 0.25rem 0.55rem; + text-transform: uppercase; +} + +.status-badge.live { + border-color: var(--ok); + color: var(--ok); +} + +.status-badge.warn { + border-color: var(--warn); + color: var(--warn); +} + +.status-badge.error { + border-color: var(--danger); + color: var(--danger); +} + +.admin-dashboard .btn-console { + background: var(--panel); + border: 1px solid var(--panel-border); + color: var(--text); + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; +} + +.admin-dashboard .btn-console:hover, +.admin-dashboard .btn-console:focus { + background: color-mix(in srgb, var(--accent) 8%, var(--panel)); + border-color: var(--accent); + color: var(--text); +} + +.admin-dashboard .btn-console.copied { + border-color: var(--ok); + color: var(--ok); +} + +.admin-dashboard .console-input { + background: var(--bg); + border: 1px solid var(--panel-border); + color: var(--text); + font-family: inherit; + font-size: 0.875rem; +} + +.admin-dashboard .console-input:focus { + background: var(--bg); + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); + color: var(--text); +} + +.admin-dashboard .alert-danger { + background: color-mix(in srgb, var(--danger) 8%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--panel-border)); + color: var(--danger); + font-size: 0.8rem; +} + +.admin-dashboard .login-box { + margin: 2rem auto 0; + max-width: 28rem; +} + +.dashboard-nav { + background: var(--panel); + border: 1px solid var(--panel-border); + left: 0.75rem; + min-width: 12rem; + padding: 0.85rem; + position: fixed; + top: 4.5rem; + transform: translateX(-120%); + transition: transform 160ms ease; + z-index: 1041; +} + +.admin-dashboard.nav-open .dashboard-nav { + transform: translateX(0); +} + +.nav-item { + background: var(--bg); + border: 1px solid var(--panel-border); + color: var(--text); + display: block; + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + margin-bottom: 0.4rem; + padding: 0.45rem 0.55rem; + text-align: left; + width: 100%; +} + +.nav-item.active { + border-color: var(--accent); + color: var(--accent); +} + +.nav-overlay { + background: rgba(0, 0, 0, 0.25); + bottom: 0; + display: none; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 1040; +} + +.admin-dashboard.nav-open .nav-overlay { + display: block; +} + +.chart-panel { + padding: 0; +} + +.panel-header { + border-bottom: 1px solid var(--panel-border); + color: var(--label); + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.04em; + padding: 0.55rem 0.85rem; + text-transform: uppercase; +} + +.admin-dashboard .grafana-frame { + background: #141814; + border: 0; + display: block; + height: 380px; + width: 100%; +} + +.admin-dashboard[data-theme="light"] .grafana-frame { + background: #1a1814; +} + +.dashboard-view { + width: 100%; +} + +.settings-layout { + display: grid; + gap: 0.75rem; + grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr) minmax(15rem, 22rem); +} + +.settings-toolbar { + border-bottom: 1px solid var(--panel-border); + padding-bottom: 0.75rem; +} + +.admin-dashboard .btn-settings-validate { + border-color: #5a8fd4; + color: #5a8fd4; +} + +.admin-dashboard .btn-settings-validate:hover, +.admin-dashboard .btn-settings-validate:focus { + background: color-mix(in srgb, #5a8fd4 12%, var(--panel)); + border-color: #5a8fd4; + color: #5a8fd4; +} + +.admin-dashboard .btn-settings-preview { + border-color: #7eb8d4; + color: #7eb8d4; +} + +.admin-dashboard .btn-settings-preview:hover, +.admin-dashboard .btn-settings-preview:focus { + background: color-mix(in srgb, #7eb8d4 12%, var(--panel)); + border-color: #7eb8d4; + color: #7eb8d4; +} + +.admin-dashboard .btn-settings-apply:not(:disabled) { + background: color-mix(in srgb, var(--ok) 16%, var(--panel)); + border-color: var(--ok); + color: var(--ok); +} + +.admin-dashboard .btn-settings-apply:not(:disabled):hover, +.admin-dashboard .btn-settings-apply:not(:disabled):focus { + background: color-mix(in srgb, var(--ok) 24%, var(--panel)); + border-color: var(--ok); + color: var(--ok); +} + +.admin-dashboard .btn-settings-discard:not(:disabled) { + border-color: var(--danger); + color: var(--danger); +} + +.admin-dashboard .btn-settings-discard:not(:disabled):hover, +.admin-dashboard .btn-settings-discard:not(:disabled):focus { + background: color-mix(in srgb, var(--danger) 12%, var(--panel)); + border-color: var(--danger); + color: var(--danger); +} + +.admin-dashboard .btn-settings-restore { + border-color: var(--warn); + color: var(--warn); +} + +.admin-dashboard .btn-settings-restore:hover, +.admin-dashboard .btn-settings-restore:focus { + background: color-mix(in srgb, var(--warn) 12%, var(--panel)); + border-color: var(--warn); + color: var(--warn); +} + +.admin-dashboard .btn-settings-reload { + border-color: var(--text-muted); + color: var(--text-muted); +} + +.admin-dashboard .btn-settings-reload:hover, +.admin-dashboard .btn-settings-reload:focus { + background: color-mix(in srgb, var(--text-muted) 10%, var(--panel)); + border-color: var(--text-muted); + color: var(--text); +} + +.settings-diff-header { + align-self: start; + padding: 0.85rem 1rem; + position: sticky; + top: 1rem; +} + +.settings-diff-heading-row { + align-items: baseline; + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-bottom: 0.35rem; +} + +.settings-diff-heading { + color: var(--label); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.08em; + margin: 0; + text-transform: uppercase; +} + +.settings-diff-heading::before { + color: var(--prompt); + content: "> "; + font-weight: 700; +} + +.settings-category-list { + align-self: start; + padding: 0.75rem; +} + +.settings-category-button.has-changes::after { + color: var(--warn); + content: ' •'; +} + +.settings-category-button { + background: var(--bg); + border: 1px solid var(--panel-border); + color: var(--text); + display: block; + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + margin-bottom: 0.4rem; + padding: 0.45rem 0.55rem; + text-align: left; + width: 100%; +} + +.settings-category-button.active { + border-color: var(--accent); + color: var(--accent); +} + +.settings-fields { + padding: 0.85rem 1rem; +} + +.settings-field { + border-bottom: 1px solid var(--panel-border); + margin-bottom: 0.85rem; + padding-bottom: 0.85rem; +} + +.settings-field:last-child { + border-bottom: 0; + margin-bottom: 0; + padding-bottom: 0; +} + +.settings-field-path { + color: var(--text-muted); + font-size: 0.65rem; + margin-bottom: 0.35rem; + word-break: break-all; +} + +.settings-field-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.55rem; +} + +.settings-field.changed { + border-left: 2px solid var(--warn); + padding-left: 0.65rem; +} + +.settings-field-was { + color: var(--danger); + font-size: 0.68rem; + margin-bottom: 0.35rem; +} + +.settings-field-message { + font-size: 0.72rem; + min-height: 1rem; +} + +.settings-field-message.error { + color: var(--danger); +} + +.settings-field-message.success { + color: var(--ok); +} + +.settings-success { + background: color-mix(in srgb, var(--ok) 8%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--ok) 40%, var(--panel-border)); + color: var(--ok); +} + +.settings-validation { + background: color-mix(in srgb, var(--warn) 8%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--panel-border)); + color: var(--warn); + margin-bottom: 0.75rem; + padding: 0.55rem 0.75rem; +} + +.settings-validation.valid { + background: color-mix(in srgb, var(--ok) 8%, var(--panel)); + border-color: color-mix(in srgb, var(--ok) 40%, var(--panel-border)); + color: var(--ok); +} + +.settings-diff { + align-self: start; +} + +.settings-diff-header.highlight { + border-color: var(--accent); +} + +.settings-diff-title { + color: var(--label); + font-size: 0.72rem; + font-weight: 600; + margin-bottom: 0.35rem; + text-transform: uppercase; +} + +.settings-diff-note { + color: var(--text-muted); + font-size: 0.65rem; + margin-bottom: 0.65rem; +} + +.settings-diff-content { + background: var(--bg); + border: 1px solid var(--panel-border); + font-size: 0.68rem; + margin-bottom: 0; + max-height: calc(100vh - 16rem); + min-height: 12rem; + overflow: auto; + padding: 0.55rem; +} + +.settings-diff-line { + font-family: inherit; + margin-bottom: 0.25rem; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.settings-diff-line.removed { + color: var(--danger); +} + +.settings-diff-line.added { + color: var(--ok); +} + +.settings-diff-line.meta { + color: var(--text-muted); +} + +.settings-diff-summary { + color: var(--text-muted); + font-size: 0.68rem; +} + +.settings-field-reload { + color: var(--text-muted); + font-size: 0.62rem; + letter-spacing: 0.04em; + margin-bottom: 0.35rem; + text-transform: uppercase; +} + +.settings-field-reload.restart { + color: var(--warn); +} + +@media (max-width: 1200px) { + .settings-layout { + grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr); + } + + .settings-diff-header { + grid-column: 1 / -1; + position: static; + } + + .settings-diff-content { + max-height: 14rem; + min-height: 8rem; + } +} + +@media (max-width: 768px) { + .settings-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .admin-dashboard .metric-value { + font-size: 1rem; + } + + .admin-dashboard .grafana-frame { + height: 300px; + } +} diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js new file mode 100644 index 00000000..56ea3913 --- /dev/null +++ b/resources/admin/assets/dashboard.js @@ -0,0 +1,1380 @@ +(() => { + const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' } + const pathPrefix = config.pathPrefix || '' + const adminBase = `${pathPrefix}/admin` + + const loginPanel = document.getElementById('login-panel') + const dashboardPanel = document.getElementById('dashboard-panel') + const dashboardActions = document.getElementById('dashboard-actions') + const loginForm = document.getElementById('login-form') + const loginError = document.getElementById('login-error') + const loginButton = document.getElementById('login-button') + const logoutButton = document.getElementById('logout-button') + const copySnapshotButton = document.getElementById('copy-snapshot-button') + const streamStatus = document.getElementById('stream-status') + const passwordInput = document.getElementById('password') + const themeToggle = document.getElementById('theme-toggle') + const liveClock = document.getElementById('live-clock') + const menuToggle = document.getElementById('menu-toggle') + const navOverlay = document.getElementById('nav-overlay') + const navPanel = document.getElementById('dashboard-nav') + const navItems = document.querySelectorAll('.nav-item') + const systemAlert = document.getElementById('system-alert') + const settingsCategoryList = document.getElementById('settings-category-list') + const settingsFields = document.getElementById('settings-fields') + const settingsError = document.getElementById('settings-error') + const settingsSuccess = document.getElementById('settings-success') + const settingsValidation = document.getElementById('settings-validation') + const settingsValidateButton = document.getElementById('settings-validate-button') + const settingsPreviewButton = document.getElementById('settings-preview-button') + const settingsApplyButton = document.getElementById('settings-apply-button') + const settingsDiscardButton = document.getElementById('settings-discard-button') + const settingsRestoreButton = document.getElementById('settings-restore-button') + const settingsReloadButton = document.getElementById('settings-reload-button') + const settingsDiff = document.getElementById('settings-diff') + const settingsDiffContent = document.getElementById('settings-diff-content') + const settingsDiffSummary = document.getElementById('settings-diff-summary') + const dashboardViews = document.querySelectorAll('.dashboard-view') + + let settingsLoaded = false + let settingsLoading = false + let settingsData = {} + let savedSettingsData = {} + let settingsSchema = [] + let activeSettingsCategory = null + const stagedSettingsChanges = new Map() + + let metricsSource + let reconnectTimer + let reconnectAttempt = 0 + let streamClosedIntentionally = false + let lastSnapshot + let relativeTimeTimer + let staleCheckTimer + const staleThresholdMs = 15000 + + const statusClasses = ['status-ok', 'status-degraded', 'status-unavailable', 'status-down', 'status-no-data'] + + const statusLabels = { + system: { + ok: '[OK]', + degraded: '[WARN]', + unavailable: '[NULL]', + }, + prometheus: { + online: '[OK]', + noData: '[NULL]', + }, + database: { + healthy: '[OK]', + fault: '[FAULT]', + }, + redis: { + healthy: '[OK]', + fault: '[FAULT]', + }, + stream: { + offline: '[OFF]', + init: '[SYNC]', + live: '[LIVE]', + stale: '[STALE]', + parseError: '[ERR]', + reconnect: '[RETRY]', + }, + } + + const getTheme = () => { + const stored = localStorage.getItem('admin_theme') + if (stored === 'light' || stored === 'dark') { + return stored + } + + return 'light' + } + + const applyTheme = (theme) => { + document.body.setAttribute('data-theme', theme) + if (themeToggle) { + themeToggle.textContent = theme === 'dark' ? 'Dark' : 'Light' + } + } + + const formatClock = () => { + return new Date().toISOString().slice(11, 19) + } + + const formatRelativeTime = (timestampMs) => { + const seconds = Math.max(0, Math.floor((Date.now() - timestampMs) / 1000)) + if (seconds < 8) { + return 'just now' + } + if (seconds < 60) { + return `${seconds}s ago` + } + if (seconds < 3600) { + return `${Math.floor(seconds / 60)}m ago` + } + + return `${Math.floor(seconds / 3600)}h ago` + } + + const formatTimestamp = (timestampMs) => { + const updated = new Date(timestampMs).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC') + return `> sync: ${updated} (${formatRelativeTime(timestampMs)})` + } + + const sumMetricValues = (...values) => { + return values.reduce((total, value) => { + const parsed = Number(value) + return total + (Number.isFinite(parsed) ? parsed : 0) + }, 0) + } + + const formatNumber = (value, digits = 0) => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return '0' + } + + return parsed.toLocaleString(undefined, { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }) + } + + const setText = (id, value) => { + const element = document.getElementById(id) + if (element) { + element.textContent = value + } + } + + const setSyncLine = (timestampMs) => { + const element = document.getElementById('metric-updated-at') + if (!element) { + return + } + + element.innerHTML = `${formatTimestamp(timestampMs)}` + } + + const setMetricValue = (id, value, digits = 0) => { + const element = document.getElementById(id) + if (!element) { + return + } + + const numberNode = element.querySelector('.metric-number') + if (numberNode) { + numberNode.textContent = formatNumber(value, digits) + return + } + + element.textContent = formatNumber(value, digits) + } + + const setStatusText = (id, value, statusClass) => { + const element = document.getElementById(id) + if (!element) { + return + } + + element.classList.remove(...statusClasses) + if (statusClass) { + element.classList.add(statusClass) + } + + const led = element.querySelector('.status-led') + element.replaceChildren() + if (led) { + element.appendChild(led) + } + element.appendChild(document.createTextNode(value)) + } + + const setStreamStatus = (label, statusClass) => { + if (!streamStatus) { + return + } + + streamStatus.className = `status-badge${statusClass ? ` ${statusClass}` : ''}` + streamStatus.replaceChildren() + + const led = document.createElement('span') + led.className = 'status-led' + led.setAttribute('aria-hidden', 'true') + streamStatus.appendChild(led) + streamStatus.appendChild(document.createTextNode(label)) + } + + const updateSystemAlert = (snapshot) => { + if (!systemAlert) { + return + } + + if (lastSnapshot?.timestamp && Date.now() - lastSnapshot.timestamp > staleThresholdMs) { + systemAlert.textContent = '[WARN] Metrics stale — waiting for backend update.' + systemAlert.className = 'system-alert degraded' + return + } + + if (snapshot.status === 'degraded') { + systemAlert.textContent = '[WARN] System degraded — check dependency health below.' + systemAlert.className = 'system-alert degraded' + return + } + + if (snapshot.status === 'unavailable') { + systemAlert.textContent = '[NULL] Metrics unavailable — Prometheus returned no data.' + systemAlert.className = 'system-alert unavailable' + return + } + + systemAlert.className = 'system-alert d-none' + systemAlert.textContent = '' + } + + const scheduleRelativeTimeRefresh = (timestampMs) => { + if (relativeTimeTimer) { + clearInterval(relativeTimeTimer) + } + + relativeTimeTimer = setInterval(() => { + if (lastSnapshot?.timestamp) { + setSyncLine(lastSnapshot.timestamp) + checkStaleSnapshot() + } + }, 10000) + } + + const checkStaleSnapshot = () => { + if (streamClosedIntentionally || !lastSnapshot?.timestamp) { + return + } + + const isStale = Date.now() - lastSnapshot.timestamp > staleThresholdMs + if (isStale) { + setStreamStatus(statusLabels.stream.stale, 'warn') + updateSystemAlert(lastSnapshot) + return + } + + setStreamStatus(statusLabels.stream.live, 'live') + updateSystemAlert(lastSnapshot) + } + + const startStaleCheck = () => { + if (staleCheckTimer) { + clearInterval(staleCheckTimer) + } + + staleCheckTimer = setInterval(checkStaleSnapshot, 3000) + } + + const stopStaleCheck = () => { + if (staleCheckTimer) { + clearInterval(staleCheckTimer) + staleCheckTimer = undefined + } + } + + const setNavOpen = (isOpen) => { + document.body.classList.toggle('nav-open', isOpen) + if (menuToggle) { + menuToggle.setAttribute('aria-expanded', String(isOpen)) + } + if (navPanel) { + navPanel.setAttribute('aria-hidden', String(!isOpen)) + } + } + + const isTypingTarget = (target) => { + if (!(target instanceof HTMLElement)) { + return false + } + + const tag = target.tagName.toLowerCase() + return tag === 'input' || tag === 'textarea' || target.isContentEditable + } + + const showLogin = () => { + stopMetricsStream() + setNavOpen(false) + loginPanel.classList.remove('d-none') + dashboardPanel.classList.add('d-none') + dashboardActions.classList.add('d-none') + dashboardActions.classList.remove('d-flex') + menuToggle?.classList.add('d-none') + loginError.classList.add('d-none') + passwordInput.value = '' + lastSnapshot = undefined + } + + const showDashboard = () => { + loginPanel.classList.add('d-none') + dashboardPanel.classList.remove('d-none') + dashboardActions.classList.remove('d-none') + dashboardActions.classList.add('d-flex') + menuToggle?.classList.remove('d-none') + + dashboardViews.forEach((view) => { + view.classList.toggle('d-none', view.id !== 'metrics-view') + }) + navItems.forEach((item) => { + item.classList.toggle('active', item.dataset.view === 'metrics-view') + }) + + document.querySelectorAll('.grafana-frame').forEach((frame) => { + if (!frame.src && frame.dataset.panelId) { + const theme = document.body.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' + const baseUrl = (config.grafanaBaseUrl || config.grafanaUrl || 'http://127.0.0.1:3000').replace(/\/+$/, '') + const dashboardUid = config.grafanaDashboardUid || 'nostream-overview' + const params = new URLSearchParams({ + orgId: '1', + panelId: frame.dataset.panelId, + refresh: '5s', + theme, + }) + frame.src = `${baseUrl}/d-solo/${dashboardUid}?${params.toString()}` + } + }) + + startMetricsStream() + } + + const parsePathTokens = (path) => { + const tokens = [] + const pattern = /([^.[\]]+)|\[(\d+)\]/g + let match = pattern.exec(path) + + while (match) { + if (match[1] !== undefined) { + tokens.push({ type: 'key', key: match[1] }) + } else { + tokens.push({ type: 'index', index: Number(match[2]) }) + } + match = pattern.exec(path) + } + + return tokens + } + + const getByPath = (source, path) => { + let current = source + + for (const token of parsePathTokens(path)) { + if (current === undefined || current === null) { + return undefined + } + + if (token.type === 'key') { + current = current[token.key] + continue + } + + current = Array.isArray(current) ? current[token.index] : undefined + } + + return current + } + + const hideSettingsAlerts = () => { + settingsError?.classList.add('d-none') + settingsSuccess?.classList.add('d-none') + settingsValidation?.classList.add('d-none') + } + + const showSettingsError = (message) => { + if (!settingsError) { + return + } + + settingsError.textContent = message + settingsError.classList.remove('d-none') + settingsSuccess?.classList.add('d-none') + } + + const showSettingsSuccess = (message) => { + if (!settingsSuccess) { + return + } + + settingsSuccess.textContent = message + settingsSuccess.classList.remove('d-none') + settingsError?.classList.add('d-none') + } + + const getReloadBehavior = (path) => { + return path.startsWith('workers.') || path.startsWith('payments') || path.startsWith('network.') + ? 'restart required' + : 'hot reload' + } + + const updateStagedChangesUi = () => { + const pendingCount = stagedSettingsChanges.size + + if (settingsApplyButton) { + settingsApplyButton.disabled = pendingCount === 0 + settingsApplyButton.textContent = + pendingCount === 0 ? 'Apply staged changes' : `Apply ${pendingCount} staged change(s)` + } + + if (settingsDiscardButton) { + settingsDiscardButton.disabled = pendingCount === 0 + } + + renderSettingsCategoryList() + } + + const formatDiffValue = (value) => { + if (value === undefined) { + return 'undefined' + } + + if (value === null) { + return 'null' + } + + if (typeof value === 'string') { + return value + } + + if (typeof value === 'boolean' || typeof value === 'number') { + return String(value) + } + + return JSON.stringify(value) + } + + const pathToYamlKey = (path) => { + return path.replace(/\[(\d+)\]/g, '.$1') + } + + const renderSettingsDiff = (options = {}) => { + const { highlight = false, showEmptyMessage = false } = options + + if (!settingsDiffContent) { + return + } + + settingsDiffContent.replaceChildren() + + if (stagedSettingsChanges.size === 0) { + const empty = document.createElement('p') + empty.className = 'settings-diff-empty admin-muted small mb-0' + empty.textContent = showEmptyMessage + ? 'No pending changes to preview.' + : 'Edit a setting to preview changes here.' + settingsDiffContent.appendChild(empty) + + if (settingsDiffSummary) { + settingsDiffSummary.textContent = '' + } + + settingsDiff?.classList.toggle('highlight', highlight) + return + } + + for (const change of stagedSettingsChanges.values()) { + const yamlKey = pathToYamlKey(change.path) + const removed = document.createElement('p') + removed.className = 'settings-diff-line removed' + removed.textContent = `- ${yamlKey}: ${formatDiffValue(change.previous)}` + + const added = document.createElement('p') + added.className = 'settings-diff-line added' + added.textContent = `+ ${yamlKey}: ${formatDiffValue(change.value)}` + + const meta = document.createElement('p') + meta.className = 'settings-diff-line meta' + meta.textContent = ` # ${getReloadBehavior(change.path)}` + + settingsDiffContent.append(removed, added, meta) + } + + if (settingsDiffSummary) { + const lineCount = stagedSettingsChanges.size * 2 + settingsDiffSummary.textContent = `${lineCount} line(s) changed • ${stagedSettingsChanges.size} setting(s) pending` + } + + settingsDiff?.classList.toggle('highlight', highlight) + } + + const discardStagedChanges = () => { + if (stagedSettingsChanges.size === 0) { + return + } + + settingsData = structuredClone(savedSettingsData) + stagedSettingsChanges.clear() + hideSettingsAlerts() + renderSettingsFields() + renderSettingsDiff() + updateStagedChangesUi() + showSettingsSuccess('Discarded staged changes.') + } + + const updateFieldChangeIndicator = (wrapper, field) => { + const pending = stagedSettingsChanges.get(field.path) + wrapper.classList.toggle('changed', Boolean(pending)) + + let wasNode = wrapper.querySelector('.settings-field-was') + if (!pending) { + wasNode?.remove() + return + } + + if (!wasNode) { + wasNode = document.createElement('p') + wasNode.className = 'settings-field-was' + wrapper.insertBefore(wasNode, wrapper.querySelector('.settings-field-message')) + } + + wasNode.textContent = `was: ${formatDiffValue(pending.previous)}` + } + + const trackFieldChange = (field, control, wrapper, messageNode) => { + if (messageNode) { + messageNode.textContent = '' + messageNode.className = 'settings-field-message mb-0' + } + + let value + try { + const rawValue = readControlValue(field, control) + const validationMessage = validateFieldInput(field, rawValue) + if (validationMessage) { + if (messageNode) { + messageNode.textContent = validationMessage + messageNode.classList.add('error') + } + return false + } + + value = parseFieldValue(field, rawValue) + } catch (error) { + if (messageNode) { + messageNode.textContent = error instanceof Error ? error.message : 'Invalid value' + messageNode.classList.add('error') + } + return false + } + + stageSettingsChange(field, value) + updateFieldChangeIndicator(wrapper, field) + return true + } + + const syncPendingFromVisibleFields = () => { + if (!settingsFields) { + return + } + + for (const wrapper of settingsFields.querySelectorAll('.settings-field')) { + const path = wrapper.dataset.path + const field = settingsSchema.flatMap((category) => category.settings).find((entry) => entry.path === path) + const control = wrapper.querySelector('input, select, textarea') + + if (!field || !control) { + continue + } + + trackFieldChange(field, control, wrapper) + } + } + + const stageSettingsChange = (field, value) => { + const previous = getByPath(savedSettingsData, field.path) + if (JSON.stringify(previous) === JSON.stringify(value)) { + stagedSettingsChanges.delete(field.path) + } else { + stagedSettingsChanges.set(field.path, { path: field.path, previous, value }) + } + setByPathLocal(settingsData, field.path, value) + updateStagedChangesUi() + renderSettingsDiff() + } + + const formatFieldValue = (field, value) => { + if (field.type === 'boolean') { + return Boolean(value) + } + + if (field.type === 'number') { + return value === undefined || value === null || value === '' ? '' : String(value) + } + + if (field.type === 'stringArray') { + if (!Array.isArray(value)) { + return '' + } + + return value.join('\n') + } + + if (value === undefined || value === null) { + return '' + } + + return String(value) + } + + const parseFieldValue = (field, rawValue) => { + if (field.type === 'boolean') { + return Boolean(rawValue) + } + + if (field.type === 'number') { + const trimmed = String(rawValue).trim() + if (!trimmed) { + return null + } + + const parsed = Number(trimmed) + if (!Number.isFinite(parsed)) { + throw new Error('Enter a valid number') + } + + return parsed + } + + if (field.type === 'stringArray') { + return String(rawValue) + .split('\n') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + } + + return String(rawValue) + } + + const validateFieldInput = (field, rawValue) => { + if (field.type === 'number') { + const trimmed = String(rawValue).trim() + if (!trimmed) { + return 'Value is required' + } + + if (!/^-?\d+(\.\d+)?$/.test(trimmed)) { + return 'Enter a valid number' + } + } + + if (field.type === 'string' || field.type === 'select') { + if (!String(rawValue).trim()) { + return 'Value is required' + } + } + + return undefined + } + + const renderSettingsCategoryList = () => { + if (!settingsCategoryList) { + return + } + + settingsCategoryList.replaceChildren() + + for (const category of settingsSchema) { + const hasChanges = category.settings.some((field) => stagedSettingsChanges.has(field.path)) + const button = document.createElement('button') + button.type = 'button' + button.className = `settings-category-button${category.value === activeSettingsCategory ? ' active' : ''}${hasChanges ? ' has-changes' : ''}` + button.textContent = category.label + button.dataset.category = category.value + button.addEventListener('click', () => { + syncPendingFromVisibleFields() + activeSettingsCategory = category.value + renderSettingsCategoryList() + renderSettingsFields() + renderSettingsDiff() + }) + settingsCategoryList.appendChild(button) + } + } + + const createSettingsControl = (field, inputId) => { + if (field.type === 'boolean') { + const input = document.createElement('input') + input.type = 'checkbox' + input.className = 'form-check-input' + input.id = inputId + return input + } + + if (field.type === 'select') { + const select = document.createElement('select') + select.className = 'form-select console-input' + select.id = inputId + + for (const optionValue of field.options ?? []) { + const option = document.createElement('option') + option.value = optionValue + option.textContent = optionValue + select.appendChild(option) + } + + return select + } + + if (field.type === 'stringArray') { + const textarea = document.createElement('textarea') + textarea.className = 'form-control console-input' + textarea.id = inputId + textarea.rows = 4 + textarea.placeholder = field.placeholder ?? 'One value per line' + return textarea + } + + const input = document.createElement('input') + input.className = 'form-control console-input' + input.id = inputId + input.type = field.type === 'number' ? 'number' : 'text' + if (field.placeholder) { + input.placeholder = field.placeholder + } + + return input + } + + const setControlValue = (field, control, value) => { + if (field.type === 'boolean') { + control.checked = Boolean(value) + return + } + + control.value = formatFieldValue(field, value) + } + + const readControlValue = (field, control) => { + if (field.type === 'boolean') { + return control.checked + } + + return control.value + } + + const renderSettingsFields = () => { + if (!settingsFields) { + return + } + + const category = settingsSchema.find((entry) => entry.value === activeSettingsCategory) + settingsFields.replaceChildren() + + if (!category) { + const empty = document.createElement('p') + empty.className = 'admin-muted small mb-0' + empty.textContent = 'Select a category to edit guided settings.' + settingsFields.appendChild(empty) + return + } + + for (const field of category.settings) { + const fieldId = `setting-${field.path.replace(/[^a-zA-Z0-9]+/g, '-')}` + const wrapper = document.createElement('div') + wrapper.className = 'settings-field' + wrapper.dataset.path = field.path + + const label = document.createElement('label') + label.className = 'form-label field-label' + label.setAttribute('for', fieldId) + label.textContent = field.label + + const path = document.createElement('p') + path.className = 'settings-field-path' + path.textContent = field.path + + const reload = document.createElement('p') + reload.className = `settings-field-reload${getReloadBehavior(field.path) === 'restart required' ? ' restart' : ''}` + reload.textContent = + getReloadBehavior(field.path) === 'restart required' ? '[restart required]' : '[hot reload]' + + const control = createSettingsControl(field, fieldId) + setControlValue(field, control, getByPath(settingsData, field.path)) + + const message = document.createElement('p') + message.className = 'settings-field-message mb-0' + + const onFieldUpdate = () => { + if (trackFieldChange(field, control, wrapper, message)) { + renderSettingsDiff() + } + } + + control.addEventListener('change', onFieldUpdate) + if (field.type === 'number' || field.type === 'string' || field.type === 'stringArray') { + control.addEventListener('input', onFieldUpdate) + } + + updateFieldChangeIndicator(wrapper, field) + wrapper.append(label, path, reload, control, message) + settingsFields.appendChild(wrapper) + } + } + + const setByPathLocal = (target, path, value) => { + const tokens = parsePathTokens(path) + let current = target + + for (let index = 0; index < tokens.length - 1; index += 1) { + const token = tokens[index] + const nextToken = tokens[index + 1] + + if (token.type === 'key') { + if (current[token.key] === undefined || current[token.key] === null) { + current[token.key] = nextToken.type === 'index' ? [] : {} + } + current = current[token.key] + continue + } + + while (current.length <= token.index) { + current.push(undefined) + } + + if (current[token.index] === undefined || current[token.index] === null) { + current[token.index] = nextToken.type === 'index' ? [] : {} + } + + current = current[token.index] + } + + const last = tokens[tokens.length - 1] + if (last.type === 'key') { + current[last.key] = value + return + } + + while (current.length <= last.index) { + current.push(undefined) + } + + current[last.index] = value + } + + const loadSettingsData = async (force = false) => { + if (settingsLoading) { + return + } + + if (settingsLoaded && !force) { + return + } + + settingsLoading = true + hideSettingsAlerts() + + try { + const [settingsResponse, schemaResponse] = await Promise.all([ + fetch(`${adminBase}/settings`, { credentials: 'include' }), + fetch(`${adminBase}/settings/schema`, { credentials: 'include' }), + ]) + + if (settingsResponse.status === 401 || schemaResponse.status === 401) { + showLogin() + return + } + + if (!settingsResponse.ok || !schemaResponse.ok) { + showSettingsError('Unable to load settings.') + return + } + + const settingsBody = await settingsResponse.json() + const schemaBody = await schemaResponse.json() + + settingsData = settingsBody.settings ?? {} + savedSettingsData = structuredClone(settingsData) + settingsSchema = schemaBody.categories ?? [] + activeSettingsCategory = settingsSchema[0]?.value ?? null + stagedSettingsChanges.clear() + updateStagedChangesUi() + renderSettingsDiff() + settingsLoaded = true + + renderSettingsCategoryList() + renderSettingsFields() + } catch { + showSettingsError('Network error while loading settings.') + } finally { + settingsLoading = false + } + } + + const validateAllSettings = async () => { + hideSettingsAlerts() + + try { + const response = await fetch(`${adminBase}/settings/validate`, { + method: 'POST', + credentials: 'include', + }) + + const body = await response.json().catch(() => ({})) + + if (!response.ok) { + showSettingsError('Validation request failed.') + return + } + + if (!settingsValidation) { + return + } + + settingsValidation.classList.remove('d-none', 'valid') + + if (body.valid) { + settingsValidation.textContent = 'All settings are valid.' + settingsValidation.classList.add('valid') + return + } + + const issues = Array.isArray(body.issues) ? body.issues : [] + settingsValidation.textContent = issues.map((issue) => `${issue.path}: ${issue.message}`).join(' | ') + } catch { + showSettingsError('Network error while validating settings.') + } + } + + const restoreLatestBackup = async () => { + hideSettingsAlerts() + + try { + const backupsResponse = await fetch(`${adminBase}/settings/backups`, { credentials: 'include' }) + const backupsBody = await backupsResponse.json().catch(() => ({})) + + if (!backupsResponse.ok) { + showSettingsError('Unable to list settings backups.') + return + } + + const latestBackup = backupsBody.backups?.[0] + if (!latestBackup?.filename) { + showSettingsError('No settings backups are available yet.') + return + } + + if (!window.confirm(`Restore backup ${latestBackup.filename}? Current settings will be backed up first.`)) { + return + } + + const restoreResponse = await fetch(`${adminBase}/settings/restore`, { + method: 'POST', + credentials: 'include', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ filename: latestBackup.filename }), + }) + const restoreBody = await restoreResponse.json().catch(() => ({})) + + if (!restoreResponse.ok) { + showSettingsError(restoreBody.error || 'Restore failed.') + return + } + + settingsLoaded = false + stagedSettingsChanges.clear() + updateStagedChangesUi() + renderSettingsDiff() + await loadSettingsData(true) + showSettingsSuccess(`Restored ${latestBackup.filename}.`) + } catch { + showSettingsError('Network error while restoring settings backup.') + } + } + + const applyStagedSettings = async () => { + syncPendingFromVisibleFields() + + if (stagedSettingsChanges.size === 0 || !settingsApplyButton) { + return + } + + hideSettingsAlerts() + settingsApplyButton.disabled = true + + try { + const response = await fetch(`${adminBase}/settings`, { + method: 'PATCH', + credentials: 'include', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + changes: [...stagedSettingsChanges.values()].map(({ path, value }) => ({ path, value })), + }), + }) + const body = await response.json().catch(() => ({})) + + if (!response.ok) { + const issues = Array.isArray(body.issues) ? body.issues : [] + const issueText = issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ') + showSettingsError(issueText || body.error || 'Settings update rejected.') + return + } + + const restartRequired = (body.changes ?? []).some((change) => change.reload === 'restart-required') + const successMessage = restartRequired + ? 'Settings saved atomically with a backup. Restart the relay to activate marked changes.' + : 'Settings saved atomically with a backup. Changes will hot reload.' + settingsLoaded = false + await loadSettingsData(true) + showSettingsSuccess(successMessage) + } catch { + showSettingsError('Network error while applying staged settings.') + } finally { + updateStagedChangesUi() + } + } + + const showView = (viewId) => { + dashboardViews.forEach((view) => { + view.classList.toggle('d-none', view.id !== viewId) + }) + + navItems.forEach((item) => { + item.classList.toggle('active', item.dataset.view === viewId) + }) + + if (viewId === 'settings-view') { + void loadSettingsData() + return + } + + if (viewId === 'metrics-view') { + startMetricsStream() + } + } + + const showLoginError = (message) => { + loginError.textContent = message + loginError.classList.remove('d-none') + } + + const refreshGrafanaFrames = () => { + const currentTheme = document.body.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' + const baseUrl = (config.grafanaBaseUrl || config.grafanaUrl || 'http://127.0.0.1:3000').replace(/\/+$/, '') + const dashboardUid = config.grafanaDashboardUid || 'nostream-overview' + + document.querySelectorAll('.grafana-frame').forEach((frame) => { + if (!frame.src || !frame.dataset.panelId) { + return + } + + const params = new URLSearchParams({ + orgId: '1', + panelId: frame.dataset.panelId, + refresh: '5s', + theme: currentTheme, + }) + frame.src = `${baseUrl}/d-solo/${dashboardUid}?${params.toString()}` + }) + } + + const updateSnapshot = (snapshot) => { + lastSnapshot = snapshot + + const statusClass = + snapshot.status === 'ok' + ? 'status-ok' + : snapshot.status === 'degraded' + ? 'status-degraded' + : 'status-unavailable' + + setStatusText('metric-status', statusLabels.system[snapshot.status] ?? statusLabels.system.unavailable, statusClass) + setStatusText( + 'metric-prometheus', + snapshot.prometheus?.available ? statusLabels.prometheus.online : statusLabels.prometheus.noData, + snapshot.prometheus?.available ? 'status-ok' : 'status-no-data', + ) + setStatusText( + 'metric-database', + snapshot.health?.database?.ok ? statusLabels.database.healthy : statusLabels.database.fault, + snapshot.health?.database?.ok ? 'status-ok' : 'status-down', + ) + setStatusText( + 'metric-redis', + snapshot.health?.redis?.ok ? statusLabels.redis.healthy : statusLabels.redis.fault, + snapshot.health?.redis?.ok ? 'status-ok' : 'status-down', + ) + + setMetricValue('metric-events-per-second', snapshot.metrics?.eventsPerSecond, 2) + setMetricValue('metric-events-rejected-per-second', snapshot.metrics?.eventsRejectedPerSecond, 2) + setMetricValue('metric-active-connections', snapshot.metrics?.activeConnections) + setMetricValue('metric-cpu-load', snapshot.metrics?.cpuLoadPercent, 1) + setMetricValue( + 'metric-events-total', + sumMetricValues(snapshot.metrics?.eventsAcceptedTotal, snapshot.metrics?.eventsRejectedTotal), + ) + setMetricValue('metric-accepted-total', snapshot.metrics?.eventsAcceptedTotal) + setMetricValue('metric-rejected-total', snapshot.metrics?.eventsRejectedTotal) + setMetricValue('metric-memory-used', snapshot.metrics?.memoryUsedMb, 1) + + setSyncLine(snapshot.timestamp) + scheduleRelativeTimeRefresh(snapshot.timestamp) + updateSystemAlert(snapshot) + } + + const clearReconnectTimer = () => { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = undefined + } + } + + const stopMetricsStream = () => { + streamClosedIntentionally = true + clearReconnectTimer() + if (relativeTimeTimer) { + clearInterval(relativeTimeTimer) + relativeTimeTimer = undefined + } + stopStaleCheck() + + if (metricsSource) { + metricsSource.close() + metricsSource = undefined + } + + setStreamStatus(statusLabels.stream.offline) + } + + const startMetricsStream = () => { + streamClosedIntentionally = false + stopMetricsStream() + streamClosedIntentionally = false + setStreamStatus(statusLabels.stream.init) + + metricsSource = new EventSource(`${adminBase}/metrics`, { withCredentials: true }) + + metricsSource.onopen = () => { + reconnectAttempt = 0 + setStreamStatus(statusLabels.stream.live, 'live') + startStaleCheck() + } + + metricsSource.onmessage = (event) => { + try { + updateSnapshot(JSON.parse(event.data)) + checkStaleSnapshot() + } catch { + setStreamStatus(statusLabels.stream.parseError, 'error') + } + } + + metricsSource.onerror = () => { + if (streamClosedIntentionally) { + return + } + + if (metricsSource) { + metricsSource.close() + metricsSource = undefined + } + + setStreamStatus(statusLabels.stream.reconnect, 'warn') + + const delayMs = Math.min(30000, 1000 * 2 ** reconnectAttempt) + reconnectAttempt += 1 + clearReconnectTimer() + reconnectTimer = setTimeout(() => { + startMetricsStream() + }, delayMs) + } + } + + const copyLatestSnapshot = async () => { + if (!copySnapshotButton || !lastSnapshot) { + return + } + + try { + await navigator.clipboard.writeText(JSON.stringify(lastSnapshot, null, 2)) + const original = copySnapshotButton.textContent + copySnapshotButton.textContent = 'Copied' + copySnapshotButton.classList.add('copied') + setTimeout(() => { + copySnapshotButton.textContent = original + copySnapshotButton.classList.remove('copied') + }, 2000) + } catch { + copySnapshotButton.textContent = 'Failed' + setTimeout(() => { + copySnapshotButton.textContent = 'Copy JSON' + }, 2000) + } + } + + const checkSession = async () => { + const response = await fetch(`${adminBase}/session`, { + credentials: 'include', + }) + + if (response.status === 401) { + showLogin() + return + } + + if (!response.ok) { + showLoginError('Unable to verify session.') + showLogin() + return + } + + const body = await response.json() + if (body.authenticated) { + showDashboard() + return + } + + showLogin() + } + + loginForm.addEventListener('submit', async (event) => { + event.preventDefault() + loginError.classList.add('d-none') + loginButton.disabled = true + + try { + const response = await fetch(`${adminBase}/login`, { + method: 'POST', + credentials: 'include', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + password: passwordInput.value, + }), + }) + + if (response.status === 401) { + showLoginError('Invalid password.') + return + } + + if (!response.ok) { + showLoginError('Sign in failed.') + return + } + + showDashboard() + } catch { + showLoginError('Network error. Try again.') + } finally { + loginButton.disabled = false + } + }) + + logoutButton.addEventListener('click', async () => { + try { + await fetch(`${adminBase}/logout`, { + method: 'POST', + credentials: 'include', + }) + } catch { + // Still return to the login screen if the network request fails. + } + + showLogin() + }) + + copySnapshotButton?.addEventListener('click', () => { + void copyLatestSnapshot() + }) + + if (menuToggle) { + menuToggle.addEventListener('click', () => { + const isOpen = document.body.classList.contains('nav-open') + setNavOpen(!isOpen) + }) + } + + navOverlay?.addEventListener('click', () => { + setNavOpen(false) + }) + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + setNavOpen(false) + return + } + + if (isTypingTarget(event.target)) { + return + } + + if (event.key === 't' || event.key === 'T') { + const current = document.body.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' + const nextTheme = current === 'dark' ? 'light' : 'dark' + localStorage.setItem('admin_theme', nextTheme) + applyTheme(nextTheme) + refreshGrafanaFrames() + } + + if ((event.key === 'l' || event.key === 'L') && !dashboardPanel.classList.contains('d-none')) { + logoutButton?.click() + } + }) + + navItems.forEach((item) => { + item.addEventListener('click', () => { + const viewId = item.dataset.view + if (!viewId) { + return + } + + showView(viewId) + setNavOpen(false) + }) + }) + + settingsValidateButton?.addEventListener('click', () => { + void validateAllSettings() + }) + + settingsPreviewButton?.addEventListener('click', () => { + syncPendingFromVisibleFields() + renderSettingsDiff({ highlight: true, showEmptyMessage: true }) + settingsDiff?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + }) + + settingsApplyButton?.addEventListener('click', () => { + void applyStagedSettings() + }) + + settingsDiscardButton?.addEventListener('click', () => { + discardStagedChanges() + }) + + settingsRestoreButton?.addEventListener('click', () => { + void restoreLatestBackup() + }) + + settingsReloadButton?.addEventListener('click', () => { + settingsLoaded = false + void loadSettingsData(true) + }) + + if (themeToggle) { + themeToggle.addEventListener('click', () => { + const current = document.body.getAttribute('data-theme') === 'dark' ? 'dark' : 'light' + const nextTheme = current === 'dark' ? 'light' : 'dark' + localStorage.setItem('admin_theme', nextTheme) + applyTheme(nextTheme) + refreshGrafanaFrames() + }) + } + + if (liveClock) { + liveClock.textContent = formatClock() + setInterval(() => { + liveClock.textContent = formatClock() + }, 1000) + } + + applyTheme(getTheme()) + checkSession() +})() diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html new file mode 100644 index 00000000..ee74792e --- /dev/null +++ b/resources/admin/dashboard.html @@ -0,0 +1,241 @@ + + + + + + {{name}} Admin + + + + + + + +
+
+
+ +
+

> Admin Console

+

{{name}}

+
+
+
+ +
+

--:--:--

+

UTC

+
+
+
+ Connecting + + Grafana + +
+
+ +
+
+ +
+
+ +
+ + + +
+ +
+
+

Health

+
+
+
+

SYS System

+

Pending

+
+
+
+
+

PROM Prometheus

+

Pending

+
+
+
+
+

DB Database

+

Pending

+
+
+
+
+

RDS Redis

+

Pending

+
+
+
+
+ +
+

Throughput

+
+
+
+

IN Events / sec

+

0 /s

+
+
+
+
+

RJ Rejected / sec

+

0 /s

+
+
+
+
+

WS Connections

+

0

+
+
+
+
+ +
+

Events

+
+
+
+

SUM Total

+

0

+
+
+
+
+

OK Accepted

+

0

+
+
+
+
+

ERR Rejected

+

0

+
+
+
+
+ +
+

Resources

+
+
+
+

CPU Load

+

0 %

+
+
+
+
+

MEM Used

+

0 MB

+
+
+
+
+ +

> sync: waiting for metrics…

+ +
+

Charts

+
+
+
+
CHT Events per second (15m)
+ +
+
+
+
+
CHT WebSocket connections (15m)
+ +
+
+
+
+
CHT Memory by worker
+ +
+
+
+
+
CHT CPU by worker
+ +
+
+
+
+
+ +
+
+

Configuration

+
+ + + + + + +
+ +
+
+
+
+
+

Select a category to edit guided settings.

+
+
+
+

Preview diff

+

+
+

Secrets redacted in API responses.

+
+

Edit a setting to preview changes here.

+
+
+
+
+
+
+
+ + + + + diff --git a/src/@types/admin.ts b/src/@types/admin.ts index 955e185f..0a742db1 100644 --- a/src/@types/admin.ts +++ b/src/@types/admin.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express' export interface IAdminAuthProvider { handleLogin(request: Request, response: Response): Promise + handleLogout(request: Request, response: Response): void isRequestAuthenticated(request: Request): boolean getSessionExpiresAt(request: Request): number | undefined } diff --git a/src/admin/password-admin-auth-provider.ts b/src/admin/password-admin-auth-provider.ts index 3b6f9dfc..9111934a 100644 --- a/src/admin/password-admin-auth-provider.ts +++ b/src/admin/password-admin-auth-provider.ts @@ -5,6 +5,7 @@ import { Settings } from '../@types/settings' import { adminLoginBodySchema } from '../schemas/admin-login-schema' import { verifyAdminPasswordHash, verifyPlaintextPassword } from '../utils/admin-password' import { + buildAdminSessionClearCookieHeader, buildAdminSessionCookieHeader, createAdminSessionToken, getAdminSessionTokenFromRequest, @@ -46,6 +47,16 @@ export class PasswordAdminAuthProvider implements IAdminAuthProvider { } } + public handleLogout(request: Request, response: Response): void { + const currentSettings = this.settings() + + response + .status(200) + .setHeader('content-type', 'application/json') + .setHeader('Set-Cookie', buildAdminSessionClearCookieHeader(request, currentSettings)) + .send({ authenticated: false }) + } + public isRequestAuthenticated(request: Request): boolean { const token = this.getToken(request) return token ? isValidAdminSessionToken(token) : false diff --git a/src/app/app.ts b/src/app/app.ts index 8569493b..cccdc607 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -52,7 +52,7 @@ export class App implements IRunnable { const port = process.env.RELAY_PORT ? Number(process.env.RELAY_PORT) : 8008 const logCentered = (input: string, width: number) => { - const start = (width - input.length) >> 1 + const start = Math.max(0, (width - input.length) >> 1) logger.info(' '.repeat(start), input) } logCentered(`v${packageJson.version}`, width) diff --git a/src/controllers/admin/get-settings-backups-controller.ts b/src/controllers/admin/get-settings-backups-controller.ts new file mode 100644 index 00000000..c56cca3c --- /dev/null +++ b/src/controllers/admin/get-settings-backups-controller.ts @@ -0,0 +1,10 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { listSettingsBackups } from '../../utils/settings-config' + +export class GetAdminSettingsBackupsController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + response.status(200).setHeader('content-type', 'application/json').send({ backups: listSettingsBackups() }) + } +} diff --git a/src/controllers/admin/patch-settings-controller.ts b/src/controllers/admin/patch-settings-controller.ts index 0f409980..7914d3b8 100644 --- a/src/controllers/admin/patch-settings-controller.ts +++ b/src/controllers/admin/patch-settings-controller.ts @@ -4,6 +4,7 @@ import { Settings } from '../../@types/settings' import { IController } from '../../@types/controllers' import { adminSettingsPatchBodySchema } from '../../schemas/admin-settings-schema' import { + appendSettingsAuditLog, getByPath, loadMergedSettings, loadUserSettings, @@ -26,33 +27,30 @@ export class PatchAdminSettingsController implements IController { return } - const { path, value } = validation.value + const changes = 'changes' in validation.value ? validation.value.changes : [validation.value] + const issues = changes.flatMap(({ path }) => { + if (isWriteProtectedSettingsPath(path)) { + return [{ path, message: 'Path is write-protected' }] + } - if (isWriteProtectedSettingsPath(path)) { - response - .status(400) - .setHeader('content-type', 'application/json') - .send({ - error: 'Validation failed', - issues: [{ path, message: 'Path is write-protected' }], - }) - return - } + return validatePathAgainstDefaults(path) + }) - const pathIssues = validatePathAgainstDefaults(path) - if (pathIssues.length > 0) { - response - .status(400) - .setHeader('content-type', 'application/json') - .send({ error: 'Validation failed', issues: pathIssues }) + if (issues.length > 0) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Validation failed', issues }) return } const userSettings = loadUserSettings() as unknown as Record - const nextUserSettings = setByPath(userSettings, path, value) - + const nextUserSettings = changes.reduce( + (settings, change) => setByPath(settings, change.path, change.value), + userSettings, + ) const merged = loadMergedSettings() as unknown as Record - const mergedNext = setByPath(merged, path, getByPath(nextUserSettings, path)) + const mergedNext = changes.reduce( + (settings, change) => setByPath(settings, change.path, getByPath(nextUserSettings, change.path)), + merged, + ) const validationIssues = validateSettings(mergedNext as unknown as Settings) if (validationIssues.length > 0) { @@ -64,13 +62,31 @@ export class PatchAdminSettingsController implements IController { } saveSettings(nextUserSettings as unknown as Settings) - - const updatedValue = redactSettingsValue(path, getByPath(nextUserSettings, path)) - - response.status(200).setHeader('content-type', 'application/json').send({ - ok: true, + const updatedChanges = changes.map(({ path }) => ({ path, - value: updatedValue, + value: redactSettingsValue(path, getByPath(nextUserSettings, path)), + reload: getSettingsReloadBehavior(path), + })) + appendSettingsAuditLog({ + action: 'settings.updated', + changes: updatedChanges.map(({ path, reload }) => ({ path, reload })), + remoteAddress: request.ip, }) + + if (changes.length === 1 && !('changes' in validation.value)) { + const [change] = updatedChanges + response.status(200).setHeader('content-type', 'application/json').send({ ok: true, ...change }) + return + } + + response.status(200).setHeader('content-type', 'application/json').send({ ok: true, changes: updatedChanges }) } } + +const getSettingsReloadBehavior = (path: string): 'hot-reload' | 'restart-required' => { + if (path.startsWith('workers.') || path.startsWith('payments') || path.startsWith('network.')) { + return 'restart-required' + } + + return 'hot-reload' +} diff --git a/src/controllers/admin/post-logout-controller.ts b/src/controllers/admin/post-logout-controller.ts new file mode 100644 index 00000000..50fc7829 --- /dev/null +++ b/src/controllers/admin/post-logout-controller.ts @@ -0,0 +1,12 @@ +import { Request, Response } from 'express' + +import { IAdminAuthProvider } from '../../@types/admin' +import { IController } from '../../@types/controllers' + +export class PostAdminLogoutController implements IController { + public constructor(private readonly authProvider: IAdminAuthProvider) {} + + public async handleRequest(request: Request, response: Response): Promise { + this.authProvider.handleLogout(request, response) + } +} diff --git a/src/controllers/admin/post-settings-restore-controller.ts b/src/controllers/admin/post-settings-restore-controller.ts new file mode 100644 index 00000000..aadcca09 --- /dev/null +++ b/src/controllers/admin/post-settings-restore-controller.ts @@ -0,0 +1,30 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { adminSettingsRestoreBodySchema } from '../../schemas/admin-settings-schema' +import { appendSettingsAuditLog, restoreSettingsBackup } from '../../utils/settings-config' +import { validateSchema } from '../../utils/validation' + +export class PostAdminSettingsRestoreController implements IController { + public async handleRequest(request: Request, response: Response): Promise { + const validation = validateSchema(adminSettingsRestoreBodySchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) + return + } + + try { + restoreSettingsBackup(validation.value.filename) + appendSettingsAuditLog({ + action: 'settings.restored', + filename: validation.value.filename, + remoteAddress: request.ip, + }) + + response.status(200).setHeader('content-type', 'application/json').send({ ok: true, filename: validation.value.filename }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Restore failed' + response.status(400).setHeader('content-type', 'application/json').send({ error: message }) + } + } +} diff --git a/src/factories/controllers/get-admin-settings-backups-controller-factory.ts b/src/factories/controllers/get-admin-settings-backups-controller-factory.ts new file mode 100644 index 00000000..2471ccce --- /dev/null +++ b/src/factories/controllers/get-admin-settings-backups-controller-factory.ts @@ -0,0 +1,6 @@ +import { GetAdminSettingsBackupsController } from '../../controllers/admin/get-settings-backups-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminSettingsBackupsController = (): IController => { + return new GetAdminSettingsBackupsController() +} diff --git a/src/factories/controllers/post-admin-logout-controller-factory.ts b/src/factories/controllers/post-admin-logout-controller-factory.ts new file mode 100644 index 00000000..3b11e0ac --- /dev/null +++ b/src/factories/controllers/post-admin-logout-controller-factory.ts @@ -0,0 +1,7 @@ +import { PostAdminLogoutController } from '../../controllers/admin/post-logout-controller' +import { IController } from '../../@types/controllers' +import { createAdminAuthProvider } from '../admin-auth-provider-factory' + +export const createPostAdminLogoutController = (): IController => { + return new PostAdminLogoutController(createAdminAuthProvider()) +} diff --git a/src/factories/controllers/post-admin-settings-restore-controller-factory.ts b/src/factories/controllers/post-admin-settings-restore-controller-factory.ts new file mode 100644 index 00000000..19fdb215 --- /dev/null +++ b/src/factories/controllers/post-admin-settings-restore-controller-factory.ts @@ -0,0 +1,6 @@ +import { PostAdminSettingsRestoreController } from '../../controllers/admin/post-settings-restore-controller' +import { IController } from '../../@types/controllers' + +export const createPostAdminSettingsRestoreController = (): IController => { + return new PostAdminSettingsRestoreController() +} diff --git a/src/factories/web-app-factory.ts b/src/factories/web-app-factory.ts index 697b262c..88112ab5 100644 --- a/src/factories/web-app-factory.ts +++ b/src/factories/web-app-factory.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'crypto' import { createSettings } from './settings-factory' import router from '../routes' +import { getGrafanaFrameOrigin } from '../utils/admin-grafana' export const createWebApp = (): Express => { const app = express() @@ -15,12 +16,13 @@ export const createWebApp = (): Express => { const relayUrl = new URL(settings.info.relay_url) const webRelayUrl = new URL(relayUrl.toString()) - webRelayUrl.protocol = relayUrl.protocol === 'wss:' ? 'https:' : ':' + webRelayUrl.protocol = relayUrl.protocol === 'wss:' ? 'https:' : 'http:' const directives = { 'img-src': ["'self'", 'data:', 'https://cdn.zebedee.io/an/nostr/'], 'connect-src': ["'self'", settings.info.relay_url as string, webRelayUrl.toString()], 'default-src': ["'self'"], + 'frame-src': ["'self'", getGrafanaFrameOrigin()], 'script-src-attr': [`'nonce-${nonce}'`], 'script-src': [ "'self'", diff --git a/src/handlers/event-message-handler.ts b/src/handlers/event-message-handler.ts index 5ac88b71..cae6541a 100644 --- a/src/handlers/event-message-handler.ts +++ b/src/handlers/event-message-handler.ts @@ -358,7 +358,14 @@ export class EventMessageHandler implements IMessageHandler { continue } - const isRateLimited = await hit({ period, rate, kinds }) + let isRateLimited = false + try { + isRateLimited = await hit({ period, rate, kinds }) + } catch (error) { + // Redis outages should not make the relay reject/abort incoming events. + logger('rate limiter unavailable for %s (%d/%d): %o', event.pubkey, rate, period, error) + continue + } if (isRateLimited) { logger('rate limited %s: %d events / %d ms exceeded', event.pubkey, rate, period) diff --git a/src/handlers/request-handlers/get-admin-dashboard-request-handler.ts b/src/handlers/request-handlers/get-admin-dashboard-request-handler.ts new file mode 100644 index 00000000..34afafe6 --- /dev/null +++ b/src/handlers/request-handlers/get-admin-dashboard-request-handler.ts @@ -0,0 +1,31 @@ +import { NextFunction, Request, Response } from 'express' + +import { createSettings as settings } from '../../factories/settings-factory' +import { getGrafanaBaseUrl, getGrafanaDashboardUid, getGrafanaDashboardUrl, getGrafanaEmbedUrl } from '../../utils/admin-grafana' +import { escapeHtml } from '../../utils/html' +import { getPublicPathPrefix } from '../../utils/http' +import { getTemplate } from '../../utils/template-cache' + +export const getAdminDashboardRequestHandler = (req: Request, res: Response, next: NextFunction) => { + const currentSettings = settings() + const { + info: { name }, + } = currentSettings + + let page: string + try { + page = getTemplate('./resources/admin/dashboard.html') + .replaceAll('{{name}}', escapeHtml(name)) + .replaceAll('{{path_prefix}}', escapeHtml(getPublicPathPrefix(req, currentSettings))) + .replaceAll('{{grafana_embed_url}}', escapeHtml(getGrafanaEmbedUrl())) + .replaceAll('{{grafana_url}}', escapeHtml(getGrafanaDashboardUrl())) + .replaceAll('{{grafana_base_url}}', escapeHtml(getGrafanaBaseUrl())) + .replaceAll('{{grafana_dashboard_uid}}', escapeHtml(getGrafanaDashboardUid())) + .replaceAll('{{nonce}}', res.locals.nonce) + } catch (err) { + next(err) + return + } + + res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(page) +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index f00ef280..6879b826 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -1,12 +1,15 @@ -import { json, Router } from 'express' +import express, { json, Router } from 'express' import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory' import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory' import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory' import { createGetAdminSettingsController } from '../../factories/controllers/get-admin-settings-controller-factory' +import { createGetAdminSettingsBackupsController } from '../../factories/controllers/get-admin-settings-backups-controller-factory' import { createGetAdminSettingsSchemaController } from '../../factories/controllers/get-admin-settings-schema-controller-factory' import { createPatchAdminSettingsController } from '../../factories/controllers/patch-admin-settings-controller-factory' import { createPostAdminLoginController } from '../../factories/controllers/post-admin-login-controller-factory' +import { createPostAdminLogoutController } from '../../factories/controllers/post-admin-logout-controller-factory' +import { createPostAdminSettingsRestoreController } from '../../factories/controllers/post-admin-settings-restore-controller-factory' import { createPostAdminSettingsValidateController } from '../../factories/controllers/post-admin-settings-validate-controller-factory' import { adminAuthMiddleware } from '../../handlers/request-handlers/admin-auth-middleware' import { adminEnabledMiddleware } from '../../handlers/request-handlers/admin-enabled-middleware' @@ -14,6 +17,7 @@ import { adminLoginRateLimitMiddleware, adminRateLimitMiddleware, } from '../../handlers/request-handlers/admin-rate-limit-middleware' +import { getAdminDashboardRequestHandler } from '../../handlers/request-handlers/get-admin-dashboard-request-handler' import { rateLimiterMiddleware } from '../../handlers/request-handlers/rate-limiter-middleware' import { withAdminController } from '../../handlers/request-handlers/with-admin-controller-request-handler' @@ -23,23 +27,42 @@ const router: Router = Router() router.use(rateLimiterMiddleware) // codeql[js/missing-rate-limiting] - feature gate only, not authentication router.use(adminEnabledMiddleware) +router.use('/assets', express.static('./resources/admin/assets')) +router.get('/', getAdminDashboardRequestHandler) +router.get('/dashboard', getAdminDashboardRequestHandler) router.post('/login', adminLoginRateLimitMiddleware, json(), withAdminController(createPostAdminLoginController)) +router.post('/logout', adminRateLimitMiddleware, withAdminController(createPostAdminLogoutController)) router.get('/session', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSessionController)) router.get('/health', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminHealthController)) router.get('/metrics', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminMetricsController)) router.get('/settings', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSettingsController)) +router.get( + '/settings/backups', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminSettingsBackupsController), +) router.get( '/settings/schema', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSettingsSchemaController), ) +// codeql[js/missing-rate-limiting] - adminRateLimitMiddleware applies Redis-backed admin rate limits router.patch('/settings', adminRateLimitMiddleware, adminAuthMiddleware, json(), withAdminController(createPatchAdminSettingsController)) +// codeql[js/missing-rate-limiting] - adminRateLimitMiddleware applies Redis-backed admin rate limits router.post( '/settings/validate', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createPostAdminSettingsValidateController), ) +router.post( + '/settings/restore', + adminRateLimitMiddleware, + adminAuthMiddleware, + json(), + withAdminController(createPostAdminSettingsRestoreController), +) export default router diff --git a/src/schemas/admin-settings-schema.ts b/src/schemas/admin-settings-schema.ts index b6c28d28..86ea08ae 100644 --- a/src/schemas/admin-settings-schema.ts +++ b/src/schemas/admin-settings-schema.ts @@ -1,8 +1,23 @@ import { z } from 'zod' -export const adminSettingsPatchBodySchema = z +const adminSettingsChangeSchema = z .object({ path: z.string().min(1), value: z.custom((input) => input !== undefined, { message: 'value is required' }), }) .strict() + +export const adminSettingsPatchBodySchema = z.union([ + adminSettingsChangeSchema, + z + .object({ + changes: z.array(adminSettingsChangeSchema).min(1), + }) + .strict(), +]) + +export const adminSettingsRestoreBodySchema = z + .object({ + filename: z.string().min(1), + }) + .strict() diff --git a/src/utils/admin-grafana.ts b/src/utils/admin-grafana.ts new file mode 100644 index 00000000..050fe99a --- /dev/null +++ b/src/utils/admin-grafana.ts @@ -0,0 +1,48 @@ +const DEFAULT_GRAFANA_URL = 'http://127.0.0.1:3000' +const DEFAULT_DASHBOARD_UID = 'nostream-overview' + +export const getGrafanaBaseUrl = (): string => { + const configured = process.env.GRAFANA_URL?.trim() + if (!configured) { + return DEFAULT_GRAFANA_URL + } + + return configured.replace(/\/+$/, '') +} + +export const getGrafanaDashboardUid = (): string => { + const configured = process.env.GRAFANA_DASHBOARD_UID?.trim() + return configured && configured.length > 0 ? configured : DEFAULT_DASHBOARD_UID +} + +export const getGrafanaDashboardUrl = (): string => { + const baseUrl = getGrafanaBaseUrl() + const dashboardUid = getGrafanaDashboardUid() + + return `${baseUrl}/d/${dashboardUid}/nostream-overview?orgId=1&refresh=5s&theme=light` +} + +export const getGrafanaSoloPanelUrl = (panelId: number | string, theme: 'light' | 'dark' = 'light'): string => { + const baseUrl = getGrafanaBaseUrl() + const dashboardUid = getGrafanaDashboardUid() + const params = new URLSearchParams({ + orgId: '1', + panelId: String(panelId), + refresh: '5s', + theme, + }) + + return `${baseUrl}/d-solo/${dashboardUid}?${params.toString()}` +} + +export const getGrafanaEmbedUrl = (): string => { + return `${getGrafanaDashboardUrl()}&kiosk` +} + +export const getGrafanaFrameOrigin = (): string => { + try { + return new URL(getGrafanaBaseUrl()).origin + } catch { + return new URL(DEFAULT_GRAFANA_URL).origin + } +} diff --git a/src/utils/admin-health.ts b/src/utils/admin-health.ts index 903b6c8f..97451e45 100644 --- a/src/utils/admin-health.ts +++ b/src/utils/admin-health.ts @@ -1,5 +1,6 @@ import { getCacheClient } from '../cache/client' import { getMasterDbClient } from '../database/client' +import { delayMs } from './misc' export interface AdminDependencyHealth { ok: boolean @@ -16,9 +17,17 @@ export interface AdminHealthSnapshot { redis: AdminDependencyHealth } +export const getAdminDependencyPingTimeoutMs = (): number => { + const timeoutCandidate = Number(process.env.ADMIN_DEPENDENCY_PING_TIMEOUT_MS) + if (!Number.isFinite(timeoutCandidate) || timeoutCandidate < 100) { + return 3000 + } + + return timeoutCandidate +} + export const collectAdminHealthSnapshot = async (): Promise => { - const database = await pingDatabase() - const redis = await pingRedis() + const [database, redis] = await Promise.all([pingDatabase(), pingRedis()]) return { status: database.ok && redis.ok ? 'ok' : 'degraded', @@ -32,9 +41,20 @@ export const collectAdminHealthSnapshot = async (): Promise } } +const withTimeout = async (operation: Promise, timeoutMs: number): Promise => { + return Promise.race([ + operation, + delayMs(timeoutMs).then(() => { + throw new Error(`dependency ping timed out after ${timeoutMs}ms`) + }), + ]) +} + const pingDatabase = async (): Promise => { + const timeoutMs = getAdminDependencyPingTimeoutMs() + try { - await getMasterDbClient().raw('SELECT 1') + await withTimeout(getMasterDbClient().raw('SELECT 1'), timeoutMs) return { ok: true } } catch { return { ok: false } @@ -42,12 +62,14 @@ const pingDatabase = async (): Promise => { } const pingRedis = async (): Promise => { + const timeoutMs = getAdminDependencyPingTimeoutMs() + try { const client = getCacheClient() if (!client.isOpen) { - await client.connect() + await withTimeout(client.connect(), timeoutMs) } - const pong = await client.ping() + const pong = await withTimeout(client.ping(), timeoutMs) return { ok: pong === 'PONG' } } catch { return { ok: false } diff --git a/src/utils/admin-session.ts b/src/utils/admin-session.ts index 124e08a0..e83ed25c 100644 --- a/src/utils/admin-session.ts +++ b/src/utils/admin-session.ts @@ -30,6 +30,13 @@ export const buildAdminSessionCookieHeader = ( return `admin_session=${token}; Path=${cookiePath}; HttpOnly; SameSite=Strict; Max-Age=${maxAgeSeconds}${secure}` } +export const buildAdminSessionClearCookieHeader = (request: IncomingMessage, settings: Settings): string => { + const cookiePath = joinPathPrefix(getPublicPathPrefix(request, settings), '/admin') + const secure = isSecureRequest(request, settings) ? '; Secure' : '' + + return `admin_session=; Path=${cookiePath}; HttpOnly; SameSite=Strict; Max-Age=0${secure}` +} + export const createAdminSessionToken = (expiresAt: number): string => { const signature = hmacSha256(deriveFromSecret('admin-session'), `${expiresAt}`).toString('hex') return `${expiresAt}.${signature}` diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index 2cf400e6..d16ffd1a 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -26,6 +26,10 @@ export const getSettingsFilePath = (): string => join(getConfigBaseDir(), 'setti export const getDefaultSettingsFilePath = (): string => join(process.cwd(), 'resources', 'default-settings.yaml') +export const getSettingsBackupDir = (): string => join(getConfigBaseDir(), 'backups') + +export const getSettingsAuditLogPath = (): string => join(getConfigBaseDir(), 'settings-audit.jsonl') + export const formatSettingCategoryLabel = (key: string): string => { return key .split(/[_\-.]/) @@ -215,6 +219,10 @@ const validateShape = (schema: unknown, candidate: unknown, path: PathToken[], i } for (const key of Object.keys(candidate)) { + if (renderedPath === 'admin' && key === 'passwordHash') { + continue + } + if (!hasOwn(schema, key)) { issues.push({ path: formatPathTokens([...path, { type: 'key', key }]), @@ -300,7 +308,83 @@ export const loadMergedSettings = (): Settings => { export const saveSettings = (settings: Settings): void => { ensureSettingsExists() const serialized = yaml.dump(toSerializable(settings), { lineWidth: 120 }) - fs.writeFileSync(getSettingsFilePath(), serialized, 'utf-8') + 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[] => { diff --git a/src/utils/settings-guided-schema.ts b/src/utils/settings-guided-schema.ts index 79011dc3..106723a7 100644 --- a/src/utils/settings-guided-schema.ts +++ b/src/utils/settings-guided-schema.ts @@ -36,6 +36,44 @@ export const requireSafeNonNegativeIntegerSettingValue = (value: string): string } export const guidedSettingCategories: GuidedSettingCategory[] = [ + { + value: 'relay-info', + label: 'Relay Info', + 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: 'Description', + path: 'info.description', + type: 'string', + placeholder: 'A nostr relay written in Typescript.', + }, + { + label: 'Contact', + path: 'info.contact', + type: 'string', + placeholder: 'mailto:operator@your-domain.com', + }, + { + label: 'Relay pubkey (hex)', + path: 'info.pubkey', + type: 'string', + placeholder: 'replace-with-your-pubkey-in-hex', + }, + ], + }, { value: 'payments', label: 'Payments', @@ -64,26 +102,18 @@ export const guidedSettingCategories: GuidedSettingCategory[] = [ 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, }, + { + label: 'Worker count', + path: 'workers.count', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, ], }, { @@ -97,7 +127,7 @@ export const guidedSettingCategories: GuidedSettingCategory[] = [ options: ['ewma', 'sliding_window'], }, { - label: 'Primary event content max length', + label: 'Maximum event content length', path: 'limits.event.content[0].maxLength', type: 'number', validate: requireSafeNonNegativeIntegerSettingValue, @@ -108,6 +138,123 @@ export const guidedSettingCategories: GuidedSettingCategory[] = [ type: 'number', validate: requireSafeNonNegativeIntegerSettingValue, }, + { + label: 'Maximum future event time (seconds)', + path: 'limits.event.createdAt.maxPositiveDelta', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + { + label: 'Pubkey blacklist', + path: 'limits.event.pubkey.blacklist', + type: 'stringArray', + placeholder: 'One hex pubkey per line', + }, + { + label: 'Kind whitelist', + path: 'limits.event.kind.whitelist', + type: 'stringArray', + placeholder: 'One kind or range per line, e.g. 1 or 10000-19999', + }, + ], + }, + { + value: 'client', + label: 'Client', + settings: [ + { + label: 'Max subscriptions per connection', + path: 'limits.client.subscription.maxSubscriptions', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + { + label: 'Max filters per subscription', + path: 'limits.client.subscription.maxFilters', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + { + label: 'Max filter values', + path: 'limits.client.subscription.maxFilterValues', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + { + label: 'Max REQ limit value', + path: 'limits.client.subscription.maxLimit', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + ], + }, + { + value: 'retention', + label: 'Retention', + settings: [ + { + label: 'Event retention (days)', + path: 'limits.event.retention.maxDays', + type: 'number', + }, + ], + }, + { + value: 'nip-features', + label: 'NIP Features', + settings: [ + { + label: 'NIP-05 mode', + path: 'nip05.mode', + type: 'select', + options: ['enabled', 'passive', 'disabled'], + }, + { + label: 'NIP-05 domain whitelist', + path: 'nip05.domainWhitelist', + type: 'stringArray', + placeholder: 'One domain per line', + }, + { + label: 'NIP-05 domain blacklist', + path: 'nip05.domainBlacklist', + type: 'stringArray', + placeholder: 'One domain per line', + }, + { label: 'Enable NIP-45', path: 'nip45.enabled', type: 'boolean' }, + { label: 'Enable NIP-50', path: 'nip50.enabled', type: 'boolean' }, + { + label: 'NIP-50 max query length', + path: 'nip50.maxQueryLength', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + { label: 'Enable Web of Trust', path: 'wot.enabled', type: 'boolean' }, + { + label: 'WoT seed pubkey (hex)', + path: 'wot.seedPubkey', + type: 'string', + placeholder: 'Relay owner pubkey in hex', + }, + { + label: 'WoT minimum followers', + path: 'wot.minimumFollowers', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, + ], + }, + { + value: 'admin', + label: 'Admin', + settings: [ + { label: 'Enable admin console', path: 'admin.enabled', type: 'boolean' }, + { + label: 'Session TTL (seconds)', + path: 'admin.sessionTtlSeconds', + type: 'number', + validate: requireSafeNonNegativeIntegerSettingValue, + }, ], }, ] diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 7cf90f6e..ee639d63 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -110,7 +110,6 @@ export class SettingsStatic { const basePath = SettingsStatic.getSettingsFileBasePath() const defaultsFilePath = SettingsStatic.getDefaultSettingsFilePath() const fileType = SettingsStatic.settingsFileType(basePath) - const settingsFilePath = join(basePath, `settings.${fileType}`) const reload = () => { logger('reloading settings') @@ -118,6 +117,12 @@ export class SettingsStatic { SettingsStatic.createSettings() } - return [fs.watch(defaultsFilePath, 'utf8', reload), fs.watch(settingsFilePath, 'utf8', reload)] + const watchSettingsDirectory = (_eventType: string, filename: string | Buffer | null) => { + if (filename?.toString() === `settings.${fileType}`) { + reload() + } + } + + return [fs.watch(defaultsFilePath, 'utf8', reload), fs.watch(basePath, 'utf8', watchSettingsDirectory)] } } diff --git a/test/unit/routes/admin-settings.spec.ts b/test/unit/routes/admin-settings.spec.ts index 16284dc0..04b95df6 100644 --- a/test/unit/routes/admin-settings.spec.ts +++ b/test/unit/routes/admin-settings.spec.ts @@ -10,7 +10,13 @@ import { hashAdminPassword } from '../../../src/utils/admin-password' import * as adminRateLimitMiddleware from '../../../src/handlers/request-handlers/admin-rate-limit-middleware' import * as rateLimiterMiddleware from '../../../src/handlers/request-handlers/rate-limiter-middleware' import * as settingsFactory from '../../../src/factories/settings-factory' -import { getSettingsFilePath, loadDefaults, saveSettings } from '../../../src/utils/settings-config' +import { + getSettingsAuditLogPath, + getSettingsBackupDir, + getSettingsFilePath, + loadDefaults, + saveSettings, +} from '../../../src/utils/settings-config' describe('admin settings API', () => { const originalSecret = process.env.SECRET @@ -196,13 +202,91 @@ describe('admin settings API', () => { ) expect(patchResponse.status).to.equal(200) - expect(patchResponse.data).to.deep.equal({ ok: true, path: 'payments.enabled', value: true }) + expect(patchResponse.data).to.deep.equal({ + ok: true, + path: 'payments.enabled', + value: true, + reload: 'restart-required', + }) const settingsRaw = fs.readFileSync(getSettingsFilePath(), 'utf-8') expect(settingsRaw).to.include('payments:') expect(settingsRaw).to.include('enabled: true') }) + it('atomically applies staged changes with a backup and audit entry', async () => { + const passwordHash = hashAdminPassword('settings-password') + saveSettings({ + ...loadDefaults(), + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + const patchResponse = await axios.patch( + `${baseUrl}/settings`, + { + changes: [ + { path: 'payments.enabled', value: true }, + { path: 'nip50.enabled', value: true }, + ], + }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(patchResponse.status, JSON.stringify(patchResponse.data)).to.equal(200) + expect(patchResponse.data.changes).to.have.length(2) + expect(fs.readdirSync(getSettingsBackupDir()).some((name) => name.startsWith('settings.'))).to.equal(true) + expect(fs.readFileSync(getSettingsAuditLogPath(), 'utf-8')).to.include('settings.updated') + }) + + it('lists and restores the latest settings backup', async () => { + const passwordHash = hashAdminPassword('settings-password') + saveSettings({ + ...loadDefaults(), + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + payments: { ...loadDefaults().payments, enabled: false }, + }) + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + await axios.patch( + `${baseUrl}/settings`, + { path: 'payments.enabled', value: true }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + const backupsResponse = await axios.get(`${baseUrl}/settings/backups`, { + headers: { cookie }, + validateStatus: () => true, + }) + + expect(backupsResponse.status).to.equal(200) + expect(backupsResponse.data.backups.length).to.be.greaterThan(0) + + const restoreResponse = await axios.post( + `${baseUrl}/settings/restore`, + { filename: backupsResponse.data.backups[0].filename }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(restoreResponse.status).to.equal(200) + expect(fs.readFileSync(getSettingsAuditLogPath(), 'utf-8')).to.include('settings.restored') + }) + it('rejects invalid paths and write-protected settings', async () => { const passwordHash = hashAdminPassword('settings-password') const baseUrl = await startServer({