Source: RouterRunner/registration/service.js

// @ts-check
/**
 * Runner-Registrierung: One-Time-Codes transient in Redis (TTL), Credential-
 * Exchange (Ed25519-Public-Key am Server) gegen die Companion-Zeile
 * (`Member`) des Runner-Objekts.
 *
 * Soll (080-Workspaces): Codes sind flüchtig (Redis), kein runner_registration_codes-
 * Table mehr. Die Runner-Auth liest den `lookup_key` aus `Member` und den
 * `publicKey` aus `ObjectBase.Data`; revoked Runner werden über den
 * ObjectBase-Status abgewiesen.
 *
 * @import {ExpressRequestAuthorized} from '../../types.js'
 */

import { randomBytes, createHash, verify as cryptoVerify, randomUUID } from 'node:crypto';
import { query, UUID2hex, HEX2uuid } from '@commtool/sql-query';
import { apiError } from '../../utils/apiEnvelope.js';
import { errorLoggerUpdate } from '../../utils/requestLogger.js';
import { publishEvent } from '../../utils/events.js';
import * as runnerService from '../runner/service.js';
import { kvSetEx, kvGet, kvDel } from '../redis.js';

const CODE_TTL_SECONDS = 30 * 60;
const regKey = (code) => `runner:reg:${code}`;

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

/**
 * Create a one-time registration code for the current tenant. The admin may
 * already decide the connector's mode (`personal`|`team`|`shared`), its owner
 * group (`groupUID`) and a tailnet network (`tailnetUID`) — the connector
 * cannot know these during the credential exchange, so they are bound to the
 * code (Redis, transient, TTL 30 min).
 * @param {ExpressRequestAuthorized} req
 */
