Files
website/server/src/model/uoLinkConfig/uoLinkConfig.model.js
wtclaude 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

89 lines
3.6 KiB
JavaScript

// uo-link sidecar connection config store. Mirrors botConfig/emailConfig: the DB
// layer only ever sees ciphertext, and only getWithToken() (used server-side to
// call the sidecar over REST/WS) decrypts it. The admin-facing getSafe() never
// includes the token — it exposes only `hasToken`. A blank `token` on save means
// "leave the existing token unchanged" (same convention as the other configs).
const db = require('./uoLinkConfig.db')
const secretBox = require('../../utils/secretBox')
// 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) {
return {
baseUrl: process.env.UOLINK_BASE_URL || null,
wsUrl: process.env.UOLINK_WS_URL || null,
protocol: DEFAULT_PROTOCOL,
enabled: false,
hasToken: false,
status: 'disconnected',
statusDetail: null,
pluginConnected: false,
lastEventAt: null,
bootId: null,
}
}
return {
baseUrl: row.base_url || null,
wsUrl: row.ws_url || null,
protocol: row.protocol || DEFAULT_PROTOCOL,
enabled: Boolean(row.enabled),
hasToken: Boolean(row.auth_token_enc),
status: row.status || 'disconnected',
statusDetail: row.status_detail || null,
pluginConnected: Boolean(row.plugin_connected),
lastEventAt: row.last_event_at || null,
bootId: row.boot_id || null,
}
}
async function getSafe() {
return toSafe(await db.get())
}
// Decrypted token included — server-side only (calling the sidecar's REST/WS
// API). Returns null when nothing has been saved yet.
async function getWithToken() {
const row = await db.get()
if (!row) return null
return { ...toSafe(row), token: row.auth_token_enc ? secretBox.decrypt(row.auth_token_enc) : null }
}
// Save admin-supplied config. `token` undefined or '' means "leave the existing
// token unchanged" (same convention as botConfig.save).
async function save({ baseUrl, wsUrl, token, protocol, enabled, updatedBy }) {
const fields = {}
if (baseUrl !== undefined) fields.base_url = baseUrl
if (wsUrl !== undefined) fields.ws_url = wsUrl
if (token) fields.auth_token_enc = secretBox.encrypt(token)
if (protocol !== undefined) fields.protocol = protocol
if (enabled !== undefined) fields.enabled = enabled ? 1 : 0
if (updatedBy !== undefined) fields.updated_by = updatedBy
const row = await db.upsert(fields)
return toSafe(row)
}
// Mirror the sidecar's last-reported connection state into the DB so the admin
// panel has something to show between polls and the public status endpoint can
// read it without a live round-trip.
async function recordStatus({ status, statusDetail, pluginConnected, lastEventAt, bootId }) {
const fields = {}
if (status !== undefined) fields.status = status
if (statusDetail !== undefined) fields.status_detail = statusDetail
if (pluginConnected !== undefined) fields.plugin_connected = pluginConnected ? 1 : 0
// lastEventAt may arrive as an ISO string (e.g. "2026-07-10T22:08:27Z"); the
// mariadb DATETIME parser rejects the "T"/"Z", so hand it a real Date (same
// fix as botConfig.recordStatus's last_connected_at).
if (lastEventAt !== undefined) fields.last_event_at = lastEventAt ? new Date(lastEventAt) : null
if (bootId !== undefined) fields.boot_id = bootId
if (Object.keys(fields).length === 0) return getSafe()
const row = await db.upsert(fields)
return toSafe(row)
}
module.exports = { getSafe, getWithToken, save, recordStatus }