Add opt-in "Trust this device" so a browser/app skips the TOTP step (never
the password) for 30 days, single-use bcrypt recovery codes as a 2FA-lockout
fallback, and admin trusted-device/MFA-reset management — backend, web UI,
OpenAPI spec, and tests.
- Schema: trusted_devices (sha256 token hash, looked up by unique index) and
recovery_codes (bcrypt, single-use). Both additive/idempotent.
- Session service: trust-token mint/hash/resolve + cap helpers; new rg_trust
httpOnly cookie (survives logout, revoked on untrust/password change/reset/
TOTP disable). JWTs stay stateless — trust is a server-side row, not a claim.
- Web + mobile login accept a trusted-device token / recovery code; login/totp
gains trustDevice + recoveryCode. Cap of 10/user with NO silent pruning — an
over-cap trust returns 409/trustLimitReached and the client prompts to revoke.
- Self-service /auth/me/trusted-devices* + recovery-codes*; admin
/admin/users/:id/trusted-devices* + /mfa/reset. All actions audit-logged.
- Client: "Trust this device" + recovery-code login options, one-time recovery
code display, Trusted Devices + Recovery Codes account panels, a TOTP-styled
revoke-to-continue cap modal, and admin per-user security controls.
- OpenAPI regenerated; 33 new server tests (all suites green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client,
and bot). All changes are behaviour-preserving refactors — no route, protocol,
schema, or config changes — verified against the full server (381) and client
(43) test suites plus a clean client build.
By rule:
- S3776 (20, cognitive complexity): extract helpers/handlers so each function
drops under the threshold — shard model upsert builders, page/wiki update,
block validation, notification stream mapping (dispatch table), SSO mobile
login, shard ingest deps, uo-link socket backfill/connect, the bot slash-
command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/
CharacterStats React components.
- S4624 (34, nested template literals): pull inner templates into locals /
a withQs() helper; rewrite shardEvents.describe() as a formatter table.
- S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small
components, or guarded JSX expressions.
- S6479 (12, array-index React keys): key by stable content instead of index
(two in-editor lists left as-is; index matches their by-index edit model).
- S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that
parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples.
- S6481 (2): memoize Auth/Site context values (and SiteContext brand).
- S4144: dedupe HeroEditor upload handler into useImageUpload().
- S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex ->
prefix list): assorted one-liners.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds the self-service device-session surface the mobile-SSO spec requires, on
top of the existing mobile_refresh_tokens store.
- Schema: device_name + last_used_at columns on mobile_refresh_tokens (nullable,
additive via the ALTER section; seeded to now on insert). With single-use
rotation each login/refresh inserts a fresh row, so the active row's timestamp
is the session's last activity, and the label is carried forward on refresh.
- Model: listActiveForUser (one row per live device, no token hash) +
revokeByIdForUser (ownership-scoped, idempotent).
- GET /auth/me/sessions + DELETE /auth/me/sessions/:id (role-agnostic, behind
requireAuth). Named distinctly from /auth/me/devices (push endpoints).
- device_name is an optional field on /auth/mobile/login and
/auth/mobile/sso/exchange so the app can label a device.
- Client: an "Active Devices" panel on the player account page (list + sign a
device out), plus the PlayerLogin change to honor the mobile SSO bridge's
{ redirect } deep link on a 2FA completion.
- Swagger DeviceSession schema + regenerated spec; 3 controller tests. Full
server suite green (274); client builds.
Co-Authored-By: Claude <noreply@anthropic.com>
Add a full password-reset flow — the prerequisite for the Android app
(docs/android/PLAN.md §8.2), which hands off to the website for reset
rather than shipping a native screen.
Backend:
- password_resets table: stores only the sha256 hash of an opaque 32-byte
token (mirrors user_invites / mobile_refresh_tokens), single-use, ~1h TTL.
- model/passwordResets + users.getActiveByEmail (email is non-unique, so a
request can match several accounts, each emailed its own link).
- mailer.sendPasswordReset (fails soft when email is unconfigured).
- Endpoints: POST /auth/password/forgot (always a generic 200 — no account
enumeration), GET|POST /auth/password/reset/:token. Confirming rotates the
hash and revokes every session (web cutoff + mobile refresh tokens); it does
not auto-login, so a 2FA account still passes TOTP next sign-in. Also serves
SSO-only accounts (null hash) as their set-initial-password path.
- Dedicated request/confirm rate limiters. Swagger regenerated.
Web:
- ForgotPassword + ResetPassword pages, routes /account/forgot and
/account/reset/:token, and a "Forgot your password?" link on the login page.
Tests: test/passwordResets.test.js (5). All server tests pass; client builds;
end-to-end smoketest against MariaDB passes (no-enumeration, single-use, hash
rotation, session revoke, login with the new password).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
Players whose linked Discord identity was banned or muted can now submit
an appeal from the portal and track it; staff get a queue in the admin
moderation section to claim and resolve (approve/deny) appeals. Approving
a ban/mute appeal best-effort asks the Discord bot to reverse the action
(unban / clear timeout) via the internal API and posts a mod-log embed; a
down bot never fails the resolution (reversal_status is recorded).
- Schema: new server-owned `appeals` table (no cross-owner FK to
mod_actions; existence validated in app code).
- Server: model/appeals/* + player appeals controller (submit/mine/
eligible/withdraw) and admin queue handlers (list/claim/resolve/
per-user) under the existing admin+moderator gate; one-active-appeal
enforced app-side; eligibility keyed on the caller's linked Discord id.
- 6d: bot POST /internal/mod-reverse (+ modLog.postReversal) and
server botInternalClient.reverseModAction, wired into resolve().
- Client: admin Appeals queue + resolve modal, ModerationUser appeals
tab, player Appeals page (submit/withdraw), nav + routes + api methods.
- Docs: swagger annotations + component schemas, regenerated output.
- Tests: appeals controller + pure suites (server npm test 224 green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
The public "Staff online" list on the Shard page exposed each staff
member's in-game location (map + coordinates) to everyone, including
logged-in players and unauthenticated visitors.
Location is now privileged data:
- Server: getOnline inspects the caller's role via getUserFromRequest
(the same non-rejecting helper siteMode uses on public routes) and
only includes map/x/y/z for admin/moderator callers. For everyone
else the fields are omitted from the JSON entirely, so they can't be
read from the network tab. serial + name (online status) still shown.
- Client: Shard.jsx gates the location span on the viewer's role from
useAuth() (same pattern as RoleGate); non-privileged viewers see who
is online but no location field is rendered.
Tests: publicShardOnline.test.js covers admin + moderator (location
included), player + unauthenticated + editor (location omitted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
Address review: the SVG footer badge rendered too small, and the default
hero should feature the Runic Gateway emblem rather than the moon.
- Footer: drop powered-by.svg; render the emblem PNG beside a "Powered by
Runic Gateway" label (left-justified, info text stays centered).
- Default hero: brand.hero (and the client fallbacks) now default to the
emblem PNG. The untouched default hero centers the square emblem behind
the text as a medallion with a symmetric legibility overlay (per-layer
background-size so the overlay stays full-bleed). BRAND_HERO still
overrides.
- Admin login / player shell / maintenance backgrounds center the emblem
as a capped medallion instead of a cropped full-bleed cover.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XmHdsbnLzDMAVQkAoTQSBe
Replace baked-in UOM/MysticMoon/UOMysticmoon branding with a BRAND_* env
scheme so one prebuilt image runs as any shard; UOMysticmoon becomes the
first tenant that sets these vars rather than a special case in the code.
Architecture (chosen because the app ships as a prebuilt image):
- server/src/config/brand.js + bot/src/brand.js read BRAND_* once at boot,
with Runic Gateway defaults.
- Text/colors reach the SPA at RUNTIME through the existing public settings
API (settings.model.getPublic -> SiteContext), so no client rebuild. The
admin-editable site title + contact email still override BRAND_NAME/email.
- SiteContext applies BRAND_ACCENT_COLOR to the --accent CSS var at runtime.
- Express templates the built index.html <title>/description/OG/favicon at
serve time from BRAND_* (renderIndexHtml in app.js).
- Server-side consumers read brand directly: emails, TOTP issuer, API docs,
boot logs, HTML error page. Bot uses it for embed color + logs.
Assets: logo/hero/favicon delivered from a ./brand:/app/brand bind-mount
(BRAND_LOGO/HERO/FAVICON), with neutral defaults baked in; hero falls back
to a built-in image when unset.
Scope: also genericized package.json names (uomysticmoon-* -> runic-gateway-*)
and the DB_NAME/DB_USER/COOKIE_NAME code defaults (runic_gateway/runic/
rg_token). Production keeps its real values by pinning them in .env — see
.env.uomysticmoon.example, which reproduces the exact UOMysticmoon identity
(proof the substitution works). Changing a deployed COOKIE_NAME invalidates
existing sessions, so UOMysticmoon pins uomm_token.
Verified: 193 server tests pass, client builds, app.js loads + templates the
built index.html, brand transform injects title/description/OG/favicon.
Follow-ups from live testing:
- Game-account creation is now an admin Settings control (disabled / website /
hybrid / game) instead of a hidden on/off flag. The site offers creation for
website+hybrid; help text notes the shard's SignupMode (Bridge.cfg) has the final
say. game_account_signup setting widened to a 4-value enum + validated on save.
- Invites: the accept link is ALWAYS returned and shown with a Copy button, and a
"Email the invitation" toggle lets an admin create a link-only invite (no email)
or email it. Backend takes sendEmail (default true) and always returns acceptUrl.
- Staff can create a game account from their own /admin/characters page too
(POST /admin/shard/account → the shared createGameAccount controller), so the
form is reachable in both the player and admin portals.
Note: the admin Houses view (/admin/houses) already worked; the earlier failure
was a stale Vite HMR state for the new route (needs a hard refresh).
Client build clean; server routes load; swagger regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per request, split the single public house registry into three role-scoped views:
- Public /site/houses → only houses in DANGER (IDOC), by LOCATION (region + map/
coords). No owner, price, co-owners or decay detail. Renamed "Houses in danger";
kept live via the public house.decay feed. The full-registry deltas
(house.update / house.remove — which carry owner/price) are REMOVED from the
public SSE allowlist so they never reach the public channel.
- Staff full registry → new /admin/houses (admin + moderator, RoleGate + MOD_PATHS)
backed by GET /admin/shard/houses (modAccess), with owner/price/co-owners/decay
and search, kept live on the admin SSE channel.
- Player portal → "My houses" home-status section (own houses only, with decay/
IDOC status) via GET /player/shard/houses, scoped to the caller's linked accounts.
Server tests green, client build clean, swagger regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 6: the UI for the Phase 5 provisioning backend.
- CreateGameAccountForm: reusable game-account form (own username + password),
mapping the sidecar errors (409/429/403/503) to friendly messages. Wired into
GameAccounts (self-serve) — shown alongside the [link flow when the
game_account_signup flag is on (exposed via public settings), so a registered
player can create + link a game account from their portal.
- Admin Invites view (/admin/invites, admin-only): send an invite at a chosen
access level, list invites with status, revoke pending ones. When email isn't
configured the create response's accept link is surfaced to copy manually.
- Public accept page (/invite/:token): validates the invite, sets username +
password (email + role pre-assigned), creates the account at that role and logs
in; for a player invite it then offers the built-in "create game account" step
before the portal. Honeypot-guarded like registration.
- Admin unlink wired into UserDetail via GameAccounts (per-account Unlink button,
confirm + reconcile).
- Backend: expose gameAccountSignup availability in public settings.
Client build clean; server 193/193.
Refs .plans/protocol2-integration.md (Phase 6). Completes the Protocol 2.0/2.1 integration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3: surface the new board data on existing character/user pages.
- Character sheet: render the char.profile titles block (fame/karma + skill +
selected reward title; numeric clilocs skipped since the site has no cliloc
table yet), plus "Guildmaster" and "Governor of <city>" chips.
- Char profile enrichment (player/admin /shard/char/:serial, one shared path):
attach guild + governorOf from our own boards. Guild is LEADERSHIP-ONLY — it's
verifiable from current board state, whereas guessing membership from stale
guild.join events risks showing a wrong guild, so we return null instead.
- Admin user detail (/admin/users/:id): new "Standing" section (governorships
held + guilds led) via GET /users/:id/shard/standing; Houses rows now show the
registry fields (decay level, placement price, co-owner/friend counts) already
returned by listHousesForAccounts.
Server 179/179, client build clean, swagger regenerated.
Refs .plans/protocol2-integration.md (Phase 3).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2: the public UI for the four new boards, following the ChampSpawns live
pattern (snapshot via useAsync + merge SSE deltas with useShardFeed).
- Players Online widget (components/PlayersOnline.jsx): total + region breakdown
rolled up into display buckets (data/regionBuckets.js — the one place to retune
the grouping); live via presence.online. Placed on the Shard page, replacing the
static players-online stat tile.
- Guilds (/site/guilds): searchable board of rosters/alliances/leaders with a
"recently joined" strip from guild.join.
- Governors (/site/governors): one card per city with a placeholder crest
(data/cityCrests.js — swap for real art without touching components), election
phase badge + autoPickAt countdown, and an on-demand "past governors" term
history (the look-back reads the ledger captured in Phase 1). Clean empty state
when City Loyalty isn't enabled.
- Houses (/site/houses): searchable registry with decay badges; price labelled
"placement value", not a for-sale flag.
- API client methods + nav links (Guilds / Governors / Houses).
Client build clean (240 modules).
Refs .plans/protocol2-integration.md (Phase 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bug 1 — Logged-out visitors saw the coming-soon Maintenance page while
admins saw the real hero. That difference is produced client-side by
MaintenanceGate (site_mode=maintenance && no user). Pull the `/` hero
route out from behind the gate so every visitor always lands on the real
Portal hero; the MaintenanceGate stays on all other public routes, so
content pages remain gated during maintenance and admins still preview
through it.
Bug 2 — The landing hero rendered the site nav because Portal used
PublicLayout with the default header=true. Pass header={false} so the
hero has no top nav (footer retained), using the layout's existing
escape hatch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Wire up the three uo-link sidecar surfaces that weren't integrated yet.
Champion spawns
- Ingest champ.update/champ.remove into a new shard_champs table (served from
our own store, like online/houses); public /site/champs board with a nav link,
live via the existing SSE feed (champ.* added to the public allowlist).
Staff write plane (admin + moderator)
- kick / ban / unban / broadcast via /admin/shard/*; actor is stamped server-side
from the session, never the browser. Sidecar status codes mapped (403 disabled/
protected, 404 unknown, 503/504 transient). admin.audit events are logged and
surfaced at /admin/shard/audit.
- New admin "In-Game Ops" view (/admin/shard-ops), plus per-account Kick/Ban/Unban
on the user-detail and character views (ShardAccountActions, self-gated to staff).
Help-page (support) queue
- Ingest page.new/updated/closed into a new shard_pages table; respond/close via
/admin/shard/pages/*. Champ board and page queue are snapshotted from the
sidecar's /champs and /pages on every WS (re)connect (guarded so a failed call
never wipes local state).
Verified live end-to-end against MariaDB + the Rust sidecar + ServUO; unit tests
cover ingest routing (shardIngest.champsPages.test.js). Swagger regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Add a "View" action beside Edit in the users table that opens a dedicated,
read-only page showing everything the uo-link shard knows about a user,
scoped to their linked game accounts: character rosters, currently-online
characters, houses (IDOC-first), and recent vendor sales.
Backend (admin-only, under the existing /users adminOnly gate):
- GET /admin/users/:id — single sanitized user (page is deep-linkable)
- GET /admin/users/:id/shard/{accounts,sales,houses,online}
- shardState: listHousesByAccounts / listOnlineByAccounts (+ model shapers)
- Extract salesForAccounts into utils/shardSales; reuse in player getSales
- Live rosters reuse the existing admin-bypass /admin/shard/* endpoints,
so no new routes for roster/vendors/char
Frontend:
- UserDetail page reusing CharacterStats / GameAccounts / VendorSales
- GameAccounts gains a readOnly prop (drops link form + self-voice copy)
- api.admin.getUser + api.admin.userShard(id) scope; route + layout title
Tests: adminUserShard.test.js (404, account scoping, empty accounts,
salesForAccounts cap/filter). Full server suite 164 pass; client builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Replace the fire-and-forget Discord-only announce on publish with a
retry-safe, two-leg pipeline. When a post transitions into published-news
(false→true publish while in news, or category→news while published), an
announce_jobs row is enqueued with two INDEPENDENT delivery legs:
• town crier — sidecar POST /towncrier via uoLinkClient (stable id
`post-<id>` so a retry replaces rather than duplicates)
• discord — bot POST /internal/announce via botInternalClient
(single source of truth for the #news channel stays in the bot)
An in-process poller (utils/announceWorker) sweeps the table every
ANNOUNCE_POLL_MS and dispatches each due leg with its own exponential
backoff (30s→2h, 6 attempts). A leg is retried on transient failures
(503/504/network) and failed fast on data/config errors (400 over-cap,
401/409). Publishing never blocks on the sidecar or Discord — enqueue is
local DB only. Parent `status` is a done/partial/failed rollup of the two
legs; posts.announced_at is stamped once both deliver.
Admin visibility: GET /admin/posts/:id/announce + a per-leg Retry
(POST .../announce/retry) surfaced in the PostEditor for news posts.
Pure decisions (text build/caps, classification, backoff, rollup) live in
announceJobs.logic and are unit-tested (server/test/announceJobs.test.js,
10 tests). The old manual /admin/uo-link/towncrier form is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
Replace the plain textarea for the homepage_teaser setting with the shared
TipTap rich-text editor, and render the teaser as sanitized HTML in the
portal hero's default layout.
- SettingsAdmin: teaser field now uses RichTextEditor (lazy-loaded, code-split
like PostEditor); rich fields render in a <div> wrapper instead of <label>.
- HeroElement: text-block lines flagged `html` render sanitized HTML.
- heroLayout: the default-layout teaser line is now an HTML line.
- admin.controller: sanitize homepage_teaser against the body allowlist on save.
- theme.css: collapse the teaser's nested block margins in the hero.
Closes#48
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the "Frontend Theme Redo" design (decision 1a): the logged-in
player portal now uses the same sidebar shell as Admin, and Admin's own
My Characters view gets the same stat-tile treatment.
- PlayerPortalLayout: replace the light 820px top-tab header with the
Admin sidebar shell (icon nav, sticky content header with page title,
signed-in footer with sign out). Reuses .admin-grid so the two
logged-in experiences read as one app.
- Drop the now-redundant inner <h1> from PlayerCharacters/PlayerAccount;
the title lives in the sticky header.
- CharacterStats: new stat-tile row (Characters / Online now / Linked
account) that tolerates a restarting shard and hides until an account
is linked.
- AdminCharacters: render CharacterStats above the roster instead of the
bare intro paragraph, matching the Player Portal Characters page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
Public "Online now" now lists only players whose game account is linked
to a STAFF website user (admin/editor/moderator) — linked players are no
longer exposed publicly with their name and location. listOnlineLinked
joins through to users and filters on role; the section is relabeled
"Staff online".
Character/roster/vendor reads gain an admin bypass: admins may view any
character's data, while players (and editor/moderator staff) stay limited
to accounts they have personally linked. The bypass lives in the shared
player controller and only ever widens access for genuine admins.
Also finalizes the uo-link character/vendor front end (player + admin
character sheets, VendorSales component, ShardChar removed) and
regenerates swagger-output.json.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
Front ends for the rest of the sidecar data, plus a security fix the live data
surfaced.
- lib/shardEvents.js: shared describe()/category/label for every event kind
(sales, deaths & PvP, skills, fame/karma, quests, world, and staff kinds).
- Public /site/shard/activity (ShardActivity): the full event log with category
filter tabs and a live tail (history + SSE merged, de-duped). Linked from the
Shard page. Shard page now reuses the shared describe().
- Admin: a "Live feed (all events)" panel on the Shard admin page subscribing to
the admin SSE channel — shows every kind incl. audit/cheat/login attempts.
useShardFeed generalized to take a stream url; api.adminShardStreamUrl added.
Security fix: GET /public/shard/feed now restricts to the public-safe kind
allowlist (shardEvents.list gains a `kinds` IN-filter). Previously it returned
whatever was logged — including audit.* / cheat.* / link.request. Those are
still stored for the admin channel but never served publicly (verified: a
public request for audit.command returns 0 rows).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
- Backend: /admin/shard/{link,accounts,roster/:account,vendors/:account} —
staff self-service, reusing the player/shard controller (it keys off
req.user.id, so the same handlers serve any logged-in role). Swagger under
Admin · Account; spec regenerated.
- components/GameAccounts.jsx: the link-prompt + character-roster UI extracted
into one reusable component parametrized by an api scope and a charTo(serial)
route builder.
- PlayerCharacters now renders it (player scope → /player/char/:serial).
- Admin: "My Characters" nav item + /admin/characters (AdminCharacters) and
/admin/characters/:serial (AdminCharacter, in-shell sheet), using the admin
self-service scope. api.admin.shard.* added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
- SiteHeader: a single consistent main nav (Home, News, Screenshots, Five on
Friday, Newsletter, Wiki, Shard, About) with active-state highlighting, plus
an auth-aware entry on the right — Sign in when logged out, My Account
(player) or Admin (staff) when logged in.
- Portal (landing) now renders the site header too, so the nav is present
across the entire site, not just interior pages.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
Turns the raw shard endpoints into proper, navigable pages in the site's visual
language.
- components/CharacterSheet.jsx: reusable sheet — attribute tiles, vitals bars,
resistances, skills (with bars), and equipment — styled with the shared
panel/grid vocabulary.
- Player portal with a nav bar: PlayerPortalLayout (Characters / Account tabs +
sign-out) wraps /player and /account. /player (PlayerCharacters) tells the
logged-in player if they haven't linked a game account (with the [link code
prompt) or, once linked, shows their characters grouped by account; each
character opens its sheet at /player/char/:serial. Account security moved into
the same shell (the buried "Game accounts" block was removed from it).
Login/register now land on /player.
- Public: GET /public/shard/online (redacted name+serial+map) drives an
"Online now" list on /site/shard that links to public character sheets at
/site/shard/char/:serial (ShardChar). Swagger: ShardOnlinePlayer + regenerated.
- api.shard.online added.
Verified live against the running shard: Darrow's full sheet (STR 120, 58
skills, 3 equipment) renders through the browser-facing proxy; the online list
returns the live roster; player routes 401 without a session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
- admin/uoLink.controller.js: GET /admin/uo-link/config (masked config + live
health + ingestion stats from the socket/broadcaster); PUT to save base/ws
URL + write-only token + protocol + enabled, which (re)starts or stops the WS
ingest client and activity-logs the change; POST/DELETE /uo-link/towncrier to
publish/remove town-crier messages; GET /uo-link/stream (admin SSE channel,
full feed incl. audit/cheat). Mounted adminOnly with express-validator guards
+ #swagger annotations (new "Admin · Shard" tag, TownCrierRequest schema).
- server.js: startup probe (checkUoLink) that logs reachability and warns
loudly on a protocol mismatch when the integration is enabled.
- client: api.admin uo-link methods; ShardAdmin.jsx control panel (status
panel with ingestion stats, config form, town crier) modeled on
DiscordBotAdmin; wired into AdminLayout nav/titles + the /admin/shard route.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
Frontend for the uo-link integration, matching the existing site styling.
- api/client.js: api.shard.* (status/feed/economy/idoc/char), the
shardStreamUrl SSE endpoint, and api.player.shard.* (link/accounts/roster/
vendors).
- lib/useShardFeed.js: EventSource hook over /public/shard/stream with a
rolling buffer and a connected flag (browser never touches the sidecar WS).
- routes/public/Shard.jsx: connection banner, stat tiles (online / gold supply
/ link), a gold-supply sparkline, "recent vendor sales" and "IDOC houses"
lists, and a live event ticker — built from the shared panel/grid/format
vocabulary. Registered at /site/shard under the maintenance gate and linked
from the site header.
- routes/player/PlayerAccount.jsx: a "Game accounts" section — enter a [link
code to link an account, then expand it to see characters and player vendors
on demand (503 shows a retry banner).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
- PagesAdmin: list view of pages (title/slug/status/protected/updated) with
new/edit navigation and a View link to the live page.
- PageBuilder: full-page block canvas — palette (adds any registered block),
per-block editor cards with show/hide, up/down + native drag reorder, and
remove; Content / Settings tabs; SEO metadata + layout/nav settings panels;
publish/unpublish; protect (PATCH) and password-gated unprotect (modal);
draft preview (mints a token, opens /preview/:id/:token); delete (blocked
while protected). Surfaces server block-validation details on save.
- CmsPage: public renderer for /:slug (published; staff see drafts) and the
token-gated /preview/:id/:token, rendering blocks via BlockList and
reflecting the page title/meta.
- Routing: /:slug catch-all after all named routes + /preview/:id/:token
outside the maintenance gate; admin /admin/pages, /pages/new, /pages/:id.
- api client: public page/pagePreview + admin pages CRUD/unprotect/preview.
- AdminLayout: "Pages" nav entry (Content group) with icon.
- theme.css: builder canvas + preview-banner + shell-wide styles.
Client builds clean (216 modules).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Retire env-var SMTP basic-auth and send the contact form through Gmail over
OAuth2 (SMTP XOAUTH2), configured in Admin -> Settings -> Email via an in-app
"Connect Gmail" consent flow. Reuses the existing google SSO OAuth client; the
captured refresh token is stored AES-GCM-encrypted (write-only over the API,
never returned), mirroring the auth-provider and Discord-bot secret patterns.
- schema: new email_config singleton table (mirrors bot_config)
- model: emailConfig.{db,model} with encrypted refresh token + getSafe/getWithSecret
- mailer: nodemailer OAuth2 transport (client id/secret from the google provider
row), contact recipient = contact_email setting, mailto: fallback preserved,
plus sendTest()
- routes/controller: /admin/email config, connect start+callback (ssoState CSRF
+ PKCE), test, disconnect
- client: EmailDelivery section on the Settings page + api methods; Settings copy
now spells out that contact_email is the delivery recipient
- docs/env: drop SMTP_*/CONTACT_TO from env examples; update README/BACKEND_DESIGN
- tests: emailConfig.model + mailer suites (8 new; full suite 142 pass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XKeCQEJZr1AFJN4Bgcmvh3
Regroup the flat 12-link staff sidebar into collapsible category sections
(Content / Moderation / System, with Dashboard and Account ungrouped) and
add a small inline-SVG icon per item. Category collapse state persists in
localStorage and the group holding the active route auto-opens.
Gate each item by role to match server-side enforcement so the sidebar no
longer shows links that would 403: Content is admin/editor, Moderation is
admin/moderator, System (Users, Settings, Hero Editor, Authentication,
Discord Bot, Web Bot Activity) is admin-only. Existing moderator confinement
(Moderation + Account only, plus redirect) is preserved.
Rename "Bot Activity" to "Web Bot Activity" to distinguish the bot-scoring
view from the Discord Bot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introducing the 'player' role turned 'logged-in' into 'logged-in but possibly
untrusted', but the admin router only gated content routes (dashboard, posts,
wiki, uploads) by isLoggedIn — so a player session could reach editor-tier
endpoints. Fixes:
- Backend: requireRole('admin','editor','moderator') at the admin router base;
players now 403 on all /admin/* and use /player instead.
- Client: RequireAuth redirects a signed-in player to /account (mirrors
RequirePlayer).
- Both login pages redirect by role after auth (player -> /account, staff ->
/admin) so you land in the right shell whichever door you used.
Verified live: player token 403s on /admin/dashboard + /admin/users, 200s on
/player/account; browser click-through confirms a player at /admin and at
/admin/login both land on /account. 134 server tests green; client builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
- Player portal: RequirePlayer guard, /account routes (login, register,
settings) with shared PlayerShell; register reads /public/settings derived
flags; AuthContext.register; api.register + api.player.* namespace.
- Admin UI: player role + status/email + reset-password hint in UserEditor,
status column + badge-player in UsersAdmin, player_registration select in
SettingsAdmin; 'disabled' SSO error copy.
- Swagger: Player tag + RegisterRequest/ChangeUsername/ChangePassword/
PlayerAccount/OkFlag schemas; regenerated swagger-output.json.
- Fix: remove a semicolon from a schema.sql inline comment that broke the
statement splitter in ensureSchema.
Verified against the live dev DB: schema migrations apply (player enum,
nullable password_hash, email/status/last_login_ip, seeded setting); 21-check
controller smoke (register gating, dup/reserved, null-hash rules, self change
username/password with session re-issue surviving the cutoff, SSO-only initial
password, banned-login refusal); case-insensitive uniqueness; public settings
expose only derived registration flags. Client builds; 133 server tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
Light up the moderation dashboard's previously-empty widgets by persisting the
event streams the bot only reacted to in-memory before.
Schema (bot-owned)
- member_events: join/leave, with invite_code/inviter_* for best-effort invite
attribution on joins
- filter_hits: word / foreign-invite filter deletions (matched + action_taken)
- spam_hits: rate_limit / mass_mention / mass_emoji detections
Bot
- new models memberEvents/filterHits/spamHits
- guildMemberAdd records the join with invite attribution; new inviteTracker.js
keeps an invite-use cache (GuildInvites intent + inviteCreate/inviteDelete) and
diffs it on join to find which invite was used — best-effort, never blocks
auto-role
- new guildMemberRemove records leaves
- messageFilter records filter/spam hits alongside the existing warn/mute;
inviteFilter now returns the offending code; detectSpam identifies which spam
rule tripped (preserving the rate-limit-first side-effect order)
- mod_actions still logs the resulting warn/mute — the new tables are additive
Server
- summary extended with joins/leaves/invite_joins/filter_hits/spam_hits per window
- new feeds: /api/v1/admin/moderation/{members,filter-hits,spam-hits}
Client
- overview now shows 8 tiles (mod actions + joins/leaves/filter/spam, joins tile
notes "N via invite") plus an Events panel with Members/Filter/Spam tabs;
removed the coming-soon note
Verified: 119 server unit tests, client build, 14-check DB-backed smoke, and a
browser click-through of every tile and events tab (incl. invite attribution).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
Surface the Discord bot's moderation data on the admin panel: a read-only
staff dashboard over the existing mod_actions log, per-user history, staff
notes, and a new moderator role. No bot changes.
Schema
- users.role ENUM gains 'moderator' (CREATE + idempotent ALTER for existing DBs)
- new server-owned mod_notes table (staff_only/admin_only visibility)
Server
- model/moderation: read mod_actions via the shared pool (documented read-only
cross of the bot/server ownership boundary), correlate accounts through
user_identities (provider='discord'), flag automated actions via
staff_user_id === bot_config.application_id; pure reshaping helpers isolated
in moderation.pure.js so they unit-test without opening a DB pool
- model/modNotes: list/add with role-gated admin_only visibility
- admin/moderation.controller + routes under /api/v1/admin/moderation/* gated by
requireRole('admin','moderator'); admin_only note writes require admin
- allow assigning 'moderator' in the user create/update validators
Client
- /admin/moderation overview (window tiles, type-filterable recent feed, user
lookup) and /user/:discordId history (tabs + notes with add-note)
- RoleGate; AdminLayout filters nav and confines moderators to their section
- moderator badge + action-type/auto badges
Deferred (see plan): 6b bot event capture (joins/leaves/filter/spam), 6c appeals
(needs public accounts), 6d /internal/mod-reverse bot reversal callback.
Verified: 116 server unit tests, client build, DB-backed model smoke, full
HTTP/RBAC e2e, and a browser click-through of the dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
SSO login minted a full session immediately, ignoring the account's
totp_enabled flag — so a 2FA admin with a linked Google/Discord/OIDC
identity could sign in without their authenticator code, silently
downgrading the account to single-factor (the strength of the IdP login).
The local password flow already gates on needsTotp(); SSO did not.
Wire SSO through the same staged-TOTP gate:
- ssoState: createTotpPending/verifyTotpPending + a short-lived httpOnly
sso_totp cookie. The pending token carries stage:'totp' (session
validation rejects it) + kind:'sso_totp' (scoped to the SSO endpoint)
plus the resolved context (userId, provider, authMethod, returnTo).
- sso.controller: finishLogin now stages the challenge and redirects to
/admin/login?sso_totp=1 instead of creating a session when the account
has TOTP on. New finishSsoTotp verifies the code (backoff + bot-scoring
on failure, mirroring loginTotp) and only then mints the session.
- sso.routes: POST /auth/sso/totp behind the same backoff/slow/limiter
stack and code validation as the local TOTP endpoint.
- client: AdminLogin detects ?sso_totp=1 and completes over fetch via
api.ssoLoginTotp; the challenge never touches the URL or JS.
Keeps the second factor httpOnly throughout, consistent with the SSO tx
cookie. 12 new tests; full suite 106/106.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
Standalone bot/ service (its own package.json/Dockerfile) managed entirely
through a new admin-only Discord Bot panel — token stored encrypted in the
DB and pushed to the bot process in-memory, never an env var. Built in
phases, each independently verified against a live Discord guild:
- Bot skeleton: gateway connection, internal shared-secret API, self-heals
on its own restart by pulling config from the site
- Moderation core: /ban /kick /mute /warn /warnings + mod-log channel
- Word/invite/spam filtering with leetspeak-resistant normalization and a
staff role/channel allowlist
- Scheduled messages: recurring (cron) and one-off channel posts
- Role assignment: button role menus, auto-role on join, temp roles,
bulk role ops
- Auto-rotating primary invite with an audit log
- Site integration: news-publish -> Discord announce webhook, manual
/announce, read-only /wiki search
Also fixes a pre-existing bug in both DB pools (server + bot): the mariadb
driver defaulted to timezone 'local', silently mis-serializing bound Date
params by the host's local offset instead of the DB's UTC session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Remove the two-card destination row and the below-hero quick-links nav
from the portal so the hero fills the viewport with nothing rendered
after it. The 5 quick links (News, Screenshots, Five on Friday,
Monthly Newsletter, About) move into the hero editor as a third
buttons element in defaultLayout(), reusing the existing buttons
element type so they stay fully editable with no schema changes.
Also drop overflow:hidden on the hero section: on mobile, 100vh can
compute smaller than window.innerHeight, and with overflow hidden the
wrapped quick-links text was getting clipped at the bottom edge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The text_block corner handle previously only changed the wrap width, so
the font size never tracked the box — making the editor un-WYSIWYG and
awkward to tune. Now dragging the handle scales every line's font
proportionally with the box, acting as a zoom that preserves the
h1/h2/p size ratios and keeps each line's manually-set baseline.
- Add scaleFontSize(): numeric px sizes (floored at 6px) and simple
rem/em/px strings scale by the box ratio; responsive clamp()/vw
strings are left untouched so the default hero stays fluid.
- Snapshot the box width + lines at drag start so scaling is computed
against the origin (no rounding drift mid-drag).
- Update the canvas hint to note the handle scales text.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Refactor authentication into a provider-agnostic session layer and build
two new auth surfaces on top of it, without changing local password/TOTP
behavior. Every flow now issues sessions through
sessionService.createSession(user, authMethod).
Part 1 — Session abstraction (backward-compatible refactor):
- New server/src/auth/: token.js (JWT/cookie primitives), session.service.js
(create/validate/partial-TOTP/revoke), session.middleware.js
(attachSession/requireAuth/requireRole). utils/auth.js is now a thin
compat facade so existing imports are unchanged.
Part 2 — Mobile bearer auth (additive):
- /api/v1/auth/mobile/{login,refresh,logout}: short-lived access JWT +
long-lived refresh token, stored hashed and rotated on use, in a new
mobile_refresh_tokens table. Reuses web bot-scoring/backoff; single-request
TOTP. token.signToken gains a backward-compatible expiresIn option.
Part 3 — Pluggable SSO (Google, Discord, generic OIDC):
- OAuth2Provider base + built-in Google/Discord (fixed endpoints) + generic
OIDC, a registry with health/validation, PKCE+CSRF transaction state, and
discovery (GET /auth/providers), start/link/callback routes.
- Link-only policy: SSO signs in only to an already-linked account; external
identities are never auto-provisioned. Client secrets encrypted at rest
(AES-256-GCM, utils/secretBox.js). Admin CRUD (/admin/auth/providers) and
account linking (/admin/account/identities). New auth_providers +
user_identities tables.
Frontend:
- Login page renders provider buttons from /auth/providers (inline SVG icons,
graceful with zero providers). New Authentication admin view
(Local/Google/Discord/Custom). Account page linked-accounts section.
Tests: 83 passing (session, mobile, providers, registry, secretBox, ssoState,
ssoCallback) — all DB-free via fetch mocks + model stubs. README + .env.example
updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose the botScore middleware's in-memory scoring/ban state to admins.
Previously state lived only in the store Map with no persistence or API — the
only visibility was tailing container logs.
- botScore: bounded ring buffer (300) recording scan/login-fail/honeypot and
ban events (most-recent-first); listState() snapshot of all scored IPs;
unban() to clear a single IP.
- New admin-only endpoints GET /admin/bot-activity and
POST /admin/bot-activity/unban (RBAC admin gate, IP validated). Unban is
activity-logged with the admin username.
- Bot Activity tab: currently-banned table with Unban, plus a recent-events
feed, following the existing admin table patterns.
- Tests for the buffer, listState, and unban (guard lets an unbanned IP back
through). README updated.
Read + emergency-unban only — no ban-add or weight-editing surface. Buffer is
in-memory, matching the store; not persisted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "may slow the page" prompt is only a client-side nudge — the server
hard-limits uploads at 8 MB. 1 MB was arbitrarily low and nagged on
perfectly normal hero images. Bump to 5 MB (still well under the hard cap)
and pull the threshold + message into a single tooLargeToUpload() helper so
the background, moon, and image upload paths stay in sync.
The Moon stays a dedicated, first-class hero element — only its image
source becomes configurable. Adds optional src/alt props alongside the
existing size/glow.
- HeroElement: the moon renders props.src when present, else falls back to
the default /assets/img/hero-moon.png. Size, glow, and animation are
unchanged. alt is now props.alt (default '', same as before).
- HeroEditor MoonPanel: adds an image upload (reusing the existing shared
api.admin.upload workflow, same as the image/background panels) that sets
props.src, an alt-text field, and a "Use default" reset. Size/glow
controls unchanged.
Fully backwards compatible: existing layouts with only size/glow and no
src render exactly as today via the fallback. No DB, API, or hero-JSON
changes; no migration.
Adds a layered set of protections around the admin login and the app edge.
Trust proxy (server/src/utils/trustProxy.js)
- Configurable via TRUST_PROXY; pin to the newt agent ("ptero") LAN IP so
X-Forwarded-For is trusted ONLY from that peer. A blanket "true" is
rejected (coerced to 1) to prevent XFF spoofing that would dodge every
IP-based control. DEBUG_TRUST_PROXY logs peer/XFF/req.ip to re-verify the
proxy IP without a redeploy. Documents the Omada static-reservation
assumption.
Login throttling (server/src/middleware/loginProtection.js, rateLimit.js)
- express-slow-down progressive delay + the existing hard rate cap + a
separate per-IP exponential backoff that persists across the rate window.
All failures return one generic message (no user/pass disclosure).
Honeypot (login form + auth.controller)
- Hidden, plausibly-named field ("company"); a filled value fails
generically and is scored as an unambiguous bot.
Optional per-user TOTP 2FA (speakeasy/qrcode)
- totp_secret/totp_enabled columns (+ idempotent migration). Self-service
Account page: enroll via QR, confirm a code to enable, code-gated disable.
- Login is two-step for enrolled users: after the password, a short-lived
signed challenge (stage:'totp', not a session) is required before the
real session is issued.
Bot / scanner scoring + IP ban (server/src/middleware/botScore.js)
- Weighted CMS-scanner paths (this app uses none). Junk paths 404 FIRST,
unconditionally — independent of score/ban state, so a scanner rotating
through fresh Cloudflare IPs gets no free pass. /wp-admin/install.php is
the top-weighted near-1-hit ban (worst offender in prod logs). Per-IP
score with quiet-period decay temp-bans an IP from ALL routes once past a
(deliberately low) threshold, to protect /admin from credential stuffing.
Failed logins and honeypot hits feed the same score.
- Periodic sweep evicts stale, unbanned, quiet entries so the in-memory
store can't grow unbounded; the interval is unref'd and cleared on
graceful shutdown.
Tests: node --test suite (40) covering trust-proxy parsing + live req.ip
(incl. pinned-IP), rate limiter + exponential backoff, honeypot rejection,
TOTP verify (enabled/disabled) + challenge-isn't-a-session, bot-score
threshold/decay/ban + junk-404-independence + install.php + store sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the wiki's RichTextEditor to the Posts editor and close the
stored-XSS gap on public post bodies.
- RichTextEditor: add `variant` prop — `full` (wiki), `post` (no
internal wiki-page link picker), `minimal` (image-only, for
Screenshots captions). Toolbar sections rendered conditionally.
- PostEditor: replace the body textarea with a lazy-loaded
RichTextEditor in Suspense; variant chosen by category
(minimal for screenshots, post otherwise).
- posts.model: sanitize body via shared cleanBody on create/update,
treat an empty TipTap `<p></p>` as null, and auto-derive the
excerpt from the body (max 280 chars) when left blank.
- sanitizeHtml util: add deriveExcerpt() helper.
- FiveOnFriday / NewsletterIssue: wrap dangerouslySetInnerHTML with
DOMPurify.sanitize() as defense-in-depth on render.
No schema or dependency changes. Verified end-to-end against the
local stack: 24/24 API assertions and a full UI round-trip across
all four post categories.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses feedback that the editor was too small/cramped (elements overlapping)
and that the generated CSS moon looked bad.
- AdminLayout: the /admin/hero view now uses the full content width (no 1000px cap)
- HeroEditor canvas is a scaled 1280x720 "stage" (transform: scale to fit the
column, capped at ~66vh). Because viewport-unit fonts and % positions scale
together, the canvas is now a faithful miniature of the live hero — the default
text block and CTA buttons no longer overlap. Drag snaps to the 8px stage grid;
resize math is scale-aware.
- Moon element now renders the actual moon cropped from the hero artwork
(client/public/assets/img/hero-moon.png, circular alpha mask) instead of the CSS
dot; MoonPanel exposes size + glow.
Verified: at 1440px the canvas is ~785x442 with the panel beside it, default
elements don't overlap, and the moon loads the real image. Build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final phase of the hero canvas editor (see HERO_EDITOR.md) — v1 complete.
- element tray adds moon, badge, and image; property panels:
- moon: size / glow / color
- badge: text / background / text color / corner radius
- image: upload (/admin/uploads, >1MB warning) / width% / alt
- corner resize handle on selected elements (image→width%, moon→size,
text_block→box width)
- 8px snap-grid toggle with a faint canvas grid overlay; drag snaps when on
- HeroElement: image element shows an "Upload an image" placeholder until a
source is set (a srcless image never ships live)
Verified in-browser: all five element types add + edit; moon resized 64->104px
via the handle; snap grid overlays; a published moon + badge render on the live
portal; no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Third phase of the hero canvas editor (see HERO_EDITOR.md).
- HeroElement: editor mode — inner content made non-interactive so the wrapper
handles select/drag; selection outline; box width now canvas-relative
(calc(100% - 36px)) so text blocks fit the smaller editor canvas
- HeroEditor: element tray (+ Text / + Buttons), click-to-select, native
Pointer Events drag (position as % of the canvas, clamped), Delete key + panel
delete, z-order (send back / bring forward), and per-type property panels:
- text_block: per-line text / tag / font size (px) / color / bold, add+remove
lines, alignment
- buttons: per-item label / path / variant, add+remove, alignment
empty-canvas click deselects (back to the background panel)
- theme.css: .hero-el-editable outline/hover/selected + grid helper
Verified in-browser: selecting shows the line editor, editing updates the canvas
live, drag repositions, add/delete and z-order work, deselect returns to the
background panel; no console errors. Moon/badge/image + resize + snap are Phase 4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second phase of the hero canvas editor (see HERO_EDITOR.md).
- new lib/heroLayout.js: shared defaultLayout/buildOverlay/heroBackground/
parseLayout used by both the portal and the editor (Portal refactored onto it)
- new admin view HeroEditor.jsx at /admin/hero (+ sidebar nav + route):
- live canvas preview (16:9) rendering the draft via HeroElement
- background panel: image upload (/admin/uploads, >1MB warning), 3x3 position
grid, overlay opacity slider — all update the canvas in real time
- debounced (800ms) auto-save to hero_layout_draft
- Publish (writes hero_layout + draft), Preview (opens /?preview=1), Revert
- Portal: ?preview=1 renders the draft via the admin settings endpoint, with a
"showing unpublished draft" banner; normal load renders the published layout
No schema/dep changes. Verified end to end: overlay/position update the canvas,
auto-save writes the draft, publish updates the live portal, preview shows the
draft while the public page shows live. Element drag/properties land in Phase 3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First phase of the hero canvas editor (see HERO_EDITOR.md).
- settings.model: add hero_layout to PUBLIC_KEYS so the portal receives it
(corrects the design doc — public settings is a whitelist, not getAll();
hero_layout_draft stays admin-only)
- new HeroElement.jsx: renders one layout element by type (text_block,
buttons, moon, badge, image); absolute % positioning with anchor; shared
by the portal now and the editor canvas later
- MoonDot: optional color override for the hero moon element
- Portal.jsx: parse hero_layout (version-checked, try/catch), render elements
sorted by z; fall back to a DEFAULT_LAYOUT built from the current hero so the
page is byte-for-byte unchanged until staff publish their own
No schema change. Verified: default render matches the old hero; publishing a
hero_layout re-renders the portal; the draft key is not exposed publicly; client
builds; no console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>