Source: RouterRunner/runner/service.js

// @ts-check
/**
 * Runner ObjectBase service — Type='runner' with a companion row in the generic
 * `Member` table (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,
 *                                    von createRunner gesetzt)
 *   - heartbeat/capacity/lastSeenAt → Member.Data (Topologie-Heartbeat)
 *
 * Runners follow the same visibility model as projects:
 * - `personal`: Links.Type='member' → owning user + Visible admin for creator
 * - `team`:     Links.Type='memberA' → owner group + Visible admin for creator
 *               + addVisibility (group job holders see the runner)
 * - `shared`:   Links.Type='memberA' → org root group + addVisibility
 *
 * 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 RUNNER_TYPE = `'runner'`;

/** @type {const} */
const RUNNER_MODES = ['personal', 'team', 'shared'];

const runnerSelect = `
    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 }} [extra]
 */
export const toRunner = (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';
    return {
        UID: HEX2uuid(row.UID),
        Title: row.Title || row.Display || '',
        Display: row.Display || row.Title || '',
        Data: {
            mode,
            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,
        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,
    };
};

/**
 * Resolve the owner group of a runner. `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 resolveRunnerGroup = 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} runnerHex
 */
const readRunnerGroupLink = async (runnerHex) => {
    const rows = await query(
        `SELECT UIDTarget FROM Links WHERE UID=? AND Type='memberA' LIMIT 1`,
        [runnerHex],
    );
    return rows[0]?.UIDTarget ? HEX2uuid(rows[0].UIDTarget) : null;
};

/**
 * @param {string} uidHex
 * @param {string} orgHex
 */
export const runnerInOrg = async (uidHex, orgHex) => {
    const rows = await query(
        `SELECT UID FROM ObjectBase WHERE UID=? AND Type=${RUNNER_TYPE} AND UIDBelongsTo=?`,
        [uidHex, orgHex],
    );
    return rows.length === 1;
};

/**
 * @param {string} uidHex
 */
const readRunnerRow = async (uidHex) => {
    const rows = await query(
        `${runnerSelect} WHERE ObjectBase.UID=? AND ObjectBase.Type=${RUNNER_TYPE}`,
        [uidHex],
        { cast: ['json'] },
    );
    return rows[0] || null;
};

/**
 * Minimales Runner-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, mode: string, status: string}|null>}
 */
export const readRunnerMeta = async (uidHex) => {
    const row = await readRunnerRow(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';
    return {
        UID: HEX2uuid(uidHex),
        Title: row.Title || row.Display || '',
        mode,
        status: data.status ?? 'registered',
    };
};

/**
 * Persönlicher Runner einer Person (mode=personal, `member`-Link → Person,
 * 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
 * @returns {Promise<string|null>} Runner-UID (UUID-Format) oder null
 */
export const findPersonalRunnerForUser = async (userHex, orgHex) => {
    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=${RUNNER_TYPE}
           AND r.UIDBelongsTo=?
           AND l.UIDTarget=?
           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],
    );
    return rows[0]?.UID ? HEX2uuid(rows[0].UID) : null;
};

/**
 * Ist der Runner als Projekt-/öffentlicher Runner nutzbar (Soll: nicht
 * personal, nicht revoked, in der Orga)?
 * @param {string} uidHex
 * @param {string} orgHex
 * @returns {Promise<boolean>}
 */
export const isUsableSharedRunner = async (uidHex, orgHex) => {
    if (!(await runnerInOrg(uidHex, orgHex))) return false;
    const meta = await readRunnerMeta(uidHex);
    return !!(meta && meta.mode !== 'personal' && meta.status !== 'revoked');
};

/**
 * Companion-Zeile eines Runners — 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 readRunnerCompanion = 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 runner object after registration approval / exchange.
 *
 * Mirrors the project/list creation pattern:
 * - `team`/`shared` get a `memberA` link to their owner group (team = given
 *   group, shared = org root group)
 * - the creator always gets `Visible admin`
 * - `personal` keeps the existing `member` link to the owning user
 * - for `team`/`shared` a default visibility rule is created via `addVisibility`
 *   so job holders of the group see the runner
 *
 * @param {ExpressRequestAuthorized} req
 * @param {{ name?: string, mode?: string, groupUID?: string, publicKey: string, architecture?: string, operatingSystem?: string, capabilities?: object }} fields
 */
