Commit Graph

849 Commits (8174ad77ccadd5e1e716ff9ea36dd7ab96d2d7cc)

Author SHA1 Message Date
nexxo 8174ad77cc Entwurf: die Seitenleiste der Konsole neu geordnet
Achtundzwanzig Eintraege in sieben Gruppen, und 'System' war der Platz fuer
alles, was sonst nirgends hinpasste. Der Entwurf haelt zwei Entscheidungen des
Betreibers fest: Selten-Benutztes verlaesst die Seitenleiste in einen eigenen
Einrichtungsbereich, und die drei Seiten, auf denen etwas wartet, stehen als
eigener Block ganz oben — mit einer Zahl daneben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:47:03 +02:00
nexxo ceefaf1745 Der Export braucht eine Momentaufnahme, sonst ist er keiner
Beim Ausarbeiten von Weg A aufgefallen: die Maschine LAEUFT zu dem Zeitpunkt
noch — sie muss, sie ist die Rueckfahrkarte der vierzehn Tage. Aus der Platte
einer laufenden Maschine zu lesen liefert aber keinen verlaesslichen Stand: ein
Dateisystem, in das gerade geschrieben wird, ist mitten im Lesen inkonsistent.

Man bekaeme ein Archiv, das aussieht wie eines und halb geschriebene Dateien
enthaelt — die schlimmste Sorte Attrappe, weil sie erst auffaellt, wenn jemand
die Daten wirklich braucht.

Der Weg geht deshalb ueber eine Momentaufnahme. Kein Zusatz, sondern die
Bedingung dafuer, dass Weg A ueberhaupt traegt.
2026-08-04 01:46:20 +02:00
nexxo 9ad802c4c9 Archivfrist entschieden: zwoelf Monate
Und sie wird nicht nur angekuendigt, sondern vollstreckt. Eine Frist, die in
einer Mail steht und die niemand durchsetzt, ist genau die Attrappe, gegen die
dieses Projekt seine Regeln geschrieben hat: der Speicher wuechse weiter, und
niemand merkte es, bis die Rechnung kommt.
2026-08-04 01:45:42 +02:00
nexxo 460d0db35b Entwurf: Kuendigung — Export, Archiv und Abbau
Beim Kuendigen verspricht CluPilot schriftlich einen fertigen Datenexport. Im
Code steht dazu "(both mocked for now)" — es gibt keinen, und es gab nie einen.
Und die gekuendigte Maschine wird nie abgebaut: sie laeuft weiter, fuer immer,
auf einem Platz, den niemand mehr verkaufen kann.

Der Ablauf stammt vom Betreiber, und sein Angelpunkt ist, VORHER zu fragen: wer
keinen Export braucht, loest keine Arbeit aus und wartet auf nichts. Wer einen
bestellt, bekommt ihn am Laufzeitende samt Link, bestaetigt, dass alles da ist —
und ohne Bestaetigung gilt er nach der Frist als angenommen. Das steht in
derselben Mail, sonst waere es keine Zustimmung.

Eine technische Huerde ist benannt statt ueberspielt: der Export passt NICHT auf
die Platte des Kunden. Ein Business-Paket hat 175 GB Kontingent bei 200 GB
Platte — wer sein Kontingent nutzt, hat keinen Platz fuer ein Archiv seiner
eigenen Daten, und der Gastagent ist kein Weg, hundert Gigabyte herauszutragen.
Drei Wege stehen mit ihren Kosten da; empfohlen ist der ueber den Host, an der
Maschine vorbei.

Offen und ausdruecklich als Frage markiert: wie lange ein VM-Archiv liegen
bleibt. Ohne Frist waechst dieser Speicher unbegrenzt.
2026-08-04 01:42:13 +02:00
nexxo 1eee6afe66 Version 1.6.2 — der Update-Agent sagt, dass er lebt
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:32:25 +02:00
nexxo d1755f5921 Der Update-Agent sagt, dass er lebt — auch wenn er nicht arbeiten kann
Gemeldet: „seit 1.5.1 muss ich nach jedem Update install-agent.sh fahren,
sonst kommt kein Update mehr". Die Konsole meldete „Der Update-Dienst auf dem
Server laeuft nicht" und schickte genau dorthin.

Er lief. Das Journal zeigt zwei Tage lang lueckenlos 57-59 Laeufe je Stunde.
Aber ueber zweiundachtzig Minuten hinweg startete und endete jeder Lauf in
DERSELBEN Sekunde, waehrend ein arbeitender Lauf zwei braucht: sie stiegen
alle sofort wieder aus, an der Sperre eines anderen Vorgangs — `flock -n 9 ||
exit 0`. Lautlos. Kein Journal-Eintrag (systemd sieht einen sauberen Lauf),
keine Zeile in der Statusdatei, nichts in der Konsole.

Und die Statusdatei war die einzige Lebendmeldung, die es gab. Sie wird erst
nach rund 190 Zeilen geschrieben — nach dem `git fetch` und nach einem
`docker compose exec`, beide ohne Zeitgrenze und beide unter der Sperre. Ein
Lauf, der davor aussteigt, hinterlaesst nichts, und nach zwanzig Minuten
schliesst die Konsole daraus, der Dienst sei tot. Sie schloss falsch, und die
Handlungsanweisung dazu aendert an einer gehaltenen Sperre nichts.

Drei Aenderungen:

  * Ein Lebenszeichen (agent-alive.json) als ERSTES bei jedem Lauf, vor allem,
    was blockieren kann. Zwei Zustaende: `running` heisst "ich habe die Sperre
    und arbeite", `blocked` heisst "ich bin ausgestiegen" — mit `since` (seit
    wann ununterbrochen) und `held_by` (wer, per fuser und ps).
  * Zeitgrenzen: 45 Sekunden um den docker-exec, 120 um den git fetch. Ohne
    sie wartet ein Abruf gegen eine tote Verbindung, bis das Betriebssystem
    ihn nach vielen Minuten aufgibt — und haelt dabei die Sperre.
  * Die Konsole liest das Lebenszeichen statt der Statusdatei. Der Unterschied
    ist der Punkt: Status heisst "zuletzt ERFOLGREICH nachgesehen",
    Lebenszeichen heisst "zuletzt ueberhaupt gelaufen". Ein blockierter Agent
    gilt als lebendig, aber seine Zahlen zaehlen nicht mehr als aktuell —
    `behind` und `target_release` fallen auf "unbekannt", statt eine Stunde
    alte Auskunft als frisch auszugeben.

Statt "laeuft nicht" steht dort jetzt "laeuft, kommt aber seit HH:MM nicht an
die Arbeit", mit dem Prozess darunter.

Ein Agent von VOR dieser Aenderung schreibt die Datei nicht — fuer den gilt
weiter die alte Regel. Ihn dafuer fuer tot zu erklaeren waere derselbe Fehler
mit umgekehrtem Vorzeichen; ein Test haelt das fest.

Widerlegt und damit ausgeschlossen: Besitzrechte (Wirt, .env und Behaelter
fuehren alle 1001), das Ausführbar-Bit (100755 im Repo), systemds
Startdrosselung (seit v1.1.0 abgeschaltet), ein zwischengespeicherter Zustand
im Panel (es liest bei jedem Aufruf frisch) und eine Luecke im Timer (keine).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:31:19 +02:00
nexxo 9f2a45c7bf Fuenf geparkte Kleinigkeiten nach der Mitarbeiterverwaltung
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Drei eigene Saetze statt einer geteilten Meldung an den drei
Inhaber-Wachen (sperren, Rolle aendern, entfernen) — jede nennt jetzt
den Grund: der Inhaber-Sitz ist der Zugang, mit dem der Kunde seine
eigene Cloud verwaltet.

