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