Prices. The sheet quoted net, which is the wrong number for one of its
two readers: a private customer has no way to add 20 % in their head and
no business being surprised by it at checkout. The gross figure is now
the big one, with the net underneath for a company's books, and the same
rule applies to the module prices further down — a sheet that quotes the
package with VAT and the module without it is a sheet whose numbers
cannot be added up. The rate is the one on the Finance page, the same one
an invoice uses; a percentage written into the template would be the
second source that makes the two disagree.
"zzgl. einmaliger Einrichtung" had run for months without ever naming a
figure, which leaves a visitor knowing only that there is one. It is a
field on the Finance page now and prints with the price. Zero removes the
sentence — and the comparison card that listed the fee as the honest
downside of the offer — rather than admitting to a cost that is not
charged.
Layout. Four packages in 1120px left each card 202px of text, which is
what broke "Bereitstellung in 2-3 Werktagen" over two lines and made the
cards look pressed against each other. The pricing section is wider than
the rest of the page (only it: the reading columns elsewhere are narrow
on purpose, a price sheet is four columns that must be comparable at a
glance), the gap between cards is larger, and the delivery line is short
enough to fit with whitespace-nowrap to keep it that way.
Navigation. VPN moves from Betrieb to System: the tunnel is not a task of
the day, it is how the console reaches the estate at all.
Settings. The page had grown to three headed sections in one column with
the operator's own password somewhere in the middle. It is tabbed now,
and two-factor enrolment is one of the tabs instead of a menu entry of
its own — it is a setting, and it was the one thing an operator had to
leave this page to change about their own access. Same component, no
second copy of the enrolment logic; it still answers at its own route
because RequireOperatorTwoFactor sends an operator there who may not open
anything else yet, this page included.
The open tab lives in the query string, so a reload, a bookmark and the
back button all land where the operator was. That matters here more than
elsewhere: the installation tab refreshes itself while an update runs,
and a tab kept only in the component would drop them back to the first
one mid-deployment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A paid order that no host has room for used to FAIL at the reservation
step. The customer has paid by then, so that turned a sale into an
incident and left somebody holding money against nothing. The estate is
finite and booked thick, so "no host has room right now" is an ordinary
Tuesday — it means a machine has to be bought, which takes days.
So the order waits instead. poll(), not retry(): the wait is measured in
days and must not eat the run's attempt budget. It only fails after
PARK_DAYS (14) — long enough to buy, rack and onboard a server over a
weekend, short enough that a forgotten order surfaces instead of waiting
for ever.
Then say so, in the same words on both sides of the payment:
- The price sheet marks each package "wird sofort ausgeliefert" or
"Bereitstellung in 2-3 Werktagen", read from free capacity rather
than written into the page.
- The customer's own overview says their cloud is being prepared while
it is parked, instead of a stepper standing still for two days under
"wird eingerichtet" — which reads as broken.
- The console front page shows the same per-package answer, so an
operator can see what visitors are being promised.
And a capacity page for the decision that follows: who is waiting, what
the roomiest host still has, what the queue needs ("3x Start, 1x
Business" - the LARGEST parked package sets the minimum machine, because
an instance lives on one host), and what such a machine costs today from
the provider's live list. It orders nothing: buying a server is a
contract, and a bug in a capacity calculation must not sign one.
The operator can also say where each parked order goes. A pin is
honoured or it waits — never quietly redirected — because placing it
elsewhere answers a different question than the one they answered, and
they would find out from the finished instance.
Two things this shook out:
- `current_step` is an INDEX into the pipeline, not the step's key, and
it is cast to integer. `where('current_step', 'reserve_resources')`
reads as correct and matches step 0 of EVERY pipeline instead. Both
lookups go through HostCapacity::parkedRuns() now, which resolves the
index and filters by pipeline.
- The auction feed sends `hdd_hr` as a list, not a string. Casting it
printed "Array" into the console and raised a warning that took the
page down with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The domain page shipped with no access control at all: the `custom_domain`
plan feature was checked nowhere in the codebase, and every customer on
every package could point a domain at their instance. This is the owner's
rule, built as one authority that everything else asks.
Start impossible — not bookable, not upgradable, not offered
Team optional, via the `custom_domain` module
Business included, because the plan version carries the feature
Enterprise included, likewise
App\Services\Billing\CustomDomainAccess answers all of it: may this
contract use one, may it book the module, why not in words a customer can
be shown, what a downgrade would do to a domain they have, and — after a
change lands — making the state match. Read against the FROZEN plan
version, never today's catalogue, so a feature added to a package later
cannot reach into a contract signed before it.
Which packages cannot have one at all is an explicit list in
config/provisioning.php, beside the module's price. The catalogue cannot
answer it: plan versions model what a package HAS, and nothing models what
a package may BUY. Both available proxies are dishonest — "the lowest tier
on sale" hands the right to a grandfathered Start customer the day the
owner stops selling Start or adds something cheaper below it, and reading
it off `branding` would sell a domain to anyone allowed to upload a logo.
The config comment says so.
Enforced where it can be enforced today:
- BookAddon refuses the module, with the sentence the customer is shown.
GrantAddon now writes its synthetic order inside the transaction, so a
refusal cannot leave a paid order for a module nobody got.
- The portal drops the card where it cannot be booked and shows it as
included where the package carries it — no price, no button.
- Billing::purchase() re-checks, because a hidden button is markup.
- DowngradeCheck reports the consequence beside the blockers, so the
customer reads it above the button rather than after it: losing the
domain (Team -> Start), or the choice (Business -> Team, where keeping
it means booking the module).
- PlanChange::settleCustomDomain() applies it once a change lands: the
module stops being charged for and the instance goes back to its
platform address. It clears the whole domain, not only the verified
flag — clupilot:verify-domains re-reads every row that still has a
domain and a token, and would switch it back on the next night.
- The price sheet's own-domain row now reads dash / optional + price /
included, from the same authority; the three cell states already
existed and the view is unchanged.
STILL TO APPLY, ONCE THIS AND THE CUSTOM-DOMAIN BRANCH MEET — one line at
the top of App\Livewire\CustomDomain::mount(), which belongs to the other
session and is deliberately untouched here:
abort_unless(app(\App\Services\Billing\CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);
routes/web.php is left alone for the same reason. The mount() guard covers
the route and the component both; a middleware would only repeat it.
Follow-ups, named rather than built:
- Nothing applies a plan change anywhere yet, so settleCustomDomain()
has no caller. Whoever builds that must call it after the new contract
opens, and must decide whether a booked module is carried onto the new
contract — enforce() cancels the booking on the contract it is given.
- Deactivation sets the state; it does not re-run provisioning. Traefik
and Nextcloud's trusted_domains still name the old address until
ConfigureDnsAndTls / ConfigureNextcloud run again.
- App\Support\Navigation still shows the Domain tab to every customer;
it can ask allowsCustomer() the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One boolean matrix was carrying three different kinds of statement, and it
made all three unreadable.
Updates, backup and monitoring were a tick in every column — a comparison
that compares nothing — and the same promises were repeated in a prose line
under the table. They are now stated once, above it, together with the
platform address in the shape a customer will actually type
(ihrefirma.<zone>, from the configured zone) rather than as the phrase
"eigene Subdomain", which said nothing and was modelled as though an own
domain replaced it. Provisioning issues both.
Support was two independent booleans on one axis, so enterprise — which
carries the SLA and not the priority flag — rendered "Bevorzugter Support —"
beside team's tick: the top plan looking worse than the middle one. It is
one row of levels now, and a plan with neither shows Standard, because
standard support is a level and not an absence.
A cell had two states, so a dash meant both "you cannot have this" and "you
can have this for nine euros". There are three now, and the middle one
quotes AddonCatalogue — which is also how the modules we have been selling
all along finally appear on the page at all. Nothing is invented: where we
sell no module for a missing feature, the cell stays a dash.
custom_domain joins the module list at the owner's figure so the two plans
without it can be quoted a price instead of told no.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audience line and note were LandingController::COPY, a hardcoded array
keyed on exactly the four plan keys the seed migration created — a plan
made under any other key rendered with no copy at all. They move onto
the family for the same reason the recommendation mark does: they are a
stance about the product line, not a capability that changes with a
price, so they must not be frozen per version.
Backfilled in the migration from the controller's own strings, so the
public page prints exactly what it printed a moment ago. A family with
no copy yet renders without the audience line rather than an empty gap.
Feature labels (managed_updates, daily_backups, ...) stay in the
controller: they name catalogue features, not individual plans, so
there is no per-plan or per-version place to hang an editable
customer-facing label without re-introducing a shared feature table.
That table existed once and was deliberately removed when the plan
catalogue closed its pricing split-brain; bringing it back for wording
alone is a bigger, separate decision.
Reported as "logo and font are different everywhere" — maintenance page,
customer panel, console, homepage. They were. Counting them up: the site
header set the name in ink at -0.02em, the sidebar split it into "Clu" plus
an orange "Pilot", the sign-in plate set it white at a third size, the
customer two-factor page set it in MONO with no mark at all, the error pages
and the placeholder each inlined their own — and the maintenance screen had
no wordmark whatsoever. Nine surfaces, seven ideas of the brand.
There is one now: resources/views/components/ui/brand.blade.php. Mark plus
wordmark in one piece, in ink, with an optional muted "Cloud". No orange half
— that made the accent a permanent fixture instead of something used
sparingly. The four places that cannot use a Blade component (the two
self-contained pages, the error layout, the mail layout) inline it and are
held to the same shape by a test.
The typeface. The console loads IBM Plex through Vite; the 503 page has no
stylesheet at all, so the maintenance screen — the one page shown when
somebody is already worried — was rendering in the system sans. It declares
the fonts itself now. And the bold weight was missing from public/fonts
entirely: the placeholder and the error pages both asked for
ibm-plex-sans-latin-700-normal.woff2, which was not there. A @font-face
pointing at nothing fails silently, which is why nobody noticed.
The error pages carried the same two faults the placeholder shipped with: an
empty gradient tile where the mark belongs, and `gap` on an inline-flex row
spacing "Clu", "Pilot" and "Cloud" apart as three separate words.
And the price sheet promised "Adresse auf clupilot.cloud" — a domain the
company does not own — while provisioning was issuing addresses under
whatever CLUPILOT_DNS_ZONE says. It is assembled from that setting now, the
same reason the prices are read from the catalogue rather than typed into the
page.
welcome.blade.php removed: Laravel's leftover, reachable from no route.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recommended lives on the plan family, not a version: it is a stance about
the product line ("which plan we point a visitor towards"), not a
capability that changes with a price, and freezing it per version would
make the mark silently vanish every time a new version publishes.
Setting a plan recommended clears whichever other plan carried the mark,
inside one transaction locking the whole table — two operators
recommending two different plans at once must not both win. The public
price sheet already had the highlighted-card treatment for this (it was
driven by a hardcoded "team" constant); it now reads the family instead.
Asked for after looking at how real ones are built. The shape they all share
is a convention, and a convention is what lets somebody find the answer
without being taught the page: banner, components each with ninety days of
uptime, then the written record underneath. This page had only the banner —
the version that is of no use the morning after, because "Alle Dienste in
Betrieb" is true and says nothing to somebody who was locked out the evening
before.
Three things are new.
The ninety-day bar. It cannot be reconstructed after the fact: the monitoring
table holds the last verdict per instance, not a history. So a sampler
(clupilot:sample-status, every five minutes beside the monitoring sync)
writes counts into one row per component per day, and the page divides them.
A day with no row is drawn as a gap and says "nicht aufgezeichnet" — never
green. Degraded samples count as reachable in the percentage but still colour
the day: folding them into downtime reports a slow morning as an outage,
ignoring them loses the only trace of it.
The incident record. An operator reports a disruption in the console, posts
updates as it develops, and the entry that says "behoben" is the same action
that closes it — two separate steps is how a page ends up with a resolved
note under an incident that still reports as ongoing. There is deliberately
no way to edit an update: it is a statement made at a time, corrections are a
further entry. An open incident may make a component look worse than the
probes found it, never better.
Scheduled maintenance, from the windows the console already keeps rather than
a second list somebody has to remember to fill in.
The measurement moved out of the controller into ServiceHealth, shared with
the sampler, so the banner and the bars cannot disagree about what "healthy"
means.
The page now uses the shared design system and the site's header and footer.
Its self-contained stylesheet was there to survive a broken asset build; it
does not survive one — if the stylesheet cannot be served the application
serving this page is already failing, and mid-deployment every route answers
with the maintenance page. What the exemption bought was a third design
nobody maintained.
Found while building: durationMinutes had its operands the wrong way round,
and diffInMinutes is signed, so the public page printed "Dauer -86 Minuten".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Customer::ensureUser() enforced uniqueness only within users, so a
customer provisioned (or registering publicly) with an operator's email
got a second, colliding identity for the same address — the exact thing
R21 exists to prevent. Checks Operator by address now, not by session
(the deleted assertNotAdmin() checked who was signed in, which could
never be the right question).
Public registration gets the same guard via a plain unique() validation
rule, reusing Laravel's stock "already taken" message rather than a
dedicated one — naming the collision specifically would tell a public
visitor an address belongs to staff, a worse leak than the generic
refusal every other collision here already gives them.
Impersonation answers 409 instead of crashing into it. The Stripe
webhook keeps recording the paid order (same principle as
openContract()'s own comment: the order is proof money changed hands)
and withholds only the colliding login, logged for an operator to
resolve by hand.
Verified the reverse direction Codex flagged as already covered: two
Admin\Settings actions and the clupilot:create-operator command all
already refuse an operator email that collides with a customer's, and
that check is sound today because every users row is created in
lockstep with a customers row of the same email (CreateNewUser and
ensureUser are the only two creation sites, both confirmed by grep) and
nothing in this app can change a user's email afterwards (Fortify's
updateProfileInformation feature is disabled, and no other code calls
that action).
redirect()->to(AdminArea::home()) resolved against whatever host the
leave() POST itself arrived on — the portal's, once the console has a
hostname of its own — landing the operator on the portal's dashboard
instead of back at the console. route('admin.overview') is domain-bound
to the console and generates the correct cross-host URL, matching what
this line did before this branch. The test named after this behaviour
now actually asserts it.
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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
- 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>
- 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>
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>