From 71207cef1646e9eeab3441089b61c4a2c4996459 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 29 Jul 2026 18:04:00 -0500 Subject: [PATCH 1/2] docs(link): the Protocol 3.0 cutover (v3.md order 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INTEGRATION.md was written for the window that just closed -- it told integrators the version had NOT been bumped yet and that a sidecar on `edge` reports 2 while already carrying v3 kinds. That guidance is now wrong in the direction that matters, so the version section states 3 (header, /health, ws.hello, the 409 example and the §8 worked example) and replaces the "until then" paragraph with what a v2 integration actually has to do to upgrade: change the constant it sends, and nothing else, because nothing that existed in v2 changed shape. v3.md gains §4.1 for what the bump touches and, more importantly, WHY the website's boot migration is gated on a marker row: schema.sql is re-run on every boot and uo_link_config.protocol is admin-editable, so an ungated UPDATE would silently un-pin an operator running an older sidecar. That is the one piece of the cutover a reader could not infer from the code being one constant. Progress tables: 5b done, 6 in review. Co-Authored-By: Claude --- link/INTEGRATION.md | 38 +++++++++++++++++++------------------- link/PLAN.md | 6 ++++++ link/v3.md | 43 ++++++++++++++++++++++++++++++++++++++----- 3 files changed, 63 insertions(+), 24 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index fe3b9de..a9fa80d 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -31,31 +31,31 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. -- Every response carries an **`X-UOLink-Version: 2`** header. -- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`. -- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: +- Every response carries an **`X-UOLink-Version: 3`** header. +- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 3`. +- **Optionally**, send `X-UOLink-Version: 3` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: ```json - { "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" } + { "error": "protocol version mismatch", "sidecar_protocol": 3, "client_protocol": "2" } ``` Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. **v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above. -**v3 (Protocol 3.0) is being built and the version has not been bumped yet.** It is defined as *adds -`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`*, and the bump to -`X-UOLink-Version: 3` happens **exactly once**, at the end, when [`v3.md`](v3.md) §4's `edge` → `main` -cutover lands — because a bump is an operator-visible hard break (409 on every protected route, and -the website's WS closes on the `ws.hello` mismatch), so doing it per phase would break the site -repeatedly. +**v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` / +`vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from +the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that +ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3 +sidecar. There is deliberately **no feature-negotiation array** — v3 implies all three kinds, so the +version number alone tells you what is available. -Until then, sidecars on `edge` still report `2` while already carrying some v3 kinds and endpoints. -That is safe in the direction that matters: event kinds are additive, and a client that ignores -unknown kinds and tolerates a `404` on a not-yet-present endpoint keeps working. What you must **not** -do is infer feature availability from the version number during this window — probe the endpoint, or -treat a missing `world.ruleset` as "this shard hasn't published one". There is deliberately **no -feature-negotiation array**: v3 implies all three kinds. +**Upgrading a v2 integration.** The bump is an operator-visible hard break in one direction only: a +client still declaring `2` gets a 409 on every protected route and, on the WebSocket, a closed +connection on the `ws.hello` mismatch. So update the pinned version at the same time you deploy the +v3 sidecar. Nothing that existed in v2 changed shape, so that is the whole migration — the website +does it with a one-shot boot migration of its `uo_link_config.protocol` row ([`v3.md`](v3.md) §4.1); +a third-party client changes the constant it sends. --- @@ -68,7 +68,7 @@ GET /health (no auth) ```json { "status": "ok", // "ok" when plugin connected AND db reachable, else "degraded" - "protocol": 1, + "protocol": 3, "plugin_connected": true, // is the shard link up right now? "database": "ok", // "ok" | "error" "uptime": "3d 12h", @@ -91,7 +91,7 @@ A push-only stream of game events as they happen. You do **not** send commands o **On connect**, the first frame is: ```json -{ "kind": "ws.hello", "protocol": 1 } +{ "kind": "ws.hello", "protocol": 3 } ``` **Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`. @@ -955,7 +955,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's A typical character page: ```js -const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" }; +const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "3" }; // 1. render the roster const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); diff --git a/link/PLAN.md b/link/PLAN.md index b054421..5bf9b88 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -347,6 +347,12 @@ of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff) items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own `PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site. +That completes 3.0's feature work, so the last step is the version itself: `PROTOCOL_VERSION` **2 → +3** and the coordinated `edge` → `main` merge across all four repos ([`v3.md`](v3.md) §4 and §4.1). +The bump is deliberately the *only* thing that happens at that moment — v3 adds kinds and endpoints +but changes nothing that already existed in v2 — so the operator-visible break is limited to +re-pinning the version, which the website does for itself in a one-shot boot migration. + ### Config keys (`Config/Bridge.cfg`) ```ini diff --git a/link/v3.md b/link/v3.md index b9d4abd..aa8b3c1 100644 --- a/link/v3.md +++ b/link/v3.md @@ -1,6 +1,6 @@ # Protocol 3.0 — Shard content, standings & the visibility framework -**Status:** In progress. All work lands on an `edge` branch in each repo; `edge` → `main` is the v3 cutover. +**Status:** Feature-complete on `edge`; the cutover (order 6) is in review. All work lands on an `edge` branch in each repo; `edge` → `main` is the v3 cutover. **Date:** 2026-07-28 **Codebase:** ServUO 57.4, ``, net48 / x64, Expansion **EJ**. **Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API). @@ -16,8 +16,8 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p | 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) | | 4 | **B/2** — `points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) | | 5a | **B/3 dependency** — cliloc table (§8.6) | ✅ **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) | -| 5b | **B/3** — `vendor.listing` (§8) | 🟨 In review | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) | -| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — | +| 5b | **B/3** — `vendor.listing` (§8) | ✅ **Done** | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) | +| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3 (§4) | 🟨 In review | the bump: link [#PR_LINK_BUMP](https://gitea.whitlocktech.com/RunicGateway/link/pulls/PR_LINK_BUMP), website [#PR_SITE_BUMP](https://gitea.whitlocktech.com/RunicGateway/website/pulls/PR_SITE_BUMP), docs [#PR_DOCS_BUMP](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/PR_DOCS_BUMP) — then `edge` → `main`: servuo-plugins [#PR_PLUG_CUT](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/PR_PLUG_CUT), link [#PR_LINK_CUT](https://gitea.whitlocktech.com/RunicGateway/link/pulls/PR_LINK_CUT), website [#PR_SITE_CUT](https://gitea.whitlocktech.com/RunicGateway/website/pulls/PR_SITE_CUT), docs [#PR_DOCS_CUT](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/PR_DOCS_CUT) | Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item @@ -242,6 +242,39 @@ admin-set `uo_link_config.protocol` column — so it happens **exactly once**, a from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides. - No feature-negotiation array anywhere — v3 implies all three kinds. +### 4.1 What the bump actually touches + +The version lives in five places, and all five move together: + +| Where | Change | +|---|---| +| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION` 2 → 3 (with the v3 note beside the v2 one), plus the sidecar README's worked example | +| `website/server/db/schema.sql` | `uo_link_config.protocol` column default 1 → 3, plus the boot migration below | +| `website/server/src/model/uoLinkConfig/uoLinkConfig.model.js` | `DEFAULT_PROTOCOL` — what a site with nothing saved yet declares | +| `website/server/src/utils/uoLinkClient.js` + `uoLinkSocket.js` | the `config.protocol || …` fallbacks, so an unset value can never quietly send `1` and 409 with a confusing message | +| `website/client/.../ShardAdmin.jsx`, `website/.env.example` | the admin form's initial value and the documented env default | + +**The migration has to be one-shot, and that is the only subtle part.** `schema.sql` is re-run on +*every* boot (`utils/db.js::ensureSchema`), and every other statement in its migration block is an +idempotent `ADD COLUMN IF NOT EXISTS` / `MODIFY`. A bare `UPDATE uo_link_config SET protocol = 3` +would not be idempotent in the sense that matters: `protocol` is **admin-editable**, so an operator +who deliberately pins an older sidecar in Admin → Shard would silently be un-pinned on the next +restart. It is therefore gated on a marker row in `settings`: + +```sql +ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3; +UPDATE uo_link_config SET protocol = 3 + WHERE id = 1 AND protocol < 3 + AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated'); +INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1'); +``` + +The marker is written *after* the `UPDATE`, so the first boot on the new build migrates and every +later boot is a no-op. A fresh install has no `uo_link_config` row to update and simply gets the +marker plus the new column default. `protocol < 3` rather than `= 2` so an install that never left +the old default of `1` is carried across too — it could not have been talking to a v2 sidecar +anyway. + --- ## 5. Part B/1 — `world.ruleset` ✅ Done @@ -887,8 +920,8 @@ not a blocker here.) | 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done | | 4 | **B/2** — `points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done | | 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | ✅ Done | -| 5b | **B/3** — `vendor.listing` (§8) | all four | new kinds | 🟨 In review | -| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | ⬜ | +| 5b | **B/3** — `vendor.listing` (§8) | all four | new kinds | ✅ Done | +| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | 🟨 In review | --- -- 2.49.1 From 32def88c4e33185826778f483fa1b45bf06ab48c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 29 Jul 2026 18:09:50 -0500 Subject: [PATCH 2/2] docs(link): fill in the cutover PR numbers The order-6 row was written before the seven PRs existed. Same follow-up as the cliloc row got. Co-Authored-By: Claude --- link/v3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/link/v3.md b/link/v3.md index aa8b3c1..cb393e8 100644 --- a/link/v3.md +++ b/link/v3.md @@ -17,7 +17,7 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p | 4 | **B/2** — `points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) | | 5a | **B/3 dependency** — cliloc table (§8.6) | ✅ **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) | | 5b | **B/3** — `vendor.listing` (§8) | ✅ **Done** | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) | -| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3 (§4) | 🟨 In review | the bump: link [#PR_LINK_BUMP](https://gitea.whitlocktech.com/RunicGateway/link/pulls/PR_LINK_BUMP), website [#PR_SITE_BUMP](https://gitea.whitlocktech.com/RunicGateway/website/pulls/PR_SITE_BUMP), docs [#PR_DOCS_BUMP](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/PR_DOCS_BUMP) — then `edge` → `main`: servuo-plugins [#PR_PLUG_CUT](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/PR_PLUG_CUT), link [#PR_LINK_CUT](https://gitea.whitlocktech.com/RunicGateway/link/pulls/PR_LINK_CUT), website [#PR_SITE_CUT](https://gitea.whitlocktech.com/RunicGateway/website/pulls/PR_SITE_CUT), docs [#PR_DOCS_CUT](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/PR_DOCS_CUT) | +| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3 (§4) | 🟨 In review | the bump: link [#20](https://gitea.whitlocktech.com/RunicGateway/link/pulls/20), website [#117](https://gitea.whitlocktech.com/RunicGateway/website/pulls/117), docs [#72](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/72) — then `edge` → `main`: servuo-plugins [#6](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/6), link [#21](https://gitea.whitlocktech.com/RunicGateway/link/pulls/21), website [#118](https://gitea.whitlocktech.com/RunicGateway/website/pulls/118), docs [#73](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/73) | Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item -- 2.49.1