docs(website): theming phase 5 as built — brand assets and the cached shell

Records Phase 5 of THEMING_AND_NAV.md as landed and documents the new route
and the shell lifecycle in BACKEND_DESIGN.md.

Where the build differed from the design: the upload is one admin-only call
that writes the settings row too (rather than the generic staff upload plus a
PUT, which would leave unreferenced files and let editors change the site's
identity); brand_assets needed a validator of its own because these are the
only settings values written straight into HTML as URLs; the shell cache
carries a TTL as well as explicit invalidation because it is per process; and
the logo went into all six MoonDot surfaces rather than three.

Also notes what was deliberately left alone: the shell's title and description
still come from BRAND_NAME rather than the admin-set site_title, and fixing
that would change the served shell for instances with no brand_assets row —
which is exactly what the phase's acceptance criterion forbids.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 20:10:07 -05:00
parent ce6f5b8788
commit 97cb8be2d5
3 changed files with 143 additions and 9 deletions

View File

@@ -165,12 +165,15 @@ server/
email.router.js (6) /admin/email — Gmail OAuth2
delivery — adminOnly
discordBot.router.js (2) /admin/discord-bot — adminOnly
settings.router.js (3) /admin/settings — adminOnly. The
settings.router.js (4) /admin/settings — adminOnly. The
DELETE /:key is "reset to default"
and carries its own key allowlist
(theming/nav keys + the hero draft)
so it can never drop site_mode or
the uo-link config
the uo-link config; POST
/brand-asset/:slot uploads a
logo/hero/favicon and writes the
brand_assets row in the same call
dashboard.router.js (2) GET /dashboard (staff-wide) and
PUT /site-mode (adminOnly) — the
two singletons owning no path
@@ -893,8 +896,9 @@ file a route sits in — that is the property the route manifest freezes.
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}`. Enum-constrained keys are validated on the way in; `theme_visual` additionally has every value checked against the closed sets in `config/themePresets.js` (hex color, shortlisted font stack, bounded px radius, listed shadow) and is stored stringified. The read path drops bad fields anyway, so the `400` is about **feedback** — a save that appears to succeed and then does nothing is worse than a rejection |
| GET | `/settings` · PUT `/settings` | read all / update `{key:value,...}`. Enum-constrained keys are validated on the way in; `theme_visual` additionally has every value checked against the closed sets in `config/themePresets.js` (hex color, shortlisted font stack, bounded px radius, listed shadow) and is stored stringified, and `brand_assets` has every slot checked against `utils/brandAssets.js` — a same-origin path under `/uploads/`, `/brand/` or `/assets/`, never an off-origin or protocol-relative URL, since these values are written straight into the page as an `<img src>` / `<link rel=icon>` / `og:image`. Cleared slots are dropped rather than stored as `null`. A write to `brand_assets` or `theme_visual` invalidates the cached HTML shell. The read path drops bad fields anyway, so the `400` is about **feedback** — a save that appears to succeed and then does nothing is worse than a rejection |
| DELETE | `/settings/:key` | reset one setting to its default by deleting the row. Allowlisted to the keys whose default lives outside the store (`theme_visual`, `brand_assets`, `nav_public`, `nav_admin`, `nav_player`, `hero_layout_draft`) — anything else is `400`. Idempotent: resetting a key that was never set succeeds |
| POST | `/settings/brand-asset/:slot` | upload one brand asset (`logo` · `hero` · `favicon`) **and** point `brand_assets` at it, in one call → `{ url, brand_assets }`. One call rather than "upload, then PUT" so a half-completed save never leaves an unreferenced file in `/uploads`. Uses the shared `imageUpload.js` multer config — the mimetype allowlist is never widened, only tightened per slot: favicons are **PNG only** (§4.10 of [THEMING_AND_NAV.md](THEMING_AND_NAV.md)) and capped at 512 KB, logos at 1 MB, heroes at the shared 8 MB. A refused file is unlinked before the response. Merges into the existing overrides, so uploading a logo never clears a hero. `adminOnly` — tighter than the generic `POST /admin/uploads`, which editors may reach |
| GET | `/activity?limit=&offset=` | paginated activity log |
| GET | `/users` · POST `/users` · PUT `/users/:id` · DELETE `/users/:id` | user mgmt (can't delete self / last admin; password hashed on write) |
| GET | `/users/:id/trusted-devices` | list a user's active trusted devices (never tokens) |
@@ -910,6 +914,42 @@ file a route sits in — that is the property the route manifest freezes.
Every admin write logs to `activity_log`.
### The SPA HTML shell (`app.js` → `utils/htmlShell.js`)
The SPA catch-all serves `client/dist/index.html` with this instance's branding templated into the
`<head>` — title, meta description, Open Graph / Twitter tags, `<link rel="icon">` — so one prebuilt
image serves per-instance metadata to a crawler that never runs the JavaScript.
That used to be a single render at module load, from `BRAND_*` env only. It cannot be, now that the
favicon and OG image can come from the admin's `brand_assets` row: the shell depends on state that
changes while the process runs. `utils/htmlShell.js` owns the lifecycle, and three properties are
deliberate:
- **A cached string in the steady state.** The shell is rendered lazily on first request and reused;
a settings read per page view would put the database on the critical path of every SPA route,
including during an outage where the API is already degraded. Concurrent first requests share one
render.
- **A DB fault never fails the page.** A failed read renders the env-only shell — exactly the
pre-feature behavior — and that result is cached like any other, so an outage does not become a
failing query per page view.
- **Byte-identical with no rows.** An instance that has never been themed and has uploaded nothing
gets the same bytes it got before the feature existed. Locked by `test/htmlShell.test.js`, which
keeps a verbatim copy of the old renderer as its reference.
Invalidation is explicit — the settings controller calls `htmlShell.invalidate()` after a successful
write to `brand_assets` or `theme_visual` — with a **5-minute TTL as a safety net**, because the cache
is per process: in a scaled deployment the worker that handled the write is the only one that learns
of it, and without the TTL every other worker would serve the old favicon until the next restart.
The shell also carries the resolved theme as a `<style id="theme-boot">:root{…}</style>` block, last
in `<head>` so it follows the built stylesheet and wins the equal-specificity tie. It exists only to
stop a themed instance painting the shipped palette for one frame; `SiteContext` removes it once the
`/public/settings` payload has arrived and applied — gated on a **successful** fetch, since dropping
it after a failed one would strip a themed instance back to the shipped colors. Token names and
values are re-checked against conservative patterns on the way into the block: everything there comes
from a closed set already, and this keeps that a property of the HTML writer rather than of a
validator three modules away.
---
## 5. Site mode (LIVE / MAINTENANCE)