From 080478c4a177bf20fb3eb43a555147613cac4aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:28:54 -0500 Subject: [PATCH 1/9] =?UTF-8?q?feat(shard):=20ingest=20Protocol=202.0=20bo?= =?UTF-8?q?ards=20=E2=80=94=20guilds,=20governors,=20presence,=20houses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/db/schema.sql | 98 ++++++++ server/src/model/shardState/shardState.db.js | 126 +++++++++- .../src/model/shardState/shardState.model.js | 231 ++++++++++++++++++ server/src/router/v1/public/public.routes.js | 44 ++++ .../src/router/v1/public/shard.controller.js | 70 +++++- server/src/utils/shardBroadcast.js | 9 + server/src/utils/shardIngest.js | 23 ++ server/src/utils/uoLinkClient.js | 9 + server/src/utils/uoLinkSocket.js | 27 ++ server/swagger/swagger-output.json | 159 ++++++++++++ server/test/shardIngest.protocol2.test.js | 93 +++++++ server/test/shardState.governorTerms.test.js | 77 ++++++ 12 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 server/test/shardIngest.protocol2.test.js create mode 100644 server/test/shardState.governorTerms.test.js diff --git a/server/db/schema.sql b/server/db/schema.sql index 3559a9b..920f741 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -431,6 +431,87 @@ CREATE TABLE IF NOT EXISTS shard_pages ( INDEX idx_shard_pages_handled (handled) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Guild roster board (Protocol 2.0). Upserted on guild.update (a full-state +-- snapshot emitted only on change) and removed on guild.remove. The leader is an +-- actor object flattened into leader_* columns; the full event is kept in +-- `payload` for anything not hoisted. Mirrors the sidecar's GET /guilds +-- projection into our store so the public Guilds page survives a shard outage. +CREATE TABLE IF NOT EXISTS shard_guilds ( + id INT NOT NULL PRIMARY KEY, -- in-game guild id + name VARCHAR(120) NULL, + abbr VARCHAR(24) NULL, + members INT NULL, + online INT NULL, + alliance VARCHAR(120) NULL, + leader_serial VARCHAR(20) NULL, + leader_name VARCHAR(120) NULL, + leader_acct VARCHAR(120) NULL, + leader_web_id INT NULL, + payload JSON NOT NULL, -- the full guild.update object + t BIGINT NULL, -- event time, epoch ms + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_shard_guilds_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on +-- city.update (full-state, emitted only on change; there is no remove event since +-- the set of cities is fixed). governor / governorElect are actor objects +-- flattened into columns; the full event is kept in `payload`. Empty on shards +-- that do not run the City Loyalty system. +CREATE TABLE IF NOT EXISTS shard_governors ( + city VARCHAR(40) NOT NULL PRIMARY KEY, -- Britain | Moonglow | ... + governor_serial VARCHAR(20) NULL, + governor_name VARCHAR(120) NULL, + governor_acct VARCHAR(120) NULL, + governor_web_id INT NULL, + elect_serial VARCHAR(20) NULL, + elect_name VARCHAR(120) NULL, + elect_acct VARCHAR(120) NULL, + election_phase VARCHAR(16) NULL, -- none | nominate | vote | pending + candidates INT NULL, + auto_pick_at DATETIME NULL, + payload JSON NOT NULL, -- the full city.update object + t BIGINT NULL, -- event time, epoch ms + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Governor term history — the "who governed when" ledger behind the Governors +-- board. Captured from day one (history cannot be backfilled) on every observed +-- governor CHANGE: the open term (ended_at IS NULL) is closed and a new one +-- opened. `votes` stays NULL — the city.update feed exposes only the candidate +-- COUNT and election phase, not per-candidate tallies, so we record who governed +-- and when (reliable) and never fabricate vote numbers. The look-back UI ("who +-- were all the governors of Britain?") reads this table. +CREATE TABLE IF NOT EXISTS shard_governor_terms ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + city VARCHAR(40) NOT NULL, + governor_serial VARCHAR(20) NULL, + governor_name VARCHAR(120) NULL, + governor_acct VARCHAR(120) NULL, + governor_web_id INT NULL, + started_at BIGINT NOT NULL, -- term start, epoch ms + ended_at BIGINT NULL, -- term end, epoch ms; NULL = current + votes INT NULL, -- not in the feed; reserved + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_shard_gov_terms_city (city, started_at), + INDEX idx_shard_gov_terms_open (city, ended_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Online-population snapshot (Protocol 2.0). Singleton row (id = 1) holding the +-- latest presence.online aggregate: total count plus per-facet and per-region +-- breakdown maps (stored as JSON). Distinct from shard_online (per-player) — this +-- is the rolled-up headcount the public "Players Online" widget renders. The +-- time series, if ever needed, is available from GET /history?kind=presence.online. +CREATE TABLE IF NOT EXISTS shard_presence ( + id INT PRIMARY KEY DEFAULT 1, + count INT NOT NULL DEFAULT 0, + by_facet JSON NULL, -- { "Felucca": 12, "Trammel": 30 } + by_region JSON NULL, -- { "Britain": 18, "Wilderness": 9 } + t BIGINT NULL, -- snapshot time, epoch ms + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT chk_shard_presence_singleton CHECK (id = 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Discord bot moderation core (Phase 2). These tables are owned by the bot -- process (its own DB pool, bot/src/db.js) — the main server never reads or -- writes them. They live in the same physical database as everything else @@ -772,3 +853,20 @@ ALTER TABLE wiki_pages ADD FULLTEXT INDEX IF NOT EXISTS idx_wiki_search (title, -- already keeps the two tables consistent. ALTER TABLE posts ADD COLUMN IF NOT EXISTS announced_at DATETIME NULL; ALTER TABLE posts ADD COLUMN IF NOT EXISTS announce_job_id INT NULL; + +-- House registry (Protocol 2.0). The house.update full-state feed carries richer +-- fields than the house.decay transition feed shard_houses was built for. Rather +-- than a second table for one entity, extend shard_houses: house.update writes the +-- registry columns below (owner display name, co-owner/friend counts, placement +-- price, decay level name) while house.decay keeps owning `stage`/`is_idoc`. Each +-- upsert only touches its own columns, so the two feeds never clobber each other. +-- `price` is the placement value, NOT a "for sale" flag (stock ServUO has none). +ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS owner_name VARCHAR(120) NULL; +ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS co_owners INT NULL; +ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS friends INT NULL; +ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS price BIGINT NULL; +ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS decay VARCHAR(24) NULL; +-- Distinguishes a full registry row (seen via house.update) from a decay-only row, +-- so the public Houses browser can list registered houses without pulling in rows +-- we only ever saw an IDOC transition for. +ALTER TABLE shard_houses ADD COLUMN IF NOT EXISTS in_registry TINYINT(1) NOT NULL DEFAULT 0; diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js index 5710f64..1f7ecff 100644 --- a/server/src/model/shardState/shardState.db.js +++ b/server/src/model/shardState/shardState.db.js @@ -107,12 +107,23 @@ const listHousesByAccounts = (accounts) => accounts.length === 0 ? Promise.resolve([]) : query( - `SELECT ${HOUSE_COLS} FROM shard_houses + `SELECT ${HOUSE_REG_COLS} FROM shard_houses WHERE owner_acct IN (${accounts.map(() => '?').join(', ')}) ORDER BY is_idoc DESC, updated_at DESC`, accounts, ) +// ── House registry (Protocol 2.0 house.update / house.remove) ────────────── +// The registry columns extend HOUSE_COLS; a registry row is one we've seen via +// house.update (in_registry = 1), as opposed to a decay-only transition row. +const HOUSE_REG_COLS = `${HOUSE_COLS}, owner_name, co_owners, friends, price, decay, in_registry` + +const removeHouse = (serial) => query('DELETE FROM shard_houses WHERE serial = ?', [serial]) + +// The full registered-house browser: every row we've seen via house.update. +const listRegistryHouses = () => + query(`SELECT ${HOUSE_REG_COLS} FROM shard_houses WHERE in_registry = 1 ORDER BY name ASC`) + // ── Champion spawns ──────────────────────────────────────────────────────── const CHAMP_COLS = 'serial, category, type, name, status, active, map, x, y, z, boss_up, payload, t, updated_at' @@ -157,6 +168,104 @@ const clearPages = () => query('DELETE FROM shard_pages') // Oldest-open first so the queue reads like a work list. const listPages = () => query(`SELECT ${PAGE_COLS} FROM shard_pages ORDER BY sent_ms ASC`) +// ── Guild board (Protocol 2.0) ───────────────────────────────────────────── +const GUILD_COLS = + 'id, name, abbr, members, online, alliance, leader_serial, leader_name, leader_acct, leader_web_id, payload, t, updated_at' + +async function upsertGuild(id, fields) { + const cols = Object.keys(fields) + const allCols = ['id', ...cols] + const insertCols = allCols.map((c) => `\`${c}\``).join(', ') + const placeholders = allCols.map(() => '?').join(', ') + const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ') + await query( + `INSERT INTO shard_guilds (${insertCols}) VALUES (${placeholders}) + ON DUPLICATE KEY UPDATE ${updates}`, + [id, ...cols.map((c) => fields[c])], + ) +} + +const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id]) +const clearGuilds = () => query('DELETE FROM shard_guilds') +const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`) + +// ── Governor board + term history (Protocol 2.0) ─────────────────────────── +const GOV_COLS = + 'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at' + +async function upsertGovernor(city, fields) { + const cols = Object.keys(fields) + const allCols = ['city', ...cols] + const insertCols = allCols.map((c) => `\`${c}\``).join(', ') + const placeholders = allCols.map(() => '?').join(', ') + const updates = cols.map((c) => `\`${c}\` = VALUES(\`${c}\`)`).join(', ') + await query( + `INSERT INTO shard_governors (${insertCols}) VALUES (${placeholders}) + ON DUPLICATE KEY UPDATE ${updates}`, + [city, ...cols.map((c) => fields[c])], + ) +} + +const listGovernors = () => query(`SELECT ${GOV_COLS} FROM shard_governors ORDER BY city ASC`) + +// Cities whose current governor is one of the given game accounts (cross-link: +// does this user hold a governorship?). Empty list short-circuits. +const listGovernorshipsByAccounts = (accounts) => + accounts.length === 0 + ? Promise.resolve([]) + : query( + `SELECT ${GOV_COLS} FROM shard_governors + WHERE governor_acct IN (${accounts.map(() => '?').join(', ')}) + ORDER BY city ASC`, + accounts, + ) + +// The single open term (ended_at IS NULL) for a city, if any. +async function currentGovernorTerm(city) { + const rows = await query( + 'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1', + [city], + ) + return rows[0] || null +} + +const closeGovernorTerm = (id, endedAt) => + query('UPDATE shard_governor_terms SET ended_at = ? WHERE id = ?', [endedAt, id]) + +const openGovernorTerm = ({ city, serial, name, acct, webId, startedAt }) => + query( + `INSERT INTO shard_governor_terms + (city, governor_serial, governor_name, governor_acct, governor_web_id, started_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [city, serial ?? null, name ?? null, acct ?? null, webId ?? null, startedAt], + ) + +const listGovernorTerms = (city, limit) => + query( + 'SELECT id, city, governor_serial, governor_name, governor_acct, governor_web_id, started_at, ended_at, votes FROM shard_governor_terms WHERE city = ? ORDER BY started_at DESC LIMIT ?', + [city, limit], + ) + +// ── Online-population snapshot (Protocol 2.0 presence.online) ─────────────── +async function setPresence({ count, byFacet, byRegion, t }) { + await query( + `INSERT INTO shard_presence (id, count, by_facet, by_region, t) VALUES (1, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE count = VALUES(count), by_facet = VALUES(by_facet), + by_region = VALUES(by_region), t = VALUES(t)`, + [ + Number.isFinite(count) ? count : 0, + byFacet ? JSON.stringify(byFacet) : null, + byRegion ? JSON.stringify(byRegion) : null, + Number.isFinite(t) ? t : null, + ], + ) +} + +async function latestPresence() { + const rows = await query('SELECT count, by_facet, by_region, t FROM shard_presence WHERE id = 1') + return rows[0] || null +} + module.exports = { upsertOnline, removeOnline, @@ -171,6 +280,21 @@ module.exports = { upsertHouse, listIdocHouses, listHousesByAccounts, + removeHouse, + listRegistryHouses, + upsertGuild, + removeGuild, + clearGuilds, + listGuilds, + upsertGovernor, + listGovernors, + listGovernorshipsByAccounts, + currentGovernorTerm, + closeGovernorTerm, + openGovernorTerm, + listGovernorTerms, + setPresence, + latestPresence, upsertChamp, removeChamp, clearChamps, diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js index 1773b81..f851e93 100644 --- a/server/src/model/shardState/shardState.model.js +++ b/server/src/model/shardState/shardState.model.js @@ -148,6 +148,13 @@ function shapeHouse(r) { name: r.name, ownerSerial: r.owner_serial, ownerAcct: r.owner_acct, + // Registry fields (Protocol 2.0 house.update); undefined on decay-only rows. + ownerName: r.owner_name, + coOwners: r.co_owners, + friends: r.friends, + price: r.price == null ? null : Number(r.price), + decay: r.decay, + inRegistry: r.in_registry == null ? undefined : Boolean(r.in_registry), builtOn: r.built_on, lastRefreshed: r.last_refreshed, isIdoc: Boolean(r.is_idoc), @@ -166,6 +173,42 @@ async function listHousesForAccounts(accounts) { return rows.map(shapeHouse) } +// ── House registry (Protocol 2.0 house.update / house.remove) ────────────── +// Richer per-house snapshot than the decay-transition feed. Writes only the +// registry columns (+ shared location/owner fields); is_idoc/stage stay owned by +// the house.decay path, so the two feeds never clobber each other. owner is an +// actor object (or null for an abandoned house). +async function upsertHouseRegistry(data) { + if (!data || !data.serial) return + const owner = data.owner || null + const fields = { + name: data.name ?? null, + owner_serial: owner ? owner.serial ?? null : null, + owner_acct: owner ? owner.acct ?? null : null, + owner_name: owner ? owner.name ?? null : null, + co_owners: data.coOwners ?? null, + friends: data.friends ?? null, + price: data.price ?? null, + decay: data.decay ?? null, + region: data.region ?? null, + map: data.map ?? null, + x: data.x ?? null, + y: data.y ?? null, + z: data.z ?? null, + built_on: data.builtOn ? new Date(data.builtOn) : null, + last_refreshed: data.lastRefreshed ? new Date(data.lastRefreshed) : null, + in_registry: 1, + } + await db.upsertHouse(data.serial, fields) +} + +const removeHouse = (serial) => (serial ? db.removeHouse(serial) : Promise.resolve()) + +async function listHouses() { + const rows = await db.listRegistryHouses() + return rows.map(shapeHouse) +} + // Online players on the given game accounts (admin: a user's linked accounts). async function listOnlineForAccounts(accounts) { const rows = await db.listOnlineByAccounts(accounts) @@ -287,6 +330,179 @@ async function replacePages(pages) { for (const ev of pages || []) await upsertPage(ev) } +// ── Guild board (Protocol 2.0) ───────────────────────────────────────────── +// Upsert a guild's roster snapshot (guild.update). The leader is an actor object +// flattened into leader_* columns; the full event lives in `payload`. +async function upsertGuild(ev) { + if (!ev || ev.id == null) return + const leader = ev.leader || {} + await db.upsertGuild(ev.id, { + name: ev.name ?? null, + abbr: ev.abbr ?? null, + members: ev.members ?? null, + online: ev.online ?? null, + alliance: ev.alliance ?? null, + leader_serial: leader.serial ?? null, + leader_name: leader.name ?? null, + leader_acct: leader.acct ?? null, + leader_web_id: leader.webId ?? null, + payload: JSON.stringify(ev), + t: Number.isFinite(ev.t) ? ev.t : null, + }) +} + +const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id)) +const clearGuilds = () => db.clearGuilds() + +function shapeGuild(r) { + const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload + return payload || { + kind: 'guild.update', + id: r.id, + name: r.name, + abbr: r.abbr, + members: r.members, + online: r.online, + alliance: r.alliance, + leader: r.leader_serial + ? { serial: r.leader_serial, name: r.leader_name, acct: r.leader_acct, webId: r.leader_web_id } + : null, + t: r.t, + } +} + +async function listGuilds() { + const rows = await db.listGuilds() + return rows.map(shapeGuild) +} + +// Replace the board with a fresh snapshot (sidecar GET /guilds on connect). +async function replaceGuilds(guilds) { + await db.clearGuilds() + for (const ev of guilds || []) await upsertGuild(ev) +} + +// ── Town governors (Protocol 2.0) ────────────────────────────────────────── +// Upsert a city's governance snapshot (city.update) AND capture term history. +// Term capture runs first (it reads the CURRENT open term to decide whether the +// governor changed) and is idempotent: a repeat/backfill of the same governor is a +// no-op, so it's safe to call on the live feed and on reconnect snapshots alike. +async function upsertGovernor(ev) { + if (!ev || !ev.city) return + await recordGovernorTransition(ev) + const gov = ev.governor || null + const elect = ev.governorElect || null + await db.upsertGovernor(ev.city, { + governor_serial: gov ? gov.serial ?? null : null, + governor_name: gov ? gov.name ?? null : null, + governor_acct: gov ? gov.acct ?? null : null, + governor_web_id: gov ? gov.webId ?? null : null, + elect_serial: elect ? elect.serial ?? null : null, + elect_name: elect ? elect.name ?? null : null, + elect_acct: elect ? elect.acct ?? null : null, + election_phase: ev.electionPhase ?? null, + candidates: ev.candidates ?? null, + auto_pick_at: ev.autoPickAt ? new Date(ev.autoPickAt) : null, + payload: JSON.stringify(ev), + t: Number.isFinite(ev.t) ? ev.t : null, + }) +} + +// Close the open term and open a new one when the governor CHANGES. Idempotent: +// same governor as the open term ⇒ nothing happens (so backfill/duplicate +// city.update events never spawn spurious terms). +async function recordGovernorTransition(ev) { + const gov = ev.governor || null + const newSerial = gov ? gov.serial ?? null : null + const t = Number.isFinite(ev.t) ? ev.t : Date.now() + const open = await db.currentGovernorTerm(ev.city) + const openSerial = open ? open.governor_serial : null + if (open && openSerial === newSerial) return // unchanged — nothing to record + if (open) await db.closeGovernorTerm(open.id, t) // governor changed or seat vacated + if (newSerial) { + await db.openGovernorTerm({ + city: ev.city, + serial: newSerial, + name: gov.name ?? null, + acct: gov.acct ?? null, + webId: gov.webId ?? null, + startedAt: t, + }) + } +} + +function shapeGovernor(r) { + const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload + return payload || { + kind: 'city.update', + city: r.city, + governor: r.governor_serial + ? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id } + : null, + governorElect: r.elect_serial + ? { serial: r.elect_serial, name: r.elect_name, acct: r.elect_acct } + : null, + electionPhase: r.election_phase, + candidates: r.candidates, + t: r.t, + } +} + +async function listGovernors() { + const rows = await db.listGovernors() + return rows.map(shapeGovernor) +} + +// Cities the given game accounts currently govern (cross-link badge). +async function listGovernorshipsForAccounts(accounts) { + const rows = await db.listGovernorshipsByAccounts(accounts) + return rows.map(shapeGovernor) +} + +// Term history for a city (look-back), newest first. +async function listGovernorHistory(city, limit = 100) { + const n = Math.min(Math.max(Number(limit) || 100, 1), 500) + const rows = await db.listGovernorTerms(city, n) + return rows.map((r) => ({ + city: r.city, + governor: r.governor_serial + ? { serial: r.governor_serial, name: r.governor_name, acct: r.governor_acct, webId: r.governor_web_id } + : null, + startedAt: r.started_at == null ? null : Number(r.started_at), + endedAt: r.ended_at == null ? null : Number(r.ended_at), + votes: r.votes, + })) +} + +// Upsert governors without clearing (cities are fixed, no remove event); term +// capture inside upsertGovernor stays idempotent across reconnect snapshots. +async function replaceGovernors(cities) { + for (const ev of cities || []) await upsertGovernor(ev) +} + +// ── Online-population snapshot (Protocol 2.0 presence.online) ─────────────── +async function setPresence(ev) { + if (!ev) return + await db.setPresence({ + count: ev.count, + byFacet: ev.byFacet || null, + byRegion: ev.byRegion || null, + t: ev.t, + }) +} + +async function latestPresence() { + const r = await db.latestPresence() + if (!r) return { count: 0, byFacet: {}, byRegion: {}, t: null } + const parse = (v) => (typeof v === 'string' ? safeJson(v) || {} : v || {}) + return { + count: Number(r.count) || 0, + byFacet: parse(r.by_facet), + byRegion: parse(r.by_region), + t: r.t == null ? null : Number(r.t), + } +} + function safeJson(s) { try { return JSON.parse(s) @@ -309,6 +525,9 @@ module.exports = { upsertHouse, listIdoc, listHousesForAccounts, + upsertHouseRegistry, + removeHouse, + listHouses, upsertChamp, removeChamp, clearChamps, @@ -319,4 +538,16 @@ module.exports = { clearPages, listPages, replacePages, + upsertGuild, + removeGuild, + clearGuilds, + listGuilds, + replaceGuilds, + upsertGovernor, + listGovernors, + listGovernorshipsForAccounts, + listGovernorHistory, + replaceGovernors, + setPresence, + latestPresence, } diff --git a/server/src/router/v1/public/public.routes.js b/server/src/router/v1/public/public.routes.js index 846f44c..e172df5 100644 --- a/server/src/router/v1/public/public.routes.js +++ b/server/src/router/v1/public/public.routes.js @@ -184,6 +184,50 @@ publicRouter.get( /* #swagger.responses[200] = { description: 'Champion spawns, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ shard.getChamps, ) +publicRouter.get( + '/shard/guilds', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Current guild board (rosters, alliances, leaders)' + // #swagger.description = 'The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.' + /* #swagger.responses[200] = { description: 'Guilds, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + shard.getGuilds, +) +publicRouter.get( + '/shard/governors', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Current town-governor board (City Loyalty)' + // #swagger.description = 'One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.' + /* #swagger.responses[200] = { description: 'Cities, ordered by name', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + shard.getGovernors, +) +publicRouter.get( + '/shard/governors/:city/history', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Governor term history for a city' + // #swagger.parameters['city'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'City name, e.g. Britain.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max terms (default 100, max 500).' } + /* #swagger.responses[200] = { description: 'Terms, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + param('city').isString().isLength({ min: 1, max: 40 }), + query('limit').optional().isInt({ min: 1, max: 500 }), + validate, + shard.getGovernorHistory, +) +publicRouter.get( + '/shard/presence', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'Online population aggregate (count + per-facet + per-region)' + // #swagger.description = 'The latest presence.online snapshot powering the "Players Online" widget. Live via presence.online on /shard/stream.' + /* #swagger.responses[200] = { description: 'Population snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + shard.getPresence, +) +publicRouter.get( + '/shard/houses', + // #swagger.tags = ['Public · Shard'] + // #swagger.summary = 'House registry (owner, co-owners, price, decay)' + // #swagger.description = 'Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.' + /* #swagger.responses[200] = { description: 'Houses, ordered by name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardHouse" } } } } } */ + shard.getHouses, +) publicRouter.get( '/shard/stream', // #swagger.tags = ['Public · Shard'] diff --git a/server/src/router/v1/public/shard.controller.js b/server/src/router/v1/public/shard.controller.js index 6967007..4f00bc3 100644 --- a/server/src/router/v1/public/shard.controller.js +++ b/server/src/router/v1/public/shard.controller.js @@ -104,9 +104,77 @@ async function getChamps(req, res) { } } +// GET /public/shard/guilds — the current guild board. Served from our store; +// live via guild.update / guild.remove / guild.join on the public SSE stream. +async function getGuilds(req, res) { + try { + return res.json(await shardState.listGuilds()) + } catch (err) { + log.error('shard.getGuilds', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/governors — the current town-governor board (empty on shards +// without City Loyalty). Live via city.update on the public SSE stream. +async function getGovernors(req, res) { + try { + return res.json(await shardState.listGovernors()) + } catch (err) { + log.error('shard.getGovernors', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/governors/:city/history — the term ledger for one city +// (look-back: "who were all the governors of Britain?"), newest first. +async function getGovernorHistory(req, res) { + try { + return res.json(await shardState.listGovernorHistory(req.params.city, req.query.limit)) + } catch (err) { + log.error('shard.getGovernorHistory', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/presence — the online-population aggregate (count + per-facet +// + per-region). Live via presence.online on the public SSE stream. +async function getPresence(req, res) { + try { + return res.json(await shardState.latestPresence()) + } catch (err) { + log.error('shard.getPresence', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/shard/houses — the house registry (every house we've seen via +// house.update). Live via house.update / house.remove on the public SSE stream. +async function getHouses(req, res) { + try { + return res.json(await shardState.listHouses()) + } catch (err) { + log.error('shard.getHouses', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + // GET /public/shard/stream — public live-event SSE channel (safe kinds only). function stream(req, res) { broadcast.subscribe(req, res, 'public') } -module.exports = { getStatus, getFeed, getEconomy, getOnline, getIdoc, getChamps, stream } +module.exports = { + getStatus, + getFeed, + getEconomy, + getOnline, + getIdoc, + getChamps, + getGuilds, + getGovernors, + getGovernorHistory, + getPresence, + getHouses, + stream, +} diff --git a/server/src/utils/shardBroadcast.js b/server/src/utils/shardBroadcast.js index c453efc..067d496 100644 --- a/server/src/utils/shardBroadcast.js +++ b/server/src/utils/shardBroadcast.js @@ -36,6 +36,15 @@ const PUBLIC_KINDS = new Set([ // Champion-spawn board deltas — the public Champions page renders these live. 'champ.update', 'champ.remove', + // Protocol 2.0 boards — all public, rendered live on their respective pages. + 'guild.update', + 'guild.remove', + 'guild.join', + 'city.update', + 'presence.online', + 'region.enter', + 'house.update', + 'house.remove', ]) // Open response streams per channel. diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js index 6a6c782..c537467 100644 --- a/server/src/utils/shardIngest.js +++ b/server/src/utils/shardIngest.js @@ -39,6 +39,8 @@ const LOGGED_KINDS = new Set([ 'server.hello', 'server.shutdown', 'server.crashed', + // Protocol 2.0: a real-time guild join (the board itself is state, not logged). + 'guild.join', ]) // Tracks the current shard boot id so a restart (changed bootId on server.hello) @@ -146,6 +148,27 @@ async function applyStateChange(event, deps) { case 'page.closed': await shardState.removePage(event.pageId) return + // ── Protocol 2.0 boards ────────────────────────────────────────────── + case 'guild.update': + await shardState.upsertGuild(event) + return + case 'guild.remove': + await shardState.removeGuild(event.id) + return + case 'city.update': + // Upserts the board AND captures term history (idempotent). + await shardState.upsertGovernor(event) + return + case 'presence.online': + await shardState.setPresence(event) + return + case 'house.update': + await shardState.upsertHouseRegistry(event) + return + case 'house.remove': + await shardState.removeHouse(event.serial) + return + // guild.join → logged (real-time feed); region.enter → broadcast-only. default: // No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and // broadcasting still happen in ingest(). diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js index 9d487fe..26bca79 100644 --- a/server/src/utils/uoLinkClient.js +++ b/server/src/utils/uoLinkClient.js @@ -104,6 +104,11 @@ const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(li // our own store thereafter. const getChamps = () => call('/champs') const getPages = () => call('/pages') +// Protocol 2.0 board projections — same snapshot-on-connect pattern. +const getGuilds = () => call('/guilds') +const getGovernors = () => call('/governors') +const getHouses = () => call('/houses') +const getPresence = () => call('/online') // aggregate population (count + byFacet/byRegion) // ── Commands ────────────────────────────────────────────────────────────── const confirmLink = (code, websiteUserId) => @@ -142,6 +147,10 @@ module.exports = { getEconomy, getChamps, getPages, + getGuilds, + getGovernors, + getHouses, + getPresence, confirmLink, linkLookup, postTownCrier, diff --git a/server/src/utils/uoLinkSocket.js b/server/src/utils/uoLinkSocket.js index 1e7e2a3..f84f8f1 100644 --- a/server/src/utils/uoLinkSocket.js +++ b/server/src/utils/uoLinkSocket.js @@ -75,6 +75,33 @@ async function backfill() { await shardState.replacePages(pages.data.pages) log.info('snapshotted help-page queue from /pages', { count: pages.data.pages.length }) } + + // ── Protocol 2.0 boards ────────────────────────────────────────────── + // Same as champs/pages: snapshot the authoritative current state and + // reconcile our tables to it. Each call is independently guarded so a + // failed/absent board (e.g. no City Loyalty → empty /governors) never wipes + // another. Governors are NOT cleared before upsert (cities are fixed and the + // term-capture is idempotent, so a reconnect can't spawn spurious terms). + const guilds = await uoLinkClient.getGuilds() + if (guilds.ok && guilds.data && Array.isArray(guilds.data.guilds)) { + await shardState.replaceGuilds(guilds.data.guilds) + log.info('snapshotted guild board from /guilds', { count: guilds.data.guilds.length }) + } + const governors = await uoLinkClient.getGovernors() + if (governors.ok && governors.data && Array.isArray(governors.data.cities)) { + await shardState.replaceGovernors(governors.data.cities) + log.info('snapshotted governor board from /governors', { count: governors.data.cities.length }) + } + const houses = await uoLinkClient.getHouses() + if (houses.ok && houses.data && Array.isArray(houses.data.houses)) { + for (const ev of houses.data.houses) await shardIngest.ingest(ev, { fromBackfill: true }) + log.info('snapshotted house registry from /houses', { count: houses.data.houses.length }) + } + const presence = await uoLinkClient.getPresence() + if (presence.ok && presence.data && typeof presence.data.count === 'number') { + await shardState.setPresence(presence.data) + log.info('snapshotted online population from /online', { count: presence.data.count }) + } } catch (err) { log.warn('backfill failed (continuing on live feed)', { message: err.message }) } diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 34bb1ba..1c3a7c8 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -1492,6 +1492,165 @@ } } }, + "/api/v1/public/shard/guilds": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Current guild board (rosters, alliances, leaders)", + "description": "The live board of every guild. Update in place via the guild.update / guild.remove / guild.join frames on /shard/stream.", + "responses": { + "200": { + "description": "Guilds, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/governors": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Current town-governor board (City Loyalty)", + "description": "One entry per city with its governor and election phase. Empty if the shard does not run the City Loyalty system. Live via city.update on /shard/stream.", + "responses": { + "200": { + "description": "Cities, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/governors/{city}/history": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Governor term history for a city", + "description": "", + "parameters": [ + { + "name": "city", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "City name, e.g. Britain." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max terms (default 100, max 500)." + } + ], + "responses": { + "200": { + "description": "Terms, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/presence": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "Online population aggregate (count + per-facet + per-region)", + "description": "The latest presence.online snapshot powering the \"Players Online\" widget. Live via presence.online on /shard/stream.", + "responses": { + "200": { + "description": "Population snapshot", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/public/shard/houses": { + "get": { + "tags": [ + "Public · Shard" + ], + "summary": "House registry (owner, co-owners, price, decay)", + "description": "Every house seen via the house.update registry feed. `price` is the placement value, not a for-sale flag. Live via house.update / house.remove on /shard/stream.", + "responses": { + "200": { + "description": "Houses, ordered by name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ShardHouse" + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/api/v1/public/shard/stream": { "get": { "tags": [ diff --git a/server/test/shardIngest.protocol2.test.js b/server/test/shardIngest.protocol2.test.js new file mode 100644 index 0000000..ae44452 --- /dev/null +++ b/server/test/shardIngest.protocol2.test.js @@ -0,0 +1,93 @@ +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +const shardIngest = require('../src/utils/shardIngest') + +// Stub deps recording the Protocol 2.0 board calls the dispatcher makes. Only the +// methods the tested kinds touch need to be real; the rest are no-op async so +// ingest() never throws on an unrelated kind. +function makeDeps() { + const calls = { + guildUpsert: [], guildRemove: [], + governorUpsert: [], + presenceSet: [], + houseRegistry: [], houseRemove: [], + appended: [], broadcast: [], + } + const noop = async () => {} + return { + calls, + shardEvents: { append: async (row) => { calls.appended.push(row); return true } }, + shardState: { + upsertGuild: async (ev) => { calls.guildUpsert.push(ev) }, + removeGuild: async (id) => { calls.guildRemove.push(id) }, + upsertGovernor: async (ev) => { calls.governorUpsert.push(ev) }, + setPresence: async (ev) => { calls.presenceSet.push(ev) }, + upsertHouseRegistry: async (ev) => { calls.houseRegistry.push(ev) }, + removeHouse: async (serial) => { calls.houseRemove.push(serial) }, + // Present so any stray routing is a harmless no-op. + clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, + addEconomySample: noop, + }, + uoLinkConfig: { recordStatus: noop }, + broadcast: (ev) => { calls.broadcast.push(ev) }, + log: { warn() {}, info() {}, error() {} }, + } +} + +beforeEach(() => shardIngest.reset()) + +test('guild.update routes to upsertGuild and is not logged; guild.remove routes to removeGuild', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest({ kind: 'guild.update', id: 1042, name: 'TSH', t: 1 }, deps) + assert.equal(deps.calls.guildUpsert.length, 1) + assert.equal(deps.calls.guildUpsert[0].id, 1042) + assert.equal(r.logged, false) // board state, not appended to shard_events + await shardIngest.ingest({ kind: 'guild.remove', id: 1042, t: 2 }, deps) + assert.deepEqual(deps.calls.guildRemove, [1042]) +}) + +test('guild.join is appended to the event log (real-time joins feed) and broadcast', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'guild.join', id: 1042, who: { name: 'Bran' }, t: 3 }, deps) + assert.equal(r.logged, true) + assert.equal(deps.calls.appended.length, 1) + assert.equal(deps.calls.appended[0].kind, 'guild.join') + assert.equal(deps.calls.broadcast.length, 1) +}) + +test('city.update routes to upsertGovernor (which also captures term history)', async () => { + const deps = makeDeps() + await shardIngest.ingest( + { kind: 'city.update', city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 4 }, deps) + assert.equal(deps.calls.governorUpsert.length, 1) + assert.equal(deps.calls.governorUpsert[0].city, 'Britain') +}) + +test('presence.online routes to setPresence and is not logged', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'presence.online', count: 42, byRegion: { Britain: 18 }, t: 5 }, deps) + assert.equal(deps.calls.presenceSet.length, 1) + assert.equal(deps.calls.presenceSet[0].count, 42) + assert.equal(r.logged, false) +}) + +test('house.update routes to upsertHouseRegistry; house.remove routes to removeHouse', async () => { + const deps = makeDeps() + await shardIngest.ingest({ kind: 'house.update', serial: '0x40001234', name: 'Anvil', t: 6 }, deps) + assert.equal(deps.calls.houseRegistry.length, 1) + assert.equal(deps.calls.houseRegistry[0].serial, '0x40001234') + await shardIngest.ingest({ kind: 'house.remove', serial: '0x40001234', t: 7 }, deps) + assert.deepEqual(deps.calls.houseRemove, ['0x40001234']) +}) + +test('region.enter is broadcast-only — not logged, no state side effect', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'region.enter', from: 'Britain', to: 'Despise', who: { name: 'Darrow' }, t: 8 }, deps) + assert.equal(r.logged, false) + assert.equal(deps.calls.appended.length, 0) + assert.equal(deps.calls.broadcast.length, 1) // still surfaced live +}) diff --git a/server/test/shardState.governorTerms.test.js b/server/test/shardState.governorTerms.test.js new file mode 100644 index 0000000..1b6a911 --- /dev/null +++ b/server/test/shardState.governorTerms.test.js @@ -0,0 +1,77 @@ +const { test, beforeEach } = require('node:test') +const assert = require('node:assert/strict') + +// Term capture lives in the model (shardState.model.upsertGovernor → +// recordGovernorTransition) and talks to the db module. We exercise the real +// logic against an in-memory fake by monkeypatching the shared db module object +// (same instance the model require()s) — no DB, no mocking library. +const db = require('../src/model/shardState/shardState.db') +const model = require('../src/model/shardState/shardState.model') + +let terms // in-memory shard_governor_terms +let nextId +const saved = {} + +beforeEach(() => { + terms = [] + nextId = 1 + for (const k of ['currentGovernorTerm', 'closeGovernorTerm', 'openGovernorTerm', 'upsertGovernor']) { + saved[k] = db[k] + } + db.currentGovernorTerm = async (city) => + terms.find((t) => t.city === city && t.ended_at === null) || null + db.closeGovernorTerm = async (id, endedAt) => { + const row = terms.find((t) => t.id === id) + if (row) row.ended_at = endedAt + } + db.openGovernorTerm = async ({ city, serial, name, acct, webId, startedAt }) => { + terms.push({ id: nextId++, city, governor_serial: serial, governor_name: name, + governor_acct: acct, governor_web_id: webId, started_at: startedAt, ended_at: null }) + } + db.upsertGovernor = async () => {} // snapshot write — irrelevant to term capture +}) + +function restore() { + for (const k of Object.keys(saved)) db[k] = saved[k] +} + +test('a repeated city.update with the same governor does NOT open a second term', async () => { + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 }) + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 200 }) + const open = terms.filter((t) => t.ended_at === null) + assert.equal(terms.length, 1) + assert.equal(open.length, 1) + assert.equal(open[0].governor_serial, '0x1') + assert.equal(open[0].started_at, 100) + restore() +}) + +test('a governor change closes the old term and opens a new one', async () => { + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 }) + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x2', name: 'Mira' }, t: 300 }) + assert.equal(terms.length, 2) + const [first, second] = terms + assert.equal(first.governor_serial, '0x1') + assert.equal(first.ended_at, 300) // closed at the transition time + assert.equal(second.governor_serial, '0x2') + assert.equal(second.ended_at, null) // now current + assert.equal(second.started_at, 300) + restore() +}) + +test('a seat going vacant closes the term without opening a new one', async () => { + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1', name: 'Darrow' }, t: 100 }) + await model.upsertGovernor({ city: 'Britain', governor: null, t: 400 }) + assert.equal(terms.length, 1) + assert.equal(terms[0].ended_at, 400) + restore() +}) + +test('terms are tracked independently per city', async () => { + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 100 }) + await model.upsertGovernor({ city: 'Minoc', governor: { serial: '0x9' }, t: 120 }) + await model.upsertGovernor({ city: 'Britain', governor: { serial: '0x1' }, t: 200 }) // dup, no-op + assert.equal(terms.length, 2) + assert.equal(terms.filter((t) => t.ended_at === null).length, 2) + restore() +}) -- 2.49.1 From e9aa19a83d1d14cae0f3de9357c89716cfc99abb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:33:57 -0500 Subject: [PATCH 2/9] =?UTF-8?q?feat(shard):=20Protocol=202.0=20boards=20UI?= =?UTF-8?q?=20=E2=80=94=20guilds,=20governors,=20houses,=20players-online?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.jsx | 6 + client/src/api/client.js | 7 + client/src/components/PlayersOnline.jsx | 84 +++++++++++ client/src/components/SiteHeader.jsx | 3 + client/src/data/cityCrests.js | 31 ++++ client/src/data/regionBuckets.js | 62 ++++++++ client/src/routes/public/Governors.jsx | 186 ++++++++++++++++++++++++ client/src/routes/public/Guilds.jsx | 169 +++++++++++++++++++++ client/src/routes/public/Houses.jsx | 156 ++++++++++++++++++++ client/src/routes/public/Shard.jsx | 9 +- 10 files changed, 711 insertions(+), 2 deletions(-) create mode 100644 client/src/components/PlayersOnline.jsx create mode 100644 client/src/data/cityCrests.js create mode 100644 client/src/data/regionBuckets.js create mode 100644 client/src/routes/public/Governors.jsx create mode 100644 client/src/routes/public/Guilds.jsx create mode 100644 client/src/routes/public/Houses.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 3b2e99a..66f2881 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -19,6 +19,9 @@ import Status from './routes/public/Status.jsx' import Shard from './routes/public/Shard.jsx' import ShardActivity from './routes/public/ShardActivity.jsx' import ChampSpawns from './routes/public/ChampSpawns.jsx' +import Guilds from './routes/public/Guilds.jsx' +import Governors from './routes/public/Governors.jsx' +import Houses from './routes/public/Houses.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -84,6 +87,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> {/* CMS pages: top-level /:slug, matched only after the named routes diff --git a/client/src/api/client.js b/client/src/api/client.js index a5c36a3..6472abf 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -95,6 +95,13 @@ export const api = { online: () => req('/public/shard/online'), idoc: () => req('/public/shard/idoc'), champs: () => req('/public/shard/champs'), + // Protocol 2.0 boards. + guilds: () => req('/public/shard/guilds'), + governors: () => req('/public/shard/governors'), + governorHistory: (city, limit) => + req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`), + presence: () => req('/public/shard/presence'), + houses: () => req('/public/shard/houses'), }, // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is // fetch-only, so SSE subscribers build the URL from here. The admin stream diff --git a/client/src/components/PlayersOnline.jsx b/client/src/components/PlayersOnline.jsx new file mode 100644 index 0000000..2b2194a --- /dev/null +++ b/client/src/components/PlayersOnline.jsx @@ -0,0 +1,84 @@ +import { useMemo } from 'react' +import { useAsync } from '../lib/useAsync.js' +import { useShardFeed } from '../lib/useShardFeed.js' +import { bucketize } from '../data/regionBuckets.js' +import { api } from '../api/client.js' + +// Compact live "Players Online" widget. Loads the presence.online aggregate once, +// then keeps the total + region breakdown current from the presence.online SSE +// kind. The raw byRegion map is rolled up into display buckets (see +// data/regionBuckets.js). NOT a page — drop it into any panel/column. +const PRESENCE_KINDS = new Set(['presence.online']) + +export default function PlayersOnline() { + const { loading, error, data } = useAsync(() => api.shard.presence()) + const { events } = useShardFeed({ filter: PRESENCE_KINDS, max: 4 }) + + // The freshest snapshot wins: the newest buffered presence.online event, else + // the initial fetch. + const snapshot = events[0] || data + + const { total, rows } = useMemo(() => { + const count = Number(snapshot?.count) || 0 + const { rows: bucketRows } = bucketize(snapshot?.byRegion) + return { total: count, rows: bucketRows } + }, [snapshot]) + + return ( +
+
+ + Players online + + + {loading ? '—' : total} + +
+ + {error && ( +

+ Population is unavailable right now. +

+ )} + + {!loading && !error && ( +
+ {rows.length === 0 ? ( +

+ {total > 0 ? 'Locations are settling…' : 'The realm is quiet.'} +

+ ) : ( + rows.map((r) => ( +
+ {r.label} + {/* tabular figures keep the right-aligned counts in a clean column */} + {r.count} +
+ )) + )} +
+ )} +
+ ) +} diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index 65b59aa..eeef44b 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -13,6 +13,9 @@ const NAV = [ { label: 'Wiki', to: '/wiki' }, { label: 'Shard', to: '/site/shard' }, { label: 'Champions', to: '/site/champs' }, + { label: 'Guilds', to: '/site/guilds' }, + { label: 'Governors', to: '/site/governors' }, + { label: 'Houses', to: '/site/houses' }, { label: 'About', to: '/site/about' }, ] diff --git a/client/src/data/cityCrests.js b/client/src/data/cityCrests.js new file mode 100644 index 0000000..d9bff1b --- /dev/null +++ b/client/src/data/cityCrests.js @@ -0,0 +1,31 @@ +// Placeholder heraldry for the eight City-Loyalty cities. Each entry is a simple +// emoji sigil + a ring colour — enough to make the Governors board and the +// governor badge read as distinct "crests" today, swappable for real artwork +// later WITHOUT touching any component: drop an `img` (an imported asset URL or a +// public path) onto an entry and update CityCrest to prefer it. +// +// Keyed by the exact `city` string the sidecar sends (see INTEGRATION.md §4: +// Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia). + +export const CITY_CRESTS = { + Britain: { sigil: '⚜', color: '#c9a24b', label: 'Britain' }, + Moonglow: { sigil: '🔮', color: '#7f8fd0', label: 'Moonglow' }, + Minoc: { sigil: '⚒', color: '#b0763f', label: 'Minoc' }, + Trinsic: { sigil: '⚓', color: '#5f9bd0', label: 'Trinsic' }, + Yew: { sigil: '🌳', color: '#5fb98a', label: 'Yew' }, + Jhelom: { sigil: '⚔', color: '#c76f6f', label: 'Jhelom' }, + SkaraBrae: { sigil: '🐎', color: '#9a8bbf', label: 'Skara Brae' }, + NewMagincia: { sigil: '🕊', color: '#cfc3a0', label: 'New Magincia' }, +} + +const FALLBACK = { sigil: '🏰', color: '#8c96a5', label: '' } + +// Look up a crest by the raw city key, tolerating spacing variants +// ("Skara Brae" / "New Magincia"). `label` falls back to the given name. +export function crestFor(city) { + if (!city) return FALLBACK + const key = String(city).replace(/\s+/g, '') + const crest = CITY_CRESTS[city] || CITY_CRESTS[key] + if (crest) return crest + return { ...FALLBACK, label: String(city) } +} diff --git a/client/src/data/regionBuckets.js b/client/src/data/regionBuckets.js new file mode 100644 index 0000000..7d901af --- /dev/null +++ b/client/src/data/regionBuckets.js @@ -0,0 +1,62 @@ +// Roll the sidecar's raw presence.online `byRegion` map (many named ServUO +// regions) up into a handful of labelled display buckets for the "Players Online" +// widget. This is the ONE place to retune the grouping — edit BUCKETS (order + +// membership) and the widget follows. Anything not matched lands in "Wilderness" +// so the bucket counts always reconcile to the true total. + +// Ordered list of buckets. `label` shows in the widget; `match(region)` decides +// membership. First matching bucket wins; the last bucket is the catch-all. +export const BUCKETS = [ + { + id: 'britain', + label: 'Britain', + // Passthrough for the capital + its immediate surrounds. + match: (r) => /^britain/i.test(r), + }, + { + id: 'towns', + label: 'Towns', + // The other named cities/towns. + match: (r) => + /^(moonglow|minoc|trinsic|jhelom|yew|skara ?brae|magincia|new ?magincia|vesper|nujelm|cove|ocllo|serpent'?s? hold|wind|delucia|papua)/i.test( + r, + ), + }, + { + id: 'dungeons', + label: 'Dungeons', + match: (r) => + /(despise|destard|deceit|shame|hythloth|covetous|wrong|terathan|fire|ice|orc cave|dungeon|abyss|doom|khaldun|wrong|blackthorn|exodus|labyrinth|underworld)/i.test( + r, + ), + }, + { + id: 'housing', + label: 'Housing', + // House regions expose themselves as named house/townhouse regions. + match: (r) => /(house|townhouse|homestead|tent)/i.test(r), + }, + { + id: 'wilderness', + label: 'Wilderness', + // Catch-all: the unnamed "Wilderness" region + anything unmatched above. + match: () => true, + }, +] + +// Given a raw { region: count } map, return [{ id, label, count }] in BUCKETS +// order, dropping empty buckets, with the summed total also returned. +export function bucketize(byRegion = {}) { + const totals = new Map(BUCKETS.map((b) => [b.id, 0])) + let total = 0 + for (const [region, n] of Object.entries(byRegion || {})) { + const count = Number(n) || 0 + total += count + const bucket = BUCKETS.find((b) => b.match(String(region))) || BUCKETS[BUCKETS.length - 1] + totals.set(bucket.id, totals.get(bucket.id) + count) + } + const rows = BUCKETS.map((b) => ({ id: b.id, label: b.label, count: totals.get(b.id) })).filter( + (r) => r.count > 0, + ) + return { rows, total } +} diff --git a/client/src/routes/public/Governors.jsx b/client/src/routes/public/Governors.jsx new file mode 100644 index 0000000..bfdec52 --- /dev/null +++ b/client/src/routes/public/Governors.jsx @@ -0,0 +1,186 @@ +import { useMemo, useState } from 'react' +import PublicLayout from '../../components/PublicLayout.jsx' +import PageHeader from '../../components/PageHeader.jsx' +import { Loading, ErrorState } from '../../components/PageState.jsx' +import { useAsync } from '../../lib/useAsync.js' +import { useShardFeed } from '../../lib/useShardFeed.js' +import { crestFor } from '../../data/cityCrests.js' +import { api } from '../../api/client.js' + +// The town-governor board (City Loyalty). Loaded from /public/shard/governors, +// kept live by merging city.update deltas by city. Empty on shards without the +// City Loyalty system. Each city card links to its term history (look-back). +const GOV_KINDS = new Set(['city.update']) + +const PHASE = { + none: null, + nominate: { label: 'Nominations open', color: '#7f8fd0' }, + vote: { label: 'Voting', color: '#e6c26a' }, + pending: { label: 'Result pending', color: '#c9a24b' }, +} + +// A short "in 3d" / "in 5h" for a future ISO timestamp (autoPickAt). +function until(iso) { + if (!iso) return '' + const ms = new Date(iso).getTime() - Date.now() + if (!Number.isFinite(ms) || ms <= 0) return '' + const mins = Math.round(ms / 60000) + if (mins < 60) return `in ${mins}m` + const hrs = Math.round(mins / 60) + if (hrs < 24) return `in ${hrs}h` + return `in ${Math.round(hrs / 24)}d` +} + +function fmtDate(ms) { + if (ms == null) return '' + return new Date(Number(ms)).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +} + +function CityCrest({ city, size = 44 }) { + const c = crestFor(city) + return ( + + ) +} + +// Collapsible term history for one city, fetched on demand from the ledger. +function TermHistory({ city }) { + const [open, setOpen] = useState(false) + const { loading, error, data } = useAsync( + () => (open ? api.shard.governorHistory(city, 25) : Promise.resolve(null)), + [open, city], + ) + return ( +
+ + {open && ( +
+ {loading &&

Loading…

} + {error &&

Could not load history.

} + {data && data.length === 0 && ( +

No recorded terms yet.

+ )} + {data && data.length > 0 && ( +
    + {data.map((t, i) => ( +
  • + + {t.governor?.name || 'Vacant'} + + + {fmtDate(t.startedAt)}{t.endedAt ? ` – ${fmtDate(t.endedAt)}` : ' – present'} + +
  • + ))} +
+ )} +
+ )} +
+ ) +} + +function CityCard({ c }) { + const phase = PHASE[c.electionPhase] || null + const gov = c.governor + return ( +
+
+ +
+
+ + {crestFor(c.city).label || c.city} + + {phase && ( + + {phase.label} + + )} +
+
+ {gov ? ( + <>Governor {gov.name} + ) : ( + 'Seat vacant' + )} +
+
+
+ + {c.electionPhase && c.electionPhase !== 'none' && ( +
+ {c.candidates ? `${c.candidates} candidate${c.candidates === 1 ? '' : 's'}` : 'No candidates yet'} + {c.autoPickAt && until(c.autoPickAt) ? ` · resolves ${until(c.autoPickAt)}` : ''} +
+ )} + + +
+ ) +} + +export default function Governors() { + const { loading, error, data } = useAsync(() => api.shard.governors()) + const { events, connected } = useShardFeed({ filter: GOV_KINDS, max: 30 }) + + const board = useMemo(() => { + const map = new Map() + for (const c of data || []) if (c && c.city) map.set(c.city, c) + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i] + if (ev.kind === 'city.update' && ev.city) map.set(ev.city, ev) + } + return [...map.values()].sort((a, b) => (a.city || '').localeCompare(b.city || '')) + }, [data, events]) + + return ( + +
+
+ + + + {connected ? 'Live' : 'Offline'} + +
+ + {loading && } + {error && } + + {!loading && !error && ( + <> + {board.length === 0 ? ( +
+

+ City Loyalty governance is not enabled on this shard. +

+
+ ) : ( +
+ {board.map((c) => )} +
+ )} + + )} +
+
+ ) +} diff --git a/client/src/routes/public/Guilds.jsx b/client/src/routes/public/Guilds.jsx new file mode 100644 index 0000000..a91b7ef --- /dev/null +++ b/client/src/routes/public/Guilds.jsx @@ -0,0 +1,169 @@ +import { useMemo, useState } from 'react' +import PublicLayout from '../../components/PublicLayout.jsx' +import PageHeader from '../../components/PageHeader.jsx' +import { Loading, ErrorState } from '../../components/PageState.jsx' +import { useAsync } from '../../lib/useAsync.js' +import { useShardFeed } from '../../lib/useShardFeed.js' +import { api } from '../../api/client.js' + +// The guild board. Loaded once from /public/shard/guilds, then kept live by +// merging guild.update / guild.remove deltas; guild.join drives a small "recently +// joined" strip on top of the board. +const GUILD_KINDS = new Set(['guild.update', 'guild.remove', 'guild.join']) + +function Leader({ leader }) { + if (!leader || !leader.name) return + return {leader.name} +} + +function GuildRow({ g }) { + return ( +
+
+
+ {g.abbr && ( + + {g.abbr} + + )} + + {g.name || 'A guild'} + +
+ {g.alliance && ( +
+ {g.alliance} +
+ )} +
+
+
+ {g.online ?? 0} + / {g.members ?? 0} +
+
+ +
+
+
+ ) +} + +export default function Guilds() { + const { loading, error, data } = useAsync(() => api.shard.guilds()) + const { events, connected } = useShardFeed({ filter: GUILD_KINDS, max: 60 }) + const [q, setQ] = useState('') + + // Merge snapshot + live deltas by guild id (apply oldest → newest so live wins). + const board = useMemo(() => { + const map = new Map() + for (const g of data || []) if (g && g.id != null) map.set(g.id, g) + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i] + if (ev.kind === 'guild.update' && ev.id != null) map.set(ev.id, ev) + else if (ev.kind === 'guild.remove' && ev.id != null) map.delete(ev.id) + } + return [...map.values()] + }, [data, events]) + + // Recent joins strip (newest first, deduped, capped). + const joins = useMemo( + () => events.filter((e) => e.kind === 'guild.join' && e.who).slice(0, 6), + [events], + ) + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase() + const rows = needle + ? board.filter((g) => + [g.name, g.abbr, g.alliance].some((v) => v && v.toLowerCase().includes(needle)), + ) + : board + return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || '')) + }, [board, q]) + + const totalMembers = board.reduce((n, g) => n + (Number(g.members) || 0), 0) + + return ( + +
+
+ + + + {connected ? 'Live' : 'Offline'} + +
+ + {loading && } + {error && } + + {!loading && !error && ( + <> + {board.length === 0 ? ( +
+

No guilds are being tracked right now.

+
+ ) : ( + <> + {joins.length > 0 && ( +
+
+ Recently joined +
+
+ {joins.map((j) => ( +
+ {j.who.name} + joined + {j.abbr ? `[${j.abbr}] ` : ''}{j.name} +
+ ))} +
+
+ )} + +
+

+ {board.length} guilds · {totalMembers.toLocaleString()} members +

+ setQ(e.target.value)} + placeholder="Search guilds…" + style={{ flex: 'none', width: 190, maxWidth: '50%', fontSize: '0.84rem' }} + /> +
+ +
+ {filtered.map((g) => )} +
+ {filtered.length === 0 && ( +

No guilds match “{q}”.

+ )} + + )} + + )} +
+
+ ) +} diff --git a/client/src/routes/public/Houses.jsx b/client/src/routes/public/Houses.jsx new file mode 100644 index 0000000..223c9c4 --- /dev/null +++ b/client/src/routes/public/Houses.jsx @@ -0,0 +1,156 @@ +import { useMemo, useState } from 'react' +import PublicLayout from '../../components/PublicLayout.jsx' +import PageHeader from '../../components/PageHeader.jsx' +import { Loading, ErrorState } from '../../components/PageState.jsx' +import { useAsync } from '../../lib/useAsync.js' +import { useShardFeed } from '../../lib/useShardFeed.js' +import { api } from '../../api/client.js' + +// The house registry. Loaded from /public/shard/houses, kept live by merging +// house.update / house.remove deltas by serial. `price` is the placement value — +// NOT a for-sale flag (stock ServUO has none), and the UI labels it as such. +const HOUSE_KINDS = new Set(['house.update', 'house.remove']) + +// Decay level → colour, from healthiest to collapsed. +const DECAY_TONE = { + LikeNew: '#7fd0a4', + Slightly: '#a9cf8a', + Somewhat: '#d7c56a', + Fairly: '#e0a95f', + Greatly: '#d9736f', + IDOC: '#e05a5a', + Collapsed: '#8c96a5', +} + +function DecayBadge({ decay, isIdoc }) { + const label = isIdoc ? 'IDOC' : decay + if (!label) return null + const tone = DECAY_TONE[label] || 'var(--muted)' + return ( + + {label} + + ) +} + +// house.update carries owner as a flattened ownerName/ownerAcct on our shaped row. +function ownerLabel(h) { + return h.ownerName || h.ownerAcct || null +} + +function HouseRow({ h }) { + const owner = ownerLabel(h) + return ( +
+
+
+ + {h.name || 'An unnamed house'} + + +
+
+ {owner ? <>Owned by {owner} : 'No owner'} + {(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''} +
+
+ {h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''} +
+
+ {h.price != null && ( +
+
+ {Number(h.price).toLocaleString()} +
+
+ placement value +
+
+ )} +
+ ) +} + +export default function Houses() { + const { loading, error, data } = useAsync(() => api.shard.houses()) + const { events, connected } = useShardFeed({ filter: HOUSE_KINDS, max: 60 }) + const [q, setQ] = useState('') + + const board = useMemo(() => { + const map = new Map() + for (const h of data || []) if (h && h.serial) map.set(h.serial, h) + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i] + if (ev.kind === 'house.update' && ev.serial) { + // Live house.update events arrive in the sidecar's shape (owner is an + // actor object); normalize to the flattened shape the row renders. + map.set(ev.serial, { + ...ev, + ownerName: ev.owner?.name ?? ev.ownerName, + ownerAcct: ev.owner?.acct ?? ev.ownerAcct, + }) + } else if (ev.kind === 'house.remove' && ev.serial) { + map.delete(ev.serial) + } + } + return [...map.values()] + }, [data, events]) + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase() + const rows = needle + ? board.filter((h) => + [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle)), + ) + : board + return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || '')) + }, [board, q]) + + return ( + +
+
+ + + + {connected ? 'Live' : 'Offline'} + +
+ + {loading && } + {error && } + + {!loading && !error && ( + <> + {board.length === 0 ? ( +
+

No houses are being tracked right now.

+
+ ) : ( + <> +
+

+ {board.length.toLocaleString()} houses +

+ setQ(e.target.value)} + placeholder="Search by owner, region…" + style={{ flex: 'none', width: 210, maxWidth: '55%', fontSize: '0.84rem' }} + /> +
+
+ {filtered.map((h) => )} +
+ {filtered.length === 0 && ( +

No houses match “{q}”.

+ )} + + )} + + )} +
+
+ ) +} diff --git a/client/src/routes/public/Shard.jsx b/client/src/routes/public/Shard.jsx index 1f6ea5c..431df8b 100644 --- a/client/src/routes/public/Shard.jsx +++ b/client/src/routes/public/Shard.jsx @@ -7,6 +7,7 @@ import { useShardFeed } from '../../lib/useShardFeed.js' import { describe } from '../../lib/shardEvents.js' import { ago } from '../../lib/format.js' import { api } from '../../api/client.js' +import PlayersOnline from '../../components/PlayersOnline.jsx' // ── Gold-supply sparkline ─────────────────────────────────────────────────── function Sparkline({ series }) { @@ -108,12 +109,16 @@ export default function Shard() { {/* Stat tiles */} -
- +
+ {/* Live players-online breakdown (total + region buckets) */} +
+ +
+ {/* Staff online — linked staff accounts only, with location */}
-- 2.49.1 From 2957708bab400ac10aa026f0fe7fec27be0539dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:45:15 -0500 Subject: [PATCH 3/9] =?UTF-8?q?feat(shard):=20Protocol=202.0=20cross-links?= =?UTF-8?q?=20=E2=80=94=20titles,=20guild,=20governor,=20houses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 --- client/src/api/client.js | 1 + client/src/components/CharacterSheet.jsx | 47 +++++++++++++++ client/src/routes/admin/views/UserDetail.jsx | 32 ++++++++++- server/src/model/shardState/shardState.db.js | 24 ++++++++ .../src/model/shardState/shardState.model.js | 18 ++++++ server/src/router/v1/admin/admin.routes.js | 12 ++++ .../router/v1/admin/usersShard.controller.js | 20 ++++++- .../src/router/v1/player/shard.controller.js | 21 ++++++- server/swagger/swagger-output.json | 57 +++++++++++++++++++ 9 files changed, 229 insertions(+), 3 deletions(-) diff --git a/client/src/api/client.js b/client/src/api/client.js index 6472abf..c515752 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -183,6 +183,7 @@ export const api = { sales: () => req(`/admin/users/${id}/shard/sales`), houses: () => req(`/admin/users/${id}/shard/houses`), online: () => req(`/admin/users/${id}/shard/online`), + standing: () => req(`/admin/users/${id}/shard/standing`), }), // ----- moderation dashboard (admin + moderator) ----- diff --git a/client/src/components/CharacterSheet.jsx b/client/src/components/CharacterSheet.jsx index 72f247d..967bee0 100644 --- a/client/src/components/CharacterSheet.jsx +++ b/client/src/components/CharacterSheet.jsx @@ -10,6 +10,38 @@ import ShardAccountActions from './ShardAccountActions.jsx' const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' } +// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already +// computed display strings; reward entries may be a cliloc NUMBER-as-string or a +// literal string. Without a cliloc table on the site we can only show literals, so +// numeric reward entries are skipped rather than shown as a raw number. Returns a +// de-duped list of human-readable title chips. +function displayTitles(titles) { + if (!titles) return [] + const out = [] + if (titles.fameKarma) out.push(titles.fameKarma) + if (titles.skill) out.push(titles.skill) + const reward = Array.isArray(titles.reward) ? titles.reward : [] + const sel = typeof titles.selected === 'number' ? titles.selected : -1 + // Prefer the selected reward title; fall back to the first literal one. + const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r))) + if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate)) + return [...new Set(out.filter(Boolean))] +} + +function TitleChip({ children, tone = 'var(--muted)' }) { + return ( + + {children} + + ) +} + function StatTile({ value, label }) { return (
@@ -64,6 +96,21 @@ export default function CharacterSheet({ char, moderation = false }) { {char.serial}
+ {/* Titles + standing (guild led / governorship) — all optional */} + {(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && ( +
+ {char.governorOf && char.governorOf.map((city) => ( + Governor of {city} + ))} + {char.guild && ( + + Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name} + + )} + {displayTitles(char.titles).map((t) => {t})} +
+ )} + {/* Staff moderation for this character's account (self-gates to staff). */} {moderation && char.acct && (
diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx index 5ca2f0e..7bc5a31 100644 --- a/client/src/routes/admin/views/UserDetail.jsx +++ b/client/src/routes/admin/views/UserDetail.jsx @@ -57,6 +57,33 @@ function OnlineNow({ scope }) { ) } +// Shard "standing": city governorships held and guilds led by this user's +// accounts (both reliable current-state lookups). Renders nothing when empty. +function Standing({ scope }) { + const { data } = useAsync(() => scope.standing(), [scope]) + if (!data) return null + const govs = data.governorOf || [] + const guilds = data.guildsLed || [] + if (govs.length === 0 && guilds.length === 0) return null + return ( +
+ Standing +
+ {govs.map((g) => ( + + Governor of {g.city} + + ))} + {guilds.map((g) => ( + + Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name} + + ))} +
+
+ ) +} + // Houses owned by the user's accounts, IDOC first (flagged). function Houses({ scope }) { const { data } = useAsync(() => scope.houses(), [scope]) @@ -82,10 +109,12 @@ function Houses({ scope }) { {h.region || (h.map != null ? `map ${h.map}` : 'unknown')} {h.x != null ? ` · ${h.x}, ${h.y}` : ''} {h.ownerAcct ? ` · ${h.ownerAcct}` : ''} + {(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
- {h.stage ?
{h.stage}
: null} + {(h.decay || h.stage) ?
{h.decay || h.stage}
: null} + {h.price != null ?
{Number(h.price).toLocaleString()} gp
: null} {h.lastRefreshed ?
refreshed {ago(h.lastRefreshed)}
: null}
@@ -102,6 +131,7 @@ function ShardSections({ scope }) { Linked accounts & characters `/admin/characters/${serial}`} /> + diff --git a/server/src/model/shardState/shardState.db.js b/server/src/model/shardState/shardState.db.js index 1f7ecff..2a21774 100644 --- a/server/src/model/shardState/shardState.db.js +++ b/server/src/model/shardState/shardState.db.js @@ -189,6 +189,28 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id]) const clearGuilds = () => query('DELETE FROM shard_guilds') const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`) +// The guild an actor LEADS — matched on the current board (leader_serial or the +// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders +// is not modelled (the board carries only counts + leader), so we don't guess it. +const findGuildLedByActor = (serial, acct) => + query( + `SELECT id, name, abbr, alliance, leader_name FROM shard_guilds + WHERE leader_serial = ? OR (leader_acct IS NOT NULL AND leader_acct = ?) + LIMIT 1`, + [serial ?? null, acct ?? null], + ) + +// Guilds led by any of the given game accounts (admin: a user's linked accounts). +const listGuildsLedByAccounts = (accounts) => + accounts.length === 0 + ? Promise.resolve([]) + : query( + `SELECT id, name, abbr, alliance, leader_name FROM shard_guilds + WHERE leader_acct IN (${accounts.map(() => '?').join(', ')}) + ORDER BY name ASC`, + accounts, + ) + // ── Governor board + term history (Protocol 2.0) ─────────────────────────── const GOV_COLS = 'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at' @@ -286,6 +308,8 @@ module.exports = { removeGuild, clearGuilds, listGuilds, + findGuildLedByActor, + listGuildsLedByAccounts, upsertGovernor, listGovernors, listGovernorshipsByAccounts, diff --git a/server/src/model/shardState/shardState.model.js b/server/src/model/shardState/shardState.model.js index f851e93..9a544a0 100644 --- a/server/src/model/shardState/shardState.model.js +++ b/server/src/model/shardState/shardState.model.js @@ -382,6 +382,22 @@ async function replaceGuilds(guilds) { for (const ev of guilds || []) await upsertGuild(ev) } +// The guild an actor leads (cross-link on the character sheet). Leadership only — +// see the db note; membership for rank-and-file isn't in the feed, so we return +// null rather than show a possibly-stale guess. +async function findGuildForActor({ serial, acct }) { + const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null) + const g = rows[0] + if (!g) return null + return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' } +} + +// Guilds led by any of a user's linked accounts (admin user-detail cross-link). +async function listGuildsLedForAccounts(accounts) { + const rows = await db.listGuildsLedByAccounts(accounts) + return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name })) +} + // ── Town governors (Protocol 2.0) ────────────────────────────────────────── // Upsert a city's governance snapshot (city.update) AND capture term history. // Term capture runs first (it reads the CURRENT open term to decide whether the @@ -543,6 +559,8 @@ module.exports = { clearGuilds, listGuilds, replaceGuilds, + findGuildForActor, + listGuildsLedForAccounts, upsertGovernor, listGovernors, listGovernorshipsForAccounts, diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index a423d15..ed7878f 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -1255,6 +1255,18 @@ adminRouter.get( validate, usersShard.getOnline, ) +adminRouter.get( + '/users/:id/shard/standing', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + /* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('id').isInt(), + validate, + usersShard.getStanding, +) // ── uo-link sidecar control (admin only) ────────────────────────────────── // Connection config (base/ws URL + token + protocol + enabled) and the town diff --git a/server/src/router/v1/admin/usersShard.controller.js b/server/src/router/v1/admin/usersShard.controller.js index d9e42a3..fd3c5a1 100644 --- a/server/src/router/v1/admin/usersShard.controller.js +++ b/server/src/router/v1/admin/usersShard.controller.js @@ -83,4 +83,22 @@ async function getOnline(req, res) { } } -module.exports = { getUser, listAccounts, getSales, getHouses, getOnline } +// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links: +// city governorships they currently hold and guilds they lead. Both are reliable +// current-state lookups on the user's linked accounts. +async function getStanding(req, res) { + try { + const ctx = await accountsForUser(Number(req.params.id)) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + const [governorOf, guildsLed] = await Promise.all([ + shardState.listGovernorshipsForAccounts(ctx.accounts), + shardState.listGuildsLedForAccounts(ctx.accounts), + ]) + return res.json({ governorOf, guildsLed }) + } catch (err) { + log.error('getStanding', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding } diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js index cef8929..76927d7 100644 --- a/server/src/router/v1/player/shard.controller.js +++ b/server/src/router/v1/player/shard.controller.js @@ -9,6 +9,7 @@ const uoLinkClient = require('../../../utils/uoLinkClient') const shardLinks = require('../../../model/shardLinks/shardLinks.model') +const shardState = require('../../../model/shardState/shardState.model') const { salesForAccounts } = require('../../../utils/shardSales') const activity = require('../../../model/activity/activity.model') @@ -16,6 +17,24 @@ const log = require('../../../utils/logger')('player-shard') const SERIAL_RE = /^0x[0-9a-fA-F]+$/ +// Decorate a char.profile with cross-links from our own board data: the guild the +// character leads and any city governorship on its account. Best-effort — a +// failure here never fails the profile (it's a nicety, not the sheet). +async function enrichCharProfile(profile) { + if (!profile) return profile + try { + const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct }) + if (guild) profile.guild = guild + if (profile.acct) { + const govs = await shardState.listGovernorshipsForAccounts([profile.acct]) + if (govs.length) profile.governorOf = govs.map((g) => g.city) + } + } catch (err) { + log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message }) + } + return profile +} + // POST /player/shard/link — confirm an in-game link code. async function link(req, res) { const { code } = req.body @@ -102,7 +121,7 @@ async function getChar(req, res) { const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' }) } - return res.json(result.data) + return res.json(await enrichCharProfile(result.data)) } if (result.status === 404) return res.status(404).json({ message: 'Character not found.' }) if (result.status === 503 || result.status === 0) { diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 1c3a7c8..fa3fbee 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -7091,6 +7091,63 @@ ] } }, + "/api/v1/admin/users/{id}/shard/standing": { + "get": { + "tags": [ + "Admin · Users" + ], + "summary": "A user’s shard standing — governorships held and guilds led (admin only)", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + } + ], + "responses": { + "200": { + "description": "Standing { governorOf, guildsLed }", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/uo-link/config": { "get": { "tags": [ -- 2.49.1 From 55a3adea993247e6b2010ed898a8189a2f9e8f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:40:47 -0500 Subject: [PATCH 4/9] feat(news): auto-push published news to the in-game Town Cryer News gump (2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4: sync the site's published news posts into the Protocol 2.1 News gump. - uoLinkClient.postNews / deleteNews. - utils/newsGump.js — a STATE SYNC (not a one-shot announce leg): an article stays in the gump while its post is published news and is pulled when it leaves that state. buildArticle renders a compact gump-HTML block (centred title + plain-text excerpt — the gump supports only a small HTML subset) with a "more info" link to /site/news and an optional gump image from the `news_gump_image` setting. Every call is best-effort / never-throws. - Hooked into the posts pipeline alongside the existing announce enqueue: syncPost on create/update/publish (fresh publish announces; edits refresh silently; leaving published-news pulls the article), removePost on delete. - reassertAll() runs in uoLinkSocket.backfill on every WS (re)connect — reconciles the gump to our source of truth and recovers any article whose original live push failed (silent, so a reconnect never re-proclaims old news). Server 185/185, swagger regenerated. Refs .plans/protocol2-integration.md (Phase 4). Co-Authored-By: Claude Opus 4.8 --- .../src/router/v1/admin/admin.controller.js | 9 ++ server/src/utils/newsGump.js | 124 ++++++++++++++++++ server/src/utils/uoLinkClient.js | 9 ++ server/src/utils/uoLinkSocket.js | 7 + server/test/newsGump.test.js | 69 ++++++++++ 5 files changed, 218 insertions(+) create mode 100644 server/src/utils/newsGump.js create mode 100644 server/test/newsGump.test.js diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index e3a9360..dbf98f1 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -4,6 +4,7 @@ const settings = require('../../../model/settings/settings.model') const users = require('../../../model/users/users.model') const activity = require('../../../model/activity/activity.model') const announceJobs = require('../../../model/announceJobs/announceJobs.model') +const newsGump = require('../../../utils/newsGump') const { cleanBody } = require('../../../utils/sanitizeHtml') const log = require('../../../utils/logger')('admin') @@ -22,6 +23,11 @@ const log = require('../../../utils/logger')('admin') // enqueueIfNeeded swallows its own errors, so a pipeline hiccup can't break save. async function announceIfNewlyPublished(post, transition) { await announceJobs.enqueueIfNeeded(post, transition) + // Keep the in-game Town Cryer News gump in sync with the same transition: push + // the article when it becomes published news, refresh it silently on an edit, + // and pull it when it leaves published-news. Best-effort (never throws), so a + // sidecar hiccup never breaks saving a post — same guarantee as the enqueue. + await newsGump.syncPost(post, transition) } // ── Dashboard & site mode ───────────────────────────────────────────── @@ -173,8 +179,11 @@ async function publishPost(req, res) { async function deletePost(req, res) { const id = Number(req.params.id) try { + const current = await posts.getById(id) await posts.remove(id) await activity.log({ req, action: 'post.delete', detail: { id } }) + // If it was live in the News gump, pull it (best-effort). + if (newsGump.inGump(current)) await newsGump.removePost(id) return res.json({ id }) } catch (err) { return res.status(500).json({ message: 'Internal Server Error' }) diff --git a/server/src/utils/newsGump.js b/server/src/utils/newsGump.js new file mode 100644 index 0000000..804c22b --- /dev/null +++ b/server/src/utils/newsGump.js @@ -0,0 +1,124 @@ +// ── Town Cryer News gump sync (Protocol 2.1) ─────────────────────────────── +// +// Keeps the in-game Town Cryer *News* gump in sync with the site's published +// news posts. Distinct from the scrolling town-crier lines (that's a one-shot +// announce leg in announceWorker); this is a STATE SYNC — an article stays in the +// gump while its post is published news, and is pulled when the post is +// unpublished/deleted/re-categorised. +// +// The website is the source of truth. POST /news is idempotent (re-post replaces), +// so a refresh or a reconnect re-assert is safe. Every call is best-effort and +// never throws — a sidecar/shard hiccup must never break saving or deleting a +// post. Reliability comes from reassertAll() on every WS (re)connect +// (uoLinkSocket.backfill), which re-pushes the current published set silently and +// closes the gap if an earlier live push failed. + +const posts = require('../model/posts/posts.model') +const uoLinkClient = require('./uoLinkClient') +const settings = require('../model/settings/settings.model') +const { deriveExcerpt } = require('./sanitizeHtml') +const log = require('./logger')('news-gump') + +const MAX_TITLE = 120 +const MAX_BODY = 900 + +function baseUrl() { + return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') +} + +function clamp(value, max) { + const s = String(value == null ? '' : value).replace(/\s+/g, ' ').trim() + return s.length <= max ? s : `${s.slice(0, max - 1).trimEnd()}…` +} + +// A post belongs in the gump exactly when it is published AND in the news category. +function inGump(post) { + return Boolean(post && post.published && post.category === 'news') +} + +// Optional UO gump image id for news articles (a shard art id), from the +// `news_gump_image` setting. Omitted → the sidecar uses a neutral scroll. +async function gumpImage() { + try { + const raw = await settings.get('news_gump_image') + const n = Number(raw) + return Number.isInteger(n) && n > 0 ? n : undefined + } catch { + return undefined + } +} + +// Build the in-game News article from a post. Body is a compact gump-HTML block +// (title centred + a plain-text excerpt) rather than the post's full rich HTML — +// the UO gump only supports a small HTML subset, so we keep it predictable. The +// "more info" URL is the public news list (news posts have no per-post route). +async function buildArticle(post, { announce = true } = {}) { + const title = clamp(post.title, MAX_TITLE) + const excerpt = clamp(post.excerpt || deriveExcerpt(post.body, MAX_BODY) || '', MAX_BODY) + const body = excerpt ? `
${title}


