diff --git a/.changeset/admin-settings-api.md b/.changeset/admin-settings-api.md new file mode 100644 index 00000000..9fbb142a --- /dev/null +++ b/.changeset/admin-settings-api.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat: add authenticated admin settings API endpoints diff --git a/src/controllers/admin/get-settings-controller.ts b/src/controllers/admin/get-settings-controller.ts new file mode 100644 index 00000000..2684842d --- /dev/null +++ b/src/controllers/admin/get-settings-controller.ts @@ -0,0 +1,13 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { loadMergedSettings } from '../../utils/settings-config' +import { redactSettingsSecrets } from '../../utils/settings-redaction' + +export class GetAdminSettingsController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + const settings = redactSettingsSecrets(loadMergedSettings()) + + response.status(200).setHeader('content-type', 'application/json').send({ settings }) + } +} diff --git a/src/controllers/admin/get-settings-schema-controller.ts b/src/controllers/admin/get-settings-schema-controller.ts new file mode 100644 index 00000000..a12d7be0 --- /dev/null +++ b/src/controllers/admin/get-settings-schema-controller.ts @@ -0,0 +1,10 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { guidedSettingCategories } from '../../utils/settings-guided-schema' + +export class GetAdminSettingsSchemaController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + response.status(200).setHeader('content-type', 'application/json').send({ categories: guidedSettingCategories }) + } +} diff --git a/src/controllers/admin/patch-settings-controller.ts b/src/controllers/admin/patch-settings-controller.ts new file mode 100644 index 00000000..0f409980 --- /dev/null +++ b/src/controllers/admin/patch-settings-controller.ts @@ -0,0 +1,76 @@ +import { Request, Response } from 'express' + +import { Settings } from '../../@types/settings' +import { IController } from '../../@types/controllers' +import { adminSettingsPatchBodySchema } from '../../schemas/admin-settings-schema' +import { + getByPath, + loadMergedSettings, + loadUserSettings, + saveSettings, + setByPath, + validatePathAgainstDefaults, + validateSettings, +} from '../../utils/settings-config' +import { + isWriteProtectedSettingsPath, + redactSettingsValue, +} from '../../utils/settings-redaction' +import { validateSchema } from '../../utils/validation' + +export class PatchAdminSettingsController implements IController { + public async handleRequest(request: Request, response: Response): Promise { + const validation = validateSchema(adminSettingsPatchBodySchema)(request.body) + if (validation.error) { + response.status(400).setHeader('content-type', 'application/json').send({ error: 'Invalid request' }) + return + } + + const { path, value } = validation.value + + if (isWriteProtectedSettingsPath(path)) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ + error: 'Validation failed', + issues: [{ path, message: 'Path is write-protected' }], + }) + return + } + + const pathIssues = validatePathAgainstDefaults(path) + if (pathIssues.length > 0) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: 'Validation failed', issues: pathIssues }) + return + } + + const userSettings = loadUserSettings() as unknown as Record + const nextUserSettings = setByPath(userSettings, path, value) + + const merged = loadMergedSettings() as unknown as Record + const mergedNext = setByPath(merged, path, getByPath(nextUserSettings, path)) + const validationIssues = validateSettings(mergedNext as unknown as Settings) + + if (validationIssues.length > 0) { + response + .status(400) + .setHeader('content-type', 'application/json') + .send({ error: 'Validation failed', issues: validationIssues }) + return + } + + saveSettings(nextUserSettings as unknown as Settings) + + const updatedValue = redactSettingsValue(path, getByPath(nextUserSettings, path)) + + response.status(200).setHeader('content-type', 'application/json').send({ + ok: true, + path, + value: updatedValue, + }) + } +} diff --git a/src/controllers/admin/post-settings-validate-controller.ts b/src/controllers/admin/post-settings-validate-controller.ts new file mode 100644 index 00000000..3f486746 --- /dev/null +++ b/src/controllers/admin/post-settings-validate-controller.ts @@ -0,0 +1,17 @@ +import { Request, Response } from 'express' + +import { IController } from '../../@types/controllers' +import { loadMergedSettings, validateSettings } from '../../utils/settings-config' + +export class PostAdminSettingsValidateController implements IController { + public async handleRequest(_request: Request, response: Response): Promise { + const issues = validateSettings(loadMergedSettings()) + + if (issues.length === 0) { + response.status(200).setHeader('content-type', 'application/json').send({ valid: true, issues: [] }) + return + } + + response.status(200).setHeader('content-type', 'application/json').send({ valid: false, issues }) + } +} diff --git a/src/factories/controllers/get-admin-settings-controller-factory.ts b/src/factories/controllers/get-admin-settings-controller-factory.ts new file mode 100644 index 00000000..344370f4 --- /dev/null +++ b/src/factories/controllers/get-admin-settings-controller-factory.ts @@ -0,0 +1,6 @@ +import { GetAdminSettingsController } from '../../controllers/admin/get-settings-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminSettingsController = (): IController => { + return new GetAdminSettingsController() +} diff --git a/src/factories/controllers/get-admin-settings-schema-controller-factory.ts b/src/factories/controllers/get-admin-settings-schema-controller-factory.ts new file mode 100644 index 00000000..8bb1afd5 --- /dev/null +++ b/src/factories/controllers/get-admin-settings-schema-controller-factory.ts @@ -0,0 +1,6 @@ +import { GetAdminSettingsSchemaController } from '../../controllers/admin/get-settings-schema-controller' +import { IController } from '../../@types/controllers' + +export const createGetAdminSettingsSchemaController = (): IController => { + return new GetAdminSettingsSchemaController() +} diff --git a/src/factories/controllers/patch-admin-settings-controller-factory.ts b/src/factories/controllers/patch-admin-settings-controller-factory.ts new file mode 100644 index 00000000..09838d5b --- /dev/null +++ b/src/factories/controllers/patch-admin-settings-controller-factory.ts @@ -0,0 +1,6 @@ +import { PatchAdminSettingsController } from '../../controllers/admin/patch-settings-controller' +import { IController } from '../../@types/controllers' + +export const createPatchAdminSettingsController = (): IController => { + return new PatchAdminSettingsController() +} diff --git a/src/factories/controllers/post-admin-settings-validate-controller-factory.ts b/src/factories/controllers/post-admin-settings-validate-controller-factory.ts new file mode 100644 index 00000000..3d3d6999 --- /dev/null +++ b/src/factories/controllers/post-admin-settings-validate-controller-factory.ts @@ -0,0 +1,6 @@ +import { PostAdminSettingsValidateController } from '../../controllers/admin/post-settings-validate-controller' +import { IController } from '../../@types/controllers' + +export const createPostAdminSettingsValidateController = (): IController => { + return new PostAdminSettingsValidateController() +} diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts index 3e006ca5..79079093 100644 --- a/src/routes/admin/index.ts +++ b/src/routes/admin/index.ts @@ -3,8 +3,12 @@ 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 { 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 { @@ -29,5 +33,19 @@ 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/schema', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createGetAdminSettingsSchemaController), +) +router.patch('/settings', adminRateLimitMiddleware, adminAuthMiddleware, json(), withAdminController(createPatchAdminSettingsController)) +router.post( + '/settings/validate', + adminRateLimitMiddleware, + adminAuthMiddleware, + withAdminController(createPostAdminSettingsValidateController), +) export default router diff --git a/src/schemas/admin-settings-schema.ts b/src/schemas/admin-settings-schema.ts new file mode 100644 index 00000000..b6c28d28 --- /dev/null +++ b/src/schemas/admin-settings-schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const adminSettingsPatchBodySchema = z + .object({ + path: z.string().min(1), + value: z.custom((input) => input !== undefined, { message: 'value is required' }), + }) + .strict() diff --git a/src/utils/settings-redaction.ts b/src/utils/settings-redaction.ts new file mode 100644 index 00000000..3546786d --- /dev/null +++ b/src/utils/settings-redaction.ts @@ -0,0 +1,52 @@ +const SENSITIVE_SETTING_KEYS = new Set(['passwordHash', 'secret']) + +const isPlainObject = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export const isSensitiveSettingsPath = (path: string): boolean => { + const segments = path.split('.') + const lastSegment = segments[segments.length - 1] ?? '' + const key = lastSegment.replace(/\[\d+\]$/, '') + + return SENSITIVE_SETTING_KEYS.has(key) +} + +export const isWriteProtectedSettingsPath = (path: string): boolean => { + return path === 'admin.passwordHash' || path.endsWith('.passwordHash') +} + +export const redactSettingsValue = (path: string, value: unknown): unknown => { + if (isSensitiveSettingsPath(path) && typeof value === 'string' && value.length > 0) { + return '***' + } + + return value +} + +export const redactSettingsSecrets = (settings: T): T => { + const redactWalk = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(redactWalk) + } + + if (!isPlainObject(value)) { + return value + } + + const result: Record = {} + + for (const [key, entry] of Object.entries(value)) { + if (SENSITIVE_SETTING_KEYS.has(key) && typeof entry === 'string' && entry.length > 0) { + result[key] = '***' + continue + } + + result[key] = redactWalk(entry) + } + + return result + } + + return redactWalk(settings) as T +} diff --git a/test/unit/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..16284dc0 --- /dev/null +++ b/test/unit/routes/admin-settings.spec.ts @@ -0,0 +1,266 @@ +import axios from 'axios' +import { expect } from 'chai' +import express from 'express' +import fs from 'fs' +import os from 'os' +import path from 'path' +import Sinon from 'sinon' + +import { hashAdminPassword } from '../../../src/utils/admin-password' +import * as adminRateLimitMiddleware from '../../../src/handlers/request-handlers/admin-rate-limit-middleware' +import * as rateLimiterMiddleware from '../../../src/handlers/request-handlers/rate-limiter-middleware' +import * as settingsFactory from '../../../src/factories/settings-factory' +import { getSettingsFilePath, loadDefaults, saveSettings } from '../../../src/utils/settings-config' + +describe('admin settings API', () => { + const originalSecret = process.env.SECRET + const originalAdminPassword = process.env.ADMIN_PASSWORD + const originalConfigDir = process.env.NOSTR_CONFIG_DIR + let configDir: string + let createSettingsStub: Sinon.SinonStub + let rateLimiterMiddlewareStub: Sinon.SinonStub + let adminRateLimitMiddlewareStub: Sinon.SinonStub + let adminLoginRateLimitMiddlewareStub: Sinon.SinonStub + let server: any + + const loadAdminRouter = () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + delete require.cache[require.resolve('../../../src/routes/admin/index')] + // eslint-disable-next-line @typescript-eslint/no-var-requires + delete require.cache[require.resolve('../../../src/routes/admin')] + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('../../../src/routes/admin').default + } + + const startServer = async (settings: Record) => { + createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns(settings as any) + const passthrough = async (_request: any, _response: any, next: any) => { + next() + } + rateLimiterMiddlewareStub = Sinon.stub(rateLimiterMiddleware, 'rateLimiterMiddleware').callsFake(passthrough) + adminRateLimitMiddlewareStub = Sinon.stub(adminRateLimitMiddleware, 'adminRateLimitMiddleware').callsFake( + passthrough, + ) + adminLoginRateLimitMiddlewareStub = Sinon.stub(adminRateLimitMiddleware, 'adminLoginRateLimitMiddleware').callsFake( + passthrough, + ) + const router = loadAdminRouter() + const app = express() + app.use('/admin', router) + + server = await new Promise((resolve) => { + const listeningServer = app.listen(0, () => resolve(listeningServer)) + }) + + return `http://127.0.0.1:${server.address().port}/admin` + } + + const stopServer = async () => { + createSettingsStub?.restore() + rateLimiterMiddlewareStub?.restore() + adminRateLimitMiddlewareStub?.restore() + adminLoginRateLimitMiddlewareStub?.restore() + delete require.cache[require.resolve('../../../src/routes/admin/index')] + delete require.cache[require.resolve('../../../src/routes/admin')] + + if (server) { + await new Promise((resolve, reject) => { + server.close((error: Error | undefined) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + server = undefined + } + } + + const login = async (baseUrl: string): Promise => { + const loginResponse = await axios.post( + `${baseUrl}/login`, + { password: 'settings-password' }, + { + headers: { 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(loginResponse.status).to.equal(200) + return loginResponse.headers['set-cookie']?.[0]?.split(';')[0] ?? '' + } + + before(() => { + process.env.SECRET = 'test-admin-secret-value' + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nostream-admin-settings-')) + process.env.NOSTR_CONFIG_DIR = configDir + }) + + after(() => { + if (originalSecret === undefined) { + delete process.env.SECRET + } else { + process.env.SECRET = originalSecret + } + + if (originalAdminPassword === undefined) { + delete process.env.ADMIN_PASSWORD + } else { + process.env.ADMIN_PASSWORD = originalAdminPassword + } + + if (originalConfigDir === undefined) { + delete process.env.NOSTR_CONFIG_DIR + } else { + process.env.NOSTR_CONFIG_DIR = originalConfigDir + } + + fs.rmSync(configDir, { recursive: true, force: true }) + }) + + afterEach(async () => { + delete process.env.ADMIN_PASSWORD + fs.rmSync(getSettingsFilePath(), { force: true }) + await stopServer() + }) + + it('requires authentication for settings routes', async () => { + const baseUrl = await startServer({ admin: { enabled: true } }) + + const settingsResponse = await axios.get(`${baseUrl}/settings`, { validateStatus: () => true }) + const schemaResponse = await axios.get(`${baseUrl}/settings/schema`, { validateStatus: () => true }) + const validateResponse = await axios.post(`${baseUrl}/settings/validate`, {}, { validateStatus: () => true }) + const patchResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'payments.enabled', value: true }, + { + headers: { 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(settingsResponse.status).to.equal(401) + expect(schemaResponse.status).to.equal(401) + expect(validateResponse.status).to.equal(401) + expect(patchResponse.status).to.equal(401) + }) + + it('returns redacted merged settings and guided schema', async () => { + const passwordHash = hashAdminPassword('settings-password') + saveSettings({ + ...loadDefaults(), + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + mirroring: { + static: [{ address: 'wss://mirror.example', secret: 'mirror-secret-value' }], + }, + }) + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + const settingsResponse = await axios.get(`${baseUrl}/settings`, { + headers: { cookie }, + validateStatus: () => true, + }) + const schemaResponse = await axios.get(`${baseUrl}/settings/schema`, { + headers: { cookie }, + validateStatus: () => true, + }) + + expect(settingsResponse.status).to.equal(200) + expect(settingsResponse.data.settings.admin.passwordHash).to.equal('***') + expect(settingsResponse.data.settings.mirroring.static[0].secret).to.equal('***') + expect(settingsResponse.data.settings.info.name).to.be.a('string') + + expect(schemaResponse.status).to.equal(200) + expect(schemaResponse.data.categories.some((entry: { value: string }) => entry.value === 'payments')).to.equal(true) + }) + + it('patches a setting path with validation and persists to settings.yaml', async () => { + const passwordHash = hashAdminPassword('settings-password') + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + const patchResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'payments.enabled', value: true }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(patchResponse.status).to.equal(200) + expect(patchResponse.data).to.deep.equal({ ok: true, path: 'payments.enabled', value: true }) + + const settingsRaw = fs.readFileSync(getSettingsFilePath(), 'utf-8') + expect(settingsRaw).to.include('payments:') + expect(settingsRaw).to.include('enabled: true') + }) + + it('rejects invalid paths and write-protected settings', async () => { + const passwordHash = hashAdminPassword('settings-password') + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + const unknownPathResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'payments.fakeField', value: true }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + const protectedPathResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'admin.passwordHash', value: 'new-hash' }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + const invalidBodyResponse = await axios.patch( + `${baseUrl}/settings`, + { path: 'payments.enabled', unexpected: true }, + { + headers: { cookie, 'content-type': 'application/json' }, + validateStatus: () => true, + }, + ) + + expect(unknownPathResponse.status).to.equal(400) + expect(unknownPathResponse.data.error).to.equal('Validation failed') + expect(protectedPathResponse.status).to.equal(400) + expect(protectedPathResponse.data.issues[0].message).to.equal('Path is write-protected') + expect(invalidBodyResponse.status).to.equal(400) + expect(invalidBodyResponse.data.error).to.equal('Invalid request') + }) + + it('validates merged settings', async () => { + const passwordHash = hashAdminPassword('settings-password') + const baseUrl = await startServer({ + admin: { enabled: true, passwordHash, sessionTtlSeconds: 3600 }, + }) + const cookie = await login(baseUrl) + + const validateResponse = await axios.post( + `${baseUrl}/settings/validate`, + {}, + { + headers: { cookie }, + validateStatus: () => true, + }, + ) + + expect(validateResponse.status).to.equal(200) + expect(validateResponse.data.valid).to.equal(true) + expect(validateResponse.data.issues).to.deep.equal([]) + }) +}) diff --git a/test/unit/utils/settings-redaction.spec.ts b/test/unit/utils/settings-redaction.spec.ts new file mode 100644 index 00000000..e140f903 --- /dev/null +++ b/test/unit/utils/settings-redaction.spec.ts @@ -0,0 +1,60 @@ +import { expect } from 'chai' + +import { + isSensitiveSettingsPath, + isWriteProtectedSettingsPath, + redactSettingsSecrets, + redactSettingsValue, +} from '../../../src/utils/settings-redaction' + +describe('settings-redaction', () => { + it('redacts passwordHash and secret values in nested settings', () => { + const input = { + admin: { + enabled: true, + passwordHash: 'hashed-password-value', + }, + mirroring: { + static: [ + { + address: 'wss://mirror.example', + secret: 'mirror-secret-value', + }, + ], + }, + payments: { + enabled: false, + }, + } + + const redacted = redactSettingsSecrets(input) + + expect(redacted.admin.passwordHash).to.equal('***') + expect(redacted.mirroring.static[0].secret).to.equal('***') + expect(redacted.mirroring.static[0].address).to.equal('wss://mirror.example') + expect(redacted.payments.enabled).to.equal(false) + }) + + it('leaves empty secret values unchanged', () => { + const input = { + mirroring: { + static: [{ address: 'wss://mirror.example', secret: '' }], + }, + } + + expect(redactSettingsSecrets(input).mirroring.static[0].secret).to.equal('') + }) + + it('identifies sensitive and write-protected paths', () => { + expect(isSensitiveSettingsPath('admin.passwordHash')).to.equal(true) + expect(isSensitiveSettingsPath('mirroring.static[0].secret')).to.equal(true) + expect(isSensitiveSettingsPath('payments.enabled')).to.equal(false) + expect(isWriteProtectedSettingsPath('admin.passwordHash')).to.equal(true) + expect(isWriteProtectedSettingsPath('mirroring.static[0].secret')).to.equal(false) + }) + + it('redacts single values by path', () => { + expect(redactSettingsValue('mirroring.static[0].secret', 'top-secret')).to.equal('***') + expect(redactSettingsValue('payments.enabled', true)).to.equal(true) + }) +})