Commit Graph

453 Commits (6b8412f0c394981c091fdba767fc2defe3627124)

Author SHA1 Message Date
nexxo 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>
2026-07-29 20:12:26 +02:00
nexxo 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>
2026-07-29 19:42:28 +02:00
nexxo a5ee7bdb67 Release 1.3.29
tests / pest (push) Failing after 7m57s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:37:19 +02:00
nexxo 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>
2026-07-29 19:35:15 +02:00
nexxo 7265799881 Quote what a person pays, and tab the settings page
tests / pest (push) Failing after 7m50s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Prices. The sheet quoted net, which is the wrong number for one of its
two readers: a private customer has no way to add 20 % in their head and
no business being surprised by it at checkout. The gross figure is now
the big one, with the net underneath for a company's books, and the same
rule applies to the module prices further down — a sheet that quotes the
package with VAT and the module without it is a sheet whose numbers
cannot be added up. The rate is the one on the Finance page, the same one
an invoice uses; a percentage written into the template would be the
second source that makes the two disagree.

"zzgl. einmaliger Einrichtung" had run for months without ever naming a
figure, which leaves a visitor knowing only that there is one. It is a
field on the Finance page now and prints with the price. Zero removes the
sentence — and the comparison card that listed the fee as the honest
downside of the offer — rather than admitting to a cost that is not
charged.

Layout. Four packages in 1120px left each card 202px of text, which is
what broke "Bereitstellung in 2-3 Werktagen" over two lines and made the
cards look pressed against each other. The pricing section is wider than
the rest of the page (only it: the reading columns elsewhere are narrow
on purpose, a price sheet is four columns that must be comparable at a
glance), the gap between cards is larger, and the delivery line is short
enough to fit with whitespace-nowrap to keep it that way.

Navigation. VPN moves from Betrieb to System: the tunnel is not a task of
the day, it is how the console reaches the estate at all.

Settings. The page had grown to three headed sections in one column with
the operator's own password somewhere in the middle. It is tabbed now,
and two-factor enrolment is one of the tabs instead of a menu entry of
its own — it is a setting, and it was the one thing an operator had to
leave this page to change about their own access. Same component, no
second copy of the enrolment logic; it still answers at its own route
because RequireOperatorTwoFactor sends an operator there who may not open
anything else yet, this page included.

The open tab lives in the query string, so a reload, a bookmark and the
back button all land where the operator was. That matters here more than
elsewhere: the installation tab refreshes itself while an update runs,
and a tab kept only in the component would drop them back to the first
one mid-deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:18:10 +02:00
nexxo 5b63fdb86c Deliver the storage a customer actually buys
A booked storage pack was a row in `subscription_addons` and nothing else:
priced, frozen, charged every month, and delivered nowhere. Nextcloud's
quota came from `instances.quota_gb`, which is the package alone; the disk
was sized for the package alone; and on the one occasion a disk did grow —
a plan upgrade — the guest never saw it, because nothing stretched the
partition or the filesystem over the new space.

One authority for the allowance. StorageAllowance adds the package and the
booked packs, and everything that needs the figure asks it: the quota step,
the downgrade check, the portal, the console, the repair command. It is
DERIVED rather than stored — a column would have to be rewritten by booking,
cancelling, granting, a grant expiring and a plan change, and the day one of
those paths forgot it would be silently wrong in whichever direction costs
somebody money. `quota_applied_gb` keeps its own meaning: what the guest was
last actually told, which is how a machine with the allowance enforced is
told from one where the figure has only ever been a row in our database.

Buying it delivers it. BookAddon asks ApplyStorageAllowance for a run on
booking AND on cancellation; the new `storage` pipeline grows the virtual
disk to the allowance plus the package's own overhead (read off the package
— 20/40/50/100 GB on the four catalogue packages — never a ratio invented
here), then GrowGuestFilesystem makes the guest see it, then the quota is
applied. No cold boot is involved: the data disk is scsi0 and Proxmox's
resize on a running guest is a qemu block_resize, so the capacity reaches
the guest while it runs. The new step rescans, growparts and grows the
filesystem with the tool its type actually needs — ext2/3/4, xfs, btrfs —
and fails loudly on one it does not know rather than handing it to resize2fs
and hoping. Idempotent end to end: NOCHANGE from growpart is not a failure,
and every tool here exits 0 when there is nothing left to grow.

The same step now runs in the plan-change pipeline, where an upgrade used to
stop one step short of the customer.

A downgrade blocked by data gets a way out. The block stays — it is correct
— but the check now measures against the target package PLUS the packs the
customer already owns, and reports the numbers behind the refusal: what is
stored, what would be allowed, how much has to go, and how many packs would
cover it instead. The portal offers both routes: book exactly those packs
(confirmed in a modal, R23), or delete data and have the fill level measured
on demand rather than waiting for the nightly sampler. That reading is taken
by DiskUsageProbe, which CollectInstanceTraffic now uses too, so there is one
notion of "how full is it" and not two.

