Commit Graph

227 Commits

Author SHA1 Message Date
01b3bb52bf Merge pull request 'feat(shard): admin write plane, help-page queue, and public champion board' (#58) from feature/shard-admin-champs into main
All checks were successful
Build container images / build (push) Successful in 1m30s
Reviewed-on: UOM/website#58
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-14 18:46:50 +00:00
c31553aeb6 feat(shard): admin write plane, help-page queue, and public champion board
All checks were successful
PR Checks / server-tests (pull_request) Successful in 10m20s
PR Checks / client-build (pull_request) Successful in 9m49s
PR Checks / bot-install (pull_request) Successful in 9m33s
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
2026-07-14 13:16:10 -05:00
2dc360ca48 Merge pull request 'ci: gate PRs into main on server tests + client build' (#57) from ci/pr-checks into main
All checks were successful
Build container images / build (push) Successful in 1m14s
Reviewed-on: UOM/website#57
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-12 15:40:17 +00:00
34c511c8d0 ci: gate PRs into main on server tests + client build
All checks were successful
PR Checks / server-tests (pull_request) Successful in 11m35s
PR Checks / client-build (pull_request) Successful in 9m42s
PR Checks / bot-install (pull_request) Successful in 9m34s
Add .gitea/workflows/pr-checks.yml running on pull_request into main.
Three parallel jobs on the existing ubuntu-latest runner:

  • server-tests  — npm ci + node --test (164 tests, no DB needed:
    the suite stubs models and points the pool at a dead port)
  • client-build  — npm ci + vite build
  • bot-install   — npm ci only (catches a broken/stale lockfile)

No ESLint exists in the repo yet, so no lint step. Complements
build-images.yml, which publishes images post-merge.

Enable in Branch Protection with status check pattern: PR Checks / *

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-12 10:07:16 -05:00
4fe90ea368 Merge pull request 'feat(admin): view a user's shard footprint at /admin/users/:id' (#56) from feature/admin-user-view into main
All checks were successful
Build container images / build (push) Successful in 1m48s
Reviewed-on: UOM/website#56
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-12 14:41:59 +00:00
ba4d758eab feat(admin): view a user's shard footprint at /admin/users/:id
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
2026-07-12 09:36:43 -05:00
696d82f114 Merge pull request 'deploy: split build into docker-compose.dev.yml (prod compose pulls only)' (#55) from deploy/compose-dev-prod-split into main
All checks were successful
Build container images / build (push) Successful in 1m1s
Reviewed-on: UOM/website#55
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:45:41 +00:00
f4e7fc7e20 Merge branch 'main' into deploy/compose-dev-prod-split 2026-07-11 23:45:28 +00:00
6d4cd91bcc deploy: split build into docker-compose.dev.yml overlay
Make the base docker-compose.yml strictly production-shaped — image: only, no
build: — so a production host can only ever pull, never accidentally build
(compose gives build precedence for `up --build`/`build`, which mixed the two
modes). Local builds move to an explicit, non-auto-loaded overlay.

  Production:   docker compose pull && docker compose up -d
  Development:  docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build

Verified with `docker compose config`: base renders image-only (no build) for
both services; the dev overlay adds build back (app -> Dockerfile,
bot -> bot/Dockerfile). README quick-start updated to the two-file flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 18:42:05 -05:00
3628268dda Merge pull request 'deploy: pull prebuilt registry images in compose (IMAGE_TAG)' (#54) from deploy/compose-use-registry-images into main
All checks were successful
Build container images / build (push) Successful in 56s
Reviewed-on: UOM/website#54
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:36:55 +00:00
25ff5aa836 deploy: pull prebuilt registry images in compose (IMAGE_TAG)
Point the `app` and `bot` services at the images published to the Gitea
registry by the build-images workflow, so deploys pull instead of building:

  image: gitea.whitlocktech.com/uom/website-app:${IMAGE_TAG:-latest}
  image: gitea.whitlocktech.com/uom/website-bot:${IMAGE_TAG:-latest}

`build:` is kept, so `up --build` still works locally; the server runs
`docker compose pull && up -d`. IMAGE_TAG defaults to `latest` for routine
deploys and pins to an immutable `sha-<7>` build for reproducible deploys /
rollback — no per-deploy compose edits. Documented in .env.example + README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 18:34:31 -05:00
042a151358 Merge pull request 'ci: build & publish app + bot images to Gitea registry on merge' (#53) from ci/gitea-actions-image-build into main
All checks were successful
Build container images / build (push) Successful in 24s
Reviewed-on: UOM/website#53
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 23:01:04 +00:00
4f24959d49 ci: build & publish app + bot images to Gitea registry on merge
Add a Gitea Actions workflow (.gitea/workflows/build-images.yml) that fires on
push to main (and workflow_dispatch). On the always-on ubuntu-latest runner it:

  - verifies the host Docker daemon is reachable (socket must be mounted)
  - logs into gitea.whitlocktech.com with a PAT (REGISTRY_USER / REGISTRY_TOKEN)
  - builds & pushes both images from the existing Dockerfiles, each tagged
    :latest and :sha-<7>:
      gitea.whitlocktech.com/<owner>/website-app  (./Dockerfile — server+client)
      gitea.whitlocktech.com/<owner>/website-bot   (./bot/Dockerfile)

Raw docker CLI (no marketplace actions) for portability on self-hosted Gitea;
the shared host daemon gives free layer caching between runs. Registry owner is
lowercased for Docker refs. Deploy (compose image: + pull) is a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-11 17:59:55 -05:00
99649727f3 Merge pull request 'News post → town crier + Discord announcement pipeline' (#52) from feature/news-announce-pipeline into main
Reviewed-on: UOM/website#52
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 21:58:31 +00:00
ac858875c0 Merge branch 'main' into feature/news-announce-pipeline 2026-07-11 21:57:52 +00:00
986a8d5d86 News post → town crier + Discord announcement pipeline
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
2026-07-11 16:25:25 -05:00
350433635b Merge pull request 'Homepage teaser: rich text editor' (#51) from feature/homepage-teaser-rte into main
Reviewed-on: UOM/website#51
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 16:09:50 +00:00
a2590812e0 Give the homepage teaser a rich text editor
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>
2026-07-11 11:06:58 -05:00
5ccb18e794 Merge pull request 'Frontend theme redo: player portal → Admin sidebar shell + stat-tile My Characters' (#50) from feature/frontend-theme-redo into main
Reviewed-on: UOM/website#50
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 16:00:17 +00:00
bf9edde5b7 Bring player portal in line with Admin + stat-tile My Characters
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
2026-07-11 10:58:03 -05:00
6c310629c7 Merge pull request 'uo-link: staff-only public presence + admin character access' (#49) from feature/uo-link-sidecar into main
Reviewed-on: UOM/website#49
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-11 14:32:48 +00:00
d72c2dadfc docs: document the uo-link shard integration in the README
Add a "Shard integration (uo-link)" section explaining that the live
shard bridge is a separate sidecar service at UOM/link, how the site
talks to it (admin-managed encrypted config, WebSocket ingest + REST
round-trips, SSE fan-out with public vs admin channels), the in-game
[link account-linking flow, and what the public / player / admin
surfaces each expose. Also add an intro bullet, a contents entry, and
the shard endpoint groups to the API endpoints table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kj5s1QCKobuFPYmqxjy1q
2026-07-11 09:29:45 -05:00
c4245e3f6a Restrict public presence to staff + let admins view any character
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
2026-07-11 09:15:18 -05:00
49d0c1bd11 Add shard activity feed + admin live feed; fix public-feed leak
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
2026-07-11 03:10:03 -05:00
74d2ead958 Let staff link their own characters + share the game-accounts UI
- 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
2026-07-11 03:05:43 -05:00
49ce230c3a Full-site nav: one auth-aware nav bar on every page
- 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
2026-07-11 02:59:19 -05:00
fe6f93481b Add player portal + character-sheet front end (phase 4 follow-up)
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
2026-07-11 02:51:50 -05:00
e7bc316863 Add admin shard control: config, status, town crier (phase 5)
- 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
2026-07-11 02:22:17 -05:00
1c9a9d26e1 Add public Shard page + player Game Accounts UI (phase 4)
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
2026-07-11 02:17:45 -05:00
064f02c4b6 Add player account linking + roster/vendor reads (phase 3)
Ties an in-game account to a website user and gates reads on ownership.

- schema: shard_account_links (account PK → user_id, char_name, linked_at;
  FK users ON DELETE CASCADE) — the site-side mirror of the sidecar's
  authoritative link.
- model/shardLinks: upsert/list/ownership-check/getByAccount/unlink.
- player/shard.controller.js:
  - POST /player/shard/link — confirm a one-time [link code via
    uoLinkClient.confirmLink(code, req.user.id); on link.ok mirror the link and
    activity.log it; bad/expired codes → 400, shard down → 503.
  - GET /player/shard/accounts — the caller's linked accounts.
  - GET /player/shard/roster/:account and /vendors/:account — live round-trips,
    ownership-checked against the mirror (403 otherwise), 503 on shard restart.
- player.routes.js: mounted under the existing requireRole('player') gate with
  express-validator guards + #swagger annotations; new "Player · Shard" tag and
  ShardLinkRequest/ShardLinkResult/ShardLink schemas; spec regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:13:46 -05:00
523113f013 Add public shard read endpoints + live SSE stream (phase 2)
Curated, same-origin, token-free reads so the browser never sees the sidecar
URL or token:

- public/shard.controller.js:
  - GET /public/shard/status — connection state + online count + latest economy
    (from the site's ingested data).
  - GET /public/shard/feed?kind=&limit= — recent notable events from the log.
  - GET /public/shard/economy — gold-supply series (oldest → newest).
  - GET /public/shard/idoc — houses currently at IDOC.
  - GET /public/shard/char/:serial — live sheet round-trip via uoLinkClient,
    briefly cached; 503 (shard restarting) serves a stale cache or a retry
    banner rather than an error.
  - GET /public/shard/stream — public SSE channel (safe kinds only).
- Wired into public.routes.js with express-validator guards and #swagger
  annotations; new "Public · Shard" tag + ShardStatus/ShardEvent/
  ShardEconomyPoint/ShardHouse schemas; swagger-output.json regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:11:28 -05:00
9d9f5aac28 Add uo-link WS ingest, storage tables and SSE broadcaster (phase 1)
The site now ingests the sidecar's live WebSocket feed and persists it to its
own MariaDB, and re-broadcasts curated events to browsers over SSE.

- schema: shard_events (append-only notable-kind log, sha1 dedupe_key +
  INSERT IGNORE for idempotent reconnect backfill), shard_online (current
  players, upsert/refresh/remove), shard_economy (gold-supply series),
  shard_houses (per-house decay stage + derived is_idoc).
- model/shardEvents + model/shardState: the .db.js/.model.js split; writes
  take camelCase event data, reads are shaped; online upsert uses COALESCE so
  a partial char.vitals refresh never blanks login fields.
- utils/shardIngest: single dispatcher routing each kind to state writes
  and/or the event log, then the broadcaster. High-frequency kinds
  (char.vitals, economy.supply) update state only. A changed server.hello
  bootId clears the stale online roster. Deps are injected for unit testing.
- utils/uoLinkSocket: the server's first outbound WS client (ws dep). Verifies
  the ws.hello protocol, backfills via /history + /economy on every
  (re)connect (dedupe handles overlap), reconnects with capped backoff, and
  mirrors connection state into uo_link_config. Self-guards: only connects when
  the integration is enabled with a token.
- utils/shardBroadcast: SSE fan-out with public (safe kinds only) and admin
  (all) channels, keepalive pings, per-client cleanup.
- server.js: start the ingest socket on boot (no-op until configured) and stop
  it + close SSE streams on graceful shutdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:08:56 -05:00
ab647756f0 Add uo-link sidecar foundation: config store + REST client (phase 0)
Introduces the DB-backed connection config for the uo-link sidecar (the
HTTP + WebSocket bridge to the ServUO shard) and a never-throw REST client,
mirroring the existing Discord-bot integration:

- uo_link_config singleton table (base/ws URL, AES-256-GCM-encrypted shared
  token, protocol pin, enabled, and last-known status/plugin_connected/
  last_event_at/boot_id mirrors for the admin panel).
- model/uoLinkConfig: getSafe (never returns the token — only hasToken),
  getWithToken (server-side decrypt), save (blank token = unchanged),
  recordStatus (mirror the sidecar's reported state).
- utils/uoLinkClient: never-throw fetch client returning {ok,data,status,
  error}; Bearer token + X-UOLink-Version on every call; brief config cache;
  helpers for health/char/roster/vendors/history/economy/link/towncrier.
- .env.example: UOLINK_BASE_URL/WS_URL/PROTOCOL defaults (token stays
  admin-managed in the DB, never an env var).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
2026-07-11 02:02:40 -05:00
d49008e9f2 Merge pull request 'CMS Page Builder (Wave 1): block-based Pages content type' (#47) from feature/cms-page-builder into main
Reviewed-on: UOM/website#47
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-10 02:15:05 +00:00
e7f5f24809 Regenerate Swagger with the CMS pages endpoints
swagger-output.json now documents GET/POST /admin/pages, GET/PATCH/DELETE
/admin/pages/:id, POST /admin/pages/:id/{unprotect,preview}, and the public
GET /public/pages/:slug + /public/pages/:id/preview/:token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:57:39 -05:00
1dd7603f54 Add page builder admin UI + public page route (step 5 + step 6 client)
- 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>
2026-07-09 20:54:36 -05:00
4d87c5f627 Add pages API: model, controller, routes, preview (steps 4/6/7/8)
Backend for the CMS page builder, all under the existing /api/v1:

- pages.model: authoritative save gate — validates blocks against the
  registry and sanitizes them on every create/update; maps rows to/from the
  grouped API shape (metadata / settings); slug validated + reserved-checked
  at create and immutable after; `protected` can be set true via PATCH but
  only cleared via the unprotect path; published_at stamped on first publish.
- sanitizeBlocks: post-validation normalizer (applies each block's sanitize,
  stamps version, defaults visible, recurses container slots).
- reservedSlugs: guards page slugs from shadowing named routes/API namespaces.
- Admin routes (staff-gated): GET/POST /pages, GET/PATCH/DELETE /pages/:id,
  POST /pages/:id/unprotect (password step-up, verified against the caller's
  own hash, never logged), POST /pages/:id/preview (1h token). Audit-logs
  create/publish/unpublish/protect/unprotect/delete.
- Public routes: GET /public/pages/:slug (published; staff see drafts; site-
  mode gated) and GET /public/pages/:id/preview/:token (ungated, token is the
  access control). Preview token primitives added to auth/token.js.
- Swagger annotations for all new endpoints.

Verified end-to-end: model integration test against the dev DB (sanitize,
invalid-block rejection, slug immutability, protected/unprotect, dup/reserved
slug, published_at) + authenticated HTTP smoke (201 create, 400 invalid
blocks, publish, public slug fetch, preview mint+fetch, 403 delete-protected,
401 wrong-password unprotect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:48:31 -05:00
764fb0c069 Add Wave 1 block renderers + editors (page builder step 3, client half)
Client block registry now carries a renderer, edit form, palette label/icon,
and defaults for all seven Wave 1 blocks (self-registering via
client/src/blocks/types/*): heading, rich_text, image, two_column, cta,
divider, quote.

- BlockRenderer + BlockList render stored blocks via the registry (respect
  `visible`, tolerate unknown types), reading getBlock from ./registry to
  avoid the index -> twoColumn -> BlockRenderer cycle.
- editorKit: shared Field/TextField/TextAreaField/SelectField styled with the
  existing admin form classes; rich_text editor reuses RichTextEditor
  (variant post), image editor reuses the shared uploader.
- two_column editor is a mini per-column canvas (add from the leaf-only
  palette, edit via each block's registry editor, reorder, remove).
- theme.css: public block styles (heading/image alignment/cta/quote/
  two-column responsive grid) + column sub-block editor styles.

Verified: all 11 modules transform cleanly under esbuild. Full visual
verification comes with the builder UI (step 5) + public route (step 6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:37:34 -05:00
6d31869ba2 Add Wave 1 block server schemas (page builder step 3, server half)
Register all seven Wave 1 block types with their server-side validation
schemas, self-registering via server/src/blocks/types/*:
heading, rich_text, image, two_column (container), cta, divider, quote.

- propHelpers.js: shared validators (isSafeUrl rejects javascript:/data:/
  protocol-relative, enum/required/optional text, strict key allowlist).
- rich_text carries a `sanitize` normalizer (registry now supports it) that
  runs html through the shared cleanBody allowlist on save.
- Registry entrypoint requires the type modules so all schemas load.

Verified: all 7 register; valid blocks pass; malformed props yield precise
per-path errors; one-level nesting cap enforced; rich_text sanitize strips
script/onerror.

Client renderers + editors (step 3 client half) still to come.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:30:58 -05:00
fcef08e9b6 Add pages table + block registry scaffold (page builder step 2)
New `pages` table: slug/title/blocks(JSON-as-text)/status/protected, author
FK, grouped SEO metadata + layout/nav settings columns (added up front per
spec — cheap now, painful to retrofit), published_at mirroring posts.

Block registry scaffold, server and client, defining the pattern without
any block types yet (Wave 1 lands in step 3):
- server/src/blocks: registry (register/get/list, reserved envelope keys,
  container metadata) + validateBlocks (authoritative save-time gate:
  envelope, registered-type, per-block schema, one-level nesting cap) +
  index entrypoint that will register Wave 1 defs.
- client/src/blocks: mirror registry carrying renderer/editor/palette +
  makeBlockId, plus index entrypoint.

Verified: schema applies idempotently against the dev DB (pages table +
indexes present); validator exercised for empty/non-array/unknown-type/
bad-envelope/duplicate-id/nested-container cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:26:14 -05:00
6180e8a071 Add rich-text alignment controls (left/center/right)
Shared RichTextEditor gains @tiptap/extension-text-align for heading and
paragraph nodes, serializing alignment as inline text-align on the block
node so it round-trips through save/reload. Fixed once at the shared
component so it also flows into the upcoming rich_text and two_column
page blocks.

Server sanitize allowlist now permits `style` on p/h1-h6, constrained by
allowedStyles to text-align (left/right/center/justify) only; all other
CSS properties and values are stripped.

Step 1 of the CMS Page Builder spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:21:17 -05:00
d7fc2dccb7 Merge pull request 'Modernize email: Gmail OAuth2 sending + admin sidebar redesign' (#46) from feature/email-oauth2 into main
Reviewed-on: UOM/website#46
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-08 03:33:42 +00:00
455e850b91 Merge branch 'main' into feature/email-oauth2 2026-07-08 03:33:11 +00:00
f8652c2399 Modernize email: Gmail OAuth2 sending, configured under Settings
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
2026-07-07 22:29:27 -05:00
5b3ab7f282 Merge pull request 'Redesign admin/staff sidebar: collapsible categories, icons, role-accurate nav' (#45) from feature/admin-nav-redesign into main
Reviewed-on: UOM/website#45
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-08 02:56:15 +00:00
17d42cebfe Redesign admin sidebar: collapsible categories, icons, role-accurate nav
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>
2026-07-07 21:54:37 -05:00
5da27879e5 Merge pull request 'Gate /admin to staff roles; role-aware login redirects for players' (#44) from feature/player-accounts into main
Reviewed-on: UOM/website#44
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-07 01:02:53 +00:00
cda0c16149 Merge branch 'main' into feature/player-accounts 2026-07-07 01:02:39 +00:00
82807d18d9 Gate /admin to staff roles; role-aware login redirects for players
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
2026-07-06 19:57:31 -05:00
d72deff2cc Merge pull request 'Player accounts: self-service player role, registration, and portal' (#43) from feature/player-accounts into main
Reviewed-on: UOM/website#43
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-06 20:29:42 +00:00