// @ts-check
/**
* Vault → Registry-Import (`ObjectBase`/`Links`)
*
* Überführt `orgas/data/{orgId}/apps` und `.../domains` aus Vault in das
* App-Registry. Mit `--dry-run` wird **nichts geschrieben**, sondern der
* Unterschied zwischen beiden Quellen berichtet — das ist der Nachweis D1
* („Registry liefert gleiche Zahlen wie Vault") und die Voraussetzung dafür,
* `REGISTRY_READ_MODE` von `dual` auf `db` zu stellen.
*
* ## Ablauf
*
* node src/scripts/migrateVaultToRegistry.js --dry-run # nur berichten
* node src/scripts/migrateVaultToRegistry.js # schreiben
* node src/scripts/migrateVaultToRegistry.js --org=UUID-… # eine Organisation
*
* ## Warum der Modus erzwungen wird
*
* Das Skript setzt `REGISTRY_READ_MODE=db`, **bevor** der Service importiert
* wird. Sonst würde `saveOrgApps` im Modus `dual` den Vault zurückschreiben —
* also genau die Quelle, aus der gerade gelesen wird. Der Import ist eine
* Einbahnstraße.
*
* ## Icons
*
* Der Plan sah vor, base64-Icons aus `orgData.pwa.icons` nach S3 zu kopieren.
* Das entfällt: `apps[appId].icon` ist bereits eine öffentliche S3-URL, und die
* PWA-Varianten (`{orgId}/manifests/{appId}/…`) schreibt ausschließlich
* `uploadOrgAppIcon`. In `members-back` gibt es keine PWA-Lesestelle — geprüft.
*
* ## Schreiben geht über den Service, nicht über eigenes SQL
*
* Damit die Upsert-Logik **genau** die ist, die später auch das Frontend
* auslöst. Ein zweiter Schreibpfad wäre eine zweite Wahrheit. Insbesondere die
* UID-Behandlung (bestehende Objekte behalten ihre UID) kommt damit aus
* derselben Stelle wie im Betrieb.
*
* ## Zugangsdaten und Ziel-Datenbank
*
* Die DB-Zugangsdaten stehen **nicht** im Container-Environment, sondern in
* Vault unter `GIT_SECRET_PATH`. Das Skript lädt sie deshalb selbst über
* `loadSecretsFromVault()` — dieselbe Reihenfolge wie beim App-Start.
*
* **Vor dem Schreiben prüfen, wohin geschrieben wird.** In dieser Umgebung
* zeigt `GIT_SECRET_PATH=…/devProd` auf `member@10.1.1.22` mit dem Benutzer
* `commtool-prod` — also auf die Produktions-Datenbank. `--dry-run` ist dort
* die einzige gefahrlose Betriebsart.
*/
process.env.REGISTRY_READ_MODE = 'db';
import { pathToFileURL } from 'node:url';
import { recreateMainConnection, recreatePool } from '@commtool/sql-query';
import { getSecretsFromVault, loadSecretsFromVault } from '@commtool/vault-secrets';
import * as registryService from '../Router/orgaSettings/registryService.js';
import { isValidUid, normalizeDomainMap } from '../Router/orgaSettings/registryTypes.js';
// ── CLI ───────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const DRY_RUN = args.includes('--dry-run');
const VERBOSE = args.includes('--verbose');
const onlyOrg = args.find((a) => a.startsWith('--org='))?.slice('--org='.length) ?? null;
// ── Vergleich (D1) ────────────────────────────────────────────────────────────
/**
* Legt `value` auf die Felder von `shape` um.
*
* Ohne diesen Schritt wäre der Bericht **immer** rot, und zwar aus einem Grund,
* der kein Fehler ist: die Registry hängt dem App-Objekt die `appId` an (sie ist
* der Map-Schlüssel und liegt deshalb in `Data`), Vault kennt das Feld nicht.
* Ein `JSON.stringify`-Vergleich sähe darin eine Abweichung und meldete jede App
* als „~ abweichend" — ein Bericht, der nie grün wird, wird nicht gelesen.
*
* Verglichen wird deshalb genau das, was Vault **behauptet**: ein Feld, das in
* Vault fehlt, kann in der DB hinzukommen. Ein Feld, das Vault hat und die DB
* verloren hat, bleibt eine Abweichung.
*
* @param {unknown} shape - die Vault-Seite (bestimmt die Feldmenge)
* @param {unknown} value - die DB-Seite
* @returns {unknown}
*/
const projectOnto = (shape, value) => {
if (!shape || typeof shape !== 'object' || Array.isArray(shape)) return value;
const projected = {};
for (const key of Object.keys(shape)) projected[key] = value?.[key];
return projected;
};
/**
* Vergleicht zwei flache Maps (appId bzw. domain → Wert) und beschreibt die
* Unterschiede. Bewusst auf Schlüssel-Ebene: „Zahl stimmt, Inhalt nicht" ist
* der Fall, der bei einem reinen Zähler-Vergleich durchrutscht.
* @param {Record<string, unknown>} vault
* @param {Record<string, unknown>} db
* @returns {string[]} Zeilen für den Bericht (leer = gleich)
*/
export const diffMaps = (vault, db) => {
const lines = [];
const vaultKeys = Object.keys(vault).sort();
const dbKeys = Object.keys(db).sort();
for (const key of vaultKeys.filter((k) => !dbKeys.includes(k))) lines.push(` + fehlt in DB: ${key}`);
for (const key of dbKeys.filter((k) => !vaultKeys.includes(k))) lines.push(` - nur in DB: ${key}`);
for (const key of vaultKeys.filter((k) => dbKeys.includes(k))) {
const expected = JSON.stringify(vault[key]);
const actual = JSON.stringify(projectOnto(vault[key], db[key]));
if (expected !== actual) {
lines.push(` ~ abweichend: ${key}`);
if (VERBOSE) {
lines.push(` vault: ${expected}`);
lines.push(` db: ${actual}`);
}
}
}
return lines;
};
// ── Hauptlauf ─────────────────────────────────────────────────────────────────
const main = async () => {
// DB-Zugangsdaten stehen nicht im Container-Environment, sondern in Vault
// unter `GIT_SECRET_PATH`. Ohne diesen Schritt wäre `DB_DATABASE` leer und
// `recreateMainConnection` würde eine Verbindung zu `undefined` aufbauen.
// Genau dieselbe Reihenfolge wie beim App-Start in `server.js`.
await loadSecretsFromVault();
// Der Service liest seine Verbindung aus der Umgebung; ohne diesen Schritt
// wartet `query()` auf eine `mainConnection`, die nie entsteht.
await recreateMainConnection(process.env.DB_DATABASE);
await recreatePool(process.env.DB_DATABASE);
const listed = await getSecretsFromVault('orgas/data', { list: true }) ?? [];
// KV-v2-Listen liefern Verzeichnisse mit abschließendem "/" — als Org-ID
// zählt der Name davor.
let orgIds = listed.map((raw) => String(raw).replace(/\/$/, '')).filter(Boolean);
if (onlyOrg) orgIds = orgIds.filter((id) => id === onlyOrg);
// `orgas/data` ist ein Vault-Verzeichnis, keine Org-Liste: dort liegt z.B.
// auch `default` (eine Vorlage, keine Organisation). Solche Namen sind keine
// UID, und `U_UUID2BIN('default')` ergibt **NULL** — die Zeilen wären danach
// unter keinem `UIDBelongsTo` auffindbar, also für immer verwaist. Deshalb
// hier abweisen statt später aufräumen.
const notAnOrg = orgIds.filter((id) => !isValidUid(id));
orgIds = orgIds.filter((id) => isValidUid(id));
console.log(`Vault-Import ${DRY_RUN ? '(DRY RUN — es wird nichts geschrieben)' : '(schreibend)'}`);
console.log(`Organisationen: ${orgIds.length}${onlyOrg ? ` (gefiltert auf ${onlyOrg})` : ''}\n`);
if (notAnOrg.length > 0) {
console.log(`Übersprungen (keine Org-UID): ${notAnOrg.join(', ')}\n`);
}
const summary = { orgs: 0, withData: 0, written: 0, differences: 0 };
for (const orgId of orgIds) {
summary.orgs++;
const [vaultApps, vaultDomains] = await Promise.all([
getSecretsFromVault(`orgas/data/${orgId}/apps`).catch(() => null),
getSecretsFromVault(`orgas/data/${orgId}/domains`).catch(() => null),
]);
const apps = vaultApps ?? {};
const domains = vaultDomains ?? {};
const appCount = Object.keys(apps).length;
const domainCount = Object.keys(domains).length;
if (appCount === 0 && domainCount === 0) {
if (VERBOSE) console.log(` ${orgId}: nichts zu tun`);
continue;
}
summary.withData++;
// Ist-Zustand der DB — für den Bericht immer, für den Schreiblauf nur
// informativ (der Service macht selbst Upsert).
const dbApps = await registryService.getOrgApps(orgId);
const dbDomains = await registryService.getOrgDomains(orgId);
const appDiffs = diffMaps(apps, dbApps);
// Die Vault-Seite trägt hier eine **Zeichenkette** (`"verified"`), die DB
// seit der Umstellung `{ type, status }`. Ohne diese Übersetzung meldete
// der Bericht jede Domain als abweichend — obwohl beide dasselbe sagen.
const domainDiffs = diffMaps(normalizeDomainMap(domains), dbDomains);
if (appDiffs.length > 0 || domainDiffs.length > 0) summary.differences++;
console.log(` ${orgId}`);
console.log(` Vault: ${appCount} Apps, ${domainCount} Domains | DB: ${Object.keys(dbApps).length} Apps, ${Object.keys(dbDomains).length} Domains`);
if (appDiffs.length > 0) { console.log(' Apps:'); appDiffs.forEach((l) => console.log(l)); }
if (domainDiffs.length > 0) { console.log(' Domains:'); domainDiffs.forEach((l) => console.log(l)); }
if (appDiffs.length === 0 && domainDiffs.length === 0) console.log(' identisch');
if (!DRY_RUN) {
// Domains zuerst, dann Apps: das ist die Reihenfolge der
// Vault-Quelle und hält den Bericht lesbar. Abhängig sind die
// beiden nicht mehr — der App-Host steht im App-Objekt selbst,
// es gibt kein Domain-Objekt mehr, das vorher da sein müsste.
await registryService.saveOrgDomains(orgId, domains);
await registryService.saveOrgApps(orgId, apps);
summary.written++;
}
}
console.log(`\nZusammenfassung: ${summary.withData} von ${summary.orgs} Organisationen mit Daten, ${summary.differences} mit Abweichungen`);
if (DRY_RUN) {
console.log('DRY RUN — nichts geschrieben. Ohne --dry-run wird importiert.');
} else {
console.log(`${summary.written} Organisationen geschrieben.`);
console.log('Hinweis: erst nach diesem Lauf REGISTRY_READ_MODE auf "db" stellen — vorher "dual" lassen.');
}
};
// Nur beim direkten Aufruf starten. Ohne diese Sperre würde schon ein Import —
// etwa aus einem Test — die Datenbank verbinden und Vault abfragen.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main()
.then(() => process.exit(0))
.catch((e) => {
console.error('Import fehlgeschlagen:', e);
process.exit(1);
});
}