A plan change keeps booked packs. They were paid for separately and have
nothing to do with which package the customer is on; the new package's disk
and quota are sized with them included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:13:10 +02:00
nexxo 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>
2026-07-29 18:50:46 +02:00
nexxo 30b1d41c2e Release 1.3.26
tests / pest (push) Failing after 7m47s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:30:27 +02:00
nexxo 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>
2026-07-29 18:28:28 +02:00
nexxo 98d884727a Warn that a package cannot be placed, before somebody pays for it
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
The platform books thick: a package reserves its whole disk_gb — the sold
quota plus the machine's own overhead — on ONE host at placement. That is the
right choice for a small operator, because nothing can quietly overfill. It
has one consequence nobody was being told about: the catalogue goes on
offering a package no host has room for, and the first anyone hears of it is a
PAID order failing at the reservation step.

On the machine this was found on the numbers are stark. 2 x 512 GB NVMe in a
mirror is about 400 GB usable; Start reserves 120, Team 540, Business 1050,
Enterprise 2100. Three of the four packages on sale cannot be placed at all,
and the fourth fits three times.

So the console says so now, per package, with the shortfall — "Team needs 540
GB, the roomiest host has 260" — because "buy a server" is not the decision.
"Buy a server big enough for what I am selling" is.

Two things it deliberately does not do. It does not warn on an installation
with no host at all: that is a machine nobody has onboarded yet, it has its
own notice, and without the guard a fresh console greeted its operator with
one warning per package before they had done anything. And it does not take
the overview down when the catalogue cannot be read — a capacity warning does
not get to break the page somebody opens when something is already wrong.

The largest FREE HOST, never the sum: an instance goes on one machine, so two
hosts with 300 GB each cannot take a 540 GB package between them.

And when placement does fail, the log now names the shortfall instead of only
'no_capacity'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:20:51 +02:00
nexxo 8f2252e81b Book a downgrade for a date that cannot move, and sell a module once
Two ways of charging a customer for something they did not get.

A downgrade was due when `subscriptions.current_period_end` said so, and
Stripe pushes that column forward on every renewal. So a downgrade booked in
March was deferred to the end of April by April's renewal, and to the end of
May by May's — each time by a whole term, and each time the customer was
billed again for the package they had asked to leave. The due date was being
re-derived from a moving target on every scheduler tick.

It is decided once now, at the moment the customer decides, and stamped onto
the contract: `pending_plan` and `pending_effective_at`, the two columns that
have been in the schema since the first migration and were written by nothing
at all. The shop stamps them, PlanChange reads them instead of the period end
when a move is already booked, and clupilot:apply-due-plan-changes finds
contracts by the stamped date rather than orders by a date it recomputes.

The order stays what it always was — the customer's own record of the request,
what the cart shows them and what they remove to change their mind — so it is
still consumed with the change, and removing it unbooks the change. What it no
longer does is decide when. A second downgrade replaces the first rather than
queueing behind it; an upgrade clears one, or it would come due months later
and undo the bigger package; a contract cancelled before the date takes the
booking with it; and a customer with no contract can no longer place a
downgrade at all, which was accepted before and could never be carried out.

A booked change is now visible where the contract is read: on the plan card in
the portal, because the cart entry disappears when it is paid for and the
booking does not, and on the customer row in the console, because that is
where an operator answers "what is this customer on".

The second defect is the same money in one step: BookAddon guarded duplicates
per ORDER — the unique index on (order_id, addon_key) — so two orders for the
same module on one contract both went through. A stale tab or a double click
bought priority support twice.

But storage is deliberately sold in packs, and AddonCatalogue sums the
quantities for exactly that reason, so "refuse the second" is only right for
some of them. Which is which is declared beside each price in
config/provisioning.php (`sold_as`), because it is a commercial decision about
each module and not something to infer from a key: storage is a quantity;
off-site backups are on or off, support is prioritised or it is not, Collabora
Pro is one licence, and a machine answers to one own domain. An undeclared
module counts as an entitlement — the cheaper of the two mistakes — and a test
refuses to let one ship undeclared.

Enforced in the action, where the portal, the console's grant screen and a
webhook all pass, with the sentence the customer is shown rather than a
developer's. A retried webhook for the SAME order still gets its one booking
back; that is one purchase arriving twice, not two purchases. The portal stops
offering what would be refused — booked, or already waiting in the cart — and
goes on offering the pack.

What is missing is on the Stripe side and is not invented here: nothing moves
the subscription's item onto the new Price, so the renewal after a plan change
still bills the old plan at Stripe. StripeClient only creates and archives
catalogue objects, and swapping a price also needs the subscription ITEM id,
which nothing stores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:51:37 +02:00
nexxo 82f95df04c Stop the 503 page appearing during an update
tests / pest (push) Failing after 8m3s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Reported with the network panel open: `update` and `state` both answering
503, then the overlay, then Laravel's 503 page, then a reload. Three
different screens for one event.

Livewire's default on a failed request is to render the response body. So a
wire:poll that happened to fire while the application was in maintenance mode
swapped the whole console for the 503 page — the panel the operator was
watching, replaced by an error page, for something that is not an error. A
deployment IS maintenance mode; that is the event, not a fault.

Three changes, and they are three because the request came from three places:

The status endpoint is exempt from maintenance mode. It is the deployment's
own status, read by the overlay every three seconds, and behind maintenance
mode it answered 503 for the entire run — so the overlay could never name the
step it was on. Safe to exempt: version, commit and a step name, behind the
console's host and network guards, and it writes nothing.

Livewire swallows a 503 instead of rendering it. For every request, not only
the poll: any action taken in that window did the same thing.

And the settings card stops polling for the duration. The overlay's own
watcher is a plain fetch with no component state behind it, which is why it
survives the restart; this poll cannot, and every attempt was a request that
answered 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:50:15 +02:00
nexxo 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 5f74a5f and were gone
again in the tag. Reported for the fourth time, and the fourth report was
right: nothing had changed on the server.

What happened is mine and worth writing down. I have been building commits in
a private index (GIT_INDEX_FILE) to avoid staging a parallel session's
in-flight work, and moving the branch with `git update-ref`. That moves HEAD
without touching the shared index — so the index still described the PREVIOUS
commit. The very next ordinary `git commit` (the version bump) wrote that
stale index as a tree, which reverted every file of the commit before it. The
tag was cut from the revert.

Two things follow. The private-index technique is only safe while the branch
is not checked out; once it is, `git branch -f` refuses for exactly this
reason and `update-ref` is the same operation with the safety removed. And a
release is not verified by a green push — it is verified by reading the tag,
which is what finally found this.

Content unchanged from 5f74a5f: English fragments and legal paths with
permanent redirects, the guarded zone migration, and the test that walks every
route and fragment so a German path fails the suite instead of being reported
again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:36:43 +02:00
nexxo 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>
2026-07-29 17:26:46 +02:00
nexxo 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>
2026-07-29 17:26:20 +02:00
nexxo 6c3cdffc4e Release 1.3.21
tests / pest (push) Failing after 7m51s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:20:19 +02:00
nexxo 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>
2026-07-29 17:18:55 +02:00
nexxo d5b2612e24 Always name the host we are being served from, and stop promising a duration
tests / pest (push) Failing after 8m10s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
── Why the domain list looked wrong ─────────────────────────────────────────
Reported three times, and it was not only the setting. The list came from
`admin_access.site_hosts` and the instance zone — and where SITE_HOST is not
configured, site_hosts is EMPTY. So an installation reached by a name nobody
wrote into the config listed ONLY the instance zone, and described it as the
customers' domain: the page a visitor opened to check our addresses omitted
the address they had just typed, and named the wrong thing as ours.

The host serving the request is now always in the list. It is the one address
that is true by construction and needs no configuration to be right.

(The zone itself is still one setting, and it is now clupilot.cloud on this
machine. On another installation it is Konsole → Integrationen → DNS-Zone.)

── The clock ────────────────────────────────────────────────────────────────
"Eine Zeit angeben geht nicht — wenn es fertig ist, ist es fertig." Correct,
and it was claimed three times over: an eyebrow saying "In 20 Minuten
betriebsbereit", a provisioning log with minute marks down the side, and a
closing line repeating it. A run waits on a hypervisor, on a certificate
authority and on DNS propagating. A customer who once waits forty minutes has
been told something untrue by their supplier, and the page cannot take it back.

What replaces it is what actually IS promised and kept: the order of the
steps, ticked off, numbered 1/5 to 5/5 — and then the sentence that answers
the real question behind "how long", which is not a number but "do I have to
sit here": you get an email with the address and the credentials when it is
ready.

The same claim is gone from the hero meta ("< 20 Min." → "automatisch"), from
the figure line ("20 Minuten bis zum Betrieb" → "0 Termine bis zum Start") and
from the FAQ, which now says outright that we do not name a duration and why.

Kept deliberately: the 45-minute training session and the three-to-seven
working days for setup. Those are appointments we schedule and commitments we
control, not predictions about a machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:10:06 +02:00
nexxo 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>
2026-07-29 16:50:29 +02:00
nexxo 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>
2026-07-29 16:44:52 +02:00
nexxo 085b110e7f Link the phishing page from the section people look in
tests / pest (push) Failing after 11m16s Details
tests / assets (push) Successful in 27s Details
tests / release (push) Has been skipped Details
Reported: "I cannot find that under Sicherheit on the website." Fair — the
page was linked from the site footer, from every mail footer and from under
the sign-in form, and none of those is where somebody goes when the thought
"is this really them?" occurs. They go to the section called Sicherheit.

It is now the last thing in that section, as its own card: what the page is
for, in one line, and where it leads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:30:29 +02:00
nexxo 73313c4d4c Release 1.3.17
tests / pest (push) Failing after 13m8s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:20:30 +02:00
nexxo 4e3beca759 Close the domain page to packages that may not have one
The rule was decided in CustomDomainAccess but nothing asked it on the way
in: /domain answered every customer on every package, so the entry package
could set a domain it is not sold and cannot keep.