Der Kommentar zum Auffangnetz in NextcloudUsers::applyRole() behauptete
einen engeren Schutz, als &&/|| linksassoziativ tatsaechlich bilden —
korrigiert statt geklammert, weil Klammern die gerade erst gebaute
|| true-Erkennung in FakeProxmoxClient gebrochen haetten, fuer einen in
der Praxis folgenlosen Fall.

Beide Fakes (FakeProxmoxClient, FakeRemoteShell) verweisen jetzt im
Kopfkommentar aufeinander: sie behandeln ein angehaengtes || true
unterschiedlich, und wer nur den einen kennt, soll das an der Klasse
lesen koennen.

Die Restnaht beim nachgeschickten Sperren (SyncSeatToNextcloud::invite())
bleibt Code wie er ist — Begruendung als Kommentar an der Stelle: die
Naht ist real, aber folgenlos (kein nc_synced_at, kein Wiederholen-Knopf,
Weg zurueck bleibt die Vordertuer).

Und ein kurzer Satz an der restore-Aktion in retry(), der die schon
vorhandene, aber weit oben stehende Begruendung buendelt: kein Feld fuer
ein faelliges enable, weil das die naechste Behauptung ueber eine Cloud
waere, in die das Portal nicht sehen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:09:45 +02:00
nexxo b50cc1968e Version 1.6.1 — der Versandtakt nennt die Mails beim Namen
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:59:36 +02:00
nexxo aaf8697c3d Unter jeder Spur steht, was in ihr faehrt
„Ruhig / mail-ruhig" war dieselbe Auskunft zweimal, die zweite in
Kleinschrift. Der Warteschlangenname stand da, weil ich ihn fuer nuetzlich
hielt — er ist es, aber in docker-compose.yml und in MailLane, also dort, wo
jemand ihn wirklich braucht. Auf der Seite, auf der ein Betreiber entscheidet,
was wie eilig ist, sagt er nichts.

An seiner Stelle steht jetzt, was in der Spur faehrt und warum sie so faehrt:
wer auf die direkte wartet (jemand vor einem Bildschirm), was die wichtige
traegt (Wartung, Abschaltung, Wiederanlauf), und warum die ruhige warten darf
(eine Rechnung um zwei nachts ist dieselbe Rechnung).

Nachgesehen, bevor der Name verschwand: die Bereitschaftsseite nennt die
Spuren nirgends beim Warteschlangennamen — es geht nichts verloren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:55:23 +02:00
nexxo 714f19d4d4 Der Versandtakt nennt die Mails beim Namen
Die Zuordnung zeigte class_basename(): „DormantAccountWarningMail" stand
dort, wo der Betreiber „Konto ohne Paket wird geloescht" sucht. Ein
Klassenname ist eine Auskunft ueber den Bauplan, keine ueber die Mail.

Die Namen liegen in lang/{de,en}/mail_pace.php und NICHT in MailCatalogue,
obwohl der sehr aehnlich klingt: der Katalog zaehlt Mail*arten* — vier
Mahnstufen, vier Eintraege, vier Absenderwege —, diese Seite verteilt
Mail*klassen* auf Spuren, und alle vier Mahnstufen sind EINE Klasse, die als
eine Zeile eine Spur bekommt. Zwei Listen, weil es zwei verschiedene Dinge
sind. Fehlt doch ein Name, steht der Klassenname da wie bisher statt einer
leeren Zeile — und ein Test haelt fest, dass keiner fehlt, in beiden Sprachen.

Der Warteschlangenname (mail-direkt und Geschwister) stand in der Wertspalte
zwischen Beschriftung und Eingabefeldern und las sich wie ein weiterer Wert.
Er steht jetzt als Unterzeile bei der Spur. Weg gehoert er nicht: es ist die
Zeichenkette, die der Arbeiter in docker-compose.yml abhoert.

Suite gruen, Codex ohne Befund. Der Namenstest faellt gegen die alte Fassung
— nachgemessen, nicht angenommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:52:04 +02:00
nexxo c548d1c9a6 Version 1.6.0 — Mitarbeiterverwaltung, und die Cloud lernt Mail
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Das groesste Stueck: aus der Sitzverwaltung im Userpanel wird echte
Verwaltung. Bis heute erreichte KEINE Aktion dort je die Nextcloud des
Kunden — Einladen legte keinen Benutzer an, Aussetzen sperrte niemanden aus,
Entziehen loeschte eine Zeile bei uns, waehrend der Mitarbeiter weiterarbeitete.
resend() trug seit jeher den Kommentar "Invite delivery is mocked for now."

Die tragende Entscheidung: niemand kennt ein fremdes Passwort. Nextcloud
erzeugt es selbst und mailt dem Mitarbeiter einen Link, an dem er sein eigenes
setzt. Weder der Inhaber noch der Betreiber noch CluPilot bekommen es je zu
sehen.

Dafuer musste die Kunden-Nextcloud erst ueberhaupt Mail verschicken lernen —
das konnte sie nie. Weder die Bereitstellung noch die Vorlage setzten je einen
Versand; keine Freigabe-Benachrichtigung, kein "Passwort vergessen", nichts,
stumm und ohne Fehlermeldung. Neuer Bereitstellungsschritt, eigene
Absenderdomain, und clupilot:configure-instance-mail fuer den Bestand.

Entziehen loescht ab jetzt nichts mehr: der Zugang wird gesperrt, die Arbeit
bleibt dort, wo das Team sie braucht. Wer wiederkommt, wird ueber dieselbe
Platzpruefung wieder aufgenommen.

Dazu aus dem Betrieb: das Aufheben einer Sicherheitssperre sagt jetzt die
Wahrheit, wenn die Firewall nicht mitkam, und der Abgleich holt es nach.

Und der Versandtakt der zweiten Sitzung: drei Mailspuren mit Prioritaet, ein
Register, das jeden Versand einmal zaehlt, und ein Arbeiter, der die Spuren
auch wirklich abholt.

NACH DEM UPDATE ZU TUN, sonst bleibt die Mitarbeiterverwaltung wirkungslos:
in der Konsole steht eine neue, leere und abgeschaltete Postfachzeile
`instance-relay`. Sie gehoert ausgefuellt, danach einmal
`php artisan clupilot:configure-instance-mail --dry-run` und dann ohne.
Solange sie leer ist, sagt das Panel das ehrlich und verspricht keine Mail.
2026-08-04 00:48:47 +02:00
nexxo 42966b66f3 Die zweite Tuer zur selben Attrappe, und die Adresse, die dem Kundenkonto gehoert
Zwei Befunde aus dem Re-Review.

Die Mailwache fehlte an retry(). sendInvite() weist ab, wenn der Versand nicht
eingerichtet ist — der Zweig `nc_synced_at === null => 'invite'` in retry()
schickt genau dasselbe `occ user:add --generate-password --email` und tat es
nicht. Der Weg dorthin ist am ersten Tag rein durch Klicken erreichbar: /users
steht offen, solange die Instanz noch in Bereitstellung ist (customer.active
prueft den Kunden, nicht die Instanz), und ohne AKTIVE Instanz sagt
mailversandBereit() `true`. Also kein Hinweis, keine Wache, Einladung hinaus,
Auftrag scheitert an `no_instance`, Zeile rot, Wiederholen-Knopf da. Wird die
Bereitstellung fertig, ohne dass der Versand steht, legte ein Klick das Konto
an, occ beendete mit 0, und die Plakette sagte "Eingeladen". Zweiter Weg zum
selben Ende: ein einmal eingerichtetes Versandkonto wird abgeschaltet, und jede
rote Zeile ohne nc_synced_at fuehrt beim Wiederholen dorthin.

