Die Galaxie
ORBIT II · KONSTELLATION

Ops & Security

Die Disziplin

Der Unterschied zwischen einem Projekt und einem Vorfall. Secrets, Bedrohungsmodell, adversariales Audit und Isolation zwischen Projekten. Disziplin, die die ganze Galaxie schützt.

Versorgt Alle Projekte
6 Module · 6 Lektionen
MODULE DER KONSTELLATION Öffne jedes Modul für die ganze Lektion
OS-01

Secrets: öffentlich vs. privat

Lektion

Für jeden Schlüssel und jedes Token deiner Projekte entscheiden, ob es im Client leben darf oder den Server niemals verlassen darf — und den Entscheidungsbaum bauen, der dich davor bewahrt, den Fehler je wieder zu machen.

Ein einziger service_role key, der in einem Astro-Bundle landet, bedeutet Vollzugriff auf deine Datenbank unter Umgehung von RLS. Das ist kein Bug, den man eben patcht: Es ist ein Einbruch, der dich zwingt, Credentials zu rotieren, Zugriffe zu auditieren und — falls Nutzerdaten betroffen sind — zu benachrichtigen. Der Preis für einen Fehler ist hier der höchste der ganzen Galaxie.

DIE LEKTION

Fang mit der mentalen Regel an, die nie versagt: Alles, was in eine statische Seite kompiliert wird, ist öffentlich. MOONKEY LAB ist Astro 4 im SSG-Modus — der Build erzeugt plain HTML+JS, das von Cloudflare Pages ausgeliefert wird. Es gibt keinen Server, der zur Laufzeit Secrets hütet. Jeder String, den du in eine .astro-Komponente importierst und der beim Client ankommt, landet buchstäblich in einer Datei, die jeder mit `view-source` oder über DevTools → Sources herunterlädt. Genau deshalb enthält `src/lib/supabase.ts` AUSSCHLIESSLICH den anon key. Der anon key ist dafür GEMACHT, öffentlich zu sein: Er ist eine Projekt-Kennung plus ein Token mit den Rechten der Rolle `anon`, dessen tatsächliche Autorität Postgres RLS begrenzt. Er ist kein Secret; er ist eine Adresse mit einem Türsteher.

Jetzt der Kontrast, den du verinnerlichen musst. Es gibt zwei Supabase-Schlüssel mit identischem Aussehen (ein langer JWT), aber mit gegensätzlichen Macht-Universen. Der `anon` key arbeitet unter der Rolle `anon`/`authenticated` und ist RLS UNTERWORFEN: Er sieht nur, was die Policies erlauben. Der `service_role` key arbeitet mit `BYPASSRLS`: Er ignoriert alle Policies, liest und schreibt jede Zeile jeder Tabelle — einschliesslich der von XHUB IRON (`iron_*`, `world_*`), die sich dieselbe Instanz `wuchsslgbqlhyxljsmxi` teilen. Taucht der `service_role` im Client auf, kompromittiert ein Angreifer nicht nur MOONKEY: Er pivotiert auf alle Projekte, die in dieser DB leben. Harte Regel: Der `service_role` kommt NIEMALS in ein Frontend-Repo, nicht in ein `.astro`, nicht in ein `import.meta.env.PUBLIC_*`. Sein Platz ist ein Backend mit Session (eine Edge Function, ein Server-Prozess) oder dein lokaler Schlüsselbund — niemals das Bundle.

Der häufigste Leck-Vektor ist nicht, den Schlüssel in den Code zu kleben: Es ist das Präfix der Environment-Variable. In Astro wird `import.meta.env.PUBLIC_X` zur Build-Zeit ins Client-Bundle INLINED; `import.meta.env.X` (ohne `PUBLIC_`) existiert nur im Server-/Build-Kontext und wird NICHT exponiert. Vite/Astro machen das mit Absicht. Der klassische Fehler: `PUBLIC_SUPABASE_SERVICE_KEY` benennen, 'damit die Komponente ihn sieht'. Das erreichst du — und schenkst ihn der ganzen Welt. Dein Entscheidungsbaum zum Benennen einer Env-Var: Darf dieser Schlüssel öffentlich sein? Ja → `PUBLIC_`. Nein → kein Präfix, und frag dich obendrein, was er in einem SSG-Projekt überhaupt verloren hat (wahrscheinlich gehört er gar nicht dorthin).

Bau das Inventar. Öffne dein Projekt und klassifiziere jedes Credential in einer Tabelle mit drei Spalten: Name · Klasse (öffentlich/privat) · wo es heute lebt. In MOONKEY sind die echten Einträge: `PUBLIC_SUPABASE_URL` (öffentlich, Kennung), `PUBLIC_SUPABASE_ANON_KEY` (öffentlich, durch RLS begrenzt) und der `service_role` (privat — sollte in diesem Repo schlicht GAR NICHT existieren; brauchst du ihn für ein Migrations-Skript, lebt er in deiner lokalen Shell oder in einem CI-Secret, niemals in `src/`). Ergänze jedes Drittanbieter-Token, das du anfasst: Schlüssel für transaktionale E-Mails, Webhooks, server-seitige Analytics-Tokens. Jede private Zeile, die auf der falschen Seite steht, ist ein Vorfall, der nur auf seinen Auftritt wartet.

Die Kontrolle, die den Kreis schliesst, ist die automatische Erkennung — denn menschliche Disziplin versagt. Vor jedem Commit willst du, dass etwas aufschreit, wenn sich ein privater Schlüssel einschleicht. Das Minimal-Muster: ein grep im pre-commit, der nach dem Muster eines `service_role` sucht (Supabase-Service-JWTs tragen `"role":"service_role"` im base64-Payload) und nach Wörtern wie `service_role`, `BEGIN PRIVATE KEY`, `sk-`. Für echte Repos nimm `gitleaks` oder `trufflehog` als Hook. Und falls ein Schlüssel BEREITS in einem vergangenen Commit geleakt ist: Ihn aus dem Code zu löschen reicht nicht — er steckt weiter in der Git-History. Du musst ihn im Supabase-Panel ROTIEREN (Settings → API → roll key) und die History umschreiben, falls es ein hartes Secret war. Rotieren ist Pflicht; die Datei zu löschen widerruft gar nichts.

Schliess mit dem Fall Espejo, der eine ethische Achse hinzufügt, nicht nur eine technische. Espejo ist multi-tenant: Jeder Influencer-Tenant hat seine eigenen Daten. Hier verdoppelt sich die Frage 'öffentlich vs. privat': Neben Infrastruktur-Schlüsseln verwaltest du personenbezogene/einwilligte Daten von Endnutzern. Der Service-Schlüssel, der ACROSS Tenants lesen könnte, ist das sensibelste Secret des Produkts — sein Leck ist nicht nur technisch, es verletzt die Datengrenze, die das Projektgedächtnis als nicht verhandelbar markiert. Querschnitts-Lektion: Ein privates Credential definiert sich nicht über sein Format, sondern über das, was es entsperrt. Miss es immer an seinem blast radius.

ÜBUNG

In deinem Projekt (nimm MOONKEY oder ein eigenes): 1) Erstelle `SECRETS.md` mit der Inventar-Tabelle (Name · Klasse · Ort · blast radius) für jedes Credential, das das Projekt anfasst. 2) Führe `grep -rn "service_role\|BEGIN.*PRIVATE KEY\|sk-" src/ .env* 2>/dev/null` aus und dokumentiere, was gefunden wurde (idealerweise nichts in `src/`). 3) Schreibe einen Hook `.git/hooks/pre-commit`, der diesen grep über die gestagten Dateien laufen lässt (`git diff --cached --name-only`) und mit exit 1 abbricht, falls es einen Treffer gibt. 4) Verifiziere den Hook, indem du versuchst, eine Testdatei mit dem String `"role":"service_role"` zu committen, und bestätige, dass er blockiert.

