- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
7.4 KiB
TypeScript
165 lines
7.4 KiB
TypeScript
/**
|
|
* Vollständigkeitscheck der serverseitigen Modul-Durchsetzung.
|
|
*
|
|
* Jede mutierende Server-Action eines gegateten Moduls MUSS über einen
|
|
* `moduleGuard("<key>")`-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/<moduleKey>/*.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("<moduleKey>")` 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<string, string> = {
|
|
// 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",
|
|
// L9 Lotse: Modul-Toggle "lotse" selbst (darf nicht vom Modul-Guard abhängen) + Anrede; requireSession/requirePermission("tenant:manage")
|
|
"lotse-settings.ts": "EXEMPT",
|
|
"account.ts": "EXEMPT",
|
|
"tenant-switch.ts": "EXEMPT",
|
|
"webauthn.ts": "EXEMPT",
|
|
// L15 Testphase: Plattform-Wizard/-Aktionen (requirePlatformFullAdmin) und Mandanten-Export/Onboarding
|
|
// (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand)
|
|
"trial-platform.ts": "EXEMPT",
|
|
"trial-tenant.ts": "EXEMPT",
|
|
// L17 Pakete: Stufe/Chat-Plätze/hartes Limit durch den Betreiber (requirePlatformFullAdmin)
|
|
"plans-platform.ts": "EXEMPT",
|
|
// L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
|
|
"trial-signup.ts": "PUBLIC",
|
|
};
|
|
|
|
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.`);
|
|
}
|
|
// L15 Testphase: der Lese-Modus überspringt die Schreibsperre abgelaufener Testmandanten → in Actions verboten.
|
|
if (/moduleGuard\([^)]*read\s*:/.test(src)) {
|
|
errors.push(`${label}: moduleGuard(…, { read: true }) ist nur für Lesepfade erlaubt, nicht in Server-Actions.`);
|
|
}
|
|
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/<moduleKey>/; ` +
|
|
`Fundament-Dateien in scripts/check-module-guards.ts eintragen (Modul-Key oder "EXEMPT").`,
|
|
);
|
|
continue;
|
|
}
|
|
const src = readFileSync(full, "utf8");
|
|
if (mapped === "PUBLIC") {
|
|
// Öffentliche Actions (ohne Session): jede exportierte Action muss ein Rate-Limit prüfen.
|
|
const exportRe = /export async function (\w+)\s*\(/g;
|
|
const positions: { name: string; index: number }[] = [];
|
|
let pm: RegExpExecArray | null;
|
|
while ((pm = exportRe.exec(src))) positions.push({ name: pm[1], index: pm.index });
|
|
positions.forEach((p, i) => {
|
|
const body = src.slice(p.index, i + 1 < positions.length ? positions[i + 1].index : src.length);
|
|
if (!/(enforceTrialRateLimit|checkRateLimit|consumeRateLimit)\(/.test(body)) {
|
|
errors.push(`${entry}: öffentliche Action "${p.name}" ohne Rate-Limit-Prüfung.`);
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
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.`,
|
|
);
|