Die Wache gilt nur fuer `invite`. `disable` und `restore` verschicken nichts —
sie duerfen auch ohne Mailversand laufen, und sie sind die Rueckfahrkarte aus
einem Fehlschlag. Sie zu sperren hiesse, einen offenen Zugang offen zu lassen,
weil eine Mail nicht ginge. Die Pruefung faehrt den ganzen Weg ab, mit echtem
Auftragslauf in der Mitte.

Und der W1-Fix hatte eine Zusicherung aufgeweicht: an `status === 'invited'` war
die Adresse des INHABER-Sitzes nie aenderbar, denn er steht immer auf 'active'.
An nc_username allein wurde sie es, bis linkToInstanceAdmin() greift. Die Folge
ist kein Umbenennen — laeuft die Adresse des Inhaber-Sitzes von der
Kundenadresse weg, legt "Anlegen" mit der echten Adresse eine ZWEITE Zeile fuer
dieselbe Person an, die gegen die Platzgrenze zaehlt. Der Inhaber-Sitz ist der
eine Sitz, dessen Adresse nicht ihm gehoert, sondern dem Kundenkonto.

Die Bedingung steht jetzt einmal als adresseAenderbar() statt dreimal
abgeschrieben; auseinanderlaufen muss sie nur einmal, um eine Luecke zu sein.
Der bestehende Umbenennen-Test prueft addressEditable nicht und waere gruen
geblieben — der neue faehrt am Formular vorbei und haelt zugleich fest, dass
Umbenennen am Inhaber-Sitz erlaubt bleibt.

Beide gegen den zurueckgedrehten Fix rot gesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:33:48 +02:00
nexxo 40f255565b Ein Knopf, der eine Mail verspricht, die niemand verschicken kann
Die zweite Haelfte von K4. Die Wanderung legt das Versandkonto an; solange es
niemand ausgefuellt hat, bestand die Attrappe unveraendert fort — und sie ist
nicht der Randfall, sondern der Normalfall: der Mailserver dieses Produkts
entsteht gerade erst, das Postfach ist am ersten Tag garantiert leer.

Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht das
Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne eingerichteten
Versand kommt dort aber nichts heraus, und `occ user:add --generate-password
--email` gelingt trotzdem: Nextcloud legt das Konto an, versucht die
Willkommensmail, protokolliert intern einen Fehler und beendet mit 0. Der
Auftrag meldete Erfolg, die Zeile sprang auf "Eingeladen", die Meldung versprach
einen Link, an dem der Mitarbeiter sein Passwort selbst setzt — und niemand
bekam etwas.

sendInvite() fragt jetzt GuestMailConfig::for($instance)->available(): ein
reiner Blick in die Datenbank, kein Tunnel, keine Warteschlange, deshalb darf er
auf der Seite stehen. Steht der Versand nicht, geht kein Auftrag hinaus, der
Sitz bleibt unveraendert — insbesondere ohne Anmeldenamen — und die Meldung sagt,
dass nichts verschickt wurde und woran es liegt.

Drei Entscheidungen dabei:

Der Knopf bleibt stehen, der Hinweis steht ueber der Tabelle. Ihn an jeder Zeile
verschwinden zu lassen liest sich nicht als "geht hier gerade nicht", sondern als
"das kann dieses Produkt nicht" — genau die Beschwerde, die schon einmal dazu
gefuehrt hat, dass die Aktionsspalte immer gezeichnet wird. Und der Zustand ist
voruebergehend: er endet, sobald der Betrieb das Konto ausfuellt.

Die Wache steht VOR dem Ratelimit. Sonst haette ein Inhaber seine Versuche
aufgebraucht, bevor ueberhaupt einer hinausgehen konnte.

Ohne laufende Instanz greift sie gar nicht: dann scheitert der Auftrag ohnehin an
`no_instance` und die Zeile sagt das im Klartext. Diese Wache gilt dem anderen
Fall — die Cloud laeuft, nur der Versand fehlt.

Das Anlegen bleibt offen: es verspricht ausdruecklich keine Mail, und der
Hinweistext sagt das auch. Der Bereitstellungsschritt ist unangetastet und bleibt
bei "nicht eingerichtet ist etwas anderes als kaputt".

Fuenf Zusicherungen, drei davon gegen den zurueckgedrehten Fix rot gesehen; die
uebrigen zwei sind Grenzpruefungen und muessen in beide Richtungen gruen sein.
Acht Bestandspruefungen richten den Versand jetzt ueber eine eigene
Hilfsfunktion ein — zwei davon haetten sonst aus dem falschen Grund bestanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:14:27 +02:00
nexxo 01eff483fa Das Postfach, auf dem alles steht, gibt es jetzt — leer und abgeschaltet
K4. GuestMailConfig sucht das gemeinsame Versandkonto der Kundeninstanzen unter
dem Schluessel `instance-relay`. Diesen Datensatz legte nirgends etwas an, und
die Konsole kann Postfaecher nur bearbeiten, nicht erstellen: der Betreiber
kaeme ohne Tinker gar nicht an den Start.

Daran haengt mehr als eine Einstellung. Ohne Postfach verschickt die
Kunden-Nextcloud keine Mail — aber `occ user:add --generate-password --email`
gelingt trotzdem: Nextcloud legt das Konto an, versucht die Willkommensmail,
protokolliert intern einen Fehler und beendet mit 0. Die Zeile sagte also
"Eingeladen" und die Meldung versprach einen Link, waehrend niemand eine Mail
bekam. Dieselbe Attrappe, die dieses Feature abschaffen sollte, eine Schicht
tiefer.

Die Wanderung legt die Zeile nach dem Muster der Postfach-Wanderung vom 28.07.
an: firstOrNew, mit der Adresse noreply@clupilot.cloud — inaktiv und ohne
Zugangsdaten. Ein Postfach, das ohne Zutun des Betreibers als einsatzbereit
dastuende, waere das naechste stille Versprechen; so erscheint es in der
Konsole als Zeile, die sichtbar noch etwas braucht, und isConfigured() bleibt
false. Ein bereits ausgefuelltes Postfach ruehrt ein zweiter Lauf nicht an —
sonst waere der stillste denkbare Ausfall genau ein `migrate` entfernt.

Die Zeile steht ab jetzt in jeder Testdatenbank. Die Fixtures, die sie bisher
selbst anlegten, fuellen sie aus, statt an der Eindeutigkeit des Schluessels
abzuprallen; die beiden Zusicherungen ueber die fuenf Betreiber-Postfaecher
nennen sie und halten fest, dass jede der beiden Wanderungen nur ihre eigenen
Zeilen zuruecknimmt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:57:54 +02:00
nexxo 1d0bacc650 Ein Schritt ohne Namen zeigt bei jeder Bestellung seinen Schluessel
provisioning.step.configure_instance_mail fehlte in beiden Sprachdateien,
waehrend der Schritt in der Pflicht-Pipeline steht: die Konsole zeigte bei
JEDER bezahlten Bestellung den rohen Schluessel.

Dazu die Pruefung, die danach fragt — fuer jeden Schritt jeder Pipeline, in
beiden Sprachen. Sie liest die Sprachdatei selbst und nicht trans($key, [],
'de'): Laravel faellt bei einem fehlenden deutschen Eintrag stumm auf die
Ersatzsprache zurueck, und die erste Fassung dieser Pruefung lief genau deshalb
gruen, obwohl der deutsche Eintrag geloescht war. Nachgeprueft, nicht
angenommen.