ERGEBNIS

`SECRETS.md` mit der vollständigen Inventar-Tabelle (jedes Credential nach Klasse und blast radius klassifiziert) plus ein ausführbarer Hook `.git/hooks/pre-commit`, der Commits mit privaten Secrets blockiert, getestet gegen einen Fall, der scheitern muss.

KERNEINSICHT

Ein Credential ist nicht geheim, weil es so aussieht, sondern wegen dem, was es entsperrt: anon key und service_role sind beide optisch identische JWTs, und einer ist per Design öffentlich, während der andere die ganze Galaxie kompromittiert. Klassifiziere immer nach blast radius, nie nach Format.

ZU VERMEIDENDE FEHLER

  • ×Einem privaten Schlüssel das Präfix `PUBLIC_` geben, 'damit die Komponente ihn liest' — Astro inlined ihn ins Bundle und veröffentlicht ihn an die Welt.
  • ×Glauben, das Löschen des Schlüssels aus der Datei widerrufe ihn: Er steckt weiter in der Git-History und bleibt aktiv, bis du ihn im Panel des Anbieters ROTIERST.
  • ×Den anon key wie ein Secret behandeln und verschleiern: Du verschwendest Zeit damit, etwas Öffentliches zu schützen, während die echte Autorität (RLS) ungeprüft bleibt.
  • ×Annehmen, eine SSG habe 'keine exponierten Secrets', ohne das generierte Bundle zu auditieren: Was zählt, ist, was in `dist/` steckt, nicht was in `src/` steht.
  • ×Den service_role in ein im Repo versioniertes Migrations-Skript packen, statt ihn zur Laufzeit aus der lokalen Shell oder aus einem CI-Secret zu lesen.
OS-02

Bedrohungsmodell einer SSG

Lektion

Wie der Angreifer einer statischen Seite mit anon key denken: die reale Angriffsfläche einer SSG + Supabase aufzählen und verstehen, warum die einzige Verteidigung, die zählt, RLS ist — nicht der Client.

Viele schützen die falsche Tür: Sie verstecken das Admin-Panel im Frontend und lassen die DB offen. Eine statische Seite hat keinen Server, der irgendetwas validiert — wenn dein Bedrohungsmodell nicht von 'der Client ist feindlich und vom Angreifer kontrolliert' ausgeht, baust du dekorative Verteidigungen, die man mit curl umgeht.

DIE LEKTION

Nimm den richtigen mentalen Rahmen an: In einer SSG ist der Client nicht deine Anwendung, er ist Feindesland. Der Angreifer hat dein komplettes Bundle (es ist öffentlich), deine `PUBLIC_SUPABASE_URL`, deinen anon key und das Wissen, dass dahinter Postgres steckt. Er muss dein JavaScript nicht 'hacken': Er liest es. Er kann seinen eigenen Supabase-Client mit DEINEM anon key aus einer Node-Konsole instanziieren und direkt mit deiner DB sprechen, ohne je durch dein HTML zu gehen. Also ist jede Sicherheitslogik, die in `.astro` oder im Client-JS lebt — ein `if (user.role === 'admin')`, eine Komponente, die sich versteckt — UX, nicht Security. Das CLAUDE.md von MOONKEY sagt es wörtlich: `admin.astro` ist UX; die Sicherheit hängt NICHT von diesem Gate ab.

Zähl die konkrete Angriffsfläche auf. Für MOONKEY gibt es fünf mit dem anon key erreichbare Tabellen: `profiles, progress, feedback, leads, proofs`. Der Angreifer wird mit einem über Magic-Link authentifizierten Client (jeder kann sich registrieren) das Naheliegende versuchen: `supabase.from('profiles').select('*')`, um ALLE Profile abzuziehen; `supabase.from('leads').select('*')`, um die Lead-Liste zu stehlen; ein `update` auf seine eigene Zeile in `profiles` mit `role = 'admin'` oder `founder_badge = true`, um sich selbst hochzustufen; ein `insert` in `proofs` mit fremdem `user_id`, um den Fortschritt eines anderen zu fälschen. Jede davon ist eine Angriffshypothese, die DU mit einer Policy widerlegen musst. Hast du sie nicht explizit widerlegt, geh davon aus, dass sie funktioniert.

Versteh, warum RLS die Autorität ist und wie sich die echte Verteidigung von MOONKEY zusammensetzt. SELECT auf `profiles/progress/feedback/proofs` ist self-or-admin: Die Policy lässt die Zeile nur durch, wenn `user_id = auth.uid()` ODER `is_admin()`. `is_admin()` ist SECURITY DEFINER — es läuft mit den Rechten des Eigentümers und prüft, ob der Aufrufer Admin ist, ohne dass der Aufrufer diese Entscheidung manipulieren kann. Ergebnis: Ein Nicht-Admin, der `select('*')` anfragt, bekommt keinen Fehler, er bekommt SEINE Zeilen und sonst nichts — der Massen-Dump liefert eine einzige Zeile, seine eigene. Die Privilegien-Eskalation stoppt ein Trigger (`guard_privileged_profile_columns`): `role` und `founder_badge` sind für Nicht-Admins unveränderlich, also wird das `update role='admin'` auf DB-Ebene abgelehnt. Den fremden Insert stoppt `proofs_insert_self`: Es verlangt eine Session und bindet `user_id = auth.uid()`.

Die operative Konsequenz des Modells: Verifiziere durch Impersonation, nicht durch Code-Lektüre. Eine Policy, die korrekt 'aussieht', kann ein Loch haben (ein falsch gesetztes OR, ein USING ohne WITH CHECK, das Updates durchlässt). Der einzige Beweis, der zählt, ist, dich in die Schuhe des Angreifers zu stellen: Authentifiziere dich als normaler Nutzer und FÜHRE die oben aufgezählten Angriffe gegen die echte DB AUS, und bestätige, dass jeder leer oder mit Fehler zurückkommt. CLAUDE.md erwähnt, dass die Lese-Isolation 'durch Impersonation verifiziert' ist — das ist der Standard. 'Ich habe die Policy geschrieben' ist kein Beweis; 'ich habe den Angriff als Nutzer X versucht und die Zeile von Nutzer Y nicht gelesen' schon.

Vergiss die Vektoren nicht, die kein RLS sind. Das Bedrohungsmodell einer SSG umfasst auch: (a) den Unterschied USING vs. WITH CHECK — USING filtert, welche Zeilen du siehst/betriffst, WITH CHECK validiert den Endzustand eines INSERT/UPDATE; ein vergessenes WITH CHECK in einem UPDATE lässt dich eine Zeile auf einen fremden user_id verschieben. (b) Die RPCs: Eine schlecht geschriebene SECURITY-DEFINER-Funktion ist ein Loch, das RLS per Design umgeht — MOONKEY beschränkt sie auf `authenticated` und entzieht gefährliche Operationen. (c) TRUNCATE: anon/authenticated entzogen, weil RLS nicht gegen TRUNCATE schützt (es löscht die ganze Tabelle, ohne Policies pro Zeile auszuwerten). (d) Leck durch Fehler und durch Spalten: ein SELECT, das eine sensible Spalte zurückgibt, die du auszuschliessen vergessen hast.

