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, UUID2hex } from '@commtool/sql-query';
import { errorLoggerRead, errorLoggerUpdate } from '../../utils/requestLogger.js';
import { myMinioClient, publicMinioClient, PUBLIC_BUCKET, DATA_BUCKET, publicObjectUrl, appAssetKey, appAssetUrl } 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,
    DOMAIN_STATUS_VERIFIED,
    APP_BASE_DOMAIN_DEFAULT,
    appIdFromData, appTitles, appHostFor, normalizeUid,
    normalizeDomainEntry, normalizeDomainMap, normalizeDomainValue, toLegacyDomainValue,
    validateDomainEntry, findDomainConflict,
    normalizeBranch, isValidBranch,
    validateEnvironmentObject, normalizeAppEnvironment, isValidAppKey,
    APP_ENV_META_FIELDS,
    splitAppId, productOfAppId,
} from './registryTypes.js';
import * as vaultService from './service.js';

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

/**
 * UID → `binary(16)`-Buffer, für die **direkte** Bindung an eine UID-/
 * `UIDBelongsTo`-Spalte.
 *
 * In `WHERE`/`VALUES` gehört der Buffer, **nicht** `U_UUID2BIN(?)`. Die
 * SQL-Funktion ist nicht `DETERMINISTIC`; der Optimierer kann
 * `U_UUID2BIN(<konstante>)` deshalb nicht falten und vergleicht dann **jede**
 * Zeile. Gemessen an `ObjectBase` (276k Zeilen) wird daraus ein voller
 * Index-Scan (`type=index`, 276k gelesene Zeilen, ~669 ms je Statement) statt
 * ein `const`-Zugriff über den PRIMARY KEY (~1 ms) — Faktor ~600. Auf einer
 * system-versioned Tabelle kostet jeder so getroffene Satz zusätzlich eine
 * History-Zeile, der Scan schreibt also auch noch Müll in die Historie.
 *
 * @param {string|Buffer} value UID in `UUID-…`-Form oder bereits ein Buffer
 * @returns {Buffer|undefined}
 */
const toUidBin = (value) => (Buffer.isBuffer(value) ? value : UUID2hex(value));

/**
 * 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.
 *
 * ## Zwei Namen für dieselbe Sache — und ein Default, der nicht der laufende ist
 *
 * Es gibt **zwei** Env-Namen für diese Basis, und sie sind nicht deckungsgleich:
 *
 * | Leser | Variable | Default |
 * |---|---|---|
 * | dieser Service | `APP_BASE_DOMAIN` | `commtool.org` (`APP_BASE_DOMAIN_DEFAULT`) |
 * | `server.js` / `http-server.js` | `APP_BASE` | `commtool.org` |
 * | `static-server-runner.js` (Auth-Proxy) | `APP_BASE` | **`app.commtool.org`** |
 *
 * Gemessen auf dem Prod-System ist **keine** der beiden Variablen gesetzt — es
 * entscheidet also der jeweilige Default. Für einen internen Präfix heißt das:
 *
 * ```
 * orgas/apps:  admin.app → domain 'sjm'            (SJM)
 * Auth-Proxy →  sjm.admin.app.app.commtool.org     ← das ist geroutet (Traefik + Keycloak)
 * hier       →  sjm.admin.app.commtool.org         ← das nicht
 * ```
 *
 * Ein Label Unterschied (`app`), und weil {@link resolveDomain} **exakt**
 * vergleicht (anders als die tolerante Erkennung in `shared-auth`), ist das der
 * erste Konsument, der daran scheitert: SJMs Zugang liefe 404, obwohl er heute
 * live ist.
 *
 * Deshalb fällt die Leserichtung auf `APP_BASE` zurück — dieselbe Variable, die
 * der laufende Auth-Pfad benutzt. Wer eine konsistente Basis will, setzt `APP_BASE`
 * **einmal** für den ganzen Container; dann leiten Registry und Auslieferung
 * identische Hosts ab.
 *
 * @returns {string}
 */
const appBaseDomain = () =>
    process.env.APP_BASE_DOMAIN || process.env.APP_BASE || APP_BASE_DOMAIN_DEFAULT;

/**
 * Das Zonenlabel des Übergangs (`APP_ZONE_LABEL`).
 *
 * Während der Broker neben dem Legacy-Betrieb hochgezogen wird, braucht jede
 * Organisation einen zweiten, kollisionsfreien Namensraum: `db.app.a.kpe.de`
 * statt `db.app.kpe.de`. Das Label steht **zusätzlich** zwischen Umgebung und
 * Basis, die Umgebung bleibt im Host — nach dem Flip ist die Adresse deshalb
 * wieder genau die heutige (`PLAN-app-registry.md`, „Zonenlabel").
 *
 * Es ist **ein** Wert für die ganze Installation, kein Feld je Organisation: er
 * beschreibt eine Eigenschaft des laufenden Brokers (bzw. des Übergangs), nicht
 * eine Eigenschaft des Kunden. Die Domains einer Organisation bleiben davon
 * unberührt — die gelabelte Form wird beim Lesen abgeleitet, nicht gespeichert.
 *
 * Leer = kein Label. Das ist der Endzustand nach dem Flip, deshalb ist der
 * Leerwert kein Sonderfall, sondern der Normalfall.
 *
 * @returns {string} kleingeschrieben, ohne führende/abschließende Punkte
 */
export const appZoneLabel = () =>
    String(process.env.APP_ZONE_LABEL || '').trim().toLowerCase().replace(/^\.+|\.+$/g, '');

/**
 * Die Domains, unter denen eine Organisation Apps betreiben darf.
 *
 * ## Zwei Achsen, nicht eine Liste
 *
 * Der **Punkt** im Domain-Namen entscheidet, wo er im Host steht — es sind zwei
 * verschiedene Namensräume:
 *
 * | Art | Wo im Host | Beispiel (App `member.app`) |
 * |---|---|---|
 * | `external` — verifizierte Kunden-Domain | **hinten**, als Wurzel | `db.app.kpe.de` |
 * | `internal` — Plattform-Namensraum | **vorne**, als erstes Label | `kpe.member.app.commtool.org` |
 *
 * Eine **interne** Domain ist damit kein „schlechteres" externes Ziel, sondern
 * eine eigene Form: bei ihr steht die Organisation *vorne* unter der
 * Plattform-Basis, bei einer externen *hinten* unter ihrer eigenen Domain. Sie
 * beide in eine Auswahl zu werfen, ohne die Achse mitzugeben, hieße den Aufrufer
 * raten zu lassen, an welches Ende er den Wert hängt — und der Host sähe richtig
 * aus, während ihn niemand bedient.
 *
 * Beide Regeln stammen aus `appHostFor` (members-back) bzw.
 * `organizationDomains.js` (`shared-auth`); beide setzen denselben App-ID-String
 * ein (`FRONTEND_APPS=member.app,admin.app`), deshalb stimmen sie überein.
 *
 * Bei `internal` zählt der **Typ**, nicht der Status: die Altform-Übersetzung
 * (`toLegacyDomainValue`) macht aus einem internen Eintrag immer `"internal"`,
 * unabhängig von `pending`/`verified`. Bei `external` zählt `verified` — sonst
 * dürfte eine Organisation einen fremden Host als ihre App-Domain eintragen.
 *
 * ## Das Zonenlabel erweitert **jede externe** Domain
 *
 * Ist `APP_ZONE_LABEL` gesetzt, kommt zu `kpe.de` auch `a.kpe.de`. Beide stehen
 * nebeneinander in der Auswahl, und das ist Absicht: nur so kann die Migration
 * **app-weise** laufen. Eine App auf `db.app.a.kpe.de` bedient der Broker, eine
 * auf `db.app.kpe.de` weiterhin der Legacy-Container — beide Werte müssen
 * deshalb gleichzeitig wählbar und die bereits gewählten gleichzeitig gültig
 * sein. Emittierte man nur die gelabelte Form, verlöre der Legacy-Host seine
 * Zuordnung, und `shared-auth` fiele bei der Herkunftsprüfung still auf
 * `default` zurück.
 *
 * Eine **interne** Domain bekommt kein Label: sie steht ohnehin unter einer
 * Plattform-Basis (`*.commtool.org` deckt ein Label ab), und ein zweites Label
 * davor ergäbe einen Host, für den es kein Zertifikat gibt.
 *
 * `base` nennt die Domain, von der die gelabelte Form abgeleitet ist; `zone`
 * markiert sie für die Anzeige. Beides ist Beiwerk — gebaut wird der Host
 * weiterhin allein aus `<präfix>.<umgebung>.<domain>`.
 *
 * @param {Record<string, Record<string, {type?: string, status?: string}>>} allOrgDomains
 * @param {string} orgId
 * @param {string} [zoneLabel] - `APP_ZONE_LABEL`; leer = keine gelabelte Form
 * @returns {Array<{domain: string, type: string, base?: string, zone?: boolean}>}
 */
export const orgDomainChoices = (allOrgDomains, orgId, zoneLabel = appZoneLabel()) => {
    const choices = Object.entries(allOrgDomains?.[orgId] ?? {})
        .filter(([, entry]) =>
            entry?.type === DOMAIN_TYPE_INTERNAL
            || (entry?.type === DOMAIN_TYPE_EXTERNAL && entry?.status === DOMAIN_STATUS_VERIFIED))
        .map(([domain, entry]) => ({ domain, type: entry.type }));

    if (!zoneLabel) return choices.sort((a, b) => a.domain.localeCompare(b.domain));

    const withZone = [];
    for (const choice of choices) {
        if (choice.type !== DOMAIN_TYPE_EXTERNAL) {
            withZone.push(choice);
            continue;
        }
        // Die gelabelte Form steht **vor** ihrer Basis: wer migriert, findet sie
        // ohne Scrollen, und die Reihenfolge sagt, welche die neue ist.
        withZone.push({ domain: `${zoneLabel}.${choice.domain}`, type: choice.type, base: choice.domain, zone: true });
        withZone.push(choice);
    }
    return withZone.sort((a, b) => a.domain.localeCompare(b.domain));
};


/**
 * 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\` = ?`,
            [OBJ_TYPE_APP, toUidBin(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) {
    let app;
    if (!readsDb()) {
        const apps = await vaultService.getApps(orgId);
        app = apps[appId] ?? null;
    } else {
        const rows = await query(
            `SELECT \`Data\`
               FROM \`ObjectBase\`
              WHERE \`Type\` = ? AND \`UIDBelongsTo\` = ?
                AND JSON_UNQUOTE(JSON_EXTRACT(\`Data\`, '$.appId')) = ?
              LIMIT 1`,
            [OBJ_TYPE_APP, toUidBin(orgId), appId],
            CAST,
        );
        app = rows[0]?.Data ?? null;
    }
    if (!app) return null;

    // Der Icon-Vorrang endet hier beim Vendor-Default: die App dieser
    // Organisation trägt entweder ein eigenes Icon (Upload oder Admin-Eintrag)
    // oder bekommt das Produkt-Icon. `favicon`/`appleTouchIcon` sind Funktionen
    // des Icons und werden **hier** gebildet, nicht gespeichert — der Broker
    // liest genau diese drei Felder und soll die Regel nicht kennen müssen.
    const icon = resolveIcon(app, null, appId);
    return {
        ...app,
        icon,
        favicon: appFavicon(app) ?? icon,
        appleTouchIcon: appAppleTouchIcon(app) ?? icon,
    };
}

/**
 * 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;
    await writeAppMap(orgId, apps, { removeMissing: true });
}

/**
 * Einen **einzelnen** App-Eintrag schreiben — der Weg des Edit-Dialogs.
 *
 * Der Dialog bearbeitet genau eine Zeile, also wird auch genau eine Zeile
 * geschickt. Über die Sammel-Route (`saveOrgApps`) wären es bei fünf Apps fünf
 * Schreibvorgänge, obwohl sich eine Zeile geändert hat.
 *
 * @param {string} orgId
 * @param {string} appId
 * @param {object} config
 */
export async function saveOrgApp(orgId, appId, config) {
    if (writesVault()) {
        const current = await vaultService.getApps(orgId);
        await vaultService.saveApps(orgId, { ...current, [appId]: config ?? {} });
    }
    if (!readsDb()) return;
    await writeAppMap(orgId, { [appId]: config }, { removeMissing: false });
}

/**
 * Einen **einzelnen** App-Eintrag entfernen (samt seiner Links).
 * @param {string} orgId
 * @param {string} appId
 */
export async function deleteOrgApp(orgId, appId) {
    if (writesVault()) {
        const current = await vaultService.getApps(orgId);
        if (appId in current) {
            const next = { ...current };
            delete next[appId];
            await vaultService.saveApps(orgId, next);
        }
    }
    if (!readsDb()) return;

    const rows = await query(
        `SELECT \`UID\`, \`Data\` FROM \`ObjectBase\`
          WHERE \`Type\` = ? AND \`UIDBelongsTo\` = ?`,
        [OBJ_TYPE_APP, toUidBin(orgId)],
        CAST,
    );
    const row = rows.find((r) => appIdFromData(r.Data) === appId);
    if (!row) return;
    const host = normalizeDomainValue(row.Data?.domain);
    await deleteAppRows([row.UID]);
    await invalidateRegistryCache(orgId, host ? [host] : []);
}

/**
 * Der gemeinsame Kern: liest den App-Bestand, schreibt ihn in **je einem**
 * Statement pro Operation und meldet die geänderten Hosts.
 *
 * Sequenzielle Einzel-Statements („eins je Eintrag") sind hier bewusst weg: auf
 * einer system-versioned Tabelle kostet jedes einen History-Eintrag, und ohne
 * PK-Bindung kostet es einen vollen Scan (siehe `toUidBin`). Ein Multi-Row-
 * `UPDATE` über PRIMARY KEY schreibt fünf Zeilen in ~2 ms statt in ~3,3 s.
 *
 * @param {string} orgId
 * @param {Record<string, object>} apps
 * @param {{removeMissing: boolean}} options
 */
async function writeAppMap(orgId, apps, { removeMissing }) {
    const orgBin = toUidBin(orgId);
    const existing = await query(
        `SELECT \`UID\`, \`Data\` FROM \`ObjectBase\`
          WHERE \`Type\` = ? AND \`UIDBelongsTo\` = ?`,
        [OBJ_TYPE_APP, orgBin],
        CAST,
    );

    const uidByAppId = new Map();
    // Host VOR dem Schreiben festhalten: der Broker löst nur die Zuordnung
    // `Data.domain` auf, und nur ein *geänderter* Host muss gemeldet werden.
    const previousHostByAppId = new Map();
    for (const row of existing) {
        const appId = appIdFromData(row.Data);
        if (!appId) continue;
        uidByAppId.set(appId, row.UID);
        const previousHost = normalizeDomainValue(row.Data?.domain);
        if (previousHost) previousHostByAppId.set(appId, previousHost);
    }

    const updates = [];
    const inserts = [];
    const nextHostByAppId = new Map();
    for (const [appId, config] of Object.entries(apps ?? {})) {
        const data = { ...(config ?? {}), appId };
        const { title, display, sortName } = appTitles(appId, data.title);
        const nextHost = normalizeDomainValue(data.domain);
        if (nextHost) nextHostByAppId.set(appId, nextHost);

        const uid = uidByAppId.get(appId);
        if (uid) {
            updates.push({ uid, title, display, sortName, data });
            uidByAppId.delete(appId); // verbraucht
        } else {
            inserts.push({ title, display, sortName, data });
        }
    }

    await updateAppRows(orgBin, updates);
    await insertAppRows(orgBin, inserts);

    // Was übrig ist, wurde im Frontend entfernt — inklusive seiner Links.
    if (removeMissing && uidByAppId.size) {
        await deleteAppRows([...uidByAppId.values()]);
    }

    // Genau die Hosts melden, deren Zuordnung sich ändert — neu, verschoben
    // oder weggefallen. Der Broker vergisst damit auch einen zuvor *negativ*
    // gecachten Eintrag: ohne diese Meldung bliebe ein frisch angelegter Host
    // bis zum TTL-Ablauf (Default 5 min) auf 404, obwohl die Registry ihn
    // bereits kennt — `invalidateOrg` erreicht Negativ-Einträge nicht, weil sie
    // ohne Organisation abgelegt werden.
    const changedHosts = new Set();
    for (const [appId, host] of nextHostByAppId) {
        if (previousHostByAppId.get(appId) !== host) changedHosts.add(host);
    }
    for (const [appId, host] of previousHostByAppId) {
        if (nextHostByAppId.get(appId) !== host) changedHosts.add(host);
    }

    await invalidateRegistryCache(orgId, [...changedHosts]);
}

/**
 * Bestehende App-Zeilen in **einem** Multi-Row-`UPDATE`.
 *
 * Die Zuordnung läuft über UID (PRIMARY KEY) statt über einen zweiten Lookup:
 * `Title`/`Display`/`SortName`/`Data` kommen aus der abgeleiteten Tabelle.
 * @param {Buffer} orgBin
 * @param {{uid: string, title: string, display: string, sortName: string, data: object}[]} entries
 */
async function updateAppRows(orgBin, entries) {
    if (!entries.length) return;
    const tuples = entries
        .map(() => 'SELECT ? AS uid, ? AS Title, ? AS Display, ? AS SortName, ? AS Data')
        .join(' UNION ALL ');
    const params = entries.flatMap((e) => [toUidBin(e.uid), e.title, e.display, e.sortName, JSON.stringify(e.data)]);
    await query(
        `UPDATE \`ObjectBase\` AS o
           JOIN (${tuples}) AS v ON v.uid = o.\`UID\`
            SET o.\`Title\` = v.Title, o.\`Display\` = v.Display, o.\`SortName\` = v.SortName, o.\`Data\` = v.Data
          WHERE o.\`Type\` = ? AND o.\`UIDBelongsTo\` = ?`,
        [...params, OBJ_TYPE_APP, orgBin],
    );
}

/**
 * Neue App-Zeilen in **einem** Multi-Row-`INSERT`.
 * @param {Buffer} orgBin
 * @param {{title: string, display: string, sortName: string, data: object}[]} entries
 */
async function insertAppRows(orgBin, entries) {
    if (!entries.length) return;
    const uids = [];
    for (let i = 0; i < entries.length; i += 1) uids.push(await newUid());
    const values = entries.map(() => '(?, ?, ?, ?, ?, ?, 0, ?)').join(', ');
    const params = entries.flatMap((e, i) => [
        toUidBin(uids[i]), OBJ_TYPE_APP, orgBin, e.title, e.display, e.sortName, JSON.stringify(e.data),
    ]);
    await query(
        `INSERT INTO \`ObjectBase\`
             (\`UID\`, \`Type\`, \`UIDBelongsTo\`, \`Title\`, \`Display\`, \`SortName\`, \`dindex\`, \`Data\`)
         VALUES ${values}`,
        params,
    );
}

/**
 * App-Zeilen samt ihrer Links in **je einem** `DELETE`.
 *
 * `appDomain` wird 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[]} uids
 */
async function deleteAppRows(uids) {
    if (!uids.length) return;
    const bins = uids.map((uid) => toUidBin(uid));
    const placeholders = bins.map(() => '?').join(', ');
    await query(
        `DELETE FROM \`Links\` WHERE \`UID\` IN (${placeholders}) AND \`Type\` IN (?, ?)`,
        [...bins, LINK_TYPE_APP_DOMAIN, LINK_TYPE_APP_ASSET],
    );
    await query(
        `DELETE FROM \`ObjectBase\` WHERE \`UID\` IN (${placeholders}) AND \`Type\` = ?`,
        [...bins, OBJ_TYPE_APP],
    );
}

// ── 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\` = ?`,
            [OBJ_TYPE_APP_DOMAIN, toUidBin(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 orgBin = toUidBin(orgId);
    const existing = await query(
        `SELECT \`UID\`, \`Data\` FROM \`ObjectBase\`
          WHERE \`Type\` = ? AND \`UIDBelongsTo\` = ?`,
        [OBJ_TYPE_APP_DOMAIN, orgBin],
        CAST,
    );
    const uidByDomain = new Map();
    for (const row of existing) {
        const domain = normalizeDomainValue(row.Data?.domain);
        if (domain) uidByDomain.set(domain, row.UID);
    }

    const updates = [];
    const inserts = [];
    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) {
            updates.push({ uid, data });
            uidByDomain.delete(domain);
        } else {
            inserts.push({ domain, data });
        }
    }

    // Ein Statement je Operation — nicht eines je Domain (siehe `saveOrgApps`).
    if (updates.length) {
        const tuples = updates.map(() => 'SELECT ? AS uid, ? AS Title, ? AS Data').join(' UNION ALL ');
        const params = updates.flatMap((u) => [toUidBin(u.uid), u.data.domain, JSON.stringify(u.data)]);
        await query(
            `UPDATE \`ObjectBase\` AS o
               JOIN (${tuples}) AS v ON v.uid = o.\`UID\`
                SET o.\`Title\` = v.Title, o.\`Display\` = v.Title, o.\`SortName\` = v.Title, o.\`Data\` = v.Data
              WHERE o.\`Type\` = ? AND o.\`UIDBelongsTo\` = ?`,
            [...params, OBJ_TYPE_APP_DOMAIN, orgBin],
        );
    }

    if (inserts.length) {
        const uids = [];
        for (let i = 0; i < inserts.length; i += 1) uids.push(await newUid());
        const values = inserts.map(() => '(?, ?, ?, ?, ?, ?, 0, ?)').join(', ');
        const params = inserts.flatMap((it, i) => [
            toUidBin(uids[i]), OBJ_TYPE_APP_DOMAIN, orgBin, it.domain, it.domain, it.domain, JSON.stringify(it.data),
        ]);
        await query(
            `INSERT INTO \`ObjectBase\`
                 (\`UID\`, \`Type\`, \`UIDBelongsTo\`, \`Title\`, \`Display\`, \`SortName\`, \`dindex\`, \`Data\`)
             VALUES ${values}`,
            params,
        );
    }

    if (uidByDomain.size) {
        // 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.
        const gone = [...uidByDomain.values()].map((uid) => toUidBin(uid));
        const placeholders = gone.map(() => '?').join(', ');
        await query(
            `DELETE FROM \`Links\` WHERE \`UIDTarget\` IN (${placeholders}) AND \`Type\` = ?`,
            [...gone, LINK_TYPE_APP_DOMAIN],
        );
        await query(
            `DELETE FROM \`ObjectBase\` WHERE \`UID\` IN (${placeholders}) AND \`Type\` = ?`,
            [...gone, 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.
 *
 * ## Ein Host, eine App — Mehrfachtreffer sind ein Fehler, keine Auswahl
 *
 * Der Vergleich ist **exakt** (`===`), nicht tolerant wie die Erkennung in
 * `shared-auth`. Das ist Absicht: der Broker soll liefern oder 404 sagen, nicht
 * raten. Es macht aber jeden Treffer-Konflikt sichtbar, den eine tolerante
 * Suche verdeckt hätte.
 *
 * Ein Konflikt ist möglich, weil die `domain` **pro Zeile** frei eingetragen
 * wird und nichts zwei Apps derselben Organisation an derselben Ableitung
 * hindert. Gemessen im Bestand: `admin.app` und `admin.test` zeigen in der
 * CommTool-Organisation beide auf `admin.app.commtool.org`.
 *
 * Die frühere Fassung nahm den **ersten** Treffer der Iteration — die Antwort
 * hing damit am Ausführungsplan, und derselbe Host führte mal zur Admin-, mal
 * zur Test-Admin-App. Jetzt wird sortiert, gewählt und **laut geloggt**.
 *
 * @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,
        );

        // Das Zonenlabel gilt hier genauso wie in `getAllDomainMappings` — der
        // Broker löst **ausschließlich** über diesen Weg auf. Bei leerem Label
        // entfällt die zusätzliche Abfrage: der Endzustand ist der Normalfall,
        // nicht der Sonderfall.
        const zoneLabel = appZoneLabel();
        const verifiedBases = zoneLabel ? await verifiedExternalBaseDomains() : [];

        /** @type {Array<{orgId: string, appId: string|null, appUid: string}>} */
        const matches = [];
        for (const row of rows) {
            const appId = appIdFromData(row.Data);
            if (!appId) continue;
            const values = appDomainValues(row.Data?.domain, verifiedBases, zoneLabel);
            if (values.some((value) => appHostFor(appId, value, appBaseDomain()) === wanted))
                matches.push({ orgId: row.orgId, appId, appUid: row.UID });
        }

        if (matches.length === 0) return null;

        if (matches.length > 1) {
            // Deterministisch statt zufällig: nach `appId`, dann `orgId`.
            matches.sort((a, b) =>
                `${a.appId}\u0000${a.orgId}`.localeCompare(`${b.appId}\u0000${b.orgId}`));
            errorLoggerRead(new Error(
                `Host "${wanted}" wird von ${matches.length} Apps beansprucht ` +
                `(${matches.map((m) => `${m.appId}@${m.orgId}`).join(', ')}) — ` +
                `deterministisch gewählt: ${matches[0].appId}@${matches[0].orgId}`,
            ));
        }

        return matches[0];
    } catch (e) {
        errorLoggerRead(e);
        return null;
    }
}

