575 Commits (d3ac839b1eeaf5b04a20622dc75ff3308eac6967)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
d3ac839b1e |
Office (collabora_pro) auch über die Abrechnungsseite sperren
tests / pest (push) Waiting to run
Details
tests / assets (push) Waiting to run
Details
tests / release (push) Blocked by required conditions
Details
Der Vortag nahm Office nur von der Preistafel — ein eingeloggter Team- oder Business-Kunde hätte collabora_pro technisch weiterhin über Billing::purchase() buchen können, weil AddonCatalogue den Preis unverändert kannte und die Abrechnungsseite nie danach fragte. Der Schalter (OFFICE_ON_SALE) wandert deshalb vom LandingController in AddonCatalogue, der einzigen Stelle, die Preistafel UND Abrechnungsseite kennen; eine neue Methode saleRefusal() weist die Buchung an beiden Enden ab (Kauf-Aufruf und Karten-Anzeige), ohne BookAddon/GrantAddon anzufassen — Bestandsfälle und Geschenke des Betreibers bleiben möglich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
42fd059fdd |
Office aus dem Verkauf nehmen, abschaltbar statt gelöscht
tests / pest (push) Waiting to run
Details
tests / assets (push) Waiting to run
Details
tests / release (push) Blocked by required conditions
Details
Office (ONLYOFFICE über einen künftigen gemeinsamen Dokumentenserver) wurde an drei Stellen beworben, ohne dass irgendwo im Repository ein Dokumentenserver existiert: der Kachel "Office im Browser" auf der Preistafel, dem Paketmerkmal `office` in Team und Business, und dem Zusatzmodul `collabora_pro` für 22,80 €/Monat. Alle drei sind jetzt stillgelegt, aber nicht gelöscht — ein einziger dokumentierter Schalter (LandingController::OFFICE_ON_SALE) plus eine neu veröffentlichte Planversion holen das Versprechen zurück, sobald der Dokumentenserver steht. Das Paketmerkmal wird über das im Katalog bereits etablierte Handover-Muster entfernt (neue Migration, analog zu switch_to_new_plan_ladder): die laufende Version von Team/Business wird geschlossen und durch eine identische ohne `office` ersetzt. Bestehende Verträge bleiben auf ihrer alten, eingefrorenen Version stehen und behalten das Merkmal unverändert. Die dritte Planversion für Team/Business hat 24 Bestandstests berührt, die eine feste Versionsnummer oder eine feste Preis-/Versionszahl annahmen — repariert, überwiegend durch dynamisches Lesen der aktuellen Version statt eines eingetippten Werts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
033578d75d |
Release-Decke: einen Server aus der Konsole auf eine Version festnageln
Bei zehn Servern liess sich eine Auslieferung nicht staffeln: entweder alle nehmen die neueste Version oder niemand. Der einzige Griff, der eine bestimmte setzt, war RELEASE=vX.Y.Z bash deploy/update.sh auf der Kommandozeile. Der Kern ist eine geklemmte Variable. Der Agent uebergab in Zeile 669 ohnehin schon RELEASE="$TARGET_RELEASE"; wird die an der Decke geklemmt, faellt `behind` aus derselben Rechnung, und Knopf wie Wartungsfenster folgen von selbst. Task 4 belegt genau das mit einem Test, der KEINEN Produktivcode braucht: es gibt keinen zweiten Weg in eine Auslieferung. Die Decke faellt zu, nicht auf. Unlesbar, formwidrig oder ins Leere zeigend heisst: nichts wird angeboten. Ein Rueckfall auf "neueste Version" installierte genau das, wovon weggenagelt wurde. Nicht enthalten: Zurueckrollen. Das ist verboten (update.sh:222), und der Datenbank-Schnappschuss, auf den die Fehlermeldung dort verweist, wird nirgends genommen. Eigene Baustelle, ihr fehlendes Stueck ist der Schnappschuss, nicht der Knopf. Unterwegs gefunden und mitbehoben: zwei Stellen, an denen eine Zuweisung aus einer Kommandoersetzung unter set -e + pipefail den Agenten toetete, BEVOR er eine Statusdatei schreiben konnte (sync_vpn_certificate, release_manifest_ version) — dieselbe Ausfallart, die die Konsole eine nie endende Pruefung zeigen laesst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
24eb7b3d80 |
Fix-Welle: Schlussreview-Befunde 1-6 zur Release-Decke
Sechs Befunde aus dem Schlussreview, in einer Welle behoben: - BEFUND 1 (Important): eine leere Auswahl im Festnageln-Feld liess pinRelease() ueber `$this->ceilingChoice ?: null` in setCeiling(null) laufen — die Gegenhandlung (Decke abnehmen) — und meldete dabei die Erfolgsmeldung des Festnagelns. ConfirmPinRelease::confirm() schickt die Version jetzt als Event-Nutzlast (wie ConfirmSaveSecret den Schluessel), und pinRelease(string $version) weist eine leere Version ausdruecklich ab, mit einer eigenen Meldung (release_pin_empty). - BEFUND 2 (Minor, durch 1 miterledigt): Modal und Seite lasen bisher zwei getrennte Eigenschaften ($version vs. $ceilingChoice). Der Fix oben beseitigt die Trennung. - BEFUND 3 (Important, Text only): der Kommentar bei release_tag_exists() in deploy/lib/release.sh und der Fehlerbehandlungs-Abschnitt der Spec behaupteten, ceiling_missing schuetze gegen einen vom Release-Prozess geloeschten Tag. Tut es nicht: `git fetch --tags --force` (ohne --prune-tags, bewusst) entfernt keine lokal bereits geholten Tags, die drueben verschwunden sind. Beide Stellen beschreiben jetzt, wogegen die Pruefung tatsaechlich schuetzt (ein nie geholter oder nie existierender Tag) und wogegen nicht. Kein --prune-tags hinzugefuegt. - BEFUND 4 (Minor): ConfirmPinRelease hatte keinen Test. Zwei neue Tests nach dem Vorbild von ConfirmSaveSecret in IntegrationsPageTest. - BEFUND 5 (Minor): ceilingChoice wurde nie aus dem gesetzten Zustand vorbelegt. UpdateChannel::ceiling() ist jetzt public, Settings::mount() belegt das Feld damit vor. - BEFUND 6 (Minor): eine von Hand geleerte Deckendatei liest die Konsole als "keine Decke" (ceiling() -> null), der Agent meldet dafuer aber ceiling_error. Der "Decke abnehmen"-Knopf stand nur hinter @if($update['ceiling']) und verschwand damit genau in dem Zustand, aus dem er zurueckfuehren muesste. Bedingung erweitert auf ($update['ceiling'] || $update['ceiling_error']). Jeder Befund traegt einen eigenen Test in ReleaseCeilingConsoleTest.php. Volle Suite: 2973 passed (10389 assertions). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8b631acb72 |
Fix-Welle Ganz-Branch-Review: weltlesbare .env-Sicherung, blinder Test, drei Installer-Texte
Fuenf Befunde aus der abschliessenden Review, in einer Runde behoben: - EnvFileEditor::backup() liess PHPs copy() die Umask entscheiden statt den Modus der Quelle zu uebernehmen — .env stand mit deploy/install.sh auf 0600, jede Sicherung landete trotzdem weltlesbar bei 0644, mit APP_KEY, DB_PASSWORD, VPN_CONFIG_KEY und STRIPE_SECRET darin. Reproduziert (per kurzzeitigem git stash des Fixes: 420 statt 384) und jetzt durch einen expliziten chmod nach dem Kopieren sowie einen neuen Test verhindert. - HostSeparationTest pruefte "jede Route hat einen Hostnamen" nur scheinbar allgemein — deploy/install.sh schreibt ADMIN_HOST_EXCLUSIVE=false als Vorgabe, und im nicht-exklusiven Fallback registriert routes/web.php jede /admin/*-Route absichtlich ohne Domain. Der Test setzte zwar ADMIN_HOST_EXCLUSIVE=true, sagte aber nirgends, dass genau das die Voraussetzung der Pruefung ist. hostSeparationTable() gibt AdminArea:: isExclusive() jetzt als Out-Parameter zurueck, gelesen waehrend die zweite Anwendung noch gebootet ist, und der Test besteht darauf. - clupilot:bind-hosts existierte, aber nichts sagte einem Operator, dass es ihn braucht. deploy/update.sh druckt jetzt einen eigenen Hinweis, wenn APP_HOST in .env leer ist — mit der vollen docker-compose-Zeile statt der internen in_app-Abkuerzung, weil der Operator sie in seiner eigenen Shell eintippt. - ask STATUS_DOMAIN und ask FILES_DOMAIN versprachen "blank to keep it auf jedem Host", fuellten Enter aber ueber den dritten ask()-Parameter mit dem Default. Fuer FILES_DOMAIN war das kein Schoenheitsfehler: der Default verschiebt /bootstrap.tar.gz vom Portal weg, bevor DNS fuer den neuen Namen existiert. Beide Defaults entfernt. - ask WWW_DOMAIN erklaerte nicht, dass SITE_HOST mehrere kommagetrennte Namen traegt (der erste kanonisch, der Rest leitet dauerhaft um) — ein Operator, der die Apex-Domain zusaetzlich zu www. binden wollte, hatte keinen Weg, das aus dem Prompt zu erfahren. Nur der Prompt-Text geaendert, kein neuer Prompt, Default unveraendert. Voller Testlauf: 2953 passed (Baseline 2952 + der neue Backup-Berechtigungs- Test), 0 failed. routes/web.php, RestrictAdminHost, config/fortify.php und PublicSiteGate unangetastet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6bb705e566 |
Fix-Runde 1: bind-hosts schreibt keine Rueckverweise mehr in die .env
apply() ersetzte eine vorhandene Zeile per preg_replace($pattern, $line, ...) - $line kommt von der Befehlszeile, und preg_replace deutet $1/\1/\\ im ERSATZ als Rueckverweis, auch ohne Klammern im Muster. Ein Hostname mit solchen Zeichen wuerde still verstuemmelt in die Datei geschrieben, die jedes Geheimnis der Installation haelt - und EnvFileEditor::isValidLine() kann das nicht fangen, das Ergebnis ist syntaktisch weiter KEY=value. apply() arbeitet jetzt zeilenweise ohne jede Regex im Ersatzpfad. Zusaetzlich: EnvFileEditor::write() wirft InvalidEnvContentException, wenn der neue Inhalt nicht parst - das war ungefangen und zeigte dem Betreiber einen Stapelabzug auf der Zugangsdatendatei statt eines Satzes wie jeder andere Fehlerpfad in diesem Befehl. Neuer Test deckt den Rueckverweis-Fall ab, ueber die bereits vorhandene (leere) SITE_HOST-Zeile - der Pfad, den preg_replace tatsaechlich traf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d90676632e | clupilot:bind-hosts traegt die Hostnamen in eine bestehende .env nach | |
|
|
073e147ff8 |
Festnageln aus der Konsole
Task 5: Bedienoberfläche zum Festnageln einer Release-Version, in der bestehenden Update-Handlungsleiste statt einer zweiten daneben. Bestätigt im Modal (R23), Auswahlfeld selbst ohne Modal (R20). |
|
|
|
5fe33553ad |
Zertifikats-Spec auf Weg B umgeschrieben, Registereintrag gestrichen
tests / pest (push) Waiting to run
Details
tests / assets (push) Waiting to run
Details
tests / release (push) Blocked by required conditions
Details
Die Spec fuer die Host-Konsole (docs/superpowers/specs/2026-08-01-host- konsole-zertifikat-design.md) war auf einer eigenen DNS-Zone und Proxmox' Alias-Modus fuer DNS-01 gebaut. Hetzner kennt keine Subzonen (docs.hetzner. com/networking/dns/faq/zones/, Artikel NE-7597D: "Subzones are not supported") -- damit entfallen eigene Zone, CNAME je Host, Alias-Modus und der pro Host verteilte DNS-Token ersatzlos. Der Grund bleibt in der Spec stehen, statt geloescht zu werden, damit niemand denselben Weg in einem halben Jahr erneut vorschlaegt. Weg B, wie im Register vorgegeben: CluPilot stellt zentral aus, DNS-01 ueber den vorhandenen kontoweiten Hetzner-Token, Zertifikat per SSH (RemoteShell::putFile + `pvenode cert set --force --restart`, geprueft gegen die Proxmox-Dokumentation) auf den Host, Erneuerung als geplanter Job auf der Bereitstellungs-Warteschlange (dieselbe Grenze wie SyncVpnPeers -- nur queue-provisioning erreicht einen Host ueber den Tunnel). Dazu ein Vergleich mit der Kundeninstanz (ConfigureDnsAndTls, HTTP-01) und eine genaue Bestandsaufnahme der Bereitschaftsseite: sie kennt heute kein Zertifikat, weder fuer Hosts noch, trotz ersten Anscheins, uebertragbar fuer die Plattform -- CertificateSweep/ProxyHost misst nur oeffentlich erreichbare Namen und laeuft im falschen Container fuer einen Host-FQDN. Im Code bestaetigt und in der Spec vermerkt: RrsetId::zone() ist heute fest auf die Kundenzone verdrahtet, ein Host-FQDN liegt aber in der Plattformzone -- das ist Bauarbeit, keine offene Entscheidung. Offen bleibt nur, welches Werkzeug das ACME-Protokoll auf CluPilot-Seite spricht (keine Bibliothek/kein Tool dafuer im Repo) und ob Plattform- und Kundenzone im selben Hetzner-Projekt liegen -- beides als offene Fragen benannt, keine davon blockiert den Rest des Ablaufs. Registereintrag in OpenWork.php gestrichen: die Spec beschreibt keinen toten Weg mehr, und genau das war der einzige Punkt, den der Eintrag festhielt. Getestet: php artisan test --filter=OpenWork, 8 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c93510ffe4 |
Fix-Runde 1: writeAtomic prueft Rueckgabewerte, ceiling() faengt Throwable
Zwei Important-Befunde aus dem Code-Review zu Task 3: - writeAtomic() ignorierte den Rueckgabewert von File::put()/File::move() und meldete setCeiling() als "true", selbst wenn ein I/O-Fehler (volle Platte, Rechteproblem) nichts geschrieben oder eine .tmp liegen gelassen hatte. Beide Rueckgabewerte werden jetzt geprueft, eine liegen gebliebene .tmp wird im Fehlerfall aufgeraeumt, und der Fehlschlag wird bis zu setCeiling() durchgereicht (Rueckgabe false). - ceiling() konnte state() doch werfen lassen: zwischen File::exists() und File::get() liegt ein Zeitfenster, und File::get() wirft eine FileNotFoundException, wenn die Datei dazwischen verschwindet. readJson() und lastLog() kapseln genau dieses Muster schon in try/catch(Throwable); ceiling() zieht jetzt nach. Beide Befunde tragen einen eigenen Test: ein Verzeichnis an der Ceiling- Datei-Stelle (exists() wahr, get() wirft) fuer den zweiten, eine echte Rechteverweigerung (chmod 0500 als nicht-root Testbenutzer) fuer den ersten -- kein Facade-Mock noetig. |
|
|
|
367198459c | Der Kanal schreibt die Decke atomar und reicht sie durch | |
|
|
fd216be623 |
Der Hostnamen-Zähler übersteht jetzt das Löschen eines Rechenzentrums
Der Zähler lag auf der Rechenzentrums-ZEILE (next_host_number). Ein leeres Rechenzentrum liess sich löschen - bewusst so entschieden, was nichts mehr hält, soll entfernbar bleiben -, aber die Zeile nahm den Zähler mit. Wer denselben Code neu anlegte, bekam eine frische Zeile mit dem Schema-Default 1, und der nächste Host hiess wieder <code>-01, obwohl dieser Name schon in alten Protokollen, Sicherungen und DNS-Zwischenspeichern auf eine ANDERE Maschine zeigt. Der Zähler zieht deshalb in eine eigene Tabelle host_name_sequences um, geführt über den rohen Code statt über die id der Rechenzentrums-Zeile. ConfirmDeleteDatacenter bleibt unangetastet: das Löschen war nie das Problem, nur was es mitriss. Die Migration überträgt den Bestand (fsn/hel) vor dem Löschen der alten Spalte und ist gegen echtes MariaDB in beide Richtungen geprüft (hoch, Werte kontrolliert, zurück, wieder hoch). Neuer Test in HostNamingTest stellt den ganzen Bruch nach: Rechenzentrum anlegen, Host vergeben, Host entfernen, über den echten Bestätigungsdialog löschen, mit demselben Code neu anlegen - der nächste Name bleibt fortlaufend statt wieder bei 01 zu beginnen. Gegen den unveränderten Code lief er rot (HostName::preview lieferte nbg-01 statt nbg-02). Registereintrag "Ein Zähler kann durch Löschen eines Rechenzentrums zurückfallen" gestrichen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
507636f38f |
Die abgebuchte Domain wird jetzt wirklich von der Maschine genommen
tests / pest (push) Waiting to run
Details
tests / assets (push) Waiting to run
Details
tests / release (push) Blocked by required conditions
Details
Der Registereintrag nannte den falschen Grund: das Deaktivieren startet sehr wohl eine Provisionierung. CustomDomainAccess::deactivate() ruft seit Langem ReapplyInstanceAddress, das legt einen Lauf der `address`-Pipeline an und schickt AdvanceRunJob auf die provisioning-Warteschlange; ConfigureNextcloud loescht dort trusted_domains 2 und ConfigureDnsAndTls schreibt den Router ohne den Namen neu. Das ist gebaut und geprueft. Der Schaden war trotzdem echt, nur eine Tuer weiter. Erreicht wurde deactivate() allein ueber PlanChange::settleCustomDomain, also ueber den Paketwechsel. Der zweite und haeufigere Weg, auf dem das Recht endet — der Kunde bucht das Modul in der Abrechnung ab, clupilot:end-cancelled-addons haelt den Termin am Ende des bezahlten Zeitraums — ging an dieser Stelle vorbei: BookAddon::cancel() lieferte Speicher nach und sprach mit Stripe, fragte aber niemanden nach der Adresse. Die Domain verschwand aus jeder Ansicht und blieb auf der Maschine stehen. BookAddon::cancel() fragt jetzt CustomDomainAccess::enforce() — die ganze Regel, nicht den Modulschluessel: wer von Team auf Business aufgestuft hat und sein altes Modul loswird, behaelt die Domain, weil das Paket sie selbst traegt. Und der Anstoss darf die Entscheidung nicht kippen. deactivate() faengt jetzt einen Fehlschlag der Nachfuehrung ab und schreibt ihn als Fehler ins Log: die Wahrheit steht in der Datenbank, die Maschine zieht nach, und eine Kuendigung haengt nicht daran, ob ein fremder Host gerade antwortet. Die Gegenrichtung brauchte nichts: der Entzug loescht die Domain-Spalte, also traegt der Kunde sie nach der Neubuchung neu ein und weist sie neu nach — und genau dort haengt seit jeher der Lauf, der sie wieder ausliefert. Ein Test haelt das fest, damit es keine Einbahnstrasse wird. Registereintrag gestrichen. Rot gesehen: ohne den settleCustomDomain-Aufruf fallen drei der vier neuen Tests; ohne das try/catch faellt der vierte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
29f13275f8 |
Das Register behauptete etwas Falsches: der Plan-Wechsel IST gebaut
"Ein Plan-Wechsel wird nirgends angewendet" stimmt seit laengerem nicht mehr. ApplyPlanChange faehrt die plan-change-Pipeline, aufgerufen vom OrderObserver bei einer Aufstufung und von clupilot:apply-due-plan-changes bei einer Abstufung zum Laufzeitende. settleCustomDomain() hat sehr wohl einen Aufrufer (ApplyPlanChange:256), und sieben Testdateien mit 43 Pruefungen decken den Weg. Aufgeschrieben, weil der Kopfkommentar dieser Datei genau das ausschliesst: "Ein Punkt verschwindet, wenn die Arbeit im selben Commit fertig wird, der ihn streicht — und damit kann die Liste nicht behaupten, etwas sei offen, das es laengst nicht mehr ist." Genau das ist passiert. Wer eine Liste fuehrt, deren einziger Zweck Ehrlichkeit ist, muss sie mit der Arbeit streichen, nicht danach. Gepruefte Restliste: neun Punkte. Zwei davon (zweiter Sicherungsort, Office Pro) haengen an Infrastruktur, die es noch nicht gibt; einer (Hostnamen- Abnahme) an der echten Anlage; einer (Support-Mail) an einem SMTP-Konto, das der Betreiber anlegen muss. |
|
|
|
19a770e1eb |
Kuendigung B1, Fix-Welle: der Platz wird frei, und das Loeschen ist vollzogen
K1 — abgebaute Instanzen zaehlten weiter als belegend. `scopeOccupyingHost` filtert jetzt `torn_down_at`: eine abgebaute Instanz behaelt `ended`, `vmid` und `disk_gb` als Nachweis, `status != 'failed'` war fuer sie also wahr. Der Knoten wurde physisch leer und die Buchhaltung blieb voll — die naechste bezahlte Bestellung derselben Groesse waere geparkt und verworfen worden. K2 — `deleteVm()` gibt die UPID zurueck, der Abbau wartet den qmdestroy-Auftrag ab und schreibt `torn_down_at` erst danach. Ein 200 heisst nur, dass Proxmox den Auftrag angenommen hat; scheitert die Zerstoerung danach, stand bisher eine laufende Maschine mit einem Datensatz da, der "abgebaut" sagte — und niemand sah sie je wieder an. Die Attrappe bildet die Asynchronitaet ab (`destroyedVmids`, `destroyExitStatus`, `destroyHangs`). Die Fristen des Auftrags wandern mit: Sichern 900 s, Zerstoeren 300 s, Summe unveraendert 1800 s unter der Uhr des Arbeiters. K3 — vor `shutdownVm()` steht derselbe `vmStatus()`-Riegel wie im Nachbarschritt. Eine wegen offener Zahlung gesperrte Cloud und jeder Wiederholungslauf nach einem Teilfehlschlag treffen einen gestoppten Gast; der Wurf haette die Instanz unheilbar gemacht und den echten Grund am Datensatz ueberschrieben. Die Attrappe weist eine Bitte gegen eine stehende Maschine jetzt ab, und ein Test faehrt erstmals einen zweiten Lauf nach einem Fehlschlag durch. W2 — der rote Kasten "Abbau haengt" filtert `status = 'ended'`. Eine wiederbelebte Instanz waere sonst fuer immer darin stehengeblieben. Neun Pruefungen im Mahnwesen lassen ihre Cloud jetzt laufen, bevor sie gesperrt wird — die geschaerfte Attrappe legt offen, dass `SuspendInstance` denselben fehlenden Riegel hat (Folgepunkt im Bericht). Zu jedem der vier Punkte eine Zusicherung, die ohne den Fix rot ist; die Rotproben stehen im Bericht. |
|
|
|
df73e558e9 |
Kuendigung B1, Tasks 4+5: der Zeitplan-Griff und die Sichtbarkeit
Gekuendigte Kundenmaschinen liefen bisher fuer immer weiter. Task 3 hat den Abbau gebaut; hier kommen der Griff, der ihn faehrt, und der Ort, an dem man sieht, was passiert ist. Die Wartezeit-Frage, entschieden: ein Auftrag je Instanz auf der provisioning-Warteschlange. Das ist keine Abwaegung — nur queue-provisioning steht im Netz-Namensraum des vpn-hub, der scheduler-Container nicht. Ein Befehl, der selbst mit Proxmox spraeche, haette gar keine Route zu einem Host. Die Fristen stehen ausdruecklich ineinander: die Aktion bekommt 600+1200 = 1800 s, der Auftrag hat $timeout 2100 s, retry_after der Verbindung ist 2400 s. Nur die unterste Uhr hinterlaesst einen lesbaren Grund am Datensatz; die mittlere toetet den Arbeiterprozess stumm, die oberste startet einen ZWEITEN Abbau gegen eine Maschine mitten im vzdump. $tries=1, weil ein sofortiger zweiter Versuch am Herunterfahren einer gesperrten VM scheitern und den richtigen Grund ueberschreiben wuerde. Die Staffelung ist als Pruefung festgenagelt. Der Preis — zwanzig statt sechzig Minuten fuers Sichern — steht im Kopfkommentar ausgeschrieben. Dazu zwei Entscheidungen, nach denen niemand gefragt hat: eine Obergrenze je Lauf, weil ueber dieselbe serielle Warteschlange bezahlte Bestellungen laufen; und eine Reihenfolge, die einen Dauerfall die uebrigen nicht aushungern laesst. Zeitplan taeglich um 05:30 — der Abbau hat keinen Moment, auf den es ankommt, aber er darf nicht ins naechtliche vzdump-Fenster um 02:00 fallen. Sichtbarkeit: zwei Kaesten in der Konsole. „Abbau haengt" (rot, ganz oben) — eine Instanz mit gefuelltem teardown_error steht unbegrenzt und belegt weiter einen Platz. Und „Archiviert und abgebaut" (unter der Liste) mit archive_volid im Klartext. Beide sortieren absteigend und beide haben ein Ende: der Fehler raeumt sich beim naechsten erfolgreichen Lauf selbst ab, das Archiv faellt nach zwoelf Monaten heraus. Der Folgepunkt vom Export-Kasten also nicht noch einmal. Suite 2929 gruen. 25 neue Pruefungen, vier Mutationsproben rot gesehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
51ec6ebed2 |
Kuendigung B1, Task 3 — Fix-Runde: der Nachtrag braucht ein Lebenszeichen
Vier Befunde aus der Pruefung. Der schwerste: der Nachtrags-Zweig konnte eine LAUFENDE Kundenmaschine als abgebaut verbuchen. vmExists() ist ->get(...)->successful() ohne ->throw(), und Proxmox antwortet auf die Konfiguration einer nicht vorhandenen VM mit 500 — demselben Code wie ein hakender Knoten. "Gibt es nicht" und "ich konnte nicht fragen" sahen damit gleich aus, und der Zustand entsteht im Regelbetrieb: ein Lauf sichert, scheitert am Loeschen, und der naechste findet archive_volid gesetzt und einen Knoten, der nichts beantwortet. Die Ablagenpruefung steht deshalb jetzt VOR dem Zweig: nodeStorage() ruft ->throw(), laeuft sie durch, hat der Knoten geantwortet, und erst dann ist ein "nein" aus vmExists() ein Befund statt einer Vermutung. Drei Ausnahmen lagen ausserhalb des try und haetten im Zeitplan die uebrigen Instanzen mitgerissen: das Anlegen der Sperre (im Betrieb Redis), das Vermerken des Grundes im catch, und die Freigabe im finally — die sogar am Erfolgsfall vorbei. Alle drei abgesichert. Die Sperrfrist war mit backupWaitSeconds + 600 knapper als der laengste Lauf (Herunterfahren UND Sichern) und konnte kurz vor dem Loeschen auslaufen. Jetzt shutdownWaitSeconds + backupWaitSeconds + Puffer — und sie hat endlich eine eigene Pruefung. Ein einzelnes Archiv ohne Zeitpunkt bleibt gueltig, mehrere nicht: sie lassen sich nicht ordnen, und vermerkt wuerde womoeglich die Sicherung von vorgestern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
104ac46d45 |
Version 1.7.3 — ein Tor, eine Liste
tests / pest (push) Has been cancelled
Details
tests / assets (push) Has been cancelled
Details
tests / release (push) Has been cancelled
Details
Bei verborgener Website kam der Betreiber auf admin. und stand auf www., app. und status. vor der Baustellenseite seines eigenen Portals — obwohl er im VPN war und seine Adresse in der Konsole freigegeben hatte. Zwei Tore hatten zwei Vorstellungen davon, wer "wir" sind: RestrictConsoleNetwork VPN-Subnetz + die Freigabeliste aus der Konsole PublicSiteGate nur admin_access.trusted_ranges Wer seine Bueroadresse eintrug, oeffnete damit nur das eine Tor. Das andere kannte die Liste nicht und liess ihn stehen — was wie ein kaputtes VPN aussah und keines war. Jetzt fragt das Seiten-Tor dieselbe Stelle wie das Konsolentor. Eine Liste, ein Begriff davon, wer hereindarf: wer die Konsole sehen darf, darf die versteckte Seite auch sehen. Das ist dieselbe Person. 2901 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
24a0f64128 |
Kuendigung B1, Task 3: archivieren, nachsehen, und erst dann abbauen
Gekuendigte Kundenmaschinen wurden nie abgebaut. EndInstanceService nimmt der Instanz am Laufzeitende die Adresse weg und laesst die Maschine ausdruecklich stehen; danach kam nichts mehr, und sie belegte fuer immer einen Platz auf dem Host. ArchiveAndTearDown ist der Schritt danach: vierzehn Tage nach dem Ende wird die Maschine archiviert und geloescht. Die eine Regel: niemals loeschen, bevor das Archiv nachweislich existiert. Ein vzdump kann mit einer Auftragskennung enden und trotzdem nichts hinterlassen — volle Ablage, abgebrochener Lauf, ein Fehler im Gast. Deshalb steht zwischen Sichern und Loeschen eine echte Nachschau auf der Ablage (backupsFor), und der juengste Eintrag muss nach dem Beginn dieses Laufes entstanden sein: eine naechtliche Sicherung von gestern ist kein Archiv, dem der letzte Tag fehlen darf. Beide Proxmox-Aufrufe liefern nur eine Kennung, keine Zusage, also wird auf den Auftrag gewartet und danach nachgesehen — beim Herunterfahren, ob der Gast wirklich steht, beim Sichern, ob die Datei liegt. Unmittelbar vor dem Loeschen wird der Datensatz neu gelesen: eine Instanz, die in der Zwischenzeit wiederbelebt wurde, wird nicht geloescht. Keine Ausnahme entkommt — ein Fehlschlag ist false und ein Grund in teardown_error, weil dieser Ablauf spaeter im Zeitplan ueber viele Instanzen laeuft. Die Ablage kommt aus einer Einstellung (provisioning.proxmox.archive_storage, Vorgabe local wie bei den naechtlichen Sicherungen) UND wird gegen die Ablagen des Knotens geprueft. Fehlt sie oder nimmt sie keine Sicherungen auf, bricht der Abbau ab, bevor eine Kundenmaschine dafuer heruntergefahren wurde. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
91d3ce4064 |
Vier Spalten zur Verfolgung von Archivierung und Abbau von Instanzen
archive_volid: speichert den Ort des Archivs, ohne den es nach 12 Monaten nicht wiederzufinden ist. archived_at: speichert den Zeitpunkt der Archivierung, daraus berechnet sich die 12-Monats-Frist. torn_down_at: speichert den Zeitpunkt der Maschinenlöschung, getrennt von archived_at, damit der Zwischenstand sichtbar bleibt, wenn Archivierung erfolgreich war aber Löschung noch nicht versucht oder gescheitert ist. teardown_error: speichert die Fehlermeldung beim Löschen in Klartext, damit ein Betreiber ohne Log-Suche reagieren kann. Vier Tests zeigen, dass die Felder auf null stehen, in Carbon casten und dass der Zwischenstand (archiviert, nicht abgebaut) ein gültiger Zustand ist. Eine Mutation-Prüfung bestätigt, dass die Tests greifen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6ab868c522 |
Kuendigung B1, Task 1: einzelner vzdump und Nachschau auf der Ablage
backupNow() faehrt einen einmaligen vzdump (mode=stop, weil der Abbau die Maschine vorher ohnehin herunterfaehrt) und liefert die Auftragskennung. backupsFor() ist das eigentliche Fundament fuer den spaeteren Abbau: sie fragt nach, was auf der Ablage wirklich liegt, statt der Auftragskennung zu vertrauen — ein vzdump kann enden und trotzdem kein Archiv hinterlassen. $storage ist an beiden Methoden ein Pflichtparameter, keine Einstellung: der Client raet nicht, welche Ablage gemeint ist, und wiederholt damit nicht den fest verdrahteten "local"-Namen aus createBackupJob(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
37e1914246 |
Merge branch 'claude/nice-moser-521659'
# Conflicts: # VERSION # deploy/install-agent.sh # deploy/update.sh |
|
|
|
b3ddc44256 |
Zweite Fix-Welle: kein „beendet" ueber einem laufenden Aufbau
tests / pest (push) Has been cancelled
Details
tests / assets (push) Has been cancelled
Details
tests / release (push) Has been cancelled
Details
Zwei Nachwirkungen der ersten Fix-Welle (
|
|
|
|
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> |
|
|
|
9fa45e869f |
Ein Terminal auf den CluPilot-Server selbst
Eigene Faehigkeit, eigener Schluessel, eigene Tuer. Die Bruecke bleibt unveraendert: sie kennt keine Hosts, nur Tickets. Den Weg zum Wirt traegt die root-eigene Haelfte des Updaters — sie holt sich die oeffentliche Haelfte selbst, prueft sie hart und schreibt die Optionsliste, die das Dienstkonto nicht bestimmen darf. Kein sudo-Weg dorthin: einen Root-Schluessel eintraegt ein Mensch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c9fef59983 |
Fix-Welle: die Kuendigung sagt jedem nur das, was fuer ihn stimmt
Vier Befunde aus dem Gesamt-Review, und alle vier hatten dieselbe Wurzel:
`export_wish` fuehrt drei Zustaende, und jede Stelle, die den Kunden
ansprach, kannte nur zwei.
K1 — Der Streifen im Dashboard trug einen Schalter, und `(bool) null` ist
`false`. Wer vor dieser Ausrollung gekuendigt hat, las unter dem Streifen
„Kein Export gewuenscht" — als waere das seine eigene Antwort. Jetzt stehen
dort dieselben zwei Auswahlfelder wie im Kuendigungsdialog: gleiche Frage,
gleiche Form, und ein unbeantworteter Zustand markiert schlicht keines von
beiden. Der Satz daneben fragt dann, statt zu behaupten, und sagt, was
passiert, wenn die Frage offen bleibt.
K2 — Die Vertragsseite versprach jedem den Export, auch dem, den der Dialog
eine Sekunde vorher mit einem bewussten „Nein" genau dorthin umgeleitet
hatte. Drei Fassungen statt einer, an `export_wish` gebunden. Die Ja-Fassung
verspricht dabei nicht mehr den Export selbst, sondern dass der Wunsch
vermerkt ist und sich jemand meldet — den Export gibt es nicht, und ein
gebundenes, aber weiterhin unhaltbares Versprechen haette den Fehler nur
verschoben.
Dazu der Zustand danach: eine `ended`-Instanz holt Dashboard::render() nicht
mehr, und der Kunde fiel in denselben Zweig wie jemand, der noch nie etwas
bestellt hat — „Ihre Cloud wird eingerichtet." samt „Paket buchen", am Tag,
an dem ihm die Adresse eingezogen wurde. Der Fall hat jetzt seinen eigenen
Kasten, mit dem Datum, an dem das Paket endete.
W1 — Die Erinnerungsmail behauptete im Praesens, wir bereiteten bereits einen
Export vor. Der Satz sagt jetzt, was stimmt. Und die Antwort hatte in der
ganzen Konsole keinen einzigen Leser: ein „Ja" landete in einer Spalte, die
niemand je zu Gesicht bekam. Ueber der Instanzliste steht deshalb ein
Abschnitt „Datenexport bestellt" — wer, und bis wann. Nicht als Plakette in
der Zeile, weil die Liste geblaettert ist und ein alter Eintrag auf Seite acht
saesse; nicht auf der Uebersicht, weil ein Hinweis, den nichts je wieder
abraeumen kann, Moebel waere.
W3 — Die einzige Pruefung zur Anzeige der Antwort konnte nicht fehlschlagen:
`x-ui.switch` rendert beide Woerter und ueberlaesst dem CSS die Auswahl, also
war `assertSee('Kein Export gewuenscht')` bei true, bei false UND bei null
gruen. Nachgewiesen mit einer Wegwerf-Pruefung gegen den alten Streifen:
dreimal derselbe Satz, dreimal gruen. Jetzt drei Pruefungen, je eine pro
Zustand, am `checked`-Attribut der Auswahlfelder.
Zu jedem der vier Punkte wurde der Fix kurz zurueckgedreht und die neue
Zusicherung rot gesehen; die Ergebnisse stehen im Bericht.
Ganze Suite: 2819 gruen, 2 rot — beide fremd. ReadinessPageTest scheitert an
`server.private_key` aus der parallel laufenden Terminal-Arbeit im selben
Baum; HostStepTest ist auf main vorbestehend rot (install-agent.sh traegt
CONTRACT=3, update.sh HOST_STEP_NEEDS=2, beide unveraendert).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
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> |
|
|
|
cfd1481797 |
Ein Streifen im Dashboard sagt, wie lange der Zugang noch steht
Eine Mail sieben Tage vorher kann im Postfach untergehen; die eigene Uebersicht oeffnet der Kunde ohnehin. Der Streifen nennt das Datum, die Restzeit und was danach geschieht — dass der Zugang zur Cloud endet, nicht dass irgendetwas geloescht wird. Das ist die Frist, die ihn betrifft. Und er traegt die Antwort zum Export: wer liest, dass die Zeit laeuft, will es sich im selben Atemzug anders ueberlegen koennen (Aufgabe 4) — bis zum Laufzeitende, danach nicht mehr, geprueft direkt an der Methode und nicht nur am Schalter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5eef03d267 |
Fix-Runde: Markierung im selben Versuch wie der Versand, zwei Randpruefungen
tests / pest (push) Has been cancelled
Details
tests / assets (push) Has been cancelled
Details
tests / release (push) Has been cancelled
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. |
|
|
|
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.
|
|
|
|
ae8c8680bb |
Fix-Runde: echte Umlaute, und das Versprechen sagt "auf Wunsch"
tests / pest (push) Has been cancelled
Details
tests / assets (push) Has been cancelled
Details
tests / release (push) Has been cancelled
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> |
|
|
|
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>
|
|
|
|
5dd75dac86 |
Die Instanz merkt sich, was der Kunde zu seinen Daten gesagt hat
tests / pest (push) Has been cancelled
Details
tests / assets (push) Has been cancelled
Details
tests / release (push) Has been cancelled
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. |
|
|
|
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>
|
|
|
|
d04a76b6ee |
Ein Rückweg aus der abgelaufenen Betreiber-Einladung
tests / pest (push) Has been cancelled
Details
tests / assets (push) Has been cancelled
Details
tests / release (push) Has been cancelled
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> |
|
|
|
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> |
|
|
|
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>
|
|
|
|
9f2a45c7bf |
Fuenf geparkte Kleinigkeiten nach der Mitarbeiterverwaltung
tests / pest (push) Has been cancelled
Details
tests / assets (push) Has been cancelled
Details
tests / release (push) Has been cancelled
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> |
|
|
|
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>
|
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
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. |
|
|
|
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. |
|
|
|
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. |
|
|
|
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> |
|
|
|
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. |
|
|
|
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). |