Commit Graph

83 Commits (main)

Author SHA1 Message Date
nexxo 7880ac7cf5 Release v1.3.77
tests / pest (push) Failing after 8m33s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
The DNS check now requires a real zones array before it says anything about the
account. A 200 carrying an HTML page — a portal, a filter, a proxy — was being
read as "no zones at all", which is what the live server was seeing while the
token had read and write and the zone existed with fifteen records.

An intercepted response is now reported as one, with its status and the first
characters of what actually came back.

2053 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:42:46 +02:00
nexxo 64045e0a04 Release v1.3.76
tests / pest (push) Failing after 8m37s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The DNS check no longer reports an unclassified HTTP failure as "this account
holds no zones". Anything other than a successful zone list is now named as
what it is, with its status code, and says explicitly that it tells you nothing
about the zones.

2050 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:37:01 +02:00
nexxo f9620f29a3 Release v1.3.75
tests / pest (push) Failing after 8m55s Details
tests / assets (push) Successful in 29s Details
tests / release (push) Has been skipped Details
The readiness page no longer shows a green badge over a red measurement. A
check that was actually run decides the badge; the passive "something is
configured" answer only fills in where nothing was measured.

zone_not_found now names the zone it looked for and the zones the token can
actually see, so it stops reading like "the token is wrong" — a wrong token
never gets that far.

Also: two traffic tests failed on the 29th, 30th and 31st of long months,
because they built "last month" with subMonth() and Carbon resolves that
calendrically. The production code was never affected.

2045 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:26:50 +02:00
nexxo d3f2533565 Release v1.3.74
tests / pest (push) Failing after 8m19s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
IP addresses no longer appear in the certificate overview. 127.0.0.1 comes from
ADMIN_HOSTS, where a bare IP is deliberately allowed as the way back into the
console — but no authority issues certificates for one, so the row was
permanently red and unfixable.

The row already stored is removed on the next sync. A vanished config entry that
holds a live certificate becomes a manual entry instead of disappearing.

2043 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:02:07 +02:00
nexxo d013124c12 Release v1.3.73
tests / pest (push) Failing after 8m19s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
The hostnames page fills itself from the installation instead of starting
blank. Every name this installation answers on — from SITE_HOSTS, APP_HOST,
FILES_HOST and ADMIN_HOSTS — appears with its certificate and the days it has
left, without anybody typing it in.

Four counts at the top: total, valid, expiring soon, without a certificate. A
daily run at 04:17 keeps them current, because the question is not whether a
certificate is valid today but whether renewal is running.

2040 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 00:53:41 +02:00
nexxo 1e3199b72e Release v1.3.72
tests / pest (push) Failing after 8m45s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
install-agent.sh now creates /etc/caddy/clupilot-proxy-hosts.conf and adds the
import line itself, validating afterwards and taking the line back out if Caddy
rejects it. The previous release told the operator to add that line by hand
while nothing created the file — and an import of a missing file makes the whole
proxy configuration invalid, which would have taken the portal down with it.

A hostname already written by hand in the Caddyfile is skipped when the managed
file is rendered. Two blocks for one name make Caddy reject everything.

Run once per server:

  sudo bash /opt/clupilot/deploy/install-agent.sh

2037 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:35:38 +02:00
nexxo dac84f024a Release v1.3.71
tests / pest (push) Failing after 8m16s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The hostnames page is now in the navigation under Betrieb — it had a route and
no entry, which meant it existed only for whoever knew the URL.

And files.… no longer redirects to app.… when called without a path. It answers
404, like every other address in this installation that has nothing to offer:
the redirect told anyone who tried the name where the portal lives and that both
share a machine.

2037 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 23:21:39 +02:00
nexxo 3c5bded19c Release v1.3.70
tests / pest (push) Failing after 8m57s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Hostnames and certificates from the console: add a name, see whether it has a
certificate and for how long, and apply the list to the reverse proxy without
logging into the server.

Two numbers are skipped and both were my mistake, so they are named here rather
than left as gaps somebody has to work out later. v1.3.68 was cut while the
working directory was still inside the feature worktree, so it landed on the
branch instead of on main; this release carries it. v1.3.69 was pushed pointing
at v1.3.67's commit. Deleted rather than moved, both times: a published tag that
changes what it points at is worse than a number nobody uses.

This release needs one thing done once per server, because the root-owned helper
gained a verb:

  sudo bash deploy/install-agent.sh

and one line in /etc/caddy/Caddyfile:

  import /etc/caddy/clupilot-proxy-hosts.conf

The installer creates that file before anything imports it — an import of a
missing file makes the whole proxy configuration invalid, and that takes the
public site with it.

2035 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:57:47 +02:00
nexxo 87264ef1f7 Merge console-managed hostnames, and put the release line back on main
One conflict, in VERSION, and it is worth naming rather than resolving quietly.

v1.3.68 was cut on the FEATURE branch, not on main: the working directory was
still inside .worktrees/host-bootstrap when the merge-and-tag ran, so the merge
was a no-op against itself, the version commit landed on the branch, and the tag
went with it. main stayed on 1.3.67 while a tag claimed otherwise.

This merge brings that commit onto main, so v1.3.68 becomes reachable from the
line it always claimed to be on, and the version continues at 1.3.69 rather than
pretending the number was never used.

The project's own memory records this exact failure — check which branch you are
on before every commit — from the time two releases landed on feat/granted-plans
while live stayed where it was. It was a parallel session then and a stale
working directory now; the rule is the same one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:56:49 +02:00
nexxo a3b43c5175 Release v1.3.68
The reference Caddyfile now carries a block for FILES_HOST. Setting the variable
alone was never enough: the proxy has to terminate TLS for that name and forward
it, and without a site block Caddy never even asks for a certificate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 22:34:15 +02:00
nexxo fced87d23a Release v1.3.67
tests / pest (push) Failing after 8m48s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Add host rebuilt as a page with one job: a sticky rail of six short steps, the
two provider prerequisites in a panel above the form, and after saving the
command line as the page itself.

Downloads now pass PublicSiteGate. While the site is hidden, a server in a
rescue system would have received the 503 placeholder and piped it into tar — an
unpack error on a machine only reachable through the provider's console.

The number skips 1.3.66. That tag was pushed pointing at v1.3.65's commit,
because the merge behind it had not actually happened, and a server pinning it
would have installed the older tree. It was deleted rather than moved: a
published tag that changes what it points at is worse than a number nobody uses.

2026 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:53:59 +02:00
nexxo fc9cc20610 Release v1.3.65
tests / pest (push) Failing after 8m51s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
A hostname of its own for downloads. Public: the terms, the DPA and the TOM,
anonymous and indexable, under versioned filenames so an address in a signed
contract keeps meaning what it meant. Private: the bootstrap archive, gated by
the one-time enrolment code that is already in the copied line.

Set FILES_HOST to switch it on. Left empty, the archive stays on the portal
host and nothing changes.

2025 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:37:48 +02:00
nexxo 1e22d66639 Release v1.3.64
tests / pest (push) Failing after 8m53s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Two features reach a tag at once, because the host-takeover branch was cut from
the operating-mode branch and carries all of it.

The operating mode: a test and a live setting for the whole installation, every
credential with a test slot beside its live one, a stored key that says which
mode it belongs to, and a Stripe catalogue that refuses to act on objects from
the other account.

The host takeover: the bootstrap script that turns a machine in a rescue system
into a finished Proxmox host, the archive that serves it, the one-time enrolment
code, and the six-step guide in the console under Hosts and Add host. None of
the script's steps has been accepted on real hardware yet — every hardware step
in its plan is still unticked, and the console work is what this release makes
visible.

Also in here, found while writing the takeover: host names were being built from
the CUSTOMER zone. RegisterHostDns says clupilot.com in its own docblock, and on
this installation a host was actually called fsn-01.node.clupilot.cloud. There
is a platform_zone now and the step uses it.