Land mit dem Projekt-Kontrast, um das Prinzip zu fixieren. XNLAB ist eine Markenseite fast ohne Backend: Ihre Fläche ist minimal — Kontaktformulare, Header, vielleicht ein Akquise-Endpoint. Dort ist das Bedrohungsmodell 'Spam und XSS in dem wenigen, das Input annimmt', nicht DB-Eskalation. XHUB IRON lebt in DERSELBEN DB wie MOONKEY, ist aber ein operatives Panel des Gründers: Seine `iron_*`-Tabellen dürfen für einen MOONKEY-Nutzer nicht lesbar sein — dieselbe RLS-Engine muss ZWEI Produkte tragen, die sich Postgres teilen, ohne dass eines das andere liest (das ist OS-05). Das Bedrohungsmodell ist nicht generisch: Es leitet sich ab aus WELCHE Daten es gibt, WER sich authentifizieren kann und WAS die Infrastruktur teilt. Fang immer damit an, diese drei Achsen zu inventarisieren.

ÜBUNG

Auf MOONKEY (oder deinem Supabase-Projekt): 1) Schreibe `THREAT-MODEL.md` und liste für jede mit dem anon key erreichbare Tabelle die Angriffe auf, die ein feindlicher authentifizierter Nutzer versuchen würde (Massen-Dump, Privilegien-Eskalation, Schreiben auf fremde Zeile). 2) Notiere für jeden Angriff die Verteidigung, die ihn STOPPEN sollte (welche Policy/welcher Trigger), und markiere ihn als 'verifiziert' oder 'nicht verifiziert'. 3) Schreibe das Impersonation-Snippet, das du ausführen würdest, um den Dump von `profiles` als normaler Nutzer zu testen (Client mit anon key + Session eines Test-Users, der `.from('profiles').select('*')` macht), und sag das erwartete Ergebnis voraus (nur 1 Zeile).

ERGEBNIS

`THREAT-MODEL.md`: eine Tabelle Angriff→Verteidigung→Verifikationsstatus, die jede vom anon key exponierte Tabelle abdeckt, plus das konkrete Impersonation-Snippet für mindestens einen Massen-Dump-Angriff mit seinem erwarteten Ergebnis.

KERNEINSICHT

In einer SSG ist der Client Angreifer-Territorium: Er hat dein Bundle, deinen anon key und spricht per curl direkt mit deinem Postgres, ohne dein HTML zu berühren. Jede Sicherheitsprüfung, die in .astro lebt, ist UX; die einzige echte Grenze ist RLS, und sie zählt nur, wenn du sie durch Impersonation widerlegt hast, nicht durch Lesen.

ZU VERMEIDENDE FEHLER

  • ×Das Verstecken des Admin-Panels im Frontend mit dem Schützen verwechseln: `admin.astro` versteckt die UI, aber die DB bleibt per curl mit dem anon key zugänglich.
  • ×Policies durch Lesen verifizieren statt sie als impersonierter Nutzer anzugreifen — ein falsch gesetztes OR sieht man nur, indem man den Angriff ausführt.
  • ×Ein UPDATE mit USING, aber ohne WITH CHECK schreiben, was das Umhängen der Zeile auf einen fremden `user_id` erlaubt, obwohl die Policy zu schützen 'scheint'.
  • ×Vergessen, dass TRUNCATE und SECURITY-DEFINER-RPCs RLS umgehen: Sie zu schützen verlangt ein explizites REVOKE, Zeilen-Policies allein reichen nicht.
  • ×Ein generisches, aus einem Tutorial kopiertes Bedrohungsmodell anwenden, statt es aus deinen drei realen Achsen abzuleiten: welche Daten, wer authentifiziert sich, welche Infrastruktur wird geteilt.
OS-03

Adversariales Audit

Lektion

Adversariell auditieren: jeden Sicherheitsbefund als Hypothese behandeln, die du widerlegen musst, bevor du sie glaubst — statt 'sieht verwundbar aus' oder 'sieht sicher aus' per Inspektion zu akzeptieren.

Der Bestätigungsfehler ruiniert Audits: Ein Scanner sagt 'RLS deaktiviert' und du gerätst in Panik, oder du sagst 'die Policy sieht gut aus' und gibst dein Okay. Beide Fehler kosten teuer — Falsch-Positive verbrennen deine Zeit und Glaubwürdigkeit; Falsch-Negative lassen Lücken offen. Die Disziplin, zuerst zu widerlegen, trennt ein echtes Audit von einer Meinung.

DIE LEKTION

Das Leitprinzip ist popperianisch: Ein Sicherheitsbefund ist nicht wahr, weil du ihn behauptest, er ist wahr, weil du VERSUCHT hast, ihn zu widerlegen, und es nicht konntest. Dreh den natürlichen Ablauf um. Wenn du glaubst, eine Verwundbarkeit gefunden zu haben ('jeder kann `leads` lesen'), ist dein nächster Schritt nicht, sie zu melden — es ist der Versuch zu zeigen, dass du dich irrst: Gibt es eine Policy, die es verhindert? Habe ich es mit der richtigen Rolle getestet? Hat das `select` echte Daten geliefert oder leer? Erst wenn dein Widerlegungsversuch scheitert, steigt der Befund zu 'bestätigt' auf. Und umgekehrt: Wenn du glaubst, etwas sei sicher ('die self-or-admin-Policy schützt `profiles`'), ist deine Aufgabe, es anzugreifen, bis es bricht oder überlebt.

Definiere die zwei Fehler, die du jagst, mit klaren Namen. Falsch-Positiv: Du meldest eine Verwundbarkeit, die nicht existiert — z. B. schreist du 'der anon key ist im Bundle exponiert', während der anon key per Design öffentlich ist und die DB durch RLS geschützt (siehe OS-01). Du verbrennst Vertrauen; beim nächsten Mal glaubt dir niemand. Falsch-Negativ: Du hältst etwas für sicher, das es nicht ist — z. B. 'das Admin-Panel ist versteckt, alles gut', ohne den direkten DB-Zugriff zu testen. Du lässt die Lücke offen. Das adversarielle Audit existiert, um BEIDE zu minimieren, und das Werkzeug für beide ist dasselbe: der ausführbare Test.

Die konkrete Methode: Erzeuge für jeden Befund einen reproduzierbaren Test, keine Behauptung. 'Reproduzierbar' heisst: ein Befehl oder Snippet, das jeder ausführen und dasselbe Ergebnis sehen kann. Um zu bestätigen, dass `profiles` NICHT gedumpt werden kann: ein Skript, das sich als user_A authentifiziert, `.from('profiles').select('*')` macht und die Zeilenzahl ausgibt — erwartet 1 (nur seine eigene). Um zu bestätigen, dass die Privilegien-Eskalation geschlossen ist: als user_A `.from('profiles').update({ role: 'admin' }).eq('id', myId)` und den Fehler ausgeben, den der Guard-Trigger zurückgibt. Liefert der Angriff, was du erwartet hast (leer/Fehler), ist die Kontrolle verifiziert. Liefert er Daten, hast du einen ECHTEN Befund — und er kommt gleich mit seinem PoC im Anhang.

Arbeite mit dem Werkzeug von Supabase selbst, aber kapituliere nicht davor. `get_advisors` (security lints) zeigt dir Tabellen ohne RLS, permissive Policies, SECURITY-DEFINER-Funktionen ohne fixen search_path. Behandle es als HYPOTHESEN-Generator, nicht als Urteilsinstanz. Ein Advisor, der sagt 'Funktion X ist SECURITY DEFINER mit mutable search_path', ist eine Risiko-Hypothese: Geh zur Funktion, schau, ob ein Angreifer ein Objekt in ein Schema platzieren kann, das sie zuerst auflöst. Manchmal ist es ausnutzbar, manchmal nicht — je nach Kontext. Der Advisor spart dir den ersten Durchgang; die Widerlegung machst du. CLAUDE.md institutionalisiert es bereits: RLS-Änderungen = versionierte Migration + danach `get_advisors`. Dieser zweite Teil ist das adversarielle Audit als Routine.

