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.

Eine Organisation pro Deployment

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.

Kein Plattform-Admin-Dashboard in diesem Repository

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:

StatusSchweregradVersandBedeutung
clean0ErlaubtNormalbetrieb (zugleich der Standard, wenn nie ein Status geschrieben wurde)
warned1ErlaubtWarnhinweis erteilt — weiterhin voll betriebsfähig
suspended2BlockiertJeglicher Versand blockiert; das Konto bleibt zugänglich
banned3BlockiertKonto 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 in transactional/dispatch.ts), und requireSendingAllowed(ctx), ein Helfer für den Mutation-Kontext, der instanceSettings lädt und bei suspended/banned wirft. Der Kampagnen-Sendepfad erzwingt dasselbe Prädikat inline über seine Pre-Flight-Query (campaigns/preflight.ts, die abuseStatus direkt gegen suspended/banned prü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 von abuseStatus und 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.

EinstiegspunktGenutzt vonRegeln
transition (interne Mutation)MTA-Circuit-Breaker, reputationsbasierte Auto-Durchsetzungbanned 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 setOrganizationStatusUmgeht 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öserQuelleZielstatus
Reputation erreicht Risiko criticalanalytics/sendingReputation.tsevaluateAutoEnforce (stündlicher Cron) → autoEnforceReputationsuspended
Reputation erreicht Risiko highanalytics/sendingReputation.tsevaluateAutoEnforce (stündlicher Cron) → autoEnforceReputationwarned
MTA-Circuit-Breaker löst auswebhooks/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:

FunktionArtZweck
requirePlatformAdmin(ctx)HelferWirft FORBIDDEN, sofern der Aufrufer nicht in platformAdmins steht; liefert { authUserId, email, role }
isPlatformAdminÖffentliche QueryBoolescher Navigationshelfer, sicher für anonyme Aufrufer
isPlatformAdminByUserIdInterne QueryAdmin-Prüfung anhand der Benutzer-ID, für HTTP-/Action-Kontexte ohne QueryCtx
migrations/0036_seed_platform_admin:runVon Hand ausgeführte interne MutationOptionaler 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:

QueryLiefert
getPlatformStatsKontaktanzahl, Abuse-Status, 30-Tage-Aggregate zu Versand/Zustellung/Bounces/Beschwerden, Anmeldungen pro Tag
getOrganizationDetailEinstellungen, Reputationszusammenfassung, Anzahl blockierter E-Mail-Adressen (bounced/complained/manual), jüngste Inhaltsscans und Kampagnen
listFlaggedOrganizationsDen Instanzeintrag, aber nur bei warned/suspended/banned oder wenn das Reputationsrisiko high/critical ist
listAllOrganizationsDen Instanzeintrag mit optionalem search/statusFilter
listAllUsersBenutzerprofile mit optionalem search
listRecentAbuseMarkierte Inhaltsscans (suspicious/blocked) sowie Kampagnen im Status pending_review
listPlatformAdminsDas vollständige Admin-Verzeichnis

Mutationen für Verzeichnis + Status

Diese liegen in apps/api/convex/platformAdmin/mutations.ts:

MutationWirkung
setOrganizationStatusSetzt 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
addPlatformAdminFügt einen Admin hinzu (nur superadmin; lehnt Duplikate ab)
removePlatformAdminEntfernt 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:

MutationWirkung auf die Ressource
approveCampaignKampagne → draft (der Eigentümer kann sie dann versenden)
approveTransactionalTransaktionsmail → published (setzt publishedAt)
rejectContentKampagne → 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).

Unumkehrbar

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. STEPS ist 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; auditLogs steht an vorletzter Stelle (es sammelt während des Wipes weiterhin Einträge aus delegierten Lifecycle-Aufrufen an); instanceSettings ist der abschließende Schritt.
  • Ein Modul pro Tabelle. Jeder Eintrag hat ein Geschwister-Step-Modul unter deletion/steps/<table>.ts, das den Contract deleteBatch(ctx) aus steps/_common.ts implementiert (Standard-Batchgröße 100). Einige Tabellen delegieren — so fegt der Step contacts fünf Kindtabellen mit (contactTopics, contactPropertyValues, contactActivities, contactIdentities, contactRelationships), die keine eigenständigen Steps sind.
  • Selbstgeplanter Hop. runStep führt einen Batch aus, feuert sich selbst erneut, solange hasMore wahr ist, rückt auf nextTable vor, wenn ein Step leerläuft, und endet, wenn es keinen nächsten Step gibt. Das Argument table wird gegen die Literal-Union in _common.ts validiert, 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.

FunktionArtHinweise
checkForUpdatesActionAdmin-gesichert; respektiert den 1-Stunden-Cache, sofern nicht force: true; liefert bei GitHub-Rate-Limit 403/429 den Cache zurück
getLatestReleaseQueryLiest die gecachte Zeile der letzten Prüfung
listUpdateHistoryQueryListet 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).

EndpunktMethodeZweck
/updatePOSTDas 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
/healthGETContainerzustand, Image-Tag und Health pro Service (authentifizierungspflichtig, um Enumeration zu verhindern)
/configure-ipPOSTEine Floating-IPv4 an eth0 und den Kampagnen-IP-Pool an-/abhängen, dann den MTA neu starten
/rotate-envPOSTRotierte 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.

Vom Betreiber bereitgestellte Distribution

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.

EndpunktGuardZweck
POST /dev/resetOWLAT_DEV_MODE + X-Instance-SecretDie 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/demoOWLAT_DEV_MODE + X-Instance-SecretRealistische Demo-Inhalte einspielen (idempotent)
forceVerifyDomainOWLAT_DEV_MODE + organization:manageEine Domain mit synthetisierten DNS-Ergebnissen zwangsweise auf verified setzen und dabei Live-DNS-Abfragen umgehen
OWLAT_DEV_MODE niemals in Produktion aktivieren

/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:

CronIntervallJob
process scheduled campaigns1 Min.Absichernder Versand für Kampagnen, deren scheduledAt verstrichen ist
reconcile sending campaigns1 Min.sending → sent fortschreiben, wenn keine eingereihten Sendungen mehr offen sind
process account deletions24 Std.Konten nach Ablauf ihrer 30-tägigen Karenzzeit verarbeiten
cleanup webhook logswöchentlichZustellprotokolle löschen, die älter als 30 Tage sind
cleanup sending reputation1 Std.Reputations-Buckets älter als 60 Tage ausaltern lassen (beide Scopes)
cleanup soft-deleted contacts24 Std.Kontakte nach Ablauf ihrer 30-tägigen Aufbewahrung endgültig löschen
knowledge graph maintenance24 Std.Konfidenzabfall + Bereinigung abgelaufener Einträge
channel health checks5 Min.Channel-Konnektivität prüfen
agent metrics rollup5 Min.Queue-Tiefe/Latenz/Fehlerraten berechnen und Circuit Breaker auswerten
report analytics15 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
In der Pre-Prod lieber zurücksetzen als nachziehen

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.

Verwandte Seiten