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>
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>
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>
- 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>