90 Commits (v1.3.40)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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> |
|
|
|
f02e86769b |
Charge the price the website shows, and hand a withdrawal back in full
tests / pest (push) Failing after 9m21s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Stripe was charging the catalogue's NET figure while the document added the domestic rate on top: a customer quoted 214,80 paid 179,00 and was then invoiced for VAT nobody had collected. Under para 11(12) UStG that VAT is owed to the tax office whether or not it ever arrived, so every document issued created a liability against revenue that did not contain it. The Stripe Price now carries the GROSS figure. price_cents stays net - it is frozen onto every contract and PlanChange prorates against it, so redefining it would corrupt every pro-rata sum ever computed. Only the amount at the till moved, and it is formed by the one call TaxTreatment already answers for the price sheet and for the invoice, so displayed, charged and invoiced cannot drift apart. Stripe's automatic_tax is deliberately not used. TaxTreatment is the single tax authority here, and a second rate computed by Stripe would take 19 % from a German consumer while our document said 20 %. A Stripe Price cannot be edited, so a changed figure means a new Price and the old one archived - which stops it being SOLD and leaves every subscription already on it billing the old amount for ever. stripe:reprice-subscriptions is the second half, and a command of its own because it touches live money. A withdrawing consumer is refunded in full, as the owner decided. That is more generous than FAGG para 16 requires, and it makes the express-request consent the statute hinges on irrelevant - so the gate is gone rather than left looking load-bearing. 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> |
|
|
|
6d7c2bdf35 |
Ask whether they are a consumer, and let one change their mind
tests / pest (push) Failing after 8m35s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Three things the product owed its customers and did not have. **Who is on the other side.** There was no consumer/business flag anywhere, and `vat_id` was standing in for one — which it cannot: a business without a VAT number is an ordinary small business, and a consumer with one does not exist. It is asked at sign-up now, correctable in the portal, and NULL where nobody has been asked. Unknown is read as CONSUMER everywhere it decides a right, because that mistake costs us a refund while the other one takes a statutory right away from somebody who has it. Reverse charge asks the recorded type instead of the number: an explicit consumer is charged the domestic rate whatever `vat_id` says — previously they were not, and anyone could zero their own VAT by getting a number verified. An unrecorded type still falls back to the verified number, so no contract that is already running changes rate. **The fourteen-day right of withdrawal** (FAGG, §312g BGB), for consumers only, at every door: the window is stamped on the contract when it is concluded, the customer exercises it from the portal, an operator records one that arrived by telephone or post, and both go through one action that refuses a business customer on the server rather than by hiding a card. The money follows the paperwork rather than being computed beside it. The invoice is cancelled by a Storno with its own gapless number — nothing is ever edited or deleted — a new invoice states the pro-rata value of the service actually delivered (FAGG §16, by days over the term paid for), and the refund is exactly the difference between the two documents. Where the consumer never expressly asked for the service to begin at once, they owe nothing and the whole amount goes back. `StripeClient::refund()` is new, keyed so a retry cannot send the money twice. The service ends through EndInstanceService and the `cancellation_scheduled` machinery that was already there. **The cancel button for modules.** BookAddon::cancelAtPeriodEnd() had no caller in the interface at all, so a customer could book a recurring charge in two clicks and had no way to stop it. It is on the module card now, with the date it runs until and a way back while the cancellation is still pending — and putting a module back settles nothing, because the term it was cancelled for was paid for in advance. 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> |
|
|
|
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> |
|
|
|
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> |
|
|
|
712803edd6 |
Serve the custom domain, not just announce it
A verified custom domain was reported as the customer's address by
Instance::address(), by the portal and by the credentials mail while
nothing on the platform routed it: the Traefik router's rule was
hard-coded to {subdomain}.{zone}. The address a customer was handed
answered nothing, and a withdrawn domain stayed in Nextcloud's
trusted_domains forever, because the only thing that ever wrote either
was the initial provisioning run.
- TraefikWriter::write() takes a LIST of hostnames under one stable
router name — the platform address always, the verified domain as
well when there is one. One file per instance, so a withdrawal is a
rewrite rather than a second thing somebody has to remember.
- ConfigureDnsAndTls records the custom domain's certificate instead of
waiting on it: it depends on an A record in the customer's own zone,
which may never appear, and must never fail a run. The platform
address keeps its 840s deadline. instances.domain_cert_ok tells
"proven" apart from "answering", and the portal now says which.
- ConfigureNextcloud deletes trusted_domains 2 when there is no verified
domain, so a withdrawn one stops being trusted.
- New `address` pipeline (those two steps) plus ReapplyInstanceAddress,
which starts one against the order and refuses to start a second while
any run is in flight. The route is rewritten when the hostname list
differs from what the router carries — not on route_written, which
would short-circuit exactly the case a re-apply exists for.
- Triggered where the address changes: verification flipping either way
in clupilot:verify-domains, the customer's own domain page, and
CustomDomainAccess::deactivate() on a package downgrade.
- A maintenance run no longer condemns its subject: an address run that
failed used to mark the order failed and release the live instance
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
6ef9b510ce |
Merge branch 'feat/plan-reopen'
# Conflicts: # app/Http/Controllers/LandingController.php |
|
|
|
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.
|
|
|
|
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> |
|
|
|
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. |
|
|
|
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> |
|
|
|
b3081a544b |
Let the export have as many destinations as somebody wants
tests / pest (push) Failing after 7m35s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
One path and one exported_at could not say "on the office NAS, not yet on the off-site box" — which is the only interesting question once there is more than one destination, and the reason for a second is precisely that any single one can fail. A row per destination now, and a row per (invoice, destination) pair. Two kinds, and the difference is where the protocol lives. 'local' is a directory this machine can already write to: a NAS mounted over NFS 4.1, a bind mount, a disk. The application knows nothing about the protocol, the target changes without code changing with it, and the whole thing stays testable against a temporary directory. 'sftp' is for something reachable with credentials — a Hetzner Storage Box being the obvious one — and the password goes into the secret vault with only its key on the row, because a credential in a settings table is a credential in every database dump. An empty password field on an existing target means unchanged, not cleared: the field cannot show what is stored, so blank-means-delete would wipe it on every unrelated edit. One job per pair rather than one per invoice. A job writing to both would retry the one that worked every time the other did not, and report one outcome for two different things. The ordering fix the tests found: Laravel's local adapter creates its root when it is CONSTRUCTED, so the "never create the root" guard was checking a directory the disk had just made for it. It runs before the disk is built now. That guard is the difference between an archive and a hole in the ground — mkdir on an absent mountpoint succeeds, writes onto the container's own disk and reports success, and an archive that silently is not the archive is worse than none. The single path from the previous commit is migrated into a destination called "Archiv" rather than dropped. 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> |
|
|
|
735daf46a4 |
Lay the foundation for self-issued invoices: series, numbers, frozen documents
tests / pest (push) Failing after 7m49s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
An invoice is a tax document, so two things drive the shape of this. It is frozen at issue. Everything the finished document says — issuer address, VAT number, bank details, customer address, every line, the rate, the totals — is copied into the invoice row when the number is assigned. The PDF is rendered from that and never stored, which is what was asked for; rendering it from today's settings instead would have been simpler and wrong, because an address changed in 2028 would rewrite an invoice from 2026. The width of this table IS the feature. Numbers are gapless and ascending, which is a legal requirement rather than a preference, and that is why none of it is derived from the invoices table. "Highest number plus one" hands the same number to two sales that land together and hands a used number back out after a row is removed — a number already printed on a document somebody holds. The counter is a column, read and advanced under a row lock inside the caller's transaction, and taking one outside a transaction throws rather than quietly working. Four Rechnungskreise seeded — Rechnung, Gutschrift, Storno, Anzahlung — each with its own prefix and its own counter, because a credit note is not an invoice with a minus sign in front of it. Seeded rather than left to the operator: an installation with no series cannot issue anything, and discovering that at the first sale is the worst possible moment. TCPDF added, for the renderer that comes next. There is deliberately no test for the outside-a-transaction guard, and a note where it would have been: RefreshDatabase opens a transaction around every test, so the guard cannot fire and a green test for it would be green for a reason that has nothing to do with the guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2609393e3a |
Recognise the devices an account signs in from, and warn about a new one
tests / pest (push) Failing after 7m52s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Sessions lived in Redis, where they cannot be enumerated — one key per session, no index by user — so "where am I signed in" and "end that one" were not answerable at all. The database driver keeps a row per session and makes ending one a delete. It costs every currently signed-in person one forced sign-in, once, which is why it is happening now rather than later. The warning is only worth anything if it is rare, so the two obvious signals are deliberately not used. Not the IP: it changes on every train, every café, every mobile handover, and behind a VPN it is not theirs to begin with. Not the user-agent: Chrome ships every four weeks and the string changes each time, which would be twelve warnings a year per customer, describing nothing. A device is a long-lived signed cookie holding a random token, and nothing else is consulted. The honest cost — cleared cookies, a private window, a second browser — is written into the mail itself, because a reader who thinks the warning is wrong stops reading the next one. Three things the tests found or hold: The first device on an account is new and must not warn. Replying to somebody creating an account by telling them they signed in from a new device is noise on the one message that has to stay worth reading. token_hash was globally unique, and the shared-browser test failed on it. Two people on one machine share one cookie and must each own a device row against it; the obvious repair — issue a fresh token for the second — would orphan the first person's row and warn them about their own desk. Unique is now per identity. Recording a device may never block a sign-in. A safeguard that locks the owner out when it breaks has stopped being one, so the listener logs and swallows, and a test drops the tables to prove it. Sessions are linked by a uuid carried inside the session payload rather than by the session id, which Laravel rotates on every sign-in and every regenerate(). Laravel's own sessions.user_id cannot carry the link either: it is filled from the default guard, while operators and customers are two guards and two tables (R21) — operator 1 and customer 1 would each be offered the other's sessions. Also lands the shared mail layout the rest of the templates will use: tables and inline styles for Outlook, no image and no web font because half of all clients block remote content, and the button in --accent-active rather than the brand orange, which is 2.9:1 against white. Not tagged: the session list is not built yet, and without a tag no server can install this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
669d0471a8 |
Repair two comment blocks the admin-hosts edit ran together
tests / pest (push) Failing after 8m1s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
An earlier edit removed the ADMIN_HOSTS example line and left its "Beispiel:" dangling into the VPN_CONFIG_KEY block, so the file documented neither properly. SECRETS_KEY was never in here at all — without it the console silently stores no credentials, which is exactly the kind of thing an example file exists to prevent. |
|
|
|
75b4a47768 |
Stop mailing the initial admin password, hold it in the panel until noted
The password no longer travels through third-party mail servers or sits forever in an inbox: it moves onto the instance (encrypted, same as nc_admin_ref) and shows on the dashboard until the customer clicks "Notiert", which deletes it for good. |
|
|
|
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> |
|
|
|
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
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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 | |
|
|
a6a9c76660 |
Trust an explicitly configured mail host or port over the placeholder
env('MAIL_HOST')/env('MAIL_PORT') returning anything, or MAIL_URL supplying
either, now counts as "supplied" and wins over the sentinel comparison — a
relay genuinely on 127.0.0.1:2525 no longer reads as unconfigured. The
sentinel stays as a fallback for when neither signal says anything (including
under config:cache, where env() goes null and this collapses to today's
behaviour).
|
|
|
|
15c81489d4 |
Stop downgrading an opportunistic smtp scheme to plaintext
MAIL_SCHEME=smtp is Symfony's opportunistic scheme (upgrade to STARTTLS if offered, don't fail if not) — not a "no encryption" statement. Mapping it to 'none' silently turned every existing MAIL_SCHEME=smtp installation's encrypted-when-possible connection into an always- plaintext one. Maps to 'tls' (STARTTLS required) instead: stricter than smtp's own default, so a relay lacking STARTTLS now fails loudly rather than leaking credentials. |
|
|
|
5d306fce31 |
Let a mailbox send without a password when it never needed one
An SMTP relay with a host and a from address but no username or password - a trusted local or private-network relay - always used to be seeded as a placeholder, because the takeover required a username to treat an account as real, and Mailbox::isConfigured() always required a stored password. Laravel's own SMTP mailer has supported unauthenticated relays all along. Add mailboxes.authenticates (default true, so every existing row keeps its current behaviour) to tell "does not authenticate" apart from "authenticates, but nobody has typed the password yet" - the same empty password column used to mean both. The takeover migration now seeds the real account off the host/address alone and marks it authenticates=false only when neither a username nor a password resolved; isConfigured(), MailboxTransport and MailboxTester all read it before requiring or sending a password; the edit modal gets a checkbox for it with the password field's hint updated to match, in both languages. |
|
|
|
40a6b3269d |
Trust a stated mail scheme over the port guess when seeding encryption
MAIL_SCHEME=smtps or an smtps:// MAIL_URL on a non-465 port used to be downgraded to STARTTLS because mail.encryption was derived from the port alone. Resolve the scheme first (from MAIL_SCHEME directly, or from the URL's own scheme via the same ConfigurationUrlParser Laravel uses) and only fall back to the port heuristic when nothing was actually stated - which is also what keeps this machine's invalid MAIL_SCHEME=tls from silently steering the result. |
|
|
|
f0c3bd9c2e |
Resolve MAIL_URL before seeding, so a takeover doesn't blank out a working relay
The seed migration read config('mail.mailers.smtp.host') and its three
siblings directly. An install configuring SMTP through MAIL_URL (the
other form config/mail.php supports) never sets those individual keys —
MailManager only substitutes the URL's components in at transport-build
time, inside its own protected getConfig(). Read raw, that left the
migration seeding config/mail.php's own placeholder defaults, which the
UNCONFIGURED_HOST/UNCONFIGURED_PORT guards then read as genuinely unset:
blank host, port 0, an unconfigured mailbox, for an install that was
sending mail successfully.
resolveSmtpConfig() calls the same Illuminate\Support\ConfigurationUrlParser
MailManager::getConfig() itself delegates to, so this is the same
resolution rather than a second copy of its merge order. Verified against
the real (protected) getConfig() via reflection: the URL wins for
host/port/username/password wherever it actually supplies one, and an
unset MAIL_URL leaves this a no-op.
|
|
|
|
e8a3b8d5be |
Seed the mailbox address from mail.from.address, not the SMTP login
The takeover migration seeded `address` from MAIL_USERNAME and left `username` null unconditionally, conflating the SMTP login with the sender address. Whenever the two differ — an API-key username, a shared relay login — mail would go out From the login instead of the address the system was actually configured to send from, exactly like this installation: MAIL_USERNAME is no-reply@clupilot.com but MAIL_FROM_ADDRESS is hello@clupilot.local. `address` now reads config('mail.from.address'), falling back to the SMTP login only when that reads back config/mail.php's own unset placeholder (env('MAIL_FROM_ADDRESS', 'hello@example.com') — the same masking problem UNCONFIGURED_HOST already guards against for MAIL_HOST). `username` is set to the SMTP login only when it actually differs from the resolved address, since Mailbox::smtpUsername() already falls back to address when username is null. display_name now reads config('mail.from.name') instead of a hardcoded literal. The placeholder domain for the four non-configured mailboxes still comes from the SMTP login, not from mail.from.address — the two can name different domains, and only the login's is proven to accept mail for a given install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
99b7393361 |
Stop committing a real password, and say the truth about what carried over
MailboxSeedMigrationTest.php had the actual .env MAIL_PASSWORD for no-reply@clupilot.com hard-coded into a test fixture — replaced with an obvious dummy. Grepped the whole branch (576cfca..HEAD) and the working tree for that string and for every other real secret value in .env; nothing else leaked. Separately, the seed migration's SECRETS_KEY-missing warning claimed a password "was NOT copied into the mailbox table" even when a vault row already existed and its ciphertext WAS carried across a few lines below, unconditionally on $canEncrypt. An operator debugging a live mail outage would have been sent to re-enter a password that was already sitting in the column. The warning now distinguishes the two cases and says which one actually happened. |
|
|
|
67a3e78cb4 |
Stop the mailbox password save from crashing without SECRETS_KEY
EditMailbox::save() now checks SecretCipher::isUsable() before touching a typed password and reports it as a form error, instead of letting SecretCipher::encrypt() throw all the way to Laravel's debug page. Also switches the mail.manage migration to Role::findOrCreate, matching every sibling capability migration, so a squashed replay can't hit RoleDoesNotExist. |
|
|
|
4211f3dfab | Give the mailboxes a page, and the support sender its own capability | |
|
|
a12ab148b3 |
Close the data-loss paths the review found in the mailbox takeover
The vault's mail.password row is no longer deleted by this migration at all, in either direction: deleting it unconditionally destroyed a real password whenever MAIL_USERNAME was empty (nowhere to carry it to), and even on a successful carry-over a later rollback had nothing left to fall back to. Reproduced both against real MariaDB, along with a migrations- table drift that crashed a retry on the mailboxes.key unique constraint — fixed with Mailbox::firstOrNew() and a transaction around each direction. down() now goes through the new Settings::forget() instead of a raw delete, so it stops leaving the settings cache holding what it just removed. A skipped .env password (SECRETS_KEY still unset) now prints an operator-visible line instead of migrating clean and failing silently later. Host/port that resolve to config/mail.php's own placeholder defaults are treated as unset rather than as a real relay. |
|
|
|
3e4d5472a7 |
Move the one SMTP account into the mailbox table it outgrew
The seed migration also guards against SECRETS_KEY being unset at migrate time (encrypting the .env password would otherwise throw and take the whole migration down, on a fresh install as much as in the test suite), and the four tests/Feature/Mail files written before this migration reset the mailbox table in their beforeEach so they keep testing what they did before mailboxes.key collided with the seeded rows. |
|
|
|
270ec942d6 | Give every sending address a record of its own | |
|
|
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> |
|
|
|
85fcc154b8 |
Measure availability, and let a customer move down again
Availability is now counted from our own monitoring answers. Uptime Kuma knows the figure, but the REST bridge in front of it only answers "healthy, yes or no", and waiting for the bridge to grow an endpoint would have left the panel without the number indefinitely. The sync already asks on a schedule; counting the answers gives a percentage that is ours and explainable. An unanswered check is not a failed one — the counter only moves when a real answer arrived, so our monitoring going down does not become the customer's outage on the one figure they are asked to trust. An instance nobody has ever checked shows no availability rather than 100 %. The downgrade path existed nowhere. Going down is not the mirror of going up: it can ask an instance to hold more than the target plan allows. Smaller plans are now listed even when they cannot be taken, with the obstacle in the customer's own numbers — "you have 31 users, this package allows 10" — because a greyed-out button that does not name what is in the way is the thing people ring about. Storage is checked against the last real reading rather than the contractual allowance, or anyone who once bought a large plan could never leave it. The limit is re-checked in the action: a rule enforced only in markup is not enforced. And the smaller corrections from this round: - The cloud tab carried a hardcoded "B" as the instance's initial, and the PHP and MariaDB versions, which tell a tenant nothing they can act on. - "EU — Serverstandort im Angebot festgehalten" under a label already reading "Serverstandort" was two sentences saying nothing. It is the country. - Support promised a reply "binnen 4 Std." with nothing behind it. What is true everywhere else is an answer on the same working day. - The actions column appeared even when no row in the table had an action. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ee5e92c0ed |
Measure what the template draws
The panel could not show the storage ring or the transfer trend because nothing measured them. instance_traffic keeps one row per billing period — right for an allowance, useless as a series — and storage consumption was never sampled at all, so the card could only ever state what was sold rather than what is used. instance_metrics holds one row per instance per day, written by the traffic collector on the visit it already makes. A second scheduler entry against the same Proxmox API would have doubled the load and the failure modes for no gain. Disk usage comes from `df` inside the guest rather than from Proxmox's own disk figure, which reports the allocated image: that would show every customer a full 500 GB from their first day. The rule the whole thing is built on: a reading that could not be taken is not a reading of zero. The disk columns are nullable and left untouched when the guest agent does not answer, so yesterday's figure stands instead of the ring dropping to empty — which would tell a customer their data had vanished. Missing days stay missing in the series rather than being filled with zeroes, which would draw an outage that never happened. Both are covered by tests. 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> |