Plattformbetrieb
Betreiberreferenz zu Abuse-Status und Sende-Gate, zum Plattform-Admin-Verzeichnis, zur Inhaltsprüfung, zur Organisationslöschung, zum In-App-Selbstupdate, zu Dev-Endpunkten, Crons und Migrationen.
Diese Seite dokumentiert die operative Oberfläche, die hinter dem Produkt sitzt: den Lebenszyklus des Abuse-Status, der das Senden freischaltet, die Rolle des Plattform-Admins und ihre Queries, die Warteschlange zur Inhaltsprüfung, den Walker für die Organisationslöschung, Systemzustand und In-App-Selbstupdate sowie die Dev-Mode-Endpunkte, Crons und Migrationen, die ein Deployment wartbar halten. Sie richtet sich an Betreiber und Mitwirkende, die an einer selbst gehosteten Owlat-Instanz arbeiten.
Owlat ist auf eine Organisation pro Deployment ausgelegt. Die Data Plane gehört genau einer Organisation, und Abuse-Status, Einstellungen und Statistiken, die hier beschrieben werden, liegen allesamt in der Singleton-Zeile instanceSettings. Es gibt keinen mandantenfähigen Organisationswechsler.
Dieses OSS-Repository liefert eine Seite aus, die auf Plattform-Admins beschränkt ist: Einstellungen → System & Updates (/dashboard/admin/system). Die reichhaltigere Control-Plane-Oberfläche, die die untenstehenden Queries konsumiert hat, wurde in ein separates privates Repository ausgelagert. Behandeln Sie die hier dokumentierten Funktionen für Verzeichnis, Abuse-Status und Inhaltsprüfung als Backend-/API-Oberfläche, die Sie aus eigenem Tooling oder eigenen Skripten aufrufen — nicht als Dashboard im Produkt.
Lebenszyklus des Abuse-Status und das Sende-Gate
Jedes Deployment trägt genau einen Abuse-Status auf instanceSettings.abuseStatus. Er steuert, ob die Instanz senden darf. Die vier Status, definiert in apps/api/convex/organizations/abuseStatus.ts, bilden eine Schweregradleiter:
| Status | Schweregrad | Versand | Bedeutung |
|---|---|---|---|
clean | 0 | Erlaubt | Normalbetrieb (zugleich der Standard, wenn nie ein Status geschrieben wurde) |
warned | 1 | Erlaubt | Warnhinweis erteilt — weiterhin voll betriebsfähig |
suspended | 2 | Blockiert | Jeglicher Versand blockiert; das Konto bleibt zugänglich |
banned | 3 | Blockiert | Konto vollständig deaktiviert; terminal für interne Schreiber |
Lese- und Schreibhälfte sind bewusst in Geschwistermodule getrennt (ADR-0011, docs/adr/0011-abuse-status-modules.md):
- Abuse-Gate (
apps/api/convex/organizations/abuseGate.ts) ist die Heimat des Sende-Prädikats. Es exportiert zwei Oberflächen:isSendingAllowed(status), das reine Prädikat, das die Live-Sendepfade aufrufen (der Transaktions-Dispatch-Pfad intransactional/dispatch.ts), undrequireSendingAllowed(ctx), ein Helfer für den Mutation-Kontext, derinstanceSettingslädt und beisuspended/bannedwirft. Der Kampagnen-Sendepfad erzwingt dasselbe Prädikat inline über seine Pre-Flight-Query (campaigns/preflight.ts, dieabuseStatusdirekt gegensuspended/bannedprüft), statt den Helfer aufzurufen. So oder so läuft das Gate immer — es steht nicht hinter einem Feature-Flag. - Abuse-Status (
apps/api/convex/organizations/abuseStatus.ts) ist der einzige Schreiber vonabuseStatusund den zugehörigen Feldern (abuseStatusReason,abuseStatusChangedAt,abuseStatusChangedBy).
Übergangsregeln
Es gibt zwei Schreib-Einstiegspunkte. Der Pfad für interne Schreiber erzwingt die Schweregradregeln; der Admin-Pfad umgeht sie.
| Einstiegspunkt | Genutzt von | Regeln |
|---|---|---|
transition (interne Mutation) | MTA-Circuit-Breaker, reputationsbasierte Auto-Durchsetzung | banned ist terminal; Herabstufungen werden abgelehnt, außer hinunter auf clean (der Auto-Recover-Pfad); ein Versuch auf denselben Zustand wird protokolliert, aber nicht gepatcht |
adminOverride (interne Mutation) | Die Plattform-Admin-Mutation setOrganizationStatus | Umgeht alle Schweregradregeln — ein Admin kann eine banned-Organisation zurück auf clean stufen, um einen Widerspruch aufzulösen |
Beide Pfade schreiben bei jedem Aufruf eine Audit-Log-Zeile abuse_status_changed, auch bei No-Ops auf denselben Zustand (damit „Circuit Breaker erneut ausgelöst, während bereits gewarnt“ beobachtbar ist). Eine fehlende instanceSettings-Zeile liefert { ok: false, reason: 'no_settings_row' }, statt zu werfen — ein Sonderfall in der Frühphase eines Deployments.
Was den Status automatisch umschaltet
| Auslöser | Quelle | Zielstatus |
|---|---|---|
Reputation erreicht Risiko critical | analytics/sendingReputation.ts → evaluateAutoEnforce (stündlicher Cron) → autoEnforceReputation | suspended |
Reputation erreicht Risiko high | analytics/sendingReputation.ts → evaluateAutoEnforce (stündlicher Cron) → autoEnforceReputation | warned |
| MTA-Circuit-Breaker löst aus | webhooks/dispatcher.ts (internal.circuit_breaker_tripped) | warned |
Die Reputation wird beim Lesen abgeleitet (ein rollierendes 30-Tage-Fenster — siehe Deliverability-Infrastruktur), doch die Auto-Durchsetzung wird bewusst vom heißen Lesepfad ferngehalten: Ein stündlicher Cron evaluate reputation auto-enforce (apps/api/convex/crons.ts) ruft evaluateAutoEnforce auf, das das Org-Fenster zusammenfasst und autoEnforceReputation einplant, wenn das Risiko hoch oder kritisch ist. Ein separater stündlicher Cron cleanup sending reputation (recalculateAll) lässt Buckets älter als 60 Tage ausaltern. Weil Herabstufungen des Schweregrads abgelehnt werden, lockert ein Übergang critical → warned niemals stillschweigend eine bestehende Sperre.
Plattform-Admin-Rolle, Verzeichnis und operative Queries
Plattform-Admins sind Zeilen in der Tabelle platformAdmins, geschlüsselt über die BetterAuth-Benutzer-ID. Es gibt zwei Rollen: admin und superadmin. Ein Betreiber kann den ersten Admin von Hand anlegen mit convex run migrations/0036_seed_platform_admin:run; das gelingt nur, solange die Tabelle leer ist, und legt stets einen superadmin an. Danach verwalten superadmins das Verzeichnis.
Die Absicherung ist in apps/api/convex/platformAdmin/platformAdmin.ts zentralisiert:
| Funktion | Art | Zweck |
|---|---|---|
requirePlatformAdmin(ctx) | Helfer | Wirft FORBIDDEN, sofern der Aufrufer nicht in platformAdmins steht; liefert { authUserId, email, role } |
isPlatformAdmin | Öffentliche Query | Boolescher Navigationshelfer, sicher für anonyme Aufrufer |
isPlatformAdminByUserId | Interne Query | Admin-Prüfung anhand der Benutzer-ID, für HTTP-/Action-Kontexte ohne QueryCtx |
migrations/0036_seed_platform_admin:run | Von Hand ausgeführte interne Mutation | Optionaler einmaliger Bootstrap des ersten superadmin |
Die Web-App sichert die einzige Admin-Seite mit der Route-Middleware platform-admin (apps/web/app/middleware/platform-admin.ts) ab, die isPlatformAdmin aufruft und Nicht-Admins auf /dashboard umleitet.
Operative Queries
Alle diese liegen in apps/api/convex/platformAdmin/queries.ts und beginnen mit requirePlatformAdmin. Sie lesen die Singleton-Zeile instanceSettings sowie abgeleitete Reputations-/Statistikwerte:
| Query | Liefert |
|---|---|
getPlatformStats | Kontaktanzahl, Abuse-Status, 30-Tage-Aggregate zu Versand/Zustellung/Bounces/Beschwerden, Anmeldungen pro Tag |
getOrganizationDetail | Einstellungen, Reputationszusammenfassung, Anzahl blockierter E-Mail-Adressen (bounced/complained/manual), jüngste Inhaltsscans und Kampagnen |
listFlaggedOrganizations | Den Instanzeintrag, aber nur bei warned/suspended/banned oder wenn das Reputationsrisiko high/critical ist |
listAllOrganizations | Den Instanzeintrag mit optionalem search/statusFilter |
listAllUsers | Benutzerprofile mit optionalem search |
listRecentAbuse | Markierte Inhaltsscans (suspicious/blocked) sowie Kampagnen im Status pending_review |
listPlatformAdmins | Das vollständige Admin-Verzeichnis |
Mutationen für Verzeichnis + Status
Diese liegen in apps/api/convex/platformAdmin/mutations.ts:
| Mutation | Wirkung |
|---|---|
setOrganizationStatus | Setzt den Abuse-Status über adminOverride (unter Umgehung der Schweregradregeln); schreibt sowohl die Audit-Zeile abuse_status_changed als auch die veraltete Zeile platform_admin.org_status_changed |
addPlatformAdmin | Fügt einen Admin hinzu (nur superadmin; lehnt Duplikate ab) |
removePlatformAdmin | Entfernt einen Admin (nur superadmin; man kann sich nicht selbst entfernen) |
Warteschlange zur Inhaltsprüfung (ausstehende Inhalte freigeben/ablehnen)
Kampagnen und Transaktionsmails können im Status pending_review landen (etwa wenn das Content-Scanning sie markiert — siehe E-Mail-Sicherheit). Die Prüfoberfläche ist ausschließlich Plattform-Admins vorbehalten.
getContentReviewQueue (in platformAdmin/queries.ts) liefert die ausstehenden Objekte samt ihrem jüngsten Inhaltsscan-Ergebnis sowie das Protokoll der kürzlich geprüften Objekte:
{
pending: [{ type: 'campaign' | 'transactional', id, name, subject, scan: { score, level } | null, ... }],
pendingCount: number,
recentlyReviewed: [{ action, details, userId, createdAt }],
}
Die drei Prüf-Mutationen (in platformAdmin/mutations.ts) verlangen allesamt pending_review als aktuellen Status und schreiben eine Audit-Zeile platform_admin.content_approved oder platform_admin.content_rejected:
| Mutation | Wirkung auf die Ressource |
|---|---|
approveCampaign | Kampagne → draft (der Eigentümer kann sie dann versenden) |
approveTransactional | Transaktionsmail → published (setzt publishedAt) |
rejectContent | Kampagne → draft bzw. Transaktionsmail → draft; der Grund wird festgehalten |
Beide Freigabepfade und der Ablehnungspfad holen die Ressource aus der Warteschlange; es gibt keinen separaten terminalen Zustand „blockiert“ für geprüfte Inhalte — eine Ablehnung führt zurück auf draft, wobei der Grund im Audit-Log festgehalten wird.
Walker für die Organisationslöschung
Die Organisation zu löschen ist ein harter, unumkehrbarer Wipe der gesamten Data Plane. Er ist dem Eigentümer vorbehalten: organizationSettings.remove (in apps/api/convex/organizations/settings.ts) prüft session.role === 'owner' und plant internal.organizations.deletion.walker.start ein. Der Walker liegt in apps/api/convex/organizations/deletion/walker.ts und setzt ADR-0025 um (docs/adr/0025-organization-deletion-module-family.md).
remove löscht jede Tabelle pro Organisation, einschließlich Storage-Blobs, Audit-Logs und schließlich der Singleton-Zeile instanceSettings, die die Existenz der Organisation trägt. Es gibt kein Soft-Delete und keine Wiederherstellung außer einem Datenbank-Restore.
Funktionsweise:
- Geordnete Kaskade.
STEPSist eine geordnete Liste des vollständigen Tabellensatzes pro Organisation, ~90 Tabellen. Kinder werden vor Eltern gelöscht; Tabellen mit Storage-Bezug (z. B.mediaAssets,semanticFiles,mailMessages) räumen ihre Blobs vor dem Löschen der Zeile ab;auditLogssteht an vorletzter Stelle (es sammelt während des Wipes weiterhin Einträge aus delegierten Lifecycle-Aufrufen an);instanceSettingsist der abschließende Schritt. - Ein Modul pro Tabelle. Jeder Eintrag hat ein Geschwister-Step-Modul unter
deletion/steps/<table>.ts, das den ContractdeleteBatch(ctx)aussteps/_common.tsimplementiert (Standard-Batchgröße 100). Einige Tabellen delegieren — so fegt der Stepcontactsfünf Kindtabellen mit (contactTopics,contactPropertyValues,contactActivities,contactIdentities,contactRelationships), die keine eigenständigen Steps sind. - Selbstgeplanter Hop.
runStepführt einen Batch aus, feuert sich selbst erneut, solangehasMorewahr ist, rückt aufnextTablevor, wenn ein Step leerläuft, und endet, wenn es keinen nächsten Step gibt. Das Argumenttablewird gegen die Literal-Union in_common.tsvalidiert, sodass ein Tippfehler ein Compile- und Boot-Zeit-Fehler ist statt eines stillen No-Ops.
Jede neue Tabelle pro Organisation, die dem Schema hinzugefügt wird, muss außerdem ein Literal in OrganizationDeletionTable und ein Geschwister-Step-Modul ergänzen, sonst lässt die Löschkaskade sie verwaist zurück.
Systemzustand und In-App-Selbstupdate (Updater-Sidecar)
Die Seite System & Updates (/dashboard/admin/system, nur für Plattform-Admins) zeigt die aktuelle Version, den Container-Zustand, die GitHub-Release-Prüfung, den Update-Ablauf und die Update-Historie.
Update-Prüfung
apps/api/convex/systemUpdates.ts fragt die GitHub-Releases-API ab (https://api.github.com/repos/wolvesdotink/owlat/releases/latest), cacht das Ergebnis in einer Singleton-Zeile systemUpdates (kind: 'latestCheck') mit einer TTL von 1 Stunde und berechnet updateAvailable, indem es die gecachte neueste Version mit der laufenden OWLAT_VERSION vergleicht.
| Funktion | Art | Hinweise |
|---|---|---|
checkForUpdates | Action | Admin-gesichert; respektiert den 1-Stunden-Cache, sofern nicht force: true; liefert bei GitHub-Rate-Limit 403/429 den Cache zurück |
getLatestRelease | Query | Liest die gecachte Zeile der letzten Prüfung |
listUpdateHistory | Query | Listet Zeilen mit kind: 'updateRun' (neueste zuerst; auf 200 begrenzt) |
Ein lokaler Build, der OWLAT_VERSION=dev (oder einen Nicht-Semver-Wert) meldet, gilt immer als „kein Update verfügbar“, weil Owlat nicht feststellen kann, ob ein Dev-Build dem Release-Tag voraus oder hinterher ist.
Ein Update anwenden
Ein Klick auf Jetzt aktualisieren postet an die Nitro-Route apps/web/server/api/system/update.post.ts, die:
Den Aufrufer verifizieren
Die Route prüft den Plattform-Admin-Status über das Session-Cookie erneut und verlangt, dass INSTANCE_SECRET konfiguriert ist.
Die gepinnte Compose-Datei herunterladen und verifizieren
Sie holt docker-compose-<version>.yml und das zugehörige .sha256-Manifest vom GitHub-Release, verifiziert den SHA-256-Hash und bestätigt, dass der Inhalt ghcr.io/wolvesdotink/web:<version> referenziert. Ein Hash-Mismatch bricht das Update ab.
Den Versuch protokollieren
Sie hält über recordUpdateStart eine updateRun-Zeile fest und erfasst dabei versionFrom/versionTo.
An den Updater-Sidecar übergeben
Sie POSTet das verifizierte Compose-Template mit dem Header X-Instance-Secret an http://updater:3200/update.
Das Ergebnis protokollieren
Sie hält recordUpdateFinish mit success/failed und der Ausgabe des Schritts fest. Beide Protokoll-Mutationen geben zusätzlich eine strukturierte JSON-Logzeile für externe Log-Senken aus.
Der Updater-Sidecar
Der Updater (apps/updater/src/index.ts, Image ghcr.io/wolvesdotink/updater, lauscht auf Port 3200, ausschließlich in einem internen Docker-Netzwerk exponiert (kein Host-Port-Mapping), sodass ihn nur der Web-Container über http://updater:3200 erreichen kann) ist die einzige Komponente, die Docker anfasst. Er authentifiziert jede Anfrage mit einem timing-sicheren Vergleich von X-Instance-Secret und erreicht Docker über einen Socket-Proxy nach dem Least-Privilege-Prinzip (er kann Container pullen/neu erstellen/auflisten, aber nicht exec ausführen, bauen oder Volumes anfassen).
| Endpunkt | Methode | Zweck |
|---|---|---|
/update | POST | Das Compose-Template gegen eine Image-Allowlist sowie Regeln zu gefährlichen Mounts/Privileged-Mode validieren, es schreiben, docker compose pull, einen einmaligen convex-deploy ausführen (Funktions-Deploy vor dem Neustart — ein fehlerhaftes Schema bricht hier ab und die alten Container bedienen weiter), dann docker compose up -d |
/health | GET | Containerzustand, Image-Tag und Health pro Service (authentifizierungspflichtig, um Enumeration zu verhindern) |
/configure-ip | POST | Eine Floating-IPv4 an eth0 und den Kampagnen-IP-Pool an-/abhängen, dann den MTA neu starten |
/rotate-env | POST | Rotierte Secrets in .env neu schreiben und Container erzwungen neu erstellen (verlangt alle Secret-Felder; strengstes Rate-Limit) |
Die Seite System & Updates liest /health (über apps/web/server/api/internal/updater-health.get.ts weitergereicht), um die Tabelle zum Container-Zustand zu rendern. Die Reihenfolge convex-deploy vor Neustart ist die Sicherheitseigenschaft: Ein inkompatibles Schema lässt den Deploy-Schritt scheitern, und die laufenden Web-/MTA-Container werden nie gegen ein halb deploytes Backend neu gestartet.
Der In-App-Updater wendet Images und Compose-Templates aus der GitHub-Release-Pipeline an; er ist der unterstützte Weg für Selbstupdates. Der Auto-Update-Schlüssel der Desktop-App ist ein leerer Platzhalter, daher ist das Auto-Update auf dem Desktop nicht produktionsreif und die Desktop-Distribution stellt der Betreiber bereit — siehe Desktop-App.
Das betreiberseitige Playbook für Updates (Rollback, Wiederherstellung, Automatisierung) finden Sie unter Wartung & Updates.
Dev-Mode-Endpunkte, Crons und Migrationen
Dev-Mode-Endpunkte
Für die lokale Entwicklung existiert eine kleine Menge destruktiver Abkürzungen. Sie sind fail-closed: Der Guard in apps/api/convex/devShortcuts/_guard.ts behandelt das Deployment als Produktion, sofern der Betreiber nicht ausdrücklich OWLAT_DEV_MODE=true in der Laufzeitumgebung des Convex-Backends setzt (npx convex env set OWLAT_DEV_MODE true). Die CLI-seitige Umgebungsvariable CONVEX_DEPLOYMENT wird nicht in die Function-Runtime propagiert und taugt daher nicht als Sicherheitsgrenze.
| Endpunkt | Guard | Zweck |
|---|---|---|
POST /dev/reset | OWLAT_DEV_MODE + X-Instance-Secret | Die Instanz auf einen leeren Stand zurücksetzen (Mandantentabellen, BetterAuth-Tabellen und lokale Auth-Tabellen), damit der Signup-Flow ohne docker compose down -v erneut durchlaufen werden kann; idempotent |
POST /seed/demo | OWLAT_DEV_MODE + X-Instance-Secret | Realistische Demo-Inhalte einspielen (idempotent) |
forceVerifyDomain | OWLAT_DEV_MODE + organization:manage | Eine Domain mit synthetisierten DNS-Ergebnissen zwangsweise auf verified setzen und dabei Live-DNS-Abfragen umgehen |
/dev/reset löscht alles auf Mandantenseite — nicht nur Zeilen mit Seed-Tag. Lassen Sie OWLAT_DEV_MODE auf jedem Deployment ungesetzt, das echte Daten hält.
Der Wunsch nach Demo-Inhalten ist kein Grund, ihn zu aktivieren. POST /sample-data/{install,remove,status} (apps/api/convex/sampleData/) spielt denselben Datensatz abzüglich der Dummy-Anmeldungen und Postfächer ein, wird allein mit X-Instance-Secret authentifiziert und entfernt exakt das, was es eingefügt hat (auf seedTag eingegrenzt, innerhalb der Löschtransaktion erneut geprüft). Steuern Sie ihn mit owlat sample-data <install|remove|status>.
Crons
Geplante Jobs sind in apps/api/convex/crons.ts registriert. Operativ relevant sind unter anderem:
| Cron | Intervall | Job |
|---|---|---|
| process scheduled campaigns | 1 Min. | Absichernder Versand für Kampagnen, deren scheduledAt verstrichen ist |
| reconcile sending campaigns | 1 Min. | sending → sent fortschreiben, wenn keine eingereihten Sendungen mehr offen sind |
| process account deletions | 24 Std. | Konten nach Ablauf ihrer 30-tägigen Karenzzeit verarbeiten |
| cleanup webhook logs | wöchentlich | Zustellprotokolle löschen, die älter als 30 Tage sind |
| cleanup sending reputation | 1 Std. | Reputations-Buckets älter als 60 Tage ausaltern lassen (beide Scopes) |
| cleanup soft-deleted contacts | 24 Std. | Kontakte nach Ablauf ihrer 30-tägigen Aufbewahrung endgültig löschen |
| knowledge graph maintenance | 24 Std. | Konfidenzabfall + Bereinigung abgelaufener Einträge |
| channel health checks | 5 Min. | Channel-Konnektivität prüfen |
| agent metrics rollup | 5 Min. | Queue-Tiefe/Latenz/Fehlerraten berechnen und Circuit Breaker auswerten |
| report analytics | 15 Min. | Instanz-Analytics an die Control Plane melden |
Die Crons zum Zählerabgleich (reconcile contact counts, reconcile topic member counts, refresh segment counts, reconcile transactional send counts) halten denormalisierte Zähler gegenüber jeglicher Drift aus Teilfehlern ehrlich.
Migrationen
Schema-/Datenmigrationen sind einmalige interne Mutationen unter apps/api/convex/migrations/, die jeweils eine run-Mutation exportieren. Sie folgen dem Pre-Prod-Muster der „atomaren Breaking Change“: Jede Migration ist idempotent (ein erneuter Lauf ist ein No-Op, sobald jede Zeile umgeschrieben ist), und weil Deployments einorganisatorisch und zurücksetzbar sind, laufen die meisten synchron gegen .collect().
Sie rufen eine Migration namentlich über die Convex-CLI auf, zum Beispiel:
npx convex run migrations/0033_campaign_audience:run
Owlats Konvention sind saubere Breaking Changes plus ein Daten-Reset statt der Zeremonie zweiphasiger, rückwärtskompatibler Migrationen. Mehrere Migrationen halten fest, dass sie auf einem frisch eingespielten Deployment keine Altzeilen vorfinden und lediglich eine idempotente Absichtserklärung sind — sollte ein Deployment doch einmal Altzeilen tragen, die eine Migration nicht abbilden kann, wirft die Migration lautstark, statt Daten stillschweigend zu verwerfen.