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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user