${excerpt}` : `
${title}
` + return { + id: String(post.id), + title, + body, + image: await gumpImage(), + url: `${baseUrl()}/site/news`, + announce, + } +} + +// Push a post to the gump (only if it belongs there). announce=true has the criers +// proclaim the title; false is a silent refresh/re-assert. +async function pushPost(post, { announce = true } = {}) { + if (!inGump(post)) return { ok: false, skipped: true } + const res = await uoLinkClient.postNews(await buildArticle(post, { announce })) + if (!res.ok) log.warn('news gump push failed', { id: post.id, status: res.status, error: res.error }) + return res +} + +// Remove a post from the gump. A 404 (not present) is not an error worth noting. +async function removePost(id) { + const res = await uoLinkClient.deleteNews(String(id)) + if (!res.ok && res.status !== 404) { + log.warn('news gump remove failed', { id, status: res.status, error: res.error }) + } + return res +} + +// Reconcile the gump after a post create/update/publish. `transition` +// ({ wasPublished, wasNews }) tells a fresh publish (announce) from an in-place +// edit (silent refresh) and catches a post leaving published-news (pull it). +async function syncPost(post, transition = {}) { + try { + if (inGump(post)) { + const wasInGump = Boolean(transition.wasPublished && transition.wasNews) + await pushPost(post, { announce: !wasInGump }) + } else if (transition.wasPublished && transition.wasNews) { + await removePost(post.id) + } + } catch (err) { + log.warn('news gump sync failed', { id: post && post.id, message: err.message }) + } +} + +// Re-push every currently-published news post, silently — run on each WS +// (re)connect to reconcile the gump to our source of truth (also recovers any +// article whose original live push failed). Best-effort; never throws. +async function reassertAll() { + try { + const list = await posts.listAll('news') + const published = (list || []).filter((p) => p.published) + let pushed = 0 + for (const p of published) { + const full = await posts.getById(p.id) // list projection may omit the body + if (full) { + await pushPost(full, { announce: false }) + pushed += 1 + } + } + if (pushed) log.info('re-asserted news gump articles', { count: pushed }) + } catch (err) { + log.warn('news gump reassert failed', { message: err.message }) + } +} + +module.exports = { inGump, buildArticle, pushPost, removePost, syncPost, reassertAll } diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js index 26bca79..ff3ef66 100644 --- a/server/src/utils/uoLinkClient.js +++ b/server/src/utils/uoLinkClient.js @@ -118,6 +118,13 @@ const postTownCrier = ({ id, lines, durationSec }) => call('/towncrier', { method: 'POST', body: { id, lines, durationSec } }) const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }) +// Town Cryer News gump (Protocol 2.1). A full article (title/HTML body/image/URL) +// in the in-game News window; re-posting the same id REPLACES it. `announce` +// (default true on the sidecar) controls whether the criers proclaim the title. +const postNews = ({ id, title, body, image, url, announce }) => + call('/news', { method: 'POST', body: { id: String(id), title, body, image, url, announce } }) +const deleteNews = (id) => call(`/news/${encodeURIComponent(id)}`, { method: 'DELETE' }) + // ── Staff write plane (§6) ───────────────────────────────────────────────── // Every call carries `actor` — the website username of the staff member — set by // the controller from the session, NEVER from the browser. The shard records it @@ -155,6 +162,8 @@ module.exports = { linkLookup, postTownCrier, deleteTownCrier, + postNews, + deleteNews, adminKick, adminBan, adminUnban, diff --git a/server/src/utils/uoLinkSocket.js b/server/src/utils/uoLinkSocket.js index f84f8f1..4adbacc 100644 --- a/server/src/utils/uoLinkSocket.js +++ b/server/src/utils/uoLinkSocket.js @@ -17,6 +17,7 @@ const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model') const uoLinkClient = require('./uoLinkClient') const shardIngest = require('./shardIngest') const shardState = require('../model/shardState/shardState.model') +const newsGump = require('./newsGump') const log = require('./logger')('uo-link-socket') const BACKOFF_MIN_MS = 1000 @@ -102,6 +103,12 @@ async function backfill() { await shardState.setPresence(presence.data) log.info('snapshotted online population from /online', { count: presence.data.count }) } + + // Re-assert our published news into the in-game Town Cryer News gump. The + // website is the source of truth; this reconciles the gump on every + // (re)connect (and recovers any article whose original live push failed). + // Silent (announce:false) so a reconnect never re-proclaims old news. + await newsGump.reassertAll() } catch (err) { log.warn('backfill failed (continuing on live feed)', { message: err.message }) } diff --git a/server/test/newsGump.test.js b/server/test/newsGump.test.js new file mode 100644 index 0000000..beca3a2 --- /dev/null +++ b/server/test/newsGump.test.js @@ -0,0 +1,69 @@ +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// Exercise the News-gump sync decisions against a fake sidecar client by +// monkeypatching the shared modules newsGump require()s (same instance) — no DB, +// no network. +const uoLinkClient = require('../src/utils/uoLinkClient') +const settings = require('../src/model/settings/settings.model') +const newsGump = require('../src/utils/newsGump') + +let calls +const saved = {} + +beforeEach(() => { + calls = { post: [], del: [] } + saved.postNews = uoLinkClient.postNews + saved.deleteNews = uoLinkClient.deleteNews + saved.get = settings.get + uoLinkClient.postNews = async (article) => { calls.post.push(article); return { ok: true, status: 200 } } + uoLinkClient.deleteNews = async (id) => { calls.del.push(id); return { ok: true, status: 200 } } + settings.get = async () => null // no gump image configured +}) + +afterEach(() => { + uoLinkClient.postNews = saved.postNews + uoLinkClient.deleteNews = saved.deleteNews + settings.get = saved.get +}) + +const newsPost = (over = {}) => ({ id: 42, category: 'news', published: true, title: 'Double XP Weekend', excerpt: 'Starts Friday.', body: null, ...over }) + +test('buildArticle centres the title, links the news list, and respects announce', async () => { + const a = await newsGump.buildArticle(newsPost(), { announce: false }) + assert.equal(a.id, '42') + assert.match(a.body, /
Double XP Weekend<\/CENTER>/) + assert.match(a.body, /Starts Friday\./) + assert.match(a.url, /\/site\/news$/) + assert.equal(a.announce, false) +}) + +test('a fresh publish into news pushes with announce=true', async () => { + await newsGump.syncPost(newsPost(), { wasPublished: false, wasNews: false }) + assert.equal(calls.post.length, 1) + assert.equal(calls.post[0].announce, true) + assert.equal(calls.del.length, 0) +}) + +test('an edit of already-published news refreshes silently (announce=false)', async () => { + await newsGump.syncPost(newsPost({ title: 'Edited' }), { wasPublished: true, wasNews: true }) + assert.equal(calls.post.length, 1) + assert.equal(calls.post[0].announce, false) +}) + +test('unpublishing published news pulls the article from the gump', async () => { + await newsGump.syncPost(newsPost({ published: false }), { wasPublished: true, wasNews: true }) + assert.equal(calls.post.length, 0) + assert.deepEqual(calls.del, ['42']) +}) + +test('a draft never-published news post does nothing', async () => { + await newsGump.syncPost(newsPost({ published: false }), { wasPublished: false, wasNews: false }) + assert.equal(calls.post.length, 0) + assert.equal(calls.del.length, 0) +}) + +test('a non-news post (e.g. screenshot) is never pushed', async () => { + await newsGump.syncPost(newsPost({ category: 'screenshot' }), { wasPublished: false, wasNews: false }) + assert.equal(calls.post.length, 0) +}) -- 2.49.1 From 91c206bf76b89d24206fbfcc8e38890d9a8a9d67 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:50:49 -0500 Subject: [PATCH 5/9] feat(provisioning): game-account signup, admin email invites, unlink (2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/db/schema.sql | 28 ++ server/src/model/invites/invites.db.js | 47 ++ server/src/model/invites/invites.model.js | 73 +++ server/src/model/settings/settings.model.js | 9 + server/src/model/shardLinks/shardLinks.db.js | 8 +- .../src/model/shardLinks/shardLinks.model.js | 5 +- server/src/router/v1/admin/admin.routes.js | 56 +++ .../src/router/v1/admin/invites.controller.js | 84 ++++ .../router/v1/admin/usersShard.controller.js | 40 +- server/src/router/v1/auth/auth.controller.js | 2 +- server/src/router/v1/auth/auth.routes.js | 35 +- .../src/router/v1/auth/invite.controller.js | 82 ++++ server/src/router/v1/player/player.routes.js | 19 + .../src/router/v1/player/shard.controller.js | 57 ++- server/src/utils/mailer.js | 34 +- server/src/utils/shardIngest.js | 12 +- server/src/utils/uoLinkClient.js | 16 + server/swagger/swagger-output.json | 447 ++++++++++++++++++ server/test/invites.test.js | 78 +++ server/test/shardIngest.protocol2.test.js | 26 + 20 files changed, 1150 insertions(+), 8 deletions(-) create mode 100644 server/src/model/invites/invites.db.js create mode 100644 server/src/model/invites/invites.model.js create mode 100644 server/src/router/v1/admin/invites.controller.js create mode 100644 server/src/router/v1/auth/invite.controller.js create mode 100644 server/test/invites.test.js diff --git a/server/db/schema.sql b/server/db/schema.sql index 920f741..a33942c 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -512,6 +512,30 @@ CREATE TABLE IF NOT EXISTS shard_presence ( CONSTRAINT chk_shard_presence_singleton CHECK (id = 1) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Admin email invites (Protocol 2.0 provisioning). A staff member invites someone +-- by email at a pre-chosen access level; the invitee accepts via a tokened link, +-- which creates their website user at that role (and optionally a linked game +-- account). Only the sha256 hash of the opaque token is stored — a DB read never +-- yields a usable invite link, same as mobile_refresh_tokens. status tracks the +-- lifecycle; accepted_user_id back-points at the created user. Single-use + +-- expiring (enforced in the model on top of expires_at). +CREATE TABLE IF NOT EXISTS user_invites ( + id INT AUTO_INCREMENT PRIMARY KEY, + token_hash CHAR(64) NOT NULL UNIQUE, -- sha256 hex of the opaque token + email VARCHAR(255) NOT NULL, + role ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'player', + status ENUM('pending','accepted','revoked') NOT NULL DEFAULT 'pending', + invited_by INT NULL, -- staff user who sent it + accepted_user_id INT NULL, -- the user created on accept + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + accepted_at DATETIME NULL, + CONSTRAINT fk_user_invites_inviter FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL, + CONSTRAINT fk_user_invites_user FOREIGN KEY (accepted_user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_user_invites_email (email), + INDEX idx_user_invites_status (status, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Discord bot moderation core (Phase 2). These tables are owned by the bot -- process (its own DB pool, bot/src/db.js) — the main server never reads or -- writes them. They live in the same physical database as everything else @@ -836,6 +860,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) NULL; -- Player self-registration mode: disabled | password | sso | both. Default off, -- so the system behaves exactly as today until an admin opts in. INSERT IGNORE INTO settings (`key`, value) VALUES ('player_registration', 'disabled'); +-- Game-account signup (Protocol 2.0 hybrid mode): whether a signed-in website user +-- may provision a linked game account from the site. Default off; the shard's own +-- signup mode still has the final say (a 'game'-mode shard refuses regardless). +INSERT IGNORE INTO settings (`key`, value) VALUES ('game_account_signup', 'disabled'); ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL; ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL; diff --git a/server/src/model/invites/invites.db.js b/server/src/model/invites/invites.db.js new file mode 100644 index 0000000..10a0313 --- /dev/null +++ b/server/src/model/invites/invites.db.js @@ -0,0 +1,47 @@ +const { query } = require('../../utils/db') + +const COLS = + 'id, token_hash, email, role, status, invited_by, accepted_user_id, expires_at, created_at, accepted_at' + +async function insert({ tokenHash, email, role, invitedBy, expiresAt }) { + const res = await query( + `INSERT INTO user_invites (token_hash, email, role, invited_by, expires_at) + VALUES (?, ?, ?, ?, ?)`, + [tokenHash, email, role, invitedBy ?? null, expiresAt], + ) + return res.insertId +} + +async function getById(id) { + const rows = await query(`SELECT ${COLS} FROM user_invites WHERE id = ? LIMIT 1`, [id]) + return rows[0] || null +} + +async function findByTokenHash(tokenHash) { + const rows = await query(`SELECT ${COLS} FROM user_invites WHERE token_hash = ? LIMIT 1`, [tokenHash]) + return rows[0] || null +} + +const listRecent = (limit) => + query(`SELECT ${COLS} FROM user_invites ORDER BY created_at DESC LIMIT ?`, [limit]) + +// Mark accepted only if still pending (atomic guard against a double-accept race). +// Returns rows changed (1 = we won, 0 = already used/revoked). +async function markAccepted(id, userId) { + const res = await query( + `UPDATE user_invites SET status = 'accepted', accepted_user_id = ?, accepted_at = NOW() + WHERE id = ? AND status = 'pending'`, + [userId, id], + ) + return res.affectedRows || 0 +} + +async function revoke(id) { + const res = await query( + `UPDATE user_invites SET status = 'revoked' WHERE id = ? AND status = 'pending'`, + [id], + ) + return res.affectedRows || 0 +} + +module.exports = { insert, getById, findByTokenHash, listRecent, markAccepted, revoke } diff --git a/server/src/model/invites/invites.model.js b/server/src/model/invites/invites.model.js new file mode 100644 index 0000000..c5652c9 --- /dev/null +++ b/server/src/model/invites/invites.model.js @@ -0,0 +1,73 @@ +// Admin email invites. A staff member invites someone by email at a pre-chosen +// access level; the invitee accepts via a tokened link that creates their website +// user at that role. The opaque token lives only in the emailed link — the DB +// stores just its sha256 hash (like mobile refresh tokens), so a DB read never +// yields a usable invite. Invites are single-use and expiring. + +const crypto = require('crypto') +const db = require('./invites.db') + +const DEFAULT_TTL_DAYS = 7 + +function hashToken(raw) { + return crypto.createHash('sha256').update(String(raw)).digest('hex') +} + +// Public-safe shape (never exposes the token hash). +function toSafe(row) { + if (!row) return null + return { + id: row.id, + email: row.email, + role: row.role, + status: row.status, + invitedBy: row.invited_by, + acceptedUserId: row.accepted_user_id, + expiresAt: row.expires_at, + createdAt: row.created_at, + acceptedAt: row.accepted_at, + expired: new Date(row.expires_at).getTime() < Date.now(), + } +} + +// Create an invite. Returns { invite, token } — the plaintext token is returned +// ONCE (for the email link) and never stored or recoverable afterwards. +async function create({ email, role, invitedBy, ttlDays = DEFAULT_TTL_DAYS }) { + const token = crypto.randomBytes(32).toString('base64url') + const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000) + const id = await db.insert({ tokenHash: hashToken(token), email, role, invitedBy, expiresAt }) + return { invite: toSafe(await db.getById(id)), token } +} + +// Resolve a pending, unexpired invite from its plaintext token, else null. Returns +// the RAW row (incl. id) for the accept flow; callers sanitize with publicView. +async function findValidByToken(token) { + if (!token) return null + const row = await db.findByTokenHash(hashToken(token)) + if (!row || row.status !== 'pending') return null + if (new Date(row.expires_at).getTime() < Date.now()) return null + return row +} + +// Atomically consume a pending invite (double-accept-safe). Returns true if this +// call won the race and bound the invite to userId. +async function accept(id, userId) { + return (await db.markAccepted(id, userId)) === 1 +} + +const revoke = (id) => db.revoke(id) + +async function list(limit = 100) { + const n = Math.min(Math.max(Number(limit) || 100, 1), 500) + const rows = await db.listRecent(n) + return rows.map(toSafe) +} + +// A minimal, safe view of an invite for the (unauthenticated) accept page — +// only what the form needs, never the token or internal ids. +function publicView(row) { + if (!row) return null + return { email: row.email, role: row.role } +} + +module.exports = { create, findValidByToken, accept, revoke, list, publicView, toSafe, hashToken } diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 3ab7b69..19e0d9f 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -32,6 +32,13 @@ function registrationFlags(mode) { } } +// Game-account signup (Protocol 2.0 hybrid mode). Off unless an admin opts in; +// the shard's own signup mode still has the final say when we call the sidecar. +const GAME_SIGNUP_KEY = 'game_account_signup' +async function isGameAccountSignupEnabled() { + return (await settingsDb.get(GAME_SIGNUP_KEY)) === 'enabled' +} + async function get(key) { return settingsDb.get(key) } @@ -78,4 +85,6 @@ module.exports = { REGISTRATION_MODES, getRegistrationMode, registrationFlags, + GAME_SIGNUP_KEY, + isGameAccountSignupEnabled, } diff --git a/server/src/model/shardLinks/shardLinks.db.js b/server/src/model/shardLinks/shardLinks.db.js index 915b53d..76b0418 100644 --- a/server/src/model/shardLinks/shardLinks.db.js +++ b/server/src/model/shardLinks/shardLinks.db.js @@ -33,4 +33,10 @@ async function isOwnedBy(account, userId) { const remove = (account, userId) => query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId]) -module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove } +// Drop the mirror for an account regardless of which user held it — used to +// reconcile when the tie is severed at the source (an in-game [unlink → +// account.unlinked event, or a site-side DELETE /link/{account}). +const removeByAccount = (account) => + query('DELETE FROM shard_account_links WHERE account = ?', [account]) + +module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount } diff --git a/server/src/model/shardLinks/shardLinks.model.js b/server/src/model/shardLinks/shardLinks.model.js index 9e1c7fd..3d0f907 100644 --- a/server/src/model/shardLinks/shardLinks.model.js +++ b/server/src/model/shardLinks/shardLinks.model.js @@ -31,4 +31,7 @@ async function getByAccount(account) { const unlink = (account, userId) => db.remove(account, userId) -module.exports = { link, listForUser, ownsAccount, getByAccount, unlink } +// Drop the local mirror for an account (source-of-truth severed elsewhere). +const removeByAccount = (account) => db.removeByAccount(account) + +module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index ed7878f..c8a967d 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -14,6 +14,7 @@ const emailConfig = require('./emailConfig.controller') const uoLink = require('./uoLink.controller') const shardOps = require('./shardOps.controller') const usersShard = require('./usersShard.controller') +const invites = require('./invites.controller') const selfShard = require('../player/shard.controller') const moderation = require('./moderation.controller') const pagesCtrl = require('./pages.controller') @@ -1267,6 +1268,61 @@ adminRouter.get( validate, usersShard.getStanding, ) +adminRouter.delete( + '/users/:id/shard/link/:account', + // #swagger.tags = ['Admin · Users'] + // #swagger.summary = 'Unlink a game account from this user (admin only)' + // #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' } + // #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' } + /* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */ + /* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').isInt(), + param('account').matches(SHARD_ACCOUNT_RE), + validate, + usersShard.unlinkAccount, +) + +// ── Email invites (admin only) ───────────────────────────────────────────── +adminRouter.post( + '/invites', + // #swagger.tags = ['Admin · Invites'] + // #swagger.summary = 'Create and email an account invite at a chosen access level' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */ + /* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + adminOnly, + body('email').isEmail().isLength({ max: 255 }), + body('role').isIn(['admin', 'editor', 'moderator', 'player']), + validate, + invites.create, +) +adminRouter.get( + '/invites', + // #swagger.tags = ['Admin · Invites'] + // #swagger.summary = 'List recent invites (no tokens)' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */ + adminOnly, + invites.list, +) +adminRouter.delete( + '/invites/:id', + // #swagger.tags = ['Admin · Invites'] + // #swagger.summary = 'Revoke a pending invite' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + // #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' } + /* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */ + /* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + param('id').isInt(), + validate, + invites.revoke, +) // ── uo-link sidecar control (admin only) ────────────────────────────────── // Connection config (base/ws URL + token + protocol + enabled) and the town diff --git a/server/src/router/v1/admin/invites.controller.js b/server/src/router/v1/admin/invites.controller.js new file mode 100644 index 0000000..e2f67e4 --- /dev/null +++ b/server/src/router/v1/admin/invites.controller.js @@ -0,0 +1,84 @@ +// ── Admin: email invites ─────────────────────────────────────────────────── +// +// Admin-only. A staff member invites someone by email at a pre-chosen access +// level; the invitee accepts via a tokened link (auth/invite.controller) which +// creates their website user at that role. The plaintext token exists only in the +// emailed link and in the create response (so the admin can copy the link if email +// isn't configured); the DB stores only its hash. + +const invites = require('../../../model/invites/invites.model') +const activity = require('../../../model/activity/activity.model') +const mailer = require('../../../utils/mailer') + +const log = require('../../../utils/logger')('admin-invites') + +const ROLES = ['admin', 'editor', 'moderator', 'player'] + +function baseUrl() { + return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') +} + +function acceptUrl(token) { + return `${baseUrl()}/invite/${token}` +} + +// POST /admin/invites — create an invite and email it. +async function create(req, res) { + const email = String(req.body.email || '').trim() + const role = req.body.role + if (!email || !ROLES.includes(role)) { + return res.status(400).json({ message: 'A valid email and role are required.' }) + } + try { + const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id }) + const url = acceptUrl(token) + + // Send the email; if mail isn't configured, hand the link back so the admin + // can share it manually. A send failure doesn't delete the invite — surface it. + let emailed = false + let emailError = null + try { + const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username }) + emailed = Boolean(result.sent) + } catch (err) { + emailError = err.message + log.warn('invite email failed (invite still created)', { id: invite.id, message: err.message }) + } + + await activity.log({ req, userId: req.user.id, action: 'invite.create', detail: { email, role, emailed } }) + log.info('invite created', { id: invite.id, email, role, emailed, by: req.user.username }) + + // The accept link is returned only when email did not deliver, so the admin + // can copy it. When emailed, we don't echo the token. + return res.status(201).json({ invite, emailed, acceptUrl: emailed ? undefined : url, emailError }) + } catch (err) { + log.error('create invite', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /admin/invites — recent invites (no tokens). +async function list(req, res) { + try { + return res.json(await invites.list(req.query.limit)) + } catch (err) { + log.error('list invites', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// DELETE /admin/invites/:id — revoke a pending invite. +async function revoke(req, res) { + const id = Number(req.params.id) + try { + const changed = await invites.revoke(id) + if (!changed) return res.status(404).json({ message: 'No pending invite to revoke.' }) + await activity.log({ req, userId: req.user.id, action: 'invite.revoke', detail: { id } }) + return res.json({ id, revoked: true }) + } catch (err) { + log.error('revoke invite', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { create, list, revoke } diff --git a/server/src/router/v1/admin/usersShard.controller.js b/server/src/router/v1/admin/usersShard.controller.js index fd3c5a1..c620b3e 100644 --- a/server/src/router/v1/admin/usersShard.controller.js +++ b/server/src/router/v1/admin/usersShard.controller.js @@ -10,6 +10,8 @@ const users = require('../../../model/users/users.model') const shardLinks = require('../../../model/shardLinks/shardLinks.model') const shardState = require('../../../model/shardState/shardState.model') +const uoLinkClient = require('../../../utils/uoLinkClient') +const activity = require('../../../model/activity/activity.model') const { salesForAccounts } = require('../../../utils/shardSales') const log = require('../../../utils/logger')('admin-user-shard') @@ -101,4 +103,40 @@ async function getStanding(req, res) { } } -module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding } +// DELETE /admin/users/:id/shard/link/:account — unlink a game account from this +// user, site-side. `actor` is stamped from the session (never the browser). On +// success the sidecar clears the WebsiteUserId tag on the shard and we drop the +// local mirror so attribution stops immediately. +async function unlinkAccount(req, res) { + const { account } = req.params + try { + const ctx = await accountsForUser(Number(req.params.id)) + if (!ctx) return res.status(404).json({ message: 'Not found' }) + // Only unlink an account actually linked to THIS user (avoid cross-user unlink). + if (!ctx.accounts.includes(account)) { + return res.status(404).json({ message: 'That account is not linked to this user.' }) + } + const result = await uoLinkClient.unlinkAccount({ actor: req.user.username, account }) + if (result.ok) { + await shardLinks.removeByAccount(account) + await activity.log({ req, userId: ctx.user.id, action: 'shard.account.unlink', detail: { account } }) + log.info('game account unlinked', { account, userId: ctx.user.id, actor: req.user.username }) + return res.json({ account, unlinked: true }) + } + if (result.status === 403) return res.status(403).json({ message: 'That account is protected and cannot be unlinked.' }) + if (result.status === 404) { + // Not linked on the shard — reconcile our mirror anyway so the two agree. + await shardLinks.removeByAccount(account) + return res.status(404).json({ message: 'That account is not linked.' }) + } + if (result.status === 503 || result.status === 0) { + return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' }) + } + return res.status(502).json({ message: 'Could not reach the shard to unlink the account.' }) + } catch (err) { + log.error('unlinkAccount', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding, unlinkAccount } diff --git a/server/src/router/v1/auth/auth.controller.js b/server/src/router/v1/auth/auth.controller.js index a301874..c91d780 100644 --- a/server/src/router/v1/auth/auth.controller.js +++ b/server/src/router/v1/auth/auth.controller.js @@ -188,4 +188,4 @@ async function me(req, res) { } } -module.exports = { login, register, loginTotp, logout, me, needsTotp, HONEYPOT_FIELD } +module.exports = { login, register, loginTotp, logout, me, needsTotp, issueSession, HONEYPOT_FIELD } diff --git a/server/src/router/v1/auth/auth.routes.js b/server/src/router/v1/auth/auth.routes.js index d30f004..031b9e0 100644 --- a/server/src/router/v1/auth/auth.routes.js +++ b/server/src/router/v1/auth/auth.routes.js @@ -1,7 +1,8 @@ const express = require('express') -const { body } = require('express-validator') +const { body, param } = require('express-validator') const { login, register, loginTotp, logout, me, HONEYPOT_FIELD } = require('./auth.controller') +const { getInvite, acceptInvite } = require('./invite.controller') const { isLoggedIn } = require('../../../utils/auth') const { attachSession } = require('../../../auth/session.middleware') const { loginLimiter, registerLimiter } = require('../../../middleware/rateLimit') @@ -87,6 +88,38 @@ authRouter.post( loginTotp, ) +// ── Email-invite acceptance (public, token-gated) ────────────────────────── +authRouter.get( + '/invite/:token', + // #swagger.tags = ['Auth'] + // #swagger.summary = 'Look up an email invite by token' + // #swagger.description = 'Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.' + /* #swagger.responses[200] = { description: 'Invite details', content: { "application/json": { schema: { type: "object", properties: { email: { type: "string" }, role: { type: "string" } } } } } } */ + /* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('token').isString().isLength({ min: 8, max: 128 }), + validate, + getInvite, +) +authRouter.post( + '/invite/:token/accept', + // #swagger.tags = ['Auth'] + // #swagger.summary = 'Accept an email invite (creates the account at the invited role)' + // #swagger.description = 'Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.' + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["username","password"], properties: { username: { type: "string" }, password: { type: "string" } } } } } */ + /* #swagger.responses[200] = { description: 'Account created and session issued', content: { "application/json": { schema: { $ref: "#/components/schemas/LoginResponse" } } } } */ + /* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[404] = { description: 'Invalid or expired invite', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Username taken or invite already used', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + ...loginGuards, + registerLimiter, + param('token').isString().isLength({ min: 8, max: 128 }), + body('username').isString().trim().isLength({ min: 3, max: 32 }), + body('password').isString().isLength({ min: 8, max: 64 }), + body(HONEYPOT_FIELD).optional(), + validate, + acceptInvite, +) + authRouter.post( '/logout', // #swagger.tags = ['Auth'] diff --git a/server/src/router/v1/auth/invite.controller.js b/server/src/router/v1/auth/invite.controller.js new file mode 100644 index 0000000..33c0f6e --- /dev/null +++ b/server/src/router/v1/auth/invite.controller.js @@ -0,0 +1,82 @@ +// ── Invite acceptance (public, token-gated) ──────────────────────────────── +// +// The other end of the admin email-invite flow (admin/invites.controller). An +// invitee opens the tokened link, sees their pre-assigned email + role, and sets +// a username + password. Accepting creates their website user AT THE PRESET ROLE +// (bypassing the player_registration gate — the invite is its own authority) and +// logs them straight in. The optional "create game account" step afterwards reuses +// POST /player/shard/account (players only), so it isn't handled here. + +const invites = require('../../../model/invites/invites.model') +const users = require('../../../model/users/users.model') +const activity = require('../../../model/activity/activity.model') +const usernamePolicy = require('../../../auth/usernamePolicy') +const { issueSession, HONEYPOT_FIELD } = require('./auth.controller') + +const log = require('../../../utils/logger')('auth-invite') + +// GET /auth/invite/:token — validate an invite and return what the accept form +// needs (email + role). 404 for anything not currently acceptable so we never +// distinguish "expired" from "revoked" from "never existed". +async function getInvite(req, res) { + try { + const row = await invites.findValidByToken(req.params.token) + if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' }) + return res.json(invites.publicView(row)) + } catch (err) { + log.error('getInvite', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /auth/invite/:token/accept — create the user at the invite's role and log +// them in. Honeypot + validation mirror register; the invite replaces the +// registration-mode gate. +async function acceptInvite(req, res) { + // Honeypot: a filled hidden field means a bot. + if (req.body[HONEYPOT_FIELD]) { + log.warn('honeypot invite-accept hit', { ip: req.ip }) + return res.status(400).json({ message: 'Registration failed.' }) + } + try { + const row = await invites.findValidByToken(req.params.token) + if (!row) return res.status(404).json({ message: 'This invitation is invalid or has expired.' }) + + const check = usernamePolicy.validateUsername(req.body.username) + if (!check.ok) return res.status(400).json({ message: check.message }) + + let user + try { + user = await users.createUser({ + username: check.name, + password: req.body.password, + email: row.email, + role: row.role, + emailVerified: true, // they proved control of the address by using the link + }) + } catch (err) { + if (users.isDuplicateUsername(err)) { + return res.status(409).json({ message: 'That username is already taken.' }) + } + throw err + } + + // Consume the invite atomically. If we lost a double-accept race, roll back the + // user we just created so a spent invite never yields two accounts. + const won = await invites.accept(row.id, user.id) + if (!won) { + await users.remove(user.id).catch(() => {}) + return res.status(409).json({ message: 'This invitation has already been used.' }) + } + + await activity.log({ req, userId: user.id, action: 'invite.accept', detail: { inviteId: row.id, role: row.role } }) + log.info('invite accepted', { inviteId: row.id, userId: user.id, role: row.role, ip: req.ip }) + // New accounts never have TOTP yet — log straight in. + return issueSession(req, res, user, 'local') + } catch (err) { + log.error('acceptInvite', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getInvite, acceptInvite } diff --git a/server/src/router/v1/player/player.routes.js b/server/src/router/v1/player/player.routes.js index 80da8b7..6999c12 100644 --- a/server/src/router/v1/player/player.routes.js +++ b/server/src/router/v1/player/player.routes.js @@ -148,6 +148,25 @@ playerRouter.post( validate, shard.link, ) +playerRouter.post( + '/shard/account', + // #swagger.tags = ['Player · Shard'] + // #swagger.summary = 'Create a game account (hybrid signup) and link it to the caller' + // #swagger.description = 'Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */ + /* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, linked: { type: "boolean" } } } } } } */ + /* #swagger.responses[400] = { description: 'Validation error or rejected name/password', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ + /* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[429] = { description: 'Per-IP account cap reached', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[503] = { description: 'Shard unavailable — retry', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + accountChangeLimiter, + body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/), + body('password').isString().isLength({ min: 8, max: 64 }), + validate, + shard.createGameAccount, +) playerRouter.get( '/shard/accounts', // #swagger.tags = ['Player · Shard'] diff --git a/server/src/router/v1/player/shard.controller.js b/server/src/router/v1/player/shard.controller.js index 76927d7..e609524 100644 --- a/server/src/router/v1/player/shard.controller.js +++ b/server/src/router/v1/player/shard.controller.js @@ -10,6 +10,7 @@ const uoLinkClient = require('../../../utils/uoLinkClient') const shardLinks = require('../../../model/shardLinks/shardLinks.model') const shardState = require('../../../model/shardState/shardState.model') +const settings = require('../../../model/settings/settings.model') const { salesForAccounts } = require('../../../utils/shardSales') const activity = require('../../../model/activity/activity.model') @@ -147,4 +148,58 @@ async function getSales(req, res) { } } -module.exports = { link, listAccounts, roster, vendors, getChar, getSales } +// Map a failed uoLinkClient.createAccount result to a user-facing HTTP response. +// The password is never echoed anywhere; only the mapped reason is returned. +function mapCreateAccountError(res, result) { + const reason = (result.data && result.data.reason) || '' + switch (result.status) { + case 409: + return res.status(409).json({ message: 'That account name is already taken.' }) + case 429: + return res.status(429).json({ message: 'The account limit for your network has been reached.' }) + case 403: + return res.status(403).json({ message: 'Game-account signups are not available on this shard right now.' }) + case 400: + return res.status(400).json({ message: reason || 'The account name or password was not accepted.' }) + case 503: + case 0: + return res.status(503).json({ message: 'The game server is unavailable — try again shortly.' }) + default: + return res.status(502).json({ message: 'Could not reach the shard to create the account.' }) + } +} + +// POST /player/shard/account — provision a GAME account for the signed-in website +// user and auto-link it (Protocol 2.0 hybrid). Used by self-serve signup and the +// invite-accept "create game account" step alike (both act as the signed-in user). +// actor + websiteUserId are stamped from the session; the browser IP (req.ip, +// trust-proxy configured) is forwarded for the shard's per-IP cap; the password is +// never logged. Gated by the game_account_signup setting AND the shard's own mode. +async function createGameAccount(req, res) { + const { account, password } = req.body + try { + if (!(await settings.isGameAccountSignupEnabled())) { + return res.status(403).json({ message: 'Game-account signup is not available right now.' }) + } + const result = await uoLinkClient.createAccount({ + actor: req.user.username, + account, + password, + websiteUserId: req.user.id, + ip: req.ip, + }) + if (result.ok) { + // Mirror the link locally so the portal lists the account immediately. + await shardLinks.link({ account, userId: req.user.id }) + await activity.log({ req, userId: req.user.id, action: 'shard.account.create', detail: { account } }) + log.info('game account created', { account, userId: req.user.id, ip: req.ip }) + return res.status(201).json({ account, linked: true }) + } + return mapCreateAccountError(res, result) + } catch (err) { + log.error('player.shard.createGameAccount', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { link, listAccounts, roster, vendors, getChar, getSales, createGameAccount } diff --git a/server/src/utils/mailer.js b/server/src/utils/mailer.js index 31e1dfe..1ff7260 100644 --- a/server/src/utils/mailer.js +++ b/server/src/utils/mailer.js @@ -122,4 +122,36 @@ async function sendTest(to) { } } -module.exports = { isConfigured, sendContactMessage, sendTest } +/** + * Send an account invite. `to` is the invitee's email, `acceptUrl` the tokened + * accept link, `role` their assigned access level, `invitedByName` optional. If + * email is not configured, returns { sent: false, reason: 'NOT_CONFIGURED' } so + * the caller can surface the accept link for the admin to share manually rather + * than throwing. Throws only on an actual send failure. + */ +async function sendInvite({ to, acceptUrl, role, invitedByName }) { + const built = await buildTransport() + if (!built) return { sent: false, reason: 'NOT_CONFIGURED' } + const { transport, config } = built + const roleLabel = role && role !== 'player' ? ` as ${role}` : '' + const by = invitedByName ? ` by ${invitedByName}` : '' + try { + await transport.sendMail({ + from: fromHeader(config), + to, + subject: 'Your UOMysticmoon invitation', + text: + `You have been invited${by} to join UOMysticmoon${roleLabel}.\n\n` + + `Accept your invitation and set up your account here:\n${acceptUrl}\n\n` + + `This link is single-use and will expire. If you weren't expecting this, you can ignore it.`, + }) + await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Invite send OK', lastVerifiedAt: new Date() }) + return { sent: true } + } catch (err) { + log.error('invite send failed', err) + await emailConfig.recordStatus({ status: 'error', statusDetail: err.message }) + throw err + } +} + +module.exports = { isConfigured, sendContactMessage, sendTest, sendInvite } diff --git a/server/src/utils/shardIngest.js b/server/src/utils/shardIngest.js index c537467..f27b6a1 100644 --- a/server/src/utils/shardIngest.js +++ b/server/src/utils/shardIngest.js @@ -14,6 +14,7 @@ const shardEventsModel = require('../model/shardEvents/shardEvents.model') const shardStateModel = require('../model/shardState/shardState.model') +const shardLinksModel = require('../model/shardLinks/shardLinks.model') const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model') const broadcaster = require('./shardBroadcast') const defaultLog = require('./logger')('shard-ingest') @@ -41,6 +42,9 @@ const LOGGED_KINDS = new Set([ 'server.crashed', // Protocol 2.0: a real-time guild join (the board itself is state, not logged). 'guild.join', + // Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS). + 'account.audit', + 'account.unlinked', ]) // Tracks the current shard boot id so a restart (changed bootId on server.hello) @@ -168,7 +172,12 @@ async function applyStateChange(event, deps) { case 'house.remove': await shardState.removeHouse(event.serial) return - // guild.join → logged (real-time feed); region.enter → broadcast-only. + case 'account.unlinked': + // A player ran [unlink in game (or a site-side unlink echoed back) — drop + // our local link mirror so attribution stops immediately. + if (event.account) await deps.shardLinks.removeByAccount(event.account) + return + // guild.join / account.audit → logged; region.enter → broadcast-only. default: // No state side effect (e.g. vendor.sale, audit.*, cheat.*) — logging and // broadcasting still happen in ingest(). @@ -182,6 +191,7 @@ async function ingest(event, deps = {}) { const d = { shardEvents: deps.shardEvents || shardEventsModel, shardState: deps.shardState || shardStateModel, + shardLinks: deps.shardLinks || shardLinksModel, uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel, broadcast: deps.broadcast || broadcaster.broadcast, log: deps.log || defaultLog, diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js index ff3ef66..318dbee 100644 --- a/server/src/utils/uoLinkClient.js +++ b/server/src/utils/uoLinkClient.js @@ -114,6 +114,20 @@ const getPresence = () => call('/online') // aggregate population (count + byFac const confirmLink = (code, websiteUserId) => call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } }) const linkLookup = (account) => call(`/link/${encodeURIComponent(account)}`) + +// Account provisioning (Protocol 2.0). createAccount provisions a game account and +// auto-links it to the website user in one step; `ip` is the END USER's browser IP +// (read from the request), which the shard needs for its per-IP account cap — the +// sidecar only sees our server. The password is hashed on the shard and never +// appears in any reply/event/log. unlinkAccount severs a game account's tie from +// the site side. `actor` is the staff/website id, recorded in the shard audit. +const createAccount = ({ actor, account, password, websiteUserId, ip }) => + call('/accounts/create', { + method: 'POST', + body: { actor, account, password, websiteUserId: websiteUserId == null ? undefined : String(websiteUserId), ip }, + }) +const unlinkAccount = ({ actor, account }) => + call(`/link/${encodeURIComponent(account)}`, { method: 'DELETE', body: { actor } }) const postTownCrier = ({ id, lines, durationSec }) => call('/towncrier', { method: 'POST', body: { id, lines, durationSec } }) const deleteTownCrier = (id) => call(`/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }) @@ -160,6 +174,8 @@ module.exports = { getPresence, confirmLink, linkLookup, + createAccount, + unlinkAccount, postTownCrier, deleteTownCrier, postNews, diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index fa3fbee..2c74635 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -326,6 +326,126 @@ } } }, + "/api/v1/auth/invite/{token}": { + "get": { + "tags": [ + "Auth" + ], + "summary": "Look up an email invite by token", + "description": "Returns the pre-assigned email + role for a valid, pending, unexpired invite so the accept form can render. 404 for anything not currently acceptable.", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Invite details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "role": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Invalid or expired invite", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, + "/api/v1/auth/invite/{token}/accept": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Accept an email invite (creates the account at the invited role)", + "description": "Creates the website user at the invite’s pre-assigned role and logs them in (sets the session cookie). Bypasses the player_registration gate — the invite is its own authority. Rate limited + honeypot-guarded like registration.", + "parameters": [ + { + "name": "token", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Account created and session issued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "404": { + "description": "Invalid or expired invite", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Username taken or invite already used", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "requestBody": {} + } + }, "/api/v1/auth/logout": { "post": { "tags": [ @@ -7148,6 +7268,239 @@ ] } }, + "/api/v1/admin/users/{id}/shard/link/{account}": { + "delete": { + "tags": [ + "Admin · Users" + ], + "summary": "Unlink a game account from this user (admin only)", + "description": "Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "User id." + }, + { + "name": "account", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Game account to unlink." + } + ], + "responses": { + "200": { + "description": "Unlinked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "unlinked": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Protected staff account (refused by shard)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Not linked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "502": { + "description": "Bad Gateway" + }, + "503": { + "description": "Service Unavailable" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/invites": { + "post": { + "tags": [ + "Admin · Invites" + ], + "summary": "Create and email an account invite at a chosen access level", + "description": "", + "responses": { + "201": { + "description": "Invite created", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": {} + }, + "get": { + "tags": [ + "Admin · Invites" + ], + "summary": "List recent invites (no tokens)", + "description": "", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Invites, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/invites/{id}": { + "delete": { + "tags": [ + "Admin · Invites" + ], + "summary": "Revoke a pending invite", + "description": "", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Invite id." + } + ], + "responses": { + "200": { + "description": "Revoked", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "No pending invite to revoke", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/uo-link/config": { "get": { "tags": [ @@ -7977,6 +8330,100 @@ } } }, + "/api/v1/player/shard/account": { + "post": { + "tags": [ + "Player · Shard" + ], + "summary": "Create a game account (hybrid signup) and link it to the caller", + "description": "Provisions a new game account with its own username + password and auto-links it to the signed-in website user. Available only when game_account_signup is enabled and the shard accepts website signups. The password is hashed on the shard and never stored or logged by the site.", + "responses": { + "201": { + "description": "Account created and linked", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "linked": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "description": "Validation error or rejected name/password", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationError" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Game-account signup unavailable (site or shard)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Account name already taken", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Per-IP account cap reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Shard unavailable — retry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": {} + } + }, "/api/v1/player/shard/accounts": { "get": { "tags": [ diff --git a/server/test/invites.test.js b/server/test/invites.test.js new file mode 100644 index 0000000..3c9108d --- /dev/null +++ b/server/test/invites.test.js @@ -0,0 +1,78 @@ +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// Exercise invite create/lookup/single-use accept against an in-memory fake by +// monkeypatching the shared db module the model require()s. No DB. +const db = require('../src/model/invites/invites.db') +const invites = require('../src/model/invites/invites.model') + +let rows +let nextId +const saved = {} + +beforeEach(() => { + rows = [] + nextId = 1 + for (const k of ['insert', 'getById', 'findByTokenHash', 'markAccepted', 'revoke']) saved[k] = db[k] + db.insert = async ({ tokenHash, email, role, invitedBy, expiresAt }) => { + const id = nextId++ + rows.push({ id, token_hash: tokenHash, email, role, status: 'pending', invited_by: invitedBy ?? null, accepted_user_id: null, expires_at: expiresAt, created_at: new Date(), accepted_at: null }) + return id + } + db.getById = async (id) => rows.find((r) => r.id === id) || null + db.findByTokenHash = async (h) => rows.find((r) => r.token_hash === h) || null + db.markAccepted = async (id, userId) => { + const row = rows.find((r) => r.id === id && r.status === 'pending') + if (!row) return 0 + row.status = 'accepted' + row.accepted_user_id = userId + return 1 + } + db.revoke = async (id) => { + const row = rows.find((r) => r.id === id && r.status === 'pending') + if (!row) return 0 + row.status = 'revoked' + return 1 + } +}) + +afterEach(() => { + for (const k of Object.keys(saved)) db[k] = saved[k] +}) + +test('create stores only the token hash, never the plaintext token', async () => { + const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 }) + assert.ok(token && token.length >= 20) + assert.equal(rows[0].token_hash, invites.hashToken(token)) + assert.notEqual(rows[0].token_hash, token) // hash, not the raw token + assert.equal(invite.email, 'a@b.com') + assert.equal(invite.role, 'player') + assert.equal(invite.status, 'pending') +}) + +test('findValidByToken resolves a pending token and rejects a wrong/used one', async () => { + const { token } = await invites.create({ email: 'a@b.com', role: 'moderator', invitedBy: 1 }) + assert.ok(await invites.findValidByToken(token)) + assert.equal(await invites.findValidByToken('not-a-real-token'), null) +}) + +test('accept is single-use — the second accept loses the race', async () => { + const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 }) + const row = await invites.findValidByToken(token) + assert.equal(await invites.accept(row.id, 55), true) + assert.equal(await invites.accept(row.id, 66), false) // already consumed + assert.equal(await invites.findValidByToken(token), null) // no longer pending +}) + +test('an expired invite is not valid (exercises the expiry branch, not a bad token)', async () => { + const { token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1, ttlDays: -1 }) + // The token itself is correct and the row is pending — only expires_at rejects it. + assert.ok(rows[0] && rows[0].status === 'pending') + assert.equal(await invites.findValidByToken(token), null) +}) + +test('revoke makes a pending invite unusable', async () => { + const { invite, token } = await invites.create({ email: 'a@b.com', role: 'player', invitedBy: 1 }) + assert.equal(await invites.revoke(invite.id), 1) + assert.equal(await invites.findValidByToken(token), null) +}) diff --git a/server/test/shardIngest.protocol2.test.js b/server/test/shardIngest.protocol2.test.js index ae44452..27617a8 100644 --- a/server/test/shardIngest.protocol2.test.js +++ b/server/test/shardIngest.protocol2.test.js @@ -12,6 +12,7 @@ function makeDeps() { governorUpsert: [], presenceSet: [], houseRegistry: [], houseRemove: [], + linkRemove: [], appended: [], broadcast: [], } const noop = async () => {} @@ -29,6 +30,7 @@ function makeDeps() { clearOnline: noop, upsertOnline: noop, setOffline: noop, upsertHouse: noop, addEconomySample: noop, }, + shardLinks: { removeByAccount: async (account) => { calls.linkRemove.push(account) } }, uoLinkConfig: { recordStatus: noop }, broadcast: (ev) => { calls.broadcast.push(ev) }, log: { warn() {}, info() {}, error() {} }, @@ -91,3 +93,27 @@ test('region.enter is broadcast-only — not logged, no state side effect', asyn assert.equal(deps.calls.appended.length, 0) assert.equal(deps.calls.broadcast.length, 1) // still surfaced live }) + +test('account.unlinked reconciles the local link mirror and is logged', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'account.unlinked', origin: 'in-game', account: 'bob', websiteUserId: '9931', t: 9 }, deps) + assert.deepEqual(deps.calls.linkRemove, ['bob']) // mirror dropped + assert.equal(r.logged, true) // provisioning audit trail + assert.equal(deps.calls.appended[0].kind, 'account.unlinked') +}) + +test('account.audit is logged (provisioning history) but has no state side effect', async () => { + const deps = makeDeps() + const r = await shardIngest.ingest( + { kind: 'account.audit', origin: 'web', action: 'create', actor: 'web:jane', target: 'bob', t: 10 }, deps) + assert.equal(r.logged, true) + assert.equal(deps.calls.linkRemove.length, 0) + assert.equal(deps.calls.appended[0].kind, 'account.audit') +}) + +test('account.audit / account.unlinked are NOT on the public SSE allowlist', () => { + const broadcast = require('../src/utils/shardBroadcast') + assert.equal(broadcast.PUBLIC_KINDS.has('account.audit'), false) + assert.equal(broadcast.PUBLIC_KINDS.has('account.unlinked'), false) +}) -- 2.49.1 From 2976d5982fe4fd51690133d205291bd51f72f721 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:06:01 -0500 Subject: [PATCH 6/9] =?UTF-8?q?feat(provisioning):=20provisioning=20UI=20?= =?UTF-8?q?=E2=80=94=20signup,=20invites,=20accept=20page,=20unlink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.jsx | 4 + client/src/api/client.js | 11 ++ .../src/components/CreateGameAccountForm.jsx | 69 ++++++++ client/src/components/GameAccounts.jsx | 78 +++++++-- client/src/routes/admin/AdminLayout.jsx | 2 + .../src/routes/admin/views/InvitesAdmin.jsx | 152 ++++++++++++++++++ client/src/routes/admin/views/UserDetail.jsx | 2 +- client/src/routes/player/AcceptInvite.jsx | 132 +++++++++++++++ server/src/model/settings/settings.model.js | 3 + 9 files changed, 442 insertions(+), 11 deletions(-) create mode 100644 client/src/components/CreateGameAccountForm.jsx create mode 100644 client/src/routes/admin/views/InvitesAdmin.jsx create mode 100644 client/src/routes/player/AcceptInvite.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 66f2881..f803e27 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -46,6 +46,7 @@ import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx' +import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' @@ -53,6 +54,7 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx' // Player portal import PlayerLogin from './routes/player/PlayerLogin.jsx' import PlayerRegister from './routes/player/PlayerRegister.jsx' +import AcceptInvite from './routes/player/AcceptInvite.jsx' import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx' import PlayerCharacters from './routes/player/PlayerCharacters.jsx' import PlayerCharacter from './routes/player/PlayerCharacter.jsx' @@ -147,6 +149,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> @@ -154,6 +157,7 @@ export default function App() { {/* Player portal */} } /> } /> + } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index c515752..9b46a73 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -48,6 +48,10 @@ export const api = { // optional email. Returns { user } and sets the session cookie on success. register: (username, password, extra = {}) => req('/auth/register', { method: 'POST', body: { username, password, ...extra } }), + // Email invites (public, token-gated accept). + getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`), + acceptInvite: (token, username, password, extra = {}) => + req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }), loginTotp: (challenge, code) => req('/auth/login/totp', { method: 'POST', body: { challenge, code } }), // Second factor for an SSO login (challenge is held in an httpOnly cookie set by @@ -171,6 +175,10 @@ export const api = { createUser: (data) => req('/admin/users', { method: 'POST', body: data }), updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }), + // Email invites. + listInvites: () => req('/admin/invites'), + createInvite: (email, role) => req('/admin/invites', { method: 'POST', body: { email, role } }), + revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }), // A single user's shard (uo-link) footprint, scoped to their linked accounts. // accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char // reuse the admin-bypass /admin/shard/* endpoints (which already read any @@ -184,6 +192,7 @@ export const api = { houses: () => req(`/admin/users/${id}/shard/houses`), online: () => req(`/admin/users/${id}/shard/online`), standing: () => req(`/admin/users/${id}/shard/standing`), + unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }), }), // ----- moderation dashboard (admin + moderator) ----- @@ -313,6 +322,8 @@ export const api = { vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`), sales: () => req('/player/shard/sales'), + createAccount: (account, password) => + req('/player/shard/account', { method: 'POST', body: { account, password } }), }, }, } diff --git a/client/src/components/CreateGameAccountForm.jsx b/client/src/components/CreateGameAccountForm.jsx new file mode 100644 index 0000000..7b045f6 --- /dev/null +++ b/client/src/components/CreateGameAccountForm.jsx @@ -0,0 +1,69 @@ +import { useState } from 'react' + +// Reusable "create a game account" form (its own username + password — the game +// client credentials, distinct from the website login). Calls `submit(account, +// password)` which should POST /player/shard/account; on success calls onCreated. +// Used by the player portal (self-serve) and the invite-accept page alike. +export default function CreateGameAccountForm({ submit, onCreated, compact = false }) { + const [account, setAccount] = useState('') + const [password, setPassword] = useState('') + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [error, setError] = useState('') + + async function onSubmit(e) { + e.preventDefault() + setMsg(''); setError('') + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) { + return setError('Account name must be 3–30 letters, numbers, . _ or -.') + } + if (password.length < 8) return setError('Password must be at least 8 characters.') + setBusy(true) + try { + await submit(account, password) + setMsg(`Game account “${account}” created and linked.`) + setAccount(''); setPassword('') + if (onCreated) await onCreated() + } catch (err) { + if (err.status === 409) setError('That account name is already taken.') + else if (err.status === 429) setError('The account limit for your network has been reached.') + else if (err.status === 403) setError('Game-account signup is not available right now.') + else if (err.status === 503) setError('The game server is unavailable — try again shortly.') + else setError(err.message || 'Could not create the account right now.') + } finally { + setBusy(false) + } + } + + return ( +
+ {!compact && ( +

+ Choose the username and password you’ll type into the game client. These are your + game credentials — separate from your website login. +

+ )} + + + + {error &&

{error}

} + {msg &&

{msg}

} + + +
+ ) +} diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx index fccc8b8..ff1539d 100644 --- a/client/src/components/GameAccounts.jsx +++ b/client/src/components/GameAccounts.jsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { Loading, ErrorState } from './PageState.jsx' import ShardAccountActions from './ShardAccountActions.jsx' +import CreateGameAccountForm from './CreateGameAccountForm.jsx' +import { api } from '../api/client.js' // Shared game-account linking + character roster, used by both the player portal // (/player) and the staff account page (/admin/account). `scope` is the api @@ -108,9 +110,37 @@ function AccountRoster({ scope, account, charTo }) { ) } -export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false }) { +// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms, +// then calls onUnlink(account) and reloads. Errors surface inline. +function UnlinkButton({ account, onUnlink }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + async function go() { + if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return + setBusy(true); setError('') + try { + await onUnlink(account) + } catch (err) { + setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.')) + setBusy(false) + } + } + return ( + + + {error && {error}} + + ) +} + +export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) { const [accounts, setAccounts] = useState(null) const [error, setError] = useState('') + // Whether the site currently offers game-account creation (public flag). Only + // relevant for the self-service (non-readOnly) view with a createAccount scope. + const [signupOk, setSignupOk] = useState(false) const load = useCallback(async () => { setError('') @@ -122,6 +152,17 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati }, [scope, readOnly]) useEffect(() => { load() }, [load]) + useEffect(() => { + if (readOnly || !scope.createAccount) return + let active = true + api.publicSettings() + .then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup))) + .catch(() => {}) + return () => { active = false } + }, [readOnly, scope]) + + const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk + if (error) return if (!accounts) return @@ -138,13 +179,21 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati ) } return ( -
-
Link your game account
-

