5 Commits

Author SHA1 Message Date
5103b74a9d Merge pull request 'feat(shard)!: Protocol 3.0 cutover — visibility framework, spawn atlas, marketplace' (#118) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 16s
Build container images / build (push) Successful in 1m12s
Build container images / deploy (push) Successful in 39s
SonarQube / analysis (push) Successful in 3m58s
Reviewed-on: #118
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 07:19:31 +00:00
c91fd128bf Merge pull request 'fix(shard): answer with the instance name when the shard is unnamed' (#119) from fix/ruleset-shard-name into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 27s
PR Checks / client-build (pull_request) Successful in 35s
PR Checks / server-tests (pull_request) Successful in 1m43s
Reviewed-on: #119
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 06:04:19 +00:00
01a559792c fix(shard): answer with the instance name when the shard is unnamed
ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it
publishes that verbatim, so the rules page read "My Shard" under a header
carrying the real name. That value is the shard saying *unnamed* rather than
naming anything, so the site now answers with its own.

`settings.getInstanceName()` resolves `site_title || BRAND_NAME` — the same
resolution `getPublic().brand.name` already uses, so an install that set only
the site title can never show two different names on two pages. Bare
`brand.name` would have been wrong for exactly that case.

Substituted at INGEST rather than on read: world.ruleset is also broadcast
live, and the same object is handed to the SSE fan-out, so a read-time fix
would be undone by the next reconnect's frame. Matched case- and
padding-insensitively but only as a whole value, so a shard genuinely called
"My Shard Reborn" keeps its name.

Fixes a second ruleset writer found on the way: uoLinkSocket.backfill() called
shardState.setRuleset directly instead of going through the dispatcher as
ingestEach does, so the boot/reconnect snapshot silently skipped this
normalization. The two arrival orders have to produce the same stored frame.

Also renders a placeholder row on an unscored leaderboard — the instance name
with an em dash where a score goes, deliberately not shaped like an entry (no
medal, no bar) because a placeholder that looked like a real standing would be
a fabricated one. Presentation only; the API still sends an empty `top`.

Verified live against the shard + sidecar: rules page and leaderboards on web
and Android both correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-08-01 00:58:21 -05:00
e50fab241f Merge pull request 'feat(shard)!: declare wire protocol 3' (#117) from chore/protocol-3-cutover into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 1m34s
Reviewed-on: #117
2026-07-30 03:02:20 +00:00
779a304173 feat(shard)!: declare wire protocol 3
The site's declared version is the admin-set uo_link_config.protocol column, so
the sidecar's PROTOCOL_VERSION 2 -> 3 bump has to be matched here or every REST
call 409s and uoLinkSocket closes the WS on the ws.hello mismatch. Five places
carry the number and all five move together: the column default, the model's
DEFAULT_PROTOCOL (what a site with nothing saved yet declares), the two
`config.protocol || 1` fallbacks in uoLinkClient/uoLinkSocket -- unreachable
today, but an unset value quietly sending 1 is exactly the confusing 409 the
version check exists to prevent -- the admin form's initial value, and the
documented env default.

The boot migration is the only subtle part. schema.sql is re-run on EVERY boot,
and `protocol` is admin-editable, so a bare UPDATE would silently un-pin an
operator who had deliberately pinned an older sidecar in Admin -> Shard. It is
therefore gated on a marker row in `settings`, written after the UPDATE: the
first boot on this build migrates, every later boot is a no-op. `protocol < 3`
rather than `= 2` picks up an install still on the old default of 1, which could
not have been talking to a v2 sidecar anyway. A fresh install has no row to
update and just gets the marker plus the new column default.

Verified against the local MariaDB through ensureSchema (the production path):
2 -> 3 with the marker written and the column default now 3; pinned back to 2 by
hand, re-ran, and it STAYED 2 -- the one-shot property holds. 673 server tests,
47 client tests, client build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:03:48 -05:00
10 changed files with 178 additions and 11 deletions

View File

@@ -117,7 +117,10 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string
# token). These URLs are just defaults; the admin can override them at runtime. # token). These URLs are just defaults; the admin can override them at runtime.
UOLINK_BASE_URL=http://127.0.0.1:8080 UOLINK_BASE_URL=http://127.0.0.1:8080
UOLINK_WS_URL=ws://127.0.0.1:8080/ws UOLINK_WS_URL=ws://127.0.0.1:8080/ws
UOLINK_PROTOCOL=1 # Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site
# with nothing saved yet — the admin panel's pinned value wins — but set it lower
# if you deliberately run an older sidecar.
UOLINK_PROTOCOL=3
# ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ─── # ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications # The `ntfy` compose service and the backend's push fan-out (opt-in notifications

View File

@@ -155,7 +155,7 @@ export default function ShardAdmin() {
const [baseUrl, setBaseUrl] = useState('') const [baseUrl, setBaseUrl] = useState('')
const [wsUrl, setWsUrl] = useState('') const [wsUrl, setWsUrl] = useState('')
const [token, setToken] = useState('') const [token, setToken] = useState('')
const [protocol, setProtocol] = useState(1) const [protocol, setProtocol] = useState(3)
const [enabled, setEnabled] = useState(false) const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
@@ -172,7 +172,7 @@ export default function ShardAdmin() {
if (!initializedRef.current) { if (!initializedRef.current) {
setBaseUrl(c.baseUrl || '') setBaseUrl(c.baseUrl || '')
setWsUrl(c.wsUrl || '') setWsUrl(c.wsUrl || '')
setProtocol(c.protocol || 1) setProtocol(c.protocol || 3)
setEnabled(c.enabled) setEnabled(c.enabled)
initializedRef.current = true initializedRef.current = true
} }

View File

@@ -5,6 +5,7 @@ import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js' import { useAsync } from '../../lib/useAsync.js'
import { useShardFeed } from '../../lib/useShardFeed.js' import { useShardFeed } from '../../lib/useShardFeed.js'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
import { useSite } from '../../contexts/SiteContext.jsx'
// Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate // Points / loyalty leaderboards (Protocol 3.0 §7). The shard carries ~25 separate
// point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city // point currencies — Queen's Loyalty, Void Pool, Clean Up Britannia, the nine city
@@ -96,6 +97,7 @@ function Entry({ entry, best }) {
} }
function Board({ board }) { function Board({ board }) {
const { siteTitle } = useSite()
const top = Array.isArray(board.top) ? board.top : [] const top = Array.isArray(board.top) ? board.top : []
// Bars are relative to the board leader, not to maxPoints: most systems have no // Bars are relative to the board leader, not to maxPoints: most systems have no
// cap (maxPoints 0), and where there is one the leader is often nowhere near it, // cap (maxPoints 0), and where there is one the leader is often nowhere near it,
@@ -116,9 +118,28 @@ function Board({ board }) {
</div> </div>
{top.length === 0 ? ( {top.length === 0 ? (
<p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}> // A board nobody has scored on still gets a row, so the page reads as a set
Nobody has earned points here yet. // of standings waiting to be filled rather than a stack of blanks. It is
</p> // deliberately NOT shaped like an Entry — no medal, no bar, an em dash where
// a score goes — because a placeholder that looked like a real standing would
// be a fabricated one. The first real entry replaces it.
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10, padding: '6px 0' }}>
<span
className="sans"
style={{
color: 'var(--muted)', fontSize: '0.86rem',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{siteTitle}
</span>
<span className="sans dim" style={{ fontSize: '0.82rem', flex: 'none' }}>&mdash;</span>
</div>
<p className="sans dim" style={{ margin: 0, fontSize: '0.78rem' }}>
Nobody has earned points here yet.
</p>
</div>
) : ( ) : (
<div> <div>
{top.map((entry) => ( {top.map((entry) => (

View File

@@ -359,7 +359,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
base_url VARCHAR(255) NULL, base_url VARCHAR(255) NULL,
ws_url VARCHAR(255) NULL, ws_url VARCHAR(255) NULL,
auth_token_enc TEXT NULL, auth_token_enc TEXT NULL,
protocol INT NOT NULL DEFAULT 1, protocol INT NOT NULL DEFAULT 3,
enabled TINYINT(1) NOT NULL DEFAULT 0, enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL, status_detail VARCHAR(500) NULL,
@@ -1397,3 +1397,19 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME
-- trust token. A boolean only — the token is returned over that app→server call -- trust token. A boolean only — the token is returned over that app→server call
-- and never persisted here (only its sha256 lands in trusted_devices). -- and never persisted here (only its sha256 lands in trusted_devices).
ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0; ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0;
-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset,
-- points.board, vendor.listing), so the pinned version an existing install
-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call
-- and closes the WS on ws.hello. MODIFY fixes the column default for installs
-- created before the bump (idempotent, like the other MODIFYs here).
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this
-- must be one-shot: an operator who deliberately pins an older sidecar in
-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes
-- it fire once — written after the UPDATE, and on a fresh install (no
-- uo_link_config row yet) it is simply written with nothing to update.
UPDATE uo_link_config SET protocol = 3
WHERE id = 1 AND protocol < 3
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');

View File

@@ -70,6 +70,24 @@ async function isMobileAppLinksEnabled() {
} }
} }
/**
* This instance's name, resolved exactly as `getPublic().brand.name` resolves it —
* the admin-editable site title wins over BRAND_NAME. Anything that has to *speak*
* the instance's name outside the settings payload must use this rather than
* `brand.name`, or an install that set only the site title gets two different names
* on two different pages.
*
* Never throws: a name is always better than an error, so a DB fault falls back to
* the env value.
*/
async function getInstanceName() {
try {
return (await settingsDb.get('site_title')) || brand.name
} catch {
return brand.name
}
}
async function get(key) { async function get(key) {
return settingsDb.get(key) return settingsDb.get(key)
} }
@@ -154,6 +172,7 @@ module.exports = {
setMany, setMany,
getAll, getAll,
getPublic, getPublic,
getInstanceName,
PUBLIC_KEYS, PUBLIC_KEYS,
REGISTRATION_KEY, REGISTRATION_KEY,
REGISTRATION_MODES, REGISTRATION_MODES,

View File

@@ -7,7 +7,10 @@
const db = require('./uoLinkConfig.db') const db = require('./uoLinkConfig.db')
const secretBox = require('../../utils/secretBox') const secretBox = require('../../utils/secretBox')
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1 // The wire protocol this build speaks (link/sidecar/src/main.rs PROTOCOL_VERSION).
// Only used before an admin has saved anything — the stored row wins once it exists,
// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar.
const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 3
function toSafe(row) { function toSafe(row) {
if (!row) { if (!row) {

View File

@@ -17,6 +17,7 @@ const shardStateModel = require('../model/shardState/shardState.model')
const shardLinksModel = require('../model/shardLinks/shardLinks.model') const shardLinksModel = require('../model/shardLinks/shardLinks.model')
const shardMarketModel = require('../model/shardMarket/shardMarket.model') const shardMarketModel = require('../model/shardMarket/shardMarket.model')
const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model') const uoLinkConfigModel = require('../model/uoLinkConfig/uoLinkConfig.model')
const settingsModel = require('../model/settings/settings.model')
const broadcaster = require('./shardBroadcast') const broadcaster = require('./shardBroadcast')
const pushDispatch = require('./pushDispatch') const pushDispatch = require('./pushDispatch')
const defaultLog = require('./logger')('shard-ingest') const defaultLog = require('./logger')('shard-ingest')
@@ -63,6 +64,31 @@ function shouldLog(event) {
return LOGGED_KINDS.has(event.kind) return LOGGED_KINDS.has(event.kind)
} }
// ServUO's stock Server.cfg name. An operator who never set one publishes this
// verbatim, so it carries no more information than a blank — matched
// case-insensitively and trim-tolerantly, but ONLY as an exact whole value: a
// shard genuinely called "My Shard Reborn" keeps its name.
const STOCK_SHARD_NAME = 'my shard'
/**
* The name to publish for the shard: its own, or this instance's when it has
* effectively not given one.
*
* Deliberately not a general "blank means brand" rule applied across the wire —
* it is scoped to this one field, where the two names denote the same thing.
*/
async function resolveShardName(shard, deps) {
const given = String(shard ?? '').trim()
if (given !== '' && given.toLowerCase() !== STOCK_SHARD_NAME) return given
try {
return (await deps.settings.getInstanceName()) || given
} catch {
// A ruleset that publishes the stock name is still better than one that
// fails to store because the settings read hiccuped.
return given
}
}
// Apply the state-change side effect for a kind (if any). Returns a promise. // Apply the state-change side effect for a kind (if any). Returns a promise.
async function applyStateChange(event, deps) { async function applyStateChange(event, deps) {
const { shardState, uoLinkConfig, log } = deps const { shardState, uoLinkConfig, log } = deps
@@ -180,6 +206,15 @@ async function applyStateChange(event, deps) {
// would put a duplicate row in the event log on every reconnect, and // would put a duplicate row in the event log on every reconnect, and
// server.hello already marks each of those. // server.hello already marks each of those.
case 'world.ruleset': case 'world.ruleset':
// A shard whose operator never edited Server.cfg publishes ServUO's stock
// "My Shard". That is the shard saying *unnamed*, not a name, so the site
// answers with its own — the rules page reading "My Shard" under a header
// reading UOMysticmoon is the shard failing to introduce itself.
//
// Normalized HERE rather than on read because the ruleset is also live: the
// same `event` object is handed to the SSE broadcast a few lines below, and
// a read-time fix would be undone by the next reconnect's frame.
event.shard = await resolveShardName(event.shard, deps)
await shardState.setRuleset(event) await shardState.setRuleset(event)
return return
// Board state, like guild.update — the newest frame for a system replaces the // Board state, like guild.update — the newest frame for a system replaces the
@@ -225,6 +260,7 @@ function resolveDeps(deps) {
shardLinks: deps.shardLinks || shardLinksModel, shardLinks: deps.shardLinks || shardLinksModel,
shardMarket: deps.shardMarket || shardMarketModel, shardMarket: deps.shardMarket || shardMarketModel,
uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel, uoLinkConfig: deps.uoLinkConfig || uoLinkConfigModel,
settings: deps.settings || settingsModel,
broadcast: deps.broadcast || broadcaster.broadcast, broadcast: deps.broadcast || broadcaster.broadcast,
pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent, pushDispatch: deps.pushDispatch || pushDispatch.fromShardEvent,
log: deps.log || defaultLog, log: deps.log || defaultLog,

View File

@@ -58,7 +58,7 @@ async function call(path, { method = 'GET', body } = {}) {
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-UOLink-Version': String(config.protocol || 1), 'X-UOLink-Version': String(config.protocol || 3),
} }
if (config.token) headers.Authorization = `Bearer ${config.token}` if (config.token) headers.Authorization = `Bearer ${config.token}`

View File

@@ -142,9 +142,15 @@ async function backfill() {
// snapshot() (which asserts an array under `key`). The shard also re-emits // snapshot() (which asserts an array under `key`). The shard also re-emits
// world.ruleset on its own connect — this covers the other order, where the // world.ruleset on its own connect — this covers the other order, where the
// sidecar was already up and holding the ruleset when WE reconnected. // sidecar was already up and holding the ruleset when WE reconnected.
//
// Routed through the dispatcher rather than straight to shardState, exactly as
// ingestEach does for the array-shaped boards: the two orders must produce the
// same stored frame, and calling setRuleset directly here made this a second
// write path that silently skipped the shard-name normalization the live frame
// gets. One writer, one set of rules.
const ruleset = await uoLinkClient.getRuleset() const ruleset = await uoLinkClient.getRuleset()
if (ruleset.ok && ruleset.data && ruleset.data.ruleset) { if (ruleset.ok && ruleset.data && ruleset.data.ruleset) {
await shardState.setRuleset(ruleset.data.ruleset) await shardIngest.ingest(ruleset.data.ruleset, { fromBackfill: true })
log.info('snapshotted shard ruleset from /ruleset', { rev: ruleset.data.ruleset.rev }) log.info('snapshotted shard ruleset from /ruleset', { rev: ruleset.data.ruleset.rev })
} }
@@ -197,7 +203,7 @@ async function connect() {
return return
} }
state.protocol = config.protocol || 1 state.protocol = config.protocol || 3
helloSeen = false helloSeen = false
const url = buildUrl(config.wsUrl, config.token) const url = buildUrl(config.wsUrl, config.token)

View File

@@ -96,3 +96,66 @@ test('a setRuleset failure does not throw or stop the broadcast', async () => {
assert.equal(r.logged, false) assert.equal(r.logged, false)
assert.equal(deps.calls.broadcast.length, 1) assert.equal(deps.calls.broadcast.length, 1)
}) })
// ── Shard name fallback ────────────────────────────────────────────────────
//
// ServUO ships Server.cfg with `Name=My Shard`. An operator who never edited it
// publishes that verbatim, which says "unnamed" rather than naming anything — so
// the site answers with its own instance name instead of printing the stock
// default under a header carrying the real one.
//
// Applied at INGEST, not on read, because world.ruleset is also broadcast live:
// the same object goes to the SSE fan-out, so a read-time fix would be undone by
// the next reconnect's frame. These tests assert both halves.
function withSettings(deps, name) {
return { ...deps, settings: { getInstanceName: async () => name } }
}
test('the stock ServUO shard name is replaced with the instance name', async () => {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
})
test('the substituted name reaches the live broadcast, not just the store', async () => {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.broadcast.length, 1)
assert.equal(deps.calls.broadcast[0].shard, 'UOMysticmoon')
})
test('a missing or blank shard name gets the same treatment', async () => {
for (const shard of [undefined, null, '', ' ']) {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
}
})
// The match is on the whole value, case- and padding-insensitive. A shard that
// deliberately calls itself "My Shard Reborn" has named itself and keeps it.
test('a real name that merely contains the stock one is left alone', async () => {
const deps = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: 'My Shard Reborn' }, withSettings(deps, 'UOMysticmoon'))
assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard Reborn')
const padded = makeDeps()
await shardIngest.ingest({ ...FRAME, shard: ' MY SHARD ' }, withSettings(padded, 'UOMysticmoon'))
assert.equal(padded.calls.rulesetSet[0].shard, 'UOMysticmoon')
})
test('a shard that named itself is never overridden by the brand', async () => {
const deps = makeDeps()
await shardIngest.ingest(FRAME, withSettings(deps, 'Some Other Brand'))
assert.equal(deps.calls.rulesetSet[0].shard, 'UOMysticmoon')
})
// The settings read is a DB call on a path that must never fail ingest.
test('a settings read failure leaves the frame storable', async () => {
const deps = makeDeps()
const boom = { ...deps, settings: { getInstanceName: async () => { throw new Error('db down') } } }
await shardIngest.ingest({ ...FRAME, shard: 'My Shard' }, boom)
assert.equal(deps.calls.rulesetSet.length, 1)
assert.equal(deps.calls.rulesetSet[0].shard, 'My Shard')
})