/**
 * Die Rohtwerte, unter denen eine App laufen darf: der gespeicherte Wert — und
 * im Übergang der gelabelte.
 *
 * Das Zonenlabel ist ein **Overlay**: gespeichert ist nur `db.app.kpe.de`, der
 * gelabelte Host (`db.app.t.kpe.de`) entsteht beim Lesen. Diese Funktion ist die
 * **eine** Stelle, die das entscheidet — `resolveDomain` und
 * `getAllDomainMappings` rufen sie beide. Genau daran fehlte es: nur die Liste
 * trug das Label, die Auflösung nicht, und der Broker fragt ausschließlich über
 * die Auflösung (`/resolve`). Die Zone lief damit ins Leere, obwohl die Route
 * stand.
 *
 * Das Label tritt **unmittelbar vor die Kunden-Domain**, nicht vor den Host: aus
 * `db.sjm.net` wird `db.a.sjm.net`, nicht `a.db.sjm.net`. Die Stelle kommt aus
 * `verifiedBases` und wird nicht geraten. Eine **interne** Domain bekommt kein
 * Label: sie steht ohnehin unter einer Plattform-Basis (`*.commtool.org` deckt
 * ein Label ab), und ein zweites Label davor ergäbe einen Host, für den es kein
 * Zertifikat gibt.
 *
 * Der gespeicherte Wert steht **zuletzt** — er ist der Vorrang, die gelabelte
 * Form die Zugabe.
 *
 * @param {string|null|undefined} rawDomain - gespeicherter Wert aus `Data.domain`
 * @param {string[]} verifiedBases - verifizierte externe Basis-Domains
 * @param {string} zoneLabel - `APP_ZONE_LABEL`; leer = keine gelabelte Form
 * @returns {string[]} ein oder zwei Rohtwerte, gespeicherter Wert zuletzt
 */
const appDomainValues = (rawDomain, verifiedBases, zoneLabel) => {
    const value = normalizeDomainValue(rawDomain);
    if (!value) return [];
    // Nur eine **externe** Domain trägt das Label: sie enthält einen Punkt.
    if (!zoneLabel || !value.includes('.')) return [value];
    const base = verifiedBases.find((candidate) => value === candidate || value.endsWith('.' + candidate));
    if (!base) return [value];
    return [`${value.slice(0, value.length - base.length)}${zoneLabel}.${base}`, value];
};

/**
 * 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.
 *
 * `orgName` ist der **Anzeigename** der Organisation und ausdrücklich von `org`
 * getrennt: `org` bleibt die UID. Der Name kommt aus den Vault-Stammdaten der
 * Organisation und ist nullable — fehlt er, fällt der Konsument auf die UID
 * zurück. Beides zusammen gibt das Verhalten der Bibliothek wieder, die sich
 * ihren Satz früher selbst gebaut hat (`org = metadata.name || orgId`), ohne
 * dass ein Konsument dafür noch alle Mandanten auslesen muss.
 *
 * @returns {Promise<Array<{domain: string, org: string, orgId: string, orgName: string|null, 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 verifiedBases = await verifiedExternalBaseDomains();
        const orgNames = await orgDisplayNames(rows.map((row) => row.orgId));
        const zoneLabel = appZoneLabel();
        const mappings = [];
        for (const row of rows) {
            const appId = appIdFromData(row.Data);
            const rawDomain = normalizeDomainValue(row.Data?.domain);
            if (!appId || !rawDomain) continue;
            // Derselbe Punkt, an dem `appHostFor` die beiden Formen trennt —
            // hier als Aussage statt als Verzweigung.
            const type = rawDomain.includes('.') ? DOMAIN_TYPE_EXTERNAL : DOMAIN_TYPE_INTERNAL;

            // Das Zonenlabel als **Overlay**: gespeichert ist nur die App-Adresse
            // (`db.app.kpe.de`), der gelabelte Host (`db.app.t.kpe.de`) entsteht
            // beim Lesen. Beide fahren mit — sonst koennte der Broker waehrend des
            // Uebergangs nur die Basis aufloesen, und die Zone liefe ins Leere,
            // obwohl die Route steht. Die Regel steht in `appDomainValues`, damit
            // `resolveDomain` dieselbe anwendet: Liste und Aufloesung duerfen nicht
            // auseinanderlaufen, der Broker fragt nur die Aufloesung.
            const domains = appDomainValues(rawDomain, verifiedBases, zoneLabel);

            for (const domain of domains) {
                const host = appHostFor(appId, domain, appBaseDomain());
                if (!host) continue;
                mappings.push({
                    domain: host,
                    org: row.orgId,
                    orgId: row.orgId,
                    orgName: orgNames.get(row.orgId) || null,
                    app: appId,
                    type,
                    // Ein interner Host liegt unter der Plattform-Basis-Domain und
                    // braucht keinen Nachweis. Ein externer Host gehört einem Kunden:
                    // ohne verifizierte Basis-Domain dürfte ihn jeder Mandant für
                    // sich beanspruchen und fremde Hostnamen übernehmen.
                    verified: type === DOMAIN_TYPE_INTERNAL
                        || verifiedBases.some((base) => host === base || host.endsWith('.' + base)),
                });
            }
        }
        return mappings;
    } catch (e) {
        errorLoggerRead(e);
        return [];
    }
}

/**
 * Anzeigenamen der genannten Organisationen.
 *
 * Nur für die Organisationen, die in den App-Zeilen **tatsächlich vorkommen** —
 * nicht für alle. Die Stammdaten liegen ausschließlich im Vault, es gibt für
 * sie keine Tabelle; ein Leser mehr wäre eine Schleife über alle Mandanten,
 * obwohl die Antwort für die meisten nicht gebraucht wird.
 *
 * Fehler je Organisation werden verschluckt: ein fehlender Name kostet den
 * Anzeigenamen und damit einen Rückfall auf die UID. Er darf **nicht** die
 * ganze Zuordnung kosten — sonst wäre ein einzelner kaputter Pfad gleich
 * „keine Organisation aufgelöst", und `detectOrganizationFromHost` landete
 * still bei `default`.
 *
 * @param {string[]} orgIds
 * @returns {Promise<Map<string, string>>} orgId → Anzeigename (ohne leere)
 */
const orgDisplayNames = async (orgIds) => {
    const unique = [...new Set((orgIds ?? []).filter(Boolean))];
    const entries = await Promise.all(
        unique.map(async (orgId) => {
            try {
                const metadata = await vaultService.getOrgMetadata(orgId);
                const name = typeof metadata?.name === 'string' ? metadata.name.trim() : '';
                return [orgId, name];
            } catch (e) {
                errorLoggerRead(e);
                return [orgId, ''];
            }
        }),
    );
    return new Map(entries.filter(([, name]) => name));
};

/**
 * Die **verifizierten** Basis-Domains über alle Organisationen.
 *
 * Gebraucht wird das nicht im Registry selbst, sondern von `shared-auth`: dort
 * hängt die Freigabe eines externen App-Hosts an einer verifizierten
 * Basis-Domain. Ohne diese Aussage müsste der Konsument den Domain-Bestand
 * erneut aus dem Vault lesen — die Umstellung wäre dann nur halb.
 *
 * Der Vergleich im Aufrufer ist bewusst **global**, nicht auf die eigene
 * Organisation beschränkt: das gibt das bisherige Verhalten der Bibliothek
 * wieder, die ebenfalls einen organisationsweiten Satz bildete. Fachlich ist das
 * zu weit gefasst — eine Organisation gilt als verifiziert, weil eine **andere**
 * die Basis-Domain nachgewiesen hat. Für den heutigen Bestand ist der
 * Unterschied folgenlos (jede Organisation besitzt ihre Basis-Domain selbst);
 * geändert wird das erst als eigene Entscheidung, nicht nebenbei.
 *
 * Ein Fehler liefert eine leere Liste, nicht `null`: der Aufrufer lässt dann die
 * externen Hosts fallen und liefert die internen weiterhin aus. Genau das tat
 * die Bibliothek bei einem fehlgeschlagenen Vault-Lesezugriff.
 *
 * @returns {Promise<string[]>}
 */
const verifiedExternalBaseDomains = async () => {
    try {
        const all = await getAllOrgDomains();
        const bases = [];
        for (const domains of Object.values(all)) {
            for (const [domain, entry] of Object.entries(domains)) {
                if (entry.type === DOMAIN_TYPE_EXTERNAL && entry.status === DOMAIN_STATUS_VERIFIED)
                    bases.push(domain);
            }
        }
        return bases;
    } 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 **App-Ebene des Vertrags**.
 *
 * Er kommt aus `AppCatalog` — der Tabelle, die der Vertragsimport füllt. Ein
 * Eintrag ist ein **flaches env.js-Objekt** (`environment`): die Schlüssel, die
 * die App liest (`api`, `baseUrl`, `NODE_ENV`, …) und die Anzeige-Vorgaben
 * (`title`, `icon`, `roles`, `domain`). Es gibt hier keine zweite Ebene — die
 * Zweige (`AppBackendBranch`) liegen daneben.
 *
 * Vorher zeigte diese Funktion auf Vault (`orgas/data/default/apps`); das war
 * die Vorlage, aus der eine neue Organisation ihre Apps bekam, und wurde nie
 * aus dem Vertrag gespeist.
 *
 * Hier steht das **Angebot**. Was eine Organisation daraus gemacht hat, liegt an
 * ihrem App-Objekt (`ObjectBase`) — der Katalog kennt weder `OrgUID` noch
 * `OrgAppDeployment`.
 *
 * @returns {Promise<Record<string, object>>} `{ [appKey]: environment }`
 */
export async function getAppCatalog() {
    const rows = await query(
        'SELECT `AppKey`, `Data` FROM `AppCatalog` ORDER BY `AppKey`',
        [],
        CAST,
    );

    /** @type {Record<string, object>} */
    const result = {};
    for (const row of rows) {
        result[row.AppKey] = row.Data ?? {};
    }
    return result;
}

/**
 * Schreibt das `environment` einer App fort — der App-Teil des **Importpfads**
 * (der Zweig-Teil ist {@link saveBackendBranches}).
 *
 * Prüft die Form vollständig (`validateEnvironmentObject`): nicht-leeres,
 * JSON-sicheres Objekt, keine Secret-Schlüssel, und die bekannten Meta-Schlüssel
 * (`title`, `icon`, `roles`, `domain`) in ihrer Form. Ein abgelehnter Eintrag
 * ist ein Fehler des Absenders (400 beim Router), kein Serverfehler.
 *
 * Der Import überschreibt das `environment` **als Ganzes** (Upsert auf
 * `AppKey`). Anders als bei den Zweigen gibt es hier nichts zu schützen:
 * `AppCatalog` ist reines Angebot, keine Organisation hängt daran. Ein Feld, das
 * aus dem Vertrag verschwindet, verschwindet deshalb auch hier — die
 * Überschreibung der Organisation liegt daneben in `ObjectBase` und bleibt.
 *
 * @param {string} appKey
 * @param {object} environment - flaches env.js-Objekt
 * @returns {Promise<object>} der geschriebene Stand
 */
export async function saveAppCatalog(appKey, environment) {
    if (!isValidAppKey(appKey)) {
        throw new Error(`Invalid appKey "${appKey}" (lowercase letters, digits, dots and hyphens).`);
    }

    const invalid = validateEnvironmentObject(environment);
    if (invalid) throw new Error(invalid);

    const clean = normalizeAppEnvironment(environment);

    await query(
        `INSERT INTO \`AppCatalog\` (\`AppKey\`, \`Data\`) VALUES (?, ?)
              ON DUPLICATE KEY UPDATE \`Data\` = VALUES(\`Data\`)`,
        [appKey, JSON.stringify(clean)],
    );

    // Organisationen mit einer Zuordnung lesen das `environment` über `env.js` —
    // sie sollen die neue Vorgabe sehen, ohne auf den Host-Cache-TTL zu warten.
    await invalidateOrgsWithDeployment(appKey);

    return clean;
}

/**
 * PWA-Manifest einer App (Branding aus dem Registry statt Vault-PWA-Cache).
 *
 * Die `icons` haben zwei mögliche Quellen, und welche greift, entscheidet die
 * **Herkunft** der Grafik — nicht, ob überhaupt eine da ist:
 *
 * 1. **Upload der Organisation** (ein `appAsset` vom Typ {@link ASSET_TYPE_ICON}):
 *    dann liegen die vier PWA-Größen unter `${orgId}/manifests/${appId}/…` im
 *    public bucket, und das Manifest nennt sie. Das Original taugt dafür nicht —
 *    es ist eine beliebige Nutzergrafik ohne PWA-Format.
 * 2. **Vertrags-Default** (`Data.icon`, kein Asset): dann bleibt es bei seinem
 *    einen Eintrag. Die Größen existieren in diesem Fall nicht; sie aufzuführen
 *    hieße, dem Browser vier 404s ins Manifest zu schreiben.
 *
 * Die Unterscheidung muss am `appAsset` hängen: `getOrgApp` liefert in
 * `Data.icon` bereits den Vertrags-Default, wenn die Organisation nichts
 * hochgeladen hat — ein Test auf `app.icon` würde also jeden Default für einen
 * Upload halten.
 *
 * @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,
        ...(app.description ? { description: app.description } : {}),
        start_url: '/',
        scope: '/',
        display: 'standalone',
        // Die Farben standen früher in `app.config.json` im App-Build; im
        // Vertrag sind `theme_color`/`background_color` freie Meta-Schlüssel
        // (siehe APP_ENV_META_FIELDS) und damit je Organisation überschreibbar.
        // Ohne Angabe bleibt es beim CommTool-Default — derselbe Wert, den
        // `index.html` als `<meta name="theme-color">` mitbringt.
        theme_color: app.theme_color ?? '#2185d0',
        background_color: app.background_color ?? '#ffffff',
        icons: /** @type {object[]} */ ([]),
    };

    /** Der Vertrags-Default ist immer ein einzelnes Bild ohne PWA-Varianten. */
    const pushDefaultIcon = () => {
        if (app.icon) manifest.icons.push({ src: app.icon, sizes: '512x512', type: 'image/png' });
        return manifest;
    };

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

    const assets = await query(
        `SELECT a.\`Data\` FROM \`ObjectBase\` a
           JOIN \`Links\` l ON l.\`UIDTarget\` = a.\`UID\`
          WHERE l.\`UID\` = ? AND l.\`Type\` = ? AND a.\`Type\` = ?`,
        [toUidBin(appUid), LINK_TYPE_APP_ASSET, OBJ_TYPE_APP_ASSET],
        CAST,
    );
    const iconAssets = assets.filter(
        (asset) => asset.Data?.assetType === ASSET_TYPE_ICON && asset.Data.s3Key,
    );
    if (iconAssets.length === 0) return pushDefaultIcon();

    const token = iconCacheToken(app.icon) ?? iconCacheToken(iconAssets[0].Data.s3Key);
    const version = token ? `?v=${token}` : '';
    for (const { name: fileName, size, purpose, manifestIcon } of PWA_ICON_SIZES) {
        if (!manifestIcon) continue;
        manifest.icons.push({
            src: `${appAssetUrl(`${orgId}/manifests/${appId}/${fileName}`)}${version}`,
            sizes: `${size}x${size}`,
            type: 'image/png',
            purpose,
        });
    }
    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',
};

/** Die acht Bytes, mit denen jede PNG-Datei beginnt. */
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);

/**
 * Ein Eingabefehler des Aufrufers — 400, kein Serverfehler.
 *
 * Eigener Typ statt einer Meldungs-Heuristik: der Controller unterscheidet daran,
 * ob **der Bediener** etwas falsch gemacht hat (falsche Datei → 400, nicht ins
 * Fehler-Log) oder ob der Dienst es nicht konnte (500, gehört ins Log). Eine
 * Textprüfung wäre die zweite Wahrheit über dieselbe Aussage.
 */
export class IconInputError extends Error {
    /** @param {string} message */
    constructor(message) {
        super(message);
        this.name = 'IconInputError';
        this.statusCode = 400;
    }
}

/**
 * Das Bildformat an den **ersten Bytes** erkennen — nicht am `mimeType` des Clients.
 *
 * Der Client sagt, was er zu senden glaubt; die Bytes sagen, was ankommt. Das ist
 * hier kein Misstrauen gegen den Browser, sondern gegen alles daneben: der
 * `accept`-Filter der Dateiauswahl hält eine Datei **nicht** auf, die selbst auf
 * `.png` endet — und genau das tut der macOS-Metadaten-Zwilling `._logo.png`. Er
 * kommt als `image/png` an, ist aber keins (siehe {@link isAppleDouble}).
 *
 * Deshalb **vor** dem ersten Schreibzugriff prüfen: scheitert die Dekodierung erst
 * im `sharp`-Aufruf, liegt das Original bereits im public bucket. Genau so lagen
 * nach zwei Fehlversuchen zwei Karteileichen à 263 Byte unter `apps/<org>/icons/`
 * — Dateien, die nie ein Icon waren und niemand aufräumt.
 *
 * @param {Buffer} buffer
 * @returns {string|null} erkannter MIME-Typ oder null
 */
const sniffIconType = (buffer) => {
    if (buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return 'image/png';
    if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg';
    if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('latin1'))) return 'image/gif';
    if (buffer.length >= 12
        && buffer.subarray(0, 4).toString('latin1') === 'RIFF'
        && buffer.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp';
    // SVG ist Text, kein Binärformat: nicht an Bytes erkennbar, nur am Inhalt.
    const head = buffer.subarray(0, 2048).toString('utf-8').replace(/^\uFEFF/, '').trimStart();
    if (/^<svg[\s>]/i.test(head)) return 'image/svg+xml';
    if (/^<\?xml/i.test(head) && /<svg[\s>]/i.test(head)) return 'image/svg+xml';
    return null;
};

/**
 * Der macOS-Metadaten-Zwilling (AppleDouble): `._logo.png` neben `logo.png`.
 *
 * Ein eigener Befund, weil er einen **eigenen** Rat verdient. „Kein Bild" schickt
 * den Bediener auf die Suche nach dem falschen Format; „das ist die
 * Metadatendatei" schickt ihn direkt zum richtigen Eintrag in der Auswahl. Beide
 * Dateien enden auf `.png`, der Filter der Auswahl zeigt also beide — der Zwilling
 * ist der kleinere und hat kein Vorschaubild.
 *
 * Magic `0x00051607` (`0x00051600` bei der älteren Finder-Form).
 *
 * @param {Buffer} buffer
 * @returns {boolean}
 */
const isAppleDouble = (buffer) => {
    if (buffer.length < 8) return false;
    const magic = buffer.readUInt32BE(0);
    return magic === 0x00051607 || magic === 0x00051600;
};

/**
 * PWA-Icon-Varianten unter `${orgId}/manifests/${appId}/…` **innerhalb des
 * App-Registry-Namespace** (`apps/…`, siehe {@link appAssetKey}).
 *
 * `manifestIcon` markiert die Einträge, die ins Web-Manifest gehören. Ein
 * Favicon ist **kein** Manifest-Icon (kein `purpose`, keine PWA-Größe) und wird
 * deshalb nur mitgeschrieben — es hat aber seine eigene Rolle: `/favicon.ico`
 * und sein `apple-touch-icon`-Pendant werden genau hieraus bedient.
 *
 * `favicon.png` (32 px) und `apple-touch-icon.png` (180 px) sind damit die
 * „komprimierten" Varianten: sie entstehen beim ohnehin nötigen Resize, es
 * braucht weder ein eigenes Kompressionswerkzeug noch ein Multi-Resolution-`.ico`.
 */
const PWA_ICON_SIZES = [
    { name: 'icon-192.png', size: 192, purpose: 'any', manifestIcon: true },
    { name: 'icon-512.png', size: 512, purpose: 'any', manifestIcon: true },
    { name: 'icon-192-maskable.png', size: 192, purpose: 'maskable', manifestIcon: true },
    { name: 'icon-512-maskable.png', size: 512, purpose: 'maskable', manifestIcon: true },
    { name: 'favicon.png', size: 32, purpose: null, manifestIcon: false },
    { name: 'apple-touch-icon.png', size: 180, purpose: null, manifestIcon: false },
];

/**
 * Cache-Bust-Token für die PWA-Größen.
 *
 * Die Dateinamen (`icon-192.png`) sind **fest**. Ohne Token liefert der Browser
 * nach einem Icon-Wechsel bis zu 24 h das alte Bild weiter, weil die Objekte im
 * public bucket keine Cache-Control-Angabe tragen, die den Namen entwertet. Der
 * Token kommt aus `Data.icon`: jeder Upload legt dort einen neuen Namen mit
 * `-<timestamp>` an, also ändert er sich genau mit dem Icon — auch für
 * migrierte Altdaten, die noch keinen Zeitstempel tragen (dann bleibt er leer
 * und es wird nichts entwertet, was nie entwertet werden musste).
 *
 * @param {unknown} iconUrl
 * @returns {string|null}
 */
const iconCacheToken = (iconUrl) => {
    const match = /-(\d{10,})\./.exec(String(iconUrl ?? ''));
    return match ? match[1] : null;
};

/**
 * Das Favicon einer App — die Quelle für `/favicon.ico` und seine Geschwister.
 *
 * **Ein Bild, eine Wahrheit:** es wird nicht neu erzeugt und nicht skaliert.
 * Hochgeladene Apps tragen die passende 32-px-Variante in `favicon` (siehe
 * {@link uploadOrgAppIcon}); alle anderen — Contract-Default, Vendor-Default,
 * manuell gesetztes Icon — fallen auf `icon` zurück. Ohne das Feld müsste jeder
 * Leser dieselbe Fallunterscheidung treffen, und der Broker, der sie am
 * wenigsten kennen sollte, träfe sie mit.
 *
 * @param {{favicon?: string|null, icon?: string|null}|null|undefined} app
 * @returns {string|null}
 */
const appFavicon = (app) => app?.favicon ?? app?.icon ?? null;

/** Das Apple-Touch-Icon einer App — gleiche Regel wie {@link appFavicon}. */
const appAppleTouchIcon = (app) => app?.appleTouchIcon ?? app?.icon ?? null;

