/**
* App-Registry Service — Persistenz des Mandanten-Registry in `ObjectBase`/`Links`
*
* Ersetzt den Vault-basierten `service.js` schrittweise. Die **API-Form bleibt
* identisch** (siehe `service.js`): Apps als `{ [appId]: AppEntry }`, Domains als
* `{ [domain]: type }`. `admin` und `portal` dürfen keinen Unterschied merken.
*
* ## Dual-Read / Dual-Write — der Rückweg ist ein Env-Wert, kein Deploy
*
* `REGISTRY_READ_MODE` steuert, wo gelesen und geschrieben wird:
*
* | Wert | Lesen | Schreiben |
* |---|---|---|
* | `vault` | Vault | Vault |
* | `dual` | DB, bei leerem Ergebnis Vault | **beide** |
* | `db` (Default) | DB | DB |
*
* Der Modus wird **pro Aufruf** aus der Umgebung gelesen, nicht beim Import —
* sonst wäre er in Tests und bei einem Neustart-Wechsel nicht umstellbar.
*
* ## Warum Upsert statt „alles löschen und neu einfügen"
*
* Der naheliegende Weg für „die Map kommt als Ganzes" ist `DELETE` + `INSERT`.
* Er ist hier **falsch**: Domains und Assets hängen über `Links` an der UID der
* App. Neu erzeugte UIDs bei jedem Speichern würden bei jedem Klick im
* `admin`-Frontend sämtliche Domain-Zuordnungen der Organisation zerreißen — und
* zwar still, weil die App danach existiert und nur der Host nicht mehr auflöst.
*
* Stattdessen: bestehende Objekte werden über ihren fachlichen Schlüssel
* (`Data.appId`, `Data.domain`) wiedergefunden, **behalten ihre UID** und werden
* aktualisiert; nur tatsächlich verschwundene Einträge werden gelöscht.
*
* ## Löschen in system-versioned Tabellen
*
* `DELETE` ist hier korrekt und **nicht** „ValidUntil schließen": MariaDB schließt
* bei `DELETE` auf einer system-versioned Tabelle die Periode selbst und behält
* die Historie (`FOR SYSTEM_TIME ALL` liefert die Zeile weiterhin). `ValidUntil`
* ist `GENERATED ALWAYS AS ROW END` und von Hand gar nicht beschreibbar —
* gemessen gegen MariaDB 11.7.
*/
import { query, transaction, HEX2uuid } from '@commtool/sql-query';
import { errorLoggerRead, errorLoggerUpdate } from '../../utils/requestLogger.js';
import { myMinioClient, publicMinioClient, PUBLIC_BUCKET, DATA_BUCKET, publicObjectUrl } from '../../utils/s3Client.js';
import { publishEvent } from '../../utils/events.js';
import sharp from 'sharp';
import {
OBJ_TYPE_APP, OBJ_TYPE_APP_DOMAIN, OBJ_TYPE_APP_ASSET,
LINK_TYPE_APP_DOMAIN, LINK_TYPE_APP_ASSET,
ASSET_TYPE_ICON,
DOMAIN_TYPE_INTERNAL, DOMAIN_TYPE_EXTERNAL,
APP_BASE_DOMAIN_DEFAULT,
appIdFromData, appTitles, appHostFor, normalizeUid,
normalizeDomainEntry, normalizeDomainMap, normalizeDomainValue, toLegacyDomainValue,
validateDomainEntry, findDomainConflict,
} from './registryTypes.js';
import * as vaultService from './service.js';
/** UID-Spalten als `UUID-…`, `Data` als Objekt (§ registryTypes: Cast-Regeln) */
const CAST = { cast: ['UUID', 'json'] };
/**
* Basis-Domain für interne Hosts, zur **Laufzeit** gelesen.
*
* Nicht als Modulkonstante: in den Dev-Umgebungen steht `APP_BASE_DOMAIN` auf
* `dev.commtool.org`, und ob die Variable beim Import schon gesetzt ist, hängt
* davon ab, wer die Datei zuerst lädt. Ein einmal eingefrorener Wert würde dort
* Hosts für die Produktions-Basis erzeugen — die Auflösung fände nichts.
* @returns {string}
*/
const appBaseDomain = () => process.env.APP_BASE_DOMAIN || APP_BASE_DOMAIN_DEFAULT;
/**
* Wie `normalizeDomainMap`, nur eine Ebene tiefer: `{ orgId: { domain: entry } }`.
* Der Konflikt-Check vergleicht über **alle** Organisationen und muss dafür
* dieselbe Form sehen wie eine einzelne Organisation.
* @param {unknown} map
* @returns {Record<string, Record<string, {type: string, status: string}>>}
*/
const normalizeDomainMapByOrg = (map) => {
const result = {};
for (const [orgId, domains] of Object.entries(map ?? {})) {
result[orgId] = normalizeDomainMap(domains);
}
return result;
};
/** @returns {'vault'|'dual'|'db'} */
const readMode = () => {
const mode = (process.env.REGISTRY_READ_MODE ?? 'db').toLowerCase();
return mode === 'vault' || mode === 'dual' ? mode : 'db';
};
/** Liest aus der DB? (`dual` und `db` lesen beide zuerst die DB) */
const readsDb = () => readMode() !== 'vault';
/** Schreibt zusätzlich nach Vault? (`vault` schreibt nur dorthin) */
const writesVault = () => readMode() === 'vault' || readMode() === 'dual';
/**
* Erzeugt eine UID **in der Datenbank** (`UIDV1()` = echte, sortierbare v1).
* Bewusst nicht in JavaScript: `randomUUID()` wäre eine v4, und ihr Hex-String
* passt in keines der SQL-UUID-Formate (§ registryTypes).
* @returns {Promise<Buffer>} 16 Byte
*/
const newUid = async () => {
const [row] = await query('SELECT UIDV1() AS UID', []);
return row.UID;
};
// ── Apps ──────────────────────────────────────────────────────────────────────
/**
* Alle Apps einer Organisation.
* @param {string} orgId - UID in `UUID-`-Form
* @returns {Promise<Record<string, object>>} `{ [appId]: AppEntry }`
*/
export async function getOrgApps(orgId) {
if (!readsDb()) return vaultService.getApps(orgId);
try {
const rows = await query(
`SELECT \`UID\`, \`Title\`, \`Data\`
FROM \`ObjectBase\`
WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)`,
[OBJ_TYPE_APP, orgId],
CAST,
);
const result = {};
for (const row of rows) {
const appId = appIdFromData(row.Data);
if (!appId) continue; // Objekt ohne appId ist kein App-Eintrag
result[appId] = { ...row.Data };
}
// Dual-Read: die DB ist erst dann die Wahrheit, wenn sie den Bestand
// kennt. Ein leeres Ergebnis kann „noch nicht migriert" heißen — dann
// liefert Vault die Antwort, statt die Organisation leer zu zeigen.
if (rows.length === 0 && readMode() === 'dual') {
return vaultService.getApps(orgId);
}
return result;
} catch (e) {
errorLoggerRead(e);
if (readMode() === 'dual') return vaultService.getApps(orgId);
throw e;
}
}
/**
* Eine einzelne App einer Organisation.
* @param {string} orgId
* @param {string} appId
* @returns {Promise<object|null>}
*/
export async function getOrgApp(orgId, appId) {
if (!readsDb()) {
const apps = await vaultService.getApps(orgId);
return apps[appId] ?? null;
}
const rows = await query(
`SELECT \`Data\`
FROM \`ObjectBase\`
WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)
AND JSON_UNQUOTE(JSON_EXTRACT(\`Data\`, '$.appId')) = ?
LIMIT 1`,
[OBJ_TYPE_APP, orgId, appId],
CAST,
);
return rows[0]?.Data ?? null;
}
/**
* Ersetzt den App-Bestand einer Organisation (die Map kommt als Ganzes).
*
* Bestehende Apps behalten ihre UID — siehe Dateikopf.
*
* Der Host einer App steht als Feld `domain` **im App-Objekt selbst**; es gibt
* bewusst kein Domain-Objekt und keinen Link dafür. Eine frühere Fassung legte
* für jeden App-Host automatisch ein `appDomain`-Objekt an und verlinkte es —
* dadurch enthielt die Domain-Liste einer Organisation anschließend jeden
* App-Host, und App-Hosts (Routing) und Mandanten-Domains (CORS, Cookie-Umfang,
* Basis-Domain) waren nicht mehr unterscheidbar. `resolveDomain()` liest das
* Feld deshalb direkt; bei 4–14 Apps je Organisation braucht es dafür keinen
* Index.
*
* @param {string} orgId
* @param {Record<string, object>} apps
*/
export async function saveOrgApps(orgId, apps) {
if (writesVault()) await vaultService.saveApps(orgId, apps);
if (!readsDb()) return;
const existing = await query(
`SELECT \`UID\`, \`Data\` FROM \`ObjectBase\`
WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)`,
[OBJ_TYPE_APP, orgId],
CAST,
);
const uidByAppId = new Map();
for (const row of existing) {
const appId = appIdFromData(row.Data);
if (appId) uidByAppId.set(appId, row.UID);
}
for (const [appId, config] of Object.entries(apps ?? {})) {
const data = { ...(config ?? {}), appId };
const { title, display, sortName } = appTitles(appId, data.title);
const uid = uidByAppId.get(appId);
if (uid) {
await query(
`UPDATE \`ObjectBase\`
SET \`Title\` = ?, \`Display\` = ?, \`SortName\` = ?, \`Data\` = ?
WHERE \`UID\` = U_UUID2BIN(?)`,
[title, display, sortName, JSON.stringify(data), uid],
);
uidByAppId.delete(appId); // verbraucht
} else {
await query(
`INSERT INTO \`ObjectBase\`
(\`UID\`, \`Type\`, \`UIDBelongsTo\`, \`Title\`, \`Display\`, \`SortName\`, \`dindex\`, \`Data\`)
VALUES (?, ?, U_UUID2BIN(?), ?, ?, ?, 0, ?)`,
[await newUid(), OBJ_TYPE_APP, orgId, title, display, sortName, JSON.stringify(data)],
);
}
}
// Was übrig ist, wurde im Frontend entfernt — inklusive seiner Links.
for (const [goneAppId, uid] of uidByAppId) {
await deleteAppByUid(uid, goneAppId);
}
await invalidateRegistryCache(orgId);
}
/**
* Löscht eine App samt ihrer Links.
*
* `appDomain` wird beim Löschen mitgenommen, obwohl der Bestand seit der
* Umstellung keine solchen Links mehr anlegt: ein früherer Import hat sie
* erzeugt, und ein Löschpfad, der sie stehen ließe, hinterließe Karteileichen.
*
* @param {string} uid
* @param {string} [appId] - nur für den Log
*/
async function deleteAppByUid(uid, appId) {
await query(
`DELETE FROM \`Links\` WHERE \`UID\` = U_UUID2BIN(?) AND \`Type\` IN (?, ?)`,
[uid, LINK_TYPE_APP_DOMAIN, LINK_TYPE_APP_ASSET],
);
await query(`DELETE FROM \`ObjectBase\` WHERE \`UID\` = U_UUID2BIN(?) AND \`Type\` = ?`, [uid, OBJ_TYPE_APP]);
if (appId) void appId;
}
// ── Domains ───────────────────────────────────────────────────────────────────
/**
* Alle Domains einer Organisation.
*
* Rückgabe ist `{ [domain]: { type, status } }` — **eine** Form für Aufrufer
* und Oberfläche. Der Altbestand (in Vault wie in der Datenbank) trägt statt
* dessen eine Zeichenkette wie `"verified"`; übersetzt wird hier einmal, nicht
* bei jedem Konsumenten.
*
* @param {string} orgId
* @returns {Promise<Record<string, {type: string, status: string}>>}
*/
export async function getOrgDomains(orgId) {
if (!readsDb()) return normalizeDomainMap(await vaultService.getDomains(orgId));
try {
const rows = await query(
`SELECT \`Data\` FROM \`ObjectBase\`
WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)`,
[OBJ_TYPE_APP_DOMAIN, orgId],
CAST,
);
const result = {};
for (const row of rows) {
const data = row.Data ?? {};
const domain = normalizeDomainValue(data.domain);
const entry = normalizeDomainEntry(data);
if (domain && entry) result[domain] = entry;
}
if (rows.length === 0 && readMode() === 'dual') return normalizeDomainMap(await vaultService.getDomains(orgId));
return result;
} catch (e) {
errorLoggerRead(e);
if (readMode() === 'dual') return normalizeDomainMap(await vaultService.getDomains(orgId));
throw e;
}
}
/**
* Ersetzt den Domain-Bestand einer Organisation.
*
* Angenommen wird `{ [domain]: { type, status } }` **und** die Altform
* (`"internal"`/`"external"`/`"verified"`) — eine Fassung, die nur die neue Form
* verstünde, würde den vorhandenen Bestand beim ersten Speichern leeren.
*
* Bestehende Domains behalten ihre UID: an ihnen hängen die Basis-Domain-
* Erkennung (`shared-auth` prüft Kunden-Domains gegen die verifizierten) und der
* Cookie-Umfang. Löschen und Neuanlegen würde diesen Anker bei jedem Speichern
* austauschen.
*
* @param {string} orgId
* @param {Record<string, unknown>} domains
*/
export async function saveOrgDomains(orgId, domains) {
// Vault bekommt weiterhin die **Zeichenkette**: `shared-auth` und die
// übrigen Konsumenten lesen dort `state === 'internal'` bzw. `'verified'`.
// Ein Objekt würde sie brechen, solange `REGISTRY_READ_MODE` nicht `db` ist.
if (writesVault()) {
const legacy = {};
for (const [domain, value] of Object.entries(domains ?? {})) {
const entry = normalizeDomainEntry(value);
if (entry) legacy[domain] = toLegacyDomainValue(entry);
}
await vaultService.saveDomains(orgId, legacy);
}
if (!readsDb()) return;
const existing = await query(
`SELECT \`UID\`, \`Data\` FROM \`ObjectBase\`
WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)`,
[OBJ_TYPE_APP_DOMAIN, orgId],
CAST,
);
const uidByDomain = new Map();
for (const row of existing) {
const domain = normalizeDomainValue(row.Data?.domain);
if (domain) uidByDomain.set(domain, row.UID);
}
for (const [rawDomain, value] of Object.entries(domains ?? {})) {
const domain = normalizeDomainValue(rawDomain);
const entry = normalizeDomainEntry(value);
// Nicht deutbare Einträge werden nicht geschrieben: ein stiller Default
// würde eine Aussage anlegen, die niemand getroffen hat. Die Prüfung
// liegt im Controller (`validateDomains`), hier wird nur übersetzt.
if (!domain || !entry) continue;
const data = { domain, ...entry };
const uid = uidByDomain.get(domain);
if (uid) {
await query(
`UPDATE \`ObjectBase\` SET \`Title\` = ?, \`Display\` = ?, \`SortName\` = ?, \`Data\` = ?
WHERE \`UID\` = U_UUID2BIN(?)`,
[domain, domain, domain, JSON.stringify(data), uid],
);
uidByDomain.delete(domain);
} else {
await query(
`INSERT INTO \`ObjectBase\`
(\`UID\`, \`Type\`, \`UIDBelongsTo\`, \`Title\`, \`Display\`, \`SortName\`, \`dindex\`, \`Data\`)
VALUES (?, ?, U_UUID2BIN(?), ?, ?, ?, 0, ?)`,
[await newUid(), OBJ_TYPE_APP_DOMAIN, orgId, domain, domain, domain, JSON.stringify(data)],
);
}
}
for (const [, uid] of uidByDomain) {
// Karteileichen aus dem früheren Import: dort zeigte ein Link von der App
// auf die Domain. Der aktuelle Bestand legt keine solchen Links mehr an.
await query(`DELETE FROM \`Links\` WHERE \`UIDTarget\` = U_UUID2BIN(?) AND \`Type\` = ?`, [uid, LINK_TYPE_APP_DOMAIN]);
await query(`DELETE FROM \`ObjectBase\` WHERE \`UID\` = U_UUID2BIN(?) AND \`Type\` = ?`, [uid, OBJ_TYPE_APP_DOMAIN]);
}
await invalidateRegistryCache(orgId);
}
/**
* Alle Domains aller Organisationen (für den Konflikt-Check).
* @returns {Promise<Record<string, Record<string, {type: string, status: string}>>>}
*/
export async function getAllOrgDomains() {
if (!readsDb()) return normalizeDomainMapByOrg(await vaultService.getAllOrgDomains());
try {
const rows = await query(
`SELECT \`UIDBelongsTo\` AS orgId, \`Data\` FROM \`ObjectBase\` WHERE \`Type\` = ?`,
[OBJ_TYPE_APP_DOMAIN],
CAST,
);
/** @type {Record<string, Record<string, {type: string, status: string}>>} */
const result = {};
for (const row of rows) {
const domain = normalizeDomainValue(row.Data?.domain);
const entry = normalizeDomainEntry(row.Data);
if (!row.orgId || !domain || !entry) continue;
(result[row.orgId] ??= {})[domain] = entry;
}
return result;
} catch (e) {
errorLoggerRead(e);
if (readMode() === 'dual') return normalizeDomainMapByOrg(await vaultService.getAllOrgDomains());
throw e;
}
}
/**
* Prüft einen vorgeschlagenen Domain-Bestand.
*
* Gehört hierher und nicht in den Vault-Adapter: geprüft wird gegen **den
* Bestand, in den geschrieben wird**. Als die Prüfung noch aus Vault las, hat
* sie den eigenen, inzwischen in der Datenbank liegenden Bestand nicht gesehen —
* eine Domain ließ sich damit zweimal vergeben.
*
* @param {Record<string, unknown>} newDomains
* @param {string} currentOrgId
* @returns {Promise<{domain: string, error: string}[]>} leer = in Ordnung
*/
export async function validateDomains(newDomains, currentOrgId) {
const errors = [];
// Erst die Einzelprüfungen: sie kommen ohne Datenbankzugriff aus. Sind sie
// fehlerhaft, wäre der Konflikt-Check nur Rauschen über einem kaputten Wert.
for (const [rawDomain, value] of Object.entries(newDomains ?? {})) {
const domain = normalizeDomainValue(rawDomain);
if (!domain) {
errors.push({ domain: String(rawDomain), error: 'Not a valid domain name (e.g. myclub.com).' });
continue;
}
const err = validateDomainEntry(domain, value);
if (err) errors.push({ domain, error: err });
}
if (errors.length > 0) return errors;
const allOrgDomains = await getAllOrgDomains();
for (const [rawDomain, value] of Object.entries(newDomains ?? {})) {
const domain = normalizeDomainValue(rawDomain);
if (findDomainConflict(domain, value, allOrgDomains, currentOrgId))
errors.push({ domain, error: 'Domain is already claimed by another organisation.' });
}
return errors;
}
// ── Runtime-Auflösung (Broker / shared-auth) ─────────────────────────────────
/**
* Host → Organisation + App.
*
* Gesucht wird über das Feld `domain` der App, nicht über ein Domain-Objekt:
* eine frühere Fassung legte für jeden App-Host ein eigenes `appDomain`-Objekt
* an und verlinkte es. Das machte den Domain-Bestand einer Organisation
* ununterscheidbar von ihrer Routing-Tabelle — dieselbe Liste trug plötzlich
* Mandanten-Domains (Cookie-Umfang, CORS) und App-Hosts. Der Host steht dort,
* wo der Admin ihn einträgt: im App-Eintrag.
*
* Der Vergleich läuft über {@link appHostFor}, also über **dieselbe** Regel,
* nach der der Host aufgebaut wird. Ein reiner String-Vergleich mit dem
* `domain`-Feld wäre falsch: bei einem internen Präfix (`sjm`) steht dort nicht
* der Host, sondern nur `sjm`.
*
* Kosten: ein Durchlauf über die App-Objekte (gemessen 29 in dieser Datenbank).
* Ein Index wäre erst bei einem Vielfachen davon nötig.
*
* @param {string} host
* @returns {Promise<{orgId: string, appId: string|null, appUid: string}|null>}
*/
export async function resolveDomain(host) {
const wanted = normalizeDomainValue(host);
if (!wanted) return null;
try {
const rows = await query(
`SELECT \`UID\`, \`UIDBelongsTo\` AS orgId, \`Data\` FROM \`ObjectBase\` WHERE \`Type\` = ?`,
[OBJ_TYPE_APP],
CAST,
);
for (const row of rows) {
const appId = appIdFromData(row.Data);
if (appId && appHostFor(appId, row.Data?.domain, appBaseDomain()) === wanted) {
return { orgId: row.orgId, appId, appUid: row.UID };
}
}
return null;
} catch (e) {
errorLoggerRead(e);
return null;
}
}
/**
* Die Routing-Tabelle: für jede App der Host, unter dem sie läuft.
*
* Form wie bei `shared-auth` (`loadOrganizationDomains`): `domain` ist der
* **fertige Host**, nicht der Rohtwert aus dem App-Eintrag. Genau daran hängt
* die Erkennung „welche Organisation bedient dieser Host" — würde hier `sjm`
* statt `sjm.admin.app.commtool.org` stehen, fände die Erkennung nichts.
*
* @returns {Promise<Array<{domain: string, org: string, orgId: string, app: string, type: string}>>}
*/
export async function getAllDomainMappings() {
try {
const rows = await query(
`SELECT \`UIDBelongsTo\` AS orgId, \`Data\` FROM \`ObjectBase\` WHERE \`Type\` = ?`,
[OBJ_TYPE_APP],
CAST,
);
const mappings = [];
for (const row of rows) {
const appId = appIdFromData(row.Data);
const rawDomain = normalizeDomainValue(row.Data?.domain);
const host = appHostFor(appId, rawDomain, appBaseDomain());
if (!appId || !host) continue;
mappings.push({
domain: host,
org: row.orgId,
orgId: row.orgId,
app: appId,
// Derselbe Punkt, an dem `appHostFor` die beiden Formen trennt —
// hier als Aussage statt als Verzweigung.
type: rawDomain.includes('.') ? DOMAIN_TYPE_EXTERNAL : DOMAIN_TYPE_INTERNAL,
});
}
return mappings;
} catch (e) {
errorLoggerRead(e);
return [];
}
}
/**
* CORS-Origins: **alles außer** den internen Domains.
*
* Die Negation ist Absicht. Ein `=== 'external'` stand hier einmal, und weil im
* Bestand `verified` lag (`{"kpe.de":"verified"}`), traf es **nichts** — die
* Liste war leer, obwohl vier Kunden-Domains existierten. Mit getrenntem
* `type`/`status` wäre `=== 'external'` zwar wieder richtig, aber die Negation
* bleibt die robustere Aussage: eine neue Domänenart soll nicht stillschweigend
* aus CORS herausfallen.
*
* Die App-Hosts stehen bewusst **nicht** darin: sie liegen unterhalb einer
* dieser Domains (`admin.app.kpe.de` ⊂ `kpe.de`), und `createCorsOriginChecker`
* prüft mit Punktgrenze auf die Basis-Domain. Sie einzeln aufzuzählen wäre
* doppelt gemoppelt — und bei jeder neuen App zu ändern.
*
* @returns {Promise<string[]>}
*/
export async function getAllCorsOrigins() {
try {
const rows = await query(
`SELECT \`Title\` AS domain, \`Data\` FROM \`ObjectBase\` WHERE \`Type\` = ?`,
[OBJ_TYPE_APP_DOMAIN],
CAST,
);
const origins = new Set();
for (const row of rows) {
const domain = normalizeDomainValue(row.domain);
const entry = normalizeDomainEntry(row.Data);
if (!domain || !entry) continue;
if (entry.type === DOMAIN_TYPE_INTERNAL) continue;
origins.add(`https://${domain}`);
}
return [...origins];
} catch (e) {
errorLoggerRead(e);
return [];
}
}
/**
* Der App-Katalog: die Vorlage, aus der eine neue Organisation ihre Apps
* bekommt.
*
* Bewusst ein Durchgriff auf Vault und **keine** DB-Variante. Der Katalog hängt
* an keinem Mandanten (`orgas/data/default/apps`), und der Import lässt den
* `default`-Ordner ausdrücklich aus, damit kein Objekt ohne `UIDBelongsTo`
* entsteht. Ein `readsDb()`-Zweig würde hier nur so tun, als gäbe es Zeilen.
*
* @returns {Promise<Record<string, object>>}
*/
export async function getAppCatalog() {
return await vaultService.getAppCatalog();
}
/**
* PWA-Manifest einer App (Branding aus dem Registry statt Vault-PWA-Cache).
* @param {string} orgId
* @param {string} appId
* @returns {Promise<object|null>}
*/
export async function getAppManifest(orgId, appId) {
const app = await getOrgApp(orgId, appId);
if (!app) return null;
const name = app.title || appId;
const manifest = {
name,
short_name: name,
start_url: '/',
display: 'standalone',
icons: /** @type {object[]} */ ([]),
};
if (app.icon) {
manifest.icons.push({ src: app.icon, sizes: '512x512', type: 'image/png' });
}
const appRow = await query(
`SELECT \`UID\` FROM \`ObjectBase\`
WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)
AND JSON_UNQUOTE(JSON_EXTRACT(\`Data\`, '$.appId')) = ? LIMIT 1`,
[OBJ_TYPE_APP, orgId, appId],
CAST,
);
const appUid = appRow[0]?.UID;
if (!appUid) return manifest;
const assets = await query(
`SELECT a.\`Data\` FROM \`ObjectBase\` a
JOIN \`Links\` l ON l.\`UIDTarget\` = a.\`UID\`
WHERE l.\`UID\` = U_UUID2BIN(?) AND l.\`Type\` = ? AND a.\`Type\` = ?`,
[appUid, LINK_TYPE_APP_ASSET, OBJ_TYPE_APP_ASSET],
CAST,
);
for (const asset of assets) {
if (asset.Data?.assetType === ASSET_TYPE_ICON && asset.Data.s3Key) {
manifest.icons.push({
src: publicObjectUrl(asset.Data.s3Key),
sizes: asset.Data.sizes ?? '512x512',
type: asset.Data.mimeType ?? 'image/png',
});
}
}
return manifest;
}
// ── Assets (Icon) ─────────────────────────────────────────────────────────────
/** MIME type → extension für akzeptierte Icon-Formate */
const ICON_EXT = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/svg+xml': 'svg',
'image/gif': 'gif',
'image/webp': 'webp',
};
/** PWA-Icon-Varianten im data bucket — Pfade, die der Static-Server erwartet */
const PWA_ICON_SIZES = [
{ name: 'icon-192.png', size: 192 },
{ name: 'icon-512.png', size: 512 },
{ name: 'icon-192-maskable.png', size: 192 },
{ name: 'icon-512-maskable.png', size: 512 },
{ name: 'favicon.png', size: 32 },
];
/** Put in MinIO — public bucket über den publicClient, sonst der interne */
function minioput(bucket, key, buffer, mimeType) {
const client = bucket === PUBLIC_BUCKET ? (publicMinioClient ?? myMinioClient) : myMinioClient;
return new Promise((resolve, reject) => {
client.putObject(bucket, key, buffer, buffer.length, { 'Content-Type': mimeType },
(err) => (err ? reject(err) : resolve()));
});
}
/**
* Lädt ein App-Icon hoch (Original + PWA-Varianten) und legt ein `appAsset` an.
*
* Die S3-Pfade sind **dieselben** wie im Vault-basierten `service.js` — der
* Static-Server findet sie also unverändert, ohne dass dort etwas umgestellt
* werden muss.
*
* @param {string} orgId
* @param {string} appId
* @param {import('stream').Readable} fileStream
* @param {string} mimeType
* @returns {Promise<string>} öffentliche Icon-URL
*/
export async function uploadOrgAppIcon(orgId, appId, fileStream, mimeType) {
const ext = ICON_EXT[mimeType];
if (!ext) throw new Error(`Unsupported image type: ${mimeType}`);
const chunks = [];
for await (const chunk of fileStream) chunks.push(chunk);
const inputBuffer = Buffer.concat(chunks);
const originalKey = `${orgId}/icons/${appId}-${Date.now()}.${ext}`;
await minioput(PUBLIC_BUCKET, originalKey, inputBuffer, mimeType);
const iconUrl = publicObjectUrl(originalKey);
const baseImage = sharp(inputBuffer);
await Promise.all(
PWA_ICON_SIZES.map(async ({ name, size }) => {
const resized = await baseImage.clone()
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png().toBuffer();
await minioput(DATA_BUCKET, `${orgId}/manifests/${appId}/${name}`, resized, 'image/png');
}),
);
// Icon-URL am App-Eintrag hinterlegen (Data.icon) — `getApps` liefert sie so
// an das Frontend, wie es die Vault-Variante tat.
const appRow = await query(
`SELECT \`UID\`, \`Data\` FROM \`ObjectBase\`
WHERE \`Type\` = ? AND \`UIDBelongsTo\` = U_UUID2BIN(?)
AND JSON_UNQUOTE(JSON_EXTRACT(\`Data\`, '$.appId')) = ? LIMIT 1`,
[OBJ_TYPE_APP, orgId, appId],
CAST,
);
const app = appRow[0];
if (!app) throw new Error(`App "${appId}" not found for this organisation.`);
const oldIconUrl = app.Data?.icon;
if (oldIconUrl && oldIconUrl !== iconUrl) {
try {
const base = `${process.env.S3publicBaseUrl ?? `https://${process.env.publicS3endPoint ?? process.env.S3endPoint}:${process.env.publicS3port ?? process.env.S3port}`}/${PUBLIC_BUCKET}/`;
const oldKey = oldIconUrl.replace(base, '');
const publicClient = publicMinioClient ?? myMinioClient;
if (oldKey && oldKey !== oldIconUrl) await publicClient.removeObject(PUBLIC_BUCKET, oldKey);
} catch (e) {
errorLoggerUpdate(e); // best-effort
}
}
await query(
`UPDATE \`ObjectBase\` SET \`Data\` = ? WHERE \`UID\` = U_UUID2BIN(?)`,
[JSON.stringify({ ...app.Data, icon: iconUrl }), app.UID],
);
const assetUid = await newUid();
const assetUidString = normalizeUid(assetUid, HEX2uuid);
if (!assetUidString) throw new Error('UID des Icon-Assets ließ sich nicht in UUID-Form bringen');
await query(
`INSERT INTO \`ObjectBase\`
(\`UID\`, \`Type\`, \`UIDBelongsTo\`, \`Title\`, \`Display\`, \`SortName\`, \`dindex\`, \`Data\`)
VALUES (?, ?, U_UUID2BIN(?), ?, ?, ?, 0, ?)`,
[assetUid, OBJ_TYPE_APP_ASSET, app.UID, ASSET_TYPE_ICON, ASSET_TYPE_ICON, ASSET_TYPE_ICON,
JSON.stringify({ assetType: ASSET_TYPE_ICON, s3Key: originalKey, mimeType })],
);
await query(
`INSERT INTO \`Links\` (\`UID\`, \`Type\`, \`UIDTarget\`) VALUES (U_UUID2BIN(?), ?, U_UUID2BIN(?))`,
[app.UID, LINK_TYPE_APP_ASSET, assetUidString],
);
await invalidateRegistryCache(orgId);
return iconUrl;
}
// ── Releases (Auslieferung) ───────────────────────────────────────────────────
/**
* Ein Release ist ein **Zeiger**, kein Deployment.
*
* `AppRelease` hält pro `(AppKey, Version)` das S3-Präfix und optional die
* Backend-URLs; `Current` markiert die ausgelieferte Zeile. `OrgReleaseOverride`
* ist die Ausnahme für eine einzelne Organisation (Canary) und im Normalfall
* leer — kein Eintrag heißt „folgt dem Zeiger".
*
* `AppKey` ist die `appId` aus dem Registry (`Data.appId` des `app`-Objekts),
* keine UID: sie adressiert ein Release über Organisationen hinweg.
*
* @see src/config/migrations/20260928-app-release.js — Schema und Begründung
*/
/** @typedef {'override'|'current'} ReleaseSource */
const RELEASE_COLUMNS = '`AppKey`, `Version`, `Prefix`, `Backends`, `Current`';
/**
* Zeile → Release-Objekt. `Backends` kommt über den JSON-Cast bereits als
* Objekt; `null` bleibt `null` und bedeutet „keine Backend-URLs im Release".
* @param {object} row
*/
const mapRelease = (row) => ({
appKey: row.AppKey,
version: row.Version,
prefix: row.Prefix,
backends: row.Backends ?? null,
current: !!row.Current,
});
/**
* Alle Releases einer App, Zeiger zuerst.
* @param {string} appKey
* @returns {Promise<object[]>}
*/
export async function listAppReleases(appKey) {
const rows = await query(
`SELECT ${RELEASE_COLUMNS} FROM \`AppRelease\` WHERE \`AppKey\` = ? ORDER BY \`Current\` DESC, \`Version\` DESC`,
[appKey],
CAST,
);
return rows.map(mapRelease);
}
/**
* Löst die Auslieferung auf: Canary zuerst, sonst der Zeiger, sonst nichts.
*
* Die Reihenfolge ist der Kern des Modells — sie macht Canary **additiv**. Wer
* keinen Override hat, folgt `Current`; es gibt keinen Zustand, in dem eine
* Organisation versehentlich kein Release mehr hat, nur weil Canary eingeführt
* wurde.
*
* @param {string} appKey
* @param {string} [orgId] - UID in `UUID-`-Form (41 Zeichen)
* @returns {Promise<(object & {source: ReleaseSource})|null>} `null` = kein Release
*/
export async function resolveRelease(appKey, orgId = null) {
if (!appKey) return null;
try {
// 1. Canary dieser Organisation. Der Join auf `AppRelease` ist bewusst
// ein INNER JOIN: zeigt der Override auf eine gelöschte Version, ist
// er ungültig — und dann ist der Zeiger die richtige Antwort, nicht
// ein halb gefülltes Release.
if (orgId) {
const [row] = await query(
`SELECT r.\`AppKey\`, r.\`Version\`, r.\`Prefix\`, r.\`Backends\`, r.\`Current\`
FROM \`OrgReleaseOverride\` o
JOIN \`AppRelease\` r ON r.\`AppKey\` = o.\`AppKey\` AND r.\`Version\` = o.\`Version\`
WHERE o.\`AppKey\` = ? AND o.\`OrgUID\` = U_UUID2BIN(?)`,
[appKey, orgId],
CAST,
);
if (row) return { ...mapRelease(row), source: 'override' };
}
// 2. Der Zeiger.
const [current] = await query(
`SELECT ${RELEASE_COLUMNS} FROM \`AppRelease\` WHERE \`AppKey\` = ? AND \`Current\` = 1 LIMIT 1`,
[appKey],
CAST,
);
if (current) return { ...mapRelease(current), source: 'current' };
// 3. Nichts — der Aufrufer antwortet mit 503, nicht mit einem leeren Artefakt.
return null;
} catch (e) {
errorLoggerRead(e);
return null;
}
}
/**
* Legt ein Release an oder schreibt es fort (Schlüssel `AppKey`+`Version`).
*
* `current: true` macht den Zeiger in **einer** Transaktion um: erst alle
* anderen Zeilen der App auf 0, dann diese auf 1. Sonst könnte bei einem Fehler
* dazwischen kein oder — schlimmer — _jeder_ Zeiger gesetzt sein, und die
* Auflösung oben (`Current = 1 LIMIT 1`) würde zufällig wählen.
*
* @param {{appKey: string, version: string, prefix: string, backends?: object|null, current?: boolean}} release
*/
export async function saveAppRelease({ appKey, version, prefix, backends = null, current = false }) {
if (!appKey || !version || !prefix) throw new Error('appKey, version and prefix are required');
await transaction(async (connection) => {
if (current) {
await connection.query('UPDATE `AppRelease` SET `Current` = 0 WHERE `AppKey` = ?', [appKey]);
}
await connection.query(
`INSERT INTO \`AppRelease\` (\`AppKey\`, \`Version\`, \`Prefix\`, \`Backends\`, \`Current\`)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
\`Prefix\` = VALUES(\`Prefix\`),
\`Backends\` = VALUES(\`Backends\`),
\`Current\` = VALUES(\`Current\`)`,
[appKey, version, prefix, backends === null ? null : JSON.stringify(backends), current ? 1 : 0],
);
});
}
/**
* Setzt den Zeiger auf eine vorhandene Version.
*
* Ein Deploy ist damit ein Config-Update und ein Rollback das Zurücksetzen
* desselben Zeigers — es startet nichts neu.
* @param {string} appKey
* @param {string} version
*/
export async function setCurrentRelease(appKey, version) {
await transaction(async (connection) => {
const [row] = await connection.query(
'SELECT `Version` FROM `AppRelease` WHERE `AppKey` = ? AND `Version` = ?',
[appKey, version],
);
// Nicht raten: ein Zeiger auf eine nicht existierende Version würde die
// Auflösung später ins Leere laufen lassen.
if (!row) throw new Error(`Unknown release ${appKey}@${version}`);
await connection.query('UPDATE `AppRelease` SET `Current` = 0 WHERE `AppKey` = ?', [appKey]);
await connection.query(
'UPDATE `AppRelease` SET `Current` = 1 WHERE `AppKey` = ? AND `Version` = ?',
[appKey, version],
);
});
}
/**
* Stellt eine Organisation auf ein bestimmtes Release (Canary).
* @param {string} appKey
* @param {string} orgId - UID in `UUID-`-Form
* @param {string} version
*/
export async function setOrgReleaseOverride(appKey, orgId, version) {
await query(
`INSERT INTO \`OrgReleaseOverride\` (\`AppKey\`, \`OrgUID\`, \`Version\`)
VALUES (?, U_UUID2BIN(?), ?)
ON DUPLICATE KEY UPDATE \`Version\` = VALUES(\`Version\`), \`AddedAt\` = CURRENT_TIMESTAMP`,
[appKey, orgId, version],
);
}
/**
* Nimmt eine Organisation aus dem Canary. Ab hier folgt sie wieder dem Zeiger —
* das ist der Rollback, und er braucht keinen Rückbau eines Deployments.
* @param {string} appKey
* @param {string} orgId
* @returns {Promise<boolean>} true, wenn wirklich ein Eintrag entfernt wurde
*/
export async function clearOrgReleaseOverride(appKey, orgId) {
const result = await query(
'DELETE FROM `OrgReleaseOverride` WHERE `AppKey` = ? AND `OrgUID` = U_UUID2BIN(?)',
[appKey, orgId],
);
return (result?.affectedRows ?? 0) > 0;
}
/**
* Der `window.env`-Aufsatz eines Releases — die Schlüssel aus
* `AppRelease.Backends`, **unverändert**.
*
* Bewusst ohne Übersetzungstabelle: die Backends liegen bereits unter dem Namen,
* unter dem das Frontend sie liest (`api`, `apiPortal`, …). Eine Umbenennung an
* dieser Stelle würde nur eine zweite Stelle schaffen, an der ein neuer
* Backend-Name nachgetragen werden müsste — und eine, die niemand beim Anlegen
* eines Releases sieht.
*
* `env: null` ist eine **gültige** Antwort und heißt „kein Release-Aufsatz":
* der Consumer behält dann seine Deployment-Werte. `null` als ganzer
* Rückgabewert dagegen heißt „die App hat überhaupt kein Release" — der
* Unterschied zwischen „kein Canary" und „falsche App", und der Grund für den
* 404 in der Route.
*
* @param {string} appKey
* @param {string} orgId
* @returns {Promise<{env: object|null, version: string, source: ReleaseSource}|null>}
*/
export async function resolveReleaseEnv(appKey, orgId) {
const release = await resolveRelease(appKey, orgId);
if (!release) return null;
return { env: release.backends, version: release.version, source: release.source };
}
// ── Cache-Invalidierung ───────────────────────────────────────────────────────
/**
* Sagt den Consumern, dass sich das Registry dieser Organisation geändert hat.
* Best-effort: ein fehlendes Redis darf das Speichern nicht scheitern lassen.
* @param {string} orgId
*/
async function invalidateRegistryCache(orgId) {
try {
await publishEvent('registry:changed', { organization: orgId, data: { orgId } });
} catch (e) {
errorLoggerUpdate(e);
}
}