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:
7
server/model/uoLinkConfig/uoLinkConfig.db.js
Normal file
7
server/model/uoLinkConfig/uoLinkConfig.db.js
Normal 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)
|
||||
88
server/model/uoLinkConfig/uoLinkConfig.model.js
Normal file
88
server/model/uoLinkConfig/uoLinkConfig.model.js
Normal 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 }
|
||||
Reference in New Issue
Block a user