Source: Router/orgaSettings/registryService.js

/**
 * App-Registry Service — Persistenz des Mandanten-Registry in `ObjectBase`/`Links`
 *
 * Ersetzt den Vault-basierten `service.js` schrittweise. Die **API-Form bleibt
 * identisch** (siehe `service.js`): Apps als `{ [appId]: AppEntry }`, Domains als
 * `{ [domain]: type }`. `admin` und `portal` dürfen keinen Unterschied merken.
 *
 * ## Dual-Read / Dual-Write — der Rückweg ist ein Env-Wert, kein Deploy
 *
 * `REGISTRY_READ_MODE` steuert, wo gelesen und geschrieben wird:
 *
 * | Wert | Lesen | Schreiben |
 * |---|---|---|
 * | `vault` | Vault | Vault |
 * | `dual` | DB, bei leerem Ergebnis Vault | **beide** |
 * | `db` (Default) | DB | DB |
 *
 * Der Modus wird **pro Aufruf** aus der Umgebung gelesen, nicht beim Import —
 * sonst wäre er in Tests und bei einem Neustart-Wechsel nicht umstellbar.
 *
 * ## Warum Upsert statt „alles löschen und neu einfügen"
 *
 * Der naheliegende Weg für „die Map kommt als Ganzes" ist `DELETE` + `INSERT`.
 * Er ist hier **falsch**: Domains und Assets hängen über `Links` an der UID der
 * App. Neu erzeugte UIDs bei jedem Speichern würden bei jedem Klick im
 * `admin`-Frontend sämtliche Domain-Zuordnungen der Organisation zerreißen — und
 * zwar still, weil die App danach existiert und nur der Host nicht mehr auflöst.
 *
 * Stattdessen: bestehende Objekte werden über ihren fachlichen Schlüssel
 * (`Data.appId`, `Data.domain`) wiedergefunden, **behalten ihre UID** und werden
 * aktualisiert; nur tatsächlich verschwundene Einträge werden gelöscht.
 *
 * ## Löschen in system-versioned Tabellen
 *
 * `DELETE` ist hier korrekt und **nicht** „ValidUntil schließen": MariaDB schließt
 * bei `DELETE` auf einer system-versioned Tabelle die Periode selbst und behält
 * die Historie (`FOR SYSTEM_TIME ALL` liefert die Zeile weiterhin). `ValidUntil`
 * ist `GENERATED ALWAYS AS ROW END` und von Hand gar nicht beschreibbar —
 * gemessen gegen MariaDB 11.7.
 */

import { query, transaction, HEX2uuid } from '@commtool/sql-query';
import { errorLoggerRead, errorLoggerUpdate } from '../../utils/requestLogger.js';
import { myMinioClient, publicMinioClient, PUBLIC_BUCKET, DATA_BUCKET, publicObjectUrl } from '../../utils/s3Client.js';
import { publishEvent } from '../../utils/events.js';
import sharp from 'sharp';
import {
    OBJ_TYPE_APP, OBJ_TYPE_APP_DOMAIN, OBJ_TYPE_APP_ASSET,
    LINK_TYPE_APP_DOMAIN, LINK_TYPE_APP_ASSET,
    ASSET_TYPE_ICON,
    DOMAIN_TYPE_INTERNAL, DOMAIN_TYPE_EXTERNAL,
    APP_BASE_DOMAIN_DEFAULT,
    appIdFromData, appTitles, appHostFor, normalizeUid,
    normalizeDomainEntry, normalizeDomainMap, normalizeDomainValue, toLegacyDomainValue,
    validateDomainEntry, findDomainConflict,
} from './registryTypes.js';
import * as vaultService from './service.js';

/** UID-Spalten als `UUID-…`, `Data` als Objekt (§ registryTypes: Cast-Regeln) */
const CAST = { cast: ['UUID', 'json'] };

/**
 * Basis-Domain für interne Hosts, zur **Laufzeit** gelesen.
 *
 * Nicht als Modulkonstante: in den Dev-Umgebungen steht `APP_BASE_DOMAIN` auf
 * `dev.commtool.org`, und ob die Variable beim Import schon gesetzt ist, hängt
 * davon ab, wer die Datei zuerst lädt. Ein einmal eingefrorener Wert würde dort
 * Hosts für die Produktions-Basis erzeugen — die Auflösung fände nichts.
 * @returns {string}
 */
const appBaseDomain = () => process.env.APP_BASE_DOMAIN || APP_BASE_DOMAIN_DEFAULT;

/**
 * Wie `normalizeDomainMap`, nur eine Ebene tiefer: `{ orgId: { domain: entry } }`.
 * Der Konflikt-Check vergleicht über **alle** Organisationen und muss dafür
 * dieselbe Form sehen wie eine einzelne Organisation.
 * @param {unknown} map
 * @returns {Record<string, Record<string, {type: string, status: string}>>}
 */
const normalizeDomainMapByOrg = (map) => {
    const result = {};
    for (const [orgId, domains] of Object.entries(map ?? {})) {
        result[orgId] = normalizeDomainMap(domains);
    }
    return result;
};

/** @returns {'vault'|'dual'|'db'} */
const readMode = () => {
    const mode = (process.env.REGISTRY_READ_MODE ?? 'db').toLowerCase();
    return mode === 'vault' || mode === 'dual' ? mode : 'db';
};

/** Liest aus der DB? (`dual` und `db` lesen beide zuerst die DB) */
const readsDb = () => readMode() !== 'vault';

