// ── 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 ` and // `X-UOLink-Version: ` 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. // // **It takes no idempotency key, and the parameter is gone rather than optional.** // It used to accept one, and `revertOwned` passed the step's — the key the SPAWN // went out under. The shard's at-most-once store is keyed on the key alone, so the // despawn was answered with the spawn's stored reply and nothing was ever deleted. // A repeat despawn needs no key: the second pass answers `gone`, which both ends // already treat as a success. Removed from the signature so it cannot be handed // one again by accident. const despawnWorld = ({ runId, serials }) => call(`/world/${encodeURIComponent(runId)}/despawn`, { method: 'POST', body: { serials }, }) // ── 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, }