Commit Graph

71 Commits (1e22d666399c5fc64f29eb100130c77ee9e00207)

Author SHA1 Message Date
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
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 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 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 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 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 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 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 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