Files
Module-uo/server/utils/uoLinkClient.js
wtclaude 10fde87724
All checks were successful
PR Checks / server-tests (pull_request) Successful in 38s
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 53s
feat(events): what an author borrows, and two one-shots (Phase 12b)
Five targeted leases over two planes, the item grant, the world save, and the
atlas work the spawner dropdown needed.

FIVE LEASES, ONE FACTORY

`uo.spawner.maxcount`, `.mindelay`, `.maxdelay`, `.running` and
`uo.seasonal.status`. The four callables differ only in which key they name, so
they are built rather than repeated: five copies would be five chances for one of
them to forget the drift check, which is the one thing §F says a lease must not
be allowed to skip.

It is `MaxCount`, not the `Amount` EVENTS_PLAN.md named -- there is no such
property on ServUO 57.4. `MinDelay`/`MaxDelay` are TimeSpans, so the wire carries
SECONDS: the spawn files' own `DelayInSec` flag proves both units are in use on a
real tree, and a unit that cannot express five seconds cannot express this
shard's own data.

The seasonal lease is a THREE-value enum over EIGHT events. §G called
`GetEntry(type).Status` "a nine-value enum" and had it backwards: `EventStatus`
has three values and it is `EventType` that has nine entries. Eight rather than
nine because `TreasuresOfTokuno` is excluded -- `IsActive()` reads its own
`DropEra` rather than `Status`, so leasing it would apply cleanly, read back,
restore cleanly and do nothing at all.

Two behaviours worth the review. `inForce()` reads the frame's `holds` rather
than a row's `held` flag, because a catalog walk can enumerate the keys but never
the holds on a targeted one. And a target that VANISHED mid-run is a SUCCESSFUL
restore: there is nothing to give back, and reporting it failed would leave a
ledger row unresolved for ever over an object that is gone -- 12a's `gone` in the
lease plane's vocabulary.

THE GRANT NAMES A RUN, NEVER A RECIPIENT LIST

Core has the participants in `event_run_participants`, but a module cannot read
core's tables -- so the alternative was a new core surface handing them over. Not
needed: the shard has held the run's ledger since it opened, keyed by the same
serials core stores as `member_key`.

And the grant is RETRYABLE. §G called it un-retryable because a lost
acknowledgement and a grant that never applied were the same event, which is
exactly the argument that made `uo.broadcast` answer `retry: false` in Phase 9.
Protocol 6's idempotency key closes it. `uo.rewards` counts ITEMS rather than
grants: 500 gold to forty people and a candle to forty people are not the same
imposition.

THE ATLAS KEEPS UniqueId AGAIN, AND THE SPAWNER SOURCE SEARCHES

The parser has read `<UniqueId>` and thrown it away since the atlas shipped, on a
line citing a committed artifact -- there is no committed artifact, as
`spawnAtlasSource.js` says in its own header. It is the ONLY name for one
particular spawner that exists off the shard, so a property lease could not have
had a dropdown without it. `PARSER_VERSION` -> 4 so an unchanged tree is re-read.

`uo.options.spawners` is the first searchable source and the first that had to
be: 6,707 spawn points against `MAX_OPTIONS`' 2,000, so a flat list would drop
two thirds of the world and say nothing about which two thirds.

ONE DEFECT IN ALREADY-MERGED CODE, AND IT WOULD HAVE BROKEN EVERYTHING

The protocol pin never left 5. `uo_link_config.protocol` reaches the sidecar as
`X-UOLink-Version` on every REST call and an exact mismatch is a 409, so from
Phase 11a onward every sidecar call on a real deployment would have been refused
-- the whole event plane dead, loudly, for a reason nobody would look here for.
11a took the wire to 6 and 12a to 7; neither moved the pin, in either of the two
places this repo declares it. It survived both because both live walks set the
column by hand while standing the rig up, which is exactly what makes a migration
nobody runs invisible. All three sites go to 7.

The test that guards them is worth understanding before trusting it:
`schemaFragment.test.js` asserts the three declarations agree WITH EACH OTHER --
a real check they once failed -- but all three being equally stale passes it, and
nothing in this repo can anchor it to the wire. Recorded in the model's own
header so the next reader knows.

CHECKS