- You haven’t linked a game account yet. In game, type [link to get a - one-time code, then enter it below to see your characters, stats, skills and vendors here. -

- +
+
+
Link your game account
+

+ Already play? In game, type [link to get a + one-time code, then enter it below to see your characters, stats, skills and vendors here. +

+ +
+ {canCreate && ( +
+
Create a new game account
+ +
+ )}
) } @@ -154,8 +203,11 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
{accounts.map((a) => (
-
- {a.account} +
+
+ {a.account} +
+ {onUnlink && { await onUnlink(acct); await load() }} />}
{moderation && } @@ -165,6 +217,12 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
Link another account
+ {canCreate && ( +
+
Create another game account
+ +
+ )}
)}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 3073b89..5294579 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -70,6 +70,7 @@ const NAV = [ title: 'System', items: [ { to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] }, + { to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] }, { to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] }, { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, @@ -104,6 +105,7 @@ const TITLES = { '/admin/characters': 'My Characters', '/admin/auth-providers': 'Authentication', '/admin/users': 'Users', + '/admin/invites': 'Invites', '/admin/account': 'Account Security', } diff --git a/client/src/routes/admin/views/InvitesAdmin.jsx b/client/src/routes/admin/views/InvitesAdmin.jsx new file mode 100644 index 0000000..f064194 --- /dev/null +++ b/client/src/routes/admin/views/InvitesAdmin.jsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { dateTime } from '../../../lib/format.js' +import { api } from '../../../api/client.js' + +// Admin email invites: send an invite at a chosen access level, see recent +// invites and their status, revoke pending ones. When email delivery isn't +// configured the create response hands back the accept link to copy manually. + +const ROLES = ['player', 'moderator', 'editor', 'admin'] +const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator', player: 'badge-player' } +const STATUS_COLOR = { pending: 'var(--accent)', accepted: '#7fd0a4', revoked: 'var(--muted)' } + +function CreateInvite({ onCreated }) { + const [email, setEmail] = useState('') + const [role, setRole] = useState('player') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [result, setResult] = useState(null) // { emailed, acceptUrl } + + async function submit(e) { + e.preventDefault() + setError(''); setResult(null) + if (!email.trim()) return setError('Enter an email address.') + setBusy(true) + try { + const res = await api.admin.createInvite(email.trim(), role) + setResult(res) + setEmail('') + await onCreated() + } catch (err) { + setError(err.message || 'Could not create the invite.') + } finally { + setBusy(false) + } + } + + return ( +
+
Invite someone
+
+ + + +
+ + {error &&

{error}

} + {result && ( +
+ {result.emailed ? ( +

Invitation emailed.

+ ) : ( +
+

+ Email isn’t configured{result.emailError ? ` (${result.emailError})` : ''} — share this single-use link: +

+ + {result.acceptUrl} + +
+ )} +
+ )} +
+ ) +} + +export default function InvitesAdmin() { + const [invites, setInvites] = useState(null) + const [error, setError] = useState('') + + const load = useCallback(async () => { + setError('') + try { + setInvites(await api.admin.listInvites()) + } catch { + setError('Could not load invites.') + } + }, []) + useEffect(() => { load() }, [load]) + + async function revoke(id) { + if (!window.confirm('Revoke this pending invitation?')) return + try { + await api.admin.revokeInvite(id) + await load() + } catch { + /* surfaced by the row staying; keep it simple */ + } + } + + if (error) return + + return ( +
+ + + {!invites ? ( + + ) : ( +
+ + + + + + + + + + + + {invites.length === 0 && ( + + )} + {invites.map((iv) => { + const status = iv.status === 'pending' && iv.expired ? 'expired' : iv.status + return ( + + + + + + + + + ) + })} + +
EmailRoleStatusExpiresCreated +
No invites yet.
{iv.email}{iv.role}{status}{dateTime(iv.expiresAt)}{dateTime(iv.createdAt)} + {iv.status === 'pending' && ( + + )} +
+
+ )} +
+ ) +} diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx index 7bc5a31..bdf276a 100644 --- a/client/src/routes/admin/views/UserDetail.jsx +++ b/client/src/routes/admin/views/UserDetail.jsx @@ -130,7 +130,7 @@ function ShardSections({ scope }) { <> Linked accounts & characters - `/admin/characters/${serial}`} /> + `/admin/characters/${serial}`} /> diff --git a/client/src/routes/player/AcceptInvite.jsx b/client/src/routes/player/AcceptInvite.jsx new file mode 100644 index 0000000..4498ad6 --- /dev/null +++ b/client/src/routes/player/AcceptInvite.jsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { useAuth } from '../../contexts/AuthContext.jsx' +import { api } from '../../api/client.js' +import PlayerShell, { honeypotStyle } from './PlayerShell.jsx' +import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx' + +// Public, token-gated invite acceptance (/invite/:token). Validates the invite, +// lets the invitee set a username + password (their email + role are pre-assigned), +// creates the account at that role and logs them in. For a player invite it then +// offers the built-in "create game account" step before sending them to the portal. +export default function AcceptInvite() { + const { token } = useParams() + const navigate = useNavigate() + const { refresh } = useAuth() + + const [invite, setInvite] = useState(null) // { email, role } + const [loadErr, setLoadErr] = useState('') + const [signupOk, setSignupOk] = useState(false) + + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [company, setCompany] = useState('') // honeypot + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [accepted, setAccepted] = useState(false) + + useEffect(() => { + let active = true + api.getInvite(token) + .then((iv) => active && setInvite(iv)) + .catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.')) + api.publicSettings() + .then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup))) + .catch(() => {}) + return () => { active = false } + }, [token]) + + const dest = invite && invite.role === 'player' ? '/player' : '/admin' + + async function onSubmit(e) { + e.preventDefault() + setError('') + if (username.trim().length < 3) return setError('Username must be at least 3 characters.') + if (password.length < 8) return setError('Password must be at least 8 characters.') + setBusy(true) + try { + await api.acceptInvite(token, username.trim(), password, { company }) + await refresh() // pull the freshly-issued session into context + setAccepted(true) + // Staff invites are web-only — no game step; go straight in. + if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true }) + } catch (err) { + if (err.status === 409) setError('That username is already taken, or the invite was already used.') + else if (err.status === 404) setError('This invitation is invalid or has expired.') + else if (err.status === 400) setError(err.message || 'Please check your details and try again.') + else setError('Could not accept the invitation right now.') + setBusy(false) + } + } + + // ── Loading / invalid ───────────────────────────────────────────────────── + if (loadErr) { + return ( + +

{loadErr}

+

+ Go to sign in +

+
+ ) + } + if (!invite) { + return ( + +
+
+ ) + } + + // ── Accepted: optional game-account step (player invites) ────────────────── + if (accepted) { + return ( + +

