Source: RouterNode/node/service.js

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