74 Commits (v1.3.35)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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> |
|
|
|
8f1630ba91 |
Bill a booked module every month, and put it on the invoice
A module was never an item on the Stripe subscription, so it was charged in the month it was booked and never again. And the renewal document was written from our own contract snapshot, so it would not have said so even if the money had been taken. Two halves of one fault, closed together. Modules are items now. stripe:sync-catalogue mirrors a Product and a Price per module on both terms — a Stripe Price carries its own interval, and a booking frozen at an older figure needs its own Price, so they are keyed by module, amount, currency and interval in stripe_addon_prices. Booking adds an item with always_invoice: prorated by days AND charged there and then, which is the invoice the owner wants for a module booked mid-term. A second storage pack is a quantity on one item, not a second item. Cancelling takes the item off with no proration — no credit, and the next cycle is simply smaller — while the module itself is kept until the period end on subscription_addons.cancels_at and ended by clupilot:end-cancelled-addons. Documents are built from Stripe's own invoice lines. Stripe is what charged the customer; a document assembled from our figures would state a sum that is not the one taken. So every paid Stripe invoice gets one document with one number, idempotent on stripe_invoice_id — a cycle carrying package and every module together, a mid-period booking carrying just its prorated line, an upgrade carrying its proration. Only the wording is ours: each line is named from the catalogue rather than printed as a Price id, and a line nobody can name stays on the document under Stripe's own description and is logged. The checkout's own invoice is still the one exception; that purchase already has a document. Stripe being away never undoes a booking that has already reached the machine. The failure is parked on the contract in stripe_addon_sync and swept by the same clupilot:sync-stripe-subscriptions that retries a plan change, which finds its work from the bookings themselves rather than from the marker. A granted contract has no Stripe subscription and is not touched at all. 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> |
|
|
|
35b312d5ef |
Invoice every renewal, and tell Stripe when the package changes
Two things Stripe was doing on its own, without us. **A renewal produced no invoice and no mail.** InvoiceMail went out from exactly one place — the first purchase — so a customer paying every month for a year received one invoice, for month one. Stripe already says when the money lands: invoice.paid with billing_reason subscription_cycle. What was missing was the document. A renewal has no Order behind it, and one was not invented for the occasion: an Order is something a customer bought, and writing a purchase nobody made would corrupt the record of what they have actually ordered. IssueInvoice::forBilledPeriod() issues from the CONTRACT instead, at its frozen net price, for the term Stripe billed. Idempotent against the Stripe invoice id, which is unique in the database rather than checked in PHP — and because the number is drawn inside the same transaction as the row, a redelivery takes its number back with it and the series keeps its sequence. Nothing in the paperwork can fail the webhook: issuing and mailing are both caught and logged, exactly as confirmByMail() does. Only a cycle renewal gets a document. The checkout's own invoice already has one; an upgrade's proration was worked out against Stripe's boundaries and would not match a document written from our snapshot; a charge raised by hand is for something this catalogue cannot describe. All three stay in the register, and are logged once as money no document of ours covers. **After a plan change, Stripe went on billing the old price.** There was no way to move a subscription onto another price at all, so a customer who upgraded paid for the smaller package every month afterwards. The client can now swap one, the item id it needs is learnt from Stripe's own events (and asked for once, for contracts older than the column), and the behaviour is chosen per direction: an upgrade settles immediately, so the cycle invoice stays exactly the contract price the renewal document states; a downgrade lands at the period boundary and settles nothing. A granted contract has no Stripe subscription and is left untouched. A change that reaches the machine and not Stripe is never rolled back — it is parked on the contract with the error and the behaviour it needed, logged as an error, and retried hourly by clupilot:sync-stripe-subscriptions. 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> |
|
|
|
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>
|
|
|
|
34874adec3 |
Apply a bought plan change instead of only pricing it
tests / pest (push) Failing after 8m6s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
PlanChange could say what a move would cost and whether it was allowed, and that was all it could do. Billing::purchase() wrote an upgrade order and nothing ever consumed it: same snapshot, same machine, same quota. A customer could pay for a bigger package and receive nothing. ApplyPlanChange is now the single place a change lands — it moves the contract onto the target's current version, writes one register row, settles the custom domain, and starts a run that resizes the machine. Applying the same order twice is a no-op, enforced by a unique event key rather than by a check two callers could both pass. Two things a plan change must not do, and now does not. A disk is never shrunk — Proxmox cannot, so the QUOTA shrinks instead, which is what was sold anyway. And a live machine is never rebooted as a side effect: cores and RAM are written, and where they need a restart the instance says so where an operator and the customer can both see it. The storage allowance also joins the build pipeline. ApplyStorageQuota was written for plan changes and ran only there, so a brand-new customer still got no quota at all — quota_gb reached the instance row and stopped, and every package delivered the whole disk. The test guarding that pipeline only counted its steps, which is how a list missing the one step that makes a package's storage real stayed green for its whole life. It now names the step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3a4324fb6f |
Give people a way back in, and put the URL in English
tests / pest (push) Failing after 7m54s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
── The hole ──────────────────────────────────────────────────────────────── There was no password reset. Fortify's feature was commented out, so there was no link on the sign-in form, no page and no route — a customer who forgot their password was locked out of their own cloud until somebody opened a shell. On a product whose selling point is that you can ring somebody, that is a support call a week and an embarrassing one. Two pages of ours under Fortify's route names (R1/R2), because with views off Fortify registers only the POST endpoints. The mail is a Mailable in this product's design rather than the framework's MailMessage: somebody who has just been locked out is exactly the person a phishing mail is aimed at, and a message that looks nothing like the rest of our post is one they cannot check — ours carries the footer that names our domains. Three decisions worth stating, each with a test: The answer is identical whether the address is known or not. "No account with that address" turns the form into a way of finding out who is a customer. The reset kills every other session. Whoever knew the old password may still be signed in somewhere, and a reset that leaves them there has fixed nothing. It does NOT sign the visitor in. The link arrived by email, and a mailbox somebody else can read would otherwise be a session somebody else gets. ── /sicherheit → /security ───────────────────────────────────────────────── R13: paths are English. Mine was not. The old path stays as a permanent redirect because it has already gone out in mail footers. ── One wordmark ──────────────────────────────────────────────────────────── "Sometimes it says Cloud and sometimes it does not" — correct. It was on the footer, the placeholder and the maintenance screen, and absent from the header, the sidebar and the sign-in plate. The product is called CluPilot Cloud; that is the name on the invoices and in every mail, so it is now the name everywhere. The console sidebar takes the small size so the lockup, the ADMIN badge and the close button still share one line (R18). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f8874f32ea |
Put customer instances on their own domain, and say which domains are ours
tests / pest (push) Failing after 8m1s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
── The zone ──────────────────────────────────────────────────────────────── A fresh install now addresses customer instances under clupilot.cloud rather than clupilot.com. Not branding: a Nextcloud is third-party software that strangers sign in to, and on the same registrable domain as the portal it shares cookie scope, document.domain and CAA with it. The same reason googleusercontent.com and vercel.app exist. Existing installations keep whatever the console says — the zone has been a setting all along. `clupilot:check-zone <zone>` reports what a change would touch before anything is touched: the DNS records, the monitoring targets, the certificates and Nextcloud's trusted_domains all carry the old name and the setting reaches none of them. With no instances in service it says so and stops. Changing it later is a migration with an outage, and the command says that too. ── Which addresses are ours ──────────────────────────────────────────────── A new public page at /sicherheit: the domains that belong to us, the rule for reading an address bar, what we never do, and — the part that matters — what to do if you have already typed your password into a copy of our sign-in form. Change it, end every other session, change it wherever else you use it, tell us. In that order. Reachable without an account, deliberately: somebody who has just given their password away is signed in nowhere, and the page they need cannot be behind the thing they lost. Linked from the three places a person is when the question comes up: every mail footer (the mail is the vector), the sign-in form (this is the page a phishing kit copies, so the real one says which host you are on), and the site footer. The domain list is DERIVED from configuration and keyed by what each domain is for. A list typed into a page would eventually tell a customer that a phishing domain is one of ours, or that one of ours is not — the same two-sources-of-truth failure that had the price sheet promising an address on a domain the company does not own. The operator console is deliberately absent from it: naming it on a public page tells an attacker where to aim. 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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
d8ff717cb5 |
Let a destination say how it is laid out and how long anything stays
tests / pest (push) Failing after 7m29s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two destinations doing two jobs. A handover directory a NAS collects from wants a folder per day and wants emptying — thirty days is enough to notice a NAS that stopped collecting, and keeping more only hides it. An archive on the NAS itself wants a folder per year and wants nothing deleted, ever. So both are per destination rather than global. Deleting is safe here in a way it usually is not, and that is worth saying plainly: the invoice is a frozen document in the database and its PDF is rendered from that on demand. Nothing is moved anywhere — it is copied, and anything removed from a directory can be produced again and re-exported. The directory is a handover point, not the record. keep_days is null by default and stays null unless somebody sets it: a retention rule nobody asked for is a deletion nobody expected. The prune only touches folders whose NAME is one of the two shapes this application writes, and leaves everything else alone — a prune that deletes what it does not recognise is how an archive loses something nobody was watching. It also skips a destination whose path is missing rather than acting on whatever is underneath an absent mountpoint. The shape is matched with a pattern before parsing rather than by handing nonsense to Carbon: under strict mode createFromFormat throws instead of returning false, so one folder somebody made by hand would have ended the whole prune instead of being skipped. The test with a foreign folder in it found that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ab4b0311d4 |
Copy every invoice to the archive when it is issued, and notice when that fails
tests / pest (push) Failing after 11m24s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The application knows nothing about NFS and should not. It writes to a directory; whether that is a local disk, a bind mount or a NAS over NFS 4.1 belongs to the mount. No protocol library, the target changes without code changing with it, and the whole thing is testable against a temp directory. The finding that shaped it: mkdir -p on a path whose mount is NOT there succeeds. It creates the empty directory beneath the mountpoint, writes the invoice onto the container's own disk and reports success — so an unmounted NAS would quietly collect invoices locally while every check said it worked. An archive that silently is not the archive is worse than none. The root is therefore never created, only used: a missing root means a missing mount, and that is a failure. Only the year subdirectory is created, inside a root already proven to exist. Written to a .part and renamed. A rename within one filesystem is atomic, so whatever watches that folder never sees a truncated PDF and takes it for a finished invoice. The size is read back afterwards rather than the write being trusted: over a network mount a short write can report success, and an archive of truncated files looks exactly like one that worked. Queued, not done during the request. A network mount can block for its whole timeout, and an invoice must not fail to be issued because a NAS is rebooting. Dispatched after the commit, or a worker could look for an invoice that has not landed yet. And copy-on-issue is not enough on its own: an invoice issued while the office connection was down never arrives, the queue eventually gives up, and nobody opens an archive to check whether last Tuesday is in it. An hourly sweep re-queues whatever is still missing, the failure is recorded on the invoice rather than only in a log, and the Finance page says how many are outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
86bf4f26e0 |
List issued invoices in the console, with no way to change one
tests / pest (push) Failing after 7m34s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Read-only by design, and the absence is the feature: an issued invoice is corrected by cancelling it and issuing another, which is the only lawful way to correct one. There is no edit button here whose absence needs explaining, and a test asserts there is none. The PDF is a plain route rather than a Livewire action. A download is a download, and routing one through a component round-trip only adds a way for it to fail. Two findings on the way, and both were mine. The year filter used YEAR() in raw SQL, which is MySQL's. SQLite has no such function, so the page worked against the real database and threw against the test one — a query that only runs on one engine makes the test database a different product from the one being shipped. The years are derived in PHP now. And a test put a User on the operator guard, where EnsureAdmin calls isActive() on it and a 403 becomes a 500. Not a code defect: actingAs() calls Auth::shouldUse(), so a second actingAs() in the same test with no guard named lands on whichever guard the FIRST one named. That trap is written up in CLAUDE.md, I quoted it earlier today, and I walked into it anyway. The tests are split now and both name their guard. 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> |
|
|
|
e6d2e6dc33 |
Give the console a Finance tab: company details, VAT rate, invoice series
tests / pest (push) Failing after 7m49s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Its own tab rather than a section of Settings, as asked. What is set here appears on a legal document, and burying it beside the site-visibility switch invites somebody to change one in passing. Everything the printed invoice needs — registered name, address, Firmenbuch number and court, VAT ID, bank details, payment terms, logo — with the logo replaceable, because a company changes its mark. None of it reaches an invoice that already exists: the values are copied into each document when its number is assigned, so a corrected address is correct from the next one onwards and cannot rewrite an old one. The console refuses to consider itself ready while a name, an address or a VAT number is missing, and says which. An invoice without them is not a valid invoice in Austria, and issuing one is worse than refusing to. The series editor carries the same shape as EditDatacenter and for the same reason. The prefix is free while the series is empty and frozen once a document carries it: changing RE to AR renames nothing and leaves a series whose past says RE and whose future says AR, with no record they belong together. The counter may be raised — an operator migrating from another system needs exactly that — but never lowered, because a lower number is one already printed on a document somebody holds. Both rules are recomputed from the database on save rather than read back from the properties the browser returns. CompanyProfile drops keys that are not part of the profile. It is fed from a form, and a forged key must not be able to write an arbitrary setting into the same table the site-visibility switch lives in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
21762284a7 |
Bind the portal to its own hostname too, so the website cannot serve it
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
The previous release bound the landing page and stopped there. The website duly
vanished from the portal — and the portal stayed reachable from the website:
www.clupilot.com/dashboard, /login, /settings all kept working. Half a
separation is no separation, and it was reported back within the hour.
APP_HOST binds every portal route, Fortify's own POST actions included through
fortify.domain — a sign-in form on the website's hostname would otherwise post
to a route that answers there. SITE_HOST now takes the legal pages and
robots.txt with the landing page rather than leaving them everywhere; binding
them is also what makes route('legal.impressum') produce a www URL from inside
a queued mail, where there is no request to take a hostname from.
There is no clever middleware here and there should not be. Route::domain() is
the mechanism: a route registered for one host does not exist on another, and a
request for it gets the 404 it deserves. The only deliberate exception is "/" on
the portal host, which redirects to the dashboard or the sign-in page — that is
what people type from memory, and turning it into an error page to make a point
about hostnames helps nobody. The Stripe webhook and /up stay host-agnostic:
Stripe posts to whichever URL it was given, and a mismatch there loses payments.
Both are opt-in and empty by default, which every development machine reached
by a bare IP depends on.
The tests now check BOTH directions for every path rather than only the one
that was reported. That is the mistake this commit exists to correct.
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. |
|
|
|
49a3ca2e33 |
Keep the shop window off the portal's front door
tests / pest (push) Failing after 7m35s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
The landing page was registered host-agnostically, so app.clupilot.com served it: marketing copy, a pricing table and a "sign up" call to action, at the address where a customer's servers live. Reported exactly that way. SITE_HOST binds the website to its own hostname. Every other host — the portal, a bare IP — answers "/" with the product: the dashboard for somebody signed in, the sign-in page for everybody else. Never a 404 there: "/" is what people type from memory, and turning that into an error page to make a point about hostnames helps nobody. Empty keeps today's behaviour, which every development machine reached by IP depends on. The console is unaffected — its routes are registered first and win on their own host, the ordering the status page above already documents. The legal pages are deliberately not bound. An imprint has to be reachable from wherever the reader is standing, and a mail footer links to it from whichever host sent the mail. Also drops "just reply to this message" from two mails. A reply lands in the billing or provisioning mailbox rather than in the support queue somebody actually works through — so it now says to raise it in the portal, where it gets a place in that queue instead of getting lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
5b8c28595a |
Require a confirmed address before an account can use anything
Registration opened an account that could sign in immediately on an address nobody had proved was theirs — and anybody can type a stranger's into a registration form. Both halves were commented out: the Fortify feature and MustVerifyEmail on the model. `verified` sits on the whole portal group rather than on the pages that spend money. An unconfirmed address is not a billing problem to be caught at checkout; it is an account that may not belong to the person holding it, and the servers, the users and the backups behind it are worth as much to whoever typed the address as to whoever owns it. The mail is a Mailable in this product's design rather than Laravel's notification, which would arrive with the framework's logo, button and footer as the first thing anybody ever receives from CluPilot. It also has to leave through the SYSTEM mailbox like every other account mail, which the notification path does not do on its own. The link is signed and expires, and the address is part of what is signed — so a link issued to a mistyped address stops working the moment the address is corrected, rather than confirming an address nobody can read. Tests cover the tampered link, the expired one, the corrected address, and one account trying to confirm another. The waiting page is ours (Fortify has views off) and offers the only two things that move somebody forward: send it again, or sign out and register with the address they meant. The resend is throttled, because otherwise it is a button that makes the server send mail to an arbitrary address as fast as it can be clicked. Somebody already confirmed is redirected away from it — the `verified` middleware only guards the other direction. 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 | |
|
|
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> |
|
|
|
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. |
|
|
|
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. |
|
|
|
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.
|
|
|
|
e519653f66 | Let the owner make two-factor compulsory, without locking themselves out | |
|
|
26c84e3695 | Hand impersonation over by signed link, not by a cookie that cannot cross hosts | |
|
|
3f318c3f6a | Give the console a front door of its own | |
|
|
87c49de6e5 | Move the console's identity out of the customer table | |
|
|
4211f3dfab | Give the mailboxes a page, and the support sender its own capability | |
|
|
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> |
|
|
|
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> |
|
|
|
f6b9181ed8 |
Give customers two-factor, and stop every button on the settings page reacting at once
tests / pest (push) Successful in 7m55s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 6s
Details
Clicking Save made every button on the console's settings page appear to fire. Seven `wire:loading.attr="disabled"` had no `wire:target`, and without one Livewire applies the loading state to ANY request on the component — so one save put all of them into their disabled state simultaneously. Each names its own action now. Two-factor for customers. Fortify's endpoints already existed; only the screen was missing. Setting up requires re-entering the password first, and every action re-checks that server-side rather than relying on the button not being on screen — a Livewire action is reachable by anyone who can post to /livewire/update. The confirmation marker is Laravel's own session key, so it and the framework's password.confirm middleware mean the same thing rather than drifting apart. The secret never enters a Livewire property. Component state travels to the browser and back in the snapshot; the QR image is derived from the secret, the secret is not in it — and there is a test that says so. The status page moves to the ROOT of its hostname: status.clupilot.com/status says the same word twice. That needed the domain-bound `/` registered BEFORE the landing page, because Laravel takes the first match and the landing route is host-agnostic — so the status host would have served the marketing site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6082164f8b |
Give the status page its own hostname, and name the right one for Stripe
tests / pest (push) Successful in 7m43s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Successful in 5s
Details
STATUS_HOST binds /status to a single hostname. Every other host redirects there rather than 404ing: a status page is the one address people keep in a bookmark and reach for when something is already wrong, so breaking old links to prove a point about separation would be exactly backwards. Unset, nothing changes. The /legal/status redirect goes through route() instead of a literal path. A relative redirect lands on whatever host the visitor is already on — which, once the page has a hostname of its own, is precisely where it no longer answers. The installer told the operator to point Stripe's webhook at the customer portal. Stripe posts server-to-server and never sees the portal; the endpoint is the api hostname, which the installer never even asked for. It asks now, and prints the address that actually receives the events. Getting this wrong does not fail loudly — payments simply stop being recorded. It also writes ADMIN_HOST_EXCLUSIVE=false on a fresh install. Switching the console onto its own hostname before the DNS for it exists makes the console unreachable under any other name, and that is not a thing to have on by default. 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> |
|
|
|
b844ff377d |
Rebuild the public site as a document, and read prices from the catalogue
tests / pest (push) Successful in 7m30s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Successful in 4s
Details
The marketing page was a generic centred hero in the system font stack: it neither used the design system the console is built on nor looked like the product it sells. Rebuilt around what CluPilot actually promises — security you can produce evidence for — so the page reads as a controlled technical document: a specification plate instead of a hero image, numbered sections, an audit register with rhythm and proof per measure, and a price sheet rather than floating cards. IBM Plex Serif joins Sans and Mono as the display voice, self hosted as static files so the public page renders even without the Vite build. The prices were written into the page by hand, and the page and the catalogue had already drifted apart on three of four plans: the site advertised 249 € for a plan the checkout charges 399 € for. The sheet now reads the catalogue, like every other caller — including the currency, which is configurable and was also hard-coded here. The catalogue fails loudly on purpose; a public website must not. A broken or overlapping catalogue is caught in the controller alone: the page still renders, the sheet is replaced by "on request", and nobody is quoted a number the checkout would not honour. The sign-in and registration panels shared a copy-pasted orange gradient. They now share one component and the same ink plate as the site, so the two halves of the product no longer look like two companies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0560ae743d |
feat(billing): Stripe owns the billing cycle, we own capability
A Product per plan family and a Price per priced row of a published version, plus the four subscribed webhook events that were arriving and being ignored. `stripe:sync-catalogue` mirrors the catalogue. Idempotent twice over: a stored id is skipped, and each call carries an idempotency key derived from our own row, so a crash between Stripe creating a Price and us recording its id gives back the same object rather than a second one. That matters more here than usual — a Stripe Price cannot be edited or deleted, so a duplicate is permanent. Drafts are not synced at all: a version that has promised nothing has no business in a price list. --dry-run shows what would be created. The webhook now handles invoice.paid, invoice.payment_failed, customer.subscription.updated and customer.subscription.deleted. It never re-derives an amount: the invoice is the authority for what was charged, and recomputing it from our catalogue would produce a second, disagreeing answer. A failed payment is recorded and nothing else — Stripe runs the dunning schedule, and cutting a customer off on the first failure would punish an expired card as though it were a refusal to pay. Only a cycle renewal moves the term on. Stripe also sends paid invoices for prorations and manual charges, and the checkout's own invoice — which is already in the register as the purchase, and would otherwise double every customer's first payment. Stripe does not guarantee delivery order, which is the source of most of the care here: - state changes are judged and written in one transaction with the row held, so two deliveries cannot both pass a check made on a stale copy; - an older event never overwrites a newer one, with a rank breaking ties between events sharing a second — their timestamps have one-second resolution, and a failed attempt and its successful retry routinely do; - a term is never shortened, and an ended contract is never revived; - an event for a contract that does not exist yet is HELD and replayed the moment it appears, rather than acknowledged and forgotten. A cancellation dropped that way would leave us serving someone who had already left. Held rows that never match are pruned after a week. Register entries are keyed by what they are about — the invoice, the attempt, the subscription — with a unique index doing the work, because a check two concurrent deliveries can both pass is not idempotency. PlanChange is now documented as the preview shown before someone confirms, not the invoice: Stripe's proration accounts for tax, existing credit and the exact second the change lands, and ours cannot. Not run against the live account — `stripe:sync-catalogue` creates objects that cannot be deleted, so that is the owner's call. The dry run lists 12. 457 tests green. Codex review clean after seven rounds. 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> |
|
|
|
c1e81808a7 |
feat(traffic): meter the monthly allowance, show it, throttle instead of blocking
Customers can now see what they have used and what is left, and the service slows down rather than stopping when the allowance is gone — a slow Nextcloud gets a top-up, a dead one gets a cancellation. - instance_traffic keeps one row per instance per month. Proxmox counters are cumulative since the VM last started, so usage is the difference between two samples, and a counter that went backwards means a restart, not a refund. - Outbound is what counts: inbound is free at our providers and egress is what Hetzner's 20 TB per server applies to. - CollectInstanceTraffic samples every 15 minutes, warns once per threshold (80/95 %) rather than on every run, and at 100 % limits the VM's NIC via Proxmox — released again as soon as the customer tops up or the month rolls over. - The dashboard gets a full-width band (it throttles the service, so it is not a tile among tiles) with the top-up offer right next to the warning. - Bytes are formatted in SI units now: allowances are computed in SI, so dividing by 1024 made a 1000 GB plan read as "931 GB". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
e2b4cdbac4 |
feat(site): switch the public website and portal off from the console
While the product is still being built, the marketing site and the customer portal should not be reachable — but they must stay reachable for us. - A toggle in the console (site.manage, Owner/Admin) stored in a new app_settings table, because this has to be flippable without a deploy. - Outsiders get a placeholder with 503 + noindex, not 200: a 200 invites search engines to index the placeholder as the site's content, which is far harder to undo than to prevent. - Anyone on the management VPN, and any signed-in operator, sees the real site. The console, Livewire's endpoint, the Stripe webhook and the health check are always reachable — otherwise the switch could only be flipped once. - robots.txt is generated by the app and follows the switch. It had to stop being a static file: nginx short-circuited it and Laravel's stock file said 'Disallow:' with an empty value, so crawlers were never told anything. - Settings reads fall back to the default when the table is unavailable. The gate reads one on every request, so a deploy running new code before migrate would otherwise answer the entire site with a 500. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |