Files
link/sidecar
wtclaude a8f1804de9
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m39s
feat(sidecar): protocol 8 — the asset plane, and a bound on what the shard can send
Asset Bridge phase 1, sidecar half (docs/link/v8.md §3.3, §14).
Shard half: RunicGateway/servuo-plugins#28. Docs half: RunicGateway/docs#236.

Three things, one of which is not additive.

## The inbound line cap (§3.3) — the one that matters

`read_line` had **no bound at all**. That was survivable only because the shard had
never had a reason to send a large line. Protocol 8 gives it one deliberately, and an
unbounded read facing a component that now sends megabytes is a memory-exhaustion
shape we would be inventing ourselves.

`MAX_INBOUND_LINE_BYTES` is **1 MiB** — symmetric with the cap `BridgeLink.cs` has
always applied to its own inbound lines, so both directions of this link now read the
same. The shard's batch budget is 512 KiB, and the factor of two is load-bearing: a
page always admits its first item even when that item alone exceeds the budget (the
alternative is an oversized item skipped for the budget on every page forever), so the
wire needs room for one overshoot.

An over-long line is **discarded and the connection kept** — `BridgeLink.cs`'s own
disposition in the other direction. Tearing the link down would take the live event
feed with it over one malformed frame, and the lost reply just times out and is
re-requested; everything on this plane is idempotent.

**`LineReader` holds its state in a struct rather than in locals, and that is the
subtle part.** This is polled inside a `tokio::select!`, so the future is dropped
whenever a command wins the race. A `discarding` flag in a local would be lost with
it — and losing it turns the tail of an over-long line into a line of its own, silently.
There is a test for exactly that, and another for an over-long line whose terminator
lands in the very chunk that crosses the cap.

## `GET /assets/sources`

Stage 1 of the import gate, forwarded verbatim like everything else. `respond_assets`
maps `bridge.busy` → **425** and a disabled plane → **403**.

425 deserves a note: on this plane it is not an idempotency collision, it is flow
control, and it is the **ordinary** answer mid-import rather than a rare one. The shard
serves one asset request at a time because its outbound queue is bounded in lines, not
bytes. A caller treating it as an error would abandon a healthy transfer.

403 for the same reason the event plane's gate is a 403: `Bridge.AssetsEnabled` off is
an operator declining to let the website read their client files, not a malformed
request, and 400 would send an administrator hunting a bug in a correct call.

## `PROTOCOL_VERSION` 7 → 8

Paired with `servuo-plugins/overlay.toml` in the linked PR — the installer refuses to
compose a bundle whose halves disagree, so a split bump fails silently at the next
release.

## Also

`docs/link/INTEGRATION.md` still advertised `X-UOLink-Version: 6`; it was already two
versions stale before this change. Fixed in the docs PR.

61 tests pass, `cargo fmt --check` and `cargo clippy -- -D warnings` clean. Verified
against the real shard: `/health` reports protocol 8, `/assets/sources` returns 200 with
`X-UOLink-Version: 8`, and live events kept flowing through the new reader with no
warnings logged.

- [x] AI-assisted — Claude Code (Opus 5)

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

uo-link sidecar

The Rust half of the bridge. It terminates the loopback link to the ServUO shard and (as it grows) exposes WebSocket + REST to the website.

website ──WS (live feed) / REST (queries)──►  sidecar  ──loopback TCP 127.0.0.1:7788──►  shard
                                              (this)      newline-JSON, bidirectional

The sidecar is the TCP listener; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See PLAN.md §2.

Run

cargo run                 # info logging
RUST_LOG=debug cargo run  # see every event, incl. pong heartbeats

On first run it writes sidecar.toml with a generated auth token and logs the path. Binds the shard listener (127.0.0.1:7788) and the web server (127.0.0.1:8080) from that file, then waits for the shard to connect.

Command line

Four flags. Everything else is configuration, and configuration lives in the file.

uo-link-sidecar [--print-config] [--config <PATH>] [-V|--version] [-h|--help]
Flag What
--print-config Resolve the configuration, print it as JSON on stdout, exit.
--config <PATH> Path to sidecar.toml. Outranks $UOLINK_CONFIG; default ./sidecar.toml.
-V, --version uo-link-sidecar <ver> (protocol <n>).
-h, --help Usage.

