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.
73 KiB
ServerKit WordPress Roadmap
Turning ServerKit's WordPress support from "a folder + a database + an nginx config" into a managed WordPress platform object.
The thesis: integration, not absence
ServerKit already owns an unusually deep set of primitives — a multi-server agent, container-to-container DB cloning with serialized-safe search-replace, full Let's Encrypt + DNS-01 wildcard SSL, S3/B2/MinIO offsite backups, a workflow engine, RBAC/workspaces, status pages, notifications — plus a genuinely WordPress-aware environment pipeline (prod → staging → dev → multidev, locking, pre-op snapshots, promote/sync, basic-auth).
A capability audit of ~144 managed-WordPress features scored them:
| Status | Count | Meaning |
|---|---|---|
| ✅ Have | 23 | Built end-to-end (service + API + UI) |
| 🟡 Partial | 71 | Exists but generic, half-wired, or backend-only |
| ❌ Missing | 50 | Genuinely absent |
That 71 is the roadmap. Most primitives terminate at a generic Application/domain/db-name target and were never wired to the WordPressSite object. SSL never touches a WP site; offsite backups never touch a WP snapshot; routes that already work server-side (flush-cache, search-replace, harden, per-site health, disk usage) have no frontend button at all. The fastest path to a managed feel is wiring, not building.
How to read this document
- Tasks are globally numbered (#1 … #35) so they're individually addressable — e.g. "do #12".
- Tasks are grouped into Phases 0 → D, ordered so cheap, high-trust wiring lands before net-new systems.
- Each task lists Today (what already exists, with
file:line), Do (the concrete change), Reuse (existing services to lean on), and Done when (acceptance).
Effort legend (single-developer estimate; many are parallelizable):
| Tag | Meaning |
|---|---|
| S | ≤ 2 hours — mostly wiring |
| M | ~ half a day to a day |
| L | 1–3 days |
| XL | multi-day epic; split before starting |
Status legend:
| Mark | Meaning |
|---|---|
| 🐛 | Active defect / data-loss risk — fix regardless of roadmap |
| 🟡 | Almost there — the backend or model already exists; this is glue |
| ❌ | Net-new build |
Target: the "WordPress site object"
The north star. A managed site should expose this button row on every site and every environment, all backed by tasks below:
Open Site · WP Admin · Auto Login · Create Backup · Restore · Create Staging · Push to Live · Pull from Live · Purge Cache · Update · Security Scan · View Logs · Open Database · Open Files · WP-CLI · Manage Domains
And this consolidated health header: WP version · PHP version · SSL status · backup status · update count · disk usage · cache status · security warnings · uptime %.
Phase 0 — Foundational fixes (blocker, do first)
These are bugs the rest of the roadmap stands on. Estimated cluster: half a day.
#1 — Collapse the duplicate /sites blueprints [S] 🐛 — ✅ Done
- Today: Both
wordpress_bpandwordpress_sites_bpregister at/api/v1/wordpress(backend/app/__init__.py:122-123), and both defineGET /sitesandPOST /sites(api/wordpress.py:20,28vsapi/wordpress_sites.py:31,53) — plus collisions onGET/DELETE /sites/<id>, environments, plugins, themes, andupdate. Flask keeps the first-registered rule, so the bare Docker-template create wins and the richer hardening/install create is dead-routed. Behavior depends on URL-map ordering. - Do: Pick one create surface. Move the unique routes from one blueprint, delete the duplicate
/sitesGET+POST, and register a single create path. - Reuse:
WordPressService.create_site,wordpress_sites.pycreate body. - Done when: Exactly one rule answers
POST /api/v1/wordpress/sites; the UI payload matches it. - Landed:
wordpress_bp(the canonical Docker-stack model the live UI creates against with{name, adminEmail}) keeps the hub/plugins/themes/update routes; the shadowed duplicates were removed fromwordpress_sites_bp, which now owns only its unique routes (sync, snapshots, clone-db, git). Verified 0 path+method collisions across the two blueprints. Provably behavior-preserving sincewordpress_bpalready won every collision. (Follow-up for #2/#15: the resource-tier gate and snapshot-before-update that lived in the removed legacy create/update should be ported onto the canonical path.)
#2 — Make every create path harden + install properly [S] 🐛 — ✅ Done (Docker-valid hardening via wp_cli; admin password surfaced once)
- Today: The UI path (
pages/WordPress.jsx:43-65→services/wordpress.js:9) sends only{name, adminEmail}and runs the container-only template path — no wp-config hardening, no admin user, no SSL. The hardening path (WordPressService.install_wordpress→harden_wordpress,services/wordpress_service.py:146,497) is the one that gets shadowed by #1. - Do: Route the single create through
install_wordpress+harden_wordpressregardless of standalone vs Docker. - Reuse:
install_wordpress(:146),harden_wordpress(:497). - Done when: A freshly created site has an admin user,
DISALLOW_FILE_EDIT/FORCE_SSL_ADMIN, XML-RPC off, and shuffled salts.
#3 — Report the real site URL [S] 🐛 — ✅ Done
- Today:
_enrich_site_data(services/wordpress_service.py:918-932) setssite.urltohttp://localhost:<port>, so "Open Site"/"Open WP Admin" are wrong for any real domain. - Do: Derive
urlfrom the site's primary domain (HTTPS when SSL present), falling back to the port only when no domain is attached. - Reuse:
application.domains(already into_dict,models/wordpress_site.py:158). - Done when: "Open Site" lands on the live domain.
#4 — Backup-before-delete + archive state [M] 🐛 — ✅ Done
- Today:
delete_site→_teardown_wp_site(services/wordpress_service.py:1032,1178) doescompose_down(remove_volumes=True)+rmtreewith no snapshot.delete_environmentis the same. Promote/sync already snapshot-first — the pattern exists, it's just not applied to delete. - Do: Take a final
DatabaseSnapshot(+ optional file archive) before teardown. Add anarchivedstate that stops the stack but retains a restorable backup. - Reuse:
db_sync_servicesnapshot creation; the snapshot-first pattern fromenvironment_pipeline_service.py. - Done when: Deleting a site leaves a restorable snapshot; an archived site can be brought back.
- Landed:
delete_site(create_backup=True)now runs the Docker-awarebackup_wordpress()(full files + DB toBACKUP_DIR, outside the site root so it survivesrmtree) before teardown, and returns the backup info. New reversiblearchive_site/unarchive_site(compose_down(volumes=False)keeps data →status='archived';unarchiverestarts →running). New routesPOST /sites/<id>/archive,/unarchive, andDELETE /sites/<id>?create_backup=falseopt-out; frontendarchiveSite/unarchiveSite/deleteSite({createBackup})added. Also fixed a latent bug:_teardown_wp_sitepassedcompose_down(remove_volumes=…)but the real param isvolumes=— the old call would have raisedTypeError, so delete was effectively broken. (Follow-up: surface Archive/Delete buttons in a site DangerZone — there is currently no site-delete UI in the WP hub.)
Phase A — Glue the object together (almost-there wins)
Every task here reuses a service that already exists and is verified present. This is the highest "managed feel" per hour. Many run in parallel. Estimated cluster: 1–2 days, parallelizable.
#5 — Surface the orphaned WP-CLI actions in the UI [S] 🟡 — ✅ Done
- Today: Routes exist and work —
harden(api/wordpress.py:499),search-replace(:513, serialized-safe),flush-cache(:550) — butservices/wordpress.jshas noharden/searchReplace/flushCachemethods, so there's no button. - Do: Add the three
ApiServicemethods + controls onWordPressDetail(a "Purge Cache" button, a guarded search-replace dialog, a "Harden" action). - Reuse:
WordPressService.harden_wordpress/search_replace/flush_cache(:497,609,626). - Done when: Purge/search-replace/harden are one click each.
#6 — One consolidated site-health card [M] 🟡 — ✅ Done
- Today:
EnvironmentHealthService.check_health/get_disk_usagecompute container/MySQL/WP/disk status; frontend stubsgetProjectHealth/getEnvironmentHealth/getEnvironmentDiskUsageexist;components/wordpress/DiskUsageBar.jsxis imported nowhere.WordPressDetailOverviewTab shows static text and calls none of them. - Do: Assemble one health header (WP/PHP version, SSL status [from #8], backup status, update count [from #7], disk usage, cache status [from #22/#23], security warnings) and render
DiskUsageBar. - Reuse:
EnvironmentHealthService, the existing frontend stubs,DiskUsageBar.jsx. - Done when: The site overview shows a live health header instead of static fields.
#7 — Update badges + Update buttons [M] 🟡 — ✅ Done
- Today:
get_wordpress_info(services/wordpress_service.py:216) computesupdate_available/latest_version; the plugin/theme list JSON already carries per-item update flags. The OverviewTab/PluginsTab/ThemesTab parse none of it.update_themesservice method is missing (onlyupdate_plugins/update_wordpressexist). - Do: Render a core update badge + per-plugin/theme "Update" buttons; add
update_themes. - Reuse:
update_wordpress(:256),update_plugins(:312),get_plugins/get_themes. - Done when: Available updates show a count and update in place.
#8 — Wire SSL to the WordPress site [M] 🟡 — ✅ Done (live status; Docker/localhost degrades gracefully)
- Today:
ssl_service.obtain_certificate/advanced_ssl_service.issue_wildcard_cert/setup_auto_renewaltake arbitrary domains and are never called bycreate_site; the per-env nginx template listens on:80only.WordPressSitehas nossl_status. The SSL-health methods inservices/security.js(getSSLHealth,issueWildcardCert, expiry alerts) have zero.jsxconsumers. - Do: Add a per-site "Enable SSL" action + an
ssl_statusfield; callSSLServiceon create/domain-attach; use a wildcard cert forstaging.*/dev.*/multidev subdomains; surface cert grade/expiry on the site's domain panel. - Reuse:
ssl_service,advanced_ssl_service,security.js. - Done when: Creating/attaching a domain provisions TLS and the health card shows SSL status.
#9 — Stop dropping the sanitization profile [S] 🟡 — ✅ Done
- Today:
PromoteModal/SyncModalsendsanitization_profile_id;db_sync_service.apply_sanitization_profileexists — butapi/environment_pipeline.py/EnvironmentPipelineServicenever resolve the id; only the booleanoptions['sanitize']is honored. The profile (incl. WooCommerce payment-table stripping) is silently ignored. - Do: Resolve
SanitizationProfileby id inpromote_database/sync_from_productionand pass it to_transform_dump. - Reuse:
apply_sanitization_profile,SanitizationProfilemodel. - Done when: A selected profile demonstrably rewrites/strips data on sync.
#10 — Close the rollback loop on promotions [M] 🟡 — ✅ Done
- Today:
PromotionJob.pre_promotion_snapshot_idand statusrolled_backexist;db_sync_service.restore_snapshotexists; pre-promotion snapshots are captured on every promote — but nothing ever calls restore androlled_backis never set. The safety net is captured and never used. - Do: Add
POST /promotions/<id>/rollbackthat restores the linked snapshot and flips status; optionally auto-trigger on failed promote. - Reuse:
restore_snapshot,PromotionJob. - Done when: A bad promote can be undone in one click.
#11 — Push WP snapshots offsite + enforce retention [M] 🟡 — ✅ Done
- Today:
DatabaseSnapshots land only in the localSNAPSHOT_DIR.storage_provider_service.upload_file/upload_directory(S3/B2/MinIO/Wasabi, SSRF-validated, verify-by-MD5) andbackup_serviceretention/scheduling/notifications are fully built but bound to the genericApplicationmodel.db_sync_service.cleanup_old_snapshotsexists but no scheduler calls it andexpires_atis never set. - Do: Hand each completed snapshot to
StorageProviderService; populateexpires_at; schedulecleanup_old_snapshots. - Reuse:
storage_provider_service,backup_service, the cron scheduler. - Done when: Snapshots replicate to configured object storage and age out per policy.
#12 — Deep-link DB, files, and logs from the site [S] 🟡 — ✅ Done
- Today:
QueryRunner.jsx+api/databases.pyand the File Manager + host log viewer exist but are reached only by raw DB-name / manual path; none deep-link to a site'sdb_name/ docroot /debug.log. - Do: Add "Open Database", "Open Files", and "View Logs" buttons that pass the site's
db_name/root_path. - Reuse:
QueryRunner, File Manager, log viewer. - Done when: Each button opens the right resource pre-scoped to the site.
#13 — Make push-to-deploy actually deploy WordPress [M] 🟡 — ✅ Done
- Today:
WordPressSite.auto_deploy/git_repo_url/git_branch/last_deploy_commitare persisted (connect_repo) and returned in status, butauto_deployis never consumed — inbound git webhooks only deploy genericApplications.git_wordpress_service.rollback_to_commitanddeploy_from_commitexist with no route/UI; WP keeps only a singlelast_deploy_commit. - Do: Route inbound git webhooks to
GitWordPressService.deploy_from_commitwhenauto_deployis set; expose a WP deploy history list + rollback button. - Reuse:
git_wordpress_service, the existing webhook receiver, theGitDeploymenthistory pattern. - Done when: A push to the tracked branch deploys theme/plugin/code and history is browsable + reversible.
#14 — Make the WP REST surface API-key reachable [S] 🟡 — ✅ Done
- Today: The
X-API-Keymiddleware authenticates only the RBAC decorators, but all ofapi/wordpress_sites.pyis bare@jwt_required()(24 routes), so programmatic automation can't touch WordPress. - Do: Switch WP routes to the RBAC decorators that honor
g.api_key_user. - Reuse: the existing API-key middleware + RBAC decorators.
- Done when: A scoped
sk_key can drive WP site operations.
Phase B — Lifecycle + login (the platform-object identity)
These are the features that make a user say "this is a managed platform, not a folder." They sit directly on the primitives glued in Phase A. Estimated cluster: 3–5 days.
#15 — True one-click site lifecycle [L] ❌ — ✅ Done (Docker-correct create bundle: PHP + page/object cache + admin in one wizard; TLS/custom-domain deferred — same infra gap as #21)
- Today: Create is incoherent (see #1/#2): UI collects only
{name, adminEmail}; no SSL/PHP/cache options anywhere in the flow. - Do: A single
WordPressSiteService.createthat orchestrates existing services in sequence — provision DB + admin (install_wordpress+harden_wordpress), issue SSL (#8), honor a chosen PHP version (#24), optionally enable cache (#22/#23), then register theWordPressSite. Expose domain + PHP version + admin + "enable SSL/cache" inCreateSiteModal. - Reuse: everything from Phase 0 + #8.
- Done when: One wizard produces a hardened, TLS'd, admin-ready site on a real domain.
- Landed:
WordPressService.create_siteis now the single orchestrator and gainedphp_version/enable_page_cache/enable_object_cache. The chosen PHP version is baked into the initial image tag (concrete6.4-php<x.y>-apache, so the site boots on the right PHP with no post-create recreate), followed by the existing finalize+_harden_docker_site, then best-effort page cache (#22) and Redis object cache (#23) afterwp core install(redis already ships in the stack, so no recreate). Cache enablement never fails the create — failures degrade to a non-fatalwarning; the one-time admin password is still surfaced once. The live create modal (inline inWordPress.jsx, not the orphanedCreateSiteModal.jsx) gained a PHP version selector (Default / 8.1 / 8.2 / 8.3) and page-cache / object-cache toggles, threaded throughcreateForm→POST /sitesasphpVersion/enablePageCache/enableObjectCache(PHP version validated server-side againstget_available_php_versions()). Mechanism: declared a hiddenVERSIONtemplate var and switched the image line to bare${VERSION}in bothwordpress.yamlandwordpress-external-db.yamlso a concrete tag is written at install — this also fixes a latent #24 bug where the unresolved${VERSION:-…}literal madeset_php_versiondrop the WP-core pin (set_php_versionnow also falls back to the known core for legacy compose files). No DB migration: PHP / object-cache / SSL are read live; the page-cache flag rides the existingsync_configJSON column (mirroring the per-site page-cache route). Verified: backendpy_compile, frontend lint-clean + production build, a 3-lens adversarial review (no blockers), and Docker Hub confirms all6.4-php8.{1,2,3}-apachetags exist. (Deferred: SSL/TLS + custom-domain at create. Managed sites arehttp://localhost:PORTcontainers with no per-site reverse proxy and no domain attached at create — a public, routable domain + nginx vhost is the same missing infrastructure that blocks #21 and that #8 left as frontend-only. TLS is already operable post-create viaSiteSSLPanel(#8) once a domain is routed; thedomains.py→NginxService.create_siteprimitive exists for a future create-time wiring.)
#16 — Passwordless WP-admin login (the signature feature) [M] ❌ — ✅ Done (WP-CLI magic link; needs container egress to install the wp-cli-login package once)
- Today: "Open WP Admin" is a dumb
window.opento{site.url}/wp-admin. Grep confirms zero login/magic/token/SSO code in the WP API.create_user(:646) andreset_password(:666) exist, so managed-admin provisioning is half-done. - Do (no heavy plugin — WP-CLI bridge):
- Mint a one-time URL via
wp package install wp-cli/login-commandthenwp login create <user> --url-only(or a ~30-line mu-plugin validating an HMAC nonce). - Backend route resolves the managed admin tied to the operator's JWT email (create one via
create_userif absent). - Write a
wordpress.admin_loginAuditLogentry (the genericAuditLogalready exists). - Resolve a relocated login slug via
wp eval 'echo wp_login_url();'instead of hardcoding/wp-admin. - Add
auto_login_enabled+ a per-site permitted-users check.
- Mint a one-time URL via
- Reuse:
wordpress_service.wp_cli(:61-143),create_user/reset_password,AuditLog, panel RBAC. - Done when: "Auto Login" drops a permitted operator straight into
wp-admin, logged, with no stored WP password.
#17 — Existing-site import [M] ❌ — ✅ Done (.sql/.sql.gz + wp-content/full-site zip; SFTP pull deferred)
- Today: No import path exists — only blank create or prod→child-env clone.
- Do: Accept a
wp-content/full-site zip +.sqldump (or an SFTP pull), provision DB + files, run the serialized-safesearch-replacefor the new URL, and register aWordPressSite. - Reuse:
db_sync_servicesearch-replace/clone,_copy_wordpress_files,install_wordpress,ftp_service. - Done when: An exported site comes up running on a new domain.
- Landed (wp-content zip): the deferred files half now ships on top of the
.sqlMVP. Theimport_site(wp_content_zip_path=…)stub (it previously just warned "not yet supported") is implemented: after the fresh stack is up and the DB imported + search-replaced, an optional wp-content/full-site.zipis safely extracted and copied into the container. Security-first extraction —_safe_extract_ziprejects any member whose resolved path escapes the temp dir (zip-slip:../traversal, deep traversal, absolute paths) before writing anything, and rejects non-zip/corrupt archives._resolve_wp_content_dirhandles the three real-world layouts — a zip of wp-content (root hasplugins/themes/uploads), a zip withwp-content/at the root, and a full-site zip nested one wrapper folder deep — then_import_wp_content_zipdocker cps the resolved tree into/var/www/html/wp-contentandchowns it towww-data(imported files land root-owned). Failures are non-fatal: the DB import still succeeds and the response carries awarning+wp_content_imported: false. RoutePOST /sites/importgained an optionalwp_contentmultipart file (validated.zip, saved to its own temp, cleaned up infinallyalongside the SQL temp); clientimportSite({wpContentFile})+ an optional wp-content archive file input in the import modal (with a success toast when it lands). Verified: backendpy_compile, 11 new pytest tests (zip-slip..//deep/absolute rejection, bad-zip rejection, safe extraction, all 3 wp-content layouts + the unrecognizable case, and thedocker cp+chownflow / cp-failure / no-wp-content paths — Docker mocked), full suite 211 passed / 12 skipped, frontend lint-clean + production build. (Deferred: the SFTP pull source — the zip/.sqlupload path covers the common export case; an SFTP-pull importer is a separate connector.) Thedocker cp/chownstep is not runtime-exercised on a real Docker host (Windows dev).
#18 — Clone-to-new-independent-site with fresh credentials [M] 🟡 — ✅ Done
- Today:
create_environment(services/wordpress_env_service.py:97-228) clones files + DB (table-prefix + search-replace) but always links viaproduction_site_idand inherits the parent'sadmin_user/admin_email/credentials (:165-171). No "duplicate as a brand-new top-level site". - Do: Generalize the clone to produce a standalone site and generate fresh admin credentials via
create_user/reset_password. - Reuse:
create_environmentinternals,db_sync_service. - Done when: "Clone" yields an independent site with its own credentials.
#19 — Multisite detection [S] 🟡 — ✅ Done
- Today:
WordPressSite.multisiteis a decorative boolean (models/wordpress_site.py:25), never populated from reality. - Do: Populate it via
wp core is-multisiteduring create/health. - Reuse:
wp_cli. - Done when: The flag reflects the actual install.
#20 — Site labels / tags (agency organization) [S] ❌ — ✅ Done
- Today: No tags/labels/client/group field on
WordPressSite; UI renders no chips. - Do: Add a
tags(and optionalclient/group) field + UI chips + filtering on the sites list. - Reuse:
WordPressSite.to_dict,WordPressSiteCard.jsx. - Done when: Sites can be tagged and filtered.
#21 — Temporary preview URL before DNS is pointed [M] ❌ — ✅ Done (unblocked by the base-domain + wildcard-cert stack: once sites_base_domain is set and Phase-5 wildcard HTTPS is enabled, every new site is reachable at <slug>.<base> with a valid *.<base> cert the moment it is created — no per-site DNS, no localhost:port. The create flow now surfaces the live link — and, when no base domain is configured, nudges the operator to set one. A user pointing a custom domain keeps this base subdomain as the working preview until DNS propagates. The distinct *.preview.<panel-domain> scheme is unnecessary given the wildcard base domain.)
- Today:
EnvironmentDomainService.generate_domainonly emits real subdomains or a.localhostfallback; the env nginx template listens on:80only. No preview hostname scheme. - Do: Add a wildcard/preview hostname (e.g.
<site>.preview.<panel-domain>or a*.sslip.io-style scheme) with auto TLS so a site is reachable and verifiable pre-DNS. - Reuse:
environment_domain_service, wildcard SSL (#8). - Done when: Every new site gets a working HTTPS preview link immediately.
Phase C — WordPress-aware performance & observability
The headline value of a managed platform, and the most net-new — but it leans on existing nginx/cache/health/metrics/notification primitives. Estimated cluster: 4–7 days.
#22 — Page cache with WP-aware skip rules [L] ❌ — ✅ Done (Docker-correct: in-container cache-enabler plugin, NOT nginx fastcgi_cache which doesn't apply to the apache-container model)
- Today:
nginx_advanced_servicecan emitproxy_cache_path/proxy_cache/proxy_cache_bypasszones with config-test + diff, but the WP location template (services/nginx_service.py:215-237) has no cache and the WP UI has no cache controls. - Do: Add
fastcgi_cache/proxy_cacheto the per-site vhost with skip rules (wp-admin/wp-login/preview, auth/cart/checkout cookies, query strings) + a single-URL purge action wired to the existingflush-cache(#5). - Reuse:
nginx_advanced_service,nginx_serviceWP template. - Done when: Anonymous hits are cached; logged-in/cart paths bypass; purge works.
#23 — Per-site Redis object cache [L] ❌ — ✅ Done (adds a redis service to the compose stack + redis-cache plugin via wp_cli; new sites ship redis, existing sites get it injected on enable)
- Today: Only a blind
wp cache flushwith nothing behind it. - Do: Add a Redis service to the compose stack (or a shared Redis DB index per env);
wp plugin install redis-cache --activate; writeWP_REDIS_HOST/WP_REDIS_DATABASEintowp-config; surface enable/status/flush on the site. - Reuse:
wp_cli,wordpress.yamlcompose template,flush_cache. - Done when: Object cache is enabled and reports a hit ratio.
#24 — Per-site PHP version & limits panel [M] 🟡 — ✅ Done (read panel: live version + limits via wp eval; version switch via image tag + recreate; arbitrary limit-WRITE now durable via a conf.d ini + compose bind-mount)
- Today: The full PHP-FPM API (
getPHPVersions/setDefaultPHPVersion/installPHPVersion/createPHPPool/getPHPPools/restartPHPFPMinservices/system.js) has zero frontend consumers;php_versiononApplicationis write-once with no update route, and pools are decoupled from the WP model. - Do: A per-site PHP panel (version +
memory_limit/upload size/max_execution_time/workers) bound to the WP environment + an update-pool/change-version route. - Reuse:
php_service,system.js. - Done when: A site's PHP version and limits are editable per environment.
- Landed (limit-WRITE): the deferred write side now ships, Docker-correct for the
wordpress:*-apache(Apache + mod_php) model. NewWordPressService.set_php_limits(path, limits)whitelists +re.fullmatch-validates each directive (memory_limit,upload_max_filesize,post_max_size,max_execution_time,max_input_time,max_input_vars) — fullmatch (not match) so a value like"256M\nevil = 1"can't smuggle a second ini line past the$anchor — then writes a host-sidephp-conf/zz-serverkit.iniand bind-mounts that single file into/usr/local/etc/php/conf.d/so it survives container recreate (a plaindocker execwrite would be lost on the nextcompose up). The host file is written before the mount is added (a single-file bind-mount needs its source to pre-exist, else Docker makes a directory); a newly-added mount applies viacompose up -d(recreate), a content-only change viacompose_restart('wordpress')so mod_php re-readsphp.ini. Partial updates merge with the existing drop-in (no directive is dropped), and the mount is injected once (idempotent). RoutePOST /sites/<id>/php/limits(@admin_required), clientsetPhpLimits, and an Edit PHP Limits panel added to the PHP tab (per-directive inputs seeded from live values, partial-diff save). Also fixed a latent #24 bug: thePhpTabcomponent (the read panel + version switch) was built but never wired intoWordPressDetail— no tab button, no render branch — so the entire PHP panel was invisible; added'php'toVALID_TABS+ a PHP tab + the render branch, so the read panel and the new limits editor actually render. Verified: backendpy_compile, 7 new pytest tests (unknown-directive + injection-value rejection, non-stack-site guard, ini-write + single-file-mount injection, partial-merge + no-duplicate-mount, unlimited/size-suffix acceptance, ini parsing), full suite 200 passed / 12 skipped, frontend lint-clean (thePhpTab-unused warning is now gone) + production build. Not runtime-exercised on a real Docker host (Windows dev).
#25 — Per-site traffic / error / cache analytics [L] ❌ — ✅ Done (Docker-correct: on-demand apache access-log analytics + PHP-fatals from the WP_DEBUG log; response-time / cache-ratio deferred)
- Today: All metrics are host/agent-keyed; API analytics covers only ServerKit's own API. No per-site visits/bandwidth/5xx/404/PHP-fatal.
- Do: An nginx access/error +
wp-content/debug.logingest pipeline keyed to each WP vhost → visits, bandwidth, bot %, top URLs, 5xx/404, PHP fatals, slow pages, cache hit ratio. - Reuse: the existing log-tail /
metrics_history_serviceaggregation patterns. - Done when: The site dashboard shows per-site traffic and error analytics.
- Landed: Architectural pivot — managed WP sites are
wordpress:*-apachecontainers onlocalhost:PORTwith no per-site nginx, so the roadmap's "nginx access log" premise doesn't hold; the official image symlinks Apache's access log to the container's stdout, makingdocker logsthe Docker-correct source. NewWpAnalyticsService.get_traffic(container_name, hours)pullsdocker logs --tail 20000 --since <h>(hard 15s timeout, reads only stdout to separate the access log from the stderr error_log,returncode-checked so stopped/missing/remote-agent containers degrade to an accurate note) and parses the Apache combined log on-demand — no store / collector / migration. It returns requests, unique visitors, bandwidth, status distribution (2xx/3xx/4xx/5xx), 404s, bot %, error rate, top URLs (grouped by route — query strings stripped so visitor tokens aren't surfaced), and a continuous hourly requests/errors series. RouteGET /sites/<id>/analytics?hours=(owner/admin-guarded), clientgetSiteAnalytics, and a new Analytics tab on the site detail (stat cards + a recharts requests/errors AreaChart + status codes + Top URLs) with graceful empty/timeout/unavailable states. This also supplies the 5xx/error-rate signal #27's "error spike" rule needs. (Deferred — not derivable from the default access log: PHP fatals needwp-content/debug.logvia a WP_DEBUG toggle (#30); response time / slow pages need a%DLogFormat the official image doesn't emit; cache hit ratio is a cache-plugin concern (#22/#23). History is point-in-time over the retained container log (resets on container recreate) — a persistent time-series collector on themetrics_historypattern is a clean follow-up.) Verified: backendpy_compile, frontend lint-clean + production build, a 3-lens adversarial review (major fixed: docker-logs timeout + memory cap; minor: accurate unavailable note viareturncode; query-string token stripping). Not runtime-exercised on a real Docker host (Windows dev). - Landed (PHP fatals): the deferred PHP-fatals signal now ships, sourced from the WP_DEBUG log (
/tmp/wp-debug.log) that #30's Security-tab toggle writes — not the access log (which can't carry it), exactly as the original deferral noted. NewWpAnalyticsService.get_php_errors(container)does one boundeddocker exec(a marker-guardedtail -c 200000with the same 15s single-worker timeout asget_traffic) to distinguish "WP_DEBUG logging off / no log" from "on but clean", then parses WP debug lines ([time] PHP <Level>: <msg>) into severity counts (fatal/warning/notice/deprecated —Parse/Recoverable fatalfold into fatal) + the 25 most-recent entries (messages capped at 300 chars; stack-trace continuation lines skipped). Merged intoGET /sites/<id>/analyticsasphp_errors, and a PHP errors panel added to the Analytics tab (severity counts + recent list, with an explicit "enable WP_DEBUG logging on the Security tab" empty state). This also supplies the per-site error signal #27's "error spike" rule wanted. Verified: backendpy_compile, 7 new pytest tests (level classification, parse-and-skip-stack-traces, logging-off vs on-but-empty vs container-unavailable, message truncation — docker mocked), full suite 218 passed / 12 skipped, frontend lint-clean + production build. (Still deferred: response-time/slow-pages — needs a%DLogFormat the official image doesn't emit — and cache hit-ratio, a #22/#23 plugin concern.) Thedocker execread is not runtime-exercised on a real host (Windows dev).
#26 — Per-site uptime + auto-incidents [M] 🟡 — ✅ Done (server-side health poller → bound status-page component with a real uptime % + auto-open/resolve incidents)
- Today: Status pages / uptime have no FK to a WP site; components are hand-typed with arbitrary
check_target;EnvironmentHealthServiceoutages don't open incidents. - Do: Background-schedule
EnvironmentHealthServicechecks to accrue per-site uptime %; auto-create a status-page component from a managed site; bridgehealth_status→ status component + auto-open incident on outage. - Reuse:
EnvironmentHealthService,status_page_service,uptime_service. - Done when: A managed site appears on a status page with a real uptime % and auto-incidents.
- Landed: A server-side daemon-thread health poller (
_start_health_check_scheduler, 300s, single-worker-guarded) runscheck_healthfor every running production site — skipping archived/stopped stacks so an intentional stop is never an outage — which keepshealth_statusfresh autonomously (so #27's alerts fire without the browser health card open) and drives bound status-page components. Schema (Alembic010+ the boot-time_fix_missing_columnsauto-add):status_components.wordpress_site_id(→wordpress_sites) andstatus_incidents.component_id(→status_components); the existingHealthChecktable is reused as the uptime sample store (no new table). NewStatusPageService.sync_component_from_healthmaps the health verdict (healthy→operational, degraded→degraded, unhealthy→major_outage;unknownskipped so it never pollutes the %), records aHealthCheck, recomputes a real uptime % (COUNT-based over 24h/7d/30d/90d — only fully-healthy checks count, so degraded reduces it), and auto-opens an incident on entering a major outage / auto-resolves on leaving it (to operational or degraded, so a degraded intermediate poll never strands the incident). Sample rows are pruned to 90 days (once/day) to bound growth. WP routesGET/POST/DELETE /sites/<id>/status-pageread the binding + attach (production-only) / detach (resolves & unlinks the incident first → no dangling FK on PostgreSQL, no stale public incident), plus a new Uptime tab on the site detail (health + uptime % + attach/detach). Security: internal probe config (check_target, intervals) is stripped from the unauthenticated public-page projection, and a health-driven component stores no internallocalhost:porttarget. Verified: backendpy_compile, frontend lint-clean + production build, and a 3-lens adversarial review (3 major findings fixed: the incident-resolve edge, the detach FK/orphan, and the publiccheck_targetleak). Not runtime-exercised on a real Docker host (Windows dev).
#27 — WP-aware alerting [M] 🟡 — ✅ Done (site down/recovered → channels + catalog event, edge-triggered; error-spike deferred to #25)
- Today: Multi-channel
notification_service(email/Slack/Discord/Telegram/webhook) exists; theWorkflowEventBushealth_check_failedhook exists; neither knows about WordPress sites. - Do: Add WP alert rules (site down / health failed / error spike) routed through
notification_servicevia the event bus. - Reuse:
notification_service,WorkflowEventBus. - Done when: A site outage or error spike pings the configured channel.
- Landed:
EnvironmentHealthService.check_healthnow captures the priorhealth_statusand, on a state TRANSITION only (edge-triggered — a continuously-down site fires a single down alert, recovery fires a single up alert, so an autonomous poller never spams), routes a WP health alert through three sinks: (1) the existingWorkflowEventBus.emit('health_check_failed')hook is preserved (still fires every failing poll for back-compat with event-trigger workflows); (2) NEW —NotificationService.send_alldispatches to every enabled channel (Discord/Slack/Telegram/email/generic),unhealthy→critical/degraded→warning(delivered by default channels) and recovery→info(delivered only to channels that opt intoinfo); fired off-thread becausesend_alldoes blocking HTTP/SMTP and must not stall the health check; (3) NEW —wordpress.site_down/wordpress.site_upadded toEVENT_CATALOGand emitted viaEventService, so user webhook subscriptions (incl. awordpress.*wildcard) deliver with HMAC + retry, and the events auto-appear in the event-subscription UI (GET …/eventsreturns the catalog). No schema/migration and no frontend — channel delivery is governed by the existing Notifications settings; this also seeds #35's WP lifecycle-event registry. (Deferred: the "error spike" rule needs the per-site error metrics that #25 will provide — revisit once #25 lands. Alerts are most useful once #26's server-side scheduler runscheck_healthautonomously; today health only runs while the #6 card is open.)
Phase D — Safe updates, security depth, agency scale
Highest-effort, broadest surface — but by now the WordPressSite is a first-class object with health, SSL, backups, cache, and an API-key-reachable REST surface, so these bind cleanly. Estimated cluster: 1–2 weeks, parallelizable.
#28 — Plugin/theme/core vulnerability scanning [L] ❌ — ✅ Done (keyless WPVulnerability feed; per-site findings with severity)
- Today:
get_plugins/get_themesreturn slug + version; the VulnerabilityTab is OS-only (Lynis). No WP vuln feed. - Do: Cross-reference slug+version against a public WordPress vulnerability API (e.g. a WPScan/Patchstack-style feed), persist findings per
WordPressSite, surface in the VulnerabilityTab. - Reuse:
get_plugins/get_themes, the existing vuln UI shell. - Done when: Vulnerable plugins/themes are flagged per site with severity.
- Landed: New
WpVulnerabilityServicereads installed plugin/theme/core slug+version via the Docker-aware WP-CLI bridge (wp core version+get_plugins/get_themesdirectly — not the host-filesystem-gatedget_wordpress_info, which fails for volume-backed sites) and cross-references the keyless community WPVulnerability API (https://www.wpvulnerability.net/{plugin,theme,core}/…, no signup, 1h in-memory cache, descriptive UA, 8s timeout). It matches the installed version against each advisory'soperatorbounds (plugins/themes; core advisories all apply since the feed is version-scoped), extracts CVE id / severity (cvss3→cvssshort-code → score fallback) / fixed-in / reference, and persists results as a newWordPressVulnerabilitychild model (FK →wordpress_sites.id) + alast_vuln_scan_atcolumn (Alembic 011, idempotent). The scan runs in a background thread (Lynis-style, so the single worker never blocks on the WP-CLI + external HTTP), and a new Vulnerabilities tab on the site detail polls for status and renders findings with severity badges, CVE links, installed/fixed versions, and a summary. On-demand only (no scheduled external calls). Security hardening (3-lens review):reference_urlis restricted to http(s) (blocksjavascript:hrefs), the feed slug is percent-encoded, and an unrecognized version operator skips rather than over-flags. (Deferred follow-ups: a scheduled periodic re-scan, and an optional "bring-your-own WPScan API key" for manually-verified data.) Verified: backendpy_compile, frontend lint-clean + production build, 3-lens adversarial review (major fixed: the Docker volume-backed core-read gating). Not runtime-exercised on a real host.
#29 — Safe update manager [L] 🟡 — ✅ Done (snapshot → update → health-check → auto-rollback + history + schedule; staging-first deferred)
- Today: Update primitives exist (#7); no history, scheduling, exclusions, staging-first, and pre-update backup is DB-only/core-only.
- Do: Scheduled updates via the cron workflow scheduler + a maintenance-window concept; an exclusion list + update-run records per site; files + db pre-update snapshot (not just db); run updates against the staging env via the promote pipeline; auto-rollback on failure using the pre-update snapshot +
restore_snapshot(#10). - Reuse: cron scheduler,
db_sync_servicesnapshots, promote pipeline,restore_snapshot. - Done when: Updates run on a schedule, staging-first, with backup + auto-rollback and a report.
- Landed: New
WpUpdateService.start_updateruns a background safe update (Docker-correct, all viawp_cli): record pre-update versions →wp db exporta DB snapshot (a generous 600s timeout — added atimeoutparam towp_cli— so a large DB is never truncated; aborts if the snapshot fails — no net, no update) → apply updates only to components with an available update minus an exclusion list → a quiet, side-effect-free health probe (wp eval+ the HTTP probe; deliberately does NOT callcheck_health, so an auto-rollback never fires spurious #27 down/up pages) → if the update regressed a previously-healthy site (now unhealthy/degraded), AUTO-ROLLBACK: version-pin each updated component back (wp <type> update --version=<old> --force --skip-plugins --skip-themes, so it runs even on a fatally-broken site) + re-import the snapshot → re-check → persist aWordPressUpdateRunreport. The snapshot is kept for manual restore whenever the site doesn't end verified-healthy; deleted only on success. A per-site cron schedule + exclusion list is driven by a new daemon-thread scheduler bounded by a concurrency semaphore (a shared 3am cron can't stampede the host). New modelWordPressUpdateRun+ 2 columns (Alembic 012); a new Updates tab (run-now, schedule, exclusions, run-history). Also fixed a latent #26/#27 bug surfaced by the review:EnvironmentHealthService.check_healthreturnedunknownfor every production site (its_get_compose_pathneeds acontainer_prefixproduction sites lack), making uptime/incidents/alerts inert for the primary site type — now falls back to the Applicationroot_pathcompose so production health actually works. (Deferred: staging-first promotion — update a staging env via the promote pipeline, validate, then promote to prod — needs a staging env + full env-pipeline orchestration; revisit on #15/#18.) Verified: backendpy_compile, frontend lint-clean (full build was blocked only by a concurrent agent's unrelated in-progress AI import, not #29 code), 2-lens adversarial review (blockers fixed: production-site health gating + DB-import timeout; majors fixed: scheduler stampede, snapshot-abort). Not runtime-exercised on a real host.
#30 — Per-site security depth [L] 🟡 — ✅ Done (file integrity + WP_DEBUG toggle + WP-Cron + brute-force jail, Docker-correct)
- Today: ClamAV + file-integrity scan host paths (
/var/www,/home,/etc) and can't reach Docker WP volumes; nothing resolves a site to a scannable docroot; integrity never callswp ... verify-checksums; nowp-login.php/xmlrpcrate-limit jail. - Do: Per-site scan-target resolution; file integrity via
wp core/plugin/theme verify-checksums; a Fail2ban jail/filter (or nginxlimit_reqzone) targeting each site's access log forwp-login.php/xmlrpc.php; per-siteWP_DEBUG/SCRIPT_DEBUGtoggle and WP-Cron list/run/disable. - Reuse:
security_service(Fail2ban jails),wp_cli,cron_service,nginx_serviceWP template. - Done when: A site can be scanned, checksum-verified, brute-force-protected, and debug-toggled.
- Landed: New
WpSecurityService+ a per-site Security tab (Docker-correct, all via the WP-CLI bridge): (1) File integrity — a backgroundwp core verify-checksums+wp plugin verify-checksums --all(in-container, so it reaches the volume; runs off the request thread and the UI polls), flagging tampered/unexpected files. (2) Debug toggle — setsWP_DEBUG/SCRIPT_DEBUGwithWP_DEBUG_DISPLAY=falseand, crucially,WP_DEBUG_LOG=/tmp/wp-debug.log— outside the web root, so the log is never publicly fetchable (the defaultwp-content/debug.logis apache-served — a real info leak this avoids); the write is gated so a read-only/stopped site reports an honest failure. (3) WP-Cron —DISABLE_WP_CRONstatus + due-event list, run-due-now, and enable/disable (with a clear "needs a real system cron" warning). Routes mirror the established per-site pattern (owner-or-admin GET,@admin_requiredmutations). Verified: backendpy_compile, frontend lint-clean + build, focused security review (debug-log web-exposure caught + fixed). Not runtime-exercised on a real host. - Update — layered brute-force protection shipped: every managed site has a per-site nginx reverse-proxy vhost whose access log (
NginxService.site_access_log_path) recordswp-login.php/xmlrpc.phphits with the real client IP.Fail2banJailServicegenerates a ServerKit-owned filter and per-site jail (jail.d/serverkit-<app>.conf, ownership-guarded, Linux-only, best-effort); the Security tab controls it, shows live bans, and unbans. WordPress publishing also installs shared nginx request-limit zones and exact endpoint locations automatically:wp-login.phpallows 10 requests/minute with a burst of 10, whilexmlrpc.phpallows 30 requests/minute with a burst of 20. The zone key combines server name and client IP, which isolates each site's allowance. Focused tests cover rendering, idempotent zone writes, WordPress-only publishing, and the generic-Docker boundary. Live enforcement still needs a Linux nginx host.
#31 — Selective push UI [S] 🟡 — ✅ Done (specific-tables selectors added; files-only/db-only/specific-folders already existed)
- Today: The dump layer already supports table filters / files-only / db-only;
PromoteModalexposes only coarse code/db/full. - Do: Expose files-only / db-only / specific-folders / specific-tables selectors in
PromoteModal. - Reuse:
db_sync_service_transform_dumpfilters,environment_pipelinepromote. - Done when: A promote can move just selected files/tables.
- Landed:
PromoteModalalready had files-only (codetype), db-only (databasetype), and specific-folders (theinclude_plugins/themes/mu_plugins/uploadscheckboxes); the only missing piece was specific-tables, now added as two comma-separated inputs in the database section — Exclude tables (omitted entirely) and Truncate tables (structure promoted, rows dropped) — split into arrays on submit. The full backend chain already honored them (POST …/promotedocuments+forwardsconfig.exclude_tables/truncate_tables→promote_database/promote_full→clone_options→_transform_dump), so this is pure UI wiring with no backend change. Verified: frontend lint-clean + production build.
#32 — Per-site SFTP [M] ❌ — ⏸️ Deferred (Docker-correct path is a per-site SFTP sidecar, but it ships an unverifiable, publicly-exposed file-access surface)
- Today: FTP is host-global (
ftp_service); no per-site SFTP. - Do: Add SFTP via an sshd
Subsystem/chroot scoped to the site root, bound to the site object. - Reuse:
ftp_servicepatterns. - Done when: Each site exposes scoped SFTP credentials.
- Assessment / why deferred: The roadmap's host-sshd-chroot approach doesn't fit the Docker model — a managed site's files live in the
wordpress_htmlnamed volume, not a host path, so a host-level chroot would target the wrong place. The Docker-correct design is a per-site SFTP sidecar container (e.g.atmoz/sftp) mountingwordpress_html, injected into the compose stack exactly like #23's redis (_ensure_*_in_stack+ additivecompose up -d), with a generated credential surfaced once and a new mapped port (via the template's port-allocation). That is genuinely implementable on the proven pattern — but it (a) opens a publicly-exposed, security-sensitive file-access surface that should be firewalled and that I cannot runtime-verify from the dev host, and (b) consumes a port + a container per site. Shipping security-sensitive infra blind violates the quality bar, so this is deferred for a focused pass on a real Docker host (where the sidecar, chroot perms, and firewall can be exercised). No code shipped.
#33 — Agency scale (workspaces, RBAC, white-label, reports) [XL] 🟡 — 🟡 In progress (✅ reports · workspace_id foundation · selector · brand color · resource reassignment · WP-hub scoping · per-site ACL (backend + sharing UI + viewer/editor roles + operate access) · domains/databases workspace scoping · workspace↔User role reconciliation; remaining: server-side workspace-scoped logo, broad per-route write-enforcement of the workspace viewer-cap)
- Today: No
workspace_idFK onApplication/WordPressSite/Server; permissions are global with no per-site ACL; white-label lives inlocalStorageand never readsWorkspace.logo_url/primary_color. - Do: Add
workspace_idFKs + per-site ACL rows + environment-tier RBAC in promote/sync/bulk endpoints; server-side branding scoped to workspace; monthly client reports aggregating uptime/updates/backups/security. - Reuse:
workspace_service,permission_service,audit_service. - Done when: A site/environment can be scoped to a workspace with per-site roles and branded reports.
- Assessment / why deferred: A genuine
XLmulti-day epic (the roadmap's own guidance is "split before starting"), and it cuts across the whole system rather than the WordPress object: (1)workspace_idFK migrations onApplication/WordPressSite/Server+ backfill + scoping every list/query; (2) a per-site ACL model + enforcement woven through promote/sync/bulk endpoints; (3) moving white-label fromlocalStorageto server-rendered, workspace-scoped branding; (4) a monthly reports generator aggregating the now-existing uptime (#26) / updates (#29) / backups / security (#28/#30) signals. Each sub-part is its own feature; done together they're a milestone, not a single change — and the cross-cutting FK + RBAC rework is high-blast-radius. Foundation now in place from this session: the report's data sources (per-site uptime/incidents #26, update history #29, vuln findings #28) all exist, so the reports sub-feature is the most self-contained slice to start with. Deferred to a dedicated milestone. - Landed (reports slice): the self-contained monthly client reports sub-feature shipped — the milestone's first slice, deliberately built without the high-blast-radius
workspace_idrework. NewWpReportsServiceaggregates a calendar month's signals into a single persisted, printable report: uptime % + a daily series + incidents re-aggregated from the bound status component'sHealthChecksamples for the exact month (#26), update runs (counts + components-updated, #29), DatabaseSnapshot backups (count + bytes), and the current vulnerability posture (#28) + health/disk — each section honestly labelled PERSISTED-history vs point-in-time (vulns are snapshot-replaced each scan, so they're captured as-of generation; traffic/on-demand security scans are noted as excluded). Why persisted, not live: generation snapshots the point-in-time sources so a report regenerated months later still shows the right month's posture; the true-historical sources are re-queried per month. Generation is synchronous (pure DB aggregation — no WP-CLI/HTTP, so no background thread), and upserts per(site, YYYY-MM)so regenerating replaces. Client grouping is derived from theclient-*/client:*tag (#20) — noworkspace_idFK needed. NewWordPressReportmodel (Alembic 014, chains off013_ai_assistant; defined inwordpress_site.py+ imported lazily in the service so the concurrent AI agent'smodels/__init__.pyis never touched), routesGET /sites/<id>/reports,POST …/reports/generate(@admin_required),DELETE …/reports/<rid>, clientgetReports/generateReport/deleteReport, and a new Reports tab (month picker → generate, per-month selector, a recharts daily-uptime chart, summary stat cards, incident/update/backup/vuln sections) with a Print → Save-as-PDF path (scoped@media printisolation, no PDF dependency) and a Download JSON export. Verified: backendpy_compile, 9 new pytest tests (helpers, bound + unbound aggregation, upsert, future-month guard, API round-trip, + 2 regression tests for review fixes), frontend lint-clean + production build, and a 3-lens adversarial review (5 findings; 4 fixed — daily-series future-day off-by-one, ongoing-incident duration clamped to the in-month window, dead error branch, robust print-cleanup; the 5th — admin-only buttons shown to non-admin owners — left for parity with #28/#29/#30 which do the same). (Still deferred for the dedicated milestone: cross-cuttingworkspace_idFKs + per-site ACL + server-side workspace-scoped branding. The report already inherits the operator's existing client-side white-label in-app; server-side branding upgrades cleanly later by feeding the same{name, logo, color}triplet into the print view.) Not runtime-exercised on a real Docker host (Windows dev). - Landed (workspace foundation slice): the cross-cutting backbone the rest of #33 needs — a nullable
workspace_idFK onapplications+servers(Alembic 015, chains off014, with an idempotent raw-SQL backfill that creates a Default workspace, attaches every workspace-less row, and makes every existing user a member so nobody loses visibility) + a centralized scope helper inWorkspaceService(ensure_default_workspace,resolve_workspace_id,scope_query). The design rule is opt-in + narrow-only: a request only filters by workspace when it carries anX-Workspace-Idheader /?workspace_id=, andscope_queryintersects the workspace filter with the existing per-user ownership filter so it can only ever narrow visibility, never broaden it — with no workspace context the behavior is byte-identical to before (admin→all, owner→own apps, servers global). Wired intoapps.py(list + create) andservers.py(list + create), replacing the duplicatedif admin … else filter_by(user_id)branch and stamping new resources with the active-or-default workspace. Deliberately not a 36-route rewrite: this lands the mechanism + the two core resource surfaces as the proven pattern; broad route adoption, per-site ACL, the workspace↔User role reconciliation, server-side branding, and a frontend active-workspace selector are the follow-on slices. Verified: backendpy_compile, 10 new pytest tests (helper branches, scope-narrowing, an explicit can't-broaden-access regression, deactivated-user gate, default-stamping, the raw-SQL backfill run against real SQLite + idempotency), full suite 120 passed, a real end-to-endalembic upgradeto015on a fresh DB (columns + both indexes created), and a 2-lens adversarial review (3 findings, all fixed —is_activegate on the workspace path, FK-add guarded on theworkspacestable existing, and the missingworkspace_idindexes for upgraded installs). The earlier draft's biggest risk — that the backfill (everything→Default, everyone→member) plus a workspace filter would let a non-admin enumerate all resources via the default workspace — was caught and fixed pre-review by makingscope_queryintersect with ownership. - Landed (frontend workspace selector): the keystone that makes the foundation user-visible — the research flagged "no current-workspace context in the frontend" as the gap #33 must close. A self-contained
WorkspaceSwitcherin the sidebar reads/writes anactive_workspace_idinlocalStorage, which the central API client (services/api/client.js) now sends ambiently as theX-Workspace-Idheader on every request (endpoints that don't honor it ignore it; cleared on logout). Switching reloads so all lists re-fetch under the new scope; the switcher self-hides for single-workspace installs and drops a stale selection on load. To make an ambient header safe, backendresolve_workspace_idwas first made lenient (a deleted/forbidden workspace degrades to no-scope instead of 403/404, so a stale header can never break the apps/servers lists — still safe since no-scope only ever shows the user's own resources). Verified: frontend lint-clean + production build; the header→scoping path is covered end-to-end by the backend API tests. (Follow-on: surfacingworkspace_idin more list endpoints, a workspace management UX beyond the existing admin list, and per-workspace logo override.) - Landed (workspace-scoped brand color): the visible payoff — selecting a client workspace now recolors the whole panel to that client's brand. A workspace's
primary_color(already on the model; now editable via a color picker added to the Workspaces create modal) is mirrored by theWorkspaceSwitcherinto aworkspace_accentlocalStorage key, andThemeContextapplies it via the existingapplyAccentToDOMwith precedence over the user's personal accent (workspace_accent || accentColor) — recoloring the logo gradient, buttons, and highlights. Deterministic (no effect-ordering races) because switching reloads and the value is read once on mount; cleared on logout and on a stale selection; reverts to the user accent on "All workspaces". Default color is the app accent, so a workspace is unbranded unless a custom color is chosen. Verified: frontend lint-clean (no new warnings) + production build. (Deferred: per-workspace logo swap in the sidebar brand — the accent recolor is the high-impact piece and the default logo already recolors with the accent.) - Landed (resource reassignment): completes the organizing loop — existing resources were stuck wherever the backfill put them (Default); now they can be moved into client workspaces. New endpoints
PUT /apps/<id>/workspace(owner-or-admin on the app) andPUT /servers/<id>/workspace(developer+), both requiring the target be a workspace the caller can access (member or admin); a null target moves a resource back to Default. UI: an Apps management modal on each Workspaces card (mirrors the Members modal) listing the workspace's apps with move-in/move-out — it fetches withallWorkspaces(an emptyX-Workspace-Idheader) so it can see apps in other workspaces to move them in, sidestepping the ambient scope. Fixed a real bug caught by tests: the ownership check comparedapp.user_idto the stringifiedget_jwt_identity()(int≠str → spurious 403); now usesuser.id. Verified: backendpy_compile, 2 new endpoint tests (ownership + target-membership + null→Default + role gates), full suite 122 passed, frontend lint-clean + production build. The management modal now handles both apps and servers (a "Resources" modal on each Workspaces card), each with move-in/move-out; servers fetch with the sameallWorkspacesheader bypass. - Landed (WordPress hub workspace scoping): the active-workspace switcher now also filters the WordPress sites hub, not just apps/servers (it was the inconsistent gap). WP sites carry no
workspace_idof their own, soWordPressService.get_sites(workspace_id=…)scopes join-based through the site's parentApplication.workspace_id;list_sitesresolves the active workspace and passes it. The hub is global today, so with no workspace context behavior is unchanged (additive, narrow-only). No frontend change — the WP hub already fetches through the header-injectingapi.request, so it auto-scopes on the ambientX-Workspace-Id. Verified: backendpy_compile, a new API test (global vs workspace-scoped), full suite 123 passed. (Follow-on for broad adoption: databases/domains are murkier — server-level vs app-child — and need their own analysis before scoping.) - Landed (per-site ACL — backend): per-resource sharing — an owner/admin can grant a specific user access to a specific application (transitively its WordPress site / DB / domains) without that user owning it (access-level; per-grant view/edit roles reserved for later via a
rolecolumn). NewResourceGrantmodel (Alembic 016, unique per user×resource) +ResourceGrantService(grant/revoke/list/user_has_grant/granted_ids). Enforcement at the cleanest seams:WorkspaceService.scope_querygainedgrant_resource_typeso granted apps appear in the apps list (own OR granted, then workspace-narrowed);get_appand — crucially — the WordPress_owner_or_admin_apphelper became grant-aware, so one change opens every WP per-site route to a grantee. Grant-management API onapps.py(GET/POST/DELETE /apps/<id>/grants, owner-or-admin; can't grant to the owner; revoke is resource-scoped so you can't revoke another app's grant by id). Also usesuser.id(int) not the stringifiedget_jwt_identity(). Verified: 4 new ACL tests (visibility via list, get_app, WP route access, management perms + idempotency + revoke), full suite 127 passed, end-to-endalembic upgradeto016. Sharing UI (shipped): a per-app Share action in the Workspaces "Resources" modal opens an inline sub-view (grant a user → access list with revoke; the owner and existing grantees are filtered out of the add-list); client methodsgetAppGrants/grantAppAccess/revokeAppAccess. Verified: frontend lint-clean + build. Viewer/editor roles + operate access (shipped): grants now carry a real role —editor(view + operate) orviewer(read-only) — and the generic app operate endpoints became grant-aware. Two helpers split it:_can_access_app(owner/admin/any grant) gates reads (get_app,/linked,/logs,/container-logs,/containers,/status),_can_edit_app(owner/admin/editor grant) gates mutations (/link,/unlink,/environment,PUT,/start,/stop,/restart);ResourceGrantService.grant_rolebacks the split. Delete stays owner-or-admin (an editor can't delete — tested). The grant POST validatesrole ∈ {viewer, editor}(default editor); the Sharing UI gained a role selector. The same sweep fixed the latent int-vs-str ownership bug across ~14 app endpoints (they comparedapp.user_idto the stringifiedget_jwt_identity()— nowuser.id). Verified: 3 more ACL tests (viewer read-only + can't-operate, editor operates + can't-delete, role validation/default), full suite 130 passed, frontend lint-clean + build. Cross-blueprint sweep (shipped): the per-resource grant now extends to every app-related blueprint, not just apps.py + WordPress. The two access helpers were promoted to a shared seam (ResourceGrantService.can_access_app/can_edit_app;apps.pydelegates), and 33 ownership checks across 6 blueprints were made grant-aware, categorized read-vs-mutate (via a parallel mapping workflow):builds.py(12),domains.py(7, incl. SSL enable/disable/renew + domain CRUD = editor),deploy.py(6),private_urls.py(5),templates.py(2),databases.py(1). Reads → any grant; mutates → editor grant. The sweep also fixed the same latent int-vs-str ownership bug in all 33 (nowuser.id). Verified: a cross-blueprint integration test (grantee reachesbuildsdeployments; stranger 403), full suite 131 passed.python.py(shipped): re-reading showed every mutating endpoint there is independently@admin_required(runs before the sharedget_app_or_404gate), so the gate only governs the 4 read GETs — pointing it atcan_access_appsafely opens those to grantees while the admin-only mutations are untouched. No per-method split needed. With this, every app-related blueprint is grant-aware. Verified: a python read-gate test (stranger 403, grantee passes), full suite 132 passed. - Landed (domains/databases scoping + role reconciliation): the two named remaining scoping slices, plus the reconciliation primitive that makes the per-workspace role mean something. (1) Domains —
get_domainsnow scopes through the parentApplication(domains carry noworkspace_idof their own): it resolves the active workspace and lists the domains of the appsscope_queryreturns (own + granted, workspace-narrowed). With no workspace context this preserves prior behavior (admin → all domains, owner → own apps' domains) and now also surfaces granted apps' domains — closing a latent gap where the per-domain routes authorized viacan_access_appbut the list ignored grants. SinceDomain.application_idis non-nullable, the admin-no-context path is byte-identical to the oldDomain.query.all(). (2) Databases — only the Docker-databases enumeration (/docker/databases) is app-linked and therefore workspace-scopable; it now drives its app iteration through the samescope_query(replacing a manualcan_access_apploop with the equivalent query-level filter + workspace narrowing). The host-level MySQL/PostgreSQL/SQLite endpoints are server-global system resources with no app/workspace linkage (and already admin-gated for mutation), so they're intentionally left unscoped — documented inline. (3) Role reconciliation — newWorkspaceService.effective_role(user, workspace_id)+can_write_in_workspace: reconcile the user's globalUser.rolewith their per-workspaceWorkspaceMember.role, narrow-only (mirrorsscope_query) — a workspace role can only reduce capability, never elevate. A system admin is never capped (no workspace lock-out); a workspace viewer caps a global developer to read-only in that workspace; any other membership leaves the global role intact. Enforced at the resource-stamping write seams (apps + serverscreateand…/workspacereassign): a workspace viewer gets a 403 creating/moving resources into that workspace — checked only when a workspace context is active, so no-context behavior is unchanged. The reconciled role is surfaced onGET /workspaces(my_role/my_effective_role) so the frontend can show read-only state. No schema/migration (pure query + capability logic on existing columns) and no frontend change (the WP hub/apps/domains/databases already fetch through theX-Workspace-Id-injecting client; the 403s surface via the existing toast). Verified: backendpy_compile, 6 new pytest tests (effective-role reconciliation incl. the never-elevate + admin-never-capped cases, viewer-blocked create + reassign, domains scoping incl. grant-visibility + admin-sees-all, docker-databases scoping, list-workspaces surfacing), full suite 193 passed / 12 skipped. (Still remaining for the dedicated milestone: server-side workspace-scoped logo swap, and the broad per-route write-enforcement of the viewer-cap beyond the create/reassign seams — an enforcement sweep across the mutating routes, the same XL surface as the rest of #33.) Not runtime-exercised against the live frontend (backend + API-level tests only).
#34: Horizontal scaling [XL] 🟡 | ⏸️ WordPress HA deferred; edge and generic scaling shipped (docs/HORIZONTAL_SCALING_SPEC.md)
- Today: local managed WordPress sites have a base-domain, DNS/TLS, primary
Domain, and host-nginx reverse-proxy path. Generic local Compose apps also have manual and CPU-driven replica controls in Container Ops. WordPress still runs one published container; uploads remain local, and the scaler does not update the site's nginx upstream. - Do: Make WP stateless — shared media (uploads on a shared volume or S3 offload), shared Redis object cache (#23), then nginx
upstreamload balancing across replicas with sticky handling where needed. - Reuse:
nginx_advanced_service, #23,storage_provider_service. - Done when: A site can run N replicas behind a balancer with consistent media + cache.
- Assessment / why deferred: the old reverse-proxy blocker is gone. The remaining work is WordPress-specific: shared uploads, an internal replica topology, scaler-to-upstream registration, health-based draining, and live failover verification. The site's Redis service can serve all replicas in one Compose project, but it does not solve uploads or disk caches. Generic
docker compose --scalesupport is a control-plane building block, not proof of HA. - Verification: frontend tests pin policy normalization and API calls. Backend tests drive the policy-to-evaluation-to-manual-scale workflow through the API and database. A real Linux/Docker host must still prove request distribution, replica-loss continuity, and cross-replica media consistency before this item can move to done.
#35 — WordPress workflow nodes, events & CLI [L] 🟡 — ✅ Done (WP lifecycle events emitted + subscribable; workflow action-nodes + CLI deferred)
- Today: The event/webhook + workflow engine can't see a
WordPressSite—EVENT_CATALOGhas no WP lifecycle events, WP operations emit nothing, the workflow builder has no WP node types, and there's noserverkit wpCLI. - Do: Emit WP lifecycle events (
backup.completed/deploy.failed/ssl.renewed/update.available); add WordPress workflow nodes that call existing WP services; add CLI verbs (serverkit wp backup:create/env:clone/deploy) against the now-API-key-reachable routes (#14). - Reuse:
workflow_engine,event_service,webhook_service, the API-key surface. - Done when: "Every Sunday: snapshot all sites, update staging, notify" can be built as a workflow, and the CLI drives it.
- Landed: WP lifecycle events added to
EVENT_CATALOG—wordpress.created/deleted/backup_completed/updated/update_rolled_back/deployed/deploy_failed(joining #27'ssite_down/site_up) — plus a reusableEventService.emit_wp(event_type, site, **extra)helper (best-effort; never breaks the WP op). Emitted at concrete service points:create_site(wordpress.created), the #29 safe-update (wordpress.updated/update_rolled_back), and git deploy (wordpress.deployed/deploy_failed+ the pre-deploybackup_completed). Because the catalog drives the subscription UI (GET …/events) andEventSubscription.matches_eventsupports awordpress.*wildcard, these are immediately subscribable as outbound webhooks (HMAC + retry) and — via the existingWorkflowEventBusevent-trigger path — can fire workflows (e.g. "onwordpress.update_rolled_back→ notify Slack"). This delivers the trigger half of the Done-when. (Deferred: WordPress workflow action nodes — needs the workflow node-type registry + builder UI — and theserverkit wpCLI binary; both are substantial standalone builds on top of #14's API-key-reachable routes.) Verified: backendpy_compile.
The "almost there" cluster (knock these out first)
These are 🟡 tasks where the backend/model already exists — mostly wiring. Highest payoff, lowest risk, very parallelizable:
#3, #5, #6, #7, #9, #10, #11, #12, #13, #14, #19, #24, #26, #27, #31
Plus the two safety bugs: #1, #4.
A focused session could realistically land the Phase 0 bugs (#1–#4) and a chunk of the glue cluster (#5, #9, #10, #12, #14) in a single sitting, since each is an isolated wire-up against a service that already works.
Reference — key WordPress files
| Area | File |
|---|---|
| Site object model | backend/app/models/wordpress_site.py (WordPressSite, DatabaseSnapshot, SyncJob) |
| WP-CLI + lifecycle | backend/app/services/wordpress_service.py |
| Environment pipeline | backend/app/services/environment_pipeline_service.py, wordpress_env_service.py |
| DB clone / search-replace / sanitize | backend/app/services/db_sync_service.py |
| Env Docker / domains / health | environment_docker_service.py, environment_domain_service.py, environment_health_service.py |
| WP API routes | backend/app/api/wordpress.py, wordpress_sites.py, environment_pipeline.py |
| Blueprint registration | backend/app/__init__.py:118-124 |
| Frontend hub | frontend/src/pages/WordPress.jsx, WordPressDetail.jsx, WordPressProject.jsx, WordPressProjects.jsx |
| Frontend components | frontend/src/components/wordpress/ |
| Frontend API client | frontend/src/services/wordpress.js, services/api/wordpress.js |
| Reusable primitives | ssl_service.py, advanced_ssl_service.py, storage_provider_service.py, backup_service.py, nginx_advanced_service.py, notification_service.py, workflow_engine.py, security_service.py |
Scope: 35 tasks across 5 phases. Phase 0 is a blocker; Phase A is mostly wiring of existing services; Phases B–D are the net-new managed-platform layer. Numbers are stable references — cite them directly (e.g. "do #16").