build-release.sh deleted every png/jpg in the staged tree except under
frontend/dist. That emptied frontend/src/assets/extension-icons in the
shipped /opt/serverkit, so the tree could no longer rebuild its own
frontend: ExtensionIcons.js imports 18 of those files and any `npm run
build` from the release (which `serverkit doctor --fix` triggered through
the Dockerfile) died on unresolved imports. Leave the whole frontend/ tree
alone; the tar excludes already keep docs and dev screenshots out.
Guarded in test_release_workflow.sh.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGmHp7mZ2wSeA6z8BXZVxh
`serverkit doctor` assumed the frontend is the `serverkit-frontend`
container. On a host install that container never exists: install.sh has
nginx serve frontend/dist from disk, and the container only lives under the
opt-in `legacy-frontend` compose profile. Doctor flagged a phantom issue on
every host install, and `--fix` "repaired" it with a bare `docker compose
up -d` in /opt/serverkit, which does not start that container at all: it
builds the whole all-in-one panel image, which would then collide with the
systemd backend on :5000. start/stop/restart/status/logs carried the same
assumption.
Add `frontend_mode`, which reads the layout off the serverkit vhost (a
`root .../frontend/dist` is static, a `proxy_pass` on / is the legacy
container, neither is unknown), and make every command key off it. Static
installs are checked for the bundle nginx actually serves; when it is
missing doctor names the command that restores it (`serverkit update`)
instead of promising an auto-fix. The legacy path targets the profile's
service (`docker compose --profile legacy-frontend up -d frontend`), never
the panel image.
Tests: static install passes with no compose call, missing bundle under
--fix hints at update and still never calls compose, frontend_mode
classifies all three layouts, and an awk guard fails the suite if a bare
compose call from $INSTALL_DIR comes back.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGmHp7mZ2wSeA6z8BXZVxh
Generated files the connect work moved and nobody re-ran:
- docs/API_SURFACE.md gains GET /connect/status
- docs/MIGRATION_INVENTORY.md: the hand-shaped error bodies count is 1145
(ceiling 1150) for the 409 the backups PUT now answers when Cloud owns
the destination
- en.json gains app.backups.storageManagedByCloud, so the sentence the
read-only backup form shows is translatable like every other string
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
Four things the guards and the review caught, all of them real:
- app/api/connect.py: GET /connect/status carried a bare @jwt_required(),
which the jwt-only census forbids for a new route (609 vs a ceiling of
608). It is an admin read of pairing state, so it takes @admin_required
— the same door the rest of the admin surface uses, API-key capable by
way of auth_required() like its neighbours.
- connect_policy._systemctl ran systemctl through raw subprocess, one over
the raw-subprocess ceiling. run_privileged() adds sudo when the panel is
not root, so the fail2ban repair now works off a packaged install
instead of reporting a permission error nobody can act on.
- connect_client.hello_headers assumed the end-to-end key fields were
always there. build_hello() drops them rather than fail the connection,
so a panel that could not read its X25519 key would KeyError on the
long-poll fallback — exactly the path taken when things are already bad.
- status() called resolve_cloud_url(), which raises on a URL with no
scheme. A typo in SERVERKIT_CLOUD_URL should surface when you pair, not
turn `serverkit connect status` and the Settings panel into an error.
Both connect threads are now classified in the thread-ownership registry:
the relay loop is a process loop, and a signed command is bounded delivery
whose record lives in the control plane.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
A second keypair, X25519, published in the signed hello with an Ed25519
signature over it so ServerKit Cloud can tell it is really ours — and so a
relay cannot substitute one of its own, because it does not have this
panel's identity key.
Command arguments then arrive encrypted to that key. The relay carries a
blob. That is what makes it safe for Cloud to send an object-storage
credential through it, which until now crossed the relay in the clear.
A payload this panel cannot open is a refused command with a sentence
saying what to do, never a handler running on arguments we guessed at.
- backend/app/services/connect_keys.py — the key (a raw 32-byte file, not
PEM: it is a key with no certificate story around it), the signed hello
fields, and open_sealed.
- backend/app/services/connect_client.py — publishes it on WebSocket and
on the long-poll fallback, so limited mode is not quietly less private.
- backend/app/services/connect_commands.py — unseal before the handler.
- backend/tests/test_connect_sealing.py — 13 tests, including the control plane's
half of the scheme written independently from the specification, so the two
implementations cannot drift apart silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
A facts document — firewall, SSH port, brute-force protection, the panel's
two-factor policy, its version, and when its backups last succeeded and were
verified — goes up the policy stream on connect and every six hours.
`policy.report` sends one now, which is what "Check now" presses.
The important part is what it does not claim. Each bundle is advertised in
`capabilities` only when its probe actually answered, so a rule Cloud cannot
get facts for reads "not checkable here" rather than marking a healthy
server non-compliant. Pending OS security updates have no probe here, so
that bundle is never advertised and its handler refuses honestly instead of
pretending.
Four repairs, each the panel's own path with its own refusals:
FirewallService.enable (which declines to lock SSH out), the require-2FA
policy with its grace window, systemctl start fail2ban, and the offsite
verification.
- backend/app/services/connect_policy.py
- backend/app/services/connect_commands.py — the three consent bundles
- backend/app/services/connect_client.py — the policy stream
- backend/tests/test_connect_policy.py — 17 tests, nothing touches the host
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
The panel can now run the signed commands Cloud sends, and the first four
of them are the Storage Hub's. A destination assigned in Cloud is tested
here before it is saved, written as `s3.path_prefix` (which every upload,
list and verify path already honoured), and reported back on a `storage`
stream. Unassigning hands it back and deletes nothing in the bucket.
- backend/app/services/connect_commands.py — verify a command JWT against
ServerKit Cloud's JWKS (signature, audience, exp, nonce, consent scope), run the
handler on its own thread, report running/succeeded/failed. An action
this version does not know fails with a sentence saying so.
- backend/app/services/connect_storage.py — storage.assign/unassign/
test/report, and the snapshot the storage stream carries.
- backend/app/services/connect_client.py — handle `command` opens, fetch
the JWKS per connection, publish a storage snapshot on connect and
every five minutes.
- backend/app/api/backups.py — GET /storage says when Cloud owns the
destination; PUT answers 409 rather than letting two writers race.
- frontend/src/pages/Backups.jsx — the local form is read-only while
Cloud manages it, with the sentence that says how to take it back.
- backend/tests/test_connect_storage.py — 23 tests, no network.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
On reconnect the client asks ServerKit Cloud what the current release is and
verifies the answer against the JWKS it already trusts before believing it —
the artefact lives on a CDN we do not control, so the signature is the only
reason to trust the URL in it.
- services/connect_updates.py fetch, verify, compare, and act: an install
the panel can update runs its own update path; one it cannot (Docker,
non-root) says exactly what to run on the host instead
- services/connect_client.py ask once per reconnect, at most hourly, and
never let a failed check take the connection down with it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
The panel already samples the host every 60 s into MetricsHistory for its own
charts. connect_metrics.MetricsPublisher turns the newest of those rows into
the summary Cloud expects, adds the counts Cloud alerts on (containers,
unhealthy apps, certificates near expiry, backup age) and hands it to the
relay socket on a `metrics` stream.
- services/connect_metrics.py build, buffer (five minutes, then drop rather
than send late), and apply the interval Cloud asks for
- services/connect_client.py publish on the interval from the WS loop and
read the stream close for Cloud's answer
Nothing new is collected: a minute the panel did not record is a minute Cloud
does not hear about. Every count source is optional — a panel without Docker,
domains or a backup policy sends null for those fields instead of failing the
whole summary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018q56AfqpgKjio4n9KYubvZ
- app/services/connect_client.py: RelayClient background thread (WS hello
signed device_id|ts|nonce, 20s pings, jittered 1s->60s backoff with reset
after 2 stable minutes, >6 reconnects/10min flap log, close-code ->
state_reason mapping, terminal revoked, long-poll fallback with 15min WS
re-probe); connect-state.json runtime state merged into status();
disconnect() stops the client and removes the state file; run_doctor()
implements the connect check table
- app/services/connect_keys.py: strict load_keypair() for the transport
- app/__init__.py: start_client_if_paired() next to the linked-panel hook
- cli.py: connect status shows transport/last-seen/relay-instance; new
connect doctor command (exit 1 on hard-check failure)
- requirements.txt: websockets==15.0.1 (WS client; the SocketIO stack is
server-side only)
- tests/test_connect_relay.py: 21 unit tests for backoff, flap detection,
close-code mapping, hello signature, state file, loop transitions
- app/services/connect_keys.py: Ed25519 device keypair (0600, config dir),
fingerprint = sha256(raw pubkey)[:16] shown in groups of four
- app/services/connect_client.py: enroll/poll (signed PoP)/rotate against
the control plane, writes connect.json (0600) on claim;
status()/disconnect(); control-plane URL from --cloud / SERVERKIT_CLOUD_URL / https://app.serverkit.ai
- cli.py: 'connect' group (pair, status, disconnect)
- app/api/connect.py + core_blueprints.py: GET /api/v1/connect/status (JWT)
- tests/test_core_blueprint_registry.py: registry guard constants for the
new blueprint
The Walkthrough Studio page mounted globally on top of the dashboard on
panels where the extension is not installed. Its module is baked into the
bundle (eager glob), it carried a default export, and PluginLoader's
legacy auto-render only excluded plugins whose contributions appear in
the RUNTIME envelope - which an uninstalled extension never populates.
Three guards:
- drop the studio's default export (routes resolve the named
WalkthroughStudioPage export; both the baked copy and the
builtin-extensions source)
- PluginLoader now also excludes any plugin whose BAKED manifest
declares contributions (declaresBakedContributions)
- regression test pinning both guards for every baked plugin
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit asked "does a compose file exist on disk?" and sent
everything else down the container path. That was too wide in both directions:
- test_start_app_authenticates_registry_before_compose_up seeds a
registry-bound app whose compose file has not been rendered yet, and
rightly expects `docker login` -> `compose up` -> `docker logout`. The
disk probe diverted it and the start 400'd.
- it also silently changed behaviour for any compose app whose file was
missing for an unrelated reason, turning a clear compose error into a
confusing "not been deployed yet".
Invert it. `_is_single_container_app()` is a positive test: a local app, no
compose file recorded, none on disk, and `buildpack_type` set -- i.e. one the
build pack deployed as a single container. Compose stays the default for
everything else, so no existing path changes.
Also seed the new test through `make_application` rather than constructing
Application(...) by hand, which is what test_seed_one_door_ratchet enforces
for files added after 2026-08-19.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`app_type == 'docker'` was being read as "compose-managed", but the build-pack
path deploys by building an image and running a single container
(DeploymentService._deploy_docker, named serverkit-app-<id>) and never writes
a compose file. start/stop/restart called `docker compose` anyway, which -- in
a directory with nothing to read -- fails with:
no configuration file provided: not found
So every build-pack app returned 400 from the panel's start, stop and restart
buttons and could not be run at all. There was already a materialize-compose
guard for BYO-image apps, but it only fires when root_path is NULL; a
build-pack app has source on disk, so it fell straight through to compose.
Decide on the artifact rather than the type: `_has_local_compose_project()`
looks for a compose file on disk (the app's own `compose_file`, else the four
default names). When there is one, nothing changes. When there isn't, drive
the container the deploy actually created.
Stop treats an already-gone container as stopped -- reporting that as a
failure would strand the app in `running` forever. Start and restart explain
that the app has not been deployed yet rather than surfacing a docker error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`is-blue` resolved to var(--cyan), making it pixel-identical to `is-cyan`
while Walkthrough Studio offers both in its tone dropdown. The theme defines
no --blue token, so point it at --accent, the one remaining distinct hue
among the tones on offer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
addStep() appended through a functional setDraft but then selected with
setStepIndex(draft.steps.length), reading the closed-over render snapshot.
The two can disagree under fast interaction, selecting the wrong step or an
index past the end of the list.
Build the list and derive the index from the same snapshot so they cannot
drift apart. Applied to the builtin-extensions source and the bundled copy
together; sync-builtin-frontends --check keeps them identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The signal handler searched the whole step list for a matching signal and
completed whichever step it found. These signals are ordinary product events
-- running a monitor check, creating a service -- so a later step's signal
fires readily while an earlier step is still current, marking it done out of
order and skipping every step in between.
Scope the match to currentStep, which is what the route effect directly above
already does. Normalization flattens an explicit `completion` block onto the
step (normalized.signal), so this covers both spellings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Core walkthroughs author single-condition steps with `signal:`, `check:` or
`route:` directly on the step -- 24 sites in frontend/src/data/walkthroughs.js
do it -- and the frontend registry's completionFor() has always desugared
them. The backend validator did not: `_STEP_FIELDS` rejected all three, so an
extension that copied the pattern passed `new-extension --validate` and was
then refused at install with an "unsupported fields" error.
Teach the backend the same shorthand rather than take it away from core.
`_completion_for()` mirrors completionFor() exactly (an explicit `completion`
block still wins) and returns which spelling supplied the value, so problems
are reported against the key the author wrote -- `steps[0].signal`, not
`steps[0].completion.signal`. The shorthand is a spelling, not an escape
hatch: the same token regexes and length bounds apply either way.
The published schema gains the three properties so the contract, the
validator and the frontend now agree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both committed inventories went stale behind the walkthrough work, so the
two ratchets that pin them failed on dev:
test_api_surface_inventory::test_committed_surface_matches_served_surface
test_migration_inventory::test_the_committed_inventory_matches_the_measured_state
API_SURFACE picks up GET and PUT /walkthroughs/definitions, the endpoints the
Walkthrough Studio contribution contract added. MIGRATION_INVENTORY moves the
hand-shaped {'error': ...} census 1141 -> 1142 against an unchanged ceiling of
1150; the routes above are the new sites and the ceiling still holds.
Regenerated, not hand-edited:
SERVERKIT_UPDATE_API_SURFACE=1 pytest tests/test_api_surface_inventory.py
python scripts/generate-migration-inventory.py
Version Bump failed only as a dependent of Backend CI ("Sibling CI failed;
VERSION will not advance") and needs no change of its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generated Node Dockerfile installs the full dependency set when the plan
has a build command, because vite/tsc/webpack are devDependencies. That is
correct, but it also runs the postinstall of any browser driver in the tree:
puppeteer alone downloads ~500MB of Chrome into the build layer.
On a 25GB host that is the difference between a build that exports and one
that dies with "no space left on device" while unpacking the image -- the
observed failure fetched .cache/puppeteer/chrome/linux-145/... for a static
vite site whose runtime is `serve -s dist`.
Export the three standard opt-outs before `npm install`. The packages still
install (nothing else changes); only their browser binaries are skipped, and
no production build drives a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Duplicate page-scoped keys (Title, Duration, Publish, Live preview,
Move up/down) and Walkthrough Studio strings now reuse common.labels/
common.actions instead of re-declaring identical English text.
en.json regenerated: 6430 -> 6420 keys.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Visual authoring tool for walkthroughs: create and reorder steps, pick
highlighted UI targets, choose completion modes (route, signal, manual,
check), validate against the shared schema, preview in a live dock,
publish to the server library, and import/export JSON or copy an
extension contribution block. The Studio dogfoods the new system by
declaring its own getting-started walkthrough in its manifest.
WalkthroughContext now merges built-in walkthroughs with extension
contributions and server-persisted custom guides through a shared
registry, so enabling or disabling an extension adds or removes its
guides at runtime. Adds the walkthrough contribution type to the plugin
SDK (with TypeScript types and vendor shim), Studio API methods, and
i18n strings.
Extensions can now contribute walkthroughs via contributions.walkthroughs
in plugin.json. The backend validates definitions against a shared
contract (route, signal, manual, and check completion types), transports
them through the dynamic contribution envelope, and persists custom
walkthroughs from the Studio. Includes the JSON Schema, SDK version gate,
and extension scaffold support.
on_purge_application only rmtree'd the storage dir for source=='upload'
apps. A purged git/template app left its clone under APPS_DIR behind, so
the name stayed blocked ('App directory already exists') forever while
the Recycle Bin showed nothing to explain it.
Now any non-manual app whose root_path lives under APPS_DIR is reclaimed
at purge; manual apps keep the operator-owned-directory guard.
The user-delete guard 79a78c3c added queried Application straight from
the controller, tripping the API controller boundary ratchet. The count
now lives in application_restore.applications_owned_by — the module
that owns Application's recycle-bin semantics and documents why
tombstoned rows block too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5bfb0f67 dropped the tab-local header but did not regenerate en.json,
so the lint gate failed on catalog drift. Regenerate en.json and delete
the three now-unused recycleBinTab keys from every locale bundle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An audit found 102 FK columns with no index — every join, per-parent
listing, and the delete cascades from _delete_cascade_policy scanned
whole tables on them (deployments.app_id, audit_logs.user_id,
dns_records.zone_id, webhook_logs.webhook_id, ...). All FK columns now
declare index=True; migration 096 creates the same indexes on existing
installs, deriving the list from the live schema at upgrade time (so
it is idempotent and cannot drift from what an install actually has).
Ratchet: test_every_fk_column_is_indexed pins the rule with no
allowlist — a new FK column is born indexed or the build fails.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every NOT NULL FK from the KNOWN_DANGLING_ON_DELETE audit now declares
its parent-side relationship, so the delete-cascade policy door covers
it: AI conversations/pending actions, API keys, dashboards, event
subscriptions, login links, sent invitations, resource grants (users);
env vars + history, previews + settings, WAF policy (applications);
surveys, doctor results, proxy stack, tunnels (servers); manifests
(projects); DDNS hosts (zones); exposed services (tunnels); drills
(backup policies); rollouts (agent versions); site plugin installs
(WordPress sites); queue messages (groups). The ratchet set is now
EMPTY and stays that way.
One new deliberate exception: Workspace.projects — projects carry
environments and app groupings, so delete_workspace now refuses (409)
while any remain instead of silently destroying them (previously it
crashed with IntegrityError).
Migration 095 sweeps rows already orphaned on existing installs by
pre-fix hard-deletes — including an application's leftover env-var
secrets, which a reused SQLite row id could have attached to a future
app.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Companion sweep to _delete_cascade_policy: 26 NOT NULL FK columns have
no parent-side relationship, so a parent hard-delete leaves their rows
dangling (SQLite FK enforcement is off, and a purged parent id can be
reused). Freeze the audited set so it may only shrink — a new model
declares the parent-side relationship (the door then handles its
delete) or consciously adds itself with a reason.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the 29 hand-written cascade='all, delete-orphan' declarations
from c1d4c0af/1704d60b/79a78c3c with a single mapper hook
(_delete_cascade_policy): at configure time, every one-to-many whose
child FK is NOT NULL gets the delete cascade automatically — with a
NOT NULL FK the children cannot outlive the parent, so the only choice
is cascade or crash. New models (extensions included — registering one
re-fires the hook) are covered with zero ceremony; nothing to remember.
Relationships where the parent delete should be REFUSED instead opt
out in the module's DELIBERATELY_UNCASCADED registry with their
justification (User.applications guarded by delete_user's 409,
CloudProvider.servers with no delete path). The sweep test now proves
the door works rather than policing model authors, and pins the
registry so it can only shrink.
Model files revert to their pre-fix text: delete semantics live in one
place instead of twenty.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 1704d60b, prompted by 'can this happen elsewhere?' — a
mapper-wide audit found 22 more one-to-many relationships whose child
FK is NOT NULL but which lacked a delete cascade, so hard-deleting the
parent (user/server/workspace/template/plugin delete, deployment
retention pruning, WP site teardown) raised the same IntegrityError as
the Recycle Bin purge.
20 get the cascade: plugin installs, deployment diffs, metric alerts,
template assignments (both sides), WP activities/sync/promotion jobs
(both sides), workspace members/keys, and the per-user detail rows
(passkeys, OAuth identities, notification prefs, sanitization
profiles, source connections, workflows, workspace memberships).
2 stay uncascaded on purpose, now enforced: User.applications (delete_
user refuses with 409 while the user owns apps — apps must go through
app delete + Recycle Bin purge so containers/volumes are cleaned) and
CloudProvider.servers (no delete path exists; dropping VM inventory
must stay a deliberate decision). The invariant sweep is now global
across every mapper with exactly that allowlist, which may only shrink.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tab-local title, retention blurb and standalone Refresh button
duplicated chrome the grid already provides (Refresh data lives in the
grid tools menu; per-row 'purges in Xd' carries the retention signal).
The page now leads with the view picker and table like other grids.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Field follow-up to c1d4c0af: the live box then crashed on
deployments.app_id — dynamic relationships get the same FK null-out
on parent delete as the one-to-ones. Cascade every one-to-many from
Application whose child FK is NOT NULL (deployments, config
snapshots, image scans, SBOMs, image update checks, git deployments),
and pin the invariant with a mapper-inspection sweep so a future
NOT NULL child cannot reintroduce the purge crash.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>