/**
 * Der **Icon-Vorrang** einer App: Org-Upload → Vertrag → Vendor-Default.
 *
 * Der Vendor-Default ist der letzte Schritt, damit eine Organisation ohne
 * eigenes Branding ein Produkt-Icon bekommt statt eines leeren Tab-Symbols. Er
 * liegt org-los unter `apps/default/icons/<produkt>.png` — die erste Ebene
 * „ohne Org-Präfix = Vendor-Material" des Namespace.
 *
 * @param {{icon?: string|null}|null|undefined} owned - die App der Organisation
 * @param {{icon?: string|null}|null|undefined} effective - Vertrag (App + Zweig)
 * @param {string} appId
 * @returns {string}
 */
const resolveIcon = (owned, effective, appId) => {
    if (owned?.icon) return owned.icon;
    if (effective?.icon) return effective.icon;
    const product = splitAppId(appId)?.product ?? appId;
    return appAssetUrl(`default/icons/${product}.png`);
};

/** 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.
 *
 * Das **Original** landet im public bucket unter `${orgId}/icons/${appId}-<ts>.<ext>`
 * und wird als `Data.icon` am App-Objekt hinterlegt; seine URL ist der
 * Cache-Bust-Token für die PWA-Größen (siehe {@link iconCacheToken}).
 *
 * Die **PWA-Größen** gehen unter `${orgId}/manifests/${appId}/…` in denselben
 * public bucket — feste Namen, entwertet über den Cache-Bust-Token des
 * Originals (`?v=<ts>`, siehe {@link iconCacheToken}).
 *
 * @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) {
    // Der **angekündigte** Typ muss ein Bildtyp sein — die billigste Prüfung, sie
    // kommt ohne einen einzigen Lesevorgang aus. **Welcher** es dann ist,
    // entscheidet der Inhalt (`sniffIconType`): der Browser leitet den Typ aus der
    // Dateiendung ab und liegt damit regelmäßig daneben.
    if (!ICON_EXT[mimeType]) throw new IconInputError(`Unsupported image type: ${mimeType}`);

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

    if (inputBuffer.length === 0) throw new IconInputError('The uploaded file is empty.');

    // Der **Inhalt** entscheidet, nicht der angekündigte Typ: der `accept`-Filter
    // der Auswahl lässt eine Datei durch, die selbst auf `.png` endet (der
    // macOS-Zwilling `._logo.png` tut das). Beides wird hier abgewiesen, bevor
    // irgendetwas geschrieben ist.
    const detected = sniffIconType(inputBuffer);
    if (!detected) {
        if (isAppleDouble(inputBuffer)) {
            throw new IconInputError(
                'This is a macOS metadata file (`._<name>.png`), not the image itself. '
                + 'Please pick the entry without the leading `._`.',
            );
        }
        throw new IconInputError(
            `The file content is not an image (declared as "${mimeType || 'unknown'}"). `
            + 'Use PNG, JPG, SVG, GIF or WebP.',
        );
    }

    const ext = ICON_EXT[detected];
    if (!ext) throw new IconInputError(`Unsupported image type: ${detected}`);

    // Erst **rendern**, dann schreiben. Die frühere Reihenfolge (Original ablegen,
    // danach skalieren) ließ bei jedem Fehlversuch eine Datei im public bucket
    // zurück, die niemand mehr einem Upload zuordnen kann — und die kein Icon ist.
    const baseImage = sharp(inputBuffer);
    let variants;
    try {
        variants = await Promise.all(
            PWA_ICON_SIZES.map(async ({ name, size }) => ({
                name,
                buffer: await baseImage.clone()
                    .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
                    .png().toBuffer(),
            })),
        );
    } catch (e) {
        // Format erkannt, aber nicht dekodierbar: beschädigt oder abgeschnitten.
        throw new IconInputError(`The image could not be read: ${e?.message || e}`);
    }

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

    // Nur der public bucket. Er ist die **einzige** Quelle für Bilder: Broker und
    // Manifest lesen von hier, und nur hier lässt sich eine URL bilden, die ohne
    // Zugangsdaten auskommt (`publicObjectUrl` kennt ausschließlich den public
    // bucket). Eine zweite Ablage im data bucket wäre eine zweite Wahrheit, die
    // beim nächsten Umzug still veraltet — es gibt sie bewusst nicht mehr.
    await Promise.all(
        variants.map(({ name, buffer }) => minioput(
            PUBLIC_BUCKET, appAssetKey(`${orgId}/manifests/${appId}/${name}`), buffer, '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\` = ?
            AND JSON_UNQUOTE(JSON_EXTRACT(\`Data\`, '$.appId')) = ? LIMIT 1`,
        [OBJ_TYPE_APP, toUidBin(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
        }
    }

    const token = iconCacheToken(iconUrl);
    const version = token ? `?v=${token}` : '';
    const variantUrl = (fileName) => `${appAssetUrl(`${orgId}/manifests/${appId}/${fileName}`)}${version}`;

    await query(
        `UPDATE \`ObjectBase\` SET \`Data\` = ? WHERE \`UID\` = ?`,
        [JSON.stringify({
            ...app.Data,
            icon: iconUrl,
            // Favicon und Apple-Touch-Icon sind **dasselbe Bild** in ihrer
            // passenden Größe — sie entstehen beim Resize oben mit und werden
            // hier festgehalten. Der Ort, an dem ihre Existenz bekannt ist, ist
            // genau dieser: ein zweiter Ableitungspfad über den
            // `appAsset`-Link kostete den Broker auf seinem heißen Pfad eine
            // zusätzliche Abfrage. Fehlen die Felder (Altdaten, manuell
            // gesetztes Icon), fällt der Leser auf `icon` zurück — siehe
            // {@link appFavicon}.
            favicon: variantUrl('favicon.png'),
            appleTouchIcon: variantUrl('apple-touch-icon.png'),
        }), toUidBin(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 (?, ?, ?, ?, ?, ?, 0, ?)`,
        [assetUid, OBJ_TYPE_APP_ASSET, toUidBin(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 (?, ?, ?)`,
        [toUidBin(app.UID), LINK_TYPE_APP_ASSET, toUidBin(assetUidString)],
    );

    await invalidateRegistryCache(orgId);
    return iconUrl;
}

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

/**
 * Ein Release ist ein **Name**, kein Zustand.
 *
 * `AppRelease` hält pro `(AppKey, Version)` ein S3-Präfix. `Version` ist dabei
 * ein **Name**, unter dem die Zuordnung einer Organisation das Artefakt wählt:
 * `latest`, `stable` oder eine konkrete Version (`5.9.0`). Es gibt keinen
 * Zeiger und keine Canary-Zeile — wer nichts wählt, wählt `stable`
 * ({@link DEFAULT_RELEASE}).
 *
 * `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
 */

/**
 * Der Name, dem eine Organisation ohne eigene Wahl folgt: die letzte von
 * CommTool als `stable` getaggte Version. Er muss als `AppRelease`-Zeile
 * existieren (der Tag `X.Y.Z-stable` legt sie an); bis dahin gibt es kein
 * Release, und die Auslieferung endet als 503 statt mit einem zufälligen
 * Artefakt.
 */
export const DEFAULT_RELEASE = 'stable';

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

/**
 * Die **Kanäle** — `latest` und `stable`.
 *
 * Kein Release-Name, sondern eine Auflösungsregel: existiert keine Zeile mit
 * diesem Namen, fällt die Auflösung auf die **neueste** registrierte Version
 * zurück. Entscheidung im Umbau: der Artefakt-Bucket ist die Quelle, ein Kanal
 * muss dort nicht als Zeile liegen. Eine vorhandene Kanal-Zeile **gewinnt** —
 * der Release-Job darf sie weiterhin setzen (Promotion).
 */
const CHANNEL_NAMES = new Set(['latest', 'stable']);

/** Ein konkreter Versionsname: beginnt mit einer Ziffer (`5.30.1`). */
export const isVersionName = (name) => typeof name === 'string' && /^\d/.test(name);

/**
 * Versionsnamen absteigend sortieren (`5.10.0` **vor** `5.9.0`).
 *
 * Als String sortiert wäre die Reihenfolge falsch — und genau die neueste
 * Version ist der Kanal-Fallback. Numerische Segmente werden numerisch
 * verglichen, alles andere als String (Suffixe wie `-rc1`).
 */
export const compareVersionNames = (a, b) => {
    const pa = String(a).split('.');
    const pb = String(b).split('.');
    for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
        const na = Number(pa[i]);
        const nb = Number(pb[i]);
        if (Number.isFinite(na) && Number.isFinite(nb)) {
            if (na !== nb) return nb - na;
        } else {
            const sa = pa[i] ?? '';
            const sb = pb[i] ?? '';
            if (sa !== sb) return sb.localeCompare(sa);
        }
    }
    return 0;
};

/** Die Release-Zeile eines **Namens** — die Wahl gewinnt (auch ein Kanal). */
const findReleaseRow = async (product, name) => {
    const [row] = await query(
        `SELECT ${RELEASE_COLUMNS} FROM \`AppRelease\` WHERE \`AppKey\` = ? AND \`Version\` = ? LIMIT 1`,
        [product, name],
        CAST,
    );
    return row ?? null;
};

/**
 * Der Rückfall der Kanäle: die **neueste** registrierte Version des Produkts.
 *
 * Reihenfolge: konkrete Versionen (numerisch, absteigend) → sonst eine
 * `latest`-Zeile → sonst `stable` → sonst nichts.
 *
 * @param {string} product
 * @returns {Promise<object|null>}
 */
const newestRelease = async (product) => {
    const rows = await query(
        `SELECT ${RELEASE_COLUMNS} FROM \`AppRelease\` WHERE \`AppKey\` = ?`,
        [product],
        CAST,
    );
    const concrete = rows
        .filter((row) => isVersionName(row.Version))
        .sort((a, b) => compareVersionNames(a.Version, b.Version));
    if (concrete[0]) return concrete[0];
    const byName = (name) => rows.find((row) => row.Version === name);
    // **Rohe** Zeile — `resolveRelease` mappt genau einmal (sonst stünde das
    // Mapping hier und beim Aufrufer, und der zweite Durchlauf läse `undefined`).
    return byName('latest') ?? byName('stable') ?? null;
};

/**
 * Zeile → Release-Objekt. `Version` ist der **Name** der Zeile (`latest`,
 * `stable`, `5.9.0`), nicht die Artefakt-Version — die steht im `Prefix`
 * (S3-Layout `<produkt>/<version>/`).
 * @param {object} row
 */
const mapRelease = (row) => ({
    appKey: row.AppKey,
    version: row.Version,
    prefix: row.Prefix,
});

/**
 * Alle Releases einer App — die wählbaren Namen.
 *
 * Der Vertrag ist je **Produkt** geschlüsselt (`member`), die App-ID trägt die
 * Umgebung (`member.test`). Das Release-Artefakt ist **produktweit** (ein Build
 * bedient `member.app`, `member.test`, …), deshalb wird über
 * {@link productOfAppId} aufgelöst — nicht über die App-ID. Sortiert wird
 * numerisch, nicht als String (`5.10.0` vor `5.9.0`).
 *
 * @param {string} appKey - App-ID (`member.test`) oder Produkt (`member`)
 * @returns {Promise<object[]>}
 */
export async function listAppReleases(appKey) {
    const product = productOfAppId(appKey) ?? appKey;
    const rows = await query(
        `SELECT ${RELEASE_COLUMNS} FROM \`AppRelease\` WHERE \`AppKey\` = ?`,
        [product],
        CAST,
    );
    return rows
        .map(mapRelease)
        .sort((a, b) => compareVersionNames(a.version, b.version));
}

/**
 * Entfernt eine Release-Zeile — der Pruning-Schritt des Bucket-Imports.
 * @param {string} appKey - Produktname
 * @param {string} version
 */
export async function deleteAppRelease(appKey, version) {
    await query('DELETE FROM `AppRelease` WHERE `AppKey` = ? AND `Version` = ?', [appKey, version]);
}

/**
 * Alle Release-Zeilen — Eingabe des Prunings.
 * @returns {Promise<Array<{AppKey: string, Version: string, Prefix: string}>>}
 */
