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)
|
||||
|
||||
@@ -537,7 +537,7 @@ today until the admin acts.
|
||||
| **2 — Radius/shadow token groundwork** ✅ | Promote the literals in `theme.css` to the four tokens of §4.7, values unchanged. Verify zero visual diff before any admin UI exists |
|
||||
| **3 — Theme engine** ✅ | Three presets, the combined Google Fonts link, `SiteContext` extension, and the effective-value resolution in `getPublic().brand` (§4.5) |
|
||||
| **4 — Admin theme UI** ✅ | `/admin/appearance` view + route in `App.jsx` + `NAV`/`TITLES` entries in `AdminLayout.jsx` |
|
||||
| **5 — Brand assets** | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the three shells; `heroImage` chain extension |
|
||||
| **5 — Brand assets** ✅ | Cached-shell rewrite in `app.js` (§4.3); upload endpoint on the existing multer config; `<img>` logo slot beside `MoonDot` in the shells; `heroImage` chain extension |
|
||||
| **6 — Public nav wiring** | `SiteHeader.jsx` → `nav_public`. Lowest risk of the three: no roles, no groups |
|
||||
| **7 — Nav builder UI** | `NavEditor.jsx` with `@dnd-kit` (new dependency), **Public tab only** |
|
||||
| **8 — Admin + Player nav** | Wire the remaining two layouts, add the remaining two tabs, once the public pattern is validated in use |
|
||||
@@ -655,17 +655,107 @@ follow the palette", which the dark presets need too. Not fixed here: it is the
|
||||
23-declaration-style promotion Phase 2 was, and folding it into the phase that
|
||||
introduced the presets would have hidden it inside an unrelated diff.
|
||||
|
||||
**Still open, by decision:** the theme arrives with the `/public/settings` fetch,
|
||||
so a themed instance paints the shipped palette for one frame before repainting.
|
||||
Phase 5 has to rewrite `renderIndexHtml` into a cached, invalidated shell anyway
|
||||
(§4.3) — injecting a `<style>` block with the effective tokens there removes the
|
||||
flash for free, so it is deferred rather than solved twice.
|
||||
**Deferred to Phase 5, and done there:** the theme arrives with the
|
||||
`/public/settings` fetch, so a themed instance painted the shipped palette for
|
||||
one frame before repainting. Phase 5 had to rewrite `renderIndexHtml` into a
|
||||
cached, invalidated shell anyway (§4.3), and injecting a `<style>` block with the
|
||||
effective tokens there removed the flash for free rather than solving it twice.
|
||||
|
||||
**Also fixed in passing:** `settings/nav.controller.js` imported the logger
|
||||
*factory* rather than calling it, so `log.error` was `undefined` and a DB fault
|
||||
would have thrown a `TypeError` inside the catch — no response sent, request
|
||||
left hanging — instead of returning a 500. Introduced in Phase 0.
|
||||
|
||||
### Phase 5 as landed
|
||||
|
||||
**The upload is one call, not two.** §8 said "upload endpoint on the existing
|
||||
multer config", which reads as: reuse `POST /admin/uploads`, then `PUT` the
|
||||
`brand_assets` row. Two problems with that. The generic upload is `staffOnly` —
|
||||
editors can reach it — while the row it would write is `adminOnly`, and the
|
||||
site's identity is not the editor tier's to change. And a run that uploaded and
|
||||
then failed (or was abandoned) would leave a file in `/uploads` that nothing
|
||||
references.
|
||||
|
||||
So: **`POST /api/v1/admin/settings/brand-asset/:slot`**, `adminOnly`, using the
|
||||
shared `imageUpload.js` multer config and returning `{ url, brand_assets }`. It
|
||||
read-modify-writes the row, so uploading a logo never clears a hero (§6.3). The
|
||||
per-slot rules only ever *tighten* the shared allowlist, never widen it (§9):
|
||||
|
||||
| Slot | Types | Cap |
|
||||
|---|---|---|
|
||||
| `logo` | the shared image allowlist | 1 MB |
|
||||
| `hero` | the shared image allowlist | 8 MB (the shared ceiling) |
|
||||
| `favicon` | **PNG only** (§4.10) | 512 KB |
|
||||
|
||||
The cap is enforced after multer has written the file and the file is unlinked
|
||||
before the response, rather than by a second multer instance with its own limits.
|
||||
One upload config and one allowlist is the property worth keeping; a briefly
|
||||
written file that is deleted before the request returns is not.
|
||||
|
||||
**There is no per-slot delete route.** Clearing one asset is a `PUT` of the
|
||||
remaining ones, and clearing the last one is the existing reset-by-delete —
|
||||
`{}` is never stored, because absence of the row is what selects the env
|
||||
defaults (§2) and a stored empty object would be a second way to say the same
|
||||
thing.
|
||||
|
||||
**`brand_assets` needed a validator of its own, which the design did not
|
||||
anticipate.** These are the only settings values written straight into HTML as
|
||||
URLs the browser then fetches — an `<img src>`, a `<link rel="icon">`, an
|
||||
`og:image`. `utils/brandAssets.js` accepts a same-origin path under `/uploads/`,
|
||||
`/brand/` or `/assets/` and nothing else: no scheme, no protocol-relative
|
||||
`//host` (which looks like a path and loads off-origin), no `..`, no whitespace
|
||||
or quotes. Same asymmetry as the theme — strict on write with the field named,
|
||||
forgiving on read so one hand-edited slot does not cost the admin the other two.
|
||||
|
||||
**The shell cache carries a TTL as well as explicit invalidation.** §4.3 asked
|
||||
for a module-level cache invalidated on write, and that is what the settings
|
||||
controller does. But the cache is *per process*: in a scaled deployment the
|
||||
worker that handled the write is the only one that learns of it, and every other
|
||||
would serve the old favicon until the next restart. A 5-minute TTL makes the rest
|
||||
converge on their own while keeping the steady state at one render per process
|
||||
per five minutes — not one per page view. Concurrent first requests share a
|
||||
single render, an invalidation that lands mid-render is not overwritten by the
|
||||
in-flight result, and a failed settings read renders the env-only shell and
|
||||
caches *that*, so an outage is not a failing query per page view.
|
||||
|
||||
**Theme flash: fixed here, with a handoff.** The shell now also carries the
|
||||
resolved tokens as `<style id="theme-boot">:root{…}</style>`, injected last in
|
||||
`<head>` so it follows the built stylesheet and wins the equal-specificity tie.
|
||||
`SiteContext` removes that block once the `/public/settings` payload has arrived
|
||||
and been applied — otherwise a later reset would remove the inline properties
|
||||
only to reveal the stale block underneath. The removal is gated on a
|
||||
**successful** fetch, not merely a finished one: a failed request leaves the app
|
||||
with no theme at all, and dropping the block then would strip a themed instance
|
||||
back to the shipped palette for no reason.
|
||||
|
||||
**The logo went into all six MoonDot surfaces, not three.** §8 named the three
|
||||
persistent shells (site header, admin sidebar, portal sidebar); the admin login,
|
||||
the player login/register card and the maintenance page carry the same mark and
|
||||
an operator who uploads a logo means their instance, not three of its pages.
|
||||
`components/BrandLogo.jsx` renders **nothing** when `brand.logo` is empty — which
|
||||
is the shipped default — so every one of those surfaces is unchanged on an
|
||||
untouched instance. On the three centered layouts the logo is stacked *above* the
|
||||
moon rather than beside it, because turning that block into a flex row would have
|
||||
changed its height on instances with no logo.
|
||||
|
||||
The footer's "powered by Runic Gateway" emblem is deliberately untouched (§4.11):
|
||||
it is the project's badge, not the instance's.
|
||||
|
||||
**The hero chain needed no code.** §4.9's real order —
|
||||
`hero_layout.background.image_url` → `brand_assets.hero` → `BRAND_HERO` →
|
||||
`/assets/img/runic-emblem.png` — already holds, because Phase 3 resolved
|
||||
`brand_assets` into `getPublic().brand.hero` and `SiteContext.heroImage` reads
|
||||
that. What was missing was saying so: the hero row in the admin panel now states
|
||||
that a hero-editor background wins over the uploaded one, so "I uploaded a hero
|
||||
and the portal ignored it" does not become a bug report against a working system.
|
||||
|
||||
**Observed and left alone:** the shell's `<title>` and description still come
|
||||
from `BRAND_NAME`/`BRAND_DESCRIPTION`, not from the admin-set `site_title` that
|
||||
`getPublic().brand.name` prefers, so an instance that renamed itself through the
|
||||
admin panel still has the env name in its tab and its link previews. Fixing it
|
||||
would change the served shell for instances with no `brand_assets` row, which is
|
||||
exactly what §9 says must not change in this phase. It wants its own change.
|
||||
|
||||
### 8.1 Admin builder UI notes
|
||||
|
||||
- Tabbed control for the three navs; drag-and-drop reorderable list.
|
||||
|
||||
@@ -253,6 +253,10 @@
|
||||
"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"
|
||||
|
||||
Reference in New Issue
Block a user