A destination is a kind of connection, not a product. The wording said "Hetzner
Storage Box" and the host field suggested one particular provider's hostname
format, which reads as a requirement rather than an example — and the whole
point of the directory kind is that whatever is behind it is nobody's business
here.
Both kinds stay. A directory is how an NFS 4.1 mount is used: the mount is made
on the server, and the application writes into the path it produces. SFTP is for
a destination that is not mounted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first version put it in the credential vault. The vault refuses it, and
correctly: it keeps a registry of fixed, documented credentials so the Secrets
page can describe each one, and a per-destination password is not that. Forcing
a dynamic key into that registry would have made the registry meaningless.
Encrypted onto the row with the same cipher instead — the same key, so it is no
more readable in a database dump than anything the vault holds, and it lives
with the record it describes. A rotated SECRETS_KEY now says so by name rather
than surfacing as an authentication failure against a host that is perfectly
fine.
Also answers the question that prompted this: the host, port and user fields are
there, and appear once SFTP is chosen. A mounted directory has no host on
purpose — the mount is made on the server with mount(8), and the application
only names the directory it writes into. A test asserts both halves, because
"the fields are there, you just cannot see them yet" is not something anybody
should have to take on trust.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One path and one exported_at could not say "on the office NAS, not yet on the
off-site box" — which is the only interesting question once there is more than
one destination, and the reason for a second is precisely that any single one
can fail. A row per destination now, and a row per (invoice, destination) pair.
Two kinds, and the difference is where the protocol lives. 'local' is a
directory this machine can already write to: a NAS mounted over NFS 4.1, a bind
mount, a disk. The application knows nothing about the protocol, the target
changes without code changing with it, and the whole thing stays testable
against a temporary directory. 'sftp' is for something reachable with
credentials — a Hetzner Storage Box being the obvious one — and the password
goes into the secret vault with only its key on the row, because a credential
in a settings table is a credential in every database dump. An empty password
field on an existing target means unchanged, not cleared: the field cannot show
what is stored, so blank-means-delete would wipe it on every unrelated edit.
One job per pair rather than one per invoice. A job writing to both would retry
the one that worked every time the other did not, and report one outcome for
two different things.
The ordering fix the tests found: Laravel's local adapter creates its root when
it is CONSTRUCTED, so the "never create the root" guard was checking a
directory the disk had just made for it. It runs before the disk is built now.
That guard is the difference between an archive and a hole in the ground — mkdir
on an absent mountpoint succeeds, writes onto the container's own disk and
reports success, and an archive that silently is not the archive is worse than
none.
The single path from the previous commit is migrated into a destination called
"Archiv" rather than dropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The application knows nothing about NFS and should not. It writes to a
directory; whether that is a local disk, a bind mount or a NAS over NFS 4.1
belongs to the mount. No protocol library, the target changes without code
changing with it, and the whole thing is testable against a temp directory.
The finding that shaped it: mkdir -p on a path whose mount is NOT there
succeeds. It creates the empty directory beneath the mountpoint, writes the
invoice onto the container's own disk and reports success — so an unmounted NAS
would quietly collect invoices locally while every check said it worked. An
archive that silently is not the archive is worse than none. The root is
therefore never created, only used: a missing root means a missing mount, and
that is a failure. Only the year subdirectory is created, inside a root already
proven to exist.
Written to a .part and renamed. A rename within one filesystem is atomic, so
whatever watches that folder never sees a truncated PDF and takes it for a
finished invoice. The size is read back afterwards rather than the write being
trusted: over a network mount a short write can report success, and an archive
of truncated files looks exactly like one that worked.
Queued, not done during the request. A network mount can block for its whole
timeout, and an invoice must not fail to be issued because a NAS is rebooting.
Dispatched after the commit, or a worker could look for an invoice that has not
landed yet.
And copy-on-issue is not enough on its own: an invoice issued while the office
connection was down never arrives, the queue eventually gives up, and nobody
opens an archive to check whether last Tuesday is in it. An hourly sweep
re-queues whatever is still missing, the failure is recorded on the invoice
rather than only in a log, and the Finance page says how many are outstanding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DisplayTimezoneTest went red on main: the year filter formatted issued_on
without ->local(). A year looks timezone-proof until New Year's night,
when the UTC year is still the previous one and the filter offers a year
the operator has already left. R19 is absolute for exactly that reason.
Read-only by design, and the absence is the feature: an issued invoice is
corrected by cancelling it and issuing another, which is the only lawful way to
correct one. There is no edit button here whose absence needs explaining, and a
test asserts there is none.
The PDF is a plain route rather than a Livewire action. A download is a
download, and routing one through a component round-trip only adds a way for it
to fail.
Two findings on the way, and both were mine.
The year filter used YEAR() in raw SQL, which is MySQL's. SQLite has no such
function, so the page worked against the real database and threw against the
test one — a query that only runs on one engine makes the test database a
different product from the one being shipped. The years are derived in PHP now.
And a test put a User on the operator guard, where EnsureAdmin calls isActive()
on it and a 403 becomes a 500. Not a code defect: actingAs() calls
Auth::shouldUse(), so a second actingAs() in the same test with no guard named
lands on whichever guard the FIRST one named. That trap is written up in
CLAUDE.md, I quoted it earlier today, and I walked into it anyway. The tests are
split now and both name their guard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The PDF is rendered when the mail goes out rather than fetched from anywhere,
because nothing stores it — and rendering from the frozen snapshot means the
attachment is the document as issued no matter when the queue gets to it or how
often it retries. fromData rather than fromPath: there is no path.
Three attempts at testing it, and the first two were wrong in ways worth
recording. Attachment::$data does not exist — the closure lives in a protected
$resolver, and reaching for it was me testing the framework's internals rather
than my own code. And a byte comparison of the PDF cannot work at all: TCPDF
embeds a creation time and a document id, so two renders of one invoice differ.
Split into the two things that can actually go wrong. The mail carries an
attachment with the right filename and type — that is metadata, and it is
asserted as metadata. And the renderer produces a real PDF rather than an empty
one, asserted where the renderer is, because an attachment closure that returns
nothing still makes a perfectly valid mail with a nought-byte file on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One invoice per purchase rather than per order: a customer who buys a plan and
two add-ons in one go has bought once, and three invoices for one purchase is
three times the paperwork for the same money. Each order becomes a line, which
is also how the add-ons get listed individually.
Everything the document says is copied into the snapshot at the moment the
number is assigned — issuer, recipient, lines, rate, the reason for the rate.
A test changes the company name and the customer name afterwards and asserts
the invoice still says what it said. That is the whole reason no PDF is stored.
The number and the invoice commit in one transaction. A number taken and then
lost to a failure is a gap in a series that must not have one, and the refusal
test asserts that a rejected invoice leaves the counter where it was.
It refuses outright until the company details are complete. An invoice without
a registered name, an address or a VAT number is not a valid invoice here, and
issuing one consumes a number that can never be handed out again.
VAT comes from TaxTreatment, which already existed and already handles a
verified EU VAT ID correctly — including the reverse-charge note, without which
a zero-rated invoice says nothing about why no VAT was charged.
The billing address is carried as the lines it was written as. It is one
free-text field today, and guessing which line is the postcode would put it
where the street belongs, on a document nobody can correct afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Finance tab wrote a rate into settings while TaxTreatment kept reading one
from config — two sources for one number, added by me two commits ago. It had
not bitten yet only because both happened to say 20.
TaxTreatment now takes the domestic rate from CompanyProfile, which falls back
to the .env-derived config until an operator saves one. Same shape as
ProvisioningSettings, which already solves this for the deployment values, and
the same reason: an operator can change a settings page and cannot change a
config value, so a page that appears to set the rate and does not is worse than
no page at all.
Reverse charge and the seller country stay where they are. That logic already
existed, already handles a verified EU VAT ID correctly, and nothing here
needed to know about it — which is exactly why it should not have been
duplicated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second correction to the same block, and this one came from the right place: a
line reading 202,50 beside "Rabatt 10 %" does not say whether the discount is
in that number or still to come. It was unreadable, and unreadable on the one
figure a customer checks.
Lines are at full price now. Discounts are still entered per line — that is
where they belong and where somebody decides them — but they are summed and
shown once, in euros, under a subtotal that the "Gesamt netto" column adds up
to. Subtotal, one subtraction, VAT, total: a sum anybody can follow without
being told which figures are already adjusted.
The gross line-total column is gone and a gross UNIT price takes its place.
Full-price gross totals would sum to 315,60 against a total of 288,60 — the
same contradiction in a different column. Nobody adds up unit prices, and a
consumer reading this wants to know what one of the things costs.
lineTotal() and adjustmentLabel() went with it. Nothing calls them any more,
and a helper kept for a layout that no longer exists is the next person's
wrong turn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With one discount applied under the table, the gross column summed to 315,60
against a total of 284,04. Nothing was miscalculated — the discount simply came
after — but a reader who adds up a column found a document that does not agree
with itself, and no way to tell which figure was the real one. On an invoice
that is not a presentation detail.
A discount or surcharge now sits on its line, percentage or fixed amount, and
is inside that line's total. The label under the line is built from the same
value that did the arithmetic, so the words cannot end up describing a
different number than the one printed beside them.
The invoice-wide adjustment stays in the arithmetic for anything that genuinely
applies to the whole document, but nothing uses it by default.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything the console has no field for is still real — MAIL_*, DB_*,
APP_KEY, whichever key nobody has built a form for yet — and without a way to
touch it from here, the operator needs a shell and the console page misses
its own point. EnvFileEditor is the net, not just the warning:
1. Validates before writing. A line that is neither blank, nor a comment, nor
KEY=value is rejected outright, and an empty file (syntactically "valid" by
that rule, but not survivable) is refused too. write() never touches the
file before checking the new content.
2. Backs up before every write that actually happens — a timestamped copy
beside .env, before the new content lands. Never pruned automatically; the
page says so, next to where it says where they land.
3. Names the keys a mistake here can lock an operator out with — APP_KEY,
DB_*, REDIS_*, SESSION_* — rather than a blanket warning nobody reads.
4. Gated by secrets.manage and the same confirmed password the vault entries
use — this is the one place on the page that can reach every credential
the vault otherwise keeps write-only.
5. Says plainly what saving does not do: queue, queue-provisioning, scheduler
and reverb only read .env at their own startup, and names the restart
command plus config:clear.
6. Marks which .env keys SecretVault currently overrides, so editing a line
that a stored vault value already shadows does not look broken.
Two issues surfaced by Codex review and fixed before this commit: the raw
file content stayed in the Livewire component snapshot after the password
confirmation window expired on its own (not only on an explicit re-lock), and
the first save on an installation with no .env yet failed trying to back up a
file that was never there.
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>
The footer is four columns of three lines, as designed — but the specimen only
filled the fields an invoice legally needs, so two of the four columns came out
empty and it read as a missing footer rather than as missing data. Reported
that way, which is the correct reading of what was on the page.
A specimen exists to show the layout. Every field now carries an obvious
placeholder, so the shape is visible before any real detail has been entered,
and what is still a placeholder is obvious at a glance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TCPDF takes leading whitespace in HTML literally, so every line of the address
and every line description came out indented by however far the Blade template
happened to be nested — and the one line that had no indentation, the VAT
number, sat flush against the margin looking like the mistake. It was the only
correct one.
Also puts the title and the invoice number in one block. As an h1 with a
paragraph beneath it, TCPDF gave the heading a margin of its own and the two
drifted a centimetre apart.
Found by rendering the page and looking at it, which is the only way any of
this is ever found.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TCPDF 6, deliberately, after installing 7 and finding out what it is. Version 7
is not a newer TCPDF; it is a compatibility shim over tc-lib-pdf whose own
mapping table lists 115 of 293 methods as stubs, and whose font package ships
no font files at all — the first render died on a missing helvetica.json. The
6.x line carries a hundred and sixty-five fonts, a working writeHTML and real
header and footer hooks, and is what everybody means by TCPDF.
The body is a Blade template handed to writeHTML rather than a hundred Cell()
calls with millimetre coordinates, because this table has to be adjusted the
first time somebody sees it on paper, and nobody adjusts the second kind.
InvoiceMath is integer cents end to end, and rounds once. A percentage applied
line by line and rounded each time drifts from the same percentage applied to
the sum — three lines at 3,33 € less 10 % is 99 cents that way and 100 cents
the honest way, and the customer's calculator is what finds it. Gross is always
derived from net and never the reverse: taking VAT back out of a gross figure
returns a different number often enough to matter. A discount and a surcharge
are one operation with opposite signs, so nothing has to remember which of two
flags means which.
Quantities are thousandths, because hours, gigabytes and part-months are all
real, and are printed with the trailing zeroes stripped — "12,000 Std." reads
as a defect.
Every line shows quantity, unit price net, total net AND total gross: this goes
to businesses and to consumers, and each reads a different column first.
Verifying it took two goes and both failures were mine. `strings` over a PDF set
in a Unicode font finds nothing, because the text is stored as subset glyph ids
— the check has to render in a core font to be a check at all. And two of the
strings I then declared missing were simply expectations I had got wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SITE_HOST takes a comma-separated list now, the first name canonical. It serves
the site; every other name in the list redirects there permanently, path and
query intact. An apex domain and its www are two names for one thing, and
answering on both without picking one splits search rankings and cookies
between them.
The redirect is a host-bound catch-all rather than middleware. Middleware that
only some routes carry is the same half-measure this file already made once,
and a catch-all registered without a host would swallow every path in the
application — which is what the third test is there to stop.
The test harness needed fixing too, and the fix is the finding. Router::dispatch
on a standalone router does not rebind the container's request, so a route
reading one — through the helper OR through an injected parameter, both of which
resolve from the container — gets the test's request rather than the one being
answered. The redirect silently lost its query string in the test while working
in production. dispatchOn() binds it where the framework would.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Its own tab rather than a section of Settings, as asked. What is set here
appears on a legal document, and burying it beside the site-visibility switch
invites somebody to change one in passing.
Everything the printed invoice needs — registered name, address, Firmenbuch
number and court, VAT ID, bank details, payment terms, logo — with the logo
replaceable, because a company changes its mark. None of it reaches an invoice
that already exists: the values are copied into each document when its number
is assigned, so a corrected address is correct from the next one onwards and
cannot rewrite an old one.
The console refuses to consider itself ready while a name, an address or a VAT
number is missing, and says which. An invoice without them is not a valid
invoice in Austria, and issuing one is worse than refusing to.
The series editor carries the same shape as EditDatacenter and for the same
reason. The prefix is free while the series is empty and frozen once a document
carries it: changing RE to AR renames nothing and leaves a series whose past
says RE and whose future says AR, with no record they belong together. The
counter may be raised — an operator migrating from another system needs exactly
that — but never lowered, because a lower number is one already printed on a
document somebody holds. Both rules are recomputed from the database on save
rather than read back from the properties the browser returns.
CompanyProfile drops keys that are not part of the profile. It is fed from a
form, and a forged key must not be able to write an arbitrary setting into the
same table the site-visibility switch lives in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous release bound the landing page and stopped there. The website duly
vanished from the portal — and the portal stayed reachable from the website:
www.clupilot.com/dashboard, /login, /settings all kept working. Half a
separation is no separation, and it was reported back within the hour.
APP_HOST binds every portal route, Fortify's own POST actions included through
fortify.domain — a sign-in form on the website's hostname would otherwise post
to a route that answers there. SITE_HOST now takes the legal pages and
robots.txt with the landing page rather than leaving them everywhere; binding
them is also what makes route('legal.impressum') produce a www URL from inside
a queued mail, where there is no request to take a hostname from.
There is no clever middleware here and there should not be. Route::domain() is
the mechanism: a route registered for one host does not exist on another, and a
request for it gets the 404 it deserves. The only deliberate exception is "/" on
the portal host, which redirects to the dashboard or the sign-in page — that is
what people type from memory, and turning it into an error page to make a point
about hostnames helps nobody. The Stripe webhook and /up stay host-agnostic:
Stripe posts to whichever URL it was given, and a mismatch there loses payments.
Both are opt-in and empty by default, which every development machine reached
by a bare IP depends on.
The tests now check BOTH directions for every path rather than only the one
that was reported. That is the mistake this commit exists to correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An invoice is a tax document, so two things drive the shape of this.
It is frozen at issue. Everything the finished document says — issuer address,
VAT number, bank details, customer address, every line, the rate, the totals —
is copied into the invoice row when the number is assigned. The PDF is rendered
from that and never stored, which is what was asked for; rendering it from
today's settings instead would have been simpler and wrong, because an address
changed in 2028 would rewrite an invoice from 2026. The width of this table IS
the feature.
Numbers are gapless and ascending, which is a legal requirement rather than a
preference, and that is why none of it is derived from the invoices table.
"Highest number plus one" hands the same number to two sales that land together
and hands a used number back out after a row is removed — a number already
printed on a document somebody holds. The counter is a column, read and advanced
under a row lock inside the caller's transaction, and taking one outside a
transaction throws rather than quietly working.
Four Rechnungskreise seeded — Rechnung, Gutschrift, Storno, Anzahlung — each
with its own prefix and its own counter, because a credit note is not an
invoice with a minus sign in front of it. Seeded rather than left to the
operator: an installation with no series cannot issue anything, and discovering
that at the first sale is the worst possible moment.
TCPDF added, for the renderer that comes next.
There is deliberately no test for the outside-a-transaction guard, and a note
where it would have been: RefreshDatabase opens a transaction around every
test, so the guard cannot fire and a green test for it would be green for a
reason that has nothing to do with the guard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SSH private key CluPilot deploys to every host is now stored through
SecretVault (ssh.private_key), preferred over CLUPILOT_SSH_PRIVATE_KEY(_PATH)
at all three real consumers — SshTraefikWriter and HostStep::keyLogin.
kuma.password/kuma.totp stay in .env: they authenticate a separate Python
container at boot, and nothing in this app reads them, so the vault would
have nothing to wire them to.
A new /admin/infrastructure page, backed by App\Support\ProvisioningSettings,
makes the non-secret deployment settings that used to force a shell —
DNS zone, WireGuard hub endpoint and public key, Traefik's dynamic-config
path, the SSH public key, and the monitoring bridge URL — visible and
editable from the console, each wired to every real consumer. WG subnet/hub
IP and Kuma's own connection details stay in .env: the compose file and a
separate container read those before this application ever does.
Two of the Zugangsdaten page's three entries were decorative: whatever an
operator typed was encrypted and stored, but HttpHetznerDnsClient and
HttpMonitoringClient still read config() directly, so the stored value never
reached an outgoing request. Both now resolve through SecretVault::get() at
the point of use, matching how the Stripe client already worked.
The landing page was registered host-agnostically, so app.clupilot.com served
it: marketing copy, a pricing table and a "sign up" call to action, at the
address where a customer's servers live. Reported exactly that way.
SITE_HOST binds the website to its own hostname. Every other host — the portal,
a bare IP — answers "/" with the product: the dashboard for somebody signed in,
the sign-in page for everybody else. Never a 404 there: "/" is what people type
from memory, and turning that into an error page to make a point about
hostnames helps nobody. Empty keeps today's behaviour, which every development
machine reached by IP depends on.
The console is unaffected — its routes are registered first and win on their
own host, the ordering the status page above already documents.
The legal pages are deliberately not bound. An imprint has to be reachable from
wherever the reader is standing, and a mail footer links to it from whichever
host sent the mail.
Also drops "just reply to this message" from two mails. A reply lands in the
billing or provisioning mailbox rather than in the support queue somebody
actually works through — so it now says to raise it in the portal, where it
gets a place in that queue instead of getting lost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four templates, one layout. Two of them — the maintenance announcement and its
cancellation — still wore Laravel's markdown component, which brings its own
logo, its own button and its own footer: the product was sending mail that
looked like two different companies, one of which was the framework.
New: an order confirmation, sent when the payment lands rather than when
provisioning finishes. Those are minutes apart at best and can be much longer,
and somebody who has just been charged and heard nothing assumes the worst
about both the charge and the product. It deliberately promises no time for the
cloud itself — provisioning takes what it takes, CloudReady announces the end,
and a promised minute that slips is worse than no promise. Queued after the
transaction, never inside it, and never allowed to fail the webhook: Stripe
retries anything that is not a 2xx, and a retry would re-enter provisioning
over a mail server having a bad minute.
The finished-cloud mail now names what was actually built — address, plan,
storage, location — because somebody who ordered a fortnight ago is checking it
against what they bought, and that is a comparison rather than a sentence. Still
no credential in it.
Two tests earn their place. One holds every template on the shared layout, so a
new one written in a hurry cannot copy-paste its way back to the framework's.
The other proves the finished-cloud mail carries no password — asserted against
the data the mail is given and the HTML it renders, not against the template's
source, because the first version of that test tripped over the word in a
comment and would have passed a template that leaked the value through a
variable. It tested the prose, not the mail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Since 1.2.0 the agent has ended with a non-zero status on every tick, before
writing anything. The console showed a check that never finished and a
last-checked time frozen at the moment of the deployment, and the update button
could not be reached at all — the one mechanism that would have delivered the
fix.
Two instances of the same trap, both mine, both under `set -Eeuo pipefail`:
The tag count was `release_version_gt … && echo`. The LAST iteration is almost
always a tag that is not newer, so the loop ended non-zero, pipefail carried it
out of the pipeline, the command substitution inherited it, and set -e ended
the agent. `if` rather than `&&` is the whole fix: an if whose condition is
false still returns 0.
The newest tag came from `git tag … | head -1`. head exits after one line, git
takes SIGPIPE, pipefail does the rest. install-agent.sh has carried a written
warning about exactly this since it was written; I read it and wrote it anyway.
Both now live in deploy/lib/release.sh, because the reason neither was caught
is that no PHP test can reach a bash pipeline.
tests/Feature/ReleaseComparisonTest.php runs the real functions in a real
throwaway repository under the agent's own set -Eeuo pipefail, and asserts the
EXIT CODE as well as the answer — an answer nobody lives to read is not an
answer. The SIGPIPE case needs four hundred tags to reproduce, because a short
list finishes writing before head leaves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ownership repair added in 1.1.1 sampled the owner of each top-level
directory and skipped the recursion when it already matched. node_modules was
owned by www-data; node_modules/.vite-temp, left behind by an earlier root
build, was not. So the repair walked past it, `npm run build` failed with
EACCES, and the deployment stopped in maintenance mode — on the release whose
whole point was that this could not happen.
A directory's owner says nothing about its contents. It now walks each tree
once with find and changes only the entries that are wrong, which is also
cheaper than the detect-then-chown-everything it replaces.
Verified against the shape that actually failed: a root-owned file inside a
www-data-owned node_modules, repaired.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Registration opened an account that could sign in immediately on an address
nobody had proved was theirs — and anybody can type a stranger's into a
registration form. Both halves were commented out: the Fortify feature and
MustVerifyEmail on the model.
`verified` sits on the whole portal group rather than on the pages that spend
money. An unconfirmed address is not a billing problem to be caught at
checkout; it is an account that may not belong to the person holding it, and
the servers, the users and the backups behind it are worth as much to whoever
typed the address as to whoever owns it.
The mail is a Mailable in this product's design rather than Laravel's
notification, which would arrive with the framework's logo, button and footer
as the first thing anybody ever receives from CluPilot. It also has to leave
through the SYSTEM mailbox like every other account mail, which the
notification path does not do on its own.
The link is signed and expires, and the address is part of what is signed — so
a link issued to a mistyped address stops working the moment the address is
corrected, rather than confirming an address nobody can read. Tests cover the
tampered link, the expired one, the corrected address, and one account trying
to confirm another.
The waiting page is ours (Fortify has views off) and offers the only two things
that move somebody forward: send it again, or sign out and register with the
address they meant. The resend is throttled, because otherwise it is a button
that makes the server send mail to an arbitrary address as fast as it can be
clicked. Somebody already confirmed is redirected away from it — the `verified`
middleware only guards the other direction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the device work: the warning mail now has somewhere to send people.
A warning whose only advice is "if this was not you, take action" names a
problem and hands back nothing to do about it.
Both sides get their own component rather than one shared with the guard passed
in. Operators and customers are two guards and two tables (R21) — a component
taking the guard as input is one forged property away from listing and ending
the other side's sessions. Every query in SessionRegistry is scoped by guard as
well as by id for the same reason, and three tests hold that line, because the
unscoped version looks exactly like a working feature while doing it.
TouchLoginSession keeps each row pointing at the session it describes. Both
sign-in paths raise the Login event BEFORE session()->regenerate(), so the row
is written against an id that is dead a moment later — and the once-a-minute
throttle would have held the correction back for the first minute of every
session, hiding the session its reader is sitting in. A changed id is therefore
never throttled.
A session counts as live only if the framework's row exists AND was touched
within the session lifetime. The join alone is not enough: Laravel collects
expired sessions by lottery, so on a quiet installation rows sit there for days
and the list would offer somebody a laptop they shut last month.
Ending the others goes through the product's own modal (R23) and leaves the
asking session alone: signing somebody out of the page they are using to sign
other people out is not what the button says.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sessions lived in Redis, where they cannot be enumerated — one key per session,
no index by user — so "where am I signed in" and "end that one" were not
answerable at all. The database driver keeps a row per session and makes ending
one a delete. It costs every currently signed-in person one forced sign-in,
once, which is why it is happening now rather than later.
The warning is only worth anything if it is rare, so the two obvious signals
are deliberately not used. Not the IP: it changes on every train, every café,
every mobile handover, and behind a VPN it is not theirs to begin with. Not the
user-agent: Chrome ships every four weeks and the string changes each time,
which would be twelve warnings a year per customer, describing nothing. A
device is a long-lived signed cookie holding a random token, and nothing else
is consulted. The honest cost — cleared cookies, a private window, a second
browser — is written into the mail itself, because a reader who thinks the
warning is wrong stops reading the next one.
Three things the tests found or hold:
The first device on an account is new and must not warn. Replying to somebody
creating an account by telling them they signed in from a new device is noise
on the one message that has to stay worth reading.
token_hash was globally unique, and the shared-browser test failed on it. Two
people on one machine share one cookie and must each own a device row against
it; the obvious repair — issue a fresh token for the second — would orphan the
first person's row and warn them about their own desk. Unique is now per
identity.
Recording a device may never block a sign-in. A safeguard that locks the owner
out when it breaks has stopped being one, so the listener logs and swallows,
and a test drops the tables to prove it.
Sessions are linked by a uuid carried inside the session payload rather than by
the session id, which Laravel rotates on every sign-in and every regenerate().
Laravel's own sessions.user_id cannot carry the link either: it is filled from
the default guard, while operators and customers are two guards and two tables
(R21) — operator 1 and customer 1 would each be offered the other's sessions.
Also lands the shared mail layout the rest of the templates will use: tables
and inline styles for Outlook, no image and no web font because half of all
clients block remote content, and the button in --accent-active rather than the
brand orange, which is 2.9:1 against white.
Not tagged: the session list is not built yet, and without a tag no server can
install this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An earlier edit removed the ADMIN_HOSTS example line and left its
"Beispiel:" dangling into the VPN_CONFIG_KEY block, so the file
documented neither properly. SECRETS_KEY was never in here at all —
without it the console silently stores no credentials, which is exactly
the kind of thing an example file exists to prevent.
The password no longer travels through third-party mail servers or sits
forever in an inbox: it moves onto the instance (encrypted, same as
nc_admin_ref) and shows on the dashboard until the customer clicks
"Notiert", which deletes it for good.
The 500 on the VPN config download was never in the VPN code. storage/logs/
laravel.log was owned by root, mode 644, since 27.07 04:16 — the moment a
failed `artisan optimize` wrote its error there during a deployment. From then
on the application could not append to its own log: Monolog threw on every
attempt, and a throw while logging is a 500 on any page that logs, with nothing
written down to say why. The error page said "Er wurde protokolliert". It was
not.
It surfaced on the config download because that is one of the few pages writing
a log line on its way through — on success and on a wrong password alike. Pages
that log nothing were unaffected, which is exactly why it looked like a VPN
fault for days.
`docker compose exec` is root unless told otherwise, and every deployment
script relied on that default — including the agent's allowlist sync, which
runs every minute. docker/entrypoint.sh had it right all along and drops to
www-data for precisely these commands. Now so does everything else, help text
included: telling an operator to run artisan as root is how the file ends up
owned by root in the first place.
update.sh also repairs what an earlier run left behind, before its first
unprivileged step rather than after — a root-owned log file is not
self-healing, the page that trips over it is nowhere near the deployment that
caused it, and with in_app unprivileged a root-owned vendor/ would break the
next composer step outright.
tests/Feature/DeploymentRunsAsTheAppUserTest.php holds the line. It was checked
against the previous commit and finds all nine places that were wrong.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked for directly: a commit on main must change nothing on a server, and only
a tag whose version is higher than the installed one may be installed.
The agent asked two different questions before this. A pinned server compared
tags; everything else counted commits on its branch — so a console following
main offered an update for every push, verified or not, and "Jetzt
aktualisieren" meant "take whatever is on the branch right now". Landing on main
and being released are different events, and only the second is a decision
somebody made.
It is one question now, regardless of what the checkout is attached to: is there
a tag whose version is higher than the deployed one? Compared with sort -V
against the version in the deployment manifest — not the VERSION file, which an
update moves before it migrates, so a run that failed in between would leave the
checkout claiming a version it never finished installing. A run always targets
that tag; the branch path is gone. A request that arrives with nothing to
install is answered rather than obeyed, before anything announces a run.
The console follows: the button is disabled with nothing released, says so in a
line rather than leaving a grey button to be guessed at, names the version it
would install rather than a count, and refuses the action server-side as well —
a Livewire action is a public endpoint, and one place a rule may not live is
only in the disabled attribute of a button.
VERSION becomes 1.1.0, to be tagged as the first release this mechanism can see.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking "Einrichten" generated a secret and showed the QR with no exit:
leaving meant navigating away, which left an unconfirmed enrolment on the
account that nothing on the console ever displayed. cancelSetup() clears
it — it was never confirmed, so it never protected anything.
The page was also one card with four states stacked into it. Now each
state is its own panel, the enrolment step says which step it is and
carries numbered instructions beside the code, and the recovery codes get
their own card with a copy button instead of being a footnote under
whatever came before them.
Found uncommitted in the working tree at the start of this session and carried
in as its own commit so it can be reverted on its own. Not written here: a
cancel action that clears an unconfirmed secret, the wording for every state of
the page, and a rebuilt setup view. The suite is green with it in place,
including the R18-R23 scan tests.
The code was immutable, which made a typo permanent. It cannot be freely mutable
either, and not only because hosts.datacenter references it: each host's DNS name
was minted from it and registered at the provider, the machine's own hostname was
set from it as it was built, the counter handing out those numbers is keyed by
it, and every past order stores it as plain text with no foreign key to follow.
Renaming a code with hosts in it is not a rename — it is a datacenter called hel
full of machines called fsn-01.
So it is editable exactly while nothing references it, which is the case that
matters: a typo noticed shortly after creating one. Once a host or an order
depends on it the field is disabled and says what is holding it, with the
numbers. The lock is recomputed from the database on every save, never read back
from the property the browser returns.
Alongside it, an optional free-text field for the specific datacenter —
fsn-dc-15. A provider runs several within one location, on separate power and
separate uplinks, and two entries both reading "Falkenstein (fsn)" are not a
choice anybody can make. Nothing keys off it; the code remains the identifier.
Also renames the country loop variables in the edit modal. They were $code and
$name — the component's own properties — and shadowed them for the rest of the
file. Harmless while nothing below the loop read them, which stopped being true
the moment the code became an editable field above it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checking for updates was a request written into the same mailbox as a real
update, collected by the same agent on the same systemd timer — so asking what
was new was answered on the next tick, and the console, having nothing else to
show, counted down to it. An operator who asked a question was told an update
would start in 3:44.
A systemd path unit now wakes the agent the moment the request file is written,
for a check and for a run alike. PathChanged, not PathExists: PathExists re-arms
as soon as the service goes inactive, so a request the agent could not consume —
it holds a lock for the length of an update — would restart it in a tight loop.
The timer stays as the fallback, and a drop-in disables systemd's start rate
limit, because two triggers on one oneshot service can otherwise wedge the unit
and leave neither of them able to run.
The countdown is gone from both the settings card and the maintenance overlay.
It could not be made honest: its target was recomputed on every poll, and where
the interval the agent reports is shorter than the timer actually installed on
that host it had already gone by, so the fallback moved it forward again each
time. It ran backwards four seconds and snapped back to a full minute, forever —
reported exactly that way. A queued run says it is starting; a queued check says
nothing beyond its badge, because a check starts nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>