2017 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:22:00 +02:00
nexxo 69a9322a4e Release v1.3.63
tests / pest (push) Failing after 8m36s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:59:16 +02:00
nexxo 4b0eebeccc Release v1.3.62
tests / pest (push) Failing after 8m35s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:38:57 +02:00
nexxo f25a0030f0 Release v1.3.61
tests / pest (push) Failing after 8m45s 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-30 17:30:55 +02:00
nexxo 976637afb2 Release v1.3.60
tests / pest (push) Failing after 8m51s 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-30 17:19:39 +02:00
nexxo 3b0c6b19b9 Release v1.3.59
tests / pest (push) Failing after 8m48s 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-30 17:12:30 +02:00
nexxo 56daaef7de Release v1.3.58
tests / pest (push) Failing after 8m40s 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-30 16:56:58 +02:00
nexxo 48581c3978 Release v1.3.57
tests / pest (push) Failing after 8m31s 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-30 16:41:32 +02:00
nexxo 74d925bc66 Release v1.3.56
tests / pest (push) Failing after 8m40s 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-30 16:25:10 +02:00
nexxo 7da2939c55 Release v1.3.55
tests / pest (push) Failing after 8m19s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:05:56 +02:00
nexxo d4b70b7477 Release v1.3.54
tests / pest (push) Failing after 8m32s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:00:09 +02:00
nexxo de3ecacc13 Release v1.3.53
tests / pest (push) Failing after 8m6s 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-30 15:44:45 +02:00
nexxo ba6ed43219 Release v1.3.52
tests / pest (push) Failing after 8m32s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:22:45 +02:00
nexxo 4ac9fb1d03 Release v1.3.51
tests / pest (push) Failing after 8m28s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:53:07 +02:00
nexxo 3e7026407b Stop inventing a package, and rebuild the booking page
tests / pest (push) Failing after 8m12s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Two pages said opposite things about one account, straight after
registering: the dashboard said "noch keine Instanz", and Paket & Addons
said "AKTUELLES PAKET Start — Status Aktiv". The dashboard was right. The
package page fell back to `$instance?->plan ?? 'start'` and rendered that
fallback as a contract the customer had entered into, complete with a status
of Aktiv, on the one page somebody opens to find out what they pay.

The contract is resolved from the CUSTOMER now, not from the instance — a
paid order parked for want of a machine has a subscription and no instance
yet, and asking the instance would tell somebody who has paid that they have
no package. An instance counts as well, whatever its subscription row says.
With neither, the page says so and points at the booking page: an "Upgrade"
from nothing is a purchase, and that has its own page.

The cart stays outside that gate. A customer can have something in it before
they have any contract at all, and hiding it there would hide the thing they
came to pay for — which is what my first version did, and seven existing
tests said so.

And the booking page. Five cards in a four-column grid left the fifth alone
on its own row, reading as a mistake; the consent box sat at the BOTTOM with
every buy button disabled until it was ticked, so the page arrived looking
broken before anybody had done anything wrong; and the price ran across the
card in one block with nothing to compare.

Now the consent comes first, because it is the condition for everything under
it, and it colours itself green once given. Three columns, so five plans read
as 3 + 2. Each card is the same four rows in the same order — price, delivery,
what you get, the button — so the eye can run across a row and compare like
with like. The button stays ENABLED with the reason it will not go through
said under it: a disabled control explains nothing, and the server is the gate
either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:29:22 +02:00
nexxo 2321854967 Discard an unconfirmed registration, and sweep the abandoned ones
tests / pest (push) Failing after 8m17s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
"Abmelden und neu registrieren" was advice that could not work. Signing out
frees nothing: the address is still held by the unique index, so registering
again with the address somebody meant fails — and every abandoned attempt
stayed in `users` for ever. Reported exactly that way, and it was right.

The button discards the account instead. Only an UNCONFIRMED one, and only
one with nothing behind it: the webhook creates a customer's login from a
paid checkout, so a user can sit on this page unconfirmed while already
having a contract, and deleting that is data loss dressed up as a
convenience. It refuses and says where to go. A confirmed account is closed,
not discarded — that is ConfirmCloseAccount, with an invoice history behind
it.

R23: confirmed in a modal. The modal mutates nothing; it dispatches the event
the page listens for, so the checks stay in the one place they already are
rather than being duplicated where they could drift.

