Source: Router/orgaSettings/registryTypes.js

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