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 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 18:03:48 -05:00
parent c6c0c257dd
commit 779a304173
6 changed files with 29 additions and 7 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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');

View File

@@ -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) {

View File

@@ -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}`

View File

@@ -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)