Achte auf die Falsch-Negative der Methode selbst: Der Angriff, der 'durchgeht', kann aus dem falschen Grund durchgehen. Wenn dein `select('*')` auf `profiles` leer zurückkommt — liegt das daran, dass RLS es blockiert hat, oder dass deine Test-Session abgelaufen war / du nicht authentifiziert warst / die Tabelle leer war? Eine Kontrolle gilt nie als gut ohne einen KONTRAST-Fall: Beweise, dass dieselbe Query SEHR WOHL Daten liefert, wenn sie soll (als Admin, oder als Eigentümer der Zeile). Ein Sicherheitstest ohne seine positive Kontrolle ist ein als Erfolg getarntes Falsch-Negativ. Verifiziere immer, dass deine Waffe funktioniert, bevor du das Ziel für immun erklärst.

Land die Strenge in den Projekten, wo der Irrtum am meisten schmerzt. In XCAP ist das adversarielle Audit zentrale Doktrin: Das Projektgedächtnis beschreibt es als 'Anti-Fabrikation per Konstruktion' — ein System, das Forecasts erfasst und sie gegen reale Ergebnisse kalibriert, das seine eigenen Vorhersagen widerlegt, statt sie zu glauben. Denselben Muskel wendest du auf Security an: Unterschreib nicht 'das Ledger ist read-only', bis ein Schreibversuch mit dem Ingest-Credential vor deinen Augen scheitert. In Espejo, wo die Grenze personenbezogener Daten ethisch und technisch ist, ist ein Audit, das sagt 'es gibt kein Leck zwischen Tenants', ohne einen PoC, in dem ein Tenant versucht, einen anderen zu lesen, KEIN Audit: Es ist ein Wunsch. Zuerst widerlegen, immer, besonders wenn das erwartete Ergebnis das bequeme ist.

ÜBUNG

Nimm eine echte Sicherheitskontrolle von MOONKEY (z. B. 'ein Nicht-Admin kann keine fremden Profile lesen'). 1) Formuliere sie als widerlegbare Hypothese. 2) Schreibe ZWEI Tests: den Angriff (als user_A versuche ich, die Zeile von user_B zu lesen → erwarte leer) und den Kontrast (als user_A lese ich MEINE Zeile → erwarte 1 Zeile; oder als Admin lese ich alle → erwarte N). 3) Führe `get_advisors` aus (oder dokumentiere, was es melden würde) und klassifiziere jeden Lint als 'Hypothese als reales Risiko bestätigt' oder 'widerlegt (nicht ausnutzbar, weil…)'. 4) Schreibe einen Mini-Report, in dem jede Aussage von ihrem reproduzierbaren Befehl begleitet wird.

ERGEBNIS

`AUDIT.md`: ein Report, in dem jeder Befund (verwundbar oder sicher) seinen reproduzierbaren PoC trägt — Angriff + positiver Kontrast-Fall — und in dem jeder Lint von `get_advisors` als bestätigt oder widerlegt mit seiner Begründung markiert ist.

KERNEINSICHT

Ein Sicherheitstest, der ohne positiven Kontrast-Fall 'durchgeht', ist ein getarntes Falsch-Negativ: Das leere select kann an RLS oder an einer abgelaufenen Session liegen. Verifiziere immer, dass deine Waffe feuert, bevor du das Ziel für immun erklärst — zuerst widerlegen gilt auch für deine eigene Methode.

ZU VERMEIDENDE FEHLER

  • ×Einen Befund durch Code-Inspektion melden, ohne ausführbaren PoC: 'die Policy wirkt schwach' ist kein Befund, es ist ein Bauchgefühl.
  • ×Die Lints von `get_advisors` als Urteile behandeln statt als zu widerlegende Hypothesen — sie erzeugen Falsch-Positive, die, wiederholt, deine Glaubwürdigkeit verbrennen.
  • ×Eine Kontrolle für sicher halten, weil der Angriff leer zurückkam, ohne zu beweisen, dass dieselbe Query SEHR WOHL Daten liefert, wenn sie soll (positive Kontrolle).
  • ×'anon key exponiert' als Verwundbarkeit schreien, obwohl er per Design öffentlich ist: das klassische Falsch-Positiv, das verrät, das Sicherheitsmodell nicht verstanden zu haben.
  • ×Nur in die bequeme Richtung auditieren (das bestätigen, was du erwartest), statt die Kontrollen, die du für sicher hältst, genauso hart anzugreifen.
OS-04

Incident Response & Rollback

Lektion

Auf einen Vorfall in Produktion mit einem kühlen Verfahren reagieren: erkennen, eindämmen, sicher zurückrollen und ein schuldfreies Post-mortem führen, das die Wurzelursache schliesst.

Wenn in Produktion etwas bricht, verschlimmert der Instinkt (improvisieren, an der DB im laufenden Betrieb herumfummeln, den Fehler verstecken) meist den Schaden. Der Unterschied zwischen einem Zehn-Minuten-Schreck und einem verlorenen Wochenende ist ein Runbook, das VOR dem Vorfall geschrieben wurde. Ohne es zerstört ein falsch gemachter Rollback Daten, die der Bug nur beschädigt hatte.

DIE LEKTION

Verinnerliche die Sequenz DCRP: Detektieren → Containen (eindämmen) → Recovern (Rollback) → Post-mortem. Der Fehler der meisten ist, direkt zum 'Reparieren' zu springen und blind in Produktion einzugreifen. Die Reihenfolge zählt, weil jede Phase die nächste schützt: Detektierst du schlecht, dämmst du das Falsche ein; dämmst du nicht ein, wächst der Schaden, während du recoverst; recoverst du ohne zu verstehen, wiederholst du den Vorfall. Und die Null-Regel, in jedem ernsthaften Runbook festgehalten: In einem Vorfall improvisierst du NICHT in Produktion. Du führst vordefinierte Schritte aus. Kreativität ist fürs Post-mortem, nicht für 3 Uhr nachts mit ausgefallener Seite.

Detektieren heisst zu wissen, WAS kaputt ist und seit WANN. In MOONKEY (Cloudflare Pages + Supabase) sind deine Signale: das Cloudflare-Deploy (welcher Commit ist live? Ist der letzte Build durchgelaufen?), die Supabase-Logs (`get_logs` pro Service: api, postgres, auth — dort siehst du Policy-Fehler, scheiternde Queries, Peaks von 4xx/5xx) und die Nutzerbeschwerde (oft das erste Signal). Das Erste, was du etablierst, ist die Zeitleiste: Was hat sich kurz davor geändert? Fast immer korreliert der Vorfall mit einem kürzlichen Deploy oder einer Migration. 'Begann nach dem Merge um 16:20' verengt den Suchraum von Stunden auf Minuten.

