Skip to content
5 changes: 5 additions & 0 deletions .changeset/admin-settings-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat: add authenticated admin settings API endpoints
17 changes: 17 additions & 0 deletions src/controllers/admin/get-settings-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Request, Response } from 'express'

import { IController } from '../../@types/controllers'
import { Settings } from '../../@types/settings'
import { loadDefaults, loadMergedSettings, filterSettingsAgainstDefaults } from '../../utils/settings-config'
import { redactSettingsSecrets } from '../../utils/settings-redaction'

export class GetAdminSettingsController implements IController {
public async handleRequest(_request: Request, response: Response): Promise<void> {
const merged = loadMergedSettings()
const defaults = loadDefaults()
const filtered = filterSettingsAgainstDefaults(merged, defaults) as Settings
const settings = redactSettingsSecrets(filtered)

response.status(200).setHeader('content-type', 'application/json').send({ settings })
}
Comment thread
Ferryx349 marked this conversation as resolved.
}
10 changes: 10 additions & 0 deletions src/controllers/admin/get-settings-schema-controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
response.status(200).setHeader('content-type', 'application/json').send({ categories: guidedSettingCategories })
}
}
76 changes: 76 additions & 0 deletions src/controllers/admin/patch-settings-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Request, Response } from 'express'

import { Settings } from '../../@types/settings'
import { IController } from '../../@types/controllers'
import { adminSettingsPatchBodySchema } from '../../schemas/admin-settings-schema'
import {
getByPath,
loadMergedSettings,
loadUserSettings,
saveSettings,
setByPath,
validatePathAgainstDefaults,
validateSettings,
} from '../../utils/settings-config'
import {
isWriteProtectedSettingsPath,
redactSettingsValue,
} from '../../utils/settings-redaction'
import { validateSchema } from '../../utils/validation'

export class PatchAdminSettingsController implements IController {
public async handleRequest(request: Request, response: Response): Promise<void> {
const validation = validateSchema(adminSettingsPatchBodySchema)(request.body)
if (validation.error) {
response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' })
return
}

const { path, value } = validation.value

if (isWriteProtectedSettingsPath(path)) {
response
.status(400)
.setHeader('content-type', 'application/json')
.send({
error: 'Validation failed',
issues: [{ path, message: 'Path is write-protected' }],
})
return
}

const pathIssues = validatePathAgainstDefaults(path)
if (pathIssues.length > 0) {
response
.status(400)
.setHeader('content-type', 'application/json')
.send({ error: 'Validation failed', issues: pathIssues })
return
Comment thread
Ferryx349 marked this conversation as resolved.
}

const userSettings = loadUserSettings() as unknown as Record<string, unknown>
const nextUserSettings = setByPath(userSettings, path, value)

const merged = loadMergedSettings() as unknown as Record<string, unknown>
const mergedNext = setByPath(merged, path, getByPath(nextUserSettings, path))
const validationIssues = validateSettings(mergedNext as unknown as Settings)

if (validationIssues.length > 0) {
response
.status(400)
.setHeader('content-type', 'application/json')
.send({ error: 'Validation failed', issues: validationIssues })
return
}

saveSettings(nextUserSettings as unknown as Settings)

Comment thread
Ferryx349 marked this conversation as resolved.
const updatedValue = redactSettingsValue(path, getByPath(nextUserSettings, path))

response.status(200).setHeader('content-type', 'application/json').send({
ok: true,
path,
value: updatedValue,
})
}
}
17 changes: 17 additions & 0 deletions src/controllers/admin/post-settings-validate-controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 })
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { GetAdminSettingsController } from '../../controllers/admin/get-settings-controller'
import { IController } from '../../@types/controllers'