export const createRunner = async (req, fields) => {
    try {
        const orgHex = UUID2hex(req.session.root);
        const userHex = UUID2hex(req.session.user);
        const UID = await getUID(req);
        const name = (fields.name || 'Runner').trim().slice(0, 255);
        const mode = RUNNER_MODES.includes(fields.mode) ? fields.mode : 'personal';
        if (!fields.publicKey || typeof fields.publicKey !== 'string') {
            throw apiError(422, 'INVALID_PUBLIC_KEY', 'publicKey is required');
        }
        const data = {
            mode,
            publicKey: fields.publicKey,
            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 resolveRunnerGroup(UUID2hex(groupUid), orgHex);
        }

        await transaction(async (connection) => {
            await connection.query(
                `INSERT INTO ObjectBase (UID, Type, UIDBelongsTo, Title, Display, SortName, dindex, Data, UIDuser)
                 VALUES (?, 'runner', ?, ?, ?, ?, 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', ?, ?)`,
                    [UID, group.UID, userHex],
                );
            }
            await connection.query(
                `INSERT INTO Visible (UID, Type, UIDUser) VALUES (?, 'admin', ?)`,
                [UID, userHex],
            );
            // Member-Muster: Companion-Zeile in der generischen `Member`-Tabelle
            // für heißen Zustand (Credential, Heartbeat) — nicht versioniert,
            // kein ObjectBase-History-Wachstum. Die UID ist die des Runners.
            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 runner 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 readRunnerRow(UID);
        const groupUid = await readRunnerGroupLink(UID);
        return { success: true, result: toRunner(row, null, { groupUid }) };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * List runners of the current org the user can see (visible | changeable |
 * admin). Org admins and bots see all runners of the organization. Non-admins
 * are filtered through the Visible table (creator/admin row + addVisibility
 * group membership) — exactly the project `getListing` pattern.
 * @param {ExpressRequestAuthorized} req
 */
export const listRunners = async (req) => {
    try {
        const orgHex = UUID2hex(req.session.root);
        const userHex = UUID2hex(req.session.user);
        const admin = await isAdmin(req.session);

        const rows = await query(
            `${runnerSelect}
             INNER JOIN Visible ON (Visible.UID = ObjectBase.UID)
             WHERE ObjectBase.Type=${RUNNER_TYPE} AND ObjectBase.UIDBelongsTo=?
             ${admin ? '' : 'AND Visible.UIDUser = ?'}
             GROUP BY ObjectBase.UID
             ORDER BY ObjectBase.SortName, ObjectBase.ValidFrom DESC`,
            admin ? [orgHex] : [orgHex, userHex],
            { cast: ['json'] },
        );

        const result = [];
        for (const row of rows) {
            const runtime = await readRunnerCompanion(row.UID);
            const groupUid = await readRunnerGroupLink(row.UID);
            result.push(toRunner(row, runtime, { groupUid }));
        }
        return result;
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * @param {ExpressRequestAuthorized} req
 * @param {string} runnerUid
 */
export const getRunner = async (req, runnerUid) => {
    try {
        const uidHex = UUID2hex(runnerUid);
        const orgHex = UUID2hex(req.session.root);
        if (!(await runnerInOrg(uidHex, orgHex))) {
            throw apiError(404, 'RUNNER_NOT_FOUND', 'Runner not found in this organization');
        }
        if (!(await isObjectVisible(req, uidHex))) {
            throw apiError(403, 'RUNNER_NOT_ACCESSIBLE', 'Runner is not visible to this user');
        }
        const row = await readRunnerRow(uidHex);
        if (!row) throw apiError(404, 'RUNNER_NOT_FOUND', 'Runner not found in this organization');
        const runtime = await readRunnerCompanion(uidHex);
        const groupUid = await readRunnerGroupLink(uidHex);
        return { success: true, result: toRunner(row, runtime, { groupUid }) };
    } 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} runnerUid
 */
export const revokeRunner = async (req, runnerUid) => {
    try {
        const uidHex = UUID2hex(runnerUid);
        const orgHex = UUID2hex(req.session.root);
        const userHex = UUID2hex(req.session.user);
        if (!(await runnerInOrg(uidHex, orgHex))) {
            throw apiError(404, 'RUNNER_NOT_FOUND', 'Runner not found in this organization');
        }
        if (!(await isAdmin(req.session))) {
            if (!(await isListAdmin(req, HEX2uuid(uidHex)))) {
                throw apiError(403, 'RUNNER_NOT_CHANGEABLE', 'Runner is not changeable by this user');
            }
        }

        const row = await readRunnerRow(uidHex);
        const data = typeof row.Data === 'string' ? JSON.parse(row.Data) : { ...(row.Data || {}) };
        data.status = 'revoked';
        data.revokedAt = new Date().toISOString();

        await transaction(async (connection) => {
            await connection.query(
                `UPDATE ObjectBase SET Data=?, UIDuser=? WHERE UID=? AND Type='runner'`,
                [JSON.stringify(data), userHex, uidHex],
            );
        });

        // Runner-Event (Vertrag 090-Runner-Events): Revoke sofort in der
        // ide-server `runner_registry` wirken lassen (Status revoked).
        const runnerUuid = HEX2uuid(uidHex);
        await publishEvent(`/change/runner/${runnerUuid}`, {
            organization: HEX2uuid(orgHex),
            data: {
                runnerUid: runnerUuid,
                tenantUid: HEX2uuid(orgHex),
                status: 'revoked',
            },
        });

        return { success: true, result: { UID: HEX2uuid(uidHex), revoked: true } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Mark runner draining — der Zusteller (ide-server) weist einem drainenden
 * Runner keine neuen Sessions mehr zu. Reine Topologie-Markierung.
 * @param {ExpressRequestAuthorized} req
 * @param {string} runnerUid
 */
export const drainRunner = async (req, runnerUid) => {
    try {
        const uidHex = UUID2hex(runnerUid);
        const orgHex = UUID2hex(req.session.root);
        const userHex = UUID2hex(req.session.user);
        if (!(await runnerInOrg(uidHex, orgHex))) {
            throw apiError(404, 'RUNNER_NOT_FOUND', 'Runner not found in this organization');
        }
        if (!(await isAdmin(req.session))) {
            if (!(await isListAdmin(req, HEX2uuid(uidHex)))) {
                throw apiError(403, 'RUNNER_NOT_CHANGEABLE', 'Runner is not changeable by this user');
            }
        }
        const row = await readRunnerRow(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='runner'`,
            [JSON.stringify(data), userHex, uidHex],
        );

        // Runner-Event (Vertrag 090-Runner-Events): draining-Zustand in der
        // ide-server `runner_registry` spiegeln (keine neuen Sessions).
        const runnerUuid = HEX2uuid(uidHex);
        await publishEvent(`/change/runner/${runnerUuid}`, {
            organization: HEX2uuid(orgHex),
            data: {
                runnerUid: runnerUuid,
                tenantUid: HEX2uuid(orgHex),
                status: 'draining',
            },
        });

        return { success: true, result: { UID: HEX2uuid(uidHex), status: 'draining' } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Hard-delete a runner: the topology object, its `Member` companion row,
 * all links and the visibility grants/filters.
 *
 * `revokeRunner` is the graceful path (soft-delete, keeps the object for
 * audit); this is the cleanup path — e.g. for runners that are already
 * `revoked` and would otherwise accumulate forever.
 *
 * 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} runnerUid
 */
export const deleteRunner = async (req, runnerUid) => {
    try {
        const uidHex = UUID2hex(runnerUid);
        const orgHex = UUID2hex(req.session.root);
        if (!(await runnerInOrg(uidHex, orgHex))) {
            throw apiError(404, 'RUNNER_NOT_FOUND', 'Runner not found in this organization');
        }
        if (!(await isAdmin(req.session))) {
            if (!(await isListAdmin(req, HEX2uuid(uidHex)))) {
                throw apiError(403, 'RUNNER_NOT_CHANGEABLE', 'Runner is not changeable by this user');
            }
        }

        // Visibility owns the Visible rows *and* the filter objects that
        // createRunner'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=?`, [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='runner'`, [uidHex]);
        });

        const runnerUuid = HEX2uuid(uidHex);
        await publishEvent(`/remove/runner/${runnerUuid}`, {
            organization: HEX2uuid(orgHex),
            data: {
                runnerUid: runnerUuid,
                tenantUid: HEX2uuid(orgHex),
            },
        });

        return { success: true, result: { UID: runnerUuid, deleted: true } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * 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} runnerUidHex
 * @param {string} tenantUidHex
 * @param {{ capacity?: object, heartbeat?: object }} payload
 */
export const upsertRuntime = async (runnerUidHex, 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)`,
        [runnerUidHex, data],
    );
};

/**
 * Update runner Data fields (tailnet, capabilities) — schreibt nur bei echten
 * Änderungen (kein versioniertes ObjectBase-Wachstum durch identische
 * Heartbeat-Meldungen).
 * @param {string} runnerUidHex
 * @param {string} userHex
 * @param {object} patch
 */
export const patchRunnerData = async (runnerUidHex, userHex, patch) => {
    const row = await readRunnerRow(runnerUidHex);
    if (!row) throw apiError(404, 'RUNNER_NOT_FOUND', 'Runner 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='runner'`,
            [JSON.stringify(data), userHex, runnerUidHex],
        );
    }
    return toRunner({ ...row, Data: data });
};