Und label() des Schrittes selbst statt eines hier nachgebauten Schluessels:
Host- und Kundenschritte liegen in verschiedenen Sprachdateien, und ein Test,
der die Zusammensetzung nachrechnet, prueft die Zusammensetzung statt den
Eintrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:57:42 +02:00
nexxo f62304a245 Jemanden aus einer Gruppe zu nehmen, in der er nicht ist, ist kein Fehler
K3. `mitarbeiter` und `nur-lesen` legt niemand an — sie entstehen erst, wenn
`user:add --group=…` sie zum ersten Mal braucht. `occ group:removeuser` legt
nichts an und beendet mit Fehlercode, wenn die Gruppe fehlt, und run() verundet
alle Exitcodes. Auf einer frischen Instanz scheiterte damit die erste
Rollenaenderung dauerhaft: "fehlgeschlagen — Die Cloud hat die Aenderung nicht
angenommen", und Wiederholen waehlte wieder role, also wieder denselben
Fehlschlag.

`2>/dev/null || true` hinter dem Entfernen, mit derselben Begruendung wie in
HostFirewall::releaseMany(): nicht in der Gruppe zu sein IST der gewuenschte
Endzustand. Nur fuers Entfernen — ein gescheitertes group:adduser bleibt ein
Fehlschlag, denn wer in keiner Gruppe landet, sieht in seiner neuen Cloud
nichts.

Warum die Suite das nie sah: FakeProxmoxClient liess jeden nicht verskripteten
Befehl gelingen, und die Pruefungen belegten die erzeugte Befehlsmenge, nie die
Antwort des Gasts. Der Fake beachtet jetzt ein abschliessendes `|| true` — das
ist eine Aussage der Shell, nicht des Aufrufers, und ein Fake, der trotzdem
einen Fehlercode zurueckgaebe, liesse einen Test beweisen, dass ein Befehl
scheitert, den keine echte Shell je scheitern laesst. Damit haelt die Pruefung
den Fake ausdruecklich auf Fehlercode und sieht applyRole() trotzdem true
liefern.

Dazu ein Testkommentar, der den falschen Schutz benannte: bei owner/admin auf
denselben Gruppennamen traegt `if ($gruppe !== $ziel)`, nicht array_unique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:57:15 +02:00
nexxo f476ee6bd1 Die Adresse ist die Person, sobald es das Konto im Gast gibt
W1. `addressEditable` hing an status === 'invited' — und nichts im Bestand
setzt seats.status je von 'invited' auf 'active'. Ein eingeladener Sitz blieb
damit fuer immer 'invited' und seine Adresse dauerhaft aenderbar, entgegen dem
eigenen Kopfkommentar dieser Klasse.

Der Ablauf: einladen fuer a@x, spaeter im Modal auf b@y aendern. seats.email
wird b@y, nc_username bleibt a@x (richtig, Nextcloud kann nicht umbenennen),
und kein Befehl traegt die neue Adresse in den Gast. Danach schickt "Erneut
senden" ein user:welcome --reset-password 'a@x': das Portal fuehrt b@y als
Inhaberin des Sitzes, tatsaechlich haelt a@x den Zugang und bekommt jederzeit
einen neuen Zuruecksetzen-Link.

Massgeblich ist jetzt blank($seat->nc_username) — genau die Bedingung, die der
Kommentar ohnehin meinte: einmal gesetzt, danach unveraenderlich. Der Kommentar
ist berichtigt. Beide Stellen, das Anzeigen und die erneute Pruefung in save(),
lesen dieselbe Angabe; die hydrierte Eigenschaft bleibt ungeglaubt.

Die Adresse nachtraeglich in den Gast zu fahren ist ausdruecklich nicht Teil
dieses Durchgangs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:57:14 +02:00
nexxo 1fd5d5f8d2 Was der Auftrag ausfuehrt, ist die Absicht von jetzt, nicht die von vorhin
Drei Befunde aus dem Gesamt-Review, alle dieselbe Klasse: die Zeile im Portal
behauptet etwas, das in der Cloud des Kunden nicht gilt.

K1 — Entziehen, waehrend die Einladung noch in der Warteschlange steht. Der
Arbeiter teilt sich die Warteschlange mit der bezahlten Bereitstellung; das
Fenster ist Minuten lang. queueSync() stieg bei nc_synced_at === null aus und
kannte damit den dritten Waechter-Fall nicht: bei `pending` entsteht dort
gerade etwas, das gesperrt werden muss. Und der invite-Auftrag liest den Status
jetzt am Ende frisch aus der Datenbank nach und schiebt bei revoked/suspended
ein disable hinterher — dieselbe Begruendung wie beim schon gebauten "ein
angelegtes Konto sofort vermerken". Vorher endete der Ablauf mit
status=revoked, nc_state=synced und einem aktiven Konto in der Nextcloud, ohne
jeden Knopf, es nachzuholen.

K2 — retry() konnte ein gescheitertes Entsperren nie wiederholen: die Ableitung
kannte disable, invite und role, aber kein enable, und waehlte deshalb role.
Der Auftrag fuhr Gruppen und Quota, gelang, die Zeile sprang auf "Aktiv" — und
user:enable war nie geschickt. Woran das zu erkennen waere, steht nirgends am
Sitz; ein Feld dafuer waere die naechste Behauptung ueber die Cloud, die
irgendwann nicht mehr stimmt. Deshalb raet retry() nicht, sondern schickt an
einer offenen Zeile beides: der neue Auftrag `restore` sperrt auf UND setzt die
Rolle. suspend() bleibt bei enable, denn dort ist bekannt, was fehlt.

W2 — der Inhaber konnte sich selbst aussperren. setRole() nahm jede Rolle aus
Seat::ROLES an, also auch owner; das Auswahlfeld bietet sie nicht an, die
Livewire-Methode ist trotzdem oeffentlich erreichbar. Mit zwei Inhaber-Sitzen
griff die Zaehlung in revoke() nicht mehr, und der echte Inhaber bekam
user:disable admin samt user:auth-tokens:delete admin in seine eigene Cloud.
revoke() und setRole() weisen owner jetzt genauso ab wie suspend(), und owner
ist keine zulaessige Zielrolle mehr. Damit faellt die Zaehlung selbst weg —
eine Sperre, die man sich erst erarbeiten muss, ist keine — und mit ihr die
Meldung users.last_owner.

Sechs Pruefungen, jede einzeln gegen den zurueckgedrehten Fix rot gesehen.
Umlaute in den beruehrten Dateien nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:56:48 +02:00
nexxo 87ecb4d064 Runbook fuer den Nachweis gegen echte Hardware: Einladung und Speicherplatz
Zwei Dinge, die sich gegen einen Fake nicht beweisen lassen. Der zweite kann
den Entwurf umwerfen: ob Nextcloud "0 B" als null oder als unbegrenzt
auslegt, steht in keiner Dokumentation.

Beide Nachweise stehen aus, nicht simuliert: mail.clupilot.cloud existiert
noch nicht. Das Runbook haelt Voraussetzungen, Ablauf und Fehlersuche fest,
inklusive einer Luecke, die der Aufgabenzettel nicht kannte -- die Konsole
kann heute nur ein bestehendes Postfach bearbeiten, keines mit dem
Schluessel "instance-relay" neu anlegen -- und traegt ein leeres
Ergebnisfeld mit dem Vermerk "steht aus" fuer beide Nachweise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:12:36 +02:00
nexxo 3704620bb5 Ein Knopf, der nur wieder rot zurueckkommt, und eine Zeile, die niemand einlud
Zwei Reste aus dem letzten Fix, beide dieselbe Klasse: die Zeile behauptet etwas
ueber den Gast, das dort nicht gilt.

Der Wiederholen-Knopf an einer entzogenen, nie gespiegelten Zeile lief auf
user:disable gegen einen Benutzer, den es nie gab — scheitert, dieselbe rote
Zeile, beliebig oft. retry() traegt jetzt denselben nc_synced_at-Vorbehalt wie
queueSync(), mit invite als Ausnahme, denn genau dafuer ist der Knopf da. Und
gezeichnet wird er nur noch, wo der Server ihn auch annimmt.

