feat(theming): settings-store, nav merge util and radius tokens

Phases 0-2 of docs/website/THEMING_AND_NAV.md. Groundwork only: no admin UI,
no consumer wiring, and an instance that never touches the new settings keys
renders exactly as it does today.

Phase 0 - settings store:
- settingsDb.remove() and DELETE /api/v1/admin/settings/:key, the "reset to
  default" primitive. Defaults for these keys live in BRAND_* env, theme.css
  and the hardcoded NAV arrays, so reset has to delete the row rather than
  store a copy of the default. Allowlisted to the five theming/nav keys plus
  hero_layout_draft, admin-only, idempotent.
- GET /api/v1/settings/nav behind requireAuth with no role gate. AdminLayout
  renders for editors and moderators and PlayerPortalLayout for players, and
  none of them can read GET /admin/settings, so without this their nav
  override would silently never apply.
- A fifth router group for it: /public is anonymous, /admin/settings is
  adminOnly, /player is self-scoped data. This is configuration that needs a
  login.
- parseJsonSetting() in utils/settingsJson.js. settings.value is TEXT, so
  every JSON key arrives as a string; malformed or wrong-shaped reads as
  absent, never as an error and never half-applied.
- theme_visual / brand_assets / nav_public join PUBLIC_KEYS; nav_admin and
  nav_player deliberately do not.

Phase 1 - client/src/lib/navOverrides.js, the pure merge util. Presentation
only: it can set label/order/hidden and (grouped navs) group, and nothing
else. It cannot introduce a `to`, cannot touch roles/feature, and hidden:false
cannot un-hide anything - the existing filters run afterward, unchanged, and
remain the boundary.

Phase 2 - promoted 23 border-radius literals in theme.css to four tokens at
today's values (14x8px, 4x999px, 4x10px, 1x12px). The 7px/6px editor chrome
and the two 50% circles stay literal. --shadow-card and --panel-grad were
already tokens.