Containen heisst, die Blutung zu stoppen, bevor du die Wunde versorgst. Schlüsselfrage: Korrumpiert das gerade JETZT Daten? Wenn ein Bug schlechte Zeilen in `proofs` oder `progress` schreibt, vervielfacht jede Minute, die du brauchst, das Cleanup. Eindämmungs-Optionen nach Schweregrad: das Cloudflare-Deploy auf das vorherige zurücksetzen (ein Klick — Cloudflare Pages bewahrt frühere Deploys und erlaubt sofortigen Rollback, das ist dein schnellster und sicherster Hebel), das kaputte Feature deaktivieren, oder im Extremfall destruktiven Schreibens den Zugriff auf Policy-Ebene kappen. Containen ist NICHT reparieren: Es ist, den Schaden in seinem aktuellen Zustand einzufrieren, um Zeit zum Nachdenken zu haben.

Recovern verlangt, ZWEI Rollbacks zu unterscheiden, die Leute verwechseln und die gegensätzliche Risiken haben. (1) CODE-Rollback: zurück zu einem früheren Deploy/Commit. Er ist billig, reversibel und fast immer sicher — in Cloudflare Pages ist er sofortig. Das ist deine erste Option. (2) SCHEMA-/DATEN-Rollback: eine Migration zurücknehmen oder Daten wiederherstellen. Er ist GEFÄHRLICH und irreversibel: Eine `down`-Migration, die ein `DROP COLUMN` macht, löscht Daten, die vielleicht nur beschädigt, nicht verloren waren. Regeln: Führe nie einen Daten-Rollback aus, ohne zuvor ein Backup/PITR bestätigt zu haben; bevorzuge eine FORWARD-Migration, die korrigiert (ein neues `UPDATE`, das die schlechten Zeilen repariert), gegenüber einem destruktiven `down`; jede Supabase-Migration ist versioniert (`apply_migration`), genau damit der Zustand rekonstruierbar ist. Ist die Frage 'Code- oder Daten-Rollback', fang IMMER mit dem Code-Rollback an: oft reicht er und fasst die DB nicht an.

Das Post-mortem ist, wo der Vorfall seine Schuld begleicht, indem er zu Lernen wird. Es ist OHNE Schuldzuweisung (blameless): Das Ziel ist nicht, wer es brach, sondern was am SYSTEM einem normalen Menschen erlaubt hat, es zu brechen. Struktur: Zeitleiste (was passierte wann, Minute für Minute), Impact (wie viele Nutzer, welche Daten, wie lange), Wurzelursache (die '5 Warums', bis du beim System-Versagen landest, nicht beim Personen-Versagen), und korrigierende Massnahmen mit Eigentümer und Datum. Jeder Vorfall muss mindestens eine Kontrolle hervorbringen, die ihn VERHINDERT oder früher ERKANNT hätte — ein Test, ein Advisor in CI, ein Alarm. Wenn das Post-mortem nichts am System ändert, war es kein Post-mortem, es war ein Geständnis.

Veranker das Runbook in der Realität jedes Projekts, denn Schweregrad und Hebel ändern sich. In XCAP existiert eine heilige Kapital-Invariante — das Kapital akkumuliert über Sessions und bewegt sich nur bei realisierten Closes, es wird nie zurückgesetzt. Ein Vorfall, der diese Invariante berührt, wird NICHT mit einem blinden Daten-Rollback repariert: Du würdest reale Buchhaltungs-Historie löschen. Dort zwingt das Runbook zur Forward-Korrektur und zur Verifikation der Invariante, bevor du irgendetwas anfasst. In Espejo, multi-tenant, hat ein Vorfall eine Zusatzfrage in der Detektions-Phase: Hat das Versagen die Grenze zwischen Tenants überschritten? Hat ein Bug Daten eines Tenants einem anderen exponiert, gibt es neben dem Rollback eine Pflicht zur Eindämmung des Lecks. Das generische Runbook gibt dir das Rückgrat; die Invarianten jedes Projekts geben dir die Regeln, die du in der Panik NIEMALS verletzt.

ÜBUNG

Schreibe das Vorfall-`RUNBOOK.md` für MOONKEY: 1) Den Abschnitt Detektieren (welche Befehle/Panels du anschaust: Cloudflare-Deploy, `get_logs` von Supabase pro Service, wie du die Zeitleiste etablierst). 2) Den Abschnitt Containen mit dem Cloudflare-Pages-Rollback-Hebel Schritt für Schritt. 3) Einen Entscheidungsbaum 'Code-Rollback vs. Daten-Rollback' mit der Regel 'fang mit Code an, Daten nur mit bestätigtem PITR'. 4) Eine Vorlage für ein blameless Post-mortem (Zeitleiste / Impact / 5-Warums / korrigierende Massnahmen mit Eigentümer). 5) Simuliere einen Vorfall: 'nach dem Deploy um 16:20 können Nutzer keine proofs speichern' und schreibe die Reaktion Schritt für Schritt nach deinem eigenen Runbook.

ERGEBNIS

`RUNBOOK.md` mit den vier DCRP-Phasen, operationalisiert für Cloudflare Pages + Supabase, dem Entscheidungsbaum Code-vs-Daten, einer Vorlage für ein blameless Post-mortem und einem simulierten Vorfall, end-to-end gelöst mit deinem eigenen Verfahren.

KERNEINSICHT

Es gibt zwei Rollbacks mit gegensätzlichen Risiken: Der Code-Rollback ist billig und reversibel (fang immer dort an), der Daten-Rollback ist destruktiv und irreversibel. Im Zweifel ist eine Forward-Migration, die korrupte Zeilen repariert, fast immer besser als ein `down`, das sie löscht — denn oft waren die Daten beschädigt, nicht verloren.

ZU VERMEIDENDE FEHLER

  • ×Direkt zum 'Reparieren' springen und blind in Produktion eingreifen, statt Detektieren→Containen→Recovern zu folgen: Improvisation um 3 Uhr nachts ist, wie ein Schreck zur Katastrophe wird.
  • ×Einen Daten-Rollback machen (down-Migration mit DROP) ohne bestätigtes Backup/PITR und damit Daten zerstören, die der Bug nur beschädigt hatte.
  • ×Die Zeitleiste nicht etablieren, bevor du handelst: Ohne zu wissen, welcher Deploy oder welche Migration den Vorfall auslöste, suchst du die Ursache im ganzen System statt in den letzten Änderungen.
  • ×Containen mit Reparieren verwechseln: den Bug schlechte Zeilen schreiben lassen, während du 'ermittelst', vervielfacht das spätere Cleanup.
  • ×Den Vorfall mit einem Post-mortem schliessen, das einen Schuldigen sucht statt eines System-Versagens, und das nicht eine einzige neue Kontrolle hervorbringt, die ihn verhindert hätte.
OS-05

Isolation zwischen Projekten

Lektion

Mehrere Projekte sich eine einzige Postgres-Datenbank teilen lassen, ohne dass eines die Daten eines anderen lesen, schreiben oder kaputtmachen kann — Multi-Projekt- und Multi-Tenant-Isolation, erzwungen durch die DB, nicht durch Konvention.

Eine Supabase-Instanz zwischen Projekten zu teilen spart Kosten, schafft aber das leiseste Risiko der Galaxie: dass der MOONKEY-Client mit seinem anon key die Tabellen von XHUB IRON liest. Isolation per 'denk dran, diese Tabellen nicht anzufassen' ist Papier; irgendwann fasst sie jemand an. Echte Isolation ist strukturell und wird verifiziert, indem man sie angreift.

DIE LEKTION