Nach einer Wiederaufnahme stand nc_state auf synced und status auf invited, also
zeichnete die Spalte "Eingeladen" — fuer jemanden, den niemand eingeladen hat
und dessen Konto im Gast weiter gesperrt ist. nc_state geht beim Wiederaufnehmen
zurueck auf none; nc_username und nc_synced_at bleiben, denn an letzterem haengt
das Entsperren beim folgenden Einladen. Eine Pruefung geht den ganzen Weg.
2026-08-03 22:46:32 +02:00
nexxo 02a5120b9c Der Weg zurueck geht durch die Vordertuer, und ein Fehlschlag bleibt sichtbar
Der letzte Fix hat zwei eigene Loecher gerissen, beide dieselbe Klasse wie das,
was er schliessen sollte: die Oberflaeche versicherte etwas, das im Gast nicht
eingetreten war.

Entzogen stand VOR dem Fehlschlag, also verschwanden Grund und
Wiederholen-Knopf genau dort, wo sie am noetigsten sind: scheitert das disable,
ist das Konto weiter offen, waehrend die Zeile "entzogen" sagt. Es gibt keinen
Wiederholungslauf und keinen Abgleich, und diese Seite ist die einzige, die
nc_state anzeigt. Beides steht jetzt nebeneinander.

Und die Meldung empfahl einen neuen Sitz, den die Eindeutigkeit von
(customer_id, email) unmoeglich machte. addSeat() nimmt einen entzogenen Sitz
derselben Adresse wieder auf — durch dieselbe Platzpruefung wie jeder neue,
denn das ist der Grund, warum es die Vordertuer sein muss und kein Knopf an der
Zeile. Der Auftrag entsperrt dabei, was revoke() gesperrt hat; user:welcome tut
das nicht.

retry() bekommt die owner-Wache nach, die bisher nur am Knopf davor hing. Und
der Kommentar in mount() behauptet keine Heilung mehr, die nicht stattfindet.
2026-08-03 22:32:45 +02:00
nexxo a569d2215b Entzogen bleibt entzogen, und der Inhaber laedt sich nicht selbst ein
Dass revoke() nicht mehr loescht, hat eine Kehrseite, die nirgends stand: der
Umschalter in suspend() kannte nur zwei Zustaende und machte aus einem
entzogenen Sitz beim zweiten Klick wieder einen aktiven — samt user:enable und
vorbei an der Platzgrenze, die nur beim Anlegen geprueft wird. sendInvite() bot
denselben Weg. Beide weisen 'revoked' jetzt ab, die Zeile traegt keinen
Handlungsknopf mehr, und wieder aufmachen kann man sie gar nicht: der Weg
zurueck ist ein neuer Sitz.

Der Einladen-Knopf stand auch an der Inhaber-Zeile. Ein Klick vor der fertigen
Bereitstellung haette spaeter ein zweites Konto in der Gruppe admin angelegt,
neben dem echten — und mount() haette den Sitz danach nie wieder verknuepft,
weil die Bedingung am Zustand hing statt am Anmeldenamen.

queueSync() entscheidet ueber nc_synced_at statt ueber nc_state: ein Sitz,
dessen Einladung an einem unerreichbaren Gast scheiterte, hat dort nichts, was
man sperren koennte. Damit das keine Luecke reisst, vermerkt der Auftrag ein
angelegtes Konto sofort, auch wenn die Rolle danach scheitert.
2026-08-03 22:14:19 +02:00
nexxo 4477a7699e Die Mailseite fragt drei Fragen statt eine Wand zu zeigen
Vier Karten in einer schmalen Spalte, und die letzten beiden allein
zweiundzwanzig gleich aussehende Zeilen: fuenf Zwecke, siebzehn Mailarten,
jede „Beschriftung, Auswahlfeld", darunter zweimal derselbe Speichern-Knopf.
Wer die Zuordnung EINER Mailart aendern wollte, scrollte an allem anderen
vorbei und fand am Ende eine Wand. Der Betreiber hat die Seite als unlesbar
gemeldet.

Jetzt drei Reiter nach dem Muster von Admin\Integrations, das aus demselben
Grund umgebaut wurde — geteilt nach der Frage, die man mitbringt: WOMIT
gesendet wird, WER sendet, WAS von wo rausgeht. Der offene Reiter steht in der
Adresszeile, ein Neuladen landet dort, wo man war.

Die eigentliche Wand war die Zuordnung, und zwar nicht wegen ihrer Laenge:
dass die Wegwahl je Mailart auf den Zweck zurueckfaellt, stand nur als Satz in
der leeren Auswahl — siebzehnmal derselbe Satz neben siebzehn Zeilen, die alle
gleich aussahen. Jetzt steht jede Mailart unter ihrem Zweck: oben das Postfach
des Zwecks, darunter eingerueckt, was davon abweichen darf. Die Regel ist die
Form geworden.

Die leere Auswahl nennt die Adresse, die stattdessen greift — und zwar die,
die WIRKLICH greift. Ein Codex-Befund am eigenen Umbau: ein abgeschaltetes
Postfach faellt in MailboxResolver::for() auf „System" zurueck, und
writePurposes() erlaubt genau das. Das Etikett haette eine Adresse genannt,
die nichts verschickt, an der Stelle, an der man nachsieht, um es richtig zu
machen. Es fragt deshalb dieselbe Aufloesung, die beim Senden entscheidet.

Ein Knopf statt zwei: Zwecke und Wegwahl sind fuer den Betreiber ein Vorgang.
Die Reihenfolge ist die Zusicherung — die Zwecke werden geprueft und werfen,
BEVOR eine einzige Wegwahl geschrieben ist, sonst hinterliesse eine abgelehnte
Eingabe die halbe Zuordnung. savePurposes() und saveRoutes() bleiben, was sie
waren.

Beide Warnungen stehen ueber den Reitern: sie gelten fuer die ganze Seite, und
wer nachsieht, warum keine Mail ankam, soll nicht erst den richtigen Reiter
suchen.

Vier bestehende Tests zeigen jetzt auf den Postfach-Reiter, fuenf neue kamen
dazu — darunter der Regressionstest zum Codex-Befund, der gegen die alte
Fassung faellt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:06:50 +02:00
nexxo 0e3a76a7cb Aus der Attrappe wird Verwaltung
Anlegen und Einladen sind zwei Vorgaenge. Einladen schickt einen Auftrag auf
die Bereitstellungs-Warteschlange — die einzige, die einen Gast erreicht — und
der Sitz zeigt danach, was WIRKLICH passiert ist, samt Grund und
Wiederholen-Knopf. Ohne das drueckt der Inhaber wieder und wieder.

Entziehen loescht nichts mehr. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde,
mit echter Restzeit in der Meldung statt stummer Verweigerung.

Eine Pruefung verbietet user:delete im ganzen app/-Verzeichnis.
2026-08-03 21:52:59 +02:00
nexxo e1052f6c4e Zwei fehlende Pruefungen aus der Nachbesserung nachgezogen
owner und admin teilen sich dieselbe Nextcloud-Gruppe (admin) — richtig
geloest durch array_unique plus Zielausschluss, aber bislang ungeprueft: ein
Rollenwechsel nach admin darf group:removeuser admin nie auslösen. Und eine
Instanz ohne Host muss false liefern, ohne einen einzigen Befehl abzusetzen.