Tests: 16 new server tests, 20 new client tests. The route-manifest guard now
also asserts /settings/** sits behind requireAuth. Swagger and both route
artifacts regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 18:15:29 -05:00
parent d765280e28
commit ec0036ce6d
18 changed files with 1042 additions and 28 deletions

View File

@@ -616,6 +616,15 @@
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/admin/settings/:key",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/admin/shard/account",
@@ -2136,6 +2145,15 @@
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/settings/nav",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
}
],
"internal": [

View File

@@ -249,6 +249,10 @@
"method": "PUT",
"path": "/api/v1/admin/settings"
},
{
"method": "DELETE",
"path": "/api/v1/admin/settings/:key"
},
{
"method": "POST",
"path": "/api/v1/admin/shard/account"
@@ -892,6 +896,10 @@
{
"method": "GET",
"path": "/api/v1/public/wiki/tags"
},
{
"method": "GET",
"path": "/api/v1/settings/nav"
}
],
"internal": [

View File

@@ -22,4 +22,12 @@ async function seedDefault(key, value) {
await query('INSERT IGNORE INTO settings (`key`, value) VALUES (?, ?)', [key, value])
}
module.exports = { getAll, get, set, seedDefault }
// Delete a settings row. "Reset to defaults" for the theming/nav keys is the
// *absence* of a row, not a stored copy of the defaults — see
// docs/website/THEMING_AND_NAV.md §2. Deleting a key that was never set is a
// no-op, so reset is idempotent.
async function remove(key) {
await query('DELETE FROM settings WHERE `key` = ?', [key])
}
module.exports = { getAll, get, set, seedDefault, remove }

View File

@@ -10,8 +10,27 @@ const PUBLIC_KEYS = [
'contact_email',
'site_title',
'hero_layout', // portal hero composition (JSON). Draft key stays admin-only.
'theme_visual', // preset/custom colors, fonts, radii (JSON). See THEMING_AND_NAV.md §6.1.
'brand_assets', // uploaded logo/hero/favicon overrides (JSON). §6.3.
'nav_public', // public site nav overrides (JSON). §6.4.
]
// Admin-configurable theming & navigation (docs/website/THEMING_AND_NAV.md).
// All five are JSON strings and all five are ABSENT by default — no migration
// seeds them. Absence of the row, not an empty value, is what makes a surface
// fall back to BRAND_* env / the hardcoded theme.css / the hardcoded NAV arrays.
//
// nav_admin and nav_player are deliberately not public: an anonymous visitor has
// no use for either, and the admin nav's labels describe the shape of the admin
// surface. They are read by their owners through GET /api/v1/settings/nav (§4.2).
const THEMING_KEYS = ['theme_visual', 'brand_assets', 'nav_public', 'nav_admin', 'nav_player']
// Keys a reset may delete. An explicit allowlist, not "any key": DELETE on an
// arbitrary key would let a bad request drop site_mode or the uo-link config,
// whose absence means something else entirely. hero_layout_draft is included
// because discarding a draft is the same operation.
const DELETABLE_KEYS = [...THEMING_KEYS, 'hero_layout_draft']
// Player self-registration mode. Stored under the 'player_registration' key.
// NOTE: the raw value is never exposed publicly — getPublic() derives boolean
// availability flags from it instead (see below).
@@ -96,6 +115,10 @@ async function set(key, value, updatedBy = null) {
return settingsDb.set(key, value, updatedBy)
}
async function remove(key) {
return settingsDb.remove(key)
}
async function setMany(obj, updatedBy = null) {
for (const [key, value] of Object.entries(obj)) {
await settingsDb.set(key, value, updatedBy)
@@ -155,6 +178,19 @@ async function getPublic() {
return out
}
// The two nav-override keys their own audiences need but cannot read from
// GET /admin/settings (admin-only, while AdminLayout renders for editors and
// moderators and PlayerPortalLayout renders for players — THEMING_AND_NAV.md
// §4.2). Values are returned as stored: raw JSON strings, or null when the
// admin never overrode that nav.
async function getNav() {
const all = await getAll()
return {
nav_admin: all.nav_admin ?? null,
nav_player: all.nav_player ?? null,
}
}
// The client-facing ntfy base URL (no trailing slash), or null when unset.
function publicNtfyUrl() {
const explicit = (process.env.NTFY_PUBLIC_URL || '').trim()
@@ -169,11 +205,15 @@ function publicNtfyUrl() {
module.exports = {
get,
set,
remove,
setMany,
getAll,
getPublic,
getNav,
getInstanceName,
PUBLIC_KEYS,
THEMING_KEYS,
DELETABLE_KEYS,
REGISTRATION_KEY,
REGISTRATION_MODES,
getRegistrationMode,

View File

@@ -539,6 +539,33 @@ async function updateSettings(req, res) {
}
}
// Delete one settings row — the "reset to defaults" primitive.
//
// For the theming/nav keys, defaults live in BRAND_* env, theme.css and the
// hardcoded NAV arrays; the *absence* of the row is what selects them
// (docs/website/THEMING_AND_NAV.md §2). Resetting therefore has to delete, not
// store a copy of the defaults, or the next change to a default would not reach
// an instance that had ever pressed reset.
//
// The key allowlist is the point of the route: an unrestricted DELETE would let
// a stray request drop site_mode or the uo-link config, where absence means
// something else entirely. Deleting a key that is not set succeeds — reset is
// idempotent and the UI should not have to know whether a row exists.
async function deleteSetting(req, res) {
const { key } = req.params
if (!settings.DELETABLE_KEYS.includes(key)) {
return res.status(400).json({ message: 'Setting is not resettable' })
}
try {
await settings.remove(key)
await activity.log({ req, action: 'settings.reset', detail: { key } })
return res.json({ message: 'Setting reset to default' })
} catch (err) {
log.error('deleteSetting', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// ── Activity log ──────────────────────────────────────────────────────
async function listActivity(req, res) {
const limit = Math.min(Number(req.query.limit) || 50, 200)
@@ -764,6 +791,7 @@ module.exports = {
deleteWikiCategory,
getSettings,
updateSettings,
deleteSetting,
listActivity,
listUsers,
createUser,

View File

@@ -41,5 +41,22 @@ settingsRouter.put(
adminOnly,
ctrl.updateSettings,
)
// Reset one setting to its default by deleting the row. Only the keys whose
// default lives outside the store (theming, nav, hero draft) are deletable —
// the controller holds the allowlist.
settingsRouter.delete(
'/:key',
// #swagger.tags = ['Admin · Settings']
// #swagger.summary = 'Reset one setting to its default (admin only)'
// #swagger.description = 'Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.parameters['key'] = { in: 'path', required: true, description: 'Settings key to reset', schema: { type: 'string' } } */
/* #swagger.responses[200] = { description: 'Setting reset', content: { "application/json": { schema: { $ref: "#/components/schemas/Message" } } } } */
/* #swagger.responses[400] = { description: 'Setting is not resettable', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
adminOnly,
ctrl.deleteSetting,
)
module.exports = settingsRouter

View File

@@ -0,0 +1,33 @@
// /api/v1/settings — settings any *authenticated* account needs to read, whoever
// they are.
//
// A fifth group alongside /auth, /public, /admin and /player, and deliberately
// not folded into any of them:
//
// - /public is anonymous, and the admin nav's labels describe the shape of the
// admin surface — that belongs behind a login.
// - /admin is `staffOnly` + `requireRole('admin')` on settings, but AdminLayout
// renders for editors and moderators too, so they could never read their own
// nav overrides from there (docs/website/THEMING_AND_NAV.md §4.2).
// - /player is self-service data scoped to req.user.id. These rows are
// site-wide configuration that happens to need a login, not anything about
// the caller.
//
// Group gate: authenticated only, no role restriction — staff and players alike
// read their own layout's nav. It lives here, ahead of every mount, so a route
// added later cannot ship ungated.
const express = require('express')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const navRouter = require('./nav.router')
const settingsRouter = express.Router()
settingsRouter.use(noindex, requireAuth)
settingsRouter.use('/nav', navRouter)
module.exports = settingsRouter

View File

@@ -0,0 +1,17 @@
const settings = require('../../../model/settings/settings.model')
const log = require('../../../utils/logger')
// The nav overrides for the two authenticated layouts. Values are the raw stored
// JSON strings (settings.value is TEXT) or null; the caller parses them with the
// same fail-safe posture as every other JSON setting — malformed reads as
// absent, and absent means the hardcoded NAV array is used unchanged.
async function getNav(req, res) {
try {
return res.json(await settings.getNav())
} catch (err) {
log.error('getNav', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getNav }

View File

@@ -0,0 +1,28 @@
// Settings · Nav — the admin-sidebar and player-portal nav overrides, readable
// by the accounts those navs are rendered for.
//
// Mounted at /api/v1/settings/nav by settings/index.js, which already applied
// `noindex, requireAuth`. No role gate on purpose: an editor, a moderator and a
// player each need the override for the layout they see, and the payload is
// presentation-only — label/order/hidden/group over items the reader's own
// role/feature filter still gets the final say on
// (docs/website/THEMING_AND_NAV.md §7).
const express = require('express')
const ctrl = require('./nav.controller')
const navRouter = express.Router()
navRouter.get(
'/',
// #swagger.tags = ['Settings']
// #swagger.summary = 'Nav overrides for the admin and player layouts'
// #swagger.description = 'Returns the stored nav_admin and nav_player overrides as raw JSON strings (null when the admin never overrode that nav). Any authenticated account may read them: AdminLayout renders for editors and moderators, PlayerPortalLayout for players, and none of them can read GET /admin/settings. Presentation-only — the role/feature filters in the layouts still decide what is actually shown.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Nav overrides', content: { "application/json": { schema: { $ref: "#/components/schemas/NavSettings" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.getNav,
)
module.exports = navRouter

View File

@@ -6,11 +6,18 @@ const authRouter = require('./auth')
const publicRouter = require('./public')
const adminRouter = require('./admin')
const playerRouter = require('./player')
const settingsRouter = require('./settings')
v1Router.use('/auth', authRouter)
v1Router.use('/public', publicRouter)
v1Router.use('/admin', adminRouter)
v1Router.use('/player', playerRouter)
// Site-wide settings that need a login but no particular role — currently the
// nav overrides the admin and player layouts read for themselves. Not /public
// (the admin nav's labels describe the admin surface), not /admin (editors and
// moderators render AdminLayout but are not admins), not /player (this is
// configuration, not self-scoped data). See settings/index.js.
v1Router.use('/settings', settingsRouter)
// NOTE: /internal is intentionally NOT mounted here. Those routes return the
// decrypted Discord bot token and must never share the public listener that
// Pangolin proxies. They live on a separate, unpublished port via

View File

@@ -0,0 +1,35 @@
// Parse a JSON-valued settings row.
//
// `settings.value` is TEXT (db/schema.sql), so every JSON-shaped key —
// hero_layout, and now theme_visual / brand_assets / nav_* — is stored
// stringified and arrives as a string. Consumers must parse it, and the parse
// has to be fail-safe: a malformed or wrong-shaped value is treated as
// **absent** (the surface falls back to its BRAND_* env / theme.css / NAV
// default), never as an error and never as a half-applied object. That is the
// same posture parseLayout already takes on the client
// (client/src/lib/heroLayout.js).
//
// See docs/website/THEMING_AND_NAV.md §4.4.
/**
* @param {string|null|undefined} str the raw stored value
* @param {(value: unknown) => boolean} [validator] shape check; anything it
* rejects is treated as absent
* @returns {object|null} the parsed object, or null when absent/malformed
*/
function parseJsonSetting(str, validator) {
if (typeof str !== 'string' || str === '') return null
let parsed
try {
parsed = JSON.parse(str)
} catch {
return null
}
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
// every consumer of these keys as a syntax error is.
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
if (validator && !validator(parsed)) return null
return parsed
}
module.exports = { parseJsonSetting }

