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>
89 lines
3.6 KiB
JavaScript
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 }
|