Beide Mutationsproben im Fix-Bericht dokumentiert, inklusive einer ehrlich
vermerkten Einschraenkung: der host===null-Teil der Waechterbedingung in
run() ist fuer sich genommen nicht unabhaengig von PHPs eigener Typpruefung
beweisbar, weil ProxmoxClient::forHost() ohnehin einen TypeError wirft, den
derselbe catch(Throwable) faengt. Der vmid-Teil derselben Bedingung ist es
sehr wohl, empirisch bestaetigt.
2026-08-03 21:32:42 +02:00
nexxo 4023a0939c Der Altbestand des Registers wird geradegezogen
Was der doppelte Zuhoerer geschrieben hat, steht noch da: zweiundzwanzig
Zeilen fuer zehn Vorgaenge. Das Register beantwortet damit genau die eine
Frage falsch, fuer die es gebaut wurde — hat der Kunde das bekommen, und wie
oft.

Die Wanderung liest den Fehler rueckwaerts. Der Zuhoerer verdoppelte jeden
Versand exakt, also bleibt von jeder Gruppe gleicher Zeilen (Empfaenger,
Betreff, Mailklasse, Zeitpunkt) die aeltere Haelfte stehen. Aus vier werden
zwei — das waren zwei echte Anmeldungen in derselben Sekunde, beide
verdoppelt —, aus zwei wird eine, Einzelnes bleibt unberuehrt. Ungerade
Gruppen kann dieser Fehler nicht erzeugt haben; taucht doch eine auf, wird
aufgerundet, damit im Zweifel eine Zeile zu viel stehen bleibt.

down() ist leer, mit Begruendung: geloeschte Zeilen kommen nicht zurueck, und
eine erfundene Zeile waere schlimmer als die Luecke.

Auf dieser Installation gefahren: 22 Zeilen vorher, 11 danach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:18:26 +02:00
nexxo 1bc1e972d6 Der Griff, mit dem ein Sitz in der Nextcloud wirksam wird
Anlegen, einladen, Gruppe, sperren, freigeben. Keine Methode wirft — ein nicht
erreichbarer Gast gibt false zurueck, statt den Arbeiter mitzureissen, auf dem
die bezahlte Bereitstellung laeuft.

Zwei Fallen sind hier eingebaut statt umgangen: user:disable allein laesst
Sitzungen fuenf Minuten weiterleben (deshalb auth-tokens:delete daneben), und
ein Konto mit eigenem Speicherplatz folgt der Paketvorgabe nicht mehr
(deshalb --delete beim Verlassen von readonly, kein Ueberschreiben).
2026-08-03 21:16:37 +02:00
nexxo ea54387a5e Eine verschickte Mail hinterlaesst wieder eine Zeile, nicht zwei
App\Listeners\RecordSentMail hoert auf MessageSent, und Laravel meldet die
Zuhoerer in app/Listeners von selbst an. Die zusaetzliche Anmeldung von Hand
im AppServiceProvider wirkte deshalb nicht als Bekraeftigung, sondern als
zweiter Zuhoerer: jeder Versand wurde zweimal verbucht.

An einer einzelnen Mail faellt das niemandem auf — der Trockenlauf des
Versandtakts schickte fuenfzig und fand hundert Zeilen. Alle zweiundzwanzig
echten Zeilen im Register sind entsprechend Doppelungen; das sind Daten, ueber
die der Betreiber entscheidet, und sie bleiben hier unangetastet.

Die Handanmeldung ist weg, ihre Begruendung steht an ihrer Stelle. Ein Test in
MailRegisterTest nagelt eine Mail auf genau eine Zeile fest, egal wie der
Zuhoerer angemeldet ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:13:36 +02:00
nexxo 320e485215 Dauerhafte Pruefung: Wanderung und linkToInstanceAdmin() bleiben gleich
Die Gleichheit zwischen der eingefrorenen Wanderungsfassung und der
Modellmethode war bisher nur mit einer geloeschten Testdatei belegt - gut
fuer heute, nicht fuer morgen. Nach dem Muster von HostTokenKeyMigrationTest
laedt der neue Test die echte Wanderungsdatei per require() und haelt sie
gegen linkToInstanceAdmin(): einmal die entschluesselte Falle direkt
geprueft (nc_username darf nie Chiffretext werden), einmal beide Wege bei
derselben Ausgangslage gegeneinander.
2026-08-03 21:07:19 +02:00
nexxo 80974108b1 Wanderung friert das Nachziehen selbst ein, statt die Modellmethode zu rufen
up() rief bisher Seat::linkToInstanceAdmin() auf. Ein migrate:fresh auf
frischer Datenbank haette damit die dann aktuelle Fassung dieser Methode
mitgefahren, nicht die von heute. Die Nachbefuellung ist jetzt eine eigene,
dokumentiert duplizierte Fassung ueber den Query Builder.

nc_admin_ref ist am Modell verschluesselt gecastet - roher Spaltenzugriff
haette Chiffretext statt Klartext geliefert. Crypt::decryptString() macht
denselben Aufruf wie der Modell-Cast, ohne eine Modellklasse zu laden.

Kleinere Nachbesserung: fill() statt forceFill() in linkToInstanceAdmin()
(alle drei Felder stehen in $fillable), und die Gruppen-Zuordnungspruefung
verifiziert jetzt die konkreten Namen 'mitarbeiter'/'nur-lesen', nicht nur
"irgendein Wert".
2026-08-03 20:59:45 +02:00
nexxo 43d4c02353 Plan: der Inhaber-Sitz muss auch zur Laufzeit verknuepft werden
Die Wanderung aus Aufgabe 5 loest das fuer den Bestand. Users::mount() legt
aber beim ersten Besuch einen owner-Sitz an — und der stuende ohne
Verknuepfung auf 'none'. Das Panel boete dem Inhaber dann an, sich selbst
einzuladen, und der Auftrag traefe auf ein Konto, das die Bereitstellung
laengst angelegt hat.

Beim Pruefen von Aufgabe 5 aufgefallen, nicht beim Planen.
2026-08-03 20:55:21 +02:00
nexxo 94210a7176 Ein Sitz fuehrt Absicht und Wirklichkeit getrennt
status = was der Inhaber will, nc_state = was in der Nextcloud ist. Ein
einzelnes Feld muesste luegen — und "fehlgeschlagen" liesse sich gar nicht
sagen.

Rollen werden Nextcloud-Gruppen. Der Inhaber-Sitz wird mit dem Admin-Konto
verknuepft, das die Bereitstellung laengst angelegt hat.

SeatFactory setzt nc_state jetzt explizit (wie status/locale bei Customer
und Instance): Eloquents create() liest DB-Spalten-Vorgaben nicht ins
In-Memory-Modell zurueck, sonst haette Seat::factory()->create()->nc_state
null statt 'none' geliefert.
2026-08-03 20:48:36 +02:00
nexxo 6513ccdb00 Behebt Zaehlfehler: gestartete Instanzen wurden doppelt gezaehlt
$started++ stand an zwei Stellen: nach der Uebersprungsgruendung und nach
dem Dispatch. Im echten Lauf wurde jede Instanz zweimal gezaehlt.

Entfernt die redundante Zaehlung nach dem Dispatch und ergaenzt zwei Tests,
die die Zahlenwerte in der Abschlusszusammenfassung pruefen — diese Tests
haetten den Fehler gefangen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:39:14 +02:00
nexxo d7d116e9a5 Jede Mail faehrt wieder ueber ihren eigenen Mailer
Mail::to(...) loest den Standard-Mailer auf, und dessen queue() schreibt
danach `$view->mailer($this->name)`. Damit war der im Konstruktor gewaehlte
Mailer (cp_mail_<typ>) durch 'smtp' ersetzt, bevor der Auftrag ueberhaupt
gebaut war.