export async function listReleaseRows() {
    return query(`SELECT ${RELEASE_COLUMNS} FROM \`AppRelease\``, [], CAST);
}

/**
 * Löst die Auslieferung auf: **ein** Nachschlagen unter dem Namen, den die
 * Organisation gewählt hat — ohne eigene Wahl `stable` ({@link DEFAULT_RELEASE}).
 *
 * ## Der Name gewinnt, sonst die neueste Version
 *
 * Ist der gewählte Name eine **Zeile** (`latest`, `stable` oder eine konkrete
 * Version), wird sie genommen — so wirkt eine Promotion des Release-Jobs. Gibt
 * es keine Zeile mit dem Namen, fällt die Auflösung auf die **neueste**
 * registrierte Version zurück (`newestRelease`). Ein verschwundener Name kostet
 * damit keine Auslieferung mehr; erst ein Produkt **ohne** jede Version ergibt
 * `null` (→ 503 beim Aufrufer).
 *
 * ## Das Release ist produktweit, die Zuordnung app-spezifisch
 *
 * `AppRelease.AppKey` ist das **Produkt** (`member`) — ein Build bedient
 * `member.app`, `member.test`, … Die Zuordnung (`OrgAppDeployment`) und der
 * Backend-Zweig bleiben **je App-ID** (`member.test`).
 *
 * ## Backends kommen aus dem Zweig, nicht aus dem Release
 *
 * Die **Zuordnung** bestimmt den Backend-Zweig (`OrgAppDeployment.Branch` →
 * `AppBackendBranch.Backends`), nicht die Release-Zeile. Frontend-Bewegung und
 * Backend-Wahl sind damit zwei Entscheidungen — bewusst, denn der Admin wählt
 * das Backend, während die Version aus dem Artefakt kommt (§6.9). `AppRelease`
 * trägt deshalb **keine** Backends: ein Rückfall auf die Release-Zeile hätte eine
 * Admin-Wahl still überschrieben.
 *
 * @param {string} appKey
 * @param {string} [orgId] - UID in `UUID-`-Form (41 Zeichen)
 * @returns {Promise<(object & {branch: string|null, backends: object|null})|null>} `null` = kein Release
 */
export async function resolveRelease(appKey, orgId = null) {
    if (!appKey) return null;

    try {
        // Zwei Schlüsselräume, eine App-ID: der Vertrag (`AppCatalog`,
        // `AppBackendBranch`) liegt je **Produkt** (`member`), die Zuordnung und
        // das Release je **App-ID** (`member.app`). Das Suffix der App-ID ist die
        // Umgebung — und ist der Zweig; fehlt eine Wahl, gilt das Suffix.
        const { product, environment: envSuffix } = splitAppId(appKey) ?? {};
        const productKey = product ?? appKey;

        // Die Zuordnung der Organisation — je App-ID.
        let deployment = null;
        if (orgId) {
            const [row] = await query(
                `SELECT \`Version\`, \`Branch\` FROM \`OrgAppDeployment\`
                  WHERE \`AppKey\` = ? AND \`OrgUID\` = ?`,
                [appKey, toUidBin(orgId)],
                CAST,
            );
            if (row) deployment = row;
        }

        const branch = deployment?.Branch ?? envSuffix ?? null;

        // Zwei Overlay-Ebenen, ein env.js: das App-`environment` des **Produkts**
        // ist die Basis, der Zweig überschreibt **je Schlüssel**. Ein Zweig darf
        // damit jeden Schlüssel setzen — auch `title` oder `icon`.
        const [catalogRow] = await query(
            'SELECT `Data` FROM `AppCatalog` WHERE `AppKey` = ? LIMIT 1',
            [productKey],
            CAST,
        );
        const environment = catalogRow?.Data ?? {};

        let branchBackends = null;
        if (branch) {
            const [branchRow] = await query(
                'SELECT `Backends` FROM `AppBackendBranch` WHERE `AppKey` = ? AND `Branch` = ? LIMIT 1',
                [productKey, branch],
                CAST,
            );
            branchBackends = branchRow?.Backends ?? null;
        }

        const env = { ...environment, ...(branchBackends ?? {}) };

        // 1. Der genaue Name gewinnt — auch `latest`/`stable`, wenn der
        //    Release-Job eine Zeile gesetzt hat (Promotion).
        // 2. Sonst der Kanal-Rückfall: die **neueste** registrierte Version.
        // 3. Sonst nichts — der Aufrufer antwortet mit 503.
        const chosen = deployment?.Version || DEFAULT_RELEASE;
        const row = (await findReleaseRow(productKey, chosen)) ?? (await newestRelease(productKey));
        if (!row) return null;
        return { ...mapRelease(row), branch, env };
    } catch (e) {
        errorLoggerRead(e);
        return null;
    }
}

/**
 * Legt ein Release an oder schreibt es fort (Schlüssel `AppKey`+`Version`).
 *
 * `Version` ist der **Name**, unter dem die Zuordnung wählt (`latest`, `stable`
 * oder eine konkrete Version). Es gibt keinen Zeiger mehr: der Default ist der
 * Name `stable`, den der `X.Y.Z-stable`-Tag setzt (`DEFAULT_RELEASE`).
 *
 * @param {{appKey: string, version: string, prefix: string}} release
 */
export async function saveAppRelease({ appKey, version, prefix }) {
    if (!appKey || !version || !prefix) throw new Error('appKey, version and prefix are required');

    await query(
        `INSERT INTO \`AppRelease\` (\`AppKey\`, \`Version\`, \`Prefix\`)
              VALUES (?, ?, ?)
         ON DUPLICATE KEY UPDATE \`Prefix\` = VALUES(\`Prefix\`)`,
        [appKey, version, prefix],
    );
}

// ── Backend-Zweige: das Angebot ───────────────────────────────────────────────

/** @typedef {{branch: string, backends: object, updatedAt: string|null}} BackendBranch */

const mapBackendBranch = (row) => ({
    branch: row.Branch,
    backends: row.Backends ?? {},
    updatedAt: row.UpdatedAt ?? null,
});

/**
 * Die wählbaren Backend-Zweige einer App.
 *
 * Autoritativ ist der **Vertrag** (YAML, importiert); diese Liste ist seine
 * Projektion. Sie wird gebraucht, um zu prüfen, ob eine Wahl überhaupt zulässig
 * ist — und um einem Re-Import zu sagen, welche Zweige verschwunden sind.
 *
 * @param {string} appKey
 * @returns {Promise<BackendBranch[]>}
 */
export async function listBackendBranches(appKey) {
    const rows = await query(
        'SELECT `Branch`, `Backends`, `UpdatedAt` FROM `AppBackendBranch` WHERE `AppKey` = ? ORDER BY `Branch`',
        [appKey],
        CAST,
    );
    return rows.map(mapBackendBranch);
}

/**
 * Der **Katalog** einer App in der Form, die eine Oberfläche braucht: welche
 * Versionen und welche Zweige sind wählbar — **ohne** die Backend-URLs.
 *
 * Warum ohne URLs: die Wahl trifft der **Kunde** (`db-admin`, §2.6.1), und die
 * URLs enthalten Infrastruktur-Hostnamen (`canary.api.commtool.org`). Ein
 * Kunden-Admin darf fremde bzw. interne Hosts nicht auszählen. Er braucht die
 * Namen zum Wählen, nicht die Ziele.
 *
 * Die Versionen kommen aus `AppRelease` (das Frontend-Artefakt), die Zweige aus
 * `AppBackendBranch` (der Vertrag) — genau die Trennung aus §6.9. Die
 * „Versionen" sind die **wählbaren Namen** (`latest`, `stable`, `5.9.0`).
 *
 * @param {string} appKey
 * @returns {Promise<{appKey: string, versions: string[], branches: string[]}>}
 */
export async function getCatalogProjection(appKey) {
    const product = productOfAppId(appKey) ?? appKey;
    const [releases, branches] = await Promise.all([
        query('SELECT `Version` FROM `AppRelease` WHERE `AppKey` = ?', [product], CAST),
        query('SELECT `Branch` FROM `AppBackendBranch` WHERE `AppKey` = ? ORDER BY `Branch`', [product], CAST),
    ]);

    return {
        appKey,
        versions: releases.map((row) => row.Version).sort((a, b) => compareVersionNames(a, b)),
        branches: branches.map((row) => row.Branch),
    };
}

/**
 * Der Katalog **aller** Apps einer Organisation, zusammen mit ihrer jeweiligen
 * Wahl — eine Antwort für den Apps-Reiter des Kunden-Admin.
 *
 * ## Zwei Quellen, ein Schlüsselraum
 *
 * Der Vertrag (`AppCatalog`) ist das **Angebot**, der Bestand der Organisation
 * (`ObjectBase`) ihre Umsetzung. Beide sind über denselben `AppKey` adressiert,
 * und beide gehören in dieselbe Antwort — sonst gäbe es keinen Weg, eine
 * Vertrags-App in die Organisation zu holen (die „Hinzufügen"-Liste fehlte).
 *
 * Deshalb ist die App-Menge die **Vereinigung**: die Apps des Vertrags und die
 * der Organisation. Je App wird gemergt, und zwar **je Feld**, nicht als Block:
 *
 * | Feld | Quelle |
 * |---|---|
 * | `environment` | nur der Vertrag — die Organisation kennt es nicht |
 * | `title`, `icon`, `roles` | Überschreibung der Organisation, sonst Vertrag |
 * | `domain` | Überschreibung der Organisation (voller Host); sonst `null` |
 * | `domainPrefix` | Vorgabe aus dem Vertrag (für „Präfix + Org-Domain") |
 *
 * Feldweise zu mergen ist der Punkt: läge die Überschreibung als ganzer Block
 * vor, würde ein gesetzter `title` die Rollen des Vertrags wieder verdecken.
 *
 * ## Warum gebündelt und nicht je App
 *
 * Releases, Zweige und Zuordnungen kommen in je **einer** Abfrage für die ganze
 * App-Menge — drei statt drei pro App. Bei vier bis acht Apps je Organisation
 * wäre das noch erträglich, aber es wächst mit dem Bestand des Kunden, und der
 * Reiter lädt bei jedem Öffnen.
 *
 * ## Was **nicht** in der Antwort steht
 *
 * Die Backend-URLs der Zweige. Der Reiter wird von einem `db-admin` des Kunden
 * bedient; die URLs enthalten Infrastruktur-Hostnamen. Er wählt einen **Namen**,
 * das Ziel bleibt CommTool-Sache (§2.6.1).
 *
 * @param {string} orgId - UID in `UUID-`-Form
 * @returns {Promise<Record<string, object>>}
 */
