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