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>
Purging an app from the Recycle Bin crashed with IntegrityError
(NOT NULL constraint failed: container_scale_policies.application_id)
when the app had an auto-scale policy: with no delete cascade,
SQLAlchemy nulls the child FK on parent delete, and these one-to-one
rows declare it NOT NULL. Scale policy, sleep policy and wp_site all
share the shape; all three now ride the purge.
Reported on Discord (vaultwarden/hedgedoc delete-reinstall flow).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`vite preview` host-checks every request against `preview.allowedHosts`
and blocks any domain the repo's vite config doesn't list -- the deployed
container answered localhost probes fine and refused every request the
panel proxy forwarded ("Blocked request. This host ... is not allowed").
Preview is a dev convenience, not a production server, per vite's own docs.
The vite default start command is now `serve -s dist -l tcp://0.0.0.0:4173`
(SPA fallback, no host check); the generator preinstalls serve globally
when the start command needs it, since the repo never declares it. A
repo-authored start script still wins and gets no serve install.
The @docker_builds leg now sends a foreign `Host:` header to every booted
container -- the localhost-only probe was exactly the blind spot that let
this class through.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The self-update feature commit added the 10 new app.aboutTab keys to en
and es only; every other locale fell back to English in an otherwise
fully translated About tab. Add ar, bn, de, fr, id, it, ko, pl, pt, ru,
th, tr, vi, zh-Hans and zh-Hant, matching each file's existing register
(Sie/vous/formal).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The About tab could only detect a new release and link to it; applying the
update meant SSH and scripts/update.sh. Wire that script to the UI:
- panel_update_service launches update.sh via systemd-run in its own
transient unit — a plain subprocess child would be killed when the
updater restarts the panel's own systemd unit. Status is stateless
(transient-unit state + the updater's own update-*.log) so it survives
the mid-update backend restart.
- Capability gates keep the button honest: root systemd installs only.
Docker installs get the docker compose pull instructions; dev servers
and hosts without systemd-run get a reason instead of a button.
- GET/POST /api/v1/system/update (admin_required, confirm-gated,
audited). API surface doc regenerated.
- About tab gains Update Now with inline confirm, live log tail while
the update runs, a restarting phase that tolerates the panel being
briefly unreachable, and success/rollback/timeout outcomes. New
aboutTab locale keys in en + es.
The heavy lifting (blue/green switch, migrations, health check,
automatic rollback) stays in update.sh, which is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_buildpacks.py asserts on Dockerfile TEXT, which cannot see a bug that
only fails at build or boot time -- the `--omit=dev` dead fallback shipped
`vite: not found` to a user's deploy. New @docker_builds leg (gated on
SERVERKIT_DOCKER_BUILDS=1, sibling of @real_binaries) runs the full path
per stack: detect() -> generate_dockerfile() -> `docker build` ->
`docker run` -> HTTP 200. Fixtures: vite (the regression), express with no
build step (prod-only install must still boot), flask, static. Wired as a
backend-ci job beside real-binaries.
Writing the flask fixture surfaced the same dead-at-boot class one layer
up: detection invents `gunicorn app:app` / `uvicorn main:app` as the start
command but never installed the server, so a repo that doesn't declare
gunicorn built green and died at boot. The build command now installs the
invented server (a no-op when the repo already pins it); Procfile-authored
start commands remain the author's contract.
All four stacks verified locally: build + boot + HTTP 200 in 53s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generated node Dockerfile ran `npm install --omit=dev || npm install`.
`--omit=dev` exits 0, so the fallback never fired and `npm run build` died
with `vite: not found` -- build tooling (vite, tsc, webpack) is a
devDependency, and so is the server behind preview-style start commands.
Install the full dependency set whenever a build command exists, and put
node_modules/.bin on PATH so locally installed CLIs resolve in both RUN and
CMD. Apps with no build step still install production-only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a31cc5d3 widened the jwt-only census to app/plugins and shipped a ceiling
of 611 measured on a dev box. CI measured 591 and went red: most
app/plugins/ live copies are GITIGNORED, so the count depended on which
extensions a machine happened to have installed. That is the same
machine-dependence the builtin-copy drift census exists to prevent, and
this walked straight into it.
Count builtin-extensions/ instead -- the source, pinned equal to the live
copy by test_builtin_extension_drift.py, and tracked. Filter to
git-tracked files as well, because extractions leave stale ignored
directories behind (cloudflare-ops, git, localkit after plan 52) that
would otherwise be counted on some working copies but not others.
Stable population: 608 (35 of them extension routes across
cloud-provision, remote-access and status). Also regenerates
docs/MIGRATION_INVENTORY.md, whose row is derived from this ceiling, and
points the CLAUDE.md / AGENTS.md notes at the scope actually scanned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
30b780f4 corrected the serverkit-status description in two of its three
copies. The extension's manifest also exists at
frontend/src/plugins/serverkit-status/plugin.json, which is generated from
the builtin-extensions/ source by scripts/sync-builtin-frontends.mjs --
so Extensions CI went red on the builtin-frontend drift check, and
bump-version failed downstream of it rather than on its own.
Regenerated with the sync script rather than hand-edited; the diff is the
one description line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #122 review suggested converting the serverkit-status routes from
@jwt_required() + an inline is_admin check to rbac decorators. Declined:
plan 76's recorded decision (2026-08-19) is that JWT-only is the
deliberate default, because every rbac decorator authenticates through
auth_required() and therefore GRANTS API-key callers access -- an
expansion of the authenticated surface, never a mechanical sweep.
Two gaps let that suggestion look uncontroversial:
1. jwt_only_census.py scanned only app/api, so the extension's 15 bare
@jwt_required() routes were invisible to the ratchet. It now scans
app/plugins as well; ceiling 577 -> 611 for the newly visible
population (a scope widening, not a regression). Only the live copy
is counted -- builtin-extensions/ is the same code twice and the
drift guard pins them equal.
2. Neither CLAUDE.md nor AGENTS.md said the inline shape is already a
valid explicit decision (test_route_authz_static counts .is_admin via
ATTRIBUTE_GATES), or that ~91 routes across 21 files use it. Both now
do, with the plan 76 quote and a note that automated reviewers raise
this routinely.
Audited 2026-08-31 by AST over app/api + app/plugins + builtin-extensions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #122 review. Four routes passed request.get_json() straight into the
service, which does `if field in data` and raises TypeError on None.
Copilot reported the trigger as "empty/invalid JSON bodies"; measured on
Flask 3.1.3 that is wrong — an empty body is already a 400 and a wrong
content-type a 415, both raised by Flask before the view runs. The body
that actually reaches the view is literal `null`, which is valid JSON and
decodes to None. Same fix, accurate reason.
Also corrects plugin.json, which claimed installing the extension serves
the public /status/<slug> pages; the registry catalog says that page
stays core, and the two contradicted each other.
Both copies edited — app/plugins/ is the live copy the loader imports,
builtin-extensions/ is the source — and a test pins that they agree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
send_telegram posts with parse_mode='HTML' but interpolated the hostname,
alert type, message, value and threshold straight into the body. Telegram
then parses those as markup, so a message as ordinary as
Disk usage > 90% on /var & /tmp
is rejected with "can't parse entities" and the entire notification is
dropped — precisely when something is already wrong on the box. A value
containing <b> could also inject markup into the rendered alert.
Escape every substituted value with html.escape(quote=False); the
template's own tags are untouched.
Proving tests pinned against the unfixed service first: the three
escaping cases fail without this change, the literal-markup case passes,
so they test the behaviour and not the implementation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The venv check probed four imports (dotenv, flask, flask_socketio,
sqlalchemy). Those catch a missing or broken venv, but they are blind to
the common case: a dependency is added to requirements.txt, all four
imports still succeed, dev.sh reports nothing, and the backend dies on
the fifth import at startup. That has derailed several sessions.
Stamp a sha256 of requirements.txt into the venv after a successful
install and compare on each start, so drift triggers a sync. The import
probe stays as the broken-venv check; the hash covers what it cannot see.
Guards: the hash is computed with the venv's own python (no dependency on
sha256sum being installed), an unreadable requirements.txt yields an
empty hash and leaves a working venv alone rather than reinstalling every
start, and the marker is written only after a successful install so a
failed one retries.
Verified by driving the decision logic with pip stubbed: no marker →
install, marker current → skip, requirement appended → install, settled →
skip.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The deploy drawer posted only url/branch/app_type/build_method/port, so a
repo template's `build_command`, `start_command` and `dockerfile_path`
were decorative — declared in the YAML, read by nobody. The endpoint has
always accepted custom_build_cmd / custom_start_cmd / dockerfile_path;
only the caller was dropping them.
The visible cost was nixpacks builds: with no start command it falls back
to `npm start`, so any repo whose package.json has no `start` script
produced an image that built fine and could not boot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
localkit, email, cloudflare-ops and git are now published, signed and in
the registry index, so the panel no longer needs in-tree copies of them.
This removes ~29k lines that existed in two places at once and flips the
bundled registry so they install from the catalog like any other
extension.
Unblocked by the operator publishing all nine repos (2026-08-30); the two
registry-mirror tests that plan 83 expected to stay red now pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
# builtin-extensions/serverkit-cloudflare-ops/frontend/components/StoragePanel.jsx
# frontend/src/plugins/serverkit-cloudflare-ops/components/StoragePanel.jsx
The cutover removes four builtin extensions, so collection legitimately
drops. Verified against dev by diffing collected node ids rather than
trusting the delta:
-7 test_extension_manifests the 7 plugin.json files of the four
departing extensions (builtin + frontend)
-4 test_builtin_extension_drift anchor swap, email (7 py files) ->
status (3)
0 test_files_rbac / *_extraction param-id churn only
No feature lost its proving tests. 4799 -> 4791.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
serverkit-email's live copy under app/plugins/ was the only git-TRACKED
one, which is what kept test_builtin_extension_drift non-vacuous on a
fresh clone. Plan 52's cutover deletes it, so on CI the guard had zero
pairs to compare and test_drift_check_is_not_vacuous fired -- correctly.
It still passed on a dev box, where the gitignored live copies exist, so
the failure only reproduces in a clean checkout.
Track serverkit-status's live copy instead (verified byte-identical to
builtin-extensions/serverkit-status/backend), un-ignore just that path,
and update both docstrings to name the new anchor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #121 review follow-ups. The setup summary de-duplicated the install
queue with recommendations winning, so a security-posture extension
that also appeared as an unchecked recommendation was silently dropped
— the posture half now wins regardless of checkbox state. The
Cloudflare storage panel templated English around a translated title
(`No ${title.toLowerCase()} yet.`); each section now passes a fully
translated emptyHint instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The nine extracted extensions are now published, signed, and merged
into the serverkit-extensions main catalog; the bundled offline copy
picks up the five new security-suite entries and the source/sha256/
signature fields on localkit, email, cloudflare-ops, and git.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Extensions CI drift check (sync-builtin-frontends.mjs --check) had
no local counterpart, so a builtin-extensions/ manifest edit without a
re-sync reached CI unrun — exactly how the serverkit-git split went red
on the next PR. Same contract as the migration-inventory gate: a stale
copy is re-synced on the spot and flagged so the diff gets committed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan 52 Phase 6 git split updated the source manifest under
builtin-extensions/ (shell permission, entry_point, url_prefix,
min_panel_version) without running sync-builtin-frontends.mjs, so the
Extensions CI drift check went red on the next PR.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>