export async function getOrgCatalog(orgId) {
    const [catalog, apps] = await Promise.all([getAppCatalog(), getOrgApps(orgId)]);

    // Die App-Menge der Organisation — je **App-ID** (`member.app`, `member.test`).
    // Der Vertrag ist je **Produkt** (`member`) geschlüsselt; `productOfAppId` ist
    // die Naht. Zwei Umgebungen sind damit **zwei** Apps, jede mit eigener Zeile —
    // genau das ist gewollt: der Kunde darf beide gleichzeitig führen.
    //
    // Die Vertrags-App erscheint **nicht** von selbst in dieser Liste; sie ist das
    // Angebot im „Add app"-Dialog (siehe `getAppOffers`). Vorher stand beides im
    // selben Schlüsselraum (`member` **und** `member.app`) und ergab Doppelzeilen.
    const appKeys = Object.keys(apps ?? {}).sort();
    if (appKeys.length === 0) return {};

    const productByApp = new Map(appKeys.map((appKey) => [appKey, productOfAppId(appKey) ?? appKey]));
    const products = [...new Set(productByApp.values())];
    const placeholders = (list) => list.map(() => '?').join(', ');

    const [releaseRows, branchRows, deploymentRows, allOrgDomains] = await Promise.all([
        query(
            `SELECT \`AppKey\`, \`Version\` FROM \`AppRelease\`
              WHERE \`AppKey\` IN (${placeholders(products)})`,
            products,
            CAST,
        ),
        // Zweige des **Produkts**, mit `Backends`: derselbe Aufsatz, den
        // `resolveRelease` mischt. Die Oberfläche zeigt daraus `title`/`icon`,
        // nicht die Infrastruktur-Hosts (die stehen in `defaults`/`environment`
        // und gehen **nicht** an den Kunden-Admin).
        query(
            `SELECT \`AppKey\`, \`Branch\`, \`Backends\` FROM \`AppBackendBranch\`
              WHERE \`AppKey\` IN (${placeholders(products)}) ORDER BY \`Branch\``,
            products,
            CAST,
        ),
        query(
            `SELECT \`AppKey\`, \`Version\`, \`Branch\` FROM \`OrgAppDeployment\`
              WHERE \`OrgUID\` = ? AND \`AppKey\` IN (${placeholders(appKeys)})`,
            [toUidBin(orgId), ...appKeys],
            CAST,
        ),
        // Die Domains der Organisation — nur für die Auswahl der Basis. Ein Fehler
        // hier darf den Katalog **nicht** mitnehmen: die Tabelle bleibt auch ohne
        // die Auswahl brauchbar, und die Oberfläche bietet die bereits benutzte
        // Basis ohnehin an, weil sie sie aus dem gespeicherten Host liest.
        getAllOrgDomains().catch((e) => {
            errorLoggerRead(e);
            return {};
        }),
    ]);

    /** Zweig-Aufsatz je `produkt|zweig`. */
    const backendsByProductBranch = new Map();
    /** Wählbare Zweig-Namen je Produkt. */
    const branchesByProduct = new Map();
    for (const row of branchRows) {
        backendsByProductBranch.set(`${row.AppKey}|${row.Branch}`, row.Backends ?? {});
        const list = branchesByProduct.get(row.AppKey) ?? [];
        list.push(row.Branch);
        branchesByProduct.set(row.AppKey, list);
    }

    const deploymentByApp = new Map(deploymentRows.map((row) => [row.AppKey, row]));

    // Die Domains der Organisation, getrennt nach Achse (siehe
    // `orgDomainChoices`): die externe ist die Wurzel, die interne das erste
    // Label. Ein Fehler hier darf den Katalog **nicht** mitnehmen: die Tabelle
    // bleibt auch ohne die Auswahl brauchbar, und die Oberfläche bietet die
    // bereits benutzte Domain ohnehin an, weil sie sie aus dem gespeicherten Host
    // liest.
    const domains = orgDomainChoices(allOrgDomains, orgId);

    /** @type {Record<string, object>} */
    const result = {};
    for (const appKey of appKeys) {
        const product = productByApp.get(appKey);
        const defaults = catalog?.[product] ?? {};
        const owned = apps?.[appKey] ?? null;
        const deployment = deploymentByApp.get(appKey) ?? null;

        // Der Zweig ist das Umgebungs-Suffix der App-ID und **nicht** frei
        // wählbar — sonst driften Name und Zweig auseinander (`member.test`
        // würde gegen den `app`-Backend laufen). `member.test` → `test`.
        const environment = splitAppId(appKey)?.environment ?? null;
        const branch = deployment?.Branch ?? environment ?? null;

        const effective = { ...defaults, ...(backendsByProductBranch.get(`${product}|${branch}`) ?? {}) };
        const icon = resolveIcon(owned, effective, appKey);

        result[appKey] = {
            appKey,
            product,
            inCatalog: catalog?.[product] !== undefined,
            owned: owned !== null,
            defaults,
            environment: effective,
            // Die Überschreibung des Admins gewinnt **je Feld** — läge sie als
            // ganzer Block vor, würde ein gesetzter `title` die `roles` des
            // Vertrags wieder verdecken.
            title: owned?.title ?? effective.title ?? null,
            icon,
            // Abgeleitet, mitfallen auf das aufgelöste Icon — das schließt den
            // Vendor-Default schon ein.
            favicon: owned?.favicon ?? icon,
            appleTouchIcon: owned?.appleTouchIcon ?? icon,
            roles: owned?.roles ?? effective.roles ?? [],
            domain: owned?.domain ?? null,
            domainPrefix: effective.domain ?? null,
            // Die Domains, unter denen die Organisation Apps betreiben darf —
            // getrennt nach Achse. Hier liegt das Zertifikat (extern) bzw. der
            // Namensraum (intern), also darf sie nur innerhalb dieser Werte einen
            // Host beanspruchen. Leer heißt: erst eine Domain im Reiter „Domain
            // Management" verifizieren — ohne sie gäbe es keinen Host, den jemand
            // bedient.
            domains,
            // Die Plattform-Basis — sie wird **nur** für einen internen Host
            // gebraucht (`<intern>.<appId>.<plattform>`). `appHostFor` liest sie
            // aus derselben Quelle (`APP_BASE`), damit die Vorschau nicht anders
            // rechnet als der Dienst.
            platformDomain: appBaseDomain(),
            deployment: deployment
                ? { version: deployment.Version ?? null, branch: deployment.Branch }
                : null,
            versions: [],
            // Alle Zweige des **Produkts**: der Zweig bleibt wählbar. Die
            // Vorbelegung ist das Umgebungs-Suffix der App-ID (`member.test` →
            // `test`), damit ohne Zutun Name und Zweig zusammenpassen.
            branches: branchesByProduct.get(product) ?? [],
        };
    }

    // Die Releases sind **produktweit** — je App-ID dieselbe Liste (ein Build
    // bedient `member.app`, `member.test`, …). Numerisch sortiert, damit `5.10.0`
    // vor `5.9.0` steht.
    const releasesByProduct = new Map();
    for (const row of releaseRows) {
        const list = releasesByProduct.get(row.AppKey) ?? [];
        list.push(row.Version);
        releasesByProduct.set(row.AppKey, list);
    }
    for (const appKey of appKeys) {
        const versions = releasesByProduct.get(productByApp.get(appKey)) ?? [];
        result[appKey].versions = [...versions].sort((a, b) => compareVersionNames(a, b));
    }

    return result;
}

/**
 * Das **Angebot** für den „Add app"-Dialog: je Produkt die Vorgaben aus dem
 * Vertrag und seine wählbaren Umgebungen (Zweige) — je Umgebung mit den
 * **Anzeige**-Feldern, die der Zweig überschreibt.
 *
 * Bewusst getrennt von {@link getOrgCatalog}: das ist der Katalog (was es
 * **gibt**), jenes der Bestand der Organisation (was sie **führt**). Vorher lag
 * beides in einer Antwort und derselbe Produktname stand zweimal darin.
 *
 * `environments[zweig]` ist das Produkt-`environment`, das der Zweig je
 * Schlüssel überschreibt — **reduziert auf die Anzeige-Felder**
 * (`title`, `description`, `icon`, `roles`, `domain`). Die Backend-Ziele
 * (`api`, `baseUrl`, …) verlassen diese Funktion nicht nach außen: der
 * Kunden-Admin wählt einen Umgebungsnamen, nicht einen Host.
 *
 * Zurück kommt `{ products, domains, platformDomain }` und **nicht** mehr die
 * Produkte direkt als Objekt: die Domains gehören zur Organisation, nicht zum
 * Produkt, und ein Produktname als Schlüssel (`member`) hätte sie überschrieben.
 * Sie kommen außerdem hierher und nicht aus `getOrgCatalog`, weil eine Organisation
 * mit **null** Apps diesen Katalog nie sieht — sie braucht die Auswahl aber genau
 * dann.
 *
 * @param {string} orgId - UID in `UUID-`-Form (41 Zeichen)
 * @returns {Promise<{products: Record<string, object>, domains: Array<{domain: string, type: string}>, platformDomain: string}>}
 */
export async function getAppOffers(orgId) {
    const [catalog, branchRows, allOrgDomains] = await Promise.all([
        getAppCatalog(),
        query('SELECT `AppKey`, `Branch`, `Backends` FROM `AppBackendBranch` ORDER BY `Branch`', [], CAST),
        // Nur für die Basis-Auswahl — ein Fehler hier darf das Angebot nicht
        // mitnehmen, sonst wäre die Seite leer, obwohl sie fast alles weiß.
        getAllOrgDomains().catch((e) => {
            errorLoggerRead(e);
            return {};
        }),
    ]);

    const displayFields = (/** @type {object} */ source) => {
        /** @type {Record<string, unknown>} */
        const display = {};
        for (const field of APP_ENV_META_FIELDS) {
            if (source?.[field] !== undefined) display[field] = source[field];
        }
        return display;
    };

    /** @type {Map<string, string[]>} */
    const branchesByProduct = new Map();
    /** @type {Map<string, Record<string, object>>} */
    const environmentsByProduct = new Map();

    for (const row of branchRows) {
        const names = branchesByProduct.get(row.AppKey) ?? [];
        names.push(row.Branch);
        branchesByProduct.set(row.AppKey, names);

        const defaults = catalog?.[row.AppKey] ?? {};
        const merged = { ...defaults, ...(row.Backends ?? {}) };
        const envs = environmentsByProduct.get(row.AppKey) ?? {};
        envs[row.Branch] = displayFields(merged);
        environmentsByProduct.set(row.AppKey, envs);
    }

    /** @type {Record<string, object>} */
    const products = {};
    for (const [product, defaults] of Object.entries(catalog ?? {})) {
        products[product] = {
            ...displayFields(defaults),
            branches: branchesByProduct.get(product) ?? [],
            environments: environmentsByProduct.get(product) ?? {},
        };
    }

    // Beide Achsen (siehe `orgDomainChoices`): eine interne Domain steht **vorne**
    // im Host, eine externe **hinten**. Sie kommen hierher und nicht aus
    // `getOrgCatalog`, weil eine Organisation mit **null** Apps diesen Katalog nie
    // sieht — sie braucht die Auswahl aber genau dann.
    const domains = orgDomainChoices(allOrgDomains, orgId);

    // Die Plattform-Basis — **nur** für einen internen Host gebraucht
    // (`<namensraum>.<appId>.<plattform>`). Sie kommt aus derselben Quelle wie in
    // `appHostFor`, damit die Vorschau nicht anders rechnet als der Dienst.
    return { products, domains, platformDomain: appBaseDomain() };
}

/**
 * Schreibt den Backend-Katalog einer App fort — der **Importpfad** des Vertrags.
 *
 * ## Idempotenz mit Referenzschutz
 *
 * Der naheliegende Weg („die Map kommt als Ganzes" → `DELETE` + `INSERT`) ist
 * hier falsch, und zwar gefährlicher als beim App-Speichern: ein Zweig, den eine
 * Organisation gewählt hat, würde beim nächsten Import verschwinden, wenn er im
 * YAML fehlt. Ein Tippfehler im Vertrag risse damit einer Organisation das
 * Backend weg — und weil `resolveRelease` die Backends **nur** aus dem Zweig
 * liest, fiele es **nicht** als Fehler auf, sondern als fehlende Adresse.
 *
 * Deshalb: bestehende Zweige werden über `(AppKey, Branch)` wiedergefunden und
 * aktualisiert; verschwundene werden nur entfernt, wenn sie **keine**
 * `OrgAppDeployment`-Zeile mehr nennt. Ein noch benutzter Zweig bleibt stehen
 * (und wird im Ergebnis als `kept` gemeldet) — der Import ist dann sauber, aber
 * der Vertrag ist es nicht, und das soll sichtbar sein.
 *
 * ## Verbote sind hier billiger als später
 *
 * Jeder Zweig wird gegen {@link validateEnvironmentObject} geprüft — dieselbe
 * Form wie das App-`environment`, denn beide werden gemischt und landen
 * ungefiltert in `window.env`. Eine Ablehnung an dieser Stelle ist die einzige
 * Gelegenheit, einen Secret-Schlüssel zu verhindern.
 *
 * @param {string} appKey
 * @param {Record<string, object>} branches - `{ test: {api, baseUrl}, prod: {...} }`
 * @returns {Promise<{appKey: string, upserted: string[], removed: string[], kept: string[]}>}
 */
