/**
* App-Registry — Datenmodell-Konstanten und ID-Helfer
*
* Das Registry legt Apps, Domains und Assets als Objekte in `ObjectBase` ab und
* verbindet sie über `Links`. Diese Datei hält die Literale und die Regeln für
* die UID-Behandlung an **einer** Stelle — vorher lagen sie als String-Literale
* über Service und Router verstreut, was bei einem Tippfehler erst im
* fehlgeschlagenen INSERT auffällt (MariaDB ist bei `enum` strikt).
*
* ## UID-Format — die eine Regel, an der alles hängt
*
* In dieser Datenbank existieren **zwei** UUID-Darstellungen nebeneinander, und
* sie sind nicht austauschbar:
*
* | Form | Länge | Erzeuger / Leser |
* |---|---|---|
* | `UUID-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | 41 | `U_UUID2BIN()` (SQL), `UUID2hex()` / `HEX2uuid()` (JS) |
* | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | 36 | `UUID2BIN()` / `BIN2UUID()` (SQL) |
*
* Die JS-Seite von `@commtool/sql-query` und `U_UUID2BIN()` benutzen **dieselbe**
* Form (41 Zeichen, mit Präfix) — das ist der Grund, warum `cast: ['UUID']` beim
* Lesen und `U_UUID2BIN(?)` beim Schreiben zusammenpassen. Die rohe 36-Zeichen-Form
* gehört zu `UUID2BIN`/`BIN2UUID` und wird hier **nicht** verwendet.
*
* ## UIDs werden nicht in JavaScript erzeugt
*
* Der naheliegende Weg — `crypto.randomUUID()` im Service — ist aus zwei Gründen
* falsch: `randomUUID()` liefert eine **v4** (zufällig, nicht sortierbar), und ihr
* Hex-String passt in keines der beiden SQL-Formate (beide erwarten die
* Bindestriche). Erzeugt wird deshalb **in der Datenbank** über `SELECT UIDV1()`
* (`UIDV1() = UUID2BIN(UUID())`, echte v1, sortierbar, korrekte Byte-Reihenfolge).
*/
// ── ObjectBase.Type ───────────────────────────────────────────────────────────
/** Eine App (Auslieferungseinheit). `UIDBelongsTo` → Organisation. */
export const OBJ_TYPE_APP = 'app';
/** Eine Domain/Host-Zuordnung. `UIDBelongsTo` → Organisation. */
export const OBJ_TYPE_APP_DOMAIN = 'appDomain';
/** Ein App-Artefakt (Icon, Favicon, …). `UIDBelongsTo` → App. */
export const OBJ_TYPE_APP_ASSET = 'appAsset';
// ── Links.Type ────────────────────────────────────────────────────────────────
/**
* Verbindet eine **App** (als Link-`UID`) mit einer **Domain** (`UIDTarget`).
*
* Richtung ist bewusst „von der App weg" — und damit **gleich** wie bei
* `appAsset`. Ein gemischtes Modell (App→Asset, Domain→App) hätte jede Abfrage
* zu einer Frage der Erinnerung gemacht. Der Preis ist eine Umkehrung beim
* Auflösen: „welche App hängt an diesem Host" muss über den `UIDTarget` der
* Domain suchen, nicht über ihren `UID`.
*/
export const LINK_TYPE_APP_DOMAIN = 'appDomain';
/** Verbindet eine **App** (als Link-`UID`) mit einem **Asset** (`UIDTarget`). */
export const LINK_TYPE_APP_ASSET = 'appAsset';
// ── Asset-Typen (`Data.assetType`) ───────────────────────────────────────────
export const ASSET_TYPE_ICON = 'icon';
export const ASSET_TYPE_FAVICON = 'favicon';
export const ASSET_TYPE_LOGO = 'logo';
export const ASSET_TYPE_MANIFEST = 'manifest';
// ── Domains: `type` und `status` sind zwei verschiedene Dinge ────────────────
/**
* Eine Domain trägt **zwei** unabhängige Aussagen. Sie lagen früher in einem
* einzigen Feld: in Vault stand `{"kpe.de":"verified","ct":"internal"}` — eine
* Domänenart neben einem Prüfstand. Das ging nicht auf, denn es sind zwei
* Achsen:
*
* | Achse | Werte | Frage |
* |---|---|---|
* | `type` | `internal` \| `external` | Wo liegt der Host? |
* | `status` | `pending` \| `verified` | Ist er nachgewiesen? |
*
* In einem Feld führte das zu einem stillen Widerspruch: die Oberfläche bot
* `internal`/`external` an, der Bestand enthielt `verified`, und
* `validateDomains` wies damit **den eigenen Bestand** als ungültig zurück.
* Getrennt ist jede Achse für sich prüfbar — und der DNS-Nachweis hat einen
* Platz, ohne ein dritter „Typ" zu werden.
*/
export const DOMAIN_TYPE_INTERNAL = 'internal';
export const DOMAIN_TYPE_EXTERNAL = 'external';
export const DOMAIN_STATUS_PENDING = 'pending';
export const DOMAIN_STATUS_VERIFIED = 'verified';
/**
* Bringt einen Domain-Eintrag auf die Form `{ type, status }`.
*
* Nimmt **beide** Formen an, denn der Altbestand in Vault ist eine
* Zeichenkette (`{"kpe.de":"verified"}`). Die Zuordnung ist eindeutig, weil die
* alten Werte sich gegenseitig ausschließen:
*
* | Alt | `type` | `status` | Begründung |
* |---|---|---|---|
* | `internal` | internal | verified | Ein System-Präfix liegt im eigenen Namensraum — nachzuweisen gibt es nichts |
* | `external` | external | pending | Eine Kunden-Domain bleibt offen, bis der DNS-Eintrag zeigt |
* | `verified` | external | verified | Nur Kunden-Domains wurden je von Hand verifiziert — sie tragen einen Punkt |
*
* @param {unknown} value - Zeichenkette (Altbestand) oder `{type, status}`
* @returns {{type: string, status: string}|null} `null`, wenn nicht deutbar
*/
export const normalizeDomainEntry = (value) => {
if (typeof value === 'string') {
const raw = value.trim().toLowerCase();
if (raw === DOMAIN_TYPE_INTERNAL) return { type: DOMAIN_TYPE_INTERNAL, status: DOMAIN_STATUS_VERIFIED };
if (raw === DOMAIN_TYPE_EXTERNAL) return { type: DOMAIN_TYPE_EXTERNAL, status: DOMAIN_STATUS_PENDING };
if (raw === DOMAIN_STATUS_VERIFIED) return { type: DOMAIN_TYPE_EXTERNAL, status: DOMAIN_STATUS_VERIFIED };
return null;
}
if (value && typeof value === 'object') {
const entry = /** @type {{type?: unknown, status?: unknown}} */ (value);
const rawType = typeof entry.type === 'string' ? entry.type.trim().toLowerCase() : '';
// Auch das `type`-Feld kann einen Altbestand-Status tragen: ältere Zeilen
// wurden als `{ type: 'verified' }` geschrieben. Ohne diesen Zweig läse
// man daraus „external + pending" und verlöre den Nachweis.
if (rawType === DOMAIN_STATUS_VERIFIED) return { type: DOMAIN_TYPE_EXTERNAL, status: DOMAIN_STATUS_VERIFIED };
const type = rawType === DOMAIN_TYPE_INTERNAL ? DOMAIN_TYPE_INTERNAL : DOMAIN_TYPE_EXTERNAL;
const status = entry.status === DOMAIN_STATUS_VERIFIED ? DOMAIN_STATUS_VERIFIED
: entry.status === DOMAIN_STATUS_PENDING ? DOMAIN_STATUS_PENDING
// Ohne Angabe entscheidet die Art: intern ist per Definition gültig,
// eine Kunden-Domain ist es erst nach dem Nachweis.
: (type === DOMAIN_TYPE_INTERNAL ? DOMAIN_STATUS_VERIFIED : DOMAIN_STATUS_PENDING);
return { type, status };
}
return null;
};
/**
* Bringt eine ganze Domain-Map auf `{ domain: {type, status} }`.
* @param {unknown} map - Map in Alt- oder Neuform
* @returns {Record<string, {type: string, status: string}>}
*/
export const normalizeDomainMap = (map) => {
const result = /** @type {Record<string, {type: string, status: string}>} */ ({});
if (!map || typeof map !== 'object') return result;
for (const [domain, value] of Object.entries(map)) {
const entry = normalizeDomainEntry(value);
if (entry) result[domain] = entry;
}
return result;
};
/**
* Gegenstück zu {@link normalizeDomainEntry} für den **Vault-Schreibpfad**.
*
* Solange `REGISTRY_READ_MODE` nicht auf `db` steht, schreibt `saveOrgDomains`
* weiter nach Vault — und dort lesen `shared-auth` (`organizationDomains.js`)
* und die übrigen Konsumenten eine **Zeichenkette**: sie vergleichen mit
* `state === 'internal'` bzw. `state === 'verified'`. Ein Objekt dort würde sie
* brechen. Die Rückübersetzung ist verlustfrei, weil die drei Altwerte die drei
* sinnvollen Kombinationen genau abdecken.
*
* @param {{type: string, status: string}} entry
* @returns {'internal'|'external'|'verified'}
*/
export const toLegacyDomainValue = (entry) => {
if (entry.type === DOMAIN_TYPE_INTERNAL) return DOMAIN_TYPE_INTERNAL;
return entry.status === DOMAIN_STATUS_VERIFIED ? DOMAIN_STATUS_VERIFIED : DOMAIN_TYPE_EXTERNAL;
};
/**
* Normalisiert einen Domain-Namen: Kleinschreibung, ohne führenden `*.`/`.` und
* ohne abschließenden Punkt. `*.Kpe.de.` → `kpe.de`.
* @param {unknown} value
* @returns {string|null}
*/
export const normalizeDomainValue = (value) => {
if (typeof value !== 'string') return null;
const raw = value.trim().toLowerCase()
.replace(/^\*\./, '')
.replace(/^\./, '')
.replace(/\.$/, '');
return raw || null;
};
/** Basis-Domain, aus der interne Hosts gebildet werden (`APP_BASE_DOMAIN`). */
export const APP_BASE_DOMAIN_DEFAULT = 'commtool.org';
/**
* Baut den Host, unter dem eine App erreichbar ist.
*
* Der **Punkt entscheidet**, und das ist keine eigene Erfindung: dieselbe Regel
* steht in `shared-auth` (`organizationDomains.js`) und im Portal-Bot
* (`portal.controller.js` → `buildAppUrl`). Sie wird hier nur nachgebildet,
* damit Routing und Anmeldung nicht auseinanderlaufen.
*
* | `domain` der App | Ergebnis |
* |---|---|
* | `db.app.kpe.de` (mit Punkt) | `db.app.kpe.de` — eine Kunden-Domain gilt, wie sie steht |
* | `sjm` (ohne Punkt) | `sjm.admin.app.commtool.org` — Präfix, App-Kennung, Basis |
*
* @param {string|null} appId
* @param {unknown} domain
* @param {string} [baseDomain] - aus `APP_BASE_DOMAIN`
* @returns {string|null}
*/
export const appHostFor = (appId, domain, baseDomain = APP_BASE_DOMAIN_DEFAULT) => {
const value = normalizeDomainValue(domain);
if (!appId || !value) return null;
return value.includes('.') ? value : `${value}.${appId}.${baseDomain}`;
};
// ── Domain-Regeln ─────────────────────────────────────────────────────────────
/**
* Präfixe, die eine Organisation nicht belegen darf.
*
* Sie liegen im System-Namensraum: `admin`, `api`, `auth` … würden mit
* Infrastruktur-Hosts verwechselt, die von der Plattform selbst vergeben
* werden. Eine Organisation, die `admin` beansprucht, bekäme
* `admin.{app}.commtool.org` — und damit einen Host, der wie die
* Administrationsumgebung aussieht.
*/
export const RESERVED_PREFIXES = new Set([
'admin', 'api', 'auth', 'vault', 'www', 'mail', 'smtp', 'ns', 'ns1', 'ns2',
'ftp', 'ssh', 'vpn', 'git', 'registry', 'cdn', 'app', 'dev', 'test',
'staging', 'prod', 'support', 'help', 'status', 'monitor', 'ops',
]);
/** intern: Kleinbuchstaben, Ziffern, Bindestriche — kein führender/letzter Bindestrich */
const INTERNAL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
/** extern: mindestens zwei Labels, gültige TLD */
const EXTERNAL_RE = /^([a-z0-9][a-z0-9-]{0,61}[a-z0-9]\.)+[a-z]{2,}$/;
/**
* Prüft einen einzelnen Domain-Eintrag gegen die Regeln seiner Art.
*
* Gibt eine Meldung für **Menschen** zurück, keinen Code: sie landet direkt in
* der Fehlerliste der Oberfläche.
*
* @param {string} domain - bereits normalisiert
* @param {unknown} value - Altform (Zeichenkette) oder `{ type, status }`
* @returns {string|null} `null` = gültig
*/
export const validateDomainEntry = (domain, value) => {
const rawType = value && typeof value === 'object' ? value.type : value;
const rawStatus = value && typeof value === 'object' ? value.status : undefined;
// Erst die Art, dann der Status: eine unbekannte Art macht die Statusprüfung
// sinnlos, und die Meldung wäre dann verwirrend.
if (rawType !== DOMAIN_TYPE_INTERNAL && rawType !== DOMAIN_TYPE_EXTERNAL && rawType !== DOMAIN_STATUS_VERIFIED) {
return `Unknown domain type "${rawType}". Use "internal" or "external".`;
}
if (rawStatus !== undefined && rawStatus !== DOMAIN_STATUS_PENDING && rawStatus !== DOMAIN_STATUS_VERIFIED) {
return `Unknown domain status "${rawStatus}". Use "pending" or "verified".`;
}
const entry = normalizeDomainEntry(value);
if (!entry) return `Unknown domain type "${rawType}". Use "internal" or "external".`;
if (entry.type === DOMAIN_TYPE_INTERNAL) {
if (!INTERNAL_RE.test(domain))
return 'Only lowercase letters, digits and hyphens allowed; may not start or end with a hyphen.';
if (RESERVED_PREFIXES.has(domain))
return `"${domain}" is a reserved system name.`;
return null;
}
if (!EXTERNAL_RE.test(domain)) return 'Not a valid domain name (e.g. myclub.com).';
return null;
};
/**
* Sucht einen Konflikt zwischen einer Domain und dem Bestand **anderer**
* Organisationen.
*
* Verglichen wird nur die Achse `type`. Ob eine Domain verifiziert ist, ändert
* nichts daran, wem sie gehört — würde der Status mitgezählt, ließe sich
* dieselbe Domain zweimal vergeben, solange eine Seite den Nachweis noch nicht
* erbracht hat.
*
* Zwei Regeln:
* - gleiche Art + gleicher Name → direkter Konflikt
* - intern `foo` ↔ extern `foo.*` → Präfix-Konflikt (dieselbe Wurzel)
*
* @param {string} domain
* @param {unknown} value
* @param {Record<string, Record<string, unknown>>} allOrgDomains
* @param {string} currentOrgId
* @returns {string|null} die UID der kollidierenden Organisation, oder `null`
*/
export const findDomainConflict = (domain, value, allOrgDomains, currentOrgId) => {
const type = normalizeDomainEntry(value)?.type;
if (!type) return null;
for (const [orgId, domains] of Object.entries(allOrgDomains ?? {})) {
if (orgId === currentOrgId) continue;
for (const [existingDomain, existingValue] of Object.entries(domains ?? {})) {
const existingType = normalizeDomainEntry(existingValue)?.type;
if (!existingType) continue;
if (type === existingType && domain === existingDomain) return orgId;
if (type === DOMAIN_TYPE_INTERNAL && existingType === DOMAIN_TYPE_EXTERNAL) {
if (existingDomain.split('.')[0] === domain) return orgId;
}
if (type === DOMAIN_TYPE_EXTERNAL && existingType === DOMAIN_TYPE_INTERNAL) {
if (domain.split('.')[0] === existingDomain) return orgId;
}
}
}
return null;
};
// ── ID-Helfer ─────────────────────────────────────────────────────────────────
/** `UUID-`-präfixierte Form, wie sie `U_UUID2BIN()` und die JS-Casts erwarten */
const UUID_STRING_RE = /^UUID-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
/**
* Prüft, ob ein Wert eine UID in der hier gültigen Form ist.
* @param {unknown} value
* @returns {boolean}
*/
export const isValidUid = (value) =>
typeof value === 'string' && UUID_STRING_RE.test(value);
/**
* Bringt eine UID in die gültige Form. Akzeptiert bereits korrekte Werte,
* ein `Buffer(16)` (wie ihn `UIDV1()` liefert, via `HEX2uuid` umgesetzt) und
* die rohe 36-Zeichen-Form.
*
* Bewusst tolerant beim Lesen, streng beim Schreiben: Werte aus `session`,
* Vault oder einer älteren Zeile sind nicht immer schon normalisiert.
*
* @param {string|Buffer|null|undefined} value
* @param {(buffer: Buffer) => string|undefined} [hexToUuid] - `HEX2uuid` aus
* `@commtool/sql-query`; wird übergeben statt importiert, damit diese Datei
* ohne DB-Abhängigkeit testbar bleibt.
* @returns {string|null} UID in `UUID-`-Form, oder `null` wenn nicht ableitbar
*/
export const normalizeUid = (value, hexToUuid) => {
if (value == null) return null;
if (Buffer.isBuffer(value)) {
if (value.length !== 16 || typeof hexToUuid !== 'function') return null;
return hexToUuid(value) ?? null;
}
if (typeof value !== 'string') return null;
if (UUID_STRING_RE.test(value)) return value;
// Rohe 36-Zeichen-Form → präfixieren (nicht konvertieren: die Byte-Reihenfolge
// ist dieselbe, nur die Schreibweise unterscheidet sich).
const bare = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
if (bare.test(value)) return `UUID-${value.toLowerCase()}`;
return null;
};
/**
* Liest die `appId` aus dem `Data`-Feld eines App-Objekts.
*
* Die `appId` (z.B. `member.app`) ist **keine** UID: sie ist der logische,
* menschenlesbare Schlüssel aus Vault (`orgas/data/{orgId}/apps` → Map-Key) und
* wird auch in der API (`admin`) als Map-Key verwendet. Sie liegt deshalb als
* Feld in `Data` und nicht im Primärschlüssel (§6.1 der Planung).
*
* @param {unknown} data - bereits geparstes `Data`-Objekt
* @returns {string|null}
*/
export const appIdFromData = (data) => {
if (!data || typeof data !== 'object') return null;
const appId = /** @type {{ appId?: unknown }} */ (data).appId;
return typeof appId === 'string' && appId.length > 0 ? appId : null;
};
/**
* Baut die Objekt-Titel-Felder aus `appId` und Anzeigetitel.
*
* `Title`/`Display` tragen den **Anzeigetitel**, `SortName` dessen
* Kleinschreibung — die App-ID liegt in `Data.appId`. Den Schlüssel in `Title`
* zu legen wäre verlockend (er wäre dann indexiert), würde aber Anzeigename und
* Identität vermischen: ein umbenannter Titel hätte den Lookup zerbrochen.
*
* @param {string} appId
* @param {string|undefined|null} title
* @returns {{ title: string, display: string, sortName: string }}
*/
export const appTitles = (appId, title) => {
const display = (typeof title === 'string' && title.trim()) || appId;
return { title: display, display, sortName: display.toLowerCase() };
};