+
diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css
index 351ce14..dd24ae3 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,12 +184,20 @@ 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);
font-family: var(--sans);
font-size: 0.86rem;
+ /* Stated, not inherited. A would otherwise take the UA
+ stylesheet's `line-height: normal` — form controls do not inherit it from
+ body — and come out ~7px shorter than an beside it. Every
+ other property here is already explicit for the same reason; this was the
+ one gap, and it only became visible once the public header put a button
+ pill (a dropdown trigger) on the same row as the link pills. Matches
+ body's 1.6, so no link pill changes. */
+ line-height: 1.6;
text-decoration: none;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
@@ -186,7 +210,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 +238,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 +254,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 +336,7 @@ button[disabled] {
}
.prose img {
max-width: 100%;
- border-radius: 8px;
+ border-radius: var(--radius-input);
border: 1px solid var(--line);
}
@@ -320,7 +344,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 +431,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 +473,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 +518,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 +556,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 +627,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 +804,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 +887,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 +905,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 +943,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 +952,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 +984,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 +994,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 +1106,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..b27183c
--- /dev/null
+++ b/client/test/navOverrides.test.js
@@ -0,0 +1,544 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ applyNavOverrides,
+ buildNavRows,
+ buildNavOverrides,
+ buildPublicNav,
+ pruneNav,
+ buildPublicNavOverrides,
+} 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' } }), [])
+})
+
+// ── The editor's round trip (phase 7) ─────────────────────────────────────
+//
+// buildNavRows and buildNavOverrides are inverse, and the property that matters
+// is that the editor and the site agree: the rows an admin drags come out of the
+// same merge the layouts render, hidden ones included.
+
+const rowLabels = (groups) => groups.map((g) => [g.title, g.items.map((i) => i.label)])
+
+test('rows with no override are the coded nav, in code order', () => {
+ const rows = buildNavRows(FLAT, null)
+ assert.deepEqual(rowLabels(rows), [[null, ['Home', 'News', 'Wiki', 'Shard']]])
+ assert.equal(rows[0].items.every((i) => i.hidden === false), true)
+})
+
+test('a flat nav becomes one untitled group, so one editor handles both shapes', () => {
+ assert.equal(buildNavRows(FLAT, null).length, 1)
+ assert.equal(buildNavRows(GROUPED, null).length, 3)
+})
+
+test('rows keep hidden items, in place and marked — the site drops them', () => {
+ const overrides = { '/site/news': { hidden: true } }
+ // The layout must not render it...
+ assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Home', 'Wiki', 'Shard'])
+ // ...while the editor must, or there is no way to un-hide it.
+ const rows = buildNavRows(FLAT, overrides)[0].items
+ assert.deepEqual(rows.map((i) => i.label), ['Home', 'News', 'Wiki', 'Shard'])
+ assert.equal(rows[1].hidden, true)
+ assert.equal(rows[0].hidden, false)
+})
+
+test('rows carry the coded label alongside the overridden one', () => {
+ const rows = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })[0].items
+ assert.equal(rows[1].label, 'Announcements')
+ assert.equal(rows[1].defaultLabel, 'News')
+})
+
+test('rows show the same order the site renders', () => {
+ const overrides = { '/wiki': { order: 0 }, '/': { order: 1 } }
+ assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Wiki', 'Home', 'News', 'Shard'])
+ assert.deepEqual(rowLabels(buildNavRows(FLAT, overrides)), [[null, ['Wiki', 'Home', 'News', 'Shard']]])
+})
+
+test('rows keep an emptied group so something can be moved back into it', () => {
+ // applyNavOverrides drops a group whose every item is hidden; the editor must
+ // still show the header, or the section is unreachable forever.
+ const overrides = { '/admin/posts': { hidden: true }, '/admin/wiki': { hidden: true } }
+ assert.equal(applyNavOverrides(GROUPED, overrides).some((g) => g.title === 'Content'), false)
+ assert.equal(buildNavRows(GROUPED, overrides).some((g) => g.title === 'Content'), true)
+})
+
+test('an untouched editor saves nothing at all', () => {
+ // Opening the screen and pressing Save must not pin the position of every
+ // item — the caller deletes the row when this comes back empty.
+ assert.deepEqual(buildNavOverrides(buildNavRows(FLAT, null), FLAT), {})
+ assert.deepEqual(buildNavOverrides(buildNavRows(GROUPED, null), GROUPED), {})
+})
+
+test('a rename alone writes a label and no orders', () => {
+ const groups = buildNavRows(FLAT, null)
+ groups[0].items[1].label = 'Announcements'
+ assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/news': { label: 'Announcements' } })
+})
+
+test('a label typed back to the coded one is not stored as an override', () => {
+ const groups = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })
+ groups[0].items[1].label = 'News'
+ assert.deepEqual(buildNavOverrides(groups, FLAT), {})
+ // Whitespace-only reads as "use the default" too.
+ groups[0].items[1].label = ' '
+ assert.deepEqual(buildNavOverrides(groups, FLAT), {})
+})
+
+test('hiding alone writes hidden and no orders', () => {
+ const groups = buildNavRows(FLAT, null)
+ groups[0].items[3].hidden = true
+ assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/shard': { hidden: true } })
+})
+
+test('reordering writes an order for every row in the list', () => {
+ // §7.1: explicit and implicit sort keys share one number line, so a partial
+ // set of orders is the stale-row case rather than something the editor makes.
+ const groups = buildNavRows(FLAT, null)
+ const [home] = groups[0].items.splice(0, 1)
+ groups[0].items.push(home)
+ assert.deepEqual(buildNavOverrides(groups, FLAT), {
+ '/site/news': { order: 0 },
+ '/wiki': { order: 1 },
+ '/site/shard': { order: 2 },
+ '/': { order: 3 },
+ })
+})
+
+test('the round trip is stable: save, reload, save again yields the same thing', () => {
+ const groups = buildNavRows(FLAT, null)
+ groups[0].items.reverse()
+ groups[0].items[0].label = 'The Shard'
+ const first = buildNavOverrides(groups, FLAT)
+ const second = buildNavOverrides(buildNavRows(FLAT, first), FLAT)
+ assert.deepEqual(second, first)
+ // And it renders what the editor showed.
+ assert.deepEqual(labels(applyNavOverrides(FLAT, first)), ['The Shard', 'Wiki', 'News', 'Home'])
+})
+
+test('moving an item to another section writes group, and moving it back clears it', () => {
+ const groups = buildNavRows(GROUPED, null)
+ const [posts] = groups[1].items.splice(0, 1)
+ groups[2].items.push(posts)
+ const saved = buildNavOverrides(groups, GROUPED)
+ assert.equal(saved['/admin/posts'].group, 'System')
+ assert.deepEqual(groupLabels(applyNavOverrides(GROUPED, saved)), [
+ [null, ['Dashboard']],
+ ['Content', ['Wiki']],
+ ['System', ['Settings', 'Users', 'Posts']],
+ ])
+ const back = buildNavRows(GROUPED, saved)
+ const [moved] = back[2].items.splice(2, 1)
+ back[1].items.unshift(moved)
+ assert.equal(buildNavOverrides(back, GROUPED)['/admin/posts'], undefined)
+})
+
+test('an override for an item outside this admin’s palette survives a save', () => {
+ // §8.1 filters the editor to what the editing admin can themselves see. An
+ // item filtered out has no row, and must not be quietly reset by their save.
+ const visible = buildNavRows(FLAT, { '/site/shard': { hidden: true } }).map((g) => ({
+ ...g,
+ items: g.items.filter((i) => !i.feature),
+ }))
+ const stored = { '/site/shard': { hidden: true }, '/site/news': { label: 'Old' } }
+ const out = buildNavOverrides(visible, FLAT, stored)
+ assert.deepEqual(out['/site/shard'], { hidden: true })
+ // The rows they *could* see still win over what was stored.
+ assert.equal(out['/site/news'], undefined)
+})
+
+test('a stored entry for a route the code no longer declares is dropped on save', () => {
+ const groups = buildNavRows(FLAT, null)
+ const out = buildNavOverrides(groups, FLAT, { '/site/gone': { label: 'Ghost' } })
+ assert.deepEqual(out, {})
+})
+
+test('degenerate input yields an empty result rather than throwing', () => {
+ assert.deepEqual(buildNavRows(null, {}), [])
+ assert.deepEqual(buildNavRows([], {}), [])
+ assert.deepEqual(buildNavOverrides(null, FLAT), {})
+ assert.deepEqual(buildNavOverrides([], null), {})
+})
+
+// ── The public header: sections and added links (phase 10) ────────────────
+//
+// The one nav an admin can restructure rather than only reorder. The invariant
+// that has to survive is §7's, in its narrower form: a CODED entry still cannot
+// have its `to` or `feature` touched, and everything that can name an arbitrary
+// path lives in `links`, where the path rule applies.
+
+const PUB = [
+ { label: 'Home', to: '/', end: true },
+ { label: 'News', to: '/site/news' },
+ { label: 'Champions', to: '/site/champs', feature: 'champs' },
+ { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
+ { label: 'About', to: '/site/about' },
+]
+const shape = (tree) =>
+ tree.map((n) => (n.kind === 'section' ? { [n.label]: n.items.map((i) => i.label) } : n.label))
+
+test('no override yields the coded header, in code order', () => {
+ assert.deepEqual(shape(buildPublicNav(PUB, null)), ['Home', 'News', 'Champions', 'Guilds', 'About'])
+ assert.deepEqual(shape(buildPublicNav(PUB, {})), ['Home', 'News', 'Champions', 'Guilds', 'About'])
+})
+
+test('a phase 6-8 bare map still reads as the items map', () => {
+ // Nothing has shipped, but a row written during review must not become
+ // unreadable just because the wrapper arrived.
+ assert.deepEqual(shape(buildPublicNav(PUB, { '/site/news': { label: 'Announcements' } })), [
+ 'Home',
+ 'Announcements',
+ 'Champions',
+ 'Guilds',
+ 'About',
+ ])
+})
+
+const SECTIONED = {
+ items: { '/site/champs': { section: 'sec_aaaa', order: 0 }, '/site/guilds': { section: 'sec_aaaa', order: 1 } },
+ sections: [{ id: 'sec_aaaa', label: 'The World', order: 2 }],
+ links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_aaaa', order: 2 }],
+}
+
+test('a section collects its members and sits in the top-level order', () => {
+ assert.deepEqual(shape(buildPublicNav(PUB, SECTIONED)), [
+ 'Home',
+ 'News',
+ { 'The World': ['Champions', 'Guilds', 'Guide'] },
+ 'About',
+ ])
+})
+
+test('an added link is kept apart from the coded items', () => {
+ const tree = buildPublicNav(PUB, SECTIONED)
+ const link = tree.find((n) => n.kind === 'section').items.find((i) => i.kind === 'link')
+ assert.equal(link.to, '/wiki/new-player-guide')
+ assert.equal(link.id, 'lnk_bbbb')
+ // It carries no gate of its own — that is the documented contract, and the
+ // page behind it is what actually enforces access.
+ assert.equal(link.feature, undefined)
+ assert.equal(link.roles, undefined)
+})
+
+test('an off-origin link is dropped rather than rendered', () => {
+ for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', '/x y', '/a"b']) {
+ const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Bad', to }] })
+ assert.equal(
+ tree.some((n) => n.kind === 'link'),
+ false,
+ `${to} should be dropped`,
+ )
+ }
+})
+
+test('an item naming a section that does not exist stays at the top level', () => {
+ const tree = buildPublicNav(PUB, { items: { '/site/champs': { section: 'sec_gone' } } })
+ assert.deepEqual(shape(tree), ['Home', 'News', 'Champions', 'Guilds', 'About'])
+})
+
+test('an override still cannot introduce a coded route', () => {
+ const tree = buildPublicNav(PUB, { items: { '/site/secret': { label: 'Secret' } } })
+ assert.equal(
+ tree.some((n) => n.to === '/site/secret'),
+ false,
+ )
+})
+
+test('hidden entries are dropped for the site and kept for the editor', () => {
+ const overrides = { items: { '/site/news': { hidden: true } } }
+ assert.equal(shape(buildPublicNav(PUB, overrides)).includes('News'), false)
+ const rows = buildPublicNav(PUB, overrides, { keepHidden: true })
+ assert.equal(rows.find((n) => n.to === '/site/news').hidden, true)
+})
+
+// ── pruneNav: the empty dropdown ──────────────────────────────────────────
+
+test('a section keeps the entries the viewer may see', () => {
+ const tree = buildPublicNav(PUB, SECTIONED)
+ const out = pruneNav(tree, (i) => i.feature !== 'guilds')
+ assert.deepEqual(shape(out), ['Home', 'News', { 'The World': ['Champions', 'Guide'] }, 'About'])
+})
+
+test('a section whose every entry is gated out does not render at all', () => {
+ // The case that matters: a dropdown that opens onto nothing is worse than no
+ // dropdown, and shard visibility can empty one at any time.
+ const overrides = {
+ items: { '/site/champs': { section: 'sec_aaaa' }, '/site/guilds': { section: 'sec_aaaa' } },
+ sections: [{ id: 'sec_aaaa', label: 'The World' }],
+ }
+ const tree = buildPublicNav(PUB, overrides)
+ assert.deepEqual(shape(pruneNav(tree, () => true)), [
+ 'Home',
+ 'News',
+ 'About',
+ { 'The World': ['Champions', 'Guilds'] },
+ ])
+ assert.deepEqual(shape(pruneNav(tree, (i) => !i.feature)), ['Home', 'News', 'About'])
+})
+
+test('an added link is never pruned — it carries no gate', () => {
+ const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/g' }] })
+ assert.equal(
+ pruneNav(tree, () => false).some((n) => n.kind === 'link'),
+ true,
+ )
+})
+
+// ── The editor round trip ─────────────────────────────────────────────────
+
+test('an untouched public editor saves nothing', () => {
+ assert.deepEqual(buildPublicNavOverrides(buildPublicNav(PUB, null, { keepHidden: true }), PUB), {})
+})
+
+test('a nav with no sections still stores the plain items map', () => {
+ // Adding this feature changed nothing for a nav that does not use it.
+ const tree = buildPublicNav(PUB, null, { keepHidden: true })
+ tree[1].label = 'Announcements'
+ const out = buildPublicNavOverrides(tree, PUB)
+ assert.deepEqual(out, { '/site/news': { label: 'Announcements' } })
+ assert.equal(out.items, undefined)
+})
+
+test('the sectioned round trip is stable and renders what the editor showed', () => {
+ const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
+ const first = buildPublicNavOverrides(tree, PUB)
+ const second = buildPublicNavOverrides(buildPublicNav(PUB, first, { keepHidden: true }), PUB)
+ assert.deepEqual(second, first)
+ assert.deepEqual(shape(buildPublicNav(PUB, first)), [
+ 'Home',
+ 'News',
+ { 'The World': ['Champions', 'Guilds', 'Guide'] },
+ 'About',
+ ])
+})
+
+test('deleting a section returns its entries to the top level, never deletes them', () => {
+ // The one destructive act this screen could commit, so it is locked here.
+ const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
+ const section = tree.find((n) => n.kind === 'section')
+ const flattened = [...tree.filter((n) => n.kind !== 'section'), ...section.items]
+ const out = buildPublicNavOverrides(flattened, PUB)
+ const rendered = buildPublicNav(PUB, out)
+ assert.equal(
+ rendered.some((n) => n.kind === 'section'),
+ false,
+ )
+ assert.deepEqual(shape(rendered), ['Home', 'News', 'About', 'Champions', 'Guilds', 'Guide'])
+})
+
+test('an override for a feature-gated item outside the palette survives a save', () => {
+ // §8.1 filters the editor to what this admin can see. The rows come from their
+ // palette, but membership is judged against the FULL coded nav — otherwise a
+ // row a shard feature hid from them is indistinguishable from a deleted route,
+ // and their save would silently reset it.
+ const palette = PUB.filter((i) => i.feature !== 'champs')
+ // The editor was opened on a nav that only hides champs — which their palette
+ // does not show them. `stored` additionally carries a label for a row they CAN
+ // see, and which they have since reset.
+ const tree = buildPublicNav(palette, { items: { '/site/champs': { hidden: true } } }, { keepHidden: true })
+ const stored = { items: { '/site/champs': { hidden: true }, '/site/news': { label: 'Old' } } }
+ const out = buildPublicNavOverrides(tree, PUB, stored)
+ assert.deepEqual(out['/site/champs'], { hidden: true }, 'carried: they could not see it')
+ assert.equal(out['/site/news'], undefined, 'not carried: their row is the authority for what they can see')
+})
+
+test('a stored entry for a route the code no longer declares is dropped on save', () => {
+ const tree = buildPublicNav(PUB, null, { keepHidden: true })
+ assert.deepEqual(buildPublicNavOverrides(tree, PUB, { items: { '/site/gone': { label: 'Ghost' } } }), {})
+})
diff --git a/client/test/settingsJson.test.js b/client/test/settingsJson.test.js
new file mode 100644
index 0000000..4416e2c
--- /dev/null
+++ b/client/test/settingsJson.test.js
@@ -0,0 +1,28 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import { parseJsonSetting } from '../src/lib/settingsJson.js'
+
+// The client counterpart to the server's parseJsonSetting. The property that
+// matters is the fail-safe one: anything unusable reads as **absent**, so the
+// consumer falls back to its coded default rather than rendering an error or a
+// half-applied object (THEMING_AND_NAV.md §4.4).
+
+test('absent, empty and malformed values read as absent', () => {
+ for (const bad of [undefined, null, '', '{', 'not json', 4, {}, []]) {
+ assert.equal(parseJsonSetting(bad), null, `${JSON.stringify(bad)} should read as absent`)
+ }
+})
+
+test('valid JSON that is not a plain object reads as absent', () => {
+ // A stored `null`, number, string or array is as unusable to every consumer of
+ // these keys as a syntax error is.
+ for (const bad of ['null', '4', '"x"', '[]', '[{"to":"/"}]', 'true']) {
+ assert.equal(parseJsonSetting(bad), null, `${bad} should read as absent`)
+ }
+})
+
+test('a well-formed object is returned as parsed', () => {
+ assert.deepEqual(parseJsonSetting('{"/site/news":{"order":2}}'), { '/site/news': { order: 2 } })
+ assert.deepEqual(parseJsonSetting('{}'), {})
+})
diff --git a/client/test/themeVars.test.js b/client/test/themeVars.test.js
new file mode 100644
index 0000000..1bd15eb
--- /dev/null
+++ b/client/test/themeVars.test.js
@@ -0,0 +1,100 @@
+// applyThemeTokens — writing the server-resolved theme onto the document, and
+// (the part with real logic) taking back exactly what it wrote last time.
+//
+// Pure module, exercised against a fake CSSStyleDeclaration: node --test has no
+// DOM, and the function only ever needs setProperty/removeProperty.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import { applyThemeTokens } from '../src/lib/themeVars.js'
+
+// Minimal stand-in for element.style, plus a log of the calls so a test can
+// assert that a property was *removed* rather than merely absent.
+function fakeStyle() {
+ const props = new Map()
+ const removed = []
+ return {
+ props,
+ removed,
+ setProperty: (name, value) => props.set(name, value),
+ removeProperty: (name) => {
+ props.delete(name)
+ removed.push(name)
+ },
+ get: (name) => props.get(name),
+ }
+}
+
+test('writes each token and reports the keys it applied', () => {
+ const style = fakeStyle()
+ const applied = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
+ assert.equal(style.get('--accent'), '#c9973f')
+ assert.equal(style.get('--bg'), '#1a120b')
+ assert.deepEqual(applied.sort(), ['--accent', '--bg'])
+})
+
+// The untouched-instance case: no theme block means the stylesheet's :root
+// stands and nothing is written at all.
+test('no theme writes nothing', () => {
+ for (const empty of [null, undefined, {}]) {
+ const style = fakeStyle()
+ const applied = applyThemeTokens(style, empty)
+ assert.equal(style.props.size, 0)
+ assert.deepEqual(applied, [])
+ }
+})
+
+test('removes a token that is no longer in the theme', () => {
+ const style = fakeStyle()
+ const first = applyThemeTokens(style, { '--accent': '#c9973f', '--bg': '#1a120b' })
+ const second = applyThemeTokens(style, { '--accent': '#c9973f' }, first)
+ assert.equal(style.get('--accent'), '#c9973f')
+ assert.equal(style.get('--bg'), undefined)
+ assert.deepEqual(style.removed, ['--bg'])
+ assert.deepEqual(second, ['--accent'])
+})
+
+// "Reset to defaults" — the case that would look broken without the removal
+// half: the payload stops mentioning the variables, and the inline values have
+// to come off for :root to show through again.
+test('resetting to no theme clears everything previously applied', () => {
+ const style = fakeStyle()
+ const first = applyThemeTokens(style, { '--accent': '#c9973f', '--radius-card': '2px' })
+ const second = applyThemeTokens(style, null, first)
+ assert.equal(style.props.size, 0)
+ assert.deepEqual(style.removed.sort(), ['--accent', '--radius-card'])
+ assert.deepEqual(second, [])
+})
+
+// Only ever clears its own keys. SiteContext writes --accent itself from
+// brand.accent, and a future feature may write others; those are not ours.
+test('never removes a property it did not apply', () => {
+ const style = fakeStyle()
+ style.setProperty('--accent', '#ff0000') // someone else's write
+ applyThemeTokens(style, { '--bg': '#000000' }, [])
+ assert.equal(style.get('--accent'), '#ff0000')
+ assert.deepEqual(style.removed, [])
+})
+
+test('ignores anything that is not a custom property', () => {
+ const style = fakeStyle()
+ const applied = applyThemeTokens(style, { background: 'url(http://evil.example/x)', '--bg': '#000000' })
+ assert.equal(style.get('background'), undefined)
+ assert.deepEqual(applied, ['--bg'])
+})
+
+test('ignores non-string and empty values', () => {
+ const style = fakeStyle()
+ const applied = applyThemeTokens(style, { '--a': 4, '--b': null, '--c': '', '--d': '#fff' })
+ assert.deepEqual(applied, ['--d'])
+})
+
+// A stale key list must not survive a call that could not write: the next call
+// still has to know what is actually on the element.
+test('a token dropped as invalid is removed if it was applied before', () => {
+ const style = fakeStyle()
+ const first = applyThemeTokens(style, { '--bg': '#000000' })
+ const second = applyThemeTokens(style, { '--bg': '' }, first)
+ assert.equal(style.get('--bg'), undefined)
+ assert.deepEqual(second, [])
+})
diff --git a/server/routes.guards.json b/server/routes.guards.json
index c7c6c5e..3b32255 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -616,6 +616,25 @@
"requireAuth"
]
},
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/settings/:key",
+ "handlers": 2,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/settings/brand-asset/:slot",
+ "handlers": 3,
+ "gates": [
+ "noindex",
+ "requireAuth",
+ "multerMiddleware"
+ ]
+ },
{
"method": "POST",
"path": "/api/v1/admin/shard/account",
@@ -2136,6 +2155,24 @@
"gates": [
"siteMode"
]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/settings/nav",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/settings/theme/options",
+ "handlers": 1,
+ "gates": [
+ "noindex",
+ "requireAuth"
+ ]
}
],
"internal": [
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 1692271..3e97f78 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -249,6 +249,14 @@
"method": "PUT",
"path": "/api/v1/admin/settings"
},
+ {
+ "method": "DELETE",
+ "path": "/api/v1/admin/settings/:key"
+ },
+ {
+ "method": "POST",
+ "path": "/api/v1/admin/settings/brand-asset/:slot"
+ },
{
"method": "POST",
"path": "/api/v1/admin/shard/account"
@@ -892,6 +900,14 @@
{
"method": "GET",
"path": "/api/v1/public/wiki/tags"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/settings/nav"
+ },
+ {
+ "method": "GET",
+ "path": "/api/v1/settings/theme/options"
}
],
"internal": [
diff --git a/server/src/app.js b/server/src/app.js
index 211f240..62bfe38 100644
--- a/server/src/app.js
+++ b/server/src/app.js
@@ -16,6 +16,7 @@ const brand = require('./config/brand')
const csp = require('./config/csp')
const { cspReportLimiter } = require('./middleware/rateLimit')
const createLogger = require('./utils/logger')
+const htmlShell = require('./utils/htmlShell')
const { applyTrustProxy, trustProxyDebug } = require('./utils/trustProxy')
const botScore = require('./middleware/botScore')
@@ -96,31 +97,6 @@ const htmlEscape = (s) =>
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
)
-// Template the built index.html with instance branding (title, meta
-// description, Open Graph/Twitter, favicon). Done once at boot from BRAND_* env,
-// so the prebuilt SPA image serves per-instance metadata without a rebuild.
-function renderIndexHtml(html) {
- const title = htmlEscape(brand.name)
- const desc = htmlEscape(brand.description)
- const tags = [
- ` `,
- ` `,
- ' ',
- brand.url ? ` ` : '',
- brand.logo ? ` ` : '',
- ' ',
- ` `,
- ` `,
- brand.favicon ? ` ` : '',
- ]
- .filter(Boolean)
- .join('\n ')
- return html
- .replace(/[\s\S]*?<\/title>/i, `${title} `)
- .replace(/( )/i, `$1${desc}$2`)
- .replace(/<\/head>/i, ` ${tags}\n `)
-}
-
// Uploaded images — always served, even during maintenance. Force nosniff so a
// stored file is never interpreted as anything other than its declared type
// (defense in depth alongside helmet's global X-Content-Type-Options, and in
@@ -204,9 +180,23 @@ if (fs.existsSync(BRAND_DIR)) {
if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) {
// Serve a branded copy of the index.html shell for every SPA route; assets keep
// their own cache-friendly static handler.
- const indexHtml = renderIndexHtml(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
+ //
+ // The shell is templated from BRAND_* env *and* the admin's brand_assets /
+ // theme_visual rows, so it is rendered lazily and cached rather than built once
+ // at boot: see utils/htmlShell.js for the caching, the invalidation and why a
+ // DB fault still serves a page.
+ htmlShell.init(fs.readFileSync(path.join(CLIENT_DIST, 'index.html'), 'utf8'))
app.use(express.static(CLIENT_DIST, { index: false }))
- app.get('*', (req, res) => res.type('html').send(indexHtml))
+ app.get('*', async (req, res, next) => {
+ // htmlShell.get() swallows a settings-read failure itself; the try is for
+ // anything unforeseen, since an async handler that rejects in Express 4
+ // hangs the request instead of reaching the error handler below.
+ try {
+ res.type('html').send(await htmlShell.get())
+ } catch (err) {
+ next(err)
+ }
+ })
} else {
app.get('*', (req, res) =>
res
diff --git a/server/src/config/themePresets.js b/server/src/config/themePresets.js
new file mode 100644
index 0000000..1b989b0
--- /dev/null
+++ b/server/src/config/themePresets.js
@@ -0,0 +1,215 @@
+// ── Theme presets & the closed sets an admin may choose from ───────────────
+//
+// The single authority for admin-configurable theming (docs/website/THEMING_AND_NAV.md
+// §5-§6). Everything an admin can pick is enumerated here; nothing is free text.
+//
+// Why the server owns this rather than theme.css:
+// The effective token set is resolved server-side and returned by
+// settings.getPublic() as `theme`, which the SPA writes onto the document as
+// CSS custom properties. That keeps ONE authority for the override merge
+// (:root ← preset ← custom), lets brand.accent — a cross-repo contract the
+// Android app themes itself from — report the same accent the website paints,
+// and avoids the precedence trap of `[data-theme]` blocks losing to the inline
+// `--accent` SiteContext already sets on .
+//
+// theme.css's `:root` remains the default and is NOT duplicated here beyond
+// the runic-gateway preset. An instance with no `theme_visual` row gets no
+// `theme` block at all and renders from :root exactly as it does today.
+//
+// Security note: these values end up as CSS custom property values. Every one is
+// picked from a closed set (a preset id, a shortlist stack, a bounded px length,
+// a hex color) — see utils/themeResolve.js, which both the write path and the
+// read path validate through.
+
+// The three color tokens that are semantic rather than decorative. They mean
+// "live" and "maintenance" and stay fixed across every preset — green is not a
+// brand choice. Deliberately absent from every preset block below.
+const FIXED_TOKENS = ['--mode-live', '--mode-maint']
+
+// Full palettes. A preset must carry EVERY color token, not just the eight the
+// admin form exposes: a partial palette leaves e.g. --line and --blue at their
+// dark-blue :root values, which reads as broken on a warm background.
+//
+// --panel-grad is deliberately absent: it is derived (`linear-gradient(180deg,
+// var(--panel-a), var(--panel-b))`) and must stay derived, or a future light
+// preset silently inherits a dark gradient.
+const PRESETS = {
+ // Today's :root, verbatim. Declared as a preset so that switching back to it
+ // after trying another is the same code path as any other choice.
+ 'runic-gateway': {
+ label: 'Runic Gateway',
+ tokens: {
+ '--bg': '#0e1318',
+ '--bg-deep': '#0b0f14',
+ '--panel-a': '#192231',
+ '--panel-b': '#141a21',
+ '--panel-flat': '#11161d',
+ '--line': '#2a3544',
+ '--line-soft': '#1d2733',
+ '--accent': '#7f99bd',
+ '--accent-bright': '#cdd9e8',
+ '--ink': '#eef3f8',
+ '--head': '#e6edf6',
+ '--text': '#c4cdd8',
+ '--muted': '#aeb8c4',
+ '--dim': '#6f7d8e',
+ '--blue': '#13243c',
+ '--radius-pill': '999px',
+ '--radius-panel': '12px',
+ '--radius-card': '10px',
+ '--radius-input': '8px',
+ '--shadow-card': '0 14px 34px rgba(0, 0, 0, 0.3)',
+ '--serif': 'Georgia, "Times New Roman", serif',
+ '--display': 'Cinzel, Georgia, serif',
+ '--sans': '"Helvetica Neue", Arial, sans-serif',
+ },
+ },
+ // Flatter, cooler, sans-heavy. Reads as a SaaS dashboard, not fantasy.
+ modern: {
+ label: 'Modern',
+ tokens: {
+ '--bg': '#101114',
+ '--bg-deep': '#0a0a0c',
+ '--panel-a': '#1c1d22',
+ '--panel-b': '#17181c',
+ '--panel-flat': '#141519',
+ '--line': '#2b2d34',
+ '--line-soft': '#212329',
+ '--accent': '#4f8ef7',
+ '--accent-bright': '#a8c8ff',
+ '--ink': '#f2f3f5',
+ '--head': '#f7f8fa',
+ '--text': '#b8bcc4',
+ '--muted': '#a9aeb8',
+ '--dim': '#71767f',
+ '--blue': '#1b2c47',
+ '--radius-pill': '8px',
+ '--radius-panel': '8px',
+ '--radius-card': '6px',
+ '--radius-input': '6px',
+ '--shadow-card': '0 8px 20px rgba(0, 0, 0, 0.25)',
+ '--serif': 'Inter, Arial, sans-serif',
+ '--display': "'Work Sans', Arial, sans-serif",
+ '--sans': 'Inter, Arial, sans-serif',
+ },
+ },
+ // Warmer, higher contrast, carved corners; leans into UO harder.
+ fantasy: {
+ label: 'Fantasy',
+ tokens: {
+ '--bg': '#1a120b',
+ '--bg-deep': '#120c07',
+ '--panel-a': '#2c1f14',
+ '--panel-b': '#241a10',
+ '--panel-flat': '#1f160d',
+ '--line': '#4a3721',
+ '--line-soft': '#33251a',
+ '--accent': '#c9973f',
+ '--accent-bright': '#e8c374',
+ '--ink': '#f3e8d4',
+ '--head': '#f7efe0',
+ '--text': '#d3bfa0',
+ '--muted': '#bfa985',
+ '--dim': '#8a7454',
+ '--blue': '#382613',
+ '--radius-pill': '4px',
+ '--radius-panel': '3px',
+ '--radius-card': '2px',
+ '--radius-input': '2px',
+ '--shadow-card': '0 16px 38px rgba(0, 0, 0, 0.45)',
+ '--serif': "'EB Garamond', Georgia, serif",
+ '--display': 'Cinzel, Georgia, serif',
+ '--sans': "'EB Garamond', Georgia, serif",
+ },
+ },
+}
+
+// 'custom' is a valid stored preset meaning "no preset base" — :root plus
+// whatever custom fields are set. It has no palette of its own.
+const CUSTOM_PRESET = 'custom'
+const PRESET_IDS = [...Object.keys(PRESETS), CUSTOM_PRESET]
+
+// The colors the admin form exposes, mapped to their CSS token. Deliberately
+// the eight of §6.1 rather than all fifteen: the rest are supporting shades a
+// preset sets coherently but that are not worth (or safe to) hand-picking.
+const COLOR_FIELDS = {
+ bg: '--bg',
+ bgDeep: '--bg-deep',
+ panelA: '--panel-a',
+ panelB: '--panel-b',
+ accent: '--accent',
+ accentBright: '--accent-bright',
+ ink: '--ink',
+ text: '--text',
+}
+
+const RADIUS_FIELDS = {
+ radiusPill: '--radius-pill',
+ radiusPanel: '--radius-panel',
+ radiusCard: '--radius-card',
+ radiusInput: '--radius-input',
+}
+
+const FONT_FIELDS = {
+ serif: '--serif',
+ display: '--display',
+ sans: '--sans',
+}
+
+// The curated Google Fonts shortlist (§5.1). The dropdown's VALUE is the full
+// stack exactly as applied, so no string is ever built from admin input and no
+// Google Fonts URL is ever assembled at runtime — the combined css2? request in
+// client/index.html is static and covers all eight web families.
+//
+// One addition to §5.1's twelve: Georgia in the serif list. The shortlist as
+// drafted gave the sans role a "current default" option (Arial, byte-identical
+// to today's --sans) but left the serif role with no way back to today's
+// `Georgia, "Times New Roman", serif` short of resetting the whole theme. It
+// pulls in no web family, so §5.2's URL is unchanged.
+const FONT_OPTIONS = {
+ serif: [
+ { value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — strongest fantasy/historic' },
+ { value: 'Merriweather, Georgia, serif', label: 'Merriweather — excellent readability' },
+ { value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant/editorial' },
+ { value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — old-world (no bold weight)' },
+ { value: 'Georgia, "Times New Roman", serif', label: 'Georgia — the shipped default' },
+ ],
+ display: [
+ { value: 'Cinzel, Georgia, serif', label: 'Cinzel — current Runic Gateway identity' },
+ { value: "'Playfair Display', Georgia, serif", label: 'Playfair Display — elegant alternative' },
+ { value: "'EB Garamond', Georgia, serif", label: 'EB Garamond — softer/classic' },
+ { value: "'IM Fell English', Georgia, serif", label: 'IM Fell English — very strong fantasy (no bold weight)' },
+ ],
+ sans: [
+ { value: 'Inter, Arial, sans-serif', label: 'Inter — default modern UI choice' },
+ { value: "'Work Sans', Arial, sans-serif", label: 'Work Sans — slightly more character' },
+ { value: "'Source Sans 3', Arial, sans-serif", label: 'Source Sans 3 — extremely readable' },
+ { value: '"Helvetica Neue", Arial, sans-serif', label: 'Arial — no webfont; the shipped default' },
+ ],
+}
+
+// Shadow depth, as a closed set for the same reason fonts are: the stored value
+// is applied verbatim as --shadow-card.
+const SHADOW_OPTIONS = [
+ { value: 'none', label: 'None — flat' },
+ { value: '0 8px 20px rgba(0, 0, 0, 0.25)', label: 'Soft' },
+ { value: '0 14px 34px rgba(0, 0, 0, 0.3)', label: 'Default' },
+ { value: '0 18px 44px rgba(0, 0, 0, 0.45)', label: 'Deep' },
+]
+
+// Corner radius is a number, not a shortlist, so it is bounded instead: an
+// integer count of px from 0 to 999 (999 being the pill).
+const RADIUS_MAX_PX = 999
+
+module.exports = {
+ PRESETS,
+ PRESET_IDS,
+ CUSTOM_PRESET,
+ FIXED_TOKENS,
+ COLOR_FIELDS,
+ RADIUS_FIELDS,
+ FONT_FIELDS,
+ FONT_OPTIONS,
+ SHADOW_OPTIONS,
+ RADIUS_MAX_PX,
+}
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..1fa816f 100644
--- a/server/src/model/settings/settings.model.js
+++ b/server/src/model/settings/settings.model.js
@@ -1,5 +1,8 @@
const settingsDb = require('./settings.db')
const brand = require('../../config/brand')
+const { parseJsonSetting } = require('../../utils/settingsJson')
+const { resolveThemeTokens } = require('../../utils/themeResolve')
+const { resolveBrandAssets } = require('../../utils/brandAssets')
// Keys safe to expose on the public site.
const PUBLIC_KEYS = [
@@ -10,8 +13,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 +118,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)
@@ -124,9 +150,29 @@ async function getPublic() {
// the final say when the call is made). Lets the portal show/hide the form.
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
- // Instance branding (BRAND_* env defaults). The two admin-editable settings —
- // site title and contact email — override the env value when set, so existing
- // installs keep their DB-configured name; everything else comes from env.
+ // The effective CSS custom properties for the admin's theme, or absent when
+ // no theme_visual row exists (or nothing in it was usable). The SPA writes
+ // these onto ; absence means it writes nothing and theme.css's :root
+ // stands, which is what keeps an untouched instance byte-for-byte as today.
+ // Resolution — :root ← preset ← custom — happens here rather than in CSS so
+ // there is one authority and brand.accent below can report the same value the
+ // site actually paints. See THEMING_AND_NAV.md §6.
+ const theme = resolveThemeTokens(all.theme_visual)
+ if (theme) out.theme = theme
+ // Uploaded brand-asset overrides (§6.3), resolved here so every consumer of
+ // the brand block — the SPA, the Android app, the Discord bot — picks them up
+ // through the one contract. Forgiving on read like the theme: a slot holding
+ // something we would not emit as a URL is dropped and its neighbours kept.
+ const brandAssets = resolveBrandAssets(parseJsonSetting(all.brand_assets))
+ // Instance branding (BRAND_* env defaults). The admin-editable settings —
+ // site title, contact email, and now the theme accent and uploaded assets —
+ // override the env value when set, so existing installs keep their
+ // DB-configured name; everything else comes from env.
+ //
+ // brand.accent is a CROSS-REPO CONTRACT: the Android app themes its whole
+ // Material palette from it (BrandDto → RunicGatewayTheme) and the Discord bot
+ // colors its embeds from it. Resolving the effective accent here is what lets
+ // both track admin theming with no client change.
out.brand = {
name: out.site_title || brand.name,
shortName: brand.shortName,
@@ -134,10 +180,10 @@ async function getPublic() {
description: brand.description,
contactEmail: out.contact_email || brand.contactEmail,
url: brand.url,
- accent: brand.accent,
- logo: brand.logo,
- hero: brand.hero,
- favicon: brand.favicon,
+ accent: theme?.['--accent'] || brand.accent,
+ logo: brandAssets.logo || brand.logo,
+ hero: brandAssets.hero || brand.hero,
+ favicon: brandAssets.favicon || brand.favicon,
}
// Push-notification relay (M7). The client-facing ntfy base URL the app's
// embedded distributor registers its device topic against; null when push is
@@ -155,6 +201,41 @@ async function getPublic() {
return out
}
+/**
+ * What the HTML shell needs, resolved exactly as getPublic() resolves it: the
+ * effective favicon and logo, plus the theme token map for the boot ` : ''
+}
+
+/**
+ * Provide the built index.html. Called once at boot by app.js; a separate step
+ * from get() so the file read stays synchronous and startup still fails loudly
+ * if the client build is unreadable.
+ */
+function init(html) {
+ template = html
+ cached = null
+ inflight = null
+ generation += 1
+}
+
+/** Drop the cached shell. Called after any write that can change it. */
+function invalidate() {
+ cached = null
+ inflight = null
+ generation += 1
+}
+
+/**
+ * The current shell. Renders on a cold or expired cache, otherwise returns the
+ * cached string. Never rejects: a settings read that fails yields the env-only
+ * shell.
+ *
+ * @returns {Promise}
+ */
+async function get() {
+ if (template === null) throw new Error('htmlShell.init() was never called')
+ if (cached && Date.now() - cached.at < TTL_MS) return cached.html
+ if (inflight) return inflight
+
+ const startedAt = generation
+ const run = (async () => {
+ let overrides = {}
+ try {
+ // Required lazily: this module is loaded by app.js at boot, and the
+ // settings model pulls in the DB pool. Requiring it at the top would make
+ // the HTML shell a startup-time dependency of the database.
+ // eslint-disable-next-line global-require
+ const settings = require('../model/settings/settings.model')
+ overrides = await settings.getShellBrand()
+ } catch {
+ // A DB fault must never fail the page (§4.3). Fall back to the env-only
+ // shell — the pre-feature behaviour — and cache it, so an outage does not
+ // mean a failing query per page view.
+ overrides = {}
+ }
+ const html = render(template, overrides)
+ // An invalidation that landed while this read was in flight means the value
+ // we just read may already be stale. Serve it, but do not cache it.
+ if (generation === startedAt) cached = { html, at: Date.now() }
+ // Only retire our own registration: an invalidation during the read may have
+ // already started a newer render, and clearing that one would cost an extra
+ // render on the next request.
+ if (inflight === run) inflight = null
+ return html
+ })()
+ inflight = run
+ return run
+}
+
+module.exports = { init, get, invalidate, render, TTL_MS, THEME_STYLE_ID }
diff --git a/server/src/utils/navOverrides.js b/server/src/utils/navOverrides.js
new file mode 100644
index 0000000..a9e90b9
--- /dev/null
+++ b/server/src/utils/navOverrides.js
@@ -0,0 +1,336 @@
+// Navigation overrides — the `nav_public` / `nav_admin` / `nav_player` rows.
+//
+// { "/site/news": { "label": "Announcements", "order": 2 },
+// "/site/market": { "hidden": true },
+// "/admin/houses": { "order": 1, "group": "Moderation" } }
+//
+// Keyed by an item's existing `to`; every field is optional and an absent one
+// falls back to the code default (docs/website/THEMING_AND_NAV.md §6.4). The
+// merge itself happens on the client — client/src/lib/navOverrides.js — and the
+// role/feature filters in the layouts run *after* it, so this layer is
+// presentation and never authorization (§7).
+//
+// **What this module cannot check, deliberately: whether a `to` exists.** The
+// three base NAV arrays are client constants (SiteHeader.jsx, AdminLayout.jsx,
+// PlayerPortalLayout.jsx). Shipping a copy of them to the server would create a
+// second source of truth for navigation that drifts the first time a route is
+// added, and it would buy nothing: `applyNavOverrides` already drops an entry
+// whose `to` the base array does not declare, which is the right place for it —
+// deleting a route in code stops mattering immediately, with no migration and no
+// stale row doing something unexpected later. So the server validates *shape*
+// and the client owns *membership*.
+//
+// Same strict-on-write / forgiving-on-read asymmetry as the theme and the brand
+// assets (utils/themeResolve.js, utils/brandAssets.js): a bad write is rejected
+// with the offending key named, while a bad stored value is dropped entry by
+// entry so one hand-edited row does not cost the admin the rest of their nav.
+
+// The overridable fields on a CODED item. `group` is only meaningful on the
+// grouped admin nav and `section` only on the public header, but accepting both
+// everywhere costs nothing — the merge util drops a group the base nav does not
+// declare, and a section id no `sections` entry declares.
+const FIELDS = ['label', 'order', 'hidden', 'group', 'section']
+
+// Bounds. None of these is a security control on its own — the row is written by
+// an admin and rendered as text by React — they keep a single settings row from
+// growing without limit, and they are what makes "the admin nav has 21 items"
+// the shape this store is sized for.
+const MAX_ENTRIES = 200
+const MAX_PATH = 128
+const MAX_LABEL = 64
+const MAX_GROUP = 64
+const MAX_SECTIONS = 12
+const MAX_LINKS = 40
+
+// Only the public header supports admin-created dropdown sections and
+// admin-authored links (THEMING_AND_NAV.md §7, Phase 10). The admin sidebar has
+// its own coded sections and the player portal is three flat rows, so both keep
+// the bare items map; `sections`/`links` are dropped for them rather than
+// rejected, the same posture as every other unusable field here.
+const SECTIONED_KEYS = ['nav_public']
+
+// Generated by the editor, never typed. Constrained so a stored id is safe to
+// use as a React key and as a DOM id fragment without further escaping.
+const SECTION_ID = /^sec_[a-z0-9]{4,16}$/
+const LINK_ID = /^lnk_[a-z0-9]{4,16}$/
+
+// The one item an override may never hide: the nav editor itself. An admin who
+// hid it would lose the only screen that can un-hide it, and "type the URL from
+// memory" is not a recovery path. Enforced here as well as in the editor's UI so
+// a hand-written row cannot do it either.
+const UNHIDEABLE = { nav_admin: ['/admin/navigation'] }
+
+/**
+ * Is this a usable key — that is, something that could be a `to` in a nav array?
+ * An app-internal path: absolute, same-origin, no scheme and no whitespace.
+ * Whether it *is* one of the declared routes is the client's question (above).
+ * @param {unknown} value
+ * @returns {boolean}
+ */
+function isNavPath(value) {
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PATH) return false
+ if (!value.startsWith('/')) return false
+ // `//host` is protocol-relative and would leave the origin despite looking
+ // like a path; whitespace and quotes have no business in a route.
+ if (value.startsWith('//') || /[\s<>"'\\]/.test(value)) return false
+ return true
+}
+
+/**
+ * Split a stored value into its three parts.
+ *
+ * The public header grew dropdown sections in Phase 10, so `nav_public` may be
+ * a wrapper — `{ items, sections, links }` — while the other two navs stay the
+ * bare items map phases 6-8 wrote. **A bare map is still read as the items
+ * map**, which is unambiguous because every item key is a path beginning with
+ * `/` and so can never be the string `items`.
+ *
+ * @param {object} value a parsed, non-array object
+ * @returns {{items: object, sections: unknown, links: unknown, wrapped: boolean}}
+ */
+function unwrap(value) {
+ const wrapped = value.items && typeof value.items === 'object' && !Array.isArray(value.items)
+ if (!wrapped) return { items: value, sections: undefined, links: undefined, wrapped: false }
+ return { items: value.items, sections: value.sections, links: value.links, wrapped: true }
+}
+
+// A section is a dropdown an admin created: a label and a position, no route.
+// It is never itself a link — it only opens — so there is no `to` to validate.
+function validateSections(sections, key) {
+ if (sections === undefined || sections === null) return { ok: true }
+ if (!Array.isArray(sections)) return { ok: false, message: `${key}.sections must be an array` }
+ if (sections.length > MAX_SECTIONS) {
+ return { ok: false, message: `${key} may hold at most ${MAX_SECTIONS} sections` }
+ }
+ const seen = new Set()
+ for (const section of sections) {
+ if (!section || typeof section !== 'object' || Array.isArray(section)) {
+ return { ok: false, message: `${key}.sections entries must be objects` }
+ }
+ if (typeof section.id !== 'string' || !SECTION_ID.test(section.id)) {
+ return { ok: false, message: `${key}.sections has an entry with an invalid id` }
+ }
+ if (seen.has(section.id)) {
+ return { ok: false, message: `${key}.sections has a duplicate id '${section.id}'` }
+ }
+ seen.add(section.id)
+ if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) {
+ return { ok: false, message: `${key}.sections['${section.id}'].label must be text of at most ${MAX_LABEL} characters` }
+ }
+ if (section.order !== undefined && (typeof section.order !== 'number' || !Number.isFinite(section.order))) {
+ return { ok: false, message: `${key}.sections['${section.id}'].order must be a number` }
+ }
+ }
+ return { ok: true }
+}
+
+// A link is the one thing an admin may ADD to a nav, and the only place a `to`
+// is not required to already exist in code. It is kept in its own array rather
+// than in `items` on purpose: `items` may only key routes the base array
+// declares, so an override structurally cannot invent a route, and everything
+// that CAN name an arbitrary path is here where the path rule is applied.
+//
+// A link carries no `roles` or `feature` of its own. It does not need one: the
+// page behind it enforces its own access, so a link to somewhere the viewer
+// cannot reach 403s exactly as typing the URL would (§7).
+function validateLinks(links, key) {
+ if (links === undefined || links === null) return { ok: true }
+ if (!Array.isArray(links)) return { ok: false, message: `${key}.links must be an array` }
+ if (links.length > MAX_LINKS) {
+ return { ok: false, message: `${key} may hold at most ${MAX_LINKS} added links` }
+ }
+ const seen = new Set()
+ for (const link of links) {
+ if (!link || typeof link !== 'object' || Array.isArray(link)) {
+ return { ok: false, message: `${key}.links entries must be objects` }
+ }
+ if (typeof link.id !== 'string' || !LINK_ID.test(link.id)) {
+ return { ok: false, message: `${key}.links has an entry with an invalid id` }
+ }
+ if (seen.has(link.id)) {
+ return { ok: false, message: `${key}.links has a duplicate id '${link.id}'` }
+ }
+ seen.add(link.id)
+ if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) {
+ return { ok: false, message: `${key}.links['${link.id}'].label must be text of at most ${MAX_LABEL} characters` }
+ }
+ // The whole point of the restriction: an added link points somewhere on this
+ // site. No scheme, no `//host` — the nav is not a place to send visitors off
+ // to an origin the operator does not control.
+ if (!isNavPath(link.to)) {
+ return { ok: false, message: `${key}.links['${link.id}'].to must be a path on this site, such as /wiki/new-player-guide` }
+ }
+ if (link.order !== undefined && (typeof link.order !== 'number' || !Number.isFinite(link.order))) {
+ return { ok: false, message: `${key}.links['${link.id}'].order must be a number` }
+ }
+ if (link.section !== undefined && link.section !== null && typeof link.section !== 'string') {
+ return { ok: false, message: `${key}.links['${link.id}'].section must be a section id` }
+ }
+ }
+ return { ok: true }
+}
+
+/**
+ * Validate a nav-override object for WRITING. Strict: names the offending key.
+ * @param {unknown} value the parsed object, or null to clear every override
+ * @param {string} [key] which nav row this is, for the messages
+ * @returns {{ok: true} | {ok: false, message: string}}
+ */
+function validateNavOverrides(value, key = 'nav') {
+ if (value === null || value === undefined) return { ok: true }
+ if (typeof value !== 'object' || Array.isArray(value)) {
+ return { ok: false, message: `${key} must be a JSON object` }
+ }
+ const { items, sections, links } = unwrap(value)
+ if (!items || typeof items !== 'object' || Array.isArray(items)) {
+ return { ok: false, message: `${key}.items must be a JSON object` }
+ }
+ const sectionCheck = validateSections(sections, key)
+ if (!sectionCheck.ok) return sectionCheck
+ const linkCheck = validateLinks(links, key)
+ if (!linkCheck.ok) return linkCheck
+
+ const entries = Object.entries(items)
+ if (entries.length > MAX_ENTRIES) {
+ return { ok: false, message: `${key} may hold at most ${MAX_ENTRIES} entries` }
+ }
+ for (const [to, entry] of entries) {
+ if (!isNavPath(to)) {
+ return { ok: false, message: `${key} key '${to}' must be an app path such as /site/news` }
+ }
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
+ return { ok: false, message: `${key}['${to}'] must be an object` }
+ }
+ for (const [field, fieldValue] of Object.entries(entry)) {
+ if (!FIELDS.includes(field)) {
+ return { ok: false, message: `Unknown nav field '${field}' on ${key}['${to}']` }
+ }
+ if (field === 'label' && (typeof fieldValue !== 'string' || fieldValue.length > MAX_LABEL)) {
+ return { ok: false, message: `${key}['${to}'].label must be text of at most ${MAX_LABEL} characters` }
+ }
+ if (field === 'group' && (typeof fieldValue !== 'string' || fieldValue.length > MAX_GROUP)) {
+ return { ok: false, message: `${key}['${to}'].group must be text of at most ${MAX_GROUP} characters` }
+ }
+ if (field === 'order' && (typeof fieldValue !== 'number' || !Number.isFinite(fieldValue))) {
+ return { ok: false, message: `${key}['${to}'].order must be a number` }
+ }
+ if (field === 'section' && fieldValue !== null && typeof fieldValue !== 'string') {
+ return { ok: false, message: `${key}['${to}'].section must be a section id` }
+ }
+ // `hidden: false` is not an error — it is simply the default, and the
+ // editor sends it while a row is being edited. It is dropped below, never
+ // stored, because hiding is subtractive only (§7): a stored `false` could
+ // read as "force visible" to a later reader, and nothing may un-hide.
+ if (field === 'hidden' && typeof fieldValue !== 'boolean') {
+ return { ok: false, message: `${key}['${to}'].hidden must be true or false` }
+ }
+ }
+ }
+ return { ok: true }
+}
+
+/**
+ * Keep only the entries and fields that would actually do something. Serves both
+ * directions, like resolveBrandAssets:
+ *
+ * • writing — an admin who cleared every override stores nothing, and the
+ * caller deletes the row instead, so "a row exists" keeps meaning "this nav
+ * was customised" (§4.1);
+ * • reading — a hand-edited entry is dropped and its neighbours kept.
+ *
+ * Sections and added links are honored only for the navs that can render them
+ * (`nav_public`), and a `section` naming no surviving section falls back to the
+ * top level rather than stranding the item in a dropdown that is not there.
+ *
+ * The return shape mirrors the input: a nav with no sections and no added links
+ * resolves to the bare items map phases 6-8 wrote, so adding this feature
+ * changed nothing at all for a nav that does not use it.
+ *
+ * @param {object|null} value an object, or a parseJsonSetting result
+ * @param {string} [key] the settings key, so the un-hideable rule can apply
+ * @returns {object} a new object, `{}` when nothing survives
+ */
+function resolveNavOverrides(value, key = 'nav') {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
+ const { items, sections, links } = unwrap(value)
+ if (!items || typeof items !== 'object' || Array.isArray(items)) return {}
+
+ const sectioned = SECTIONED_KEYS.includes(key)
+ const cleanSections = sectioned ? resolveSections(sections) : []
+ const known = new Set(cleanSections.map((s) => s.id))
+ const cleanLinks = sectioned ? resolveLinks(links, known) : []
+
+ const out = resolveItems(items, key, known)
+ if (cleanSections.length === 0 && cleanLinks.length === 0) return out
+ // A section with nothing in it renders as an empty dropdown, so an admin who
+ // emptied one has simply stopped using it — but it is theirs to keep until
+ // they delete it, and the editor is where that happens. Kept here; the
+ // renderer drops it (client/src/lib/navOverrides.js pruneNav).
+ const wrapper = { items: out }
+ if (cleanSections.length) wrapper.sections = cleanSections
+ if (cleanLinks.length) wrapper.links = cleanLinks
+ return wrapper
+}
+
+function resolveSections(sections) {
+ const out = []
+ const seen = new Set()
+ if (!Array.isArray(sections)) return out
+ for (const section of sections.slice(0, MAX_SECTIONS)) {
+ if (!section || typeof section !== 'object' || Array.isArray(section)) continue
+ if (typeof section.id !== 'string' || !SECTION_ID.test(section.id) || seen.has(section.id)) continue
+ if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) continue
+ seen.add(section.id)
+ const clean = { id: section.id, label: section.label.trim() }
+ if (typeof section.order === 'number' && Number.isFinite(section.order)) clean.order = section.order
+ out.push(clean)
+ }
+ return out
+}
+
+function resolveLinks(links, knownSections) {
+ const out = []
+ const seen = new Set()
+ if (!Array.isArray(links)) return out
+ for (const link of links.slice(0, MAX_LINKS)) {
+ if (!link || typeof link !== 'object' || Array.isArray(link)) continue
+ if (typeof link.id !== 'string' || !LINK_ID.test(link.id) || seen.has(link.id)) continue
+ if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) continue
+ if (!isNavPath(link.to)) continue
+ seen.add(link.id)
+ const clean = { id: link.id, label: link.label.trim(), to: link.to }
+ if (typeof link.order === 'number' && Number.isFinite(link.order)) clean.order = link.order
+ if (typeof link.section === 'string' && knownSections.has(link.section)) clean.section = link.section
+ out.push(clean)
+ }
+ return out
+}
+
+function resolveItems(items, key, knownSections) {
+ const out = {}
+ const unhideable = UNHIDEABLE[key] || []
+ for (const [to, entry] of Object.entries(items)) {
+ if (!isNavPath(to) || !entry || typeof entry !== 'object' || Array.isArray(entry)) continue
+ const clean = {}
+ // A label that is only whitespace is not a label — it would render an
+ // unclickable-looking gap — so it falls back to the coded one.
+ if (typeof entry.label === 'string' && entry.label.trim() && entry.label.length <= MAX_LABEL) {
+ clean.label = entry.label.trim()
+ }
+ if (typeof entry.order === 'number' && Number.isFinite(entry.order)) clean.order = entry.order
+ // Only the literal `true` is stored: `hidden: false` is the default and
+ // carrying it would suggest an override that can un-hide something.
+ if (entry.hidden === true && !unhideable.includes(to)) clean.hidden = true
+ if (typeof entry.group === 'string' && entry.group.trim() && entry.group.length <= MAX_GROUP) {
+ clean.group = entry.group.trim()
+ }
+ // Only a section that survived resolution: an item pointing at a deleted or
+ // malformed one belongs at the top level, visible, rather than inside a
+ // dropdown that no longer exists.
+ if (typeof entry.section === 'string' && knownSections.has(entry.section)) clean.section = entry.section
+ if (Object.keys(clean).length > 0) out[to] = clean
+ }
+ return out
+}
+
+module.exports = { validateNavOverrides, resolveNavOverrides, NAV_KEYS: ['nav_public', 'nav_admin', 'nav_player'] }
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/src/utils/themeResolve.js b/server/src/utils/themeResolve.js
new file mode 100644
index 0000000..c1b556b
--- /dev/null
+++ b/server/src/utils/themeResolve.js
@@ -0,0 +1,202 @@
+// ── theme_visual: validate on write, resolve on read ───────────────────────
+//
+// Two jobs, one closed set of rules (config/themePresets.js):
+//
+// validateThemeVisual() the WRITE path. PUT /admin/settings rejects a bad
+// theme_visual with a 400 rather than storing it, so an
+// admin gets told why instead of watching a save appear
+// to succeed and do nothing.
+// resolveThemeTokens() the READ path. Turns the stored value into the CSS
+// custom properties settings.getPublic() ships as
+// `theme`. Fail-safe, per §4.4: anything unrecognized
+// is dropped field-by-field and the surface falls back
+// to theme.css's :root — never an error, never a
+// half-applied palette.
+//
+// The write path is the strict one and the read path is the forgiving one on
+// purpose. Strict-on-write gives feedback; forgiving-on-read means a row
+// hand-edited in the DB, or written by an older version of this code, degrades
+// to the shipped default instead of rendering a broken site.
+//
+// See docs/website/THEMING_AND_NAV.md §5-§6.
+
+const {
+ PRESETS,
+ PRESET_IDS,
+ CUSTOM_PRESET,
+ COLOR_FIELDS,
+ RADIUS_FIELDS,
+ FONT_FIELDS,
+ FONT_OPTIONS,
+ SHADOW_OPTIONS,
+ RADIUS_MAX_PX,
+} = require('../config/themePresets')
+const { parseJsonSetting } = require('./settingsJson')
+
+const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
+const PX_LENGTH = /^(\d{1,3})px$/
+
+const SHADOW_VALUES = SHADOW_OPTIONS.map((o) => o.value)
+const FONT_VALUES = Object.fromEntries(
+ Object.keys(FONT_FIELDS).map((role) => [role, FONT_OPTIONS[role].map((o) => o.value)]),
+)
+
+function isPlainObject(v) {
+ return !!v && typeof v === 'object' && !Array.isArray(v)
+}
+
+function isColor(v) {
+ return typeof v === 'string' && HEX_COLOR.test(v)
+}
+
+// A bounded px length. `0` on its own is not accepted — a radius is always
+// written with a unit here, which keeps the stored shape uniform.
+function isRadius(v) {
+ if (typeof v !== 'string') return false
+ const m = PX_LENGTH.exec(v)
+ return !!m && Number(m[1]) <= RADIUS_MAX_PX
+}
+
+function isShadow(v) {
+ return typeof v === 'string' && SHADOW_VALUES.includes(v)
+}
+
+function isFont(role, v) {
+ return typeof v === 'string' && (FONT_VALUES[role] || []).includes(v)
+}
+
+// Per-field check for one custom group. Returns the list of offending field
+// names, so the write path can say which field was wrong.
+function checkGroup(group, fields, check) {
+ const bad = []
+ for (const [field, value] of Object.entries(group)) {
+ if (!(field in fields)) {
+ bad.push(field)
+ } else if (!check(field, value)) {
+ bad.push(field)
+ }
+ }
+ return bad
+}
+
+/**
+ * Strict shape check for the write path.
+ *
+ * @param {unknown} value the parsed theme_visual object
+ * @returns {{ ok: true } | { ok: false, message: string }}
+ */
+function validateThemeVisual(value) {
+ if (!isPlainObject(value)) return { ok: false, message: 'theme_visual must be a JSON object' }
+
+ const keys = Object.keys(value).filter((k) => k !== 'preset' && k !== 'custom')
+ if (keys.length) return { ok: false, message: `theme_visual: unknown field(s) ${keys.join(', ')}` }
+
+ if (!PRESET_IDS.includes(value.preset)) {
+ return { ok: false, message: `theme_visual.preset must be one of ${PRESET_IDS.join(', ')}` }
+ }
+
+ // `custom` is optional and may be explicitly null ("preset only").
+ const custom = value.custom
+ if (custom === undefined || custom === null) return { ok: true }
+ if (!isPlainObject(custom)) return { ok: false, message: 'theme_visual.custom must be an object or null' }
+
+ const groups = Object.keys(custom).filter((g) => !['colors', 'structure', 'fonts'].includes(g))
+ if (groups.length) return { ok: false, message: `theme_visual.custom: unknown group(s) ${groups.join(', ')}` }
+
+ for (const [group, spec] of [
+ ['colors', { fields: COLOR_FIELDS, check: (_f, v) => isColor(v) }],
+ ['fonts', { fields: FONT_FIELDS, check: (f, v) => isFont(f, v) }],
+ [
+ 'structure',
+ {
+ fields: { ...RADIUS_FIELDS, shadowDepth: '--shadow-card' },
+ check: (f, v) => (f === 'shadowDepth' ? isShadow(v) : isRadius(v)),
+ },
+ ],
+ ]) {
+ const supplied = custom[group]
+ if (supplied === undefined || supplied === null) continue
+ if (!isPlainObject(supplied)) return { ok: false, message: `theme_visual.custom.${group} must be an object` }
+ const bad = checkGroup(supplied, spec.fields, spec.check)
+ if (bad.length) return { ok: false, message: `theme_visual.custom.${group}: invalid value for ${bad.join(', ')}` }
+ }
+
+ return { ok: true }
+}
+
+// Copy the fields of one custom group that pass their check onto the token map.
+// Field-by-field: a bad accent does not discard a good bg beside it.
+function applyGroup(tokens, group, fields, check) {
+ if (!isPlainObject(group)) return
+ for (const [field, token] of Object.entries(fields)) {
+ const value = group[field]
+ if (value !== undefined && check(field, value)) tokens[token] = value
+ }
+}
+
+/**
+ * The effective CSS custom properties for a stored theme_visual value.
+ *
+ * Layered :root ← preset ← custom, per field. `null` means "no row, or nothing
+ * usable in it" — the caller omits the block entirely and the client applies
+ * nothing, which is what makes an untouched instance render byte-for-byte as
+ * today.
+ *
+ * @param {string|object|null|undefined} stored the raw settings value (TEXT) or
+ * an already-parsed object
+ * @returns {Record|null}
+ */
+function resolveThemeTokens(stored) {
+ const parsed = typeof stored === 'string' ? parseJsonSetting(stored) : isPlainObject(stored) ? stored : null
+ if (!parsed) return null
+
+ // An unrecognized preset id falls back to no base rather than to a guess: the
+ // admin's custom fields still apply on top of :root.
+ const base = PRESETS[parsed.preset]
+ const tokens = base ? { ...base.tokens } : {}
+
+ const custom = parsed.custom
+ if (isPlainObject(custom)) {
+ applyGroup(tokens, custom.colors, COLOR_FIELDS, (_f, v) => isColor(v))
+ applyGroup(tokens, custom.fonts, FONT_FIELDS, (f, v) => isFont(f, v))
+ applyGroup(tokens, custom.structure, RADIUS_FIELDS, (_f, v) => isRadius(v))
+ applyGroup(tokens, custom.structure, { shadowDepth: '--shadow-card' }, (_f, v) => isShadow(v))
+ }
+
+ // A row that parsed but yielded nothing usable (e.g. `{"preset":"custom"}`
+ // with no custom fields) is the same as no row at all to every consumer.
+ return Object.keys(tokens).length ? tokens : null
+}
+
+/**
+ * The catalog the admin UI builds its controls from. Served rather than
+ * duplicated client-side so the options offered can never drift from the
+ * options validateThemeVisual() accepts.
+ */
+function themeOptions() {
+ return {
+ // Full token maps, not just a swatch: the form shows each control's
+ // *effective* default for the selected preset, so an admin opening the
+ // accent picker on Fantasy sees Fantasy's gold rather than a hardcoded
+ // client-side copy of the shipped palette. `custom` has no map — it means
+ // "no preset base", and the form falls back to the shipped theme, which is
+ // the runic-gateway map.
+ presets: [
+ ...Object.entries(PRESETS).map(([id, p]) => ({ id, label: p.label, tokens: p.tokens })),
+ { id: CUSTOM_PRESET, label: 'Custom', tokens: null },
+ ],
+ // Each editable field paired with the CSS variable it drives, so the form
+ // can look its current value up in the preset map above without knowing the
+ // naming convention that relates the two.
+ colorFields: Object.entries(COLOR_FIELDS).map(([name, token]) => ({ name, token })),
+ radiusFields: Object.entries(RADIUS_FIELDS).map(([name, token]) => ({ name, token })),
+ fonts: FONT_OPTIONS,
+ shadows: SHADOW_OPTIONS,
+ radiusMaxPx: RADIUS_MAX_PX,
+ // The shipped default, i.e. what theme.css's :root already declares. What
+ // an unset field actually resolves to when no preset is selected.
+ shippedTokens: PRESETS['runic-gateway'].tokens,
+ }
+}
+
+module.exports = { validateThemeVisual, resolveThemeTokens, themeOptions }
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 766d02e..60e9a52 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"
@@ -3459,7 +3463,7 @@
"Admin · Settings"
],
"summary": "Update site settings (admin only)",
- "description": "",
+ "description": "Writes the given keys. The JSON-valued theming keys (theme_visual, brand_assets, nav_public, nav_admin, nav_player) accept an object or its stringified form, are validated strictly with the offending field named in the 400, and are stored stringified with unusable fields dropped. Nav overrides key coded entries by their existing route and carry only label/order/hidden/group/section; whether a key names a route the nav declares is settled client-side at merge time. nav_public may additionally carry admin-created dropdown `sections` and admin-authored `links` — the only place an arbitrary path may be named, and therefore restricted to same-origin paths (no scheme, no protocol-relative host). Sections and links are dropped for the other two navs, which cannot render them.",
"responses": {
"200": {
"description": "Updated settings",
@@ -3528,6 +3532,205 @@
}
}
},
+ "/api/v1/admin/settings/brand-asset/{slot}": {
+ "post": {
+ "tags": [
+ "Admin · Settings"
+ ],
+ "summary": "Upload a brand asset and set it as the override (admin only)",
+ "description": "Stores the image and writes the brand_assets settings row in one call, so an upload never leaves an unreferenced file. Favicons must be PNG (max 512 KB); logos max 1 MB; heroes max 8 MB. Absent slots keep falling back to the BRAND_* env defaults — uploading a logo does not clear a hero.",
+ "parameters": [
+ {
+ "name": "slot",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "logo",
+ "hero",
+ "favicon"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "description": "Which asset to replace"
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Stored file URL and the updated overrides",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "example": "/uploads/1712345678901-ab12cd34.png"
+ },
+ "brand_assets": {
+ "type": "object",
+ "properties": {
+ "logo": {
+ "type": "string"
+ },
+ "hero": {
+ "type": "string"
+ },
+ "favicon": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "No file, unknown slot, disallowed type, or over the slot size cap",
+ "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": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "image": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/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 +12853,96 @@
}
}
}
+ },
+ "/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": []
+ }
+ ]
+ }
+ },
+ "/api/v1/settings/theme/options": {
+ "get": {
+ "tags": [
+ "Settings"
+ ],
+ "summary": "Theme presets and the curated option lists",
+ "description": "The closed sets an admin may choose from when theming the site: the three presets (with swatch colors), the curated Google Fonts shortlist per role, the shadow depths, and the editable color/radius field names. Served so the admin form can never offer a value the server would reject. Static — no database read.",
+ "responses": {
+ "200": {
+ "description": "Theme option catalog",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ThemeOptions"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Not authenticated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
}
},
"components": {
@@ -17598,7 +17891,7 @@
},
"description": {
"type": "string",
- "example": "Seed/accent color (hex) for theming."
+ "example": "Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change."
}
}
},
@@ -17615,7 +17908,7 @@
},
"description": {
"type": "string",
- "example": "Logo URL or site-relative path; empty = no logo."
+ "example": "Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO."
}
}
},
@@ -17632,7 +17925,7 @@
},
"description": {
"type": "string",
- "example": "Hero image URL or site-relative path."
+ "example": "Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO."
}
}
},
@@ -17649,7 +17942,7 @@
},
"description": {
"type": "string",
- "example": "Favicon URL or site-relative path."
+ "example": "Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON."
}
}
}
@@ -17758,6 +18051,49 @@
"brand": {
"$ref": "#/components/schemas/Brand"
},
+ "theme": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow)."
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "example": {
+ "type": "object",
+ "properties": {
+ "--accent": {
+ "type": "string",
+ "example": "#c9973f"
+ },
+ "--bg": {
+ "type": "string",
+ "example": "#1a120b"
+ },
+ "--radius-card": {
+ "type": "string",
+ "example": "2px"
+ }
+ }
+ }
+ }
+ },
"push": {
"type": "object",
"properties": {
@@ -17801,6 +18137,393 @@
}
}
},
+ "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": {}
+ }
+ }
+ }
+ }
+ }
+ },
+ "ThemeOptions": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "presets": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means \"no preset base — the shipped theme plus whatever custom fields are set\"."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "fantasy"
+ }
+ }
+ },
+ "label": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "Fantasy"
+ }
+ }
+ },
+ "tokens": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "example": {
+ "type": "object",
+ "properties": {
+ "--bg": {
+ "type": "string",
+ "example": "#1a120b"
+ },
+ "--accent": {
+ "type": "string",
+ "example": "#c9973f"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "colorFields": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "Editable color fields, each paired with the CSS variable it drives."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "accent"
+ }
+ }
+ },
+ "token": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "--accent"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "radiusFields": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "radiusCard"
+ }
+ }
+ },
+ "token": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "--radius-card"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "shippedTokens": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "What the stylesheet declares by default — the values an unset field resolves to when no preset is selected."
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ }
+ }
+ },
+ "fonts": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Curated Google Fonts shortlist per role. Each option's `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input."
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "label": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "shadows": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "label": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "radiusMaxPx": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 999
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"DeletedId": {
"type": "object",
"properties": {
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index d1d6ba7..7395c79 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' },
@@ -747,10 +748,15 @@ const doc = {
description: { type: 'string' },
contactEmail: { type: 'string', example: '' },
url: { type: 'string', example: '' },
- accent: { type: 'string', example: '#7f99bd', description: 'Seed/accent color (hex) for theming.' },
- logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo.' },
- hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path.' },
- favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path.' },
+ accent: {
+ type: 'string',
+ example: '#7f99bd',
+ description:
+ 'Seed/accent color (hex) for theming. **Effective** value: the admin theme (theme_visual) wins over BRAND_ACCENT_COLOR, so a client that themes from this tracks admin theming with no change.',
+ },
+ logo: { type: 'string', example: '', description: 'Logo URL or site-relative path; empty = no logo. An uploaded brand_assets.logo overrides BRAND_LOGO.' },
+ hero: { type: 'string', example: '/assets/img/runic-emblem.png', description: 'Hero image URL or site-relative path. An uploaded brand_assets.hero overrides BRAND_HERO.' },
+ favicon: { type: 'string', example: '/assets/img/favicon.ico', description: 'Favicon URL or site-relative path. An uploaded brand_assets.favicon overrides BRAND_FAVICON.' },
},
},
PublicSettings: {
@@ -767,6 +773,14 @@ const doc = {
},
gameAccountSignup: { type: 'boolean', example: false },
brand: { $ref: '#/components/schemas/Brand' },
+ theme: {
+ type: 'object',
+ nullable: true,
+ description:
+ 'The effective CSS custom properties for the admin theme, resolved server-side (:root ← preset ← custom). **Absent** when the admin never set a theme, which is what makes an untouched instance render from the shipped stylesheet unchanged. Keys are CSS variable names; every value comes from a closed set (hex color, curated font stack, bounded px length, listed shadow).',
+ additionalProperties: { type: 'string' },
+ example: { '--accent': '#c9973f', '--bg': '#1a120b', '--radius-card': '2px' },
+ },
push: {
type: 'object',
description:
@@ -778,6 +792,83 @@ 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 },
+ },
+ },
+ ThemeOptions: {
+ type: 'object',
+ description:
+ 'The closed sets an admin may choose from when theming the site (GET /settings/theme-options). Served so the admin form cannot offer a value PUT /admin/settings would reject. Static — derived from the server theme config, not the database.',
+ properties: {
+ presets: {
+ type: 'array',
+ description:
+ 'Selectable presets and their full token maps, so a form can show what an unset field currently resolves to. `custom` has null tokens and means "no preset base — the shipped theme plus whatever custom fields are set".',
+ items: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', example: 'fantasy' },
+ label: { type: 'string', example: 'Fantasy' },
+ tokens: {
+ type: 'object',
+ nullable: true,
+ additionalProperties: { type: 'string' },
+ example: { '--bg': '#1a120b', '--accent': '#c9973f' },
+ },
+ },
+ },
+ },
+ colorFields: {
+ type: 'array',
+ description: 'Editable color fields, each paired with the CSS variable it drives.',
+ items: {
+ type: 'object',
+ properties: { name: { type: 'string', example: 'accent' }, token: { type: 'string', example: '--accent' } },
+ },
+ },
+ radiusFields: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { name: { type: 'string', example: 'radiusCard' }, token: { type: 'string', example: '--radius-card' } },
+ },
+ },
+ shippedTokens: {
+ type: 'object',
+ description: 'What the stylesheet declares by default — the values an unset field resolves to when no preset is selected.',
+ additionalProperties: { type: 'string' },
+ },
+ fonts: {
+ type: 'object',
+ description: 'Curated Google Fonts shortlist per role. Each option\'s `value` is the full CSS font-family stack exactly as it will be applied — the stored value, so no stack is ever built from admin input.',
+ additionalProperties: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { value: { type: 'string' }, label: { type: 'string' } },
+ },
+ },
+ },
+ shadows: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { value: { type: 'string' }, label: { type: 'string' } },
+ },
+ },
+ radiusMaxPx: { type: 'integer', example: 999 },
+ },
+ },
// Delete/mutation acknowledgements — each echoes the affected resource key
// or a boolean flag rather than a { message } string.
DeletedId: {
diff --git a/server/test/brandAssets.test.js b/server/test/brandAssets.test.js
new file mode 100644
index 0000000..773f62a
--- /dev/null
+++ b/server/test/brandAssets.test.js
@@ -0,0 +1,352 @@
+// Point the DB at a closed port BEFORE the pool is built, and the upload
+// directory at a throwaway one BEFORE imageUpload.js resolves it — both are read
+// at require time. Every model call is monkeypatched, so no query runs.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+
+const os = require('os')
+const path = require('path')
+const fs = require('fs')
+
+const UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-brand-assets-'))
+process.env.UPLOAD_DIR = UPLOAD_DIR
+
+const { test, after, afterEach } = require('node:test')
+const assert = require('node:assert/strict')
+
+// Phase 5 of docs/website/THEMING_AND_NAV.md: the brand-asset overrides. Two
+// halves are worth locking — what a stored value is allowed to be (these values
+// are written straight into HTML as URLs) and the upload route's per-slot rules,
+// which tighten the shared allowlist without ever widening it (§9).
+const { startApp } = require('./_helper')
+const { isSafeAssetPath, validateBrandAssets, resolveBrandAssets, SLOTS } = require('../src/utils/brandAssets')
+const settingsRouter = require('../src/router/v1/admin/settings.router')
+const settingsDb = require('../src/model/settings/settings.db')
+const sessionService = require('../src/auth/session.service')
+const { requireAuth } = require('../src/auth/session.middleware')
+const users = require('../src/model/users/users.model')
+const activity = require('../src/model/activity/activity.model')
+const htmlShell = require('../src/utils/htmlShell')
+const db = require('../src/utils/db')
+
+after(() => {
+ db.close()
+ fs.rmSync(UPLOAD_DIR, { recursive: true, force: true })
+})
+
+const originals = {
+ validateSession: sessionService.validateSession,
+ isSessionRevoked: sessionService.isSessionRevoked,
+ sessionMeta: sessionService.sessionMeta,
+ getById: users.getById,
+ get: settingsDb.get,
+ set: settingsDb.set,
+ log: activity.log,
+}
+afterEach(() => {
+ Object.assign(sessionService, {
+ validateSession: originals.validateSession,
+ isSessionRevoked: originals.isSessionRevoked,
+ sessionMeta: originals.sessionMeta,
+ })
+ users.getById = originals.getById
+ settingsDb.get = originals.get
+ settingsDb.set = originals.set
+ activity.log = originals.log
+})
+
+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 () => {}
+}
+
+// ── What a stored asset path may be ───────────────────────────────────
+
+test('only same-origin paths under the directories this server serves are accepted', () => {
+ for (const ok of ['/uploads/1-a.png', '/brand/logo.svg', '/assets/img/runic-emblem.png']) {
+ assert.equal(isSafeAssetPath(ok), true, `${ok} should be accepted`)
+ }
+ const rejected = [
+ 'https://evil.example/x.png', // off-origin: an the operator did not choose
+ '//evil.example/x.png', // protocol-relative — looks like a path, loads off-origin
+ 'javascript:alert(1)', // no scheme survives the prefix check, but be explicit
+ '/uploads/../../etc/passwd', // climbing out of the served directory
+ '/uploads/a b.png', // whitespace is the raw material for smuggling
+ '/uploads/"onerror="alert(1)', // quote would break out of the attribute
+ '/etc/passwd', // a path, but not one we serve
+ 'uploads/1-a.png', // relative to the current route, not to the origin
+ '',
+ null,
+ 42,
+ ]
+ for (const bad of rejected) {
+ assert.equal(isSafeAssetPath(bad), false, `${String(bad)} should be rejected`)
+ }
+})
+
+// Strict on write: the admin gets told which field is wrong, rather than saving
+// something that silently never renders.
+test('a write naming an unknown slot or an unusable path is rejected by field', () => {
+ assert.equal(validateBrandAssets({ logo: '/uploads/a.png', hero: null }).ok, true)
+ assert.equal(validateBrandAssets(null).ok, true) // clearing every slot
+
+ const unknown = validateBrandAssets({ banner: '/uploads/a.png' })
+ assert.equal(unknown.ok, false)
+ assert.match(unknown.message, /banner/)
+
+ const offsite = validateBrandAssets({ favicon: 'https://evil.example/f.png' })
+ assert.equal(offsite.ok, false)
+ assert.match(offsite.message, /favicon/)
+
+ assert.equal(validateBrandAssets(['/uploads/a.png']).ok, false)
+})
+
+// Forgiving on read: one hand-edited slot must not cost the admin the other two.
+test('a bad stored slot is dropped and its neighbours are kept', () => {
+ const resolved = resolveBrandAssets({ logo: '/uploads/a.png', hero: 'https://evil.example/h.png', favicon: null })
+ assert.deepEqual(resolved, { logo: '/uploads/a.png' })
+})
+
+test('resolve is also how a cleared slot stops being stored', () => {
+ // '' and null are how the UI clears a slot; neither may survive into the row,
+ // or "the field is absent" would stop being the single meaning of "use env".
+ assert.deepEqual(resolveBrandAssets({ logo: '', hero: null }), {})
+ assert.deepEqual(resolveBrandAssets(null), {})
+ assert.deepEqual(SLOTS, ['logo', 'hero', 'favicon'])
+})
+
+// ── POST /admin/settings/brand-asset/:slot ────────────────────────────
+
+// A 1x1 PNG and a 1x1 GIF, small enough to inline and real enough for multer to
+// accept by mimetype (which is what the shared allowlist keys off).
+const PNG = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+ 'base64',
+)
+const GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64')
+
+function form(buffer, { filename = 'x.png', type = 'image/png' } = {}) {
+ const fd = new FormData()
+ fd.append('image', new Blob([buffer], { type }), filename)
+ return fd
+}
+
+const startSettingsApp = () =>
+ startApp((a) => a.use('/api/v1/admin/settings', requireAuth, settingsRouter))
+
+const filesInUploadDir = () => fs.readdirSync(UPLOAD_DIR)
+
+test('uploading a slot stores the file and points brand_assets at it', async () => {
+ signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
+ settingsDb.get = async () => null // never set before
+ let stored = null
+ settingsDb.set = async (key, value) => {
+ stored = { key, value }
+ }
+ const before = filesInUploadDir().length
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
+ method: 'POST',
+ body: form(PNG),
+ })
+ assert.equal(res.status, 201)
+ const body = await res.json()
+ assert.match(body.url, /^\/uploads\/\d+-[0-9a-f]{16}\.png$/)
+ assert.deepEqual(body.brand_assets, { logo: body.url })
+ assert.equal(stored.key, 'brand_assets')
+ assert.deepEqual(JSON.parse(stored.value), { logo: body.url })
+ assert.equal(filesInUploadDir().length, before + 1, 'the file is kept')
+ } finally {
+ await app.close()
+ }
+})
+
+// §6.3: uploading a logo does not force the admin to also pick a hero — and must
+// not silently discard the hero they picked last week.
+test('an upload merges into the existing overrides rather than replacing them', async () => {
+ signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
+ settingsDb.get = async () => JSON.stringify({ hero: '/uploads/existing-hero.png' })
+ let stored = null
+ settingsDb.set = async (key, value) => {
+ stored = value
+ }
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
+ method: 'POST',
+ body: form(PNG),
+ })
+ assert.equal(res.status, 201)
+ const saved = JSON.parse(stored)
+ assert.equal(saved.hero, '/uploads/existing-hero.png', 'the hero survives')
+ assert.match(saved.favicon, /^\/uploads\//)
+ } finally {
+ await app.close()
+ }
+})
+
+// §4.10: .ico would mean adding a type to MIME_EXT, and the stored extension
+// coming from that map is what makes the upload path safe. PNG only, and the
+// rejected file does not stay on disk.
+test('a favicon that is not a PNG is refused and the file is discarded', async () => {
+ signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
+ settingsDb.set = async () => assert.fail('a refused upload must not write the row')
+ const before = filesInUploadDir().length
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
+ method: 'POST',
+ body: form(GIF, { filename: 'x.gif', type: 'image/gif' }),
+ })
+ assert.equal(res.status, 400)
+ assert.match((await res.json()).message, /PNG/)
+ assert.equal(filesInUploadDir().length, before, 'no orphan file left behind')
+ } finally {
+ await app.close()
+ }
+})
+
+test('a file over the slot cap is refused and discarded', async () => {
+ signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
+ settingsDb.set = async () => assert.fail('a refused upload must not write the row')
+ // Valid PNG header, then padding past the favicon's 512 KB cap — the shared
+ // multer limit is 8 MB, so only the per-slot rule can reject this.
+ const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
+ const before = filesInUploadDir().length
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/favicon`, {
+ method: 'POST',
+ body: form(big),
+ })
+ assert.equal(res.status, 400)
+ assert.match((await res.json()).message, /512 KB or smaller/)
+ assert.equal(filesInUploadDir().length, before)
+ } finally {
+ await app.close()
+ }
+})
+
+test('the same file is accepted for a slot with a bigger cap', async () => {
+ signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
+ settingsDb.get = async () => null
+ settingsDb.set = async () => {}
+ const big = Buffer.concat([PNG, Buffer.alloc(600 * 1024)])
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/hero`, {
+ method: 'POST',
+ body: form(big),
+ })
+ assert.equal(res.status, 201)
+ } finally {
+ await app.close()
+ }
+})
+
+test('an unknown slot is refused and the file is discarded', async () => {
+ signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
+ settingsDb.set = async () => assert.fail('an unknown slot must not write the row')
+ const before = filesInUploadDir().length
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/banner`, {
+ method: 'POST',
+ body: form(PNG),
+ })
+ assert.equal(res.status, 400)
+ assert.equal(filesInUploadDir().length, before)
+ } finally {
+ await app.close()
+ }
+})
+
+// The generic POST /admin/uploads is reachable by editors. The site's identity
+// is not theirs to change, so this route carries the same admin gate as the
+// settings row it writes.
+test('an editor cannot upload a brand asset', async () => {
+ signInAs({ id: 2, username: 'e', role: 'editor', status: 'active' })
+ settingsDb.set = async () => assert.fail('an editor must not write brand_assets')
+ const before = filesInUploadDir().length
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
+ method: 'POST',
+ body: form(PNG),
+ })
+ assert.equal(res.status, 403)
+ assert.equal(filesInUploadDir().length, before, 'the gate runs before multer writes')
+ } finally {
+ await app.close()
+ }
+})
+
+// ── PUT /admin/settings { brand_assets } — how a slot is CLEARED ──────
+//
+// There is no per-slot delete route: clearing the logo is a write of the
+// remaining slots, and clearing the last one is the existing reset-by-delete.
+
+test('clearing a slot through the settings write drops it from the row', async () => {
+ signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
+ let stored = null
+ settingsDb.set = async (key, value) => {
+ stored = value
+ }
+ settingsDb.getAll = async () => []
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ brand_assets: { logo: '/uploads/a.png', hero: null, favicon: '' } }),
+ })
+ assert.equal(res.status, 200)
+ assert.deepEqual(JSON.parse(stored), { logo: '/uploads/a.png' }, 'no null fields survive into the row')
+ } finally {
+ await app.close()
+ }
+})
+
+test('a settings write carrying an off-origin asset URL is rejected by field', async () => {
+ signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
+ settingsDb.set = async () => assert.fail('an invalid brand_assets must not be stored')
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ brand_assets: { logo: 'https://tracker.example/pixel.png' } }),
+ })
+ assert.equal(res.status, 400)
+ assert.match((await res.json()).message, /brand_assets\.logo/)
+ } finally {
+ await app.close()
+ }
+})
+
+test('a successful upload invalidates the cached HTML shell', async () => {
+ signInAs({ id: 1, username: 'a', role: 'admin', status: 'active' })
+ settingsDb.get = async () => null
+ settingsDb.set = async () => {}
+ let invalidated = 0
+ const realInvalidate = htmlShell.invalidate
+ htmlShell.invalidate = () => {
+ invalidated += 1
+ }
+ const app = await startSettingsApp()
+ try {
+ const res = await fetch(`${app.url}/api/v1/admin/settings/brand-asset/logo`, {
+ method: 'POST',
+ body: form(PNG),
+ })
+ assert.equal(res.status, 201)
+ assert.equal(invalidated, 1, 'the favicon an admin just uploaded must not wait for the TTL')
+ } finally {
+ htmlShell.invalidate = realInvalidate
+ await app.close()
+ }
+})
diff --git a/server/test/htmlShell.test.js b/server/test/htmlShell.test.js
new file mode 100644
index 0000000..03b5b4d
--- /dev/null
+++ b/server/test/htmlShell.test.js
@@ -0,0 +1,229 @@
+// Point the DB at a closed port before the pool is built; the settings read is
+// monkeypatched in every test that reaches it, so no query runs.
+process.env.DB_HOST = '127.0.0.1'
+process.env.DB_PORT = '59999'
+// A brand URL, so the og:image absolutization of an uploaded path is exercised
+// rather than being dead code in the test environment.
+process.env.BRAND_URL = process.env.BRAND_URL || 'https://shard.example'
+// BRAND_LOGO defaults to empty (no logo image rendered), which would make the
+// "og:image still comes from env" assertions below pass vacuously.
+process.env.BRAND_LOGO = process.env.BRAND_LOGO || '/brand/logo.png'
+
+const { test, afterEach, after } = require('node:test')
+const assert = require('node:assert/strict')
+
+// The cached, settings-aware HTML shell (docs/website/THEMING_AND_NAV.md §4.3).
+// Three properties are load-bearing enough to lock here: that an untouched
+// instance gets byte-for-byte the shell it got before this feature existed, that
+// a DB fault still serves a page, and that the steady state is one cached string
+// rather than a settings read per page view.
+const htmlShell = require('../src/utils/htmlShell')
+const settings = require('../src/model/settings/settings.model')
+const brand = require('../src/config/brand')
+const db = require('../src/utils/db')
+
+after(() => db.close())
+
+const originalGetShellBrand = settings.getShellBrand
+afterEach(() => {
+ settings.getShellBrand = originalGetShellBrand
+})
+
+// A stand-in for the built client/dist/index.html: the two tags the shell
+// rewrites plus the stylesheet link the theme block has to follow.
+const TEMPLATE = `
+
+
+
+ Vite App
+
+
+
+
+`
+
+// The shell app.js served BEFORE this phase, reproduced verbatim. The point of
+// the test is that this string and the new renderer's output are identical for
+// an instance with no brand_assets and no theme_visual row (§9), so it is copied
+// rather than imported.
+function legacyRenderIndexHtml(html) {
+ const htmlEscape = (s) =>
+ String(s).replace(
+ /[&<>"']/g,
+ (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
+ )
+ const title = htmlEscape(brand.name)
+ const desc = htmlEscape(brand.description)
+ const tags = [
+ ` `,
+ ` `,
+ ' ',
+ brand.url ? ` ` : '',
+ brand.logo ? ` ` : '',
+ ' ',
+ ` `,
+ ` `,
+ brand.favicon ? ` ` : '',
+ ]
+ .filter(Boolean)
+ .join('\n ')
+ return html
+ .replace(/[\s\S]*?<\/title>/i, `${title} `)
+ .replace(/( )/i, `$1${desc}$2`)
+ .replace(/<\/head>/i, ` ${tags}\n `)
+}
+
+// ── The byte-identical guarantee (§9) ─────────────────────────────────
+
+test('with no overrides the shell is byte-identical to the pre-feature one', () => {
+ assert.equal(htmlShell.render(TEMPLATE, {}), legacyRenderIndexHtml(TEMPLATE))
+})
+
+test('an empty theme and empty assets are the same as no overrides at all', () => {
+ // A row that parsed to nothing usable resolves to null/undefined rather than
+ // to an empty block, or "reset" would leave a `', 'color': 'red' },
+ })
+ assert.match(html, /