Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
13 changes: 13 additions & 0 deletions src/controllers/admin/get-settings-controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const settings = redactSettingsSecrets(loadMergedSettings())

response.status(200).setHeader('content-type', 'application/json').send({ settings })
}
}
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
}

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)

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))
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,
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))
router.post(
'/settings/validate',
adminRateLimitMiddleware,
adminAuthMiddleware,
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()
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