Geh vom konkreten Fakt aus: Die Instanz `wuchsslgbqlhyxljsmxi` beherbergt ZWEI Produkte. MOONKEY nutzt `profiles, progress, feedback, leads, proofs`. XHUB IRON nutzt `iron_*, world_*, focus_*, daily_focus_history`. Sie teilen sich dasselbe Postgres, dasselbe `auth.users` und — entscheidend — DERSELBE anon key wird benutzt, um mit der DB zu sprechen. Das heisst, der öffentliche MOONKEY-Client KANN technisch `from('iron_command').select('*')` versuchen. Der einzige Grund, warum das nicht funktioniert, muss eine harte Barriere in der DB sein, nicht die CLAUDE.md-Notiz, die sagt 'fass sie nicht an'. Diese Notiz schützt davor, dass DU sie aus Versehen beim Schreiben von Code editierst; sie schützt nicht gegen einen Angreifer, der deinen anon key bereits hat.

Versteh die Isolations-Schichten von der schwächsten zur stärksten. (1) Konvention (Namen `iron_*` vs. ohne Präfix): organisiert, isoliert nicht — ein Angreifer ignoriert Konventionen. (2) RLS pro Tabelle: Jede XHUB-Tabelle muss Policies haben, die Zugriff nur ihrer eigenen Rolle/ihrem eigenen Nutzer gewähren; eine `iron_*`-Tabelle ohne RLS oder mit einer permissiven Policy ist ein direktes Leck. (3) Separate Schemas: XHUB in ein eigenes Postgres-Schema legen und es NICHT in der PostgREST-API exponieren (das `db.schema`, das Supabase publiziert) ist eine stärkere Barriere — was nicht im exponierten Schema ist, sieht der Client gar nicht. (4) Rollen/Grants: REVOKE von `anon`/`authenticated` auf den fremden Tabellen/Objekten. Die robuste Verteidigung STAPELT diese Schichten; vertrau nicht einer einzigen.

Für den Fall MOONKEY↔XHUB ist die Audit-Frage direkt: Kann ein authentifizierter MOONKEY-Nutzer eine Zeile aus `iron_*` lesen? Widerleg es (OS-03): Authentifiziere dich als normaler MOONKEY-Nutzer und führe `supabase.from('iron_command').select('*')` aus. Korrekt ist, dass es einen Berechtigungsfehler oder leer durch RLS zurückgibt. Gibt es Daten zurück, hast du ein kritisches Isolations-Leck. Das Projektgedächtnis sagt, dass die Health berechnet wird und das Modell für den HUB 'open RLS, computed health' ist — das heisst, du musst EXPLIZIT verifizieren, dass 'open' für den HUB nicht 'offen für MOONKEY-Clients' bedeutet. Isolation zwischen Projekten = jede Tabelle antwortet nur ihrem legitimen Eigentümer, bewiesen durch gekreuzte Impersonation.

Die zweite Achse ist Multi-Tenant INNERHALB eines Projekts, das ist Espejo. Hier sind es nicht zwei Produkte, es sind N Tenants (jeder Influencer) in denselben Tabellen. Die Isolation wird zeilenbasiert: Jede Zeile trägt einen `tenant_id`, und die RLS-Policy verlangt, dass `tenant_id` mit dem Tenant des Aufrufers übereinstimmt. Der zu widerlegende Angriff: Tenant A versucht, Zeilen mit dem `tenant_id` von B zu lesen/schreiben. Die klassische Falle ist das UPDATE ohne WITH CHECK: USING begrenzt, welche Zeilen A sieht, aber ohne WITH CHECK könnte A eine Zeile auf B umhängen oder eine mit fremdem `tenant_id` erzeugen. Eine weitere Falle: eine SECURITY-DEFINER-RPC, die `tenant_id` als Parameter empfängt und nicht validiert, dass er mit `auth.uid()` übereinstimmt — ein Loch, das alle Tenants durchkreuzt. Multi-Tenant-Isolation ist nur dann real, wenn JEDE Policy jeder Operation (SELECT/INSERT/UPDATE/DELETE) den Tenant an die Identität des Aufrufers ankert, nicht an einen Parameter, den der Client kontrolliert.

Es gibt Übergangs-Vektoren, die kein SELECT sind und die Leute vergessen. `auth.users` ist geteilt: Ein Nutzer ist derselbe in MOONKEY und in XHUB — stell sicher, dass ein Konto im einen keine Rollen im anderen gewährt (in MOONKEY sind `role` und `founder_badge` für Nicht-Admins genau deshalb unveränderlich, damit die Registrierung keine gekreuzten Privilegien eskaliert). Die RPCs und SECURITY-DEFINER-Funktionen sind globale Objekte: Eine von `authenticated` ausführbare XHUB-Funktion ist vom MOONKEY-Client aufrufbar — beschränke sie per Rolle oder validiere den Kontext innen. Geteilte Trigger und Sequenzen, Views, die JOINs zwischen Tabellen beider Produkte machen, und `get_advisors`, das fehlendes RLS in irgendeiner Tabelle des Projekts meldet: All das ist Übergangsfläche. Isolation ist keine Policy, sie ist eine Eigenschaft des gesamten Systems.

Schliess mit der operativen Disziplin, die die Isolation über die Zeit am Leben hält. Jede Schema-Änderung läuft als versionierte Migration ein (`apply_migration`), nie als manuelle Bearbeitung im Panel — so ist der Sicherheitszustand rekonstruierbar und auditierbar. Nach jeder Migration, die Tabellen oder Policies berührt, lauf `get_advisors`, um deaktiviertes RLS oder versehentlich eingeführte permissive Policies zu jagen. Und halte einen Isolations-Test als Teil des Runbooks: ein Skript, das als MOONKEY-Nutzer versucht, jedes fremde Präfix (`iron_*`, `world_*`) zu lesen, und den Build fehlschlagen lässt, falls eines Zeilen zurückgibt. Isolation zwischen Projekten wird nicht 'einmal konfiguriert'; sie wird bei jeder Änderung neu verifiziert, denn eine harmlose Migration kann eine Tür öffnen, die monatelang geschlossen war.

ÜBUNG

Auf der geteilten DB `wuchsslgbqlhyxljsmxi` (oder einer Test-Replik): 1) Liste in `ISOLATION.md` die Tabellen jedes Produkts auf (MOONKEY vs. XHUB) und die Isolations-Schicht, die jede schützt (RLS / nicht-exponiertes Schema / REVOKE). 2) Schreibe den gekreuzten Impersonation-Test: Versuche als normaler MOONKEY-Nutzer ein `select('*')` auf `iron_command` (oder irgendein `iron_*`/`world_*`) und dokumentiere das erwartete Ergebnis (Fehler/leer). 3) Schreibe für den Multi-Tenant-Fall von Espejo die vier Policies (SELECT/INSERT/UPDATE/DELETE) einer Tabelle mit `tenant_id` und stelle sicher, dass UPDATE ein WITH CHECK trägt und keine einem vom Client übergebenen `tenant_id` vertraut. 4) Dokumentiere, wie `get_advisors` in deine Routine nach der Migration einfliesst.

ERGEBNIS

`ISOLATION.md`: die Karte Tabellen→Isolations-Schicht für die zwei Produkte der geteilten DB, ein ausführbarer Test gekreuzter Impersonation MOONKEY→XHUB mit erwartetem Ergebnis und das Set der vier RLS-Policies pro Tenant von Espejo mit korrektem WITH CHECK bei den Schreiboperationen.

KERNEINSICHT

Postgres zwischen Projekten zu teilen ist nur dann sicher, wenn die Isolation strukturell ist (RLS + nicht-exponiertes Schema + REVOKE), nicht dokumentarisch: Die Notiz 'fass diese Tabellen nicht an' schützt gegen deine Code-Fehler, niemals gegen einen Angreifer, der deinen anon key bereits hat und fremde Präfixe aufzählt.

