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 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>
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>
A deleted app deliberately keeps reserving its name (Plan 70: a restore
must never collide), but every clash site said only "already exists" —
pointing the operator at an app that is in no list (GH report: delete an
app, then a same-name deploy fails with no way forward).
app_name_clash_error() now distinguishes a tombstone from a live row and
names the release path (restore or purge from Settings -> Recycle Bin);
all five clash sites go through it. Live-row wording is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
API surface regenerated (the flagship-seeded cloudflare zone routes no
longer boot with a lean panel); the frontend route contract gains an
explicit EXTRACTED_EXTENSION_PREFIXES excuse list for the /git, /email and
/cloudflare prefixes core clients probe fail-soft.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
builtin-extensions/serverkit-git + the tracked frontend live copy removed;
the bundled registry entry flips to bundled:false at v1.1.0 (the version
that carries the Gitea backend) with the local zip's sha256; the gated
auto-install (_gitea_was_used) moves to REGISTRY_GATED_SLUGS, leaving
GATED_BUILTIN_SLUGS empty with the machinery intact.
i18n: en.json regenerated (the app.git/app.email/app.cloudflare* namespaces
ride with their extensions, which carry en+es bundles; other languages are
recoverable from git history), all locales pruned, intl-door ratchet
tightens 123 -> 115.
Expected red on this branch: the two registry-mirror tests, until the four
repos are published + signed + re-vendored (see docs/plans/83).
OPERATOR: publish jhd3197/serverkit-git, sign, sync-registry-index.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
builtin-extensions/serverkit-cloudflare-ops + the tracked frontend live
copy removed; FLAGSHIP_SLUGS is now empty (machinery kept);
plugins-manifest.json empties; the bundled registry entry flips to
bundled:false with the local zip's sha256. cloudflare_ops_bridge resolves
the copy-installed package FIRST so a registry install works without the
builtin registration path.
Tests: new conftest.mount_sibling_extension mirrors a real install from
the sibling checkout (the _wp_support.ensure_plugin analogue) and a
cf_extension fixture forces a private app; the zone-settings and round2
suites mount it autouse (74/74 green), the extraction suite pins the
lean-panel shape, and the flagship-era onboarding tests prove the
machinery with a fabricated member instead.
OPERATOR: publish jhd3197/serverkit-cloudflare-ops, sign,
sync-registry-index.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
builtin-extensions/serverkit-email + both tracked live copies removed; the
bundled registry entry flips to bundled:false with the local zip's sha256;
the gated auto-install (_email_was_configured) moves from
GATED_BUILTIN_SLUGS to REGISTRY_GATED_SLUGS so mail users still get it back
once the entry is published.
Panel-core tests re-home onto the sibling checkout via the new
conftest.sibling_extension_dir helper (skip cleanly when absent): the
extraction suite installs from the serverkit-email repo, the dovecot-probe
suite loads its module from there. All 39 affected tests green with the
sibling present.
OPERATOR: publish jhd3197/serverkit-email, sign, sync-registry-index.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
builtin-extensions/serverkit-localkit removed; the bundled registry entry
flips to bundled:false pointing at the (to-be-published) v0.2.0 release
asset with the sha256 of the locally built zip.
OPERATOR: publish jhd3197/serverkit-localkit, let its release workflow (or
update-registry.mjs) upsert + sign the registry entry, then run
scripts/sync-registry-index.sh - which replaces this hand-staged entry with
the published, signed one. Until then the two registry-mirror tests
(signature pin + published-index match) are red BY DESIGN on this branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Controller-boundary baseline drops the three api/security.py crossings that
moved to serverkit-image-scan; API surface sheds the 18 Gitea operations;
migration inventory regenerated by preflight; test-count floor raised
4735 -> 4799 (SERVERKIT_CLEAN_COLLECT).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The split point was clean: git_service.py L602-881 (Gitea install/
lifecycle/config) and 18 of git_bp's 32 routes (lifecycle + repo browsing
via GiteaAPIService) move into builtin-extensions/serverkit-git/backend/,
mounted back at the same /api/v1/git prefix when the extension is
installed. GiteaAPIService moves wholesale (its only consumer).
The deploy pipeline stays core untouched: clone/pull/deploy/webhook-verify
on GitService (all 5 core importers keep every method they call), the
webhook routes including the unauthenticated signature-verified receiver,
and the deployment routes. NginxService's gitea-config helpers stay core
as a deliberate two-speed seam the extension calls.
Upgrade parity: serverkit-git joins GATED_BUILTIN_SLUGS (auto-install only
when the panel-managed serverkit-gitea application exists) and
run_backend_acquisition now sweeps gated slugs too, so a panel that
installed the frontend-only extension picks the backend up once. The core
repo pickers keep three fail-soft getGitea* aliases that 404 into a clean
'not installed' state.
Proving suite test_git_split.py (7 tests); live smoke: builtin install
mounts all 18 routes. Render smoke green for the Git + Security pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_malware_scan_job.py / test_security_scan_jobs.py moved to their
extension repos with the security split; the staleness guard rightly
flagged them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 1791-line security omnibus splits along tool lines and the install-gated
surfaces leave core for five standalone extensions - serverkit-clamav
(ClamAV+YARA scanning, quarantine, scan jobs), serverkit-fail2ban
(management UI; probes stay core on Fail2banJailService), serverkit-lynis,
serverkit-auto-updates, and serverkit-image-scan (headless CVE/SBOM
scanning; models stay core per the plan 52 D1 data seam). Each mounts its
routes back at the same /api/v1/security prefix when installed, so route
shapes never change; absent extensions mean plain 404s, no dead UI.
Core keeps the zero-host-package baseline: status/config, legacy integrity,
suspicious activity/events, SSH keys, IP lists, the audit (fail2ban check
now probes via the jail service; updates check was already self-contained),
scoped FIM, and the scan/alert logs + notification helpers extensions write
through. Job handlers for security.malware_scan/lynis_scan/image_scan now
come exclusively from the extensions' manifest jobs key.
Security page becomes core-tabs-only; extension tabs contribute into the
security tab group (3a groupId). Overview posture drops the hardcoded
ClamAV rows and offers the Marketplace when no security extension is
installed. Upgrade parity: run_registry_auto_install() installs each
extension from the registry once, gated on the host tool actually being
present, fail-soft until the registry entry is published.
Wizard round 2 (Ph5): a security-posture chooser (minimal/recommended/
hardened) on the Summary step, catalog-resolved so unpublished slugs never
render dead installs; persisted as onboarding_security_posture.
Moved test suites re-home to the extension repos; panel keeps
test_security_suite_extraction.py (18 proving tests: absent-shape 404s,
lean summary, jail-service probes, migration gates, posture wiring).
docs/API_SURFACE.md regenerated (34 operations moved out).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fail2banJailService gains get_fail2ban_status / get_fail2ban_jail_status /
unban_ip (moved verbatim from SecurityService) and stops lazy-importing
SecurityService for its own per-site status; Setup Health probes through the
jail service too. This is the read/undo half core keeps when the fail2ban
management surface moves to the serverkit-fail2ban extension - pure
fail2ban-client probes that work with or without the extension.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WP-Phase-4 pattern, in-tree Tier C: pages/Email.jsx, its SCSS partial and a
new self-contained /email API client move into
builtin-extensions/serverkit-email/frontend/; the extension index.jsx now
imports locally (no more core re-export shim) and side-effect-imports the
SCSS so it compiles through the extension's module graph. main.scss drops
the _email partial.
Core keeps only the email-adjacent surfaces core Settings renders: the
DNS-provider methods (core dns_providers blueprint) and the relay tile
methods. serverkit-sdk grows the generic list-page chrome the relocation
needed (DataTableFooter/ListToolbar/SearchField/SortChipBar, ds/grid saved-
view chrome, PageLayout, useTableSort, useColumnVisibility) - SDK 1.4.0,
backend mirror bumped in lock-step; vendor shim regenerated.
Frontend build green, lint 0 errors, i18n check no orphans.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
serverkit-extensions#3 published the signed first-party catalog and
restored serverkit-redis (re-pinned to the rebuilt v1.0.1 asset). The
bundled mirror already carried the signatures; this picks up the
serverkit-redis entry so test_matches_published_index agrees with main
again. Generated by scripts/sync-registry-index.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rollback guard only caught ValueError from field validation, but
db.session.commit() itself can raise SQLAlchemy errors (IntegrityError,
OperationalError), leaving callers a dirty session despite the comment's
promise. Catch everything, roll back, re-raise. (Copilot review on #120)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
586f556b/025fa0be deliberately moved template icons off inline data URIs
onto https://serverkit.ai/imgs/template-icons/ (proxied from the
serverkit-templates repo), but test_roadmap_template_conventions still
pinned the old offline-inline contract, failing all 41 hosted roadmap
templates in CI. The contract now accepts the registry-hosted icon URL,
keeping the inline data-URI form only for templates with no public logo
(prompture-hub).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Harness: bootstrap find/awk in distro-compatibility.py (AL2023,
Tumbleweed, SLES BCI minimal images lack them; the source-level
suites legitimately use both)
- Python gate 3.11-3.14: Arch/Manjaro rolling ship only python 3.14
with no versioned fallback packages (same trap as Debian 13/#99);
align scripts/update.sh, which had drifted to a 3.11-3.12 gate
- Gentoo: add emerge as a supported installer package-manager path
(install.sh, scripts/lib/pkg.sh, distro.py family, deps manifest);
provision via slotted dev-lang/python:3.13 -> 3.12
- Tests: move out-of-range stubs above the new ceiling, shadow all
locate_python candidate names (real python3.14/3.13 in Manjaro/
Gentoo images were winning over fixtures), cover the 7th manager
- Catalog: Gentoo is no longer a documented expected failure
Verified: test_lib 32/32, test_install 124/124, test_update 76/76,
pytest test_serverkit_installer 46/46, and all 6 previously failing
images now pass both quick and provision container probes (12/12).
test_wordpress_ha_foundations constructed Application(...) by hand, which
the seed-one-door ratchet refuses for new files. The factory also seeds
the owning admin, so the hand-rolled User goes too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
set_policy assigned fields to the ORM row and then raised ValueError
mid-validation, leaving the API route to clean up the dirty session with a
direct db.session.rollback() — a new controller-boundary crossing the
inventory ratchet rightly refused. The service now wraps apply+commit and
rolls back before re-raising, so callers never inherit a dirty session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The drawer showed '<name>.<base> — Published automatically once the deploy
finishes' whenever a managed base domain existed, but publishing was gated
on a per-template auto_domain flag the operator can't see. For most of the
catalog the install landed on host:port instead — a link a default firewall
doesn't even expose — and the domain had to be redone by hand in the
service's Domain settings.
The promise on screen is now the contract: when the drawer displays a
domain it sends auto_domain: true, which rides the install plan to the
finalizer and overrides the template's silence (the forced flag also feeds
the SERVICE_FQDN magic context, so tokens and the published host agree).
Absent flag = the template YAML still decides, as before.
The drawer also stops promising what can't be delivered: the domain field
only renders for compose templates targeting the local server — the panel's
nginx can't proxy a remote container, and the repo pipeline has no
auto-publish step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The template fit check measured ONE filesystem (the panel data path) and
declared "This server doesn't have room for it" in red while an attached
volume sat with plenty of free space — more disk than the check admitted
the box had. Local headroom now also reports the other real mounted
filesystems (host_inventory_service.enumerate_filesystems), and when the
shortfall is on disk and another writable filesystem could hold the whole
footprint, the verdict downgrades to tight and names the volume so the
operator can point the app's data there. Memory shortfalls stay red — a
volume holds files, not processes.
Also: "more disk than it has" -> "more disk than it has free", since the
number shown is free space, not the box's total.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to 025fa0be: keep serverkit.ai as the stable icon host rather
than jsDelivr. The registry repo remains the canonical home of the logo
files; serverkit.ai proxies /imgs/template-icons/ from its raw tree, so
these URLs now resolve for all 210 logos instead of only the legacy
subset the website bundles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The offline template bundle carried the same generic stroke-icon data
URIs the registry had, and even the templates with real icons pointed at
serverkit.ai, coupling icon availability to website deploys. Icons now
live in the serverkit-templates repo itself, so reference them through
its jsDelivr mirror. Also fixes pgvector/timescaledb extensions, which
referenced a postgresql.svg that never existed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
flask-limiter picks RATELIMIT_STORAGE_URI up from app.config at init_app,
so surfacing the env var in config (only when non-empty) lets production
deployments share limit state across processes and silences the in-memory
storage warning. Unset keeps in-memory storage, matching the deliberate
single-worker gunicorn design. Documented in .env.example with probe and
mechanism tests.
- run wildcard/SAN certbot privileged and fail on non-zero exit instead
of reporting unissued cert paths as success
- cloudflare DNS-01 credentials: unpredictable mkstemp file, 0600,
always deleted (was predictable /tmp path, never cleaned up)
- route53 credentials via sudo env A=B (sudo env_reset strips env= kwarg)
- validate domains before they reach certbot argv or cert file paths
- validate reverse-proxy builder input (domains, upstreams, headers,
locations, rate-limit/cache values) against nginx config injection
- write_vhost: refuse non-plain-filename site names and roll back the
vhost when the reload itself fails, not only when nginx -t fails
- create_proxy API returns 400 on service errors instead of always 201
Pin the Compose template's full-tree wordpress_html named volume as the
supported same-host shared-state contract (uploads, plugins, themes, disk
page cache), and make WordPress vhost publishing install shared
wp-login.php/xmlrpc.php limit_req zones keyed by server name + client IP.
Generic Docker vhosts are unaffected; drift repair regenerates existing
WordPress vhosts with the policy. Spec and roadmap rows move from Partial
to Shipped with their real-host boundaries recorded.
Add ContainerScaleService policies (min/max replicas, CPU thresholds,
cooldown) persisted via migration 093, with manual scale, single
evaluation, and an admin sweep endpoint. Container Ops panel exposes
policy editing; the configured minimum acts as a floor on the next
evaluation. Frontend node:test suites pin policy normalization and the
API contract.
test_upgrade_head_matches_models failed in full CI runs with
"table ext_testext_thing: in models but never created by any migration".
Collecting test_extension_platform.py registers its synthetic plugin
model on the shared db.metadata for the whole process, and the builtin
serverkit-analytics extension leaves the same footprint when imported.
Extension tables (ext_<slug>_* per extension_lifecycle) are created at
plugin install and dropped on uninstall --purge - core Alembic
migrations never touch them - so the drift comparison now ignores the
whole ext_ namespace on both sides instead of chasing individual names.
Verified: test_extension_platform.py + test_migration_schema_drift.py
in one process (the failing CI condition) - 12 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PReuKGW6G4bac931Xxn2q
The plan 82 SE real-binaries leg failed in CI because the wrapper conf
did not stand in for what a real deployment provides: nginx -t opens
every access_log for writing (/var/log/nginx is not writable by the CI
user), `include fastcgi_params;` resolves against the conf prefix (the
tmp dir under -c), the micro-cache vhost references http-level zones
that ship as a conf.d snippet, and this nginx build's -t also binds the
listen sockets, so privileged ports fail next.
The harness now localizes /var/log/nginx, /var/cache/nginx and the
listen ports into tmp_path, writes the system fastcgi_params (or a
stub), and includes the real MICROCACHE_ZONE_SNIPPET at http level with
its cache-path parent pre-created (nginx mkdirs only the leaf). Only
path strings and port numbers are swapped - every directive still goes
through the real parser, and the garbage-rejection canary still fails.
Verified in WSL as uid 1001 against nginx/1.24.0: 8 passed under
-m real_binaries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PReuKGW6G4bac931Xxn2q
OpenAPIService.generate_spec() existed but nothing snapshotted it, so a
route rename/removal/method change shipped invisibly to API-key
consumers, the CLI, and agents. docs/API_SURFACE.md now pins the sorted
METHOD /path inventory (1418 operations) with the same
regenerate-and-review contract as docs/MIGRATION_INVENTORY.md: an
intentional surface change regenerates the doc in the same commit
(SERVERKIT_UPDATE_API_SURFACE=1 pytest
tests/test_api_surface_inventory.py) so the diff is the review.
Method+path only on purpose — summaries and schemas churn without
breaking anyone. Named *_inventory so preflight's globs run it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The panel's crontab/nginx code only ever met the fake_subprocess stub —
the 8-distro matrix runs stubs and the container legs run 12 detection
tests, so a syntax the real parser rejects failed on a user's box, not
in CI (the #117 class).
- new `real_binaries` marker (registered in conftest); the tests also
gate on SERVERKIT_REAL_BINARIES=1 so ordinary suite runs never mutate
a machine's crontab by surprise. Local loop (WSL works):
SERVERKIT_REAL_BINARIES=1 pytest tests -m real_binaries
- test_real_crontab_roundtrip: add -> list -> toggle -> remove through
the real `crontab -`/`crontab -l`, operator lines preserved, name
injection blocked; user crontab saved/restored around each test
(verified green on WSL against the real binary)
- test_real_nginx_vhost_syntax: every render_site_config flavor through
a real `nginx -t` in a tmp wrapper conf, plus a garbage probe so the
harness can't go vacuous
- backend-ci gains a real-binaries job (ubuntu runner, apt nginx+cron)
- scripts-ci gains the probe-matrix job: scripts/test/probe-matrix.sh
(real ufw/nginx/firewalld in 4 container shapes) existed since plan
75 but was wired into NO workflow; the ubuntu-root-sbinless leg was
re-verified green locally before wiring
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
version and pool_name flow from the URL straight into a filesystem path
handed to privileged write/rm — a traversal pool_name could delete an
arbitrary *.conf via run_privileged(['rm', ...]), and a hostile version
escaped the pool.d directory the same way. Both are now validated at
the service door (create/delete/get share the helpers).
_parse_pool_config returned {} on any read error, so an unreadable
pool listed as "user: www-data, pm: dynamic" — fabricated defaults.
It now returns None and the listing reports the honest unknown
(user/listen/pm null + unreadable: true), per the probe-honesty rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The jail filename derives from the site name at enable time, so a site
renamed afterwards orphans its jail: disable_jail derives a path that
no longer exists (honestly reports removed: False) while the stale
jail keeps banning against a log nginx no longer writes — invisible
to every name-derived lookup.
- list_jails() scans JAIL_DIR for serverkit-*.conf and parses the site
label + logpath out of the rendered header, the only view that still
sees orphans
- cleanup_orphans(active_keys) removes ServerKit jails no live key
derives any more (operator files never touched), reloading once
- the _sanitize collision (my.site / "my site" -> one shared jail) is
pinned as documented behavior: changing the derivation would orphan
every existing install's jail files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drift byte-compared the live vhost against the bare template render,
but an app with an enforcing WAF policy carries the post-render
include — so every WAF'd app read as permanently drifted, and one
"repair" click regenerated the vhost without the include: silently
unprotected while the DB still said enforcing.
- the injector is now a text-level function (inject_include_text) so
the live wiring and drift's expected render share one implementation
- _nginx_render_expected injects the include for enforcing policies,
making a correctly-wired vhost compare clean
- _nginx_repair re-runs WafService.apply after regenerating, so a
genuine repair re-wires protection instead of stripping it
Basic auth (WordPress-site-scoped) and the analytics tracker
(plugin-scoped) have the same erase-on-regen exposure and are tracked
in plan 82 §H — their state lives outside the app row this check walks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tests/subprocess_stub.py is the shared stubbing kit — hard-failing
UnscriptedCommand, sudo/basename normalization, sbin-guard still in
force — but adoption stalled half-way: 26 sites across 9 test files
still monkeypatch subprocess by hand, execs the runtime guard never
sees. Every other convergence here has a census + ceiling; this one
did not. Same contract as the rest: AST-counted over tracked files,
ceiling 26, may only go down; patching a service door
(run_privileged etc.) is the blessed pattern and not counted.
Preflight picks both files up by its existing globs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test suite's schema comes from db.create_all() (see conftest), so
none of the 93 alembic revisions had ever been executed by a test — a
model column with no migration keeps every test green while every real
upgrade dies with "no such column". New test runs `flask db upgrade`
against a throwaway sqlite file in a subprocess and diffs the migrated
schema against db.metadata in both directions.
First run caught real drift: migrations 059/065/068 create columns
(events_json, management_mode, allow_agent_update_observed) that the
models had lost in the recovery rebuild — restored in the previous
commit, which also brought two 500ing survey routes back to life.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Migrations 065/068 have always created servers.management_mode and
servers.allow_agent_update_observed, but the model fields,
MANAGEMENT_MODES, is_managed, and agent_registry.observed_blocked_count
were lost in the 2026-07 data loss — POST /management-mode and
GET /observed-status raised AttributeError, and a mode flip that got
past validation would land on a transient Python attribute and
evaporate. test_survey_api carried skip-stubs documenting the hole.
Restored: the two columns (specs mirror the migrations), the mode
constant and is_managed property, to_dict emission, and a minimal
observed-block counter on the registry. The four skip-stubs are now
real tests, including one proving the flip persists across sessions.
The dispatch-level Observe guard remains hollow and is tracked in
plan 82 §H. events_json on notification_preferences is likewise
re-mapped (column carried by migration 059; its resolution surface is
still lost) so pre-loss override data survives.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing verified that the ~1200 URL literals in
frontend/src/services/api/*.js resolve to rules in the backend url map,
so a backend route rename surfaced as a dead button found by a user.
New test statically extracts every this.request() literal (mirror of
test_socket_contract.py's cross-language pattern) and asserts each
matches an /api/v1 rule from the session app's route_rules; paths under
a builtin extension's plugin.json url_prefix are excused only while
that manifest still declares the prefix, and a second test keeps the
allowlist from rotting.
First run caught 78 unresolved paths. Extension-owned families
(email/ftp/cloud/status/tunnels) were excused; the rest were dead
frontend code calling routes that no longer exist, removed here:
- 11 workflow wrappers in apps.js (the workflow engine left the
backend in v1.7.33; zero callers remained)
- notification catalog/org-defaults wrappers (routes never existed;
zero callers)
- 6 WordPress standalone lifecycle wrappers (no routes, no callers;
the live status probe stays, allowlisted as extension-provided)
Frontend suite still green after the deletions (141/141, lint clean).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Locks in the round-trip suites added for the #117-class fixes (ssh
keys, env export/import, waf vhost injection, ufw parser parity,
postfix maps) plus tests accumulated since the floor was last set.
Measured with SERVERKIT_CLEAN_COLLECT=1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>