And nothing has to be discarded by hand. clupilot:prune-unverified removes
registrations nobody confirmed after five days — long enough for somebody who
signed up on a Friday and found the mail in a spam folder on Monday, short
enough that a typo does not hold the correct address for a month. It skips a
confirmed account whatever its age, and any account with a customer record
behind it, matched on user_id AND on the address, because either link means
somebody is a customer. Every removal is logged with the address: a line that
disappears is what somebody asks about later, and a count answers nothing.

The deadline is said on the page as well as in the mail. Somebody who never
received the mail is looking at the page, and the deadline is the reason they
do not have to do anything about an attempt they abandon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:08:02 +02:00
nexxo f49f1dca09 Say when no mail is being delivered at all
tests / pest (push) Failing after 8m40s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details
"Ich versuche mich zu registrieren, erhalte aber keine Verify-Mail, auch
nicht beim erneuten Senden." Traced rather than guessed, and the evidence is
in storage/logs: the address appears three times, so the mails were built and
handed to a transport that writes them to a file.

MAIL_MAILER=log. MailboxTransport::resolution() short-circuits on it —
NON_DELIVERING is ['log', 'array', null] — so every purpose mailer becomes
the log transport however well the five mailboxes are configured, which is
correct for a development machine and exactly what was happening here.

The reason it deserves a notice is that the failure is silent in every
direction an operator would look. Mail::queue() succeeds. No job fails. The
queue empties. And the console's own mailbox test reports SUCCESS, because
MailboxTester builds its own transport on purpose — a check that honoured
MAIL_MAILER=log would report success while writing to a file. So every
instrument on the page said fine.

Now the front page says it, linked to the mail page, and the mail page says
it above the mailboxes it makes decorative. All three swallowing values are
named, not only 'log': array and an unset default do the same thing, and a
notice that only knew about one would be silent in the two cases nobody
thinks to check.

Two existing tests asserted that a clean installation raises no notice, and
went red — correctly, because phpunit.xml forces MAIL_MAILER=array so a suite
cannot send mail. They pin a delivering mailer now: the warning is about the
environment, those tests are about the estate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:55:55 +02:00
nexxo 70fa136e22 Say on the front page that the site is switched off
tests / pest (push) Failing after 8m54s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
"Ich komme auf dem lokalen Dev-Server nicht auf www.dev." Diagnosed rather
than guessed: Laravel answers 200 on that host, and the request never
reaches it — site.public is false, so PublicSiteGate returns the placeholder
with a 503 to every visitor who is not on the management VPN and not signed
in as an operator. Which is precisely what a broken deployment looks like
from outside, and the only place that said so was the switch itself, on
another page.

So the console says it now, on the page an operator opens when something
looks wrong, and the notice links to the switch.

The second half of the same report — being sent to app.… — is the missing
SITE_HOST. Without it the marketing site is registered without a hostname:
it answers on the PORTAL's host as well, and every route() it generates
points at APP_URL, so a link on the site lands on the portal's domain. Also
said, also with the host named.

Only where the installation is host-separated at all, though. A fresh
checkout binds nothing to a hostname by design — RestrictAdminHost:
"upgrading must not lock anyone out of a system that was working" — and a
warning there is furniture. My first version fired on every clean
installation and two existing tests said so immediately; they were right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:36:42 +02:00
nexxo f9993a1f48 Name the server variable under a credential, not the internal key
tests / pest (push) Failing after 8m35s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Under each credential stood `stripe.secret`, `dns.token`,
`inbound_mail.password`, `monitoring.token` — identifiers out of
SecretVault::REGISTRY. They mean nothing on a settings page and there is
nowhere an operator could type them; asked about directly, and the answer is
no, that was not deliberate.

The env variable name is the useful one: it is what stands in the server
file, it is what the .env tab of this same page lists, and when the badge
beside it says "Aus der Serverdatei" it answers the obvious next question —
which line?

The registry key stays where it belongs: in wire:key and in the modal
arguments, as an identifier nobody reads. The test asserts on the text node
rather than on the key's absence from the HTML, because absence would have
demanded breaking the page to pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:20:29 +02:00
nexxo a84251260c Read the mailbox password where it actually is, and let the console test it
tests / pest (push) Failing after 9m6s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The inbox kept saying no mailbox was set up while the password sat right
there on the settings page. isConfigured() read
config('services.inbound_mail.password'), which is only ever the .env
value: this project deliberately does not overlay stored secrets onto config
at boot — SecretVault's own docblock says so, and HttpMonitoringClient reads
its token at the point of use for exactly that reason. My docblock even
asserted the opposite mechanism, which I never verified. It reads the vault
now, and falls back to .env because SecretVault::get() already does.

