Commit Graph

23 Commits

Author SHA1 Message Date
d83bb1748c feat(web): the borrowed planes and the one-shots on the wire (Phase 12b)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m8s
The sidecar half of protocol 7 part b. `PROTOCOL_VERSION` stays 7: 12b amends 7
in place rather than bumping again, which is tolerable for the single reason 6
and 7 already are and no other -- nothing is released from `edge`.

The lease family gains a `target` rather than a family of its own. A property
lease, a seasonal toggle and a config key are one protocol with three catalogs,
so there is one deadline, one compare-and-set, one grace window and one set of
counters instead of three of each.

`GET /lease?key=&target=` narrows to one row, and a targeted key needs it.
`Spawner.MaxCount` is one capability over thousands of spawners, so it has no
single `current` and the catalog walk cannot fill one in -- while the website's
`read()` needs exactly one value for exactly one target BEFORE it applies
anything. Naming both answers that.

The frame also always carries `holds`: every lease the shard is actually holding,
whatever key or target it is on. A catalog walk can enumerate the KEYS but never
the holds on a targeted one -- there is no list of spawners to walk -- so without
it a reconcile after an outage would have no way to ask "what are you still
holding?". `inForce()` reads that.

Three new routes. `GET /items` is the shard's own grant allowlist, so the
website's dropdown offers what this shard will actually build. `POST
/items/grant` names a RUN and never a recipient list: the shard has held the
run's participation ledger since protocol 6 part b, keyed by the same character
serials the website's `member_key` holds, so sending a list would put it on the
wire twice with a window in which the two disagree. `POST /world/save` starts a
save; what actually happened rides `world.save.before`/`after`, which have been
on the stream since protocol 2.

Two status mappings are the point of the diff rather than plumbing:

A run with no ledger open is a 404 and a run whose ledger is open and empty is a
200 with `granted: 0`. "You never told me to count" and "nobody came" are
different facts, and only the first is a mistake -- an event nobody attended
still happened, and answering it as a failure would have the module retry against
a ledger that will be just as empty next time.

A save refused for coming too soon is a 429, not the 400 every other refusal on
this plane is. It is the one refusal here that the same request gets past by
waiting, so 429 says exactly that and keeps it out of the module's
permanent-status set -- which is what makes a phase boundary retried rather than
abandoned.

`cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test`
all clean: 51 passed (was 49). The two new tests pin those two mappings.

Also exercised end to end against the real local ServUO 57.4 world driving this
binary's REST -- including that `/world/save` is not eaten by `/world/:run_id`
next door. See servuo-plugins for the walk.

Refs: docs/link/v7.md §11-§13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:07:34 -05:00
5d909ca0a3 feat(web): the world verbs on the wire (protocol 7, Phase 12a)
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 9s
`POST /world` places, `GET /world/:runId` says what a run still owns, and
`POST /world/:runId/despawn` gives it back. One route family for five
author-facing verbs, because each of them ends in "an object exists and this run
owns it" -- the differences between a boss's multipliers, an oracle's lines and
a gate's destination are fields on one command, not five commands.

`PROTOCOL_VERSION` -> 7. The overlay's `overlay.toml` is bumped in the same
window; 12b amends 7 in place rather than bumping again, so an overlay and a
sidecar both declaring 7 are interchangeable only within one side of that merge
-- tolerable for the same single reason 6 was, and no other: nothing is released
from `edge`.

`world.owned` is a GET, unlike `participation.snapshot`: it carries no
idempotency key and the shard answers it in one pass. A run the shard has no
rows for answers with an EMPTY hand rather than a 404, and the distinction is
load-bearing for reconcile -- "owns nothing" and "never heard of it" are the
same fact once the registry is the only record of ownership, and they stay the
same fact across a restart, because the registry is written by the same world
save as the objects it describes.

Two tests pin what the world verbs depend on from `respond_event`, rather than
trusting that its reason-sniffing keeps covering a kind it predates: a ceiling
refusal is a 400 (permanent -- retrying "you asked for 80 and this shard places
30" gets the same answer forever), the event gate being off is still a 403, and
an empty owned list is a 200.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 01:51:51 -05:00
93411966d7 feat(sidecar): carry the event plane (Phase 11b)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m6s
Protocol 6 amended in place, so PROTOCOL_VERSION is unchanged. Six routes and a
fourth responder; no store migration and no new machinery.

`event_call` is `admin_call` without the required `actor`: an event verb's author
is a RUN, which the body carries as `runId`, and demanding a human name for
something no human is doing would have the runner inventing one.

`respond_event` exists for two mappings the generic responder gets wrong. A
drifted lease is a 200 -- the shard was asked to compare and set, it compared,
and it declined to overwrite somebody's deliberate change, which is the mechanism
working -- and deliberately not the 409 the version gate owns, for the same
reason 425 is not. And the event plane being switched off is a 403 rather than a
reason-sniffed 400: it is an operator's deliberate refusal, and a 400 would send
an administrator hunting a bug in a step that is written correctly.

`participation.snapshot` is a POST for a read, because it carries the caller's
idempotency key and the shard may refuse it as a repeat in flight.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:31 -05:00
5612fba744 feat(sidecar): protocol 6 — carry the idempotency key, answer bridge.busy (Phase 11a)
Some checks failed
PR Checks / rust-gates (pull_request) Failing after -16s
PROTOCOL_VERSION 5 -> 6, and one behaviour: `bridge.busy` maps to 425 Too Early in
all three responders.

Everything else is free, and that is the point. The key rides in the command body,
which every write endpoint already passes through verbatim; `champ.boss.killed`
lands in `events` and on the feed through the generic forward path with no arm of
its own. No store migration — nothing gains a column.

Worth naming what the dumb-forwarder property means here specifically: the sidecar
makes no idempotency promise of its own. It does not dedupe, does not cache, and
does not know what a key means. The guarantee is the shard's, end to end, which is
the only place it can be.

425 rather than 409 because 409 is already the protocol-version gate's answer, and
the two want opposite dispositions from a client: a version mismatch is a
deployment fault nobody should retry, a busy shard is a retry that will succeed.
Sharing a status would make the difference readable only by inspecting the body,
which is how a retry loop ends up hiding a mismatched deployment. A replayed reply
is an ordinary 200 — `replayed: true` is for the log.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 14:57:08 -05:00
81becaf7c8 feat(sidecar): make the sidecar installable — CLI, --print-config, anchored data paths
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 7m52s
Phase 0.2 of the installer plan (docs/installer/PLAN.md §5). The installer has to
drive this binary non-interactively, and today it cannot: the auth token is only
readable by scraping the startup log, the config path can only be named through an
environment variable, and a relative db path follows the process working directory —
which a service manager, not the operator, chooses.

- Add a four-flag CLI (cli.rs): --print-config, --config <PATH>, --version, --help.
  Hand-rolled; an argument-parsing dependency would be larger than the code it
  replaced. An unrecognized flag exits 2 rather than starting a sidecar that is not
  the one that was asked for.
- --print-config resolves the configuration exactly as a normal start does —
  including writing a missing config file and generating a blank auth token — and
  prints it as JSON on stdout: versions, protocol, both bind addresses, ws path,
  resolved db path, and the token. config_created / token_generated let a re-run
  tell "read an existing install" from "provisioned a new one". The log subscriber
  is deliberately not started in this mode, so the document is the whole output.
- Anchor a relative [store].path to the config file's directory instead of the CWD,
  and report resolved absolute paths. A unit pinning UOLINK_CONFIG now keeps its
  database beside its config rather than in %SystemRoot%\System32 or a VirtualStore
  redirect. Development is unaffected: under cargo run the two directories are the
  same. :memory: and file: URIs are left alone.
- Hand the db path to sqlx as a filesystem path instead of formatting it into a
  sqlite:// URL, which percent-decodes it and splits it on '?'. An installed path
  containing %20 previously opened a different file; verified it now does not.
- Create the config's and the database's parent directories when missing, so a
  service can name /var/lib/runicgateway on a host where nothing made it yet.
- 22 unit tests covering argument parsing, path anchoring, token persistence, the
  generated config template, and the --print-config document.

No protocol change: PROTOCOL_VERSION stays 3.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 10:45:12 -05:00
480423090a feat(sidecar): store and serve the player-vendor market index
Protocol 3.0 §8. Ingests vendor.listing / vendor.listing.remove into a `vendors`
table and serves GET /market.

The frame is authoritative for one vendor, so the upsert is a whole-row
overwrite. Unlike the other 3.0 boards there IS a remove: a vendor is dismissed,
expires, or its owner switches off the in-game Vendor Search flag — the last of
those is a privacy control, so dropping the row promptly is the point.

Items ride inside the stored blob and are deliberately not normalized into a
vendor_items table. The sidecar's job for the market is outage resilience
(PROTOCOL_2.md §12.2), not search; search lives in MariaDB on the website side,
where the query surface, the indexes and the cliloc-resolved names already are.

/market is the only PAGED read the sidecar serves, because it is the only board
that can be a whole world's inventory. limit clamps to 1..1000 (default 200) and
`total` comes back so a caller knows when to stop rather than paging until it
sees a short page, which would race a concurrent sweep. Ordering is by SERIAL,
not shop name: a serial is stable while a shop name is renameable, so a rename
mid-walk cannot make a vendor skip or repeat a page.

The route is /market and not /vendors: /vendors/:account next door is the
per-account RPC, and two routes a prefix apart meaning "this player's shops" and
"every shop on the shard" is a readability trap.

Frames are served verbatim, owner names and coordinates included — the sidecar
defines no audiences (v3.md §3.2).

Verified against the live shard: 27 vendors / 1,040 listings ingested from the
plugin, plus a synthetic insert-then-remove confirming the delete path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:14 -05:00
21a1462e62 feat(store): persist points.board and serve it from GET /points
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards as one points.board frame per system; the sidecar folds each into a
projection table and serves them back, so the site's leaderboards page renders
during a shard outage.

  - points_boards(system PK, name, json, updated_t), keyed by the shard's own
    PointsType name. `name` is hoisted only for the ORDER BY.
  - main.rs gains a points.board arm keyed on `system`, alongside the existing
    champ/guild/governor/house/ruleset projections. There is deliberately no
    delete counterpart: the shard's set of point systems is fixed at startup, so
    it emits no points.remove — the same shape the governor board already has.
  - GET /points returns every board ordered by display name; GET /points/:system
    returns one, or 404 when the shard has never published that system. 404 and
    "a published board nobody has scored in yet" (200, empty top) are different
    answers, and the website renders them differently.

Store-backed rather than an RPC for the same reason as the other boards, and it
matters more here: these are standings accumulated over months, so blanking them
during a shard restart reads as data loss rather than as staleness.

PROTOCOL_VERSION stays at 2 — the bump to 3 is the one-time edge → main cutover
in v3.md §4, not a per-phase change.

cargo build and cargo clippy --all-targets are clean. Smoke-tested against a
driver on the loopback link: two boards stored and served, a re-emitted system
overwriting rather than accumulating, 404 for an unknown system, 401
unauthenticated. Also verified against the real ServUO shard, which fed five
live boards through this path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:23 -05:00
8e018a01e9 feat(store): persist world.ruleset and serve it from GET /ruleset
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes one world.ruleset frame
per connect describing how it is configured; the sidecar folds it into a
singleton row and serves it back.

Store-backed rather than an RPC, for the same reason /guilds and /houses are
(PROTOCOL_2.md §12.2): a rules page that goes blank while the shard restarts is
worse than one that is briefly stale. `{"ruleset": null}` distinguishes "the
shard has never published one" — an old plugin, or Bridge.RulesetEnabled=false —
from a published ruleset, which the website renders differently.

`rev` (the shard's FNV-1a of the body) is kept alongside the JSON so a reader can
tell "same ruleset, re-sent on reconnect" from "the operator changed something"
without diffing.

PROTOCOL_VERSION stays 2. The 2→3 bump is a hard operator-visible cutover and
happens exactly once, at the end of v3 (§4), not per phase.

Smoke-tested against a fake shard on loopback: frame ingested, GET /ruleset
returns it with plugin_connected=false (outage path), and the route sits behind
the gate (409 on a version mismatch, 401 unauthenticated). cargo build + clippy
clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 11:19:10 -05:00
8751151abc docs: move docs to RunicGateway/docs, repoint all references
Extracted docs/ (ADMIN_CONTROLS, INTEGRATION, PLAN, PROTOCOL_2, RESEARCH,
SHARD_PREREQS) into the central RunicGateway/docs repo under link/, with
full commit history preserved via git filter-repo.

The source cites these design docs by section throughout, so every in-repo
reference (C# + Rust comments, Bridge.cfg, and the READMEs) is repointed at
the new docs-repo URL. README references are rendered as markdown links; a
Documentation pointer section is added to the top-level README.

Docs repo: https://gitea.whitlocktech.com/RunicGateway/docs
2026-07-18 00:08:34 -05:00
fd9c9fd96a feat(protocol2): Town Cryer news-gump integration (§16, Protocol 2.1)
Website news articles now land in the modern Town Cryer News gump
(TownCryerSystem.NewsEntries), separate from the scrolling-crier lines.

Overlay BridgeNews (new): news.add / news.remove insert/remove a
TownCryerNewsEntry directly in the public NewsEntries list (no stock edit),
tracking our own id->entry map so stock uo.com news is left intact. Title,
HTML body, image, and URL are all supported (the stock gumps already branch on
TextDefinition.Number, so string content renders). On add the article title is
also proclaimed via GlobalTownCrierEntryList (announce defaults on; set
announce:false to suppress). Config caps: NewsMaxTitleLength/BodyLength/
External, NewsAnnounceDurationSec.

Sidecar: POST /news (add/replace, id-correlated), DELETE /news/{id}; news table
stores each article as its news.add command; on shard server.hello the sidecar
replays the stored set with announce:false (the shard rebuilds NewsEntries each
boot and does not persist ours, so the website is the source of truth).

Docs: PROTOCOL_2 §16 (design + verified), INTEGRATION.md /news endpoints.

Verified live: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors); booted shard + sidecar and exercised add/replace/
remove/error paths and the reconnect replay end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:27:13 -05:00
b858d526b8 feat(protocol2): house registry board (Part B ph.3)
Overlay BridgeHousing (new): a diff sweep over BaseHouse.AllHouses ->
house.update / house.remove (owner, region, location, decay level, co-owners,
friends, placement price), complementing the existing house.decay transition
feed. HousingSweepSeconds (300s); wired into [bridge reload|sweepnow|status.
Stock ServUO has no "for sale" flag, so this is an owner->houses registry;
price is the placement value, not a listing.

Sidecar: houses board table with upsert/delete/all; main routes house.update/
remove into it; GET /houses served from the store.

Docs: INTEGRATION.md house.* events + /houses endpoint; PROTOCOL_2 ph.3 built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live run pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:05:47 -05:00
d47170581d feat(protocol2): presence stream — online population + region transitions (Part B ph.2)
Overlay BridgePresence (new):
- presence.online sweep over online PlayerMobiles: total plus per-facet and
  per-region counts, emitted only when the population changes.
- region.enter real-time from EventSink.OnEnterRegion (player-filtered), the
  cheap location signal PLAN.md prefers over Movement.
- PresenceSweepSeconds (30s); wired into [bridge reload|sweepnow|status.

Sidecar:
- GET /online serves the latest presence.online snapshot from the event store
  (survives restart); population time series via /history?kind=presence.online.

Docs: INTEGRATION.md presence events + /online endpoint; PROTOCOL_2 ph.2 built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live run pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 08:02:05 -05:00
2ed0b0bd00 feat(protocol2): guild and town-governor world-state streams (Part B ph.1)
Adds the first Part B streams from docs/PROTOCOL_2.md: guild rosters and
town governors ("mayors"), both outbound diff-board sweeps mirroring the
existing champ board.

Overlay:
- BridgeSocial (new): guild sweep+diff over BaseGuild.List -> guild.update /
  guild.remove (full-state upsert; disband detected via Disbanded), plus a
  real-time guild.join from EventSink.JoinGuild. (EventSink.CreateGuild is only
  the load-time factory, so creation is derived sidecar-side from a first-seen
  id, as champs do.)
- BridgeGovernance (new): city sweep over CityLoyaltySystem.Cities -> city.update
  (governor / governor-elect / election phase), gated on CityLoyaltySystem.Enabled.
- BridgeJson.Actor: shared serial/name/acct/webId/player writer used by both.
- BridgeConfig: GuildSweepSeconds (60s), CitySweepSeconds (300s).
- BridgeBoot: both wired into [bridge reload|sweepnow|status.

Sidecar:
- store: guilds + governors board tables with upsert/delete/all.
- main: route guild.update/remove and city.update into the boards.
- web: GET /guilds, GET /governors served from the store (snapshot-companion
  rule, so a fresh page or a restarted sidecar hydrates without the shard).

Docs: INTEGRATION.md event catalog (guild.*, city.update) + board endpoints;
PROTOCOL_2.md Part B phase 1 marked built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 07:54:11 -05:00
808f6ab68b feat(protocol2): website account provisioning & unlinking (Part A)
Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the
website can create game accounts and unlink them, gated by a shard-wide
signup mode. The existing [link flow is unchanged.

Overlay:
- BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized
  falls back to game), AccountCreateEnabled (mode-following default),
  RequireIpForCreate, name/password caps, and a boot warning when the core
  Accounts.AutoCreateAccounts setting contradicts the mode.
- BridgeAccounts (new): account.create (mode gate, actor required, char-safety
  mirrored from AccountHandler, collision check, per-IP cap via CanCreate/
  LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link,
  account.audit; password never logged or echoed) and account.unlink (Owner
  floor via BridgeAdmin.Protected, clears the tag).
- BridgeAccountLink: in-game [unlink command, emits account.unlinked.
- BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse.

Sidecar:
- POST /accounts/create, DELETE /link/:account, respond_account status mapping
  (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400).
- store.record_unlink drops the mirrored link row.
- PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2).

Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429;
PROTOCOL_2.md Part A marked built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 07:42:06 -05:00
b258ee3e60 feat(champ): stream champion-spawn state to the sidecar board
Champion spawns have no ServUO EventSink, so add a fourth polled stream
(BridgeChamps) modeled on BridgeSweeps: enumerate every spawn each tick,
fold to a small record, and emit champ.update only on change. No core
patch — every field used is public.

Covers all three families via a `category` field:
  - champion: ChampionSpawn (type/level/kills/boss/cooldown ETA)
  - mini:     MiniChamp (type/level; auto-restarts, no kill counter)
  - sea:      BaseSeaChampion (a High Seas world-boss mobile, alive only
              while summoned; removed via champ.remove when slain)

Status folds to active/cooldown/dormant. A (re)connection clears the diff
cache so the next sweep re-emits the full board, rebuilding a sidecar that
restarted on its own. Transient entries leave via champ.remove.

Sidecar: a `champs` current-state table (one row per serial) fed by
champ.update (upsert) and champ.remove (delete), exposed at GET /champs as
the live board. New ChampSweepSeconds config (default 10s), wired into
[bridge reload/sweepnow/status. Documented in docs/INTEGRATION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 05:46:21 -05:00
359bb937b2 style(sidecar): apply rustfmt
Format the sidecar source with `cargo fmt` so the new `cargo fmt --check`
CI gate passes on the first run. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 14:40:05 -05:00
17e1c91fb3 feat(pages): help-page (support) queue — stream, snapshot, respond, close
Phase 2 of docs/ADMIN_CONTROLS.md: surface the in-game help-page queue to the
website.

- BridgePages.cs: the queue has no EventSink, so it is polled (PageSweepSeconds,
  default 5s) and diffed, keyed by sender serial (one page per player) ->
  page.new / page.updated / page.closed. Inbound pages.snapshot -> pages.list;
  page.respond delivers a staff reply to the player (online: a gump now; offline:
  queued for next login; shows as "Staff") and can close; page.close removes it.
- BridgeConfig/Bridge.cfg: PageSweepSeconds. BridgeBoot: reload re-arms the poll,
  status reports it.
- sidecar/src/web.rs: GET /pages, POST /pages/{id}/respond, POST /pages/{id}/close.
- INTEGRATION.md: page events (§4) and endpoints (§6).
- tools/scaffolding/BridgePageProbe.cs: gated headless verification.

Verified live (probe-seeded tickets): snapshot returns the queue, the poll emits
page.new for both and page.closed on removal, respond -> 200, close removes the
page, unknown page -> 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-13 02:54:19 -05:00
a0dbb80e1e 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
c0c01a38d6 Sidecar: auth always-on, protocol version, rich health
Auth is now impossible to turn off by accident. A blank auth_token is never
allowed even on loopback: config load generates a token, writes it back into
sidecar.toml (preserving the rest of the file), logs it, and continues -- so a
forgotten or cleared token self-heals into a working, authenticated setup instead
of silently disabling auth.

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

Protocol versioning (PROTOCOL_VERSION = 1) lets the website and sidecar detect a
mismatch immediately when a message shape changes. Every response carries an
X-UOLink-Version header; /health and ws.hello include "protocol"; a request that
declares a different X-UOLink-Version is rejected 409 with both versions so the
mismatch is unambiguous. Bump the constant when a contract changes.

/health is now a real troubleshooting panel: status (ok/degraded), protocol,
plugin_connected (is the shard link up), database (SELECT 1), uptime, and
last_event (the timestamp of the last line from the shard). Unauthenticated so
monitoring can reach it.

Verified: a blank token generates + persists + enforces (401 without, 200 with);
X-UOLink-Version header on every response; 409 on a declared mismatch; /health
reports degraded/plugin_connected:false with no shard, then flips to ok/true and a
populated last_event once the shard connects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:09:35 -05:00
c4de5fa8ad Sidecar: auth token for the website-facing API
config.rs loads all runtime settings from an external sidecar.toml (path via
$UOLINK_CONFIG), with env-var overrides (UOLINK_WEB_TOKEN, UOLINK_WEB_BIND,
UOLINK_SHARD_BIND, UOLINK_DB_PATH). Nothing is compiled into the binary. On first
run the file is generated with a random 24-byte auth token, so the sidecar is
secured out of the box and the operator just copies the token to the website.

An axum middleware rejects any request to a non-/health route that does not
present the token, as Authorization: Bearer, X-Api-Key, or ?token= (the last so
browser WebSocket clients, which cannot set handshake headers, can authenticate).
The comparison is constant-time. An empty token disables auth and is only
tolerated on a loopback bind; binding to 0.0.0.0 with no token logs a warning.

Verified: /health open (200); /history 401 without a token, 401 with a wrong one,
200 with the right one via either Bearer or X-Api-Key; an authed shard query
falls through to 503 when no shard is connected; WS rejected (401) with a bad
?token= and upgraded (101) with the right one.

sidecar.toml is gitignored (holds the secret); sidecar.toml.example is committed
as the reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:56:55 -05:00
946ba7027b Sidecar: SQLite persistence
store.rs (sqlx/sqlite) makes the data durable and queryable over time. 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). The event loop persists every live event before broadcasting it;
pong is dropped as ephemeral chatter.

New read endpoints served from the DB rather than the shard: GET /history
(optionally ?kind=), GET /economy (the money-supply series), GET /link/{account}.
GET /char/serial/{serial} now falls back to the cached profile when the shard is
unreachable, so an already-viewed character still renders during an outage;
link.confirm mirrors a successful link into the store.

Verified end to end: 10 economy.supply snapshots and the rest of the live stream
persisted and served via /history and /economy; the data survived a sidecar
restart (14 events still present, and the shard reconnected to the new sidecar);
and with the shard killed, a cached profile returned at HTTP 200 while an uncached
query failed cleanly at 503.

The sidecar is feature-complete: shard link, WebSocket feed, REST queries, and
persistence all work end-to-end against the live shard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:20:33 -05:00
f93f47fad6 Sidecar: REST query layer
rpc.rs bridges synchronous REST to the async shard stream. A call registers a
pending entry under a correlation id, sends the command, and awaits the reply
(10s timeout). The event loop routes any incoming line whose id is pending back
to the waiting caller; everything else stays a live event and is broadcast. Three
correlation fields are recognized, matching what the plugin echoes: reqId
(queries), code (link.confirm), id (towncrier).

web.rs adds the routes: GET /char/{account}/{slot}, /char/serial/{serial},
/roster/{account}, /vendors/{account}; POST /link/confirm, POST /towncrier,
DELETE /towncrier/{id}. A shard *.error reply maps to 404 or 400; no shard -> 503;
no reply in time -> 504.

Verified end to end against the live shard: roster and full char profile returned
as JSON (reqId correlation visible as r-1, r-2, ...), an unknown account returned
bridge.error as HTTP 404, vendor snapshot returned seed_000's two shops, towncrier
publish and remove returned towncrier.ok, and a bad link code returned link.error
as 404. The website can now query the game and push commands, all correlated over
the single loopback socket, all through the sidecar the game never directly
exposes.

Only SQLite persistence remains on the sidecar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:10:06 -05:00
aff10e846c Sidecar: WebSocket live feed
web.rs adds the website-facing HTTP surface (axum): GET /health and GET /ws.
Every shard event is broadcast to all connected WebSocket clients as a JSON text
frame. main.rs's event loop now broadcast::sends each event after logging it.

A lagging client is warned and kept live (misses events) rather than stalling
the others; a dead-but-not-closed socket is caught by a 30s server ping. The feed
is live-only -- no replay -- since history belongs to REST + SQLite. This side may
be exposed beyond loopback (it is the gatekeeper); it defaults to 127.0.0.1:8080
and wants auth before going public.

Verified end to end: a WebSocket client connected to /ws, received ws.hello, then
live pong events relayed from the shard through the shard-link -> broadcast -> ws
path -- the same path a login, sale, or IDOC alert will take to a browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:04:58 -05:00