Files
docs/link/INTEGRATION.md
Claude a9bb5e8641 feat(admin): sidecar REST routes for the write plane
Phase 1 (sidecar side): POST /admin/{kick,ban,unban,broadcast} forward to the
shard, correlated on a fresh reqId, with an admin-specific status mapping —
unknown target -> 404, protected target / plane-disabled -> 403, missing actor
/ bad body -> 400. actor is required and checked up front. Documents the
endpoints and the admin.audit event in INTEGRATION.md.

Verified end-to-end (real sidecar + booted shard): 200 on success, 403 on the
Owner floor, 404 unknown target, 400 missing actor, 401 no token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 02:00:17 -05:00

19 KiB
Raw Blame History

uo-link Sidecar — Website Integration Guide

This is the API the website talks to. The sidecar is the only thing the site connects to; it relays to and from the ServUO shard over a private loopback socket. The game itself exposes no ports and is never reachable directly.

website ──WebSocket (live feed) + REST (queries/commands)──►  sidecar  ──loopback──►  shard
  • Base URL — default http://127.0.0.1:8080 (WebSocket: ws://127.0.0.1:8080). Configurable in sidecar.toml (web.bind) or UOLINK_WEB_BIND. If you serve the site from another host, bind the sidecar to 0.0.0.0:8080 and put it behind TLS.
  • Content type — all request and response bodies are JSON (application/json).
  • Timestamps — every t field is epoch milliseconds (UTC). Human-readable timestamps (e.g. house.decay.builtOn, /health.last_event) are ISO-8601 UTC.
  • Serials — game object ids are hex strings like "0x24C" (mobiles) or "0x40013AAD" (items). Treat them as opaque keys.

1. Authentication

Every route except GET /health requires the shared token from sidecar.toml (web.auth_token). Present it any of these ways:

Transport How
REST Authorization: Bearer <token>
REST X-Api-Key: <token>
WebSocket ?token=<token> in the connect URL (browsers can't set headers on a WS handshake)

Missing or wrong token → 401 {"error":"missing or invalid auth token"}. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing sidecar.toml and restarting.


2. Protocol version

The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.

  • Every response carries an X-UOLink-Version: 1 header.

  • GET /health and the WebSocket ws.hello frame include "protocol": 1.

  • Optionally, send X-UOLink-Version: 1 on your requests. If it disagrees with the sidecar, the request is rejected 409 Conflict:

    { "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" }
    

Pin the version you built against and compare it to the header (or /health.protocol) at startup.


3. Health

GET /health          (no auth)
{
  "status": "ok",                      // "ok" when plugin connected AND db reachable, else "degraded"
  "protocol": 1,
  "plugin_connected": true,            // is the shard link up right now?
  "database": "ok",                    // "ok" | "error"
  "uptime": "3d 12h",
  "last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
}

Always returns HTTP 200 (read status/plugin_connected for real state). Use it for liveness checks and to detect when the shard has dropped (plugin_connected: false).


4. WebSocket live feed

GET /ws?token=<token>        (WebSocket upgrade)

A push-only stream of game events as they happen. You do not send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.

On connect, the first frame is:

{ "kind": "ws.hello", "protocol": 1 }

Then a continuous stream of event frames, each with at least t (epoch ms) and kind. Route on kind.

Notes:

  • Live-only, no replay. A client that connects now sees events from now on. For history/backfill, use GET /history.
  • The sidecar sends WebSocket ping frames every ~30s for keepalive; browser clients answer automatically.
  • You may occasionally see a {"kind":"pong",...} frame (the sidecar's internal heartbeat to the shard). Ignore any kind you don't handle.
  • A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.

Minimal browser client

const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
ws.onmessage = (m) => {
  const ev = JSON.parse(m.data);
  switch (ev.kind) {
    case "ws.hello":      /* check ev.protocol === 1 */ break;
    case "mob.login":     onLogin(ev); break;
    case "vendor.sale":   onSale(ev); break;
    case "house.decay":   onIdoc(ev); break;
    // ...handle the kinds you care about; ignore the rest
  }
};
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history

Event catalog

Every event has t (epoch ms) and kind. A nested actor object looks like {"serial","name","acct","player"} (acct present only for player-owned mobiles).

Lifecycle

kind fields notes
server.hello shard, bootId, connects, items, mobiles, accounts Sent to the sidecar on every shard (re)connect. bootId changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected".
server.shutdown Clean shutdown.
server.crashed error Not always sent (a hard crash may skip it).
world.save.before / world.save.after (after adds items, mobiles) Save-cycle boundaries; a natural consistency checkpoint.

Sessions & identity

kind fields
mob.login who, map, x, y, z, webId (present if the account is linked)
mob.logout who
account.login.attempt acct, ip — an authentication attempt (no password ever leaves the shard)

Economy & commerce

kind fields notes
gold.change acct, old, new, delta AccountGold flow (gold in bank/account, not physical coins).
vendor.buy who, vendor, item, itemSerial, amount, perUnit, total, committed:false NPC vendor purchase (validation stage).
vendor.sell who, vendor, item, itemSerial, amount, perUnit, total, committed:false NPC vendor sale.
vendor.sale buyerSerial, buyerAcct, ownerSerial, ownerAcct, vendorSerial, itemType, itemSerial, itemId, amount, price, commission, committed:true Player vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match.
vendor.placed owner, vendor A player vendor was placed.
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
 "ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
 "itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
 "price":100,"commission":0,"t":1783720195626}

Character progression & vitals

kind fields notes
char.vitals serial, hits,hitsMax, mana,manaMax, stam,stamMax, str,dex,int, map, x,y Periodic snapshot of each online player (~every 30s; configurable). Diff successive snapshots to detect change.
skill.gain who, skill, gained, base, cap Player skill gains only (NPC gains are filtered out).
fame.change / karma.change who, old, new Player only.
quest.complete who, quest

Death & PvP

kind fields
player.death who, killer
player.murdered victim, murderer
mob.killed killed, killer — only kills that involve a player

Housing / IDOC

kind fields
house.decay serial, from, to, map, x,y,z, region, name, ownerSerial, ownerAcct, ban:{x,y,z}, builtOn, lastRefreshed

from/to are decay stages (LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, …). Emitted only on a transition, so watch for to == "IDOC". ban is where a player would stand to see the sign.

{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
 "map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
 "ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
 "builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}

Economy supply (periodic)

kind fields
economy.supply accounts, gold — total money supply across all accounts (~every 5 min; configurable)

Cheat detection & staff audit

kind fields notes
cheat.fastwalk who, ip The shard's own speed-hack detector fired.
audit.set staff, prop, target, targetSerial, old, new A staff member used [set to change a property. staff may be null.
audit.command staff, command, args A staff command was invoked.
admin.audit origin, action, actor, target, reason, plus action-specific (durationSec, sessions, hue, text) A moderation action was applied. origin is "web" (from the site, actor:"web:<user>") or "in-game" (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the admin.ok reply for web actions; see §6.

Account linking

kind fields notes
link.request code, account, char, ttlSec A player ran [link in game. Show them a prompt to enter code on the site; you then confirm it via POST /link/confirm. See §6.

5. REST — read queries

These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning 504.

Character profile

GET /char/{account}/{slot}          # by account + character slot (0-based)
GET /char/serial/{serial}           # by serial, e.g. /char/serial/0x24C

Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for offline characters too. GET /char/serial/... falls back to the last cached profile if the shard is unreachable (so a page still renders during a shard restart).

{
  "kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
  "body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
  "stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
             "mana":123,"manaMax":123, "stam":120,"stamMax":120,
             "fame":0,"karma":0,"luck":0,
             "resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
  "skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
  "equipment": [
    { "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
      "cliloc":1027933,"mods":{} },
    { "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
      "weapon":{"minDamage":16,"maxDamage":18},
      "mods":{"WeaponDamage":50,"HitLightning":40} }
  ]
}

Field notes:

  • skills[].base is trained value, value includes item/temp bonuses, cap is the cap. Do not assume base <= cap — GM characters can exceed it.
  • equipment[].mods is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty {} for plain items.
  • Item names are usually clilocs, not strings: use name when present, otherwise resolve cliloc against a UO cliloc table on the site.
  • Errors: unknown account → 404 {"kind":"bridge.error","reason":"unknown account"}; bad slot → 404/400 similarly.

Account roster

GET /roster/{account}

Lightweight list of an account's characters (up to 57), including offline ones. Use this for a character-picker, then fetch the full profile on demand.

{ "kind":"account.roster", "acct":"whitlocktech",
  "chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] }

Player vendors

GET /vendors/{account}

Every player vendor owned by any character on the account, with held gold and current listings.

{ "kind":"vendor.snapshot", "acct":"seed_000",
  "vendors":[
    { "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186,
      "ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604,
      "listings":[
        {"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}
      ] } ] }

6. REST — commands & history

The in-game [link flow: the player runs [link, the shard emits a link.request event (over the WebSocket) carrying a one-time code. Your site shows the logged-in website user a box to enter that code, then:

POST /link/confirm
{ "code": "AB12CD", "websiteUserId": "9931" }
  • Success → 200 {"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}. The game account is now permanently tagged with your websiteUserId (persisted on the shard); subsequent mob.login events for that account carry webId.
  • Bad/expired code → 404 {"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}.

Codes are one-time and expire (default 5 min).

GET /link/{account}
  • 200 {"account":"PerryAdimn","websiteUserId":"9931"} if linked.
  • 404 {"account":"PerryAdimn","linked":false} if not.

(This reads the sidecar's mirror of confirmed links — no shard round-trip.)

Publish / remove town-crier news

Push a message that every in-game town crier announces until it expires.

POST /towncrier
{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 }

200 {"kind":"towncrier.ok","id":"news-42"}. Re-posting the same id replaces the prior entry.

DELETE /towncrier/{id}

200 {"kind":"towncrier.ok","id":"news-42"}, or 404 {"kind":"towncrier.error","reason":"unknown id"}.

Caps apply (line count/length, active entries, duration); an over-cap post returns towncrier.error.

Staff moderation — the write plane

Account and session moderation against the live shard. These are privileged. The sidecar does not model per-user roles — your site must authenticate the staff user and check their permission before calling. The shard trusts the loopback socket and applies each command with CoOwner-level authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner account) is refused (403). The whole plane is opt-in on the shard (AdminWriteEnabled in Bridge.cfg); when it's off, every call returns 403 "admin write plane disabled".

Every request requires an actor — the website username/id of the staff member taking the action. It is recorded in the shard console log, the ban's BanDealer tag, and the admin.audit event, so actions are always attributable. A missing actor is 400.

POST /admin/kick        { "actor":"jane", "account":"griefer42" }        # or "serial":"0x2E0"
POST /admin/ban         { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" }
POST /admin/unban       { "actor":"jane", "account":"griefer42" }
POST /admin/broadcast   { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 }
  • kick — disconnects every live session of the target account (including one parked at character-select). Target by account or serial. Reply carries sessions (how many were cut).
  • ban — bans the account (works offline) and disconnects any live sessions. durationSec > 0 is a timed ban that auto-expires; 0/absent is indefinite. Clamped to the shard's AdminBanMaxDurationSec.
  • unban — clears the ban.
  • broadcast — a system message to everyone online. hue optional (default 53, staff green). Length-capped by the shard.

Success → 200 with an admin.ok:

{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 }

Failure → an admin.error with a mapped status:

Status When
400 missing actor, malformed body, or bad parameter
401 missing/invalid auth token
403 target is protected (at/above the floor), or the write plane is disabled on the shard
404 unknown or accountless target
503 / 504 shard not connected / didn't reply in time

Each applied action also emits an unsolicited admin.audit frame on the WebSocket (§4) with origin:"web", so every connected dashboard — not just the caller — sees it. In-game moderation by staff in the game client surfaces the same way with origin:"in-game".

History (from the sidecar's database)

GET /history?kind={kind}&limit={n}     # kind optional, limit default 100 (max 1000)
GET /economy?limit={n}                 # the money-supply series (economy.supply events)

Recent events, newest first, served from SQLite (no shard needed). This is your backfill when a WebSocket client (re)connects, and the source for feeds like "recent sales" or "latest IDOC".

GET /history?kind=vendor.sale&limit=50
→ { "events": [ {"kind":"vendor.sale", "...": "...", "t": 1783720195626}, ... ] }

GET /economy?limit=200
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }

7. Status codes

Code Meaning
200 OK
400 Bad request (malformed body, invalid parameter, or a shard *.error that isn't a not-found)
401 Missing or invalid auth token
404 Not found (unknown account / character / id)
409 Protocol version mismatch (you sent X-UOLink-Version and it disagreed)
500 Internal error (e.g. database)
503 Shard not connected — the query needs the live game and it's down
504 Shard connected but didn't reply within 10s

503 vs 404: a 503 is transient (shard restarting — retry), a 404 is a real "doesn't exist."


8. Putting it together

A typical character page:

const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "1" };

// 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

// 2. full sheet for the selected character
const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H });
if (res.status === 503) showBanner("Game server is restarting…");
else renderProfile(await res.json());

// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives
//    (see the WebSocket client in §4)

// 4. recent sales widget
const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H })
  .then(r => r.json());

9. Caveats & current limits

  • No rate limiting yet. The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side.
  • WebSocket is push-only and live-only. No client→server messages, no replay. Backfill via /history.
  • Cache freshness. GET /char/serial/... may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
  • bootId on server.hello is your signal to invalidate site-side caches: if it changed, the shard restarted.
  • Protocol changes bump X-UOLink-Version. Compare it on startup and fail fast rather than mis-parsing a newer shape.