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