The guard sits in mount(), not on the route. A Livewire component answers
POSTs to /livewire/update by itself, so a route-only check would have left
save() and the rest reachable to exactly the customers it was meant to stop.

The sidebar asks the same question through a gate rather than a second copy
of the rule — a customer holds no permissions at all (R21 puts all seventeen
on the operator guard), so can() had nothing to filter on and the tab was
shown to everyone, leading straight into the 403 the page now raises.

Customer::forUser() carries the resolution the Livewire concern used to own,
because the gate has to answer outside any component and a second copy of
its email fallback is how the two would drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:20:21 +02:00
nexxo 6ef9b510ce Merge branch 'feat/plan-reopen'
# Conflicts:
#	app/Http/Controllers/LandingController.php
2026-07-29 16:20:07 +02:00
nexxo 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>
2026-07-29 16:03:13 +02:00
nexxo b3651a14be Decide who may have their own domain, and decide it in one place
tests / pest (push) Failing after 9m46s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The domain page shipped with no access control at all: the `custom_domain`
plan feature was checked nowhere in the codebase, and every customer on
every package could point a domain at their instance. This is the owner's
rule, built as one authority that everything else asks.

  Start        impossible — not bookable, not upgradable, not offered
  Team         optional, via the `custom_domain` module
  Business     included, because the plan version carries the feature
  Enterprise   included, likewise

App\Services\Billing\CustomDomainAccess answers all of it: may this
contract use one, may it book the module, why not in words a customer can
be shown, what a downgrade would do to a domain they have, and — after a
change lands — making the state match. Read against the FROZEN plan
version, never today's catalogue, so a feature added to a package later
cannot reach into a contract signed before it.

Which packages cannot have one at all is an explicit list in
config/provisioning.php, beside the module's price. The catalogue cannot
answer it: plan versions model what a package HAS, and nothing models what
a package may BUY. Both available proxies are dishonest — "the lowest tier
on sale" hands the right to a grandfathered Start customer the day the
owner stops selling Start or adds something cheaper below it, and reading
it off `branding` would sell a domain to anyone allowed to upload a logo.
The config comment says so.

Enforced where it can be enforced today:

  - BookAddon refuses the module, with the sentence the customer is shown.
    GrantAddon now writes its synthetic order inside the transaction, so a
    refusal cannot leave a paid order for a module nobody got.
  - The portal drops the card where it cannot be booked and shows it as
    included where the package carries it — no price, no button.
  - Billing::purchase() re-checks, because a hidden button is markup.
  - DowngradeCheck reports the consequence beside the blockers, so the
    customer reads it above the button rather than after it: losing the
    domain (Team -> Start), or the choice (Business -> Team, where keeping
    it means booking the module).
  - PlanChange::settleCustomDomain() applies it once a change lands: the
    module stops being charged for and the instance goes back to its
    platform address. It clears the whole domain, not only the verified
    flag — clupilot:verify-domains re-reads every row that still has a
    domain and a token, and would switch it back on the next night.
  - The price sheet's own-domain row now reads dash / optional + price /
    included, from the same authority; the three cell states already
    existed and the view is unchanged.