/** Schreibt zusätzlich nach Vault? (`vault` schreibt nur dorthin) */
const writesVault = () => readMode() === 'vault' || readMode() === 'dual';

/**
 * Erzeugt eine UID **in der Datenbank** (`UIDV1()` = echte, sortierbare v1).
 * Bewusst nicht in JavaScript: `randomUUID()` wäre eine v4, und ihr Hex-String
 * passt in keines der SQL-UUID-Formate (§ registryTypes).
 * @returns {Promise<Buffer>} 16 Byte
 */
const newUid = async () => {
    const [row] = await query('SELECT UIDV1() AS UID', []);
    return row.UID;
};

// ── Apps ──────────────────────────────────────────────────────────────────────

/**
 * Alle Apps einer Organisation.
 * @param {string} orgId - UID in `UUID-`-Form
 * @returns {Promise<Record<string, object>>} `{ [appId]: AppEntry }`
 */
export async function getOrgApps(orgId) {
    if (!readsDb()) return vaultService.getApps(orgId);

    try {
        const rows = await query(
            `SELECT \`UID\`, \`Title\`, \`Data\`
               FROM \`ObjectBase\`
              WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)`,
            [OBJ_TYPE_APP, orgId],
            CAST,
        );

        const result = {};
        for (const row of rows) {
            const appId = appIdFromData(row.Data);
            if (!appId) continue; // Objekt ohne appId ist kein App-Eintrag
            result[appId] = { ...row.Data };
        }

        // Dual-Read: die DB ist erst dann die Wahrheit, wenn sie den Bestand
        // kennt. Ein leeres Ergebnis kann „noch nicht migriert" heißen — dann
        // liefert Vault die Antwort, statt die Organisation leer zu zeigen.
        if (rows.length === 0 && readMode() === 'dual') {
            return vaultService.getApps(orgId);
        }
        return result;
    } catch (e) {
        errorLoggerRead(e);
        if (readMode() === 'dual') return vaultService.getApps(orgId);
        throw e;
    }
}

/**
 * Eine einzelne App einer Organisation.
 * @param {string} orgId
 * @param {string} appId
 * @returns {Promise<object|null>}
 */
export async function getOrgApp(orgId, appId) {
    if (!readsDb()) {
        const apps = await vaultService.getApps(orgId);
        return apps[appId] ?? null;
    }

    const rows = await query(
        `SELECT \`Data\`
           FROM \`ObjectBase\`
          WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)
            AND JSON_UNQUOTE(JSON_EXTRACT(\`Data\`, '$.appId')) = ?
          LIMIT 1`,
        [OBJ_TYPE_APP, orgId, appId],
        CAST,
    );
    return rows[0]?.Data ?? null;
}

/**
 * Ersetzt den App-Bestand einer Organisation (die Map kommt als Ganzes).
 *
 * Bestehende Apps behalten ihre UID — siehe Dateikopf.
 *
 * Der Host einer App steht als Feld `domain` **im App-Objekt selbst**; es gibt
 * bewusst kein Domain-Objekt und keinen Link dafür. Eine frühere Fassung legte
 * für jeden App-Host automatisch ein `appDomain`-Objekt an und verlinkte es —
 * dadurch enthielt die Domain-Liste einer Organisation anschließend jeden
 * App-Host, und App-Hosts (Routing) und Mandanten-Domains (CORS, Cookie-Umfang,
 * Basis-Domain) waren nicht mehr unterscheidbar. `resolveDomain()` liest das
 * Feld deshalb direkt; bei 4–14 Apps je Organisation braucht es dafür keinen
 * Index.
 *
 * @param {string} orgId
 * @param {Record<string, object>} apps
 */
export async function saveOrgApps(orgId, apps) {
    if (writesVault()) await vaultService.saveApps(orgId, apps);
    if (!readsDb()) return;

    const existing = await query(
        `SELECT \`UID\`, \`Data\` FROM \`ObjectBase\`
          WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)`,
        [OBJ_TYPE_APP, orgId],
        CAST,
    );
    const uidByAppId = new Map();
    for (const row of existing) {
        const appId = appIdFromData(row.Data);
        if (appId) uidByAppId.set(appId, row.UID);
    }

    for (const [appId, config] of Object.entries(apps ?? {})) {
        const data = { ...(config ?? {}), appId };
        const { title, display, sortName } = appTitles(appId, data.title);

        const uid = uidByAppId.get(appId);
        if (uid) {
            await query(
                `UPDATE \`ObjectBase\`
                    SET \`Title\` = ?, \`Display\` = ?, \`SortName\` = ?, \`Data\` = ?
                  WHERE \`UID\` = U_UUID2BIN(?)`,
                [title, display, sortName, JSON.stringify(data), uid],
            );
            uidByAppId.delete(appId); // verbraucht
        } else {
            await query(
                `INSERT INTO \`ObjectBase\`
                     (\`UID\`, \`Type\`, \`UIDBelongsTo\`, \`Title\`, \`Display\`, \`SortName\`, \`dindex\`, \`Data\`)
                 VALUES (?, ?, U_UUID2BIN(?), ?, ?, ?, 0, ?)`,
                [await newUid(), OBJ_TYPE_APP, orgId, title, display, sortName, JSON.stringify(data)],
            );
        }
    }

    // Was übrig ist, wurde im Frontend entfernt — inklusive seiner Links.
    for (const [goneAppId, uid] of uidByAppId) {
        await deleteAppByUid(uid, goneAppId);
    }

    await invalidateRegistryCache(orgId);
}

