L15 Testphase & Onboarding: Selbstanmeldung mit Double-Opt-in, Plattform-Wizard, Nur-Lesen-Sperre, Export, Lebenszyklus-Job

- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt,
  Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status
- /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung),
  Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot,
  Enumeration-Schutz, Slug-Kollisionen
- Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern,
  umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit)
- Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi),
  Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil
- Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand
- Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding
- Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 19:01:47 +02:00
co-authored by Claude Opus 5
parent 6b8cdf543b
commit d9290a187c
79 changed files with 4576 additions and 19 deletions
+20
View File
@@ -57,6 +57,12 @@ const ACTION_MODULE: Record<string, string> = {
"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",
// L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
"trial-signup.ts": "PUBLIC",
};
const errors: string[] = [];
@@ -118,6 +124,20 @@ for (const entry of readdirSync(ACTIONS_DIR)) {
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)) {