201 Commits (main)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
3e7026407b |
Stop inventing a package, and rebuild the booking page
tests / pest (push) Failing after 8m12s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Two pages said opposite things about one account, straight after registering: the dashboard said "noch keine Instanz", and Paket & Addons said "AKTUELLES PAKET Start — Status Aktiv". The dashboard was right. The package page fell back to `$instance?->plan ?? 'start'` and rendered that fallback as a contract the customer had entered into, complete with a status of Aktiv, on the one page somebody opens to find out what they pay. The contract is resolved from the CUSTOMER now, not from the instance — a paid order parked for want of a machine has a subscription and no instance yet, and asking the instance would tell somebody who has paid that they have no package. An instance counts as well, whatever its subscription row says. With neither, the page says so and points at the booking page: an "Upgrade" from nothing is a purchase, and that has its own page. The cart stays outside that gate. A customer can have something in it before they have any contract at all, and hiding it there would hide the thing they came to pay for — which is what my first version did, and seven existing tests said so. And the booking page. Five cards in a four-column grid left the fifth alone on its own row, reading as a mistake; the consent box sat at the BOTTOM with every buy button disabled until it was ticked, so the page arrived looking broken before anybody had done anything wrong; and the price ran across the card in one block with nothing to compare. Now the consent comes first, because it is the condition for everything under it, and it colours itself green once given. Three columns, so five plans read as 3 + 2. Each card is the same four rows in the same order — price, delivery, what you get, the button — so the eye can run across a row and compare like with like. The button stays ENABLED with the reason it will not go through said under it: a disabled control explains nothing, and the server is the gate either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f49f1dca09 |
Say when no mail is being delivered at all
tests / pest (push) Failing after 8m40s
Details
tests / assets (push) Successful in 28s
Details
tests / release (push) Has been skipped
Details
"Ich versuche mich zu registrieren, erhalte aber keine Verify-Mail, auch nicht beim erneuten Senden." Traced rather than guessed, and the evidence is in storage/logs: the address appears three times, so the mails were built and handed to a transport that writes them to a file. MAIL_MAILER=log. MailboxTransport::resolution() short-circuits on it — NON_DELIVERING is ['log', 'array', null] — so every purpose mailer becomes the log transport however well the five mailboxes are configured, which is correct for a development machine and exactly what was happening here. The reason it deserves a notice is that the failure is silent in every direction an operator would look. Mail::queue() succeeds. No job fails. The queue empties. And the console's own mailbox test reports SUCCESS, because MailboxTester builds its own transport on purpose — a check that honoured MAIL_MAILER=log would report success while writing to a file. So every instrument on the page said fine. Now the front page says it, linked to the mail page, and the mail page says it above the mailboxes it makes decorative. All three swallowing values are named, not only 'log': array and an unset default do the same thing, and a notice that only knew about one would be silent in the two cases nobody thinks to check. Two existing tests asserted that a clean installation raises no notice, and went red — correctly, because phpunit.xml forces MAIL_MAILER=array so a suite cannot send mail. They pin a delivering mailer now: the warning is about the environment, those tests are about the estate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
70fa136e22 |
Say on the front page that the site is switched off
tests / pest (push) Failing after 8m54s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
"Ich komme auf dem lokalen Dev-Server nicht auf www.dev." Diagnosed rather than guessed: Laravel answers 200 on that host, and the request never reaches it — site.public is false, so PublicSiteGate returns the placeholder with a 503 to every visitor who is not on the management VPN and not signed in as an operator. Which is precisely what a broken deployment looks like from outside, and the only place that said so was the switch itself, on another page. So the console says it now, on the page an operator opens when something looks wrong, and the notice links to the switch. The second half of the same report — being sent to app.… — is the missing SITE_HOST. Without it the marketing site is registered without a hostname: it answers on the PORTAL's host as well, and every route() it generates points at APP_URL, so a link on the site lands on the portal's domain. Also said, also with the host named. Only where the installation is host-separated at all, though. A fresh checkout binds nothing to a hostname by design — RestrictAdminHost: "upgrading must not lock anyone out of a system that was working" — and a warning there is furniture. My first version fired on every clean installation and two existing tests said so immediately; they were right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f9993a1f48 |
Name the server variable under a credential, not the internal key
tests / pest (push) Failing after 8m35s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Under each credential stood `stripe.secret`, `dns.token`, `inbound_mail.password`, `monitoring.token` — identifiers out of SecretVault::REGISTRY. They mean nothing on a settings page and there is nowhere an operator could type them; asked about directly, and the answer is no, that was not deliberate. The env variable name is the useful one: it is what stands in the server file, it is what the .env tab of this same page lists, and when the badge beside it says "Aus der Serverdatei" it answers the obvious next question — which line? The registry key stays where it belongs: in wire:key and in the modal arguments, as an identifier nobody reads. The test asserts on the text node rather than on the key's absence from the HTML, because absence would have demanded breaking the page to pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a84251260c |
Read the mailbox password where it actually is, and let the console test it
tests / pest (push) Failing after 9m6s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
The inbox kept saying no mailbox was set up while the password sat right
there on the settings page. isConfigured() read
config('services.inbound_mail.password'), which is only ever the .env
value: this project deliberately does not overlay stored secrets onto config
at boot — SecretVault's own docblock says so, and HttpMonitoringClient reads
its token at the point of use for exactly that reason. My docblock even
asserted the opposite mechanism, which I never verified. It reads the vault
now, and falls back to .env because SecretVault::get() already does.
And a way to find out. "Verbindung prüfen" saves the form first — testing
what is on screen while the server still holds the previous values would
report on a mailbox nobody configured — then performs the same four verbs
the real fetch performs: LOGIN, SELECT, SEARCH UNSEEN. A check that proves
something easier than the actual job is worse than none.
What it reports back is one of five fixed words, never the server's own
prose: an IMAP error carries the command it failed on, and for LOGIN that
command contains the password. Each word maps to a sentence an operator can
act on — address and port, mailbox and password, the folder.
The line saying when the mailbox was last reached is on both pages, and it
is written by the SCHEDULED run as well as by the button. Otherwise "last
checked" would mean "last time somebody pressed a button", which is the less
interesting of the two: a mailbox that stopped answering at three in the
morning is the one worth seeing, and nobody presses a button at three in the
morning. It is also shown when the answer is "never" — an empty inbox and a
mailbox that stopped answering look identical, and this line is the whole
difference.
A fetch against a mailbox that just refused the login now takes nothing in
and records the refusal, rather than reporting "nothing new" — which was
true and useless.
Also: HostStepsTest asserted the literal clupilot.com for a host name. That
was true until CLUPILOT_DNS_ZONE was set to clupilot.cloud on this
installation, and then a correct configuration failed a test. The zone is
pinned in the test now; what it is about is the shape of the name and which
address it carries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
727f2e41db |
Make source() respect strict entries like get() does
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
bc6eee4dc3 |
Give every credential a test slot beside its live one
Each entry in the vault now stores under key:mode instead of a bare key, and get() resolves the active mode internally — falling back from an empty test slot to live (never the reverse) so test operation works before anyone has filled in test credentials, but live operation never silently reaches for a test one. get()'s signature is unchanged on purpose: its three callers (HttpStripeClient, StripeCheck, SshTraefikWriter) have no business knowing which mode is active. put()/forget()/source()/outline()/ updatedAt() take an optional trailing $mode for the console, which will use it in a later task. SecretVaultTest's raw app_secrets queries assumed the old bare key and needed the :live suffix plus a pinned OperatingMode — otherwise they'd depend on whatever mode an earlier test file left in the array cache. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
889b401faf |
Fix nine defects in the provisioning pipelines
The capacity park could not survive its own first poll. ReserveResources
polls every 120 s and declared a maxDuration of 60, and RunRunner measures
a step's budget from a started_at it deliberately does not reset on a poll
— so every re-entry was ruled timed out before the body ran, a timeout
consumes an attempt, and five of them failed a paid order in about six
minutes. The fourteen-day promise, the console's capacity queue and the
"go and buy a server" workflow were unreachable code. maxDuration is now
the whole park plus one poll interval, and a test drives a park through
the runner rather than calling execute() directly, which is why this was
invisible.
Every guest occ call ran as root. `docker compose exec` defaults to root
and the Nextcloud image's console.php exits 1 unless the caller owns
config/config.php, so all five call sites were failing on every instance.
There is now one builder, App\Support\NextcloudOcc, and a test refuses a
second: nothing in app/ may spell the invocation out by hand. deploy/
update.sh had learned this for our own container and nobody carried it
across.
RunAcceptanceChecks was terminal on the first no. certReachable() answers
false for a connect timeout as readily as for a missing certificate, so
one bad second ended a finished, certified Nextcloud as a failed order
with the instance released. The probes retry now and the run still fails
for good with the probe's own reason once the budget is spent; the two
facts a retry cannot change stay terminal.
The in-flight guard asked whether anything was running, not whether it
would do the work. App\Provisioning\WorkInFlight asks the second question,
reading the step lists the runner executes: a domain proven during a
restart is routed, and a storage pack booked during an address run is
delivered instead of charged monthly and forgotten.
The address pipeline ran its steps in the wrong order for the direction it
exists for. Nextcloud before the router in `address` and `plan-change`, as
in `customer`, because a run that got the certificate and then failed left
the customer's own domain serving an untrusted-domain error under a valid
certificate while the portal called it live. Safe in both directions; the
reasoning is written above the pipeline.
A stale guest_ip could never be corrected. ConfigureDnsAndTls compared the
hostnames but never the backend, and nothing re-read the address after the
build. It now records and compares `routed_backend`, and ConfigureNetwork
joins the `restart` pipeline — a cold boot is what moves a DHCP lease — and
asks for the address to be re-applied when the guest has actually moved.
Also: HetznerDnsClient::upsertRecord read only the first page of a zone
that pages at 100, so past a hundred records it created a second A record
for the same name and the cloud was up about half the time; the console's
retry left a live customer's order in `provisioning` for ever for any
maintenance pipeline; and a revived run's first pass burned an attempt on a
timeout that had already happened, because the clause written for it used
`??=`.
Seven test sites wrote config('provisioning.plans.*'), a key that has not
existed since the catalogue moved into the database. Every write was a
no-op, so each test proved something other than its name: the missing
template, the two snapshot regressions and the grandfathered-price scenario
are now actually constructed, ValidateOrder's reasons are asserted rather
than only its type, and the end-to-end run checks that the quota step
delivered something.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
518bc9aa41 |
Put the open tab in the address bar, including the first one
tests / pest (push) Failing after 11m2s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Reported as "die Tabs fehlen in der Query beim Kunden". The binding was there and identical to the settings page — checked in the rendered snapshot, which is where Livewire's JS reads it from — but `except` hid the parameter while the DEFAULT tab was open. So arriving on a customer showed no ?tab= at all, and a link to the page could not say which tab it meant unless the tab happened not to be the first one. From the address bar that is indistinguishable from the feature not existing. No `except` now, on all three tabbed pages: the parameter is there from the first render. And `history: true`, so each tab is a step the back button can take rather than a silent replaceState — which also makes the change unmistakable while clicking. Asserted against the rendered page rather than the component, because the parameter is written client-side from the `url` memo in the snapshot: the memo IS the behaviour, and asserting the property would have passed throughout the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f00cbf18d3 |
Let an operator correct a customer's details
tests / pest (push) Failing after 7m59s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
The console could show them and not touch them, so a moved office or a new contact person meant impersonating the customer or editing the database by hand. Both are worse than a form. R20: a modal, opened from the Stammdaten tab, authorising again and reading the record itself rather than trusting anything the browser hydrated. Most of the work is in what saving must NOT quietly do. The customer type is never reset to "nobody asked". The field opens on "leave unchanged" precisely so that saving an address correction cannot undo it — that answer decides whether a withdrawal right exists, and losing it silently would be the expensive kind of quiet. A changed VAT number is unverified from the moment it is stored — the verification is bound to the value, so that happens by itself — but `vat_id_verified_value` is left alone: it is the record of what WAS checked, and clearing it would destroy the evidence rather than the claim. The address is also the sign-in address and the key inbound mail is matched by, so changing it moves the linked portal login in the same transaction and puts it back to unverified, which is what Laravel's own profile update does for the same reason: nobody has shown they can read the new address. No mail is sent from here — the portal asks for the confirmation at the next sign-in, and a mail an operator did not knowingly send is a surprise. Three addresses are refused: one belonging to an operator (R21 — no index spans the two tables, so it is checked), one belonging to another customer, and one belonging to somebody else's user row, which can outlive or predate a matching customer. Not offered here, each for its own reason: `status`, because suspending has consequences for access and a lifecycle switch hidden among address fields is one somebody flips by accident; `stripe_customer_id`, because a wrong one does not move a contract, it points our records at somebody else's payments; the brand fields, because the customer sets those for themselves and an operator overwriting a logo is not a correction. There is a test that fails if any of them appears — with the comments stripped first, since the class documents at length why they are absent and that documentation is not the offence. This repository has fallen into that trap three times. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
57c6912987 |
Say that a click landed, and make moving one actually move it
tests / pest (push) Failing after 7m52s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
"Man klickt und sieht nicht was passiert." Every action in this console is a round trip, and a round trip with no sign of itself reads as a click that missed — so the answer is in three places, from the general to the specific. A thin bar at the top of the window for any Livewire request, in app.js. Delayed by 140 ms, because a request that answers faster than that is perceived as immediate and a bar flashing on every keystroke of a .live field is worse than none. It creeps to 70 % and only completes on the answer: a bar that reaches the end while the answer is still on its way is a lie about the thing it exists to report. Released on failure as well — the request somebody must not be left waiting on is precisely the one that went wrong — and on wire:navigate, which is not a request hook at all and would otherwise leave the bar behind on a page that is gone. Per button: a pressed state on the way down, disabled while its own request is out, scoped with wire:target to that button's own call so a row does not grey itself out because a neighbour is moving. The edit button carries its own spinner, because opening the modal is a request made by the MODAL component — wire:loading on this page never sees it. It clears when the package announces the modal is up, and on a six second timer as well, so a request that never answers cannot leave a row spinning for ever. And the reordering was not only slow to look at, it was wrong. It added or subtracted a fixed amount: with the seeded values ten apart that usually landed right, and sometimes landed on a value another template already had — or on the floor along with three others. Equal sort values fall back to the name, so a click could reorder something else, or nothing, which is indistinguishable from a click that never registered. It swaps with the neighbour now: exactly one place, or nothing at the ends, both of which are what an operator expects to see. Where two shared a value the swap leaves them distinct, or the next click would do nothing again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
7fd45b01fb |
Ship the thirteen answers this business actually sends
tests / pest (push) Failing after 8m30s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
An empty template list is a feature nobody starts using: writing the first one costs the same as writing the mail, so the operator writes the mail. These are the questions that come up in a managed-Nextcloud business — enquiry received, migration question, migration quote, cloud being prepared, credentials asked for again, storage filling up, package change, incident update, incident resolved, maintenance announced, payment reminder, cancellation confirmed, request closed. Every figure and every date is a gap, never a number. A seeded "innerhalb von 24 Stunden" is a promise put in the operator's mouth that nobody agreed to, and a seeded price is wrong for every customer but one. Nothing here says a migration is possible before somebody has looked at the source system either — that was the whole point of the wording on the website. There is a test that fails if a shipped template ever grows a duration or an amount. German only, deliberately: these are texts written TO customers, not interface labels, and the audience is this company's customers. An operator serving another market writes their own; the mechanism and the page are already translated. firstOrCreate on the name, so re-running changes nothing and an operator who has edited or retired one keeps their version — a migration must never overwrite text somebody worked on. The rollback deletes only rows still identical to what was seeded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
33566cd404 |
Show the customer, and write the answers once
tests / pest (push) Failing after 9m49s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Two complaints, one cause: the customer page was a compose box with three
lists under it. It showed nothing ABOUT the customer, and every answer was
typed from scratch.
The page is five tabs now. Stammdaten — contact, phone, since when,
language, and the tax standing: consumer or business, with "nobody asked"
saying so rather than passing for business, because that answer decides
whether a withdrawal right exists at all. Paket — the contract's own
FROZEN figures, the modules at the price they were booked at, and the
machine: address, state, host, datacenter. Zahlungen — invoices with net,
gross and their PDF, and the orders beside them, because an order is a
purchase and an invoice is a document about one. Nachrichten — the portal
requests in the customer's own words, the mail they sent, the mail we
sent. Schreiben — last, because it is what you do after reading.
The tab is in the query string, like Settings and Integrations: a reload,
a bookmark and the back button all land where the operator was, and a link
can point at the tab that matters.
And templates, under Betrieb → E-Mail-Vorlagen. Most customer questions
are the same five questions, and typing the answer again every time is how
two customers get told two different things about one subject. A template
is INSERTED into the compose field with the customer's own details filled
in — never sent as it stands, because the last two sentences always belong
to the person asking. What goes out is what the operator saw and edited.
Three decisions worth naming. {{contact}} takes the contact person where
one is on record and the company name otherwise, because "Guten Tag
Beispiel GmbH" is the line that gives away a mail written by a database.
{{amount}} is the gross figure — what the price sheet quoted them and what
their bank statement says. And a typo comes out as {{kunde}}, standing
there in the field where the operator sees it, rather than as an empty
string that produces "Guten Tag ,". A placeholder with no value for this
customer stays empty; nothing is invented.
Placeholders are named in English because they are identifiers, not prose:
a template has to survive somebody switching the console's language. The
list the page documents is the renderer's own, so a token added in one
place appears in the other without being written down twice.
Blade's echo tag ends at the first }} it finds, which turned a literal
placeholder in the documentation into compiled nonsense. Assembled in PHP
now, and caught by the test that renders the page rather than by somebody
opening it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
e376b571be |
Read the support mailbox into the console
tests / pest (push) Failing after 7m59s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
No, not an API token: mailcow's API administers domains and mailboxes — it does not read mail. Reading a mailbox is IMAP whoever runs the server, so that is what this is. Host, user and folder are console settings; the password is a vault entry, like every other credential that opens something. IMAP by hand rather than a dependency. Four verbs are needed — LOGIN, SELECT, SEARCH UNSEEN, FETCH — and the parsing that follows is the real work either way. It is bounded by what the console shows: a sender, a subject, the words a person wrote, and the NAMES of anything attached. Real mail from real people arrives encoded, so it handles RFC 2047 subjects (every German subject with an umlaut in it), quoted-printable and base64 bodies, Windows charsets, and multipart — text wins over HTML, because a console is not a mail client and the words are in the text part. A message it cannot make sense of is skipped and logged, never guessed at: a garbled question in front of an operator is worse than one they go and read in the mailbox, where it still is. ATTACHMENTS ARE NOT STORED. Only their names and sizes, so an operator knows something was attached and can go and look if it matters. Keeping whatever a stranger chooses to send would make this application a malware store with a web interface in front of it. Whose a mail is, is decided by the sender's ADDRESS and by nothing else — not by a name in the subject, not by anything in the body. A mail is easy to write and this decision attaches a stranger's words to a real customer's file; there is a test that forges the name and the subject and still gets nothing. An address nobody recognises stays unassigned, shown FIRST and never hidden: that is a new enquiry, or a customer writing from their private account, and it is the mail that must not be missed. An operator can place it by hand from the same row. Nothing is deleted on the mail server, ever. Messages are flagged seen once the row is safely written — and only then, because flagging first and failing after would lose the message with no second copy anywhere. Filing one away is this console's own state; the mailbox is untouched. Two fixes on the way past: the customer page printed "customers.status.active" and "support.status.open" — a lang key with no file behind it renders as itself and Laravel says nothing — and the same page is now covered by a test proving each list is scoped to the customer in the URL, which was reported as "unterhalb steht immer das selbe". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0e25fe88d4 |
Keep a register of what was sent, and answer the customer from here
Three complaints, one shape: the console knew things and could not act on them, so the work happened somewhere else and left no trace. The header said "4 Hinweis(e)" and led nowhere. Whoever read it had to know that the list was a card further down the same page, and then go looking for the host or the failed run by hand. The count is a link now, every notice carries the page that shows the thing it is about, and the plural is a word rather than a bracket. Nothing recorded what this installation had sent. "Hat der Kunde die Zugangsdaten je bekommen, und wann?" was answered on the mail server — a different machine, a different program, and nothing an operator can put in front of somebody who says nothing ever arrived. Every mail now writes a row: when, to whom, which mailable, and the customer it belongs to. Written from MessageSent, so a row means the transport accepted it; that is the strongest thing an application can honestly claim, and a column called "delivered" would be pretending otherwise. Verification and reset mails are left out on purpose — they go to unconfirmed addresses and anybody who can type into a form can send them, so recording them would hand the register to whoever wants to fill it. And the customer list was the end of the road. There is a page behind it now: what they bought, what they asked in their own words, what we sent them, and a box to write the next message. It goes out from the support mailbox and is recorded on the way — with its body, because an operator typed it and "what exactly did I write to them in March" is a question the mail server's log cannot answer either. Answering a request from here closes it, which is the reason to answer from here at all: answering from a mail client leaves it open for ever, because nothing tells it otherwise. Found on the way, by a test that sent a real mail rather than asserting it was queued: resources/views/mail/reset-password.blade.php was missing its closing </x-mail.layout>. The component's contents were never terminated, the layout's own @if ran off the end of the file, and the mail raised a Blade syntax error instead of rendering. Password reset has therefore never delivered a link since it was built. Closed, plus a test that renders it and one that counts opening against closing tags across every mail view. Reading INCOMING mail in the console is not in this: it needs the mailbox polled over IMAP, which is a credential and a dependency. Requests filed through the portal appear here in full; a customer who writes by mail instead still has to be read in the mail client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
559a466630 |
Show that the update is running, and stop showing the rsync log
tests / pest (push) Failing after 12m32s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The maintenance screen answered a question nobody was asking. Under the headline sat a card with the deployment's raw output in it — rsync listing every font file it had copied — and, before a step was known, a box drawn around the words "Wird jetzt gestartet." A person waiting on this page wants one thing settled: is anything happening, or am I looking at a dead page. So: an indeterminate bar, in CSS. Indeterminate because a deployment has no honest percentage and a bar that claims one always stalls at 90 %; in CSS because this page is served while the application is down, so it may not depend on JavaScript having loaded or on the server answering another request. Motion off gets a steady bar rather than none — the point is to show the page is alive, and a still bar says that better than a gap. Shown to everybody, not only in the console: a customer on the 503 is asking exactly the same question. What is left of the operator detail is the current step, as one quiet line, because a run that stops on a step is worth seeing without a shell. The elapsed time and the log are gone from the overlay entirely — with them goes a file read on EVERY console page load, running or not. The settings page still shows the log, where somebody is actually looking for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6b8412f0c3 |
Write an invoice for work that came off no price list
tests / pest (push) Failing after 8m11s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Not everything we are paid for is a package. Somebody asks whether their data can be moved into Nextcloud; we look at it, we say what it costs, we do it. That is a real invoice with no order and no contract behind it, and the only way to produce one was to write it somewhere else — which puts it outside the series. A numbered series with a document missing from it is worth nothing at an audit. So it goes through the same door as every other invoice: IssueInvoice::forService() draws from the same series and therefore the same consecutive number, applies the same TaxTreatment (a verified business in another member state gets its reverse charge and the note that makes a zero-rated document lawful), freezes the same immutable snapshot and dispatches the same archive jobs. The page under Rechnungen writes what the operator typed and nothing else. Amounts are in euro because it is a form — 12,95 becomes 1295 by round(), not by a cast that would make it 1294 — and quantities go to a thousandth because hours are billed in quarters. The total beside the lines is the same InvoiceMath the document uses; a preview doing its own arithmetic would be a second answer, and the first one anybody notices differs is on a document that cannot be changed. There is no draft: a "save as draft" that draws a number is an issued invoice with a friendlier name. The website's buy buttons go to the sign-in now, not to /order. Booking belongs in the panel, where the account exists and its address has been confirmed — which matters because that address is where the finished cloud's credentials are sent. Sending an anonymous visitor at a page behind auth worked, but only via a middleware bounce off a page they never asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f85f57152e |
Let the customer buy it themselves, and tab the integrations page
tests / pest (push) Failing after 21m20s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Every path on the site ended in "anfragen": a mailto: link and a promise to answer the same working day. That is an afternoon of somebody's time per customer, for a product whose whole point is that the machine does the work — and it scales exactly as far as one inbox does. Now: sign in, pick a package, pay, and provisioning starts by itself. The pieces were already there — Stripe holds a Price per plan version (the catalogue sync writes the ids), the webhook opens the contract, the pipeline builds the VM — and what was missing was the checkout session between them. CheckoutController opens one and gets out of the way; the purchase becomes real on the webhook, which stays the only place allowed to believe a payment happened. The success URL is a redirect target and therefore a URL anybody can type, so it says "we are building it", not "you have paid". Nothing in the request may decide the price. The plan key arrives from a form and everything else is re-derived from the catalogue, including the plan VERSION, so a version published while the customer types their card number cannot change what they were quoted. Two cases that are not errors: no capacity is a parked order (already built), and an existing customer is sent to the plan change rather than sold a second, empty cloud that bills twice. The site says what it can keep. "danach wissen Sie … wie Ihre Daten sicher umziehen" was a promise made before seeing the thing it is about: we do not know where a visitor's data is, what shape it is in, or whether it can be moved at all. Migration is named as its own question, looked at first and quoted afterwards. The integrations page gets the settings page's treatment for the same reason: it was one narrow column of six cards, so an operator connecting DNS scrolled past Stripe to reach it and the raw .env editor sat at the bottom of everything. Three tabs by what a section is FOR — services we buy from outside, our own machines, the file underneath — the same axis the page was rebuilt around when it stopped being split by storage mechanism. The tab is in the query string; unlocking survives a switch, because a confirmed password is a session fact and not a property of a tab. The .env tab is Owner-only, like the editor it holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
7265799881 |
Quote what a person pays, and tab the settings page
tests / pest (push) Failing after 7m50s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Prices. The sheet quoted net, which is the wrong number for one of its two readers: a private customer has no way to add 20 % in their head and no business being surprised by it at checkout. The gross figure is now the big one, with the net underneath for a company's books, and the same rule applies to the module prices further down — a sheet that quotes the package with VAT and the module without it is a sheet whose numbers cannot be added up. The rate is the one on the Finance page, the same one an invoice uses; a percentage written into the template would be the second source that makes the two disagree. "zzgl. einmaliger Einrichtung" had run for months without ever naming a figure, which leaves a visitor knowing only that there is one. It is a field on the Finance page now and prints with the price. Zero removes the sentence — and the comparison card that listed the fee as the honest downside of the offer — rather than admitting to a cost that is not charged. Layout. Four packages in 1120px left each card 202px of text, which is what broke "Bereitstellung in 2-3 Werktagen" over two lines and made the cards look pressed against each other. The pricing section is wider than the rest of the page (only it: the reading columns elsewhere are narrow on purpose, a price sheet is four columns that must be comparable at a glance), the gap between cards is larger, and the delivery line is short enough to fit with whitespace-nowrap to keep it that way. Navigation. VPN moves from Betrieb to System: the tunnel is not a task of the day, it is how the console reaches the estate at all. Settings. The page had grown to three headed sections in one column with the operator's own password somewhere in the middle. It is tabbed now, and two-factor enrolment is one of the tabs instead of a menu entry of its own — it is a setting, and it was the one thing an operator had to leave this page to change about their own access. Same component, no second copy of the enrolment logic; it still answers at its own route because RequireOperatorTwoFactor sends an operator there who may not open anything else yet, this page included. The open tab lives in the query string, so a reload, a bookmark and the back button all land where the operator was. That matters here more than elsewhere: the installation tab refreshes itself while an update runs, and a tab kept only in the component would drop them back to the first one mid-deployment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ec8675861e |
Take the order, park it, and say when it will be delivered
tests / pest (push) Failing after 7m44s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A paid order that no host has room for used to FAIL at the reservation
step. The customer has paid by then, so that turned a sale into an
incident and left somebody holding money against nothing. The estate is
finite and booked thick, so "no host has room right now" is an ordinary
Tuesday — it means a machine has to be bought, which takes days.
So the order waits instead. poll(), not retry(): the wait is measured in
days and must not eat the run's attempt budget. It only fails after
PARK_DAYS (14) — long enough to buy, rack and onboard a server over a
weekend, short enough that a forgotten order surfaces instead of waiting
for ever.
Then say so, in the same words on both sides of the payment:
- The price sheet marks each package "wird sofort ausgeliefert" or
"Bereitstellung in 2-3 Werktagen", read from free capacity rather
than written into the page.
- The customer's own overview says their cloud is being prepared while
it is parked, instead of a stepper standing still for two days under
"wird eingerichtet" — which reads as broken.
- The console front page shows the same per-package answer, so an
operator can see what visitors are being promised.
And a capacity page for the decision that follows: who is waiting, what
the roomiest host still has, what the queue needs ("3x Start, 1x
Business" - the LARGEST parked package sets the minimum machine, because
an instance lives on one host), and what such a machine costs today from
the provider's live list. It orders nothing: buying a server is a
contract, and a bug in a capacity calculation must not sign one.
The operator can also say where each parked order goes. A pin is
honoured or it waits — never quietly redirected — because placing it
elsewhere answers a different question than the one they answered, and
they would find out from the finished instance.
Two things this shook out:
- `current_step` is an INDEX into the pipeline, not the step's key, and
it is cast to integer. `where('current_step', 'reserve_resources')`
reads as correct and matches step 0 of EVERY pipeline instead. Both
lookups go through HostCapacity::parkedRuns() now, which resolves the
index and filters by pipeline.
- The auction feed sends `hdd_hr` as a list, not a string. Casting it
printed "Array" into the console and raised a warning that took the
page down with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
d76de5ada3 |
Restart a machine, enforce the quota that was sold, end a route that ended
Three places where the product said one thing and did another. **Nothing could restart a virtual machine.** ProxmoxClient had startVm and no way to stop anything, so `restart_required_since` — set by every plan change that grows a running guest — could only ever be cleared by a resize step that happened to find the machine stopped, which nothing in the product could arrange. A paid upgrade's cores and RAM could stay unreached for the life of the contract, and the cloud page's "Neu starten" was a toast. There is now a `restart` pipeline: shutdown, start, wait for the agent, confirm. A shutdown and a start rather than a reboot, because only a cold boot makes qemu read the VM definition again — a reset would take the machine round and bring it back exactly as small as it was. The shutdown is a REQUEST, bounded at ten minutes, and there is no escalation: ProxmoxClient deliberately does not expose Proxmox's forceStop, because the guest is a Nextcloud and cutting power to a database mid-write to apply a CPU change trades an inconvenience for a restore from backup. A guest that ignores ACPI for ten minutes fails the run instead, and a person decides what happens to it. `restart_required_since` is cleared by the last step, from what the RUNNING guest reports (`cpus`/`maxmem`) — not by the button, and not by the run merely finishing. A machine that comes back smaller than it was sold fails the run. Started by App\Actions\RestartInstance, which re-checks authorisation itself rather than trusting a hidden button: an operator holding the new `instances.restart` may restart anything, a portal user their own machine and nothing else. Refused while another run is in flight against the same order. Confirmed in a modal on both sides (R23), addressed by uuid (R11). **Existing instances had no storage quota.** ApplyStorageQuota reaches new and changed machines; everything built before it kept the whole disk whatever it had paid for, and nothing recorded which was which. `instances.quota_applied_gb` is now what the guest was actually told, written by the step only after the occ call succeeds — so "sold" and "enforced" are separate facts and can be compared. `clupilot:apply-quotas` sweeps the estate: one `quota` run per instance through the same step the pipelines use, skipping what it should and saying why, a no-op once an allowance is enforced, and inert under --dry-run. Deliberately NOT scheduled — a nightly sweep would quietly cover for the pipeline step the day it stopped working, which is how the original hole survived this long. **A Traefik router was never torn down.** TraefikWriter::remove() had no caller anywhere. What was missing was the moment: ConfirmCancelPackage wrote a date into `service_ends_at` and nothing ever went back to it, so every route this platform ever wrote was written for good — pointing at a guest address the host is free to reassign. `clupilot:end-due-services` (hourly) keeps that appointment through App\Actions\EndInstanceService. "Ended" means cancellation_scheduled AND `service_ends_at` passed, never the day a cancellation is scheduled: that customer has paid to the end of the term and is working in it. The DNS record goes with the route — it is in our zone, pointing at a host that serves other people, which is the shape of every subdomain takeover there has ever been. The customer's own domain is in their zone and is not ours to touch; the virtual machine is left alone, because the cancellation flow promises a data export first and destroying disks is not this change's decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
98d884727a |
Warn that a package cannot be placed, before somebody pays for it
tests / pest (push) Failing after 7m55s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
The platform books thick: a package reserves its whole disk_gb — the sold quota plus the machine's own overhead — on ONE host at placement. That is the right choice for a small operator, because nothing can quietly overfill. It has one consequence nobody was being told about: the catalogue goes on offering a package no host has room for, and the first anyone hears of it is a PAID order failing at the reservation step. On the machine this was found on the numbers are stark. 2 x 512 GB NVMe in a mirror is about 400 GB usable; Start reserves 120, Team 540, Business 1050, Enterprise 2100. Three of the four packages on sale cannot be placed at all, and the fourth fits three times. So the console says so now, per package, with the shortfall — "Team needs 540 GB, the roomiest host has 260" — because "buy a server" is not the decision. "Buy a server big enough for what I am selling" is. Two things it deliberately does not do. It does not warn on an installation with no host at all: that is a machine nobody has onboarded yet, it has its own notice, and without the guard a fresh console greeted its operator with one warning per package before they had done anything. And it does not take the overview down when the catalogue cannot be read — a capacity warning does not get to break the page somebody opens when something is already wrong. The largest FREE HOST, never the sum: an instance goes on one machine, so two hosts with 300 GB each cannot take a 540 GB package between them. And when placement does fail, the log now names the shortfall instead of only 'no_capacity'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
82f95df04c |
Stop the 503 page appearing during an update
tests / pest (push) Failing after 8m3s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Reported with the network panel open: `update` and `state` both answering 503, then the overlay, then Laravel's 503 page, then a reload. Three different screens for one event. Livewire's default on a failed request is to render the response body. So a wire:poll that happened to fire while the application was in maintenance mode swapped the whole console for the 503 page — the panel the operator was watching, replaced by an error page, for something that is not an error. A deployment IS maintenance mode; that is the event, not a fault. Three changes, and they are three because the request came from three places: The status endpoint is exempt from maintenance mode. It is the deployment's own status, read by the overlay every three seconds, and behind maintenance mode it answered 503 for the entire run — so the overlay could never name the step it was on. Safe to exempt: version, commit and a step name, behind the console's host and network guards, and it writes nothing. Livewire swallows a 503 instead of rendering it. For every request, not only the poll: any action taken in that window did the same thing. And the settings card stops polling for the duration. The overlay's own watcher is a plain fetch with no component state behind it, which is why it survives the restart; this poll cannot, and every attempt was a request that answered 503. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
98afd1d7e0 |
Re-apply what the last release commit silently reverted
tests / pest (push) Failing after 7m54s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
The English paths and the zone migration went out in
|
|
|
|
1620cb9395 |
Release 1.3.22
tests / pest (push) Failing after 8m15s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
v1.3.21 was taken by a parallel release between reading main and writing the commit; the version file went out saying 1.3.21 on a commit that is not that tag. Corrected here rather than by rewriting a pushed commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5f74a5f370 |
Move the zone by migration, and put every path in English
tests / pest (push) Failing after 8m8s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
── The zone, for the fourth time ────────────────────────────────────────────
"Change it in the console" was the wrong answer three times running. An
installation with nothing to migrate should simply arrive on the new zone, so
a migration does it — guarded by exactly the condition clupilot:check-zone
reports on: no instance in service, therefore nothing carrying the old name in
DNS, in a certificate or in trusted_domains. With even one instance it does
nothing and logs why, because re-addressing a running Nextcloud is a migration
with an outage and a migration is not the place to start one. It leaves an
installation already on another zone alone: the setting exists so somebody
else can run this on their own domain.
That fixes both reports at once — the security page and the price sheet read
the same setting, which is the entire reason neither of them has the domain
typed into it.
── R13, applied to all of them this time ────────────────────────────────────
"Ich schreibe das nun zum dritten Mal" — fair. The rule was cited, I changed
the one route that had been pointed at, and left every other German path
standing. A fragment is part of a URL too.
#produkt → #product /legal/impressum → /legal/imprint
#ablauf → #process /legal/datenschutz → /legal/privacy
#preise → #pricing /legal/agb → /legal/terms
#fragen → #faq (old paths redirect, permanently — they have been
#kontakt → #contact in the footer and in mail for weeks)
The headings stay German. "Impressum" is the legal term an Austrian company
has to use; the address is not part of the copy.
A test now walks every registered route and every fragment the site links to,
so the next German path fails the suite instead of being reported a fourth
time.
Three existing tests set config('provisioning.dns.zone') and read it back
through the accessor. That worked while the setting was empty and stopped the
moment the migration filled it — they set the setting now, and the one whose
premise is "nothing saved yet" establishes that premise itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
6ef9b510ce |
Merge branch 'feat/plan-reopen'
# Conflicts: # app/Http/Controllers/LandingController.php |
|
|
|
a4626d2569 |
Stop root workers breaking every page, and let the panel be closed
tests / pest (push) Failing after 12m38s
Details
tests / assets (push) Successful in 26s
Details
tests / release (push) Has been skipped
Details
── The 500 ───────────────────────────────────────────────────────────────── "touch(): Utime failed: Operation not permitted", from BladeCompiler, on every request for the affected view. Blade compiles a view and then touch()es the compiled file to the source's mtime. touch() with an explicit time needs ownership of the file. A compose service without `user:` runs as ROOT — and queue, scheduler and reverb had none — so the first worker to render a view wrote a root-owned compiled file that the web process, as www-data, could never refresh again. The queue is what renders mails, which is why this surfaced now. queue, reverb and scheduler run as www-data. queue-provisioning stays root and says why: it brings wg0 up and runs `wg set` for every peer change, which needs NET_ADMIN on the running process. It renders no mail. And update.sh normalises ownership at the END of a run as well as at the start. The first call heals what a previous run left; the second heals what this one made — `git checkout` rewrites the tree as the service account while the old build is still serving. ── The panel that would not go away ───────────────────────────────────────── The same 500 is why it kept coming back: every poll failed, the watcher treats a failed request as "still restarting" (which it normally is), and the overlay stayed up over a console nobody could then reach to find out why. A restart is seconds. After two minutes of nothing the panel now says so and offers a way out — for an operator only, behind the same flag as the step and the log, so a customer on the 503 page is not shown a button suggesting they can call the deployment off. A successful answer clears it again: one bad minute must not leave a "something is wrong" notice sitting there for the rest of the run. Not an always-present close button. The deployment does not stop because somebody dismissed a panel, and offering that at the wrong moment is a lie. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6c92aa5dd7 |
Let an incident be deleted, and start measuring whether the hosts answer
tests / pest (push) Failing after 8m13s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Three things, all from trying to actually use the incident page.
── Where the later entries go ───────────────────────────────────────────────
The form only ever creates the first one, and nothing said so — so it looked
as though the stage had to be chosen up front and there was only a dropdown
for the impact. The field now says what it becomes ("wird untersucht") and
where the rest are added: on the incident itself, once it exists.
── Deleting ────────────────────────────────────────────────────────────────
Asked for in order to test the thing at all, and right. A whole incident can
now be removed, with its entries, behind a confirmation in the product's own
dialog (R23).
Deliberately not the same as editing an entry, which there is still no way to
do: an update is a statement made at a time, and rewriting one afterwards is
what the record exists to prevent. Removing the whole incident is a different
act — it is how a test entry, or one published against the wrong service, is
taken back, and it leaves nothing half-standing behind. A test holds that
distinction so the delete button does not quietly become an exception to it.
── The hosts, which nothing was watching ────────────────────────────────────
`hosts.last_seen_at` drove the health dot in the console and was written
exactly once, at onboarding — RegisterCapacity stamped it and that was the
only write in the codebase. So every host read "offline" thirty minutes after
it was added, permanently, and the dot meant nothing. It is also why host
reachability could not appear on the status page: it was never being measured.
PingHosts asks every host's API the cheapest question it answers, once a
minute, and stamps the timestamp on success. Failure writes nothing on
purpose: absence IS the signal, and a second column counting failures would be
a second version of the same fact.
The status page has a fifth component for it. Counts only — never a host name,
never an address; the page is world-readable and the estate is not public
information. A host between five and thirty minutes silent reads as degraded,
not as healthy: "stale" is one of three answers healthState() gives and it is
not the good one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
0e3c50d9a1 |
Update on a schedule if the owner wants one, and say where incidents go
tests / pest (push) Failing after 8m22s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
── Where the status page gets its data ────────────────────────────────────── Asked in as many words, and fairly: the page for maintaining incidents exists (console → Betrieb → Störungen) and nothing anywhere said so. The page now carries the answer at the top — what is written there appears publicly at once, the four components measure themselves, and there is a link straight to the live status page. ── Automatic updates, inside a window, switchable ─────────────────────────── Tick it and name the days and hours; leave it alone and nothing changes. The button stays either way: manual is always possible, at any hour. It leaves exactly the same request the button leaves — same file, same agent, same run. A second path into a deployment would be a second set of rules to keep in step with the first, and the automation must not be able to do what an operator is prevented from doing by hand: it checks the same three things the button is disabled by (agent alive, something released, nothing running). Nothing guards against having already updated by hand, and nothing needs to: an installation that is current has nothing available, so pressing the button at four leaves the five o'clock window with nothing to do. That was the owner's own question and it answers itself. Defaults are Tuesday to Thursday, 05:00–06:00. Not Monday — the week starts and every fault costs double. Not Friday — nobody wants to repair a deployment on a Friday afternoon. Not three in the morning: if it goes wrong, the person who has to fix it is asleep. A window whose end is before its start wraps past midnight, because 23:00 to 02:00 is a reasonable thing to ask for and reading it as an empty range would silently mean "never". Switched on with no weekday selected is refused rather than corrected — it is a configuration that reads as working and does nothing. The clock is the wall clock (R19): an operator who writes 05:00 means five in the morning where they live, and a window that drifts by an hour twice a year is a window nobody trusts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d4e8822dbb |
Paketversionen: Verkauf wieder aufnehmen und saubere Übergabe
tests / pest (push) Failing after 7m58s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Eine geschlossene Version konnte nie wieder in den Verkauf. Schlimmer: um ein Paket zu ändern, musste die laufende Version zuerst beendet werden — und wenn die Nachfolgerin dann nicht sofort veröffentlicht wurde, war das Paket dauerhaft dunkel. - PlanCatalogue::reopen() setzt available_until zurück auf null, über schedule(), damit Family-Lock und Überlappungsprüfung dieselben bleiben. Ein nie veröffentlichter Entwurf wird abgelehnt. - PlanCatalogue::publish() übergibt eine laufende Version an ihre Nachfolgerin (available_until = deren Start) statt die Überlappung abzulehnen — unter demselben Lock, in derselben Transaktion. Nur die unmittelbare Vorgängerin, und nur wenn es genau eine gibt. - Konsole: Knopf „Wieder verkaufen", übersetzte Ablehnung statt der englischen Ausnahme, und die Übergabe wird im Veröffentlichen-Formular vorher angekündigt (Version und Zeitpunkt, ->local()). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ea643b5e73 |
Prove a custom domain before serving it, and keep proving it
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two things. ── The update screen, still opening twice ─────────────────────────────────── Reported again on 1.3.9, and the cause was not the one fixed in 1.3.8. The agent consumes the request file BEFORE it resolves the release — deliberately, because update.sh may kill the shell and a request left in place would loop — and writes `state: running` only once it has decided to go ahead. In between, the request is gone and the status does not say running yet, so the endpoint honestly answers "nothing is running". The watcher read that as "the run has finished" and reloaded the page: overlay on the click, gone a poll later, 503 after it. The overlay now closes only once the server has BOTH confirmed a run and then stopped reporting it. Before the confirmation, silence means the agent has not got there yet. Bounded at twenty polls so a request the agent refuses does not leave the console covered forever. ── Custom domains, proven and re-proven ───────────────────────────────────── `custom_domain` was a free-text field and everything downstream believed it: the proxy served it, the certificate was issued for it, Nextcloud trusted it. Anyone who pointed any hostname at the platform got somebody else's files under their own name. The proof is a TXT record at _clupilot-challenge.<domain> holding a token only this instance has. Nothing is served until it has been read. Every reader now goes through Instance::address(), which is the one place the decision is made — `custom_domain ?: subdomain` was the hole, written out four times. It is re-read every night at 03:40, because a token checked once can be taken straight back out and a domain that later lapses keeps resolving here. Three consecutive misses before a live domain is withdrawn: one failed lookup is a nameserver having a bad minute, and withdrawing takes a working Nextcloud off its own address. The domain and its token stay on the row so the customer can put the record back rather than start over. Changing the domain mints a NEW token. Reusing it would let somebody who once verified example.com claim any other domain later without touching its DNS — the old record is still sitting there and only the value is compared. The domain is changeable at any time, and removable. Fixing it once set was considered and rejected: adding or moving an address is a proxy entry, a certificate and one line in trusted_domains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8c105c17d6 |
Move each plan's marketing copy from the controller into the console
tests / pest (push) Failing after 8m10s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Audience line and note were LandingController::COPY, a hardcoded array keyed on exactly the four plan keys the seed migration created — a plan made under any other key rendered with no copy at all. They move onto the family for the same reason the recommendation mark does: they are a stance about the product line, not a capability that changes with a price, so they must not be frozen per version. Backfilled in the migration from the controller's own strings, so the public page prints exactly what it printed a moment ago. A family with no copy yet renders without the audience line rather than an empty gap. Feature labels (managed_updates, daily_backups, ...) stay in the controller: they name catalogue features, not individual plans, so there is no per-plan or per-version place to hang an editable customer-facing label without re-introducing a shared feature table. That table existed once and was deliberately removed when the plan catalogue closed its pricing split-brain; bringing it back for wording alone is a bigger, separate decision. |
|
|
|
4ef5b9519c |
Let the owner mark one plan as recommended, from the console
Recommended lives on the plan family, not a version: it is a stance about
the product line ("which plan we point a visitor towards"), not a
capability that changes with a price, and freezing it per version would
make the mark silently vanish every time a new version publishes.
Setting a plan recommended clears whichever other plan carried the mark,
inside one transaction locking the whole table — two operators
recommending two different plans at once must not both win. The public
price sheet already had the highlighted-card treatment for this (it was
driven by a hardcoded "team" constant); it now reads the family instead.
|
|
|
|
b843c10ec3 |
Give the update screen the product's colours, and only one window
tests / pest (push) Failing after 8m16s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Two complaints, both correct, both mine. Dark blue. The panel carried a `prefers-color-scheme: dark` block — the only one in a product that has no dark mode. On a phone set to dark the update screen, and nothing else, turned dark blue. A dark mode for one page is not a dark mode; it is one page that does not match. It is gone. And even in daylight the panel was not this product's colours: it used Tailwind's slate ramp (#0f172a, #64748b, #e2e8f0), which is blue-tinted, against neutrals that are warm. Every value is now a copy of a token from portal-tokens.css, with the comment saying so, because this file cannot import them — it renders when the asset build may not exist. The small window then the big one. The settings card grew its own little "läuft gerade" block the instant a run started; up to three seconds later the full-page overlay covered it and said the same thing in a different size. The card no longer says it at all, and the overlay now opens on the button's own click — requestUpdate dispatches an event, but only once a run is actually pending, so a refused second press does not black out the console. Two more things found while looking: The panel had `min-height: 100%`, which resolves against the parent's height. On the 503 page the parent is <body>, whose height is auto — so it computed to zero, the panel was only as tall as its text, and the bottom two thirds of the screen stayed the browser's default white. Viewport units now, dvh so a phone's collapsing address bar leaves no strip either. And the mark was a plain orange tile again, the same empty-square that was just fixed on the placeholder. The real logo is inlined. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b85df4c141 |
Rebuild the status page as a status page
Asked for after looking at how real ones are built. The shape they all share is a convention, and a convention is what lets somebody find the answer without being taught the page: banner, components each with ninety days of uptime, then the written record underneath. This page had only the banner — the version that is of no use the morning after, because "Alle Dienste in Betrieb" is true and says nothing to somebody who was locked out the evening before. Three things are new. The ninety-day bar. It cannot be reconstructed after the fact: the monitoring table holds the last verdict per instance, not a history. So a sampler (clupilot:sample-status, every five minutes beside the monitoring sync) writes counts into one row per component per day, and the page divides them. A day with no row is drawn as a gap and says "nicht aufgezeichnet" — never green. Degraded samples count as reachable in the percentage but still colour the day: folding them into downtime reports a slow morning as an outage, ignoring them loses the only trace of it. The incident record. An operator reports a disruption in the console, posts updates as it develops, and the entry that says "behoben" is the same action that closes it — two separate steps is how a page ends up with a resolved note under an incident that still reports as ongoing. There is deliberately no way to edit an update: it is a statement made at a time, corrections are a further entry. An open incident may make a component look worse than the probes found it, never better. Scheduled maintenance, from the windows the console already keeps rather than a second list somebody has to remember to fill in. The measurement moved out of the controller into ServiceHealth, shared with the sampler, so the banner and the bars cannot disagree about what "healthy" means. The page now uses the shared design system and the site's header and footer. Its self-contained stylesheet was there to survive a broken asset build; it does not survive one — if the stylesheet cannot be served the application serving this page is already failing, and mid-deployment every route answers with the maintenance page. What the exemption bought was a third design nobody maintained. Found while building: durationMinutes had its operands the wrong way round, and diffInMinutes is signed, so the public page printed "Dauer -86 Minuten". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3b85e8f1d1 |
Grant a package or module from the customer's row in the console
tests / pest (push) Failing after 7m55s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
GrantPlan is a modal (R20), opened from a "Schenken" button on the customer's own row in Admin\Customers, gated by customers.grant_plan rather than merely hidden. It offers a whole package (plan, term, datacenter, an optional quota bonus baked into the frozen snapshot) or a module on an existing contract, either free or at a discount the operator sets against today's catalogue price. Existing grants show on the same modal — who gave what, when, and until when — with a warning once a dated grant is nearing its end; nothing lapses on its own, exactly as decided. The customers list also badges a granted plan in the Paket column. |
|
|
|
c71b2c362b |
Skip the invoice for a full gift, keep it out of revenue, hide its price
IssueInvoice now rejects free-grant orders before it will consume an invoice number for them — a full gift produces no invoice at all, while a discounted grant still charges something and is invoiced exactly like an ordinary sale. Keyed on the linked subscription/add-on's own provenance, not on the amount being zero, so a genuinely fully-discounted Stripe checkout is unaffected. Revenue excludes granted contracts from MRR/ARR/ARPU and the contracts count — a gift at 0 must not drag the average down — and shows their number as its own KPI instead of folding it in, since the owner measures himself on it. The portal (Cloud, Billing) shows a granted package or module without a price at all: not "free", not struck through, just the service, so a later conversion to paid does not read as a negotiation. |
|
|
|
602602864a |
Let a subscription or add-on be opened for free, with who and why on the row
A grant is an order without money, not a second code path: GrantSubscription and GrantAddon create the same Order/Subscription/SubscriptionAddon rows a paid purchase does, with stripe_subscription_id left null and price_cents set to whatever the customer actually pays. OpenSubscription and BookAddon gained an optional overrides parameter so the price and provenance (who granted it, when, a note, an optional end date, and the catalogue price for legibility) land on the same snapshot every real purchase goes through, not a duplicate. New customers.grant_plan capability, Owner-only like secrets.manage — giving away service is exactly the kind of blast radius that precedent reserves for the Owner. |
|
|
|
0671e8e119 |
Show one design while updating, not two swapping mid-run
Reported from live: pressing update showed a blue panel first, which then vanished and was replaced by a completely different "we are updating" screen. Both were mine. The console drew its own overlay in the app's light tokens; a moment later the containers went down and Laravel's 503 page took over with its own standalone styling. One event, two faces, swapping while an operator watches. There is now one panel — resources/views/partials/updating-panel.blade.php — rendered by both. It carries its own inline CSS and cpu- prefixed class names because the 503 page is served with the application down: no compiled stylesheet, no Tailwind, nothing but what is in that file. Dark mode comes from prefers-color-scheme for the same reason. The operator detail (step, running since, log tail) rides inside the panel behind a flag, since the same file is shown to customers on the 503 page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
33e5b15099 |
Restart the workers automatically after saving .env, instead of handing the operator back to the shell
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
90131f088a |
Add a raw .env editor to the Integrations page, with a net under it
Everything the console has no field for is still real — MAIL_*, DB_*, APP_KEY, whichever key nobody has built a form for yet — and without a way to touch it from here, the operator needs a shell and the console page misses its own point. EnvFileEditor is the net, not just the warning: 1. Validates before writing. A line that is neither blank, nor a comment, nor KEY=value is rejected outright, and an empty file (syntactically "valid" by that rule, but not survivable) is refused too. write() never touches the file before checking the new content. 2. Backs up before every write that actually happens — a timestamped copy beside .env, before the new content lands. Never pruned automatically; the page says so, next to where it says where they land. 3. Names the keys a mistake here can lock an operator out with — APP_KEY, DB_*, REDIS_*, SESSION_* — rather than a blanket warning nobody reads. 4. Gated by secrets.manage and the same confirmed password the vault entries use — this is the one place on the page that can reach every credential the vault otherwise keeps write-only. 5. Says plainly what saving does not do: queue, queue-provisioning, scheduler and reverb only read .env at their own startup, and names the restart command plus config:clear. 6. Marks which .env keys SecretVault currently overrides, so editing a line that a stored vault value already shadows does not look broken. Two issues surfaced by Codex review and fixed before this commit: the raw file content stayed in the Livewire component snapshot after the password confirmation window expired on its own (not only on an explicit re-lock), and the first save on an installation with no .env yet failed trying to back up a file that was never there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3fadaa14b0 |
Merge credentials and infrastructure into one Integrations page
Zugangsdaten and Infrastruktur split the same subject by storage mechanism — SecretVault vs App\Support\Settings — instead of by what each field configures, which is not a distinction an operator setting up Stripe or DNS should have to know or care about. One page now, grouped by integration: Zahlungen (Stripe), DNS (Hetzner), Monitoring, VPN/WireGuard, SSH-Identität. A secret and a plain setting sit side by side within a section; the difference stays visible — a vault entry is write-only and shows only an outline, a setting shows its value in full — as a property of the field, not a reason for a separate page. Both storage mechanisms are unchanged underneath: this is a presentation change, not a data migration. The vault keeps its own gate (secrets.manage + a recently confirmed password); plain settings keep theirs (hosts.manage alone). Reachable with either capability, since the two are no longer two pages a role happens to see one, both, or neither of — every section and every save action still checks its own capability server-side. admin.secrets and admin.infrastructure redirect here permanently rather than 404ing a bookmark. The nav entry that replaces both is visible to either capability — Navigation's capability field now accepts an array meaning "any of these", not only a single ability. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
77bd30ca56 |
Answer on every name for the website, and send them all to one
tests / pest (push) Failing after 7m23s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
SITE_HOST takes a comma-separated list now, the first name canonical. It serves the site; every other name in the list redirects there permanently, path and query intact. An apex domain and its www are two names for one thing, and answering on both without picking one splits search rankings and cookies between them. The redirect is a host-bound catch-all rather than middleware. Middleware that only some routes carry is the same half-measure this file already made once, and a catch-all registered without a host would swallow every path in the application — which is what the third test is there to stop. The test harness needed fixing too, and the fix is the finding. Router::dispatch on a standalone router does not rebind the container's request, so a route reading one — through the helper OR through an injected parameter, both of which resolve from the container — gets the test's request rather than the one being answered. The redirect silently lost its query string in the test while working in production. dispatchOn() binds it where the framework would. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
864126ec7f |
Add the SSH identity to the vault, and give deployment config a console page
tests / pest (push) Failing after 7m28s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The SSH private key CluPilot deploys to every host is now stored through SecretVault (ssh.private_key), preferred over CLUPILOT_SSH_PRIVATE_KEY(_PATH) at all three real consumers — SshTraefikWriter and HostStep::keyLogin. kuma.password/kuma.totp stay in .env: they authenticate a separate Python container at boot, and nothing in this app reads them, so the vault would have nothing to wire them to. A new /admin/infrastructure page, backed by App\Support\ProvisioningSettings, makes the non-secret deployment settings that used to force a shell — DNS zone, WireGuard hub endpoint and public key, Traefik's dynamic-config path, the SSH public key, and the monitoring bridge URL — visible and editable from the console, each wired to every real consumer. WG subnet/hub IP and Kuma's own connection details stay in .env: the compose file and a separate container read those before this application ever does. |
|
|
|
a457bea87e |
Read the DNS and monitoring tokens from the vault, not config
Two of the Zugangsdaten page's three entries were decorative: whatever an operator typed was encrypted and stored, but HttpHetznerDnsClient and HttpMonitoringClient still read config() directly, so the stored value never reached an outgoing request. Both now resolve through SecretVault::get() at the point of use, matching how the Stripe client already worked. |
|
|
|
7a5a71dc89 | Read VPN host state from the synced peer table, not a live hub call | |
|
|
eb743d34ed | Show WireGuard tunnel state per host in the hosts list | |
|
|
21b7aaca42 |
Update only to a released version, never to whatever landed on main
tests / pest (push) Failing after 7m21s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Asked for directly: a commit on main must change nothing on a server, and only a tag whose version is higher than the installed one may be installed. The agent asked two different questions before this. A pinned server compared tags; everything else counted commits on its branch — so a console following main offered an update for every push, verified or not, and "Jetzt aktualisieren" meant "take whatever is on the branch right now". Landing on main and being released are different events, and only the second is a decision somebody made. It is one question now, regardless of what the checkout is attached to: is there a tag whose version is higher than the deployed one? Compared with sort -V against the version in the deployment manifest — not the VERSION file, which an update moves before it migrates, so a run that failed in between would leave the checkout claiming a version it never finished installing. A run always targets that tag; the branch path is gone. A request that arrives with nothing to install is answered rather than obeyed, before anything announces a run. The console follows: the button is disabled with nothing released, says so in a line rather than leaving a grey button to be guessed at, names the version it would install rather than a count, and refuses the action server-side as well — a Livewire action is a public endpoint, and one place a rule may not live is only in the disabled attribute of a button. VERSION becomes 1.1.0, to be tagged as the first release this mechanism can see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b64a9f9238 |
Give the two-factor page a way back, and a shape
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
Clicking "Einrichten" generated a secret and showed the QR with no exit: leaving meant navigating away, which left an unconfirmed enrolment on the account that nothing on the console ever displayed. cancelSetup() clears it — it was never confirmed, so it never protected anything. The page was also one card with four states stacked into it. Now each state is its own panel, the enrolment step says which step it is and carries numbered instructions beside the code, and the recovery codes get their own card with a copy button instead of being a footnote under whatever came before them. |
|
|
|
d65ab71029 |
Let a datacenter code be corrected while nothing depends on it, and say which building
The code was immutable, which made a typo permanent. It cannot be freely mutable either, and not only because hosts.datacenter references it: each host's DNS name was minted from it and registered at the provider, the machine's own hostname was set from it as it was built, the counter handing out those numbers is keyed by it, and every past order stores it as plain text with no foreign key to follow. Renaming a code with hosts in it is not a rename — it is a datacenter called hel full of machines called fsn-01. So it is editable exactly while nothing references it, which is the case that matters: a typo noticed shortly after creating one. Once a host or an order depends on it the field is disabled and says what is holding it, with the numbers. The lock is recomputed from the database on every save, never read back from the property the browser returns. Alongside it, an optional free-text field for the specific datacenter — fsn-dc-15. A provider runs several within one location, on separate power and separate uplinks, and two entries both reading "Falkenstein (fsn)" are not a choice anybody can make. Nothing keys off it; the code remains the identifier. Also renames the country loop variables in the edit modal. They were $code and $name — the component's own properties — and shadowed them for the rest of the file. Harmless while nothing below the loop read them, which stopped being true the moment the code became an editable field above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
07634c8a1d |
Answer a check at once, start an update at once, and count down to neither
Checking for updates was a request written into the same mailbox as a real update, collected by the same agent on the same systemd timer — so asking what was new was answered on the next tick, and the console, having nothing else to show, counted down to it. An operator who asked a question was told an update would start in 3:44. A systemd path unit now wakes the agent the moment the request file is written, for a check and for a run alike. PathChanged, not PathExists: PathExists re-arms as soon as the service goes inactive, so a request the agent could not consume — it holds a lock for the length of an update — would restart it in a tight loop. The timer stays as the fallback, and a drop-in disables systemd's start rate limit, because two triggers on one oneshot service can otherwise wedge the unit and leave neither of them able to run. The countdown is gone from both the settings card and the maintenance overlay. It could not be made honest: its target was recomputed on every poll, and where the interval the agent reports is shorter than the timer actually installed on that host it had already gone by, so the fallback moved it forward again each time. It ran backwards four seconds and snapped back to a full minute, forever — reported exactly that way. A queued run says it is starting; a queued check says nothing beyond its badge, because a check starts nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f05547921d | Separate checking for updates from applying them, and show live progress | |
|
|
973fcb3f2d | Replace native confirm() dialogs with the app's own modal pattern | |
|
|
c2681f2801 |
Show a full-screen overlay on every console page while an update runs
tests / pest (push) Failing after 7m27s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
164145463c |
Send an operator with an expired session to sign in, not a 500
tests / pest (push) Failing after 7m15s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
VpnConfigAccess::reveal() dereferenced Auth::guard('operator')->user()
unguarded, which is null when a modal's session has ended since it was
opened (a Livewire action reaches its component through /livewire/update
on its own, without the page's original route middleware). The same
unguarded pattern was in TwoFactorSetup, InstanceAdminAccess and Settings.
Add App\Livewire\Concerns\ResolvesOperator with currentOperator(), the one
place that resolves the operator and redirects to admin.login when there
isn't one, and use it everywhere the pattern occurred.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
495d314aa5 |
Preflight every customer conflict before this migration mutates anything
tests / pest (push) Failing after 7m24s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
tests / pest (pull_request) Failing after 7m32s
Details
tests / assets (pull_request) Successful in 20s
Details
tests / release (pull_request) Has been skipped
Details
|
|
|
|
58835a1051 |
Check users directly for the reverse operator-identity collision, not just customers
The three sites that refuse to create or rename an operator onto a
customer's email (Admin\Settings::saveAccount(), ::inviteStaff(), and
clupilot:create-operator) all checked Customer::where('email', ...) as
a proxy for "does this address already have a portal login". A users
row with no matching customers row — an email changed on one side
only, or legacy/orphaned data — passed straight through: this dev
database already had one.
Extracted the three copies into Customer::emailTaken(), which checks
both tables directly, so the three sites cannot drift from each other
again.
|
|
|
|
278c4b9953 |
Match the logout exemption through AdminArea, not a bare route check
The setup-route exemption already went through AdminArea::routeIs(),
which matches both admin.<name> and admin.via*.<name>. The logout
exemption next to it used a bare $request->routeIs('admin.logout'),
which matches only the canonical name — on a recovery hostname the
logout route is admin.via0.logout, so the check failed there and an
unenrolled operator's logout POST bounced back to enrolment instead
of being let through, on exactly the host that exists because the
canonical one is not working.
|
|
|
|
27292237c3 |
Move operator two-factor enrolment off admin.settings onto its own page
RequireOperatorTwoFactor exempted the whole of admin.settings while compulsory two-factor was on, on the theory that it was "the page where two-factor is set up". That component also carries staff management, site visibility, network restrictions, account changes, and the two-factor policy switch itself, so an unenrolled operator got unrestricted access to all of that, not just enrolment. Enrolment (secret, QR, confirm, recovery codes, regenerate, disable) moves to its own route and component, admin.two-factor-setup / Admin\TwoFactorSetup, reachable by every operator regardless of capability. That is now the only page the middleware exempts besides logout, and its redirect points there instead. admin.settings goes back behind the policy like every other console page; only the policy switch itself stays there, since only someone who already satisfies it should be the one changing it. |
|
|
|
9c851b28b4 |
Satisfy Pint on the two new guard-boundary tests
Import the classes instead of referencing them fully-qualified, and use single quotes where no interpolation or escaping is needed. |
|
|
|
a5db914696 |
Key the password-confirmation marker by guard and identity
confirmPassword() already checked the right account per guard, but the session marker it stamped, auth.password_confirmed_at, was one flat key shared by every guard. In shared-host mode the portal and console guards keep their login in the same session, so confirming a customer's portal password left the marker in place for an operator-only gate too. One private method now computes the key for all three callers so they cannot drift apart again. |
|
|
|
913d19d39c |
Give operators somewhere to actually set up two-factor
console.require_2fa exempted admin.settings from the redirect it forces on an unconfirmed operator, but that page only ever configured the global policy — nobody could enrol behind it. A newly invited operator stayed permanently redirected to a page with no way to satisfy the requirement, the opposite of what the switch was for. Adds enable/confirm/regenerate/disable to Admin\Settings, reusing Fortify's own actions exactly as the portal's Settings already does — they only ever touch the model they're handed, no guard or auth() call inside them, so pointing ConfirmsPassword's guard at 'operator' is the only change needed. The new card is deliberately not gated behind the site.manage capability that hides the rest of this page's Owner-only sections: the compulsory switch applies to every operator, so enrolment has to be reachable by every operator too. Dropped the account card's stale hint pointing at a "separate security area" that never existed. |
|
|
|
4ae5281c91 |
Carry operators into the RBAC move via direct permissions too, not only roles
operatorUserIds() only discovered console users through model_has_roles. A
pre-existing account granted console.view straight through
model_has_permissions, holding no role at all, was invisible to the
worklist — meanwhile the permission's own guard is switched to `operator`
a few lines above, so that account would silently lose console access
while staying behind as a working portal login. Worklist is now the
deduplicated, sorted union of both tables; orderBy('model_id') kept on
each side since carryAcross() remaps in place and the ordering still
matters regardless of which table an id came from.
|
|
|
|
2b2bb439a5 |
Stop the console logout from destroying a shared portal session
Where the console and the portal share a host (shared/fallback mode — this VM's own mode), both guards keep their login key in the SAME session. session()->invalidate() is flush() + migrate(true) — it discards every attribute in the session, not only the operator's, so signing out of the console silently signed a customer out of the portal too if the same browser carried both. Replaced with session()->regenerate(): a new session id, so there is no fixation risk, while attributes belonging to any other guard survive untouched. |
|
|
|
5e22e16291 |
Send a guest bounced off the console to the console's own sign-in
ApplicationBuilder::withMiddleware() defaults redirectGuestsTo() to
fn () => route('login') before bootstrap/app.php's own callback ever
runs — always the portal's Fortify page, regardless of which side of
the site turned the guest away. In non-exclusive (shared, this VM's
own mode) that sends a guest bounced off /admin to a sign-in form whose
table holds no operators: no 404 any more, but a sign-in that can never
succeed. Exclusive mode happened to self-correct only because
RestrictAdminHost 404s the wrong host before this ever ran.
Fixed for both modes at once rather than depending on which one a given
deployment runs: ask AdminArea, the one place routes/web.php's own
registration and RestrictAdminHost already agree is the source of truth
for "is this the console".
|
|
|
|
47812cfca9 |
Authorize the console's live feed on the operator guard, not the default
POST /broadcasting/auth carries only the 'web' middleware group
(Laravel's own withBroadcasting() registers it that way), so
PusherBroadcaster::auth() resolved retrieveUser() on the DEFAULT guard
before the admin.runs channel's own Auth::guard('operator') check ever
ran — found nobody, since an operator is never signed in on 'web', and
threw straight from there. The guard was correct but unreachable.
Fixed by naming both guards on the channel registration itself.
Also adds broadcasting/auth to RestrictAdminHost::SHARED: in exclusive
mode it was neither admin.* by name nor on the shared list, so it 404'd
on the console host too — degrading silently to wire:poll.4s rather
than breaking outright, which is why nothing screamed.
|
|
|
|
54fb6deb03 |
Make the RBAC move retry-safe and order-safe, and round-trip permissions
Three faults in the same migration:
- up()'s removal loop was driven by operatorUserIds() (model_has_roles
WHERE model_type=User) via the $carried array — but carryAcross()
repoints model_has_roles to Operator for every operator before a
single users row is deleted. A run that stops partway (the
customer-conflict abort, or a dropped connection on real MariaDB,
which this file deliberately runs without a transaction) leaves that
worklist empty on the next attempt, so `migrate` records the
migration as applied while every leftover users row still grants a
live portal login on the operator's own password. Now driven by
`operators` joined to `users` on email: that finds every
carried-but-not-yet-deleted row regardless of which run carried it.
- operatorUserIds() had no ORDER BY. carryAcross() remaps four owned
columns in place, keyed on the old users.id, while operators.id hands
out new values in the same order the list is walked — unordered, a
later operator's freshly assigned id can equal an earlier operator's
still-unprocessed users.id, and the second remap catches a row the
first one already rewrote. One operator ends up silently owning
another operator's VPN peer. Added ->orderBy('model_id').
- down() restored model_has_roles but not model_has_permissions, so an
operator holding a direct (not role-granted) permission lost it on
rollback. Mirrored the same move carryAcross() does forward.
Also removes seed_roles_and_permissions' now-stale
User::OPERATOR_ROLES/hasAnyRole()/assignRole() bootstrap loop — all
three gone from User per this branch's own work — which only a deep
rollback (down() of the move migration recreates is_admin users) would
ever have reached, but would fatal when it did.
|
|
|
|
e519653f66 | Let the owner make two-factor compulsory, without locking themselves out | |
|
|
73dec7f685 |
Prove ConfirmsPassword resolves its guard, not the ambient default
actingAs()/Livewire::actingAs() call Auth::shouldUse(), which made every existing test for this trait pass identically whether confirmPassword() asked for the right guard explicitly or just read whichever guard happened to be default — reverting the explicit guard() call to a bare auth()->user() passed the full suite. This test signs the operator in without actingAs and deliberately decouples the ambient default guard from their session before calling confirmPassword(), so only the explicit resolution can succeed. |
|
|
|
8f8a0ad4f7 |
Stop the console serving the portal's sign-in page
Deleted tests/Feature/Auth/ConsoleLoginLandsInTheConsoleTest.php — it tested the three deleted response classes. Its intent (an operator's sign-in landing in the console, not the portal) is covered by OperatorLoginTest's "signs an operator in on the operator guard" and OperatorTwoFactorChallengeTest's "signs the operator in once the code actually checks out", both confirmed present before deletion; the route-separation half is covered by the new ConsoleHostSeparationTest cases added in this commit. |
|
|
|
513cadc5ce | Confirm a console password against the console's own record | |
|
|
f54d00b3e7 | Move the value remap and constraint swap before the delete, and fix the seeder | |
|
|
87c49de6e5 | Move the console's identity out of the customer table | |
|
|
8d1fa52a5a | Give the people who run CluPilot a table of their own | |
|
|
74ac406fc0 |
Make isUsable() apply the same 32-byte rule encrypter() does
isUsable() checked only "is SECRETS_KEY nonempty", so a set-but-malformed key (wrong length, garbage base64) read back as usable even though encrypter() rejects it two lines below. EditMailbox::save() and MailboxTester::run() both gate on isUsable() specifically to avoid an uncaught RuntimeException reaching the operator; a lying isUsable() meant that guard did not fire in exactly the configuration it exists for. Both methods now read resolveKey() — the base64: prefix, the raw-base64 path, the 32-byte check — so they cannot disagree about a value either one is given. SecretVault::isUsable() and the secrets console page's "no key" banner both delegate down to this and are covered here too, not assumed to inherit the fix correctly. |
|
|
|
38c5518f45 | Drop the stray path comment after the opening PHP tag | |
|
|
93d409e18f | Pull the secrets encrypter out where two callers can share it | |
|
|
9b8d5dfd1e |
Editing in modals, an update button that is not gated on a stale reading, and a support page that is real
tests / pest (push) Successful in 7m18s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 3s
Details
Three things reported together. ── Editing belongs in a modal (R20) The seats table grew its input fields into the row. It worked and it looked broken: the row grew, the columns beside it jumped, and a table half in edit mode reads as a rendering fault rather than as a form. The project already had the answer — EditDatacenter, whose own header comment says it avoids exactly that row-height jump — and the seats table simply did not use it. EditSeat is now a ModalComponent. A modal is reachable WITHOUT the page's route middleware, so it resolves the customer itself and re-reads the record rather than trusting a hydrated property: a forged addressEditable would otherwise open the address of an accepted seat, and the address is the person — editing it hands one employee's access to another with nobody told. Only an invitation still in flight can have its address corrected, which is the case that actually comes up. The actions column is now always drawn. It used to disappear when the only seat was the owner, on the reasoning that there was nothing to act on. But every seat can be renamed, and a column that vanishes does not read as "not applicable here" — it reads as "this product cannot do that", which is how it was reported, three times. The owner's row says "Geschützt" rather than leaving an empty cell. Also caught here: the edit fields carried class-wide #[Validate] attributes, so an empty edit form made the INVITE button fail on a field the invite form does not have. Rules for an action belong to the action. ── The update button "I cannot run an update, it says everything is current." `behind` is a READING taken by the agent every five minutes, not the state of the world — push a commit and the console insists it is up to date and refuses to act. The agent does its own fetch before deciding, so asking against a stale reading costs one fetch and finds nothing; being locked out costs the deployment. The button is now offered whenever an agent is alive and no run is in flight, labelled for what it does rather than for what the last reading said. ── The update never announced that it had finished Because the thing being watched restarts the thing doing the watching. Mid-run every wire:poll request fails, and what answers afterwards is a new build being questioned by the old page's JavaScript — so the card sat on "läuft" until somebody reloaded by hand. A small Alpine watcher now asks a plain JSON endpoint (no Livewire, no component state, no assets), treats a failed request as the restart rather than as a fault worth giving up over, and reloads once the build it is looking at is no longer the build it started with. ── Support The page was a decorated placeholder: a button that raised a toast saying the form was "only hinted at in the prototype", three invented ticket titles living in the translation file, and no way to see what became of anything. It looked thin because nothing on it was real. It now leads with the customer's own requests — what somebody arriving here wants to know is what they asked and whether anyone answered — with contact details moved to the side where they belong. The form attaches the plan, the instance and who is asking automatically: making a customer describe their own server back to the people who built it is the part of support people hate. FAQ answers end where the thing can actually be done, and "is it me or is it you" is answered by a link to the status page. 637 tests. R20 recorded in CLAUDE.md and enforced by EditInModalTest: no page view may grow an input field inside a <td>. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
bd779f00e5 |
Show times on the operator's clock, keep storing them in UTC
tests / pest (push) Successful in 7m55s
Details
tests / assets (push) Successful in 30s
Details
tests / release (push) Successful in 6s
Details
The console announced an update for "spätestens um 15:21" while the clock on
the wall said 17:21. Not a formatting slip in one string: fourteen views and
four components printed database values straight out, so every absolute time in
the product was two hours out all summer and one hour out all winter.
Two of those views looked as though they had handled it. They called
->timezone(config('app.timezone'))
which reads like "convert to local time" and, app.timezone being the STORAGE
zone that must stay UTC, converts to UTC — a no-op wearing the costume of a
fix, which is worse than no call at all because it stops the next person
looking. That exact string is now banned by test.
Worse than the labels were two FORMS. Maintenance windows and plan versions
filled their datetime-local fields from UTC and parsed what came back as UTC. A
datetime-local field carries no offset — it is the digits a person reads off
their own clock — so an operator typing 21:00 scheduled a window for 23:00
their time, and nothing anywhere said so. App\Support\LocalTime now holds
toField() and fromField() side by side, because getting one end right is not
half a fix, it is a fresh bug.
The mechanism is one Carbon macro, ->local(). It copies before converting:
Illuminate\Support\Carbon is mutable, so without that, rendering a timestamp
would rewrite the model attribute as a side effect and anything comparing or
saving it afterwards would be an hour or two out. Both Carbon classes get the
same body — Carbon keeps macros in one global table, so the second registration
replaces the first for every Carbon class, and two different bodies meant the
immutable version, safe for itself, silently became the mutable one's
implementation too. My own test caught that.
The existing tests had not caught any of this because they built their expected
values with the same wrong call the views used — a test that recomputes the
implementation asserts nothing. They now assert the wall clock, and the queued
update additionally asserts that the UTC time is NOT shown.
Recorded as R19 in CLAUDE.md and enforced by tests/Feature/DisplayTimezoneTest:
no Blade or Livewire component may format an absolute time without ->local(),
the UTC no-op is banned, storage stays UTC, both sides of the DST boundary are
checked, ->local() must not mutate, and a form field must round-trip to the
same instant.
APP_DISPLAY_TIMEZONE, default Europe/Vienna. 615 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
6376f5e9fa |
One design system for every surface
The console was a second product. admin-tokens.css held a complete dark set — its own surfaces, its own orange, its own status triad — so every shared component had to work in two worlds, and the two halves of the application drifted apart in ways nobody could see from inside either one. That file now carries density and nothing else: smaller type, tighter rows, tighter corners. Colour comes from one place. The tokens themselves are rebuilt around what the redesign settled on: - Radii scaled to object size (9/11/16/22) instead of one value everywhere, which is what made the interface read as a design-system specification rather than as a product. - Warm, broad, low shadows. A neutral grey drop shadow belongs to a different design language and reads as cold on this ground. - --accent-press, which several rules already referenced and nothing defined. An undefined var() does not make a browser skip the declaration; it computes to , and for background-color that is transparent — the accent button lost its fill on hover and left white text on white. - No serif. A serif headline is what made every page read as a document, which is the impression the whole redesign exists to remove. --font-serif now points at the sans so nothing breaks while call sites are cleaned up. The two shells are one shell with two configurations, and both finally have a mobile navigation: the sidebar used to simply disappear below 900px, leaving no way to move around the application at all on a phone. The customer dashboard is bound to real records — instance, seats, traffic, backups, contract — instead of the fixtures it shipped with. Where something is not measured, storage consumption, it says what was contractually agreed rather than inventing a figure. This is the sheet a customer forwards to their auditor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3699c7cdb9 |
Ask the tunnel gateway where it actually listens
tests / pest (push) Successful in 7m15s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 5s
Details
The console has been unreachable over the VPN, and the cause was the readiness probe rather than the tunnel. The gateway binds to the WireGuard hub address alone; the probe asked 127.0.0.1, where nothing has ever listened. It could only fail, so VPN_READY stayed false, and on that basis the application withheld the `DNS = 10.66.0.1` line from every client config it issued. A phone then built the tunnel, asked its normal resolver for the console hostname, got the public address and had its connection closed — indistinguishable from "this site does not exist", which is exactly what it looked like. Probing over TLS on the bare address would not have worked either: the site matches on the console's hostname, so a request without SNI is offered no certificate. The gateway now answers a plain-HTTP health port on the hub address, which removes TLS, SNI and name resolution from the question and answers only what is being asked — is this gateway listening, in this network namespace, right now. Caddy refuses to start when the certificate is unreadable, so a health port that answers still proves the whole file loaded. Also here, all found while looking: - Icons pushed their label onto a second line and rendered a size larger than asked for. Tailwind's preflight makes an svg display:block, and `.size-4` and `.size-5` have equal specificity, so stylesheet order decided — and it emits size-4 first. Every icon written as 16px was silently 20px. Recorded as R18. - Four error pages printed `errors.404.hint` in place of a sentence: the lang files give those codes a null hint and Laravel returns the key for a null line. - The Developer role had no label in either language, so the dropdown showed `admin_settings.role_developer`. - The secrets area held one key, which is not worth a password gate. It now carries the credentials that actually stop the business when they expire — DNS, monitoring, SMTP — and the test button appears only where a checker exists, instead of reporting on Stripe whatever was being looked at. - The update button said nothing about when a queued run would start or where a running one had got to; both are now shown, and a failure names its step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2bb1d7cc3b |
Make the private hostnames look like nothing is there, and close the way past the proxy
tests / pest (push) Successful in 6m56s
Details
tests / assets (push) Successful in 27s
Details
tests / release (push) Successful in 4s
Details
An empty 404 is itself information: it says something terminates TLS here and chose not to answer. The console and the websocket endpoint now close the connection instead, which is what a hostname that serves nothing looks like. The websocket endpoint answers a genuine upgrade — verified live, 101 — and nothing else; a browser opening it gets a closed connection. Reviewing that turned up two holes that mattered more than the thing being reviewed. The compose defaults published the application and Reverb on every interface. Docker publishes ports ahead of UFW, so those backends were reachable from the internet with the firewall closed — and reaching a backend directly skips the proxy, and with it every hostname and address rule keeping the console private. The defaults are loopback now, and an update rebinds an existing installation that is behind a proxy actually running and holding 443. A development box without one is left alone, because taking its port away would look like the machine had broken. And the console's own "open" switch was emitting 0.0.0.0/0 into the proxy's allowlist. One click would have put the console on the public internet, which is the exact state the owner's rule exists to prevent. Switching it off relaxes the application's check; the proxy keeps its list, always. The reference proxy config carries the arrangement: QUIC early data off, since an address-based decision taken on 0-RTT can answer 425 and some browsers do not retry — an intermittent lockout from the console is the worst kind — and explicit handling for plain HTTP, whose automatic redirect otherwise announces both hidden names to anyone who asks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
457eeeeaef |
Serve the console inside the tunnel, without publishing the internal network
tests / pest (push) Successful in 10m12s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Successful in 5s
Details
The owner's phone shows a blank page on the VPN. That page is the reverse proxy refusing the request, and it refuses because the phone is not on the tunnel: the client config routes only the management subnet, so a request to the console's public hostname leaves over the mobile network and arrives from a carrier address. It is also why the peer reads "last contact: never" — WireGuard only handshakes when it has traffic to send, and nothing is ever routed in. The obvious fixes are both wrong. Adding the server's public address to AllowedIPs routes the WireGuard endpoint into the tunnel it is trying to establish. A public DNS record pointing at 10.66.0.1 publishes the internal subnet to anyone enumerating the domain — the owner raised that himself, and he was right. So the console is served INSIDE the tunnel instead. A small resolver and a small gateway share the hub container's network namespace, which is where wg0 lives, and answer on 10.66.0.1 directly. A client therefore needs no route beyond the subnet it already has, the host's docker bridge is never exposed, and the request reaches the application with its real 10.66.0.x source — which is what the console's own allowlist checks. The gateway reuses the certificate the public Caddy already renews: a certificate is bound to the name, not to the address serving it, so nothing new is issued and nothing internal reaches a public zone. Most of this commit is the arithmetic of not lying about it. Review found fourteen ways the arrangement could report itself working while it was not: enabling the compose profile after the services were started rather than before; starting a gateway with empty certificate paths, which can only crash; treating "container created" as "container running"; carrying a stale readiness marker through a failed restart; reusing the previous hostname's certificate after a rename; leaving the profile enabled when the hostname was cleared, in a script that exits early precisely when nothing else would fix it; reading the renewal timestamp as a user who cannot traverse Caddy's storage; and a `find | head` that aborts the whole installer under pipefail the moment two issuers hold a certificate for the same name. One of them was mine and worth naming: to let the container read the certificate, I had made the TLS private key world-readable. On a multi-user host that hands the console's identity to every local account. The client is told about the resolver only when both services are confirmed running, because a config naming a resolver that does not exist takes the device's entire name resolution with it for as long as the tunnel is up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
99715989aa |
Dress the placeholder page, and record why the obvious VPN fix cannot work
tests / pest (push) Successful in 8m55s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 4s
Details
The page shown while the site is hidden was still in the old style — a coloured square and system fonts — on the two hostnames a visitor is most likely to try. It now uses the site's own plate, typeface and registration marks, still entirely self-contained because it is shown precisely when the asset build may not exist. Both it and the error pages lighten the accent in dark mode: the tone is chosen for contrast against paper, and on the ink plate the small text is the first thing to stop being readable. The VPN investigation ended somewhere useful, and not where it started. The blank page on the phone is the reverse proxy refusing the request — `respond 404` sends no body. It refuses because the phone is not on the tunnel: the client config lists only the management subnet in AllowedIPs, so a request to the console's public hostname goes out over the mobile network and arrives from a carrier address. It also explains "last contact: never", since WireGuard only performs a handshake when it has traffic to send, and nothing is ever routed into the tunnel. The obvious fix — add the server's public address to AllowedIPs — is written down here as a comment and a test rather than as code, because it cannot work. The WireGuard endpoint is that same address, so routing it into the tunnel routes the handshake packets into the tunnel they are trying to establish. The result is a loop and a connection that never comes up: the same blank page, now with no way in at all. Reaching the console over the VPN needs an address INSIDE the subnet — the proxy answering on the hub address, and a name that resolves to it. That is a deployment change, not something a config line can express. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a58faf3f85 |
Manage the Stripe key from the console, behind a password and a test
tests / pest (push) Successful in 8m56s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 4s
Details
Changing a key meant a shell, a file edit and a cache rebuild — and the person who owns the Stripe account is not necessarily the person who owns the server. Two gates, not one. The capability decides who may open the page; every operator has console.view, and that must not mean "can read the payment key". The password decides whether this SESSION may see or change anything, because the realistic threat is not a stranger but an unlocked machine, and a session is exactly what that hands over. Both are re-checked server-side on every action — a Livewire action is reachable by anyone who can post to /livewire/update. The value is stored encrypted under a key of its own, SECRETS_KEY, and the vault refuses to work without it rather than falling back to APP_KEY: rotating APP_KEY is ordinary maintenance and would otherwise destroy every stored credential, discovered when Stripe stops answering. It is read where it is used, not overlaid onto config at boot — an overlay adds a query to every request including the public site, and leaves queue workers holding whatever was true when they started. It is never shown again, only outlined, and never enters a Livewire property that would carry it to the browser and back in the snapshot. A registry, not an env editor: a form that can set any environment variable is a privilege-escalation primitive, and one bad value bricks the installation with no way back through that same form. The test button is the part that matters. It reports which Stripe account the key belongs to, whether it is LIVE or test — the most expensive mistake here is pasting one where the other belongs, and both look identical in a form — and which webhook endpoints exist with the events each subscribes to. A key can be perfectly valid while the endpoint listens for the wrong five events, and nothing fails until a payment goes unrecorded. The webhook signing secret deliberately stays in .env. It is read on every incoming payment event; in the database, a database problem becomes silently failing signature checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
935c9ae6ac |
Let people change their own password, and a location be switched off while editing it
tests / pest (push) Successful in 7m29s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 4s
Details
Three gaps, all of the "half-built" kind. Nobody could change a password. Fortify's updatePasswords feature was switched off, so an account created with a generated password kept it until someone opened a shell on the server — including the owner's own. Both settings pages have the form now, sharing one concern, because two copies of a password rule is how one of them ends up weaker. It goes through Fortify's own action rather than hashing here, and the current password is required: an unlocked machine should not be two keystrokes away from locking its owner out. The datacenter edit form had no active switch. The column and the scope existed, and the list page has a toggle — but the form you open to change the thing did not offer the one lifecycle action a location actually needs. Switching one off now says what it does NOT do: existing hosts keep running. Once, on the actual transition, and as the only message — the generic "saved" toast would otherwise replace it, and an already-inactive location would have announced its own deactivation every time its name was edited. The staff table's actions cell is empty for your own row, because you cannot revoke yourself. With a one-person team the column was therefore always blank and read as broken rather than as a rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
bc8bbc56a5 |
Make the console's access list reach the proxy, price in euros, and answer errors
tests / pest (push) Successful in 7m4s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 4s
Details
The console was unreachable, and not for the reason it looked like. Two causes, both mine. A failed `artisan optimize` — the one that hit duplicate route names — left a broken route cache behind, so every request to the console host answered 500. Rebuilt. Underneath that: the reverse proxy has its own console allowlist, hard-coded, and it runs BEFORE the application. Everything added on the console's own access page was therefore ineffective, and when the owner's address changed they were turned away by the proxy before reaching the page that would have fixed it. The proxy now imports a fragment generated from the same list the console manages, regenerated by the host agent and reloaded when it changes. Getting that safe took most of the review. It refuses to rewrite an ambiguous Caddyfile rather than replacing some other site's matcher; it never falls back to a loopback-only list when the application cannot be reached, because that list validates cleanly and locks out every remote operator; it retries a reload that failed instead of assuming it worked; and an installation that upgrades without rerunning the installer is told, because otherwise the whole mechanism is invisibly absent. Prices are entered in euros. The form asked for cents, so €799 was typed as 79900 and one slipped digit was a factor of ten on an invoice. Conversion happens in one place, on the string rather than through a float — (float) '79.90' × 100 is 7989.999… and casting truncates to 7989, one cent short on exactly the prices people charge — and it refuses an amount the column cannot hold instead of failing at the database. Every error code has a page now, in the site's own language and typeface, self-contained so it still renders when the asset manifest is the thing that broke. There was only a bare white 404. The VPN list stops being a seven-column table nobody could fit: names broke across two lines and so did the headings. These are attributes of one access, not quantities compared down a column, so each access is a row — identity on one line, measurements in mono on the next. The status page says what it measures rather than what it promises. "New orders are delivered without failures" read as a marketing claim on a page whose only job is to be believed, and said nothing about the last 24 hours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
233fc73430 |
Let the console keep its recovery hostnames without breaking route caching
tests / pest (push) Successful in 7m22s
Details
tests / assets (push) Successful in 24s
Details
tests / release (push) Successful in 4s
Details
Registering the console once per accepted hostname reused one set of route names, and Laravel refuses to serialise two routes under the same name — so `artisan optimize` failed on the live server the moment the separation was switched on, mid-deploy. Only the canonical registration carries the `admin.` names now; the alternates answer under `admin.viaN.*`. They exist to be MATCHED — they are the addresses someone locked out reaches for — never to have URLs generated for them, so route() keeps producing the canonical hostname. That renaming then broke every exact route-name check, which is how the console navigation decides what is active: reached through a recovery address, nothing in the sidebar was marked, exactly when something is already going wrong. The check goes through AdminArea now and matches either form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
de6821b53e |
Move the console off /admin, give the status page its own address, and measure monitoring
tests / pest (push) Successful in 7m43s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 5s
Details
Three things the owner asked for, and one the review found underneath them. The console leaves /admin. It has its own route file now, registered before routes/web.php because once it has a hostname to itself it sits at the ROOT of that host — where `/` and `/settings` also exist for the portal, and the first matching route wins. Where it mounts is one decision in one place: `/` on the console hostname where it has one to itself, `/admin` on any host otherwise. Names stay admin.* in both modes, so nothing that builds a URL has to know. Exclusivity is its own switch, not a consequence of having a hostname. The first attempt made "this host is the console" follow from ADMIN_HOSTS, and a development machine lists its own IP there so the console works without DNS — which took the public site and the portal off that machine entirely. The switch also registers the console on every listed hostname, canonical last, so the alternates that exist as recovery paths keep working. The status page moves out of /legal, where it sat beside the imprint and the terms. Nothing about the current health of the platform is a legal document. It is a real page now: portal, instances, provisioning and backups, each derived from records, aggregate only, and a component with no signal reports "unknown" rather than "operational". That last rule is what exposed the real bug. monitoring_targets.status was written once — 'up', at provisioning — and nothing ever updated it. Both the console's notices and the new public page read it, so an outage would have been published as healthy indefinitely. There is a sync job now, on a five-minute schedule, and a checked_at column so a verdict can go stale instead of standing forever. The monitoring contract had to grow a third state for that to be honest. isHealthy() answers true when no monitoring is configured and false when the monitor is unreachable; recording that boolean would have published either a fleet-wide all-clear nobody measured or a fleet-wide outage that was really one broken monitor. health() returns null for both, the recorder leaves the old verdict to go stale, and isHealthy() keeps its forgiving semantics for the one caller that wants them — the provisioning acceptance check, which must not fail a delivery because monitoring is not set up. Backups are counted from the instances that need protecting rather than from the backup rows that exist, so an instance with no schedule at all cannot be missing from the arithmetic that declares the estate protected. Also: the update panel polls itself, offers the button only when there is something to install, and opens the log while a run is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
276f926d57 |
Add the update button, and give it a host-side agent that can actually update
tests / pest (push) Successful in 7m41s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 4s
Details
The panel had no way to update the installation, and could not have one on its own: it runs as www-data inside a container, and the update restarts that container. A button that shelled out would kill its own web server mid-response, and would need the application to hold host-level credentials — a far worse thing to own than an out-of-date checkout. So the button is a mailbox. The panel writes a request into the checkout; a systemd timer on the host, running as the service account, consumes it, runs deploy/update.sh and writes back what happened. The same timer answers the question the panel cannot answer alone — is there anything to update — which needs a git fetch, and therefore credentials the application does not have. The parts that were wrong before review, all of which would have shipped as a button that looks fine and does nothing: - The agent was only installed by install.sh, which the installed base never runs again. Every existing server would have shown the button permanently disabled. It has its own root entry point now, install.sh delegates to it, and update.sh says so when the unit is missing. - On a release-pinned server the checkout is detached, so it followed "origin/HEAD" and then ran an update that deliberately stays on the pinned tag: updates advertised, nothing applied. It now compares TAGS in release mode and passes the target release through. - On a branch other than main it advertised that branch's commits and then deployed main's, because update.sh defaults to main. - A request written while the agent was down was executed whenever the agent next started, days later. - A single status file meant the routine five-minute check overwrote a failed update with "idle" before anyone saw it. - An agent that had been stopped left the button enabled forever, because a status file written once counted as an agent. - The agent's error messages were German strings rendered into an English interface; it reports codes now and the panel translates them. Also: the add-address button in the console-access panel was as tall as the field plus its hint, because the hint was a sibling inside the flex row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
30a80b6c15 |
Report the estate from the database, and print the traffic that is sold
Three console pages were fiction. The front page claimed 42 customers, 39 instances, four hosts named pve-fsn-1..3 and €7,842 a month, over a twelve-month growth curve; the instance list held seven invented machines; the revenue page reported churn and a trend for a business with no recorded history. All of it was hard-coded. It read like a running company and measured nothing. They now read the database. Two figures are gone rather than approximated — the revenue trend, which needs a monthly history nobody records, and churn, which needs a base the data cannot supply. ARR stays, labelled as the projection it is. The green "all systems normal" badge is computed from the notice list instead of asserted, and the notices themselves come from failed runs, hosts reporting errors or gone quiet, and monitoring that is down. Host load is the one number that had to agree with something else: placement counts the VM disk allocation, ignores a failed instance that never got a VM, and subtracts the host's reserve. A dashboard doing its own arithmetic would show a host as comfortable while orders were already being refused on it, so it uses the host's own accounting — with the filter moved into a scope both share, and the sum preloaded so listing hosts stays one query. The instance list drops the Nextcloud version column: that version is not recorded anywhere, and a column filled with a plausible number is worse than no column. Statuses the lifecycle writes but nobody had translated no longer render as "admin.status.failed". The price sheet also gains the included traffic, which the catalogue has always carried and the page simply never printed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0066b1c6a0 |
feat(security): a way back into the console that does not need the console
tests / pest (push) Successful in 7m28s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 4s
Details
The allowlist is managed in the console, which is fine until the address you manage it from changes — and then the page that would fix the problem is the page the problem blocks. Every gate needs a door that does not depend on itself, and on a server that door is a shell: php artisan clupilot:console-access show php artisan clupilot:console-access allow 203.0.113.7 php artisan clupilot:console-access open The address check moved to RestrictConsoleNetwork::isNetwork() so the command and the console apply the same rule. Codex caught the version that did not: a typo like 203.0.113.9/99 was stored, reported as success, and matched nothing — leaving whoever was recovering still locked out, now believing they were not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9ccd4f59d8 |
feat(security): the console decides who may reach it, and the owner keeps the list
tests / pest (push) Successful in 6m53s
Details
tests / assets (push) Successful in 18s
Details
tests / release (push) Successful in 3s
Details
RestrictAdminHost answers under which NAME the console responds — and the caller picks the Host header, so it can never answer who is asking. This does: a network gate on the client address, which behind a trusted proxy is not something the client chooses. The management VPN is always in the list and cannot be removed — it is the one way in that survives a bad entry. Beyond that the owner keeps their own addresses in the console, because being away from the VPN should not mean being locked out, and the person who needs to change that list is the person sitting in front of it. Two refusals rather than warnings, since by the time a warning renders the request that would show it has already been rejected: switching the restriction ON is refused unless the address doing the switching is already covered, and removing the entry you are sitting behind is refused. Entries are validated as an address or CIDR — a typo that matches nothing is how someone locks themselves out while believing they have not. 404, never 403. Registered as persistent Livewire middleware, and verified in a browser that it holds there: with the settings page open, narrowing the list turned the next action from 200 into 404. Codex read the path guard as skipping /livewire/update; Livewire in fact replays middleware against a duplicate of the request carrying the original component's path, so the guard matches and the client address is preserved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5c2e481985 |
fix(security): console and account checks now survive a Livewire action
tests / pest (push) Successful in 7m8s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 4s
Details
Livewire re-applies only the middleware on its persistent list when an action posts to /livewire/update. Its defaults cover auth; ours were not on the list — only RestrictAdminHost had been added. So EnsureAdmin and EnsureCustomerActive guarded the PAGE and not the actions, and the page is not where the actions run: a signed-in non-operator who could reach the admin host could drive console components, and a suspended customer could keep driving the portal. Found by Codex while reviewing the domain-separation design, and confirmed against Livewire's own default list in vendor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f6ddf45dc0 |
feat(admin): a console for creating, pricing and scheduling plans
The owner can now do from the console what only a config edit could do before: create a plan line, draft a version, price it, publish it into a window, and pull a plan out of the shop. Two pages and one modal, because the catalogue has exactly two levels. The plan list is a name, a rank and a kill switch. Everything that can be got wrong — capabilities, prices, windows — lives on the versions, where the page is built around the one rule that matters: a draft is freely editable, a published version is not touchable at all. So drafting and publishing are separate acts, and publishing says plainly that it is final. The console refuses everything the catalogue refuses, on the form rather than as a stack trace: an overlapping window, a window that ends before it starts, a version with no price for a term we sell on, and — new here — a version with no VM template, which would be bought and then fail provisioning every time. Numbers are bounded on both sides, because a mistyped price otherwise overflows the column and answers with a 500 instead of saying which field was wrong. Performance class and features are picked from the keys we have labels for; free text would be frozen at publication and shown to customers as a raw translation key forever. Concurrency, since two admins share one catalogue: draft numbers are allocated under a lock on the family, publication is an atomic conditional claim, and discarding a draft is one statement conditional on it still being a draft — otherwise a draft published in the meantime could be deleted out from under the customers now contracted to it. `plans.manage` (Owner and Admin) guards the pages themselves, not only the buttons, and the modal authorises itself — prices, drafts and unreleased plans are not something to leave readable to anyone who types the URL. Also fixes the shared checkbox, which ticked in the browser's own blue on every page that used one: a native checkbox ignores text colour and needs accent-color. Verified in the browser: withdrawing a plan in the console removes it from the customer's billing page immediately, and restoring it brings it back. The public landing page carries no catalogue-driven plan list, so there is nothing there to hide. 421 tests green. Codex review clean after six rounds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6387c747d0 |
feat(billing): the plan catalogue becomes three tables, and config stops selling
Plans lived in config/provisioning.php, where the owner cannot reach them and
where a plan has no history. They now live in plan_families / plan_versions /
plan_prices — a name that never moves, what the plan WAS at a point in time,
and a price per version and term with its own Stripe Price id.
- Availability is computed on every read (available_from <= now < until,
half-open, UTC) plus a per-family sales kill switch. Nothing schedules a plan
into or out of sale: a job that fails to run is a plan that silently
misbehaves.
- Overlaps crash rather than resolve. currentVersion() uses sole(), and
scheduling takes a lock on the family and rejects an overlapping window —
two versions on sale at once would decide a customer's price by row order.
- A version is frozen from publication, not from first sale, and so is its
price: a checkout is not instant, and an amount edited between the session
opening and the webhook landing would contract someone at a price they were
never quoted. Repricing publishes a new version, as Stripe requires anyway.
- Neither a published version, its price, nor a family with customers can be
deleted, and a family key cannot be renamed. All of those would null the
provenance off existing contracts.
- Subscriptions and orders record plan_version_id. A historical reference
resolved by plan NAME hands back today's terms, which is the split-brain one
level up. A checkout that carried its version is honoured even after the
window closes — they paid for what they were shown.
Switched atomically and failing closed: config('provisioning.plans') and
plan_features are gone, and nothing falls back to them. A fallback would
resurrect a plan the owner had just switched off. The seed lives in the
migration, and PlanCatalogueTest pins what the config catalogue sold as the
shadow comparison. `php artisan plans:check` reports overlaps, gaps and
missing prices before a customer finds them.
Contract-backed displays, which were reading the live catalogue:
seat limits, the cloud card, the plan card, and admin MRR — the last three now
divide a yearly contract down via Subscription::monthlyPriceCents(), since all
three label the figure per month.
Two recovery gaps closed along the way: the order now commits before the
contract is opened (so a contract failure can never erase the record of a
payment), a Stripe retry repairs a missing contract and restarts a run stranded
with no_subscription, and TickProvisioning sweeps runs left pending by a crash
rather than only running/waiting ones.
402 tests green. Codex review clean after thirteen rounds. Verified in the
browser: portal, billing and console render unchanged off the new catalogue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
55b1a1468f |
fix(admin): the admin password is shown once, and means it
tests / pest (push) Successful in 7m37s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
The handoff stayed in the cache for its full ten minutes, so any replayed component request could fetch the plaintext again — "shown once" was a figure of speech. It is consumed on the first read and the token dropped; the payload reaches the view and nothing else. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
d40113be8e |
fix(admin): never reset a stranger's VM, never leave the console spinning
Two from Codex: - The job accepted any stored instance with a host. A closed one's VMID can have been reused on the same machine, and the reset would then land on another customer's VM. It now requires a live instance, and the action is not offered for anything else. - An unreachable Proxmox or guest agent throws rather than returning an exit code, so nothing was ever written to the handoff and the modal polled forever. The throw is caught, and a failed() handler covers anything that still escapes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
aae0457e19 |
feat(admin): administrator access to a customer's Nextcloud
Impersonation borrows the customer's portal session; this is access to their installation, which is a different thing and now a different button. Nextcloud has no passwordless admin jump, so this does the only honest thing it offers: it resets OUR managed admin account inside that installation and hands the credentials over once. The customer's own accounts are untouched, and the next request sets a new password again. - New capability instances.adminlogin, Owner and Admin only — stronger than impersonation, because it hands over control rather than a session. - The operator's own password is required every time, rate-limited: taking over a customer's installation is not something an unattended browser should manage on its own. - The reset runs on the provisioning worker (it owns the tunnel and the Proxmox credentials); the console polls a handoff token, so the credentials never enter a Livewire snapshot. - A silent instance is reported as such instead of appearing to succeed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |