2 Commits

Author SHA1 Message Date
e50fab241f Merge pull request 'feat(shard)!: declare wire protocol 3' (#117) from chore/protocol-3-cutover into edge
All checks were successful
PR Checks / bot-install (pull_request) Successful in 18s
PR Checks / client-build (pull_request) Successful in 25s
PR Checks / server-tests (pull_request) Successful in 1m34s
Reviewed-on: #117
2026-07-30 03:02:20 +00:00
779a304173 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>
2026-07-29 18:03:48 -05:00
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. # token). These URLs are just defaults; the admin can override them at runtime.
UOLINK_BASE_URL=http://127.0.0.1:8080 UOLINK_BASE_URL=http://127.0.0.1:8080
UOLINK_WS_URL=ws://127.0.0.1:8080/ws 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 ─── # ─── Push notifications (M7) — self-hosted ntfy UnifiedPush relay ───
# The `ntfy` compose service and the backend's push fan-out (opt-in notifications # 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 [baseUrl, setBaseUrl] = useState('')
const [wsUrl, setWsUrl] = useState('') const [wsUrl, setWsUrl] = useState('')
const [token, setToken] = useState('') const [token, setToken] = useState('')
const [protocol, setProtocol] = useState(1) const [protocol, setProtocol] = useState(3)
const [enabled, setEnabled] = useState(false) const [enabled, setEnabled] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('') const [msg, setMsg] = useState('')
@@ -172,7 +172,7 @@ export default function ShardAdmin() {
if (!initializedRef.current) { if (!initializedRef.current) {
setBaseUrl(c.baseUrl || '') setBaseUrl(c.baseUrl || '')
setWsUrl(c.wsUrl || '') setWsUrl(c.wsUrl || '')
setProtocol(c.protocol || 1) setProtocol(c.protocol || 3)
setEnabled(c.enabled) setEnabled(c.enabled)
initializedRef.current = true initializedRef.current = true
} }

View File

@@ -359,7 +359,7 @@ CREATE TABLE IF NOT EXISTS uo_link_config (
base_url VARCHAR(255) NULL, base_url VARCHAR(255) NULL,
ws_url VARCHAR(255) NULL, ws_url VARCHAR(255) NULL,
auth_token_enc TEXT 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, enabled TINYINT(1) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'disconnected', status VARCHAR(20) NOT NULL DEFAULT 'disconnected',
status_detail VARCHAR(500) NULL, 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 -- 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). -- 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; 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 db = require('./uoLinkConfig.db')
const secretBox = require('../../utils/secretBox') 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) { function toSafe(row) {
if (!row) { if (!row) {

View File

@@ -58,7 +58,7 @@ async function call(path, { method = 'GET', body } = {}) {
const headers = { const headers = {
'Content-Type': 'application/json', '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}` if (config.token) headers.Authorization = `Bearer ${config.token}`

View File

@@ -197,7 +197,7 @@ async function connect() {
return return
} }
state.protocol = config.protocol || 1 state.protocol = config.protocol || 3
helloSeen = false helloSeen = false
const url = buildUrl(config.wsUrl, config.token) const url = buildUrl(config.wsUrl, config.token)