/**
 * Löscht eine App samt ihrer Links.
 *
 * `appDomain` wird beim Löschen mitgenommen, obwohl der Bestand seit der
 * Umstellung keine solchen Links mehr anlegt: ein früherer Import hat sie
 * erzeugt, und ein Löschpfad, der sie stehen ließe, hinterließe Karteileichen.
 *
 * @param {string} uid
 * @param {string} [appId] - nur für den Log
 */
async function deleteAppByUid(uid, appId) {
    await query(
        `DELETE FROM \`Links\` WHERE \`UID\` = U_UUID2BIN(?) AND \`Type\` IN (?, ?)`,
        [uid, LINK_TYPE_APP_DOMAIN, LINK_TYPE_APP_ASSET],
    );
    await query(`DELETE FROM \`ObjectBase\` WHERE \`UID\` = U_UUID2BIN(?) AND \`Type\` = ?`, [uid, OBJ_TYPE_APP]);
    if (appId) void appId;
}

// ── Domains ───────────────────────────────────────────────────────────────────

/**
 * Alle Domains einer Organisation.
 *
 * Rückgabe ist `{ [domain]: { type, status } }` — **eine** Form für Aufrufer
 * und Oberfläche. Der Altbestand (in Vault wie in der Datenbank) trägt statt
 * dessen eine Zeichenkette wie `"verified"`; übersetzt wird hier einmal, nicht
 * bei jedem Konsumenten.
 *
 * @param {string} orgId
 * @returns {Promise<Record<string, {type: string, status: string}>>}
 */
export async function getOrgDomains(orgId) {
    if (!readsDb()) return normalizeDomainMap(await vaultService.getDomains(orgId));

    try {
        const rows = await query(
            `SELECT \`Data\` FROM \`ObjectBase\`
              WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)`,
            [OBJ_TYPE_APP_DOMAIN, orgId],
            CAST,
        );

        const result = {};
        for (const row of rows) {
            const data = row.Data ?? {};
            const domain = normalizeDomainValue(data.domain);
            const entry = normalizeDomainEntry(data);
            if (domain && entry) result[domain] = entry;
        }

        if (rows.length === 0 && readMode() === 'dual') return normalizeDomainMap(await vaultService.getDomains(orgId));
        return result;
    } catch (e) {
        errorLoggerRead(e);
        if (readMode() === 'dual') return normalizeDomainMap(await vaultService.getDomains(orgId));
        throw e;
    }
}

/**
 * Ersetzt den Domain-Bestand einer Organisation.
 *
 * Angenommen wird `{ [domain]: { type, status } }` **und** die Altform
 * (`"internal"`/`"external"`/`"verified"`) — eine Fassung, die nur die neue Form
 * verstünde, würde den vorhandenen Bestand beim ersten Speichern leeren.
 *
 * Bestehende Domains behalten ihre UID: an ihnen hängen die Basis-Domain-
 * Erkennung (`shared-auth` prüft Kunden-Domains gegen die verifizierten) und der
 * Cookie-Umfang. Löschen und Neuanlegen würde diesen Anker bei jedem Speichern
 * austauschen.
 *
 * @param {string} orgId
 * @param {Record<string, unknown>} domains
 */
export async function saveOrgDomains(orgId, domains) {
    // Vault bekommt weiterhin die **Zeichenkette**: `shared-auth` und die
    // übrigen Konsumenten lesen dort `state === 'internal'` bzw. `'verified'`.
    // Ein Objekt würde sie brechen, solange `REGISTRY_READ_MODE` nicht `db` ist.
    if (writesVault()) {
        const legacy = {};
        for (const [domain, value] of Object.entries(domains ?? {})) {
            const entry = normalizeDomainEntry(value);
            if (entry) legacy[domain] = toLegacyDomainValue(entry);
        }
        await vaultService.saveDomains(orgId, legacy);
    }
    if (!readsDb()) return;

    const existing = await query(
        `SELECT \`UID\`, \`Data\` FROM \`ObjectBase\`
          WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)`,
        [OBJ_TYPE_APP_DOMAIN, orgId],
        CAST,
    );
    const uidByDomain = new Map();
    for (const row of existing) {
        const domain = normalizeDomainValue(row.Data?.domain);
        if (domain) uidByDomain.set(domain, row.UID);
    }

    for (const [rawDomain, value] of Object.entries(domains ?? {})) {
        const domain = normalizeDomainValue(rawDomain);
        const entry = normalizeDomainEntry(value);
        // Nicht deutbare Einträge werden nicht geschrieben: ein stiller Default
        // würde eine Aussage anlegen, die niemand getroffen hat. Die Prüfung
        // liegt im Controller (`validateDomains`), hier wird nur übersetzt.
        if (!domain || !entry) continue;

        const data = { domain, ...entry };
        const uid = uidByDomain.get(domain);
        if (uid) {
            await query(
                `UPDATE \`ObjectBase\` SET \`Title\` = ?, \`Display\` = ?, \`SortName\` = ?, \`Data\` = ?
                  WHERE \`UID\` = U_UUID2BIN(?)`,
                [domain, domain, domain, JSON.stringify(data), uid],
            );
            uidByDomain.delete(domain);
        } else {
            await query(
                `INSERT INTO \`ObjectBase\`
                     (\`UID\`, \`Type\`, \`UIDBelongsTo\`, \`Title\`, \`Display\`, \`SortName\`, \`dindex\`, \`Data\`)
                 VALUES (?, ?, U_UUID2BIN(?), ?, ?, ?, 0, ?)`,
                [await newUid(), OBJ_TYPE_APP_DOMAIN, orgId, domain, domain, domain, JSON.stringify(data)],
            );
        }
    }

    for (const [, uid] of uidByDomain) {
        // Karteileichen aus dem früheren Import: dort zeigte ein Link von der App
        // auf die Domain. Der aktuelle Bestand legt keine solchen Links mehr an.
        await query(`DELETE FROM \`Links\` WHERE \`UIDTarget\` = U_UUID2BIN(?) AND \`Type\` = ?`, [uid, LINK_TYPE_APP_DOMAIN]);
        await query(`DELETE FROM \`ObjectBase\` WHERE \`UID\` = U_UUID2BIN(?) AND \`Type\` = ?`, [uid, OBJ_TYPE_APP_DOMAIN]);
    }

    await invalidateRegistryCache(orgId);
}