+ Your account is ready. Create a game account now to play, or skip and do it later from your portal. +

+ navigate('/player', { replace: true })} + /> +

+ +

+
+ ) + } + + // ── Accept form ──────────────────────────────────────────────────────────── + return ( + +

+ You’ve been invited as {invite.role} + {invite.email ? <> for {invite.email} : null}. Choose a username and password to finish. +

+
+ + + + + {error &&

{error}

} + + +
+
+ ) +} diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 19e0d9f..6488558 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -71,6 +71,9 @@ async function getPublic() { // page show/hide the password form and SSO buttons. const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled' out.registration = registrationFlags(mode) + // Whether the site offers game-account creation (the shard's own mode still has + // the final say when the call is made). Lets the portal show/hide the form. + out.gameAccountSignup = all[GAME_SIGNUP_KEY] === 'enabled' return out } -- 2.49.1 From a165c90c622c3c6ebb65b544481f79fa50a7d1fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:25:14 -0500 Subject: [PATCH 7/9] fix(schema): remove semicolons from shard_governor_terms inline comments ensureSchema() splits schema.sql on ';' and is not comment-aware, so the inline comments "epoch ms; NULL = current" and "not in the feed; reserved" shattered the CREATE TABLE into invalid fragments (ER_PARSE_ERROR on a fresh boot). Reworded both to drop the semicolons. Caught during live-stack bring-up. Co-Authored-By: Claude Opus 4.8 --- server/db/schema.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/db/schema.sql b/server/db/schema.sql index a33942c..e235498 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -490,8 +490,8 @@ CREATE TABLE IF NOT EXISTS shard_governor_terms ( governor_acct VARCHAR(120) NULL, governor_web_id INT NULL, started_at BIGINT NOT NULL, -- term start, epoch ms - ended_at BIGINT NULL, -- term end, epoch ms; NULL = current - votes INT NULL, -- not in the feed; reserved + ended_at BIGINT NULL, -- term end epoch ms (NULL = current) + votes INT NULL, -- not in the feed (reserved) created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_shard_gov_terms_city (city, started_at), INDEX idx_shard_gov_terms_open (city, ended_at) -- 2.49.1 From 1629796235fb8eaa17c744223308513a5977a568 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:50:37 -0500 Subject: [PATCH 8/9] =?UTF-8?q?feat(houses):=20tier=20house=20visibility?= =?UTF-8?q?=20=E2=80=94=20public=20IDOC-only,=20staff=20full,=20player=20o?= =?UTF-8?q?wn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.jsx | 9 ++ client/src/api/client.js | 2 + client/src/routes/admin/AdminLayout.jsx | 4 +- client/src/routes/admin/views/HousesAdmin.jsx | 119 ++++++++++++++ client/src/routes/player/PlayerCharacters.jsx | 45 +++++- client/src/routes/public/Houses.jsx | 145 +++++------------- server/src/router/v1/admin/admin.routes.js | 10 ++ .../router/v1/admin/shardOps.controller.js | 15 +- server/src/router/v1/player/player.routes.js | 9 ++ .../src/router/v1/player/shard.controller.js | 16 +- .../src/router/v1/public/shard.controller.js | 19 ++- server/src/utils/shardBroadcast.js | 8 +- server/swagger/swagger-output.json | 76 +++++++++ 13 files changed, 362 insertions(+), 115 deletions(-) create mode 100644 client/src/routes/admin/views/HousesAdmin.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index f803e27..ac320d1 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -47,6 +47,7 @@ import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx' import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx' +import HousesAdmin from './routes/admin/views/HousesAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' @@ -144,6 +145,14 @@ export default function App() { } /> + + + + } + /> } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 9b46a73..8f36c2a 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -259,6 +259,7 @@ export const api = { vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`), char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`), sales: () => req('/admin/shard/sales'), + houses: () => req('/admin/shard/houses'), // full registry (admin/moderator) }, // ----- auth providers / SSO config (admin only) ----- @@ -322,6 +323,7 @@ export const api = { vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`), sales: () => req('/player/shard/sales'), + houses: () => req('/player/shard/houses'), // the caller's own houses createAccount: (account, password) => req('/player/shard/account', { method: 'POST', body: { account, password } }), }, diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 5294579..9e2204b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -64,6 +64,7 @@ const NAV = [ items: [ { to: '/admin/moderation', label: 'Moderation', icon: IconShield, roles: ['admin', 'moderator'] }, { to: '/admin/shard-ops', label: 'In-Game Ops', icon: IconShard, roles: ['admin', 'moderator'] }, + { to: '/admin/houses', label: 'Houses', icon: IconShard, roles: ['admin', 'moderator'] }, ], }, { @@ -97,6 +98,7 @@ const TITLES = { '/admin/hero': 'Hero Editor', '/admin/moderation': 'Moderation', '/admin/shard-ops': 'In-Game Ops', + '/admin/houses': 'House Registry', '/admin/settings': 'Site Settings', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Web Bot Activity', @@ -143,7 +145,7 @@ export default function AdminLayout() { // Moderators only get the moderation section (Discord + in-game ops) + their // own account security. const isModerator = user?.role === 'moderator' - const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/account'] + const MOD_PATHS = ['/admin/moderation', '/admin/shard-ops', '/admin/houses', '/admin/account'] const visible = (item) => { if (item.roles && !item.roles.includes(user?.role)) return false if (isModerator) return MOD_PATHS.includes(item.to) diff --git a/client/src/routes/admin/views/HousesAdmin.jsx b/client/src/routes/admin/views/HousesAdmin.jsx new file mode 100644 index 0000000..89eb787 --- /dev/null +++ b/client/src/routes/admin/views/HousesAdmin.jsx @@ -0,0 +1,119 @@ +import { useMemo, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { useAsync } from '../../../lib/useAsync.js' +import { useShardFeed } from '../../../lib/useShardFeed.js' +import { api } from '../../../api/client.js' + +// Staff-only FULL house registry (admin + moderator). Owner, price, co-owners and +// decay — everything the public board hides. Loaded from /admin/shard/houses, kept +// live from the admin SSE channel (house.update / house.remove). +const HOUSE_KINDS = new Set(['house.update', 'house.remove', 'house.decay']) + +const DECAY_TONE = { + LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a', + Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5', +} + +function DecayBadge({ decay, isIdoc }) { + const label = isIdoc ? 'IDOC' : decay + if (!label) return null + const tone = DECAY_TONE[label] || 'var(--muted)' + return ( + + {label} + + ) +} + +function ownerLabel(h) { + return h.ownerName || h.ownerAcct || null +} + +function HouseRow({ h }) { + const owner = ownerLabel(h) + return ( +
+
+
+ + {h.name || 'An unnamed house'} + + +
+
+ {owner ? <>Owned by {owner} : 'No owner'} + {(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''} +
+
+ {h.region || h.map || '—'}{h.x != null ? ` (${h.x}, ${h.y})` : ''} +
+
+ {h.price != null && ( +
+
{Number(h.price).toLocaleString()}
+
placement value
+
+ )} +
+ ) +} + +export default function HousesAdmin() { + const { loading, error, data } = useAsync(() => api.admin.shard.houses()) + // Full registry deltas ride the admin SSE channel (never the public one). + const { events, connected } = useShardFeed({ url: api.adminShardStreamUrl, filter: HOUSE_KINDS, max: 80 }) + const [q, setQ] = useState('') + + const board = useMemo(() => { + const map = new Map() + for (const h of data || []) if (h && h.serial) map.set(h.serial, h) + for (let i = events.length - 1; i >= 0; i -= 1) { + const ev = events[i] + if (!ev.serial) continue + if (ev.kind === 'house.update') { + map.set(ev.serial, { ...ev, ownerName: ev.owner?.name ?? ev.ownerName, ownerAcct: ev.owner?.acct ?? ev.ownerAcct }) + } else if (ev.kind === 'house.remove') { + map.delete(ev.serial) + } else if (ev.kind === 'house.decay') { + const cur = map.get(ev.serial) || { serial: ev.serial, name: ev.name, region: ev.region, map: ev.map, x: ev.x, y: ev.y } + map.set(ev.serial, { ...cur, isIdoc: String(ev.to).toUpperCase() === 'IDOC' }) + } + } + return [...map.values()] + }, [data, events]) + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase() + const rows = needle + ? board.filter((h) => [h.name, h.region, h.map, ownerLabel(h)].some((v) => v && String(v).toLowerCase().includes(needle))) + : board + return [...rows].sort((a, b) => (a.name || '').localeCompare(b.name || '')) + }, [board, q]) + + if (loading) return + if (error) return + + return ( +
+
+

+ {board.length.toLocaleString()} houses + {connected ? '● live' : '○ offline'} +

+ setQ(e.target.value)} placeholder="Search by owner, region…" style={{ flex: 'none', width: 230, maxWidth: '55%', fontSize: '0.84rem' }} /> +
+ {board.length === 0 ? ( +
+

No houses are being tracked right now.

+
+ ) : ( +
+ {filtered.map((h) => )} +
+ )} + {board.length > 0 && filtered.length === 0 && ( +

No houses match “{q}”.

+ )} +
+ ) +} diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx index 27711cd..e8ddb77 100644 --- a/client/src/routes/player/PlayerCharacters.jsx +++ b/client/src/routes/player/PlayerCharacters.jsx @@ -1,14 +1,57 @@ import GameAccounts from '../../components/GameAccounts.jsx' import VendorSales from '../../components/VendorSales.jsx' +import { useAsync } from '../../lib/useAsync.js' import { api } from '../../api/client.js' // The logged-in player's characters. Shows the link prompt when no game account // is linked, otherwise their characters grouped by account (shared component), -// plus their own recent vendor sales. +// plus their own home status and recent vendor sales. + +const DECAY_TONE = { + LikeNew: '#7fd0a4', Ageless: '#7fd0a4', Slightly: '#a9cf8a', Somewhat: '#d7c56a', + Fairly: '#e0a95f', Greatly: '#d9736f', IDOC: '#e05a5a', Collapsed: '#8c96a5', +} + +// The caller's own houses (home status). Only their own — never anyone else's. +function MyHouses() { + const { data } = useAsync(() => api.player.shard.houses(), []) + if (!data || data.length === 0) return null + return ( +
+
My houses
+
+ {data.map((h) => { + const label = h.isIdoc ? 'IDOC' : (h.decay || h.stage) + const tone = h.isIdoc ? '#e05a5a' : (DECAY_TONE[label] || 'var(--muted)') + return ( +
+
+
{h.name || 'An unnamed house'}
+
+ {h.region || h.map || '—'}{h.x != null ? ` · ${h.x}, ${h.y}` : ''} +
+
+ {label && ( + + {label} + + )} +
+ ) + })} +
+