And a way to find out. "Verbindung prüfen" saves the form first — testing
what is on screen while the server still holds the previous values would
report on a mailbox nobody configured — then performs the same four verbs
the real fetch performs: LOGIN, SELECT, SEARCH UNSEEN. A check that proves
something easier than the actual job is worse than none.

What it reports back is one of five fixed words, never the server's own
prose: an IMAP error carries the command it failed on, and for LOGIN that
command contains the password. Each word maps to a sentence an operator can
act on — address and port, mailbox and password, the folder.

The line saying when the mailbox was last reached is on both pages, and it
is written by the SCHEDULED run as well as by the button. Otherwise "last
checked" would mean "last time somebody pressed a button", which is the less
interesting of the two: a mailbox that stopped answering at three in the
morning is the one worth seeing, and nobody presses a button at three in the
morning. It is also shown when the answer is "never" — an empty inbox and a
mailbox that stopped answering look identical, and this line is the whole
difference.

A fetch against a mailbox that just refused the login now takes nothing in
and records the refusal, rather than reporting "nothing new" — which was
true and useless.

Also: HostStepsTest asserted the literal clupilot.com for a host name. That
was true until CLUPILOT_DNS_ZONE was set to clupilot.cloud on this
installation, and then a correct configuration failed a test. The zone is
pinned in the test now; what it is about is the shape of the name and which
address it carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:12:29 +02:00
nexxo 5d12983ef7 Release 1.3.44
tests / pest (push) Failing after 7m56s Details
tests / assets (push) Successful in 30s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 02:18:15 +02:00
nexxo 518bc9aa41 Put the open tab in the address bar, including the first one
tests / pest (push) Failing after 11m2s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Reported as "die Tabs fehlen in der Query beim Kunden". The binding was
there and identical to the settings page — checked in the rendered
snapshot, which is where Livewire's JS reads it from — but `except` hid the
parameter while the DEFAULT tab was open. So arriving on a customer showed
no ?tab= at all, and a link to the page could not say which tab it meant
unless the tab happened not to be the first one. From the address bar that
is indistinguishable from the feature not existing.

No `except` now, on all three tabbed pages: the parameter is there from the
first render. And `history: true`, so each tab is a step the back button can
take rather than a silent replaceState — which also makes the change
unmistakable while clicking.

Asserted against the rendered page rather than the component, because the
parameter is written client-side from the `url` memo in the snapshot: the
memo IS the behaviour, and asserting the property would have passed
throughout the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:46:13 +02:00
nexxo 0a89695189 R24: a modal is never taller than the screen
tests / pest (push) Failing after 11m6s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
A modal grew with its content, so a form ran off the bottom of the window
and took its own save button with it — reachable only by scrolling the page
BEHIND the backdrop, which on a phone means not reachable at all. The
customer form with its eight fields is what made it obvious, but it was
true of every long modal in the console.

Two halves. The panel is capped once, in the published package view, so no
modal can opt out of it and none has to remember to: max-h in dvh rather
than vh, because on a phone the browser's own chrome counts towards vh and
that is exactly the case where the last centimetre decides whether the
button can be reached. And x-ui.modal is the other half — a header slot, a
scrolling body, a footer slot. min-h-0 on the body is the line that makes it
work: a flex child will not shrink below its content without it, and the
overflow never engages.

Fourteen modals converted: the ones that carry a field, which are the ones
that can grow. A two-line confirmation with one button has nothing that
needs to stay put, and the rule says so rather than leaving a footnote —
the test's criterion is the field, so the moment somebody adds an input to a
confirmation it fails and tells them.

A submit button in a footer sits outside the form it submits, so it carries
form="…". That is the price of a footer that does not scroll away, and an
HTML attribute rather than a workaround; there is a test for it, because
without it the button silently does nothing.