/**
 * Alle Domains aller Organisationen (für den Konflikt-Check).
 * @returns {Promise<Record<string, Record<string, {type: string, status: string}>>>}
 */
export async function getAllOrgDomains() {
    if (!readsDb()) return normalizeDomainMapByOrg(await vaultService.getAllOrgDomains());
    try {
        const rows = await query(
            `SELECT \`UIDBelongsTo\` AS orgId, \`Data\` FROM \`ObjectBase\` WHERE \`Type\` = ?`,
            [OBJ_TYPE_APP_DOMAIN],
            CAST,
        );
        /** @type {Record<string, Record<string, {type: string, status: string}>>} */
        const result = {};
        for (const row of rows) {
            const domain = normalizeDomainValue(row.Data?.domain);
            const entry = normalizeDomainEntry(row.Data);
            if (!row.orgId || !domain || !entry) continue;
            (result[row.orgId] ??= {})[domain] = entry;
        }
        return result;
    } catch (e) {
        errorLoggerRead(e);
        if (readMode() === 'dual') return normalizeDomainMapByOrg(await vaultService.getAllOrgDomains());
        throw e;
    }
}

/**
 * Prüft einen vorgeschlagenen Domain-Bestand.
 *
 * Gehört hierher und nicht in den Vault-Adapter: geprüft wird gegen **den
 * Bestand, in den geschrieben wird**. Als die Prüfung noch aus Vault las, hat
 * sie den eigenen, inzwischen in der Datenbank liegenden Bestand nicht gesehen —
 * eine Domain ließ sich damit zweimal vergeben.
 *
 * @param {Record<string, unknown>} newDomains
 * @param {string} currentOrgId
 * @returns {Promise<{domain: string, error: string}[]>} leer = in Ordnung
 */
export async function validateDomains(newDomains, currentOrgId) {
    const errors = [];

    // Erst die Einzelprüfungen: sie kommen ohne Datenbankzugriff aus. Sind sie
    // fehlerhaft, wäre der Konflikt-Check nur Rauschen über einem kaputten Wert.
    for (const [rawDomain, value] of Object.entries(newDomains ?? {})) {
        const domain = normalizeDomainValue(rawDomain);
        if (!domain) {
            errors.push({ domain: String(rawDomain), error: 'Not a valid domain name (e.g. myclub.com).' });
            continue;
        }
        const err = validateDomainEntry(domain, value);
        if (err) errors.push({ domain, error: err });
    }
    if (errors.length > 0) return errors;

    const allOrgDomains = await getAllOrgDomains();
    for (const [rawDomain, value] of Object.entries(newDomains ?? {})) {
        const domain = normalizeDomainValue(rawDomain);
        if (findDomainConflict(domain, value, allOrgDomains, currentOrgId))
            errors.push({ domain, error: 'Domain is already claimed by another organisation.' });
    }
    return errors;
}

// ── Runtime-Auflösung (Broker / shared-auth) ─────────────────────────────────

/**
 * Host → Organisation + App.
 *
 * Gesucht wird über das Feld `domain` der App, nicht über ein Domain-Objekt:
 * eine frühere Fassung legte für jeden App-Host ein eigenes `appDomain`-Objekt
 * an und verlinkte es. Das machte den Domain-Bestand einer Organisation
 * ununterscheidbar von ihrer Routing-Tabelle — dieselbe Liste trug plötzlich
 * Mandanten-Domains (Cookie-Umfang, CORS) und App-Hosts. Der Host steht dort,
 * wo der Admin ihn einträgt: im App-Eintrag.
 *
 * Der Vergleich läuft über {@link appHostFor}, also über **dieselbe** Regel,
 * nach der der Host aufgebaut wird. Ein reiner String-Vergleich mit dem
 * `domain`-Feld wäre falsch: bei einem internen Präfix (`sjm`) steht dort nicht
 * der Host, sondern nur `sjm`.
 *
 * Kosten: ein Durchlauf über die App-Objekte (gemessen 29 in dieser Datenbank).
 * Ein Index wäre erst bei einem Vielfachen davon nötig.
 *
 * @param {string} host
 * @returns {Promise<{orgId: string, appId: string|null, appUid: string}|null>}
 */