ZU VERMEIDENDE FEHLER

  • ×Die Isolation Namens-Konventionen oder einer Notiz in CLAUDE.md anvertrauen: Ein Angreifer mit dem anon key ignoriert beide und probiert `from('iron_*').select('*')` direkt.
  • ×Eine fremde Tabelle (`iron_*`, `world_*`) ohne RLS oder mit permissiver Policy lassen, in einer DB, deren anon key sich mehrere Projekte teilen — direktes Leck zwischen Produkten.
  • ×In Multi-Tenant ein UPDATE/INSERT mit USING, aber ohne WITH CHECK schreiben, was einem Tenant erlaubt, Zeilen mit dem `tenant_id` eines anderen umzuhängen oder zu erzeugen.
  • ×Eine SECURITY-DEFINER-RPC bauen, die einem vom Client als Parameter übergebenen `tenant_id` (oder `user_id`) vertraut, statt ihn aus `auth.uid()` abzuleiten.
  • ×Die Isolation als einmalige Konfiguration behandeln und `get_advisors` nach jeder Migration nicht erneut laufen lassen: Eine harmlose Änderung kann eine vor Monaten geschlossene Tür wieder öffnen.
OS-06

Observabilidad y coste

Lektion

Ein Observability-Panel und ein Budget mit Alerts für ein lebendes System aufsetzen (Astro auf Cloudflare Pages + Supabase): definieren, welche Logs und Metriken du überwachst, was einen Alert auslöst, und welche Nutzungs- und Token-Kostengrenzen dich warnen, BEVOR die Überraschungsrechnung kommt.

Launchen heisst nicht Fertigsein: Es ist der Moment, in dem du anfängst zu operieren. Ein System in Produktion erzeugt drei Ströme, die fast niemand anschaut, bis sie wehtun: Logs (was gerade passiert), Fehler (was bricht, ohne dass es dir jemand sagt) und Kosten (wie viel man dir berechnet, dass du am Leben bleibst). MOONKEY LAB, XHUB, Espejo und XCAP teilen sich echte Infrastruktur mit Free-/Pro-Plänen und realen Kontingenten von Supabase und Cloudflare, und jede KI-Nutzung wird pro Token abgerechnet. Der Operator, der nicht monitort, entdeckt Probleme über zwei Wege, beide teuer: ein Nutzer, der sich beschwert, oder eine Abbuchung auf der Karte. Observability verwandelt 'ich weiss nicht, was in Produktion passiert' in 'ich habe ein Dashboard, das es mir sagt'. Kosten sind Sicherheit: Ein offener Endpoint, den jemand hämmert, ist nicht nur ein Loch, er ist eine Rechnung. Das ist die letzte Disziplin der Konstellation, weil sie den Kreis schliesst: Du hast gebaut, abgesichert, jetzt operierst du mit offenen Augen.

DIE LEKTION

Die drei Ströme eines lebenden Systems. Logs = das Tagebuch dessen, was passiert (Requests, Queries, Auth). Fehler = das, was stillschweigend auf der Client-Seite bricht, wo deine Server-Logs nicht hinreichen. Kosten = der Zähler, der läuft, auch wenn niemand das System nutzt. Es sind drei verschiedene Leitungen mit drei verschiedenen Werkzeugen; sie zu verwechseln ist der Grund, warum Leute glauben zu monitorn und es nicht tun.

Observability in einer SSG ist nicht dasselbe wie in einem Server. MOONKEY ist statisch: Es gibt KEINEN server-seitigen Handler, in den man einen Logger setzen könnte. Deine Telemetrie lebt an drei Orten ausserhalb deines HTML: (1) Cloudflare Pages Analytics (Requests, Bandbreite, Edge-Fehler), (2) die Supabase-Logs (Dashboard → Logs: API, Postgres, Auth — jede Query, die durch PostgREST läuft, und jeder Login hinterlässt eine Spur) und (3) der Browser des Nutzers (JS-Fehler, die nur du nicht siehst). Akzeptier es: In einer SSG passiert ein Grossteil dessen, was scheitert, auf einer Maschine, die du nicht kontrollierst.

Was wirklich überwachen, nicht alles. Die Versuchung ist, alles zu loggen und nichts zu lesen. Definiere eine Handvoll Signale, die zählen: 4xx/5xx-Fehlerrate am Edge (ein Peak von 401/403 = RLS lehnt ab oder ein Angreifer probiert herum), Auth-Fehler in Supabase (gescheiterte Magic-Link-Versuche), Latenz der langsamen Queries (Supabase markiert die, die dauern) und Volumen gelesener Zeilen (eine Query ohne Filter, die plötzlich 10.000 Zeilen liefert, ist ein Kosten-Bug). Eine Metrik, die keine Entscheidung auslöst, ist Rauschen; lösch sie.

Error-Tracking auf der Client-Seite: der blinde Fleck. In einer statischen Seite bricht ein TypeError in deinem JS die Erfahrung des Nutzers, und bei dir kommt NICHTS an — kein Server-Log, kein Alert. Du musst Fehler im Browser einfangen und nach aussen senden. Das ehrliche Minimum: ein window.addEventListener('error', ...) und ('unhandledrejection', ...), das ein fetch an einen eigenen Endpoint macht (eine Supabase Edge Function, die in eine Log-Tabelle mit Insert-only-RLS einfügt). Das Robuste: Sentry oder Ähnliches mit seinem kostenlosen SDK bis zu einem gewissen Volumen. Die Regel: Wenn du keine Client-Fehler einfängst, operierst du in der Hälfte deines Systems blind.

Die realen Kosten des Betriebs, aufgeschlüsselt. Der Supabase Free Tier hat konkrete Decken, bei deren Überschreiten man dich entweder kappt oder dir berechnet: DB-Zeilen, Egress-Bandbreite, Speicher, Monthly Active Users von Auth und Edge-Function-Invocations. Cloudflare Pages ist grosszügig, hat aber Limits bei Builds/Monat und bei Requests in Funktionen. Und die Ausgabe, die am stärksten skaliert und am wenigsten sichtbar ist: die KI-Tokens. Jeder Aufruf an die Claude-API wird nach Input- und Output-Tokens abgerechnet, mit unterschiedlichen Tarifen; ein Prompt mit einem riesigen CLAUDE.md oder eine Agent-Schleife ohne Deckel kann die Kosten vervielfachen, ohne dass sich ändert, 'was die App tut'. Der Operator kennt seine drei Rechnungen: DB, Edge und Tokens.

Warum Überraschungsrechnungen kommen und wie man sie nicht bekommt. Drei klassische Ursachen: (1) ein Endpoint oder eine Edge Function ohne Rate-Limit, den jemand entdeckt und hämmert — Nutzung = Geld; (2) eine Query ohne LIMIT und ohne Index, die bei jedem Laden die ganze Tabelle scannt; (3) eine KI-Agent-Schleife ohne Token- und Iterations-Deckel (die XCAP-Kapital-Invariante existiert teils deswegen: Schleifen, die Lernen akkumulieren, nicht Kosten). Die Verteidigung ist nicht, die Rechnung zum Monatsende zu überwachen, sondern die Bremsen VORHER zu setzen: Budgets mit Alert bei 50/80/100%, harte Limits, wo die Plattform sie bietet, und explizite Decken für max_tokens und Iterationen bei jedem KI-Aufruf.

