// @ts-check
/**
* Node ObjectBase service — `Type='node'` mit Companion-Zeile in der generischen
* `Member`-Tabelle (Member-Muster: UID = ObjectBase.UID, nicht versioniert) für
* heißen Zustand:
*
* - lookup_key → gezogener Lookup-Slot (Runner-Auth, UNIQUE)
* - publicKey → ObjectBase.Data (Quelle der Wahrheit)
* - heartbeat/capacity/lastSeenAt → Member.Data (Topologie-Heartbeat)
*
* Ein **Node** ist jede Maschine im Tailnet, die einem Team/einer Person gehört.
* Die Rolle steht in `Data.role`:
*
* - `runner` → Ausführungs-Node (früher `Type='runner'`); hat Credential in
* `Member` (`lookup_key`) und die heißen Heartbeat-Felder.
* - `gateway` → Relay-Gateway (z. B. `db-members-dev`): stellt einen
* Bestandsdienst im Tailnet bereit. Kein Credential, keine
* Companion-Zeile.
* - `dev-pc` → lokaler Entwickler-Rechner. Kein Credential.
*
* Nodes folgen demselben Sichtbarkeitsmodell wie Projekte/Lists/Runners:
* - `personal`: Links.Type='member' → Owning-User + Visible admin für den Creator
* - `team`: Links.Type='memberA' → Owner-Gruppe + Visible admin für den Creator
* + addVisibility (Gruppen-Job-Inhaber sehen den Node)
* - `shared`: Links.Type='memberA' → Org-Root-Gruppe + addVisibility
*
* Die Netzzuordnung ist ein Link `tailnetNode` (UID=Netz, UIDTarget=Node) —
* daraus ergibt sich „welches Netz/Team" und damit die Umgruppierung im UI.
*
* Soll (080-Workspaces): members ist nur noch Topologie/Authorization — es gibt
* keine deployment-/job-/queue-Mechanik mehr (die liegt beim Zusteller/ide-server).
*
* @import {ExpressRequestAuthorized} from '../../types.js'
*/
import { query, transaction, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { getUID } from '../../utils/UUIDs.js';
import { isAdmin, isObjectVisible, isListAdmin } from '../../utils/authChecks.js';
import { addVisibility, deleteVisibility } from '../../utils/listVisibilty.js';
import { apiError } from '../../utils/apiEnvelope.js';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';
import { publishEvent } from '../../utils/events.js';
const NODE_TYPE = `'node'`;
/** Erlaubte Node-Rollen. `runner` ist die einzige mit Credential/Heartbeat. */
export const NODE_ROLES = ['runner', 'gateway', 'dev-pc'];
/** Rolle, die einen Ausführungs-Node beschreibt (früher `Type='runner'`). */
export const RUNNER_ROLE = 'runner';
/** Eigentums-Modi (wie bisher beim Runner). */
/** @type {const} */
const RUNNER_MODES = ['personal', 'team', 'shared'];
/** SQL-Fragment, das einen Node auf eine Rolle einschränkt. */
const roleFilter = (role) =>
role ? `AND JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.role')) = '${String(role)}'` : '';
const nodeSelect = `
SELECT ObjectBase.UID, ObjectBase.Type, ObjectBase.UIDBelongsTo,
ObjectBase.Title, ObjectBase.Display, ObjectBase.Data, ObjectBase.UIDuser,
DATE_FORMAT(ObjectBase.ValidFrom, '%Y-%m-%dT%H:%i:%s.%fZ') AS source_updated_at
FROM ObjectBase
`;
/**
* @param {any} row
* @param {any} [runtime]
* @param {{ groupUid?: string|null, tailnetUid?: string|null }} [extra]
*/
export const toNode = (row, runtime = null, extra = {}) => {
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : (row.Data || {});
const mode = RUNNER_MODES.includes(data.mode) ? data.mode : 'personal';
const role = NODE_ROLES.includes(data.role) ? data.role : RUNNER_ROLE;
return {
UID: HEX2uuid(row.UID),
Title: row.Title || row.Display || '',
Display: row.Display || row.Title || '',
Data: {
role,
mode,
hostname: data.hostname ?? null,
publicKey: data.publicKey ?? null,
tailnetNodeId: data.tailnetNodeId ?? null,
tailnetIp: data.tailnetIp ?? null,
architecture: data.architecture ?? null,
operatingSystem: data.operatingSystem ?? null,
capabilities: data.capabilities ?? {},
status: data.status ?? 'registered',
revokedAt: data.revokedAt ?? null,
},
groupUID: extra.groupUid || null,
tailnetUID: extra.tailnetUid || null,
OrgUID: row.UIDBelongsTo ? HEX2uuid(row.UIDBelongsTo) : null,
source_updated_at: row.source_updated_at,
runtime: runtime
? {
// Companion-Felder: für den runnerSync-Bot-Bootstrap
// (Registry-Projektion) sowie Diagnose sichtbar — publicKey ist
// ein öffentlicher Schlüssel (in ObjectBase.Data), credentialId
// ist der Auth-Lookup (Member.lookup_key).
credentialId: runtime.lookup_key ?? null,
publicKey: data.publicKey ?? null,
lastSeenAt: runtime.last_seen_at,
capacity: runtime.capacity,
heartbeat: runtime.heartbeat,
}
: null,
};
};
/** Runner-Kompatibilität (identische Ausgabeform). */
export const toRunner = toNode;
/**
* Resolve the owner group of a node. `groupHex` may be a group/event UID or an
* org UID (root group). Falls back to the org root group when the given group
* is not a group/event object — exactly like project `resolveProjectGroup`.
* @param {string} groupHex
* @param {string} orgHex
*/
const resolveNodeGroup = async (groupHex, orgHex) => {
const load = async (uidHex, types) => {
const rows = await query(
`SELECT ObjectBase.UID, Member.Display, Member.Data, ObjectBase.Title
FROM ObjectBase
INNER JOIN Member ON (Member.UID = ObjectBase.UID)
WHERE ObjectBase.UID=? AND ObjectBase.Type IN (${types})`,
[uidHex],
{ cast: ['UUID', 'json'] },
);
return rows[0] || null;
};
let group = await load(groupHex, `'group','event'`);
if (!group) {
group = await load(orgHex, `'group'`);
}
return group;
};
/**
* @param {string} nodeHex
*/
const readNodeGroupLink = async (nodeHex) => {
const rows = await query(
`SELECT UIDTarget FROM Links WHERE UID=? AND Type='memberA' LIMIT 1`,
[nodeHex],
);
return rows[0]?.UIDTarget ? HEX2uuid(rows[0].UIDTarget) : null;
};
/**
* Netz-Link eines Nodes (`tailnetNode`, UID=Netz, UIDTarget=Node). Liest
* übergangsweise auch den alten Typ `tailnetRunner`.
* @param {string} nodeHex
* @returns {Promise<string|null>} Tailnet-UID (UUID) oder null
*/
export const readNodeTailnetLink = async (nodeHex) => {
const rows = await query(
`SELECT UID FROM Links WHERE UIDTarget=? AND Type IN ('tailnetNode','tailnetRunner') LIMIT 1`,
[nodeHex],
);
return rows[0]?.UID ? HEX2uuid(rows[0].UID) : null;
};
/**
* @param {string} uidHex
* @param {string} orgHex
* @param {string} [role] optionale Rollen-Einschränkung
*/
export const nodeInOrg = async (uidHex, orgHex, role) => {
const rows = await query(
`SELECT UID FROM ObjectBase WHERE UID=? AND Type=${NODE_TYPE} AND UIDBelongsTo=? ${roleFilter(role)}`,
[uidHex, orgHex],
);
return rows.length === 1;
};
/** Alias für Aufrufer, die den Runner-Begriff weiterverwenden. */
export const runnerInOrg = (uidHex, orgHex) => nodeInOrg(uidHex, orgHex, RUNNER_ROLE);
/**
* @param {string} uidHex
*/
const readNodeRow = async (uidHex) => {
const rows = await query(
`${nodeSelect} WHERE ObjectBase.UID=? AND ObjectBase.Type=${NODE_TYPE}`,
[uidHex],
{ cast: ['json'] },
);
return rows[0] || null;
};
/**
* Minimales Node-Record für interne Validierung/Auflösung
* (kein Sichtbarkeits-Check — der Aufrufer prüft Orga/Auth selbst).
* @param {string} uidHex
* @returns {Promise<{UID: string, Title: string, role: string, mode: string, status: string}|null>}
*/
export const readNodeMeta = async (uidHex) => {
const row = await readNodeRow(uidHex);
if (!row) return null;
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : (row.Data || {});
const mode = RUNNER_MODES.includes(data.mode) ? data.mode : 'personal';
const role = NODE_ROLES.includes(data.role) ? data.role : RUNNER_ROLE;
return {
UID: HEX2uuid(uidHex),
Title: row.Title || row.Display || '',
role,
mode,
status: data.status ?? 'registered',
};
};
/** Runner-Kompatibilität: nur echte Runner-Nodes (role='runner'). */
export const readRunnerMeta = async (uidHex) => {
const meta = await readNodeMeta(uidHex);
return meta && meta.role === RUNNER_ROLE ? meta : null;
};
/**
* Persönlicher Runner einer Person (role=runner, mode=personal, `member`-Link,
* nicht revoked) in derselben Orga. Für die Session-Auflösung „mein
* Personal-Runner" (Soll: Projekt-Runner → Personal-Runner des Users).
* @param {string} userHex
* @param {string} orgHex
* @param {string} [role]
* @returns {Promise<string|null>} Node-UID (UUID) oder null
*/
export const findPersonalNodeForUser = async (userHex, orgHex, role = RUNNER_ROLE) => {
const rows = await query(
`SELECT r.UID
FROM ObjectBase r
INNER JOIN Links l ON (l.UID = r.UID AND l.Type = 'member')
WHERE r.Type=${NODE_TYPE}
AND r.UIDBelongsTo=?
AND l.UIDTarget=?
AND JSON_UNQUOTE(JSON_VALUE(r.Data, '$.role')) = ?
AND JSON_UNQUOTE(JSON_VALUE(r.Data, '$.mode')) = 'personal'
AND JSON_UNQUOTE(JSON_VALUE(r.Data, '$.status')) <> 'revoked'
ORDER BY r.ValidFrom DESC
LIMIT 1`,
[orgHex, userHex, role],
);
return rows[0]?.UID ? HEX2uuid(rows[0].UID) : null;
};
/** Runner-Kompatibilität. */
export const findPersonalRunnerForUser = (userHex, orgHex) =>
findPersonalNodeForUser(userHex, orgHex, RUNNER_ROLE);
/**
* Ist der Node als Projekt-/öffentlicher Node nutzbar (nicht personal, nicht
* revoked, in der Orga)?
* @param {string} uidHex
* @param {string} orgHex
* @param {string} [role]
*/
export const isUsableSharedNode = async (uidHex, orgHex, role = RUNNER_ROLE) => {
if (!(await nodeInOrg(uidHex, orgHex, role))) return false;
const meta = await readNodeMeta(uidHex);
return !!(meta && meta.mode !== 'personal' && meta.status !== 'revoked');
};
/** Runner-Kompatibilität. */
export const isUsableSharedRunner = (uidHex, orgHex) =>
isUsableSharedNode(uidHex, orgHex, RUNNER_ROLE);
/**
* Companion-Zeile eines Nodes — heißer, nicht versionierter Zustand nach dem
* Member-Muster, in der generischen `Member`-Tabelle: `lookup_key` als
* Lookup-Slot, heartbeat/capacity/lastSeenAt im `Data`-JSON.
* @param {string} uidHex
* @returns {Promise<{ last_seen_at: string|null, capacity: object, heartbeat: object, lookup_key: string|null }|null>}
*/
const readNodeCompanion = async (uidHex) => {
const rows = await query(
`SELECT lookup_key, Data FROM Member WHERE UID=?`,
[uidHex],
);
const row = rows[0] || null;
if (!row) return null;
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : (row.Data || {});
return {
last_seen_at: data.lastSeenAt ?? null,
capacity: data.capacity ?? {},
heartbeat: data.heartbeat ?? {},
lookup_key: row.lookup_key ? String(row.lookup_key) : null,
};
};
/**
* Create a node object.
*
* Mirrors the project/list creation pattern:
* - `team`/`shared` get a `memberA` link to their owner group
* - the creator always gets `Visible admin`
* - `personal` keeps the `member` link to the owning user
* - for `team`/`shared` a default visibility rule is created via `addVisibility`
*
* Nur `role='runner'` bekommt eine `Member`-Companion-Zeile (Credential,
* Heartbeat) und verlangt `publicKey`. Gateways und Dev-PCs brauchen keinen
* heißen Zustand — alles Nötige steht in `ObjectBase.Data`.
*
* @param {ExpressRequestAuthorized} req
* @param {{ name?: string, role?: string, mode?: string, groupUID?: string,
* hostname?: string, publicKey?: string, architecture?: string,
* operatingSystem?: string, capabilities?: object, tailnetUID?: string }} fields
*/
export const createNode = async (req, fields) => {
try {
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
const UID = await getUID(req);
const role = NODE_ROLES.includes(fields.role) ? fields.role : RUNNER_ROLE;
const name = (fields.name || (role === RUNNER_ROLE ? 'Runner' : 'Node')).trim().slice(0, 255);
const mode = RUNNER_MODES.includes(fields.mode) ? fields.mode : 'personal';
if (role === RUNNER_ROLE && (!fields.publicKey || typeof fields.publicKey !== 'string')) {
throw apiError(422, 'INVALID_PUBLIC_KEY', 'publicKey is required for a runner node');
}
const data = {
role,
mode,
hostname: fields.hostname ? String(fields.hostname).trim() : null,
publicKey: fields.publicKey || null,
tailnetNodeId: null,
tailnetIp: null,
architecture: fields.architecture || 'unknown',
operatingSystem: fields.operatingSystem || 'unknown',
capabilities: fields.capabilities || {},
status: 'registered',
revokedAt: null,
};
// Resolve the owner group for team/shared modes (like createProject);
// shared defaults to the org root group.
let group = null;
if (mode !== 'personal') {
const groupUid = fields.groupUID ? fields.groupUID : HEX2uuid(orgHex);
group = await resolveNodeGroup(UUID2hex(groupUid), orgHex);
// Owner-Gruppe auch im Data spiegeln (Konvention wie `createTailnet`):
// der `memberA`-Link ist der Zugriff, `Data.groupUID` die Herkunft —
// nur so ist ein kaputter Link später noch reparierbar.
data.groupUID = group ? HEX2uuid(group.UID) : null;
}
await transaction(async (connection) => {
await connection.query(
`INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
VALUES (?, 'node', ?, ?, ?, ?, 0, ?, ?)`,
[UID, orgHex, name, name, name, JSON.stringify(data), userHex],
);
if (mode === 'personal') {
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'member', ?, ?)`,
[UID, userHex, userHex],
);
} else if (group) {
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
// `group.UID` kommt aus `cast: ['UUID']` als präfixierter
// String (`UUID-…`, 41 Zeichen). In die binäre `UIDTarget`-
// Spalte gehört die Buffer-Form — sonst schneidet MariaDB
// den String auf 16 Bytes ab und der Owner-Link ist korrupt
// (genau wie in `createProject`: erst `UUID2hex`).
[UID, UUID2hex(group.UID), userHex],
);
}
if (fields.tailnetUID) {
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'tailnetNode', ?, ?)`,
[UUID2hex(fields.tailnetUID), UID, userHex],
);
}
await connection.query(
`INSERT INTO Visible (UID, Type, UIDUser) VALUES (?, 'admin', ?)`,
[UID, userHex],
);
// Companion-Zeile nur für Runner (Credential/Heartbeat). Für
// gateway/dev-pc gibt es keinen heißen Zustand — ObjectBase.Data
// trägt alles Nötige, ohne History-Wachstum durch Heartbeats.
if (role === RUNNER_ROLE) {
await connection.query(
`INSERT INTO Member (UID, Display, SortName, FullTextIndex, Data)
VALUES (?, ?, '', '', JSON_OBJECT())
ON DUPLICATE KEY UPDATE Display = VALUES(Display)`,
[UID, name.slice(0, 128)],
);
}
});
// Default visibility rule exactly like project/list creation: a
// `changeable` filter attached to the owner group plus super admin
// rights. `addVisibility` builds the filter from `group.Data.hierarchie`
// and queues the visibility calculation.
if (group) {
await addVisibility(req, UID, group);
}
const row = await readNodeRow(UID);
const groupUid = await readNodeGroupLink(UID);
const tailnetUid = await readNodeTailnetLink(UID);
const runtime = role === RUNNER_ROLE ? await readNodeCompanion(UID) : null;
return { success: true, result: toNode(row, runtime, { groupUid, tailnetUid }) };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/** Runner-Kompatibilität: Node mit Rolle `runner`. */
export const createRunner = (req, fields) =>
createNode(req, { ...fields, role: RUNNER_ROLE });
/**
* List nodes of the current org the user can see (visible | changeable | admin).
* Org admins and bots see all nodes. Optional `role` filter and optional
* `groupUid` filter (nodes whose owner group is that group) — like projects.
* @param {ExpressRequestAuthorized} req
* @param {{ role?: string, groupUid?: string }} [opts]
*/
export const listNodes = async (req, { role, groupUid } = {}) => {
try {
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
const admin = await isAdmin(req.session);
const params = [orgHex];
if (!admin) params.push(userHex);
const groupJoin = groupUid
? `INNER JOIN Links gl ON (gl.UID = ObjectBase.UID AND gl.Type='memberA' AND gl.UIDTarget=?)`
: '';
if (groupUid) params.push(UUID2hex(groupUid));
const rows = await query(
`${nodeSelect}
INNER JOIN Visible ON (Visible.UID = ObjectBase.UID)
${groupJoin}
WHERE ObjectBase.Type=${NODE_TYPE} AND ObjectBase.UIDBelongsTo=?
${roleFilter(role)}
${admin ? '' : 'AND Visible.UIDUser = ?'}
GROUP BY ObjectBase.UID
ORDER BY ObjectBase.SortName, ObjectBase.ValidFrom DESC`,
params,
{ cast: ['json'] },
);
const result = [];
for (const row of rows) {
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : (row.Data || {});
const runtime = data.role === RUNNER_ROLE ? await readNodeCompanion(row.UID) : null;
const groupUidLink = await readNodeGroupLink(row.UID);
const tailnetUid = await readNodeTailnetLink(row.UID);
result.push(toNode(row, runtime, { groupUid: groupUidLink, tailnetUid }));
}
return result;
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/** Runner-Kompatibilität: nur Nodes mit Rolle `runner`. */
export const listRunners = (req) => listNodes(req, { role: RUNNER_ROLE });
/**
* @param {ExpressRequestAuthorized} req
* @param {string} nodeUid
*/
export const getNode = async (req, nodeUid) => {
try {
const uidHex = UUID2hex(nodeUid);
const orgHex = UUID2hex(req.session.root);
if (!(await nodeInOrg(uidHex, orgHex))) {
throw apiError(404, 'NODE_NOT_FOUND', 'Node not found in this organization');
}
if (!(await isObjectVisible(req, uidHex))) {
throw apiError(403, 'NODE_NOT_ACCESSIBLE', 'Node is not visible to this user');
}
const row = await readNodeRow(uidHex);
if (!row) throw apiError(404, 'NODE_NOT_FOUND', 'Node not found in this organization');
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : (row.Data || {});
const runtime = data.role === RUNNER_ROLE ? await readNodeCompanion(uidHex) : null;
const groupUid = await readNodeGroupLink(uidHex);
const tailnetUid = await readNodeTailnetLink(uidHex);
return { success: true, result: toNode(row, runtime, { groupUid, tailnetUid }) };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/** Runner-Kompatibilität. */
export const getRunner = (req, uid) => getNode(req, uid);
/**
* Prüft Schreibrechte auf einem Node (Admin oder changeable/admin-Sichtbarkeit).
* @param {ExpressRequestAuthorized} req
* @param {string} uidHex
*/
const assertNodeChangeable = async (req, uidHex) => {
if (!(await isAdmin(req.session))) {
if (!(await isListAdmin(req, HEX2uuid(uidHex)))) {
throw apiError(403, 'NODE_NOT_CHANGEABLE', 'Node is not changeable by this user');
}
}
};
/**
* Update node fields (hostname, tailnet binding, status, capabilities, …).
* Nur `role` ist unveränderlich: ein Node wird nicht zum Runner, indem man die
* Rolle umschreibt (dafür fehlen Credential/Companion-Zeile).
*
* @param {ExpressRequestAuthorized} req
* @param {string} nodeUid
* @param {object} fields
*/
export const updateNode = async (req, nodeUid, fields) => {
try {
const uidHex = UUID2hex(nodeUid);
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
if (!(await nodeInOrg(uidHex, orgHex))) {
throw apiError(404, 'NODE_NOT_FOUND', 'Node not found in this organization');
}
await assertNodeChangeable(req, uidHex);
const row = await readNodeRow(uidHex);
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : { ...(row.Data || {}) };
if (fields.role !== undefined && fields.role !== data.role) {
throw apiError(422, 'ROLE_IMMUTABLE', 'Node role is immutable');
}
if (fields.hostname !== undefined) {
data.hostname = fields.hostname ? String(fields.hostname).trim().slice(0, 255) : null;
}
if (fields.tailnetNodeId !== undefined) data.tailnetNodeId = fields.tailnetNodeId;
if (fields.tailnetIp !== undefined) data.tailnetIp = fields.tailnetIp;
if (fields.capabilities !== undefined && typeof fields.capabilities === 'object') {
data.capabilities = fields.capabilities;
}
if (fields.architecture !== undefined) data.architecture = fields.architecture;
if (fields.operatingSystem !== undefined) data.operatingSystem = fields.operatingSystem;
await query(
`UPDATE ObjectBase SET Data=?, UIDuser=? WHERE UID=? AND Type=${NODE_TYPE}`,
[JSON.stringify(data), userHex, uidHex],
);
// Netzzuordnung ändern („nach Netz verschieben" auf Objekt-Ebene).
if (fields.tailnetUID !== undefined) {
await setNodeTailnetLink(uidHex, fields.tailnetUID ? UUID2hex(fields.tailnetUID) : null, userHex);
}
return getNode(req, nodeUid);
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Soft-revoke: set Data.status=revoked, keep ObjectBase for audit. Die
* Companion-Auth (Member.lookup_key ↔ ObjectBase) lehnt weitere Requests ab.
* @param {ExpressRequestAuthorized} req
* @param {string} nodeUid
*/
export const revokeNode = async (req, nodeUid) => {
try {
const uidHex = UUID2hex(nodeUid);
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
if (!(await nodeInOrg(uidHex, orgHex))) {
throw apiError(404, 'NODE_NOT_FOUND', 'Node not found in this organization');
}
await assertNodeChangeable(req, uidHex);
const row = await readNodeRow(uidHex);
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : { ...(row.Data || {}) };
data.status = 'revoked';
data.revokedAt = new Date().toISOString();
await query(
`UPDATE ObjectBase SET Data=?, UIDuser=? WHERE UID=? AND Type=${NODE_TYPE}`,
[JSON.stringify(data), userHex, uidHex],
);
// Runner-Event (Vertrag 090-Runner-Events) nur für Runner — die
// ide-server-`runner_registry` kennt ausschließlich Runner.
if (data.role === RUNNER_ROLE) {
const nodeUuid = HEX2uuid(uidHex);
await publishEvent(`/change/runner/${nodeUuid}`, {
organization: HEX2uuid(orgHex),
data: {
runnerUid: nodeUuid,
tenantUid: HEX2uuid(orgHex),
status: 'revoked',
},
});
}
return { success: true, result: { UID: HEX2uuid(uidHex), revoked: true } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/** Runner-Kompatibilität. */
export const revokeRunner = (req, uid) => revokeNode(req, uid);
/**
* Mark node draining — der Zusteller (ide-server) weist einem drainenden
* Runner keine neuen Sessions mehr zu. Reine Topologie-Markierung.
* @param {ExpressRequestAuthorized} req
* @param {string} nodeUid
*/
export const drainNode = async (req, nodeUid) => {
try {
const uidHex = UUID2hex(nodeUid);
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
if (!(await nodeInOrg(uidHex, orgHex))) {
throw apiError(404, 'NODE_NOT_FOUND', 'Node not found in this organization');
}
await assertNodeChangeable(req, uidHex);
const row = await readNodeRow(uidHex);
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : { ...(row.Data || {}) };
data.status = 'draining';
data.drainingAt = new Date().toISOString();
await query(
`UPDATE ObjectBase SET Data=?, UIDuser=? WHERE UID=? AND Type=${NODE_TYPE}`,
[JSON.stringify(data), userHex, uidHex],
);
if (data.role === RUNNER_ROLE) {
const nodeUuid = HEX2uuid(uidHex);
await publishEvent(`/change/runner/${nodeUuid}`, {
organization: HEX2uuid(orgHex),
data: {
runnerUid: nodeUuid,
tenantUid: HEX2uuid(orgHex),
status: 'draining',
},
});
}
return { success: true, result: { UID: HEX2uuid(uidHex), status: 'draining' } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/** Runner-Kompatibilität. */
export const drainRunner = (req, uid) => drainNode(req, uid);
/**
* Hard-delete a node: the topology object, its `Member` companion row, all
* links and the visibility grants/filters.
*
* `revokeNode` is the graceful path (soft-delete, keeps the object for audit);
* this is the cleanup path — e.g. for nodes that are already `revoked` and
* would otherwise accumulate forever.
*
* For runners this publishes `/remove/runner/{uid}` so runnerSync drops the
* registry entry (contract 090-Runner-Events §2.3) — without it the ide-server
* would keep accepting a credential that no longer exists in members.
*
* @param {ExpressRequestAuthorized} req
* @param {string} nodeUid
*/
export const deleteNode = async (req, nodeUid) => {
try {
const uidHex = UUID2hex(nodeUid);
const orgHex = UUID2hex(req.session.root);
if (!(await nodeInOrg(uidHex, orgHex))) {
throw apiError(404, 'NODE_NOT_FOUND', 'Node not found in this organization');
}
await assertNodeChangeable(req, uidHex);
const row = await readNodeRow(uidHex);
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : (row.Data || {});
// Visibility owns the Visible rows *and* the filter objects that
// createNode's addVisibility created; deleting them together keeps the
// rights projection consistent (deleteProject does the same).
await deleteVisibility(uidHex, req.session.root);
await transaction(async (connection) => {
await connection.query(`DELETE FROM Links WHERE UID=? OR UIDTarget=?`, [uidHex, uidHex]);
await connection.query(`DELETE FROM Visible WHERE UID=?`, [uidHex]);
// Companion-Zeile (heißer Zustand: lookup_key + Heartbeat).
await connection.query(`DELETE FROM Member WHERE UID=?`, [uidHex]);
await connection.query(`DELETE FROM ObjectBase WHERE UID=? AND Type=${NODE_TYPE}`, [uidHex]);
});
const nodeUuid = HEX2uuid(uidHex);
if (data.role === RUNNER_ROLE) {
await publishEvent(`/remove/runner/${nodeUuid}`, {
organization: HEX2uuid(orgHex),
data: {
runnerUid: nodeUuid,
tenantUid: HEX2uuid(orgHex),
},
});
}
return { success: true, result: { UID: nodeUuid, deleted: true } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/** Runner-Kompatibilität. */
export const deleteRunner = (req, uid) => deleteNode(req, uid);
/**
* Topologie-Heartbeat in die Companion-Zeile (Member.Data) schreiben — nie in
* ObjectBase (kein History-Wachstum). Der Runner bleibt Topologie-Teil von
* members; Job-/Session-Runtime liegt beim Zusteller (ide-server).
* @param {string} nodeUidHex
* @param {string} tenantUidHex
* @param {{ capacity?: object, heartbeat?: object }} payload
*/
export const upsertRuntime = async (nodeUidHex, tenantUidHex, payload = {}) => {
const data = JSON.stringify({
lastSeenAt: new Date().toISOString(),
capacity: payload.capacity || {},
heartbeat: payload.heartbeat || {},
});
await query(
`INSERT INTO Member (UID, Display, SortName, FullTextIndex, Data)
VALUES (?, '', '', '', ?)
ON DUPLICATE KEY UPDATE Data = VALUES(Data)`,
[nodeUidHex, data],
);
};
/**
* Update node Data fields (tailnet, capabilities) — schreibt nur bei echten
* Änderungen (kein versioniertes ObjectBase-Wachstum durch identische
* Heartbeat-Meldungen).
* @param {string} nodeUidHex
* @param {string} userHex
* @param {object} patch
*/
export const patchNodeData = async (nodeUidHex, userHex, patch) => {
const row = await readNodeRow(nodeUidHex);
if (!row) throw apiError(404, 'NODE_NOT_FOUND', 'Node not found');
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : { ...(row.Data || {}) };
let changed = false;
for (const [key, value] of Object.entries(patch)) {
if (JSON.stringify(data[key] ?? null) !== JSON.stringify(value ?? null)) {
data[key] = value;
changed = true;
}
}
if (changed) {
await query(
`UPDATE ObjectBase SET Data=?, UIDuser=? WHERE UID=? AND Type=${NODE_TYPE}`,
[JSON.stringify(data), userHex, nodeUidHex],
);
}
return toNode({ ...row, Data: data });
};
/** Runner-Kompatibilität. */
export const patchRunnerData = (nodeUidHex, userHex, patch) =>
patchNodeData(nodeUidHex, userHex, patch);
/**
* Bindet einen Node an ein Tailnet-Netz (`tailnetNode`), löst eine bestehende
* Bindung (auch den alten Typ `tailnetRunner`). Idempotent.
* @param {string} nodeUidHex
* @param {string|null} tailnetUidHex
* @param {string} userHex
*/
export const setNodeTailnetLink = async (nodeUidHex, tailnetUidHex, userHex) => {
// `Links` ist system-versioniert: ein DELETE/INSERT-Paar schreibt **immer**
// eine History-Zeile und setzt `ValidFrom` des Links neu. Bei einem
// periodischen Reconcile (Default 60 s) wüchse die Historie also pro Node
// und der Link sähe aus, als wäre er jede Minute neu entstanden (Befund
// 2026-10-03: 14 Versionen in 2,5 h). Deshalb erst vergleichen: ohne echte
// Änderung wird gar nichts geschrieben.
const current = await readNodeTailnetLink(nodeUidHex);
const wanted = tailnetUidHex ? HEX2uuid(tailnetUidHex) : null;
if (current === wanted) return;
await transaction(async (connection) => {
await connection.query(
`DELETE FROM Links WHERE UIDTarget=? AND Type IN ('tailnetNode','tailnetRunner')`,
[nodeUidHex],
);
if (tailnetUidHex) {
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'tailnetNode', ?, ?)`,
[tailnetUidHex, nodeUidHex, userHex],
);
}
});
};
/**
* Findet einen Node derselben Orga über seine Tailnet-Identität. Reihenfolge
* der Schlüssel — vom stabilsten zum schwächsten:
* 1. `Data.tailnetNodeId` (Headscale-Node-ID; nach dem ersten Sync gesetzt)
* 2. `Data.hostname` (der vom Client gesetzte `--hostname`)
* 3. `Data.tailnetIp` (die Tailnet-IP; fängt Bestands-Runner ohne Hostname)
*
* @param {string} orgHex
* @param {{ tailnetNodeId?: string|number|null, hostname?: string|null, tailnetIp?: string|null }} ident
* @returns {Promise<any|null>} ObjectBase-Zeile (mit `UID` binär) oder null
*/
const findNodeByTailnetIdentity = async (orgHex, { tailnetNodeId, hostname, tailnetIp } = {}) => {
const byData = async (jsonPath, value) => {
if (value === undefined || value === null || String(value).trim() === '') return null;
const rows = await query(
`${nodeSelect}
WHERE ObjectBase.Type=${NODE_TYPE} AND ObjectBase.UIDBelongsTo=?
AND JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '${jsonPath}')) = ?
AND COALESCE(JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.status')), 'registered') <> 'revoked'
ORDER BY ObjectBase.ValidFrom DESC
LIMIT 1`,
[orgHex, String(value).trim()],
{ cast: ['json'] },
);
return rows[0] || null;
};
return (
(await byData('$.tailnetNodeId', tailnetNodeId)) ||
(await byData('$.hostname', hostname)) ||
(await byData('$.tailnetIp', tailnetIp)) ||
null
);
};
/**
* Lässt einen Node die Sichtbarkeit seines Netzes **erben**. Anders als ein
* eigenes `addVisibility` legt das keinen neuen Filter an: der Node tritt der
* Owner-Gruppe des Netzes bei und wird an dieselben Sichtbarkeits-Filter
* gehängt (`Links.Type='list'`, UID=Filter, UIDTarget=Objekt). Ändert das Netz
* später seine Sichtbarkeit (Gruppe/Freigabe), folgt der Node. Zusätzlich
* werden die bestehenden `Visible`-Zeilen kopiert, damit die Vererbung sofort
* greift, ohne auf einen Tree-Rebuild zu warten. Idempotent.
*
* @param {string} nodeHex
* @param {string} tailnetHex
* @param {string} userHex
*/
export const inheritNodeVisibility = async (nodeHex, tailnetHex, userHex) => {
// 1. Owner-Gruppe des Netzes übernehmen (memberA: UID=Netz, UIDTarget=Gruppe).
const groupRows = await query(
`SELECT UIDTarget FROM Links WHERE UID=? AND Type='memberA' LIMIT 1`,
[tailnetHex],
);
const groupTarget = groupRows[0]?.UIDTarget || null;
if (groupTarget) {
await query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'memberA', ?, ?)`,
[nodeHex, groupTarget, userHex],
);
}
// 2. Sofortwirkung: die Visible-Zeilen des Netzes spiegeln.
await query(
`INSERT IGNORE INTO Visible (UID, Type, UIDUser)
SELECT ?, Type, UIDUser FROM Visible WHERE UID=?`,
[nodeHex, tailnetHex],
);
// 3. Dauerhaft: an dieselben Sichtbarkeits-Filter wie das Netz hängen.
const filters = await query(
`SELECT UID FROM Links WHERE UIDTarget=? AND Type='list'`,
[tailnetHex],
);
for (const f of filters) {
await query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'list', ?, ?)`,
[f.UID, nodeHex, userHex],
);
}
};
/**
* Idempotenter Sync-Upsert für Nodes, die der project-bot aus Headscale meldet
* (Gateway / Dev-PC / Runner).
*
* Findet der Bestand ein Objekt (über `tailnetNodeId`, `hostname` oder
* `tailnetIp`), werden nur die Tailnet-Fakten nachgezogen — **die Rolle bleibt
* unangetastet**, sie ist im UI gepflegt (ein Dev-PC wird nicht dadurch zum
* Runner, dass er im Tailnet auftaucht). Andernfalls entsteht ein neues Objekt,
* das die Sichtbarkeit des Netzes erbt (siehe `inheritNodeVisibility`).
*
* Ein `runner` ohne `publicKey` wird nicht angelegt: ein Runner braucht
* Credential + Companion-Zeile, die nur die Registrierung liefert.
*
* @param {ExpressRequestAuthorized} req
* @param {{ hostname?: string, title?: string, role?: string, mode?: string,
* tailnetUID?: string, tailnetIp?: string, tailnetNodeId?: string|number,
* architecture?: string, operatingSystem?: string, capabilities?: object,
* publicKey?: string }} fields
* @returns {Promise<{success: true, result: {created: boolean, node: object}}>}
*/
export const upsertNodeByHostname = async (req, fields) => {
try {
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
const hostname = String(fields.hostname || '').trim().slice(0, 255);
if (!hostname) {
throw apiError(422, 'HOSTNAME_REQUIRED', 'hostname is required');
}
const tailnetHex = fields.tailnetUID ? UUID2hex(fields.tailnetUID) : null;
if (tailnetHex) {
const net = await query(
`SELECT UID FROM ObjectBase WHERE UID=? AND Type='tailnet' AND UIDBelongsTo=?`,
[tailnetHex, orgHex],
);
if (net.length !== 1) {
throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
}
if (!(await isObjectVisible(req, tailnetHex))) {
throw apiError(403, 'TAILNET_NOT_ACCESSIBLE', 'Tailnet network is not visible to this user');
}
}
const existing = await findNodeByTailnetIdentity(orgHex, {
tailnetNodeId: fields.tailnetNodeId,
hostname,
tailnetIp: fields.tailnetIp,
});
if (existing) {
const data =
typeof existing.Data === 'string'
? JSON.parse(existing.Data)
: { ...(existing.Data || {}) };
// Nur echte Änderungen schreiben. `ObjectBase` ist system-versioniert:
// ein UPDATE je Sync (Default 60 s) erzeugte sonst pro Node eine
// History-Zeile — genau das, was `upsertRuntime` für den Heartbeat
// bewusst vermeidet (`Member.Data`, „kein History-Wachstum").
const patch = {};
if (!data.hostname) patch.hostname = hostname;
if (
fields.tailnetNodeId !== undefined &&
fields.tailnetNodeId !== null &&
String(data.tailnetNodeId ?? '') !== String(fields.tailnetNodeId)
) {
patch.tailnetNodeId = String(fields.tailnetNodeId);
}
if (fields.tailnetIp && String(data.tailnetIp ?? '') !== String(fields.tailnetIp)) {
patch.tailnetIp = String(fields.tailnetIp);
}
if (fields.architecture && data.architecture !== fields.architecture) {
patch.architecture = fields.architecture;
}
if (fields.operatingSystem && data.operatingSystem !== fields.operatingSystem) {
patch.operatingSystem = fields.operatingSystem;
}
// Nur ein fehlender/ungültiger Rollenwert wird gesetzt — niemals
// eine gepflegte Rolle überschreiben.
if (!NODE_ROLES.includes(data.role)) {
patch.role = NODE_ROLES.includes(fields.role) ? fields.role : 'dev-pc';
}
if (Object.keys(patch).length) {
await query(
`UPDATE ObjectBase SET Data=?, UIDuser=? WHERE UID=? AND Type=${NODE_TYPE}`,
[JSON.stringify({ ...data, ...patch }), userHex, existing.UID],
);
}
if (tailnetHex) {
await setNodeTailnetLink(existing.UID, tailnetHex, userHex);
await inheritNodeVisibility(existing.UID, tailnetHex, userHex);
}
return {
success: true,
result: {
created: false,
node: toNode(await readNodeRow(existing.UID), null, {
groupUid: await readNodeGroupLink(existing.UID),
tailnetUid: await readNodeTailnetLink(existing.UID),
}),
},
};
}
// Neu anlegen.
const role = NODE_ROLES.includes(fields.role) ? fields.role : 'dev-pc';
if (role === RUNNER_ROLE && (!fields.publicKey || typeof fields.publicKey !== 'string')) {
throw apiError(422, 'INVALID_PUBLIC_KEY', 'publicKey is required to create a runner node');
}
const title = String(fields.title || hostname).trim().slice(0, 255) || hostname;
const mode = RUNNER_MODES.includes(fields.mode) ? fields.mode : 'team';
const data = {
role,
mode,
hostname,
publicKey: fields.publicKey || null,
tailnetNodeId:
fields.tailnetNodeId !== undefined && fields.tailnetNodeId !== null
? String(fields.tailnetNodeId)
: null,
tailnetIp: fields.tailnetIp ? String(fields.tailnetIp) : null,
architecture: fields.architecture || 'unknown',
operatingSystem: fields.operatingSystem || 'unknown',
capabilities: fields.capabilities || {},
status: 'registered',
revokedAt: null,
};
const UID = await getUID(req);
await transaction(async (connection) => {
await connection.query(
`INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
VALUES (?, 'node', ?, ?, ?, ?, 0, ?, ?)`,
[UID, orgHex, title, title, title, JSON.stringify(data), userHex],
);
if (tailnetHex) {
await connection.query(
`INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'tailnetNode', ?, ?)`,
[tailnetHex, UID, userHex],
);
}
if (role === RUNNER_ROLE) {
await connection.query(
`INSERT INTO Member (UID, Display, SortName, FullTextIndex, Data)
VALUES (?, ?, '', '', JSON_OBJECT())
ON DUPLICATE KEY UPDATE Display = VALUES(Display)`,
[UID, title.slice(0, 128)],
);
}
});
if (tailnetHex) await inheritNodeVisibility(UID, tailnetHex, userHex);
const runtime = role === RUNNER_ROLE ? await readNodeCompanion(UID) : null;
return {
success: true,
result: {
created: true,
node: toNode(await readNodeRow(UID), runtime, {
groupUid: await readNodeGroupLink(UID),
tailnetUid: await readNodeTailnetLink(UID),
}),
},
};
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};
/**
* Findet einen bestehenden, **nicht entzogenen Runner** derselben Orga für eine
* **Re-Registrierung** — damit die `runnerUid` über Re-Registrierungen hinweg
* stabil bleibt.
*
* Hintergrund (Befund 2026-10-04): Der Connector registriert sich bei
* `RUNNER_AUTH_INVALID`/401 automatisch neu (mit demselben
* `REGISTRATION_CODE`). `exchangeRegistration` legte dabei bisher **immer** ein
* neues Node-Objekt an → neue `runnerUid`. Gebundene Sessions und
* `DEFAULT_RUNNER_ID` zeigten danach auf eine Leiche: „Runner weg" im IDE,
* obwohl Container und Tailnet-Node stehen.
*
* Reihenfolge der Schlüssel — vom stabilsten zum schwächsten:
* 1. `tailnetNodeId` / `hostname` (über `findNodeByTailnetIdentity`)
* 2. Name (`Title`/`Display`/`SortName`) **und** am selben Netz
* 3. Name allein — nur wenn er unter den lebenden Runnern **eindeutig** ist
*
* @param {string} orgHex
* @param {{ name?: string, hostname?: string, tailnetNodeId?: string|number|null, tailnetIp?: string|null, tailnetHex?: Buffer|null }} ident
* @returns {Promise<any|null>} ObjectBase-Zeile (mit binärem `UID`) oder `null`
*/
export const findRegisteredRunnerForReuse = async (
orgHex,
{ name, hostname, tailnetNodeId, tailnetIp, tailnetHex } = {},
) => {
const byIdent = await findNodeByTailnetIdentity(orgHex, { tailnetNodeId, hostname, tailnetIp });
// Ein Treffer über die Tailnet-Identität muss ein **Runner** sein — sonst
// würde ein Gateway/Dev-PC mit gleichem Hostname „wiederverwendet" und
// `applyRunnerReRegistration` scheiterte an der Rolle.
if (byIdent) {
const d = typeof byIdent.Data === 'string' ? JSON.parse(byIdent.Data) : (byIdent.Data || {});
if (d.role === RUNNER_ROLE) return byIdent;
}
const title = String(name || '').trim();
if (!title) return null;
const baseWhere = `
WHERE ObjectBase.Type=${NODE_TYPE} AND ObjectBase.UIDBelongsTo=?
AND JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.role')) = '${RUNNER_ROLE}'
AND COALESCE(JSON_UNQUOTE(JSON_VALUE(ObjectBase.Data, '$.status')), 'registered') <> 'revoked'
AND (ObjectBase.Title=? OR ObjectBase.Display=? OR ObjectBase.SortName=?)`;
// 2. Name + Netz (eindeutige Zuordnung, wenn das Netz bekannt ist).
if (tailnetHex) {
const byNet = await query(
`${nodeSelect}${baseWhere}
AND EXISTS (SELECT 1 FROM Links l
WHERE l.UID=? AND l.Type='tailnetNode' AND l.UIDTarget=ObjectBase.UID)
ORDER BY ObjectBase.ValidFrom DESC
LIMIT 1`,
[orgHex, title, title, title, tailnetHex],
{ cast: ['json'] },
);
if (byNet[0]) return byNet[0];
}
// 3. Name allein — nur bei genau einem Treffer (sonst droht die falsche
// Maschine wiederbelebt zu werden).
const byName = await query(
`${nodeSelect}${baseWhere}
ORDER BY ObjectBase.ValidFrom DESC
LIMIT 2`,
[orgHex, title, title, title],
{ cast: ['json'] },
);
return byName.length === 1 ? byName[0] : null;
};
/**
* Re-Registrierung eines **bestehenden** Runners: Public-Key und Fakten
* auffrischen, **UID bleibt unverändert**. Kein Sichtbarkeits-/Change-Check —
* die Registrierung ist durch den One-Time-Code autorisiert, nicht durch eine
* Nutzer-Sitzung. Die Companion-Credential (`Member.lookup_key`) rotiert der
* Aufrufer (`registration/service.js`).
*
* @param {ExpressRequestAuthorized} req
* @param {string} nodeUid
* @param {{ publicKey?: string, architecture?: string, operatingSystem?: string,
* capabilities?: object, hostname?: string, mode?: string }} fields
*/
export const applyRunnerReRegistration = async (req, nodeUid, fields = {}) => {
try {
const uidHex = UUID2hex(nodeUid);
const orgHex = UUID2hex(req.session.root);
const userHex = UUID2hex(req.session.user);
if (!(await nodeInOrg(uidHex, orgHex))) {
throw apiError(404, 'NODE_NOT_FOUND', 'Node not found in this organization');
}
const row = await readNodeRow(uidHex);
const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : { ...(row.Data || {}) };
if (data.role !== RUNNER_ROLE) {
throw apiError(422, 'NOT_A_RUNNER', 'Node is not a runner');
}
if (typeof fields.publicKey === 'string' && fields.publicKey) data.publicKey = fields.publicKey;
if (fields.architecture) data.architecture = fields.architecture;
if (fields.operatingSystem) data.operatingSystem = fields.operatingSystem;
if (fields.capabilities && typeof fields.capabilities === 'object') {
data.capabilities = fields.capabilities;
}
if (typeof fields.hostname === 'string' && fields.hostname.trim()) {
data.hostname = fields.hostname.trim().slice(0, 255);
}
// Der Modus stammt aus dem Registrierungs-Code und ist damit für diese
// Registrierung maßgeblich (nicht die UI-Pflege wie bei der Rolle).
if (RUNNER_MODES.includes(fields.mode)) data.mode = fields.mode;
await query(
`UPDATE ObjectBase SET Data=?, UIDuser=? WHERE UID=? AND Type=${NODE_TYPE}`,
[JSON.stringify(data), userHex, uidHex],
);
return { success: true, result: { UID: HEX2uuid(uidHex) } };
} catch (e) {
errorLoggerUpdate(e);
throw e;
}
};