View File

@@ -60,6 +60,10 @@
"name": "Player · Appeals",
"description": "Player-submitted moderation appeals"
},
{
"name": "Settings",
"description": "Site-wide settings any authenticated account may read (nav overrides)"
},
{
"name": "Admin · Dashboard",
"description": "Dashboard summary and site mode"
@@ -3528,6 +3532,79 @@
}
}
},
"/api/v1/admin/settings/{key}": {
"delete": {
"tags": [
"Admin · Settings"
],
"summary": "Reset one setting to its default (admin only)",
"description": "Deletes the settings row so the surface falls back to its BRAND_* env / theme.css / hardcoded default. Restricted to the resettable keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player, hero_layout_draft). Idempotent: resetting a key that was never set succeeds.",
"parameters": [
{
"name": "key",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Settings key to reset"
}
],
"responses": {
"200": {
"description": "Setting reset",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Message"
}
}
}
},
"400": {
"description": "Setting is not resettable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Admin role required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/shard/account": {
"post": {
"tags": [
@@ -12650,6 +12727,51 @@
}
}
}
},
"/api/v1/settings/nav": {
"get": {
"tags": [
"Settings"
],
"summary": "Nav overrides for the admin and player layouts",
"description": "Returns the stored nav_admin and nav_player overrides as raw JSON strings (null when the admin never overrode that nav). Any authenticated account may read them: AdminLayout renders for editors and moderators, PlayerPortalLayout for players, and none of them can read GET /admin/settings. Presentation-only — the role/feature filters in the layouts still decide what is actually shown.",
"responses": {
"200": {
"description": "Nav overrides",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NavSettings"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
}
},
"components": {
@@ -17801,6 +17923,55 @@
}
}
},
"NavSettings": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Nav overrides for the two authenticated layouts (GET /settings/nav). Each value is the stored JSON **string** — settings.value is TEXT — or null when that nav was never overridden. Parse fail-safe: treat malformed as absent and fall back to the hardcoded nav."
},
"properties": {
"type": "object",
"properties": {
"nav_admin": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "{\"/admin/posts\":{\"label\":\"Blog Posts\",\"order\":10}}"
}
}
},
"nav_player": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {}
}
}
}
}
}
},
"DeletedId": {
"type": "object",
"properties": {

View File

@@ -59,6 +59,7 @@ const doc = {
{ name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' },
{ name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' },
{ name: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
{ name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' },
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
{ name: 'Admin · Wiki', description: 'Wiki pages, categories, tags and revisions' },
@@ -778,6 +779,19 @@ const doc = {
},
additionalProperties: true,
},
NavSettings: {
type: 'object',
description:
'Nav overrides for the two authenticated layouts (GET /settings/nav). Each value is the stored JSON **string** — settings.value is TEXT — or null when that nav was never overridden. Parse fail-safe: treat malformed as absent and fall back to the hardcoded nav.',
properties: {
nav_admin: {
type: 'string',
nullable: true,
example: '{"/admin/posts":{"label":"Blog Posts","order":10}}',
},
nav_player: { type: 'string', nullable: true, example: null },
},
},
// Delete/mutation acknowledgements — each echoes the affected resource key
// or a boolean flag rather than a { message } string.
DeletedId: {

View File

@@ -51,15 +51,14 @@ test('the manifest only inventories API surface, never static mounts', () => {
}
})
test('every /admin and /player route still sits behind the shared auth gate', () => {
test('every /admin, /player and /settings route still sits behind the shared auth gate', () => {
// Router-level `use()` gates do not appear in an individual route's own stack, so a
// capability router extracted from admin.routes.js without re-applying the gate would
// silently publish authenticated endpoints. Names are only a hint — `requireRole(...)`
// returns an anonymous arrow and cannot be seen here — but a *missing* requireAuth is
// unambiguous.
const gated = collected.public.filter(
(r) => r.path.startsWith('/api/v1/admin/') || r.path.startsWith('/api/v1/player/'),
)
const AUTHENTICATED_GROUPS = ['/api/v1/admin/', '/api/v1/player/', '/api/v1/settings/']
const gated = collected.public.filter((r) => AUTHENTICATED_GROUPS.some((p) => r.path.startsWith(p)))
assert.ok(gated.length > 100, 'expected the gated surface to be found')
for (const route of gated) {
assert.ok(

View File

@@ -0,0 +1,234 @@
// Point the DB at a closed port BEFORE anything builds the pool. Every model
// call below is monkeypatched, so no query runs; db.close() releases the pool so
// the process exits cleanly.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, afterEach } = require('node:test')
const assert = require('node:assert/strict')
// Phase 0 of the admin theming & navigation feature
// (docs/website/THEMING_AND_NAV.md): the settings-store groundwork the rest of
// the feature is built on. Three things are load-bearing enough to lock here —
// the reset-by-delete allowlist, who may read the nav overrides, and the
// fail-safe JSON parse — plus the guarantee that registering the new keys did
// not change what an untouched instance serves.
const { startApp } = require('./_helper')
const settingsRouter = require('../src/router/v1/admin/settings.router')
const navSettingsRouter = require('../src/router/v1/settings')
const settingsDb = require('../src/model/settings/settings.db')
const settings = require('../src/model/settings/settings.model')
const { parseJsonSetting } = require('../src/utils/settingsJson')
const sessionService = require('../src/auth/session.service')
// The admin group applies `noindex, isLoggedIn, staffOnly` before mounting the
// settings router, and requireRole reads the req.user that requireAuth attaches.
// Mounting the router bare would 403 every caller for the wrong reason.
const { requireAuth } = require('../src/auth/session.middleware')
const users = require('../src/model/users/users.model')
const activity = require('../src/model/activity/activity.model')
const db = require('../src/utils/db')
after(() => db.close())
const originals = {
validateSession: sessionService.validateSession,
isSessionRevoked: sessionService.isSessionRevoked,
sessionMeta: sessionService.sessionMeta,
getById: users.getById,
getAll: settingsDb.getAll,
remove: settingsDb.remove,
set: settingsDb.set,
log: activity.log,
}
afterEach(() => {
Object.assign(sessionService, {
validateSession: originals.validateSession,
isSessionRevoked: originals.isSessionRevoked,
sessionMeta: originals.sessionMeta,
})
users.getById = originals.getById
settingsDb.getAll = originals.getAll
settingsDb.remove = originals.remove
activity.log = originals.log
})
// Sign every request in as the given DB user (role decides the gate outcome).
function signInAs(user) {
sessionService.validateSession = () => ({ userId: user.id, sessionId: 's1', createdAt: Date.now(), authMethod: 'jwt' })
sessionService.isSessionRevoked = async () => false
sessionService.sessionMeta = () => ({})
users.getById = async () => user
activity.log = async () => {}
}
// ── DELETE /admin/settings/:key — reset is delete, and only for some keys ──
test('resetting a theming key deletes its row', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
const deleted = []
settingsDb.remove = async (key) => deleted.push(key)
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
try {
for (const key of settings.THEMING_KEYS) {
const res = await fetch(`${app.url}/api/v1/admin/settings/${key}`, { method: 'DELETE' })
assert.equal(res.status, 200, `${key} should be resettable`)
}
assert.deepEqual(deleted, settings.THEMING_KEYS)
} finally {
await app.close()
}
})
// The whole "no migration seeds defaults" principle (§2) rests on this: reset
// must not write a stored copy of the defaults, or a later change to a default
// would never reach an instance that once pressed reset.
test('reset never writes a value, only deletes', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.remove = async () => {}
settingsDb.set = () => assert.fail('reset must not write a settings row')
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/theme_visual`, { method: 'DELETE' })
assert.equal(res.status, 200)
} finally {
settingsDb.set = originals.set
await app.close()
}
})
// An unrestricted DELETE would let a stray request drop site_mode or the
// uo-link config, where an absent row means something else entirely.
test('a key outside the allowlist is rejected and nothing is deleted', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.remove = async () => assert.fail('must not delete a non-resettable key')
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
try {
for (const key of ['site_mode', 'uo_link_token', 'player_registration', 'hero_layout']) {
const res = await fetch(`${app.url}/api/v1/admin/settings/${key}`, { method: 'DELETE' })
assert.equal(res.status, 400, `${key} must not be resettable`)
}
} finally {
await app.close()
}
})
// Reset is idempotent: the UI resets without first knowing whether a row exists.
test('resetting a key that was never set still succeeds', async () => {
signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
settingsDb.remove = async () => {} // DELETE of a missing row affects 0 rows
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/nav_public`, { method: 'DELETE' })
assert.equal(res.status, 200)
} finally {
await app.close()
}
})
test('reset is admin-only — an editor is refused', async () => {
signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' })
settingsDb.remove = async () => assert.fail('an editor must not reset a setting')
const app = await startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
try {
const res = await fetch(`${app.url}/api/v1/admin/settings/theme_visual`, { method: 'DELETE' })
assert.equal(res.status, 403)
} finally {
await app.close()
}
})
// ── GET /settings/nav — the reason this endpoint exists at all ─────────────
// AdminLayout renders for editors and moderators, PlayerPortalLayout for
// players, and none of them can read GET /admin/settings. Without this route
// their nav override would silently never apply (§4.2).
for (const role of ['admin', 'editor', 'moderator', 'player']) {
test(`GET /settings/nav is readable by an authenticated ${role}`, async () => {
signInAs({ id: 7, username: 'u', role, status: 'active' })
settingsDb.getAll = async () => [
{ key: 'nav_admin', value: '{"/admin/posts":{"label":"Blog Posts"}}' },
{ key: 'nav_player', value: '{"/portal/characters":{"hidden":true}}' },
]
const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter))
try {
const res = await fetch(`${app.url}/api/v1/settings/nav`)
assert.equal(res.status, 200, `${role} should reach the handler, got ${res.status}`)
const body = await res.json()
assert.equal(body.nav_admin, '{"/admin/posts":{"label":"Blog Posts"}}')
assert.equal(body.nav_player, '{"/portal/characters":{"hidden":true}}')
} finally {
await app.close()
}
})
}
test('GET /settings/nav rejects an anonymous caller', async () => {
sessionService.validateSession = () => null
const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter))
try {
const res = await fetch(`${app.url}/api/v1/settings/nav`)
assert.equal(res.status, 401)
} finally {
await app.close()
}
})
test('GET /settings/nav returns null for a nav that was never overridden', async () => {
signInAs({ id: 7, username: 'u', role: 'player', status: 'active' })
settingsDb.getAll = async () => []
const app = await startApp((a) => a.use('/api/v1/settings', navSettingsRouter))
try {
const res = await fetch(`${app.url}/api/v1/settings/nav`)
assert.deepEqual(await res.json(), { nav_admin: null, nav_player: null })
} finally {
await app.close()
}
})
// ── getPublic(): the new keys appear only when a row exists ───────────────
test('an untouched instance exposes none of the new keys publicly', async () => {
settingsDb.getAll = async () => []
const pub = await settings.getPublic()
for (const key of settings.THEMING_KEYS) {
assert.equal(pub[key], undefined, `${key} must be absent, not empty`)
}
})
test('theme_visual / brand_assets / nav_public are public once set; nav_admin / nav_player never are', async () => {
settingsDb.getAll = async () => [
{ key: 'theme_visual', value: '{"preset":"modern"}' },
{ key: 'brand_assets', value: '{"logo":"/uploads/a.png"}' },
{ key: 'nav_public', value: '{"/news":{"order":1}}' },
{ key: 'nav_admin', value: '{"/admin/posts":{"hidden":true}}' },
{ key: 'nav_player', value: '{"/portal":{"label":"Home"}}' },
]
const pub = await settings.getPublic()
assert.equal(pub.theme_visual, '{"preset":"modern"}')
assert.equal(pub.brand_assets, '{"logo":"/uploads/a.png"}')
assert.equal(pub.nav_public, '{"/news":{"order":1}}')
// The admin nav's labels describe the shape of the admin surface, and an
// anonymous visitor has no use for either — they stay behind /settings/nav.
assert.equal(pub.nav_admin, undefined)
assert.equal(pub.nav_player, undefined)
})
// ── parseJsonSetting: malformed reads as absent, never as an error ─────────
test('parseJsonSetting returns null for absent, empty and malformed values', () => {
for (const input of [null, undefined, '', '{', 'not json', '[]', '"str"', '4', 'null']) {
assert.equal(parseJsonSetting(input), null, `${JSON.stringify(input)} should read as absent`)
}
})
test('parseJsonSetting returns the parsed object for a well-formed value', () => {
assert.deepEqual(parseJsonSetting('{"preset":"modern"}'), { preset: 'modern' })
})
// A wrong-shaped value must fall back to the default whole, never partially —
// half a theme applied is worse than no theme applied.
test('parseJsonSetting treats a validator rejection as absent', () => {
const isThemeVisual = (v) => typeof v.preset === 'string'
assert.equal(parseJsonSetting('{"custom":{}}', isThemeVisual), null)
assert.deepEqual(parseJsonSetting('{"preset":"fantasy"}', isThemeVisual), { preset: 'fantasy' })
})