Files
website/server/src/utils/navOverrides.js
wtclaude b517d7b2df feat(theming): dropdown sections and added links in the public header
Phase 10 of docs/website/THEMING_AND_NAV.md, asked for before the edge -> main
cutover. An admin can now create dropdown sections in the public header, organise
the coded entries into them, and add links of their own.

This deliberately amends §7, which said the override layer "cannot introduce a
`to` that is not already in the hardcoded NAV array". That stays true of every
CODED entry; an admin may now also add a link, restricted to a same-origin path —
no scheme, no protocol-relative //host. A link carries no gate of its own and
needs none: the page behind it enforces its own access, so an added link
advertises a route and never grants one.

The invariant is kept structurally rather than by vigilance. Coded entries live
in an `items` map whose keys must be routes the base array declares, so that map
cannot invent a route; everything that CAN name an arbitrary path lives in
`links`, which is the one place the path rule is applied — on both the write and
the read path.

nav_public therefore grew a { items, sections, links } wrapper. A bare map still
reads as the items map, and a nav with no sections still stores one, so this
changed nothing for a nav that does not use it. Free to do now because nothing
has shipped; after the cutover it would have needed a migration.

The Public tab gets its own editor. A public section is an entry in the
top-level order that the admin created and can drag among the pills, unlike the
admin sidebar's four coded sections, where only membership moves — that is a tree
rather than a list of groups. Deleting a section returns its entries to the top
level rather than removing them, which is the one destructive act this screen
could otherwise commit.

The dropdown opens on click and never on hover, and its trigger is not a link: a
hover menu is unusable on touch, and a trigger that navigates means tapping to
open takes you somewhere instead. Escape closes and returns focus, an outside
press closes, navigating closes, and Arrow Up/Down walk the items.

pruneNav applies the shard-feature gate inside a section and drops one it leaves
empty, so a dropdown never opens onto nothing.

Also fixes a bug this surfaced in the phase 6-8 code: the save path judged "does
this route still exist?" against the palette — the base array already filtered to
what the editing admin can see — so on the public header a feature-gated row's
override could never be carried through and would have been silently reset.
Membership is now judged against the full coded nav while the rows still come
from the palette.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 00:42:19 -05:00

337 lines
17 KiB
JavaScript

// 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'] }