From ec0036ce6dd31be5936a88736c1f38e4616a8dc6 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 7 Aug 2026 18:15:29 -0500 Subject: [PATCH] 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 --- client/src/lib/navOverrides.js | 138 +++++++++++ client/src/styles/theme.css | 62 +++-- client/test/navOverrides.test.js | 203 +++++++++++++++ server/routes.guards.json | 18 ++ server/routes.manifest.json | 8 + server/src/model/settings/settings.db.js | 10 +- server/src/model/settings/settings.model.js | 40 +++ .../src/router/v1/admin/admin.controller.js | 28 +++ server/src/router/v1/admin/settings.router.js | 17 ++ server/src/router/v1/settings/index.js | 33 +++ .../src/router/v1/settings/nav.controller.js | 17 ++ server/src/router/v1/settings/nav.router.js | 28 +++ server/src/router/v1/v1.router.js | 7 + server/src/utils/settingsJson.js | 35 +++ server/swagger/swagger-output.json | 171 +++++++++++++ server/swagger/swagger.js | 14 ++ server/test/routeManifest.test.js | 7 +- server/test/settingsTheming.test.js | 234 ++++++++++++++++++ 18 files changed, 1042 insertions(+), 28 deletions(-) create mode 100644 client/src/lib/navOverrides.js create mode 100644 client/test/navOverrides.test.js create mode 100644 server/src/router/v1/settings/index.js create mode 100644 server/src/router/v1/settings/nav.controller.js create mode 100644 server/src/router/v1/settings/nav.router.js create mode 100644 server/src/utils/settingsJson.js create mode 100644 server/test/settingsTheming.test.js diff --git a/client/src/lib/navOverrides.js b/client/src/lib/navOverrides.js new file mode 100644 index 0000000..beb70f2 --- /dev/null +++ b/client/src/lib/navOverrides.js @@ -0,0 +1,138 @@ +// Apply an admin's stored navigation overrides to a hardcoded NAV array. +// +// The three navs (public header, admin sidebar, player portal) stay declared in +// code; this layer only reorders, relabels and hides what is already there. +// See docs/website/THEMING_AND_NAV.md §7. +// +// **This is presentation, never authorization.** The override can carry +// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else. +// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or +// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run +// *after* this merge, unchanged, and remain the actual boundary. An override +// saying `hidden: false` on a role-gated item still shows nothing to a viewer +// whose role check fails: hiding is subtractive here, never additive. +// +// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string +// label, a group that does not exist — is ignored rather than rejected, so a +// stale or hand-edited settings row degrades to the code default instead of +// rendering a broken nav. + +// Two shapes are supported, because two exist: +// flat [{ to, label, ... }] — public header, player portal +// grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar +function isGrouped(nav) { + return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items)) +} + +// A stored override entry is usable only field by field: a bad `label` must not +// discard a good `order` alongside it. +function cleanEntry(raw, groupTitles) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null + const out = {} + if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim() + if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order + if (raw.hidden === true) out.hidden = true + // `group` may only name a section the base nav already declares. Anything else + // — a renamed group, a typo, an invented category — is dropped, so an item can + // never land in a header that does not exist. + if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group + return out +} + +// Sort by effective order, where an item the admin never reordered keeps its +// index in the base array as its key. Two tie-breaks, in order: an explicit +// order beats a coincidental index (the admin said "first", so first), and two +// explicit orders stay in code order (the sort is stable). +// +// In practice the editor writes an order for every item in a list, the way +// drag-and-drop reordering does, so ties are the stale-row case rather than the +// normal one. They still have to resolve predictably. +function byOrder(items) { + return items + .map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined })) + .sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit)) + .map(({ item }) => { + const { __order, ...rest } = item + return rest + }) +} + +// Apply label/hidden/order to one flat list. Returns visible items only, with +// the sort key parked on `__order` for byOrder to consume. +function mergeItems(items, entries) { + const out = [] + for (const item of items) { + const o = entries.get(item.to) + if (o?.hidden) continue + // Spread the base item first so `to`, `roles`, `feature`, `icon` and `end` + // survive verbatim — the override only ever lands on `label`. + out.push({ ...item, ...(o?.label ? { label: o.label } : {}), __order: o?.order }) + } + return out +} + +/** + * @param {Array} baseNav the hardcoded nav — the source of truth for `to`, + * `roles`, `feature`, `icon` and `end` + * @param {object|null} overrides the parsed settings JSON, keyed by `to`, or + * null when the admin never touched this nav + * @returns {Array} a new array of the same shape, or `baseNav` itself when there + * is nothing to apply + */ +export function applyNavOverrides(baseNav, overrides) { + if (!Array.isArray(baseNav)) return [] + // The untouched path, and the one that matters most: no row, a malformed row, + // or a row with nothing usable in it all render the nav exactly as coded. + if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav + + const grouped = isGrouped(baseNav) + const groupTitles = new Set( + grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [], + ) + + // Keyed by `to`, and only for a `to` the base nav actually declares. An + // override for a route that no longer exists is dropped here, so deleting a + // route in code can never leave a dangling override that does something + // unexpected later. + const known = new Set( + grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to), + ) + const entries = new Map() + for (const [to, raw] of Object.entries(overrides)) { + if (!known.has(to)) continue + const entry = cleanEntry(raw, groupTitles) + if (entry && Object.keys(entry).length > 0) entries.set(to, entry) + } + if (entries.size === 0) return baseNav + + if (!grouped) return byOrder(mergeItems(baseNav, entries)) + + // Grouped: an item may also be moved into another *existing* titled section. + // Groups keep their coded order — only membership and within-group order move. + const moved = new Map() // destination title → items pulled in from elsewhere + const kept = baseNav.map((g) => { + const items = [] + for (const item of g.items) { + const o = entries.get(item.to) + if (o?.group && o.group !== g.title) { + if (!moved.has(o.group)) moved.set(o.group, []) + moved.get(o.group).push(item) + continue + } + items.push(item) + } + return { ...g, items } + }) + + return kept + .map((g) => ({ + ...g, + items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)), + })) + // A group whose every item was hidden must not leave an orphaned header. + // AdminLayout drops empty groups again after its own role filter; doing it + // here too keeps the util correct on its own. + .filter((g) => g.items.length > 0) +} + +export default applyNavOverrides diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css index 351ce14..a02c4be 100644 --- a/client/src/styles/theme.css +++ b/client/src/styles/theme.css @@ -24,6 +24,22 @@ --shadow-card: 0 14px 34px rgba(0, 0, 0, 0.3); --panel-grad: linear-gradient(180deg, var(--panel-a), var(--panel-b)); + + /* Corner radius, by the kind of surface rather than by the pixel value, so a + theme preset can restyle all of them at once (see + docs/website/THEMING_AND_NAV.md §4.7). Seeded at the values already in use + — this promotion is a no-op, and every existing instance must keep looking + exactly as it does today. + + Deliberately four tokens, not three: .card/.panel are 10px and .panel-flat + is 12px, so collapsing them would have restyled every card on every + install. The 7px (.rte-btn) and 6px (.rte-linkmenu-item) values stay + literals — interior editor chrome, not brand surface — as do the 50% + circles, which are shapes rather than radii. */ + --radius-pill: 999px; + --radius-panel: 12px; + --radius-card: 10px; + --radius-input: 8px; } * { @@ -99,7 +115,7 @@ a { flex-direction: column; padding: 24px; border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-card); text-decoration: none; color: var(--ink); background: var(--panel-grad); @@ -123,19 +139,19 @@ a.card:focus-visible { } .panel { border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-card); background: var(--panel-grad); } .panel-flat { border: 1px solid var(--line); - border-radius: 12px; + border-radius: var(--radius-panel); overflow: hidden; background: var(--panel-flat); } .note { border: 1px solid var(--line); border-left: 3px solid var(--accent); - border-radius: 8px; + border-radius: var(--radius-input); background: rgba(19, 36, 60, 0.4); padding: 18px 22px; color: var(--muted); @@ -168,7 +184,7 @@ a.card:focus-visible { /* ===== Pills / buttons ===== */ .pill { border: 1px solid var(--line); - border-radius: 999px; + border-radius: var(--radius-pill); padding: 7px 14px; color: var(--muted); background: rgba(11, 22, 48, 0.5); @@ -186,7 +202,7 @@ a.card:focus-visible { outline: none; } .btn { - border-radius: 999px; + border-radius: var(--radius-pill); padding: 12px 26px; font-family: var(--sans); font-size: 0.92rem; @@ -214,7 +230,7 @@ a.card:focus-visible { background: var(--blue); } .btn-sq { - border-radius: 8px; + border-radius: var(--radius-input); padding: 10px 18px; font-size: 0.85rem; } @@ -230,7 +246,7 @@ button[disabled] { .select { width: 100%; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); padding: 11px 14px; background: var(--bg); color: var(--ink); @@ -312,7 +328,7 @@ button[disabled] { } .prose img { max-width: 100%; - border-radius: 8px; + border-radius: var(--radius-input); border: 1px solid var(--line); } @@ -320,7 +336,7 @@ button[disabled] { .rte { position: relative; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); background: var(--bg); } .rte:focus-within { @@ -407,7 +423,7 @@ button[disabled] { width: min(360px, calc(100% - 20px)); padding: 10px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); background: var(--panel-a); box-shadow: var(--shadow-card); } @@ -449,7 +465,7 @@ button[disabled] { display: inline-block; padding: 3px 10px; border: 1px solid var(--line); - border-radius: 999px; + border-radius: var(--radius-pill); background: rgba(127, 153, 189, 0.1); color: var(--accent); font-family: var(--sans); @@ -494,7 +510,7 @@ button[disabled] { width: 100%; padding: 8px 10px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); background: var(--panel-flat); color: var(--text); text-align: left; @@ -532,7 +548,7 @@ button[disabled] { overflow-y: auto; padding: 12px 14px; border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); background: var(--bg); } .diff-add { @@ -603,7 +619,7 @@ button[disabled] { vertical-align: middle; } .badge { - border-radius: 999px; + border-radius: var(--radius-pill); padding: 3px 11px; font-size: 0.72rem; font-weight: 700; @@ -780,7 +796,7 @@ button[disabled] { } .page-image img { max-width: 100%; - border-radius: 8px; + border-radius: var(--radius-input); border: 1px solid var(--line); display: block; } @@ -863,7 +879,7 @@ button[disabled] { } .pb-column-editor { border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); padding: 12px; background: var(--panel-flat, transparent); } @@ -881,7 +897,7 @@ button[disabled] { } .pb-subblock { border: 1px solid var(--line); - border-radius: 8px; + border-radius: var(--radius-input); padding: 10px; margin-top: 10px; background: var(--bg); @@ -919,7 +935,7 @@ button[disabled] { border: 1px solid #6e3b38; background: rgba(110, 59, 56, 0.16); color: #e6a9a3; - border-radius: 8px; + border-radius: var(--radius-input); padding: 10px 14px; margin-top: 14px; font-size: 0.86rem; @@ -928,7 +944,7 @@ button[disabled] { border: 1px solid var(--accent); background: var(--blue); color: var(--accent-bright); - border-radius: 8px; + border-radius: var(--radius-input); padding: 8px 14px; margin-top: 14px; font-size: 0.86rem; @@ -960,7 +976,7 @@ button[disabled] { gap: 8px; padding: 12px; border: 1px dashed var(--line); - border-radius: 10px; + border-radius: var(--radius-card); margin-bottom: 16px; } .pb-canvas { @@ -970,7 +986,7 @@ button[disabled] { } .pb-block-card { border: 1px solid var(--line); - border-radius: 10px; + border-radius: var(--radius-card); background: var(--panel-flat, transparent); } .pb-block-card.is-dragging { @@ -1082,7 +1098,7 @@ button[disabled] { border: 1px solid var(--accent); background: var(--blue); color: var(--accent-bright); - border-radius: 8px; + border-radius: var(--radius-input); padding: 8px 14px; margin-bottom: 20px; font-size: 0.85rem; diff --git a/client/test/navOverrides.test.js b/client/test/navOverrides.test.js new file mode 100644 index 0000000..027397a --- /dev/null +++ b/client/test/navOverrides.test.js @@ -0,0 +1,203 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { applyNavOverrides } from '../src/lib/navOverrides.js' + +// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece +// of this feature with real correctness risk, so it is tested in isolation from +// React. Two properties matter above all others: +// +// 1. No override, or a useless one, renders the coded nav untouched. +// 2. The override cannot add a route, cannot touch a role/feature gate, and +// cannot un-hide anything. It is presentation only. + +const FLAT = [ + { label: 'Home', to: '/', end: true }, + { label: 'News', to: '/site/news' }, + { label: 'Wiki', to: '/wiki' }, + { label: 'Shard', to: '/site/shard', feature: 'status' }, +] + +const GROUPED = [ + { items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] }, + { + title: 'Content', + items: [ + { to: '/admin/posts', label: 'Posts', roles: ['admin', 'editor'] }, + { to: '/admin/wiki', label: 'Wiki', roles: ['admin', 'editor'] }, + ], + }, + { + title: 'System', + items: [ + { to: '/admin/settings', label: 'Settings', roles: ['admin'] }, + { to: '/admin/users', label: 'Users', roles: ['admin'] }, + ], + }, +] + +const labels = (nav) => nav.map((i) => i.label) +const groupLabels = (nav) => nav.map((g) => [g.title ?? null, g.items.map((i) => i.label)]) + +// ── The untouched path ──────────────────────────────────────────────────── + +// Most instances will never set these keys. Absence must be a true no-op, and +// cheap: the same array reference back means no needless re-render either. +test('no override returns the base nav unchanged', () => { + for (const overrides of [null, undefined, '', 0, [], 'not an object']) { + assert.equal(applyNavOverrides(FLAT, overrides), FLAT) + } +}) + +test('an override with nothing usable in it returns the base nav unchanged', () => { + assert.equal(applyNavOverrides(FLAT, {}), FLAT) + // Every field here is unusable: unknown route, blank label, non-numeric order, + // hidden as a string rather than the boolean true. + assert.equal( + applyNavOverrides(FLAT, { + '/does/not/exist': { label: 'Ghost', hidden: true }, + '/wiki': { label: ' ', order: 'first', hidden: 'yes' }, + }), + FLAT, + ) +}) + +// ── The security boundary ───────────────────────────────────────────────── + +// The single most important negative case: the override layer must never be a +// way to introduce a route into a nav. +test('an unknown `to` is ignored, never added', () => { + const out = applyNavOverrides(FLAT, { '/admin/secret': { label: 'Secret', order: 0 } }) + assert.equal(out.length, FLAT.length) + assert.ok(!out.some((i) => i.to === '/admin/secret')) +}) + +test('roles, feature, icon, end and to survive the merge verbatim', () => { + const out = applyNavOverrides(FLAT, { + '/site/shard': { label: 'Server Status', roles: ['player'], feature: null, to: '/evil' }, + }) + const shard = out.find((i) => i.to === '/site/shard') + assert.equal(shard.label, 'Server Status') // the one thing an override may set + assert.equal(shard.feature, 'status') // gate untouched + assert.equal(shard.roles, undefined) // and not invented + assert.ok(!out.some((i) => i.to === '/evil')) +}) + +test('hidden:false cannot un-hide anything — hiding is subtractive only', () => { + // The item is still present after the merge; whether it renders is decided by + // the caller's own role/feature filter, which this layer cannot reach. + const out = applyNavOverrides(GROUPED, { '/admin/settings': { hidden: false } }) + assert.equal(out, GROUPED, 'a no-op override leaves the base nav alone') +}) + +// ── Flat navs: label, order, hidden ─────────────────────────────────────── + +test('label overrides only the labelled item', () => { + const out = applyNavOverrides(FLAT, { '/site/news': { label: 'Announcements' } }) + assert.deepEqual(labels(out), ['Home', 'Announcements', 'Wiki', 'Shard']) +}) + +test('hidden drops the item', () => { + const out = applyNavOverrides(FLAT, { '/wiki': { hidden: true } }) + assert.deepEqual(labels(out), ['Home', 'News', 'Shard']) +}) + +// An item the admin never reordered keeps its position in the coded array, so +// setting one order does not scramble the rest. +test('order moves one item and leaves the others in code order', () => { + const out = applyNavOverrides(FLAT, { '/wiki': { order: -1 } }) + assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard']) +}) + +test('two items given the same order keep their code order (stable sort)', () => { + const out = applyNavOverrides(FLAT, { '/site/news': { order: 0 }, '/wiki': { order: 0 } }) + // News before Wiki — the tie resolves to the coded order, not to insertion + // order in the settings JSON. Both precede Home, whose 0 is only its index. + assert.deepEqual(labels(out), ['News', 'Wiki', 'Home', 'Shard']) +}) + +// An explicit order and an untouched item's index share one number line, so +// they can collide. "Put this first" has to actually mean first. +test('an explicit order beats an untouched item that merely sits at that index', () => { + const out = applyNavOverrides(FLAT, { '/wiki': { order: 0 } }) + assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard']) +}) + +test('the merge does not mutate the base nav', () => { + const before = JSON.stringify(FLAT) + applyNavOverrides(FLAT, { '/wiki': { label: 'Library', order: 0, hidden: false } }) + assert.equal(JSON.stringify(FLAT), before) +}) + +test('no internal sort key leaks into the returned items', () => { + const out = applyNavOverrides(FLAT, { '/wiki': { order: 1 } }) + for (const item of out) assert.ok(!('__order' in item), 'sort key must not be rendered') +}) + +// ── Grouped (admin) navs ────────────────────────────────────────────────── + +test('label and order apply within a group', () => { + const out = applyNavOverrides(GROUPED, { + '/admin/wiki': { label: 'Knowledge Base', order: 0 }, + }) + assert.deepEqual(groupLabels(out), [ + [null, ['Dashboard']], + ['Content', ['Knowledge Base', 'Posts']], + ['System', ['Settings', 'Users']], + ]) +}) + +test('group moves an item into another existing section', () => { + const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content' } }) + assert.deepEqual(groupLabels(out), [ + [null, ['Dashboard']], + ['Content', ['Posts', 'Wiki', 'Users']], + ['System', ['Settings']], + ]) +}) + +// A group that does not exist must not conjure a header. Groups are chosen from +// a dropdown of existing titles in the editor; this is the stale-row guard. +test('a group that is not an existing title is ignored', () => { + const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Danger Zone' } }) + assert.deepEqual(groupLabels(out), [ + [null, ['Dashboard']], + ['Content', ['Posts', 'Wiki']], + ['System', ['Settings', 'Users']], + ]) +}) + +test('a moved item can be ordered in its new group', () => { + const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content', order: -1 } }) + assert.deepEqual(groupLabels(out)[1], ['Content', ['Users', 'Posts', 'Wiki']]) +}) + +test('hiding every item in a group leaves no orphaned header', () => { + const out = applyNavOverrides(GROUPED, { + '/admin/settings': { hidden: true }, + '/admin/users': { hidden: true }, + }) + assert.deepEqual(groupLabels(out), [ + [null, ['Dashboard']], + ['Content', ['Posts', 'Wiki']], + ]) +}) + +test('group ordering itself is not overridable — sections stay in code order', () => { + const out = applyNavOverrides(GROUPED, { '/admin/settings': { order: -99 } }) + assert.deepEqual( + out.map((g) => g.title ?? null), + [null, 'Content', 'System'], + ) +}) + +// ── Degenerate input ────────────────────────────────────────────────────── + +test('a non-array base nav yields an empty nav rather than throwing', () => { + assert.deepEqual(applyNavOverrides(null, { '/': { hidden: true } }), []) + assert.deepEqual(applyNavOverrides(undefined, null), []) +}) + +test('an empty base nav stays empty', () => { + assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), []) +}) diff --git a/server/routes.guards.json b/server/routes.guards.json index c7c6c5e..24c25f0 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -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": [ diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 1692271..ad7d5d3 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -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": [ diff --git a/server/src/model/settings/settings.db.js b/server/src/model/settings/settings.db.js index 012493d..07c4547 100644 --- a/server/src/model/settings/settings.db.js +++ b/server/src/model/settings/settings.db.js @@ -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 } diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 6c4b61e..a2bd505 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -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, diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index d7707cb..d80ce14 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -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, diff --git a/server/src/router/v1/admin/settings.router.js b/server/src/router/v1/admin/settings.router.js index 626cce1..37b4ac8 100644 --- a/server/src/router/v1/admin/settings.router.js +++ b/server/src/router/v1/admin/settings.router.js @@ -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 diff --git a/server/src/router/v1/settings/index.js b/server/src/router/v1/settings/index.js new file mode 100644 index 0000000..2bf8c72 --- /dev/null +++ b/server/src/router/v1/settings/index.js @@ -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 diff --git a/server/src/router/v1/settings/nav.controller.js b/server/src/router/v1/settings/nav.controller.js new file mode 100644 index 0000000..249ad23 --- /dev/null +++ b/server/src/router/v1/settings/nav.controller.js @@ -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 } diff --git a/server/src/router/v1/settings/nav.router.js b/server/src/router/v1/settings/nav.router.js new file mode 100644 index 0000000..d06d853 --- /dev/null +++ b/server/src/router/v1/settings/nav.router.js @@ -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 diff --git a/server/src/router/v1/v1.router.js b/server/src/router/v1/v1.router.js index d354db9..eddc566 100644 --- a/server/src/router/v1/v1.router.js +++ b/server/src/router/v1/v1.router.js @@ -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 diff --git a/server/src/utils/settingsJson.js b/server/src/utils/settingsJson.js new file mode 100644 index 0000000..524315a --- /dev/null +++ b/server/src/utils/settingsJson.js @@ -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 } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 766d02e..dd221ff 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -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": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index d1d6ba7..450e979 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -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: { diff --git a/server/test/routeManifest.test.js b/server/test/routeManifest.test.js index e23484b..312ac9d 100644 --- a/server/test/routeManifest.test.js +++ b/server/test/routeManifest.test.js @@ -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( diff --git a/server/test/settingsTheming.test.js b/server/test/settingsTheming.test.js new file mode 100644 index 0000000..b8db7eb --- /dev/null +++ b/server/test/settingsTheming.test.js @@ -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' }) +})