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
|
email.router.js (6) /admin/email — Gmail OAuth2
|
||||||
delivery — adminOnly
|
delivery — adminOnly
|
||||||
discordBot.router.js (2) /admin/discord-bot — 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"
|
DELETE /:key is "reset to default"
|
||||||
and carries its own key allowlist
|
and carries its own key allowlist
|
||||||
(theming/nav keys + the hero draft)
|
(theming/nav keys + the hero draft)
|
||||||
so it can never drop site_mode or
|
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
|
dashboard.router.js (2) GET /dashboard (staff-wide) and
|
||||||
PUT /site-mode (adminOnly) — the
|
PUT /site-mode (adminOnly) — the
|
||||||
two singletons owning no path
|
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 |
|
| POST | `/posts/upload` | multipart image upload (multer) → `{image_url}` for screenshots |
|
||||||
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
|
| GET | `/wiki` · GET `/wiki/:slug` | read incl. unpublished |
|
||||||
| POST | `/wiki` · PUT `/wiki/:slug` · DELETE `/wiki/:slug` | manage pages |
|
| 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 |
|
| 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 | `/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` · 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) |
|
| 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`.
|
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)
|
## 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 |
|
| **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) |
|
| **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` |
|
| **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 |
|
| **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** |
|
| **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 |
|
| **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
|
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.
|
introduced the presets would have hidden it inside an unrelated diff.
|
||||||
|
|
||||||
**Still open, by decision:** the theme arrives with the `/public/settings` fetch,
|
**Deferred to Phase 5, and done there:** the theme arrives with the
|
||||||
so a themed instance paints the shipped palette for one frame before repainting.
|
`/public/settings` fetch, so a themed instance painted the shipped palette for
|
||||||
Phase 5 has to rewrite `renderIndexHtml` into a cached, invalidated shell anyway
|
one frame before repainting. Phase 5 had to rewrite `renderIndexHtml` into a
|
||||||
(§4.3) — injecting a `<style>` block with the effective tokens there removes the
|
cached, invalidated shell anyway (§4.3), and injecting a `<style>` block with the
|
||||||
flash for free, so it is deferred rather than solved twice.
|
effective tokens there removed the flash for free rather than solving it twice.
|
||||||
|
|
||||||
**Also fixed in passing:** `settings/nav.controller.js` imported the logger
|
**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
|
*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
|
would have thrown a `TypeError` inside the catch — no response sent, request
|
||||||
left hanging — instead of returning a 500. Introduced in Phase 0.
|
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
|
### 8.1 Admin builder UI notes
|
||||||
|
|
||||||
- Tabbed control for the three navs; drag-and-drop reorderable list.
|
- Tabbed control for the three navs; drag-and-drop reorderable list.
|
||||||
|
|||||||
@@ -253,6 +253,10 @@
|
|||||||
"method": "DELETE",
|
"method": "DELETE",
|
||||||
"path": "/api/v1/admin/settings/:key"
|
"path": "/api/v1/admin/settings/:key"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/v1/admin/settings/brand-asset/:slot"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"path": "/api/v1/admin/shard/account"
|
"path": "/api/v1/admin/shard/account"
|
||||||
|
|||||||
Reference in New Issue
Block a user