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.
+
+
+
+
+
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)
+ })
+})