export async function resolveDomain(host) {
    const wanted = normalizeDomainValue(host);
    if (!wanted) return null;

    try {
        const rows = await query(
            `SELECT \`UID\`, \`UIDBelongsTo\` AS orgId, \`Data\` FROM \`ObjectBase\` WHERE \`Type\` = ?`,
            [OBJ_TYPE_APP],
            CAST,
        );
        for (const row of rows) {
            const appId = appIdFromData(row.Data);
            if (appId && appHostFor(appId, row.Data?.domain, appBaseDomain()) === wanted) {
                return { orgId: row.orgId, appId, appUid: row.UID };
            }
        }
        return null;
    } catch (e) {
        errorLoggerRead(e);
        return null;
    }
}

/**
 * Die Routing-Tabelle: für jede App der Host, unter dem sie läuft.
 *
 * Form wie bei `shared-auth` (`loadOrganizationDomains`): `domain` ist der
 * **fertige Host**, nicht der Rohtwert aus dem App-Eintrag. Genau daran hängt
 * die Erkennung „welche Organisation bedient dieser Host" — würde hier `sjm`
 * statt `sjm.admin.app.commtool.org` stehen, fände die Erkennung nichts.
 *
 * @returns {Promise<Array<{domain: string, org: string, orgId: string, app: string, type: string}>>}
 */
export async function getAllDomainMappings() {
    try {
        const rows = await query(
            `SELECT \`UIDBelongsTo\` AS orgId, \`Data\` FROM \`ObjectBase\` WHERE \`Type\` = ?`,
            [OBJ_TYPE_APP],
            CAST,
        );
        const mappings = [];
        for (const row of rows) {
            const appId = appIdFromData(row.Data);
            const rawDomain = normalizeDomainValue(row.Data?.domain);
            const host = appHostFor(appId, rawDomain, appBaseDomain());
            if (!appId || !host) continue;
            mappings.push({
                domain: host,
                org: row.orgId,
                orgId: row.orgId,
                app: appId,
                // Derselbe Punkt, an dem `appHostFor` die beiden Formen trennt —
                // hier als Aussage statt als Verzweigung.
                type: rawDomain.includes('.') ? DOMAIN_TYPE_EXTERNAL : DOMAIN_TYPE_INTERNAL,
            });
        }
        return mappings;
    } catch (e) {
        errorLoggerRead(e);
        return [];
    }
}

/**
 * CORS-Origins: **alles außer** den internen Domains.
 *
 * Die Negation ist Absicht. Ein `=== 'external'` stand hier einmal, und weil im
 * Bestand `verified` lag (`{"kpe.de":"verified"}`), traf es **nichts** — die
 * Liste war leer, obwohl vier Kunden-Domains existierten. Mit getrenntem
 * `type`/`status` wäre `=== 'external'` zwar wieder richtig, aber die Negation
 * bleibt die robustere Aussage: eine neue Domänenart soll nicht stillschweigend
 * aus CORS herausfallen.
 *
 * Die App-Hosts stehen bewusst **nicht** darin: sie liegen unterhalb einer
 * dieser Domains (`admin.app.kpe.de` ⊂ `kpe.de`), und `createCorsOriginChecker`
 * prüft mit Punktgrenze auf die Basis-Domain. Sie einzeln aufzuzählen wäre
 * doppelt gemoppelt — und bei jeder neuen App zu ändern.
 *
 * @returns {Promise<string[]>}
 */
export async function getAllCorsOrigins() {
    try {
        const rows = await query(
            `SELECT \`Title\` AS domain, \`Data\` FROM \`ObjectBase\` WHERE \`Type\` = ?`,
            [OBJ_TYPE_APP_DOMAIN],
            CAST,
        );
        const origins = new Set();
        for (const row of rows) {
            const domain = normalizeDomainValue(row.domain);
            const entry = normalizeDomainEntry(row.Data);
            if (!domain || !entry) continue;
            if (entry.type === DOMAIN_TYPE_INTERNAL) continue;
            origins.add(`https://${domain}`);
        }
        return [...origins];
    } catch (e) {
        errorLoggerRead(e);
        return [];
    }
}

/**
 * Der App-Katalog: die Vorlage, aus der eine neue Organisation ihre Apps
 * bekommt.
 *
 * Bewusst ein Durchgriff auf Vault und **keine** DB-Variante. Der Katalog hängt
 * an keinem Mandanten (`orgas/data/default/apps`), und der Import lässt den
 * `default`-Ordner ausdrücklich aus, damit kein Objekt ohne `UIDBelongsTo`
 * entsteht. Ein `readsDb()`-Zweig würde hier nur so tun, als gäbe es Zeilen.
 *
 * @returns {Promise<Record<string, object>>}
 */
export async function getAppCatalog() {
    return await vaultService.getAppCatalog();
}

/**
 * PWA-Manifest einer App (Branding aus dem Registry statt Vault-PWA-Cache).
 * @param {string} orgId
 * @param {string} appId
 * @returns {Promise<object|null>}
 */
