From 779a304173e69b12debe349654c4e65a3167f0ff Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 29 Jul 2026 18:03:48 -0500 Subject: [PATCH] feat(shard)!: declare wire protocol 3 The site's declared version is the admin-set uo_link_config.protocol column, so the sidecar's PROTOCOL_VERSION 2 -> 3 bump has to be matched here or every REST call 409s and uoLinkSocket closes the WS on the ws.hello mismatch. Five places carry the number and all five move together: the column default, the model's DEFAULT_PROTOCOL (what a site with nothing saved yet declares), the two `config.protocol || 1` fallbacks in uoLinkClient/uoLinkSocket -- unreachable today, but an unset value quietly sending 1 is exactly the confusing 409 the version check exists to prevent -- the admin form's initial value, and the documented env default. The boot migration is the only subtle part. schema.sql is re-run on EVERY boot, and `protocol` is admin-editable, so a bare UPDATE would silently un-pin an operator who had deliberately pinned an older sidecar in Admin -> Shard. It is therefore gated on a marker row in `settings`, written after the UPDATE: the first boot on this build migrates, every later boot is a no-op. `protocol < 3` rather than `= 2` picks up an install still on the old default of 1, which could not have been talking to a v2 sidecar anyway. A fresh install has no row to update and just gets the marker plus the new column default. Verified against the local MariaDB through ensureSchema (the production path): 2 -> 3 with the marker written and the column default now 3; pinned back to 2 by hand, re-ran, and it STAYED 2 -- the one-shot property holds. 673 server tests, 47 client tests, client build green. Co-Authored-By: Claude --- .env.example | 5 ++++- client/src/routes/admin/views/ShardAdmin.jsx | 4 ++-- server/db/schema.sql | 18 +++++++++++++++++- .../model/uoLinkConfig/uoLinkConfig.model.js | 5 ++++- server/src/utils/uoLinkClient.js | 2 +- server/src/utils/uoLinkSocket.js | 2 +- 6 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index b8f3850..31c49cc 100644 --- a/.env.example +++ b/.env.example @@ -117,7 +117,10 @@ BOT_INTERNAL_KEY=change-me-to-a-long-random-string # token). These URLs are just defaults; the admin can override them at runtime. UOLINK_BASE_URL=http://127.0.0.1:8080 UOLINK_WS_URL=ws://127.0.0.1:8080/ws -UOLINK_PROTOCOL=1 +# Wire protocol this build speaks (3 = Protocol 3.0). Only a fallback for a site +# with nothing saved yet — the admin panel's pinned value wins — but set it lower +# if you deliberately run an older sidecar. +UOLINK_PROTOCOL=3 # ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ─── # The `ntfy` compose service and the backend's push fan-out (opt-in notifications diff --git a/client/src/routes/admin/views/ShardAdmin.jsx b/client/src/routes/admin/views/ShardAdmin.jsx index e59a405..5179b42 100644 --- a/client/src/routes/admin/views/ShardAdmin.jsx +++ b/client/src/routes/admin/views/ShardAdmin.jsx @@ -155,7 +155,7 @@ export default function ShardAdmin() { const [baseUrl, setBaseUrl] = useState('') const [wsUrl, setWsUrl] = useState('') const [token, setToken] = useState('') - const [protocol, setProtocol] = useState(1) + const [protocol, setProtocol] = useState(3) const [enabled, setEnabled] = useState(false) const [busy, setBusy] = useState(false) const [msg, setMsg] = useState('') @@ -172,7 +172,7 @@ export default function ShardAdmin() { if (!initializedRef.current) { setBaseUrl(c.baseUrl || '') setWsUrl(c.wsUrl || '') - setProtocol(c.protocol || 1) + setProtocol(c.protocol || 3) setEnabled(c.enabled) initializedRef.current = true } diff --git a/server/db/schema.sql b/server/db/schema.sql index 95532e0..a61a8bf 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -359,7 +359,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config ( base_url VARCHAR(255) NULL, ws_url VARCHAR(255) NULL, auth_token_enc TEXT NULL, - protocol INT NOT NULL DEFAULT 1, + protocol INT NOT NULL DEFAULT 3, enabled TINYINT(1) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status_detail VARCHAR(500) NULL, @@ -1397,3 +1397,19 @@ ALTER TABLE mobile_refresh_tokens ADD COLUMN IF NOT EXISTS last_used_at DATETIME -- trust token. A boolean only — the token is returned over that app→server call -- and never persisted here (only its sha256 lands in trusted_devices). ALTER TABLE mobile_auth_sessions ADD COLUMN IF NOT EXISTS trust_device TINYINT(1) NOT NULL DEFAULT 0; + +-- Protocol 3.0 cutover: this build speaks wire protocol 3 (world.ruleset, +-- points.board, vendor.listing), so the pinned version an existing install +-- carries has to move with it — a 2 against a v3 sidecar 409s every REST call +-- and closes the WS on ws.hello. MODIFY fixes the column default for installs +-- created before the bump (idempotent, like the other MODIFYs here). +ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3; +-- The row itself is admin-editable, and schema.sql runs on EVERY boot, so this +-- must be one-shot: an operator who deliberately pins an older sidecar in +-- Admin → Shard has to stay pinned. The marker row in `settings` is what makes +-- it fire once — written after the UPDATE, and on a fresh install (no +-- uo_link_config row yet) it is simply written with nothing to update. +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'); diff --git a/server/src/model/uoLinkConfig/uoLinkConfig.model.js b/server/src/model/uoLinkConfig/uoLinkConfig.model.js index 5b3736e..b783367 100644 --- a/server/src/model/uoLinkConfig/uoLinkConfig.model.js +++ b/server/src/model/uoLinkConfig/uoLinkConfig.model.js @@ -7,7 +7,10 @@ const db = require('./uoLinkConfig.db') const secretBox = require('../../utils/secretBox') -const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 1 +// The wire protocol this build speaks (link/sidecar/src/main.rs PROTOCOL_VERSION). +// Only used before an admin has saved anything — the stored row wins once it exists, +// and UOLINK_PROTOCOL still overrides for an operator running an older sidecar. +const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 3 function toSafe(row) { if (!row) { diff --git a/server/src/utils/uoLinkClient.js b/server/src/utils/uoLinkClient.js index 1d503a6..44d5f5f 100644 --- a/server/src/utils/uoLinkClient.js +++ b/server/src/utils/uoLinkClient.js @@ -58,7 +58,7 @@ async function call(path, { method = 'GET', body } = {}) { const headers = { 'Content-Type': 'application/json', - 'X-UOLink-Version': String(config.protocol || 1), + 'X-UOLink-Version': String(config.protocol || 3), } if (config.token) headers.Authorization = `Bearer ${config.token}` diff --git a/server/src/utils/uoLinkSocket.js b/server/src/utils/uoLinkSocket.js index 86c9ca9..2acb4f0 100644 --- a/server/src/utils/uoLinkSocket.js +++ b/server/src/utils/uoLinkSocket.js @@ -197,7 +197,7 @@ async function connect() { return } - state.protocol = config.protocol || 1 + state.protocol = config.protocol || 3 helloSeen = false const url = buildUrl(config.wsUrl, config.token) -- 2.49.1