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