An unrecognized argument is an error (exit 2), not something to ignore — a typo'd flag would otherwise start a sidecar that is not the one you asked for.

--print-config

The non-interactive way to read the sidecar's own settings back, so an installer or a diagnostic never has to scrape the startup log or parse TOML:

$ uo-link-sidecar --print-config --config /etc/runicgateway/sidecar.toml
{
  "component": "uo-link-sidecar",
  "config_created": false,
  "config_path": "/etc/runicgateway/sidecar.toml",
  "protocol": 3,
  "shard": { "bind": "127.0.0.1:7788" },
  "store": { "path": "/var/lib/runicgateway/uo-link.db" },
  "token_generated": false,
  "version": "0.1.0",
  "web": {
    "auth_required": true,
    "auth_token": "c0f04ace66a937edff407d9dc25d5d8a967b0300e3306f11",
    "bind": "127.0.0.1:8080",
    "ws_path": "/ws"
  }
}
  • It contains the auth token in clear text. That is the point — those values go straight into Admin → Shard — but it means the output is a secret: don't pipe it into a log or a CI artifact.
  • It performs first-run setup, exactly as a normal start would: a missing config file is written and a blank token is generated and saved. So --print-config on a fresh host provisions the sidecar and tells you its token in one step. config_created and token_generated report whether this run did either, which is how a re-run distinguishes "read an existing install" from "provisioned a new one".
  • Paths are the resolved absolute ones, not what the file literally says.
  • Nothing else is written to stdout — the log subscriber is not started in this mode, so the JSON is the entire output.

Configuration & auth

All runtime settings live in sidecar.toml (path overridable with --config or $UOLINK_CONFIG) — nothing is compiled into the binary. See sidecar.toml.example. Environment variables override the file: UOLINK_SHARD_BIND, UOLINK_WEB_BIND, UOLINK_WEB_TOKEN, UOLINK_DB_PATH.

Where the data goes

A relative [store].path resolves against the directory holding sidecar.toml, not the process's working directory. Under cargo run those are the same thing, so nothing changes for development; for an installed service they are emphatically not. A unit that pins UOLINK_CONFIG=/etc/runicgateway/sidecar.toml and leaves the default uo-link.db gets /etc/runicgateway/uo-link.db — beside its config, deterministically — instead of a database wherever the service manager happened to set CWD (%SystemRoot%\System32, or a silently redirected VirtualStore copy under C:\Program Files\).

Absolute paths are used as written, and the parent directory is created if it does not exist, so a service can name /var/lib/runicgateway/uo-link.db on a host where nothing has created that directory yet. Paths are handed to SQLite as filesystem paths rather than being formatted into a sqlite:// URL, so a %, #, ? or space in the path means what it looks like.

The website authenticates to the sidecar with a shared token, presented as:

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

/health is the only unauthenticated route. The token is compared in constant time.

Authentication is always on. If auth_token is blank (fresh install, or someone cleared it), the sidecar generates one, writes it back to sidecar.toml, logs it, and continues:

No auth token configured.
Generated new token: cb998929b2201e44914dcf077bbf115583bfbe80dcf93073
Saved to sidecar.toml. Authentication is on.

So you can never accidentally run without auth. Rotate by editing the token and restarting. sidecar.toml is gitignored because it holds the secret.

Protocol version

The wire protocol has a version (PROTOCOL_VERSION, currently 3), so the website and sidecar detect a mismatch immediately instead of failing in strange ways when a message shape changes.

  • Every response carries an X-UOLink-Version: 3 header.
  • /health and the WebSocket ws.hello include "protocol": 3.
  • If a request sends X-UOLink-Version and it disagrees with the sidecar, the request is rejected 409 Conflict with {sidecar_protocol, client_protocol} so the mismatch is obvious.

Bump PROTOCOL_VERSION in main.rs whenever an event or endpoint's shape changes.

Health

GET /health (unauthenticated) returns an at-a-glance status for troubleshooting:

{
  "status": "ok",              // "ok" when plugin connected and DB reachable, else "degraded"
  "protocol": 3,
  "plugin_connected": true,    // is the shard link up?
  "database": "ok",
  "uptime": "3d 12h",
  "last_event": "2026-07-10T22:08:27Z"   // last line received from the shard, null if none
}

