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>
225 lines
10 KiB
JavaScript
225 lines
10 KiB
JavaScript
// ── uo-link sidecar REST client ────────────────────────────────────────────
|
|
//
|
|
// Server-side HTTP client for the uo-link sidecar (the bridge to the ServUO
|
|
// shard). Same shape as botInternalClient: never throws — every call returns
|
|
// { ok, data, status, error } so an admin poll or a public page never 500s just
|
|
// because the sidecar/shard is down or restarting.
|
|
//
|
|
// The base URL + shared-secret token come from the DB-backed uoLinkConfig
|
|
// (admin-managed, encrypted at rest) — NOT env vars, and the token is NEVER sent
|
|
// to the browser. Every request carries `Authorization: Bearer <token>` and
|
|
// `X-UOLink-Version: <protocol>` so a protocol mismatch is caught (409) rather
|
|
// than mis-parsed. Config is cached for a few seconds to avoid decrypting the
|
|
// token on every call.
|
|
|
|
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
|
|
const log = require('./logger')('uo-link-client')
|
|
|
|
const TIMEOUT_MS = 12000 // sidecar waits up to 10s on the shard before 504
|
|
const CONFIG_TTL_MS = 5000
|
|
|
|
let cachedConfig = null
|
|
let cachedAt = 0
|
|
|
|
// Read (and briefly cache) the connection config incl. decrypted token.
|
|
async function resolveConfig() {
|
|
const now = Date.now()
|
|
if (cachedConfig && now - cachedAt < CONFIG_TTL_MS) return cachedConfig
|
|
cachedConfig = await uoLinkConfig.getWithToken()
|
|
cachedAt = now
|
|
return cachedConfig
|
|
}
|
|
|
|
// Drop the cache after a save so the next call picks up new URL/token immediately.
|
|
function invalidateConfig() {
|
|
cachedConfig = null
|
|
cachedAt = 0
|
|
}
|
|
|
|
// Core request. Returns { ok, data, status, error }. `ok` is true only on a 2xx
|
|
// with a parseable JSON body. Non-2xx responses still return their status + body
|
|
// so callers can distinguish 503 (shard restarting — transient) from 404.
|
|
async function call(path, { method = 'GET', body } = {}) {
|
|
const controller = new AbortController()
|
|
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
|
// resolveConfig() decrypts the stored auth token, and decryption THROWS when the
|
|
// ciphertext can't be authenticated — SECRET_ENC_KEY was rotated, or a DB dump was
|
|
// restored into an environment keyed differently. It must stay INSIDE the try: out
|
|
// here it escaped `call()` entirely and 500'd every live-shard route (admin and
|
|
// player character/roster/vendor lookups, GET /admin/uo-link/config) instead of
|
|
// degrading to "shard unavailable". This module never throws — see the header.
|
|
let configResolved = false
|
|
try {
|
|
const config = await resolveConfig()
|
|
configResolved = true
|
|
if (!config || !config.baseUrl) {
|
|
return { ok: false, status: 0, error: 'uo-link is not configured' }
|
|
}
|
|
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'X-UOLink-Version': String(config.protocol || 1),
|
|
}
|
|
if (config.token) headers.Authorization = `Bearer ${config.token}`
|
|
|
|
const res = await fetch(`${config.baseUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
signal: controller.signal,
|
|
})
|
|
|
|
let data = null
|
|
try {
|
|
data = await res.json()
|
|
} catch {
|
|
// Non-JSON (or empty) body — leave data null; status still reported.
|
|
}
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) log.warn('uo-link rejected auth token (401)', { path })
|
|
if (res.status === 409) log.error('uo-link protocol mismatch (409)', { path, body: data })
|
|
return { ok: false, status: res.status, data, error: `sidecar responded ${res.status}` }
|
|
}
|
|
return { ok: true, status: res.status, data }
|
|
} catch (err) {
|
|
// A failure before the config resolved is a misconfiguration, not a flaky
|
|
// sidecar: log it loudly (and distinctly) so "the shard looks offline" doesn't
|
|
// silently mean "the token can no longer be decrypted".
|
|
if (!configResolved) {
|
|
log.error('uo-link config unreadable — is SECRET_ENC_KEY the key the stored token was encrypted with?', {
|
|
path,
|
|
message: err.message,
|
|
})
|
|
return { ok: false, status: 0, error: 'uo-link config unreadable' }
|
|
}
|
|
log.warn('uo-link call failed', { path, message: err.message })
|
|
return { ok: false, status: 0, error: err.message }
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
}
|
|
}
|
|
|
|
// ── Read queries ───────────────────────────────────────────────────────────
|
|
// Liveness (no auth required by the sidecar, but we send it anyway).
|
|
const health = () => call('/health')
|
|
const getCharBySerial = (serial) => call(`/char/serial/${encodeURIComponent(serial)}`)
|
|
const getCharBySlot = (account, slot) =>
|
|
call(`/char/${encodeURIComponent(account)}/${encodeURIComponent(slot)}`)
|
|
const getRoster = (account) => call(`/roster/${encodeURIComponent(account)}`)
|
|
const getVendors = (account) => call(`/vendors/${encodeURIComponent(account)}`)
|
|
|
|
// History / economy series — used for WS-reconnect backfill and public feeds.
|
|
function getHistory({ kind, limit = 100 } = {}) {
|
|
const params = new URLSearchParams()
|
|
if (kind) params.set('kind', kind)
|
|
if (limit) params.set('limit', String(limit))
|
|
const qs = params.toString()
|
|
const suffix = qs ? `?${qs}` : ''
|
|
return call(`/history${suffix}`)
|
|
}
|
|
const getEconomy = (limit = 100) => call(`/economy?limit=${encodeURIComponent(limit)}`)
|
|
// Live board / queue projections — snapshotted on WS (re)connect and served from
|
|
// 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)
|
|
// Protocol 3.0: the shard's published ruleset. Object-shaped, not a board — the
|
|
// sidecar answers `{ ruleset: null }` until the shard has published one.
|
|
const getRuleset = () => call('/ruleset')
|
|
// Protocol 3.0: points/loyalty leaderboards. `/points` is board-shaped (an array
|
|
// under `boards`); the per-system read 404s for a system the shard never published.
|
|
const getPoints = () => call('/points')
|
|
const getPointsBoard = (system) => call(`/points/${encodeURIComponent(system)}`)
|
|
// Protocol 3.0: the player-vendor market index. The one PAGED sidecar read — a
|
|
// whole-world market does not fit in a response — so it answers with
|
|
// `{ vendors, total, limit, offset }` and the caller walks it (see uoLinkSocket).
|
|
const getMarket = ({ limit = 200, offset = 0 } = {}) =>
|
|
call(`/market?limit=${encodeURIComponent(limit)}&offset=${encodeURIComponent(offset)}`)
|
|
|
|
// ── Commands ──────────────────────────────────────────────────────────────
|
|
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' })
|
|
|
|
// 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
|
|
// for attribution and echoes an admin.audit event back over the WS feed.
|
|
const adminKick = ({ actor, account, serial }) =>
|
|
call('/admin/kick', { method: 'POST', body: { actor, account, serial } })
|
|
const adminBan = ({ actor, account, serial, durationSec, reason }) =>
|
|
call('/admin/ban', { method: 'POST', body: { actor, account, serial, durationSec, reason } })
|
|
const adminUnban = ({ actor, account }) =>
|
|
call('/admin/unban', { method: 'POST', body: { actor, account } })
|
|
const adminBroadcast = ({ actor, text, hue }) =>
|
|
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue } })
|
|
|
|
// ── Help-page (support) queue commands (§6) ────────────────────────────────
|
|
const respondPage = (pageId, { message, close }) =>
|
|
call(`/pages/${encodeURIComponent(pageId)}/respond`, { method: 'POST', body: { message, close } })
|
|
const closePage = (pageId) => call(`/pages/${encodeURIComponent(pageId)}/close`, { method: 'POST' })
|
|
|
|
module.exports = {
|
|
invalidateConfig,
|
|
health,
|
|
getCharBySerial,
|
|
getCharBySlot,
|
|
getRoster,
|
|
getVendors,
|
|
getHistory,
|
|
getEconomy,
|
|
getChamps,
|
|
getPages,
|
|
getGuilds,
|
|
getGovernors,
|
|
getHouses,
|
|
getPresence,
|
|
getRuleset,
|
|
getPoints,
|
|
getPointsBoard,
|
|
getMarket,
|
|
confirmLink,
|
|
linkLookup,
|
|
createAccount,
|
|
unlinkAccount,
|
|
postTownCrier,
|
|
deleteTownCrier,
|
|
postNews,
|
|
deleteNews,
|
|
adminKick,
|
|
adminBan,
|
|
adminUnban,
|
|
adminBroadcast,
|
|
respondPage,
|
|
closePage,
|
|
}
|