Two traps met on the way, both now scanned for. A Blade directive in a
component tag's attribute list (a conditional wire:poll on x-ui.modal)
compiles into the attribute bag and breaks the view outright — the same
mistake as @disabled on x-ui.button, which cost a debugging round earlier
this session; such attributes go on an element of their own. And Pest's
toContain() is variadic, so the second argument I passed as a failure
message was read as another needle: the assertion demanded that a footer
contain its own filename, and failed on a file that was correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:33:57 +02:00
nexxo f00cbf18d3 Let an operator correct a customer's details
tests / pest (push) Failing after 7m59s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
The console could show them and not touch them, so a moved office or a new
contact person meant impersonating the customer or editing the database by
hand. Both are worse than a form. R20: a modal, opened from the Stammdaten
tab, authorising again and reading the record itself rather than trusting
anything the browser hydrated.

Most of the work is in what saving must NOT quietly do.

The customer type is never reset to "nobody asked". The field opens on
"leave unchanged" precisely so that saving an address correction cannot
undo it — that answer decides whether a withdrawal right exists, and losing
it silently would be the expensive kind of quiet.

A changed VAT number is unverified from the moment it is stored — the
verification is bound to the value, so that happens by itself — but
`vat_id_verified_value` is left alone: it is the record of what WAS checked,
and clearing it would destroy the evidence rather than the claim.

The address is also the sign-in address and the key inbound mail is matched
by, so changing it moves the linked portal login in the same transaction and
puts it back to unverified, which is what Laravel's own profile update does
for the same reason: nobody has shown they can read the new address. No mail
is sent from here — the portal asks for the confirmation at the next
sign-in, and a mail an operator did not knowingly send is a surprise.

Three addresses are refused: one belonging to an operator (R21 — no index
spans the two tables, so it is checked), one belonging to another customer,
and one belonging to somebody else's user row, which can outlive or predate
a matching customer.

Not offered here, each for its own reason: `status`, because suspending has
consequences for access and a lifecycle switch hidden among address fields
is one somebody flips by accident; `stripe_customer_id`, because a wrong one
does not move a contract, it points our records at somebody else's payments;
the brand fields, because the customer sets those for themselves and an
operator overwriting a logo is not a correction. There is a test that fails
if any of them appears — with the comments stripped first, since the class
documents at length why they are absent and that documentation is not the
offence. This repository has fallen into that trap three times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:18:46 +02:00
nexxo 57c6912987 Say that a click landed, and make moving one actually move it
tests / pest (push) Failing after 7m52s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
"Man klickt und sieht nicht was passiert." Every action in this console is
a round trip, and a round trip with no sign of itself reads as a click that
missed — so the answer is in three places, from the general to the
specific.

A thin bar at the top of the window for any Livewire request, in app.js.
Delayed by 140 ms, because a request that answers faster than that is
perceived as immediate and a bar flashing on every keystroke of a .live
field is worse than none. It creeps to 70 % and only completes on the
answer: a bar that reaches the end while the answer is still on its way is
a lie about the thing it exists to report. Released on failure as well —
the request somebody must not be left waiting on is precisely the one that
went wrong — and on wire:navigate, which is not a request hook at all and
would otherwise leave the bar behind on a page that is gone.

Per button: a pressed state on the way down, disabled while its own request
is out, scoped with wire:target to that button's own call so a row does not
grey itself out because a neighbour is moving.

The edit button carries its own spinner, because opening the modal is a
request made by the MODAL component — wire:loading on this page never sees
it. It clears when the package announces the modal is up, and on a six
second timer as well, so a request that never answers cannot leave a row
spinning for ever.

And the reordering was not only slow to look at, it was wrong. It added or
subtracted a fixed amount: with the seeded values ten apart that usually
landed right, and sometimes landed on a value another template already had
— or on the floor along with three others. Equal sort values fall back to
the name, so a click could reorder something else, or nothing, which is
indistinguishable from a click that never registered. It swaps with the
neighbour now: exactly one place, or nothing at the ends, both of which are
what an operator expects to see. Where two shared a value the swap leaves
them distinct, or the next click would do nothing again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 23:06:30 +02:00
nexxo 173f05c1bd Fix a cancel button that did nothing, and fit the answers on one screen
tests / pest (push) Failing after 10m22s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
The cancel button on the template modal was written with Alpine's
$dispatch, which fires a browser DOM event — and wire-elements/modal
listens for a LIVEWIRE event of that name. So it did nothing at all, while
saving worked, which is the combination that makes somebody click twice and
then close the tab. Every other modal in the repo already used
Livewire.dispatch; this one was the odd one out, and nothing would have
caught it, so there is now a scan that fails on the Alpine form. Narrowed
to Alpine handlers deliberately: wire:click="$dispatch('closeModal')" IS
Livewire's own dispatcher and works.

