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