STILL TO APPLY, ONCE THIS AND THE CUSTOM-DOMAIN BRANCH MEET — one line at
the top of App\Livewire\CustomDomain::mount(), which belongs to the other
session and is deliberately untouched here:

    abort_unless(app(\App\Services\Billing\CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);

routes/web.php is left alone for the same reason. The mount() guard covers
the route and the component both; a middleware would only repeat it.

Follow-ups, named rather than built:

  - Nothing applies a plan change anywhere yet, so settleCustomDomain()
    has no caller. Whoever builds that must call it after the new contract
    opens, and must decide whether a booked module is carried onto the new
    contract — enforce() cancels the booking on the contract it is given.
  - Deactivation sets the state; it does not re-run provisioning. Traefik
    and Nextcloud's trusted_domains still name the old address until
    ConfigureDnsAndTls / ConfigureNextcloud run again.
  - App\Support\Navigation still shows the Domain tab to every customer;
    it can ask allowsCustomer() the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:59:04 +02:00
nexxo a4626d2569 Stop root workers breaking every page, and let the panel be closed
tests / pest (push) Failing after 12m38s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details
── The 500 ─────────────────────────────────────────────────────────────────
"touch(): Utime failed: Operation not permitted", from BladeCompiler, on
every request for the affected view.

Blade compiles a view and then touch()es the compiled file to the source's
mtime. touch() with an explicit time needs ownership of the file. A compose
service without `user:` runs as ROOT — and queue, scheduler and reverb had
none — so the first worker to render a view wrote a root-owned compiled file
that the web process, as www-data, could never refresh again. The queue is
what renders mails, which is why this surfaced now.

queue, reverb and scheduler run as www-data. queue-provisioning stays root
and says why: it brings wg0 up and runs `wg set` for every peer change, which
needs NET_ADMIN on the running process. It renders no mail.

And update.sh normalises ownership at the END of a run as well as at the
start. The first call heals what a previous run left; the second heals what
this one made — `git checkout` rewrites the tree as the service account while
the old build is still serving.

── The panel that would not go away ─────────────────────────────────────────
The same 500 is why it kept coming back: every poll failed, the watcher
treats a failed request as "still restarting" (which it normally is), and the
overlay stayed up over a console nobody could then reach to find out why.

A restart is seconds. After two minutes of nothing the panel now says so and
offers a way out — for an operator only, behind the same flag as the step and
the log, so a customer on the 503 page is not shown a button suggesting they
can call the deployment off. A successful answer clears it again: one bad
minute must not leave a "something is wrong" notice sitting there for the
rest of the run.

Not an always-present close button. The deployment does not stop because
somebody dismissed a panel, and offering that at the wrong moment is a lie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:43:54 +02:00
nexxo da76a260fb Give the settings page an outline
tests / pest (push) Failing after 8m0s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Reported as cramped boxes with empty space either side, and it was: one
column half the window wide, eight cards of identical weight stacked in an
order nobody chose — the operator's own password sitting between two
platform-wide switches — and every card taking a full row to hold three lines
of text.

The page is wider now and has three groups, each with a heading: what belongs
to the installation, what belongs to the signed-in operator, and the team.
Short cards sit side by side instead of one per row. The two-factor policy
moved into the installation group, where it belongs: it is a rule for
everybody who signs in, set by the owner, not a setting on one account — and
it fills the space the short website card was leaving beside the long
allow-list.

Fields are sized to what goes in them. The three password boxes were laid out
three across inside a narrow card; they stack in a half-width one. The window
times are four characters and were as wide as their column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:29:07 +02:00
nexxo 517606b4db Split the price sheet into what everyone gets, what differs, and what costs extra
One boolean matrix was carrying three different kinds of statement, and it
made all three unreadable.

Updates, backup and monitoring were a tick in every column — a comparison
that compares nothing — and the same promises were repeated in a prose line
under the table. They are now stated once, above it, together with the
platform address in the shape a customer will actually type
(ihrefirma.<zone>, from the configured zone) rather than as the phrase
"eigene Subdomain", which said nothing and was modelled as though an own
domain replaced it. Provisioning issues both.

Support was two independent booleans on one axis, so enterprise — which
carries the SLA and not the priority flag — rendered "Bevorzugter Support —"
beside team's tick: the top plan looking worse than the middle one. It is
one row of levels now, and a plan with neither shows Standard, because
standard support is a level and not an absence.

A cell had two states, so a dash meant both "you cannot have this" and "you
can have this for nine euros". There are three now, and the middle one
quotes AddonCatalogue — which is also how the modules we have been selling
all along finally appear on the page at all. Nothing is invented: where we
sell no module for a missing feature, the cell stays a dash.

custom_domain joins the module list at the owner's figure so the two plans
without it can be quoted a price instead of told no.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:25:20 +02:00
nexxo 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>
2026-07-29 15:16:48 +02:00
nexxo 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>
2026-07-29 15:01:54 +02:00
nexxo d4e8822dbb Paketversionen: Verkauf wieder aufnehmen und saubere Übergabe
tests / pest (push) Failing after 7m58s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Eine geschlossene Version konnte nie wieder in den Verkauf. Schlimmer: um
ein Paket zu ändern, musste die laufende Version zuerst beendet werden —
und wenn die Nachfolgerin dann nicht sofort veröffentlicht wurde, war das
Paket dauerhaft dunkel.

- PlanCatalogue::reopen() setzt available_until zurück auf null, über
  schedule(), damit Family-Lock und Überlappungsprüfung dieselben bleiben.
  Ein nie veröffentlichter Entwurf wird abgelehnt.
- PlanCatalogue::publish() übergibt eine laufende Version an ihre
  Nachfolgerin (available_until = deren Start) statt die Überlappung
  abzulehnen — unter demselben Lock, in derselben Transaktion. Nur die
  unmittelbare Vorgängerin, und nur wenn es genau eine gibt.
- Konsole: Knopf „Wieder verkaufen", übersetzte Ablehnung statt der
  englischen Ausnahme, und die Übergabe wird im Veröffentlichen-Formular
  vorher angekündigt (Version und Zeitpunkt, ->local()).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:58:11 +02:00
nexxo 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>
2026-07-29 14:47:51 +02:00
nexxo 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.
2026-07-29 14:31:44 +02:00
nexxo 17a6fabcc2 One wordmark, one typeface, and the address the server actually issues
tests / pest (push) Failing after 11m28s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Reported as "logo and font are different everywhere" — maintenance page,
customer panel, console, homepage. They were. Counting them up: the site
header set the name in ink at -0.02em, the sidebar split it into "Clu" plus
an orange "Pilot", the sign-in plate set it white at a third size, the
customer two-factor page set it in MONO with no mark at all, the error pages
and the placeholder each inlined their own — and the maintenance screen had
no wordmark whatsoever. Nine surfaces, seven ideas of the brand.

There is one now: resources/views/components/ui/brand.blade.php. Mark plus
wordmark in one piece, in ink, with an optional muted "Cloud". No orange half
— that made the accent a permanent fixture instead of something used
sparingly. The four places that cannot use a Blade component (the two
self-contained pages, the error layout, the mail layout) inline it and are
held to the same shape by a test.

The typeface. The console loads IBM Plex through Vite; the 503 page has no
stylesheet at all, so the maintenance screen — the one page shown when
somebody is already worried — was rendering in the system sans. It declares
the fonts itself now. And the bold weight was missing from public/fonts
entirely: the placeholder and the error pages both asked for
ibm-plex-sans-latin-700-normal.woff2, which was not there. A @font-face
pointing at nothing fails silently, which is why nobody noticed.

The error pages carried the same two faults the placeholder shipped with: an
empty gradient tile where the mark belongs, and `gap` on an inline-flex row
spacing "Clu", "Pilot" and "Cloud" apart as three separate words.

And the price sheet promised "Adresse auf clupilot.cloud" — a domain the
company does not own — while provisioning was issuing addresses under
whatever CLUPILOT_DNS_ZONE says. It is assembled from that setting now, the
same reason the prices are read from the catalogue rather than typed into the
page.

welcome.blade.php removed: Laravel's leftover, reachable from no route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:30:44 +02:00
nexxo 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.
2026-07-29 14:22:22 +02:00
nexxo 012d0f5092 Say EU, and stop promising to fix somebody else's deleted folder
tests / pest (push) Failing after 8m13s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
Two corrections from a read of the live pages.

The location. Three public places named countries or a city — "aus deutschen
und finnischen Rechenzentren" on the placeholder, "in Österreich und
Deutschland" in the FAQ, "EU · Wien" in the product panel — and they did not
even agree with each other. They all say EU now, which is the thing that is
promised, the thing the AV-Vertrag pins down, and the thing that stays true
when a host moves.

The same string in the customer dashboard was worse: 'EU · Rechenzentrum
Falkenstein' was hard-coded and shown to every customer whatever host their
instance actually runs on. A specific claim nobody derived from the record.

The closing headline asked who you call when a folder is missing on a Monday
morning. It promised the wrong thing: what happens inside a customer's own
Nextcloud is their own house — the recycle bin and the version history are
right there — and presenting ourselves as the first call for somebody else's
user error offers a service we do not run. It now asks who looks after
updates, backups and outages, which is what the comparison section already
says in the operator's own words and what the product is actually for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:17:30 +02:00
nexxo b843c10ec3 Give the update screen the product's colours, and only one window
tests / pest (push) Failing after 8m16s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Two complaints, both correct, both mine.

Dark blue. The panel carried a `prefers-color-scheme: dark` block — the only
one in a product that has no dark mode. On a phone set to dark the update
screen, and nothing else, turned dark blue. A dark mode for one page is not a
dark mode; it is one page that does not match. It is gone.

And even in daylight the panel was not this product's colours: it used
Tailwind's slate ramp (#0f172a, #64748b, #e2e8f0), which is blue-tinted,
against neutrals that are warm. Every value is now a copy of a token from
portal-tokens.css, with the comment saying so, because this file cannot
import them — it renders when the asset build may not exist.

The small window then the big one. The settings card grew its own little
"läuft gerade" block the instant a run started; up to three seconds later the
full-page overlay covered it and said the same thing in a different size. The
card no longer says it at all, and the overlay now opens on the button's own
click — requestUpdate dispatches an event, but only once a run is actually
pending, so a refused second press does not black out the console.

Two more things found while looking:

The panel had `min-height: 100%`, which resolves against the parent's height.
On the 503 page the parent is <body>, whose height is auto — so it computed
to zero, the panel was only as tall as its text, and the bottom two thirds of
the screen stayed the browser's default white. Viewport units now, dvh so a
phone's collapsing address bar leaves no strip either.

And the mark was a plain orange tile again, the same empty-square that was
just fixed on the placeholder. The real logo is inlined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:11:01 +02:00
nexxo 4755a4f362 Redesign the pre-launch placeholder, and fix its wordmark and mark
tests / pest (push) Failing after 8m7s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Reported from a phone on status.clupilot.com, which serves this page because
the site is still switched to private.

Two real defects behind the complaint:

The wordmark read "Clu Pilot Cloud". `gap` on a flex container applies
between EVERY child and a bare text node is a child — so an inline-flex row
wrapped around "Clu", <i>Pilot</i> and " Cloud" put nine pixels between each
of them. The wordmark is one element now and the gap only ever separates it
from the mark.

The logo was a plain gradient tile with nothing in it, which reads as an
image that failed to load. That is a poor thing to leave somebody with on a
page whose whole message is "we are building this properly". The real mark —
cloud plus autopilot ascent — is inlined; it has to be in the file because
this page renders when the asset build may not exist at all.

The redesign itself: the card is gone. The page is now a centred composition
on the open ground — the state as a pill above everything, the mark, the
headline at the size the rest of the site uses, and the three promises the
paragraph was already making set as their own line so there is something to
look at besides one block of text. One warm bloom behind it, drawn as a
gradient rather than a blurred disc, which banded into visible rings.

Still self-contained, and still the only page exempt from the shared
stylesheet: it is served mid-deploy and on a fresh install, where @vite would
throw instead of rendering the reassurance somebody came for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:43:56 +02:00
nexxo 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>
2026-07-29 12:45:18 +02:00
nexxo 3b85e8f1d1 Grant a package or module from the customer's row in the console
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
GrantPlan is a modal (R20), opened from a "Schenken" button on the customer's
own row in Admin\Customers, gated by customers.grant_plan rather than merely
hidden. It offers a whole package (plan, term, datacenter, an optional quota
bonus baked into the frozen snapshot) or a module on an existing contract,
either free or at a discount the operator sets against today's catalogue
price. Existing grants show on the same modal — who gave what, when, and
until when — with a warning once a dated grant is nearing its end; nothing
lapses on its own, exactly as decided.

The customers list also badges a granted plan in the Paket column.
2026-07-29 12:42:21 +02:00
nexxo c71b2c362b Skip the invoice for a full gift, keep it out of revenue, hide its price
IssueInvoice now rejects free-grant orders before it will consume an invoice
number for them — a full gift produces no invoice at all, while a discounted
grant still charges something and is invoiced exactly like an ordinary sale.
Keyed on the linked subscription/add-on's own provenance, not on the amount
being zero, so a genuinely fully-discounted Stripe checkout is unaffected.

Revenue excludes granted contracts from MRR/ARR/ARPU and the contracts count
— a gift at 0 must not drag the average down — and shows their number as its
own KPI instead of folding it in, since the owner measures himself on it.

The portal (Cloud, Billing) shows a granted package or module without a
price at all: not "free", not struck through, just the service, so a later
conversion to paid does not read as a negotiation.
2026-07-29 12:41:32 +02:00
nexxo 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.
2026-07-29 12:41:06 +02:00
nexxo 696c8834b8 Redesign the public website instead of recolouring it
tests / pest (push) Failing after 8m15s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Correctly rejected: the last pass was the old page in new paint. It kept the
skeleton — eight numbered sections, a split hero, a twelve-row comparison
matrix — and changed only the palette. A palette is not a design.

What is actually different now:

- The § numbering is gone. Numbered sections with a drawn rule are a document
  convention, and the document voice is exactly what the product's design
  system dropped.
- The hero shows the product instead of describing it: centred type, then a
  wide panel built from the real console's own tokens, cropped by the section
  below. The old hero put a table of facts beside the headline, so a visitor
  read two columns before seeing anything.
- Six identical cards, then six more, then six more became one asymmetric
  grid — a large panel, a dark tile, an accent tile, a wide module row — so
  the important thing is allowed to be bigger than the rest.
- The industries are one dense two-column list instead of six cards repeating
  each other's shape.
- The page has one dark hinge in the middle (the deployment) rather than a
  dark section among seven light ones.
- The evidence table became rows that pair the measure with the piece of
  paper you can hand somebody, which is what it is for.
- Prices are cards, with the full matrix folded away underneath. The matrix
  used to be the first thing a visitor met: twelve rows of ticks before a
  single price was legible.
- The FAQ heading sits still while the questions scroll past it.
- The footer is a real footer.

The header is a floating bar rather than a full-width band, and opaque in
both states — translucency plus backdrop-blur reads as a rendering fault over
the dark panels that scroll under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:43:25 +02:00
nexxo 43d58de7f8 Rebuild the public website in the shared design system
Reported plainly: the site is still in the old style. It was — it was the
last of the three surfaces the token file describes that had never been
converted. landing.blade.php carried its own complete design system inside a
<style> block: warm paper, IBM Plex Serif headlines, 3px corners, its own
scale of greys, written before the tokens existed. Somebody who clicked
"Anmelden" left one company and arrived at another.

The page now draws from resources/css/portal-tokens.css like the portal and
the console: same surfaces, same radii, same button hierarchy, same
small-caps label, one family at weight 700 instead of a serif. What still
differs is rhythm, which is what a marketing page is allowed to differ in.

Content is unchanged. New: components/layouts/site.blade.php holds the header
and footer for every public page, and legal.blade.php uses it too — the
imprint used to be a stranded card in a third style again.

Two things found on the way:
- .lbl set `color: var(--muted)`, which is not a token. An undefined var() in
  `color` computes to `unset`, so every small-caps label on every surface was
  whatever colour its container happened to be.
- The big figures were set in mono. Mono gives the decimal comma a full glyph
  cell, so "99,95" read as three separate numbers.

coming-soon and the status page stay self-contained on purpose — both are
served when the application may not have a built stylesheet at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:26:35 +02:00
nexxo f8f0de4892 Let an update install host packages, without handing out root
Asked for directly: "später wenn ich etwas lokal erweitere will ich mich
nicht auf alle adminseiten einloggen müssen und extra rsync installieren".

An update runs as the service account, so anything root-owned on the host is
out of its reach and turns into "log into every server once and paste this".
The obvious fix — sudo on deploy/install-agent.sh — is not a grant at all:
the service account owns the checkout and can rewrite the very script it
would be allowed to run as root.

So the privileged part is written OUT of the checkout by install-agent.sh, to
/usr/local/sbin/clupilot-host-step, root:root 0755, from a quoted
here-document. The service account cannot influence a byte of it. sudoers
names one exact command line including its argument, so a step added to the
helper later is not covered by a grant written before it existed, and the
helper refuses anything not on its own list as well.

update.sh uses it only when rsync is actually missing, after the restart and
never fatally: the archive is collected hours later, and an update that died
over a package would be the bigger problem. A helper older than the steps the
updater wants is reported through the existing one-time-setup hint — silently
doing nothing is worse than an error.

Existing servers still need `sudo bash deploy/install-agent.sh` once, because
that is the run that puts the helper there. After it, they do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:27:56 +02:00
nexxo 0671e8e119 Show one design while updating, not two swapping mid-run
Reported from live: pressing update showed a blue panel first, which then
vanished and was replaced by a completely different "we are updating"
screen. Both were mine. The console drew its own overlay in the app's light
tokens; a moment later the containers went down and Laravel's 503 page took
over with its own standalone styling. One event, two faces, swapping while
an operator watches.

There is now one panel — resources/views/partials/updating-panel.blade.php —
rendered by both. It carries its own inline CSS and cpu- prefixed class
names because the 503 page is served with the application down: no compiled
stylesheet, no Tailwind, nothing but what is in that file. Dark mode comes
from prefers-color-scheme for the same reason.

The operator detail (step, running since, log tail) rides inside the panel
behind a flag, since the same file is shown to customers on the 503 page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:20:40 +02:00
nexxo 15b536f393 Mint the archive collection key from the console instead of by hand
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Setting a NAS up to collect the invoice archive meant three machines and a
dozen commands: generate a keypair there, carry the public half to the server,
write it into authorized_keys with a restriction, install rsync, and get every
path right on the first try. Every one of those steps was a place to be told
"permission denied" with no clue which of the three was wrong. It was, and
several times over.

One button now. The panel asks, the host does it — because the host is where
all of it lives: the home directory, ssh-keygen, rrsync, and the archive itself.
The panel is www-data in a container and owns none of that, so it uses the
mailbox it already uses for updates. The private half is shown exactly once,
alongside the finished rsync command, and is never written to the database: it
exists to be copied into a NAS, and storing it "for convenience" would put a
working credential in every backup of that database.

rrsync does the restricting, not a pinned rsync option string. That string
differs between rsync versions and fails silently — a refusal with no reason
given, which is the shape of the afternoon this replaces. If rrsync is missing
the agent refuses rather than issuing an unrestricted key while the panel says
it is restricted.

rsync now comes with install-agent.sh, which already runs as root once per
machine. It has to be on the HOST: a NAS connects by ssh and sshd starts
`rsync --server` here, so without it the pull fails with "command not found"
from a NAS whose own setup is perfectly correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:08:32 +02:00
nexxo 186f7f2de7 Keep exported invoices out of the repository
tests / pest (push) Failing after 7m50s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
storage/archive is runtime output, and on a real server it is a mount point.
Committing it would put customer documents into git and make a deploy fight the
archive for the directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 09:56:00 +02:00
nexxo 132be7fdd5 Leave the archive readable by whoever collects from it
tests / pest (push) Failing after 7m32s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Reported as "the folder arrives empty". It was not empty. The folder was 0700
and owned by the account that wrote it, so the account that COLLECTS — an rsync
over ssh, a backup agent, anything that is not the writer — could not enter it,
and an unreadable directory looks exactly like an empty one. The invoice had
been sitting in it the whole time.

The cause is that the default follows the process umask, so under a restrictive
one 0755 quietly becomes 0700. The permissions are declared on the disk now
rather than left to inherit, and a test writes under umask 0077 and asserts the
mode — the ordinary umask hides this completely, which is why it was found on a
server and not here.

Two mistakes of mine on the way to it, both worth recording. I read the folder
as the wrong user, got no entries back, and concluded the file had been deleted
— it was a permission failure reading as an unprivileged account, not a missing
file. And the first fix was a chmod on a variable that does not exist in that
method, silenced by an @ so it failed without a word. Flysystem takes its
permissions from configuration; a chmod afterwards was the wrong layer even
before it was the wrong variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 03:48:03 +02:00