diff --git a/.changeset/admin-trust-automation-919.md b/.changeset/admin-trust-automation-919.md
new file mode 100644
index 00000000..9b1fc3fc
--- /dev/null
+++ b/.changeset/admin-trust-automation-919.md
@@ -0,0 +1,5 @@
+---
+"nostream": minor
+---
+
+Add admin trust and automation: Redis-backed audit log API and UI, signed HTTP webhooks for probe/mirror/SSL events, OpenTelemetry probe and mirror metrics, and redacted diagnostics export.
diff --git a/resources/admin/assets/admin-trust.js b/resources/admin/assets/admin-trust.js
new file mode 100644
index 00000000..c11668fa
--- /dev/null
+++ b/resources/admin/assets/admin-trust.js
@@ -0,0 +1,215 @@
+(() => {
+ const config = window.__ADMIN_DASHBOARD__ || { pathPrefix: '' }
+ const adminBase = `${config.pathPrefix || ''}/admin`
+
+ const auditBody = document.getElementById('trust-audit-body')
+ if (!auditBody) {
+ return
+ }
+
+ const elements = {
+ auditSync: document.getElementById('trust-audit-sync'),
+ auditRefresh: document.getElementById('trust-audit-refresh'),
+ auditBody,
+ diagnosticsDownload: document.getElementById('trust-diagnostics-download'),
+ diagnosticsPreviewButton: document.getElementById('trust-diagnostics-preview-button'),
+ diagnosticsPreviewWrap: document.getElementById('trust-diagnostics-preview-wrap'),
+ diagnosticsPreviewClose: document.getElementById('trust-diagnostics-preview-close'),
+ diagnosticsPreviewPlaceholder: document.getElementById('trust-diagnostics-preview-placeholder'),
+ diagnosticsPreviewBody: document.getElementById('trust-diagnostics-preview-body'),
+ diagnosticsStatus: document.getElementById('trust-diagnostics-status'),
+ error: document.getElementById('trust-error'),
+ }
+
+ const escapeHtml = (value) =>
+ String(value)
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+
+ const formatTime = (iso) => {
+ if (!iso) {
+ return '—'
+ }
+ const date = new Date(iso)
+ if (Number.isNaN(date.getTime())) {
+ return iso
+ }
+ return date.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC')
+ }
+
+ const adminFetch = async (path, options = {}) => {
+ const response = await fetch(`${adminBase}${path}`, {
+ credentials: 'same-origin',
+ ...options,
+ })
+ if (response.status === 401) {
+ throw new Error('Unauthorized')
+ }
+ return response
+ }
+
+ const showError = (message) => {
+ if (!elements.error) {
+ return
+ }
+ elements.error.textContent = message
+ elements.error.classList.remove('d-none')
+ }
+
+ const hideError = () => {
+ elements.error?.classList.add('d-none')
+ }
+
+ const handleUnauthorized = () => {
+ globalThis.dispatchEvent(new CustomEvent('admin-unauthorized'))
+ }
+
+ const handleFetchError = (error, fallbackMessage) => {
+ if (error instanceof Error && error.message === 'Unauthorized') {
+ handleUnauthorized()
+ return
+ }
+ showError(fallbackMessage)
+ }
+
+ const renderAuditEntries = (entries) => {
+ elements.auditBody.replaceChildren()
+ if (!Array.isArray(entries) || entries.length === 0) {
+ elements.auditBody.innerHTML = '
| No audit entries yet. |
'
+ return
+ }
+
+ entries.forEach((entry) => {
+ const row = document.createElement('tr')
+ const detail =
+ entry.detail && Object.keys(entry.detail).length > 0
+ ? `${escapeHtml(JSON.stringify(entry.detail))}`
+ : '—'
+ row.innerHTML = `
+ ${escapeHtml(formatTime(entry.at))} |
+ ${escapeHtml(entry.action ?? '')} |
+ ${escapeHtml(entry.remoteAddress ?? '—')} |
+ ${detail} |
+ `
+ elements.auditBody.appendChild(row)
+ })
+ }
+
+ const loadAuditLog = async () => {
+ hideError()
+ if (elements.auditSync) {
+ elements.auditSync.textContent = '> audit: loading…'
+ }
+
+ try {
+ const response = await adminFetch('/audit-log?limit=100')
+ if (!response.ok) {
+ showError('Failed to load audit log')
+ if (elements.auditSync) {
+ elements.auditSync.textContent = '> audit: load failed'
+ }
+ return
+ }
+
+ const body = await response.json()
+ renderAuditEntries(body.entries ?? [])
+ if (elements.auditSync) {
+ elements.auditSync.textContent = `> audit: ${body.entries?.length ?? 0} entries`
+ }
+ } catch (error) {
+ handleFetchError(error, 'Network error while loading audit log')
+ }
+ }
+
+ const downloadDiagnostics = async () => {
+ hideError()
+ if (elements.diagnosticsStatus) {
+ elements.diagnosticsStatus.textContent = 'Preparing export…'
+ }
+
+ try {
+ const response = await adminFetch('/diagnostics/export?download=1')
+ if (!response.ok) {
+ showError('Diagnostics export failed')
+ return
+ }
+
+ const blob = await response.blob()
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = `nostream-diagnostics-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
+ anchor.click()
+ URL.revokeObjectURL(url)
+ if (elements.diagnosticsStatus) {
+ elements.diagnosticsStatus.textContent = 'Download started (secrets redacted).'
+ }
+ } catch (error) {
+ handleFetchError(error, 'Network error during diagnostics export')
+ }
+ }
+
+ const closeDiagnosticsPreview = () => {
+ elements.diagnosticsPreviewWrap?.classList.add('d-none')
+ elements.diagnosticsPreviewPlaceholder?.classList.remove('d-none')
+ if (elements.diagnosticsPreviewBody) {
+ elements.diagnosticsPreviewBody.textContent = ''
+ }
+ if (elements.diagnosticsStatus) {
+ elements.diagnosticsStatus.innerHTML =
+ '> diagnostics: idle'
+ }
+ }
+
+ const showDiagnosticsPreview = (payload) => {
+ elements.diagnosticsPreviewPlaceholder?.classList.add('d-none')
+ elements.diagnosticsPreviewWrap?.classList.remove('d-none')
+ if (elements.diagnosticsPreviewBody) {
+ elements.diagnosticsPreviewBody.textContent = JSON.stringify(payload, null, 2)
+ }
+ if (elements.diagnosticsStatus) {
+ elements.diagnosticsStatus.textContent = `Generated ${payload.generatedAt ?? ''} · version ${payload.version ?? 'unknown'}`
+ }
+ }
+
+ const previewDiagnostics = async () => {
+ hideError()
+ if (elements.diagnosticsStatus) {
+ elements.diagnosticsStatus.textContent = 'Loading preview…'
+ }
+
+ try {
+ const response = await adminFetch('/diagnostics/export')
+ if (!response.ok) {
+ showError('Diagnostics preview failed')
+ return
+ }
+
+ const payload = await response.json()
+ showDiagnosticsPreview(payload)
+ } catch (error) {
+ handleFetchError(error, 'Network error during diagnostics preview')
+ }
+ }
+
+ elements.auditRefresh?.addEventListener('click', () => {
+ void loadAuditLog()
+ })
+ elements.diagnosticsDownload?.addEventListener('click', () => {
+ void downloadDiagnostics()
+ })
+ elements.diagnosticsPreviewButton?.addEventListener('click', () => {
+ void previewDiagnostics()
+ })
+ elements.diagnosticsPreviewClose?.addEventListener('click', () => {
+ closeDiagnosticsPreview()
+ })
+
+ window.__ADMIN_TRUST__ = {
+ load: loadAuditLog,
+ closeDiagnosticsPreview,
+ }
+})()
diff --git a/resources/admin/assets/dashboard.js b/resources/admin/assets/dashboard.js
index b008c7b3..4b82595b 100644
--- a/resources/admin/assets/dashboard.js
+++ b/resources/admin/assets/dashboard.js
@@ -41,6 +41,7 @@
const networkHealthRunAt = document.getElementById('network-health-run-at')
const networkHealthTimelineWrap = document.getElementById('network-health-timeline-wrap')
const networkHealthTimeline = document.getElementById('network-health-timeline')
+ const networkHealthRunButton = document.getElementById('network-health-run-button')
let settingsLoaded = false
let settingsLoading = false
@@ -220,11 +221,22 @@
streamStatus.appendChild(document.createTextNode(label))
}
- const updateSystemAlert = (snapshot) => {
+ let activeDashboardView = 'metrics-view'
+
+ const hideSystemAlert = () => {
if (!systemAlert) {
return
}
+ systemAlert.className = 'system-alert d-none'
+ systemAlert.textContent = ''
+ }
+
+ const updateSystemAlert = (snapshot) => {
+ if (!systemAlert || activeDashboardView !== 'metrics-view') {
+ return
+ }
+
if (lastSnapshot?.timestamp && Date.now() - lastSnapshot.timestamp > staleThresholdMs) {
systemAlert.textContent = '[WARN] Metrics stale — waiting for backend update.'
systemAlert.className = 'system-alert degraded'
@@ -359,6 +371,7 @@
}
const showDashboard = () => {
+ activeDashboardView = 'metrics-view'
loginPanel.classList.add('d-none')
dashboardPanel.classList.remove('d-none')
dashboardActions.classList.remove('d-none')
@@ -725,6 +738,42 @@
})
}
+ const runNetworkHealthProbe = async () => {
+ if (!networkHealthRunButton) {
+ return
+ }
+
+ networkHealthRunButton.disabled = true
+ setNetworkHealthSyncLine('manual probe running…')
+
+ try {
+ const response = await fetch(`${adminBase}/network-health/run`, {
+ method: 'POST',
+ credentials: 'include',
+ })
+
+ if (response.status === 401) {
+ showLogin()
+ return
+ }
+
+ if (!response.ok) {
+ setNetworkHealthSyncLine('manual probe failed')
+ return
+ }
+
+ networkHealthHistoryPinned = false
+ selectedNetworkHealthRunAt = null
+ await refreshNetworkHealth()
+ await refreshNetworkHealthHistory()
+ setNetworkHealthSyncLine('manual probe completed')
+ } catch {
+ setNetworkHealthSyncLine('network error during manual probe')
+ } finally {
+ networkHealthRunButton.disabled = false
+ }
+ }
+
const refreshNetworkHealth = async () => {
try {
const response = await fetch(`${adminBase}/network-health`, {
@@ -1476,6 +1525,11 @@
}
const showView = (viewId) => {
+ activeDashboardView = viewId
+ if (viewId !== 'metrics-view') {
+ hideSystemAlert()
+ }
+
dashboardViews.forEach((view) => {
view.classList.toggle('d-none', view.id !== viewId)
})
@@ -1509,6 +1563,14 @@
return
}
+ if (viewId === 'trust-view') {
+ stopNetworkHealthPolling()
+ stopMetricsStream()
+ window.__ADMIN_OPS__?.stop?.()
+ window.__ADMIN_TRUST__?.load?.()
+ return
+ }
+
if (viewId === 'metrics-view') {
stopNetworkHealthPolling()
window.__ADMIN_OPS__?.stop?.()
@@ -1797,6 +1859,10 @@
void loadSettingsData(true)
})
+ networkHealthRunButton?.addEventListener('click', () => {
+ void runNetworkHealthProbe()
+ })
+
if (liveClock) {
liveClock.textContent = formatClock()
setInterval(() => {
@@ -1804,6 +1870,11 @@
}, 1000)
}
+ document.addEventListener('admin-unauthorized', () => {
+ window.__ADMIN_TRUST__?.closeDiagnosticsPreview?.()
+ showLogin()
+ })
+
applyTheme(getTheme())
checkSession()
})()
diff --git a/resources/admin/assets/notifications.js b/resources/admin/assets/notifications.js
index 48ab7fe2..e76e97ac 100644
--- a/resources/admin/assets/notifications.js
+++ b/resources/admin/assets/notifications.js
@@ -60,6 +60,10 @@
'admission.invoice.failed': true,
'settings.changed': true,
'relay.restarted': false,
+ 'probe.failed': true,
+ 'probe.recovered': true,
+ 'ssl.expiring': true,
+ 'mirror.disconnected': true,
})
const newTargetId = () => {
@@ -74,6 +78,7 @@
type: 'http',
enabled: true,
url: '',
+ secret: '',
botToken: '',
chatId: '',
})
@@ -109,6 +114,7 @@
const type = card.querySelector('[data-target-field="type"]')?.value ?? 'http'
const enabled = card.querySelector('[data-target-field="enabled"]')?.checked ?? true
const urlInput = card.querySelector('[data-target-field="url"]')
+ const secretInput = card.querySelector('[data-target-field="secret"]')
const botTokenInput = card.querySelector('[data-target-field="botToken"]')
const chatIdInput = card.querySelector('[data-target-field="chatId"]')
@@ -121,6 +127,13 @@
target.url = REDACTED
}
+ const secret = secretInput?.value?.trim() ?? ''
+ if (secret === REDACTED) {
+ target.secret = REDACTED
+ } else if (secretInput) {
+ target.secret = secret
+ }
+
const botToken = botTokenInput?.value?.trim() ?? ''
if (botToken && botToken !== REDACTED) {
target.botToken = botToken
@@ -230,6 +243,20 @@
urlInput.value = target.url ?? ''
urlCol.append(urlLabel, urlInput)
+ const secretCol = document.createElement('div')
+ secretCol.className = 'col-12 col-md-6 col-lg-3'
+ const secretLabel = document.createElement('label')
+ secretLabel.className = 'form-label field-label small mb-1'
+ secretLabel.textContent = 'Signing secret'
+ const secretInput = document.createElement('input')
+ secretInput.type = 'password'
+ secretInput.autocomplete = 'off'
+ secretInput.className = 'form-control console-input form-control-sm'
+ secretInput.dataset.targetField = 'secret'
+ secretInput.placeholder = 'HTTP HMAC (optional)'
+ secretInput.value = target.secret ?? ''
+ secretCol.append(secretLabel, secretInput)
+
const botCol = document.createElement('div')
botCol.className = 'col-12 col-md-6 col-lg-3'
const botLabel = document.createElement('label')
@@ -256,7 +283,7 @@
chatInput.value = target.chatId ?? ''
chatCol.append(chatLabel, chatInput)
- fieldsRow.append(urlCol, botCol, chatCol)
+ fieldsRow.append(urlCol, secretCol, botCol, chatCol)
card.append(header, statusEl, fieldsRow)
removeButton.addEventListener('click', () => {
diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html
index 2b514dea..808ea93e 100644
--- a/resources/admin/dashboard.html
+++ b/resources/admin/dashboard.html
@@ -66,6 +66,7 @@ Sign in
> Navigation
+
@@ -248,7 +249,10 @@ Worker ops
- Tor / I2P & network probes
+
+
Tor / I2P & network probes
+
+
> probes: waiting for snapshot…
No external probe results yet. Enable NIP-66 relay monitoring to populate this panel.
@@ -411,6 +415,30 @@
Operator notifications
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -480,6 +508,51 @@ Delivery history
+
+
+ Diagnostics export
+ Download a redacted support bundle: version, health, settings, workers, mirrors, and latest probe snapshot.
+
+
+
+
+
+ > diagnostics: idle
+ Preview not loaded.
+
+
+
JSON Diagnostics preview
+
+
+
+
+
+
+
+
+
Admin audit log
+
+
+ > audit: not loaded
+
+
+ Append-only admin audit entries
+
+
+ | Time (UTC) |
+ Action |
+ Remote |
+ Detail |
+
+
+
+ | No entries loaded. |
+
+
+
+
+
+
Configuration
@@ -527,5 +600,6 @@ Preview diff
+