The list was one column of thirteen entries, each carrying a name, a
subject, two clamped lines of body and two full-width buttons — four
screens to find the one you want, with the body preview taking most of the
height while being the least useful part of it. The whole text is one click
away in the modal.

Now: two columns of one-line entries — name and subject, which is what
tells two similar answers apart — with the actions as icons that do not
compete with the name. The create form is folded away behind the button
that creates one, because a form that is always open is a screen of form
between the operator and the thing they came for. And the placeholder list
runs as a grid across the page instead of a tall sidebar that squeezed the
entries into half the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:50:16 +02:00
nexxo 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>
2026-07-29 22:44:31 +02:00
nexxo 623acb3702 Release 1.3.37
tests / pest (push) Failing after 8m4s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 22:43:00 +02:00
nexxo 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>
2026-07-29 22:37:30 +02:00
nexxo 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>
2026-07-29 21:41:29 +02:00
nexxo e0c109c449 Ask for the express request to begin, and send it to Stripe
tests / pest (push) Failing after 7m59s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Has been skipped Details
The withdrawal machinery has always read `immediate_start` off the
checkout session's metadata — StripeWebhookController stamps
`immediate_start_consent_at` from it, WithdrawalRight refunds pro rata
when it is there and in full when it is not. The checkout never put it
there. So every contract concluded through this application carried no
consent, and a withdrawal on day thirteen of a running cloud took back
the whole amount however many days the machine had run. That is the
lawful outcome without the consent, and it was the only outcome
available.

Now the checkout asks. The box is required — validated `accepted`, not
`present`, because an empty field is what an unticked checkbox sends and
a consent that can be produced by leaving something blank is not a
consent — and the metadata carries exactly the string '1' the webhook
compares against.

One box for the page rather than one per card: the sentence is the
statutory wording and four copies of it is a wall nobody reads. The forms
carry it as a hidden field bound to that box, and the buy buttons stay
disabled until it is ticked. With JavaScript off the field arrives empty
and the server refuses with the sentence explaining why — the safe
direction to fail in. Ticking a box in a browser was never the record
anyway: the record is the timestamp taken when the payment lands.

Tested at both seams and along the whole chain: refused without it,
refused for '', '0' and a word that is not yes, carried to Stripe as '1',
and a webhook carrying it lands as a timestamp on the order while one
without it lands as null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:19:03 +02:00
nexxo 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>
2026-07-29 21:02:36 +02:00
nexxo 559a466630 Show that the update is running, and stop showing the rsync log
tests / pest (push) Failing after 12m32s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The maintenance screen answered a question nobody was asking. Under the
headline sat a card with the deployment's raw output in it — rsync
listing every font file it had copied — and, before a step was known, a
box drawn around the words "Wird jetzt gestartet." A person waiting on
this page wants one thing settled: is anything happening, or am I looking
at a dead page.

So: an indeterminate bar, in CSS. Indeterminate because a deployment has
no honest percentage and a bar that claims one always stalls at 90 %; in
CSS because this page is served while the application is down, so it may
not depend on JavaScript having loaded or on the server answering another
request. Motion off gets a steady bar rather than none — the point is to
show the page is alive, and a still bar says that better than a gap.

Shown to everybody, not only in the console: a customer on the 503 is
asking exactly the same question.

What is left of the operator detail is the current step, as one quiet
line, because a run that stops on a step is worth seeing without a shell.
The elapsed time and the log are gone from the overlay entirely — with
them goes a file read on EVERY console page load, running or not. The
settings page still shows the log, where somebody is actually looking for
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 20:33:02 +02:00
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 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 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