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/.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/resources/admin/assets/dashboard.css b/resources/admin/assets/dashboard.css index f6aaa594..d5782f57 100644 --- a/resources/admin/assets/dashboard.css +++ b/resources/admin/assets/dashboard.css @@ -155,6 +155,11 @@ text-transform: uppercase; } +.section-title .sync-cursor { + margin-left: 0.2rem; + vertical-align: baseline; +} + .panel-card, .metric-card { background: var(--panel); @@ -436,6 +441,319 @@ 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; diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js index 8f5147d8..75e802d8 100644 --- a/resources/admin/assets/dashboard.js +++ b/resources/admin/assets/dashboard.js @@ -20,6 +20,29 @@ 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 @@ -294,6 +317,13 @@ 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' @@ -312,6 +342,713 @@ 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') @@ -584,12 +1321,43 @@ navItems.forEach((item) => { item.addEventListener('click', () => { - navItems.forEach((candidate) => candidate.classList.remove('active')) - item.classList.add('active') + 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' diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html index 31a85ace..ee74792e 100644 --- a/resources/admin/dashboard.html +++ b/resources/admin/dashboard.html @@ -58,11 +58,12 @@

Sign in

-
+

Health

@@ -191,6 +192,39 @@

Charts

+ +
+
+

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/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/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..7914d3b8 --- /dev/null +++ b/src/controllers/admin/patch-settings-controller.ts @@ -0,0 +1,92 @@ +import { Request, Response } from 'express' + +import { Settings } from '../../@types/settings' +import { IController } from '../../@types/controllers' +import { adminSettingsPatchBodySchema } from '../../schemas/admin-settings-schema' +import { + appendSettingsAuditLog, + 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 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' }] + } + + return validatePathAgainstDefaults(path) + }) + + 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 = changes.reduce( + (settings, change) => setByPath(settings, change.path, change.value), + userSettings, + ) + const merged = loadMergedSettings() as unknown as Record + const mergedNext = changes.reduce( + (settings, change) => setByPath(settings, change.path, getByPath(nextUserSettings, change.path)), + merged, + ) + 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 updatedChanges = changes.map(({ path }) => ({ + path, + 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-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/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-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/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-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/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 3e006ca5..6879b826 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -3,8 +3,14 @@ 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' import { @@ -29,5 +35,34 @@ router.post('/logout', adminRateLimitMiddleware, withAdminController(createPostA 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 new file mode 100644 index 00000000..86ea08ae --- /dev/null +++ b/src/schemas/admin-settings-schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' + +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/settings-config.ts b/src/utils/settings-config.ts index e2a38c47..a2ddd2bf 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 toCategoryLabel = (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 9a7bef44..d3279d64 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-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/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/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts index d72bbae0..36381c25 100644 --- a/test/unit/app/maintenance-worker.spec.ts +++ b/test/unit/app/maintenance-worker.spec.ts @@ -10,6 +10,7 @@ import { Nip05Verification } from '../../../src/@types/nip05' import { IMaintenanceService, IPaymentsService } from '../../../src/@types/services' import { Settings } from '../../../src/@types/settings' import { applyReverificationOutcome, MaintenanceWorker } from '../../../src/app/maintenance-worker' +import * as metricsTelemetry from '../../../src/telemetry/metrics' import * as misc from '../../../src/utils/misc' import * as nip05Utils from '../../../src/utils/nip05' @@ -499,6 +500,8 @@ describe('MaintenanceWorker', () => { describe('onExit', () => { it('calls close and then exits the process with code 0', async () => { + sandbox.stub(metricsTelemetry, 'shutdownMetricsTelemetry').resolves() + fakeProcess.emit('SIGTERM') await new Promise((resolve) => setImmediate(resolve)) diff --git a/test/unit/routes/admin-settings.spec.ts b/test/unit/routes/admin-settings.spec.ts new file mode 100644 index 00000000..04b95df6 --- /dev/null +++ b/test/unit/routes/admin-settings.spec.ts @@ -0,0 +1,350 @@ +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 { + getSettingsAuditLogPath, + getSettingsBackupDir, + 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, + 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({ + 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-config.spec.ts b/test/unit/utils/settings-config.spec.ts index 8f4ba1e4..8acaf643 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -1,12 +1,12 @@ import { expect } from 'chai' import { + toCategoryLabel, getByPath, getTopLevelSettingCategories, parseTypedValue, parseValue, setByPath, - toCategoryLabel, validatePathAgainstDefaults, validateSettings, } from '../../../src/utils/settings-config' 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) + }) +})