export const createRegistration = async (req) => {
    try {
        const orgUuid = req.session.root;
        const userUuid = req.session.user;
        const body = req.body || {};
        const mode = RUNNER_MODES.includes(body.mode) ? body.mode : 'personal';
        const groupUID = typeof body.groupUID === 'string' && body.groupUID.trim()
            ? body.groupUID.trim()
            : null;
        const tailnetUID = typeof body.tailnetUID === 'string' && body.tailnetUID.trim()
            ? body.tailnetUID.trim()
            : null;
        if (tailnetUID && !(await tailnetInOrg(UUID2hex(tailnetUID), UUID2hex(orgUuid)))) {
            throw apiError(404, 'TAILNET_NOT_FOUND', 'Tailnet network not found in this organization');
        }
        const code = randomBytes(24).toString('base64url');
        const payload = JSON.stringify({
            tenant: orgUuid,
            createdBy: userUuid,
            status: 'pending',
            mode,
            groupUID,
            tailnetUID,
        });
        const stored = await kvSetEx(regKey(code), CODE_TTL_SECONDS, payload);
        if (!stored) {
            throw apiError(503, 'REGISTRATION_STORE_UNAVAILABLE', 'Registration store (Redis) is unavailable');
        }
        return {
            success: true,
            result: {
                code,
                expiresAt: new Date(Date.now() + CODE_TTL_SECONDS * 1000).toISOString(),
                tenantId: orgUuid,
                mode,
                groupUID,
                tailnetUID,
            },
        };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * @param {Buffer} uidHex
 * @param {Buffer} orgHex
 */
const tailnetInOrg = async (uidHex, orgHex) => {
    const rows = await query(
        `SELECT UID FROM ObjectBase WHERE UID=? AND Type='tailnet' AND UIDBelongsTo=?`,
        [uidHex, orgHex],
    );
    return rows.length === 1;
};

/** @returns {Promise<{code: string, tenant: string, createdBy: string, status: string, mode: string, groupUID: string|null, tailnetUID: string|null}|null>} */
const readCode = async (code) => {
    const raw = await kvGet(regKey(code));
    if (!raw) return null;
    try {
        return JSON.parse(raw);
    } catch {
        return null;
    }
};

/**
 * Approve registration: bind pending code to org (already tenant-bound at create).
 * Optional publicKey from connector may be attached before exchange.
 * @param {ExpressRequestAuthorized} req
 * @param {string} code
 */
export const approveRegistration = async (req, code) => {
    try {
        const orgUuid = req.session.root;
        const reg = await readCode(code);
        if (!reg) throw apiError(404, 'REGISTRATION_NOT_FOUND', 'Registration code not found');
        if (reg.tenant !== orgUuid) {
            throw apiError(403, 'REGISTRATION_WRONG_TENANT', 'Registration belongs to another organization');
        }
        if (reg.status !== 'pending') {
            throw apiError(422, 'REGISTRATION_NOT_PENDING', 'Registration is not pending');
        }
        reg.status = 'approved';
        await kvSetEx(regKey(code), CODE_TTL_SECONDS, JSON.stringify(reg));
        return { success: true, result: { code, status: 'approved' } };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Exchange approved/pending code + publicKey for runner credential.
 * Creates ObjectBase runner + `Member` companion row (lookup_key).
 * @param {{ code: string, publicKey: string, name?: string, mode?: string, architecture?: string, operatingSystem?: string, capabilities?: object }} body
 */
export const exchangeRegistration = async (body) => {
    try {
        const code = body.code;
        if (!code || !body.publicKey) {
            throw apiError(422, 'INVALID_EXCHANGE', 'code and publicKey are required');
        }
        const reg = await readCode(code);
        if (!reg) throw apiError(404, 'REGISTRATION_NOT_FOUND', 'Registration code not found');
        if (!['pending', 'approved'].includes(reg.status)) {
            throw apiError(422, 'REGISTRATION_INVALID', 'Registration code is not usable');
        }

        // The admin's mode/group at code creation wins; the connector's own
        // body values only act as a fallback for pre-existing codes.
        const mode = RUNNER_MODES.includes(reg.mode) ? reg.mode
            : (RUNNER_MODES.includes(body.mode) ? body.mode : 'personal');
        const groupUID = reg.groupUID
            || (typeof body.groupUID === 'string' && body.groupUID ? body.groupUID : null);

        // Fake a minimal req for createRunner / getUID
        const fakeReq = {
            session: {
                root: reg.tenant,
                user: reg.createdBy,
            },
            body: {},
        };

        const created = await runnerService.createRunner(fakeReq, {
            name: body.name || 'Runner',
            mode,
            groupUID,
            publicKey: body.publicKey,
            architecture: body.architecture,
            operatingSystem: body.operatingSystem,
            capabilities: body.capabilities,
        });

        const runnerHex = UUID2hex(created.result.UID);
        const credentialId = `rc_${randomUUID().replace(/-/g, '')}`;

        // Runner explizit dem beim Code-Erstellen gewählten Tailnet-Netz zuordnen
        // (Links.Type='tailnetRunner', UID=Netz, UIDTarget=Runner).
        if (reg.tailnetUID) {
            await query(
                `INSERT IGNORE INTO Links (UID, Type, UIDTarget, UIDuser) VALUES (?, 'tailnetRunner', ?, ?)`,
                [UUID2hex(reg.tailnetUID), runnerHex, UUID2hex(reg.createdBy)],
            );
        }

        // Companion-Zeile: Credential für die Runner-Auth. createRunner hat die
        // `Member`-Zeile bereits angelegt (publicKey liegt in ObjectBase.Data) —
        // hier wird nur der lookup_key gesetzt (ON DUPLICATE für den Fall
        // einer Re-Registration am selben Runner-Objekt).
        await query(
            `INSERT INTO Member (UID, Display, SortName, FullTextIndex, Data, lookup_key)
             VALUES (?, '', '', '', JSON_OBJECT(), ?)
             ON DUPLICATE KEY UPDATE lookup_key = VALUES(lookup_key)`,
            [runnerHex, credentialId],
        );

        // Runner-Event (Vertrag 090-Runner-Events): Der runnerSync-Bot projiziert
        // den Runner in die ide-server `runner_registry` (Poll-Auth lokal).
        const runnerUid = created.result.UID; // Wire-Format "UUID-…"
        const tenantUid = reg.tenant; // Wire-Format "UUID-…"
        const runnerName = (body.name || created.result.Title || created.result.Display || 'Runner').trim().slice(0, 255);
        await publishEvent(`/add/runner/${runnerUid}`, {
            organization: tenantUid,
            data: {
                runnerUid,
                tenantUid,
                credentialId,
                publicKey: body.publicKey,
                name: runnerName,
                mode,
                status: 'registered',
            },
        });

        // Code ist Einmal-Code → verbraucht und weg.
        await kvDel(regKey(code));

        return {
            success: true,
            result: {
                runnerId: created.result.UID,
                tenantId: HEX2uuid(UUID2hex(reg.tenant)),
                credentialId,
                // Connector proves possession of private key on subsequent requests
            },
        };
    } catch (e) {
        errorLoggerUpdate(e);
        throw e;
    }
};

/**
 * Verify runner request: credentialId + signature over timestamp+method+path+bodyHash.
 * Credential liegt im Companion-Slot (`Member.lookup_key`), der
 * publicKey in `ObjectBase.Data`; revoked Runner werden über den
 * ObjectBase-Status abgewiesen.
 * @param {import('express').Request} req
 */
export const authenticateRunner = async (req) => {
    const credentialId = req.headers['x-runner-credential-id'];
    const timestamp = req.headers['x-runner-timestamp'];
    const signature = req.headers['x-runner-signature'];
    if (!credentialId || !timestamp || !signature) {
        throw apiError(401, 'RUNNER_AUTH_REQUIRED', 'Missing runner auth headers');
    }
    const ts = Number(timestamp);
    if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 5 * 60 * 1000) {
        throw apiError(401, 'RUNNER_AUTH_EXPIRED', 'Runner timestamp outside allowed window');
    }

    const rows = await query(
        `SELECT ObjectBase.UID AS runner_uid, ObjectBase.UIDBelongsTo AS tenant_uid,
                ObjectBase.Data
         FROM Member
         INNER JOIN ObjectBase ON (ObjectBase.UID = Member.UID)
         WHERE Member.lookup_key = ?`,
        [String(credentialId)],
    );
    const runnerData = rows.length
        ? (typeof rows[0].Data === 'string' ? JSON.parse(rows[0].Data) : (rows[0].Data || {}))
        : {};
    if (!rows.length || !runnerData.publicKey || runnerData.status === 'revoked') {
        throw apiError(401, 'RUNNER_AUTH_INVALID', 'Unknown or revoked runner credential');
    }

    const bodyRaw = typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {});
    const bodyHash = createHash('sha256').update(bodyRaw).digest('hex');
    const message = `${timestamp}.${req.method}.${req.originalUrl || req.url}.${bodyHash}`;

    // Ed25519: Node verify(null, data, key, signature)
    let ok = false;
    try {
        ok = cryptoVerify(
            null,
            Buffer.from(message, 'utf8'),
            String(runnerData.publicKey),
            Buffer.from(String(signature), 'base64'),
        );
    } catch {
        ok = false;
    }
    if (!ok) {
        throw apiError(401, 'RUNNER_AUTH_SIGNATURE', 'Invalid runner signature');
    }

    return {
        runnerUid: rows[0].runner_uid,
        tenantUid: rows[0].tenant_uid,
        credentialId: String(credentialId),
    };
};