Compare commits

..

312 Commits

Author SHA1 Message Date
nexxo 2979f008b5 Stop a mismatched-key decrypt from crashing the mailbox test button
tests / pest (push) Failing after 7m15s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
tests / pest (pull_request) Failing after 7m17s Details
tests / assets (pull_request) Successful in 24s Details
tests / release (pull_request) Has been skipped Details
2026-07-28 07:26:42 +02:00
nexxo a6a9c76660 Trust an explicitly configured mail host or port over the placeholder
env('MAIL_HOST')/env('MAIL_PORT') returning anything, or MAIL_URL supplying
either, now counts as "supplied" and wins over the sentinel comparison — a
relay genuinely on 127.0.0.1:2525 no longer reads as unconfigured. The
sentinel stays as a fallback for when neither signal says anything (including
under config:cache, where env() goes null and this collapses to today's
behaviour).
2026-07-28 07:10:53 +02:00
nexxo 15c81489d4 Stop downgrading an opportunistic smtp scheme to plaintext
MAIL_SCHEME=smtp is Symfony's opportunistic scheme (upgrade to STARTTLS
if offered, don't fail if not) — not a "no encryption" statement.
Mapping it to 'none' silently turned every existing MAIL_SCHEME=smtp
installation's encrypted-when-possible connection into an always-
plaintext one. Maps to 'tls' (STARTTLS required) instead: stricter than
smtp's own default, so a relay lacking STARTTLS now fails loudly rather
than leaking credentials.
2026-07-28 06:47:11 +02:00
nexxo 729f57755a Clear every mailbox's verification when the shared server config changes
saveServer() wrote host/port/encryption without touching any mailbox, so
the console kept showing every mailbox as verified against a server
nothing had tested since. Mirrors EditMailbox::save()'s existing guard on
a single mailbox's own identity, just at server scope: only clears when a
field actually changed, so re-saving unedited values leaves a real
verification alone.

Both places now delegate the actual clear to new Mailbox::
invalidateVerification()/invalidateAllVerifications() methods rather than
touching last_verified_at directly, sharing the mutation. The "did this
change" comparison stays separate in each caller - one diffs a loaded
model's attributes, the other diffs persisted settings against incoming
scalars, and forcing them through one function would add machinery no
actual duplication justifies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:36:06 +02:00
nexxo ebe29f8a5d Stop demanding a cipher, a real port, or a decrypted password a mailbox never uses
MailboxTester required a usable SECRETS_KEY unconditionally, refusing an
unauthenticated relay's test-send on a fresh install with no key yet, even
though real sending never touches the cipher for that mailbox. Gated the
check on authenticates, same as the password-presence check beside it.

Comparison pass across MailboxTester and MailboxTransport turned up two more
of the same shape. MailboxTransport's delegate-cache fingerprint decrypted
$box->password unconditionally, even when unauthenticated and never going to
use it — a mailbox that once authenticated, stored a password, and later had
authenticates unchecked would crash real sending the moment SECRETS_KEY
became unusable. And MailboxTester had no guard against a stored port <1;
Symfony silently reinterprets that as port 25 rather than refusing it, so the
test button could reach whatever happens to listen there and report success
for a configuration MailboxTransport refuses outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 06:21:28 +02:00
nexxo 5d306fce31 Let a mailbox send without a password when it never needed one
An SMTP relay with a host and a from address but no username or password
- a trusted local or private-network relay - always used to be seeded as
a placeholder, because the takeover required a username to treat an
account as real, and Mailbox::isConfigured() always required a stored
password. Laravel's own SMTP mailer has supported unauthenticated relays
all along.

Add mailboxes.authenticates (default true, so every existing row keeps
its current behaviour) to tell "does not authenticate" apart from
"authenticates, but nobody has typed the password yet" - the same empty
password column used to mean both. The takeover migration now seeds the
real account off the host/address alone and marks it authenticates=false
only when neither a username nor a password resolved; isConfigured(),
MailboxTransport and MailboxTester all read it before requiring or
sending a password; the edit modal gets a checkbox for it with the
password field's hint updated to match, in both languages.
2026-07-28 05:54:21 +02:00
nexxo 40a6b3269d Trust a stated mail scheme over the port guess when seeding encryption
MAIL_SCHEME=smtps or an smtps:// MAIL_URL on a non-465 port used to be
downgraded to STARTTLS because mail.encryption was derived from the port
alone. Resolve the scheme first (from MAIL_SCHEME directly, or from the
URL's own scheme via the same ConfigurationUrlParser Laravel uses) and
only fall back to the port heuristic when nothing was actually stated -
which is also what keeps this machine's invalid MAIL_SCHEME=tls from
silently steering the result.
2026-07-28 05:53:51 +02:00
nexxo 74ac406fc0 Make isUsable() apply the same 32-byte rule encrypter() does
isUsable() checked only "is SECRETS_KEY nonempty", so a set-but-malformed
key (wrong length, garbage base64) read back as usable even though
encrypter() rejects it two lines below. EditMailbox::save() and
MailboxTester::run() both gate on isUsable() specifically to avoid an
uncaught RuntimeException reaching the operator; a lying isUsable() meant
that guard did not fire in exactly the configuration it exists for.

Both methods now read resolveKey() — the base64: prefix, the raw-base64
path, the 32-byte check — so they cannot disagree about a value either
one is given. SecretVault::isUsable() and the secrets console page's
"no key" banner both delegate down to this and are covered here too, not
assumed to inherit the fix correctly.
2026-07-28 05:09:09 +02:00
nexxo f0c3bd9c2e Resolve MAIL_URL before seeding, so a takeover doesn't blank out a working relay
The seed migration read config('mail.mailers.smtp.host') and its three
siblings directly. An install configuring SMTP through MAIL_URL (the
other form config/mail.php supports) never sets those individual keys —
MailManager only substitutes the URL's components in at transport-build
time, inside its own protected getConfig(). Read raw, that left the
migration seeding config/mail.php's own placeholder defaults, which the
UNCONFIGURED_HOST/UNCONFIGURED_PORT guards then read as genuinely unset:
blank host, port 0, an unconfigured mailbox, for an install that was
sending mail successfully.

resolveSmtpConfig() calls the same Illuminate\Support\ConfigurationUrlParser
MailManager::getConfig() itself delegates to, so this is the same
resolution rather than a second copy of its merge order. Verified against
the real (protected) getConfig() via reflection: the URL wins for
host/port/username/password wherever it actually supplies one, and an
unset MAIL_URL leaves this a no-op.
2026-07-28 05:08:58 +02:00
nexxo e8a3b8d5be Seed the mailbox address from mail.from.address, not the SMTP login
The takeover migration seeded `address` from MAIL_USERNAME and left
`username` null unconditionally, conflating the SMTP login with the
sender address. Whenever the two differ — an API-key username, a
shared relay login — mail would go out From the login instead of the
address the system was actually configured to send from, exactly like
this installation: MAIL_USERNAME is no-reply@clupilot.com but
MAIL_FROM_ADDRESS is hello@clupilot.local.

`address` now reads config('mail.from.address'), falling back to the
SMTP login only when that reads back config/mail.php's own unset
placeholder (env('MAIL_FROM_ADDRESS', 'hello@example.com') — the same
masking problem UNCONFIGURED_HOST already guards against for
MAIL_HOST). `username` is set to the SMTP login only when it actually
differs from the resolved address, since Mailbox::smtpUsername()
already falls back to address when username is null. display_name
now reads config('mail.from.name') instead of a hardcoded literal.

The placeholder domain for the four non-configured mailboxes still
comes from the SMTP login, not from mail.from.address — the two can
name different domains, and only the login's is proven to accept mail
for a given install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 04:49:37 +02:00
nexxo 3278f063ea Validate purpose mappings against real mailboxes, and clear stale verification on identity changes
savePurposes() only checked that each key was a nonempty string, so a
stale or tampered mailbox key (reachable by posting straight to
/livewire/update) saved successfully while MailboxResolver silently
returned null for it. Every purpose's key must now name a mailbox that
exists; system's must additionally be active, since every unmapped or
inactive OTHER purpose falls through to it.

EditMailbox::save() also cleared last_verified_at only when the
password changed, but smtpUsername() depends on address and username
too (falling back to address when username is blank) — changing
either while leaving the password untouched kept showing a successful
verification for an identity nothing had actually tested.
2026-07-28 04:35:16 +02:00
nexxo c55a5d2b49 Make ssl, tls, and none mean what they say on the wire
EsmtpTransport's autoTls (default true) and requireTls (default false)
are independent of the implicit-TLS constructor argument: 'tls' left
these at their defaults, so a server that omitted STARTTLS sent
mailbox credentials in the clear while reporting success, and 'none'
would silently upgrade the moment a server offered STARTTLS. Both
MailboxTransport and MailboxTester already duplicated the "ssl, or
port 465 ⇒ implicit" half of this decision independently; MailTlsPolicy
is now the one place both read, for both dimensions.

MailboxTester still builds through Mail::build() rather than a hand-built
transport: MailManager::createSmtpTransport() forwards the whole config
array to EsmtpTransportFactory as DSN options, which already reads
auto_tls/require_tls itself, so the same policy reaches this path
through keys the config array already supports.
2026-07-28 04:35:03 +02:00
nexxo 6fc25051e4 Fix the mail scheme example — smtp and smtps, not tls
Symfony's mailer only knows the schemes smtp and smtps; the example's
MAIL_SCHEME=tls (and the "tls | smtps (465)" comment naming it as an
option) made the default mailer unconstructible. .env itself is left
alone — that is the operator's live file.
2026-07-28 03:50:28 +02:00
nexxo 5de1f45703 Close two ways a mail.manage session could take over outgoing mail
I3: nothing stopped an operator unchecking Aktiv on the mailbox that
mail.purpose.system points at. MailboxResolver::active() filters an
inactive mailbox at BOTH the direct lookup and the system fallback, so
that one checkbox could take mail.purpose.system to null and every
purpose without its own mapping down with it — the existing
system_required guard on savePurposes() reads as if this were covered,
but it only blocks the empty-mapping path, not this one.
EditMailbox::save() now refuses active=false against the mailbox 'system'
currently points at, the same way savePurposes() already refuses to leave
'system' empty.

I4: Admin\Mail::saveServer() writes mail.host — the platform's outbound
relay for every purpose mailbox at once — and CloudReady::toMail() puts a
customer's Nextcloud admin password in cleartext in the message body. So a
signed-in mail.manage session was already a credential-interception
primitive, the same threat Admin\Secrets' second gate exists for. Both
saveServer() and EditMailbox::save() (only when a new password is actually
being typed) now also require passwordRecentlyConfirmed(), following the
ConfirmsPassword pattern Secrets.php and Settings.php's two-factor actions
already use — including their per-action UI shape (an inline confirm form
scoped to the gated action), not a whole-page lock, so address, display
name, username, no-reply, active and the purpose mapping stay reachable
without confirming, per the brief's capability split.

Both gated and ungated paths are covered by tests, and both guards were
mutated away and confirmed to fail before being restored.
2026-07-28 03:50:19 +02:00
nexxo 7102d01bcc Keep the purpose mailer through the queue instead of losing it to the default
Mail::to($x)->queue($mail) resolves the DEFAULT mailer, and
Illuminate\Mail\Mailer::queue() (vendor Mailer.php:482) does
`$view->mailer($this->name)->queue($this->queue)` — overwriting
MaintenanceAnnouncementMail/MaintenanceCancelledMail's own 'cp_maintenance'
with the default mailer's name ('array' in tests, 'log'/whatever
MAIL_MAILER is in prod) before the job is even built. Both mails kept
sending through the old .env account instead of MailboxTransport. CloudReady
was never affected — MailChannel.php:66 preserves a notification's own
mailer.

Fix: name the mailer before queueing — Mail::mailer($mail->mailer)->to(...).
$this->name on the resolved Mailer is then already $mail->mailer, so the
vendor overwrite becomes a no-op instead of a silent downgrade. Confirmed by
reading the vendor source, not assumed.

SenderAddressTest's "names the purpose mailer..." test asserted
$mail->mailer on a bare, never-queued mailable, which proves nothing about
what survives queueing — the constructor sets it right regardless of what
MaintenanceNotifier does with it. Replaced it with one that drives
MaintenanceNotifier::deliver() under Queue::fake() and inspects the pushed
SendQueuedMailable's mailable. Verified it fails against the pre-fix code
first (the pushed job carried mailer = 'array'), then verified the fix
makes it pass, then mutated the fix away and watched it fail again.
2026-07-28 03:50:02 +02:00
nexxo 99b7393361 Stop committing a real password, and say the truth about what carried over
MailboxSeedMigrationTest.php had the actual .env MAIL_PASSWORD for
no-reply@clupilot.com hard-coded into a test fixture — replaced with an
obvious dummy. Grepped the whole branch (576cfca..HEAD) and the working
tree for that string and for every other real secret value in .env;
nothing else leaked.

Separately, the seed migration's SECRETS_KEY-missing warning claimed a
password "was NOT copied into the mailbox table" even when a vault row
already existed and its ciphertext WAS carried across a few lines below,
unconditionally on $canEncrypt. An operator debugging a live mail outage
would have been sent to re-enter a password that was already sitting in
the column. The warning now distinguishes the two cases and says which
one actually happened.
2026-07-28 03:49:37 +02:00
nexxo befb67327f Prove a mailbox works by actually sending from it 2026-07-28 02:56:47 +02:00
nexxo a9c777c79a Rename the SECRETS_KEY page test to match what it actually checks
It only ever loaded the page and asserted the banner, never a password
save — but its name claimed the crash-on-save case, which is exactly the
promise that let that bug ship unnoticed once. The test that earns the
crash claim is its new neighbour, added for the same finding.
2026-07-28 02:24:58 +02:00
nexxo 67a3e78cb4 Stop the mailbox password save from crashing without SECRETS_KEY
EditMailbox::save() now checks SecretCipher::isUsable() before touching a
typed password and reports it as a form error, instead of letting
SecretCipher::encrypt() throw all the way to Laravel's debug page. Also
switches the mail.manage migration to Role::findOrCreate, matching every
sibling capability migration, so a squashed replay can't hit
RoleDoesNotExist.
2026-07-28 02:22:36 +02:00
nexxo 4211f3dfab Give the mailboxes a page, and the support sender its own capability 2026-07-28 01:57:11 +02:00
nexxo a12ab148b3 Close the data-loss paths the review found in the mailbox takeover
The vault's mail.password row is no longer deleted by this migration at
all, in either direction: deleting it unconditionally destroyed a real
password whenever MAIL_USERNAME was empty (nowhere to carry it to), and
even on a successful carry-over a later rollback had nothing left to fall
back to. Reproduced both against real MariaDB, along with a migrations-
table drift that crashed a retry on the mailboxes.key unique constraint —
fixed with Mailbox::firstOrNew() and a transaction around each direction.
down() now goes through the new Settings::forget() instead of a raw
delete, so it stops leaving the settings cache holding what it just
removed. A skipped .env password (SECRETS_KEY still unset) now prints an
operator-visible line instead of migrating clean and failing silently
later. Host/port that resolve to config/mail.php's own placeholder
defaults are treated as unset rather than as a real relay.
2026-07-28 01:15:50 +02:00
nexxo 3e4d5472a7 Move the one SMTP account into the mailbox table it outgrew
The seed migration also guards against SECRETS_KEY being unset at migrate
time (encrypting the .env password would otherwise throw and take the
whole migration down, on a fresh install as much as in the test suite),
and the four tests/Feature/Mail files written before this migration reset
the mailbox table in their beforeEach so they keep testing what they did
before mailboxes.key collided with the seeded rows.
2026-07-28 00:24:54 +02:00
nexxo c899d41946 Pin CloudReady's null-mailbox guard and share it with the trait
A mutation the reviewer ran (if ($box !== null) -> if (true)) left every
test green: CloudReady hand-rolled the same null-mailbox guard as
SendsFromMailbox, but nothing exercised its null branch. Added the
missing test, then extracted the decision itself (null mailbox -> no
sender fields, no_reply -> no Reply-To) into one shared method so an
Envelope-shaped and a MailMessage-shaped consumer can no longer disagree.
2026-07-27 23:59:05 +02:00
nexxo 64a33c675e Put a real sender on every mail, and a reply address where one helps 2026-07-27 23:40:34 +02:00
nexxo 961bf62032 Make the DSN agree with send() when a mailbox has no password
Re-review of the previous fix round found the "cannot disagree" part of
finding 4 was still open. resolution() had unified the log/array/null/
mailbox decision, but inside the mailbox branch describe() returned
$box?->address unconditionally while delegate() additionally required
isConfigured() before using it. A mailbox with active=true, a real
address, and no password produced
'mailbox://support/support@clupilot.com' — reading as entirely healthy
— while send() threw. That is exactly the harm the original finding
named: the log says fine, the mail fails.

resolution() now decides "unconfigured" too, not just "which
non-delivering mode": it calls isConfigured() itself, once, and returns
that verdict alongside the box. describe() and delegate() both branch
on the SAME result again, the same way they already did for log/array/
null — a mailbox with no password now reads as
'support@clupilot.com [unconfigured]' rather than a plain address, and
the address stays visible so an operator can tell WHICH mailbox needs
fixing.

Also renamed a test the re-review flagged as promising more than it
checked: "registers a mailer per purpose without touching the database
at boot" only ever verified the config array's shape — the DB-touching
claim is a separate, already-passing test just below it
(defines every purpose mailer as a plain literal...). Renamed to
"registers a mailer for every purpose". No behaviour change.

Added a test creating a mailbox with no password and asserting the DSN
both names the address AND carries the marker, plus that send() still
throws — proving agreement, not just a matching label painted on
separately. Mutated describe()'s unconfigured branch to drop the
marker: the test failed exactly on the missing 'unconfigured' string
(DSN read as a plain, healthy-looking address again). Reverted, reran,
passes.

Full suite: 667 passed. vendor/bin/pint --test clean on both files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:17:42 +02:00
nexxo a39670f920 Close the safety-net gaps the review found in MailboxTransport
The reviewer continued the mutation sweep from Task 4's own commit and
found three more unpinned lines in delegate(), plus two real behaviour
gaps, all inside the same 40-line method:

- The fingerprint refresh (the headline feature: a worker's mailer
  must pick up a corrected password without a restart) had no test —
  mutating its check to "delegate === null" left the suite green.
- The implicit-TLS third argument to EsmtpTransport had no test either
  — mutating it to a hardcoded true left the suite green, because the
  only assertion touching it was instanceof EsmtpTransport, which the
  hanging variant also satisfies.
- isLogging() only ever checked for 'log', but phpunit.xml sets
  MAIL_MAILER=array for the whole suite. Task 5 wires real mailables
  to these mailers next; the first feature test that sends one without
  Mail::fake() would have opened a real SMTP connection.
- describe() and delegate() each ran their own copy of that guard —
  which is exactly how deleting it from ONLY delegate() passed the
  whole suite in the first place: __toString() kept reporting "log"
  from its own untouched copy.
- An unconfigured host/port (Settings::get('mail.host', '') and a
  stored null port casting to 0) built a transport pointed at
  smtp://:587 or silently downgraded to plaintext port 25, instead of
  refusing the way a missing mailbox already does.

Fixes: replaced isLogging() with resolution(), the one place that now
decides "log, array, null-default, or a real mailbox" — describe() and
delegate() both branch on ITS result, so a guard broken in one cannot
look fine in the other. Added an explicit RuntimeException for a blank
host or non-positive port, guarded the same way as the missing-mailbox
case just above it.

Tests: added coverage for the fingerprint rebuild (reuses the same
delegate until the password actually changes, proven by object
identity), the exact DSN string for both the STARTTLS (587) and
implicit-TLS (465) cases, 'array' and unset-default both landing on a
non-delivering transport, and the blank host/bad port throwing.
Mutated each of these four in turn on the final code and confirmed:
fingerprint check removed -> the reuse test fails (same object handed
back after the password changed); TLS argument hardcoded true -> the
587 DSN test fails ('smtps://...' where 'smtp://...:587' was
expected); NON_DELIVERING narrowed back to ['log'] -> both the array
and unset-default tests fail (a real EsmtpTransport where a safe
transport was expected); host/port guards removed -> both throw
expectations fail. Reverted each, reran, all twelve tests pass again.

Replaced the two tests that reached into delegate() via Closure::bind
to check "log or SMTP" with one behavioural test: point Settings at a
closed loopback port (127.0.0.1:1, so refusal is instant and entirely
local — no DNS, no dependency on this environment's network egress
policy) and assert Log::shouldReceive('debug')->once() while sending
for real through Mail::mailer('cp_support')->raw(...). Mutated
NON_DELIVERING to drop 'log' specifically and confirmed this test
alone catches it: a real connection attempt to the closed port throws
TransportException (Connection refused) in under 300ms. Reverted,
reran, passes again.

Also: the two tests that only checked the config array's shape and a
snapshot of the resolved address promised more than they verified —
CLAUDE.md R19 names a test that recomputes the implementation as
checking nothing. Added a source-level test that the five cp_* config
entries are parenthesis-free literals (same technique
DisplayTimezoneTest/IconLayoutTest use: a property of the file, not of
one run) as the actual "no query at boot" proof, and renamed the
send-time test to what it verifies now that the fingerprint test above
covers the live-refresh claim properly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:02:18 +02:00
nexxo 0be9c61484 Make a deactivated mailbox fall back like a deleted one
Review of Task 4: MailboxResolver::for() only fell back on a missing
mapping or a missing row. Deactivating a mailbox — the operator's
explicit "do not use this" — left it resolvable anyway, so
MailboxTransport reached it, found isConfigured() false, and threw
instead of quietly using the next candidate the way every other
"this mailbox is unusable" case does. Worse, __toString() still
reported the deactivated address as though it were healthy.

named() now runs through a new active() filter at BOTH lookup sites —
the purpose's own mapping and the system fallback — so an inactive
system mailbox is refused exactly like a missing one, not handed out
as the last resort. A missing password stays deliberately unfiltered
here: that is a MailboxTransport concern (loud failure naming the
mailbox), not a resolver fallback trigger — silently rerouting mail
because a password hasn't been typed yet would hide the gap instead
of surfacing it.

Added two tests and mutated active() to a no-op filter to confirm they
catch it: both failed (one expected the system mailbox, got null; the
other expected null, got the inactive mailbox's full attribute dump).
Reverted, reran, both pass again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:57:22 +02:00
nexxo 095d8e694a Resolve the sending mailbox when the mail goes out, not at boot
Five purpose-named mailers (cp_maintenance, cp_provisioning, cp_support,
cp_billing, cp_system) land in config/mail.php as plain static entries —
no query runs to build them. Mail::extend('mailbox', ...) in
AppServiceProvider registers a transport that looks up its mailbox
through MailboxResolver only once a mailer is actually resolved, which
for queued mail is inside the worker at send time, never at boot.

Mutation-tested the brief's own three tests by deleting the MAIL_MAILER
guard from MailboxTransport::delegate(): all three stayed green. They
only exercise __toString()/describe(), a code path separate from the one
that actually sends. Added two tests that reach into delegate() itself
(a bound closure, since it's private) and assert the transport it
actually builds: LogTransport while MAIL_MAILER=log, EsmtpTransport once
it isn't. Re-ran the same mutation against the strengthened suite: it
now fails (EsmtpTransport where LogTransport was expected), then passes
again once reverted.

Verified two things against the installed versions before writing this:
LogTransport takes a Psr\Log\LoggerInterface and Log::channel(null)
resolves to the default channel rather than throwing, and
Mail::extend('mailbox', ...) is read by
MailManager::createSymfonyTransport() via $config['transport'] before it
would fall back to createSmtpTransport() — both matched the brief
exactly, no changes needed there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:28:37 +02:00
nexxo 06b84c5cd5 Say which kind of mail leaves from which mailbox 2026-07-27 22:00:16 +02:00
nexxo c5021c4aa7 Make the SECRETS_KEY test actually discriminate from APP_KEY
The old test rotated app.key and expected the password to stay
readable, but Laravel's encrypter is a container singleton resolved
once — config()->set('app.key', ...) never rebuilds it, so the
rotation the test performed was invisible to any code path going
through it. Confirmed by mutation: rewiring Mailbox to Crypt::
encryptString/decryptString (genuinely APP_KEY-keyed, the exact
regression this constraint exists to forbid) left the old test green.

Replace it with two tests: a positive proof that rotating SECRETS_KEY
makes a stored password unreadable (the one that discriminates), and
a separate check that rotating APP_KEY does not, with
forgetInstance('encrypter') so that rotation is actually visible to
whatever the password accessor resolves.

Re-ran the same mutation against the new test: it fails (exit 1, the
SECRETS_KEY test reports the expected DecryptException was not
thrown), then passes again once the mutation is reverted.
2026-07-27 21:49:46 +02:00
nexxo c5252847bb Make the SECRETS_KEY test able to fail
The Task 2 reviewer rewired the model to Laravel's APP_KEY-based Crypt
facade and every test stayed green. Laravel's encrypter is a container
singleton resolved once from app.key, so config()->set('app.key') never
rebuilds it and the rotation the test performed was invisible.

Replaced with a positive proof — rotating SECRETS_KEY must break
decryption — plus the APP_KEY case with forgetInstance(), which is what
makes that direction mean anything.
2026-07-27 21:44:59 +02:00
nexxo aaddffc096 Reuse the existing UUID trait instead of a second copy of the logic
Mailbox hand-rolled the same creating-time UUID assignment that
App\Models\Concerns\HasUuid already provides to 17 other models under
R11 (URLs address records by UUID, not integer PK), and skipped the
route-key binding that comes with it. Swap onto the trait, and cover
both the assignment and the route key with a test matching the
convention already used for Host/Order.

Also match MailboxFactory's @extends annotation to how the other
factories in the repo write it (Pint's fully_qualified_strict_types
rule turns a fully-qualified docblock reference into an import plus a
short name anyway, so this is what it would end up as either way).
2026-07-27 21:31:39 +02:00
nexxo c642090d97 Point the mailbox model at the UUID trait the repo already has
Seventeen models use App\Models\Concerns\HasUuid, and R11 is the reason:
URLs address records by UUID, not by integer primary key. The plan told
the implementer to hand-roll booted() instead, losing getRouteKeyName()
with it. Authoring error, corrected before the remaining tasks copy it.
2026-07-27 21:28:23 +02:00
nexxo 270ec942d6 Give every sending address a record of its own 2026-07-27 21:25:35 +02:00
nexxo 5cbd949bd5 Say plainly that the path comment in the plan is not code
The Task 1 implementer copied '// path/to/file.php' into the files
verbatim, which breaks Pint's blank_line_after_opening_tag and matches
no file in the repo. The annotation was mine; the constraint now says so
before the next seven tasks repeat it.
2026-07-27 21:20:37 +02:00
nexxo 38c5518f45 Drop the stray path comment after the opening PHP tag 2026-07-27 21:19:32 +02:00
nexxo 93d409e18f Pull the secrets encrypter out where two callers can share it 2026-07-27 21:09:10 +02:00
nexxo 0b7961ec36 Keep the SDD scratch directory out of the repository 2026-07-27 21:06:07 +02:00
nexxo 576cfcafac Plan the mailbox work as eight testable steps
Self-review against the spec turned up four gaps and one latent bug in
what already ships.

The bug: Laravel 13's MailManager reads only 'scheme' when building an
SMTP transport (MailManager.php:196) — the old 'encryption' fallback is
gone — and .env carries MAIL_SCHEME=tls, which Symfony does not accept;
it knows smtp and smtps. Nothing has failed because MAIL_MAILER=log
means no connection has ever been opened. The plan stores a human-facing
tls/ssl choice and translates it where it is used, and never copies
MAIL_SCHEME through.

The gaps: the cancellation mail and the provisioning notification were
edited but not tested, secrets.manage was never proven insufficient for
the new page, and a missing SECRETS_KEY had no task making the page say
so rather than throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:03:13 +02:00
nexxo 743ba96d13 Design mailboxes as records, not as one more secret
The console offers a single mail.password pointing at one SMTP mailbox,
which is nowhere near enough for five sending addresses. A mailbox is
address, display name, username and password; five of them as registry
entries would be twenty secrets named mail.support.username, and the
question of which mail sends from which address would still have no home.

Send-only stays usable because every message carries Reply-To on its own
mailbox, so a customer's reply lands in a mailbox someone reads. That is
what makes IMAP unnecessary rather than merely deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:51:03 +02:00
nexxo edaceb9597 Delete the users row once the operator has moved out of it
Decided: whoever is in operators has no business in users. A row left
behind is the same mixing the separation exists to end, only smaller.

The one case that deletes nothing is a row with a customer, a seat or an
order on it. That would mean the same person is operator and paying
customer, and a silent delete would take billing data with it — so the
migration stops and names the address instead. Neither existing account
is in that state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:44:56 +02:00
nexxo ce970a5fac Design the console's own identity, separate from customer accounts
The operator console and the customer portal share one users table, one
login page and one guard. Three reported faults follow from that single
fact: the console serves the portal's sign-in page, that page offers
"Registrieren", and the link 404s because RestrictAdminHost::SHARED does
not list register.

Measured rather than assumed: driving the host guard directly gives
/login through and /register 404 on the console host, and all sixteen
Spatie permissions turn out to be console permissions — so RBAC moves to
the new guard rather than being duplicated across two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:40:22 +02:00
nexxo 96dac236e8 Write the handoff, and rescue the approved templates into the repo
tests / pest (push) Successful in 7m3s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
The three templates the design was signed off against lived in a
session-scoped scratchpad and would have disappeared with the session — the one
artefact the whole conversion is measured against. They are in docs/design/ now,
with the measurements that were got wrong at least once written down beside
them, and a note that the way to check is to render and measure rather than to
read the source. That distinction already settled one disagreement: the source
said the metric grid used a 20px gap, rendered it is 14px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:18:20 +02:00
nexxo 9b8d5dfd1e Editing in modals, an update button that is not gated on a stale reading, and a support page that is real
tests / pest (push) Successful in 7m18s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
Three things reported together.

── Editing belongs in a modal (R20)

The seats table grew its input fields into the row. It worked and it looked
broken: the row grew, the columns beside it jumped, and a table half in edit
mode reads as a rendering fault rather than as a form. The project already had
the answer — EditDatacenter, whose own header comment says it avoids exactly
that row-height jump — and the seats table simply did not use it.

EditSeat is now a ModalComponent. A modal is reachable WITHOUT the page's route
middleware, so it resolves the customer itself and re-reads the record rather
than trusting a hydrated property: a forged addressEditable would otherwise
open the address of an accepted seat, and the address is the person — editing
it hands one employee's access to another with nobody told. Only an invitation
still in flight can have its address corrected, which is the case that actually
comes up.

The actions column is now always drawn. It used to disappear when the only seat
was the owner, on the reasoning that there was nothing to act on. But every
seat can be renamed, and a column that vanishes does not read as "not
applicable here" — it reads as "this product cannot do that", which is how it
was reported, three times. The owner's row says "Geschützt" rather than leaving
an empty cell.

Also caught here: the edit fields carried class-wide #[Validate] attributes, so
an empty edit form made the INVITE button fail on a field the invite form does
not have. Rules for an action belong to the action.

── The update button

"I cannot run an update, it says everything is current." `behind` is a READING
taken by the agent every five minutes, not the state of the world — push a
commit and the console insists it is up to date and refuses to act. The agent
does its own fetch before deciding, so asking against a stale reading costs one
fetch and finds nothing; being locked out costs the deployment. The button is
now offered whenever an agent is alive and no run is in flight, labelled for
what it does rather than for what the last reading said.

── The update never announced that it had finished

Because the thing being watched restarts the thing doing the watching. Mid-run
every wire:poll request fails, and what answers afterwards is a new build being
questioned by the old page's JavaScript — so the card sat on "läuft" until
somebody reloaded by hand. A small Alpine watcher now asks a plain JSON
endpoint (no Livewire, no component state, no assets), treats a failed request
as the restart rather than as a fault worth giving up over, and reloads once
the build it is looking at is no longer the build it started with.

── Support

The page was a decorated placeholder: a button that raised a toast saying the
form was "only hinted at in the prototype", three invented ticket titles living
in the translation file, and no way to see what became of anything. It looked
thin because nothing on it was real.

It now leads with the customer's own requests — what somebody arriving here
wants to know is what they asked and whether anyone answered — with contact
details moved to the side where they belong. The form attaches the plan, the
instance and who is asking automatically: making a customer describe their own
server back to the people who built it is the part of support people hate. FAQ
answers end where the thing can actually be done, and "is it me or is it you"
is answered by a link to the status page.

637 tests. R20 recorded in CLAUDE.md and enforced by EditInModalTest: no page
view may grow an input field inside a <td>.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:55:49 +02:00
nexxo bd779f00e5 Show times on the operator's clock, keep storing them in UTC
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 30s Details
tests / release (push) Successful in 6s Details
The console announced an update for "spätestens um 15:21" while the clock on
the wall said 17:21. Not a formatting slip in one string: fourteen views and
four components printed database values straight out, so every absolute time in
the product was two hours out all summer and one hour out all winter.

Two of those views looked as though they had handled it. They called

    ->timezone(config('app.timezone'))

which reads like "convert to local time" and, app.timezone being the STORAGE
zone that must stay UTC, converts to UTC — a no-op wearing the costume of a
fix, which is worse than no call at all because it stops the next person
looking. That exact string is now banned by test.

Worse than the labels were two FORMS. Maintenance windows and plan versions
filled their datetime-local fields from UTC and parsed what came back as UTC. A
datetime-local field carries no offset — it is the digits a person reads off
their own clock — so an operator typing 21:00 scheduled a window for 23:00
their time, and nothing anywhere said so. App\Support\LocalTime now holds
toField() and fromField() side by side, because getting one end right is not
half a fix, it is a fresh bug.

The mechanism is one Carbon macro, ->local(). It copies before converting:
Illuminate\Support\Carbon is mutable, so without that, rendering a timestamp
would rewrite the model attribute as a side effect and anything comparing or
saving it afterwards would be an hour or two out. Both Carbon classes get the
same body — Carbon keeps macros in one global table, so the second registration
replaces the first for every Carbon class, and two different bodies meant the
immutable version, safe for itself, silently became the mutable one's
implementation too. My own test caught that.

The existing tests had not caught any of this because they built their expected
values with the same wrong call the views used — a test that recomputes the
implementation asserts nothing. They now assert the wall clock, and the queued
update additionally asserts that the UTC time is NOT shown.

Recorded as R19 in CLAUDE.md and enforced by tests/Feature/DisplayTimezoneTest:
no Blade or Livewire component may format an absolute time without ->local(),
the UTC no-op is banned, storage stays UTC, both sides of the DST boundary are
checked, ->local() must not mutate, and a form field must round-trip to the
same instant.

APP_DISPLAY_TIMEZONE, default Europe/Vienna. 615 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:32:21 +02:00
nexxo 186c2e46d1 Merge the panel design conversion into main
tests / pest (push) Successful in 12m28s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Successful in 4s Details
2026-07-27 17:10:35 +02:00
nexxo 4daf37a10b Give the demo account a real password, not one written in the source
The demo customer is a genuine login on a panel that answers from the public
internet. A default password in a seeder would sit on production for as long as
the demo does, and "it is only the demo account" is how the first one gets
taken. It now takes DEMO_PASSWORD if the operator sets one and otherwise mints
a 24-character random password, printed once at seed time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:09:54 +02:00
nexxo 435a202fdd Match the panel to the approved template, measured rather than assumed
tests / pest (push) Successful in 8m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The panel had been built by reading the template and believing the result.
Every round of review found the same class of defect, so this round the
template was rendered in a browser and measured with getComputedStyle, and the
implementation measured the same way — two number columns instead of an
opinion.

That immediately settled a disagreement: a review of the template's SOURCE said
the metric grid used a 20px gap. Rendered, it is 14px. The measurement wins.

Brought to the template's figures: label 11.5px (it was 11px in three separate
places, which is how it drifted — it is now one .lbl rule), value tracking
-0.02em, unit weight 450, metric row 6px above and 14px between, page head
centred with a 14px gap, grid gap 14px, columns switching at 1101/561px and the
h1 at 901px to match the template's own breakpoints, ring 62px, bar 5px.

The shared button now takes its height from min-height (40px) rather than
vertical padding, at 14px/600 with 0 18px padding and a 10px radius — a button
keeps its height whatever sits inside it, icon, spinner or bare text.

Also here, from the same round of review:

- The chart's blue border was Tailwind's own `ring` utility colliding with a
  component class of the same name. Renamed to `.metric-ring`. This was
  dismissed once as a screenshot artefact; it was real.
- Page titles lost the `sm:text-3xl` (40px) an earlier bulk edit had appended
  to 23 of them. The template's h1 is 30px.
- The users table has its actions back — edit, suspend, lock and delete — for
  every seat that is not the owner, with the owner refused in the action itself
  and not merely hidden in the markup.

DemoCustomerSeeder writes one complete customer as real rows: instance,
subscription, six seats across four roles, a backup, current-period traffic and
thirty days of samples with a deliberate wobble and one day at 286/288 checks.
Nothing in the panel is drawn from a fixture any more, so anything missing
shows up as missing. Removing the demo is one deletion.

Verified: 607 tests, and a Codex comparison of the two measurement sets —
"a person would call them the same design", the only difference a 1px gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 17:08:05 +02:00
nexxo 85fcc154b8 Measure availability, and let a customer move down again
Availability is now counted from our own monitoring answers. Uptime Kuma knows
the figure, but the REST bridge in front of it only answers "healthy, yes or
no", and waiting for the bridge to grow an endpoint would have left the panel
without the number indefinitely. The sync already asks on a schedule; counting
the answers gives a percentage that is ours and explainable.

An unanswered check is not a failed one — the counter only moves when a real
answer arrived, so our monitoring going down does not become the customer's
outage on the one figure they are asked to trust. An instance nobody has ever
checked shows no availability rather than 100 %.

The downgrade path existed nowhere. Going down is not the mirror of going up:
it can ask an instance to hold more than the target plan allows. Smaller plans
are now listed even when they cannot be taken, with the obstacle in the
customer's own numbers — "you have 31 users, this package allows 10" — because
a greyed-out button that does not name what is in the way is the thing people
ring about. Storage is checked against the last real reading rather than the
contractual allowance, or anyone who once bought a large plan could never leave
it. The limit is re-checked in the action: a rule enforced only in markup is
not enforced.

And the smaller corrections from this round:

- The cloud tab carried a hardcoded "B" as the instance's initial, and the PHP
  and MariaDB versions, which tell a tenant nothing they can act on.
- "EU — Serverstandort im Angebot festgehalten" under a label already reading
  "Serverstandort" was two sentences saying nothing. It is the country.
- Support promised a reply "binnen 4 Std." with nothing behind it. What is true
  everywhere else is an answer on the same working day.
- The actions column appeared even when no row in the table had an action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:41:15 +02:00
nexxo ee5e92c0ed Measure what the template draws
The panel could not show the storage ring or the transfer trend because nothing
measured them. instance_traffic keeps one row per billing period — right for an
allowance, useless as a series — and storage consumption was never sampled at
all, so the card could only ever state what was sold rather than what is used.

instance_metrics holds one row per instance per day, written by the traffic
collector on the visit it already makes. A second scheduler entry against the
same Proxmox API would have doubled the load and the failure modes for no gain.

Disk usage comes from `df` inside the guest rather than from Proxmox's own disk
figure, which reports the allocated image: that would show every customer a full
500 GB from their first day.

The rule the whole thing is built on: a reading that could not be taken is not a
reading of zero. The disk columns are nullable and left untouched when the guest
agent does not answer, so yesterday's figure stands instead of the ring dropping
to empty — which would tell a customer their data had vanished. Missing days
stay missing in the series rather than being filled with zeroes, which would
draw an outage that never happened. Both are covered by tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:29:28 +02:00
nexxo c2a56382e1 Build the dashboard cards to the template, and name what has no source
Card by card against the approved template: label with a chevron, the figure
and its unit on one line, a line of context underneath, a bar across the foot.
The order matches too — storage, seats, transfer, then the fourth slot.

Three of the template's four visuals have no data behind them, and the code now
says so where someone will read it rather than leaving the next person to
wonder why the charts are missing:

- Storage consumption is never sampled. The card states the contractually
  agreed allowance instead of drawing a ring at an invented level.
- instance_traffic keeps one row per period, not a daily series, so there is
  nothing to draw a sparkline from. The share of the allowance is real, so
  that card carries a bar.
- monitoring_targets records a state and a check time but no uptime figure, so
  there is no availability percentage to show. That slot carries the last
  backup — the thing this product is about and can actually prove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:21:27 +02:00
nexxo 676c38643c Follow the approved template, and stop showing invented records
The dashboard was built to my own idea of the layout rather than to the
template that was signed off: a coloured pill where the template has a stamped
mono line, bare figures where it has a label with a chevron, a unit on its own
line instead of beside the number, and no ring at all. There is no point
agreeing a design and then building something adjacent to it. The metric card
is now a component that draws the template's form, and the pages use it.

Two more places were still showing data that does not exist:

- Support listed three tickets to every customer, complete with reference
  numbers and dates. There is no ticket model; it was fixture text. A request
  list also belongs in the console, where an operator can see who filed what.
  Removed until there is something real behind it.
- The datacentre name reached the customer through the cloud tab's copy as
  well. Customer surfaces name the jurisdiction; the building is operator
  information.

And the actions column rendered an empty cell for the owner, who can be neither
re-invited nor revoked. An empty cell reads as a missing feature; it now says
so with a dash and a title.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:17:06 +02:00
nexxo 21d8dc310a One definition of the navigation, and the country instead of the building
The breadcrumb said Übersicht on every page. The sidebar and the breadcrumb
each built their own copy of the structure, so the layout had no way to know
which entry was current and fell back to the first one. Navigation::portal()
and ::console() are now the single definition, and currentLabel() matches on the
route name — which matters for the console, whose PATH changes between
host-bound and fallback mode while its route names do not.

The datacentre name is out of everything a customer sees, for the third time
and now at the source rather than in a template. Falkenstein is how an
operator places an instance; a customer's processing record names the
jurisdiction, and putting the building in customer copy means editing that copy
every time the estate grows.

Also swept the views onto the new scale: --text-faint is 2.8:1 and a decoration
token, but it was carrying table headers, hints and timestamps — text people
have to read. All of it moved to --muted, which is 5.0:1 and passes AA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:09:39 +02:00
nexxo 6376f5e9fa One design system for every surface
The console was a second product. admin-tokens.css held a complete dark set —
its own surfaces, its own orange, its own status triad — so every shared
component had to work in two worlds, and the two halves of the application
drifted apart in ways nobody could see from inside either one. That file now
carries density and nothing else: smaller type, tighter rows, tighter corners.
Colour comes from one place.

The tokens themselves are rebuilt around what the redesign settled on:

- Radii scaled to object size (9/11/16/22) instead of one value everywhere,
  which is what made the interface read as a design-system specification
  rather than as a product.
- Warm, broad, low shadows. A neutral grey drop shadow belongs to a different
  design language and reads as cold on this ground.
- --accent-press, which several rules already referenced and nothing defined.
  An undefined var() does not make a browser skip the declaration; it computes
  to , and for background-color that is transparent — the accent button
  lost its fill on hover and left white text on white.
- No serif. A serif headline is what made every page read as a document, which
  is the impression the whole redesign exists to remove. --font-serif now
  points at the sans so nothing breaks while call sites are cleaned up.

The two shells are one shell with two configurations, and both finally have a
mobile navigation: the sidebar used to simply disappear below 900px, leaving no
way to move around the application at all on a phone.

The customer dashboard is bound to real records — instance, seats, traffic,
backups, contract — instead of the fixtures it shipped with. Where something is
not measured, storage consumption, it says what was contractually agreed rather
than inventing a figure. This is the sheet a customer forwards to their auditor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:03:47 +02:00
nexxo be082b7bf9 Stop iOS zooming the page on every tap into a field
tests / pest (push) Successful in 7m9s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
Safari zooms when a focused input is smaller than 16px, and then leaves the page
zoomed — so on an iPhone every form throws the layout about, and the way back is
a pinch. The usual fix is the meta-viewport switch, which suppresses the zoom by
disabling pinch-zoom entirely: it takes a real accessibility feature away from
everyone to solve a cosmetic problem for some. Sizing the fields at 16px on
touch devices removes the reason instead.

!important on purpose: it has to beat every text-size utility on every field in
the application, and a rule that loses to `text-sm` on one form is worth
nothing. Checkboxes and radios are left alone — no text to inflate, and sizing
them there breaks the box.

Also -webkit-text-size-adjust, so iOS stops inflating body copy in landscape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:59:12 +02:00
nexxo b85c12e8b9 Give the readiness probe long enough for a restart to finish
tests / pest (push) Successful in 7m13s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
The gateway is restarted twice in a deployment — once by `up -d`, then again
after the hub whose network namespace it lives in — and `docker compose exec`
into a container that is still coming up fails outright rather than waiting.
Twenty seconds did not cover that, so the first run of the new probe reported a
gateway that answers as down, and the resolver stayed out of client configs for
one more deployment.

The health site also now binds the hub address instead of only matching on it.
Nothing was published either way, but the file should do what its comment says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:46:47 +02:00
nexxo 3699c7cdb9 Ask the tunnel gateway where it actually listens
tests / pest (push) Successful in 7m15s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 5s Details
The console has been unreachable over the VPN, and the cause was the readiness
probe rather than the tunnel. The gateway binds to the WireGuard hub address
alone; the probe asked 127.0.0.1, where nothing has ever listened. It could only
fail, so VPN_READY stayed false, and on that basis the application withheld the
`DNS = 10.66.0.1` line from every client config it issued. A phone then built
the tunnel, asked its normal resolver for the console hostname, got the public
address and had its connection closed — indistinguishable from "this site does
not exist", which is exactly what it looked like.

Probing over TLS on the bare address would not have worked either: the site
matches on the console's hostname, so a request without SNI is offered no
certificate. The gateway now answers a plain-HTTP health port on the hub
address, which removes TLS, SNI and name resolution from the question and
answers only what is being asked — is this gateway listening, in this network
namespace, right now. Caddy refuses to start when the certificate is unreadable,
so a health port that answers still proves the whole file loaded.

Also here, all found while looking:

- Icons pushed their label onto a second line and rendered a size larger than
  asked for. Tailwind's preflight makes an svg display:block, and `.size-4` and
  `.size-5` have equal specificity, so stylesheet order decided — and it emits
  size-4 first. Every icon written as 16px was silently 20px. Recorded as R18.
- Four error pages printed `errors.404.hint` in place of a sentence: the lang
  files give those codes a null hint and Laravel returns the key for a null line.
- The Developer role had no label in either language, so the dropdown showed
  `admin_settings.role_developer`.
- The secrets area held one key, which is not worth a password gate. It now
  carries the credentials that actually stop the business when they expire —
  DNS, monitoring, SMTP — and the test button appears only where a checker
  exists, instead of reporting on Stripe whatever was being looked at.
- The update button said nothing about when a queued run would start or where a
  running one had got to; both are now shown, and a failure names its step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 14:43:23 +02:00
Claude 2bb1d7cc3b Make the private hostnames look like nothing is there, and close the way past the proxy
tests / pest (push) Successful in 6m56s Details
tests / assets (push) Successful in 27s Details
tests / release (push) Successful in 4s Details
An empty 404 is itself information: it says something terminates TLS here and
chose not to answer. The console and the websocket endpoint now close the
connection instead, which is what a hostname that serves nothing looks like.
The websocket endpoint answers a genuine upgrade — verified live, 101 — and
nothing else; a browser opening it gets a closed connection.

Reviewing that turned up two holes that mattered more than the thing being
reviewed.

The compose defaults published the application and Reverb on every interface.
Docker publishes ports ahead of UFW, so those backends were reachable from the
internet with the firewall closed — and reaching a backend directly skips the
proxy, and with it every hostname and address rule keeping the console private.
The defaults are loopback now, and an update rebinds an existing installation
that is behind a proxy actually running and holding 443. A development box
without one is left alone, because taking its port away would look like the
machine had broken.

And the console's own "open" switch was emitting 0.0.0.0/0 into the proxy's
allowlist. One click would have put the console on the public internet, which
is the exact state the owner's rule exists to prevent. Switching it off relaxes
the application's check; the proxy keeps its list, always.

The reference proxy config carries the arrangement: QUIC early data off, since
an address-based decision taken on 0-RTT can answer 425 and some browsers do
not retry — an intermittent lockout from the console is the worst kind — and
explicit handling for plain HTTP, whose automatic redirect otherwise announces
both hidden names to anyone who asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:26:48 +02:00
Claude c4649571df Wait for the tunnel interface, and prove the gateway answers
tests / pest (push) Successful in 7m23s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Successful in 5s Details
Two things reported success while the tunnel console was unreachable.

The services were restarted the moment the hub container counted as started,
but the hub brings wg0 up as part of its start command — so they were binding an
address that did not exist yet. They now wait for the interface.

And readiness was a container-state check. A container can be up and running
while the process inside it listens in a network namespace that was torn down
underneath it: nothing errors, the address simply refuses connections, and
"running" reports everything fine. It asks the gateway now, and only a real
answer counts — because the consequence of getting this wrong is a client
config naming a resolver that is not there, which takes the device's whole name
resolution with it for as long as the tunnel is up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:24:29 +02:00
Claude d2d79b4f5f Restart the tunnel services after the hub they live inside
tests / pest (push) Successful in 10m42s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Successful in 6s Details
Both VPN services share the provisioning container's network namespace, and a
process holds the namespace it started in. Restarting the hub — which every
deploy does, to pick up new code — therefore leaves them listening inside a
namespace that no longer exists.

Nothing errors. The containers stay up, Caddy's log still reports it is serving
on the tunnel address, and connections to that address are simply refused. It
looks precisely like a gateway that never worked, which is how it presented on
the live server after the first successful start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:22:19 +02:00
Claude 32ae64d5fd Pin the resolver to a tag that exists
tests / pest (push) Successful in 9m52s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 4s Details
The dnsmasq image was pinned to 2.90-r0, which is not published. The gateway
therefore never started — correctly reported as not ready, so no client was
handed a resolver that was not there, but the tunnel console did not come up
either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:17:20 +02:00
Claude 457eeeeaef Serve the console inside the tunnel, without publishing the internal network
tests / pest (push) Successful in 10m12s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 5s Details
The owner's phone shows a blank page on the VPN. That page is the reverse proxy
refusing the request, and it refuses because the phone is not on the tunnel: the
client config routes only the management subnet, so a request to the console's
public hostname leaves over the mobile network and arrives from a carrier
address. It is also why the peer reads "last contact: never" — WireGuard only
handshakes when it has traffic to send, and nothing is ever routed in.

The obvious fixes are both wrong. Adding the server's public address to
AllowedIPs routes the WireGuard endpoint into the tunnel it is trying to
establish. A public DNS record pointing at 10.66.0.1 publishes the internal
subnet to anyone enumerating the domain — the owner raised that himself, and he
was right.

So the console is served INSIDE the tunnel instead. A small resolver and a small
gateway share the hub container's network namespace, which is where wg0 lives,
and answer on 10.66.0.1 directly. A client therefore needs no route beyond the
subnet it already has, the host's docker bridge is never exposed, and the
request reaches the application with its real 10.66.0.x source — which is what
the console's own allowlist checks. The gateway reuses the certificate the
public Caddy already renews: a certificate is bound to the name, not to the
address serving it, so nothing new is issued and nothing internal reaches a
public zone.

Most of this commit is the arithmetic of not lying about it. Review found
fourteen ways the arrangement could report itself working while it was not:
enabling the compose profile after the services were started rather than before;
starting a gateway with empty certificate paths, which can only crash; treating
"container created" as "container running"; carrying a stale readiness marker
through a failed restart; reusing the previous hostname's certificate after a
rename; leaving the profile enabled when the hostname was cleared, in a script
that exits early precisely when nothing else would fix it; reading the renewal
timestamp as a user who cannot traverse Caddy's storage; and a `find | head`
that aborts the whole installer under pipefail the moment two issuers hold a
certificate for the same name.

One of them was mine and worth naming: to let the container read the
certificate, I had made the TLS private key world-readable. On a multi-user host
that hands the console's identity to every local account.

The client is told about the resolver only when both services are confirmed
running, because a config naming a resolver that does not exist takes the
device's entire name resolution with it for as long as the tunnel is up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:14:33 +02:00
Claude 99715989aa Dress the placeholder page, and record why the obvious VPN fix cannot work
tests / pest (push) Successful in 8m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
The page shown while the site is hidden was still in the old style — a coloured
square and system fonts — on the two hostnames a visitor is most likely to try.
It now uses the site's own plate, typeface and registration marks, still
entirely self-contained because it is shown precisely when the asset build may
not exist. Both it and the error pages lighten the accent in dark mode: the tone
is chosen for contrast against paper, and on the ink plate the small text is the
first thing to stop being readable.

The VPN investigation ended somewhere useful, and not where it started.

The blank page on the phone is the reverse proxy refusing the request — `respond
404` sends no body. It refuses because the phone is not on the tunnel: the
client config lists only the management subnet in AllowedIPs, so a request to
the console's public hostname goes out over the mobile network and arrives from
a carrier address. It also explains "last contact: never", since WireGuard only
performs a handshake when it has traffic to send, and nothing is ever routed
into the tunnel.

The obvious fix — add the server's public address to AllowedIPs — is written
down here as a comment and a test rather than as code, because it cannot work.
The WireGuard endpoint is that same address, so routing it into the tunnel
routes the handshake packets into the tunnel they are trying to establish. The
result is a loop and a connection that never comes up: the same blank page, now
with no way in at all.

Reaching the console over the VPN needs an address INSIDE the subnet — the proxy
answering on the hub address, and a name that resolves to it. That is a
deployment change, not something a config line can express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:19:30 +02:00
Claude a58faf3f85 Manage the Stripe key from the console, behind a password and a test
tests / pest (push) Successful in 8m56s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
Changing a key meant a shell, a file edit and a cache rebuild — and the person
who owns the Stripe account is not necessarily the person who owns the server.

Two gates, not one. The capability decides who may open the page; every operator
has console.view, and that must not mean "can read the payment key". The
password decides whether this SESSION may see or change anything, because the
realistic threat is not a stranger but an unlocked machine, and a session is
exactly what that hands over. Both are re-checked server-side on every action —
a Livewire action is reachable by anyone who can post to /livewire/update.

The value is stored encrypted under a key of its own, SECRETS_KEY, and the vault
refuses to work without it rather than falling back to APP_KEY: rotating APP_KEY
is ordinary maintenance and would otherwise destroy every stored credential,
discovered when Stripe stops answering. It is read where it is used, not
overlaid onto config at boot — an overlay adds a query to every request
including the public site, and leaves queue workers holding whatever was true
when they started. It is never shown again, only outlined, and never enters a
Livewire property that would carry it to the browser and back in the snapshot.

A registry, not an env editor: a form that can set any environment variable is a
privilege-escalation primitive, and one bad value bricks the installation with
no way back through that same form.

The test button is the part that matters. It reports which Stripe account the
key belongs to, whether it is LIVE or test — the most expensive mistake here is
pasting one where the other belongs, and both look identical in a form — and
which webhook endpoints exist with the events each subscribes to. A key can be
perfectly valid while the endpoint listens for the wrong five events, and
nothing fails until a payment goes unrecorded.

The webhook signing secret deliberately stays in .env. It is read on every
incoming payment event; in the database, a database problem becomes silently
failing signature checks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 09:01:16 +02:00
Claude f6b9181ed8 Give customers two-factor, and stop every button on the settings page reacting at once
tests / pest (push) Successful in 7m55s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 6s Details
Clicking Save made every button on the console's settings page appear to fire.
Seven `wire:loading.attr="disabled"` had no `wire:target`, and without one
Livewire applies the loading state to ANY request on the component — so one save
put all of them into their disabled state simultaneously. Each names its own
action now.

Two-factor for customers. Fortify's endpoints already existed; only the screen
was missing. Setting up requires re-entering the password first, and every
action re-checks that server-side rather than relying on the button not being on
screen — a Livewire action is reachable by anyone who can post to
/livewire/update. The confirmation marker is Laravel's own session key, so it
and the framework's password.confirm middleware mean the same thing rather than
drifting apart.

The secret never enters a Livewire property. Component state travels to the
browser and back in the snapshot; the QR image is derived from the secret, the
secret is not in it — and there is a test that says so.

The status page moves to the ROOT of its hostname: status.clupilot.com/status
says the same word twice. That needed the domain-bound `/` registered BEFORE the
landing page, because Laravel takes the first match and the landing route is
host-agnostic — so the status host would have served the marketing site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:48:34 +02:00
Claude 6082164f8b Give the status page its own hostname, and name the right one for Stripe
tests / pest (push) Successful in 7m43s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 5s Details
STATUS_HOST binds /status to a single hostname. Every other host redirects
there rather than 404ing: a status page is the one address people keep in a
bookmark and reach for when something is already wrong, so breaking old links
to prove a point about separation would be exactly backwards. Unset, nothing
changes.

The /legal/status redirect goes through route() instead of a literal path. A
relative redirect lands on whatever host the visitor is already on — which,
once the page has a hostname of its own, is precisely where it no longer
answers.

The installer told the operator to point Stripe's webhook at the customer
portal. Stripe posts server-to-server and never sees the portal; the endpoint
is the api hostname, which the installer never even asked for. It asks now, and
prints the address that actually receives the events. Getting this wrong does
not fail loudly — payments simply stop being recorded.

It also writes ADMIN_HOST_EXCLUSIVE=false on a fresh install. Switching the
console onto its own hostname before the DNS for it exists makes the console
unreachable under any other name, and that is not a thing to have on by default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:08:57 +02:00
Claude 935c9ae6ac Let people change their own password, and a location be switched off while editing it
tests / pest (push) Successful in 7m29s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
Three gaps, all of the "half-built" kind.

Nobody could change a password. Fortify's updatePasswords feature was switched
off, so an account created with a generated password kept it until someone
opened a shell on the server — including the owner's own. Both settings pages
have the form now, sharing one concern, because two copies of a password rule is
how one of them ends up weaker. It goes through Fortify's own action rather than
hashing here, and the current password is required: an unlocked machine should
not be two keystrokes away from locking its owner out.

The datacenter edit form had no active switch. The column and the scope existed,
and the list page has a toggle — but the form you open to change the thing did
not offer the one lifecycle action a location actually needs. Switching one off
now says what it does NOT do: existing hosts keep running. Once, on the actual
transition, and as the only message — the generic "saved" toast would otherwise
replace it, and an already-inactive location would have announced its own
deactivation every time its name was edited.

The staff table's actions cell is empty for your own row, because you cannot
revoke yourself. With a one-person team the column was therefore always blank
and read as broken rather than as a rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 07:49:07 +02:00
Claude bc8bbc56a5 Make the console's access list reach the proxy, price in euros, and answer errors
tests / pest (push) Successful in 7m4s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
The console was unreachable, and not for the reason it looked like. Two causes,
both mine.

A failed `artisan optimize` — the one that hit duplicate route names — left a
broken route cache behind, so every request to the console host answered 500.
Rebuilt.

Underneath that: the reverse proxy has its own console allowlist, hard-coded,
and it runs BEFORE the application. Everything added on the console's own
access page was therefore ineffective, and when the owner's address changed
they were turned away by the proxy before reaching the page that would have
fixed it. The proxy now imports a fragment generated from the same list the
console manages, regenerated by the host agent and reloaded when it changes.

Getting that safe took most of the review. It refuses to rewrite an ambiguous
Caddyfile rather than replacing some other site's matcher; it never falls back
to a loopback-only list when the application cannot be reached, because that
list validates cleanly and locks out every remote operator; it retries a reload
that failed instead of assuming it worked; and an installation that upgrades
without rerunning the installer is told, because otherwise the whole mechanism
is invisibly absent.

Prices are entered in euros. The form asked for cents, so €799 was typed as
79900 and one slipped digit was a factor of ten on an invoice. Conversion
happens in one place, on the string rather than through a float — (float)
'79.90' × 100 is 7989.999… and casting truncates to 7989, one cent short on
exactly the prices people charge — and it refuses an amount the column cannot
hold instead of failing at the database.

Every error code has a page now, in the site's own language and typeface,
self-contained so it still renders when the asset manifest is the thing that
broke. There was only a bare white 404.

The VPN list stops being a seven-column table nobody could fit: names broke
across two lines and so did the headings. These are attributes of one access,
not quantities compared down a column, so each access is a row — identity on
one line, measurements in mono on the next.

The status page says what it measures rather than what it promises. "New orders
are delivered without failures" read as a marketing claim on a page whose only
job is to be believed, and said nothing about the last 24 hours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:51:05 +02:00
Claude 233fc73430 Let the console keep its recovery hostnames without breaking route caching
tests / pest (push) Successful in 7m22s Details
tests / assets (push) Successful in 24s Details
tests / release (push) Successful in 4s Details
Registering the console once per accepted hostname reused one set of route
names, and Laravel refuses to serialise two routes under the same name — so
`artisan optimize` failed on the live server the moment the separation was
switched on, mid-deploy.

Only the canonical registration carries the `admin.` names now; the alternates
answer under `admin.viaN.*`. They exist to be MATCHED — they are the addresses
someone locked out reaches for — never to have URLs generated for them, so
route() keeps producing the canonical hostname.

That renaming then broke every exact route-name check, which is how the console
navigation decides what is active: reached through a recovery address, nothing
in the sidebar was marked, exactly when something is already going wrong. The
check goes through AdminArea now and matches either form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:20:44 +02:00
Claude 492b1925fb Land a sign-in where it was performed, not always in the customer portal
tests / pest (push) Successful in 7m42s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 6s Details
Fortify sends every successful sign-in to config('fortify.home') — /dashboard.
That is why an operator signing in ended up in the customer portal, and once
the console has a hostname to itself it stops being merely confusing:
/dashboard does not exist on that host, so signing in would land on a 404.

Three outcomes now, decided by where the sign-in happened and who signed in:
the console for an operator on the console, the portal everywhere else, and for
a non-operator on the console the session is taken away again at the moment it
was created — a guard that merely refuses each page afterwards leaves that
session in the browser.

Four things the review caught, each of which would have left a hole:

- Fortify never reaches LoginResponse when a two-factor challenge was involved.
  Binding only that one left exactly the accounts most likely to be operators
  on the old behaviour, so both exits are bound and share one decision.
- The JSON branch ran before the authorization check, so a JSON client kept a
  session a browser would have lost.
- The two exits do not share a JSON success contract — an ordinary sign-in
  answers {"two_factor":false}, a completed challenge answers an empty 204 —
  and merging them breaks a client keying off the status code.
- In shared mode everyone posts to /login, so the request never looks like the
  console even when /admin is the destination. The intended URL is read too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:15:16 +02:00
Claude de6821b53e Move the console off /admin, give the status page its own address, and measure monitoring
tests / pest (push) Successful in 7m43s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 5s Details
Three things the owner asked for, and one the review found underneath them.

The console leaves /admin. It has its own route file now, registered before
routes/web.php because once it has a hostname to itself it sits at the ROOT of
that host — where `/` and `/settings` also exist for the portal, and the first
matching route wins. Where it mounts is one decision in one place: `/` on the
console hostname where it has one to itself, `/admin` on any host otherwise.
Names stay admin.* in both modes, so nothing that builds a URL has to know.

Exclusivity is its own switch, not a consequence of having a hostname. The
first attempt made "this host is the console" follow from ADMIN_HOSTS, and a
development machine lists its own IP there so the console works without DNS —
which took the public site and the portal off that machine entirely. The
switch also registers the console on every listed hostname, canonical last, so
the alternates that exist as recovery paths keep working.

The status page moves out of /legal, where it sat beside the imprint and the
terms. Nothing about the current health of the platform is a legal document. It
is a real page now: portal, instances, provisioning and backups, each derived
from records, aggregate only, and a component with no signal reports "unknown"
rather than "operational".

That last rule is what exposed the real bug. monitoring_targets.status was
written once — 'up', at provisioning — and nothing ever updated it. Both the
console's notices and the new public page read it, so an outage would have been
published as healthy indefinitely. There is a sync job now, on a five-minute
schedule, and a checked_at column so a verdict can go stale instead of standing
forever.

The monitoring contract had to grow a third state for that to be honest.
isHealthy() answers true when no monitoring is configured and false when the
monitor is unreachable; recording that boolean would have published either a
fleet-wide all-clear nobody measured or a fleet-wide outage that was really one
broken monitor. health() returns null for both, the recorder leaves the old
verdict to go stale, and isHealthy() keeps its forgiving semantics for the one
caller that wants them — the provisioning acceptance check, which must not fail
a delivery because monitoring is not set up.

Backups are counted from the instances that need protecting rather than from
the backup rows that exist, so an instance with no schedule at all cannot be
missing from the arithmetic that declares the estate protected.

Also: the update panel polls itself, offers the button only when there is
something to install, and opens the log while a run is in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 06:05:40 +02:00
Claude 276f926d57 Add the update button, and give it a host-side agent that can actually update
tests / pest (push) Successful in 7m41s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
The panel had no way to update the installation, and could not have one on its
own: it runs as www-data inside a container, and the update restarts that
container. A button that shelled out would kill its own web server
mid-response, and would need the application to hold host-level credentials —
a far worse thing to own than an out-of-date checkout.

So the button is a mailbox. The panel writes a request into the checkout; a
systemd timer on the host, running as the service account, consumes it, runs
deploy/update.sh and writes back what happened. The same timer answers the
question the panel cannot answer alone — is there anything to update — which
needs a git fetch, and therefore credentials the application does not have.

The parts that were wrong before review, all of which would have shipped as a
button that looks fine and does nothing:

- The agent was only installed by install.sh, which the installed base never
  runs again. Every existing server would have shown the button permanently
  disabled. It has its own root entry point now, install.sh delegates to it,
  and update.sh says so when the unit is missing.
- On a release-pinned server the checkout is detached, so it followed
  "origin/HEAD" and then ran an update that deliberately stays on the pinned
  tag: updates advertised, nothing applied. It now compares TAGS in release
  mode and passes the target release through.
- On a branch other than main it advertised that branch's commits and then
  deployed main's, because update.sh defaults to main.
- A request written while the agent was down was executed whenever the agent
  next started, days later.
- A single status file meant the routine five-minute check overwrote a failed
  update with "idle" before anyone saw it.
- An agent that had been stopped left the button enabled forever, because a
  status file written once counted as an agent.
- The agent's error messages were German strings rendered into an English
  interface; it reports codes now and the panel translates them.

Also: the add-address button in the console-access panel was as tall as the
field plus its hint, because the hint was a sibling inside the flex row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 05:33:17 +02:00
Claude b860ce2e9b Give the three console guards one answer to "is this the console?"
tests / pest (push) Successful in 7m44s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
The hostname guard, the network allowlist and the public-site switch each
decided for themselves, by testing the path against admin/*. That works only
while the console sits under /admin. It is also a trap: the moment the console
moves to the root of its own hostname, PublicSiteGate stops recognising it, and
with the public site hidden the console answers 503 to the very person trying
to sign in and switch it back on. The guard does not fail loudly — it silently
stops matching.

AdminArea is now the single answer. It has two modes and no third: a console
hostname is configured, in which case the console IS that host and answers at
its root; or nothing is configured, in which case the console stays under
/admin on any host exactly as before, so upgrading cannot lock anyone out of a
system that was working.

RestrictAdminHost gains the half it was missing. Binding console routes to a
hostname does not stop the customer routes from answering there too, because
they are registered without one — so the console's hostname would still serve
portal pages wherever the paths did not collide. It now enforces both
directions, with the endpoints both sides genuinely share written out as a list
rather than inferred.

Nothing changes yet for an installation with no ADMIN_HOSTS set, which is every
development machine and every fresh checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 04:52:21 +02:00
Claude 30a80b6c15 Report the estate from the database, and print the traffic that is sold
Three console pages were fiction. The front page claimed 42 customers, 39
instances, four hosts named pve-fsn-1..3 and €7,842 a month, over a twelve-month
growth curve; the instance list held seven invented machines; the revenue page
reported churn and a trend for a business with no recorded history. All of it
was hard-coded. It read like a running company and measured nothing.

They now read the database. Two figures are gone rather than approximated —
the revenue trend, which needs a monthly history nobody records, and churn,
which needs a base the data cannot supply. ARR stays, labelled as the
projection it is. The green "all systems normal" badge is computed from the
notice list instead of asserted, and the notices themselves come from failed
runs, hosts reporting errors or gone quiet, and monitoring that is down.

Host load is the one number that had to agree with something else: placement
counts the VM disk allocation, ignores a failed instance that never got a VM,
and subtracts the host's reserve. A dashboard doing its own arithmetic would
show a host as comfortable while orders were already being refused on it, so it
uses the host's own accounting — with the filter moved into a scope both share,
and the sum preloaded so listing hosts stays one query.

The instance list drops the Nextcloud version column: that version is not
recorded anywhere, and a column filled with a plausible number is worse than no
column. Statuses the lifecycle writes but nobody had translated no longer
render as "admin.status.failed".

The price sheet also gains the included traffic, which the catalogue has always
carried and the page simply never printed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 04:48:51 +02:00
Claude b844ff377d Rebuild the public site as a document, and read prices from the catalogue
tests / pest (push) Successful in 7m30s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
The marketing page was a generic centred hero in the system font stack: it
neither used the design system the console is built on nor looked like the
product it sells. Rebuilt around what CluPilot actually promises — security you
can produce evidence for — so the page reads as a controlled technical
document: a specification plate instead of a hero image, numbered sections, an
audit register with rhythm and proof per measure, and a price sheet rather than
floating cards. IBM Plex Serif joins Sans and Mono as the display voice, self
hosted as static files so the public page renders even without the Vite build.

The prices were written into the page by hand, and the page and the catalogue
had already drifted apart on three of four plans: the site advertised 249 € for
a plan the checkout charges 399 € for. The sheet now reads the catalogue, like
every other caller — including the currency, which is configurable and was also
hard-coded here.

The catalogue fails loudly on purpose; a public website must not. A broken or
overlapping catalogue is caught in the controller alone: the page still
renders, the sheet is replaced by "on request", and nobody is quoted a number
the checkout would not honour.

The sign-in and registration panels shared a copy-pasted orange gradient. They
now share one component and the same ink plate as the site, so the two halves
of the product no longer look like two companies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 03:52:25 +02:00
nexxo 0066b1c6a0 feat(security): a way back into the console that does not need the console
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 4s Details
The allowlist is managed in the console, which is fine until the address you
manage it from changes — and then the page that would fix the problem is the
page the problem blocks. Every gate needs a door that does not depend on
itself, and on a server that door is a shell:

  php artisan clupilot:console-access show
  php artisan clupilot:console-access allow 203.0.113.7
  php artisan clupilot:console-access open

The address check moved to RestrictConsoleNetwork::isNetwork() so the command
and the console apply the same rule. Codex caught the version that did not: a
typo like 203.0.113.9/99 was stored, reported as success, and matched nothing —
leaving whoever was recovering still locked out, now believing they were not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 03:09:13 +02:00
nexxo 9ccd4f59d8 feat(security): the console decides who may reach it, and the owner keeps the list
tests / pest (push) Successful in 6m53s Details
tests / assets (push) Successful in 18s Details
tests / release (push) Successful in 3s Details
RestrictAdminHost answers under which NAME the console responds — and the
caller picks the Host header, so it can never answer who is asking. This does:
a network gate on the client address, which behind a trusted proxy is not
something the client chooses.

The management VPN is always in the list and cannot be removed — it is the one
way in that survives a bad entry. Beyond that the owner keeps their own
addresses in the console, because being away from the VPN should not mean being
locked out, and the person who needs to change that list is the person sitting
in front of it.

Two refusals rather than warnings, since by the time a warning renders the
request that would show it has already been rejected: switching the restriction
ON is refused unless the address doing the switching is already covered, and
removing the entry you are sitting behind is refused. Entries are validated as
an address or CIDR — a typo that matches nothing is how someone locks
themselves out while believing they have not.

404, never 403.

Registered as persistent Livewire middleware, and verified in a browser that it
holds there: with the settings page open, narrowing the list turned the next
action from 200 into 404. Codex read the path guard as skipping
/livewire/update; Livewire in fact replays middleware against a duplicate of
the request carrying the original component's path, so the guard matches and
the client address is preserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:27:26 +02:00
nexxo 5c2e481985 fix(security): console and account checks now survive a Livewire action
tests / pest (push) Successful in 7m8s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 4s Details
Livewire re-applies only the middleware on its persistent list when an action
posts to /livewire/update. Its defaults cover auth; ours were not on the list —
only RestrictAdminHost had been added. So EnsureAdmin and EnsureCustomerActive
guarded the PAGE and not the actions, and the page is not where the actions run:
a signed-in non-operator who could reach the admin host could drive console
components, and a suspended customer could keep driving the portal.

Found by Codex while reviewing the domain-separation design, and confirmed
against Livewire's own default list in vendor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:04:34 +02:00
nexxo 5d64da1cb3 docs: hiding the site also hides the login page, and that has a consequence
tests / pest (push) Successful in 7m2s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Successful in 3s Details
The gate exempts admin/* so the console keeps working, but /admin sends a guest
to /login and /login is not the console — so with the site hidden and no VPN
yet, an operator cannot sign in to flip the switch back. Found while bringing
up the live server.

The mechanism for it already exists and is the right one: TRUSTED_RANGES. What
does NOT work is exempting the login flow by hostname, which was the obvious
patch — a Host header is chosen by the caller, so one forged header would have
lifted the gate for every route, portal included. Codex caught that; the comment
now says why the narrow-looking option is the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:27:51 +02:00
nexxo 875fd11982 fix(deploy): an answer file with a space in a value killed the install
tests / pest (push) Successful in 7m2s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Successful in 3s Details
The file is sourced, so ADMIN_NAME=Boban Blaskovic is read as a command and
dies with "Blaskovic: command not found" — naming neither the file nor the
variable — and set -e ends the install there. A display name with a space is
entirely ordinary; the example just happened to use "Administrator", one word,
so nobody hit it.

Found while installing on the live server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:49:58 +02:00
nexxo 9352935b88 fix(nginx): the live hostnames were missing from the admin denylist
tests / pest (push) Successful in 6m56s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Successful in 6s Details
The map listed only *.dev.clupilot.com, with the production names commented out
under "add the real public hostnames here before launch". Launching without
them removes the outer of the two layers that keep /admin off the public
internet — the ADMIN_HOSTS check in the app would still answer 404, but the
whole point of the pair is that neither is relied on alone.

admin.clupilot.com stays out of the list on purpose: the list names the hosts
that are PUBLIC, and the console is not one of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:43:44 +02:00
nexxo 35307e64cc fix(deploy): the installer could never finish, twice over
tests / pest (push) Successful in 6m55s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Successful in 5s Details
Found by running it on a fresh Debian 12 with systemd, no docker, no git and no
users — which is the first time it has ever been run anywhere.

`optional_env` ended in `[[ -n "$2" ]] && set_env …`. With an empty value the
function returns 1, and under `set -e` that ends the install, silently, with a
half-written root-owned .env. Absent is the NORMAL case for those variables —
they are the optional ones — so this killed every installation that did not
happen to supply a Hetzner token. An `if` returns 0 and does not.

Then it waited for `php -v` before migrating. PHP answers in seconds; the
entrypoint is still running `composer install` on a fresh checkout, which takes
minutes. So `artisan migrate` ran against a checkout with no vendor/ and died
on a missing autoload.php. It now waits for vendor/autoload.php, exactly as the
dependent containers already do, and says where to look if it never appears.

After both: exit 0, 59 migrations applied, the plan catalogue seeded and
consistent, the Owner account created with its role, and the portal answering
200 while the console redirects to login and the unconfigured Stripe webhook
fails closed with 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:42:26 +02:00
nexxo e9240c1324 feat(deploy): releases you can pin to, and a version that tells the truth
VERSION at the repository root is the release number and the only place it is
written down — a file rather than `git describe`, because a source archive, a
shallow CI checkout and a container built without .git have no tags to
describe. Cutting a release is editing that file and merging it; CI creates the
annotated v<VERSION> tag once green, only on the commit that raised the number,
and never moves it. Servers are pinned to these.

Two modes, and the difference is the point. RELEASE=v1.0.0 pins a server to an
immutable tag, checked out detached: it moves when someone decides it moves.
BRANCH=main follows the edge, where CI tags a commit only after it is pushed.
The mode is remembered, so re-running the updater on a pinned box does not
quietly walk it back onto main.

Going backwards is refused, by commit ancestry rather than by comparing version
strings — a tag can be cut from anywhere, and only ancestry says whether history
is going back. The schema has already moved forward by then, and the migrations
needed to reverse it are not in the older checkout at all.

What the console reports comes from a manifest written atomically after every
step succeeded, never from live git: git says what the files are, not whether
the deployment came up. So a failed update keeps reporting the version that is
actually serving — including its version number, not the newer one the checkout
has already moved to. A deployment pinned to the tag reads "1.0.0 (abc1234)";
anything else reads "1.0.0-dev (abc1234) · main", because every commit after the
tag still carries VERSION=1.0.0 and is not that release. Reporting it as one is
how a bug gets filed against the wrong code.

Codex reviewed the design before it was written and the result six times after.
Its findings, all real: the version had to come from the manifest too, not just
the commit; branch names need JSON escaping or the manifest silently vanishes; a
`case` glob does not anchor and accepted 1x.2y.3garbage; pinning to the commit a
server already sits on skipped recording the mode, and then skipped detaching;
a manifest that failed to write would never be repaired; an unparseable
timestamp would 500 every admin page; and an empty tag list read as a
connectivity failure.

466 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:21:38 +02:00
nexxo c119e8d96e fix(deploy): say which branches exist instead of failing inside git
The installer clones BRANCH, which defaults to main — and the repository has
no main. Git's own "Remote branch not found" reads like a bad token or an
unreachable server, and the branch is the one thing here that is routinely
wrong. It now asks first and, when the branch is missing, prints the ones that
do exist and how to pass one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:48:49 +02:00
nexxo 4f3e11ae5e docs(deploy): the installer's Stripe steps match what phase 5 actually needs
It still named two webhook events; the application handles six, and the four
new ones are the billing cycle itself — renewals, failed payments, status
changes and endings. It also said nothing about stripe:sync-catalogue, without
which Stripe has no prices and nothing can be sold at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 14:46:25 +02:00
nexxo 8c3a79e258 fix(billing): hold only what cannot be matched, and let migrations roll back
Two findings from reviewing all five phases together rather than one at a time.

The webhook held every event a handler answered `null` to — but `null` also
means "already recorded" and "deliberately skipped", which is every checkout's
own invoice and every redelivered renewal. Their contract is right there, so
`replayHeldFor()` could never come back for them, and ordinary Stripe traffic
silted up the holding area until the weekly prune. It now holds only what it
genuinely cannot match — and if the contract appeared in the meantime, applies
the event instead of dropping it: the creation race is narrow, but losing a
cancellation to it would leave us serving someone who had left.

Also fixes a rollback that predates this work and blocks `migrate:fresh` on
MariaDB entirely: dropping the unique index on customers.user_id fails while
the foreign key added one migration earlier still depends on it. Verified by
building all 33 migrations from nothing on MariaDB, rolling every one of them
back, and building them again.

458 tests green. Codex clean on the full branch diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:57:11 +02:00
nexxo 0560ae743d feat(billing): Stripe owns the billing cycle, we own capability
A Product per plan family and a Price per priced row of a published version,
plus the four subscribed webhook events that were arriving and being ignored.

`stripe:sync-catalogue` mirrors the catalogue. Idempotent twice over: a stored
id is skipped, and each call carries an idempotency key derived from our own
row, so a crash between Stripe creating a Price and us recording its id gives
back the same object rather than a second one. That matters more here than
usual — a Stripe Price cannot be edited or deleted, so a duplicate is
permanent. Drafts are not synced at all: a version that has promised nothing
has no business in a price list. --dry-run shows what would be created.

The webhook now handles invoice.paid, invoice.payment_failed,
customer.subscription.updated and customer.subscription.deleted. It never
re-derives an amount: the invoice is the authority for what was charged, and
recomputing it from our catalogue would produce a second, disagreeing answer.
A failed payment is recorded and nothing else — Stripe runs the dunning
schedule, and cutting a customer off on the first failure would punish an
expired card as though it were a refusal to pay.

Only a cycle renewal moves the term on. Stripe also sends paid invoices for
prorations and manual charges, and the checkout's own invoice — which is
already in the register as the purchase, and would otherwise double every
customer's first payment.

Stripe does not guarantee delivery order, which is the source of most of the
care here:
- state changes are judged and written in one transaction with the row held,
  so two deliveries cannot both pass a check made on a stale copy;
- an older event never overwrites a newer one, with a rank breaking ties
  between events sharing a second — their timestamps have one-second
  resolution, and a failed attempt and its successful retry routinely do;
- a term is never shortened, and an ended contract is never revived;
- an event for a contract that does not exist yet is HELD and replayed the
  moment it appears, rather than acknowledged and forgotten. A cancellation
  dropped that way would leave us serving someone who had already left. Held
  rows that never match are pruned after a week.

Register entries are keyed by what they are about — the invoice, the attempt,
the subscription — with a unique index doing the work, because a check two
concurrent deliveries can both pass is not idempotency.

PlanChange is now documented as the preview shown before someone confirms, not
the invoice: Stripe's proration accounts for tax, existing credit and the exact
second the change lands, and ours cannot.

Not run against the live account — `stripe:sync-catalogue` creates objects that
cannot be deleted, so that is the owner's call. The dry run lists 12.

457 tests green. Codex review clean after seven rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:36:28 +02:00
nexxo 7582df3de6 feat(billing): a proof register, and modules frozen at their booked price
Two things the contract could not answer on its own: what happened, and what
the customer's whole bill is.

`subscription_records` is append-only, one row per commercial event. Flat
columns for everything searched or relied on as evidence — event, customer,
subscription, plan family and version, term, net/tax/gross, currency, tax rate,
reverse charge, Stripe ids — PLUS a versioned JSON copy of the whole snapshot.
Not JSON alone: you cannot query it, and "the amount is in there somewhere" is
poor evidence. Copied, not joined, so a row still answers after the customer,
the plan or the version have gone.

The flat columns describe the TRANSACTION. Gross is what was actually taken;
net and tax are that gross split by the rate that applied on the day. A
discount lowers the taxable amount rather than creating negative VAT, and a
free checkout is recorded as free instead of as paid in full. What was agreed
sits beside it in the snapshot, so a charge that differs from the catalogue is
preserved as a question rather than reconciled away.

The register refuses to be rewritten, in bulk as well as one row at a time —
model events do not fire for `query()->update()`, which is exactly the shape a
careless data fix takes.

`subscription_addons` carries the owner's rule: a customer's total is their
subscription plus their modules, and all of it is frozen at what was agreed.
Price protection covering only the plan would let a module quietly double for
someone who booked it two years ago. A module they have NOT booked is a sale
still to be made, at today's price — so the catalogue is consulted only for
what is not in this table. Several bookings of one module are summed rather
than reduced to one arbitrary row, or the page and the bill would disagree.

Concurrency: one order books one module, enforced by a unique index rather than
a lookup two retries can both pass; cancellation is an atomic claim; and each
booking commits with its own register entry, so a failure cannot leave a sale
without evidence and a retry that finds the booking cannot skip the event.

Verified in the browser: with the module raised from 29,00 € to 59,00 € in the
catalogue, the customer who booked it still sees 29,00 € and a total of
208,00 €, while a new customer is quoted 59,00 €.

436 tests green. Codex review clean after seven rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 13:07:34 +02:00
nexxo f6ddf45dc0 feat(admin): a console for creating, pricing and scheduling plans
The owner can now do from the console what only a config edit could do before:
create a plan line, draft a version, price it, publish it into a window, and
pull a plan out of the shop.

Two pages and one modal, because the catalogue has exactly two levels. The
plan list is a name, a rank and a kill switch. Everything that can be got
wrong — capabilities, prices, windows — lives on the versions, where the page
is built around the one rule that matters: a draft is freely editable, a
published version is not touchable at all. So drafting and publishing are
separate acts, and publishing says plainly that it is final.

The console refuses everything the catalogue refuses, on the form rather than
as a stack trace: an overlapping window, a window that ends before it starts,
a version with no price for a term we sell on, and — new here — a version with
no VM template, which would be bought and then fail provisioning every time.
Numbers are bounded on both sides, because a mistyped price otherwise
overflows the column and answers with a 500 instead of saying which field was
wrong. Performance class and features are picked from the keys we have labels
for; free text would be frozen at publication and shown to customers as a raw
translation key forever.

Concurrency, since two admins share one catalogue: draft numbers are allocated
under a lock on the family, publication is an atomic conditional claim, and
discarding a draft is one statement conditional on it still being a draft —
otherwise a draft published in the meantime could be deleted out from under
the customers now contracted to it.

`plans.manage` (Owner and Admin) guards the pages themselves, not only the
buttons, and the modal authorises itself — prices, drafts and unreleased plans
are not something to leave readable to anyone who types the URL.

Also fixes the shared checkbox, which ticked in the browser's own blue on
every page that used one: a native checkbox ignores text colour and needs
accent-color.

Verified in the browser: withdrawing a plan in the console removes it from the
customer's billing page immediately, and restoring it brings it back. The
public landing page carries no catalogue-driven plan list, so there is nothing
there to hide.

421 tests green. Codex review clean after six rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:41:51 +02:00
nexxo 6387c747d0 feat(billing): the plan catalogue becomes three tables, and config stops selling
Plans lived in config/provisioning.php, where the owner cannot reach them and
where a plan has no history. They now live in plan_families / plan_versions /
plan_prices — a name that never moves, what the plan WAS at a point in time,
and a price per version and term with its own Stripe Price id.

- Availability is computed on every read (available_from <= now < until,
  half-open, UTC) plus a per-family sales kill switch. Nothing schedules a plan
  into or out of sale: a job that fails to run is a plan that silently
  misbehaves.
- Overlaps crash rather than resolve. currentVersion() uses sole(), and
  scheduling takes a lock on the family and rejects an overlapping window —
  two versions on sale at once would decide a customer's price by row order.
- A version is frozen from publication, not from first sale, and so is its
  price: a checkout is not instant, and an amount edited between the session
  opening and the webhook landing would contract someone at a price they were
  never quoted. Repricing publishes a new version, as Stripe requires anyway.
- Neither a published version, its price, nor a family with customers can be
  deleted, and a family key cannot be renamed. All of those would null the
  provenance off existing contracts.
- Subscriptions and orders record plan_version_id. A historical reference
  resolved by plan NAME hands back today's terms, which is the split-brain one
  level up. A checkout that carried its version is honoured even after the
  window closes — they paid for what they were shown.

Switched atomically and failing closed: config('provisioning.plans') and
plan_features are gone, and nothing falls back to them. A fallback would
resurrect a plan the owner had just switched off. The seed lives in the
migration, and PlanCatalogueTest pins what the config catalogue sold as the
shadow comparison. `php artisan plans:check` reports overlaps, gaps and
missing prices before a customer finds them.

Contract-backed displays, which were reading the live catalogue:
seat limits, the cloud card, the plan card, and admin MRR — the last three now
divide a yearly contract down via Subscription::monthlyPriceCents(), since all
three label the figure per month.

Two recovery gaps closed along the way: the order now commits before the
contract is opened (so a contract failure can never erase the record of a
payment), a Stripe retry repairs a missing contract and restarts a run stranded
with no_subscription, and TickProvisioning sweeps runs left pending by a crash
rather than only running/waiting ones.

402 tests green. Codex review clean after thirteen rounds. Verified in the
browser: portal, billing and console render unchanged off the new catalogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 12:05:56 +02:00
nexxo 52b41bb0d5 fix(billing): a paid order opens a contract, and provisioning obeys it
The pipeline re-resolved config('provisioning.plans') by order.plan, so the
subscription snapshot protected a customer's price but not their machine:
shrinking a plan resized an existing customer's VM on its next run. Nothing
created a subscription either, so closing this meant opening the contract at
purchase and pointing provisioning at it.

- OpenSubscription freezes the catalogue onto a subscription when a checkout
  is paid; StartCustomerProvisioning calls it inside the order transaction.
- CustomerStep::plan() reads the frozen snapshot. ValidateOrder and
  ReserveResources fail closed with no_subscription rather than falling back
  to the catalogue, which is the bug itself.
- template_vmid joins the snapshot so a re-clone cannot pick up a blueprint
  published after the sale. Deliberately outside FROZEN: it is how we build
  the machine, not a term the customer is owed, and a dead template must be
  replaceable without cancelling a contract.
- TrafficMeter reads the allowance off the contract too — cutting a plan's
  traffic was otherwise enough to start throttling someone who bought more.
- The migration backfills contracts for orders that already bought something,
  reconstructed from what was actually delivered where an instance exists,
  and adopts an existing order-less contract instead of opening a second.
  Orders paid in a currency the catalogue cannot price get none, matching the
  checkout path.

price_cents stays the catalogue's NET price, which is what PlanChange
prorates against — not Order::amount_cents, which holds Stripe's GROSS total.
Reconciling the two belongs to the proof register and Stripe (phases 4/5).

Also pins STRIPE_WEBHOOK_SECRET blank in phpunit.xml: the operator's real
secret was reaching the suite from .env and rejecting every unsigned test
payload, which is why 7 webhook tests failed before any of this.

Verified in the browser: with team traffic cut from 3000 to 500 GB in the
catalogue, the customer's portal still shows 3 TB.

373 tests green. Codex review clean after three rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 11:10:00 +02:00
nexxo 0dde76ad55 docs: handoff for the plan catalogue rebuild
tests / pest (push) Successful in 6m57s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Design is decided and Codex-reviewed; none of it is built. Records the live
split-brain bug to fix first, the three-table catalogue, computed availability,
the proof register with frozen add-ons, the Stripe split, what already exists,
and the working rules that cost time this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:43:17 +02:00
nexxo 6119044669 fix(billing): no plan changes after cancellation, no unknown terms
tests / pest (push) Successful in 6m46s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
A cancelled subscription still reported an upgrade as allowed and priced it, so
a caller trusting that would have provisioned and billed a customer who had
already left. And any term string other than the two we support was silently
priced monthly while keeping the unknown value — a subscription whose price and
billing period disagree. Both are refused now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:57:53 +02:00
nexxo 35324b986a fix(billing): no free upgrade in the last hours of a term
tests / pest (push) Successful in 6m48s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Flooring the remaining time made an upgrade with under a day left cost nothing,
and an upgrade requested after the period had ended cost nothing while being
applied immediately — the bigger plan for free. Remaining time now rounds up
while any service is left, and an expired period defers the change to the next
term instead of pricing it at zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:55:53 +02:00
nexxo 52b1acbb80 fix(billing): a scheduled downgrade must be applicable, and never cost money
tests / pest (push) Successful in 6m49s Details
tests / assets (push) Successful in 19s Details
tests / release (push) Has been skipped Details
Once the term is over, the downgrade has to be allowed — otherwise the job that
is supposed to carry it out never can, and the change waits forever for a date
that has already passed.

And a grandfathered plan can be cheaper than the smaller plan costs today, which
made the goodwill credit negative: an invoice for the privilege of downgrading.
Clamped to zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:54:16 +02:00
nexxo 460fac01b1 fix(billing): upgrade or downgrade is decided by rank, not by price
tests / pest (push) Successful in 6m51s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
With grandfathered prices the comparison inverts: a business plan bought when
it cost less than today's team plan would have treated a move to team as an
upgrade — charged immediately, while the customer loses resources. The plan's
rank is frozen with the rest of the snapshot and decides the direction; prices
only decide the amount.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:52:28 +02:00
nexxo ac250071cb feat(billing): immutable subscription snapshots and pro-rata plan changes
The plan catalogue describes what we sell today; a customer who signed up last
year bought last year's terms. Every commercially relevant condition — price,
quotas, seats, the hardware behind the plan — is now frozen onto a subscription
at signup, and the model refuses to let any of it be rewritten afterwards. A
price rise applies to new subscriptions and cannot reach back into an existing
contract.

PlanChange holds the two rules, computed against the frozen price rather than
today's catalogue:

- Upgrading is immediate and pro rata: the new plan for the days left in the
  paid term, minus what the old plan was worth over those same days. On the
  last day of a month that is one day's difference, not a month's.
- Downgrading waits for the end of the term. A yearly customer bought a year
  and can move down when it is up; a month is a month. A mid-term downgrade is
  a goodwill decision, not a self-service button, and its credit covers only
  the unused part of the difference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:49:48 +02:00
nexxo c36ea17b39 fix(billing): normalise both sides of the VAT comparison; cast the timestamp
tests / pest (push) Successful in 6m45s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
A verifier that returns the number in display form ("DE 811 907 980") would
have failed the comparison against the normalised current value and silently
switched a genuine reverse-charge customer back to domestic VAT. Both sides are
normalised now, and vat_id_verified_at is a real datetime rather than a string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:36:07 +02:00
nexxo 5aef5e693c fix(billing): verification vouches for a value, not for a row
tests / pest (push) Successful in 6m52s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
A timestamp alone said "some number was checked once": editing the field left
it intact, so a customer could swap a verified foreign VAT ID for any
plausible-looking one and keep zero-VAT pricing. The verified value is stored
and compared, which makes the rule self-enforcing — no writer has to remember
to clear a flag, and there are several writers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:34:40 +02:00
nexxo 42ceafb57f fix(billing): a self-declared VAT ID must not zero the tax
tests / pest (push) Successful in 7m13s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
Any non-empty string starting with two characters other than AT switched the
customer to reverse charge — typing "XX123" was a 20 % discount. Reverse charge
now requires a VAT ID that is verified, belongs to an EU member state other than
ours, and looks like a VAT number at all. Unverified is the normal state and
means the domestic rate: over-collecting is correctable, under-collecting is a
tax liability.

Changing the number clears its verification. Verification itself (VIES) is not
built yet, so reverse charge stays off until someone confirms a number — which
is the safe direction to be wrong in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:32:42 +02:00
nexxo 76510a59a3 fix(billing): VAT follows the customer, not a global setting
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Codex was right that this could misstate real charges: an EU business with a
VAT ID registered in another country is billed under reverse charge, and we
were adding 20 % Austrian VAT to their total anyway. TaxTreatment resolves it
from the customer's VAT ID, and the whole page — cart, plan cards, add-on
cards — now states one treatment instead of contradicting itself.

Explicitly NOT handled: cross-border sales to private individuals, which are
taxed at the buyer's national rate under OSS. That needs a maintained rate
table and a tax adviser, not a guess, so those fall back to the domestic rate —
over-collecting rather than under-collecting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:30:17 +02:00
nexxo ac3f429958 fix(billing): round the recurring figure the same way as the total
tests / pest (push) Successful in 7m33s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The note rounded VAT after aggregating while the total rounded per order, so
cent-level prices made the two disagree — and a customer who spots that stops
trusting every other number on the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:27:02 +02:00
nexxo 9fab251fb3 fix(billing): make the one-plan-change rule hold under two clicks
tests / pest (push) Successful in 7m28s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
Both requests could finish their delete before either inserted, leaving exactly
the two pending upgrades the rule exists to prevent. Replacement and insert now
run in one transaction with the customer row locked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:25:29 +02:00
nexxo a87a4f512d feat(billing): one plan change at a time, and every price says what it is
Two contradicting plan upgrades sat in the cart at 799 and 399 € — no checkout
could resolve which one the customer meant. Choosing another now replaces the
pending one and says so; add-ons still stack, because buying 200 GB as two packs
is a sensible thing to want.

Every price now states net or gross and how often. The cart shows net per line
with "pro Monat" or "einmalig", then subtotal, VAT and gross — and separates
the monthly recurring amount from a one-off traffic top-up sharing the same
cart, because those are two different commitments. The rate is configurable
(CLUPILOT_TAX_PERCENT, 20 % default) since it follows the seller's country.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:23:49 +02:00
nexxo 55b1a1468f fix(admin): the admin password is shown once, and means it
tests / pest (push) Successful in 7m37s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
The handoff stayed in the cache for its full ten minutes, so any replayed
component request could fetch the plaintext again — "shown once" was a figure
of speech. It is consumed on the first read and the token dropped; the payload
reaches the view and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:34:57 +02:00
nexxo d40113be8e fix(admin): never reset a stranger's VM, never leave the console spinning
Two from Codex:

- The job accepted any stored instance with a host. A closed one's VMID can have
  been reused on the same machine, and the reset would then land on another
  customer's VM. It now requires a live instance, and the action is not offered
  for anything else.
- An unreachable Proxmox or guest agent throws rather than returning an exit
  code, so nothing was ever written to the handoff and the modal polled forever.
  The throw is caught, and a failed() handler covers anything that still escapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:32:53 +02:00
nexxo aae0457e19 feat(admin): administrator access to a customer's Nextcloud
Impersonation borrows the customer's portal session; this is access to their
installation, which is a different thing and now a different button.

Nextcloud has no passwordless admin jump, so this does the only honest thing it
offers: it resets OUR managed admin account inside that installation and hands
the credentials over once. The customer's own accounts are untouched, and the
next request sets a new password again.

- New capability instances.adminlogin, Owner and Admin only — stronger than
  impersonation, because it hands over control rather than a session.
- The operator's own password is required every time, rate-limited: taking over
  a customer's installation is not something an unattended browser should manage
  on its own.
- The reset runs on the provisioning worker (it owns the tunnel and the Proxmox
  credentials); the console polls a handoff token, so the credentials never
  enter a Livewire snapshot.
- A silent instance is reported as such instead of appearing to succeed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:30:59 +02:00
nexxo d26200c74b feat(billing): a cart you can actually read and change
tests / pest (push) Successful in 7m10s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
"5 purchases pending" told nobody what they had ordered and offered no way to
change their mind. The billing page now lists each pending purchase by name,
what it costs, when it was added, and the total — with a remove button per row
behind a confirmation, like every other destructive action in the console.

The wording lives on the Order model rather than in the view, so the cart, the
invoice list and any later confirmation mail cannot each invent their own name
for the same row. Removal is scoped to the customer's own still-pending orders,
checked in the modal itself: modals are reachable without the page's guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 05:26:49 +02:00
nexxo a6d7594e5c test: do not reach for Redis from the provisioning queue
tests / pest (push) Successful in 7m4s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
Jobs that pin themselves to the provisioning connection bypass
QUEUE_CONNECTION, so two tests opened a Redis connection — fine on a machine
running the stack, a RedisException in CI. A test run must not depend on
infrastructure being up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:20:46 +02:00
nexxo 819872bb79 fix(ci): install dependencies by cloning, not through GitHub's API
tests / pest (push) Failing after 6m59s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details
The dist path is rate-limited for anonymous callers, and a partial failure left
a half-installed vendor/ behind — the suite then failed with 500s that had
nothing to do with the code. Source clones need no quota. A GitHub token would
let us go back to the faster path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:11:52 +02:00
nexxo 7af3e2957b test: do not depend on a built asset manifest
tests / pest (push) Failing after 7m22s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details
CI has no build, so every page rendering through @vite threw a view exception —
dozens of failures with one cause. withoutVite() makes the suite test the
application rather than the state of the asset pipeline; a broken build shows up
in the build job, which is where it belongs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:03:58 +02:00
nexxo 2c0884357c fix(pages): the update and placeholder pages must not need a build
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 26s Details
tests / release (push) Has been skipped Details
CI caught it: both rendered through @vite, so a missing manifest threw a view
exception instead of a page. That is not a test problem — the update page is
shown precisely while the application is mid-deploy or freshly installed, which
is exactly when the manifest may be absent. Both carry their own small
stylesheet now and render with nothing built at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:56:23 +02:00
nexxo b55a127a00 fix(ci): drop the cache steps this runner cannot serve
tests / pest (push) Failing after 7m55s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
actions/cache expects a cache service the runner matching Gitea 1.20 does not
speak, and it failed the job outright. It was an optimisation; the source
fallback that makes the install work is what mattered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:45:11 +02:00
nexxo 7cd3ff33bd fix(ci): survive GitHub's anonymous rate limit
tests / pest (push) Failing after 1m11s Details
tests / assets (push) Failing after 16s Details
tests / release (push) Has been skipped Details
Composer's dist downloads come from GitHub, which throttles anonymous callers —
on a shared address that is a coin flip, and it took the run down after four
minutes of setup. Source clones are the fallback, and both toolchains now cache
their package directories so a repeat run barely touches the network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:39:32 +02:00
nexxo 6367f4a9fd fix(ci): pin actions to versions the runner can execute
tests / pest (push) Failing after 1m0s Details
tests / assets (push) Successful in 52s Details
tests / release (push) Has been skipped Details
The floating tags moved to node24; act_runner 0.2.6 — the version that matches
Gitea 1.20 — supports node20 at most, so every job died before running a single
test. Pinned to the newest releases that still declare node20.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:34:37 +02:00
nexxo 53d305340f fix(vpn): a late revocation must not disconnect whoever holds the key now
tests / pest (push) Failing after 49s Details
tests / assets (push) Successful in 51s Details
tests / release (push) Has been skipped Details
Once a key is free, someone can legitimately create a new access with it. The
forced revocation looked the key up and would have deleted that person's access
and pulled their key off the hub. It now acts only on an adoption artifact — a
system peer with no owner and no creator — and leaves anything else alone.

Re-issuing also threw when VPN_CONFIG_KEY was missing, which is exactly the
situation the console tells people to fix by re-issuing. It now hands the new
config over once instead of storing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:24:55 +02:00
nexxo 4f9ca27732 fix(vpn): a replaced key must not stall the reconciliation
Codex found that a sync landing between the key swap and its removal would see
the old key as unknown. Writing the test showed something worse than the
duplicate he predicted: the adoption cannot even happen — the address still
belongs to the live access — so the insert violated the unique index and took
the ENTIRE reconciliation down with it. One stale key would have stopped every
peer's state from updating.

The sync now recognises that case and queues the removal instead of adopting,
and a key with no row of its own is revoked with force: it can only ever be
removed, never kept, so the removal cannot be talked out of it by a row that
should not exist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:23:15 +02:00
nexxo 556a560506 feat(vpn): explain a missing download, and offer a way out
An access created without storing its config had no download button and no
explanation — it looked broken. It now shows a dimmed button that says why
(the private key only exists on your device), and every staff access gets
"Re-issue": a new keypair, old key off the hub before the new one goes on,
so two peers never claim the same tunnel address at once. A stored config is
re-encrypted in step, or the owner would download a key the hub no longer
accepts.

Host peers are excluded: their key belongs to the machine's own wg0, and
swapping it here would cut the host off with nothing left to repair it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:20:04 +02:00
nexxo e5aea84539 docs(ci): wire the runner up to the point where only the token is missing
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:18:42 +02:00
nexxo 419a1a8552 fix(ci): keep the workflow and runner compatible with Gitea 1.20
github.* is the documented context alias and exists in 1.20; gitea.* came
later. The runner is pinned instead of :latest — the registration protocol
moves with the server, and a newer runner refuses to register against an older
Gitea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:15:45 +02:00
nexxo 8117630a65 feat(ops): update page, automatic 419 recovery, CI workflow
Three things that bit us on every deploy:

- artisan down now renders a branded page that says an update is running and
  reloads itself, instead of Laravel's bare 503.
- A page left open across a deploy carries a Livewire snapshot and CSRF token
  the new code rejects — the user got "419 Page Expired" and a dead interface.
  A 419 from a /livewire/ request now reloads the page; the session is still
  valid, so that is all it takes. Hooked at the fetch layer rather than through
  Livewire's request hook, whose failure callback is not invoked for this case
  in the installed version — verified against a real 419 in the browser, not a
  simulated one.
- Vite no longer empties its output directory: wiping it is what left an open
  page without its stylesheet mid-deploy, which is the "design is completely
  broken" symptom. update.sh also rebuilds the caches with optimize:clear +
  optimize instead of leaving them half-warm.

Plus CI: .gitea/workflows/tests.yml (Pest + asset build, and a tested- tag only
on a green main) and an opt-in act_runner service under the ci profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:58:27 +02:00
nexxo ac86efd9c3 feat(deploy): unattended install from a single answers file
--env-file makes the whole run non-interactive, which is what a bare server
needs: install git, clone, run, done. The file also carries the operational
secrets (Hetzner DNS, Stripe, SMTP, the Proxmox key path); those are optional,
so a first install can happen before the Stripe account exists and the features
stay dark until the values are filled in.

The installer warns when the file is readable by more than its owner, and
clupilot.env is gitignored — it holds every secret an installation will need.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:40:33 +02:00
nexxo 062d337dd0 fix(portal): move notices into the header, so the page stops scrolling twice
The banners sat outside the h-screen shell, so the page scrolled and the content
scrolled — two scrollbars whenever a maintenance window was active.

Maintenance notices are now a bell with a count in the header, next to the user
menu that was otherwise the only thing up there. Impersonation stays visible as
a header chip rather than moving into the dropdown: it is a mode, not a notice,
and acting as someone else without noticing is how mistakes happen.

Also: the VPN status pill wrapped onto two lines in a narrow column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:13:20 +02:00
nexxo 3fad319053 fix(hosts): re-read the host before deciding there is no DNS record
A registration finishing while the purge waited on the run locks wrote
dns_record_id after the purge had already loaded the host, so it skipped the
deletion and then deleted the row holding the only id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:02:18 +02:00
nexxo 7b57ab21cb fix(hosts): retry a failed DNS registration before advancing without a name
The failure may be a lost response to a request that did create the record.
Advancing straight away stored no id, so PurgeHost could never remove it and the
host's management address stayed published. The upsert is idempotent, so a retry
recovers the id; only after the attempts run out does the onboarding continue
without a name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:01:16 +02:00
nexxo 952e8e5d2f fix(hosts): number DNS names by the normalised label, not the raw code
Two legacy codes can normalise to the same label (eu_west and eu-west), and
separate counters then handed both of them eu-west-01 — a unique-constraint
failure inside the reservation, which blocks onboarding rather than being a DNS
problem the step can shrug off. Lock, counter and the in-use check all key off
the label now.

(The helper was also called label(), which HostStep already declares as the
step's display name — renamed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:59:58 +02:00
nexxo e96f4c1c1a fix(datacenters): a code becomes a DNS label, so validate it as one
alpha_dash accepted eu_west, -edge and edge-, none of which are valid DNS
labels — every host in such a datacenter would have failed registration and,
because DNS is non-fatal, silently ended up without a name. The console now
requires a proper label, and the step normalises anything created before that
rule so existing rows still get a usable name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:56:50 +02:00
nexxo 69043f9ffc fix(dns): deleting a record that is already gone is success
A lost response, or a retry after a later step failed, made every further
attempt a 404 — and the host impossible to purge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:54:45 +02:00
nexxo 9f8659da32 fix(hosts): a failed DNS deletion must not orphan the record
Swallowing it and carrying on deleted the row that held the only reference to
that record, publishing the machine's management address for good. The purge now
fails instead — and is retried, since it re-reads the host and its other steps
are idempotent — so a broken DNS provider becomes a visible failed job rather
than a silent leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:53:14 +02:00
nexxo edf24257b5 fix(hosts): reserve the DNS name under the lock, never reuse it, clean it up
Codex was right that my never-reuse claim did not hold:

- The lock was released with only a candidate in hand, so two concurrent
  onboardings in one datacenter could pick the same name and overwrite each
  other's record. The name is now persisted on the host while still locked.
- The number was derived from the hosts that happen to exist, so removing the
  highest one handed that number straight back out — a cached name would then
  resolve to a different machine. The counter is stored per datacenter, floored
  by what is actually in use so a wiped settings store cannot reissue live names.
- PurgeHost deleted the row that carried the record id, leaving the machine's
  management address published with nothing left to clean it up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:51:25 +02:00
nexxo 5db2f7fda7 feat(hosts): give each host a DNS name under the public zone
fsn-01.node.clupilot.com, numbered per datacenter and never reused — removing a
host must not renumber its neighbours onto its name, so the number is stored
rather than derived.

The record points at the host's WireGuard address, not its public IP: the name
exists so an operator can reach a host by name over the VPN, and publishing a
Proxmox host's public address would hand every scanner a target, which is the
one thing this network design avoids.

DNS is convenience, not a prerequisite: a failure logs an event and lets the
onboarding finish rather than stranding a host that is otherwise ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:49:18 +02:00
nexxo 7941935f57 fix(traffic): a sample without counters is not zero traffic
Defaulting missing netin/netout to zero reset the baseline, so the next real
sample added the entire cumulative counter again — a customer throttled for
traffic that was counted twice. Such a sample is skipped and logged now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:44:02 +02:00
nexxo 2415dde288 fix(deploy,traffic): clear caches before restarts, hide the token, widen the lock
- update.sh restarted the long-lived services before clearing the caches, so
  they booted from the old cached config and kept it for the life of the
  process — the release's configuration never reached the workers.
- install.sh put the Gitea token in the clone URL, which is visible in the
  process list to every local user while the clone runs. It goes through a
  short-lived askpass helper now.
- The collector's uniqueness lock expired at exactly the collection interval,
  so a delayed queue could run two collectors on the same baseline and count
  the same delta twice — throttling a customer for traffic they never used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:42:12 +02:00
nexxo 0135f114d3 fix(traffic): keep retrying the throttle release after a Proxmox blip
Tying the release to the creation of the new month's row meant a single failed
call was never retried: the row exists from then on, and the current period
looks unthrottled, so nothing could detect the stale NIC limit. A customer would
have stayed slow for a whole month they had paid for. Checked on every sample
now, with a test that makes the first release fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:40:33 +02:00
nexxo 25c4c5df5a fix(deploy): restart the worker after the hub key, diff against what was deployed
- The provisioning worker boots before install.sh writes
  CLUPILOT_WG_HUB_PUBKEY and holds the empty value for the life of its process,
  so every host onboarded afterwards would have got an empty PublicKey in its
  wg0.conf.
- A failed update left the checkout at the target, so the rerun compared a
  commit with itself and skipped exactly the dependency and image steps that had
  not finished. The comparison base is now the last commit actually deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:38:32 +02:00
nexxo 2b70c9226a fix(traffic): release the throttle at month end; close the Livewire bypass
Two from Codex:

- A throttled instance kept its NIC limit into the new month: the new period
  row starts unthrottled, so neither branch of enforce() fired and nothing ever
  took the limit off. A customer would have stayed slow through a month they
  had already paid for.
- PublicSiteGate exempted livewire/* wholesale. That endpoint is shared by the
  console and the portal, so a signed-in customer could keep driving portal
  components — billing included — while the portal was supposed to be offline.
  Operators pass the normal check anyway, which is all the console needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:36:38 +02:00
nexxo c1e81808a7 feat(traffic): meter the monthly allowance, show it, throttle instead of blocking
Customers can now see what they have used and what is left, and the service
slows down rather than stopping when the allowance is gone — a slow Nextcloud
gets a top-up, a dead one gets a cancellation.

- instance_traffic keeps one row per instance per month. Proxmox counters are
  cumulative since the VM last started, so usage is the difference between two
  samples, and a counter that went backwards means a restart, not a refund.
- Outbound is what counts: inbound is free at our providers and egress is what
  Hetzner's 20 TB per server applies to.
- CollectInstanceTraffic samples every 15 minutes, warns once per threshold
  (80/95 %) rather than on every run, and at 100 % limits the VM's NIC via
  Proxmox — released again as soon as the customer tops up or the month rolls
  over.
- The dashboard gets a full-width band (it throttles the service, so it is not
  a tile among tiles) with the top-up offer right next to the warning.
- Bytes are formatted in SI units now: allowances are computed in SI, so
  dividing by 1024 made a 1000 GB plan read as "931 GB".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:33:47 +02:00
nexxo 4681b135db feat(deploy): run under a dedicated account instead of root
The installer still needs root for apt and the firewall, but it now creates a
clupilot service account that owns the checkout and runs Docker, maps the
container user to it (HOST_UID/GID), and update.sh refuses to run as root —
root-owned files in the checkout are files the application cannot write.

The account has no password login: docker group membership is root-equivalent
on the host, so it is reachable only via sudo -u or an SSH key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:25:59 +02:00
nexxo 0219e3c987 fix(deploy): recreate the container, keep the root password secret, forget failures
- update.sh recreated the app container only at the very end, so an update that
  changes the PHP runtime would have installed dependencies and migrated inside
  the old one.
- install.sh randomised DB_PASSWORD but left DB_ROOT_PASSWORD at the value
  committed in .env.example — the same root credential on every installation,
  reachable from any container on the compose network.
- Settings cached the fallback after a database failure, so one blip could
  leave a hidden site publicly visible until someone cleared the cache. Only a
  successful read is cached now, proven by a test that drops the table and
  brings it back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:25:06 +02:00
nexxo 773ef4bd5f fix(deploy): migrate behind maintenance mode, resume a failed update, install deps
Codex was right on all three, and the first was a design error on my part: the
checkout is bind-mounted into every container, so 'git merge' swaps the running
code instantly — 'migrations before traffic' was not achievable by ordering.
The updater now goes down → merge → dependencies → migrate → assets → restart →
up, trading a short deliberate outage for never serving code whose schema does
not exist yet. If a step fails the site STAYS down: coming back up with new code
on an old schema is worse than staying dark until someone looks.

It also records the last successfully deployed commit. A run that died halfway
left the checkout ahead, so the next run said 'already up to date' and skipped
the rest forever.

And it installs dependencies when a lockfile moved: vendor/ and node_modules/
live in the bind mount and shadow the image, so rebuilding the image never
updated them — migrations could run against stale packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:15:15 +02:00
nexxo 834abcec40 feat(deploy): installer and updater for a fresh server
install.sh sets up a bare Debian/Ubuntu server end to end: Docker, git,
WireGuard, clone, generated secrets, stack, migrations, hub keypair, the Owner
account and a closed firewall. Re-runnable: it keeps an existing .env and never
regenerates the hub key, which would disconnect every onboarded host.

update.sh pulls and applies. Migrations run before the new containers take
traffic, the image is rebuilt only when its definition changed, and the queue
workers are restarted — they are long-running processes that otherwise keep the
old classes in memory, which cost us an hour during development.

clupilot:create-admin creates or promotes an Owner, so re-running the installer
fixes a lost role instead of failing on a taken address.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:13:24 +02:00
nexxo e2b4cdbac4 feat(site): switch the public website and portal off from the console
While the product is still being built, the marketing site and the customer
portal should not be reachable — but they must stay reachable for us.

- A toggle in the console (site.manage, Owner/Admin) stored in a new
  app_settings table, because this has to be flippable without a deploy.
- Outsiders get a placeholder with 503 + noindex, not 200: a 200 invites search
  engines to index the placeholder as the site's content, which is far harder
  to undo than to prevent.
- Anyone on the management VPN, and any signed-in operator, sees the real site.
  The console, Livewire's endpoint, the Stripe webhook and the health check are
  always reachable — otherwise the switch could only be flipped once.
- robots.txt is generated by the app and follows the switch. It had to stop
  being a static file: nginx short-circuited it and Laravel's stock file said
  'Disallow:' with an empty value, so crawlers were never told anything.
- Settings reads fall back to the default when the table is unavailable. The
  gate reads one on every request, so a deploy running new code before migrate
  would otherwise answer the entire site with a 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:10:27 +02:00
nexxo 7f60f88542 fix(maintenance): make the window form usable
The action labels wrapped to two lines and crushed the column — now icon
buttons with tooltips, like every other admin table. The form column was too
narrow for its content: the datetime fields silently clipped their own value,
so they are stacked and full width now, with duration chips because typing an
end timestamp by hand is the fiddliest part of the form. Host rows show a real
selected state and a per-datacenter count, and the impact column says
"1 Host · 1 Kunde" instead of "Host(s) · Kunde(n)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:05:33 +02:00
nexxo 7ef1b3e5c8 docs(wireguard): document the ownership model and config storage
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:55:14 +02:00
nexxo 17d7dc2ad9 fix(vpn): store the config inside the creation transaction
Encryption and the write ran after the transaction had committed and the peer
had been dispatched to the hub. A failure there left a live access whose config
was never stored — unrecoverable, and the operator would create a second one
not knowing why. Both now happen in the same insert; nothing is dispatched
unless it succeeded.

Re-verified in the browser afterwards: create with storage, wrong password
refused, right password reveals the config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:53:30 +02:00
nexxo 4ffdafc614 fix(vpn): losing the operator roles closes the owner doors by itself
Ownership alone kept download, block and delete reachable for someone whose
roles were removed by any path other than revokeStaff() — a direct role edit,
say. Being staff is now part of ownership, so revocation closes these doors
without depending on whoever removed the role also remembering the tunnel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:50:13 +02:00
nexxo e5f19f8db3 fix(vpn): count downloads in the database; repair the race test
The download counter was read-modify-written, so two concurrent retrievals
recorded one. It is an audit trail — under-reporting is the failure mode it
must not have.

The creation-race test broke when issuing moved into a transaction: its
simulated competitor writes inside our transaction, so the rollback removes
that row too. It now asserts what actually matters (the loser is told, and
creates nothing) and states what a single-connection sqlite test cannot show.
This test was red in the previous commit — my mistake for committing before
reading the run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:48:38 +02:00
nexxo 02750a7c9a fix(vpn): lock the owner row while issuing an access
A revocation committing between the operator check and the insert would find no
peer to remove and still leave the revoked colleague with a brand-new tunnel.
Issuing now runs in a transaction that locks the owner row — the same row
revokeStaff() locks — so the two cannot interleave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:46:23 +02:00
nexxo 3bd3e64a3d fix(vpn): pause polling while a private config is on screen
Every five-second poll re-rendered the page, putting the private key into
another HTTP response — the handoff kept it out of the snapshot but not out of
the responses. Traffic figures can wait the minute it takes to copy a key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:44:31 +02:00
nexxo c2b18ecfbc fix(vpn): drop the previous config before showing a new access
Creating a second access with a supplied key skipped the config branch, so the
first access's private configuration stayed on screen under the new name — and
someone would have handed it out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:42:38 +02:00
nexxo 7e17bc74b2 fix(vpn): give the QR code the specified four-module quiet zone
Two modules is below the minimum, and against a bordered card some readers fail
to locate the symbol at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:40:54 +02:00
nexxo 04ba9dd2a9 fix(vpn): an open modal stops serving the config once access is revoked
Once revealed, the plaintext sits in the handoff cache for ten minutes, and the
modal read it from the token alone. A former owner could keep pulling the key
out of an open modal after revocation — the exact window purgeSecret() exists to
close. Every request now re-checks the policy and drops the handoff when it no
longer holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:39:30 +02:00
nexxo db7b8a6ee3 fix(vpn): keep the navigation entry after the capability split
The sidebar still gated the VPN entry on vpn.manage, which the split deleted —
so the page would have vanished from the console for every role, Owner
included. Every operator can hold their own access, so the entry is shown to
all of them and the page itself shows only what the policy allows. Guarded by a
test across all five roles, which is the assertion my rewrite had dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:37:35 +02:00
nexxo 371d517261 fix(vpn): dispatch the revocation removal after the transaction commits
A worker could pick the job up mid-transaction, still see the peer as active,
leave it on the hub — and never be asked again, because the committed state is
only a soft delete. A revoked colleague would keep their tunnel until some
later reconciliation noticed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:36:11 +02:00
nexxo 1112b4cdec fix(vpn): classify adopted peers as system, not staff; drop duplicate env line
Codex spotted the migration labelling sync-adopted peers as staff with no
owner, which breaks the invariant the same migration introduces. The live sync
had the identical hole — it never set kind at all, so every adopted peer landed
on the 'staff' default. Both now use system for a peer nobody is behind, and
promote it to host once the link is known.

.env.example carried ADMIN_HOSTS twice; phpdotenv keeps the first, so a fresh
checkout would have got the list without localhost and 404'd its own console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:34:18 +02:00
nexxo 0d9b62eb50 feat(vpn): ownership, a Developer role, and password-gated config retrieval
Reworked after a design consultation with Codex, which pushed back on my first
proposal in three useful ways.

Ownership and rights:
- vpn.manage split into vpn.view.all and vpn.manage.all. Seeing an access is
  not managing it, and neither is holding its private key.
- Record-level rules live in VpnPeerPolicy, not in permissions: an access
  belongs to a person, and ownership is what grants sight of it. Every operator
  reaches the page, but sees only their own unless they may see all.
- Issuing an access is not self-service — it reaches the management network, so
  it needs vpn.manage.all even for oneself.
- New Developer role: sees everything, manages nothing. Writing code does not
  imply authority over other people's access.
- kind (staff|host|system) replaces a null user_id that had to mean two
  different things at once.

Config storage, opt-in per access:
- Downloading is owner-only — explicitly NOT for view.all or manage.all. An
  admin who needs access revokes this one and issues their own, which keeps the
  record of who holds what honest.
- The password is asked on EVERY retrieval, rate-limited. Laravel's
  password.confirm keeps a 15-minute session stamp, which would authorise
  unlimited later downloads from an unattended browser.
- Stored under VPN_CONFIG_KEY, not APP_KEY: a leaked application key must not
  also hand over the management network. Purged on revocation and when a staff
  member is revoked — console taken away, tunnel left open was the worst case.
- The plaintext never enters a Livewire snapshot (the page polls every five
  seconds); the component carries an opaque handle instead.

Also: copy button works without HTTPS again (navigator.clipboard only exists in
a secure context, so over http it silently did nothing), plus config download
as a file and a QR code for the mobile apps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:31:54 +02:00
nexxo cad245c5df docs(wireguard): document the VPN console and the invariants behind it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:02:46 +02:00
nexxo d1302e17bc fix(vpn): purging a host no longer leaves a phantom access behind
The FK only nulled host_id, so the adopted peer stayed enabled-but-absent: shown
as "wird angewendet" forever, and re-enabling it would have put the deleted
host's key and address back on the hub. PurgeHost now tombstones that row, which
hands it to the same revocation machinery as everything else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:00:13 +02:00
nexxo c54b718566 fix(vpn): never let an access hijack a host key; name adopted peers correctly
- A host's wg_pubkey may not be in vpn_peers yet, so the duplicate check passed
  and an operator could create an access with it. ApplyVpnPeer would then run
  wg set with a freshly allocated address, rewriting that host's allowed-ip and
  cutting CluPilot's management tunnel to a live machine. Creation now rejects
  keys already held by a host, checked under the allocation lock.
- A sync landing between addPeer() and the step storing wg_pubkey adopted the
  peer as "unknown", and a later sync filled in host_id but left the name, so
  the page showed that host as unknown forever. The name is now filled in when
  the link becomes known — only for sync-adopted rows, so an access an operator
  named keeps its name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:58:03 +02:00
nexxo 28e681b9df fix(vpn): lock every hub mutation; resolve duplicate keys inside the lock
- ConfigureWireguard and RemoveWireguardPeer mutate the hub too, and were not
  taking the lock, so a sync could still read the interface around them and
  write stale state. Both now hold wireguard:hub, which is what makes the
  read/mutation guarantee actually hold.
- The duplicate-key check ran before the allocation lock, so two concurrent
  creations with the same key both passed it and the loser hit the unique index
  as an unhandled 500. The check moved inside the lock, with the index kept as
  a caught backstop for any writer that does not take it — reproduced in a test
  by inserting a colliding row from within the allocation call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:55:57 +02:00
nexxo 8eb9d7ad30 fix(vpn): serialize hub reads against hub mutations
A sync could read the hub snapshot just before ApplyVpnPeer removed a peer and
purged its tombstone, then adopt what it had seen as a fresh enabled access —
restoring a revoked one. Both jobs now hold Cache::lock('wireguard:hub') around
read-decide-mutate, so a reconciliation can never straddle a removal. Retry
removals are dispatched after the lock is released, because the sync queue
driver runs them inline and they take the same lock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:53:13 +02:00
nexxo 9137eae6df fix(vpn): a stale enable job can no longer resurrect a deleted access
If an add job was retried after the access had been revoked and its tombstone
purged, it found no row and trusted its own enabled=true payload — re-adding a
key that no longer had a row anyone could revoke it with. A missing row can only
mean the access is gone, so the captured payload may now remove but never
enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:50:57 +02:00
nexxo 1b193ba4ea fix(vpn): join the host pipeline's allocation lock; re-authorize on every poll
Two more from Codex:

- Addresses come from one subnet but live in two tables (hosts.wg_ip and
  vpn_peers.allowed_ip), so a concurrent host onboarding and console creation
  could each see the same address as free and both insert — neither unique
  index sees the other. Creation now waits on Cache::lock('wireguard:allocate'),
  the same lock ConfigureWireguard already holds while reserving a host address.

- mount() runs once, so revoking vpn.manage left an open tab polling every five
  seconds and still receiving fresh peer state. Authorization now also runs on
  hydration, which is what the poll goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:49:09 +02:00
nexxo 300ea60f19 fix(docker): build assets on start when the manifest is missing
With the Vite dev server off by default, a fresh checkout had no public/build
(gitignored, and the image does not build it), so every @vite page failed with
ViteManifestNotFoundException. The entrypoint now builds once when the manifest
is absent, non-fatally — a broken build must not wedge the container into a
restart loop where the logs are unreachable.

Proven by deleting public/build and restarting: assets rebuilt automatically,
page 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:45:27 +02:00
nexxo 08670ecbee fix(vpn): stop revoked accesses coming back and stale jobs undoing intent
Two lifecycle holes Codex found in the new VPN management:

1. Delete removed the row before the hub knew. If the removal job was delayed
   or failed, SyncVpnPeers saw a peer it did not recognise and adopted it back
   as a live access — silently restoring what an operator had just revoked, and
   with no row left to revoke it again. Deletion is now a soft-delete
   tombstone: the row survives until the hub confirms the peer is gone, the
   sync retries the removal instead of adopting, and the tombstone is purged
   only once the peer is really absent. It also keeps the key and the tunnel
   address reserved meanwhile, so neither is handed out twice.

2. ApplyVpnPeer applied the state captured at dispatch. A retried block job
   could therefore undo a later unblock, and nothing would repair it — the sync
   only observes state, it never re-applies intent. The job now resolves the
   current desired state from the row and treats its payload as a fallback for
   the case where the row is already gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:41:03 +02:00
nexxo 71ca0e1394 feat(admin): VPN access management with live peer state
The console can now create, block and remove VPN accesses, and shows who is
actually connected — traffic counters, source endpoint and last handshake.

Design notes:
- The web container has no wg0, so the page never talks to WireGuard. Live
  state is copied in by SyncVpnPeers on the provisioning queue (the only worker
  whose container owns the interface); the page polls the database and merely
  nudges that job, throttled so many open tabs cannot flood the queue.
- enabled (operator intent) and present (observed on the hub) are stored
  separately, so a sync can never quietly undo a block and drift stays visible
  as "wird angewendet".
- The sync adopts peers the host pipeline registered, naming them after their
  host — otherwise "who is on the VPN" would have blind spots.
- Keypairs are generated in PHP via libsodium rather than shelling out to
  wg genkey (no interface in this container, and it keeps generation testable).
  The private key is shown once and never stored.
- allocateIp() now also considers vpn_peers, which would otherwise be handed a
  tunnel address a host already holds.
- vpn.manage is a new capability held by Owner/Admin only, and it hides the nav
  entry too — a VPN access reaches the management network.

Verified end to end against the real hub, not just mocks: created an access in
the browser, connected with the generated config, watched the console flip to
"Verbunden" with real counters, then blocked it and confirmed the peer was
gone from wg0 and the client could no longer handshake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:38:12 +02:00
nexxo a5a261fa37 test: pin APP_URL/ADMIN_HOSTS so the suite does not follow deployment env
Setting the real APP_URL made route() build https://app.dev.clupilot.com/...,
which ADMIN_HOSTS does not list, so 30 admin tests 404'd. The suite must not
depend on operator hostnames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:24:42 +02:00
nexxo 3e82b51f89 fix(ui): make modal cancel buttons work; serve the app over the HTTPS domains
The cancel buttons were dead: wire-elements registers Livewire.on('closeModal'),
but the buttons used Alpine's $dispatch, which fires a DOM event. Livewire
bridges its own events TO the DOM, not back, so nothing ever received them.
Proven in the browser both ways — before: modal stays open after Abbrechen;
after: it closes.

Domain operation behind Zoraxy:
- trustProxies for FOR/PORT/PROTO so Laravel sees https (otherwise it builds
  http:// URLs into an https page). X_FORWARDED_HOST is deliberately NOT
  trusted — the console is gated on the request host, and trusting it would let
  anyone reach /admin through a public domain by forging the header.
- APP_URL + VITE_REVERB_* point at the dev domains (wss via ws.dev).
- Vite dev server is now opt-in (VITE_AUTOSTART): over https the browser cannot
  load assets from http://<ip>:5173. Built assets are the default, and the
  entrypoint clears a stale public/hot, which otherwise 404s every asset.

Verified in the browser over https: login, styled pages, zero console errors,
zero unencrypted requests, /admin reachable only on admin.dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:24:03 +02:00
nexxo 8edb2a69e5 docs(wireguard): bring the management hub up and prove it end-to-end
The hub lives in queue-provisioning (the only worker on the provisioning queue,
and LocalWireguardHub shells out to wg in-process). Generated the hub keypair,
wrote wg0.conf, and filled CLUPILOT_WG_ENDPOINT/HUB_PUBKEY.

Verified against the running stack rather than mocks: registered a stand-in
Proxmox peer through the app's own addPeer(), got a handshake, opened hub->peer
TCP over the tunnel (what the SSH steps actually need — a handshake alone does
not prove routed TCP), confirmed wg-quick save persisted the peer, then removed
it through removePeer(). wg0 returns after a container restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:05:11 +02:00
nexxo 7247edf9d8 feat(security): keep the operator console off the public hostnames
Only the marketing site and the customer portal are public; /admin and the
Proxmox hosts stay on the private network. nginx was a single server_name _
catch-all, so /admin was served on all four dev domains.

Two layers:
- nginx denies /admin on the known-public hostnames before PHP is reached
  (denylist, so an allowlist typo cannot lock the operators out).
- ADMIN_HOSTS is now populated, making the app layer a strict allowlist that
  also covers /livewire/update, which nginx cannot attribute to /admin.

Verified live against the running stack: /admin is 404 on www/app/api/ws and on
any unlisted host, 302->login on admin.dev / the private IP / localhost, while
/ and /dashboard stay reachable everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:01:02 +02:00
nexxo ba861ad579 fix(security): match ADMIN_HOSTS case-insensitively; share test helpers
DNS names are case-insensitive and Symfony's getHost() always returns lowercase,
so ADMIN_HOSTS=Admin.Example.com rejected every request and locked operators
out. Normalised in the config AND at the comparison, so the value is safe
whichever route it took in (env, cached config, runtime set).

operator()/admin() moved from RbacTest/HostManagementTest to tests/Pest.php:
they were only loaded when those files happened to be in the run, so a targeted
single-file run died on 'undefined function operator()'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:56:26 +02:00
nexxo f5f4d2a8cd fix(security): close the Livewire bypass of the admin host restriction
Admin actions post to /livewire/update, which a path-scoped guard skips — an
operator session could drive admin components through a PUBLIC hostname despite
ADMIN_HOSTS. The restriction is now registered as Livewire-persistent middleware
and listed on the admin route group, so Livewire re-applies it from the
component snapshot.

Proven by replaying a snapshot taken from the real rendered page: identical
payload -> 404 on a public host, 200 on the allowed host (positive control, so
the test cannot pass for the wrong reason).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:50:22 +02:00
nexxo 1f42c05648 feat(security): admin console can be pinned to non-public hostnames
The Proxmox fleet and the operator console must never be publicly reachable. The
primary control is the reverse proxy, but nginx here is a catch-all
(server_name _), so /admin was served on EVERY hostname — a proxy
misconfiguration would expose it. ADMIN_HOSTS pins it; any other host gets 404
(not 403: a public domain must not disclose that a console exists).

Prepended to the  group instead of the admin route group on purpose: route
middleware is reordered by Laravel's priority list, which runs  first — a
guest would then be redirected to /login and learn the console is there. Covered
by a test for exactly that case. Empty ADMIN_HOSTS = unrestricted, so nobody is
locked out by upgrading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:45:54 +02:00
nexxo db3ef1642b chore: drop stray vim swap file
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:25:40 +02:00
nexxo 46d821a047 fix(monitoring): cap monitoring retries at the run budget; dependency-free liveness probe
- MONITORING_ATTEMPTS is capped by the run's max_attempts, so a large value can
  no longer burn the retry budget and fail the very provisioning the degradation
  exists to protect (covered by a new test at the budget boundary)
- /health is now dependency-free (200 in ~2ms with Kuma absent, verified: the
  container healthcheck stays healthy); Kuma state is exclusively in /ready

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:19:32 +02:00
nexxo d1db23398d fix(kuma-bridge): a rejected login is not-ready (was falsely 'up'); clearer connect vs login errors
Verified with a reachable Kuma and deliberately wrong credentials:
/ready -> 503 'Kuma login rejected: Incorrect username or password.'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:16:24 +02:00
nexxo 353df9f054 fix(kuma-bridge): detect Kuma outages and never trust the cached monitor list
Verified against a real Kuma by stopping it mid-test:
- get_monitors() reads the library's CACHED monitorList event, so it reported
  'up' long after Kuma died. Reachability now does a real HTTP round-trip plus a
  live socket check.
- the cached list could yield an id for a monitor that no longer exists; a match
  is now confirmed with a live get_monitor before it is returned, so CluPilot can
  never record a target that is never checked.
- liveness (/health, always 200 while serving) split from readiness (/ready, 503
  when Kuma is unreachable) — the container healthcheck must not restart a
  healthy bridge just because a dependency is down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:13:34 +02:00
nexxo 82916167cf fix(monitoring): MONITORING_ATTEMPTS is now exact (off-by-one); accept int or dict monitor id
- attempt is 0-based, so compare one-based: ATTEMPTS=2 really means two tries
- bridge accepts add_monitor returning {monitorID}|{id}|int (v1.2.1 returns a
  dict — re-verified: first create on a fresh Kuma is HTTP 200 with the id)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:08:07 +02:00
nexxo 5301ce4309 feat(monitoring): Uptime Kuma support via a REST bridge + resilient monitoring policy
Kuma has no write REST API (monitor CRUD is Socket.IO only), so docker/kuma-bridge
translates the exact generic REST contract HttpMonitoringClient already speaks —
no PHP client change needed. Opt-in compose profile 'monitoring'.

Verified end-to-end against a real Uptime Kuma 1.x:
  create -> id, create again -> same id (idempotent), list, status, delete
  and PHP MonitoringClient -> bridge -> Kuma round-trip.
The e2e run caught a real bug: add_monitor takes 'maxretries', not 'retries'.

Monitoring is now observability, not a delivery gate:
- RegisterMonitoring retries MONITORING_ATTEMPTS times on an outage, then
  continues degraded with a visible 'info' event
- RunAcceptanceChecks no longer fails when monitoring isn't green (a fresh Kuma
  monitor legitimately reports 'pending' — confirmed in the e2e run); it records
  the gap instead. MONITORING_REQUIRED=true restores strict gating.
- docs/monitoring-uptime-kuma.md documents setup, failure behaviour, alternatives

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:04:58 +02:00
nexxo f06f00c428 chore(config): documented env placeholders + SSH keys from file paths
- .env.example gains a full, commented CluPilot operations block (Stripe, Hetzner
  DNS, Traefik, WireGuard hub, SSH identity, monitoring, prod mail) with a note
  that everything is optional for local dev
- config/provisioning.php: CLUPILOT_SSH_{PUBLIC,PRIVATE}_KEY_PATH read the key
  from a file (a multi-line PEM cannot live in .env), falling back to inline vars
- documents that Uptime Kuma's REST API is read-only (monitor CRUD is Socket.IO),
  so it needs a bridge rather than being drop-in for the generic REST client

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:37:25 +02:00
nexxo 32fa1028cc fix(portal): cloud status labels for all instance states; scale storage curve into the quota
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:16:16 +02:00
nexxo d05932105d feat(portal): bind the cloud card to the real instance (plan/seats/domain/quota) so the maintenance badge matches it
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:14:42 +02:00
nexxo ac3b3a4e11 fix(portal): scope the per-instance maintenance badge to that instance's host
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:12:33 +02:00
nexxo a321d963d7 fix(portal): no silent no-ops without a customer; maintenance visible per instance; maintenance form redesign
- ResolvesCustomer trait replaces 5 duplicated customer() lookups; portal actions
  now SAY why nothing happened (operator accounts have no Customer) instead of
  returning silently — the actual cause of 'dead buttons', '0/0 seats' and
  'settings not saving' when signed in as admin
- portal layout: explicit notice for a login without a linked customer
- /cloud: per-instance maintenance badge + window details; seat note 'owner = seat 1'
- maintenance form: sectioned card, hints, placeholder, styled datetime fields,
  grouped host picker with per-datacenter select-all + selection counter

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:10:44 +02:00
nexxo 0b2d762b88 fix(auth): scope registration throttle to its own route (per-IP), not all Fortify endpoints
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:41:30 +02:00
nexxo 35a86c413c fix(auth): throttle Fortify endpoints (registration had no limiter — signup-spam guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:38:44 +02:00
nexxo 406f753311 fix(admin): remove is_admin self-heal — RBAC is the only console boundary (no revocation bypass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:35:14 +02:00
nexxo 22cbe9ac39 fix(auth/admin): atomic user+customer signup; Rule::in for country validation (comma-safe)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:33:09 +02:00
nexxo 6ca1e3fba5 fix(auth): create + link a Customer on public signup so the portal works pre-purchase
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:31:08 +02:00
nexxo 39a1f708a1 fix(auth): reject signup with an existing customer email (account-claim guard)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:29:35 +02:00
nexxo 507ab485a0 fix(admin): derive legacy datacenter location allowlist from DB, not client property
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:27:41 +02:00
nexxo 3a794b05dd fix(admin): promote only role-less legacy admins; allow editing datacenters with legacy free-form location
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:26:09 +02:00
nexxo fd7cba7403 fix(admin): authorize datacenter modal mounts; stale threshold uses step maxDuration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:23:44 +02:00
nexxo 2b73a895c9 fix(admin): don't flag scheduled-backoff runs as stale; edit-datacenter country validation from config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:21:22 +02:00
nexxo 8304f2e7dc fix(admin): self-heal legacy is_admin into Owner (no RBAC bypass); validate datacenter country server-side
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:19:24 +02:00
nexxo 88957f8f57 feat(auth): enterprise split login + signup (Fortify registration) via frontend-design
- login/register redesigned as a split brand-panel + clean form (Apple-ish
  enterprise look), cross-linked; brand panel uses accent gradient + tokens
- Fortify registration enabled; /register full-page Livewire + RegisterTest

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:16:27 +02:00
nexxo be99f413f7 feat(admin): provisioning liveness — per-run progress bar, last-activity, stale warning
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:11:17 +02:00
nexxo b50f97568f fix(admin): no admin lockout (is_admin fallback); centered modal width; datacenter edit modal + country dropdown
- isOperator()/EnsureAdmin/broadcast fall back to is_admin so a legacy admin is
  never locked out by a stale permission cache
- published modal: centered max-w-lg card (dynamic modalWidth class was purged
  by Tailwind → full-width)
- datacenter edit moved to a modal (no row-height jump); location is now a
  country dropdown (config/countries.php) instead of free text

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:09:17 +02:00
nexxo 87a81f5f40 fix(admin): read-only send-time guard (never drop mail on retry); document exactly-once residual for real-mail outbox
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:40:44 +02:00
nexxo e09965f918 fix(admin): in-flight claim (claimed_at) for exactly-once maintenance send; scope permission rollback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:38:44 +02:00
nexxo 28221549d1 fix(admin): defer maintenance sent_at to real delivery; read-only send-time dedup (no lost mail)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:35:52 +02:00
nexxo 0cc5be5479 fix(admin): atomic send-time delivery claim so backlog duplicates ship exactly once
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:34:02 +02:00
nexxo 5b85806605 fix(admin): catch racing ledger insert and re-fetch (idempotent under concurrency)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:31:43 +02:00
nexxo 72731112f4 fix(admin): atomically claim maintenance notification retries under a row lock
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:30:17 +02:00
nexxo 8bb93137a3 refactor(admin): extract MaintenanceNotifier; atomic provisioning retry; catch-up cancellation for race-delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:28:02 +02:00
nexxo ae1f78f534 fix(admin): shown-once temp password for invited staff; suppress announcements for cancelled windows at send time
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:23:21 +02:00
nexxo 718e8568c1 fix(admin): require host on any maintenance save; cancel-notify only delivered announcements
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:19:44 +02:00
nexxo 62b4d85ef7 fix(admin): release notification claim on dispatch failure so retries aren't blocked
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:17:44 +02:00
nexxo ab5ba2b169 fix(admin): dedup pending maintenance notifications (updated_at claim + staleness threshold)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:16:15 +02:00
nexxo 493b81aadf fix(admin): confirm maintenance delivery via MessageSent (retryable until sent); guard resend/cancel on derived state; fix flaky non-unique host wg_ip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:14:01 +02:00
nexxo 52fa7a34a2 feat(admin): email affected customers when maintenance is cancelled (ledger-guarded)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:07:21 +02:00
nexxo 6593dae946 fix(admin): retry path for missed maintenance emails; lock Owner role to serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:04:31 +02:00
nexxo bc2e95ba18 fix(admin): block role escalation of non-staff; validate maintenance host ids atomically; rollback ledger on dispatch failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:01:45 +02:00
nexxo e5c74c6bdd fix(admin): reject customer email on operator account update; do not claim mail delivery on dispatch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:58:24 +02:00
nexxo d68d8e1f25 fix(rbac/mail): migrate legacy is_admin users to Owner in-migration; send maintenance mail in customer locale
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:56:22 +02:00
nexxo eff8c08258 feat(admin): maintenance windows — schedule once, assign many hosts, notify
- maintenance_windows + host pivot + notification ledger; derived state (never
  stored); affected-customer + banner queries live off instances
- admin /maintenance: create draft/publish, multi-host select, impact counts,
  cancel; capability-gated (maintenance.manage)
- publish emails affected customers once (queued Mailable, ledger-idempotent)
- customer portal maintenance banner (upcoming <=72h + active) merged per window

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:53:50 +02:00
nexxo c5d60340b7 feat(admin): staff RBAC (spatie) + admin settings page
- 5 operator roles (Owner/Admin/Support/Billing/Read-only) seeded via migration
  with a capability catalogue; app checks capabilities via Gate, never role names
- every mutating admin action authorizes server-side (hosts/datacenters/customers/
  impersonate/provisioning); is_admin reads migrated to console.view / isOperator()
- admin /settings: own account + Owner-only staff invite/role/revoke with
  last-owner, self-role and customer-collision guards (transactional)
- sidebar 'zum Kundenportal' link replaced with Settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:47:42 +02:00
nexxo 4336c3bb3f feat(brand): CluPilot logo mark + SVG favicon across layouts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:31:33 +02:00
nexxo 08f61b43f5 fix(ui): modal above sidebar (z-index); datacenter edit buttons stay right; toast slides from bottom; provisioning retry action
- modal wrapper z-10 -> z-[70] (was behind the z-40 sidebar); toasts slide up/down
- datacenter inline-edit colspan fix so save/cancel stay right-aligned
- admin provisioning: retry a failed run (table + panel), mirrors host-detail retry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:29:37 +02:00
nexxo 99081284a4 fix(portal): delete old logo only after the branding update commits
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:04:27 +02:00
nexxo f6efa4f200 fix(portal): allow closure after failed provisioning; serialize last-owner guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:02:37 +02:00
nexxo 8bcb5ec268 fix(admin): enforce hosts.datacenter FK (restrictOnDelete) — no deactivation side-effect
The DB now refuses to orphan a host; datacenter delete pre-checks for a friendly
message and catches the constraint as the race backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:00:38 +02:00
nexxo 249efa0553 fix: serialize datacenter delete (lock+deactivate+recheck); store logo before deleting old
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:58:07 +02:00
nexxo 01383e7e5e fix(portal): race-safe owner-seat initialization (firstOrCreate + catch)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:55:09 +02:00
nexxo 3bf0dfb548 fix: seat limit + suspend guard per Codex; harden factory email uniqueness
- seat limit follows the active/cancelling package, not a newer inactive record
- suspend/reactivate toggle refuses closed accounts (terminal); closed shown in list
- factory emails use a large unique numeric space (safeEmail pool could collide
  late in a full suite run)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:53:15 +02:00
nexxo cbbb523f9c fix(portal): cancel the active instance explicitly, not just the newest record
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:48:57 +02:00
nexxo c011a11b48 fix(portal): atomic seat-limit (row lock) + billing-day-anchored cancellation date
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:47:04 +02:00
nexxo 074b0c041b fix(portal): enforce customer lifecycle per Codex review
- EnsureCustomerActive middleware: suspended/closed customers lose portal
  access (admins + active impersonation exempt)
- cancellation: reject non-active instances; service-end anchored on the
  subscription start date, not calendar month-end

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:45:03 +02:00
nexxo 3b288486cd feat(portal): seats management — invite/role/revoke against plan limit
- seats table + model; Users page manages team members (owner auto-created)
- invite respects plan seat allowance + dedupe; role change + revoke guarded
  so the last owner can never be removed or demoted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:40:18 +02:00
nexxo df10448e5d feat(portal): settings page — company profile, branding (logo+colors), cancel package, close account
- customers gain profile + branding + closed_at; instances gain cancellation
  fields; branding resolver (NULL -> CluPilot defaults) snapshotted into the
  provisioning run context
- cancel package: term-end, irreversible, typed-confirm modal (R5)
- close account: guarded (no active package), typed-confirm modal (R5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:36:52 +02:00
nexxo ef110b06db feat(admin): host reserve edit + maintenance drain; customer suspend/reactivate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:30:21 +02:00
nexxo d3d686e575 feat(admin): scalable host list (search/filter/health) + host detail redesign
- host list: search + datacenter/status filters, dense table with heartbeat
  health dot, instance count, capacity meter
- host detail: health hero (last_seen), storage/compute breakdown, technical
  facts, hosted-instances table
- Host model: usedPct() + healthState() helpers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:27:53 +02:00
nexxo b44d25404d test: widen DatacenterFactory code space (fix faker unique() pool exhaustion)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:23:49 +02:00
nexxo 54d9a05235 feat(admin): datacenter edit+delete (guarded); MRR chart fills card; host-load as meters
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:22:15 +02:00
nexxo e3ab3cb501 feat(portal): service framing + gradient line charts + billing button fix
- plans gain seats + performance class; customer views show storage/seats/
  performance/features, no raw vCPU/RAM (admin keeps specs)
- billing: scoped wire:target + wire:key so one purchase no longer spins all
- chart island: line-colour->transparent gradient for filled line datasets;
  MRR / backups / invoices bar charts converted to gradient line

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:18:08 +02:00
nexxo a3423a2cee docs: Phase D spec — service framing, redesign & CRUD completeness
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:13:23 +02:00
nexxo 80348d6a3f fix: unique customer↔user link + reject inactive datacenters on host create
- customers.user_id unique so impersonation/billing can't cross customers
- HostCreate validates datacenter is active (exists rule + active=1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:56:07 +02:00
nexxo b44e224bee fix: backfill datacenters on migrate; resolve billing customer by user link
- migration seeds fsn/hel + any host-referenced code so existing installs
  keep selectable datacenters after upgrade
- Billing::customer() resolves via user_id, email only as legacy fallback

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:53:06 +02:00
nexxo 4e86e135a6 fix(admin): harden impersonation + datacenter code per Codex review
- normalize datacenter code before uniqueness validation
- ensureUser: race-safe create + refuse linking admin accounts
- impersonate start/leave are POST (CSRF-protected)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:50:16 +02:00
nexxo 879697c6ea feat(admin): impersonate customer portal — session login + return banner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:46:48 +02:00
nexxo 515f26234e feat(portal): billing page — current plan, upgrades, extra storage, add-ons
New /billing page + nav; plans gained price_cents, storage_addon + addons
catalogue in config. Purchases create a pending Order intent (fulfillment
mocked). Dashboard storage upsell links here. DE+EN. 6 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:36:52 +02:00
nexxo eabcb98f91 feat(admin): compact redesign of /admin/provisioning current-run panel
Progress bar + X/total, highlighted current step, faint next step, failure
inline — replaces the long repetitive per-step 'done' list. Tactical-Terminal
tokens, DE+EN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:31:49 +02:00
nexxo 7c1a153446 fix(ui): fixed sidebars (admin + portal) + storage banner shimmer
App-shell layout: sidebar is a full-height fixed column on lg, only main
scrolls. Re-added the left->right shimmer sweep on the dashboard storage
banner (reduced-motion safe, token colours).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:28:05 +02:00
nexxo bc278c8fa1 feat(admin): datacenters management (create + select)
datacenters table/model + admin CRUD (/admin/datacenters, nav). HostCreate
picks from active datacenters (validated); hosts show the datacenter code.
Seeded fsn/hel. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:25:17 +02:00
nexxo 5fb4e637fc docs: panel improvements (phase C) design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:20:55 +02:00
nexxo d7c2edb1df fix(engine-b): idempotent local DNS record (firstOrCreate on record_id)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:08:13 +02:00
nexxo 96aceeeaa1 fix(engine-b): stop customer dashboard polling once provisioning failed
Show the failure state but only poll while pending/running/waiting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:06:31 +02:00
nexxo 66e8a4bbf6 fix(engine-b): clean re-clone on lost task ref; idempotent firewall rules
- Clone recovery: once the lock clears but the task ref is lost, destroy the
  possibly-incomplete VM and re-clone instead of advancing onto it.
- applyFirewall clears existing rules before adding, so retries don't accumulate
  duplicate Proxmox firewall entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:04:48 +02:00
nexxo 9162446b1f fix(engine-b): acceptance gate verifies real health, not just breadcrumbs
- Nextcloud: parse occ status JSON (installed + not in maintenance).
- Admin: query occ user:info for the account.
- Monitoring: MonitoringClient::isHealthy checks the provider, not the local row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:00:49 +02:00
nexxo 491800e09c fix(engine-b): grant VM.Backup to the Proxmox automation role
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:57:11 +02:00
nexxo 157496f0c5 fix(engine-b): write Traefik route to the serving host over SSH, not locally
TraefikWriter now targets the host that serves the traffic (DNS points at it):
SshTraefikWriter SSHes in and writes the file-provider YAML there. Interface
takes the traffic host + guest backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:55:24 +02:00
nexxo 483a79a822 fix(engine-b): treat unreachable cert endpoint as not-ready (poll, not retry-burn)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:52:37 +02:00
nexxo 03c4b6508f fix(engine-b): Proxmox agent exec repeated command fields; Traefik write throws on failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:50:57 +02:00
nexxo 8825f9abfe fix(engine-b): Proxmox config uses PUT; guest-agent exec is form-encoded
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:48:58 +02:00
nexxo 778bb7f117 fix(engine-b): always scrub credentials once delivered; idempotent monitoring lookup
- CompleteProvisioning scrubs admin_password whenever credentials_sent exists,
  so a crash between recording and scrubbing can't leave the password in context.
- HttpMonitoringClient looks up an existing monitor by URL before creating one,
  so a retry after a crashed POST doesn't create a duplicate external monitor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:46:34 +02:00
nexxo 661b52dbe0 fix(engine-b): clone recovery waits for lock; register rows before breadcrumb
- Clone recovery polls until the VM lock clears instead of advancing onto a
  possibly-incomplete clone.
- RegisterBackup/RegisterMonitoring create the local row BEFORE the run-resource
  breadcrumb, so a crash between them can't leave the row permanently missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:44:22 +02:00
nexxo 3c5a18b5d7 fix(engine-b): apply plan CPU/RAM; idempotent backup + monitoring registration
- ConfigureCloudInit sets cores/memory from the plan (not the template default).
- RegisterBackup uses a deterministic Proxmox job id (ignore-exists) + firstOrCreate.
- RegisterMonitoring is idempotent by URL + firstOrCreate — no duplicate schedules
  or monitors after a crash/retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:42:10 +02:00
nexxo 5cf1964d7d fix(engine-b): default-deny firewall, disk-based capacity, synchronous credential mail
- applyFirewall sets policy_in=DROP so only 80/443 are exposed.
- Capacity/placement account for disk_gb (the real VM allocation), not the
  smaller Nextcloud user quota — no systematic overcommit.
- CloudReady sent synchronously again; the step retries on mail failure with the
  password preserved (no lost credential in a failed queue job).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:39:15 +02:00
nexxo c79874ccfe fix(engine-b): keep admin password ciphertext in the queued mail; valid backup schedule
- CloudReady receives the ENCRYPTED password (decrypts only when rendering), so
  the queued payload never holds plaintext.
- RegisterBackup uses a valid Proxmox calendar expression (02:00).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:35:49 +02:00
nexxo b4cf94ee1a fix(engine-b): real backup/monitoring registration + OC_PASS scoping
- CreateCustomerAdmin puts OC_PASS on the docker invocation (survives the &&).
- RegisterBackup creates a real vzdump job via ProxmoxClient::createBackupJob.
- RegisterMonitoring registers via a new MonitoringClient service (interface +
  fake + http). Both persist the external id [E] before the breadcrumb, so
  acceptance reflects a real registration, not a fabricated row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:33:45 +02:00
nexxo 64e3ca421d fix(engine-b): async stripe payments, capacity honesty, durable credential mail
- Accept checkout.session.async_payment_succeeded (async methods) and dedupe on
  the checkout session id, not the event id.
- committedGb still counts a failed instance while its VM exists (has vmid);
  releases quota only when no VM was created — no overcommit vs no leak.
- CloudReady is queued (durable). Credential delivery is documented at-least-once
  (a rare duplicate welcome email beats a lost credential; exactly-once = v1.1 outbox).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:29:47 +02:00
nexxo e0e4c4e18d fix(engine-b): run guest-agent commands through /bin/sh -c
The Proxmox guest agent execs a program directly; our compound commands
(cd/&&/pipes/redirects/env) need an explicit shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:25:24 +02:00
nexxo f43ade79b0 fix(engine-b): address Codex round 6 (crash-window idempotency)
- Clone: recover via vmExists() when the task ref was lost, instead of
  re-cloning the reserved vmid (which Proxmox rejects).
- Deploy: persist the DB password ENCRYPTED before compose up and reuse it on
  retry, so a crash can't regenerate a mismatching credential.
- Complete: gate credential delivery on a credentials_sent breadcrumb so a
  retry after a crash doesn't re-send the email.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:23:28 +02:00
nexxo e2b8f25e78 fix(engine-b): address Codex round 5 (capacity release, activate-last, guest-ip poll, email unique)
- Failed run releases the instance (status=failed) so its quota stops counting
  against host capacity.
- CompleteProvisioning activates instance+order only AFTER onboarding tasks +
  credential delivery succeed.
- ConfigureNetwork polls until the guest has an address (no wrong Traefik route).
- customers.email unique + race-safe customer resolution in the intake action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:19:31 +02:00
nexxo c56aa14f9d fix(engine): don't reset started_at on poll so step deadlines accumulate
RunRunner::onPoll no longer resets started_at, so a poll step's own deadline
(WaitForGuestAgent 270s, ConfigureDnsAndTls cert 840s) actually fires instead
of resetting every poll. maxDuration raised above each own-deadline so the
step's fail wins over the generic timeout. Shared core fix (A + B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:15:26 +02:00
nexxo fffcecb152 fix(engine-b): address Codex round 3 (webhook email/replay/rotation, db secret)
- Require a real customer email (no unknown@ fallback merging customers).
- Stripe signature: enforce 5-min replay tolerance + accept any rotating v1.
- DeployApplicationStack no longer persists the DB password in the run context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:12:24 +02:00
nexxo e6bd6c9354 fix(engine-b): address Codex round 2 (fail-closed webhook, placement lock, clone/admin idempotency)
- Stripe webhook fails closed when the signing secret is missing (outside local/testing).
- ReserveResources places + creates the instance under a per-datacenter lock so
  concurrent orders can't overcommit a host.
- CloneVirtualMachine reserves the vmid + breadcrumb BEFORE cloning, so a crash
  can't mint a new vmid and orphan the first clone.
- CreateCustomerAdmin checks user existence and resets the password instead of
  re-running user:add on retry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:09:21 +02:00
nexxo 42fa92cf20 fix(engine-b): address Codex (paid-only stripe, guest routing, absolute disk)
- Stripe: only checkout.session.completed with payment_status=paid (dedupes the
  paired payment_intent event and blocks unpaid async sessions).
- Capture the guest IP (ConfigureNetwork) and point Traefik at the VM, not the
  Proxmox host, so ACME/HTTP-01 can reach Nextcloud.
- Resize the disk to an absolute target (not '+…') so a retry can't double it.
(Codex #4 timeout was a false positive — started_at resets per step.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:05:31 +02:00
nexxo 3e19778046 chore(engine-b): provisioning step/mail i18n (DE+EN) + demo seed data
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:59:06 +02:00
nexxo a9fbb395f9 feat(engine-b): live provisioning progress (admin console + customer dashboard)
Admin /admin/provisioning bound to real runs+steps (poll+admin.runs). Embedded
CustomerProvisioning card shows the logged-in customer their own run live
(per-customer StepAdvanced channel, email-bridged authz). Shared BuildsRunSteps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:57:35 +02:00
nexxo 65ba3d6588 feat(engine-b): Stripe webhook -> idempotent customer provisioning intake
Signed webhook (HMAC verify when secret set), CSRF-exempt route. Paid checkout
creates customer+order+run (stripe_event_id unique => duplicate webhooks start
one run) and dispatches. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:52:46 +02:00
nexxo 446da58061 feat(engine-b): 15-step customer pipeline + DNS/Traefik services
validate->reserve(placement)->clone->cloudinit->start->guestagent->network->
deploy->nextcloud->admin->dns/tls->backup->monitoring->acceptance->complete.
HetznerDnsClient + TraefikWriter (interface+fake+real), CloudReady notification,
hosts.node (set by RegisterCapacity). Secrets transient/encrypted, never
plaintext. 21 tests incl. mocked end-to-end + crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:50:26 +02:00
nexxo e5aeb3989e feat(engine-b): ProxmoxClient VM-lifecycle extension (interface + fake + http)
nextVmid/cloneVm/setCloudInit/resizeDisk/startVm/vmStatus/guestAgentPing/
guestExec/taskStatus/applyFirewall. Fake is deterministic with configurable
task/guest failure hooks; Http polls UPID + guest exec-status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:41:48 +02:00
nexxo 770a6cf7cd feat(engine-b): CustomerStep base + shared resource trait + config
ManagesRunResources trait (host+customer), CustomerStep base (order/instance/
plan/guest helpers), config plans + 15-step customer pipeline + dns/traefik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:39:56 +02:00
nexxo 406251dfde feat(engine-b): customer domain models + migrations
customers, orders (ProvisioningSubject, stripe_event_id unique), instances
(subdomain unique, encrypted nc_admin_ref), dns_records, backups,
monitoring_targets, onboarding_tasks. Host capacity: committedGb/availableGb +
datacenter placement. 7 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:38:01 +02:00
nexxo b9742f61ee docs: customer pipeline (Subsystem B) implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:35:28 +02:00
nexxo 5e6d6b11b6 docs: engine customer-pipeline spec (the missing Section 3, aligned to the built core)
Detailed build contract for Subsystem B (15 customer-provisioning steps),
written against the real Subsystem-A contracts: ProvisioningStep/StepResult
(advance/retry/poll/fail), RunResource idempotency breadcrumbs, ProvisioningRun
context, the config/provisioning.php pipeline registry, and the read-only
ProxmoxClient it must extend. Covers new models (customers/orders/instances/…),
ProxmoxClient VM-lifecycle additions, Hetzner-DNS + Traefik services, Stripe
idempotent intake, live progress binding to the existing admin/customer views,
build order and DoD. Unblocks B.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:27:28 +02:00
nexxo c7fb1ce56d fix(engine): dispatch run continuation after releasing the lock
Prevents a second worker from consuming the follow-up job and bailing on the
still-held run lock, which would stall the run until the next scheduler tick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:09:24 +02:00
nexxo 294c9aef2f fix(engine): store wg_pubkey right after addPeer so failed peers are cleanable
Keeps the wg_peer resource gated on a verified handshake (idempotency) while
ensuring PurgeHost can always remove the hub peer, even on terminal failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:07:41 +02:00
nexxo 9e664654a2 fix(engine): record wg peer only after verified handshake; async host purge
- ConfigureWireguard checks setup command results and only records the wg_peer
  resource after the handshake verifies, so a transient setup failure re-runs
  the full setup instead of getting stuck on the idempotent path.
- Host removal now deactivates immediately and queues PurgeHost, which deletes
  runs under the runner lock (waiting out a long step) — no 15s LockTimeout error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:06:13 +02:00
nexxo a6a41719bb fix(engine): long SSH command timeout; wg peer removal on privileged worker
- PhpseclibRemoteShell sets a command timeout (default 2000s) so apt
  full-upgrade/install don't hit phpseclib's ~10s default and fail.
- Host removal dispatches RemoveWireguardPeer to the provisioning queue, which
  runs in the worker that owns wg0 (the web container can't manage WireGuard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:01:02 +02:00
nexxo 5979470dda fix(engine): install wireguard-tools + bring up wg0; lock-coordinate host removal
- Image installs wireguard-tools/iproute2; the provisioning worker brings up
  wg0 before queue:work so LocalWireguardHub can manage peers.
- ConfirmRemoveHost deletes each run under its run:<uuid> lock so an in-flight
  worker can't keep mutating the server or write to a deleted run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:57:51 +02:00
nexxo 93191f5de9 fix(engine): fail on unresolvable pipeline step; make worker the WG hub
- RunRunner catches step-resolution errors (removed/renamed pipeline) and
  fails the run terminally instead of looping forever every tick.
- queue-provisioning worker gains NET_ADMIN + tun + persistent wireguard
  volume + published UDP port so LocalWireguardHub can manage wg0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:53:40 +02:00
nexxo 00b8897b9d fix(engine): compute default WireGuard hub IP from the CIDR network
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:49:43 +02:00
nexxo 8a392bfd06 fix(engine): parse WireGuard CIDR for address allocation (any prefix length)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:48:16 +02:00
nexxo b65fe69088 fix(engine): drop wg peer on host removal; clear reboot state on deadline fail
- Removing a host now removes its WireGuard hub peer so a freed wg_ip can't
  route to the removed server via a stale peer.
- RebootIntoPveKernel clears reboot_issued/deadline when it times out, so the
  manual retry action actually re-issues a reboot instead of failing instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:46:11 +02:00
nexxo 91e308efb0 fix(engine): one host per public IP (unique constraint + validation)
Prevents two onboarding runs from fighting over the same physical server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:43:30 +02:00
nexxo 48ed5e7d34 fix(engine): fail if the WireGuard peer can't be persisted (wg-quick save)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:41:11 +02:00
nexxo f240401a42 fix(engine): honour configured WireGuard prefix length in wg0.conf
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:39:46 +02:00
nexxo 18d1ed2424 fix(engine): derive WireGuard hub IP from the configured subnet
hub_ip is now defined in config (default: subnet .1), so a custom
CLUPILOT_WG_SUBNET no longer pings the wrong handshake target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:38:17 +02:00
nexxo ced7e6103d fix(engine): durable queueing for long provisioning steps
- Dedicated 'provisioning' queue connection (retry_after 2400) + worker
  (--timeout=2100); AdvanceRunJob tries=1, timeout=2100 on that queue.
- Run lock TTL raised to 2100s so a long step can't be run concurrently by a
  duplicate tick-dispatched job.
- StartHostOnboarding creates host+run in a transaction, dispatches after commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:36:49 +02:00
nexxo fbe87d99d5 fix(engine): honour backoff timing + don't double-count Proxmox storage
- RunRunner returns early for a waiting run whose next_attempt_at is in the
  future, so stale/duplicate jobs can't bypass backoff.
- RegisterCapacity takes the largest VM-capable datastore instead of summing
  overlapping pools (local + local-lvm), preventing placement overcommit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:32:14 +02:00
nexxo 8a2a2ee695 fix(engine): reset step timer on retry so timed-out steps recover
A retried step (manual or after a timeout) now gets a fresh started_at, so
RunRunner no longer immediately re-detects the timeout and burns the retry
budget without executing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:29:11 +02:00
nexxo 62c4412623 fix(engine): address Codex round 2 (poll budget, host error state, wg race)
- StepResult::poll — polling steps (reboot) wait without consuming the retry
  budget; the step owns its deadline. Reboot maxDuration > deadline.
- Failed runs move a Host subject to 'error' via ProvisioningSubject hook
  (no host stuck 'onboarding').
- ConfigureWireguard allocates + reserves the wg_ip under a global lock;
  unique index on hosts.wg_ip as a backstop against duplicate addresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:26:37 +02:00
nexxo 65717bd3bd fix(engine): address Codex review (auth token bootstrap, tunnel recheck, ssh)
- CreateAutomationToken now bootstraps the pveum role/user/token over the
  authenticated SSH session (a fresh host has no API token yet); ProxmoxClient
  is read-only in A.
- ConfigureWireguard re-verifies the handshake on the idempotent replay path,
  never advancing over a dead tunnel.
- PhpseclibRemoteShell treats a missing SSH exit status as failure (255).
- connectWithKey verifies the pinned host key fingerprint on later logins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:20:59 +02:00
nexxo 36a564d5c8 chore(seed): demo Proxmox hosts for the operator console (local/testing)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:15:12 +02:00
nexxo 8d12a40a42 feat(admin): host onboarding UI (add / live stepper / retry / remove)
Real hosts list, add-host form (StartHostOnboarding), host detail with live
progress stepper (Reverb + wire:poll fallback), retry failed run, remove via
wire-elements/modal (deregister only). DE+EN. 8 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:12:41 +02:00
nexxo ac3d17cd6a feat(engine): 11-step host onboarding pipeline (SSH -> WG -> Proxmox)
Idempotent steps: validate, ssh-trust (deploy key + scrub password),
prepare base, wireguard (hub peer), install proxmox-ve, reboot-into-pve
(retry-poll), configure, automation token, verify api, register capacity,
complete. StartHostOnboarding action. 22 tests incl. mocked end-to-end +
crash idempotency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:04:39 +02:00
nexxo c06ca0ae5d feat(engine): SSH / WireGuard / Proxmox service layer
RemoteShell (phpseclib + fake), WireguardHub (local wg + fake), ProxmoxClient
(REST + fake). Interfaces bound in AppServiceProvider; tests swap fakes via
fakeServices() helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:58:02 +02:00
nexxo 4f724d57c9 feat(engine): orchestrator core (state machine + tick + lock)
StepResult, ProvisioningStep contract, PipelineRegistry, RunRunner (per-run
lock, advance/retry/fail, backoff, timeout, append-only events + Reverb
StepAdvanced), AdvanceRunJob (provisioning queue), minutely Tick, admin.runs
channel. 21 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:54:52 +02:00
nexxo 11cdcdd4da feat(engine): core provisioning data model + hosts
hosts, provisioning_runs (polymorphic), append-only provisioning_step_events,
run_resources (idempotency breadcrumbs). Models with encrypted api_token_ref,
json context helpers, UUID routing (R11).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:49:43 +02:00
nexxo 7ae81d8127 docs: host-onboarding implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:46:58 +02:00
nexxo 772e9d35ff docs: host-onboarding (Subsystem A) design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:44:11 +02:00
nexxo bf5813057b docs: provisioning engine v1.0 build handoff (standalone fleet + cluster-per-DC target)
Focused TDD build plan for the engine: topology decision baked in (Option 1
standalone fleet as the software model, cluster-per-datacenter as the growth
target — hosts get datacenter + nullable cluster fields, placement filters by
datacenter). Covers data model, DB-state-machine orchestrator, 15 customer steps,
new ProxmoxClient, Stripe idempotent intake, and wiring the existing admin/customer
progress views to real data via Reverb. References the state handoff for workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:31:51 +02:00
nexxo 49b5b7a523 docs: state/continuation handoff (2026-07-25)
Full project state for a fresh session: env facts, stack, docker workflow, what's
built (landing + customer portal + admin console), verification workflow (pest /
R12 puppeteer / R15 codex), hard-won gotchas, open items, and the recommended next
block (provisioning engine v1.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 09:16:17 +02:00
nexxo b032d8808b feat(landing): public marketing homepage at / (+ legal placeholders)
Public www landing page (your design) as a self-contained Blade view at / — own
CSS/JS, system fonts (no CDN, R14), hero, marquee, typewriter, product mock with
scroll-zoom, security section, honest comparison, pricing gallery, FAQ, CTA. It is
intentionally outside the app token/component system (marketing page, not control
panel). Wired a sign-in link to route('login').

Robustness/a11y hardening from the Codex (R15) loop:
- reduced-motion: pin hero/reveal elements to their visible end state.
- <noscript> fallback so reveal content is visible without JS.
- login link kept visible on mobile (moved out of the hidden nav group).
- stats render their real values in HTML (correct without JS).
- footer legal links now resolve to real /legal/* routes (placeholder pages).
- reconciled contradictory data-migration pricing (add-on from €390).

48 Pest tests green; R12 browser: landing 0 console errors. Codex (R15) clean.

NOTE: the /legal/* pages (Impressum, Datenschutz, AGB) are placeholders — real
legal content must be supplied before this homepage goes public.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:59:39 +02:00
nexxo 1aa7535fc4 feat(admin): dark Tactical-Terminal operator console
Separate admin console at /admin, gated to is_admin users (EnsureAdmin middleware
+ migration/seed; a plain customer user is seeded to prove the gate). Distinct dark
aesthetic achieved purely by token scoping: .theme-admin overrides every CSS design
token to a dark graphite / signal-orange palette, so ALL shared components (button,
card, badge, table, stat, chart) render dark with zero new markup (R3). Chart island
now reads tokens from its own element, so charts are theme-aware.

Sections (each full-page class-based Livewire, English routes R13, localized DE/EN):
- Overview: fleet KPIs, fleet-growth line, host-load bars, MRR bars, active runs, alerts.
- Customers: table + plan doughnut. Instances: fleet table (vmid/host/storage).
- Hosts: capacity cards (storage/CPU bars). Provisioning: runs table + live stepper.
- Revenue: MRR/ARR/ARPU/churn KPIs, MRR line, plan doughnut, recent payments.
- Locale-aware month labels/currency (Carbon/Number).

18 new Pest tests (guest redirect / non-admin 403 / admin render per section) → 44
green. R12 browser: all six admin pages HTTP 200, ZERO console errors (Chart.js dark).
Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:35:03 +02:00
nexxo c4cff8f67c feat(portal): full sidebar — Cloud, Users, Backups, Invoices, Support
Each sidebar tab is now a full-page class-based Livewire component (R1/R2) with an
English route (R13), localized DE/EN (R16), Chart.js islands and token styling:
- Cloud: instance details + specs + storage-over-time line chart + actions.
- Users: user table (roles/groups/status badges) + users-by-group doughnut.
- Backups: 14-day size bar chart + backup history table + restore.
- Invoices: invoice table + next-charge card + monthly-spend bar chart (locale-
  aware month labels, Number::currency amounts).
- Support: contact cards + tickets table + FAQ accordion (Alpine).
- Sidebar links all tabs with routeIs() active state; global toast in app shell.
- All fixture dates/numbers locale-aware (Carbon isoFormat / Number::format).

12 new Pest tests (guard + render per tab) → 26 green. R12 browser: all six tabs
HTTP 200 with ZERO console errors (Chart.js clean). Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 08:08:33 +02:00
nexxo 499adaff00 feat(dashboard): rich overview with Chart.js (customer-portal template)
- Chart.js as an Alpine island (x-ui.chart): configs are PHP arrays; colours use
  token: strings resolved from CSS design tokens at runtime (R3). Respects
  reduced-motion; IBM Plex chart defaults.
- Tokens: soft --radius-xl (20px), --success-bright for dots/rings/charts.
  Staggered 'rise' reveal keyframe. Global toast in the app shell.
- Overview rebuilt to the customer template: storage doughnut ring, availability
  sparkline, KPI tiles, upsell, cloud card, interactive onboarding checklist
  (Alpine), backups, modules (Lucide icons — no emoji, R9), activity feed.
- Locale-aware fixture display: Carbon isoFormat dates + Number::format sizes so
  the EN dashboard is not mixed-language (R16).
- 14 Pest tests green; R12 browser: /dashboard 0 console errors with Chart.js;
  R7 no overflow at 375/768/1280. Reviewed with Codex (R15) — clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 07:59:19 +02:00
nexxo 2b08b072fe fix(docker): stop injecting .env as real env vars (test isolation)
env_file: .env exported the dev DB/cache config as real container env vars,
which overrode phpunit's forced test env — so the Pest suite ran RefreshDatabase
against the dev MariaDB and wiped it on every run. Laravel already reads .env
from the bind mount; only vite needs VITE_HMR_HOST/VITE_PORT at process level,
now injected explicitly. Tests now use sqlite :memory: and never touch dev data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:28:51 +02:00
nexxo 254a7d46a0 feat(portal): Fortify auth + Login/2FA/Dashboard + component kit
Backend (Fortify):
- laravel/fortify with TOTP two-factor + recovery codes; User uses
  TwoFactorAuthenticatable; 2FA credentials hidden from serialization.
- Views off — pages are full-page class-based Livewire components (R1/R2);
  Fortify handles POST actions. Home redirect -> /dashboard. v1 scope: login +
  2FA only (no public register/reset/passkeys). Seeder gated to local/testing.

Component kit (Blade, token-based, a11y):
- button, input, checkbox, alert, card, badge, stat-tile, otp-input (Alpine,
  auto-advance/paste, -safe submit), progress-stepper, nav-item, icon
  (Lucide), plus layouts/portal-app app-shell (sidebar drawer + topbar + menu).

Screens (localized DE/EN, R16):
- Login (form -> login.store), Two-factor challenge (OTP + recovery fallback),
  Dashboard (KPI stat tiles, instance card, provisioning stepper fixtures,
  activity). Routes English (R13).

Tests + verification:
- Pest: 14 green (login ok/invalid/throttle, dashboard guard, component render).
- R12 browser (Puppeteer, prod assets): /, /login, /two-factor-challenge and
  the authenticated /dashboard all HTTP 200 with ZERO console errors; login
  flow verified end-to-end.
- Test isolation fixed (force test env over injected .env).
- Reviewed with Codex (R15): 4 rounds, all findings fixed, final pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:20:25 +02:00
nexxo 53c2a12d6d feat(portal): design foundation — Tailwind v3, tokens, self-hosted fonts
- Downgrade Tailwind v4 -> v3 (user decision): postcss.config.js,
  tailwind.config.js mapping framework-neutral CSS-var tokens onto utilities.
- portal-tokens.css: light enterprise palette, single orange accent, IBM Plex
  type scale, radius/shadow/motion/focus (design handoff §6). AA-safe accent
  text/fill tokens (accent-active/-press/-text) — #f97316 alone fails AA.
- Self-hosted IBM Plex Sans+Mono via @fontsource, Vite-bundled (R14, no CDN).
- app.css: v3 layers, base type, uniform :focus-visible, reduced-motion.
- layouts/portal.blade.php base layout; welcome page retokenised (guarded
  login CTA, DE/EN via lang/common) — no v4-only classes.
- Reviewed with Codex (R15): 5 rounds, all findings fixed, final pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:43:01 +02:00
583 changed files with 61115 additions and 608 deletions

View File

@ -63,23 +63,150 @@ REVERB_SERVER_PORT=8080
# browser-side (baked into built assets): the address a browser can reach
VITE_REVERB_APP_KEY=change-me-app-key
VITE_REVERB_HOST=localhost
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
VITE_APP_NAME=CluPilot
# Vite HMR host the browser connects to (dev). Set to the reachable VM address
# when developing against a remote host; localhost for local-only.
VITE_HMR_HOST=localhost
# ── Mail (dev → log) ─────────────────────────────────────────────────────
# ── Mail ─────────────────────────────────────────────────────────────────
# DEV: `log` writes mails to storage/logs (nothing is sent).
# PROD: switch to smtp and fill in the credentials below.
MAIL_MAILER=log
MAIL_FROM_ADDRESS=hello@clupilot.local
MAIL_FROM_NAME=CluPilot
# MAIL_MAILER=smtp
# MAIL_HOST=smtp.your-provider.tld
# MAIL_PORT=587
# MAIL_USERNAME=
# MAIL_PASSWORD=
# MAIL_SCHEME=smtp # smtp | smtps (465)
# ═════════════════════════════════════════════════════════════════════════
# CluPilot operations — fill these in to run real provisioning.
# Everything below is OPTIONAL for local dev: left blank, the services are
# no-ops/fakes and the app + test suite still run. Only real infrastructure
# work (host onboarding, customer provisioning) needs them.
# ═════════════════════════════════════════════════════════════════════════
# ── Stripe (payments) ────────────────────────────────────────────────────
# WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe).
# Outside local/testing a missing secret makes the webhook fail closed.
STRIPE_WEBHOOK_SECRET=
# Not read yet — needed once the Checkout flow is built (currently the portal
# only records a purchase intent):
STRIPE_KEY=
STRIPE_SECRET=
# ── DNS (Hetzner DNS API) ────────────────────────────────────────────────
# Creates <subdomain>.<zone> A-records for each customer instance.
HETZNER_DNS_TOKEN=
CLUPILOT_DNS_ZONE=clupilot.com
# ── Traefik (reverse proxy + TLS) ────────────────────────────────────────
# Directory ON THE PROXMOX HOST where CluPilot writes dynamic route files.
TRAEFIK_DYNAMIC_PATH=/etc/traefik/dynamic
# ── WireGuard hub (this CluPilot VM) ─────────────────────────────────────
# Hosts join this hub during onboarding; CluPilot reaches them over the tunnel.
CLUPILOT_WG_SUBNET=10.66.0.0/24
CLUPILOT_WG_HUB_IP=10.66.0.1
CLUPILOT_WG_ENDPOINT= # public host:port peers dial, e.g. vpn.clupilot.com:51820
CLUPILOT_WG_HUB_PUBKEY= # `wg pubkey < /etc/wireguard/privatekey`
CLUPILOT_WG_CONFIG_PATH=/etc/wireguard/wg0.conf
# ── SSH identity for host onboarding ─────────────────────────────────────
# Deployed to each fresh server after the one-time root password login.
# PREFERRED: point at files (a multi-line PEM cannot live in .env).
# ssh-keygen -t ed25519 -N '' -C clupilot -f storage/app/ssh/clupilot
CLUPILOT_SSH_PUBLIC_KEY_PATH=
CLUPILOT_SSH_PRIVATE_KEY_PATH=
# Alternative (single-line values only):
CLUPILOT_SSH_PUBLIC_KEY=
CLUPILOT_SSH_PRIVATE_KEY=
CLUPILOT_SSH_COMMAND_TIMEOUT=2000
# ── Monitoring ───────────────────────────────────────────────────────────
# The built-in client speaks a GENERIC REST API:
# GET/POST /monitors, GET/DELETE /monitors/{id}
# For Uptime Kuma use the bundled bridge below (Kuma's own REST API is
# read-only — monitor CRUD goes through Socket.IO):
# MONITORING_API_URL=http://kuma-bridge:8080
# Leave blank to disable monitoring entirely: provisioning records a stable
# breadcrumb and its own health checks (occ status, TLS, admin user) still
# gate acceptance.
MONITORING_API_URL=
MONITORING_API_TOKEN=
# ── Uptime Kuma (über die mitgelieferte Bridge) ───────────────────────────
# Kuma kann Monitore nur über Socket.IO anlegen, daher die Bridge:
# docker compose --profile monitoring up -d --build kuma-bridge
# Danach hier MONITORING_API_URL auf die Bridge zeigen lassen:
# MONITORING_API_URL=http://kuma-bridge:8080
# MONITORING_API_TOKEN ist gleichzeitig das Bridge-Token (frei wählbar, lang).
KUMA_URL=
KUMA_USERNAME=
KUMA_PASSWORD=
KUMA_TOTP=
# Monitoring-Verhalten bei Ausfall:
# MONITORING_REQUIRED=false -> Bereitstellung läuft weiter (Warn-Event), Standard
# MONITORING_REQUIRED=true -> Lauf schlägt fehl, wenn Monitoring nicht erreichbar
MONITORING_REQUIRED=false
MONITORING_ATTEMPTS=2
# ── Admin-Konsole: erlaubte Hostnamen ────────────────────────────────────
# Die Proxmox-Hosts UND das Admin-Dashboard dürfen nie öffentlich erreichbar
# sein. Primär regelt das dein Reverse Proxy (nur www/app/ws/api öffentlich);
# dies ist die zweite Verteidigungslinie IN der App: auf jedem anderen Host
# antwortet /admin mit 404 (nicht 403 — verrät nicht, dass es die Konsole gibt).
# Komma-getrennt. LEER = keine Einschränkung (aktueller Dev-Stand).
# Beispiel: # Schluessel fuer gespeicherte VPN-Konfigurationen (32 Byte, base64).
# Bewusst getrennt von APP_KEY. Leer = Speichern deaktiviert.
# Erzeugen: head -c 32 /dev/urandom | base64
VPN_CONFIG_KEY=
# Netze, die als "wir" gelten, solange die Website versteckt ist
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
# Umsatzsteuersatz des Verkäufers in Prozent. Preise in der Konfiguration
# sind NETTO; die Oberfläche weist beides aus.
CLUPILOT_TAX_PERCENT=20
ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1
# ── Nextcloud blueprint template ─────────────────────────────────────────
# NOT an env var: set the Proxmox template VMID per plan in
# config/provisioning.php → plans.*.template_vmid (default 9000).
# ── docker-compose knobs (host-side) ─────────────────────────────────────
HOST_UID=1000
HOST_GID=1000
APP_PORT=80
# Behind a reverse proxy — which is every production install — these must stay
# on loopback: Docker publishes ports ahead of UFW, so anything on 0.0.0.0 is
# reachable from the internet regardless of the firewall, and reaching a backend
# directly skips every hostname and address rule the proxy enforces.
# For local development without a proxy, set APP_PORT=80.
APP_PORT=127.0.0.1:8080
# Vite dev server (HMR). Off by default: over the HTTPS domains the
# browser cannot load assets from http://<ip>:5173. Set true only when
# working over http://10.10.90.185, then run: docker compose up -d app
VITE_AUTOSTART=false
VITE_PORT=5173
REVERB_HOST_PORT=8080
REVERB_HOST_PORT=127.0.0.1:8081
DB_HOST_PORT=3306
# ── CI (Gitea Actions) ──────────────────────────────────────────────────────
# Registrierungs-Token aus Gitea: Repo → Settings → Actions → Runners →
# "Create new runner". Danach: docker compose --profile ci up -d runner
GITEA_INSTANCE_URL=https://git.bave.dev
GITEA_RUNNER_TOKEN=
GITEA_RUNNER_NAME=clupilot-local
# Passend zur Server-Version halten (1.20 verträgt keinen aktuellen Runner).
GITEA_RUNNER_VERSION=0.2.6
# Zone in der Zeiten ANGEZEIGT werden. Gespeichert wird immer UTC.
APP_DISPLAY_TIMEZONE=Europe/Vienna

118
.gitea/workflows/tests.yml Normal file
View File

@ -0,0 +1,118 @@
# CluPilot CI — runs on Gitea Actions (GitHub-Actions syntax, own runner).
#
# Deliberately not mirrored to GitHub: this repository describes how our
# infrastructure is provisioned, and the same workflow runs at home. If we ever
# move, this file goes along unchanged.
name: tests
on:
push:
branches: [main, 'feat/**']
pull_request:
jobs:
pest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- name: Set up PHP
# Pinned: the floating v2 tag now requires node24, which the runner
# matching Gitea 1.20 cannot execute. 2.34.1 is the newest that still
# declares node20.
uses: shivammathur/setup-php@2.34.1
with:
php-version: '8.4'
extensions: mbstring, pdo_sqlite, sodium, redis, bcmath, gd, zip
coverage: none
- name: Install PHP dependencies
# Source clones, not dist archives: dist downloads go through GitHub's
# API, which throttles anonymous callers and left a half-installed
# vendor/ behind — the tests then failed with 500s that had nothing to
# do with the code. Cloning is slower and does not need anyone's quota.
# Swap back to --prefer-dist once a GitHub token is configured.
run: composer install --no-interaction --prefer-source --no-progress
- name: Prepare environment
run: |
cp .env.example .env
php artisan key:generate
- name: Tests
# phpunit.xml pins its own sqlite/array drivers, so no services needed.
run: ./vendor/bin/pest --colors=always
assets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- uses: actions/setup-node@v4.1.0
with:
node-version: '22'
- name: Install JS dependencies
run: npm ci --no-fund --no-audit
# A build failure here is what used to surface as "the design is broken"
# only after deploying — catch it before it ships.
- name: Build assets
run: npm run build
release:
# Only a green run produces the tag the console offers as an update.
needs: [pest, assets]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Tag this commit as tested
run: |
tag="tested-$(date -u +%Y%m%d-%H%M)-$(git rev-parse --short HEAD)"
git tag "$tag"
git push origin "$tag"
# A release is cut by editing VERSION and merging it — nothing else. The
# tag is created here, only after the suite is green, and only if it does
# not already exist. Never moved: servers are pinned to these, and a tag
# that changes underneath them means two machines claiming one version.
- name: Tag the release when VERSION changed
run: |
version="$(tr -d ' \n\r' < VERSION)"
# Anchored, because a `case` glob does not anchor: `[0-9]*.[0-9]*`
# happily accepts 1x.2y.3garbage and would tag it.
printf '%s' "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' \
|| { echo "VERSION is not MAJOR.MINOR.PATCH: '$version'" >&2; exit 1; }
# Only the commit that RAISED the version is the release. Without
# this, any later green push finds the tag missing and claims it —
# and the tag would then name code the bump never described. Runs
# overlap and finish out of order; that is enough for it to happen.
previous="$(git show 'HEAD^:VERSION' 2>/dev/null | tr -d ' \n\r' || true)"
if [ "$previous" = "$version" ]; then
echo "::notice::VERSION is unchanged ($version) — nothing to release."
exit 0
fi
tag="v${version}"
git fetch --tags --force origin
if existing="$(git rev-parse -q --verify "refs/tags/${tag}^{commit}")"; then
# Already released. The invariant is that the tag points at the
# commit VERSION was raised in — if a later commit still carries
# that number, that is fine, but the tag must not be re-pointed.
if [ "$existing" != "$(git rev-parse HEAD)" ]; then
echo "::notice::${tag} already exists at ${existing}; leaving it alone."
fi
exit 0
fi
git config user.name "CluPilot CI"
git config user.email "ci@clupilot.local"
git tag -a "$tag" -m "CluPilot ${version}"
git push origin "$tag"
echo "::notice::Released ${tag}"

4
.gitignore vendored
View File

@ -28,3 +28,7 @@ Thumbs.db
# Gitea push token (parent home dir; never track)
.env.gitea
clupilot.env
# SDD-Arbeitsverzeichnis (Ledger, Briefs, Review-Pakete) — Kladde, nicht Quelltext.
.superpowers/

152
CLAUDE.md Normal file
View File

@ -0,0 +1,152 @@
# CluPilot — verbindliche Regeln (Repo-Teil)
Die **Vollfassung der Regeln R1R17 liegt beim Nutzer**, nicht im Repo. Die
Kurzfassung steht in `docs/handoffs/2026-07-25-clupilot-state-handoff.md` §9.
Bei Konflikt: **STOP & fragen.**
Diese Datei hält die Regeln fest, die aus konkreten Fehlern im laufenden Betrieb
entstanden sind — sie sind nicht verhandelbar und werden per Test erzwungen.
---
## R18 — Icon-Größe und Zeilenumbruch
**Ein Icon steht neben seinem Text, nie darüber, und nie größer als bestellt.**
Verboten:
1. **Icon zwingt den Text auf eine zweite Zeile.** Ein Navigationseintrag, ein
Button, ein Tabellen-Action ist **einzeilig**. Zwei Zeilen sind nur erlaubt,
wenn der Text selbst bewusst zweizeilig gesetzt ist (Label + Unterzeile) —
dann steht das Icon links davon, nicht darüber.
2. **Icon größer als die Zeile, in der es sitzt.** Standard ist `size-5` (20px)
in der Navigation, `size-4` (16px) in Buttons, Tabellen und Fließtext.
Größer nur, wenn es als eigenständiges Element gemeint ist (Leerzustand,
Statusplakette).
### Warum das zweimal schiefging
- **Zeilenumbruch.** Tailwinds Preflight setzt `svg { display: block }`. Ein
Icon in einem *inline*-Elternteil schiebt den folgenden Text damit auf die
nächste Zeile. Genau so wurde aus dem Eintrag „Zugangsdaten" ein doppelt so
hoher Kasten mit Schloss oben und Wort darunter.
- **Größe.** `.size-4` und `.size-5` haben **dieselbe Spezifität**, also
entscheidet die Reihenfolge im Stylesheet — und Tailwind gibt `.size-4` *vor*
`.size-5` aus. Eine Komponente, die `size-5` bedingungslos mitmergt, überstimmt
damit **jedes** `class="size-4"` am Aufrufort. Alle so geschriebenen Icons
liefen still auf 20px.
### Wie es jetzt gebaut ist
- `resources/views/components/ui/icon.blade.php` setzt seine Standardgröße
**nur**, wenn der Aufrufort keine `size-`/`w-`/`h-`-Klasse mitgibt, und
rendert `inline-block shrink-0 align-middle` statt des Preflight-`block`.
- `resources/views/components/ui/nav-item.blade.php` legt Label und Icon in eine
eigene Flex-Zeile, damit ein Icon auch im falschen Slot daneben landet.
- Icons in `<x-ui.nav-item>` gehören in `<x-slot:icon>`, nicht in den
Default-Slot.
### Erzwungen durch
`tests/Feature/IconLayoutTest.php` — Größe des Aufruforts gewinnt, Icon bleibt
`inline-block`, Nav-Eintrag bleibt einzeilig aus beiden Slots, und kein
Blade-File im Repo darf ein Icon am Icon-Slot vorbeischmuggeln.
---
## R19 — Zeitzone: gespeichert in UTC, angezeigt auf der Wanduhr
**Jede Zeit, die ein Mensch liest, geht vorher durch `->local()`.**
Verboten:
1. **Einen gespeicherten Zeitstempel direkt formatieren.** `$model->created_at->isoFormat(…)`
liefert UTC. Richtig ist `$model->created_at->local()->isoFormat(…)`.
2. **`->timezone(config('app.timezone'))`.** Das ist die *Speicher*zone und bleibt
UTC — der Aufruf sieht aus wie eine Umrechnung und ist keine.
3. **Ein `datetime-local`-Feld nur in eine Richtung behandeln.** Rein und raus
gehören zusammen: `LocalTime::toField()` und `LocalTime::fromField()`.
Ein Feld hat keine Zeitzone; es sind die Ziffern, die jemand auf der eigenen
Uhr abliest.
Ausgenommen: `diffForHumans()` ist relativ und in jeder Zone gleich.
### Warum das durchgerutscht ist
Die Konsole kündigte eine Aktualisierung „spätestens um 15:21" an, während die
Uhr 17:21 zeigte. Zwei der vierzehn betroffenen Ansichten sahen sogar behandelt
aus — sie riefen `->timezone(config('app.timezone'))`, was sich liest wie „in
Ortszeit umrechnen" und, weil diese Zone UTC ist, nichts tut. Eine Attrappe ist
schlimmer als gar kein Aufruf: sie hält den Nächsten vom Nachsehen ab.
Schlimmer als die Anzeigen waren zwei **Formulare**: Wartungsfenster und
Paketversionen füllten ihre Felder mit UTC und lasen sie als UTC zurück. Ein
eingetragenes „21:00" wurde zu 23:00 Ortszeit.
Und die Tests deckten es nicht auf, weil sie den erwarteten Wert **mit demselben
falschen Aufruf** bildeten. Ein Test, der die Implementierung nachrechnet, prüft
nichts.
### Wie es jetzt gebaut ist
- `config('app.display_timezone')` (`APP_DISPLAY_TIMEZONE`, Vorgabe
`Europe/Vienna`) getrennt von `app.timezone`, das UTC bleibt.
- `->local()` als Carbon-Makro in `AppServiceProvider`. Es **kopiert** vor dem
Umstellen: `Illuminate\Support\Carbon` ist mutabel, sonst schriebe das bloße
Anzeigen das Modellattribut um. Beide Klassen bekommen denselben Rumpf —
Carbon führt Makros in *einer* globalen Tabelle, die zweite Registrierung
ersetzt die erste für alle.
- `App\Support\LocalTime` hält beide Feldrichtungen nebeneinander, damit niemand
eine ändert, ohne die andere zu sehen.
### Erzwungen durch
`tests/Feature/DisplayTimezoneTest.php` — kein Blade und kein Livewire-Bauteil
darf absolut formatieren ohne `->local()`, die UTC-Attrappe ist verboten,
Speicherzone bleibt UTC, Sommer- **und** Winterzeit werden geprüft, `->local()`
verändert das Original nicht, und der Feld-Round-Trip kommt als derselbe
Zeitpunkt zurück.
---
## R20 — Bearbeiten passiert im Modal, nie in der Zeile
**Sobald etwas Eingabefelder hat, geht ein Modal auf.**
Verboten:
1. **Inline-Bearbeitung in einer Tabellenzeile.** Kein `<input>`, kein
`<textarea>` in einem `<td>`. Die Zeile wächst, die Spalten daneben springen,
und eine halb im Bearbeitungsmodus stehende Tabelle liest sich wie ein
Darstellungsfehler, nicht wie ein Formular.
2. **Ein Bearbeiten-Knopf, der eine Methode am Seiten-Bauteil aufruft.** Er
schickt `openModal` — alles andere ist der Inline-Editor unter neuem Namen.
Nicht betroffen: Formulare, die *die Seite sind* — Anlegen-Formulare, die
Einstellungsseite, die Einladen-Zeile über einer Tabelle. Die bearbeiten keinen
bestehenden Datensatz an Ort und Stelle.
Ausnahmen, die kein Modal brauchen: ein einzelnes `<select>` oder eine
Checkbox in der Zeile (Rolle umstellen, aktiv schalten). Ein Klick, ein Wert,
keine Höhenänderung.
### Warum das aufgeschrieben wurde
Das Projekt hatte das Modal längst — `EditDatacenter`, mit genau dieser
Begründung im Kopfkommentar („avoids the row-height jump of inline editing").
Die Benutzertabelle hat es einfach nicht benutzt, und ich habe die
Bearbeitung inline gebaut, obwohl das Muster danebenlag.
### Wie es jetzt gebaut ist
- `App\Livewire\EditSeat` als `ModalComponent`, geöffnet über
`$dispatch('openModal', { component: 'edit-seat', arguments: { uuid } })`.
- Ein Modal ist **ohne** die Route-Middleware der Seite erreichbar. Es löst
deshalb den Kunden selbst auf und liest den Datensatz neu, statt einer vom
Browser hydrierten Eigenschaft zu glauben.
### Erzwungen durch
`tests/Feature/EditInModalTest.php` — kein Seiten-Blade darf ein Eingabefeld in
einem `<td>` wachsen lassen, und die Benutzertabelle muss `edit-seat` per
`openModal` öffnen.

1
VERSION Normal file
View File

@ -0,0 +1 @@
1.0.0

View File

@ -0,0 +1,459 @@
<?php
namespace App\Actions;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* What Stripe tells us about the billing cycle, applied to the contract.
*
* The division of labour: Stripe owns the money retries, dunning, off-session
* SCA, invoice numbering and we own capability, because Stripe does not know
* how big the VM should be. So these handlers move period boundaries and status,
* and write the register. They never re-derive an amount: the invoice is the
* authority for what was charged, and recomputing it here from our own
* catalogue would produce a second, disagreeing answer.
*
* Every handler is idempotent. Stripe retries a webhook until it gets a 2xx,
* and it also sends the same event to several endpoints so "already applied"
* is the normal case, not the exception.
*/
class ApplyStripeBillingEvent
{
/**
* Precedence when two events share a second highest wins.
*
* Ordered by how much the event settles: a payment that went through is the
* last word, a snapshot of the subscription is a running picture, and a
* failed attempt is the least final of the three, since a retry may already
* have succeeded.
*/
private const RANK_PAID = 3;
private const RANK_UPDATED = 2;
private const RANK_FAILED = 1;
public function __construct(private RecordCommercialEvent $record) {}
/**
* A renewal was paid. Move the period on and enter it in the register.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaid(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$reason = (string) ($invoice['billing_reason'] ?? '');
// The checkout's own invoice is not a renewal — it is the purchase, and
// OpenSubscription has already entered it. Recording it again here
// would double every customer's first payment in the register.
if ($reason === 'subscription_create') {
return null;
}
// Only a cycle renewal moves the term on. Stripe also sends paid
// invoices for prorations and for charges raised by hand, and those are
// money received without being the start of a new month — calling them
// renewals would push the period forward on a top-up.
$isRenewal = $reason === 'subscription_cycle';
$invoiceId = (string) ($invoice['id'] ?? '');
[$start, $end] = $this->period($invoice);
// A contract that has ended stays ended (mutateInOrder refuses one), so
// a final invoice arriving after the deletion cannot hand a departed
// customer their service back. The payment is still entered in the
// register below; it did happen.
if ($isRenewal && $start !== null && $end !== null) {
$this->mutateInOrder($subscription, $eventAt, self::RANK_PAID, fn (Subscription $fresh) => $end
// Never backwards: a renewal delayed behind the next one would
// otherwise shorten the term the customer has already paid for.
->greaterThanOrEqualTo($fresh->current_period_end)
// Period boundaries are not part of the frozen snapshot:
// what the customer is owed does not change, only which
// month it is.
? [
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
'stripe_status' => 'active',
]
: null);
}
// One invoice, one entry — enforced by the unique index rather than by
// a check, because Stripe can deliver the same invoice twice at once
// and both deliveries would pass a check.
$event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID;
return $this->recordOnce(
fn () => ($this->record)(
event: $event,
subscription: $subscription->refresh(),
netCents: $isRenewal ? $subscription->price_cents : 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
// The exact kind, kept so a proration or a manual charge stays
// distinguishable from an ordinary renewal.
extra: ['billing_reason' => $reason ?: null],
// Stripe's total is what was actually taken, tax and all. Ours
// is what was agreed; the register states both.
chargedGrossCents: isset($invoice['amount_paid']) ? (int) $invoice['amount_paid'] : null,
eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null,
)
);
}
/**
* A renewal failed. Recorded, and nothing else: Stripe runs the dunning
* schedule, and cutting a customer off on the first failed attempt would
* punish an expired card as though it were a refusal to pay.
*
* @param array<string, mixed> $invoice
*/
public function invoicePaymentFailed(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($invoice['subscription'] ?? null);
if ($subscription === null) {
return null;
}
$invoiceId = (string) ($invoice['id'] ?? '');
// Only if this is the newest word we have. A failed attempt delayed
// behind the successful retry would otherwise flip a customer who has
// since paid back to past_due.
$this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']);
return $this->recordOnce(
fn () => ($this->record)(
event: SubscriptionRecord::EVENT_PAYMENT_FAILED,
subscription: $subscription->refresh(),
netCents: 0,
stripe: [
'invoice' => $invoiceId ?: null,
'subscription' => $subscription->stripe_subscription_id,
],
extra: ['attempt' => $invoice['attempt_count'] ?? null],
chargedGrossCents: 0,
// Per attempt, not per invoice: Stripe retries a failing
// payment on a schedule, and each attempt is its own event.
eventKey: $invoiceId !== ''
? 'payment_failed:'.$invoiceId.':'.($invoice['attempt_count'] ?? 0)
: null,
)
);
}
/**
* Stripe's own status changed. Theirs is the authority on whether the money
* is arriving; we copy it, and leave what the customer may USE to our own
* status, which only a cancellation or an operator changes.
*
* @param array<string, mixed> $object
*/
public function subscriptionUpdated(array $object, ?Carbon $eventAt = null): ?Subscription
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
[$start, $end] = $this->period($object);
// Ended is ended, whatever order events arrive in — and an older
// snapshot must never overwrite a newer one it was delivered behind.
$this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([
'stripe_status' => $object['status'] ?? null,
'current_period_start' => $start,
'current_period_end' => $end,
], fn ($value) => $value !== null));
return $subscription;
}
/**
* The subscription has ended at Stripe. That is the end of the contract,
* so it goes in the register a cancellation nobody recorded is exactly
* the kind of gap the register exists to prevent.
*
* @param array<string, mixed> $object
*/
public function subscriptionDeleted(array $object, ?Carbon $eventAt = null): ?SubscriptionRecord
{
$subscription = $this->resolve($object['id'] ?? null);
if ($subscription === null) {
return null;
}
$endedAt = isset($object['ended_at'])
? Carbon::createFromTimestamp((int) $object['ended_at'])
: now();
// The ending and its entry in the register commit together. Marking the
// contract cancelled first and failing before the record would leave a
// retry seeing "already cancelled" and returning — and the end of a
// contract would never be entered at all.
return $this->recordOnce(fn () => DB::transaction(function () use ($subscription, $object, $endedAt, $eventAt) {
// Held while we decide, so a renewal arriving at the same moment
// cannot reactivate the contract between this and the write.
$subscription = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->firstOrFail();
// No ordering guard: an ending is final, so a late delivery of it
// is still correct. Only the running picture can go stale.
$subscription->update([
'status' => 'cancelled',
'stripe_status' => $object['status'] ?? 'canceled',
'cancelled_at' => $endedAt,
'stripe_event_at' => $eventAt,
]);
return ($this->record)(
event: SubscriptionRecord::EVENT_CANCELLATION,
subscription: $subscription->refresh(),
netCents: 0,
at: $endedAt,
stripe: ['subscription' => $subscription->stripe_subscription_id],
chargedGrossCents: 0,
// A contract ends once. Two deliveries arriving together would
// both pass a status check; only one can take this key.
eventKey: 'cancellation:'.$subscription->id,
);
}));
}
/**
* Whether this event is the newest word we have about the contract.
*
* Stripe does not guarantee delivery order, and the state here is a
* running picture rather than a log: applying a stale snapshot on top of a
* fresher one is how a paid-up customer ends up looking overdue. The
* register is unaffected each entry is keyed by what it is about, so a
* late arrival still lands once, in its proper place.
*/
/**
* Decide and write in one step, with the contract's row held.
*
* The ordering guard is only worth as much as its atomicity: two
* deliveries for the same contract can both read a stale copy, both pass
* the check, and the loser then overwrites the winner a renewal
* reactivating a contract a deletion had just ended, or an old failure
* burying a payment that went through. So the row is re-read under a lock
* and re-judged inside the transaction that writes it.
*
* `$changes` returns the columns to set, or null to decline.
*
* @param callable(Subscription): ?array<string, mixed> $changes
*/
private function mutateInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank, callable $changes): void
{
DB::transaction(function () use ($subscription, $eventAt, $rank, $changes) {
$fresh = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first();
if ($fresh === null
|| $fresh->status === 'cancelled'
|| ! $this->appliesInOrder($fresh, $eventAt, $rank)) {
return;
}
$values = $changes($fresh);
if ($values === null) {
return;
}
if ($eventAt !== null) {
$values['stripe_event_at'] = $eventAt;
$values['stripe_event_rank'] = $rank;
}
$fresh->update($values);
});
$subscription->refresh();
}
private function appliesInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank): bool
{
if ($eventAt === null || $subscription->stripe_event_at === null) {
return true; // nothing to compare against
}
if ($eventAt->greaterThan($subscription->stripe_event_at)) {
return true;
}
if ($eventAt->lessThan($subscription->stripe_event_at)) {
return false;
}
// Same second, which Stripe's one-second timestamps make common enough
// to matter: a failed attempt and the retry that succeeded can share
// one. Decide by what the event says about the money.
return $rank >= (int) ($subscription->stripe_event_rank ?? 0);
}
/**
* Write a register entry, unless one already exists for this invoice and
* event.
*
* The uniqueness lives in the database, on (stripe_invoice_id, event).
* Checking first and inserting afterwards leaves a window that Stripe
* delivering the same invoice twice at once walks straight through and a
* register that counts a payment twice is worse than one that is late.
*
* @param callable(): SubscriptionRecord $write
*/
private function recordOnce(callable $write): ?SubscriptionRecord
{
try {
return $write();
} catch (UniqueConstraintViolationException) {
return null; // already recorded by a concurrent delivery
}
}
/**
* Hold an event whose contract does not exist yet, so it is not lost.
*
* @param array<string, mixed> $event
*/
public function hold(array $event): void
{
$subscriptionId = $event['data']['object']['subscription']
?? $event['data']['object']['id']
?? null;
if (! is_string($subscriptionId) || ! is_string($event['id'] ?? null)) {
return;
}
// Only what we could not match. A handler returns null for several
// reasons — already recorded, or deliberately skipped, like every
// checkout's own invoice — and holding those would fill the table with
// rows that replayHeldFor() can never revisit, because their contract
// is right there. Nothing to wait for means nothing to hold.
if (Subscription::query()->where('stripe_subscription_id', $subscriptionId)->exists()) {
// Unless it appeared just now: the contract can be created between
// the handler missing it and this check, and the replay that would
// have collected it has then already been and gone. Apply it here
// instead of dropping it. Safe to repeat — the handlers are
// idempotent, so an event that was a no-op stays one.
$this->dispatch($event);
return;
}
StripePendingEvent::query()->updateOrCreate(
['stripe_event_id' => $event['id']],
[
'stripe_subscription_id' => $subscriptionId,
'type' => (string) ($event['type'] ?? ''),
'raised_at' => isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null,
'payload' => $event,
],
);
}
/**
* Replay everything held for a contract that has just appeared.
*
* In their original order, so the ordering guard sees them as Stripe raised
* them rather than as they happened to be stored.
*/
public function replayHeldFor(Subscription $subscription): int
{
if ($subscription->stripe_subscription_id === null) {
return 0;
}
$held = StripePendingEvent::query()
->where('stripe_subscription_id', $subscription->stripe_subscription_id)
->orderBy('raised_at')
->orderBy('id')
->get();
foreach ($held as $pending) {
$this->dispatch($pending->payload ?? []);
$pending->delete();
}
return $held->count();
}
/**
* Route one event to its handler. Returns false for a type we do not
* handle, so the caller can tell "not ours" from "nothing to do".
*
* @param array<string, mixed> $event
*/
public function dispatch(array $event): mixed
{
$object = $event['data']['object'] ?? [];
$raisedAt = isset($event['created']) && is_numeric($event['created'])
? Carbon::createFromTimestamp((int) $event['created'])
: null;
return match ($event['type'] ?? '') {
'invoice.paid' => $this->invoicePaid($object, $raisedAt),
'invoice.payment_failed' => $this->invoicePaymentFailed($object, $raisedAt),
'customer.subscription.updated' => $this->subscriptionUpdated($object, $raisedAt),
'customer.subscription.deleted' => $this->subscriptionDeleted($object, $raisedAt),
default => false,
};
}
/**
* Find the contract a Stripe event is about.
*
* An unknown id is logged and dropped rather than raised: Stripe delivers
* events for objects created by hand in the dashboard, and by other
* environments pointed at the same endpoint. Failing the webhook for those
* would have Stripe retry something that can never succeed.
*/
private function resolve(mixed $stripeSubscriptionId): ?Subscription
{
$id = is_string($stripeSubscriptionId) ? $stripeSubscriptionId : null;
if ($id === null) {
return null;
}
return Subscription::query()->where('stripe_subscription_id', $id)->first();
}
/**
* @param array<string, mixed> $object
* @return array{0: ?Carbon, 1: ?Carbon}
*/
private function period(array $object): array
{
$start = $object['period_start'] ?? $object['current_period_start'] ?? null;
$end = $object['period_end'] ?? $object['current_period_end'] ?? null;
return [
is_numeric($start) ? Carbon::createFromTimestamp((int) $start) : null,
is_numeric($end) ? Carbon::createFromTimestamp((int) $end) : null,
];
}
}

132
app/Actions/BookAddon.php Normal file
View File

@ -0,0 +1,132 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\SubscriptionRecord;
use App\Services\Billing\AddonCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Books a module onto a contract at today's price, and freezes it there.
*
* Booking is the moment the module's price stops moving for this customer, for
* exactly the reason the plan's price does: they agreed to a figure. What they
* have NOT booked stays on the live catalogue that is a sale still to be
* made, at whatever it costs now.
*/
class BookAddon
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Subscription $subscription, string $addonKey, int $quantity = 1, ?Order $order = null): SubscriptionAddon
{
$price = app(AddonCatalogue::class)->priceCents($addonKey);
if ($price === null) {
throw new RuntimeException("Unknown add-on: {$addonKey}");
}
if ($quantity < 1) {
throw new RuntimeException('An add-on is booked at least once.');
}
try {
return $this->book($subscription, $addonKey, $quantity, $order, $price);
} catch (UniqueConstraintViolationException) {
// A concurrent retry won. The unique index on (order_id, addon_key)
// is what actually enforces "one order books one module" — the
// lookup below is only the fast path, and two transactions can both
// pass it.
return SubscriptionAddon::query()
->where('order_id', $order?->id)
->where('addon_key', $addonKey)
->firstOrFail();
}
}
private function book(Subscription $subscription, string $addonKey, int $quantity, ?Order $order, int $price): SubscriptionAddon
{
// The booking and its entry in the register commit together: if the
// record could fail afterwards, retrying the same order would find the
// add-on already there and skip the event for good.
return DB::transaction(function () use ($subscription, $addonKey, $quantity, $order, $price) {
// Idempotent against a retried webhook: one order books one module.
if ($order !== null) {
$existing = SubscriptionAddon::query()
->where('order_id', $order->id)
->where('addon_key', $addonKey)
->first();
if ($existing !== null) {
return $existing;
}
}
$addon = SubscriptionAddon::create([
'subscription_id' => $subscription->id,
'order_id' => $order?->id,
'addon_key' => $addonKey,
'price_cents' => $price,
'currency' => Subscription::catalogueCurrency(),
'quantity' => $quantity,
'booked_at' => now(),
]);
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_BOOKED,
subscription: $subscription,
netCents: $addon->monthlyCents(),
extra: ['addon' => ['key' => $addonKey, 'quantity' => $quantity, 'price_cents' => $price]],
order: $order,
stripe: ['event' => $order?->stripe_event_id],
// What was actually charged for the module, on the same terms
// as a plan purchase: a discount or a free booking has to be
// reconcilable, not reconstructed from the catalogue.
chargedGrossCents: $order?->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $addon;
});
}
/**
* Stop charging for a module, without losing what it cost.
*
* Cancelled, not deleted: what a customer was paying, and until when, is
* part of the same record as what they bought.
*/
public function cancel(SubscriptionAddon $addon): SubscriptionAddon
{
return DB::transaction(function () use ($addon) {
// Claim the cancellation conditionally, so two requests arriving
// together write one event between them. Checking `isActive()` on
// separate instances and updating afterwards lets both through, and
// the register would show a module cancelled twice.
$claimed = SubscriptionAddon::query()
->whereKey($addon->getKey())
->whereNull('cancelled_at')
->update(['cancelled_at' => now(), 'updated_at' => now()]);
if ($claimed === 0) {
return $addon->refresh();
}
$addon->refresh();
($this->record)(
event: SubscriptionRecord::EVENT_ADDON_CANCELLED,
subscription: $addon->subscription,
netCents: -$addon->monthlyCents(),
extra: ['addon' => ['key' => $addon->addon_key, 'quantity' => $addon->quantity]],
order: $addon->order,
);
return $addon;
});
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Actions\Fortify;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
// Never let public signup claim an existing (possibly not-yet-
// provisioned) customer's email — ensureUser() would later link
// that account and hand over the customer's portal.
Rule::unique(Customer::class, 'email'),
],
'password' => $this->passwordRules(),
])->validate();
// Create the user and its linked customer atomically — a public signup is
// a customer, and the portal (Billing::purchase, Settings, …) needs one.
// Either both exist or neither, so a failure never orphans a user.
return DB::transaction(function () use ($input) {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
Customer::query()->create([
'user_id' => $user->id,
'name' => $input['name'],
'email' => $input['email'],
'locale' => app()->getLocale(),
'status' => 'active',
]);
return $user;
});
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Opens the contract a paid order bought.
*
* This is the moment the catalogue stops applying. Everything the customer is
* owed price, quotas, seats, the hardware behind the plan is copied onto
* the subscription here and never read from the catalogue again. Provisioning
* sizes the machine from this row, so an operator editing a plan afterwards
* cannot reach a customer who has already paid.
*
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
* against deliberately not `Order::amount_cents`, which holds the GROSS total
* Stripe actually charged. The two can legitimately differ (VAT, and later
* coupons), and reconciling them is not this action's job: the proof register
* records what was charged per event, and Stripe's invoice is the authority for
* the amount. Copying a gross total into this net field would silently corrupt
* every pro-rata calculation that reads it.
*/
class OpenSubscription
{
public function __construct(private RecordCommercialEvent $record) {}
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
{
// A retried webhook must not open a second contract for one purchase.
$existing = Subscription::query()->where('order_id', $order->id)->first();
if ($existing !== null) {
return $existing;
}
$start = now();
// The contract and its entry in the register commit together. If the
// record could fail after the subscription is in, the next webhook
// retry would find the contract, return early, and leave a paid sale
// permanently missing from the evidence.
return DB::transaction(fn () => $this->open($order, $term, $start));
}
private function open(Order $order, string $term, Carbon $start): Subscription
{
$subscription = Subscription::create(array_merge(
// The version the order carries, when the checkout recorded one:
// what the customer saw beats what happens to be on sale by the
// time their payment reaches us.
Subscription::snapshotFrom($order->plan, $term, $order->plan_version_id),
[
'customer_id' => $order->customer_id,
'order_id' => $order->id,
// Carried from the checkout: from here on, Stripe's events
// name this and nothing else.
'stripe_subscription_id' => $order->stripe_subscription_id,
'started_at' => $start,
'current_period_start' => $start,
'current_period_end' => $term === Subscription::TERM_YEARLY
? $start->copy()->addYear()
: $start->copy()->addMonth(),
'status' => 'active',
],
));
// The sale, entered in the register the moment it happens. Written from
// the contract that was just frozen, so the evidence and the contract
// cannot describe different purchases.
($this->record)(
event: SubscriptionRecord::EVENT_PURCHASE,
subscription: $subscription,
netCents: $subscription->price_cents,
at: $start,
stripe: ['event' => $order->stripe_event_id],
// The order carries what Stripe actually took — gross, including
// whatever tax or discount applied on the day. The contract price
// is what was agreed; this is what was paid, and the register has
// to be able to state both.
//
// Keyed on the Stripe id, not on the amount being non-zero: a fully
// discounted checkout charges zero, and reading that as "no amount
// recorded" would file a free sale as though it had been paid for
// in full. An order without a Stripe id was never charged through
// a checkout at all, and has nothing to report.
chargedGrossCents: $order->stripe_event_id !== null ? (int) $order->amount_cents : null,
);
return $subscription;
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace App\Actions;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\TaxTreatment;
use Illuminate\Support\Carbon;
/**
* Writes one row into the proof register.
*
* Every commercial event goes through here so that the flat columns are filled
* the same way each time an evidence table where the amount means one thing
* in one row and another in the next is not evidence.
*
* Amounts are stated three ways on purpose. Net is what the catalogue and the
* contract deal in; gross is what was actually charged; the tax between them
* depends on the customer and on the day, and recomputing it later from a rate
* that has since changed would produce a different answer to the one on the
* invoice.
*/
class RecordCommercialEvent
{
/**
* @param array<string, mixed> $extra merged into the JSON snapshot
* @param array<string, string|null> $stripe event / invoice / subscription ids
*/
public function __invoke(
string $event,
Subscription $subscription,
int $netCents,
?Carbon $at = null,
array $extra = [],
array $stripe = [],
?int $chargedGrossCents = null,
?Order $order = null,
?string $eventKey = null,
): SubscriptionRecord {
$at ??= now();
$customer = $subscription->customer;
$tax = TaxTreatment::for($customer);
$agreedNet = $netCents;
$expectedGross = $tax->grossCents($agreedNet);
// The flat columns describe the TRANSACTION, not the contract: gross is
// what was taken from the customer, and net and tax are that gross
// split by the rate that applied on the day.
//
// Split, not subtracted from the agreed price. A discount lowers the
// taxable amount; it does not create negative VAT, and recording
// 14900 charged against a 179,00 contract as "minus 30,00 tax" would
// make the register wrong in exactly the case someone audits.
if ($chargedGrossCents !== null) {
$gross = $chargedGrossCents;
$net = (int) round($gross / (1 + $tax->rate));
} else {
$net = $agreedNet;
$gross = $expectedGross;
}
$version = $subscription->planVersion;
return SubscriptionRecord::create([
'event' => $event,
// What makes this event "the same one" if it arrives again. Unique
// where set, so a duplicate delivery collides in the database
// rather than in a check that two of them can both pass.
'event_key' => $eventKey,
'customer_id' => $subscription->customer_id,
'subscription_id' => $subscription->id,
// The order this event belongs to — a booked module has its own,
// and pointing it at the original plan purchase would file every
// add-on under the wrong transaction.
'order_id' => $order?->id ?? $subscription->order_id,
'plan_version_id' => $subscription->plan_version_id,
// Copied rather than joined: these have to still answer the question
// after the customer, the plan or the version have gone.
'customer_name' => $customer?->name,
'plan_key' => $subscription->plan,
'plan_version' => $version?->version,
'term' => $subscription->term,
'net_cents' => $net,
'tax_cents' => $gross - $net,
'gross_cents' => $gross,
'currency' => $subscription->currency,
'tax_rate' => round($tax->rate * 100, 2),
'reverse_charge' => $tax->reverseCharge,
'stripe_event_id' => $stripe['event'] ?? null,
'stripe_invoice_id' => $stripe['invoice'] ?? null,
'stripe_subscription_id' => $stripe['subscription'] ?? null,
'occurred_at' => $at,
'snapshot_version' => SubscriptionRecord::SNAPSHOT_VERSION,
// The long tail: everything nobody has thought to ask about yet.
// The flat columns above are what gets queried.
'snapshot' => array_merge([
'subscription' => $subscription->only(Subscription::FROZEN),
'plan' => [
'key' => $subscription->plan,
'version' => $version?->version,
'version_id' => $subscription->plan_version_id,
'family' => $version?->family?->name,
'capabilities' => $version?->capabilities(),
],
'period' => [
'start' => $subscription->current_period_start?->toIso8601String(),
'end' => $subscription->current_period_end?->toIso8601String(),
],
'addons' => $subscription->addons()->active()->get()
->map(fn ($addon) => [
'key' => $addon->addon_key,
'price_cents' => $addon->price_cents,
'quantity' => $addon->quantity,
'booked_at' => $addon->booked_at?->toIso8601String(),
])->all(),
'customer' => [
'name' => $customer?->name,
'email' => $customer?->email,
'vat_id' => $customer?->vat_id,
],
// Kept even when they agree. A charge that differs from the
// catalogue plus tax is exactly the thing someone will ask
// about later, and reconciling it silently would erase the
// question along with the answer.
'amounts' => [
// What was agreed, beside what was actually taken. The
// flat columns state the transaction; this states whether
// it came out at the contract price, which is the question
// someone asks a year later.
'agreed_net_cents' => $agreedNet,
'expected_gross_cents' => $expectedGross,
'charged_gross_cents' => $gross,
'matches_catalogue' => $gross === $expectedGross,
],
], $extra),
]);
}
}

View File

@ -0,0 +1,230 @@
<?php
namespace App\Actions;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Turns a paid Stripe event into a customer + order + provisioning run. The
* order's unique stripe_event_id is the idempotency key: a duplicate webhook
* never starts a second run.
*/
class StartCustomerProvisioning
{
public function __construct(
private OpenSubscription $openSubscription,
private ApplyStripeBillingEvent $billing,
) {}
/**
* @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string} $event
*/
public function fromStripeEvent(array $event): ?Order
{
$existing = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($existing !== null) {
$this->resume($existing);
return null;
}
$customer = $this->resolveCustomer($event);
$customer->ensureUser(); // portal login (also enables admin impersonation)
try {
[$order, $run] = DB::transaction(function () use ($event, $customer) {
$order = Order::create([
'customer_id' => $customer->id,
'plan' => $event['plan'],
// Set once Stripe checkout carries it (phase 5). Null means
// "whatever is on sale when the webhook lands", which is
// what this did before the column existed.
'plan_version_id' => $event['plan_version_id'] ?? null,
'amount_cents' => $event['amount_cents'],
'currency' => $event['currency'],
'datacenter' => $event['datacenter'],
'stripe_event_id' => $event['id'],
'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null,
'status' => 'paid',
]);
$run = ProvisioningRun::create([
'subject_type' => Order::class,
'subject_id' => $order->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
// Snapshot resolved branding so retries apply identical inputs
// (unset customer → CluPilot defaults, resolved once here).
'context' => ['branding' => $customer->brandingResolved()],
]);
return [$order, $run];
});
} catch (UniqueConstraintViolationException) {
// A concurrent delivery won the race. Both requests will answer
// Stripe with a 2xx, so this is the last look anyone takes at this
// payment — if the winner then dies before opening the contract, no
// retry is coming to fix it. So finish its work rather than just
// stepping aside. The unique index on subscriptions.order_id keeps
// both from opening one.
$winner = Order::query()->where('stripe_event_id', $event['id'])->first();
if ($winner !== null) {
$this->resume($winner);
}
return null;
}
$this->openContract($order);
AdvanceRunJob::dispatch($run->uuid); // after commit
return $order;
}
/**
* Finish what a crash interrupted.
*
* The order commits before the contract is opened, so that a failure there
* can never erase the record of a payment but that leaves a window in
* which a paid order exists with no contract and nothing running. Stripe
* retries until it gets a 2xx, so a retry is exactly the chance to close
* that window. Simply returning "already processed" would strand a paying
* customer permanently, because no later webhook would ever look again.
*
* Safe to run on every duplicate: opening a contract is idempotent. A run
* that was merely never dispatched needs no nudge from here the scheduler
* tick sweeps pending runs. A run that already ran and FAILED for want of
* the contract does, because nothing sweeps failed runs.
*/
private function resume(Order $order): void
{
if ($order->subscription()->exists()) {
return;
}
$this->openContract($order);
if ($order->subscription()->exists()) {
$this->reviveRunStrandedWithoutAContract($order);
}
}
/**
* Restart a run that failed only because the contract was missing.
*
* Narrow on purpose. `no_subscription` is returned before anything is built
* the run never got past validating the order or reserving resources so
* there is nothing half-made to trip over. A run that failed for any other
* reason failed at something a contract does not fix, and restarting it
* would just repeat whatever went wrong.
*/
private function reviveRunStrandedWithoutAContract(Order $order): void
{
$run = $order->runs()->where('pipeline', 'customer')->latest('id')->first();
if ($run?->status !== ProvisioningRun::STATUS_FAILED
|| ! str_contains((string) $run->error, 'no_subscription')) {
return;
}
// onProvisioningFailed() marked the order failed on the way down.
$order->update(['status' => 'paid']);
$run->update([
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'attempt' => 0,
'next_attempt_at' => null,
'error' => null,
]);
AdvanceRunJob::dispatch($run->uuid);
Log::info('Restarted a run that was stranded without a contract.', [
'order_id' => $order->id, 'run' => $run->uuid,
]);
}
/**
* Freeze what they bought, while the catalogue still says what they were
* shown.
*
* Deliberately AFTER the order is committed, and deliberately swallowing
* its failure. The order is the record that money changed hands: if opening
* the contract could roll it back, an unsellable plan, a missing price or
* any unforeseen fault would erase the evidence of a payment we have
* already taken and Stripe, seeing no 2xx, would retry a webhook that can
* never succeed. A missing contract is recoverable and loud: the run stops
* at ValidateOrder with `no_subscription`, in the operator's face.
*
* Two cases are expected to land here: a plan the catalogue cannot sell,
* and a payment in a currency it cannot price freezing a EUR price onto a
* CHF payment would put the contract and the payment in permanent
* disagreement.
*/
private function openContract(Order $order): void
{
try {
// A checkout that recorded its version is owed THAT version, even
// if the window has closed or the plan has been withdrawn since —
// they paid for what they were shown. Only a purchase with no
// recorded version has to ask what is on sale now.
$deliverable = $order->plan_version_id !== null
? app(PlanCatalogue::class)->isDeliverable($order->plan, $order->plan_version_id)
: Subscription::knowsPlan($order->plan);
$sellable = $deliverable
&& strtoupper((string) $order->currency) === Subscription::catalogueCurrency();
if (! $sellable) {
Log::warning('No contract opened: the catalogue cannot sell this order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'currency' => $order->currency,
]);
return;
}
$subscription = ($this->openSubscription)($order);
// Anything Stripe sent about this contract before it existed —
// a renewal, a failure, a cancellation — applied now, in the order
// they were raised.
$this->billing->replayHeldFor($subscription);
} catch (Throwable $e) {
Log::error('Failed to open a contract for a paid order.', [
'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(),
]);
}
}
/**
* Find or create the customer, race-safe against the unique email index so a
* concurrent first-time purchase can't create two customers for one email.
*
* @param array{email:string,name:?string,stripe_customer_id:?string} $event
*/
private function resolveCustomer(array $event): Customer
{
try {
return Customer::query()->firstOrCreate(
['email' => $event['email']],
['name' => $event['name'] ?? $event['email'], 'stripe_customer_id' => $event['stripe_customer_id'] ?? null],
);
} catch (UniqueConstraintViolationException) {
return Customer::query()->where('email', $event['email'])->firstOrFail();
}
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Actions;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
/**
* Registers a fresh host and kicks off its onboarding run. The one-time root
* password lives encrypted in the run context and is scrubbed after SSH trust
* is established.
*/
class StartHostOnboarding
{
/**
* @param array{name: string, datacenter: string, public_ip: string, root_password: string} $input
*/
public function run(array $input): Host
{
// Host + run are created atomically; a partial insert would otherwise
// leave a permanently pending host with no run.
[$host, $run] = DB::transaction(function () use ($input) {
$host = Host::create([
'name' => $input['name'],
'datacenter' => $input['datacenter'],
'public_ip' => $input['public_ip'],
'status' => 'pending',
]);
$run = ProvisioningRun::create([
'subject_type' => Host::class,
'subject_id' => $host->id,
'pipeline' => 'host',
'status' => ProvisioningRun::STATUS_PENDING,
'current_step' => 0,
'context' => ['root_password' => Crypt::encryptString($input['root_password'])],
]);
return [$host, $run];
});
AdvanceRunJob::dispatch($run->uuid); // after commit — the run definitely exists
return $host;
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use Illuminate\Console\Command;
/**
* Tells the owner whether the catalogue is in a state that can actually sell.
*
* Everything here is computed at read time, which means a broken window or a
* missing price does not announce itself until a customer hits it. This is the
* thing to run after editing plans before finding out from a failed checkout.
*/
class CheckPlanCatalogue extends Command
{
protected $signature = 'plans:check';
protected $description = 'Check the plan catalogue for overlaps, gaps and missing prices';
public function handle(): int
{
$currency = Subscription::catalogueCurrency();
$problems = [];
$now = now();
$families = PlanFamily::query()->with('versions.prices')->orderBy('tier')->get();
if ($families->isEmpty()) {
$this->error('The catalogue is empty. Nothing can be sold.');
return self::FAILURE;
}
foreach ($families as $family) {
$live = $family->versions->filter(fn (PlanVersion $v) => $v->isAvailableAt($now));
if ($live->count() > 1) {
$problems[] = "{$family->key}: {$live->count()} versions on sale at once (".
$live->pluck('version')->implode(', ').') — overlapping windows.';
}
if ($family->sales_enabled && $live->isEmpty()) {
$problems[] = "{$family->key}: on sale, but no version is available right now.";
}
foreach ($family->versions as $version) {
if (! $version->isPublished()) {
continue;
}
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
$priced = $version->prices
->first(fn ($p) => $p->term === $term && $p->currency === $currency);
if ($priced === null) {
$problems[] = "{$family->key} v{$version->version}: no {$term} price in {$currency}.";
}
}
}
}
// A contract that cannot say which version it was sold under has lost
// its provenance, which is the one thing the version table is for.
$orphaned = Subscription::query()->whereNull('plan_version_id')->count();
if ($orphaned > 0) {
$problems[] = "{$orphaned} subscription(s) have no plan version recorded.";
}
foreach ($families as $family) {
$state = $family->sales_enabled ? 'on sale' : 'withdrawn';
$version = $family->versions->first(fn (PlanVersion $v) => $v->isAvailableAt($now));
$this->line(sprintf(
' %-12s tier %d %-10s %s',
$family->key,
$family->tier,
$state,
$version !== null ? "v{$version->version}" : '—',
));
}
if ($problems === []) {
$this->newLine();
$this->info('Catalogue is consistent.');
return self::SUCCESS;
}
$this->newLine();
foreach ($problems as $problem) {
$this->error(' '.$problem);
}
return self::FAILURE;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Support\Settings;
use Illuminate\Console\Command;
/**
* The way back in when the console has locked its owner out.
*
* The allowlist is managed in the console which is fine until the address you
* manage it from changes, and then the page that would fix the problem is the
* page the problem blocks. Every gate needs a door that does not depend on
* itself, and on a server that door is a shell.
*/
class ConsoleAccess extends Command
{
protected $signature = 'clupilot:console-access
{action=show : show|allow|deny|open|close|caddy}
{value? : an address or CIDR, for allow and deny}';
protected $description = 'Show or change who may reach the operator console';
public function handle(): int
{
$action = (string) $this->argument('action');
$value = trim((string) $this->argument('value'));
return match ($action) {
'show' => $this->show(),
'allow' => $this->allow($value),
'deny' => $this->deny($value),
'open' => $this->setRestricted(false),
'close' => $this->setRestricted(true),
'caddy' => $this->caddy(),
default => $this->refuse("Unknown action: {$action}"),
};
}
/**
* The allowlist as a Caddy matcher, for the reverse proxy to import.
*
* The proxy has its own allowlist, hard-coded, and it runs FIRST so
* everything the owner adds in the console has no effect whatsoever, and
* the console's own access page is a decoration. Worse, when the owner's
* address changes they are turned away by the proxy before the application
* they could have fixed it in is ever reached.
*
* Emitting the matcher from the same list the console manages makes the
* console the single authority. The agent on the host writes this out and
* reloads the proxy.
*/
private function caddy(): int
{
// Always the allowlist — never 0.0.0.0/0, not even when the console's
// own restriction is switched off.
//
// The owner's rule is absolute: the console is reachable over the
// management VPN, or from an address they have listed, and from nowhere
// else. Letting the console's "open" switch also open the PROXY would
// put the console on the public internet with one click, which is
// exactly the state that rule exists to prevent. Switching it off
// relaxes the application's own check; the proxy keeps its list.
$ranges = RestrictConsoleNetwork::allowedRanges();
// Never empty: an empty remote_ip matcher matches NOTHING in Caddy, and
// the console would become unreachable from anywhere at all — including
// from the place someone would fix it. Loopback always survives, so a
// shell on the box is always a way back.
if ($ranges === []) {
$ranges = ['127.0.0.1', '::1'];
}
$this->line('# Generated from the console allowlist — do not edit by hand.');
$this->line('# Regenerated by deploy/update-agent.sh; edit it in the console.');
$this->line('@allowed remote_ip '.implode(' ', $ranges));
return self::SUCCESS;
}
private function show(): int
{
$this->line(' restricted : '.(RestrictConsoleNetwork::isRestricted() ? 'yes' : 'no — anyone reaching the hostname gets in'));
$this->line(' always : '.implode(', ', (array) config('admin_access.trusted_ranges', [])).' (VPN, not removable)');
$own = (array) Settings::get('console.allowed_ips', []);
$this->line(' additional : '.($own === [] ? '(none)' : implode(', ', $own)));
return self::SUCCESS;
}
private function allow(string $value): int
{
if ($value === '') {
return $this->refuse('Give an address or range: clupilot:console-access allow 203.0.113.7');
}
// An entry that matches nothing would be stored, reported as success,
// and leave whoever is recovering still locked out.
if (! RestrictConsoleNetwork::isNetwork($value)) {
return $this->refuse("Not an address or a range: {$value}");
}
$list = (array) Settings::get('console.allowed_ips', []);
if (! in_array($value, $list, true)) {
$list[] = $value;
Settings::set('console.allowed_ips', array_values($list));
}
$this->info("{$value} may now reach the console.");
return $this->show();
}
private function deny(string $value): int
{
Settings::set('console.allowed_ips', array_values(array_filter(
(array) Settings::get('console.allowed_ips', []),
fn ($entry) => $entry !== $value,
)));
$this->info("{$value} removed.");
return $this->show();
}
private function setRestricted(bool $on): int
{
// No lock-out check here on purpose: this command IS the recovery path,
// and it is only reachable by someone who already has the server.
Settings::set('console.network_restricted', $on);
$this->info($on ? 'Console restricted to the VPN and the listed addresses.' : 'Restriction lifted.');
return $this->show();
}
private function refuse(string $message): int
{
$this->error($message);
return self::FAILURE;
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\Support\Facades\Validator;
use Spatie\Permission\PermissionRegistrar;
/**
* Creates (or promotes) an operator with the Owner role the account the
* installer sets up so a fresh server is usable immediately.
*
* Promotion is deliberate: running this twice with the same address should fix
* a lost role, not fail with "email already taken".
*/
class CreateAdmin extends Command
{
protected $signature = 'clupilot:create-admin
{--email= : Login address}
{--name= : Display name}
{--password= : Leave empty to be prompted}';
protected $description = 'Create or promote an operator account with the Owner role';
public function handle(): int
{
$email = $this->option('email') ?: $this->ask('Email');
$name = $this->option('name') ?: $this->ask('Name', 'Administrator');
$password = $this->option('password') ?: $this->secret('Password');
$validator = Validator::make(
['email' => $email, 'name' => $name, 'password' => $password],
[
'email' => 'required|email|max:255',
'name' => 'required|string|max:255',
'password' => ['required', Password::min(12)],
],
);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return self::FAILURE;
}
// An operator address that also belongs to a customer would block that
// customer from ever getting a portal login.
if (Customer::query()->where('email', $email)->exists()) {
$this->error('That address already belongs to a customer.');
return self::FAILURE;
}
$user = User::query()->firstOrNew(['email' => $email]);
$existed = $user->exists;
$user->fill([
'name' => $name,
'password' => Hash::make($password),
'is_admin' => true,
'email_verified_at' => $user->email_verified_at ?? now(),
])->save();
$user->syncRoles(['Owner']);
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->info($existed
? "Existing account {$email} promoted to Owner and password reset."
: "Owner account {$email} created.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace App\Console\Commands;
use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Services\Stripe\StripeClient;
use Illuminate\Console\Command;
/**
* Mirrors our catalogue into Stripe: a Product per plan family, a Price per
* priced row.
*
* Stripe owns the recurring billing retries, dunning, off-session SCA,
* invoice numbering so it needs to know what it is billing for. It does not
* need to know how big the VM is, and it is not asked.
*
* Idempotent by construction: a row that already carries an id is skipped. That
* matters more here than usual, because a Stripe Price cannot be edited, so a
* second run that minted duplicates would leave two live prices for one plan
* and no way to tell which a customer is on.
*
* Only PUBLISHED versions are synced. A draft has promised nothing, and a
* Product for it would be a price list entry for something that may never
* exist.
*/
class SyncStripeCatalogue extends Command
{
protected $signature = 'stripe:sync-catalogue {--dry-run : Show what would be created without touching Stripe}';
protected $description = 'Create the Stripe products and prices for the plan catalogue';
public function handle(StripeClient $stripe): int
{
$dryRun = (bool) $this->option('dry-run');
if (! $dryRun && ! $stripe->isConfigured()) {
$this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.');
return self::FAILURE;
}
$created = 0;
foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) {
$published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished());
// A family whose versions are all drafts has promised nothing, so
// it has no business appearing in Stripe's price list yet.
if ($published->isEmpty()) {
continue;
}
$productId = $family->stripe_product_id;
if ($productId === null) {
$this->line(" product {$family->key}{$family->name}");
$created++;
if (! $dryRun) {
$productId = $stripe->createProduct(
$family->name,
['plan_family' => $family->key, 'plan_family_id' => (string) $family->id],
// Keyed on our row, so a crash between Stripe creating
// the product and us storing its id gives back the same
// product on the next run rather than a second one.
idempotencyKey: "clupilot-product-{$family->id}",
);
$family->update(['stripe_product_id' => $productId]);
}
}
foreach ($published as $version) {
foreach ($version->prices as $price) {
if ($price->stripe_price_id !== null) {
continue;
}
$this->line(sprintf(
' price %s v%d %s %d %s',
$family->key, $version->version, $price->term, $price->amount_cents, $price->currency,
));
$created++;
if ($dryRun || $productId === null) {
continue;
}
$priceId = $stripe->createPrice(
productId: $productId,
amountCents: $price->amount_cents,
currency: $price->currency,
interval: $price->term === 'yearly' ? 'year' : 'month',
metadata: [
'plan_family' => $family->key,
'plan_version' => (string) $version->version,
'plan_version_id' => (string) $version->id,
'plan_price_id' => (string) $price->id,
],
idempotencyKey: "clupilot-price-{$price->id}",
);
// Written straight through the query builder: stripe_price_id
// is not part of what publication froze, and the model would
// otherwise have to be re-read first.
PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]);
}
}
}
$this->newLine();
if ($created === 0) {
$this->info('Stripe is already in step with the catalogue.');
return self::SUCCESS;
}
$this->info($dryRun
? "{$created} object(s) would be created. Run without --dry-run to create them."
: "{$created} object(s) created in Stripe.");
return self::SUCCESS;
}
/** Versions whose prices are live in Stripe, for the status line. */
public static function syncedVersions(): int
{
return PlanVersion::query()
->whereNotNull('published_at')
->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id'))
->count();
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
/**
* Scheduler tick (every minute): dispatch an advance job for every run that is
* due. Immediate dispatch on advance handles the fast path; this catches
* waiting/retrying runs and anything a crashed worker left behind.
*
* PENDING counts as left behind. A run is created and dispatched in two steps,
* and a process that dies between them leaves a paid customer with a run that
* nothing will ever pick up. Re-dispatching one that is merely fresh is free
* the runner takes a per-run lock and the second job returns immediately.
*/
class TickProvisioning
{
/**
* How long a run may sit pending before it counts as stranded.
*
* Long enough that an ordinary queue backlog is not mistaken for a crash:
* sweeping a run whose first job is merely still queued would start a
* second chain of continuations alongside the first, and both would keep
* dispatching for the rest of the pipeline.
*/
private const STRANDED_AFTER_MINUTES = 5;
public function __invoke(): void
{
ProvisioningRun::query()
->where(function ($q) {
$q
->whereIn('status', [ProvisioningRun::STATUS_RUNNING, ProvisioningRun::STATUS_WAITING])
->orWhere(fn ($stranded) => $stranded
->where('status', ProvisioningRun::STATUS_PENDING)
->where('created_at', '<=', now()->subMinutes(self::STRANDED_AFTER_MINUTES)));
})
->where(function ($q) {
$q->whereNull('next_attempt_at')->orWhere('next_attempt_at', '<=', now());
})
->get()
->each(fn (ProvisioningRun $run) => AdvanceRunJob::dispatch($run->uuid));
}
}

View File

@ -2,7 +2,9 @@
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
//
use AuthorizesRequests;
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
/**
* Admin impersonation: an admin can log in as a customer's portal account to
* inspect their portal during an incident, then return to the admin session.
*/
class ImpersonationController extends Controller
{
/** Admin-gated: become the customer. */
public function start(Customer $customer): RedirectResponse
{
$this->authorize('customers.impersonate');
$user = $customer->ensureUser();
session(['impersonator_id' => Auth::id()]);
Auth::login($user);
return redirect()->route('dashboard');
}
/** Return to the admin session (available while impersonating). */
public function leave(): RedirectResponse
{
$adminId = session()->pull('impersonator_id');
if ($adminId !== null && ($admin = User::query()->find($adminId)) !== null) {
Auth::login($admin);
return redirect()->route('admin.overview');
}
return redirect()->route('dashboard');
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* The public price sheet.
*
* Prices, storage and seat counts are read from the catalogue rather than
* written into the page. They were hard-coded here once, and the page and the
* catalogue had already drifted apart on three of four plans a visitor was
* quoted 249 for a plan that charged 399 at checkout. The marketing site is
* a reader of the catalogue like every other caller.
*
* The catalogue fails loudly by design: an empty or overlapping catalogue is an
* outage for commerce, not something to paper over. But a public website is not
* commerce a mistyped availability window must not take the company's front
* page down with it. So the failure is caught HERE and only here: the page
* still renders, the price sheet is replaced by "on request", and nobody is
* shown a number the checkout would not honour.
*/
class LandingController extends Controller
{
/** Presentation that belongs to the marketing page, not to the catalogue. */
private const COPY = [
'start' => ['audience' => 'Für kleine Teams', 'note' => 'Einstieg für Büros, die heute noch Dateien per Mail schicken.'],
'team' => ['audience' => 'Für wachsende Teams', 'note' => 'Der Regelfall — Einschulung des gesamten Teams inklusive.'],
'business' => ['audience' => 'Für dokumentenlastige Betriebe', 'note' => 'Mehr Speicher, längere Aufbewahrung, bevorzugte Reaktionszeit.'],
'enterprise' => ['audience' => 'Individuell für Ihr Unternehmen', 'note' => 'Umfang, Einrichtung und Reaktionszeiten nach Vereinbarung.'],
];
/** Catalogue feature keys, in the words a customer uses. */
private const FEATURES = [
'managed_updates' => 'Updates & Wartung',
'daily_backups' => 'Tägliche Sicherung',
'monitoring' => 'Überwachung rund um die Uhr',
'subdomain' => 'Adresse auf clupilot.cloud',
'custom_domain' => 'Eigene Domain',
'office' => 'Office im Browser',
'branding' => 'Ihr Logo & Ihre Farben',
'priority_support' => 'Bevorzugter Support',
'premium_sla' => 'Vereinbarte Reaktionszeiten',
'extended_retention' => 'Verlängerte Aufbewahrung',
'audit_log' => 'Protokollierung der Zugriffe',
'onboarding' => 'Begleitete Einführung',
];
/** The plan carrying the recommendation mark. */
private const RECOMMENDED = 'team';
public function __invoke(): View
{
$plans = $this->plans();
return view('landing', [
'plans' => $plans,
'featureRows' => $this->featureRows($plans),
'recommended' => self::RECOMMENDED,
]);
}
/**
* Every feature any sellable plan carries, in the catalogue's narrative
* order the row headings of the price sheet.
*
* Built from what is on sale rather than from the full list, so a feature
* no current plan offers does not appear as an empty row.
*
* @param array<int, array<string, mixed>> $plans
* @return array<int, string>
*/
private function featureRows(array $plans): array
{
$offered = array_merge(...array_column($plans, 'features')) ?: [];
return array_values(array_filter(
self::FEATURES,
fn (string $label) => in_array($label, $offered, true),
));
}
/**
* @return array<int, array<string, mixed>> empty when the catalogue cannot be read
*/
private function plans(): array
{
try {
$sellable = app(PlanCatalogue::class)->sellable();
} catch (Throwable $e) {
// Deliberately swallowed: see the class docblock. Logged at error
// level because a shop that cannot list its plans is not selling.
Log::error('Landing page could not read the plan catalogue', ['exception' => $e]);
return [];
}
$plans = [];
foreach ($sellable as $key => $plan) {
$plans[] = [
'key' => $key,
'name' => $plan['name'],
'audience' => self::COPY[$key]['audience'] ?? '',
'note' => self::COPY[$key]['note'] ?? '',
'price' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
'storage' => $this->storage((int) $plan['quota_gb']),
'traffic' => $this->storage((int) $plan['traffic_gb']),
'seats' => (int) $plan['seats'],
'features' => $this->features($plan['features'] ?? []),
'recommended' => $key === self::RECOMMENDED,
];
}
return $plans;
}
/** Currencies we have a symbol for; anything else prints its ISO code. */
private const SYMBOLS = ['EUR' => '€', 'CHF' => 'CHF', 'USD' => '$', 'GBP' => '£'];
/**
* A price as the sheet prints it, currency included.
*
* The symbol comes from the catalogue rather than from the template: the
* currency is configurable (CLUPILOT_CURRENCY), and a page that says ""
* while the checkout charges francs is the same two-sources-of-truth
* mistake as a hard-coded amount, only harder to notice.
*
* Whole units when the amount is whole a price sheet reads "179", not
* "179,00".
*/
private function money(int $cents, string $currency): string
{
$amount = $cents % 100 === 0
? number_format($cents / 100, 0, ',', '.')
: number_format($cents / 100, 2, ',', '.');
// Non-breaking: in a narrow table column "49 €" otherwise wraps, and the
// currency ends up on a line of its own under the number.
return $amount."\u{00A0}".(self::SYMBOLS[strtoupper($currency)] ?? strtoupper($currency));
}
private function storage(int $gb): string
{
return $gb >= 1000 && $gb % 1000 === 0
? ($gb / 1000).' TB'
: $gb.' GB';
}
/**
* @param array<int, string> $keys
* @return array<int, string>
*/
private function features(array $keys): array
{
// Unknown keys are dropped rather than printed raw: a feature added to
// the catalogue without a translation would otherwise appear on the
// public page as "premium_sla".
return array_values(array_filter(array_map(
fn (string $key) => self::FEATURES[$key] ?? null,
$keys,
)));
}
}

View File

@ -0,0 +1,194 @@
<?php
namespace App\Http\Controllers;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
/**
* The public service status page.
*
* It lived at /legal/status, next to the imprint and the terms, which is a
* filing mistake: nothing about the current health of the platform is a legal
* document. It has its own address now.
*
* Every line on it is derived from a record. Where there is no signal the
* component says "not monitored" rather than "operational" a status page that
* reports green because it has nothing to look at is worse than no status page,
* because someone will believe it.
*
* Aggregate only: counts, never customer names, instance addresses or host
* names. This page is world-readable and the estate is not public information.
*/
class StatusController extends Controller
{
/** A backup older than this stops counting as current. */
private const BACKUP_STALE_AFTER_HOURS = 48;
/**
* How old a monitoring verdict may be before it stops counting.
*
* The sync job runs every five minutes; four missed runs is a monitoring
* pipeline that has stopped, and a verdict from then says nothing about now.
*/
private const MONITORING_STALE_AFTER_MINUTES = 20;
/** How far back a provisioning failure still says something about now. */
private const PROVISIONING_WINDOW_HOURS = 24;
public function __invoke(): View
{
$components = [
$this->portal(),
$this->instances(),
$this->provisioning(),
$this->backups(),
];
// The worst individual state is the state of the whole thing. Averaging
// it would let one outage disappear behind three healthy components.
$overall = match (true) {
in_array('down', array_column($components, 'state'), true) => 'down',
in_array('degraded', array_column($components, 'state'), true) => 'degraded',
in_array('unknown', array_column($components, 'state'), true) => 'unknown',
default => 'operational',
};
return view('status', [
'components' => $components,
'overall' => $overall,
'checkedAt' => Carbon::now(),
]);
}
/**
* The portal and the website.
*
* Answering this request is the measurement. There is no honest way for a
* page to report that the server serving it is down.
*
* @return array<string, mixed>
*/
private function portal(): array
{
return [
'key' => 'portal',
'state' => 'operational',
'detail' => null,
];
}
/**
* Customer instances, from what monitoring last saw.
*
* @return array<string, mixed>
*/
private function instances(): array
{
$total = Instance::query()->where('status', 'active')->count();
if ($total === 0) {
return ['key' => 'instances', 'state' => 'operational', 'detail' => null];
}
// Only targets belonging to instances that are actually in service. A
// healthy check on a decommissioned instance says nothing about a live
// one, and counting it would let coverage look complete when it is not.
$onActive = fn ($query) => $query->whereHas(
'instance',
fn ($instance) => $instance->where('status', 'active'),
);
// Only verdicts the sync job has actually refreshed. A row that has
// never been checked, or was last checked long enough ago that the
// answer means nothing, is not evidence of health — and this column was
// for a long time exactly that: written 'up' at provisioning and never
// touched again.
$fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES);
$checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh);
$watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id');
$down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id');
// Down first, then coverage. An instance nobody is watching is not a
// healthy instance — it is an unanswered question, and reporting the
// absence of a check as the absence of a problem is the one thing this
// page must never do.
return [
'key' => 'instances',
'state' => match (true) {
$down > 0 && $down >= $watched => 'down',
$down > 0 => 'degraded',
$watched < $total => 'unknown',
default => 'operational',
},
'detail' => match (true) {
$down > 0 => ['down' => $down, 'total' => $watched],
$watched < $total => ['down' => $total - $watched, 'total' => $total],
default => null,
},
];
}
/**
* Whether new instances are being delivered.
*
* @return array<string, mixed>
*/
private function provisioning(): array
{
$since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS);
$failed = ProvisioningRun::query()
->where('status', ProvisioningRun::STATUS_FAILED)
->where('updated_at', '>=', $since)
->count();
return [
'key' => 'provisioning',
'state' => $failed === 0 ? 'operational' : 'degraded',
'detail' => $failed > 0 ? ['failed' => $failed] : null,
];
}
/**
* Whether the last backup of every instance is recent.
*
* @return array<string, mixed>
*/
private function backups(): array
{
// Counted from the INSTANCES that need protecting, not from the backup
// rows that happen to exist. An active instance with no schedule at all
// has no row — so counting rows would leave it out of the arithmetic
// entirely and report the estate as protected because the backups that
// do exist are fine.
$total = Instance::query()->where('status', 'active')->count();
if ($total === 0) {
return ['key' => 'backups', 'state' => 'operational', 'detail' => null];
}
$fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS);
$protected = Instance::query()
->where('status', 'active')
->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh))
->count();
$unprotected = $total - $protected;
return [
'key' => 'backups',
'state' => match (true) {
$unprotected === 0 => 'operational',
$unprotected >= $total => 'down',
default => 'degraded',
},
'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null,
];
}
}

View File

@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers;
use App\Actions\ApplyStripeBillingEvent;
use App\Actions\StartCustomerProvisioning;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Stripe webhook: verifies the signature (when a secret is configured) and turns
* a paid checkout into a provisioning run. Idempotent via the order's
* stripe_event_id Stripe retries a webhook until it gets a 2xx.
*/
class StripeWebhookController extends Controller
{
public function __invoke(
Request $request,
StartCustomerProvisioning $action,
ApplyStripeBillingEvent $billing,
): JsonResponse {
$payload = $request->getContent();
$secret = (string) config('services.stripe.webhook_secret');
if (blank($secret)) {
// Fail closed: an unconfigured secret must not authorize provisioning
// outside local/testing.
abort_unless(app()->environment('local', 'testing'), 400, 'Stripe webhook secret not configured');
} elseif (! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) {
abort(400, 'invalid signature');
}
$event = json_decode($payload, true) ?: [];
$object = $event['data']['object'] ?? [];
$type = $event['type'] ?? '';
// The billing cycle is Stripe's: once a contract exists, they decide
// when it renews, when a payment failed, and when it has ended. Handled
// before the checkout branch because none of these are checkouts.
$applied = $billing->dispatch($event);
if ($applied !== false) {
if ($applied === null) {
// Either already applied, or about a contract that does not
// exist yet — a checkout takes a moment to become one, and
// Stripe does not deliver in order. Holding it costs a row and
// saves a cancellation we would otherwise never hear about
// again; a replay is a no-op if it was simply a duplicate.
$billing->hold($event);
}
// 2xx either way. Stripe retrying would not change the answer, and
// anything worth replaying is now held rather than dropped.
return response()->json(['handled' => $type, 'applied' => $applied !== null]);
}
// Paid triggers: a synchronous checkout (completed + paid) OR an async
// method clearing later (async_payment_succeeded). Ignore everything else,
// including the still-unpaid completed event for async methods.
$paid = ($type === 'checkout.session.completed' && ($object['payment_status'] ?? null) === 'paid')
|| $type === 'checkout.session.async_payment_succeeded';
if (! $paid) {
return response()->json(['ignored' => true]);
}
$meta = $object['metadata'] ?? [];
// A real email is required — never merge unrelated customers under a
// manufactured address or send credentials into the void.
$email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null);
if (blank($email)) {
return response()->json(['ignored' => 'no_customer_email']);
}
$action->fromStripeEvent([
// Deduplicate on the checkout session id (stable across the completed
// and async-succeeded events for one purchase), not the event id.
'id' => (string) ($object['id'] ?? $event['id'] ?? ''),
'email' => $email,
'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null),
'stripe_customer_id' => $object['customer'] ?? null,
// The handle every later billing event arrives with. Without it an
// invoice.paid cannot be matched to the contract it renews.
'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null,
'plan' => $meta['plan'] ?? 'start',
// What the customer was actually shown. Absent on a session created
// before phase 5 put it there, and then the version on sale applies.
'plan_version_id' => isset($meta['plan_version_id']) ? (int) $meta['plan_version_id'] : null,
'datacenter' => $meta['datacenter'] ?? 'fsn',
'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0),
'currency' => strtoupper($object['currency'] ?? 'eur'),
]);
return response()->json(['ok' => true]);
}
/** Stripe's default replay tolerance, in seconds. */
private const SIGNATURE_TOLERANCE = 300;
/** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */
private function signatureValid(string $payload, string $header, string $secret): bool
{
if (blank($header)) {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $segment) {
[$key, $value] = array_pad(explode('=', $segment, 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value; // keep every v1 (secret rotation sends several)
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
// Reject replays outside the tolerance window.
if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureAdmin
{
/**
* Only operators with the console.view capability may reach the admin console.
* Legacy is_admin accounts are migrated to roles by the seed_roles_and_permissions
* migration and the seeder the gate never trusts the bare is_admin flag, so
* an RBAC revocation is never silently undone here.
*/
public function handle(Request $request, Closure $next): Response
{
abort_unless((bool) $request->user()?->can('console.view'), 403);
return $next($request);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use App\Models\Customer;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Enforce customer account lifecycle on portal requests: a suspended or closed
* customer must lose access. Admins are exempt, and an active impersonation
* session is exempt so operators can still inspect a suspended customer's portal.
*/
class EnsureCustomerActive
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && ! $user->isOperator() && ! $request->session()->has('impersonator_id')) {
$customer = Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
if ($customer !== null && ($customer->status === 'suspended' || $customer->status === 'closed' || $customer->closed_at !== null)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$key = $customer->closed_at !== null || $customer->status === 'closed' ? 'auth.account_closed' : 'auth.account_suspended';
return redirect()->route('login')->withErrors(['email' => __($key)]);
}
}
return $next($request);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Hides the marketing site and the customer portal while the product is still
* being built, without hiding it from us.
*
* Switched from the console (site.public). Anyone coming through the management
* VPN, and any signed-in operator, sees the real thing; everyone else crawlers
* included gets a placeholder.
*
* Answers 503 with Retry-After and X-Robots-Tag rather than 200: a 200 would
* invite search engines to index the placeholder as the site's content, and
* getting that out of an index again is much harder than keeping it out.
*/
class PublicSiteGate
{
/**
* Paths that must keep working regardless: the console itself (otherwise the
* switch could not be flipped back), Stripe's webhook, and the health check.
*
* Livewire is deliberately NOT in this list. Its endpoint is shared by the
* console and the portal, so exempting it would leave a signed-in customer
* able to drive portal components including billing while the portal is
* supposed to be offline. Operators pass the check below anyway, which is
* what the console actually needs.
*
* Nor is /login, and that has a consequence worth knowing before you hide
* the site: /admin sends a guest to /login, which is not the console, so
* SIGNING IN while hidden needs the request to come from a trusted range
* the management VPN, or an address put in TRUSTED_RANGES for the initial
* setup. Exempting the login flow by hostname instead would mean trusting a
* Host header, which the caller chooses, and one forged header would unhide
* the entire portal.
*/
private const ALWAYS_ALLOWED = ['webhooks/*', 'up', 'robots.txt'];
public function handle(Request $request, Closure $next): Response
{
// The console is exempt wherever it currently lives. This used to be
// the literal paths 'admin' and 'admin/*', which stops matching the
// moment the console moves to the root of its own hostname — and the
// result would be 503 on the sign-in page of the console, which is the
// only place the switch can be turned back on. Asking AdminArea keeps
// the exemption attached to the console rather than to a path that
// happened to be true once.
if (AdminArea::isConsole($request) || $request->is(...self::ALWAYS_ALLOWED) || Settings::bool('site.public', true)) {
return $next($request);
}
if ($this->fromManagementNetwork($request) || $request->user()?->isOperator()) {
return $next($request);
}
return response()->view('coming-soon', [], 503)->withHeaders([
'Retry-After' => 3600,
'X-Robots-Tag' => 'noindex, nofollow, noarchive',
'Cache-Control' => 'no-store',
]);
}
private function fromManagementNetwork(Request $request): bool
{
$ranges = (array) config('admin_access.trusted_ranges', []);
return $ranges !== [] && IpUtils::checkIp((string) $request->ip(), $ranges);
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* The console answers only on its own hostname and that hostname answers
* only the console.
*
* Both halves matter. The first keeps the console off the public domains. The
* second is what actually separates the two products: binding the console
* routes to a hostname does not stop the CUSTOMER routes from answering there
* as well, because they are registered without a hostname. Without this,
* app.clupilot.com and admin.clupilot.com would still serve each other's pages
* wherever the paths did not collide.
*
* 404 in both directions, never 403: a stranger must not learn that a console
* lives here, and an operator on the wrong host should see the same nothing a
* stranger does.
*
* A handful of endpoints are shared by both sides and must answer on either
* host Livewire's own endpoints and the authentication actions. They are
* listed explicitly below, because the failure mode of getting that list wrong
* is "the console is broken" rather than "the rule is wrong", and a list you
* can read is easier to correct than a rule you have to infer.
*/
class RestrictAdminHost
{
/**
* Endpoints both the console and the portal need, on whichever host the
* caller is currently on.
*
* Livewire's component endpoint is guarded separately and more strictly:
* the console's own middleware is registered as persistent, so an action
* posted to /livewire/update is re-checked against the component's real
* route. Letting the path through here does not let anything through there.
*/
private const SHARED = [
'livewire/*',
'login',
'logout',
'two-factor-challenge',
'up',
];
public function handle(Request $request, Closure $next): Response
{
// Not host-bound (development, a fresh checkout): the console keeps its
// /admin prefix on any host and nothing is separated. Upgrading must
// not lock anyone out of a system that was working.
if (! AdminArea::isHostBound()) {
return $next($request);
}
$isConsoleRoute = $this->isConsoleRoute($request);
$onConsoleHost = AdminArea::covers($request->getHost());
// The console, reached through a hostname that is not the console's.
if ($isConsoleRoute && ! $onConsoleHost) {
abort(404);
}
// The portal or the public site, reached through the console's
// hostname. Only once exclusivity is switched on: a machine that lists
// its own IP in ADMIN_HOSTS so the console is reachable without DNS
// still has to serve the portal from that same address.
if (AdminArea::isExclusive() && $onConsoleHost && ! $isConsoleRoute && ! $request->is(...self::SHARED)) {
abort(404);
}
return $next($request);
}
/**
* Console routes are the ones named `admin.*`.
*
* By name rather than by path: the path is exactly what changes between
* host-bound and fallback mode, and a rule written against it would be
* wrong in one of them.
*/
private function isConsoleRoute(Request $request): bool
{
$name = $request->route()?->getName();
// Covers admin.* and the alternate-host registrations (admin.viaN.*),
// which are the same console reached by a recovery hostname.
return $name !== null && str_starts_with($name, 'admin.');
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace App\Http\Middleware;
use App\Support\AdminArea;
use App\Support\Settings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* Who may reach the operator console, by network address.
*
* RestrictAdminHost answers "under which NAME does the console respond" and a
* Host header is chosen by the caller, so it can never answer "who is asking".
* This one can: the client address behind a trusted proxy is not something the
* client gets to pick.
*
* The management VPN always counts. Beyond that the owner keeps a list an
* office line, a home connection so being away from the VPN does not mean
* being locked out. That list lives in the console rather than in a proxy
* config file, because the person who needs to change it is the person sitting
* in the console.
*
* 404, never 403: a stranger must not learn that a console lives here.
*
* This is not a substitute for a firewall. It runs after PHP has started, so it
* bounds who can USE the console, not who can make the server work. Where a
* network-level ACL is also possible, both belong there.
*/
class RestrictConsoleNetwork
{
public function handle(Request $request, Closure $next): Response
{
// Scoped to the console, wherever the console currently is. Asking
// AdminArea rather than testing the path: once the console moves to the
// root of its own hostname there is no `/admin` left to match, and a
// guard that silently stops matching is worse than no guard.
if (! AdminArea::isConsole($request)) {
return $next($request);
}
if (! self::isRestricted()) {
return $next($request);
}
if (! self::allows((string) $request->ip())) {
abort(404);
}
return $next($request);
}
/** Whether the owner has switched the restriction on at all. */
public static function isRestricted(): bool
{
return Settings::bool('console.network_restricted', false);
}
/**
* Every network that may reach the console.
*
* The VPN is always in it and is not removable it is the one path that
* exists independently of whatever the owner has typed into the list, and
* without it a bad entry would leave nobody able to fix the entry.
*
* @return array<int, string>
*/
public static function allowedRanges(): array
{
$vpn = (array) config('admin_access.trusted_ranges', []);
$own = (array) Settings::get('console.allowed_ips', []);
return array_values(array_unique(array_filter(array_merge(
$vpn,
array_map('trim', array_map('strval', $own)),
))));
}
public static function allows(string $ip): bool
{
return self::covers($ip, self::allowedRanges());
}
/**
* Would this address still get in, given that list?
*
* Split out so the "you are about to lock yourself out" decision can be
* made and tested without a request: it is the one check whose failure
* mode is that nobody can reach the page that would fix it.
*
* @param array<int, string> $extra the owner's list AFTER the change
*/
public static function wouldStillAllow(string $ip, array $extra): bool
{
$vpn = (array) config('admin_access.trusted_ranges', []);
return self::covers($ip, array_merge($vpn, $extra));
}
/**
* Whether a value is an address or a CIDR range at all.
*
* Shared by the console and the recovery command on purpose: an entry that
* matches nothing is stored happily and reports success, and the operator
* then switches the restriction on believing they are covered. That is the
* exact situation the recovery command exists to get out of.
*/
public static function isNetwork(string $value): bool
{
[$address, $prefix] = array_pad(explode('/', $value, 2), 2, null);
if (filter_var($address, FILTER_VALIDATE_IP) === false) {
return false;
}
if ($prefix === null) {
return true;
}
$max = str_contains((string) $address, ':') ? 128 : 32;
return ctype_digit((string) $prefix) && (int) $prefix >= 0 && (int) $prefix <= $max;
}
/** @param array<int, string> $ranges */
private static function covers(string $ip, array $ranges): bool
{
$ranges = array_values(array_filter($ranges));
return $ranges !== [] && $ip !== '' && IpUtils::checkIp($ip, $ranges);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\LoginResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Where an ordinary sign-in lands. See LandsWhereSignedIn for the decision;
* TwoFactorLoginResponse below makes the same one after a challenge.
*/
class ConsoleAwareLoginResponse implements LoginResponse
{
use LandsWhereSignedIn;
public function toResponse($request): Response
{
return $this->landing($request, new JsonResponse(['two_factor' => false], 200));
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Responses;
use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Where a sign-in completed by two-factor challenge lands.
*
* Fortify never reaches LoginResponse on this path, so binding only that one
* left every account with two-factor enabled which is the ones most likely to
* be operators landing in the customer portal.
*/
class ConsoleAwareTwoFactorLoginResponse implements TwoFactorLoginResponse
{
use LandsWhereSignedIn;
public function toResponse($request): Response
{
// Fortify answers an empty 204 here, not a body. Kept.
return $this->landing($request, new JsonResponse('', 204));
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Responses;
use App\Support\AdminArea;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Fortify;
use Symfony\Component\HttpFoundation\Response;
/**
* Where a completed sign-in lands, shared by both ways of completing one.
*
* Fortify has two exits: LoginResponse for an ordinary sign-in, and
* TwoFactorLoginResponse after a challenge. Binding only the first left every
* account with two-factor enabled on the old behaviour landing in the
* customer portal, and on a console-only host landing on a 404. Whichever exit
* is taken, the decision has to be the same one.
*/
trait LandsWhereSignedIn
{
protected function landing($request, JsonResponse $success): Response
{
// Where they were heading counts as much as where they posted from: in
// shared mode everyone signs in at /login, so the request itself never
// looks like the console even when /admin is the destination.
$onConsole = AdminArea::isConsole($request)
|| AdminArea::pointsAtConsole($request->session()?->get('url.intended'));
// Authorization first, before any branch. Answering JSON early let a
// non-operator keep the session on the console host that a browser
// request would have had taken away.
if ($onConsole && ! $request->user()?->can('console.view')) {
// Taken away at the moment it was created: the session exists as
// soon as Fortify authenticates, and a guard that merely refuses
// each page afterwards leaves it sitting in the browser.
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->wantsJson()) {
return new JsonResponse(['message' => __('auth.not_an_operator')], 403);
}
return redirect()->to(AdminArea::home())->withErrors([
Fortify::username() => __('auth.not_an_operator'),
]);
}
// Fortify answers JSON to a JSON client. Replacing that with a redirect
// would break every caller that is not a browser.
if ($request->wantsJson()) {
// The caller supplies it: Fortify answers {"two_factor":false} to an
// ordinary sign-in and an empty 204 after a challenge, and a client
// keying off the status code breaks if the two are merged.
return $success;
}
return redirect()->intended($onConsole ? AdminArea::home() : Fortify::redirects('login'));
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Illuminate\Database\QueryException;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for deleting a datacenter (R5). Guarded: a datacenter that still
* has hosts cannot be deleted it would orphan host placement so the modal
* blocks and explains instead. Otherwise it is safe to hard-delete an unused code.
*/
class ConfirmDeleteDatacenter extends ModalComponent
{
public string $uuid;
public string $name = '';
public int $hostCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->withCount('hosts')->firstOrFail();
$this->uuid = $uuid;
$this->name = $dc->name;
$this->hostCount = $dc->hosts_count;
}
public function delete()
{
$this->authorize('datacenters.manage');
$dc = Datacenter::query()->where('uuid', $this->uuid)->first();
if ($dc === null) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
// The hosts.datacenter → datacenters.code foreign key (restrictOnDelete)
// is the source of truth: the DB refuses to orphan a host. We pre-check
// for a friendly message and catch the constraint as the race backstop.
if (Host::query()->where('datacenter', $dc->code)->exists()) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
try {
$dc->delete();
} catch (QueryException) {
// A host was created for this code concurrently — leave it intact.
}
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-delete-datacenter');
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanVersion;
use App\Services\Billing\PlanCatalogue;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for discarding an unpublished plan version.
*
* Only a draft can be discarded at all a published version is what customers
* are contracted to, and the model refuses to delete one. So this is a small
* destructive action, and the modal says which one it is rather than leaving
* "delete" next to a row that might be either.
*/
class ConfirmDeletePlanDraft extends ModalComponent
{
public string $uuid;
public int $version;
public string $plan = '';
public function mount(string $uuid): void
{
// Modals are reachable without the page's guards, so this is the real
// check, not a convenience one.
$this->authorize('plans.manage');
$version = PlanVersion::query()->with('family')->where('uuid', $uuid)->firstOrFail();
abort_if($version->isPublished(), 403);
$this->uuid = $uuid;
$this->version = $version->version;
$this->plan = $version->family->name;
}
public function delete(): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $this->uuid)->first();
// The catalogue decides, in one conditional statement. Checking here and
// deleting afterwards would leave a window in which the version is
// published between the two — and a published version must never go.
if ($version !== null && ! app(PlanCatalogue::class)->discardDraft($version)) {
$this->dispatch('notify', message: __('plans.discard_too_late'));
}
$this->dispatch('plan-draft-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-plan-draft');
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for revoking a VPN access for good (R5). Deleting a peer that
* belongs to a host cuts CluPilot's own management path to that machine, so the
* modal says so plainly rather than hiding the option.
*/
class ConfirmDeleteVpnPeer extends ModalComponent
{
public string $uuid;
public string $name = '';
public ?string $hostName = null;
public function mount(string $uuid): void
{
// Modals are reachable without the route middleware, so this is the
// real guard, not a convenience check.
$peer = VpnPeer::query()->with('host')->where('uuid', $uuid)->firstOrFail();
$this->authorize('delete', $peer);
$this->uuid = $uuid;
$this->name = $peer->name;
$this->hostName = $peer->host?->name;
}
public function delete(): void
{
$peer = VpnPeer::query()->where('uuid', $this->uuid)->first();
if ($peer !== null) {
$this->authorize('delete', $peer);
// A tombstone that still carries a usable private key is a liability.
$peer->purgeSecret();
// Soft-delete, so the row survives as a tombstone until the hub has
// actually dropped the peer. A hard delete here would let the next
// sync adopt the still-present peer back as a live access —
// silently restoring what was just revoked. ApplyVpnPeer purges the
// tombstone once removal succeeded; SyncVpnPeers retries if not.
$peer->delete();
ApplyVpnPeer::dispatch($peer->public_key, null, false);
}
$this->dispatch('vpn-peer-deleted');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-delete-vpn-peer');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Provisioning\Jobs\PurgeHost;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for the destructive "remove host" action (R5). Deregisters the
* CluPilot record only it does not touch the physical server. The host is
* deactivated immediately and purged asynchronously so removal never blocks on
* (or races) an in-flight provisioning step.
*/
class ConfirmRemoveHost extends ModalComponent
{
public string $uuid;
public string $name = '';
public function mount(string $uuid): void
{
$this->uuid = $uuid;
$this->name = Host::query()->where('uuid', $uuid)->value('name') ?? '';
}
public function remove()
{
$this->authorize('hosts.manage');
$host = Host::query()->where('uuid', $this->uuid)->first();
if ($host !== null) {
// Deactivate now (out of placement, visibly removed), finalize on the
// provisioning worker which waits for the runner lock.
$host->update(['status' => 'disabled']);
PurgeHost::dispatch($host->uuid);
}
return $this->redirectRoute('admin.hosts', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-remove-host');
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.admin')]
class Customers extends Component
{
/** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */
public function toggleSuspend(string $uuid): void
{
$this->authorize('customers.manage');
$customer = Customer::query()->where('uuid', $uuid)->first();
if ($customer === null) {
return;
}
// A closed account is terminal — the suspend/reactivate toggle must not
// resurrect it to active while closed_at is still set.
if ($customer->closed_at !== null || $customer->status === 'closed') {
return;
}
$customer->update([
'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
]);
$this->dispatch('notify', message: __('admin.customer_'.($customer->status === 'suspended' ? 'suspended' : 'reactivated')));
}
public function render()
{
$locale = app()->getLocale();
$plans = app(PlanCatalogue::class)->sellable();
$customers = Customer::query()
->with(['instances.subscription'])
->orderBy('name')
->get();
$rows = $customers->map(function (Customer $c) use ($plans, $locale) {
$instance = $c->instances->sortByDesc('id')->first();
$planKey = $instance->plan ?? null;
// What this customer actually pays, off their contract — not what
// the plan costs today. Otherwise a price rise inflates reported
// revenue for every grandfathered customer overnight.
//
// Per MONTH, whatever the term: a yearly contract stores the whole
// year, and adding that to a monthly column would report twelve
// times the revenue for anyone who paid up front.
$contract = $instance?->subscription;
$priceCents = (int) ($contract?->monthlyPriceCents()
?? ($planKey !== null ? ($plans[$planKey]['price_cents'] ?? 0) : 0));
return [
'uuid' => $c->uuid,
'name' => $c->name,
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—',
// Only an instance that exists can hand out an admin login.
'instance_uuid' => ($instance?->status === 'active') ? $instance->uuid : null,
'closed' => $c->closed_at !== null || $c->status === 'closed',
'suspended' => $c->status === 'suspended',
'status' => match (true) {
$c->closed_at !== null || $c->status === 'closed' => 'closed',
$c->status === 'suspended' => 'suspended',
default => $instance->status ?? $c->status ?? 'provisioning',
},
];
})->all();
// Plan distribution for the doughnut — derived from live instances, and
// keyed by what customers are actually ON, not by what is on sale. A
// withdrawn plan still has customers, and dropping them from the chart
// would quietly understate the estate.
$labels = [];
$counts = [];
foreach (PlanFamily::query()->orderBy('tier')->pluck('key') as $planKey) {
$n = $customers->filter(fn (Customer $c) => $c->instances->contains('plan', $planKey))->count();
if ($n > 0) {
$labels[] = __('billing.plan.'.$planKey);
$counts[] = $n;
}
}
return view('livewire.admin.customers', [
'rows' => $rows,
'plansChart' => [
'type' => 'doughnut',
'data' => [
'labels' => $labels,
'datasets' => [[
'data' => $counts,
'backgroundColor' => ['token:info', 'token:accent', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
]);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Datacenters extends Component
{
#[Validate(['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'])]
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
// Country picked from config/countries.php (no manual code typos).
#[Validate('nullable|string|max:2')]
public string $location = '';
public function save(): void
{
$this->authorize('datacenters.manage');
// Normalize before validating so the unique check matches how the row is
// stored — otherwise `FSN` passes the rule but the lowercased insert collides.
$this->code = strtolower(trim($this->code));
// Country must be one of the curated list — a forged request can bypass
// the <select>, so enforce membership server-side (mirrors EditDatacenter).
$data = $this->validate([
// A datacenter code becomes a DNS label (fsn-01.node.clupilot.com),
// so it has to be one: lowercase, no underscores, no leading or
// trailing dash.
'code' => ['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'],
'name' => 'required|string|max:255',
'location' => ['nullable', Rule::in(array_keys((array) config('countries')))],
]);
Datacenter::create([
'code' => $data['code'],
'name' => $data['name'],
'location' => $data['location'] ?: null,
'active' => true,
]);
$this->reset('code', 'name', 'location');
$this->dispatch('notify', message: __('datacenters.created'));
}
public function toggle(string $uuid): void
{
$this->authorize('datacenters.manage');
$datacenter = Datacenter::query()->where('uuid', $uuid)->first();
$datacenter?->update(['active' => ! $datacenter->active]);
}
public function render()
{
return view('livewire.admin.datacenters', [
'datacenters' => Datacenter::query()->withCount('hosts')->orderBy('name')->get(),
'countries' => config('countries'),
]);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit a datacenter's name and location in a modal (avoids the row-height jump
* of inline editing). The code is immutable it is referenced by hosts. Country
* is picked from a list so it can't be mistyped.
*/
class EditDatacenter extends ModalComponent
{
public string $uuid = '';
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
public string $location = '';
/** The location present when the modal opened — a pre-curation free-form value. */
public string $originalLocation = '';
/**
* Whether new hosts and orders may still be placed here.
*
* The column and the scope existed; the form did not offer it, so a
* datacenter could be created and never switched off again the one
* lifecycle action a location actually needs.
*/
public bool $active = true;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->code = $dc->code;
$this->name = $dc->name;
$this->location = (string) $dc->location;
$this->originalLocation = (string) $dc->location;
$this->active = (bool) $dc->active;
}
public function save()
{
$this->authorize('datacenters.manage');
// Accept the configured countries plus the record's own legacy value, so a
// datacenter created before the curated list can still be edited. The
// legacy value is read from the DB — never from the client-hydrated
// property, which could be forged to whitelist an arbitrary location.
$dc = Datacenter::query()->where('uuid', $this->uuid)->firstOrFail();
$allowed = array_keys((array) config('countries'));
if ($dc->location) {
$allowed[] = $dc->location;
}
$data = $this->validate([
'name' => 'required|string|max:255',
'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas
'active' => 'boolean',
]);
// Only on the transition, and compared against what is STORED: an
// already-inactive location would otherwise announce that it had just
// been switched off every time its name was edited.
$justDeactivated = $dc->active && ! $data['active'];
$hostsLeftRunning = $justDeactivated ? $dc->hosts()->count() : 0;
Datacenter::query()->where('uuid', $this->uuid)->update([
'name' => $data['name'],
'location' => $data['location'] ?: null,
'active' => $data['active'],
]);
// One message, not two. Both go through the same toast, and the second
// replaces the first — so the generic "saved" would swallow the only
// sentence that says the hosts there are still running.
$this->dispatch('notify', message: $hostsLeftRunning > 0
? __('datacenters.deactivated_with_hosts', ['n' => $hostsLeftRunning])
: __('datacenters.updated'));
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
return view('livewire.admin.edit-datacenter', [
'countries' => config('countries'),
]);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Admin;
use App\Actions\StartHostOnboarding;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostCreate extends Component
{
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|exists:datacenters,code,active,1')]
public string $datacenter = '';
public function mount(): void
{
$this->datacenter = (string) \App\Models\Datacenter::query()->active()->orderBy('name')->value('code');
}
#[Validate('required|ip|unique:hosts,public_ip')]
public string $public_ip = '';
#[Validate('required|string|min:8')]
public string $root_password = '';
public function save(StartHostOnboarding $action)
{
$this->authorize('hosts.manage');
$data = $this->validate();
$host = $action->run($data);
return $this->redirectRoute('admin.hosts.show', ['host' => $host->uuid], navigate: true);
}
public function render()
{
return view('livewire.admin.host-create', [
'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
]);
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostDetail extends Component
{
public Host $host;
public function mount(Host $host): void
{
$this->host = $host;
}
/** Live refresh whenever any run advances (admins-only channel). */
#[On('echo-private:admin.runs,StepAdvanced')]
public function onStepAdvanced(): void
{
$this->host->refresh();
}
/** Adjust the capacity reserve (% of storage kept free for headroom). */
public function saveReserve(int $reserve): void
{
$this->authorize('hosts.manage');
$reserve = max(0, min(90, $reserve));
$this->host->update(['reserve_pct' => $reserve]);
$this->dispatch('notify', message: __('hosts.detail.reserve_saved'));
}
/**
* Drain / return a host: toggle between active and disabled. Disabled takes
* it out of placement (maintenance) without purging it distinct from the
* destructive "remove host". Never touches a host mid-onboarding.
*/
public function toggleMaintenance(): void
{
$this->authorize('hosts.manage');
if ($this->host->status === 'active') {
$this->host->update(['status' => 'disabled']);
} elseif ($this->host->status === 'disabled') {
$this->host->update(['status' => 'active']);
}
}
public function retry(): void
{
$this->authorize('hosts.manage');
$run = $this->currentRun();
if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) {
$run->update([
'status' => ProvisioningRun::STATUS_RUNNING,
'attempt' => 0,
'next_attempt_at' => now(),
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
'error' => null,
]);
$this->host->update(['status' => 'onboarding']);
AdvanceRunJob::dispatch($run->uuid);
}
}
private function currentRun(): ?ProvisioningRun
{
return $this->host->runs()->latest('id')->first();
}
/** @return array<int, array{label: string, state: string}> */
private function buildSteps(?ProvisioningRun $run): array
{
$pipeline = config('provisioning.pipelines.host', []);
$current = $run?->current_step ?? 0;
$status = $run?->status;
$steps = [];
foreach ($pipeline as $index => $class) {
if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) {
$state = 'done';
} elseif ($index === $current) {
$state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running';
} else {
$state = 'pending';
}
$steps[] = ['label' => __(app($class)->label()), 'state' => $state];
}
return $steps;
}
public function render()
{
$run = $this->currentRun();
/** @var Collection $events */
$events = $run
? $run->events()->latest('id')->limit(30)->get()
: collect();
return view('livewire.admin.host-detail', [
'run' => $run,
'steps' => $this->buildSteps($run),
'events' => $events,
'instances' => $this->host->instances()->latest('id')->get(),
'health' => $this->host->healthState(),
]);
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.admin')]
class Hosts extends Component
{
#[Url(as: 'q')]
public string $search = '';
#[Url]
public string $datacenter = '';
#[Url]
public string $status = '';
public function updated(): void
{
// no-op; keeps the query string in sync as filters change
}
public function clearFilters(): void
{
$this->reset('search', 'datacenter', 'status');
}
public function render()
{
$hosts = Host::query()
->withCount('instances')
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('name', 'like', $term)->orWhere('public_ip', 'like', $term));
})
->when($this->datacenter !== '', fn ($q) => $q->where('datacenter', $this->datacenter))
->when($this->status !== '', fn ($q) => $q->where('status', $this->status))
->orderBy('datacenter')->orderBy('name')
->get();
return view('livewire.admin.hosts', [
'hosts' => $hosts,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'statuses' => ['pending', 'onboarding', 'active', 'error', 'disabled'],
'total' => Host::query()->count(),
]);
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Instance;
use App\Provisioning\Jobs\IssueInstanceAdminAccess;
use App\Services\Wireguard\ConfigHandoff;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
/**
* Administrator access to a customer's Nextcloud.
*
* Not impersonation: that borrows a portal session. This resets our managed
* admin account inside the customer's installation and hands the credentials
* over once the only thing stock Nextcloud offers, and honest about what it
* does rather than pretending to be a passwordless jump.
*
* The operator's own password is required, every time. Taking control of a
* customer's installation is not something an unattended browser should be able
* to do on its own.
*/
class InstanceAdminAccess extends ModalComponent
{
public string $uuid;
public string $subdomain = '';
public string $password = '';
/** Opaque handle; the credentials never enter the component snapshot. */
public ?string $token = null;
public bool $waiting = false;
public function mount(string $uuid): void
{
$this->authorize('instances.adminlogin');
$instance = Instance::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->subdomain = $instance->subdomain;
}
public function hydrate(): void
{
$this->authorize('instances.adminlogin');
}
public function request(): void
{
$this->authorize('instances.adminlogin');
$this->resetErrorBag('password');
$key = 'instance-admin:'.auth()->id();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('password', __('instances.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
return;
}
if (! Hash::check($this->password, auth()->user()->password)) {
RateLimiter::hit($key, 300);
$this->addError('password', __('instances.wrong_password'));
return;
}
RateLimiter::clear($key);
$this->reset('password');
// The reset runs on the provisioning worker — it owns the tunnel. The
// token is where it will leave the result.
$this->token = Str::random(40);
$this->waiting = true;
IssueInstanceAdminAccess::dispatch($this->uuid, $this->token, (int) auth()->id());
}
public function render()
{
$payload = null;
if ($this->token !== null) {
$raw = ConfigHandoff::get($this->token);
if ($raw !== null) {
// Consumed on the first read: leaving it in the cache would make
// "shown once" a figure of speech — any replayed component
// request could fetch the password again for ten minutes. The
// payload lives in a local variable, so it reaches the view and
// nothing else.
ConfigHandoff::forget($this->token);
$this->token = null;
$this->waiting = false;
$payload = json_decode($raw, true);
}
}
return view('livewire.admin.instance-admin-access', [
'credentials' => isset($payload['password']) ? $payload : null,
'failed' => isset($payload['error']) ? $payload['error'] : null,
]);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Instance;
use Illuminate\Support\Facades\Lang;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Every instance on the estate, from the instances table.
*
* This page used to list seven invented instances on four invented hosts,
* complete with a Nextcloud version column. The version is not recorded
* anywhere, so the column is gone rather than filled in with something
* plausible and the storage column now shows the quota that was actually
* sold, not a made-up "used of total", because used disk is not collected.
*/
#[Layout('layouts.admin')]
class Instances extends Component
{
use WithPagination;
public function render()
{
$instances = Instance::query()
->with(['customer', 'host'])
->orderByDesc('id')
->paginate(25);
return view('livewire.admin.instances', [
'instances' => $instances,
'rows' => $instances->getCollection()->map(fn (Instance $i) => [
'address' => $i->custom_domain ?: $i->subdomain,
'customer' => $i->customer?->name ?? '—',
'host' => $i->host?->name ?? '—',
'vmid' => $i->vmid ?? '—',
'plan' => $i->plan !== null ? __('billing.plan.'.$i->plan) : '—',
'quota' => $i->quota_gb !== null ? $i->quota_gb.' GB' : '—',
'status' => $status = $i->status ?? 'provisioning',
// A status the lifecycle adds later must show as itself, never
// as "admin.status.whatever" in front of the owner.
'status_label' => Lang::has('admin.status.'.$status)
? __('admin.status.'.$status)
: $status,
])->all(),
]);
}
}

196
app/Livewire/Admin/Mail.php Normal file
View File

@ -0,0 +1,196 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Mailbox;
use App\Services\Mail\MailboxTester;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The sending addresses, and which kind of mail leaves from which.
*
* The server sits at the top because there is one of it; the mailboxes are a
* list because there are several; the mapping is last because it only makes
* sense once both exist. The test-send button lives with the mailboxes: it
* proves one specific mailbox can actually send, which is the only thing
* that makes the rest of this page more than a form.
*/
#[Layout('layouts.admin')]
class Mail extends Component
{
use ConfirmsPassword;
public string $host = '';
public int|string $port = 587;
public string $encryption = 'tls';
/** @var array<string, string> purpose => mailbox key */
public array $purposes = [];
public string $testRecipient = '';
/** @var array{ok: bool, error: ?string}|null */
public ?array $testResult = null;
public ?string $testedKey = null;
/**
* Whether credentials can be stored at all on this installation.
*
* Public so the page can say it rather than throwing on the first password
* a mailbox tries to decrypt the same courtesy the secrets page already
* extends. A missing SECRETS_KEY is a setup state, not an error.
*/
public bool $usable = true;
public function mount(): void
{
$this->authorize('mail.manage');
$this->host = (string) Settings::get('mail.host', '');
$this->port = (int) Settings::get('mail.port', 587);
$this->encryption = (string) Settings::get('mail.encryption', 'tls');
foreach (MailPurpose::ALL as $purpose) {
$this->purposes[$purpose] = (string) Settings::get(MailPurpose::settingKey($purpose), '');
}
}
public function saveServer(): void
{
$this->authorize('mail.manage');
// $this->host is the platform's outbound relay for every purpose
// mailbox at once — pointing it at an attacker's server intercepts
// everything CluPilot sends. The capability decides who may open this
// page; a recent password decides whether THIS session may repoint
// it, the same second gate Admin\Secrets uses and for the same
// reason: the realistic threat is an unlocked machine, not a
// stranger. savePurposes() and test() stay on the capability alone —
// this is the "changing an address" split's one exception.
abort_unless($this->passwordRecentlyConfirmed(), 403);
// Rules on the ACTION, not on the property: a #[Validate] attribute on
// a Livewire property applies class-wide, and savePurposes() below
// would drag these along.
$this->validate([
'host' => ['required', 'string', 'max:255'],
'port' => ['required', 'integer', 'min:1', 'max:65535'],
'encryption' => ['required', 'in:tls,ssl,none'],
]);
// Codex R15#6, P2: last_verified_at on EVERY mailbox proves a test
// against THIS server config specifically — the shared-server mirror
// of EditMailbox::save()'s guard on a single mailbox's own address/
// username/authenticates. Compared against the STORED values, not
// just "did the operator touch the field": re-opening the card and
// saving without editing anything must not wipe a legitimate
// verification. Read before Settings::set() overwrites them below,
// and deliberately not merged with EditMailbox's own comparison — that
// one diffs a single loaded model's in-memory attributes, this one
// diffs persisted Settings against incoming scalars for a config that
// has no single row to load at all; forcing one comparison function
// over both shapes would add an abstraction with nothing genuinely
// shared to justify it. What IS shared is the actual clear itself:
// both this method and EditMailbox::save() delegate to a Mailbox::
// invalidate*() method rather than touching the column directly.
$serverChanged = $this->host !== (string) Settings::get('mail.host', '')
|| (int) $this->port !== (int) Settings::get('mail.port', 587)
|| $this->encryption !== (string) Settings::get('mail.encryption', 'tls');
Settings::set('mail.host', $this->host);
Settings::set('mail.port', (int) $this->port);
Settings::set('mail.encryption', $this->encryption);
if ($serverChanged) {
Mailbox::invalidateAllVerifications();
}
$this->dispatch('notify', message: __('mail_settings.server_saved'));
}
public function savePurposes(): void
{
$this->authorize('mail.manage');
// Reachable by posting straight to /livewire/update, past whatever
// the <select> in the view would ever submit — so "is a nonempty
// string" is not enough. Every purpose's key must name a mailbox
// that actually exists; system's additionally must be ACTIVE, because
// MailboxResolver::for() falls every unmapped (or inactive) OTHER
// purpose through to system, and there is nothing left to fall back
// to when system itself is the broken one.
$activeKeys = Mailbox::query()->where('active', true)->pluck('key')->all();
$allKeys = Mailbox::query()->pluck('key')->all();
$rules = [
// system is the fallback, so it is the one that may not be empty
// — there is nothing left to fall back to.
'purposes.system' => ['required', 'string', function ($attribute, $value, $fail) use ($activeKeys) {
if ($value !== '' && ! in_array($value, $activeKeys, true)) {
$fail(__('mail_settings.system_inactive'));
}
}],
];
foreach (MailPurpose::ALL as $purpose) {
if ($purpose === MailPurpose::SYSTEM) {
continue;
}
// Blank is a legitimate choice for every OTHER purpose —
// MailboxResolver already falls an unmapped (or inactive)
// purpose through to system on its own, so only a key that names
// NO mailbox at all (a deleted row, or a tampered payload) is
// refused here.
$rules["purposes.{$purpose}"] = ['nullable', 'string', function ($attribute, $value, $fail) use ($allKeys) {
if ($value !== '' && ! in_array($value, $allKeys, true)) {
$fail(__('mail_settings.purpose_unknown_mailbox'));
}
}];
}
$this->validate($rules, [
'purposes.system.required' => __('mail_settings.system_required'),
]);
foreach (MailPurpose::ALL as $purpose) {
Settings::set(MailPurpose::settingKey($purpose), $this->purposes[$purpose] ?? '');
}
$this->dispatch('notify', message: __('mail_settings.purposes_saved'));
}
public function test(string $uuid): void
{
$this->authorize('mail.manage');
$this->validate(
['testRecipient' => ['required', 'email']],
['testRecipient.required' => __('mail_settings.test_recipient_required')],
);
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
$this->testedKey = $box->key;
$this->testResult = app(MailboxTester::class)->run($box, $this->testRecipient);
}
public function render()
{
$this->usable = app(SecretCipher::class)->isUsable();
return view('livewire.admin.mail', [
'mailboxes' => Mailbox::query()->orderBy('key')->get(),
'purposeList' => MailPurpose::ALL,
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}

View File

@ -0,0 +1,242 @@
<?php
namespace App\Livewire\Admin;
use App\Support\LocalTime;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\MaintenanceWindow;
use App\Services\Maintenance\MaintenanceNotifier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Maintenance extends Component
{
#[Validate('required|string|max:255')]
public string $title = '';
#[Validate('nullable|string|max:2000')]
public string $publicDescription = '';
#[Validate('nullable|string|max:2000')]
public string $internalNotes = '';
#[Validate('required|date')]
public string $startsAt = '';
#[Validate('required|date')]
public string $endsAt = '';
/** @var array<int> */
public array $hostIds = [];
/** Toggle every host of one datacenter (select-all / deselect-all). */
public function selectDatacenter(string $code): void
{
$this->authorize('maintenance.manage');
$ids = Host::query()->where('datacenter', $code)->pluck('id')->map(fn ($id) => (string) $id)->all();
$current = array_map('strval', $this->hostIds);
$this->hostIds = empty(array_diff($ids, $current))
? array_values(array_diff($current, $ids)) // all selected → clear them
: array_values(array_unique([...$current, ...$ids]));
}
/**
* Set the end from the start. Typing a full timestamp by hand is the
* fiddliest part of this form, and the end is almost always "start plus a
* round number of minutes".
*/
public function setDuration(int $minutes): void
{
$this->authorize('maintenance.manage');
$start = $this->parsed($this->startsAt);
if ($start === null) {
// No start yet: assume the next half hour, which is what someone
// scheduling a window in a hurry means anyway.
$start = now()->addMinutes(30 - (now()->minute % 30))->startOfMinute();
$this->startsAt = LocalTime::toField($start);
}
$this->endsAt = LocalTime::toField($start->copy()->addMinutes($minutes));
}
/** Minutes between start and end, or null while either is unusable. */
public function durationMinutes(): ?int
{
$start = $this->parsed($this->startsAt);
$end = $this->parsed($this->endsAt);
if ($start === null || $end === null || $end->lessThanOrEqualTo($start)) {
return null;
}
return (int) $start->diffInMinutes($end);
}
/** What the operator typed, as UTC. Half-typed input is normal here. */
private function parsed(string $value): ?\Illuminate\Support\Carbon
{
return LocalTime::fromField($value);
}
public function saveDraft(): void
{
$this->authorize('maintenance.manage');
$this->persist('draft');
}
public function publish(): void
{
$this->authorize('maintenance.manage');
$window = $this->persist('scheduled');
if ($window !== null) {
app(MaintenanceNotifier::class)->announce($window);
}
}
private function persist(string $state): ?MaintenanceWindow
{
$data = $this->validate([
'title' => 'required|string|max:255',
'publicDescription' => 'nullable|string|max:2000',
'internalNotes' => 'nullable|string|max:2000',
'startsAt' => 'required|date',
'endsAt' => 'required|date',
'hostIds' => 'array',
'hostIds.*' => 'integer|exists:hosts,id',
]);
$starts = LocalTime::fromField($data['startsAt']);
$ends = LocalTime::fromField($data['endsAt']);
if ($ends->lessThanOrEqualTo($starts)) {
$this->addError('endsAt', __('maintenance.end_after_start'));
return null;
}
// A window always needs a host — otherwise a hostless draft can never be
// published (there is no edit-hosts action) and is stuck.
if (empty($this->hostIds)) {
$this->addError('hostIds', __('maintenance.need_host'));
return null;
}
if ($state === 'scheduled' && $ends->isPast()) {
$this->addError('endsAt', __('maintenance.end_future'));
return null;
}
// Create the window and attach hosts atomically so a bad id can't leave
// an orphaned scheduled window behind.
$window = DB::transaction(function () use ($data, $starts, $ends, $state) {
$window = MaintenanceWindow::create([
'title' => $data['title'],
'public_description' => $data['publicDescription'] ?: null,
'internal_notes' => $data['internalNotes'] ?: null,
'starts_at' => $starts,
'ends_at' => $ends,
'state' => $state,
'created_by' => auth()->id(),
'published_at' => $state === 'scheduled' ? now() : null,
]);
$window->hosts()->sync(array_map('intval', $data['hostIds'] ?? []));
return $window;
});
$this->reset('title', 'publicDescription', 'internalNotes', 'startsAt', 'endsAt', 'hostIds');
$this->dispatch('notify', message: __($state === 'scheduled' ? 'maintenance.published' : 'maintenance.draft_saved'));
return $window;
}
public function publishExisting(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
if ($window === null || $window->state !== 'draft') {
return;
}
if ($window->hosts()->count() === 0 || $window->ends_at->isPast()) {
$this->dispatch('notify', message: __('maintenance.need_host'));
return;
}
$window->update(['state' => 'scheduled', 'published_at' => now()]);
app(MaintenanceNotifier::class)->announce($window);
$this->dispatch('notify', message: __('maintenance.published'));
}
/**
* Re-run announcements for a published window. Idempotent (ledger-guarded),
* so it only fills gaps the retry path when a transient queue outage left
* some affected customers un-notified.
*/
public function resend(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
// Only announce for a window that has not yet ended (derived state).
if ($window === null || ! in_array($window->derivedState(), ['upcoming', 'active'], true)) {
return;
}
app(MaintenanceNotifier::class)->announce($window);
$this->dispatch('notify', message: __('maintenance.notified'));
}
public function cancel(string $uuid): void
{
$this->authorize('maintenance.manage');
$window = MaintenanceWindow::query()->where('uuid', $uuid)->first();
// Cannot cancel what is already cancelled or has already completed.
if ($window === null || in_array($window->derivedState(), ['cancelled', 'completed'], true)) {
return;
}
$wasPublished = $window->state === 'scheduled';
$window->update(['state' => 'cancelled', 'cancelled_at' => now()]);
// Customers who received an announcement are told it's cancelled. The
// MessageSending listener suppresses still-pending announcements, and the
// MessageSent listener catches the race (an announcement that delivers
// just as we cancel) by queuing a catch-up cancellation for it.
if ($wasPublished) {
app(MaintenanceNotifier::class)->notifyCancellation($window);
}
$this->dispatch('notify', message: __('maintenance.cancelled'));
}
public function render()
{
$windows = MaintenanceWindow::query()
->withCount('hosts')
->orderByDesc('starts_at')
->get()
->map(fn (MaintenanceWindow $w) => [
'uuid' => $w->uuid,
'title' => $w->title,
'starts_at' => $w->starts_at,
'ends_at' => $w->ends_at,
'state' => $w->derivedState(),
'hosts' => $w->hosts_count,
'affected' => $w->affectedCustomers()->count(),
'is_draft' => $w->state === 'draft',
'notifiable' => $w->state === 'scheduled' && in_array($w->derivedState(), ['upcoming', 'active'], true),
'cancellable' => in_array($w->derivedState(), ['draft', 'upcoming', 'active'], true),
]);
return view('livewire.admin.maintenance', [
'windows' => $windows,
'datacenters' => Datacenter::query()->orderBy('name')->get(),
'hosts' => Host::query()->orderBy('datacenter')->orderBy('name')->get(),
'durationMinutes' => $this->durationMinutes(),
]);
}
}

View File

@ -0,0 +1,304 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\MonitoringTarget;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The console's front page every figure on it comes from the database.
*
* It used to be invented: forty-two customers, €7,842 of monthly revenue, hosts
* named pve-fsn-1..3, a twelve-month growth curve. It looked like an operating
* business and reported nothing. Anything without a real source has been
* removed rather than approximated the revenue trend line especially, because
* there is no revenue history to draw it from, and a plausible line is worse
* than no line.
*/
#[Layout('layouts.admin')]
class Overview extends Component
{
// current_step is an index into the pipeline, not a name. The provisioning
// page already owns the translation from one to the other; sharing it keeps
// the two pages from naming the same step differently.
use BuildsRunSteps;
/** A host is presumed missing once it has not checked in for this long. */
private const HOST_SILENT_AFTER_MINUTES = 30;
public function render()
{
$locale = app()->getLocale();
$customersOpen = Customer::query()->where('status', '!=', 'closed')->whereNull('closed_at')->count();
$customersSuspended = Customer::query()->where('status', 'suspended')->count();
$instancesByStatus = Instance::query()
->selectRaw('status, count(*) as n')
->groupBy('status')
->pluck('n', 'status');
$hostsByStatus = Host::query()
->selectRaw('status, count(*) as n')
->groupBy('status')
->pluck('n', 'status');
return view('livewire.admin.overview', [
'kpis' => [
[
'label' => __('admin.kpi.customers'),
'value' => (string) $customersOpen,
'sub' => $customersSuspended > 0
? __('admin.kpi.customers_suspended', ['n' => $customersSuspended])
: null,
],
[
'label' => __('admin.kpi.instances'),
'value' => (string) ($instancesByStatus['active'] ?? 0),
'sub' => __('admin.kpi.instances_of', ['n' => $instancesByStatus->sum()]),
],
[
'label' => __('admin.kpi.hosts'),
'value' => (string) ($hostsByStatus['active'] ?? 0),
'sub' => __('admin.kpi.hosts_of', ['n' => $hostsByStatus->sum()]),
],
[
'label' => __('admin.kpi.mrr'),
'value' => $this->monthlyRevenue($locale),
'sub' => __('admin.kpi.mrr_sub'),
],
],
'newInstances' => $this->newInstancesPerMonth($locale),
'hostLoad' => $this->hostLoad(),
'runs' => $this->openRuns(),
'notices' => $notices = $this->notices(),
'noticeCount' => count($notices),
]);
}
/**
* What the estate bills per month, net.
*
* Off the contracts, not the catalogue: a price rise must not inflate
* reported revenue for every grandfathered customer overnight. Add-ons
* included and yearly terms divided totalMonthlyCents() owns both rules.
*
* Grouped by currency and never summed across them. Adding francs to euros
* produces a number that is wrong in a way nobody notices.
*/
private function monthlyRevenue(string $locale): string
{
$byCurrency = [];
Subscription::query()
->where('status', 'active')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$byCurrency[$currency] = ($byCurrency[$currency] ?? 0) + $subscription->totalMonthlyCents();
}
});
if ($byCurrency === []) {
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
}
return collect($byCurrency)
->map(fn (int $cents, string $currency) => Number::currency($cents / 100, in: $currency, locale: $locale))
->implode(' · ');
}
/**
* Instances created per month over the last twelve.
*
* Deliberately "created", not "in operation": reconstructing how many were
* running at the end of some past month would need a history nobody keeps,
* and the honest version of that chart is this one with a truthful label.
*
* @return array<string, mixed>
*/
private function newInstancesPerMonth(string $locale): array
{
$start = Carbon::now()->startOfMonth()->subMonths(11);
$counts = Instance::query()
->where('created_at', '>=', $start)
->get(['created_at'])
->countBy(fn (Instance $i) => $i->created_at->local()->format('Y-m'));
$labels = [];
$data = [];
for ($i = 0; $i < 12; $i++) {
$month = $start->copy()->addMonths($i);
$labels[] = $month->local()->locale($locale)->isoFormat('MMM');
$data[] = (int) ($counts[$month->local()->format('Y-m')] ?? 0);
}
return [
'empty' => array_sum($data) === 0,
'config' => [
'type' => 'bar',
'data' => [
'labels' => $labels,
'datasets' => [[
'label' => __('admin.new_instances'),
'data' => $data,
'backgroundColor' => 'token:accent',
'borderRadius' => 3,
'maxBarThickness' => 26,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
// Whole instances only — a y-axis offering "1.5 instances"
// is the giveaway that a chart was never looked at.
'y' => ['beginAtZero' => true, 'ticks' => ['precision' => 0], 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
];
}
/**
* Storage committed on each host, against the capacity placement may use.
*
* Deliberately the model's own committedGb()/freeGb()/usedPct() rather than
* a quicker sum here. Placement counts the VM disk allocation, ignores a
* failed instance that never got a VM, and subtracts the host's reserve
* a dashboard doing its own arithmetic would show a host as comfortable
* while placement was already refusing to put anything on it, and the
* disagreement would only surface when an order failed.
*
* @return array<int, array<string, mixed>>
*/
private function hostLoad(): array
{
return Host::query()
// Preloaded so listing hosts is one query, not one per host.
->withSum(['instances as committed_disk_gb' => fn ($q) => $q->occupyingHost()], 'disk_gb')
->orderBy('name')
->get()
->map(function (Host $host) {
$usable = $host->freeGb();
$committed = $host->committedGb();
// Same ratio usedPct() states, computed from the two values
// already in hand rather than asking the host to sum again.
$pct = $usable > 0 ? min(100, (int) round($committed / $usable * 100)) : 0;
return [
'name' => $host->name,
'pct' => $pct,
'detail' => $usable > 0 ? "{$committed} / {$usable} GB" : __('admin.host_no_capacity'),
'level' => match (true) {
$usable === 0 => 'unknown',
$pct >= 90 => 'high',
$pct >= 75 => 'warn',
default => 'ok',
},
];
})
->all();
}
/**
* Provisioning runs that have not finished.
*
* @return array<int, array<string, mixed>>
*/
private function openRuns(): array
{
return ProvisioningRun::query()
->whereIn('status', [
ProvisioningRun::STATUS_PENDING,
ProvisioningRun::STATUS_RUNNING,
ProvisioningRun::STATUS_WAITING,
ProvisioningRun::STATUS_PAUSED,
])
// Nested through the morph, so naming the customer does not cost a
// query per run.
->with(['subject' => fn ($morph) => $morph->morphWith([Instance::class => ['customer']])])
->latest('id')
->limit(6)
->get()
->map(fn (ProvisioningRun $run) => [
'subject' => $this->describeSubject($run),
'step' => $this->currentStepLabel($run),
'status' => $run->status,
])
->all();
}
/**
* Everything currently wrong, from the records that know it.
*
* There is no invented alert here. If this list is empty, nothing in the
* database is reporting a problem which is a statement worth being able
* to trust.
*
* @return array<int, array<string, string>>
*/
private function notices(): array
{
$notices = [];
$failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count();
if ($failedRuns > 0) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.failed_runs', ['n' => $failedRuns])];
}
foreach (Host::query()->where('status', 'error')->pluck('name') as $name) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_error', ['host' => $name])];
}
$silent = Host::query()
->where('status', 'active')
->where(fn ($q) => $q
->whereNull('last_seen_at')
->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES)))
->pluck('name');
foreach ($silent as $name) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_silent', [
'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES,
])];
}
// Distinct instances, not targets: one instance can be watched by
// several checks, and counting checks would report three outages where
// one machine is down. And only verdicts the sync job has refreshed —
// an unchecked row is not a healthy one, nor a failing one.
$down = MonitoringTarget::query()
->where('status', 'down')
->where('checked_at', '>=', Carbon::now()->subMinutes(20))
->distinct()
->count('instance_id');
if ($down > 0) {
$notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])];
}
return $notices;
}
private function describeSubject(ProvisioningRun $run): string
{
$subject = $run->subject;
return match (true) {
$subject instanceof Instance => $subject->customer?->name ?? $subject->subdomain ?? '—',
$subject instanceof Host => $subject->name,
default => class_basename($run->subject_type ?? '').' #'.$run->subject_id,
};
}
}

View File

@ -0,0 +1,262 @@
<?php
namespace App\Livewire\Admin;
use App\Support\LocalTime;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
use App\Support\Money;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use RuntimeException;
/**
* The versions of one plan: what it can do, what it costs, and when it sells.
*
* The page is built around the one rule that matters here a draft can be
* edited freely, a published version cannot be touched at all. So drafting and
* publishing are separate acts, and publishing says plainly that it is final.
*/
#[Layout('layouts.admin')]
class PlanVersions extends Component
{
public string $uuid;
/** The draft being written. */
public int $quotaGb = 100;
public int $trafficGb = 1000;
public int $seats = 5;
public int $ramMb = 4096;
public int $cores = 2;
public int $diskGb = 120;
public string $performance = 'standard';
public ?int $templateVmid = 9000;
/** @var array<int, string> */
public array $features = [];
/**
* In EUROS, as typed. The catalogue stores cents; asking the owner to type
* them too turned €799 into "79900", where one slipped digit is a factor of
* ten on an invoice. Money::toCents does the conversion, once.
*/
public string $monthlyPrice = '49,00';
public string $yearlyPrice = '588,00';
/** Publishing: when it goes on sale, and optionally when it stops. */
public string $availableFrom = '';
public string $availableUntil = '';
public ?string $publishing = null;
public function mount(string $uuid): void
{
$this->authorize('plans.manage');
$family = PlanFamily::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
// Start the draft from what this plan is today, so the common case —
// "same plan, new price" — is a single edit rather than nine.
$latest = $family->versions()->orderByDesc('version')->first();
if ($latest !== null) {
$this->quotaGb = $latest->quota_gb;
$this->trafficGb = $latest->traffic_gb;
$this->seats = $latest->seats;
$this->ramMb = $latest->ram_mb;
$this->cores = $latest->cores;
$this->diskGb = $latest->disk_gb;
$this->performance = (string) ($latest->performance ?? 'standard');
$this->templateVmid = $latest->template_vmid;
$this->features = $latest->features ?? [];
$monthly = $latest->priceFor(Subscription::TERM_MONTHLY);
$yearly = $latest->priceFor(Subscription::TERM_YEARLY);
$this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900);
$this->yearlyPrice = Money::fromCents($yearly?->amount_cents ?? 58800);
}
}
private function family(): PlanFamily
{
return PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
}
/** Write a new draft. Nothing is promised until it is published. */
public function draft(): void
{
$this->authorize('plans.manage');
// Every bound is explicit, and generous rather than tight. A mistyped
// figure has to come back as a message on the field; without an upper
// limit it overflows the column instead and the owner gets a 500 with
// no idea which number was wrong.
$data = $this->validate([
'quotaGb' => 'required|integer|min:1|max:1000000',
'trafficGb' => 'required|integer|min:0|max:10000000',
'seats' => 'required|integer|min:1|max:100000',
'ramMb' => 'required|integer|min:512|max:4194304',
'cores' => 'required|integer|min:1|max:512',
// The disk has to hold the customer's quota plus the system itself.
'diskGb' => 'required|integer|min:1|max:1000000|gte:quotaGb',
// One of the classes we actually have a label for. Free text means
// a typo is published, frozen, and shown to customers forever as a
// raw translation key.
'performance' => ['required', Rule::in(array_keys((array) __('billing.perf')))],
// Required, not nullable: a version without a blueprint can be
// published and sold, and then fails provisioning every time.
'templateVmid' => 'required|integer|min:100|max:999999999',
'features' => 'array',
// Same reason as the performance class: a request can carry
// anything, and an unknown key is frozen at publication and shown
// to customers as a raw translation key.
'features.*' => ['string', Rule::in(array_keys((array) __('billing.feature')))],
// Euros with at most two decimals, either separator. The upper
// bound lives in Money's pattern: nine whole digits is far past
// anything we would charge and far short of overflowing the column.
// Checked as a rule, not afterwards: a price fault has to be
// reported alongside every other fault on the form, and a check
// that runs after validate() never runs at all once some other
// field has already thrown.
'monthlyPrice' => ['required', 'string', $price = function (string $attribute, mixed $value, callable $fail) {
if (Money::toCents((string) $value) === null) {
$fail(__('plans.price_invalid'));
}
}],
'yearlyPrice' => ['required', 'string', $price],
]);
$monthlyCents = Money::toCents($data['monthlyPrice']);
$yearlyCents = Money::toCents($data['yearlyPrice']);
$version = app(PlanCatalogue::class)->draft(
$this->family(),
[
'quota_gb' => $data['quotaGb'],
'traffic_gb' => $data['trafficGb'],
'seats' => $data['seats'],
'ram_mb' => $data['ramMb'],
'cores' => $data['cores'],
'disk_gb' => $data['diskGb'],
'performance' => $data['performance'],
'template_vmid' => $data['templateVmid'],
'features' => array_values($data['features']),
],
// Both terms, because publishing refuses a version that is not
// priced for each — better to find that out here than in front of
// a customer.
[
Subscription::TERM_MONTHLY => $monthlyCents,
Subscription::TERM_YEARLY => $yearlyCents,
],
);
$this->dispatch('notify', message: __('plans.draft_created', ['version' => $version->version]));
}
/** Open the publish form for one draft. */
public function choose(string $versionUuid): void
{
$this->publishing = $versionUuid;
$this->availableFrom = LocalTime::toField(now());
$this->availableUntil = '';
}
public function cancelPublish(): void
{
$this->reset('publishing', 'availableFrom', 'availableUntil');
}
/**
* Publish: fix the terms and put them on sale.
*
* Everything that can refuse this an unpriced version, a window that
* overlaps another refuses before anything is written, so a rejected
* publish leaves an editable draft rather than a stranded one.
*/
public function publish(): void
{
$this->authorize('plans.manage');
$this->validate([
'availableFrom' => 'required|date',
'availableUntil' => 'nullable|date|after:availableFrom',
]);
$version = PlanVersion::query()->where('uuid', $this->publishing)->firstOrFail();
abort_unless($version->plan_family_id === $this->family()->id, 404);
try {
app(PlanCatalogue::class)->publish(
$version,
LocalTime::fromField($this->availableFrom),
LocalTime::fromField($this->availableUntil),
);
} catch (RuntimeException $e) {
$this->addError('availableFrom', $e->getMessage());
return;
}
$this->cancelPublish();
$this->dispatch('notify', message: __('plans.published', ['version' => $version->version]));
}
/**
* Close a running version's window the ordinary way to take a plan off
* sale for good, since a published version can never be deleted.
*/
public function close(string $versionUuid): void
{
$this->authorize('plans.manage');
$version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail();
abort_unless($version->plan_family_id === $this->family()->id, 404);
try {
app(PlanCatalogue::class)->schedule($version, $version->available_from, now());
} catch (RuntimeException $e) {
$this->addError('availableFrom', $e->getMessage());
return;
}
$this->dispatch('notify', message: __('plans.closed', ['version' => $version->version]));
}
#[On('plan-draft-deleted')]
public function refresh(): void
{
// Livewire re-renders; the listener exists so the modal can say so.
}
public function render()
{
$family = $this->family();
$now = now();
return view('livewire.admin.plan-versions', [
'family' => $family,
'versions' => $family->versions()->with('prices')->orderByDesc('version')->get(),
'now' => $now,
'featureKeys' => array_keys((array) __('billing.feature')),
'performanceClasses' => (array) __('billing.perf'),
'currency' => Subscription::catalogueCurrency(),
]);
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace App\Livewire\Admin;
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* The plan lines we sell, and whether each is currently on sale.
*
* Deliberately shallow: a family is a name and a rank, and everything that can
* be got wrong capabilities, prices, windows lives one level down on the
* versions, where publishing makes it permanent.
*/
#[Layout('layouts.admin')]
class Plans extends Component
{
#[Validate(['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'])]
public string $key = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|integer|min:0|max:255')]
public int $tier = 1;
public function mount(): void
{
// The page itself, not only its buttons. Hiding the nav entry and
// guarding the mutations still leaves the whole catalogue — prices,
// drafts, unreleased plans — readable to any operator who types the URL.
$this->authorize('plans.manage');
}
public function save(): void
{
$this->authorize('plans.manage');
$this->key = strtolower(trim($this->key));
$data = $this->validate([
// The key is permanent once created — orders, instances and every
// contract snapshot store it as a string — so it has to be a plain
// identifier, not something anyone will want to prettify later.
'key' => ['required', 'string', 'max:32', 'regex:/^[a-z][a-z0-9_]*$/', 'unique:plan_families,key'],
'name' => 'required|string|max:255',
'tier' => 'required|integer|min:0|max:255',
]);
PlanFamily::create([
'key' => $data['key'],
'name' => $data['name'],
'tier' => $data['tier'],
// Nothing to sell until a version is published, so it starts able
// to sell and simply has nothing available.
'sales_enabled' => true,
]);
$this->reset('key', 'name', 'tier');
$this->tier = 1;
$this->dispatch('notify', message: __('plans.created'));
}
/**
* The kill switch: stop selling this plan now, without touching a window.
*
* Existing customers keep their contract and their machine this only
* decides whether anyone new can buy it.
*/
public function toggleSales(string $uuid): void
{
$this->authorize('plans.manage');
$family = PlanFamily::query()->where('uuid', $uuid)->first();
$family?->update(['sales_enabled' => ! $family->sales_enabled]);
$this->dispatch('notify', message: __($family?->sales_enabled ? 'plans.now_selling' : 'plans.withdrawn'));
}
public function render()
{
$now = now();
$families = PlanFamily::query()
->with(['versions.prices'])
->withCount('versions')
->orderBy('tier')
->get()
->map(fn (PlanFamily $family) => [
'uuid' => $family->uuid,
'key' => $family->key,
'name' => $family->name,
'tier' => $family->tier,
'sales_enabled' => $family->sales_enabled,
'versions_count' => $family->versions_count,
'live' => $family->versions->first(fn ($v) => $v->isAvailableAt($now)),
// What is queued up next, so a scheduled launch is visible
// before a customer discovers it.
'next' => $family->versions
->filter(fn ($v) => $v->isPublished() && $v->available_from->greaterThan($now))
->sortBy('available_from')
->first(),
'drafts' => $family->versions->filter(fn ($v) => ! $v->isPublished())->count(),
]);
return view('livewire.admin.plans', [
'families' => $families,
// The shop's own answer, so this page cannot disagree with what a
// customer actually sees.
'sellable' => array_keys(app(PlanCatalogue::class)->sellable()),
]);
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class Provisioning extends Component
{
use BuildsRunSteps;
#[On('echo-private:admin.runs,StepAdvanced')]
public function onStepAdvanced(): void
{
// A round-trip re-renders with fresh run state.
}
/** Retry a failed run from its current step (mirrors the host-detail retry). */
public function retry(string $uuid): void
{
$this->authorize('provisioning.retry');
// Atomically claim the run: only the request that transitions it out of
// FAILED proceeds, so two concurrent retries can't double-dispatch.
$claimed = ProvisioningRun::query()
->where('uuid', $uuid)
->where('status', ProvisioningRun::STATUS_FAILED)
->update([
'status' => ProvisioningRun::STATUS_RUNNING,
'attempt' => 0,
'next_attempt_at' => now(),
'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly
'error' => null,
]);
if ($claimed === 0) {
return; // already retried by a concurrent request
}
$run = ProvisioningRun::query()->where('uuid', $uuid)->first();
if ($run === null) {
return;
}
// Move the subject out of its error state so the console reflects the retry.
$subject = $run->subject;
if ($subject instanceof Host) {
$subject->update(['status' => 'onboarding']);
} elseif ($subject instanceof Order) {
$subject->update(['status' => 'provisioning']);
Instance::query()->where('order_id', $subject->id)->where('status', 'failed')->update(['status' => 'provisioning']);
}
AdvanceRunJob::dispatch($run->uuid);
$this->dispatch('notify', message: __('admin.run_retried'));
}
/**
* A run that claims to be in progress but hasn't advanced in a while looks
* stuck unless it is in a legitimate scheduled backoff (next_attempt_at in
* the future, e.g. RunRunner's retry delay of up to 300 s).
*/
private function isStale(ProvisioningRun $run): bool
{
if (! in_array($run->status, ['running', 'waiting'], true)) {
return false;
}
if ($run->next_attempt_at !== null && $run->next_attempt_at->isFuture()) {
return false; // waiting out a scheduled retry/poll — not stuck
}
// Compare against the CURRENT step's own allowed duration — some steps
// (VM clone, Proxmox install) legitimately run for many minutes — plus a
// small grace. Only past its own deadline without advancing is it stuck.
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$class = $pipeline[$run->current_step] ?? null;
$maxSeconds = $class !== null ? app($class)->maxDuration() : 120;
return $run->updated_at !== null && $run->updated_at->lt(now()->subSeconds($maxSeconds + 30));
}
private function subjectLabel(ProvisioningRun $run): string
{
$subject = $run->subject;
if ($subject instanceof Order) {
return $subject->customer?->name ?? 'Order';
}
if ($subject instanceof Host) {
return $subject->name;
}
return class_basename($run->subject_type);
}
public function render()
{
$runs = ProvisioningRun::query()->latest('id')->limit(30)->get();
$active = $runs->first(fn (ProvisioningRun $r) => in_array(
$r->status, ['pending', 'running', 'waiting'], true
)) ?? $runs->first();
return view('livewire.admin.provisioning', [
'hasActive' => $active !== null && in_array($active->status, ['pending', 'running', 'waiting'], true),
'rows' => $runs->map(function (ProvisioningRun $r) {
$total = max(count(config('provisioning.pipelines.'.$r->pipeline, [])), 1);
$done = $r->status === ProvisioningRun::STATUS_COMPLETED ? $total : $r->current_step;
return [
'uuid' => $r->uuid,
'customer' => $this->subjectLabel($r),
'pipeline' => $r->pipeline,
'step' => $r->status === 'completed' ? '—' : $this->currentStepLabel($r),
'n' => ($r->current_step + 1).'/'.$total,
'percent' => (int) round($done / $total * 100),
'attempt' => $r->attempt,
'state' => $this->runState($r),
'failed' => $r->status === ProvisioningRun::STATUS_FAILED,
'activity' => $r->updated_at?->diffForHumans() ?? '—',
'stale' => $this->isStale($r),
];
})->all(),
'panel' => $active ? $this->panelFor($active) : null,
]);
}
/** Compact "current run" readout for the right column. */
private function panelFor(ProvisioningRun $run): array
{
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$total = max(count($pipeline), 1);
$current = min($run->current_step, $total - 1);
$completed = $run->status === ProvisioningRun::STATUS_COMPLETED;
$done = $completed ? $total : $current;
return [
'uuid' => $run->uuid,
'subject' => $this->subjectLabel($run),
'pipeline' => $run->pipeline,
'attempt' => $run->attempt,
'status' => $this->runState($run), // running|done|failed
'failed' => $run->status === ProvisioningRun::STATUS_FAILED,
'current' => $completed ? null : (isset($pipeline[$current]) ? __(app($pipeline[$current])->label()) : null),
'next' => isset($pipeline[$current + 1]) && ! $completed ? __(app($pipeline[$current + 1])->label()) : null,
'error' => $run->error,
'done' => $done,
'total' => $total,
'percent' => (int) round($done / $total * 100),
'activity' => $run->updated_at?->diffForHumans() ?? '—',
'started' => $run->started_at?->diffForHumans() ?? null,
'stale' => $this->isStale($run),
];
}
}

View File

@ -0,0 +1,203 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Revenue, from the contracts and the commercial register.
*
* What was here before was a story: €7,842 MRR, 1.8 % churn, a twelve-month
* curve, four payments from customers who do not exist. All of it is gone.
*
* Two figures that used to be shown are not shown any more, because there is
* nothing to compute them from and an estimate would be indistinguishable from
* a measurement:
*
* - the MRR trend, which needs a monthly revenue history nobody records;
* - churn, which needs cancellations over a period, and cancelled_at alone
* does not say what the base was.
*
* ARR is shown, but only as what it is: this month's recurring revenue times
* twelve, labelled as a projection rather than as measured turnover.
*/
#[Layout('layouts.admin')]
class Revenue extends Component
{
public function render()
{
$locale = app()->getLocale();
$totals = $this->recurringTotals();
return view('livewire.admin.revenue', [
'kpis' => [
[
'label' => __('admin.rev.mrr'),
'value' => $this->format($totals, fn (array $t) => $t['cents'] / 100, $locale),
'sub' => __('admin.rev.mrr_sub'),
],
[
'label' => __('admin.rev.arr'),
'value' => $this->format($totals, fn (array $t) => $t['cents'] * 12 / 100, $locale),
'sub' => __('admin.rev.arr_sub'),
],
[
'label' => __('admin.rev.arpu'),
'value' => $this->format(
$totals,
fn (array $t) => count($t['customers']) > 0 ? $t['cents'] / count($t['customers']) / 100 : 0,
$locale,
),
'sub' => __('admin.rev.arpu_sub'),
],
[
'label' => __('admin.rev.contracts'),
'value' => (string) array_sum(array_column($totals, 'contracts')),
'sub' => __('admin.rev.contracts_sub'),
],
],
'planCharts' => $this->planCharts(),
'payments' => $this->recentPayments($locale),
]);
}
/**
* Recurring revenue per currency, off the contracts.
*
* Never summed across currencies. Frozen contract prices, not the
* catalogue, so a price rise does not retroactively inflate what
* grandfathered customers are reported to pay. Add-ons included and yearly
* terms divided totalMonthlyCents() owns both of those rules.
*
* @return array<string, array{cents:int, customers:array<int,bool>, contracts:int}>
*/
private function recurringTotals(): array
{
$totals = [];
Subscription::query()
->where('status', 'active')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$totals) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$totals[$currency] ??= ['cents' => 0, 'customers' => [], 'contracts' => 0];
$totals[$currency]['cents'] += $subscription->totalMonthlyCents();
$totals[$currency]['contracts']++;
// A customer with two contracts is one customer — ARPU
// divided by contracts would understate it.
$totals[$currency]['customers'][(int) $subscription->customer_id] = true;
}
});
return $totals;
}
/**
* @param array<string, array{cents:int, customers:array<int,bool>, contracts:int}> $totals
* @param callable(array{cents:int, customers:array<int,bool>, contracts:int}): float $amount
*/
private function format(array $totals, callable $amount, string $locale): string
{
if ($totals === []) {
return Number::currency(0, in: Subscription::catalogueCurrency(), locale: $locale);
}
return collect($totals)
->map(fn (array $t, string $currency) => Number::currency($amount($t), in: $currency, locale: $locale))
->implode(' · ');
}
/**
* Recurring revenue split by plan one chart per currency.
*
* By what customers are ON, not by what is on sale: a withdrawn plan still
* bills, and leaving it out would understate the total the chart sits next
* to.
*
* Per currency, because a doughnut adds its slices together. Two currencies
* in one ring produces a total that is not an amount of anything, and it
* looks exactly as convincing as a correct one.
*
* @return array<int, array{currency:string, config:array<string,mixed>}>
*/
private function planCharts(): array
{
$byCurrency = [];
Subscription::query()
->where('status', 'active')
->with('addons')
->chunkById(200, function ($subscriptions) use (&$byCurrency) {
foreach ($subscriptions as $subscription) {
$currency = strtoupper((string) $subscription->currency);
$plan = (string) $subscription->plan;
$byCurrency[$currency][$plan] = ($byCurrency[$currency][$plan] ?? 0)
+ $subscription->totalMonthlyCents();
}
});
$charts = [];
foreach ($byCurrency as $currency => $byPlan) {
arsort($byPlan);
$charts[] = [
'currency' => $currency,
'config' => [
'type' => 'doughnut',
'data' => [
'labels' => array_map(fn (string $key) => __('billing.plan.'.$key), array_keys($byPlan)),
'datasets' => [[
'data' => array_map(fn (int $cents) => round($cents / 100, 2), array_values($byPlan)),
'backgroundColor' => ['token:accent', 'token:info', 'token:success-bright', 'token:warning'],
'borderWidth' => 0,
]],
],
'options' => [
'cutout' => '62%',
'plugins' => ['legend' => ['position' => 'bottom', 'labels' => ['boxWidth' => 10, 'padding' => 12]]],
],
],
];
}
return $charts;
}
/**
* The last payments actually recorded.
*
* Both kinds count: an ordinary billing cycle is written as `renewal`, and
* `invoice_paid` is reserved for everything else Stripe charges for a
* proration, a manual invoice. Listing only the second would show a
* payments panel with no ordinary payments in it.
*
* Gross, because that is what left the customer's account. The register
* holds net and tax separately for the books.
*
* @return array<int, array<string, string>>
*/
private function recentPayments(string $locale): array
{
return SubscriptionRecord::query()
->whereIn('event', [SubscriptionRecord::EVENT_RENEWAL, SubscriptionRecord::EVENT_INVOICE_PAID])
->orderByDesc('occurred_at')
->limit(8)
->get()
->map(fn (SubscriptionRecord $r) => [
'customer' => $r->customer_name ?: '—',
'amount' => Number::currency(
(int) ($r->gross_cents ?: $r->net_cents) / 100,
in: strtoupper((string) ($r->currency ?: Subscription::catalogueCurrency())),
locale: $locale,
),
'when' => $r->occurred_at?->local()->translatedFormat('d. MMM') ?? '—',
])
->all();
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace App\Livewire\Admin;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Services\Secrets\SecretVault;
use App\Services\Stripe\StripeCheck;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Credentials, changeable from the console instead of over SSH.
*
* Two gates, not one. The capability decides who may open the page at all; the
* password decides whether this SESSION may see or change anything. The second
* exists because the realistic threat is not a stranger it is an unlocked
* machine, and a signed-in session is exactly what that gives away.
*
* Both are enforced on every action, server-side. A Livewire action is
* reachable by anyone who can post to /livewire/update, and the buttons not
* being on screen has never stopped anybody.
*
* The value being entered lives in a public property only while it is being
* typed, and is cleared the moment it is stored a Livewire property travels
* to the browser and back in the component snapshot.
*/
#[Layout('layouts.admin')]
class Secrets extends Component
{
use ConfirmsPassword;
/**
* The new value being entered, keyed by a DOTLESS form key.
*
* Livewire reads a dot in a property path as nesting, so binding to
* `entered.stripe.secret` writes `entered['stripe']['secret']` and the
* value never arrives where the save looks for it. The registry keys keep
* their dots; the form does not.
*/
public array $entered = [];
/** Result of the last connection test, for display only. */
public ?array $check = null;
public function mount(): void
{
$this->authorize('secrets.manage');
}
public function save(string $key): void
{
$this->guard();
$field = self::field($key);
$value = trim((string) ($this->entered[$field] ?? ''));
if ($value === '') {
$this->addError('entered.'.$field, __('secrets.empty'));
return;
}
try {
app(SecretVault::class)->put($key, $value, auth()->user());
} catch (Throwable $e) {
$this->addError('entered.'.$field, $e->getMessage());
return;
}
// Out of the component state as soon as it is stored.
$this->entered[$field] = '';
$this->check = null;
$this->dispatch('notify', message: __('secrets.saved'));
}
public function forget(string $key): void
{
$this->guard();
app(SecretVault::class)->forget($key);
$this->check = null;
$this->dispatch('notify', message: __('secrets.removed'));
}
/**
* Try the key that is in force or the one being typed, before storing it.
*
* Checking the candidate first is the point: a key that is saved and wrong
* fails later, somewhere else, usually in front of a customer.
*/
public function test(string $key): void
{
$this->guard();
// The checker named by THIS entry, not a fixed one. When the area held a
// single Stripe key a hard-coded StripeCheck was the same thing; with
// four entries it would have reported on Stripe while the operator was
// looking at the DNS token.
$checker = SecretVault::REGISTRY[$key]['check'] ?? null;
abort_if($checker === null, 404);
$candidate = trim((string) ($this->entered[self::field($key)] ?? '')) ?: null;
$this->check = app($checker)->run($candidate);
}
/** The dotless form key for a registry key. */
public static function field(string $key): string
{
return str_replace('.', '_', $key);
}
/** Capability AND a recent password, on every single action. */
private function guard(): void
{
$this->authorize('secrets.manage');
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
public function render()
{
$vault = app(SecretVault::class);
$unlocked = $this->passwordRecentlyConfirmed();
return view('livewire.admin.secrets', [
'unlocked' => $unlocked,
'usable' => $vault->isUsable(),
'entries' => collect(SecretVault::REGISTRY)
->map(fn (array $meta, string $key) => [
'key' => $key,
'field' => self::field($key),
'label' => __($meta['label']),
// Only where a checker exists. A test button that cannot
// actually test is a promise the page does not keep.
'testable' => isset($meta['check']),
'source' => $vault->source($key),
// Only ever an outline, and only once unlocked.
'outline' => $unlocked ? $vault->outline($key) : null,
'updated_at' => $unlocked ? $vault->updatedAt($key) : null,
])
->values()
->all(),
]);
}
}

View File

@ -0,0 +1,384 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\User;
use App\Models\VpnPeer;
use App\Services\Deployment\UpdateChannel;
use App\Support\Settings as AppSettings;
use App\Provisioning\Jobs\ApplyVpnPeer;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Role;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
/**
* Operator settings: the signed-in operator's own account plus (Owner-only)
* staff management. All staff mutations are capability-gated server-side and
* protect the last-Owner and self-role invariants transactionally.
*/
#[Layout('layouts.admin')]
class Settings extends Component
{
use \App\Livewire\Concerns\ChangesOwnPassword;
// My account
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|email|max:255')]
public string $email = '';
// Invite staff
#[Validate('required|string|max:255')]
public string $staffName = '';
#[Validate('required|email|max:255')]
public string $staffEmail = '';
#[Validate('required|in:Owner,Admin,Support,Billing,Read-only')]
public string $staffRole = 'Support';
/** Shown once after inviting, since email delivery is still mocked. */
public ?string $invitedEmail = null;
public ?string $invitedPassword = null;
public function mount(): void
{
$this->name = auth()->user()->name;
$this->email = auth()->user()->email;
}
/**
* Take the marketing site and the customer portal offline, or back online.
* The console keeps working either way otherwise this switch could only
* ever be flipped once.
*/
public function toggleSiteVisibility(): void
{
$this->authorize('site.manage');
$public = ! AppSettings::bool('site.public', true);
AppSettings::set('site.public', $public);
$this->dispatch('notify', message: $public
? __('admin_settings.site_now_public')
: __('admin_settings.site_now_hidden'));
}
public function saveAccount(): void
{
$user = auth()->user();
$data = $this->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255|unique:users,email,'.$user->id,
]);
// An operator email must never collide with a customer's — that would
// block the customer from ever obtaining a portal login (ensureUser).
if (Customer::query()->where('email', $data['email'])->exists()) {
$this->addError('email', __('admin_settings.is_customer'));
return;
}
$user->update($data);
$this->dispatch('notify', message: __('admin_settings.account_saved'));
}
public function inviteStaff(): void
{
$this->authorize('staff.manage');
$data = $this->validate([
'staffName' => 'required|string|max:255',
'staffEmail' => 'required|email|max:255|unique:users,email',
'staffRole' => 'required|in:Owner,Admin,Support,Billing,Read-only',
]);
// Only an Owner may create another Owner.
if ($data['staffRole'] === 'Owner' && ! auth()->user()->hasRole('Owner')) {
$this->addError('staffRole', __('admin_settings.owner_only'));
return;
}
// Never turn a customer's portal login into an operator.
if (Customer::query()->where('email', $data['staffEmail'])->exists()) {
$this->addError('staffEmail', __('admin_settings.is_customer'));
return;
}
// Email/password-setup delivery is still mocked, so generate a temporary
// password and surface it once to the Owner to share securely — the
// account is usable immediately (a proper invite link follows with mail).
$temp = Str::password(14);
$user = User::create([
'name' => $data['staffName'],
'email' => $data['staffEmail'],
'password' => Hash::make($temp),
'is_admin' => true,
]);
$user->assignRole($data['staffRole']);
$this->invitedEmail = $data['staffEmail'];
$this->invitedPassword = $temp;
$this->reset('staffName', 'staffEmail');
$this->staffRole = 'Support';
$this->dispatch('notify', message: __('admin_settings.staff_invited'));
}
public function setStaffRole(int $id, string $role): void
{
$this->authorize('staff.manage');
if (! in_array($role, User::OPERATOR_ROLES, true)) {
return;
}
$result = DB::transaction(function () use ($id, $role) {
// Serialize on the Owner role so the global owner count can't be
// raced to zero by concurrent demotions of different owners.
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = User::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null) {
return 'gone';
}
if ($target->id === auth()->id()) {
return 'self';
}
// Only existing operators may be re-roled — never escalate a customer
// (or any non-staff) user into the console via a tampered id.
if (! $target->isOperator() || Customer::query()->where('email', $target->email)->exists()) {
return 'not_staff';
}
// Granting or revoking Owner is Owner-only.
if (($role === 'Owner' || $target->hasRole('Owner')) && ! auth()->user()->hasRole('Owner')) {
return 'owner_only';
}
// Never demote the last Owner.
if ($target->hasRole('Owner') && $role !== 'Owner' && $this->ownerCount() <= 1) {
return 'last_owner';
}
$target->syncRoles([$role]);
return 'ok';
});
$this->flash($result);
}
public function revokeStaff(int $id): void
{
$this->authorize('staff.manage');
$revokedPeers = [];
$result = DB::transaction(function () use ($id, &$revokedPeers) {
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = User::query()->whereKey($id)->lockForUpdate()->first();
if ($target === null || ! $target->isOperator()) {
return 'gone';
}
if ($target->id === auth()->id()) {
return 'self';
}
if ($target->hasRole('Owner') && $this->ownerCount() <= 1) {
return 'last_owner';
}
$target->syncRoles([]);
$target->update(['is_admin' => false]);
// Taking away the console but leaving the tunnel would be the worst
// of both: no access to the panel, still a route into the
// management network. Same revocation path as the VPN page uses.
foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) {
$peer->purgeSecret();
$peer->delete();
$revokedPeers[] = $peer->public_key;
}
return 'revoked';
});
// Dispatched only once the rows are committed: a worker picking the job
// up mid-transaction would still see the peer as active, leave it on the
// hub, and never be asked again — a revoked colleague would keep their
// tunnel until the next reconciliation happened to notice.
foreach ($revokedPeers as $publicKey) {
ApplyVpnPeer::dispatch($publicKey, null, false);
}
$this->flash($result);
}
private function flash(string $result): void
{
$msg = match ($result) {
'ok' => __('admin_settings.role_updated'),
'revoked' => __('admin_settings.staff_revoked'),
'self' => __('admin_settings.not_self'),
'owner_only' => __('admin_settings.owner_only'),
'last_owner' => __('admin_settings.last_owner'),
'not_staff' => __('admin_settings.not_staff'),
default => null,
};
if ($msg !== null) {
$this->dispatch('notify', message: $msg);
}
}
private function ownerCount(): int
{
return User::query()->whereHas('roles', fn ($q) => $q->where('name', 'Owner'))->count();
}
/** A new entry for the console allowlist: a single address or a CIDR range. */
public string $consoleIp = '';
/**
* Add a network that may reach the console without the VPN.
*
* Validated as an address or CIDR before it is stored: a typo that silently
* matches nothing is how someone locks themselves out while believing they
* have not.
*/
public function addConsoleIp(): void
{
$this->authorize('site.manage');
$value = trim($this->consoleIp);
if (! \App\Http\Middleware\RestrictConsoleNetwork::isNetwork($value)) {
$this->addError('consoleIp', __('admin_settings.console_ip_invalid'));
return;
}
$list = (array) AppSettings::get('console.allowed_ips', []);
if (! in_array($value, $list, true)) {
$list[] = $value;
AppSettings::set('console.allowed_ips', array_values($list));
}
$this->consoleIp = '';
$this->dispatch('notify', message: __('admin_settings.console_ip_added', ['ip' => $value]));
}
/**
* Remove one unless it is the reason you can see this page.
*
* Refusing here rather than warning afterwards: by the time the page
* reloads, the request that would show the warning has already been
* rejected. The VPN is never in this list, so an operator on the VPN can
* always clear it out.
*/
public function removeConsoleIp(string $value): void
{
$this->authorize('site.manage');
$list = array_values(array_filter(
(array) AppSettings::get('console.allowed_ips', []),
fn ($entry) => $entry !== $value,
));
$ip = (string) request()->ip();
if (\App\Http\Middleware\RestrictConsoleNetwork::isRestricted()
&& ! \App\Http\Middleware\RestrictConsoleNetwork::wouldStillAllow($ip, $list)) {
$this->dispatch('notify', message: __('admin_settings.console_ip_last', ['ip' => $ip]));
return;
}
AppSettings::set('console.allowed_ips', $list);
$this->dispatch('notify', message: __('admin_settings.console_ip_removed', ['ip' => $value]));
}
/**
* Switch the restriction on or off.
*
* Turning it ON is refused unless the address doing the switching is
* already covered otherwise the click that secures the console is also
* the click that locks its owner out of it.
*/
public function toggleConsoleRestriction(): void
{
$this->authorize('site.manage');
$on = ! \App\Http\Middleware\RestrictConsoleNetwork::isRestricted();
if ($on && ! \App\Http\Middleware\RestrictConsoleNetwork::allows((string) request()->ip())) {
$this->dispatch('notify', message: __('admin_settings.console_lock_refused', ['ip' => (string) request()->ip()]));
return;
}
AppSettings::set('console.network_restricted', $on);
$this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked'));
}
/**
* Ask the host-side agent to update this installation.
*
* Deliberately a request rather than an action: see UpdateChannel. The
* button cannot report success, because success means this container is
* restarted out from under the response so it reports that the update was
* asked for, and the page shows the outcome once the agent has written it.
*/
public function requestUpdate(): void
{
$this->authorize('site.manage');
$accepted = app(UpdateChannel::class)->request(auth()->user()->email);
$this->dispatch('notify', message: __($accepted
? 'admin_settings.update_requested'
: 'admin_settings.update_already_requested'));
}
public function render()
{
$staff = User::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES))
->with('roles')
->orderBy('name')
->get()
->map(fn (User $u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'role' => $u->roles->first()?->name ?? '—',
'self' => $u->id === auth()->id(),
]);
return view('livewire.admin.settings', [
'update' => app(UpdateChannel::class)->state(),
'updateLog' => app(UpdateChannel::class)->lastLog(),
'sitePublic' => AppSettings::bool('site.public', true),
'canManageSite' => auth()->user()?->can('site.manage') ?? false,
// Shown so nobody has to guess why they still see the real site.
'viewerOnVpn' => \Symfony\Component\HttpFoundation\IpUtils::checkIp(
(string) request()->ip(),
(array) config('admin_access.trusted_ranges', []),
),
// Who may reach the console, and from where the viewer is asking —
// shown so the consequence of a change is visible before it is made.
'consoleRestricted' => \App\Http\Middleware\RestrictConsoleNetwork::isRestricted(),
'consoleIps' => (array) AppSettings::get('console.allowed_ips', []),
'consoleVpnRanges' => (array) config('admin_access.trusted_ranges', []),
'viewerIp' => (string) request()->ip(),
'staff' => $staff,
'roles' => User::OPERATOR_ROLES,
'canManageStaff' => auth()->user()->can('staff.manage'),
'isOwner' => auth()->user()->hasRole('Owner'),
]);
}
}

389
app/Livewire/Admin/Vpn.php Normal file
View File

@ -0,0 +1,389 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\VpnPeer;
use App\Provisioning\Jobs\ApplyVpnPeer;
use App\Provisioning\Jobs\SyncVpnPeers;
use App\Models\User;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\Keypair;
use App\Services\Wireguard\QrCode;
use App\Services\Wireguard\WireguardHub;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* VPN access management. The console container has no wg0, so live state is
* read from the database and refreshed by SyncVpnPeers on the provisioning
* queue the poll below only nudges that job, it never talks to WireGuard.
*/
#[Layout('layouts.admin')]
class Vpn extends Component
{
public string $name = '';
/** Optional: a peer that generated its own key never hands us the private half. */
public string $publicKey = '';
/** Opaque handle for the freshly created config; see ConfigHandoff. */
public ?string $configToken = null;
public ?string $newConfigName = null;
/** Owner of the new access. Defaults to the operator creating it. */
public ?int $ownerId = null;
/** Keep the config so its owner can fetch it again behind their password. */
public bool $storeConfig = false;
public bool $showQr = false;
public function mount(): void
{
$this->authorize('viewAny', VpnPeer::class);
$this->ownerId = auth()->id();
}
/**
* mount() runs once; every later request including the five-second poll
* only hydrates. Without this, revoking vpn.manage would not take effect
* until the operator happened to reload the page, and the open tab would
* keep serving fresh peer state.
*/
public function hydrate(): void
{
$this->authorize('viewAny', VpnPeer::class);
}
public function create(): void
{
$this->authorize('create', VpnPeer::class);
$this->validate([
'name' => 'required|string|max:255',
'publicKey' => 'nullable|string|max:64',
// An access belongs to a person, and only to an operator: a customer
// account must never own a way into the management network.
'ownerId' => ['required', Rule::exists('users', 'id')],
]);
// Storing needs a key. Without one we would either write the credential
// in the clear or pretend we stored it — both worse than saying no.
if ($this->storeConfig && ! ConfigVault::available()) {
$this->addError('storeConfig', __('vpn.vault_unavailable'));
return;
}
$ownKey = trim($this->publicKey) !== '';
if ($this->storeConfig && $ownKey) {
$this->addError('storeConfig', __('vpn.cannot_store_foreign_key'));
return;
}
if ($ownKey && ! Keypair::isValidKey(trim($this->publicKey))) {
$this->addError('publicKey', __('vpn.invalid_key'));
return;
}
$keypair = $ownKey ? null : Keypair::generate();
$publicKey = $ownKey ? trim($this->publicKey) : $keypair->publicKey;
$hub = app(WireguardHub::class);
// Same lock the host pipeline holds while it reserves an address
// (ConfigureWireguard). Addresses come from one subnet but live in two
// tables, so neither unique index can catch the other's insert — the
// shared lock is what keeps a host and an access off the same tunnel IP.
try {
// Filled inside the transaction; only the handoff needs it afterwards.
$plainConfig = null;
$peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey, $keypair, &$plainConfig) {
return DB::transaction(function () use ($hub, $publicKey, $keypair, &$plainConfig) {
// The owner row is locked for the whole insert, and revokeStaff()
// locks the same row: without that, a revocation could commit
// between the check and the insert, find no peer to remove, and
// leave the revoked colleague with a brand-new tunnel.
$owner = User::query()->whereKey($this->ownerId)->lockForUpdate()->first();
if ($owner === null || ! $owner->isOperator()) {
return 'owner_must_be_operator';
}
// Inside the lock: checking before it would let two concurrent
// requests both pass and the loser hit the unique index as a
// 500. withTrashed, because a revoked peer keeps its key until
// the hub confirms removal.
$existing = VpnPeer::withTrashed()->where('public_key', $publicKey)->first();
if ($existing !== null) {
return $existing->trashed() ? 'pending_removal' : 'duplicate_key';
}
// A host's key may not have been adopted into vpn_peers yet.
// Re-using it would make `wg set` rewrite that host's allowed-ip
// to the address allocated here — cutting the management tunnel
// to a live machine.
if (Host::query()->where('wg_pubkey', $publicKey)->exists()) {
return 'host_key';
}
$ip = $hub->allocateIp();
// Built and encrypted here so the stored config is part of the
// same insert. Doing it afterwards could leave a live access
// whose config was never stored — unrecoverable, and the
// operator would create a second one not knowing why.
$secret = null;
if ($keypair !== null) {
$plainConfig = $this->clientConfig($keypair, $ip);
if ($this->storeConfig) {
$secret = ConfigVault::encrypt($plainConfig);
}
}
return VpnPeer::create([
'name' => trim($this->name),
'kind' => VpnPeer::KIND_STAFF,
'user_id' => $this->ownerId,
'public_key' => $publicKey,
'allowed_ip' => $ip,
'config_secret' => $secret,
'enabled' => true,
'present' => false,
'created_by' => auth()->id(),
]);
});
});
} catch (QueryException) {
// Backstop: the unique index caught a writer that did not take this
// lock. Report it like any other duplicate instead of a 500.
$this->addError('publicKey', __('vpn.duplicate_key'));
return;
}
if (is_string($peer)) {
$field = $peer === 'owner_must_be_operator' ? 'ownerId' : 'publicKey';
$this->addError($field, __('vpn.'.$peer));
return;
}
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
// Whatever was on screen belongs to the previous access. Leaving it up
// after creating another one would present the wrong private config
// under the new name — and someone would hand it out.
$this->dismissConfig();
// Only a key we generated can be turned into a ready-to-use config.
if ($plainConfig !== null) {
$this->configToken = ConfigHandoff::put($plainConfig);
$this->newConfigName = $peer->name;
}
$this->reset('name', 'publicKey', 'storeConfig');
$this->ownerId = auth()->id();
$this->dispatch('notify', message: __('vpn.created'));
}
public function toggle(string $uuid): void
{
// Looked up globally and then judged by the policy: filtering it out of
// the query here would turn "not allowed" into a button that silently
// does nothing, which is exactly how the portal used to lie to people.
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
if ($peer === null) {
return;
}
$this->authorize('block', $peer);
$peer->update(['enabled' => ! $peer->enabled]);
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, $peer->enabled);
$this->dispatch('notify', message: $peer->enabled ? __('vpn.unblocked') : __('vpn.blocked'));
}
#[On('vpn-peer-deleted')]
public function peerDeleted(): void
{
$this->dispatch('notify', message: __('vpn.deleted'));
}
/**
* Replace an access's keypair.
*
* The way back for an access whose config was never stored: nobody can hand
* out a private key that no longer exists, so the honest option is a new
* one. The old key stops working the moment the hub is updated, which is
* also what makes this the right tool for a lost or leaked device.
*/
public function reissue(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->first();
if ($peer === null) {
return;
}
$this->authorize('update', $peer);
if ($peer->kind !== VpnPeer::KIND_STAFF) {
$this->dispatch('notify', message: __('vpn.reissue_staff_only'));
return;
}
$keypair = Keypair::generate();
$oldKey = $peer->public_key;
$config = $this->clientConfig($keypair, $peer->allowed_ip);
$peer->forceFill([
'public_key' => $keypair->publicKey,
'present' => false,
// Only re-stored when the vault can actually be used: without the key
// this would throw, and the console tells people to re-issue
// precisely when a stored config has become unreadable.
'config_secret' => $peer->hasStoredConfig() && ConfigVault::available()
? ConfigVault::encrypt($config)
: null,
])->save();
// Old key off the hub first, then the new one on: the other order would
// briefly leave two peers claiming the same tunnel address.
ApplyVpnPeer::dispatch($oldKey, null, false, force: true);
ApplyVpnPeer::dispatch($peer->public_key, $peer->allowed_ip, true);
$this->dismissConfig();
$this->configToken = ConfigHandoff::put($config);
$this->newConfigName = $peer->name;
$this->dispatch('notify', message: __('vpn.reissued'));
}
public function dismissConfig(): void
{
ConfigHandoff::forget($this->configToken);
$this->reset('configToken', 'newConfigName', 'showQr');
}
/**
* The list this operator is allowed to see: their own accesses, plus
* everything when they hold vpn.view.all. Filtering in the query as well as
* in the actions, so a forged uuid cannot reach a peer the page never showed.
*/
private function visiblePeers()
{
$query = VpnPeer::query();
if (! auth()->user()?->can('vpn.view.all')) {
$query->where('kind', VpnPeer::KIND_STAFF)->where('user_id', auth()->id());
}
return $query;
}
public function toggleQr(): void
{
$this->showQr = ! $this->showQr;
}
/**
* wg-quick takes the interface name from the filename, and Linux caps
* interface names at 15 characters so the label is slugged and cut rather
* than handing the operator a file that fails to come up.
*/
public function configFilename(): string
{
$slug = Str::slug((string) $this->newConfigName) ?: 'clupilot';
return Str::limit($slug, 15, '').'.conf';
}
/** Polled by the view; throttled so a room full of open tabs cannot flood the queue. */
public function refreshPeers(): void
{
if (Cache::add('vpn:sync-dispatched', true, 8)) {
SyncVpnPeers::dispatch();
}
}
/**
* What the client sends through the tunnel: the management subnet, and only
* that.
*
* It is tempting to add the server's own public address here, because the
* console lives at a public hostname and without that route the phone sends
* the request out over the mobile network instead it arrives from a
* carrier address, the proxy refuses it, and the operator sees a blank page
* while the VPN app says "connected".
*
* That fix cannot work. The WireGuard endpoint is the SAME address. Routing
* it into the tunnel routes the handshake packets into the tunnel too, and
* a tunnel cannot carry the packets that establish it the result is a
* loop and a connection that never comes up at all. Strictly worse: the
* same symptom, now with no way in.
*
* The console has to be reachable at an address that is INSIDE the subnet
* for the VPN to be the way in. That is a deployment change (the proxy
* answering on the hub address, and a name that resolves to it), not
* something this config line can express.
*/
private static function allowedIps(string $endpoint): string
{
return (string) config('provisioning.wireguard.subnet', '10.66.0.0/24');
}
private function clientConfig(Keypair $keypair, string $ip): string
{
$hub = app(WireguardHub::class);
return implode("\n", [
'[Interface]',
'PrivateKey = '.$keypair->privateKey,
'Address = '.$ip.'/32',
// Only present when the console is actually reachable inside the
// tunnel. Setting it otherwise would hand the device a resolver
// that does not exist and take its name resolution with it.
...(config('admin_access.vpn_ready')
? ['DNS = '.config('provisioning.wireguard.hub_address', '10.66.0.1')]
: []),
'',
'[Peer]',
'PublicKey = '.$hub->publicKey(),
'Endpoint = '.$hub->endpoint(),
'AllowedIPs = '.self::allowedIps($hub->endpoint()),
'PersistentKeepalive = 25',
'',
]);
}
public function render()
{
$hub = app(WireguardHub::class);
return view('livewire.admin.vpn', [
'newConfig' => $config = ConfigHandoff::get($this->configToken),
'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null,
'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(),
'operators' => User::query()
->whereHas('roles', fn ($q) => $q->whereIn('name', User::OPERATOR_ROLES))
->orderBy('name')->get(['id', 'name', 'email']),
'canManage' => auth()->user()?->can('vpn.manage.all') ?? false,
'vaultAvailable' => ConfigVault::available(),
'hubEndpoint' => $hub->endpoint(),
'hubPublicKey' => $hub->publicKey(),
'lastSync' => VpnPeer::query()->max('observed_at'),
]);
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Livewire\Admin;
use App\Models\VpnPeer;
use App\Services\Wireguard\ConfigHandoff;
use App\Services\Wireguard\ConfigVault;
use App\Services\Wireguard\QrCode;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use LivewireUI\Modal\ModalComponent;
/**
* Retrieving a stored VPN config again, behind the owner's own password.
*
* The password is asked for on EVERY retrieval, not once per session: Laravel's
* password.confirm middleware keeps a 15-minute session stamp, which would
* authorise unlimited later downloads from an unattended browser not what
* "enter your password to download it" means.
*/
class VpnConfigAccess extends ModalComponent
{
public string $uuid;
public string $name = '';
public string $password = '';
/** Opaque handle; the plaintext never enters the component snapshot. */
public ?string $token = null;
public bool $showQr = false;
public function mount(string $uuid): void
{
$peer = VpnPeer::query()->where('uuid', $uuid)->firstOrFail();
$this->authorize('downloadConfig', $peer);
$this->uuid = $uuid;
$this->name = $peer->name;
}
/**
* Every later request re-checks: once revealed, the plaintext sits in the
* handoff cache for ten minutes, and an open modal would otherwise keep
* handing it out after the access was revoked or reassigned exactly the
* window purgeSecret() exists to close.
*/
public function hydrate(): void
{
$peer = VpnPeer::withTrashed()->where('uuid', $this->uuid)->first();
if ($peer === null || Gate::denies('downloadConfig', $peer)) {
ConfigHandoff::forget($this->token);
$this->token = null;
abort(403);
}
}
public function reveal(): void
{
// A retry must not still be showing the previous attempt's complaint.
$this->resetErrorBag('password');
$peer = VpnPeer::query()->where('uuid', $this->uuid)->firstOrFail();
$this->authorize('downloadConfig', $peer);
// Rate-limited per user, so a stolen session cannot grind the password.
$key = 'vpn-config:'.auth()->id();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('password', __('vpn.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)]));
return;
}
if (! Hash::check($this->password, auth()->user()->password)) {
RateLimiter::hit($key, 300);
Log::warning('VPN config download refused: wrong password', [
'user_id' => auth()->id(), 'peer' => $peer->uuid,
]);
$this->addError('password', __('vpn.wrong_password'));
return;
}
RateLimiter::clear($key);
$plaintext = ConfigVault::decrypt((string) $peer->config_secret);
if ($plaintext === null) {
// Wrong or rotated key, or a tampered record. Never guess.
$this->addError('password', __('vpn.config_unreadable'));
return;
}
// Counted in the database, not read-modify-written here: two concurrent
// retrievals would otherwise record one. This is an audit trail, so
// under-reporting is the one failure mode it must not have.
VpnPeer::query()->whereKey($peer->getKey())->update([
'last_downloaded_at' => now(),
'download_count' => DB::raw('download_count + 1'),
]);
Log::info('VPN config downloaded', ['user_id' => auth()->id(), 'peer' => $peer->uuid]);
$this->reset('password');
$this->token = ConfigHandoff::put($plaintext);
}
public function toggleQr(): void
{
$this->showQr = ! $this->showQr;
}
public function filename(): string
{
return Str::limit(Str::slug($this->name) ?: 'clupilot', 15, '').'.conf';
}
public function render()
{
$config = ConfigHandoff::get($this->token);
return view('livewire.admin.vpn-config-access', [
'config' => $config,
'qrSvg' => $config !== null && $this->showQr ? QrCode::svg($config) : null,
]);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal')]
class Login extends Component
{
public function render()
{
return view('livewire.auth.login');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Full-page signup view (R1/R2). The POST is handled by Fortify's registration
* feature (register.store App\Actions\Fortify\CreateNewUser).
*/
#[Layout('layouts.portal')]
class Register extends Component
{
public function render()
{
return view('livewire.auth.register');
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Livewire\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal')]
class TwoFactorChallenge extends Component
{
public function render()
{
return view('livewire.auth.two-factor-challenge');
}
}

58
app/Livewire/Backups.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Backups extends Component
{
public function render()
{
$locale = app()->getLocale();
$gb = fn (float $v) => Number::format($v, precision: 1, locale: $locale).' GB';
$date = fn (string $iso, string $fmt) => Carbon::parse($iso)->local()->locale($locale)->isoFormat($fmt);
// 14 days of backup sizes for the bar chart.
$sizes = [18.1, 18.1, 18.2, 18.2, 18.2, 18.3, 18.3, 18.3, 18.3, 18.4, 18.4, 18.3, 18.4, 18.4];
$rows = [];
foreach (range(0, 6) as $i) {
$rows[] = [
'when' => $date(Carbon::parse('2026-07-24')->subDays($i)->toDateString(), 'dd, D. MMMM').', 03:1'.($i % 3 + 1),
'size' => $gb(18.4 - $i * 0.02),
'status' => 'ok',
];
}
return view('livewire.backups', [
'rows' => $rows,
'lastTest' => $date('2026-07-01', 'LL'),
'sizeChart' => [
'type' => 'line',
'data' => [
'labels' => array_map(fn ($i) => $date(Carbon::parse('2026-07-24')->subDays(13 - $i)->toDateString(), 'D.'), range(0, 13)),
'datasets' => [[
'label' => 'GB',
'data' => $sizes,
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

204
app/Livewire/Billing.php Normal file
View File

@ -0,0 +1,204 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\DowngradeCheck;
use App\Services\Billing\PlanCatalogue;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Billing extends Component
{
use ResolvesCustomer;
/**
* Record a purchase intent (order). Fulfillment (Stripe checkout + resize) is
* mocked for now the order is created with status 'pending'.
*/
public function purchase(string $type, ?string $key = null): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$instance = $customer->instances()->latest('id')->first();
$currentPlan = $instance?->plan ?? 'start';
$plans = app(PlanCatalogue::class)->sellable();
$addons = (array) config('provisioning.addons');
[$plan, $amount, $addonKey] = match ($type) {
'upgrade', 'downgrade' => [$key, (int) ($plans[$key]['price_cents'] ?? 0), null],
'storage' => [$currentPlan, (int) config('provisioning.storage_addon.price_cents', 0), null],
'traffic' => [$currentPlan, (int) config('provisioning.traffic.addon.price_cents', 0), null],
'addon' => [$currentPlan, (int) ($addons[$key]['price_cents'] ?? 0), $key],
default => [null, 0, null],
};
// Guard against invalid keys / non-upgrades.
$valid = match ($type) {
// By rank, matching PlanChange and the cards on the page. Comparing
// prices would call a grandfathered plan an upgrade to a smaller
// one and charge for the privilege.
'upgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) > (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0),
// Re-checked here and not only in the view: the button can be
// hidden and the action still called — a stale tab, a second
// window, anyone with the component name. A limit enforced only in
// markup is not enforced.
'downgrade' => isset($plans[$key])
&& (int) ($plans[$key]['tier'] ?? 0) < (int) ($instance?->subscription?->tier ?? $plans[$currentPlan]['tier'] ?? 0)
&& DowngradeCheck::for($customer, $instance, $plans[$key])->allowed,
'storage' => true,
// Always available: running out of traffic is exactly when someone
// needs to be able to buy more, whatever plan they are on.
'traffic' => true,
'addon' => isset($addons[$key]),
default => false,
};
if (! $valid || $plan === null) {
return;
}
$datacenter = $instance?->host?->datacenter
?? $customer->orders()->latest('id')->value('datacenter')
?? 'fsn';
// A cart cannot hold two plan changes: they contradict each other and no
// checkout could resolve which one the customer meant. Choosing another
// replaces the pending one — add-ons still stack.
//
// Replacement and insert run together with the customer row locked:
// two clicks in flight could otherwise both delete and then both
// insert, leaving exactly the two upgrades this rule exists to prevent.
$replaced = DB::transaction(function () use ($customer, $type, $plan, $addonKey, $amount, $datacenter) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
// Up and down are the same kind of change and contradict each
// other just as much, so either replaces a pending one of both.
$replaced = in_array($type, ['upgrade', 'downgrade'], true)
? Order::query()
->where('customer_id', $customer->id)
->where('status', 'pending')
->whereIn('type', ['upgrade', 'downgrade'])
->delete()
: 0;
Order::create([
'customer_id' => $customer->id,
'plan' => $plan,
'type' => $type,
'addon_key' => $addonKey,
'amount_cents' => $amount,
'currency' => 'EUR',
'datacenter' => $datacenter,
'status' => 'pending',
]);
return $replaced;
});
if ($replaced > 0) {
$this->dispatch('notify', message: __('billing.cart.plan_replaced'));
}
$this->dispatch('notify', message: __('billing.purchased'));
}
#[\Livewire\Attributes\On('order-removed')]
public function orderRemoved(): void
{
$this->dispatch('notify', message: __('billing.cart.removed'));
}
/**
* The terms to show as "your plan": the contract if there is one, and only
* otherwise the catalogue someone browsing before they have bought.
*
* @param array<string, mixed> $catalogue
* @return array<string, mixed>
*/
private function currentTerms(?Subscription $subscription, array $catalogue): array
{
if ($subscription === null) {
return $catalogue;
}
return [
'tier' => $subscription->tier,
// Per month: the card says "/ month", and a yearly contract stores
// the whole year.
'price_cents' => $subscription->monthlyPriceCents(),
'currency' => $subscription->currency,
'quota_gb' => $subscription->quota_gb,
'traffic_gb' => $subscription->traffic_gb,
'seats' => $subscription->seats,
'performance' => $subscription->performance,
'features' => $subscription->planVersion?->features ?? ($catalogue['features'] ?? []),
];
}
public function render()
{
$customer = $this->customer();
$instance = $customer?->instances()->latest('id')->first();
$plans = app(PlanCatalogue::class)->sellable();
$currentKey = $instance?->plan ?? 'start';
// Rank, not price: a grandfathered plan can cost less than a smaller
// one does today, and offering that as an "upgrade" would charge a
// customer immediately for losing resources.
$currentTier = (int) ($instance?->subscription?->tier ?? $plans[$currentKey]['tier'] ?? 0);
// Smaller plans, each with the reason it cannot be taken today. Shown
// WITH the reason rather than hidden: a customer who cannot downgrade
// needs to know what to delete, and a plan that silently disappears
// reads as "not offered any more".
$downgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) < $currentTier)
->sortByDesc(fn ($p) => (int) ($p['tier'] ?? 0))
->map(fn ($p, $k) => $p + ['check' => DowngradeCheck::for($customer, $instance, $p)])
->all();
$upgrades = collect($plans)
->filter(fn ($p) => (int) ($p['tier'] ?? 0) > $currentTier)
->keys()->all();
return view('livewire.billing', [
'currentKey' => $currentKey,
// What they HAVE comes from their contract; only what they could
// BUY comes from the shop. Reading this off the catalogue showed a
// customer today's price as though it were theirs, and dropped the
// card entirely once their plan stopped being sold.
'current' => $this->currentTerms($instance?->subscription, $plans[$currentKey] ?? []),
'instance' => $instance,
'plans' => $plans,
'features' => collect($plans)->map(fn ($p) => $p['features'] ?? [])->all(),
'upgrades' => $upgrades,
'downgrades' => $downgrades,
'storage' => (array) config('provisioning.storage_addon'),
'trafficAddon' => (array) config('provisioning.traffic.addon'),
// Resolved once: the cart and the plan cards must not state
// different tax treatments on the same page.
'tax' => \App\Services\Billing\TaxTreatment::for($customer),
'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null,
// Booked modules at the price they were booked at; everything else
// at today's. Reading them all off the catalogue would re-price
// half a customer's bill behind their back.
'addons' => collect(app(AddonCatalogue::class)->forSubscription($instance?->subscription))
->except(AddonCatalogue::STORAGE)
->all(),
'totalMonthlyCents' => $instance?->subscription?->totalMonthlyCents(),
'pending' => $customer
? $customer->orders()->where('status', 'pending')->latest('id')->get()
: collect(),
]);
}
}

89
app/Livewire/Cloud.php Normal file
View File

@ -0,0 +1,89 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\MaintenanceWindow;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Cloud extends Component
{
use ResolvesCustomer;
public function render()
{
$locale = app()->getLocale();
$growth = [180, 188, 195, 199, 204, 210, 214, 219, 223, 228, 231, 235];
// The card is built from the customer's real service instance, so the
// maintenance badge below can be scoped to exactly that instance's host.
$customer = $this->customer();
$shown = $customer?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')->first();
$maintenance = MaintenanceWindow::forInstance($shown)->first();
// The instance carries what was actually provisioned, and the contract
// what was bought. Neither is the catalogue, which only describes what
// we would sell someone new — never what this customer already has.
$contract = $shown?->subscription;
$planKey = $shown?->plan ?? 'team';
$quota = (int) ($shown?->quota_gb ?? $contract?->quota_gb ?? 500);
// Usage metering is not wired yet — scale the illustrative curve into the
// instance's quota so the chart and the "x / y GB" label never disagree.
$curveMax = max($growth);
if ($curveMax > $quota) {
$growth = array_map(fn ($v) => (int) round($v / $curveMax * $quota), $growth);
}
$used = $growth[count($growth) - 1];
$domain = $shown?->custom_domain
?: ($shown?->subdomain ? $shown->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com') : 'cloud.example.com');
return view('livewire.cloud', [
'instance' => [
'name' => $customer ? __('cloud.instance_name', ['name' => $customer->name]) : __('cloud.title'),
'domain' => $domain,
'status' => $shown->status ?? 'active',
'plan' => __('cloud.plan_line', [
'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey),
// The line ends in "/mo", so a yearly contract has to be
// divided down before it goes in.
'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100),
]),
'location' => __('cloud.datacenter'),
'storageUsed' => $used,
'storageQuota' => $quota,
'seats' => __('billing.seats_count', ['count' => $contract?->seats ?? 0]),
'performance' => __('billing.perf.'.($contract?->performance ?? 'standard')),
],
'storageChart' => [
'type' => 'line',
'data' => [
'labels' => ['', '', '', '', '', '', '', '', '', '', '', __('cloud.now')],
'datasets' => [[
'label' => 'GB',
'data' => $growth,
'borderColor' => 'token:accent',
'backgroundColor' => 'token:accent/0.10',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => false, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
'storageLabel' => Number::format($used, locale: $locale).' / '.Number::format($quota, locale: $locale).' GB',
'maintenance' => $maintenance,
]);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\ProvisioningRun;
/**
* Builds the {label, state} array a progress stepper renders from a run's
* pipeline + current step + status. Shared by admin and customer views.
*/
trait BuildsRunSteps
{
/** @return array<int, array{label: string, state: string}> */
protected function buildRunSteps(?ProvisioningRun $run): array
{
if ($run === null) {
return [];
}
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$current = $run->current_step;
$status = $run->status;
$steps = [];
foreach ($pipeline as $index => $class) {
if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) {
$state = 'done';
} elseif ($index === $current) {
$state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running';
} else {
$state = 'pending';
}
$steps[] = ['label' => __(app($class)->label()), 'state' => $state];
}
return $steps;
}
protected function currentStepLabel(ProvisioningRun $run): string
{
$pipeline = config('provisioning.pipelines.'.$run->pipeline, []);
$class = $pipeline[$run->current_step] ?? null;
return $class ? __(app($class)->label()) : '—';
}
protected function runState(ProvisioningRun $run): string
{
return match ($run->status) {
ProvisioningRun::STATUS_COMPLETED => 'done',
ProvisioningRun::STATUS_FAILED => 'failed',
default => 'running',
};
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Livewire\Concerns;
use App\Actions\Fortify\UpdateUserPassword;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
/**
* Changing your own password, from wherever you are signed in.
*
* There was no way to do it at all: Fortify's updatePasswords feature was off,
* so an account created with a generated password kept it until someone opened
* a shell on the server. Shared by the console and the customer portal because
* the rules are the same in both, and having two copies of a password rule is
* how one of them ends up weaker.
*
* Goes through Fortify's own action rather than hashing here, so the password
* rules stay in the single place that already defines them.
*/
trait ChangesOwnPassword
{
public string $currentPassword = '';
public string $newPassword = '';
public string $newPasswordConfirmation = '';
public function updateOwnPassword(): void
{
// The current password is asked for, and checked here as well as in the
// action: an attacker on an unlocked machine should not be able to lock
// the owner out of their own account with two keystrokes.
$this->validate([
'currentPassword' => 'required|string',
'newPassword' => 'required|string',
'newPasswordConfirmation' => 'required|string',
]);
if (! Hash::check($this->currentPassword, auth()->user()->password)) {
$this->addError('currentPassword', __('admin_settings.password_wrong'));
return;
}
try {
app(UpdateUserPassword::class)->update(auth()->user(), [
'current_password' => $this->currentPassword,
'password' => $this->newPassword,
'password_confirmation' => $this->newPasswordConfirmation,
]);
} catch (ValidationException $e) {
foreach ($e->errors() as $field => $messages) {
$this->addError(match ($field) {
'current_password' => 'currentPassword',
'password' => 'newPassword',
default => $field,
}, $messages[0]);
}
return;
}
$this->reset('currentPassword', 'newPassword', 'newPasswordConfirmation');
$this->dispatch('notify', message: __('admin_settings.password_changed'));
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Livewire\Concerns;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
/**
* A password re-entry gate in front of the dangerous parts of a page.
*
* Used by anything where holding a signed-in session should not be enough on
* its own: setting up two-factor authentication, and reading or changing stored
* credentials. The threat is not a stranger it is a colleague, or anyone, at
* an unlocked machine.
*
* Stored in the SAME session key Laravel's own `password.confirm` middleware
* uses, so a confirmation made here also satisfies any framework route behind
* that middleware, and vice versa. Reinventing the marker would have produced
* two independent notions of "recently confirmed", one of which would drift.
*
* Rate-limited, because a confirmation form is a password oracle: without a
* limit it is an unauthenticated-feeling place to guess at a known account.
*/
trait ConfirmsPassword
{
public string $confirmablePassword = '';
/** How long one confirmation lasts. Laravel's own default. */
protected function confirmationWindow(): int
{
return (int) config('auth.password_timeout', 10800);
}
public function passwordRecentlyConfirmed(): bool
{
$at = (int) session('auth.password_confirmed_at', 0);
return $at > 0 && (time() - $at) < $this->confirmationWindow();
}
public function confirmPassword(): void
{
$key = 'confirm-password:'.auth()->id().'|'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
$this->addError('confirmablePassword', __('auth.throttle', [
'seconds' => RateLimiter::availableIn($key),
]));
return;
}
if (! Hash::check($this->confirmablePassword, auth()->user()->password)) {
RateLimiter::hit($key, 60);
$this->addError('confirmablePassword', __('admin_settings.password_wrong'));
$this->reset('confirmablePassword');
return;
}
RateLimiter::clear($key);
$this->reset('confirmablePassword');
// Regenerated first: a confirmation raises what this session can do, and
// a session id that was already known to someone else must not be the
// one that gets the extra authority.
session()->regenerate();
session(['auth.password_confirmed_at' => time()]);
}
/** Give up the confirmation early — leaving a page should not keep it open. */
public function forgetPasswordConfirmation(): void
{
session()->forget('auth.password_confirmed_at');
}
/**
* A value shown only in outline: the last four characters, and nothing else.
*
* Enough to tell two keys apart, useless to anyone reading over a shoulder
* or scrolling back through a screen recording.
*/
protected function outline(?string $value, int $keep = 4): ?string
{
if ($value === null || $value === '') {
return null;
}
return Str::length($value) <= $keep
? str_repeat('•', Str::length($value))
: str_repeat('•', 8).Str::substr($value, -$keep);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Customer;
/**
* Resolves the customer behind the signed-in portal user.
*
* Operator accounts (admins) have no Customer, so customer-scoped actions must
* NOT silently no-op that reads as a dead button. requireCustomer() surfaces
* an explicit message instead and returns null so the caller can bail.
*/
trait ResolvesCustomer
{
protected function customer(): ?Customer
{
$user = auth()->user();
if (! $user) {
return null;
}
// Prefer the explicit link; fall back to email for legacy unlinked accounts.
return Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
}
/** Same as customer(), but tells the user why nothing happened. */
protected function requireCustomer(): ?Customer
{
$customer = $this->customer();
if ($customer === null) {
$this->dispatch('notify', message: __('dashboard.no_customer_action'));
}
return $customer;
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Instance;
use LivewireUI\Modal\ModalComponent;
/**
* Cancel the customer's package (R5). Per the founder's decision: effective at
* the end of the billing term, irreversible once confirmed; at term end the
* customer receives a finished data export, then the instance is deprovisioned
* (both mocked for now). Requires typing the instance subdomain to confirm.
*/
class ConfirmCancelPackage extends ModalComponent
{
use ResolvesCustomer;
public string $confirmName = '';
public function cancelPackage()
{
$customer = $this->customer();
// Target the ACTIVE package explicitly — a newer failed/deprovisioned
// record must not shadow an older active instance (v1: one active/customer).
$instance = $customer?->instances()->where('status', 'active')->latest('id')->first();
if ($instance === null) {
// Explain rather than silently closing: no customer behind this login
// (e.g. an operator account) or no active package to cancel.
$this->addError('confirmName', __($customer === null ? 'dashboard.no_customer_action' : 'settings.no_package'));
return null;
}
// Typed confirmation must match the instance subdomain.
if (trim($this->confirmName) !== (string) $instance->subdomain) {
$this->addError('confirmName', __('settings.cancel_mismatch'));
return;
}
$instance->update([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => now(),
'service_ends_at' => $this->currentPeriodEnd($instance),
]);
return $this->redirectRoute('settings', navigate: true);
}
/**
* End of the current monthly billing period, anchored on the subscription
* start (the order date) rather than assuming calendar-month billing.
*/
private function currentPeriodEnd(Instance $instance): \Illuminate\Support\Carbon
{
$start = $instance->order?->created_at ?? $instance->created_at ?? now();
// Always add from the immutable start so the billing-day anchor is kept
// (chaining addMonth would drift a Jan-31 start to Feb-28 → Mar-28).
$months = 1;
while ($start->copy()->addMonthsNoOverflow($months)->lessThanOrEqualTo(now())) {
$months++;
}
return $start->copy()->addMonthsNoOverflow($months);
}
public function render()
{
$instance = $this->customer()?->instances()->where('status', 'active')->latest('id')->first();
return view('livewire.confirm-cancel-package', [
'subdomain' => $instance?->subdomain ?? '',
]);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use LivewireUI\Modal\ModalComponent;
/**
* Close the whole CluPilot account (R5). Guarded: only allowed when no active
* package remains an active subscription must be cancelled first. Sets
* closed_at; financial records are retained. Requires typing "LOESCHEN".
*/
class ConfirmCloseAccount extends ModalComponent
{
use ResolvesCustomer;
public string $confirmWord = '';
public function closeAccount()
{
$customer = $this->customer();
// No customer behind this login (e.g. an operator account) — say so
// instead of leaving the button looking broken.
if ($customer === null) {
$this->addError('confirmWord', __('dashboard.no_customer_action'));
return null;
}
if ($this->hasActivePackage($customer)) {
$this->addError('confirmWord', __('settings.close_blocked'));
return;
}
if (mb_strtoupper(trim($this->confirmWord)) !== __('settings.close_keyword')) {
$this->addError('confirmWord', __('settings.close_mismatch'));
return;
}
$customer->update(['closed_at' => now(), 'status' => 'closed']);
return $this->redirectRoute('settings', navigate: true);
}
private function hasActivePackage(Customer $customer): bool
{
// Only genuinely-live packages block closure — a failed/cancelled/
// deprovisioned instance does not retain a service.
return $customer->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->exists();
}
public function render()
{
$customer = $this->customer();
return view('livewire.confirm-close-account', [
'blocked' => $customer !== null && $this->hasActivePackage($customer),
]);
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Order;
use LivewireUI\Modal\ModalComponent;
/**
* Taking a not-yet-paid purchase back out of the cart.
*
* Scoped to the signed-in customer's own orders: modals are reachable without
* the page's own guards, so the ownership check belongs here, not in the view
* that happens to link to it.
*/
class ConfirmRemoveOrder extends ModalComponent
{
use ResolvesCustomer;
public string $uuid;
public string $label = '';
public int $amountCents = 0;
public function mount(string $uuid): void
{
$order = $this->order($uuid);
abort_if($order === null, 404);
$this->uuid = $uuid;
$this->label = $order->label();
$this->amountCents = $order->amount_cents;
}
public function remove(): void
{
$order = $this->order($this->uuid);
// Gone or already paid for: say so instead of pretending it worked.
if ($order === null) {
$this->dispatch('notify', message: __('billing.cart.gone'));
$this->closeModal();
return;
}
$order->delete();
$this->dispatch('order-removed');
$this->closeModal();
}
/** @return Order|null the customer's own, still-pending order */
private function order(string $uuid): ?Order
{
$customer = $this->customer();
if ($customer === null) {
return null;
}
return Order::query()
->where('customer_id', $customer->id)
->where('uuid', $uuid)
->where('status', 'pending')
->first();
}
public function render()
{
return view('livewire.confirm-remove-order');
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\BuildsRunSteps;
use App\Models\Customer;
use App\Models\Order;
use App\Models\ProvisioningRun;
use Livewire\Component;
/**
* Embedded live provisioning progress for the logged-in customer's order.
* Polls only itself so the dashboard's charts don't churn. Renders nothing once
* provisioning has completed.
*/
class CustomerProvisioning extends Component
{
use BuildsRunSteps;
private function currentRun(): ?ProvisioningRun
{
$user = auth()->user();
if ($user === null) {
return null;
}
$customer = Customer::query()->where('email', $user->email)->first();
if ($customer === null) {
return null;
}
return ProvisioningRun::query()
->where('subject_type', Order::class)
->whereIn('subject_id', $customer->orders()->select('id'))
->latest('id')
->first();
}
public function render()
{
$run = $this->currentRun();
// Show the card while provisioning is in flight OR terminally failed; but
// only POLL while it can still change (terminal failed must not poll forever).
$show = $run !== null && $run->status !== ProvisioningRun::STATUS_COMPLETED;
$polling = $run !== null && in_array($run->status, [
ProvisioningRun::STATUS_PENDING,
ProvisioningRun::STATUS_RUNNING,
ProvisioningRun::STATUS_WAITING,
], true);
return view('livewire.customer-provisioning', [
'active' => $show,
'polling' => $polling,
'failed' => $run?->status === ProvisioningRun::STATUS_FAILED,
'steps' => $show ? $this->buildRunSteps($run) : [],
]);
}
}

320
app/Livewire/Dashboard.php Normal file
View File

@ -0,0 +1,320 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\MaintenanceWindow;
use App\Models\Seat;
use App\Models\Subscription;
use App\Services\Traffic\TrafficMeter;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* The customer's Betriebsblatt: what they have, and the evidence that it is
* being looked after.
*
* Every figure on this page comes from a record. Where something is not
* measured yet storage consumption, restore tests the page says nothing
* rather than showing a plausible number: this is the sheet a customer forwards
* to their auditor, and one invented line on it is worse than a short register.
*/
#[Layout('layouts.portal-app')]
class Dashboard extends Component
{
use ResolvesCustomer;
public function render()
{
$customer = $this->customer();
// The instance that is actually in service. A cancelled one still has
// rows, and would otherwise keep filling this page after it is gone.
$instance = $customer?->instances()
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->latest('id')
->first();
$contract = $instance?->subscription;
$maintenance = MaintenanceWindow::forInstance($instance)->first();
return view('livewire.dashboard', [
'customer' => $customer,
'instance' => $instance,
'contract' => $contract,
'domain' => $this->domain($instance),
'location' => $this->location($instance),
'seats' => $this->seats($customer, $contract),
'traffic' => $instance !== null ? TrafficMeter::for($instance) : null,
// The measured series behind the template's ring and trend. Null
// where nothing has been sampled yet — a new instance has no
// fortnight, and drawing one at zero would tell its owner their
// data had vanished.
'disk' => $instance !== null ? $this->disk($instance) : null,
'availability' => $instance !== null ? InstanceMetric::availability($instance) : null,
'availabilityTrend' => $instance !== null ? InstanceMetric::availabilitySeries($instance) : [],
'seatBreakdown' => $this->seatBreakdown($customer),
'trend' => $instance !== null
? InstanceMetric::series($instance)->map(fn (InstanceMetric $m) => $m->rx_bytes + $m->tx_bytes)->all()
: [],
'proofs' => $this->proofs($instance, $maintenance),
'openTasks' => $instance?->onboardingTasks()->where('done', false)->count() ?? 0,
// What the plan costs is true whether or not another invoice is
// coming — a customer who has given notice still pays until the
// term ends and still needs the figure on their master record.
'planPrice' => $contract === null ? null : [
'cents' => (int) $contract->price_cents,
'currency' => (string) ($contract->currency ?: 'EUR'),
'term' => (string) $contract->term,
],
'nextInvoice' => $this->nextInvoice($contract, $instance),
'asOf' => Carbon::now(),
]);
}
/**
* Who holds the seats, so the card can say something true underneath the
* figure rather than repeating it.
*
* @return array<string, int> role => count, roles with nobody omitted
*/
private function seatBreakdown(?Customer $customer): array
{
if ($customer === null) {
return [];
}
return Seat::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'invited'])
->selectRaw('role, COUNT(*) as n')
->groupBy('role')
->pluck('n', 'role')
->filter()
->all();
}
/**
* How full the instance is, as measured not as sold.
*
* Null until the sampler has managed a reading. The card then states the
* contractual allowance, which is true, rather than a ring at a level
* nobody measured.
*
* @return array{used: int, total: int, percent: float, week_delta: int|null}|null
*/
private function disk(Instance $instance): ?array
{
$metric = InstanceMetric::latestDisk($instance);
if ($metric === null || ! $metric->disk_total_bytes) {
return null;
}
// What it grew by this week, which is the line the template carries
// under the ring. Null when there is no reading a week back to compare
// against — an instance three days old has no weekly trend, and
// inventing "+0 GB" would read as "nothing happened".
$weekAgo = InstanceMetric::query()
->where('instance_id', $instance->id)
->whereNotNull('disk_used_bytes')
->where('day', '<=', now()->subDays(7)->toDateString())
->orderByDesc('day')
->first();
return [
'used' => $metric->disk_used_bytes,
'total' => $metric->disk_total_bytes,
'percent' => round($metric->disk_used_bytes / max(1, $metric->disk_total_bytes) * 100, 1),
'week_delta' => $weekAgo !== null ? $metric->disk_used_bytes - $weekAgo->disk_used_bytes : null,
];
}
/** The address the customer actually reaches their cloud at. */
private function domain(?Instance $instance): ?string
{
if ($instance === null) {
return null;
}
return $instance->custom_domain
?: ($instance->subdomain
? $instance->subdomain.'.'.config('provisioning.dns.zone', 'clupilot.com')
: null);
}
/**
* Where the data physically sits.
*
* Named from the datacenter register rather than from a translation string,
* because this is the line a customer copies into a processing record it
* has to be the place their instance really runs on, not the place we
* usually sell.
*
* @return array{name: string, note: null}|null
*/
private function location(?Instance $instance): ?array
{
$code = $instance?->host?->datacenter;
if ($code === null || $code === '') {
return null;
}
$datacenter = Datacenter::query()->where('code', $code)->first();
// The country, not the site. "Falkenstein" is how an operator places an
// instance; a customer's processing record says which jurisdiction the
// data sits in, and naming the building means editing customer-facing
// copy every time the estate grows.
return [
'name' => $datacenter?->location ?: $code,
'note' => null,
];
}
/**
* Seats in use against seats bought.
*
* Invited seats count as used: the licence is committed the moment the
* invitation goes out, and showing them as free is how a customer finds out
* at the worst moment that they cannot add the person in front of them.
*
* @return array{used: int, total: int|null}
*/
private function seats(?Customer $customer, ?Subscription $contract): array
{
return [
'used' => $customer === null ? 0 : Seat::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'invited'])
->count(),
'total' => $contract?->seats,
];
}
/**
* The proof register what was done for this customer, and when.
*
* Only entries backed by a record. A row here is something the customer can
* be asked to evidence, so "we back up nightly" is not one; "the last backup
* completed at 03:12 and reported ok" is.
*
* @return array<int, array{key: string, at: \Illuminate\Support\Carbon|null, state: string, note: string|null}>
*/
private function proofs(?Instance $instance, ?MaintenanceWindow $maintenance): array
{
if ($instance === null) {
return [];
}
$proofs = [];
// A failing job outranks a succeeding one, whatever their dates.
//
// Ordering by last_ok_at alone hides exactly the case this register
// exists for: last night's job failed and so has no success time at
// all, an older row that DID succeed sorts above it, and the sheet
// reports backups as fine. On the page a customer shows their auditor,
// a problem has to beat a date.
$backups = $instance->backups()->get();
$backup = $backups->first(fn ($b) => $b->status !== 'ok')
?? $backups->sortByDesc('last_ok_at')->first();
if ($backup !== null) {
$proofs[] = [
'key' => 'backup',
'at' => $backup->last_ok_at,
// The job's own verdict, not "there is a row, so it worked".
'state' => $backup->status === 'ok' ? 'ok' : 'attention',
'note' => $backup->schedule,
];
}
// A certificate that stopped renewing is the failure a customer meets as
// "my browser says my own cloud is unsafe", so it is stated either way.
$proofs[] = [
'key' => 'certificate',
'at' => null,
'state' => $instance->cert_ok ? 'ok' : 'attention',
'note' => null,
];
if ($maintenance !== null) {
$proofs[] = [
'key' => 'maintenance',
'at' => $maintenance->starts_at,
'state' => 'planned',
'note' => $maintenance->title,
];
}
if ($instance->service_ends_at !== null) {
$proofs[] = [
'key' => 'service_ends',
'at' => $instance->service_ends_at,
'state' => 'attention',
'note' => null,
];
}
// Newest first, but an entry with no time of its own — the certificate —
// stays at the top rather than sinking to the bottom of the register.
usort($proofs, fn (array $a, array $b) => ($b['at']?->timestamp ?? PHP_INT_MAX) <=> ($a['at']?->timestamp ?? PHP_INT_MAX));
return $proofs;
}
/**
* What is owed next, from the contract rather than from a price list.
*
* The plan and the modules are kept apart. A customer with modules booked
* would otherwise read the plan's price as their bill and be short every
* month and putting the combined figure next to the word "Paket" on the
* master record would be just as wrong in the other direction.
*
* Add-ons carry a MONTHLY price (SubscriptionAddon::monthlyCents), while the
* plan's price_cents is the price of a whole term. On a yearly contract the
* modules therefore multiply by twelve to land on the same invoice.
*
* Nothing at all once the customer has given notice. Cancelling leaves the
* subscription `active` until the term runs out that is deliberate, the
* service keeps running but `current_period_end` is then the day service
* ENDS, not the day the next charge falls. Billing a customer, on the sheet
* they check their invoices against, for a renewal that will never happen is
* the worst single line this page could carry. The end date is still stated;
* it is in the proof register as "contract ends".
*
* @return array{plan: int, addons: int, total: int, currency: string, due: \Illuminate\Support\Carbon|null, term: string}|null
*/
private function nextInvoice(?Subscription $contract, ?Instance $instance): ?array
{
if ($contract === null || $contract->status === 'cancelled') {
return null;
}
if ($instance?->cancel_requested_at !== null || $instance?->service_ends_at !== null) {
return null;
}
$months = $contract->isYearly() ? 12 : 1;
$addons = $contract->addons
->whereNull('cancelled_at')
->sum(fn ($addon) => $addon->monthlyCents()) * $months;
return [
'plan' => (int) $contract->price_cents,
'addons' => (int) $addons,
'total' => (int) $contract->price_cents + (int) $addons,
'currency' => (string) ($contract->currency ?: 'EUR'),
'due' => $contract->current_period_end,
'term' => (string) $contract->term,
];
}
}

View File

@ -0,0 +1,154 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ConfirmsPassword;
use App\Models\Mailbox;
use App\Services\Mail\MailPurpose;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use LivewireUI\Modal\ModalComponent;
/**
* Editing a mailbox, in a modal (R20).
*
* A modal is reachable WITHOUT the page's route middleware, so it authorises
* itself and re-reads the record instead of trusting a property the browser
* hydrated. save() re-authorises independently of mount() for the same reason
* Secrets::guard() checks on every action rather than once: a Livewire action
* is reachable by anyone who can post to /livewire/update, and a mount that
* succeeded earlier in the session proves nothing about the request now.
*/
class EditMailbox extends ModalComponent
{
use ConfirmsPassword;
public string $uuid = '';
public string $address = '';
public string $displayName = '';
public string $username = '';
/** Always starts empty: a stored password never travels to the browser. */
public string $password = '';
public bool $noReply = false;
public bool $active = true;
/** Whether this mailbox logs in before it sends — Codex R15#4, P1b. */
public bool $authenticates = true;
public function mount(string $uuid): void
{
$this->authorize('mail.manage');
$box = Mailbox::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $box->uuid;
$this->address = $box->address;
$this->displayName = (string) $box->display_name;
$this->username = (string) $box->username;
$this->noReply = $box->no_reply;
$this->active = $box->active;
$this->authenticates = $box->authenticates;
}
public function save(): void
{
$this->authorize('mail.manage');
$this->validate([
'address' => ['required', 'email', 'max:255'],
'displayName' => ['nullable', 'string', 'max:255'],
'username' => ['nullable', 'string', 'max:255'],
'password' => ['nullable', 'string', 'max:255'],
]);
$box = Mailbox::query()->where('uuid', $this->uuid)->firstOrFail();
// Deactivating the mailbox "system" currently points at is not just
// this purpose losing its sender: MailboxResolver::active() filters an
// inactive mailbox at BOTH the direct lookup AND the system fallback,
// so every OTHER purpose that has no mailbox of its own falls through
// to nothing too. savePurposes() already refuses to leave system
// empty; this closes the other way to reach the same broken state.
if (! $this->active && $box->key === (string) Settings::get(MailPurpose::settingKey(MailPurpose::SYSTEM))) {
$this->addError('active', __('mail_settings.cannot_deactivate_system'));
return;
}
// Setting a new SMTP password is one of the two actions the
// mail.manage split still leaves able to intercept mail outright (the
// other is Admin\Mail::saveServer()) — gated the same second way
// Admin\Secrets is: a capability decides who may open this modal at
// all, a recent password decides whether THIS session may point the
// outgoing account at new credentials.
if ($this->password !== '') {
abort_unless($this->passwordRecentlyConfirmed(), 403);
// A password can only be STORED where SECRETS_KEY exists to
// encrypt it under — the same condition the page's own banner
// already names. Checked here rather than left for
// Mailbox::setPasswordAttribute() to throw through: an uncaught
// RuntimeException would render Laravel's debug page, whose
// request-payload inspector can show the very password just
// typed. The page must say so, not crash on it.
if (! app(SecretCipher::class)->isUsable()) {
$this->addError('password', __('mail_settings.no_key'));
return;
}
}
$oldAddress = $box->address;
$oldUsername = $box->username;
$oldAuthenticates = $box->authenticates;
$box->address = $this->address;
$box->display_name = $this->displayName ?: null;
$box->username = $this->username ?: null;
$box->no_reply = $this->noReply;
$box->active = $this->active;
$box->authenticates = $this->authenticates;
// Either one changes the identity smtpUsername() authenticates as:
// username directly, or address as username's fallback when none is
// set (including username being CLEARED back to that fallback).
// authenticates itself is the fourth: whether that identity gets
// sent at all. Leaving last_verified_at standing after any of the
// three would keep showing a successful verification for a
// connection nothing has actually tested since. Mailbox::
// invalidateVerification() is the same clear Admin\Mail::saveServer()
// uses for the server card, in its single-row shape — see that
// method's own comment for why the two do NOT also share the "did
// this actually change" comparison above this block.
if ($box->address !== $oldAddress || $box->username !== $oldUsername || $box->authenticates !== $oldAuthenticates) {
$box->invalidateVerification();
}
// Empty means "leave it alone", not "delete it" — otherwise every edit
// of an address would silently drop the password.
if ($this->password !== '') {
$box->password = $this->password;
$box->invalidateVerification();
}
$box->save();
$this->password = '';
$this->dispatch('notify', message: __('mail_settings.saved'));
$this->dispatch('mailbox-saved');
$this->closeModal();
}
public function render()
{
return view('livewire.edit-mailbox', [
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
]);
}
}

108
app/Livewire/EditSeat.php Normal file
View File

@ -0,0 +1,108 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Seat;
use LivewireUI\Modal\ModalComponent;
/**
* Edit one seat, in a modal.
*
* Editing used to happen in the row itself. It worked and it looked wrong: the
* row grew, the columns beside it jumped, and a table half in edit mode reads
* as a rendering fault rather than as a form. Anything with fields of its own
* gets a modal see R20.
*
* A modal is reachable without passing the page's route middleware, so the
* customer is resolved here rather than trusted from the caller: the uuid comes
* from the browser and must never reach across customers.
*/
class EditSeat extends ModalComponent
{
use ResolvesCustomer;
public string $uuid = '';
public string $name = '';
public string $email = '';
/** Owners cannot be suspended or removed; everyone can be renamed. */
public bool $isOwner = false;
/**
* Whether the address may still be corrected.
*
* Only while the invitation is in flight. Once someone has accepted, the
* address IS the person: editing it would hand one employee's access to
* another with nobody told a transfer of access wearing the clothes of a
* rename.
*/
public bool $addressEditable = false;
public function mount(string $uuid): void
{
$seat = $this->seat($uuid);
abort_if($seat === null, 404);
$this->uuid = $uuid;
$this->name = (string) $seat->name;
$this->email = (string) $seat->email;
$this->isOwner = $seat->role === 'owner';
$this->addressEditable = $seat->status === 'invited';
}
public function save()
{
$customer = $this->requireCustomer();
$seat = $this->seat($this->uuid);
if ($customer === null || $seat === null) {
return $this->closeModal();
}
$rules = ['name' => 'nullable|string|max:255'];
// Re-read from the record, never from the hydrated property: a forged
// addressEditable would otherwise open the address of an accepted seat.
if ($seat->status === 'invited') {
$rules['email'] = 'required|email|max:255';
}
$data = $this->validate($rules);
$changes = ['name' => trim($data['name'] ?? '') ?: null];
if ($seat->status === 'invited') {
$address = trim($data['email']);
if ($address !== $seat->email) {
if ($customer->seats()->where('email', $address)->whereKeyNot($seat->id)->exists()) {
$this->addError('email', __('users.duplicate'));
return null;
}
$changes['email'] = $address;
}
}
$seat->update($changes);
$this->dispatch('notify', message: __('users.saved'));
return $this->redirectRoute('users', navigate: true);
}
private function seat(string $uuid): ?Seat
{
return $this->customer()?->seats()->where('uuid', $uuid)->first();
}
public function render()
{
return view('livewire.edit-seat');
}
}

58
app/Livewire/Invoices.php Normal file
View File

@ -0,0 +1,58 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Carbon;
use Illuminate\Support\Number;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Invoices extends Component
{
public function render()
{
$locale = app()->getLocale();
$eur = fn (float $v) => Number::currency($v, in: 'EUR', locale: $locale);
$date = fn (string $iso) => Carbon::parse($iso)->local()->locale($locale)->isoFormat('LL');
$months = collect(['2026-03-01', '2026-04-01', '2026-05-01', '2026-06-01', '2026-07-01'])
->map(fn ($m) => Carbon::parse($m)->local()->locale($locale)->isoFormat('MMM'))->all();
$rows = [
['no' => 'CP-2026-0007', 'date' => $date('2026-07-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0006', 'date' => $date('2026-06-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0005', 'date' => $date('2026-05-01'), 'amount' => $eur(198), 'status' => 'paid'],
['no' => 'CP-2026-0004', 'date' => $date('2026-04-01'), 'amount' => $eur(179), 'status' => 'paid'],
['no' => 'CP-2026-0003', 'date' => $date('2026-03-15'), 'amount' => $eur(179), 'status' => 'paid'],
];
return view('livewire.invoices', [
'rows' => $rows,
'nextCharge' => $date('2026-08-01'),
'nextAmount' => $eur(198),
'spendChart' => [
'type' => 'line',
'data' => [
'labels' => $months,
'datasets' => [[
'label' => 'EUR',
'data' => [179, 179, 198, 198, 198],
'borderColor' => 'token:accent',
'fill' => true,
'tension' => 0.35,
'pointRadius' => 0,
'borderWidth' => 2,
]],
],
'options' => [
'scales' => [
'x' => ['grid' => ['display' => false]],
'y' => ['beginAtZero' => true, 'grid' => ['color' => 'token:border']],
],
'plugins' => ['legend' => ['display' => false]],
],
],
]);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\SupportRequest;
use Illuminate\Validation\Rule;
use LivewireUI\Modal\ModalComponent;
/**
* Raise a support request.
*
* The customer is asked for three things and nothing else: what it is about,
* a subject, and the question. Everything an operator would otherwise have to
* ask for which customer, which instance, which plan the system already
* knows and attaches itself. Making somebody describe their own server back to
* the people who built it is the part of support that annoys people most.
*/
class NewSupportRequest extends ModalComponent
{
use ResolvesCustomer;
public string $category = 'technical';
public string $subject = '';
public string $body = '';
public function save()
{
$customer = $this->requireCustomer();
if ($customer === null) {
return $this->closeModal();
}
$data = $this->validate([
'category' => ['required', Rule::in(SupportRequest::CATEGORIES)],
'subject' => 'required|string|min:3|max:150',
'body' => 'required|string|min:10|max:5000',
]);
$instance = $customer->instances()
->whereIn('status', ['active', 'cancellation_scheduled'])
->latest('id')
->first()
?? $customer->instances()->latest('id')->first();
SupportRequest::create([
'customer_id' => $customer->id,
'instance_id' => $instance?->id,
'subject' => $data['subject'],
'category' => $data['category'],
'body' => $data['body'],
'status' => 'open',
// The person, not the account: on a shared login the account name
// says nothing about who is actually asking.
'reported_by' => auth()->user()?->name ?: auth()->user()?->email,
]);
$this->dispatch('notify', message: __('support.sent'));
return $this->redirectRoute('support', navigate: true);
}
public function render()
{
return view('livewire.new-support-request', [
'categories' => SupportRequest::CATEGORIES,
]);
}
}

248
app/Livewire/Settings.php Normal file
View File

@ -0,0 +1,248 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
class Settings extends Component
{
use \App\Livewire\Concerns\ChangesOwnPassword;
use \App\Livewire\Concerns\ConfirmsPassword;
/** Shown once, right after setting two-factor up. */
public ?array $recoveryCodes = null;
public string $twoFactorCode = '';
/**
* Start two-factor setup: generate the secret, then show the QR code.
*
* Not confirmed yet Fortify keeps `two_factor_confirmed_at` null until a
* code from the app has been accepted, so someone who scans nothing and
* closes the tab is not left locked out of their own account.
*/
public function enableTwoFactor(): void
{
$this->requireConfirmedPassword();
app(\Laravel\Fortify\Actions\EnableTwoFactorAuthentication::class)(auth()->user());
}
/** Accept a code from the app, which is what actually turns it on. */
public function confirmTwoFactor(): void
{
$this->requireConfirmedPassword();
try {
app(\Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication::class)(
auth()->user(),
$this->twoFactorCode,
);
} catch (\Illuminate\Validation\ValidationException $e) {
$this->addError('twoFactorCode', $e->errors()['code'][0] ?? __('settings.twofa_code_wrong'));
return;
}
$this->twoFactorCode = '';
// Shown once, here, because this is the only moment they exist in a
// form anyone will read. They stay retrievable afterwards, but nobody
// writes down what they were not shown.
$this->recoveryCodes = json_decode(decrypt(auth()->user()->two_factor_recovery_codes), true);
$this->dispatch('notify', message: __('settings.twofa_on'));
}
public function regenerateRecoveryCodes(): void
{
$this->requireConfirmedPassword();
app(\Laravel\Fortify\Actions\GenerateNewRecoveryCodes::class)(auth()->user());
$this->recoveryCodes = json_decode(decrypt(auth()->user()->refresh()->two_factor_recovery_codes), true);
}
public function disableTwoFactor(): void
{
$this->requireConfirmedPassword();
app(\Laravel\Fortify\Actions\DisableTwoFactorAuthentication::class)(auth()->user());
$this->recoveryCodes = null;
$this->dispatch('notify', message: __('settings.twofa_off'));
}
/**
* Every two-factor action goes through this.
*
* Server-side, on each call, and not merely by hiding the buttons: a
* Livewire action is reachable by anyone who can post to /livewire/update,
* and "the form was not on screen" has never stopped anybody.
*/
private function requireConfirmedPassword(): void
{
abort_unless($this->passwordRecentlyConfirmed(), 403);
}
use ResolvesCustomer, WithFileUploads;
// Company / billing profile
#[Validate('required|string|max:255')]
public string $companyName = '';
#[Validate('nullable|string|max:255')]
public string $contactName = '';
#[Validate('nullable|string|max:64')]
public string $phone = '';
#[Validate('nullable|string|max:64')]
public string $vatId = '';
#[Validate('nullable|string|max:2000')]
public string $billingAddress = '';
// Branding
#[Validate('nullable|string|max:255')]
public string $brandDisplayName = '';
#[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')]
public string $brandPrimary = '';
#[Validate('nullable|regex:/^#[0-9a-fA-F]{6}$/')]
public string $brandAccent = '';
/** New logo upload (validated on save). */
public $logo = null;
public ?string $brandLogoPath = null;
public function mount(): void
{
$c = $this->customer();
if ($c === null) {
return;
}
$this->companyName = $c->name ?? '';
$this->contactName = $c->contact_name ?? '';
$this->phone = $c->phone ?? '';
$this->vatId = $c->vat_id ?? '';
$this->billingAddress = $c->billing_address ?? '';
$this->brandDisplayName = $c->brand_display_name ?? '';
$this->brandPrimary = $c->brand_primary_color ?? '';
$this->brandAccent = $c->brand_accent_color ?? '';
$this->brandLogoPath = $c->brand_logo_path;
}
public function saveProfile(): void
{
$c = $this->requireCustomer();
if ($c === null) {
return;
}
$this->validateOnly('companyName');
$data = $this->validate([
'companyName' => 'required|string|max:255',
'contactName' => 'nullable|string|max:255',
'phone' => 'nullable|string|max:64',
'vatId' => 'nullable|string|max:64',
'billingAddress' => 'nullable|string|max:2000',
]);
$c->update([
'name' => $data['companyName'],
'contact_name' => $data['contactName'] ?: null,
'phone' => $data['phone'] ?: null,
'vat_id' => $data['vatId'] ?: null,
'billing_address' => $data['billingAddress'] ?: null,
]);
$this->dispatch('notify', message: __('settings.profile_saved'));
}
public function saveBranding(): void
{
$c = $this->requireCustomer();
if ($c === null) {
return;
}
$this->validate([
'brandDisplayName' => 'nullable|string|max:255',
'brandPrimary' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
'brandAccent' => 'nullable|regex:/^#[0-9a-fA-F]{6}$/',
'logo' => 'nullable|image|mimes:png,webp|max:2048',
]);
// Store the new upload, but keep the old file until the DB row that
// references it is updated — delete the old one only after that commits,
// so a failed update never orphans a file or dangles a reference.
$oldToDelete = null;
if ($this->logo !== null) {
$oldToDelete = $this->brandLogoPath;
$this->brandLogoPath = $this->logo->store('branding', 'public');
$this->logo = null;
}
$c->update([
'brand_display_name' => $this->brandDisplayName ?: null,
'brand_primary_color' => $this->brandPrimary ?: null,
'brand_accent_color' => $this->brandAccent ?: null,
'brand_logo_path' => $this->brandLogoPath,
]);
if ($oldToDelete !== null && $oldToDelete !== $this->brandLogoPath) {
Storage::disk('public')->delete($oldToDelete);
}
$this->dispatch('notify', message: __('settings.branding_saved'));
}
public function removeLogo(): void
{
$c = $this->requireCustomer();
if ($c === null) {
return;
}
if ($this->brandLogoPath !== null) {
Storage::disk('public')->delete($this->brandLogoPath);
}
$this->brandLogoPath = null;
$c->update(['brand_logo_path' => null]);
$this->dispatch('notify', message: __('settings.branding_saved'));
}
#[Layout('layouts.portal-app')]
public function render()
{
$c = $this->customer();
// Prefer the active/cancelling instance for the package section so the
// controls line up with what cancellation actually targets.
$active = $c?->instances()->where('status', 'active')->latest('id')->first();
$scheduled = $c?->instances()->where('status', 'cancellation_scheduled')->latest('id')->first();
$instance = $active ?? $scheduled ?? $c?->instances()->latest('id')->first();
$user = auth()->user();
return view('livewire.settings', [
// Never the secret itself — only whether it exists, and the SVG
// Fortify renders from it. The secret in a Livewire property would
// travel to the browser and back in the component snapshot.
'twoFactorPending' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null,
'twoFactorOn' => $user->two_factor_confirmed_at !== null,
'twoFactorQr' => $user->two_factor_secret !== null && $user->two_factor_confirmed_at === null
? $user->twoFactorQrCodeSvg()
: null,
'passwordConfirmed' => $this->passwordRecentlyConfirmed(),
'customer' => $c,
'instance' => $instance,
'branding' => $c?->brandingResolved(),
'logoUrl' => $this->brandLogoPath ? Storage::disk('public')->url($this->brandLogoPath) : null,
'hasActivePackage' => $active !== null,
'cancellationScheduled' => $active === null && $scheduled !== null,
]);
}
}

41
app/Livewire/Support.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\SupportRequest;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Support extends Component
{
use ResolvesCustomer;
public function render()
{
$customer = $this->customer();
$requests = $customer
? SupportRequest::query()
->where('customer_id', $customer->id)
->latest('created_at')
->limit(20)
->get()
: collect();
return view('livewire.support', [
'requests' => $requests,
'openCount' => $requests->filter(fn (SupportRequest $r) => $r->isOpen())->count(),
// The answers link into the panel where the panel can actually do
// the thing being asked about — an FAQ that only describes a button
// makes the reader hunt for it.
'faqs' => [
['q' => __('support.faq_q1'), 'a' => __('support.faq_a1'), 'to' => route('backups'), 'cta' => __('support.faq_to_backups')],
['q' => __('support.faq_q2'), 'a' => __('support.faq_a2'), 'to' => route('users'), 'cta' => __('support.faq_to_users')],
['q' => __('support.faq_q3'), 'a' => __('support.faq_a3'), 'to' => null, 'cta' => null],
['q' => __('support.faq_q4'), 'a' => __('support.faq_a4'), 'to' => route('billing'), 'cta' => __('support.faq_to_billing')],
],
]);
}
}

242
app/Livewire/Users.php Normal file
View File

@ -0,0 +1,242 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\Customer;
use App\Models\Seat;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.portal-app')]
class Users extends Component
{
use ResolvesCustomer;
#[Validate('required|email|max:255')]
public string $inviteEmail = '';
#[Validate('nullable|string|max:255')]
public string $inviteName = '';
#[Validate('required|in:admin,member,readonly')]
public string $inviteRole = 'member';
public function mount(): void
{
// Every customer starts with themselves as the owner seat. firstOrCreate
// keyed on (customer_id, email) is idempotent; the catch covers the
// concurrent-first-visit race against the unique index.
$customer = $this->customer();
if ($customer !== null && $customer->seats()->count() === 0) {
try {
$customer->seats()->firstOrCreate(
['email' => $customer->email],
['name' => $customer->name, 'role' => 'owner', 'status' => 'active', 'invited_at' => now()],
);
} catch (UniqueConstraintViolationException) {
// Another concurrent first visit created it — fine.
}
}
}
public function invite(): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$data = $this->validate();
// Serialize the limit check with the insert so two concurrent invites for
// the last free seat can't both pass (lock the customer row).
$result = DB::transaction(function () use ($customer, $data) {
$locked = Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
if ($locked->seats()->where('email', $data['inviteEmail'])->exists()) {
return 'duplicate';
}
if ($this->usedSeats($locked) >= $this->seatLimit($locked)) {
return 'limit';
}
$locked->seats()->create([
'email' => $data['inviteEmail'],
'name' => $data['inviteName'] ?: null,
'role' => $data['inviteRole'],
'status' => 'invited',
'invited_at' => now(),
]);
return 'ok';
});
if ($result === 'limit') {
$this->addError('inviteEmail', __('users.limit_reached'));
return;
}
if ($result === 'duplicate') {
$this->addError('inviteEmail', __('users.duplicate'));
return;
}
$this->reset('inviteEmail', 'inviteName', 'inviteRole');
$this->inviteRole = 'member';
$this->dispatch('notify', message: __('users.invited'));
}
public function setRole(string $uuid, string $role): void
{
if (! in_array($role, Seat::ROLES, true)) {
return;
}
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
// Lock the customer so a concurrent owner change can't race past the guard.
$ok = DB::transaction(function () use ($customer, $uuid, $role) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
$seat = $customer->seats()->where('uuid', $uuid)->first();
if ($seat === null) {
return true;
}
if ($seat->role === 'owner' && $role !== 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) {
return false; // would remove the last owner
}
$seat->update(['role' => $role]);
return true;
});
if (! $ok) {
$this->dispatch('notify', message: __('users.last_owner'));
}
}
/**
* Pause a seat without destroying it.
*
* The action an owner actually needs when someone leaves: access stops now,
* and the record of who held it survives which is the half a deletion
* throws away, on a product sold on being able to show who had access to
* what.
*/
public function suspend(string $uuid): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$seat = $customer->seats()->where('uuid', $uuid)->first();
if ($seat === null || $seat->role === 'owner') {
// The owner cannot lock themselves out of their own cloud.
$this->dispatch('notify', message: __('users.owner_locked'));
return;
}
$seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']);
$this->dispatch('notify', message: __(
$seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated',
));
}
public function revoke(string $uuid): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$result = DB::transaction(function () use ($customer, $uuid) {
Customer::query()->whereKey($customer->id)->lockForUpdate()->first();
$seat = $customer->seats()->where('uuid', $uuid)->first();
if ($seat === null) {
return 'gone';
}
if ($seat->role === 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) {
return 'last_owner';
}
$seat->delete();
return 'ok';
});
if ($result === 'last_owner') {
$this->dispatch('notify', message: __('users.last_owner'));
} elseif ($result === 'ok') {
$this->dispatch('notify', message: __('users.revoked'));
}
}
public function resend(string $uuid): void
{
// Invite delivery is mocked for now.
if ($this->seat($uuid) !== null) {
$this->dispatch('notify', message: __('users.resent'));
}
}
private function ownerCount(): int
{
$customer = $this->customer();
return $customer ? $customer->seats()->where('role', 'owner')->count() : 0;
}
private function usedSeats(Customer $customer): int
{
return $customer->seats()->where('status', '!=', 'revoked')->count();
}
private function seatLimit(Customer $customer): int
{
// The entitlement follows the active (or cancelling) package, not a newer
// failed/deprovisioned record.
$instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first()
?? $customer->instances()->latest('id')->first();
// From the contract: how many people a customer may invite is part of
// what they bought. Cutting a plan's seats in the catalogue must not
// lock users out of an existing customer's cloud.
return (int) ($instance?->subscription?->seats ?? 5);
}
private function seat(string $uuid): ?Seat
{
$customer = $this->customer();
return $customer?->seats()->where('uuid', $uuid)->first();
}
public function render()
{
$customer = $this->customer();
$seats = $customer ? $customer->seats()->orderByRaw("role = 'owner' desc")->orderBy('email')->get() : collect();
// The actions column is ALWAYS drawn. It used to be hidden when the
// only seat was the owner, on the reasoning that there was nothing to
// act on — but every seat can be renamed, and a column that disappears
// does not read as "nothing applies here", it reads as "this product
// cannot do that". Which is exactly how it was reported.
return view('livewire.users', [
'seats' => $seats,
'used' => $customer ? $this->usedSeats($customer) : 0,
'limit' => $customer ? $this->seatLimit($customer) : 0,
'roles' => Seat::ROLES,
]);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Mail\Concerns;
use App\Services\Mail\MailboxResolver;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Envelope;
/**
* From and Reply-To, taken from the mailbox behind a purpose.
*
* Reply-To is the whole reason sending alone is enough: a customer answering a
* support reply lands in the support mailbox, read in an ordinary mail client,
* so no IMAP is needed to close the loop.
*
* Except on a no-reply mailbox. A "no-reply" address you can reply to is a lie
* in the sender, and the one place the promise is made is the address itself.
*/
trait SendsFromMailbox
{
protected function mailboxEnvelope(string $purpose, string $subject): Envelope
{
[$from, $replyTo] = $this->mailboxAddresses($purpose);
return new Envelope(
from: $from,
replyTo: $replyTo ? [$replyTo] : [],
subject: $subject,
);
}
/**
* The From and Reply-To addresses for $purpose's mailbox the ONE place
* that decides "no configured mailbox means no sender fields at all" and
* "no_reply means no Reply-To". Shared by every consumer of a purpose
* mailbox regardless of the shape it builds an Envelope here (a
* Mailable), a MailMessage in CloudReady (a Notification, which cannot
* use mailboxEnvelope() directly since it is not building an Envelope).
* Two independent copies of this same decision is exactly the shape of
* gap Task 4 found and fixed in MailboxTransport::resolution().
*
* @return array{0: ?Address, 1: ?Address} [from, replyTo]
*/
protected function mailboxAddresses(string $purpose): array
{
$box = app(MailboxResolver::class)->for($purpose);
if ($box === null) {
// No mailbox configured yet — both null, so a caller falls back
// to its own framework default rather than dereferencing null.
return [null, null];
}
$from = new Address($box->address, $box->display_name ?: null);
return [$from, $box->no_reply ? null : $from];
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Models\Customer;
use App\Models\MaintenanceWindow;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
/** The ledger row this mail confirms once actually delivered. */
public ?int $notificationId = null;
public function __construct(
public MaintenanceWindow $window,
public Customer $customer,
) {
// Names the mailer, which is all that is serialised into the queue —
// the credentials behind it are resolved when the worker sends.
$this->mailer('cp_'.MailPurpose::MAINTENANCE);
}
public function headers(): Headers
{
return new Headers(text: $this->notificationId ? ['X-CP-Notification' => (string) $this->notificationId] : []);
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(
MailPurpose::MAINTENANCE,
__('maintenance.mail_subject', ['title' => $this->window->title]),
);
}
public function content(): Content
{
return new Content(markdown: 'mail.maintenance-announcement', with: [
'title' => $this->window->title,
'description' => $this->window->public_description,
'startsAt' => $this->window->starts_at,
'endsAt' => $this->window->ends_at,
'name' => $this->customer->name,
]);
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Models\Customer;
use App\Models\MaintenanceWindow;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
class MaintenanceCancelledMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
/** The ledger row this mail confirms once actually delivered. */
public ?int $notificationId = null;
public function __construct(
public MaintenanceWindow $window,
public Customer $customer,
) {
$this->mailer('cp_'.MailPurpose::MAINTENANCE);
}
public function headers(): Headers
{
return new Headers(text: $this->notificationId ? ['X-CP-Notification' => (string) $this->notificationId] : []);
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(
MailPurpose::MAINTENANCE,
__('maintenance.mail_cancel_subject', ['title' => $this->window->title]),
);
}
public function content(): Content
{
return new Content(markdown: 'mail.maintenance-cancelled', with: [
'title' => $this->window->title,
'name' => $this->customer->name,
]);
}
}

View File

@ -0,0 +1,201 @@
<?php
namespace App\Mail\Transport;
use App\Models\Mailbox;
use App\Services\Mail\MailboxResolver;
use App\Services\Mail\MailTlsPolicy;
use App\Support\Settings;
use Illuminate\Mail\Transport\ArrayTransport;
use Illuminate\Mail\Transport\LogTransport;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\NullTransport;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\RawMessage;
/**
* Resolves its credentials when it sends, not when it is built.
*
* The mails are queued. A worker builds its mailer once and lives for hours, so
* anything decided in a constructor is decided for the rest of that worker's
* life including a password the operator has since corrected in the console.
*
* The delegate is cached against a FINGERPRINT of the credentials rather than
* rebuilt per message: correct when they change, and no new SMTP connection per
* mail when they do not.
*/
class MailboxTransport implements TransportInterface
{
/**
* mail.default values that must never open a real connection.
*
* 'array' is here because phpunit.xml sets it as the suite-wide test
* default the first feature test that sends through a purpose mailer
* without Mail::fake() must not reach real SMTP just because 'array' is
* not the exact string 'log'. `null` is here because a stored setting can
* decode to it just as easily as to a real value (see the port note in
* delegate()): an unconfigured mailer must fail SAFE, not fail open.
*/
private const NON_DELIVERING = ['log', 'array', null];
private ?TransportInterface $delegate = null;
private ?string $fingerprint = null;
public function __construct(private readonly string $purpose) {}
public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage
{
return $this->delegate()->send($message, $envelope);
}
public function __toString(): string
{
return 'mailbox://'.$this->purpose.'/'.($this->describe() ?? 'unconfigured');
}
/** What this transport currently points at — used by __toString and tests. */
private function describe(): ?string
{
[$mode, $box] = $this->resolution();
return match ($mode) {
'mailbox' => $box?->address,
// Same verdict delegate() throws on, not a second opinion: a DSN
// that still read as a plain address here is the exact harm this
// exists to prevent — it looked healthy right up until send()
// threw. The address stays visible so an operator can tell WHICH
// mailbox needs a password, rather than just that something does.
'unconfigured' => $box === null ? null : $box->address.' [unconfigured]',
default => $mode,
};
}
/**
* The ONE place that decides "log, array, null-default, an unconfigured
* mailbox, or a real one" — describe() and delegate() both branch on this
* result rather than each running their own copy of a check. Two copies
* of the same guard is what let a mutation that broke only ONE of them
* hide behind a passing suite: __toString() kept reporting "log" (and
* later, a plain address) while delegate() quietly did something else
* built a real SMTP transport regardless of MAIL_MAILER, then threw on a
* mailbox describe() had just shown as fine.
*
* @return array{0: string, 1: ?Mailbox}
*/
private function resolution(): array
{
$default = config('mail.default');
if (in_array($default, self::NON_DELIVERING, true)) {
return [$default ?? 'null', null];
}
$box = app(MailboxResolver::class)->for($this->purpose);
return $box !== null && $box->isConfigured()
? ['mailbox', $box]
: ['unconfigured', $box];
}
private function delegate(): TransportInterface
{
[$mode, $box] = $this->resolution();
if ($mode === 'unconfigured') {
// Loud, not silent: a mail with no mailbox behind it must not look
// like it was sent. A missing or deactivated MAPPING already fell
// back to system inside MailboxResolver; reaching this line means
// the mailbox we ended up with — possibly system itself — has no
// usable credentials (no row at all, or one with no password),
// and switching to yet another address would only hide that.
throw new RuntimeException(
"No configured mailbox for mail purpose [{$this->purpose}]."
);
}
if ($mode !== 'mailbox') {
return match ($mode) {
'log' => new LogTransport(Log::channel(config('mail.mailers.log.channel'))),
'array' => new ArrayTransport,
default => new NullTransport, // 'null' — mail.default not configured at all
};
}
// $mode === 'mailbox' only when resolution() already confirmed $box
// is non-null and isConfigured() — nothing left to check here.
$host = (string) Settings::get('mail.host', '');
$port = (int) Settings::get('mail.port', 587);
$encryption = (string) Settings::get('mail.encryption', 'tls');
// Guarded the same way as the missing mailbox above. Tasks 6-7 are
// what write these settings, so today they default to blank/absent —
// and a STORED null port json-decodes to null, which casts to 0; left
// unguarded, EsmtpTransport turns port 0 into plaintext port 25
// rather than refusing (the 587 default only applies when the row is
// absent, not when it holds null). Loud beats a silently downgraded
// connection.
if ($host === '') {
throw new RuntimeException('Mail server host is not configured — cannot send mail.');
}
if ($port < 1) {
throw new RuntimeException('Mail server port is not configured — cannot send mail.');
}
// authenticates is part of the fingerprint too, not only host/port/
// encryption/username/password: Mailbox::smtpUsername() always
// returns something non-empty (it falls back to the address), so an
// operator toggling authenticates off with everything else unchanged
// must still invalidate the cached delegate below — otherwise a
// long-running queue worker would keep the OLD delegate, which
// already has setUsername()/setPassword() called on it from before
// the toggle, still attempting AUTH the operator just turned off.
//
// Codex R15#5, P2 comparison pass: the password component is read
// the same way setPassword() below is gated — only when this mailbox
// authenticates. $box->password decrypts under SECRETS_KEY, and an
// unauthenticated mailbox that once had a password (an operator
// unchecked "requires a password" without clearing the field, which
// EditMailbox::save() deliberately leaves alone) still carries that
// ciphertext in the column. Decrypting it here regardless would
// crash a real send the moment SECRETS_KEY became unusable, purely
// to fingerprint a value setUsername()/setPassword() below is never
// going to read either.
$fingerprint = md5(implode('|', [
$host, $port, $encryption, (int) $box->authenticates, $box->smtpUsername(),
$box->authenticates ? (string) $box->password : '',
]));
if ($this->delegate === null || $this->fingerprint !== $fingerprint) {
// MailTlsPolicy is the ONE place "ssl, tls, or none" turns into
// EsmtpTransport's three TLS switches — see its docblock. The
// third constructor argument here is implicit TLS (smtps, port
// 465), not STARTTLS: passing true for 'tls' would open an SSL
// socket against a server expecting STARTTLS and hang.
$policy = MailTlsPolicy::for($encryption, $port);
$transport = $policy->apply(new EsmtpTransport($host, $port, $policy->implicit));
// Codex R15#4, P1b: only called when this mailbox actually
// authenticates. smtpUsername()'s address fallback means it is
// NEVER empty, so calling setUsername() unconditionally would
// make Symfony attempt AUTH against any server that advertises
// it — exactly what a trusted, unauthenticated relay was never
// asked to answer.
if ($box->authenticates) {
$transport->setUsername($box->smtpUsername());
$transport->setPassword((string) $box->password);
}
$this->delegate = $transport;
$this->fingerprint = $fingerprint;
}
return $this->delegate;
}
}

21
app/Models/Backup.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Backup extends Model
{
protected $fillable = ['instance_id', 'external_job_id', 'schedule', 'last_ok_at', 'status'];
protected function casts(): array
{
return ['last_ok_at' => 'datetime'];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models\Builders;
use Illuminate\Database\Eloquent\Builder;
use RuntimeException;
/**
* A query builder that refuses to rewrite history.
*
* Model events (`updating`, `deleting`) only fire for instance operations, so a
* guard on the model alone still lets `Model::query()->where(...)->update(...)`
* through which is exactly the shape a careless data fix takes. For a table
* whose value IS that it cannot be edited, the guard has to sit where the bulk
* operations pass.
*
* Not a security boundary: anyone with database access can still do as they
* please. It is a guard against the application quietly doing it by accident,
* which is the realistic way a register loses its meaning.
*/
class AppendOnlyBuilder extends Builder
{
public function update(array $values)
{
throw new RuntimeException(
'The proof register is append-only. Record a correcting event instead of editing history.'
);
}
public function delete()
{
throw new RuntimeException(
'The proof register is append-only. A record that can be deleted is not evidence.'
);
}
public function forceDelete()
{
return $this->delete();
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Support\Str;
/**
* Assigns a random UUID on creation. Records that expose a `uuid` column use it
* as their route key (R11: URLs address records by UUID, not integer PK).
*/
trait HasUuid
{
protected static function bootHasUuid(): void
{
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

144
app/Models/Customer.php Normal file
View File

@ -0,0 +1,144 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use RuntimeException;
class Customer extends Model
{
/** Comparable form: spaces and punctuation are cosmetic in a VAT number. */
public static function normaliseVatId(?string $value): string
{
return strtoupper(preg_replace('/[\s.-]+/', '', (string) $value) ?? '');
}
public function normalisedVatId(): string
{
return self::normaliseVatId($this->vat_id);
}
/**
* True only for the value that was actually checked. Binding it to the
* value rather than to a flag means editing the field cannot inherit the
* old confirmation, whichever code path does the editing.
*/
public function hasVerifiedVatId(): bool
{
// Both sides normalised: a verifier that stores the number in display
// form ("DE 811 907 980") must not invalidate a genuine confirmation.
return $this->vat_id_verified_at !== null
&& $this->normalisedVatId() !== ''
&& $this->normalisedVatId() === self::normaliseVatId($this->vat_id_verified_value);
}
/** @use HasFactory<\Database\Factories\CustomerFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'user_id', 'name', 'contact_name', 'email', 'phone', 'vat_id', 'vat_id_verified_at', 'vat_id_verified_value', 'billing_address',
'locale', 'stripe_customer_id', 'status', 'closed_at',
'brand_display_name', 'brand_logo_path', 'brand_primary_color', 'brand_accent_color',
];
protected function casts(): array
{
return ['closed_at' => 'datetime', 'vat_id_verified_at' => 'datetime'];
}
public function seats(): HasMany
{
return $this->hasMany(Seat::class);
}
/**
* Resolve branding: customer values where set, else CluPilot defaults. Used
* for previews and snapshotted into the provisioning run so retries are
* deterministic. NULL is stored for "unset" defaults are never copied in.
*
* @return array{display_name:string,logo_path:?string,primary_color:string,accent_color:string,is_default:bool}
*/
public function brandingResolved(): array
{
$defaults = (array) config('provisioning.branding_defaults');
return [
'display_name' => $this->brand_display_name ?: ($defaults['display_name'] ?? 'CluPilot'),
'logo_path' => $this->brand_logo_path ?: ($defaults['logo_path'] ?? null),
'primary_color' => $this->brand_primary_color ?: ($defaults['primary_color'] ?? '#f97316'),
'accent_color' => $this->brand_accent_color ?: ($defaults['accent_color'] ?? '#c2560a'),
'is_default' => $this->brand_display_name === null
&& $this->brand_logo_path === null
&& $this->brand_primary_color === null
&& $this->brand_accent_color === null,
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function instances(): HasMany
{
return $this->hasMany(Instance::class);
}
/**
* Find or create the portal login account for this customer and link it.
* Race-safe against the unique email index, and never links an operator
* account that would carry admin authorization into an impersonated
* "customer" session.
*/
public function ensureUser(): User
{
if ($this->user) {
return $this->user;
}
if (($existing = User::query()->where('email', $this->email)->first()) !== null) {
$this->assertNotAdmin($existing);
$this->update(['user_id' => $existing->id]);
return $existing;
}
try {
$user = User::query()->create([
'email' => $this->email,
'name' => $this->name,
'password' => Hash::make(Str::random(40)),
'is_admin' => false,
]);
} catch (UniqueConstraintViolationException) {
// Concurrent first-time creation — re-fetch and link the winner.
$user = User::query()->where('email', $this->email)->firstOrFail();
$this->assertNotAdmin($user);
}
$this->update(['user_id' => $user->id]);
return $user->refresh();
}
private function assertNotAdmin(User $user): void
{
if ($user->is_admin || $user->isOperator()) {
throw new RuntimeException(
"Refusing to link operator account {$user->email} as a portal login for customer {$this->id}.",
);
}
}
}

31
app/Models/Datacenter.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Datacenter extends Model
{
/** @use HasFactory<\Database\Factories\DatacenterFactory> */
use HasFactory, HasUuid;
protected $fillable = ['code', 'name', 'location', 'active'];
protected function casts(): array
{
return ['active' => 'boolean'];
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function hosts()
{
return $this->hasMany(Host::class, 'datacenter', 'code');
}
}

16
app/Models/DnsRecord.php Normal file
View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DnsRecord extends Model
{
protected $fillable = ['instance_id', 'provider', 'record_id', 'fqdn', 'type', 'value'];
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

139
app/Models/Host.php Normal file
View File

@ -0,0 +1,139 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Provisioning\Contracts\ProvisioningSubject;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Host extends Model implements ProvisioningSubject
{
/** @use HasFactory<\Database\Factories\HostFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey',
'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores',
'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_name', 'dns_record_id',
];
protected $hidden = ['api_token_ref'];
protected function casts(): array
{
return [
'api_token_ref' => 'encrypted',
'last_seen_at' => 'datetime',
'total_gb' => 'integer',
'total_ram_mb' => 'integer',
'cpu_cores' => 'integer',
'cpu_weight' => 'integer',
'reserve_pct' => 'integer',
];
}
/** Placement runs (polymorphic subject). */
public function runs(): MorphMany
{
return $this->morphMany(ProvisioningRun::class, 'subject');
}
/** Runner hook: a failed onboarding run moves the host to the error status. */
public function onProvisioningFailed(): void
{
$this->update(['status' => 'error']);
}
public function instances(): HasMany
{
return $this->hasMany(Instance::class);
}
public function maintenanceWindows(): BelongsToMany
{
return $this->belongsToMany(MaintenanceWindow::class);
}
/** Free committable storage: total minus reserve. */
public function freeGb(): int
{
if ($this->total_gb === null) {
return 0;
}
return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100);
}
/**
* Host storage already committed, counted as the VM disk allocation
* (disk_gb) since that is what is actually placed on the host not the
* (smaller) Nextcloud user quota. A failed instance still counts while its VM
* exists (has a vmid); a failure before any VM was created releases it.
*/
public function committedGb(): int
{
// Answer a preloaded sum when the caller supplied one (see
// Instance::scopeOccupyingHost) — listing every host otherwise costs a
// query per host, twice over once usedPct() asks again.
//
// Tested for PRESENCE, not for null: SUM over no rows is null, so a
// host with nothing on it arrives preloaded-but-null. Reading that as
// "not preloaded" would re-query for exactly the empty hosts, which is
// the case the preload exists for.
if (array_key_exists('committed_disk_gb', $this->getAttributes())) {
return (int) $this->getAttributes()['committed_disk_gb'];
}
return (int) $this->instances()->occupyingHost()->sum('disk_gb');
}
/** Storage still available for new instances. */
public function availableGb(): int
{
return max(0, $this->freeGb() - $this->committedGb());
}
/** Used-storage percentage (committed vs committable free), 0100. */
public function usedPct(): int
{
$free = $this->freeGb();
return $free > 0 ? min(100, (int) round($this->committedGb() / $free * 100)) : 0;
}
/**
* Heartbeat health from last_seen_at: online (≤5 min), stale (≤30 min),
* offline (older or never seen). Drives the health dot in the console.
*/
public function healthState(): string
{
if ($this->last_seen_at === null) {
return 'offline';
}
$minutes = $this->last_seen_at->diffInMinutes(now());
return match (true) {
$minutes <= 5 => 'online',
$minutes <= 30 => 'stale',
default => 'offline',
};
}
/**
* Placement (spec §1): first active host in the datacenter with enough free
* committable storage. Cluster is ignored in v1.0.
*/
public static function placeableIn(string $datacenter, int $quotaGb): ?self
{
return self::query()
->where('datacenter', $datacenter)
->where('status', 'active')
->orderBy('name')
->get()
->first(fn (self $host) => $host->availableGb() >= $quotaGb);
}
}

102
app/Models/Instance.php Normal file
View File

@ -0,0 +1,102 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Instance extends Model
{
/** @use HasFactory<\Database\Factories\InstanceFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'traffic_addons', 'disk_gb',
'ram_mb', 'cores', 'subdomain', 'custom_domain', 'nc_admin_ref',
'route_written', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at',
];
protected $hidden = ['nc_admin_ref'];
protected function casts(): array
{
return [
'nc_admin_ref' => 'encrypted',
'route_written' => 'boolean',
'cert_ok' => 'boolean',
'vmid' => 'integer',
'traffic_addons' => 'integer',
'quota_gb' => 'integer',
'disk_gb' => 'integer',
'ram_mb' => 'integer',
'cores' => 'integer',
'cancel_requested_at' => 'datetime',
'service_ends_at' => 'datetime',
];
}
/**
* Instances that are actually occupying storage on their host.
*
* A failed instance still holds its disk while its VM exists; a failure
* before any VM was created releases it. Kept here rather than written out
* at each call site so the host's own accounting and anything reporting on
* it cannot drift apart a dashboard that counted differently from
* placement would call a host comfortable while orders were being refused
* on it.
*
* @param \Illuminate\Database\Eloquent\Builder<self> $query
*/
public function scopeOccupyingHost($query): void
{
$query->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid'));
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
/**
* The contract this machine fulfils. The authority on what the customer is
* entitled to the catalogue only describes what we sell today.
*/
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class);
}
public function host(): BelongsTo
{
return $this->belongsTo(Host::class);
}
public function dnsRecords(): HasMany
{
return $this->hasMany(DnsRecord::class);
}
public function backups(): HasMany
{
return $this->hasMany(Backup::class);
}
public function monitoringTargets(): HasMany
{
return $this->hasMany(MonitoringTarget::class);
}
public function onboardingTasks(): HasMany
{
return $this->hasMany(OnboardingTask::class);
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* A day in the life of one instance: how full it was, and what it moved.
*
* Read by the customer panel to draw the storage ring and the transfer trend.
* Written by CollectInstanceTraffic, which already visits every active
* instance a second scheduler entry hitting the same Proxmox API would only
* double the load and the failure modes.
*/
class InstanceMetric extends Model
{
protected $fillable = ['instance_id', 'day', 'disk_used_bytes', 'disk_total_bytes', 'rx_bytes', 'tx_bytes', 'checks_total', 'checks_ok'];
protected function casts(): array
{
return [
'day' => 'date',
'disk_used_bytes' => 'integer',
'disk_total_bytes' => 'integer',
'rx_bytes' => 'integer',
'tx_bytes' => 'integer',
'checks_total' => 'integer',
'checks_ok' => 'integer',
];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
/**
* The last $days days, oldest first, with gaps left as gaps.
*
* A missing day is not a zero. Filling it would draw a cliff into the chart
* on every day the sampler could not reach the host, and the customer would
* read an outage that never happened.
*
* @return Collection<int, self>
*/
public static function series(Instance $instance, int $days = 14): Collection
{
return self::query()
->where('instance_id', $instance->id)
->where('day', '>=', Carbon::today()->subDays($days - 1))
->orderBy('day')
->get();
}
/**
* Availability over the window, as a percentage, or null.
*
* Null when nothing was ever checked. Reporting an unmonitored instance as
* 100 % is the single most dishonest number this application could print
* it is the figure a customer would quote to their own auditor.
*/
public static function availability(Instance $instance, int $days = 30): ?float
{
$row = self::query()
->where('instance_id', $instance->id)
->where('day', '>=', Carbon::today()->subDays($days - 1))
->selectRaw('SUM(checks_total) as total, SUM(checks_ok) as ok')
->first();
$total = (int) ($row->total ?? 0);
return $total === 0 ? null : round((int) $row->ok / $total * 100, 2);
}
/**
* Daily availability for the trend, oldest first.
*
* Days without a single check are skipped rather than drawn at zero: our
* monitoring being down is not the customer's cloud being down.
*
* @return array<int, float>
*/
public static function availabilitySeries(Instance $instance, int $days = 30): array
{
return self::query()
->where('instance_id', $instance->id)
->where('day', '>=', Carbon::today()->subDays($days - 1))
->where('checks_total', '>', 0)
->orderBy('day')
->get()
->map(fn (self $m) => round($m->checks_ok / max(1, $m->checks_total) * 100, 2))
->all();
}
/** The most recent day that carries a disk reading, or null. */
public static function latestDisk(Instance $instance): ?self
{
return self::query()
->where('instance_id', $instance->id)
->whereNotNull('disk_used_bytes')
->orderByDesc('day')
->first();
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InstanceTraffic extends Model
{
protected $table = 'instance_traffic';
protected $guarded = [];
protected function casts(): array
{
return [
'rx_bytes' => 'integer',
'tx_bytes' => 'integer',
'last_netin' => 'integer',
'last_netout' => 'integer',
'notified_percent' => 'integer',
'throttled' => 'boolean',
'sampled_at' => 'datetime',
'throttled_at' => 'datetime',
];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
public static function currentPeriod(): string
{
return now()->format('Y-m');
}
}

105
app/Models/Mailbox.php Normal file
View File

@ -0,0 +1,105 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Services\Secrets\SecretCipher;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* One sending address.
*
* The password is stored under SECRETS_KEY rather than APP_KEY, and therefore
* not through Laravel's `encrypted` cast that cast uses APP_KEY, which is
* rotated as ordinary maintenance and would take every mailbox with it.
*/
class Mailbox extends Model
{
use HasFactory, HasUuid;
protected $fillable = [
'key', 'address', 'display_name', 'username',
'password', 'no_reply', 'active', 'authenticates', 'last_verified_at',
];
protected function casts(): array
{
return [
'no_reply' => 'boolean',
'active' => 'boolean',
'authenticates' => 'boolean',
'last_verified_at' => 'datetime',
];
}
public static function findByKey(string $key): ?self
{
return static::query()->where('key', $key)->first();
}
/** Plaintext in, ciphertext to the column. */
public function setPasswordAttribute(?string $value): void
{
$this->attributes['password'] = ($value === null || $value === '')
? null
: app(SecretCipher::class)->encrypt($value);
}
/** Ciphertext from the column, plaintext out. */
public function getPasswordAttribute(?string $value): ?string
{
return ($value === null || $value === '')
? null
: app(SecretCipher::class)->decrypt($value);
}
/** The SMTP user: an explicit one, or the address itself. */
public function smtpUsername(): string
{
return $this->username !== null && $this->username !== ''
? $this->username
: $this->address;
}
/**
* last_verified_at proves a successful test against the config THIS
* mailbox held at the time an address, username or authenticates
* change (or a new password) invalidates only this row. Sets the
* attribute without saving: EditMailbox::save() calls this alongside
* other attribute changes so the whole edit lands in one write, not two.
*/
public function invalidateVerification(): void
{
$this->last_verified_at = null;
}
/**
* The mirror of invalidateVerification() for the server card
* (Admin\Mail::saveServer()): host, port and encryption are shared by
* every mailbox at once, not a per-row concept, so a change there
* invalidates all of them in one write rather than each row deciding
* independently Codex R15#6, P2. Scoped to rows that still have
* something to clear, so saving unrelated server settings does not bump
* every mailbox's updated_at for nothing.
*/
public static function invalidateAllVerifications(): int
{
return static::query()->whereNotNull('last_verified_at')->update(['last_verified_at' => null]);
}
/**
* Enough to send with: an address, and a password ONLY when this mailbox
* actually authenticates.
*
* A trusted local or private-network relay can legitimately need no
* password at all (Codex R15#4, P1b) — requiring one unconditionally
* would refuse a relay that was never broken, just unauthenticated.
*/
public function isConfigured(): bool
{
return $this->active
&& $this->address !== ''
&& (! $this->authenticates || $this->getRawOriginal('password') !== null);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MaintenanceNotification extends Model
{
protected $fillable = ['maintenance_window_id', 'customer_id', 'event', 'email', 'sent_at', 'claimed_at'];
protected function casts(): array
{
return ['sent_at' => 'datetime', 'claimed_at' => 'datetime'];
}
public function window(): BelongsTo
{
return $this->belongsTo(MaintenanceWindow::class, 'maintenance_window_id');
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class MaintenanceWindow extends Model
{
/** @use HasFactory<\Database\Factories\MaintenanceWindowFactory> */
use HasFactory, HasUuid;
/** How far before the start the banner appears (Codex: 72 h). */
public const DISPLAY_HOURS = 72;
protected $fillable = [
'title', 'public_description', 'internal_notes', 'starts_at', 'ends_at',
'state', 'created_by', 'published_at', 'cancelled_at', 'cancellation_reason',
];
protected function casts(): array
{
return [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'published_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
public function hosts(): BelongsToMany
{
return $this->belongsToMany(Host::class);
}
public function deliveries(): HasMany
{
return $this->hasMany(MaintenanceNotification::class);
}
/** Derived lifecycle state — never stored (Codex). */
public function derivedState(): string
{
if ($this->state === 'cancelled') {
return 'cancelled';
}
if ($this->state === 'draft') {
return 'draft';
}
if (now()->lt($this->starts_at)) {
return 'upcoming';
}
if (now()->lte($this->ends_at)) {
return 'active';
}
return 'completed';
}
public function isPublished(): bool
{
return $this->state === 'scheduled';
}
/** Service-bearing customers on the assigned hosts (recomputed live). */
public function affectedCustomers(): Collection
{
$hostIds = $this->hosts()->pluck('hosts.id');
if ($hostIds->isEmpty()) {
return collect();
}
return Customer::query()
->whereHas('instances', fn (Builder $q) => $q
->whereIn('host_id', $hostIds)
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']))
->get();
}
/**
* Scheduled windows within the display horizon that affect ONE instance's
* host for a per-instance badge, so a customer with several instances
* never sees another instance's maintenance on this card.
*/
public static function forInstance(?Instance $instance): Collection
{
if ($instance === null || $instance->host_id === null) {
return collect();
}
return self::query()
->where('state', 'scheduled')
->where('ends_at', '>=', now())
->where('starts_at', '<=', now()->addHours(self::DISPLAY_HOURS))
->whereHas('hosts', fn (Builder $q) => $q->whereKey($instance->host_id))
->orderBy('starts_at')
->get();
}
/**
* Scheduled windows to show a given customer right now: within the display
* horizon (72 h before start until the end) and touching one of the
* customer's service instances' hosts.
*/
public static function bannerFor(Customer $customer): Collection
{
$hostIds = Instance::query()
->where('customer_id', $customer->id)
->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled'])
->whereNotNull('host_id')
->pluck('host_id')->unique();
if ($hostIds->isEmpty()) {
return collect();
}
return self::query()
->where('state', 'scheduled')
->where('ends_at', '>=', now())
->where('starts_at', '<=', now()->addHours(self::DISPLAY_HOURS))
->whereHas('hosts', fn (Builder $q) => $q->whereIn('hosts.id', $hostIds))
->orderBy('starts_at')
->get();
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MonitoringTarget extends Model
{
protected $fillable = ['instance_id', 'external_id', 'url', 'status', 'checked_at'];
protected function casts(): array
{
// Without the cast the freshness comparison happens between a string
// and a Carbon instance, and without checked_at being fillable the sync
// job's update silently drops it — leaving every verdict permanently
// stale, which reads on the status page as "not monitored".
return ['checked_at' => 'datetime'];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OnboardingTask extends Model
{
protected $fillable = ['instance_id', 'key', 'done', 'done_at'];
protected function casts(): array
{
return ['done' => 'boolean', 'done_at' => 'datetime'];
}
public function instance(): BelongsTo
{
return $this->belongsTo(Instance::class);
}
}

109
app/Models/Order.php Normal file
View File

@ -0,0 +1,109 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Services\Billing\TaxTreatment;
use App\Provisioning\Contracts\ProvisioningSubject;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Order extends Model implements ProvisioningSubject
{
/** @use HasFactory<\Database\Factories\OrderFactory> */
use HasFactory, HasUuid;
protected $fillable = [
'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency',
'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'status',
];
protected function casts(): array
{
return ['amount_cents' => 'integer', 'plan_version_id' => 'integer'];
}
/**
* What this order actually buys, in words.
*
* Lives on the model rather than in the view: the cart, the invoice list and
* any future confirmation mail must not each invent their own wording for
* the same row.
*/
public function label(): string
{
return match ($this->type) {
'upgrade' => __('billing.cart.upgrade', ['plan' => ucfirst($this->plan)]),
'storage' => __('billing.cart.storage', ['gb' => (int) config('provisioning.storage_addon.gb', 100)]),
'traffic' => __('billing.cart.traffic', ['gb' => (int) config('provisioning.traffic.addon.gb', 1000)]),
'addon' => __('billing.addon.'.$this->addon_key.'.name'),
default => __('billing.cart.plan', ['plan' => ucfirst($this->plan)]),
};
}
/**
* Monthly or one-off.
*
* Traffic is bought for the month that is running out and does not renew;
* everything else changes the recurring bill.
*/
public function isRecurring(): bool
{
return $this->type !== 'traffic';
}
/**
* Net is what is stored; gross is what gets charged and how much VAT that
* is depends on the customer, not on a global setting.
*/
public function grossCents(): int
{
return $this->taxTreatment()->grossCents($this->amount_cents);
}
public function taxTreatment(): TaxTreatment
{
return TaxTreatment::for($this->customer);
}
/** Only a pending order is still the customer's to change. */
public function isRemovable(): bool
{
return $this->status === 'pending';
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function instance(): HasOne
{
return $this->hasOne(Instance::class);
}
/** The contract this order opened, if it was the purchase that opened one. */
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class);
}
public function runs(): MorphMany
{
return $this->morphMany(ProvisioningRun::class, 'subject');
}
/**
* Runner hook: a failed provisioning run marks the order failed (no
* auto-refund) AND releases the reserved instance, so its quota stops
* counting against host capacity.
*/
public function onProvisioningFailed(): void
{
$this->update(['status' => 'failed']);
$this->instance()->update(['status' => 'failed']);
}
}

94
app/Models/PlanFamily.php Normal file
View File

@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* A plan line the thing that has a name.
*
* "Team" stays Team through every price change and every resize. Orders,
* instances and contracts have referred to plans by `key` since day one, so the
* key is the one part of a family that must never move.
*/
class PlanFamily extends Model
{
use HasFactory;
use HasUuids;
protected $guarded = [];
protected static function booted(): void
{
static::updating(function (self $family) {
// Orders, instances and every subscription snapshot store this
// string. Renaming it does not rename those — it orphans them, and
// a checkout in flight for this family stops matching its own
// version. Display names are what the `name` column is for.
if ($family->isDirty('key')) {
throw new RuntimeException(
'A plan family key is permanent; existing orders and contracts refer to it by name. '.
'Change the display name instead.'
);
}
});
static::deleting(function (self $family) {
if ($family->versions()->whereNotNull('published_at')->exists()) {
throw new RuntimeException(
'A plan family with published versions cannot be deleted; customers are contracted to them. '.
'Switch sales off instead — it disappears from the shop and stays on record.'
);
}
// Only drafts left. The foreign key restricts, so they have to go
// explicitly — and nothing was ever promised on a draft.
$family->versions->each->delete();
});
}
protected function casts(): array
{
return [
'tier' => 'integer',
'sales_enabled' => 'boolean',
];
}
public function uniqueIds(): array
{
return ['uuid'];
}
public function versions(): HasMany
{
return $this->hasMany(PlanVersion::class);
}
/**
* The version on sale at a given moment, or null.
*
* Resolved with sole(): two overlapping windows are a mistake we want to
* hear about, not one we want silently resolved by whichever row the
* database happened to return first.
*
* @throws \Illuminate\Database\MultipleRecordsFoundException on overlap
*/
public function versionAt(?Carbon $at = null): ?PlanVersion
{
$query = $this->versions()->available($at);
return $query->count() === 0 ? null : $query->sole();
}
/** On sale right now: not killed by the switch, and a version is running. */
public function isSellableAt(?Carbon $at = null): bool
{
return $this->sales_enabled && $this->versionAt($at) !== null;
}
}

Some files were not shown because too many files have changed in this diff Show More