+ Keep an eye on the decay status — refresh a house in game before it reaches IDOC. +

+
+ ) +} + export default function PlayerCharacters() { return (
`/player/char/${serial}`} /> +
) diff --git a/client/src/routes/public/Houses.jsx b/client/src/routes/public/Houses.jsx index 223c9c4..246a72f 100644 --- a/client/src/routes/public/Houses.jsx +++ b/client/src/routes/public/Houses.jsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useMemo } from 'react' import PublicLayout from '../../components/PublicLayout.jsx' import PageHeader from '../../components/PageHeader.jsx' import { Loading, ErrorState } from '../../components/PageState.jsx' @@ -6,67 +6,30 @@ import { useAsync } from '../../lib/useAsync.js' import { useShardFeed } from '../../lib/useShardFeed.js' import { api } from '../../api/client.js' -// The house registry. Loaded from /public/shard/houses, kept live by merging -// house.update / house.remove deltas by serial. `price` is the placement value — -// NOT a for-sale flag (stock ServUO has none), and the UI labels it as such. -const HOUSE_KINDS = new Set(['house.update', 'house.remove']) - -// Decay level → colour, from healthiest to collapsed. -const DECAY_TONE = { - LikeNew: '#7fd0a4', - Slightly: '#a9cf8a', - Somewhat: '#d7c56a', - Fairly: '#e0a95f', - Greatly: '#d9736f', - IDOC: '#e05a5a', - Collapsed: '#8c96a5', -} - -function DecayBadge({ decay, isIdoc }) { - const label = isIdoc ? 'IDOC' : decay - if (!label) return null - const tone = DECAY_TONE[label] || 'var(--muted)' - return ( - - {label} - - ) -} - -// house.update carries owner as a flattened ownerName/ownerAcct on our shaped row. -function ownerLabel(h) { - return h.ownerName || h.ownerAcct || null -} +// PUBLIC houses board: only houses in danger (IDOC), shown by location. Owner, +// price, decay detail and the full registry are staff-only (admin Houses view). +// Loaded from /public/shard/houses (IDOC-only), kept live by house.decay: a +// house entering IDOC appears, one leaving it drops off. +const HOUSE_KINDS = new Set(['house.decay']) function HouseRow({ h }) { - const owner = ownerLabel(h) return (
+