feat(server): port the UO models, utils and schema fragment

The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.

server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.

Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.

The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.

One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:06:26 -05:00
committed by Claude
parent 47809854ef
commit fe3251a543
40 changed files with 7967 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
const singletonConfigDb = require('../singletonConfigDb')
const COLS =
'id, base_url, ws_url, auth_token_enc, protocol, enabled, status, status_detail, plugin_connected, last_event_at, boot_id, updated_by, created_at, updated_at'
// Singleton row (id = 1). See ../singletonConfigDb for the get/upsert contract.
module.exports = singletonConfigDb('uo_link_config', COLS)

View File

@@ -0,0 +1,88 @@
// 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.
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 }