/** * Vollständigkeitscheck der serverseitigen Modul-Durchsetzung. * * Jede mutierende Server-Action eines gegateten Moduls MUSS über einen * `moduleGuard("")`-Guard laufen (src/server/action-guard.ts), damit ein * für den Mandanten deaktiviertes Modul auch Writes serverseitig abweist. * * ── Konvention (Craftvia) ──────────────────────────────────────────────────── * * 1) Modul-Actions liegen in einem Unterordner je Modul: * src/server/actions//*.ts (auch tiefer verschachtelt) * Der Modul-Key wird aus dem ERSTEN Ordnernamen abgeleitet und muss in MODULE_KEYS * (src/lib/modules.ts) stehen, z. B. src/server/actions/work_orders/assign.ts → * Modul „work_orders". Keine Eintragung in diesem Skript nötig. * Jede solche Datei muss * - `moduleGuard("")` verwenden und * - jede `export async function` über `await guard(` laufen lassen. * Reine Hilfsdateien ohne exportierte async functions (z. B. schemas.ts) sind erlaubt; * Dateien, die mit `_` beginnen, werden als intern übersprungen. * * 2) Top-Level-Dateien src/server/actions/*.ts sind Fundament (Auth, Plattform, * Einstellungen …) und stehen in der expliziten Map ACTION_MODULE — entweder mit * Modul-Key (dann gelten die Regeln aus 1) oder "EXEMPT" (eigene Auth-Prüfung * erforderlich: require…-Guard oder auth()). * * Schlägt fehl (Exit 1 → prebuild/Gate rot), sobald * - eine Top-Level-Datei nicht zugeordnet ist ("vergessener Endpoint"), * - ein Modulordner keinen gültigen Modul-Key trägt, * - eine gegatete Datei den erwarteten moduleGuard nicht verwendet, oder * - eine exportierte Action nicht über `await guard(...)` läuft. */ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join, dirname, relative, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { MODULE_KEYS } from "../src/lib/modules"; const ACTIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "server", "actions"); /** Top-Level-Action-Datei → Modul-Key oder "EXEMPT" (Fundament mit eigener Auth). */ const ACTION_MODULE: Record = { // Plattform-Betrieb (Auth über die Plattform-Session) "admin.ts": "EXEMPT", "mail.ts": "EXEMPT", "backup-admin.ts": "EXEMPT", "backup-settings.ts": "EXEMPT", "platform.ts": "EXEMPT", "platform-users.ts": "EXEMPT", "platform-admins.ts": "EXEMPT", // SEC2: Passwort-Self-Service. Reset-Abläufe laufen bewusst OHNE Session; abgesichert // über Rate-Limit, Enumeration-Neutralität und single-use-Tokens. "auth-recovery.ts": "EXEMPT", // Mandanten-Fundament (requireSession/requirePermission) "tenant-users.ts": "EXEMPT", "tenant-settings.ts": "EXEMPT", "account.ts": "EXEMPT", "tenant-switch.ts": "EXEMPT", "webauthn.ts": "EXEMPT", }; const errors: string[] = []; let checked = 0; function checkGatedFile(label: string, src: string, moduleKey: string) { if (!(MODULE_KEYS as readonly string[]).includes(moduleKey)) { errors.push(`${label}: unbekannter Modul-Key "${moduleKey}" (nicht in src/lib/modules.ts).`); } const exportRe = /export async function (\w+)\s*\(/g; const positions: { name: string; index: number }[] = []; let m: RegExpExecArray | null; while ((m = exportRe.exec(src))) positions.push({ name: m[1], index: m.index }); if (positions.length === 0) return; // Hilfsdatei ohne Actions if (!src.includes(`moduleGuard("${moduleKey}")`)) { errors.push(`${label}: erwartet moduleGuard("${moduleKey}") — Modul-Gating fehlt oder falscher Key.`); } for (let i = 0; i < positions.length; i++) { const start = positions[i].index; const end = i + 1 < positions.length ? positions[i + 1].index : src.length; if (!/await guard\(/.test(src.slice(start, end))) { errors.push(`${label}: Action "${positions[i].name}" läuft nicht über await guard(...) — Modul-/Rechte-Guard fehlt.`); } } } function walk(dir: string): string[] { return readdirSync(dir).flatMap((name) => { const full = join(dir, name); return statSync(full).isDirectory() ? walk(full) : [full]; }); } for (const entry of readdirSync(ACTIONS_DIR)) { const full = join(ACTIONS_DIR, entry); if (statSync(full).isDirectory()) { // (1) Modulordner: Key = Ordnername. const moduleKey = entry; for (const file of walk(full).filter((f) => f.endsWith(".ts"))) { const rel = relative(ACTIONS_DIR, file).split(sep).join("/"); if (rel.split("/").some((seg) => seg.startsWith("_"))) continue; checked++; checkGatedFile(rel, readFileSync(file, "utf8"), moduleKey); } continue; } if (!entry.endsWith(".ts")) continue; // (2) Top-Level-Fundament-Datei. checked++; const mapped = ACTION_MODULE[entry]; if (!mapped) { errors.push( `Nicht zugeordnete Action-Datei: ${entry} — Modul-Actions gehören nach src/server/actions//; ` + `Fundament-Dateien in scripts/check-module-guards.ts eintragen (Modul-Key oder "EXEMPT").`, ); continue; } const src = readFileSync(full, "utf8"); if (mapped === "EXEMPT") { // Auth-Nachweis: ein require*-Guard ODER ein direkter auth()-Aufruf. if (!/require(Session|Platform\w*|Permission)|\bauth\(\)/.test(src)) { errors.push(`${entry}: als EXEMPT markiert, aber keine erkennbare Auth-Prüfung.`); } continue; } checkGatedFile(entry, src, mapped); } if (errors.length) { console.error("✗ Modul-Guard-Vollständigkeitscheck fehlgeschlagen:"); for (const e of errors) console.error(" - " + e); process.exit(1); } console.log( `✓ Modul-Guard-Vollständigkeitscheck: ${checked} Action-Dateien geprüft — alle mutierenden Actions sind modul- und rechtegegated.`, );