Commit Graph

865 Commits (4debc798839c6d857b337047d868598ad2b862a4)

Author SHA1 Message Date
nexxo 4debc79883 Eine hängende Sperre ist jetzt ein Knopf, kein SSH-Zugang
Am 4. August 2026 hielt ein einziger Aufruf — `timeout 45 docker compose
exec` ohne `--kill-after` — die Sperre stundenlang, und jeder folgende
Lauf wurde übersprungen. Behoben ist die Ursache in v1.6.5; was fehlte,
war der Griff für das nächste Mal. Er lag per SSH auf dem Wirt.

Die Konsole kann ihn nicht selbst führen: sie ist www-data in einem
Behälter, der Sperrenhalter ist ein Prozess auf dem WIRT. Also bittet
sie den Agenten, und der ruft den root-eigenen Helfer — dasselbe Muster
wie `apply-proxy-hosts`, mit eigener sudoers-Zeile.

Die Bitte liegt in einer EIGENEN Datei, und das ist keine
Geschmacksfrage: der Agent nimmt die Sperre in seinen ersten Zeilen,
lange bevor er den Postkasten ansieht. Ein blockierter Lauf steigt
vorher aus — eine Entsperr-Bitte im Postkasten erreichte ihn also genau
dann nie, wenn sie gebraucht wird. Dazu nimmt der Postkasten eine Bitte
zur Zeit an, und die dreißig Minuten, in denen dort eine wartende
Update-Anfrage liegt, sind die, in denen jemand entsperren will.

Der Dienstbenutzer sagt „gib die Sperre frei", nicht „töte 1234": welcher
Prozess das ist, sucht der Helfer selbst. Dürfte der Anrufer die Nummer
liefern, wäre die Freigabe das Recht, jeden beliebigen Prozess als root
zu beenden.

Beendet werden alle, die die Sperrdatei OFFEN halten — nicht nur der,
der das flock genommen hat. Ein flock hängt an der offenen
Dateibeschreibung, und die wird vererbt: stirbt der Agent, sein
hängendes `docker compose exec` aber nicht, bleibt die Sperre gehalten.
Genau das war der Vorfall. Verschont bleiben PID 1 und der Anrufer samt
Vorfahren — im Betrieb hält der blockierte Agent die Datei selbst offen
und ist zugleich der, der anruft.

Erst SIGTERM, fünf Sekunden, dann SIGKILL. Eine Frist ohne Nachdruck ist
keine Frist.

Bestätigt wird im Modal (R23), das VORHER nennt, was es beendet — PID,
Laufzeit und Kommandozeile aus dem Lebenszeichen. Eigene Berechtigung
`deployment.unblock`, nicht `site.manage`: wer aktualisieren darf, darf
damit nicht automatisch in einen laufenden Vorgang hineingreifen.

Der Helfer-Vertrag steigt auf 3, und update.sh verlangt ihn. Ohne das
wäre der Knopf auf einem Wirt, der install-agent.sh seither nicht mehr
gefahren hat, ein Knopf, der still nichts tut — und die Konsole sagt es
jetzt, statt den Betreiber dorthin zurückzuschicken, wo er ohne sie
schon war.

Geprüft wird ausgeführt, nicht begutachtet: der Helfer läuft im Test
gegen eine echte Sperrdatei mit echten Prozessen daran. Das hat gleich
einen Fehler gefunden — der Helfer erbt den Deskriptor und hielt seine
eigenen Kommandosubstitutionen für Halter, sodass die Runde nie zum Ende
gekommen wäre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:07:24 +02:00
nexxo 6a5609cfc7 Ein einzelner übersprungener Lauf ist kein Ausfall
Die Konsole meldete „kommt seit HH:MM nicht an die Arbeit", sobald der
Agent EINMAL an der Sperre vorbeilief. Gemessen wurde das bei einem
Überholen von neun Sekunden: Zeitgeber und Wächter laufen beide
minütlich, einer nimmt die Sperre, der andere geht weg. Das ist Betrieb,
kein Fehler — und der Besitzer hat daraufhin eine Stunde lang eine
gesunde Anlage auseinandergenommen.

