/**
* 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;
};
// ── Backend-Zweige ────────────────────────────────────────────────────────────
/**
* Ein Backend-Zweig heißt wie er heißt: ein kurzer Name für **eine** Adressierung
* des Backends (`test`, `prod`, `canary`).
*
* Bewusst **kein `enum`**: ein neuer Zweig darf keine Schemaänderung und kein
* Deploy sein. Die Form wird deshalb beim Schreiben geprüft — dieselbe Regel wie
* bei einem internen Domain-Präfix, denn ein Zweig ist ein Namensraum, kein Satz.
*/
const BRANCH_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
/**
* Normalisiert einen Zweig-Namen: Kleinschreibung, getrimmt.
* @param {unknown} value
* @returns {string|null}
*/
export const normalizeBranch = (value) => {
if (typeof value !== 'string') return null;
const raw = value.trim().toLowerCase();
return raw || null;
};
/**
* @param {unknown} value
* @returns {boolean} `true`, wenn der Zweig-Name zulässig ist
*/
export const isValidBranch = (value) => {
const branch = normalizeBranch(value);
return branch !== null && BRANCH_RE.test(branch);
};
/**
* Schlüssel, die **niemals** in einem Zweig stehen dürfen.
*
* Die Werte eines Zweigs landen über `orgContext` ungefiltert in `window.env` —
* `createEnvEndpoint` wendet seine Secret-Filterung
* (`extractSafeFrontendConfig`) nur auf `appSecrets` an, nicht auf den
* Overlay. Für die Backend-Adressierung ist das beabsichtigt (ein Overlay ist
* keine Vault-Quelle), es verschiebt die Grenze aber: die Prüfung muss hier
* passieren.
*
* Deshalb dieselben Muster wie im Filter dort (`secret`, `password`, `token`,
* `key`, `cert`) — doppelt gehalten, weil es hier nicht um
* „nicht versehentlich mitliefern" geht, sondern um „gar nicht erst annehmen".
* Keiner der real gelesenen Schlüssel (`api`, `baseUrl`, `apiBase`, `loginApi`,
* `otherApis`, `basePath`) trifft eines dieser Muster.
*/
const FORBIDDEN_BACKEND_KEY = /secret|password|token|key|cert/i;
/**
* Prüft ein flaches env.js-Objekt — `environment` **oder** einen Zweig.
*
* Beides ist dieselbe Form und geht denselben Weg: der Broker mischt
* `{...environment, ...branch}` und schreibt das Ergebnis nach `window.env`.
* Deshalb gibt es hier **keine** Unterscheidung zwischen „Backend-Schlüsseln"
* und anderen — ein Zweig darf jeden Schlüssel überschreiben, auch `title` oder
* `icon`.
*
* Ein Schema gibt es bewusst nicht: das Frontend liest je App unterschiedliche
* Schlüssel (`members-app` `api`/`baseUrl`/`apiBase`, `admin` zusätzlich
* `loginApi`/`otherApis`), und `window.env` ist genau die Schnittstelle, die es
* erwartet. Ein Schema würde jede neue Frontend-App zu einer Registry-Änderung
* machen. Geprüft wird nur, was für **alle** gilt:
*
* - ein nicht-leeres, JSON-sicheres Objekt (mit `allowEmpty` darf eine
* Zweig-Ebene auch leer sein — „erbt alles von der App-Ebene"),
* - keine Secret-Schlüssel (die Werte landen ungefiltert im Browser),
* - die **bekannten** Meta-Schlüssel in ihrer Form, wenn sie vorkommen
* (`title`, `description`, `icon`, `roles`, `domain`) — sonst wäre ein
* Tippfehler dort erst im Frontend sichtbar.
*
* @param {unknown} obj
* @param {string} [label] - für die Meldung (`Environment`, `Branch`)
* @param {{allowEmpty?: boolean}} [options]
* @returns {string|null} `null` = gültig
*/
export const validateEnvironmentObject = (obj, label = 'Environment', { allowEmpty = false } = {}) => {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
return `${label} must be an object of env.js keys (e.g. { "api": "member", "baseUrl": "api.commtool.org/api" }).`;
}
const entries = Object.entries(obj);
if (entries.length === 0) return allowEmpty ? null : `${label} must not be empty.`;
for (const [key, value] of entries) {
if (!key.trim()) return `${label} must not contain an empty key.`;
if (FORBIDDEN_BACKEND_KEY.test(key)) {
return `${label} key "${key}" looks like a secret. ${label} is delivered to the browser — secrets belong in Vault.`;
}
if (value == null) return `${label} key "${key}" must have a value.`;
// `otherApis` ist eine Map — ein Wert darf deshalb ein Objekt sein, aber
// nur mit primitiven Einträgen. Tiefere Strukturen wären in `window.env`
// nicht mehr adressierbar und sind mit hoher Wahrscheinlichkeit ein Fehler.
if (typeof value === 'object' && !Array.isArray(value)) {
if (Object.keys(value).length === 0) return `${label} key "${key}" must have a value.`;
for (const nested of Object.values(value)) {
if (nested && typeof nested === 'object') {
return `${label} key "${key}" must not nest objects deeper than one level.`;
}
}
}
const metaError = validateMetaValue(key, value);
if (metaError) return `${label}: ${metaError}`;
}
return null;
};
/**
* Die Form der **bekannten** Meta-Schlüssel — nur geprüft, wenn der Schlüssel
* vorkommt. Unbekannte Schlüssel sind frei (siehe oben), diese fünf nicht:
* `title` trägt die Anzeige, `description` die Kurzbeschreibung (Portal:
* Hover/Help), `icon` die Manifest-Grafik, `roles` die Sichtbarkeit, `domain`
* den Host-Präfix. Ein Fehler darin fiele sonst erst im Frontend auf.
*
* @param {string} key
* @param {unknown} value
* @returns {string|null}
*/
const validateMetaValue = (key, value) => {
if (key === 'title' && (typeof value !== 'string' || !value.trim())) {
return '"title" must be a non-empty string.';
}
if (key === 'description' && (typeof value !== 'string' || !value.trim())) {
return '"description" must be a non-empty string.';
}
if (key === 'icon' && value !== '' && (typeof value !== 'string' || !/^https:\/\/\S+$/i.test(value.trim()))) {
return '"icon" must be an https:// URL.';
}
if (key === 'roles' && (!Array.isArray(value) || value.some((role) => typeof role !== 'string' || !role.trim()))) {
return '"roles" must be an array of non-empty strings.';
}
if (key === 'domain' && value !== '' && !INTERNAL_RE.test(String(value).trim().toLowerCase())) {
return '"domain" must be a lowercase prefix (letters, digits, hyphens).';
}
return null;
};
/**
* Die Meta-Schlüssel, die der Kunden-Admin im Formular sieht. Alle anderen
* Schlüssel eines `environment`/Zweigs sind frei und werden im Formular nicht
* als eigenes Feld geführt — sie kommen aus dem Vertrag und sind dort gepflegt.
*/
export const APP_ENV_META_FIELDS = ['title', 'description', 'icon', 'roles', 'domain'];
/** App-Schlüssel (`member.app`, `ext-my-link`) — Kleinbuchstaben, Ziffern, Punkt, Bindestrich. */
export const APP_KEY_RE = /^[a-z0-9][a-z0-9.-]*$/;
/**
* @param {unknown} value
* @returns {boolean}
*/
export const isValidAppKey = (value) =>
typeof value === 'string' && APP_KEY_RE.test(value.trim());
/**
* Zerlegt eine App-ID in **Produkt** und **Umgebung**.
*
* Eine App-ID ist `<produkt>.<umgebung>` (`member.test`). Das Suffix ist die
* Umgebung und **ist** der Backend-Zweig — dieselbe Achse, nicht zwei. Der
* Vertrag (`AppCatalog`/`AppBackendBranch`) ist dagegen je **Produkt**
* geschlüsselt (`member`); diese Funktion ist die Naht zwischen beiden
* Schlüsselräumen.
*
* Ohne Punkt ist die ganze ID das Produkt (Umgebung `null`) — so bleiben
* CommTool-eigene Apps ohne Umgebung adressierbar.
*
* Zwei Punkte sind kein Fehler, aber auch keine Umgebung: `a.b.c` hat das
* Produkt `a` und die Umgebung `b.c`, was `isValidBranch` ablehnt. Bewusst
* **nicht** hier verboten — diese Funktion deutet, sie prüft nicht.
*
* @param {unknown} appId
* @returns {{product: string, environment: string|null}|null} `null` = nicht deutbar
*/
export const splitAppId = (appId) => {
if (typeof appId !== 'string') return null;
const trimmed = appId.trim();
if (!trimmed) return null;
const dot = trimmed.indexOf('.');
if (dot === -1) return { product: trimmed, environment: null };
const product = trimmed.slice(0, dot);
const environment = trimmed.slice(dot + 1);
if (!product || !environment) return null;
return { product, environment };
};
/**
* Das **Produkt** einer App-ID (`member.test` → `member`) — der Schlüssel, unter
* dem der Vertrag (`AppCatalog`) und die Zweige (`AppBackendBranch`) liegen.
*
* Ohne Punkt ist die ID selbst das Produkt (dann liegt sie als Ganzes im
* Vertrag, wie `member`).
*
* @param {unknown} appId
* @returns {string|null}
*/
export const productOfAppId = (appId) => {
if (typeof appId !== 'string') return null;
return splitAppId(appId)?.product ?? (appId.trim() || null);
};
/**
* Normalisiert das `environment` einer App bzw. eines Zweigs.
*
* Heute ein Durchgriff — der Aufrufer übergibt bereits das geprüfte Objekt. Die
* Funktion ist der Ort, an dem eine spätere Umbenennung oder ein Default
* (etwa ein eingestreutes `NODE_ENV`) läge, ohne jeden Aufrufer anzufassen.
*
* @param {object} obj
* @returns {object}
*/
export const normalizeAppEnvironment = (obj) =>
(obj && typeof obj === 'object' && !Array.isArray(obj)) ? { ...obj } : {};
// ── 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() };
};