export async function getAppManifest(orgId, appId) {
    const app = await getOrgApp(orgId, appId);
    if (!app) return null;

    const name = app.title || appId;
    const manifest = {
        name,
        short_name: name,
        start_url: '/',
        display: 'standalone',
        icons: /** @type {object[]} */ ([]),
    };

    if (app.icon) {
        manifest.icons.push({ src: app.icon, sizes: '512x512', type: 'image/png' });
    }

    const appRow = await query(
        `SELECT \`UID\` FROM \`ObjectBase\`
          WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)
            AND JSON_UNQUOTE(JSON_EXTRACT(\`Data\`, '$.appId')) = ? LIMIT 1`,
        [OBJ_TYPE_APP, orgId, appId],
        CAST,
    );
    const appUid = appRow[0]?.UID;
    if (!appUid) return manifest;

    const assets = await query(
        `SELECT a.\`Data\` FROM \`ObjectBase\` a
           JOIN \`Links\` l ON l.\`UIDTarget\` = a.\`UID\`
          WHERE l.\`UID\` = U_UUID2BIN(?) AND l.\`Type\` = ? AND a.\`Type\` = ?`,
        [appUid, LINK_TYPE_APP_ASSET, OBJ_TYPE_APP_ASSET],
        CAST,
    );
    for (const asset of assets) {
        if (asset.Data?.assetType === ASSET_TYPE_ICON && asset.Data.s3Key) {
            manifest.icons.push({
                src: publicObjectUrl(asset.Data.s3Key),
                sizes: asset.Data.sizes ?? '512x512',
                type: asset.Data.mimeType ?? 'image/png',
            });
        }
    }
    return manifest;
}

// ── Assets (Icon) ─────────────────────────────────────────────────────────────

/** MIME type → extension für akzeptierte Icon-Formate */
const ICON_EXT = {
    'image/png': 'png',
    'image/jpeg': 'jpg',
    'image/svg+xml': 'svg',
    'image/gif': 'gif',
    'image/webp': 'webp',
};

/** PWA-Icon-Varianten im data bucket — Pfade, die der Static-Server erwartet */
const PWA_ICON_SIZES = [
    { name: 'icon-192.png', size: 192 },
    { name: 'icon-512.png', size: 512 },
    { name: 'icon-192-maskable.png', size: 192 },
    { name: 'icon-512-maskable.png', size: 512 },
    { name: 'favicon.png', size: 32 },
];

/** Put in MinIO — public bucket über den publicClient, sonst der interne */
function minioput(bucket, key, buffer, mimeType) {
    const client = bucket === PUBLIC_BUCKET ? (publicMinioClient ?? myMinioClient) : myMinioClient;
    return new Promise((resolve, reject) => {
        client.putObject(bucket, key, buffer, buffer.length, { 'Content-Type': mimeType },
            (err) => (err ? reject(err) : resolve()));
    });
}

/**
 * Lädt ein App-Icon hoch (Original + PWA-Varianten) und legt ein `appAsset` an.
 *
 * Die S3-Pfade sind **dieselben** wie im Vault-basierten `service.js` — der
 * Static-Server findet sie also unverändert, ohne dass dort etwas umgestellt
 * werden muss.
 *
 * @param {string} orgId
 * @param {string} appId
 * @param {import('stream').Readable} fileStream
 * @param {string} mimeType
 * @returns {Promise<string>} öffentliche Icon-URL
 */
export async function uploadOrgAppIcon(orgId, appId, fileStream, mimeType) {
    const ext = ICON_EXT[mimeType];
    if (!ext) throw new Error(`Unsupported image type: ${mimeType}`);

    const chunks = [];
    for await (const chunk of fileStream) chunks.push(chunk);
    const inputBuffer = Buffer.concat(chunks);

    const originalKey = `${orgId}/icons/${appId}-${Date.now()}.${ext}`;
    await minioput(PUBLIC_BUCKET, originalKey, inputBuffer, mimeType);
    const iconUrl = publicObjectUrl(originalKey);

    const baseImage = sharp(inputBuffer);
    await Promise.all(
        PWA_ICON_SIZES.map(async ({ name, size }) => {
            const resized = await baseImage.clone()
                .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
                .png().toBuffer();
            await minioput(DATA_BUCKET, `${orgId}/manifests/${appId}/${name}`, resized, 'image/png');
        }),
    );

    // Icon-URL am App-Eintrag hinterlegen (Data.icon) — `getApps` liefert sie so
    // an das Frontend, wie es die Vault-Variante tat.
    const appRow = await query(
        `SELECT \`UID\`, \`Data\` FROM \`ObjectBase\`
          WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)
            AND JSON_UNQUOTE(JSON_EXTRACT(\`Data\`, '$.appId')) = ? LIMIT 1`,
        [OBJ_TYPE_APP, orgId, appId],
        CAST,
    );
    const app = appRow[0];
    if (!app) throw new Error(`App "${appId}" not found for this organisation.`);

    const oldIconUrl = app.Data?.icon;
    if (oldIconUrl && oldIconUrl !== iconUrl) {
        try {
            const base = `${process.env.S3publicBaseUrl ?? `https://${process.env.publicS3endPoint ?? process.env.S3endPoint}:${process.env.publicS3port ?? process.env.S3port}`}/${PUBLIC_BUCKET}/`;
            const oldKey = oldIconUrl.replace(base, '');
            const publicClient = publicMinioClient ?? myMinioClient;
            if (oldKey && oldKey !== oldIconUrl) await publicClient.removeObject(PUBLIC_BUCKET, oldKey);
        } catch (e) {
            errorLoggerUpdate(e); // best-effort
        }
    }

    await query(
        `UPDATE \`ObjectBase\` SET \`Data\` = ? WHERE \`UID\` = U_UUID2BIN(?)`,
        [JSON.stringify({ ...app.Data, icon: iconUrl }), app.UID],
    );

    const assetUid = await newUid();
    const assetUidString = normalizeUid(assetUid, HEX2uuid);
    if (!assetUidString) throw new Error('UID des Icon-Assets ließ sich nicht in UUID-Form bringen');

    await query(
        `INSERT INTO \`ObjectBase\`
             (\`UID\`, \`Type\`, \`UIDBelongsTo\`, \`Title\`, \`Display\`, \`SortName\`, \`dindex\`, \`Data\`)
         VALUES (?, ?, U_UUID2BIN(?), ?, ?, ?, 0, ?)`,
        [assetUid, OBJ_TYPE_APP_ASSET, app.UID, ASSET_TYPE_ICON, ASSET_TYPE_ICON, ASSET_TYPE_ICON,
            JSON.stringify({ assetType: ASSET_TYPE_ICON, s3Key: originalKey, mimeType })],
    );
    await query(
        `INSERT INTO \`Links\` (\`UID\`, \`Type\`, \`UIDTarget\`) VALUES (U_UUID2BIN(?), ?, U_UUID2BIN(?))`,
        [app.UID, LINK_TYPE_APP_ASSET, assetUidString],
    );

    await invalidateRegistryCache(orgId);
    return iconUrl;
}