export const createGetAdminSettingsController = (): IController => {
return new GetAdminSettingsController()
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { PatchAdminSettingsController } from '../../controllers/admin/patch-settings-controller'
import { IController } from '../../@types/controllers'

export const createPatchAdminSettingsController = (): IController => {
return new PatchAdminSettingsController()
}
Original file line number Diff line number Diff line change
@@ -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()
}
18 changes: 18 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
import { createGetAdminHealthController } from '../../factories/controllers/get-admin-health-controller-factory'
import { createGetAdminMetricsController } from '../../factories/controllers/get-admin-metrics-controller-factory'
import { createGetAdminSessionController } from '../../factories/controllers/get-admin-session-controller-factory'
import { createGetAdminSettingsController } from '../../factories/controllers/get-admin-settings-controller-factory'
import { createGetAdminSettingsSchemaController } from '../../factories/controllers/get-admin-settings-schema-controller-factory'
import { createPatchAdminSettingsController } from '../../factories/controllers/patch-admin-settings-controller-factory'
import { createPostAdminLoginController } from '../../factories/controllers/post-admin-login-controller-factory'
import { createPostAdminLogoutController } from '../../factories/controllers/post-admin-logout-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 {
Expand All @@ -23,11 +27,25 @@
router.use(adminEnabledMiddleware)
router.use('/assets', express.static('./resources/admin/assets'))
router.get('/', getAdminDashboardRequestHandler)
router.get('/dashboard', getAdminDashboardRequestHandler)

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
router.post('/login', adminLoginRateLimitMiddleware, json(), withAdminController(createPostAdminLoginController))
router.post('/logout', adminRateLimitMiddleware, withAdminController(createPostAdminLogoutController))
router.get('/session', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSessionController))
router.get('/health', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminHealthController))

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
router.get('/metrics', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminMetricsController))
router.get('/settings', adminRateLimitMiddleware, adminAuthMiddleware, withAdminController(createGetAdminSettingsController))
Comment thread
cameri marked this conversation as resolved.
router.get(

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
'/settings/schema',
adminRateLimitMiddleware,
adminAuthMiddleware,
Comment thread
cameri marked this conversation as resolved.
withAdminController(createGetAdminSettingsSchemaController),

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
)
router.patch('/settings', adminRateLimitMiddleware, adminAuthMiddleware, json(), withAdminController(createPatchAdminSettingsController))
Comment thread
cameri marked this conversation as resolved.
router.post(
'/settings/validate',
adminRateLimitMiddleware,
adminAuthMiddleware,
Comment thread
cameri marked this conversation as resolved.
withAdminController(createPostAdminSettingsValidateController),
)

export default router
8 changes: 8 additions & 0 deletions src/schemas/admin-settings-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from 'zod'

export const adminSettingsPatchBodySchema = z
.object({
path: z.string().min(1),
value: z.custom<unknown>((input) => input !== undefined, { message: 'value is required' }),
})
.strict()
54 changes: 53 additions & 1 deletion src/utils/settings-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ const pathExistsInSchema = (schema: unknown, tokens: PathToken[]): boolean => {
return false
}

if (current.length === 0) {
return true
}

current = current[0]
}

Expand Down Expand Up @@ -297,10 +301,58 @@ export const loadMergedSettings = (): Settings => {
return mergeDeepRight(loadDefaults(), loadUserSettings()) as Settings
}

export const filterSettingsAgainstDefaults = (settings: unknown, defaults: unknown): unknown => {
if (Array.isArray(settings)) {
if (!Array.isArray(defaults)) {
return []
}
if (defaults.length === 0) {
return [...settings]
}

const itemSchema = defaults[0]
return settings.map((item) => filterSettingsAgainstDefaults(item, itemSchema))
}

if (isPlainObject(settings)) {
if (!isPlainObject(defaults)) {
return {}
}

const filtered: Record<string, unknown> = {}
for (const key of Object.keys(settings)) {
if (hasOwn(defaults, key) || key === 'passwordHash') {
// If it's a known non-schema key like passwordHash, we don't have a default schema for its children.
// We can pass {} to allow it to be preserved.
const defaultSubSchema = hasOwn(defaults, key)
? (defaults as Record<string, unknown>)[key]
: {}

filtered[key] = filterSettingsAgainstDefaults(
(settings as Record<string, unknown>)[key],
defaultSubSchema
)
}
}
return filtered
}

return 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 backupPath = `${settingsPath}.${Date.now()}.bak`

if (fs.existsSync(settingsPath)) {
fs.copyFileSync(settingsPath, backupPath)
}

const tempPath = `${settingsPath}.${Date.now()}.tmp`
fs.writeFileSync(tempPath, serialized, 'utf-8')
fs.renameSync(tempPath, settingsPath)
}

export const getTopLevelSettingCategories = (): string[] => {
Expand Down
52 changes: 52 additions & 0 deletions src/utils/settings-redaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
const SENSITIVE_SETTING_KEYS = new Set(['passwordHash', 'secret'])

const isPlainObject = (value: unknown): value is Record<string, unknown> => {
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 = <T>(settings: T): T => {
const redactWalk = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(redactWalk)
}

if (!isPlainObject(value)) {
return value
}

const result: Record<string, unknown> = {}

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
}
3 changes: 3 additions & 0 deletions test/unit/app/maintenance-worker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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))

Expand Down
Loading
Loading