Status

Piece State
Shard link (shard.rs) done — accepts the shard, reads events, sends commands, re-accepts on disconnect. Verified against the live shard: received server.hello, round-tripped a pingpong, and reconnected after a sidecar restart.
WebSocket feed (web.rs) done/ws fans every shard event out to connected clients via a broadcast. Verified: a WS client received ws.hello then live pong events relayed from the shard. Live-only, no replay.
REST queries (rpc.rs + web.rs) done — synchronous queries and commands, correlated to shard replies by id. Verified end-to-end against the live shard, success and error paths.
SQLite persistence (store.rs) done — every live event persisted; history/economy served from the DB; profiles cached with shard-down fallback; link map. Verified: data survived a sidecar restart, and a cached profile served at 200 with the shard killed.

The sidecar is feature-complete. All four pieces work end-to-end against the live shard.

The web server binds per sidecar.toml (default 127.0.0.1:8080). All routes except /health require the auth token (see Configuration & auth above).

Routes

Method Path Shard command Reply
GET /health ok
GET /ws live event feed (WebSocket)
GET /char/{account}/{slot} char.request char.profile
GET /char/serial/{serial} char.request char.profile
GET /roster/{account} account.roster account.roster
GET /vendors/{account} vendor.snapshot vendor.snapshot
POST /link/confirm {code, websiteUserId} link.confirm link.ok / link.error
POST /towncrier {id, lines, durationSec} towncrier.add towncrier.ok / towncrier.error
DELETE /towncrier/{id} towncrier.remove towncrier.ok / towncrier.error
GET /link/{account} — (reads store) {account, websiteUserId} or 404
GET /history?kind=&limit= — (reads store) {events: [...]} newest first
GET /economy?limit= — (reads store) {series: [...]} supply snapshots

A shard *.error reply maps to HTTP 404 (unknown/not-found) or 400 (bad request). No shard connected → 503; no reply within 10 s → 504. GET /char/serial/{serial} falls back to the cached profile when the shard is unreachable, so an already-viewed character still renders during an outage.

Design

  • shard.rsserve() binds the listener and accepts shard connections in a loop. Each connection splits into read/write halves: the read half parses newline-JSON into ShardEvent { kind, value } and forwards them; the write half drains an mpsc of command lines. ShardHandle::send posts a command to whichever shard is currently connected, and drops with a warning if none is — a website query during a shard outage should fail fast and retry, not queue behind a reconnect. Live events that must survive an outage are buffered by the shard, not here.
  • web.rs — the website-facing HTTP surface (axum). AppState holds the broadcast::Sender<String>; each /ws client subscribes and forwards every event as a text frame. A client that lags past the broadcast buffer is warned and kept live (it just misses events) rather than stalling the others. This side may be exposed beyond loopback — it is the gatekeeper, so add auth when you do.
  • rpc.rs — request/reply correlation over the one shard socket. A REST call registers a pending entry under a correlation id, sends the command, and awaits the reply (10 s timeout). The event loop routes any incoming line whose id is pending back to the waiter; everything else flows on as a live event. Recognizes three correlation fields, matching what the plugin echoes: reqId (queries), code (link), id (town-crier).
  • store.rs — SQLite (sqlx). Three tables: events (the full live stream, append-only), links (account ↔ website user, mirrored from link.ok), profiles (last-known character sheet, cached from char.profile). History and economy read here instead of the shard; pong is dropped as ephemeral chatter. The DB file is [store].path (default uo-link.db beside the config), gitignored.
  • config.rs — resolves the config file, applies the environment overrides, guarantees an auth token, anchors relative paths, and renders the --print-config document.
  • cli.rs — the four flags above. Hand-rolled; no argument-parsing dependency.
  • main.rs — wires it together: the shard event loop first tries to route each line as an RPC reply; if it isn't one, the line is a live event — logged, persisted, and broadcast to WS.

Wire protocol

Every line is one JSON object with t (epoch ms) and kind. The shard→sidecar events and sidecar→shard commands are catalogued in PLAN.md (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: char.request, account.roster, vendor.snapshot, link.confirm, towncrier.add/remove, ping.