Über das alarmieren, was zählt, ohne Alert-Müdigkeit. Ein Alert, der jede Stunde springt, wird in einer Woche ignoriert. Das Kriterium: Alarmiere nur das Handlungsfähige und Dringende. Gut: 'die Ausgabe des Monats hat 80% des Budgets überschritten', 'die 5xx-Rate ging über 1%', 'Client-Fehler > X in 10 Min'. Schlecht: 'es gab einen Request'. Konfiguriere die Budget-Alerts, die dir Supabase, Cloudflare und die Anthropic-Konsole bereits geben — sie sind gratis und sind deine erste Linie. Jeder Alert muss 'was tue ich, wenn er springt' beantworten; gibt es keine Handlung, ist es kein Alert, es ist Angst.

Schliess den Kreis mit der Vorfall-Reaktion (OS-04). Beobachten ohne Reaktionsplan ist nur Zuschauen, wie es brennt. Das Panel und die Alerts dieses Moduls sind die Sensoren; der Rollback und das Incident Response von OS-04 sind die Aktoren. Ein gut operiertes System verbindet beide: Der Alert weckt dich, die Logs sagen dir, was passiert ist, und der Reaktionsplan sagt dir, was zu tun ist. Observability + Kosten + Reaktion = wirklich operieren, nicht beten.

ÜBUNG

Du operierst MOONKEY LAB (Astro SSG auf Cloudflare Pages + Supabase moonkey-lab + mögliche KI-Aufrufe). Setze seine reale Observability und sein Budget in vier Schritten auf. SCHRITT 1 — Signal-Karte (schriftliches Deliverable). Öffne das Supabase-Dashboard → Logs und sieh dir die drei Streams an (API, Postgres, Auth). Dokumentiere in einer Tabelle, welches Signal du in jedem überwachst (z. B. Auth: Rate gescheiterter Magic-Links; API: Anteil von 401/403; Postgres: Queries > 500ms), und von jedem: Schwelle, was das Überschreiten bedeutet und welche Handlung es auslöst. Mindestens 6 Signale. Eines ohne Handlung wird verworfen. SCHRITT 2 — Erfassung von Client-Fehlern. Implementiere ein globales Skript (in Layout.astro, neben dem bereits globalen IntersectionObserver — dupliziere es nicht) mit window.addEventListener('error') und ('unhandledrejection'), das ein fetch an eine Supabase Edge Function macht. Erstelle die Edge Function und eine Tabelle client_errors mit Insert-only-RLS für anon (niemals SELECT für anon: Logs werden nicht vom Client gelesen). Wende es als versionierte Migration an und lauf danach get_advisors (Kostenregel des Projekts). Provoziere absichtlich einen Fehler und verifiziere, dass die Zeile in der Tabelle erscheint. SCHRITT 3 — Die drei Rechnungen und ihre Decken. Dokumentiere die aktuellen Limits und wo man sie sieht: (a) Supabase — DB-Nutzung, Egress, Auth MAU, Function-Invocations (Dashboard → Reports/Usage); (b) Cloudflare — Builds und Requests (Pages → Analytics); (c) KI-Tokens — falls es Aufrufe an die Claude-API gibt, berechne die Kosten eines typischen Aufrufs (Input-Tokens des CLAUDE.md + Prompt, erwartete Output-Tokens, mal dem gültigen Tarif des Modells). Notiere für jede Rechnung die kostenlose Decke und bei welchem % du heute stehst. SCHRITT 4 — Bremsen und Alerts. Aktiviere Budget-Alerts in der Anthropic-Konsole und in Supabase (oder dokumentiere genau, wo sie konfiguriert werden, falls dein Plan sie nicht exponiert). Setze für jeden KI-Aufruf im System ein explizites max_tokens und einen Iterations-Deckel, falls es eine Schleife ist. Liefere ein einseitiges Runbook: 'wenn Alert X springt → schau Log Y an → führe Handlung Z aus (die mit dem Rollback von OS-04 verknüpft)'. Liefere keine Screenshots von 'sieht so aus, als ob es funktioniert'. Liefere: die Signal-Tabelle, die Edge Function + Migration laufend mit einer echten erfassten Fehlerzeile, die Aufschlüsselung der drei Rechnungen mit Prozentsätzen und das Runbook, das Alert → Log → Handlung verbindet.

ERGEBNIS

Ein operatives Observability-Panel für MOONKEY LAB mit: (1) einer Tabelle von 6+ Signalen (Schwelle · Bedeutung · Handlung) über den realen Logs von Supabase und Cloudflare; (2) funktionierender Erfassung von Client-Fehlern — Edge Function + Tabelle client_errors mit Insert-only-RLS, als versionierte Migration angewendet und mit einer echten Fehlerzeile verifiziert; (3) der Aufschlüsselung der drei Rechnungen (Supabase, Cloudflare, KI-Tokens) mit der kostenlosen Decke und dem aktuellen Nutzungs-% jeder einzelnen; und (4) einem einseitigen Runbook mit aktiven Budget-Alerts, das jeden Alert mit seinem Log und seiner Reaktionshandlung verbindet (verknüpft mit dem Rollback von OS-04).

KERNEINSICHT

In einer SSG gibt es keinen Server, wo man loggen könnte, also passiert die Hälfte dessen, was scheitert, im Browser des Nutzers und kommt nie bei dir an: Wenn du keine Client-Fehler einfängst, monitorst du nicht, du rätst. Und Kosten sind eine Angriffsfläche, nicht nur eine Buchhaltungszeile — ein Endpoint ohne Bremse, den jemand hämmert, ist zugleich ein Sicherheitsloch und eine Überraschungsrechnung, also sind die Decken (max_tokens, Rate-Limit, Budget-Alerts bei 80%) Sicherheit, nicht Buchhaltung. Die Rechnung überwacht man nicht zum Monatsende; man setzt die Bremse vorher.

ZU VERMEIDENDE FEHLER

  • ×Glauben, loggen = beobachten. Logs anzuhäufen, die niemand liest, ist keine Observability; Observability ist eine Handvoll Signale zu haben, die Entscheidungen auslösen. Eine Metrik, die nichts auslöst, ist Rauschen: lösch sie.
  • ×Client-Fehler ignorieren, weil 'der Server nichts meldet'. In einer SSG KANN der Server den TypeError, der den Bildschirm des Nutzers bricht, nicht melden. Ohne Erfassung im Browser operierst du in der halben App blind.
  • ×Eine Log-Tabelle für anon zum SELECT öffnen. Logs werden vom Client eingefügt, aber NIEMALS von ihm gelesen (du würdest Daten anderer Nutzer leaken). Insert-only-RLS für anon, Lesen nur Admin — und bestehe get_advisors.
  • ×Eine KI-Agent-Schleife oder eine Edge Function ohne max_tokens und ohne Iterations-Deckel starten. Das ist Ursache Nr. 1 für Überraschungsrechnungen: Die Kosten skalieren unsichtbar, während 'was die App tut' sich nicht ändert.
  • ×Die Rechnung zum Monatsende überwachen, statt Budget-Alerts bei 50/80/100% vorher zu setzen. Wenn du die Abbuchung siehst, hast du sie schon bezahlt; der Alert existiert, um zu bremsen, nicht um zu klagen.
  • ×Alert-Müdigkeit: Warnungen für alles konfigurieren, bis das Team sie stummschaltet. Alarmiere nur das Handlungsfähige und Dringende; jeder Alert muss 'was tue ich, wenn er springt' beantworten oder sollte nicht existieren.
  • ×Tabellen anderer Projekte in der geteilten DB (iron_*, world_*, focus_*) beim Aufsetzen des Loggings anfassen. Die Fehler-Tabelle gehört MOONKEY; erstelle sie mit eigenem Präfix und tritt XHUB IRON nicht auf die Füsse.

Nächste Konstellation

Operator Core

KI operieren