export async function saveBackendBranches(appKey, branches) {
    if (!appKey) throw new Error('appKey is required');
    if (!branches || typeof branches !== 'object' || Array.isArray(branches)) {
        throw new Error('branches must be an object keyed by branch name');
    }

    /** @type {Array<[string, object]>} */
    const wanted = [];
    for (const [rawBranch, backends] of Object.entries(branches)) {
        const branch = normalizeBranch(rawBranch);
        if (!isValidBranch(branch)) {
            throw new Error(`Invalid branch name "${rawBranch}" (lowercase letters, digits and hyphens).`);
        }
        const invalid = validateEnvironmentObject(backends, `Branch "${branch}"`, { allowEmpty: true });
        if (invalid) throw new Error(`${branch}: ${invalid}`);
        wanted.push([branch, backends]);
    }

    const branchNames = wanted.map(([branch]) => branch);

    // Über die Closure statt über die Callback-Rückgabe: `transaction` liefert
    // keine Werte aus dem Callback, und das Ergebnis wird hier gebraucht. Zwei
    // Variablen im Funktionsumfang sind dafür ehrlicher als ein Feld auf der
    // Funktion (das zwei gleichzeitige Importe vermischen würde).
    /** @type {string[]} */
    let removed = [];
    /** @type {string[]} */
    let kept = [];

    await transaction(async (connection) => {
        // Was im Bestand steht und nicht mehr im Vertrag — Kandidaten fürs Löschen.
        // `connection.query` reicht das Ergebnis des `mariadb`-Treibers durch, und
        // der liefert die **Zeilenliste direkt** (kein `[rows, fields]` wie `mysql`).
        // Ein `const [existingRows] = …` wäre deshalb die erste Zeile — und bei
        // leerem Bestand `undefined`.
        const existingRows = await connection.query(
            'SELECT `Branch` FROM `AppBackendBranch` WHERE `AppKey` = ?',
            [appKey],
        );
        const gone = existingRows
            .map((row) => row.Branch)
            .filter((branch) => !branchNames.includes(branch));

        // Nur die **benutzten** unter ihnen bleiben. Eine Abfrage für alle statt
        // einer je Zweig: die Liste kommt aus dem Bestand, nicht aus dem Vertrag,
        // und ist damit klein.
        if (gone.length > 0) {
            const placeholders = gone.map(() => '?').join(', ');
            const usedRows = await connection.query(
                `SELECT DISTINCT d.\`Branch\` FROM \`OrgAppDeployment\` d
                  WHERE d.\`AppKey\` = ? AND d.\`Branch\` IN (${placeholders})`,
                [appKey, ...gone],
            );
            kept = usedRows.map((row) => row.Branch);
        }

        for (const [branch, backends] of wanted) {
            await connection.query(
                `INSERT INTO \`AppBackendBranch\` (\`AppKey\`, \`Branch\`, \`Backends\`)
                      VALUES (?, ?, ?)
                 ON DUPLICATE KEY UPDATE \`Backends\` = VALUES(\`Backends\`)`,
                [appKey, branch, JSON.stringify(backends)],
            );
        }

        removed = gone.filter((branch) => !kept.includes(branch));
        if (removed.length > 0) {
            const placeholders = removed.map(() => '?').join(', ');
            await connection.query(
                `DELETE FROM \`AppBackendBranch\` WHERE \`AppKey\` = ? AND \`Branch\` IN (${placeholders})`,
                [appKey, ...removed],
            );
        }
    });

    await invalidateOrgsWithDeployment(appKey);

    return { appKey, upserted: branchNames, removed, kept };
}

/**
 * Sagt allen Organisationen Bescheid, die für diese App eine Zuordnung haben.
 *
 * **Warum nicht ein globales Invalidieren:** der Subscriber im Broker liest die
 * Organisation aus der Nachricht und kehrt bei einer fehlenden **sofort** zurück
 * (`if (!orgId) return`) — eine Nachricht ohne `orgId` wäre ein stiller No-Op.
 * Ein Katalogwechsel betrifft aber ohnehin nur Organisationen, die einen Zweig
 * **gewählt** haben; alle anderen lesen den Katalog gar nicht. Die Menge ist
 * damit klein und exakt, und der bestehende Vertrag reicht aus.
 *
 * `OrgUID` wird über den `CAST` als `UUID-…` gelesen — dieselbe Form, unter der
 * der Broker die Organisation in seinem Index führt.
 *
 * @param {string} appKey
 */
const invalidateOrgsWithDeployment = async (appKey) => {
    try {
        const rows = await query(
            'SELECT DISTINCT `OrgUID` AS orgId FROM `OrgAppDeployment` WHERE `AppKey` = ?',
            [appKey],
            CAST,
        );
        for (const row of rows) {
            if (row.orgId) await invalidateRegistryCache(row.orgId);
        }
    } catch (e) {
        errorLoggerUpdate(e);
    }
}

// ── Die Zuordnung einer Organisation: die Wahl ────────────────────────────────

/** @typedef {{appKey: string, version: string|null, branch: string, updatedAt: string|null}} OrgDeployment */

const mapOrgDeployment = (row) => ({
    appKey: row.AppKey,
    version: row.Version ?? null,
    branch: row.Branch,
    updatedAt: row.UpdatedAt ?? null,
});

/**
 * Die Zuordnung einer Organisation: welchen Release-**Namen** (oder „dem
 * Default `stable` folgen") und welcher Backend-Zweig.
 * @param {string} appKey
 * @param {string} orgId
 * @returns {Promise<OrgDeployment|null>}
 */
export async function getOrgDeployment(appKey, orgId) {
    const [row] = await query(
        `SELECT \`AppKey\`, \`Version\`, \`Branch\`, \`UpdatedAt\` FROM \`OrgAppDeployment\`
          WHERE \`AppKey\` = ? AND \`OrgUID\` = ?`,
        [appKey, toUidBin(orgId)],
        CAST,
    );
    return row ? mapOrgDeployment(row) : null;
}

/**
 * Setzt die Zuordnung einer Organisation — der **Schreibweg des Org-Admin**.
 *
 * Das ist die einzige Schreiboperation, die einem Kunden-Admin offensteht
 * (`checkRoot`, §2.6.1). Katalog (`AppBackendBranch`) und Releases
 * (`AppRelease`) bleiben CommTool — nicht aus Anzeigegründen, sondern weil
 * `admin.app` alle Kunden bedient und ein `db-admin` sonst über dieselbe API
 * alle Mandanten erreichen könnte.
 *
 * ## Beide Werte werden gegen den Bestand geprüft, nicht geglaubt
 *
 * `branch` muss im **Katalog** stehen: sonst wäre die Wahl eine Adresse ohne
 * Ziel, und `resolveRelease` lieferte still keine Backends — der Admin hätte
 * etwas gewählt und bekäme nichts. `version` (wenn gesetzt) muss ein
 * **Release** sein, sonst zeigt der Name ins Leere.
 *
 * `version: null` ist ausdrücklich gültig und heißt „folgt `stable`".
 *
 * @param {{appKey: string, orgId: string, version?: string|null, branch: string}} deployment
 * @returns {Promise<OrgDeployment>}
 */
export async function saveOrgDeployment({ appKey, orgId, version = null, branch }) {
    if (!appKey) throw new Error('appKey is required');
    if (!orgId) throw new Error('orgId is required');

    // Zwei Schlüsselräume: die Zuordnung liegt je **App-ID** (`member.test`), der
    // Katalog je **Produkt** (`member`). Der Zweig bleibt die Wahl des Admins
    // („branch-spezifisch eintragen"), geprüft gegen die Zweige des Produkts;
    // **ohne** Wahl ist das Umgebungs-Suffix der App-ID der Default (`member.test`
    // → `test`), damit die Zuordnung zur App-ID passt, wenn niemand etwas setzt.
    const product = productOfAppId(appKey) ?? appKey;
    const environment = splitAppId(appKey)?.environment ?? null;

    const wantedBranch = normalizeBranch(branch) || environment || '';
    if (!isValidBranch(wantedBranch)) {
        throw new Error(`Invalid branch name "${branch}" (lowercase letters, digits and hyphens).`);
    }

    const known = await listBackendBranches(product);
    if (!known.some((entry) => entry.branch === wantedBranch)) {
        throw new Error(
            `Unknown backend branch "${wantedBranch}" for ${product}. Known: ${known.map((e) => e.branch).join(', ') || 'none'}.`,
        );
    }

    if (version != null) {
        // Kanäle (`latest`, `stable`) sind **immer** gültig — sie werden zur
        // Laufzeit auf die neueste Version aufgelöst und brauchen keine Zeile.
        // Eine konkrete Version muss dagegen existieren, sonst zeigt der Name ins
        // Leere. Geprüft wird gegen das **Produkt** (`member`), weil dort die
        // Release-Zeilen liegen — die App-ID (`member.test`) trägt nur die Umgebung.
        if (!CHANNEL_NAMES.has(version)) {
            const [row] = await query(
                'SELECT `Version` FROM `AppRelease` WHERE `AppKey` = ? AND `Version` = ?',
                [product, version],
                CAST,
            );
            if (!row) throw new Error(`Unknown release ${product}@${version}`);
        }
    }

    await query(
        `INSERT INTO \`OrgAppDeployment\` (\`AppKey\`, \`OrgUID\`, \`Version\`, \`Branch\`)
              VALUES (?, ?, ?, ?)
         ON DUPLICATE KEY UPDATE \`Version\` = VALUES(\`Version\`), \`Branch\` = VALUES(\`Branch\`)`,
        [appKey, toUidBin(orgId), version, wantedBranch],
    );

    await invalidateRegistryCache(orgId);
    return { appKey, version: version ?? null, branch: wantedBranch, updatedAt: null };
}

/**
 * Nimmt eine Organisation aus der Zuordnung. Ab hier folgt sie wieder `stable`
 * und hat keinen Backend-Zweig mehr — der Rückweg ist ein `DELETE`, kein
 * Deployment.
 * @param {string} appKey
 * @param {string} orgId
 * @returns {Promise<boolean>} true, wenn wirklich eine Zeile entfernt wurde
 */
export async function clearOrgDeployment(appKey, orgId) {
    const result = await query(
        'DELETE FROM `OrgAppDeployment` WHERE `AppKey` = ? AND `OrgUID` = ?',
        [appKey, toUidBin(orgId)],
    );
    await invalidateRegistryCache(orgId);
    return (result?.affectedRows ?? 0) > 0;
}

/**
 * Der `env.js`-Aufsatz eines Releases — die Backends des **Zweigs**, den die
 * Zuordnung gewählt hat, **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 Backend-Zweig
 * gewählt": 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 „keine Zuordnung" 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, branch: string|null}|null>}
 */
export async function resolveReleaseEnv(appKey, orgId) {
    const release = await resolveRelease(appKey, orgId);
    if (!release) return null;
    return { env: release.env, version: release.version, branch: release.branch };
}

// ── 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.
 *
 * `hosts` sind die **punktgenau** betroffenen App-Hosts. Der Broker braucht sie
 * zusätzlich zur Organisation: einen zuvor negativ gecachten (unbekannten) Host
 * erreicht `invalidateOrg` nicht, weil ein Negativ-Eintrag ohne Organisation
 * abgelegt wird und damit in keinem Org-Index steht.
 *
 * @param {string} orgId
 * @param {string[]} [hosts] - betroffene Hosts (werden dedupliziert/normalisiert)
 */
async function invalidateRegistryCache(orgId, hosts = []) {
    try {
        /** @type {{orgId: string, hosts?: string[]}} */
        const data = { orgId };
        const list = [...new Set((hosts ?? []).map((host) => normalizeDomainValue(host)).filter(Boolean))];
        if (list.length) data.hosts = list;
        await publishEvent('registry:changed', { organization: orgId, data });
    } catch (e) {
        errorLoggerUpdate(e);
    }
}