The page was a wall. Six paragraphs of procedure stacked above a form, so the
thing the page actually asks for — four fields — sat underneath an essay about
what would happen afterwards. It answered everything and showed nothing.
It is now built the way the settings page is built, because that page was
rebuilt for the same reason and there is no case for a second idiom: an eyebrow,
a title, a sticky rail on the left and panels of rows on the right.
The rail carries the six steps as two-word labels rather than paragraphs. That
is what the question at this moment actually is — where am I, how much is left —
and it fits in 232 pixels. The numbers carry the state; a tick beside them would
be a second sign for one statement, and a number can be counted.
What a step MEANS now appears in the row where it is due, not six times in
advance. The two provider steps get a panel of their own above the form, because
they have to be done before you save: the one-time code starts expiring the
moment you do. Everything after the form waits until there is something to say.
After saving the command becomes the page. Full width, its own framed block with
the warning in the header strip rather than floating above, and the two
remaining steps below it as ordinary rows.
Two token bugs went with it. `bg-canvas` does not exist — it was a class that
compiled to nothing, which is part of why the block looked wrong. And
`text-accent` on white is 2.9:1; the config says in as many words to use
accent-text for anything read, so the current step number does.
Also fixed, and it would have broken every takeover in production: PublicSiteGate
is appended to the whole web group, so while the site is hidden a server in a
rescue system fetching the archive would have received the 503 placeholder and
piped it into tar. The operator would have seen an unpack error on a machine
only reachable through the provider's console, with nothing pointing at a switch
in the admin area — and they are by definition neither on a management network
nor signed in, since leaving that state is the whole point. Exempted by ROUTE
NAME, not by hostname: the docblock rightly warns that exempting by host means
trusting a header the caller picks, but that warning is about the entire portal.
Behind these two routes are documents that are public anyway and an archive that
404s without a valid one-time code.
2026 tests pass, assets build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit shipped the guide AFTER the host was created. Its first two
steps happen at the provider — order the machine, boot the rescue system — and
whoever reads them there has the one-time code already in the clipboard with its
24-hour window running. The instructions were correct and in the wrong place,
which is its own kind of wrong.
Six steps now, not three, and the two provider ones are named rather than
assumed. They are marked "first" so the list does not read as something you can
start at the top of and work down: step 2 is half an hour of waiting, and doing
it inside a running code is exactly the mistake the page should prevent.
All six are visible the whole time, including the done ones and the ones still
to come. A guide that shows only the current step cannot answer "how much is
left", which is the question somebody has while a server they are paying for
sits in a rescue system.
It is on the hosts list too, collapsed. Somebody standing there may not have
ordered the machine yet — and that is step 1. Requiring them to click "add host"
to find out what the procedure is puts the answer behind the action it describes.
One component, two places, so the archive address in the instructions is the same
string the command line will carry. Two copies of that would drift, and the
difference would surface on a server that has already been paid for.
The step-4 command box moved into the guide rather than sitting beside it, so
after creating a host the whole procedure stays on screen. Somebody stuck at
step 5 should not be looking at a page that has become a single box.
1991 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three real conflicts, and one file that did not conflict and mattered more.
Overview::notices(): both sides added notices. Kept all of them, and pointed
main's mail check at MailboxTransport::NON_DELIVERING — its own comment already
named the constant while the code carried a copy of the list.
billing.blade.php: main wrapped the page in a contract branch. The "payment is
not set up" error moved OUTSIDE it, because the customer most likely to meet
that message is the one buying for the first time, who has no contract yet and
would never have seen it from inside the @else.
ConsoleReportsRealDataTest: kept both sides rather than choosing. The renamed
test and its docblock explain why admin.systems_ok can no longer be asserted on
a bare install; main's mail pin still keeps "clean" clean in the mail
dimension. Picking one would have quietly weakened the other's claim.
HostStepsTest: git combined main's config()->set('provisioning.dns.zone',
'clupilot.com') with this branch's assertion on clupilot.cloud, and produced a
test that contradicted itself with no marker. Main's approach is the better one
— it pins the zone in the test and asserts the SHAPE of the name rather than
this box's domain — so its assertion stands.
HttpStripeClient merged silently and correctly: secret() still throws,
isConfigured() still reads the vault directly. Had it taken main's
filled($this->secret()), six callers that ask in order NOT to get an exception
would have become exception throwers, and the suite would have stayed green.
CheckoutWithoutStripeKeyTest is the lock that would have caught it.
StripeIdempotencyKeyTest (new on main) leaned on the environment fallback for
stripe.secret. That entry is strict now — no fallback in either direction, so
the .env cannot be a back door for a live key in test mode. It stores a real
vault row instead.
1966 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mode test, dns.token only in the :live slot, HETZNER_DNS_TOKEN also in the
.env — the state this installation is in the moment the migration runs:
get() -> "live-token-value" (the stored live row is in force)
source() -> "environment" -> card reads "Aus der Serverdatei"
outline() -> null -> no "Hinterlegt" block at all
So the console sent an operator to the .env to rotate a token that the
database supplies, where the edit would have had no effect. R19's dummy,
literally: a display that reads like an answer, is wrong, and stops the
next person looking.
source(), outline() and updatedAt() now ask the same one place get() does
(inForce()/fallback()), and the answer gets a THIRD value, 'stored_live'.
Not 'stored': that shows the Vergessen button, which would then have
pointed at the empty test slot. Strict entries (Stripe) still have no
fallback in either direction — pinned by its own test.
Red first (before the vault changed):
⨯ it names the live slot as the source when it is the one in force
-'stored_live'
+'environment'
⨯ it shows the outline and the date of the value that is actually in force
Two existing expectations moved from 'none' to 'stored_live' with the
reason written beside them: 'none' meant "nichts hinterlegt" about a
credential that was in use.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Names which of the two slots a card actually reads and writes right now, at
the card an operator types into (not only the page-top badge) — save() and
render() resolve without a mode argument, so the card looked identical in
both modes while quietly acting on whichever slot was active. Also adds the
ConfirmSwitchMode coverage IntegrationsPageTest already carries for
ConfirmSaveSecret/ConfirmForgetSecret: a modal is reachable without the
page's route middleware, and reading the code is not the same as testing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Zugangsdaten and Infrastruktur split the same subject by storage mechanism —
SecretVault vs App\Support\Settings — instead of by what each field
configures, which is not a distinction an operator setting up Stripe or DNS
should have to know or care about. One page now, grouped by integration:
Zahlungen (Stripe), DNS (Hetzner), Monitoring, VPN/WireGuard, SSH-Identität. A
secret and a plain setting sit side by side within a section; the difference
stays visible — a vault entry is write-only and shows only an outline, a
setting shows its value in full — as a property of the field, not a reason
for a separate page.
Both storage mechanisms are unchanged underneath: this is a presentation
change, not a data migration. The vault keeps its own gate (secrets.manage +
a recently confirmed password); plain settings keep theirs (hosts.manage
alone). Reachable with either capability, since the two are no longer two
pages a role happens to see one, both, or neither of — every section and
every save action still checks its own capability server-side.
admin.secrets and admin.infrastructure redirect here permanently rather than
404ing a bookmark. The nav entry that replaces both is visible to either
capability — Navigation's capability field now accepts an array meaning "any
of these", not only a single ability.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>