// 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('../../core') // 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. // // This says 7 because this build speaks protocol 7: the idempotency key and the // participation ledger (6), and the world verbs plus the targeted lease planes (7). // // It said 4 before 5, and 3 for a while after protocol 4 shipped — which is the bug this // constant was introduced to fix. A FRESH install pinned 3, the sidecar answered // `409 protocol version mismatch` to every REST call, and a new deployment read nothing // from its shard until an admin edited the number by hand in Admin → Shard. // // **And it happened again, twice, in Phases 11a and 12a** — this constant and the two in // `db/schema.sql` all sat at 5 while the wire went to 6 and then 7, so every sidecar call // on a real deployment would have been refused. Both live walks set the column by hand // while standing the rig up, which is exactly what makes a migration nobody runs // invisible. Phase 12b carries all three to 7. // // **Nothing in this repo can check this against the wire**, and that is worth knowing // before trusting the test that guards it: `schemaFragment.test.js` asserts the three // declarations agree WITH EACH OTHER, which is a real check — they drifted apart once — // but all three being equally stale passes it. The wire's version lives in `link` // (`PROTOCOL_VERSION`) and the overlay's in `servuo-plugins/overlay.toml`; the thing that // actually pairs them is the installer's bundle check, at deploy time. So bumping this in // the same change as the emitters is still the discipline, and no test here replaces it. const DEFAULT_PROTOCOL = Number(process.env.UOLINK_PROTOCOL) || 7 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) } // DEFAULT_PROTOCOL is exported for the schema test, which asserts that this constant // and schema.sql's two declarations of the same number AGREE, rather than asserting a // hardcoded version at each site -- which is what let them drift apart before. module.exports = { getSafe, getWithToken, save, recordStatus, DEFAULT_PROTOCOL }