Phase 5: the account-provisioning backend — link-only stays, plus hybrid
self-signup, an admin email-invite tool, and site-side unlink.
- uoLinkClient.createAccount / unlinkAccount (v2). Password is forwarded to the
shard (hashed there) and never stored/logged; the end-user browser IP is passed
for the shard's per-IP cap; actor is stamped server-side.
- Hybrid signup: POST /player/shard/account provisions a game account (its own
username + password) for the signed-in user and mirrors the link locally. Gated
by the new game_account_signup setting AND the shard's own mode (mapped 403/409/
429/400/503). Serves both self-serve signup and the invite-accept game step.
- Email invites: user_invites table (sha256 token hash, single-use, expiring);
invites model + admin CRUD (POST/GET/DELETE /admin/invites, admin-only) +
mailer.sendInvite (falls back to returning the accept link if email is off);
public token-gated accept (GET /auth/invite/:token, POST .../accept) creates the
user at the invite's preset role and logs them in, bypassing the registration
gate. Accept is race-safe (atomic single-use; rolls back the user if it loses).
- Admin unlink: DELETE /admin/users/:id/shard/link/:account (admin-only) + local
mirror drop; account.unlinked ingest reconciles the mirror when a player runs
[unlink in game. account.audit / account.unlinked are logged (admin channel
only — never on the public SSE allowlist).
Tests: invites model (hashing, single-use, expiry, revoke) + account.* ingest
reconcile/visibility. Full suite 193/193; swagger regenerated.
Refs .plans/protocol2-integration.md (Phase 5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 of the Protocol 2.0/2.1 integration: the read/ingest backend for the four
new uo-link boards, following the established champs/pages pattern (ingest → our
MariaDB + snapshot-on-reconnect + public SSE + token-free public endpoint).
- Schema: shard_guilds, shard_governors, shard_governor_terms, shard_presence;
extend shard_houses with the house.update registry columns (owner_name,
co_owners, friends, price, decay, in_registry) so the decay-transition and
registry feeds share one house row without clobbering each other.
- Ingest: route guild.update/remove, city.update, presence.online,
house.update/remove; log guild.join (real-time joins feed); region.enter is
broadcast-only. All new public kinds added to the SSE allowlist.
- Governor term history captured from day one: on every observed governor CHANGE
the open term is closed and a new one opened, idempotent so backfill/duplicate
city.update never spawn spurious terms. votes stays NULL (the feed carries only
candidate count, not tallies) — we never fabricate vote numbers.
- Client + backfill: getGuilds/getGovernors/getHouses/getPresence; snapshot each
board on every WS (re)connect, independently guarded so an empty/failed board
(e.g. no City Loyalty) never wipes another.
- Public endpoints: /shard/{guilds,governors,governors/:city/history,presence,houses}.
- Tests: ingest routing for all new kinds + governor term-capture idempotency
(15 new; full suite 179/179). Swagger regenerated.
Refs .plans/protocol2-integration.md (Phase 1).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
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