// ── Releases (Auslieferung) ───────────────────────────────────────────────────

/**
 * Ein Release ist ein **Zeiger**, kein Deployment.
 *
 * `AppRelease` hält pro `(AppKey, Version)` das S3-Präfix und optional die
 * Backend-URLs; `Current` markiert die ausgelieferte Zeile. `OrgReleaseOverride`
 * ist die Ausnahme für eine einzelne Organisation (Canary) und im Normalfall
 * leer — kein Eintrag heißt „folgt dem Zeiger".
 *
 * `AppKey` ist die `appId` aus dem Registry (`Data.appId` des `app`-Objekts),
 * keine UID: sie adressiert ein Release über Organisationen hinweg.
 *
 * @see src/config/migrations/20260928-app-release.js — Schema und Begründung
 */

/** @typedef {'override'|'current'} ReleaseSource */

const RELEASE_COLUMNS = '`AppKey`, `Version`, `Prefix`, `Backends`, `Current`';

/**
 * Zeile → Release-Objekt. `Backends` kommt über den JSON-Cast bereits als
 * Objekt; `null` bleibt `null` und bedeutet „keine Backend-URLs im Release".
 * @param {object} row
 */
const mapRelease = (row) => ({
    appKey: row.AppKey,
    version: row.Version,
    prefix: row.Prefix,
    backends: row.Backends ?? null,
    current: !!row.Current,
});

/**
 * Alle Releases einer App, Zeiger zuerst.
 * @param {string} appKey
 * @returns {Promise<object[]>}
 */
export async function listAppReleases(appKey) {
    const rows = await query(
        `SELECT ${RELEASE_COLUMNS} FROM \`AppRelease\` WHERE \`AppKey\` = ? ORDER BY \`Current\` DESC, \`Version\` DESC`,
        [appKey],
        CAST,
    );
    return rows.map(mapRelease);
}

/**
 * Löst die Auslieferung auf: Canary zuerst, sonst der Zeiger, sonst nichts.
 *
 * Die Reihenfolge ist der Kern des Modells — sie macht Canary **additiv**. Wer
 * keinen Override hat, folgt `Current`; es gibt keinen Zustand, in dem eine
 * Organisation versehentlich kein Release mehr hat, nur weil Canary eingeführt
 * wurde.
 *
 * @param {string} appKey
 * @param {string} [orgId] - UID in `UUID-`-Form (41 Zeichen)
 * @returns {Promise<(object & {source: ReleaseSource})|null>} `null` = kein Release
 */
export async function resolveRelease(appKey, orgId = null) {
    if (!appKey) return null;

    try {
        // 1. Canary dieser Organisation. Der Join auf `AppRelease` ist bewusst
        //    ein INNER JOIN: zeigt der Override auf eine gelöschte Version, ist
        //    er ungültig — und dann ist der Zeiger die richtige Antwort, nicht
        //    ein halb gefülltes Release.
        if (orgId) {
            const [row] = await query(
                `SELECT r.\`AppKey\`, r.\`Version\`, r.\`Prefix\`, r.\`Backends\`, r.\`Current\`
                   FROM \`OrgReleaseOverride\` o
                   JOIN \`AppRelease\` r ON r.\`AppKey\` = o.\`AppKey\` AND r.\`Version\` = o.\`Version\`
                  WHERE o.\`AppKey\` = ? AND o.\`OrgUID\` = U_UUID2BIN(?)`,
                [appKey, orgId],
                CAST,
            );
            if (row) return { ...mapRelease(row), source: 'override' };
        }

        // 2. Der Zeiger.
        const [current] = await query(
            `SELECT ${RELEASE_COLUMNS} FROM \`AppRelease\` WHERE \`AppKey\` = ? AND \`Current\` = 1 LIMIT 1`,
            [appKey],
            CAST,
        );
        if (current) return { ...mapRelease(current), source: 'current' };

        // 3. Nichts — der Aufrufer antwortet mit 503, nicht mit einem leeren Artefakt.
        return null;
    } catch (e) {
        errorLoggerRead(e);
        return null;
    }
}

/**
 * Legt ein Release an oder schreibt es fort (Schlüssel `AppKey`+`Version`).
 *
 * `current: true` macht den Zeiger in **einer** Transaktion um: erst alle
 * anderen Zeilen der App auf 0, dann diese auf 1. Sonst könnte bei einem Fehler
 * dazwischen kein oder — schlimmer — _jeder_ Zeiger gesetzt sein, und die
 * Auflösung oben (`Current = 1 LIMIT 1`) würde zufällig wählen.
 *
 * @param {{appKey: string, version: string, prefix: string, backends?: object|null, current?: boolean}} release
 */
