feat(shard)!: Protocol 3.0 cutover — visibility framework, spawn atlas, marketplace #118
Reference in New Issue
Block a user
No description provided.
Delete Branch "edge"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What & why
Order 6 of the Protocol 3.0 plan (
v3.md§4) — theedge→maincutover, website side. One of four PRs that merge together; that merge is thePROTOCOL_VERSION2 → 3 bump. 19 commits, six phases:Part A — the visibility framework, and a live leak (#109, #110)
shardBroadcast.jswent from a static public/admin allowlist to per-connection audience filtering over all ten shard surfaces, with an admin panel at/admin/shard-visibility. Two rules are locked and cannot be loosened:acct/webIdare admin-only always, and a kind absent from the kind→feature map is never broadcast belowadmin(fail closed).It shipped first and alone because it closes a verified live leak:
BridgeJson.Actor()writesacct+webId,shapeGuild()returned the payload verbatim, andGET /public/shard/guildsis anonymous — so guild leaders' game account names and website user ids were public.#110 is the one worth reading. Running the §11 smoke test live found Part A had enforced visibility on the SSE path and on
/guilds+/governorsbut not on the other public REST reads, so the same event was projected live and served verbatim from stored history —/feedreturnedacct/webIdanonymously for every logged kind, broader than the leak Part A set out to close. The rule that came out of it, and that the rest of 3.0 was built to: a read path returning shard data that does not callprojectFeatureis a bug.Part C — the spawn atlas (#112, #113)
Where things spawn, parsed from the shard's own
Spawns/*.xml+Regions.xmlon every boot, hash-gated — no committed snapshot, because it goes stale as maps change. Facet names are discovered, never hardcoded. A refresh that would remove a facet is staged for admin approve/reject rather than applied, since facet loss is indistinguishable from a half-copied tree. No artwork is ever shipped; text-only is the supported default.Part B — the three new streams
world.ruleset(#111) →/site/rules. Skill caps arrive in tenths, converted client-side because the raw number is actively misleading.points.board(#114) →/site/leaderboards.Cliloc.*compressed, and ServUO's ownUltima.StringListcan't read it either. Resolution is an operator-run one-time export, reading a set of sources (base +custom/overlays) so shard-added items get names too.vendor.listing(#116) →/site/market, the player-vendor marketplace with search, filters and vendor detail.The bump (#117 — must merge into
edgebefore this PR)The declared version moves to 3 in five places, and
uo_link_config.protocolmigrates itself on boot — gated on a marker row so it fires exactly once and never un-pins an operator who deliberately set an older version.Breaking change
The site declares
X-UOLink-Version: 3after this, so it requires a v3 sidecar (link #21) — an older one 409s every REST call and drops the WS on thews.hellomismatch. Deploy the two together. No admin action is needed: the boot migration moves the pinned version.How it was tested
npm run buildall green on this branch.?q=%and?q=_correctly returning 0 (LIKE metacharacters are escaped), visibility gating live, and the rate limiter firing.ensureSchema()against the local MariaDB, including the one-shot property (pinned back to 2 by hand, re-ran, stayed 2).Note:
pr-checks.ymlonly triggers on PRs intomain, so no v3 PR has ever run CI. This is the first CI run over the whole stack — the local runs above are what it has been gated on until now.Merge order
edge(the bump), link #20 →edge, docs #72 →edge.This PR's diff picks up #117 automatically once that merges.
Checklist
AI-assisted contributions (required)
Claude Code. I have reviewed and understandevery change, and take responsibility for it. AI-authored commits are
marked with a
Co-Authored-By/Assisted-Bytrailer.License
(GNU GPL v3.0 or later), and I have the right to contribute it.
Protocol 3.0 Part A. Replaces the static PUBLIC_KINDS allowlist - which was the entire public/admin boundary - with per-feature, per-field audience control an admin owns from Admin -> Shard Visibility. Closes a live leak. BridgeJson.Actor() writes acct and webId; shapeGuild() returned the stored payload verbatim; GET /api/v1/public/shard/guilds is anonymous. Guild leaders' game account names and website user ids were readable by anyone, and the same path existed for governors. Both are now projected. The ladder is anonymous < logged_in < player < staff < admin, each rung implying the ones below. Staff satisfy `player` without a linked account (as /player/* already does); `editor` is a content role and gets no shard privilege, since mapping it to staff would silently widen what editors see. Two invariants are code, not configuration, and both reject rather than silently ignore: 1. acct/webId are admin-only always - not configurable, discarded on read as well as rejected on write. 2. A kind absent from KIND_FEATURE never reaches anyone below admin. Fail closed, so a shard emitting a new event degrades to staff-only rather than to public. Enforcement is three points over one config: requireFeature() on routes (404 disabled, 403 out-of-rung) plus field projection; per-connection filtering on SSE, where a subscriber's rung is resolved once at subscribe time and frozen so a long-open stream cannot gain privilege; and /public/shard/features so the SPA hides links it cannot follow. PUBLIC_KINDS still exists and is still exported (/feed filtering, notificationStreams) but is now derived from the kind map, so the two can no longer drift. Defaults reproduce pre-3.0 behavior exactly - a test pins the derived set against the old allowlist. Also fixes an SSE resource leak found while testing: a client dropped because its write threw was removed from the bucket but its keepalive interval was never cleared, firing forever on a dead socket. Both paths now go through one drop(). Tests: 478 server (33 new across shardVisibility + shardBroadcast), 43 client. Route manifest and OpenAPI spec regenerated. Co-Authored-By: Claude <noreply@anthropic.com>Protocol 3.0 Part A follow-up, found by the live five-rung smoke test. Part A implemented the visibility framework correctly on the SSE path and on /guilds + /governors, but the remaining public REST reads never called into it. The result was that one event was projected live and served verbatim from history: * GET /public/shard/feed returned the stored payload as-is, so actor.acct and actor.webId were readable ANONYMOUSLY for every logged kind - player.death, player.murdered, mob.killed, quest.complete, skill.gain, fame/karma.change, mob.login/logout, guild.join. Broader than the guild-leader leak Part A set out to close, since it covers every player rather than board holders. * GET /public/shard/idoc returned ownerAcct - the house owner's game account - to anonymous callers. * The `houses` field rules (owner/price -> staff) were dead config: neither getIdoc nor getHouses projected, so an admin could set them in the panel and nothing happened. * /feed filtered on PUBLIC_KINDS, a module-load constant derived from the compiled DEFAULTS, so live audience changes did not reach it. With `guilds` moved to staff, /guilds 403'd while /feed happily served guild.join to anonymous. Four fixes, all at the root rather than per-route: 1. Rule 1 now matches a field's MEANING, not one spelling. The wire nests actors (leader.acct) but the read models flatten them (shapeHouse -> ownerAcct, shapeGuild -> leaderWebId), and an exact-key check missed every flattened one. isLockedField() locks a key that is or ends in acct/webId, case-insensitively, so it fails closed for shapes not yet written. The admin PUT rejects those spellings too - `ownerAcct` is no longer configurable. 2. visibleKinds(level, config) resolves readable kinds from the LIVE config; getFeed uses it and projects each row against its own kind's feature. Deliberately independent of the `stream` flag, which governs SSE fan-out only - so market history stays readable with its firehose off. This makes the set a superset of PUBLIC_KINDS by exactly the two vendor kinds. 3. getIdoc/getHouses/getChamps/getPresence project, so every shard surface honours the same config. 4. shardEvents.db.list treats an EMPTY kinds array as "serve nothing". It previously fell through to the unfiltered query, so a fully-gated config would have dumped the whole event log, staff audit included. Also fixes a bug introduced while wiring this up: projectValue recursed into any object, so a Date column came back as {}. It now walks arrays and plain objects only. The unit tests used JSON fixtures and could not have caught it - the live /idoc read did. Verified live against MariaDB + a stub sidecar, all five rungs: 13 routes x 5 rungs, defaults reproducing pre-v3 access exactly, zero acct/webId below admin on any read, unmapped kinds (staff.command, cheat.detect, login.attempt) reaching only admin on SSE, and audience / enabled / stream changes taking effect live on an already-open stream. Tests: 487 server (+9). Swagger regenerated; route manifest unchanged. Co-Authored-By: Claude <noreply@anthropic.com>Protocol 3.0 §5 (docs/link/v3.md). The shard publishes its own ruleset — expansion, which optional systems are on, skill/stat caps, account and house limits, champion scroll rules, the save/restart schedule — and the site renders it, so the rules page cannot drift from how the shard actually plays. Server - shard_ruleset: a singleton table (id = 1) holding the whole frame in `payload`, with `rev` and `expansion` hoisted. Nothing is normalized out: the frame is a flat description of config read as one page, and splitting it into columns would mean a schema change every time the shard grows a block. - shardIngest routes world.ruleset to setRuleset and deliberately does NOT log it — the shard re-emits the whole ruleset on every sidecar connect, so logging would append a duplicate row per reconnect, and server.hello already marks each of those. - uoLinkSocket backfills GET /ruleset explicitly rather than via snapshot(), which asserts an array; this covers the order where the sidecar was already up and holding the ruleset when we reconnected. - GET /public/shard/ruleset behind requireFeature('ruleset') and projected, per §3.6.1's rule that a shard read which doesn't project is a bug. `null` means the shard has never published one — a real answer, distinct from a published ruleset, and the page says so. Client - routes/public/Rules.jsx at /site/rules, live via world.ruleset (a frame is a complete ruleset, not a delta, so the newest one wins outright). Caps are rendered from tenths — 7000 is 700.0, and showing the raw number would mislead. A systems key this build doesn't know still renders, humanised, so a newer plugin can't go invisible against an older client. - Nav entry gated on the `ruleset` feature, so it hides rather than 403s. Verified end to end against the local MariaDB and a sidecar fed by a fake shard: backfill snapshot, live SSE delivery of a changed ruleset, REST reflecting the overwrite, an empty /feed (not logged), and the gate — 200 by default, 403 at audience=staff (and dropped from /features so nav hides it), 404 when disabled. Page rendered clean at all breakpoints checked, no console errors. 497 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI spec regenerated. Co-Authored-By: Claude <noreply@anthropic.com>Protocol 3.0 order 3 (Part C), first of two website PRs. This half is the data pipeline only — parsers, the build/import CLI, and the tables. No routes and no client, so nothing is user-visible yet; the API and pages follow in PR 2. Part C is website-only: no plugin, no sidecar, no new event kinds, no wire change. ## Parsing `src/utils/spawnAtlasParse.js` is pure and fs-free so CI covers it with no ServUO tree. Zero new dependencies — `Regions.xml` genuinely nests, so it gets a small hand-rolled subset tokenizer rather than a new XML package. The 10.5 MB of `Spawns/*.xml` never touches it: those records are flat and get a streaming regex sweep instead. The high-value transform is point-in-rect placement — highest region priority wins, ties break to the smaller rect, then a nearest-landmark fallback within 200 tiles, else "Wilderness". That is what turns "lizardman at 5411,1234" into "Despise, Felucca", and it resolves 83.2% of points (5,369 of 6,455). Three things the real data forced, none of which were in the design: - **Only 6 facets, not 13.** `Eodon.xml`, `GravewaterLake.xml` and the other named-area files carry TerMur/Trammel points, so the facet comes from each record's own `<Map>` and the artifact shards 6 ways. - **Facet names disagree across sources.** `Data/Locations/*.xml` spells them `Ter Mur` and `Tokuno Islands`; `<Map>` and `<Facet name>` say `TerMur` and `Tokuno`. Unreconciled this is silent — the landmark fallback simply never fires on those facets and every unregioned spawn there reads "Wilderness". - **Spawn type tokens carry XmlSpawner directives**: `Fairy,{RND,4,8}`, `alchemist/z/-50`, `Agralem/Name/Agralem`. Taken literally these invent creatures that do not exist AND split real ones in two, since `Fairy` and `Fairy,{RND,4,8}` slug apart. 71 of 845 entries were affected; stripping at the first `/` or `,` leaves 800 clean ones. ## Artifact `npm run atlas:build -- --servuo <path>` writes `db/data/spawnAtlas.*.json`: 6 facet shards + a compact index + a small indented `meta`. 1.41 MB committed, down from 4.40 MB by dropping `facet` per record, omitting defaulted fields, and tuple-encoding the ~24,000 type entries. `encodePoint()` and the importer's `readPoint()` are exact inverses and are round-tripped in tests. Display spelling is chosen deterministically (most common, ties to the capitalised form) because the spawn files are inconsistent about case and the name would otherwise depend on file read order — a spurious diff on every unrelated rebuild. ## Import `npm run atlas:import` needs no ServUO tree, which is the whole reason build and import are separate: the container has the artifact but not the tree. It reloads all six tables in one transaction (DELETE, not TRUNCATE, which is DDL and would implicitly commit), so a failed import leaves the previous atlas intact. ## No artwork, by design The repo ships no creature art and no extraction tooling. Sprites live in the operator's own client `.mul`/`.uop` files and are theirs, not ours to redistribute. `shard_spawn_creatures.art` is nullable and NULL on every fresh import; an operator who wants art extracts it themselves, drops it under `server/uploads/atlas/` (already gitignored) and maps slugs in a gitignored `spawnAtlas.art.json`. Text-only is the normal, fully supported state. ## Verification - **544 server tests pass**, 57 new across `spawnAtlas.parse.test.js` (the `:OBJ=` split, directive stripping, nested-region priority inheritance, half-open rects, the facet reconciliation, tokenizer edge cases) and `spawnAtlas.build.test.js` (aggregation, deterministic naming, and the encode/decode round trip). - Built and imported for real against the local MariaDB and the ServUO tree at `C:\Users\colby\Desktop\ServUO`: 6,455 points, 800 creatures, 23,927 point/type rows, 387 regions, 558 landmarks, 25 champion altars. - "Where does a lizardman spawn?" answers Shrines / Isamu-Jima / Yew across Felucca, Trammel and Tokuno. No routes changed, so the OpenAPI spec and route manifest are untouched. --- - [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bPProtocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty leaderboards — Queen's Loyalty, Void Pool, the nine city loyalties, Clean Up Britannia — and the site renders them, plus each character's own standings on their sheet. Server - shard_points_boards: one row per system, keyed by the shard's PointsType name. The top-N list stays inside `payload` — a fixed-size list read whole, exactly like shard_governors.candidates. Normalizing into an entries table buys nothing until something needs a per-character reverse lookup, and a character's own standings already ride inside char.profile. - shardIngest routes points.board to upsertPointsBoard and deliberately does NOT log it: this is board state like guild.update, and the shard emits a frame every time anyone's score moves a top ten. - uoLinkSocket backfills /points through snapshot() with ingestEach rather than a replace*: there is no points.remove and the system set is fixed, so upserting IS the reconciliation, and a system the operator later excludes keeps its last-known board rather than vanishing. - GET /public/shard/points and /points/:system behind requireFeature('leaderboards'), both projected per §3.6.1. :system is constrained to an identifier before any query runs; 404 for a system never published, distinct from a published board nobody has scored in (200, empty top). The leaderboards field rule now keys on `name`, not `characterName` Part A pre-wired FEATURES.leaderboards.fields = { characterName: ... }, but projectValue matches on the LITERAL JSON key and the wire key is `name`. As written the rule was inert: an admin tightening character names would have got no enforcement and no error — precisely the failure §3.6.1 records for the flattened `ownerAcct` spelling. Fixed, with a test that fails if it is renamed back, and the admin panel's FIELD_LABEL carries the meaning instead. Client - routes/public/Leaderboards.jsx at /site/leaderboards. A points.board frame describes ONE system, so live frames merge over the fetched set by system key rather than replacing it wholesale the way the ruleset does. Filter matches board name, system key, or any ranked player — the last is what makes it useful ("where do I appear?"). - A "Loyalty & Points" section in CharacterSheet.jsx, one edit serving both PlayerCharacter and AdminCharacter. - Both treat maxPoints: 0 as UNCAPPED and both fall back to humanising the system key when nameString is null. Neither is defensive padding: on a real shard uncapped and cliloc-only names are the majority case. Verified end to end against the local MariaDB, the Rust sidecar, and the real ServUO shard: backfill from /points, live SSE delivery (a board absent from the initial fetch appearing without a reload, and an existing one updating in place), REST reflecting the overwrite, and the gate at every rung — 200 by default with names, names stripped but points kept at fieldRules name=staff, 403 plus dropped from /features at audience=staff, 404 when disabled. Page rendered clean, no console errors beyond the pre-existing React Router v7 warnings. 605 server tests pass; routes.manifest.json, routes.guards.json and the OpenAPI spec regenerated. Co-Authored-By: Claude <noreply@anthropic.com>Protocol 3.0 §8.6 (docs/link/v3.md), the dependency order 5 was sequenced behind. Items on the wire carry a LabelNumber, not a name — the bridge has always sent it (char.profile.equipment.cliloc, reward titles as a cliloc number in string form, and one per marketplace listing) but the site had no table to resolve it against, so a character sheet could only render `id 1023721` where the game renders "quarter staff". The number was never the missing piece. The table was. Sourced from a file the operator converts once from their own client, at a path from the `cliloc_client_path` setting falling back to UO_CLIENT_PATH. Nothing client-derived is committed: UO's strings are EA's, exactly as the creature sprites are. A shard with nothing configured is fully supported — names render as ids, as they did before. The conversion step is not avoidable, and that is the substantive finding here: every current client ships its cliloc files COMPRESSED (first DWORD's high byte 0x8E, the Mythic container), and ServUO's own bundled Ultima.StringList cannot read that either — so VendorSearch.GetItemName is already inert on such a shard and the plugin could not supply names instead. v3.md's original "read the client's Cliloc.enu" recommendation was therefore not implementable as written, and its committed db/data/clilocs.json artifact also predates the Part C corrections (no committed derived snapshots, nothing EA-derived shipped). Replaced with the spawn-atlas pattern: parse on boot from an operator-configured path, hash-gated, output gitignored. - utils/clilocParse.js — pure parsers, fs-free so the suite runs in CI. Accepts the plain binary layout and delimited text, sniffed by header rather than extension. Rejects a compressed file BY NAME: without that check the plain parser reads it as ~19k records of negative ids and 60 KB "strings" before dying mid-file, and the resulting error names the wrong problem. displayText() drops the ~1_val~ arguments the bridge never sends. - utils/clilocSource.js — the fs layer. hashSource reports `compressed` so the admin panel can flag an unconverted file WITHOUT parsing 5 MB per poll; otherwise pointing at a client directory reports a healthy file with pending drift ("ready to import") and the operator only finds out on failure. - model/shardClilocs — refresh/status/lookup. All-or-nothing replace (DELETE, not TRUNCATE — TRUNCATE is DDL in MariaDB and implicitly commits). Batched server-side resolution behind a capped cache; never throws, because a cliloc lookup is decoration on a character sheet. - Deliberately NO staged-approval flow, unlike the atlas: the atlas escalates facet loss because a half-copied tree and a real map change are indistinguishable from inside the process, whereas a partial cliloc copy makes the parser fail on a truncated record. The ambiguity the atlas must escalate is one this parser simply detects. - No public route. The table is never served AS a table: 67k rows would dwarf any page using them, and the Android client consumes the same resolved JSON. Two parser bugs found by building it, both now covered by tests: trimming a text line before splitting ate the trailing separator on empty-text entries and silently dropped 55,994 of 123,490 while still reporting success; and Number('') is 0, not NaN, so a line starting with a separator imported as a bogus cliloc 0. Verified against the real client table (123,490 entries) and the live MariaDB: import 663 ms, hash-gated boot no-op 14 ms, cold resolve 4.2 ms / warm 0.015 ms. Binary and TSV imports converge on the same 67,496 rows with identical keys (blank entries — half the table — are dropped at import). A file truncated to half its length is refused with TRUNCATED and leaves the previous table serving. Boot logs verified for both the import and the compressed-file warning; neither blocks startup. All three admin routes exercised over HTTP with a real session. 629 server tests pass; client builds clean; swagger, routes.manifest.json and routes.guards.json regenerated. Not covered by an automated test: the character sheet renders resolved names in presentational React with no DOM test harness in this repo, and was not rendered against a live linked-player profile — that needs a logged-in player with a linked game account and a shard answering a profile RPC. Co-Authored-By: Claude <noreply@anthropic.com>Shards edit items and add new ones, and those carry cliloc ids no stock client table has. Reading exactly one converted file meant an operator had to re-export 5 MB every time they added one item — friction enough that the table would simply go stale, which is the failure the spawn atlas was redesigned to avoid in the first place. So this mirrors spawnAtlasSource.readSources(): a BASE (the converted client table) plus every operator-maintained overlay under `custom/`, all re-read on every boot and hash-gated as a SET. Later sources win, so an overlay both adds ids the client never had and overrides stock ones the shard re-purposed. Adding, editing or removing any overlay counts as drift. `custom/` is the one convention here that is ours rather than the shard's, and deliberately so: ServUO has no server-side notion of a custom cliloc — they live in the patched client a shard distributes, and nothing in the tree declares them. There is nothing to discover. (An operator who does patch their client cliloc needs no overlay: convert the patched file and the edits are in the base.) Scale, measured on the live shard: its script tree references 16,434 cliloc ids and only 37 are absent from stock — tens against a 67k base, which is why this is an overlay and not a second table. The set brings back a hazard a single file did not have, and it gets the atlas's answer. A corrupt source fails the parse loudly, but a source that has VANISHED parses perfectly and imports a table quietly missing everything it contributed — an unmounted volume is indistinguishable from a deliberate deletion. So it is staged, not applied (`needsReview`), reported by both the import and status(), and accepted with `{approve:true}`. That is a flag rather than the atlas's approve/reject pair because the atlas stores a pending decision SO THAT approving re-parses; here nothing is stored, so re-reading at approval time is automatic. Also reports a per-source breakdown (entries/added/overrode) on import and in status, which is how an operator confirms an overlay took effect — "overrode: 0" on a file meant to re-label stock items says it did not. Two bugs this surfaced, both found by running a shard-style overlay rather than by another stock-table fixture: - displayText tidied punctuation unconditionally, so a custom "Runic Gateway Sigil (v2)" rendered as "(v2". Stripping leftover brackets is right after a placeholder is removed and wrong otherwise — the same condition the `%` rule already had. - CANDIDATE_NAMES did not include `clilocs.plain`, which is the exact filename CLILOCS.md and the export tool's README tell operators to write. Pointing at the directory they were told to create failed with NO_FILE. Verified end to end against the live MariaDB and a real server boot: base-only import, overlay adding one id and overriding another (per-source breakdown correct), unchanged set as a no-op, an edited overlay re-importing and withdrawing its override, a vanished overlay refused with the table intact, status reporting missingSources, approve applying it, and a file-path configuration still finding overlays beside it. All three resolve correctly through the running server: shard-added, overridden and stock. 646 server tests pass (16 new in clilocSource.test.js, 3 new in clilocParse.test.js); swagger, routes.manifest.json and routes.guards.json regenerated. Co-Authored-By: Claude <noreply@anthropic.com>Protocol 3.0 §8, the website half. Ingests vendor.listing / vendor.listing.remove into shard_vendors + shard_vendor_items, serves a searchable public API over them, and ships /site/market and /site/market/vendors/:serial. Three things the pages have to say out loud, all consequences of how the data is gathered: - The prices are NOT live. The shard sweeps vendors round-robin, so a shop can be a full cycle behind. The banner is driven by the OLDEST vendor row, not the newest — the one stale shop is the one that wastes somebody's trip. - A shop can be truncated. `total` exceeding `count` means the shop holds more than the shard publishes per frame; the vendor page says "showing 250 of 3,104" rather than presenting a partial shop as complete. - An item may have no name. On a shard with no cliloc table the honest render is the item id, never an invented label. ## The pre-wired visibility rules, re-checked Part A pre-wired market.ownerName and market.location before the frame existed, and the sibling rule it pre-wired for leaderboards (`characterName`) turned out to be INERT because projectValue matches literal JSON keys. Both market rules were checked against the real frame this time: - `ownerName` is a real key. Kept. - `location` is a real key ONLY because the frame nests it. Flat map/x/y/region would have made the rule match nothing — the same failure, one part later. It is nested on the wire and on the read model so one rule hides the facet, the coordinates, the region and the house together; five flat keys would be five rules that drift apart. - `ownerSerial` was ADDED. An admin who hides the owner's name and leaves a serial that the leaderboards and guild boards resolve back to that same name has not hidden anything. Tests assert all three bite, on the stored read model AND on the raw frame — the market's SSE stream is off by default but an admin can turn it on, and a rule that worked on only one path is exactly the leak §3.6.1 records. ## Notable - **No payload column on shard_vendors**, unlike shard_points_boards next door. The board's top-N is a fixed-size list read whole; here the items ARE the searchable rows, so they are normalized and nothing is left worth duplicating. - **display_name is denormalized at ingest** (literal name preferred over the cliloc — a player set it, so it is more specific). Resolving at query time would put the cliloc table on the hot path and make search-by-name impossible. Because the shard's diff sweep will not re-send an unchanged shop just because the site learned what its items are called, a cliloc import now triggers a bulk re-resolution — 50 ms per thousand rows, never throws. - **updated_at is written explicitly** on every upsert. MariaDB does not fire ON UPDATE CURRENT_TIMESTAMP when every column is written back unchanged, and a shop re-published identically is still freshly confirmed — without this the staleness banner would age a perfectly current shop forever. - **LIKE wildcards in `q` are escaped.** `%` and `_` are LIKE metacharacters, not SQL ones, so parameterization does not neutralize them: `?q=%` would otherwise match every listing on the shard. - **Rate-limited** (60/min/IP), the only limited public read. Every other public GET is an indexed lookup of bounded size; this is a LIKE scan plus a COUNT over the largest shard_* table, anonymous by default. - Reconnect backfill pages /market, bounded by MARKET_SNAPSHOT_MAX = 5000 and stopping on a short page as well as on `total`, so a concurrent sweep shrinking the index cannot spin the walk. ## How it was tested 673 server tests pass (27 new). Client builds clean; swagger-output.json, routes.manifest.json and routes.guards.json regenerated. Verified full-stack against the live MariaDB and a real shard, not only units: - 27 real vendors / 1,040 listings swept off the ServUO tree, through the Rust sidecar, into the site — names resolving through the cliloc table ("longsword", "katana"), real facets and regions in the filters. - `?q=sword` 682, `?q=%` and `?q=_` **0** (the escape), map/region/price/sort filters, paging, and the vendor detail route. - Visibility live: fields gated to staff vanish for an anonymous caller while shopName and price survive; audience=player 403s; enabled=0 404s; and /shard/features correctly drops `market` so the nav hides it. - Re-publishing a shop smaller leaves no orphan items; an identical re-publish moves updated_at. - The limiter fires (38x200 then 32x429 on a 70-request burst). Not covered by an automated test: the two React pages are presentational and this repo's client suite covers pure-logic modules only. They were driven against the live API above, but not rendered in a DOM harness. Co-Authored-By: Claude <noreply@anthropic.com>