Mit dem Mailer fiel alles weg, was an ihm haengt: MailboxTransport, die
Postfachsuche samt passendem Absender — und der Notschalter aus
App\Support\MailDelivery, der genau dort sitzt. Eine abgeschaltete Zustellung
schaltete deshalb nichts ab, und die Mails meldeten sich mit den Zugangsdaten
aus der Konfiguration an, waehrend der Absender aus der Mail kam: „553 Sender
address rejected". Das lief hier im Minutentakt.

Zwei Stellen hatten es schon richtig — MaintenanceNotifier und MailPreview,
beide mit der Erklaerung an der Zeile. Neun andere nicht. Alle beginnen jetzt
mit Mail::mailer($mail->mailer), sodass das Ueberschreiben denselben Wert
zurueckschreibt.

Die Attrappen in drei Tests zielten auf to() und trafen damit nicht mehr den
Einstieg; drei von ihnen bestanden ohnehin nur zufaellig, weil ein Aufruf auf
null ebenfalls warf. Sie zielen jetzt auf mailer().

Erzwungen durch tests/Feature/MailDispatchTest.php: Mail::to( ist in app/
verboten, und ein Gegentest haelt das Framework-Verhalten fest, damit die
Regel fallen darf, wenn Laravel sie eines Tages unnoetig macht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:36:20 +02:00
nexxo 41554da7c0 Behebe 4 kritische Fehler in ConfigureInstanceMail
1. Übergebe $run->uuid statt $run->id an AdvanceRunJob (KRITISCH: Lauf
   wird nie gefahren, da HasUuid eine separate uuid erzeugt)
2. Benutze STATUS_PENDING statt STATUS_RUNNING beim Anlegen
3. Fuege hasRunInFlight-Schutz gegen doppelte Laeufe ein
4. Stelle Umlaute in Betreibertexten her, halte Code umlautfrei
5. Reorganisiere handle() nach RefreshHostFirewall-Muster
6. Erweitere Tests um Queue::assertPushed() fuer AdvanceRunJob mit uuid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:33:50 +02:00
nexxo d861c34c26 Plan berichtigt: der Nachruestbefehl haette nie einen Lauf gefahren
AdvanceRunJob sucht per uuid, mein Zettel uebergab die Autoincrement-Id.
HasUuid erzeugt beide unabhaengig; sie sind nie gleich. In Produktion haette
der Auftrag nie eine Zeile gefunden, still geendet, und jeder Lauf waere fuer
immer auf running stehengeblieben — waehrend der Befehl "gestartet" meldet.
Dazu STATUS_RUNNING statt STATUS_PENDING beim Anlegen.

Die eigentliche Luecke war die fehlende Zusicherung: die Pruefungen zaehlten
Laufzeilen und sahen nie nach, WAS an den Auftrag ging. Sie ist ergaenzt.

Ausserdem zwei Reste in der Spezifikation: mail_smtpauth folgt seit Aufgabe 1
dem Postfach statt einem festen Wert, und der Anzeigename je Kunde entfaellt —
`instances` traegt keine Spalte, aus der er kommen koennte.
2026-08-03 20:32:25 +02:00
nexxo 3bd0d56077 Bestehende Instanzen bekommen den Mailversand nachgetragen
Ohne diesen Lauf bliebe die Mitarbeiterverwaltung fuer jeden Altkunden tot:
die Einladung geht von SEINER Nextcloud aus, und die kann bis heute nichts
verschicken. Ein Befehl, kein Zeitplan — dieselbe Begruendung wie bei
clupilot:refresh-host-firewall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:25:08 +02:00
nexxo b92176ee59 Mailversand-Uebersprung wird am Lauf sichtbar, nicht nur im Log
Log::warning allein reicht nicht: bis der Mailserver steht, trifft dieser
Zweig auf JEDE einzelne Bestellung zu, und die einzige Rueckmeldung darf
nicht in einer Logdatei verschwinden, in die niemand schaut.
RegisterMonitoring macht sein Ueberspringen genau deshalb am Lauf sichtbar
(outcome: info) statt nur im Log — ConfigureInstanceMail bekommt jetzt
dieselbe Behandlung, das Log bleibt daneben fuer die Nachschau ausserhalb
der Konsole.

Zwei weitere Befunde aus der Pruefung behoben:
- Der Test "traegt jeden Wert einzeln in den Gast" prueft jetzt alle acht
  Schluessel aus GuestMailConfig::values() statt zwei — ein vergessener
  Schluessel faellt jetzt hier auf, nicht erst beim Kunden.
- `$config->problem() ?? 'mail_unavailable'` entfernt: im Zweig
  `! $config->available()` liefert problem() immer einen Grund, der
  Rueckfall konnte nie greifen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:19:01 +02:00
nexxo 3930cf12ae Mailversand darf eine bezahlte Bereitstellung nie aufhalten
ConfigureInstanceMail stand als Pflichtschritt in der customer-Pipeline und
liess den ganzen Lauf scheitern, wenn Server oder Postfach fehlten. Der
Mailserver dieses Produkts existiert noch nicht — er wird gerade erst
aufgesetzt. Jede neue bezahlte Bestellung waere damit an einer Nebenfunktion
haengengeblieben: der Kunde zahlt, die Cloud kommt nicht.

Denselben Fall hat dieses Projekt bei RegisterMonitoring schon richtig
entschieden: eine nicht erreichbare Ueberwachung blockiert die Bereitstellung
nie. Mailversand bekommt jetzt dieselbe Behandlung. Fehlt Server oder
Postfach, geht der Schritt mit advance() weiter und protokolliert den Grund
per Log::warning (Instanz-UUID + Grund) fuer den Betreiber; nachgeholt wird es
ueber clupilot:configure-instance-mail (Aufgabe 4), sobald beides steht.

Nicht eingerichtet bleibt etwas anderes als kaputt: sobald Server und
Postfach da sind, wird geschrieben wie zuvor, und ein echter Schreibfehler
(occ-Fehlercode, Gast antwortet nicht) bleibt weiterhin ein Fehlschlag ueber
CustomerStep::guest().

Der bestehende Test "schreibt GAR NICHTS, wenn der Mailserver fehlt" prueft
jetzt StepResult::ADVANCE statt FAIL und zusaetzlich, dass der Grund geloggt
wird. Ein neuer End-to-End-Test in CustomerProvisioningEndToEndTest faehrt
eine bezahlte Bestellung ohne jede Mail-Fixture vollstaendig bis "completed"
— die Zusicherung, um die es hier eigentlich geht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:06:34 +02:00
nexxo 499240dd45 Die Kundeninstanz lernt Mail verschicken
Bis hierher konnte sie es nicht — keine Freigabe-Benachrichtigung, kein
"Passwort vergessen", nichts. Das ist die Voraussetzung dafuer, dass die
Einladung an einen Mitarbeiter aus SEINER Nextcloud kommt und das Passwort
dort entsteht, wo niemand sonst es sieht.

Ohne Server oder Postfach wird nichts geschrieben und der Schritt scheitert
mit Grund.

Zwei bestehende Pruefungen mitgezogen: CustomerStepBaseTest erwartete eine
feste Schrittzahl (16 -> 17), und CustomerProvisioningEndToEndTest lief ohne
Mailversand-Fixtures durch die volle Pipeline und scheiterte jetzt genau dort
— beide auf dieselbe Art nachgezogen wie ApplyStorageQuota es vormacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:59:44 +02:00
nexxo 58b82aa8a2 commandExpandingEnv zurueckgenommen — sie schuetzte vor nichts
Die Methode sollte das SMTP-Passwort aus der Prozessliste der Kunden-VM
halten. Nachgerechnet: guestExec faehrt die ganze Zeile als /bin/sh -c, die
innere Shell setzt den Wert vor dem exec ein, und Nextcloud legt ihn danach
ohnehin im Klartext in config.php ab. Eingespart wurde einzig das Argv des
docker-Aufrufs, auf einer Maschine, die den Wert an zwei anderen Stellen
zeigt.

Ein Versteck, das dieselbe Person nicht aussperrt, die den Ablageort lesen
kann, ist keines — und ein Kommentar, der Schutz behauptet, haelt den
Naechsten vom Nachsehen ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:46:44 +02:00
nexxo 62e69ecde2 Aufgabe 2 gestrichen: das Passwortversteck schuetzte vor nichts
Die Pruefung hat nachgerechnet, was ich im Entwurf behauptet hatte, und es
stimmte nicht. guestExec faehrt die GANZE Zeile als /bin/sh -c auf der
Kunden-VM; die innere Shell setzt den Wert vor dem exec ein, er steht also
auch im Argv von php occ; und Nextcloud legt ihn danach ohnehin im Klartext
in config.php ab. Eingespart wurde einzig das Argv des docker-Aufrufs — auf
einer Maschine, die denselben Wert an zwei anderen Stellen zeigt.

Statt den Kommentar schoenzureden faellt der Mechanismus weg. Aufgabe 3
schreibt das Passwort als gewoehnliches Argument und sagt in ihrem
Kopfkommentar, wo es ueberall sichtbar ist. Geprueft wird stattdessen, was
wirklich gilt: dass der Wert maskiert ist und keinen zweiten Befehl starten
kann — der Aufruf laeuft als root auf einer Kundenmaschine.

Die Eingrenzung liegt in der Infrastruktur: Versandkonto nur mit Senderecht,
Versandport nur fuer die eigenen Hostadressen, Sendelimit je Konto.
2026-08-03 19:45:35 +02:00
nexxo 0c8b7813b4 Kommentare korrigiert nach Codex-Befund
Der Kopfkommentar versprach falsch, dass der Wert nur im Container staende.
Die Realitaet: guestExec() uebergibt die ganze Zeichenkette als sh -c, daher
steht der Praefx im Argv der VM-Shell. Debians dash optimiert ihn nicht weg.

Der echte Gewinn: Der Wert reist als Umgebungsvariable in den Container und
wird dort von sh eingesetzt — sitzt nicht im Prozessabbild DES CONTAINERS.
Das war auch bei command() mit OC_PASS=... schon so.

Testname: haelt den Wert aus dem occ-Aufruf heraus (prueft Innenteil).
Kommentar: Das "sh -c" trennt VM-Shell von Container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:32:40 +02:00
nexxo 4feaba1bb1 Eine occ-Befehlsform, deren Wert erst im Container eingesetzt wird
config:system:set will den Wert als Argument. Setzte ihn die aeussere Shell
ein, stuende das SMTP-Passwort in der Prozessliste der Kunden-VM. Mit `sh -c`
im Container steht dort nur der Variablenname.

Im Kopfkommentar steht ausdruecklich, dass das Hygiene ist und kein Schutz:
Nextcloud legt den Wert danach im Klartext in config.php ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:19:54 +02:00
nexxo cacdca0a18 Passwort-Leck-Test verschaerft, mail_smtpauth an authenticates gekoppelt
Der Passwort-Test prüfte nur die Abwesenheit des Schlüssels
mail_smtppassword — ein Leck unter anderem Namen wäre durchgerutscht. Prüft
jetzt zusätzlich, dass der Passwort-WERT in keinem values()-Eintrag steckt.

mail_smtpauth stand fest auf 'true', obwohl Mailbox::isConfigured() ein
Postfach ohne Anmeldung ausdrücklich zulässt. Nextcloud hätte sich dann mit
leerem Passwort anzumelden versucht — jeder Versand wäre still gescheitert.
Richtet sich jetzt nach $box->authenticates, mit Test für den false-Fall.
2026-08-03 19:15:36 +02:00
nexxo da9e71bc88 Welche Mailwerte eine Kundeninstanz bekommt — und wann gar keine
Server aus app_settings, Zugangsdaten aus dem Postfach, an einer Stelle
zusammengelegt. Fehlt eines von beiden, wird NICHTS geschrieben: eine halb
eingetragene Mailkonfiguration laesst Nextcloud bei jedem Versand still
scheitern.

mailboxFor() ist die Naht, an der spaeter ein Konto je Kunde haengt.
2026-08-03 19:06:04 +02:00
nexxo 96bc2c5a07 Fix-Runde 2: eine fremde Mailklasse faehrt im ruhigen Takt statt ungebremst
gedrosselteSpur() las die Spur nur aus $this->mailable->queue. Die setzt aber
allein RidesALane, und den tragen ausschliesslich unsere vierzehn Klassen. Eine
Mailklasse aus einem Paket — Fortify, ein kuenftiges Abhaengigkeitspaket —
bekam damit gar keine Drossel.

Das ist die Gegenrichtung zu der Entscheidung aus Aufgabe 1: dort faellt eine
unbekannte Klasse ausdruecklich in die ruhige Spur, weil eine zu langsam
verschickte Mail ein kleinerer Fehler ist als ein ungedrosselter Schub, den
niemand vorhergesehen hat. Steht keine Spur am Mailable, entscheidet jetzt
MailLane::for() — dieselbe Quelle, die Aufgabe 1 dafuer gebaut hat.

Schlange und Takt fallen fuer so eine Mail auseinander: sie bleibt auf default,
weil die Schlange der Trait waehlt, und faehrt trotzdem im ruhigen Takt, weil
den der Auftrag waehlt. Der Kommentar sagt, warum das kein Versehen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:04:32 +02:00
nexxo e99da32aa2 Plan: Testname in Aufgabe 3 sagte das Gegenteil seiner Zusicherung
Die Pruefung stellt fest, dass der Schritt sich NICHT mit einem Merker
sperrt, sondern beim zweiten Lauf erneut schreibt — das ist bei einer
Nachruestung ueber den Bestand der Normalfall.
2026-08-03 19:02:01 +02:00
nexxo f1fb06699f Plan: Mitarbeiterverwaltung in acht Aufgaben
Aufgabe 1-7 sind ohne den neuen Mailserver pruefbar und koennen vollstaendig
gebaut werden, bevor er steht. Aufgabe 8 ist der Nachweis gegen echte
Hardware — und der zweite Punkt darin kann den Entwurf umwerfen: ob Nextcloud
"0 B" als null oder als unbegrenzt auslegt, steht in keiner Dokumentation.

Beim Vorabdurchgang drei eigene Fehler gefunden und behoben: StepResult hat
kein failed(), und drei von vier Rueckrufen in NextcloudUsers trugen eine
andere Signatur als der vierte.
2026-08-03 18:55:40 +02:00
nexxo 7087ef6a2f Die Bereitschaftsseite meldet eine Spur, die steht
Eine Spur, die steht, sah von außen aus wie eine Spur, die leer ist —
Queue::size() unterscheidet beides nicht. Die neue Prüfung liest das
Alter des ältesten wartenden Auftrags je Spur ausdrücklich über die
Redis-Verbindung `redis` (dieselbe, mit der der Arbeiter in
docker-compose.yml läuft), unabhängig von QUEUE_CONNECTION — und bleibt
damit auch unter der auf sync gezwungenen Testsuite ehrlich, weil Redis
in beiden Umgebungen erreichbar ist. Die Grenze liegt bei einer Stunde:
20 je 10 Minuten (die langsamste Spur) sind 120 in der Stunde, und ein
Lauf dieser Größe steht bei elf Kunden nicht so lange an.

Die eigene Seite admin/mail-pace (Task 5) bekam dabei einen eigenen
tab-Wert in Readiness::checkUrl() — der bestehende ReadinessPageTest
hält jeden Verweis gegen die Stelle, an der sein Feld wirklich liegt,
und 'mail' hätte auf die falsche Seite gezeigt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:37:20 +02:00