`npm test`: 620 pass, 0 fail (was 605). `check:imports` and `check:externals`
clean; the client builds and its 42 tests pass. `check:swagger` reports the
fragment stale -- it is ALREADY stale on `edge` (verified by stashing this
branch's changes and re-running) and this phase adds no route, so it is left
alone rather than regenerated inside an unrelated change.

Two bugs the new tests caught in this branch's own code before it left: `counted()`
returns `.count` and the grant read `.value`, so every grant went out with
`amount: undefined` and the non-stackable guard never fired; and `optionalInt`'s
`ok` was ignored, so a bad hue passed silently instead of refusing.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:08:08 -05:00

420 lines
20 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.
//
// ── Protocol 6: `idempotencyKey` on a write ────────────────────────────────
//
// The three write helpers the event engine drives take an optional
// `idempotencyKey`, which the sidecar passes to the shard verbatim. The shard
// executes a key at most once and answers a repeat with the ORIGINAL reply, which
// is what makes retrying a world write safe — before it, a lost acknowledgement
// and a command that never applied were the same event seen from here.
//
// **A key is a function of the caller's unit of work, never of the attempt.** The
// event runner derives it from `sha256(runId|stepId)`, so every retry of one step
// carries the same key and a different step never collides with it. Passing a
// fresh value per call would satisfy the type and defeat the entire mechanism.
//
// **The DELETEs deliberately take no key.** Their idempotency is inherent — the
// second removal of a town-crier entry or a news article is a no-op the shard is
// already happy to perform — and the sidecar builds those commands from the path
// rather than from a body, so carrying one would be a protocol change bought for
// a guarantee that already holds.
//
// A caller that sends no key gets exactly the pre-protocol-6 behaviour, which is
// what leaves the admin screens (which send none, being driven by a human who can
// see whether the thing happened) unchanged.
//
// One new status can now come back from a keyed write: **425**, the sidecar's
// mapping of `bridge.busy` — a command under this key is still in flight on the
// shard. It is transient and retryable, and `shardAnnounce.classify` already
// treats it so by falling through to its retry case.
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const log = require('../core').logger('uo-link-client')
// The sidecar waits up to 10s on the shard before answering 504, so this sits
// just above it — every call answers rather than being abandoned mid-flight.
//
// **Exported because the event actions are declared against it** (EVENTS_PLAN.md
// Phase 9). An action's `budgetMs` must exceed this or core's dispatch deadline
// fires first and classifies the step `retry` without asking the module, which
// for a broadcast means announcing twice. `config/uoEventActions.js` states that
// relationship and its test asserts it, and both need the number to come from
// here rather than from a copy that can drift.
const TIMEOUT_MS = 12000
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 || 3),
}
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, idempotencyKey }) =>
call('/towncrier', { method: 'POST', body: { id, lines, durationSec, idempotencyKey } })
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, idempotencyKey }) =>
call('/news', {
method: 'POST',
body: { id: String(id), title, body, image, url, announce, idempotencyKey },
})
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, idempotencyKey }) =>
call('/admin/broadcast', { method: 'POST', body: { actor, text, hue, idempotencyKey } })
// ── The event plane (protocol 6, EVENTS_PLAN.md Phase 11b) ─────────────────
//
// Leases and the run-scoped participation ledger. Both are gated on the shard by
// `Bridge.EventsEnabled`, which is deliberately NOT the admin plane's switch: an
// operator consenting to staff moderation from a screen has not thereby consented
// to the website changing their world on a schedule at four in the morning. A
// shard with the plane off answers 403, and the actions turn that into a refusal
// an author can read rather than a retry.
// Every lease this shard offers, with what each is worth right now and what is
// holding it. One read serves both questions core asks — `read()` wants the
// current value, `inForce()` wants to know whether the shard still has a record
// of the hold — so a lease costs one round trip, not two.
// **A targeted lease must name its target here** (protocol 7 part b). A key like
// `Spawner.MaxCount` is one capability over thousands of spawners, so it has no
// single `current` and the catalog walk cannot fill one in — while `read()` needs
// exactly one value for exactly one target before it applies anything. Naming both
// narrows the frame to that row and fills it.
//
// The frame also carries `holds`: every hold this shard has, whatever key or
// target. A catalog walk enumerates the KEYS but can never enumerate the holds on
// a targeted one — there is no list of spawners to walk — so `inForce()` reads
// that rather than the row's `held` flag.
const getLeases = ({ key, target } = {}) => {
const params = new URLSearchParams()
if (key) params.set('key', key)
if (target) params.set('target', target)
const query = params.toString()
return call(query ? `/lease?${query}` : '/lease')
}
// `holdMs` is authoritative and `untilMs` is display only. An absolute deadline
// computed here and honoured there is a deadline measured against two clocks, and
// a shard running ten minutes fast would restore a ten-minute lease the moment it
// took it. Values cross as TEXT whatever the lease's declared type: `1200` and
// `1200.0` are one number to a JSON parser and two strings to a compare-and-set.
const applyLease = ({ key, target, value, holdMs, untilMs, runId, idempotencyKey }) =>
call('/lease', {
method: 'POST',
body: { key, target, value: String(value), holdMs, untilMs, runId, idempotencyKey },
})
// `expected` is what this run applied and `baseline` is what to put back, both out
// of core's ledger rather than the shard's memory — so a release still works after
// a reconnect, and a shard that has forgotten the lease entirely (a restart, which
// reverts every config lease by design) answers honestly instead of refusing.
const releaseLease = ({ key, target, expected, baseline, idempotencyKey }) =>
call('/lease/release', {
method: 'POST',
body: {
key,
target,
expected: expected == null ? undefined : String(expected),
baseline: baseline == null ? undefined : String(baseline),
idempotencyKey,
},
})
// The participation ledger. The area is a map, a point and a radius rather than a
// region name, because protocol 6's own walk established that the most specific
// region containing an event is routinely anonymous.
const openParticipation = ({ runId, map, x, y, radius, holdMs, idempotencyKey }) =>
call('/participation', {
method: 'POST',
body: { runId: String(runId), map, x, y, radius, holdMs, idempotencyKey },
})
// A POST for a read, and the reason is the phase's headline: on a well-attended
// run the shard walks its members across Core ticks rather than in one inbound
// call, so a repeat arriving mid-walk is answered `bridge.busy` (425). A read that
// can legitimately be refused as a repeat in flight is not a GET.
const snapshotParticipation = ({ runId, idempotencyKey }) =>
call(`/participation/${encodeURIComponent(runId)}/snapshot`, {
method: 'POST',
body: { idempotencyKey },
})
const closeParticipation = ({ runId, idempotencyKey }) =>
call(`/participation/${encodeURIComponent(runId)}/close`, {
method: 'POST',
body: { idempotencyKey },
})
// ── The world verbs (protocol 7) ───────────────────────────────
//
// One endpoint for five author-facing verbs. `what` is the discriminator, and the
// per-verb fields ride alongside it: `type`/`name`/`hue`/`spread` for creatures and
// decoration, the three multipliers for a boss, `greeting`/`lines` for an oracle,
// `target`/`holdMs` for a gate.
//
// The shard registers every serial it places against the run and persists that
// registry, which is what makes `despawnWorld` below safe to point at a list of
// serials: it can only delete what the run actually owns.
const spawnWorld = (body) => call('/world', { method: 'POST', body })
// What the run still owns. A GET, unlike the participation snapshot: it carries no
// idempotency key and the shard answers it in one pass. An unknown run answers with an
// empty hand rather than a 404 — "owns nothing" and "never heard of it" are the same
// fact once the registry is the only record, and they stay the same fact across a
// restart, because the registry is written by the same world save as the objects it
// describes.
const ownedWorld = ({ runId }) => call(`/world/${encodeURIComponent(runId)}`)
// Give back what the run owns. No `serials` means everything, which is the call
// teardown makes. The reply splits three ways: `removed` was deleted, `gone` was
// already absent (a player killed it — an ordinary success), and `refused` was never
// this run's to delete.
const despawnWorld = ({ runId, serials, idempotencyKey }) =>
call(`/world/${encodeURIComponent(runId)}/despawn`, {
method: 'POST',
body: { serials, idempotencyKey },
})
// ── 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' })
// ── The one-shots (protocol 7 part b, EVENTS_PLAN.md Phase 12b) ────────────
//
// Neither owned nor borrowed: done is done. Both are gated on the shard by the
// same `Bridge.EventsEnabled` as the rest of the plane.
// What this shard will actually build, with the bounds it will build within. The
// module holds the same allowlist for its dropdown, so the form still works with
// the shard down; this is what is true when that copy is wrong.
const getGrantCatalog = () => call('/items')
// **The recipients are not sent.** The shard has held this run's participation
// ledger since it opened, keyed by the same character serials core stores as
// `member_key`, so the grant names a run and the shard resolves who was there.
// Sending a list would put the same list on the wire twice with a window in which
// the two disagree — and would have needed a core surface handing a module core's
// own participants.
const grantItem = ({ runId, item, amount, hue, name, where, idempotencyKey }) =>
call('/items/grant', {
method: 'POST',
body: { runId: String(runId), item, amount, hue, name, where, idempotencyKey },
})
// Starts a save. What actually happened rides `world.save.before`/`after` on the
// event stream, which have been there since protocol 2 — so this asserts only that
// the save was started, and a caller that needs the completion watches the feed it
// is already connected to.
const saveWorld = ({ idempotencyKey } = {}) =>
call('/world/save', { method: 'POST', body: { idempotencyKey } })
module.exports = {
TIMEOUT_MS,
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,
getLeases,
applyLease,
releaseLease,
openParticipation,
snapshotParticipation,
closeParticipation,
spawnWorld,
ownedWorld,
despawnWorld,
getGrantCatalog,
grantItem,
saveWorld,
adminKick,
adminBan,
adminUnban,
adminBroadcast,
respondPage,
closePage,
}