export async function saveAppRelease({ appKey, version, prefix, backends = null, current = false }) {
    if (!appKey || !version || !prefix) throw new Error('appKey, version and prefix are required');

    await transaction(async (connection) => {
        if (current) {
            await connection.query('UPDATE `AppRelease` SET `Current` = 0 WHERE `AppKey` = ?', [appKey]);
        }
        await connection.query(
            `INSERT INTO \`AppRelease\` (\`AppKey\`, \`Version\`, \`Prefix\`, \`Backends\`, \`Current\`)
                  VALUES (?, ?, ?, ?, ?)
             ON DUPLICATE KEY UPDATE
                  \`Prefix\`   = VALUES(\`Prefix\`),
                  \`Backends\` = VALUES(\`Backends\`),
                  \`Current\`  = VALUES(\`Current\`)`,
            [appKey, version, prefix, backends === null ? null : JSON.stringify(backends), current ? 1 : 0],
        );
    });
}

/**
 * Setzt den Zeiger auf eine vorhandene Version.
 *
 * Ein Deploy ist damit ein Config-Update und ein Rollback das Zurücksetzen
 * desselben Zeigers — es startet nichts neu.
 * @param {string} appKey
 * @param {string} version
 */
export async function setCurrentRelease(appKey, version) {
    await transaction(async (connection) => {
        const [row] = await connection.query(
            'SELECT `Version` FROM `AppRelease` WHERE `AppKey` = ? AND `Version` = ?',
            [appKey, version],
        );
        // Nicht raten: ein Zeiger auf eine nicht existierende Version würde die
        // Auflösung später ins Leere laufen lassen.
        if (!row) throw new Error(`Unknown release ${appKey}@${version}`);

        await connection.query('UPDATE `AppRelease` SET `Current` = 0 WHERE `AppKey` = ?', [appKey]);
        await connection.query(
            'UPDATE `AppRelease` SET `Current` = 1 WHERE `AppKey` = ? AND `Version` = ?',
            [appKey, version],
        );
    });
}

/**
 * Stellt eine Organisation auf ein bestimmtes Release (Canary).
 * @param {string} appKey
 * @param {string} orgId - UID in `UUID-`-Form
 * @param {string} version
 */
export async function setOrgReleaseOverride(appKey, orgId, version) {
    await query(
        `INSERT INTO \`OrgReleaseOverride\` (\`AppKey\`, \`OrgUID\`, \`Version\`)
              VALUES (?, U_UUID2BIN(?), ?)
         ON DUPLICATE KEY UPDATE \`Version\` = VALUES(\`Version\`), \`AddedAt\` = CURRENT_TIMESTAMP`,
        [appKey, orgId, version],
    );
}

/**
 * Nimmt eine Organisation aus dem Canary. Ab hier folgt sie wieder dem Zeiger —
 * das ist der Rollback, und er braucht keinen Rückbau eines Deployments.
 * @param {string} appKey
 * @param {string} orgId
 * @returns {Promise<boolean>} true, wenn wirklich ein Eintrag entfernt wurde
 */
export async function clearOrgReleaseOverride(appKey, orgId) {
    const result = await query(
        'DELETE FROM `OrgReleaseOverride` WHERE `AppKey` = ? AND `OrgUID` = U_UUID2BIN(?)',
        [appKey, orgId],
    );
    return (result?.affectedRows ?? 0) > 0;
}

/**
 * Der `window.env`-Aufsatz eines Releases — die Schlüssel aus
 * `AppRelease.Backends`, **unverändert**.
 *
 * Bewusst ohne Übersetzungstabelle: die Backends liegen bereits unter dem Namen,
 * unter dem das Frontend sie liest (`api`, `apiPortal`, …). Eine Umbenennung an
 * dieser Stelle würde nur eine zweite Stelle schaffen, an der ein neuer
 * Backend-Name nachgetragen werden müsste — und eine, die niemand beim Anlegen
 * eines Releases sieht.
 *
 * `env: null` ist eine **gültige** Antwort und heißt „kein Release-Aufsatz":
 * der Consumer behält dann seine Deployment-Werte. `null` als ganzer
 * Rückgabewert dagegen heißt „die App hat überhaupt kein Release" — der
 * Unterschied zwischen „kein Canary" und „falsche App", und der Grund für den
 * 404 in der Route.
 *
 * @param {string} appKey
 * @param {string} orgId
 * @returns {Promise<{env: object|null, version: string, source: ReleaseSource}|null>}
 */
export async function resolveReleaseEnv(appKey, orgId) {
    const release = await resolveRelease(appKey, orgId);
    if (!release) return null;
    return { env: release.backends, version: release.version, source: release.source };
}

// ── Cache-Invalidierung ───────────────────────────────────────────────────────

/**
 * Sagt den Consumern, dass sich das Registry dieser Organisation geändert hat.
 * Best-effort: ein fehlendes Redis darf das Speichern nicht scheitern lassen.
 * @param {string} orgId
 */
async function invalidateRegistryCache(orgId) {
    try {
        await publishEvent('registry:changed', { organization: orgId, data: { orgId } });
    } catch (e) {
        errorLoggerUpdate(e);
    }
}