Der Kopfkommentar an der Stelle kannte den Unterschied längst („ließe
sich von einem gesunden Überholen zweier Läufe nicht unterscheiden").
Die Dauer wurde mitgeführt, nur gegen nichts verglichen.

Der Agent zählt die Serie jetzt mit (`skips` im Lebenszeichen), die
Konsole macht ab zwei Läufen eine Meldung daraus. Gezählt wird in
LÄUFEN, nicht in Minuten: wie oft der Zeitgeber wirklich auslöst, steht
in der systemd-Unit auf dem Wirt, die diese Anwendung nicht sehen kann.

Die Schwelle gilt für den ganzen Zustand, nicht nur für den Satz. Nur
die Meldung zu unterdrücken hätte den Fehlalarm gegen einen stilleren
getauscht: `blocked_since` blendet auch „N Aktualisierungen zurück" und
die Zielversion aus, und die wären beim ersten übersprungenen Lauf
verschwunden, ohne dass irgendwo stünde warum.

Geprüft wird der Zähler am echten Skript, nicht an einer Nachbildung
seiner Logik: der Zweig liegt vor allem Teuren, also läuft der Agent im
Test gegen eine gehaltene Sperre und steigt aus, bevor `git fetch` oder
`docker compose` in die Nähe kommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:42:07 +02:00
nexxo 5eef03d267 Fix-Runde: Markierung im selben Versuch wie der Versand, zwei Randpruefungen
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
$instance->update(['export_reminded_at' => now()]) lag ausserhalb des
try/catch, das nur den Mailversand absicherte. Ein Fehlschlag dieses einen
Schreibzugriffs haette die Marke leer gelassen (morgen eine zweite Mail fuer
dieselbe Instanz) UND den ganzen Lauf abgebrochen, statt nur diese eine
Instanz zu ueberspringen. Jetzt beides in derselben Absicherung.

Dazu zwei Pruefungen, die der Rand "Ende schon vorbei" bislang nicht hatte:
keine Erinnerung mehr, wenn die Frist schon abgelaufen ist (kein akademischer
Fall - EndInstanceService laesst einen DNS-Fehler bewusst durch, eine
Instanz kann also real tagelang mit abgelaufener Frist stehenbleiben), und
die obere Einschlussgrenze bei genau sieben Tagen, damit beide Raender der
"hoechstens sieben Tage"-Regel durch je eine eigene Pruefung belegt sind.

Ausserdem: die Selbst-Herunterladen-Adresse selbst wird jetzt im Rendertest
geprueft, nicht nur Datum und Sprachschluessel.
2026-08-04 09:14:33 +02:00
nexxo 6f01097558 Version 1.6.5 — eine Frist ohne Nachdruck ist keine Frist
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Der Update-Agent kam seit 08:02 nicht mehr an die Arbeit. Gemessen auf dem
Live-Server: ein Kind mit "timeout 45" stand nach VIER MINUTEN noch.

timeout schickt nach der Frist ein SIGTERM. `docker compose exec` stirbt daran
nicht, wenn es auf den Docker-Daemon wartet — und danach wartet timeout selbst
unbegrenzt weiter. Der Aufruf haelt dabei die Sperre, also wurde JEDER folgende
Lauf uebersprungen. Stundenlang.

Beide Fristen im Agenten bekommen jetzt `-k 10`: zehn Sekunden nach dem SIGTERM
folgt ein SIGKILL, den niemand ignorieren kann. Aus einem unbegrenzten Haenger
wird eine Verzoegerung von 55 Sekunden.

Der Kommentar an der Stelle kannte die Gefahr bereits ("timeout, weil dieser
Aufruf die Sperre haelt") — die Massnahme reichte nur nicht. Die Begruendung
steht jetzt mit dem Messwert dabei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:07:50 +02:00
nexxo e83b1d886a Eine Warnung, bevor die eigene Cloud den Kunden aussperrt
Ab dem Laufzeitende zieht EndInstanceService die Adresse ein — der Kunde kommt
ab dem Moment nicht mehr an seine Nextcloud. Wer selbst etwas herunterladen
will, muss es VORHER tun, und das wusste bisher niemand.

Die Auswahl ist bewusst "hoechstens sieben Tage" und nicht "genau sieben Tage":
ein Gleichheitsvergleich verfehlt jede Instanz, die zwischen zwei Laeufen
durchrutscht, und der Preis dafuer waere, dass jemand ausgesperrt wird, ohne es
gewusst zu haben.

Zusaetzlich zum Zettel: lang/{de,en}/mail_pace.php bekommen einen Eintrag fuer
ServiceEndingSoonMail, weil MailPacePageTest fuer jede Klasse in MailLane::all()
einen Anzeigenamen verlangt — ohne ihn waere die Suite rot.
2026-08-04 08:59:24 +02:00
nexxo ae8c8680bb Fix-Runde: echte Umlaute, und das Versprechen sagt "auf Wunsch"
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Befund 1: neuer deutscher Text (Kommentare, ein Testname) benutzte ae/oe/ue/ss
statt ä/ö/ü/ß — der Bestand schreibt mit echten Umlauten, das Original wird
hier nachgezogen. Betroffen: die drei Kommentare in ConfirmCancelPackage.php,
der Blade-Kommentar, sowie Kopfkommentare und ein Testname in
CancelAsksAboutExportTest.php. Der Funktionsname kuendbareInstanz() und die
Test-Fixtures (E-Mail/Subdomain "kuendigt") bleiben ASCII — ersterer ist
wörtlich aus dem Zettel übernommen, letztere sind technische Werte wie jede
andere Test-Subdomain im Bestand (acme, berger).

Befund 2 (Entscheidung des Betreibers): cancel_point_export versprach den
Datenexport unbedingt, direkt über einer Frage, die ihn an ein Ja knüpft. "auf
Wunsch" eingefügt, in beiden Sprachdateien — der Satz sagt jetzt, was der
Dialog tatsächlich tut, ohne sonst etwas am Text zu ändern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 08:44:20 +02:00
nexxo 7d041db5e5 Beim Kuendigen wird gefragt, ob der Kunde seine Daten will
Der Angelpunkt des ganzen Vorhabens: wer keinen Export braucht, loest keine
Arbeit aus und wartet auf nichts. Zwei Auswahlfelder und keine Checkbox — eine
Checkbox kennt keinen dritten Zustand, und "nicht angekreuzt" waere von "nein"
nicht zu unterscheiden.

Die Frage ist nicht ueberspringbar (#[Validate('required|boolean')], vor Stripe
geprueft wie jede andere Vorbedingung hier). Das macht drei bestehende
Erfolgspfad-Pruefungen zu ConfirmCancelPackage neu pflichtig in einem Feld, das
sie vorher nicht kannten — PackageCancellationTest, SettingsTest und
EndInstanceServiceTest setzen deshalb jetzt zusaetzlich exportWish, ohne dass
sich an ihren eigentlichen Zusicherungen etwas aendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 08:29:34 +02:00
nexxo 5dd75dac86 Die Instanz merkt sich, was der Kunde zu seinen Daten gesagt hat
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Drei Zustaende, nicht zwei: `null` heisst "noch nicht gefragt". Jede Instanz,
die vor diesem Bau gekuendigt wurde, hat die Frage nie gesehen — ihr ein "nein"
zu unterstellen hiesse, still fuer Menschen zu antworten, die niemand gefragt
hat.
2026-08-04 08:07:56 +02:00
nexxo 31f96bd3c9 Version 1.6.4 — die Einladenzeile im Portal springt nicht mehr
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
abfa489 lag auf main, aber in keinem Release: das Gate ist der v*-Tag, nicht
der Commit. Ohne diese Zeile bleibt die Korrektur auf dem Server unsichtbar.

Enthalten ist die Einladen-Zeile der Benutzerseite. Sie war unten ausgerichtet,
also schoben Fehlermeldung und Rollen-Hinweis ihre Eingabefelder nach oben —
drei Felder auf drei Höhen, und die Zeile sprang, sobald ein Fehler erschien.
Jetzt oben ausgerichtet, und alles was wachsen kann steht unter der Zeile.

Dazu: der Satz zu "Nur Lesen" stand immer da, auch wenn "Mitglied" gewählt
war — eine Erklärung zu etwas, das gar nicht passiert. Er erscheint nur noch
zur wirklich gewählten Rolle.

2791 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 08:02:19 +02:00
nexxo abfa4890ac Die Einladenzeile im Portal springt nicht mehr
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Arbeit der Parallelsitzung an der Mitarbeiterverwaltung, hier nur eingecheckt
— nicht von mir geschrieben.

`sm:items-end` richtete die drei Felder unten aus. Der Fehler unter der
E-Mail und der Hinweis unter der Rolle machten ihre Zelle hoeher und schoben
damit deren Eingabefelder nach oben: drei Felder auf drei Hoehen, und je
nachdem, ob gerade ein Fehler stand, sprang die Zeile.

Jetzt `items-start` mit gleich hohen Beschriftungen; Fehler und Hinweis stehen
unter der Zeile, wo sie niemanden mehr verschieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 07:57:27 +02:00
nexxo 82ace84d01 Plan: ein Streifen im Dashboard zeigt die Restfrist
Vom Betreiber gefordert, und der Ort stimmt: eine Mail sieben Tage vorher kann
im Postfach untergehen, die eigene Uebersicht oeffnet der Kunde ohnehin.

Er nennt, wie lange der ZUGANG noch steht — nicht, wann etwas geloescht wird.
Das ist die Frist, die den Kunden betrifft. Und er ist der natuerliche Platz
fuer die Export-Antwort: wer liest, dass die Zeit laeuft, will es sich im
selben Atemzug anders ueberlegen koennen.
2026-08-04 07:57:21 +02:00
nexxo 34ef35518c Version 1.6.3 — die Seitenleiste fragt zuerst, ob etwas auf dich wartet
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 07:52:48 +02:00
nexxo 0ba3a285c2 Plan: Kuendigung Teil A — die Frage und die Warnung
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Bewusst nur die erste Haelfte des Entwurfs. Teil B (Export, Abbau, Archiv)
haengt an einem Speicherserver, den es noch nicht gibt: keine Adresse, keine
Zugangsdaten, kein Wissen darueber, welches Werkzeug auf den Proxmox-Hosts
wirklich steht. Dafuer jetzt Code zu schreiben hiesse, Pfade und Fehlerfaelle zu
erfinden und sie als Plan auszugeben — an diesem Vorhaben sind heute neun
Befunde aufgelaufen, und jeder stammte aus einem Plan, der an einer Stelle
selbstsicher war, an der niemand nachgesehen hatte.

Teil A traegt fuer sich den Punkt, der heute am meisten weh tut: ab dem
Laufzeitende zieht EndInstanceService die Adresse ein, und der Kunde kommt nicht
mehr an seine Nextcloud. Wer selbst herunterladen will, muss es vorher tun — und
das sagt ihm bisher niemand.

Drei Voraussetzungen fuer Teil B stehen am Ende des Plans. Die dritte ist die
wichtigste: dass eine Momentaufnahme einmal von Hand gefahren und das Ergebnis
aufgeschrieben wurde. Ob Proxmox eine laufende Maschine so einfriert, dass das
Dateisystem darin brauchbar ist, gehoert gemessen und nicht angenommen.
2026-08-04 02:53:17 +02:00
nexxo cfbe339df0 Die Seitenleiste fragt zuerst, ob etwas auf dich wartet
Achtundzwanzig Eintraege in sieben Gruppen, und „System" war der Platz fuer
alles, was sonst nirgends hinpasste: Mail-Einrichtung neben einem
Rechtsdokument, neben den persoenlichen Kontoeinstellungen, neben der
Mitarbeiterverwaltung — und ganz unten die Seite, die sagt, was liegt. Der
Betreiber hat es so beschrieben: „offene Punkte ist der letzte Punkt, dann
Rolle drueber und Einstellungen wieder drueber".

Zwei Regeln ordnen es jetzt.

Was man einmal einrichtet, verlaesst die Leiste: neun Seiten liegen als
Kacheln hinter EINEM Eintrag (admin.setup), gruppiert nach dem, was sie
einrichten. Keine dieser Seiten wurde angefasst — sie behalten Route,
Berechtigung und Inhalt, es aendert sich nur der Weg dorthin. Damit ist der
Umbau rueckholbar.

Und die drei Seiten, auf denen etwas WARTET, stehen ganz oben, mit einer Zahl
daneben: das ist die Frage, mit der man eine Konsole oeffnet. Stoerungen sind
aus „Betrieb" nach oben gezogen und Zahlungsprobleme aus „Geld" — umgezogen,
nicht verdoppelt. Bei null faellt die Plakette weg, der Eintrag bleibt: eine
Seite, die verschwindet, sobald nichts offen ist, ist genau dann nicht
erreichbar, wenn man nachsehen will, ob wirklich nichts offen ist.

Zwanzig Eintraege statt achtundzwanzig, jede Seite genau einmal.

Zwei Dinge, die beim Verschieben kaputtgegangen waeren:

  * Die neun verschobenen Seiten standen nicht mehr in console(). Damit war auf
    ihnen KEIN Eintrag markiert (Codex R15, P2) und currentLabel() lieferte
    null — die Brotkrume haette dort nur noch „Konsole" gesagt. Zwei Stellen,
    eine Wurzel: die Kachelliste ist nach Navigation::setup() gewandert, wo
    beide sie lesen, und isCurrent() haelt die Tuer markiert, solange man
    dahinter steht.
  * Die Versionszeile im Fuss stand als toter Text da, waehrend die Seite mit
    dem Aktualisierungsknopf in die Einrichtung gezogen war. Gemeldet vom
    Betreiber: „man sieht es nicht, ohne genau hinzuklicken." Sie ist jetzt der
    Weg dorthin — und sagt in der Akzentfarbe, wenn etwas wartet. Wartet
    nichts, bleibt sie grau: eine Zeile, die immer ruft, ruft nie.

Die drei Zahlen liegen fuer eine Minute im Zwischenspeicher. Diese Leiste
rendert auf JEDER Konsolenseite; ohne das waeren es vier Abfragen je
Seitenaufruf — eine Abgabe, die man erst sucht, wenn die Konsole zaeh ist.

Achtzehn Zusicherungen, darunter die, auf die es ankommt: keine der
achtundzwanzig Seiten von vorher ist verlorengegangen. Die Liste steht im Test
ausgeschrieben und nicht aus der Repository-Geschichte gelesen — ein Test, der
sich seine Erwartung aus demselben Repository holt, das er prueft, prueft
nichts.

Entwurf: docs/superpowers/specs/2026-08-04-konsolen-seitenleiste-design.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 02:51:35 +02:00
nexxo d04a76b6ee Ein Rückweg aus der abgelaufenen Betreiber-Einladung
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Läuft der 72-Stunden-Link ab, gab es keinen Weg zurück ausser der Shell —
und die setzt dabei ungefragt auf Owner. Settings::resendInvitation() zieht
für ein noch nie benutztes Konto (last_login_at ist null) ein neues Token
und verschickt dieselbe Einladungsmail; der Broker macht das alte Token
dabei von selbst ungültig. inviteStaff() läuft jetzt in einer Transaktion,
damit eine Störung zwischen Kontoanlage und Mailversand keine für immer
blockierte Adresse mehr hinterlässt.

Dazu der Nachtrag am Testnachweis: die reflektierende Prüfung auf ein
verstecktes Passwort steigt jetzt in Arrays ab statt sie zu überspringen,
und prüft zusätzlich das gerenderte HTML statt nur die Komponenteneigen-
schaften. Drei Kleinigkeiten: ein falscher Kommentarhinweis auf eine
angeblich fehlende Übersetzungsdatei korrigiert, eine fehlende Zusicherung
gegen einen rohen Statusschlüssel ergänzt, und ein toter throttle-Wert aus
config/auth.php entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 02:24:13 +02:00
nexxo 7edd88b71c Ein Zugang zur Konsole, dessen Passwort niemand kennt
Bisher erzeugte Settings::inviteStaff() beim Einladen eines
Betreiber-Mitarbeiters ein Passwort und zeigte es dem Inhaber einmal auf dem
Bildschirm, damit er es "sicher weitergibt" -- fuer einen Zugang zur Konsole,
die die ganze Flotte verwaltet. Der Kommentar dort nannte sich selbst eine
Attrappe.

Jetzt geht eine Einladung ohne Passwort hinaus: der Eingeladene bekommt eine
Mail mit einem Link und vergibt sein Passwort selbst, ueber
App\Livewire\Auth\OperatorSetPassword. Niemand -- auch der Inhaber nicht --
kennt je ein fremdes Passwort.

Dafuer brauchte es einen eigenen Weg, den es fuer Betreiber noch nicht gab
(R21: Konsole und Portal teilen keine Identitaet). Ein eigener Broker mit
eigener Tabelle (operator_password_reset_tokens, config/auth.php), eine
eigene Route in der Gast-Gruppe der Konsole (admin.invitation), und
Operator::sendPasswordResetNotification() wirft jetzt, statt Laravels
Vorgabe zu nutzen, die auf die Portalseite verlinkt haette.

Der Einladungslink gilt 72 Stunden -- lang genug fuer ein Wochenende, kurz
genug, dass ein altes Postfach nicht auf Dauer einen Schluessel zur Konsole
haelt. Zwei-Faktor bleibt unberuehrt: die neue Seite meldet niemanden
automatisch an, der Eingeladene durchlaeuft danach die normale Anmeldung
samt ihrer bestehenden Zwei-Faktor-Pruefung. Die Route liegt hinter denselben
Netz- und Hostwaechtern wie der Rest der Konsole (RestrictAdminHost,
RestrictConsoleNetwork), ohne Sonderfall.

OperatorInvitationMail reiht sich in den Versandtakt ein (MailLane::LOCKED,
wie ResetPasswordMail und VerifyEmailMail -- ein Mensch wartet gerade) und
ist damit in der Vorschau- und Versandtakt-Uebersicht der Konsole sichtbar,
statt lautlos in die gedrosselte Spur zu fallen.

Zwei Mutationsproben gegen die tragende Zusicherung durchgefuehrt (Bericht:
.superpowers/sdd/betreiber-einladung-report.md): die erste (durch die
Vorgaengersitzung) hielt fest, dass drei Zusicherungen fallen wuerden, waere
das Konto sofort anmeldbar; die zweite -- das Passwort testweise wieder auf
die Seite gebracht -- faellt exakt an der dafuer gebauten reflektierenden
Pruefung ueber alle oeffentlichen Eigenschaften der Seite.

Suite: 2771 passed (9683 assertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:50:42 +02:00
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