# 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_bp` and `wordpress_sites_bp` register at `/api/v1/wordpress` (`backend/app/__init__.py:122-123`), and **both** define `GET /sites` and `POST /sites` (`api/wordpress.py:20,28` vs `api/wordpress_sites.py:31,53`) — plus collisions on `GET/DELETE /sites/`, environments, plugins, themes, and `update`. 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 `/sites` GET+POST, and register a single create path. - **Reuse:** `WordPressService.create_site`, `wordpress_sites.py` create 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 from `wordpress_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 since `wordpress_bp` already 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_wordpress` regardless 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`) sets `site.url` to `http://localhost:`, so "Open Site"/"Open WP Admin" are wrong for any real domain. - **Do:** Derive `url` from the site's primary domain (HTTPS when SSL present), falling back to the port only when no domain is attached. - **Reuse:** `application.domains` (already in `to_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`) does `compose_down(remove_volumes=True)` + `rmtree` with **no snapshot**. `delete_environment` is 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 an `archived` state that stops the stack but retains a restorable backup. - **Reuse:** `db_sync_service` snapshot creation; the snapshot-first pattern from `environment_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-aware `backup_wordpress()` (full files + DB to `BACKUP_DIR`, outside the site root so it survives `rmtree`) before teardown, and returns the backup info. New reversible `archive_site` / `unarchive_site` (`compose_down(volumes=False)` keeps data → `status='archived'`; `unarchive` restarts → `running`). New routes `POST /sites//archive`, `/unarchive`, and `DELETE /sites/?create_backup=false` opt-out; frontend `archiveSite`/`unarchiveSite`/`deleteSite({createBackup})` added. **Also fixed a latent bug:** `_teardown_wp_site` passed `compose_down(remove_volumes=…)` but the real param is `volumes=` — the old call would have raised `TypeError`, 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`) — but `services/wordpress.js` has **no** `harden`/`searchReplace`/`flushCache` methods, so there's no button. - **Do:** Add the three `ApiService` methods + controls on `WordPressDetail` (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_usage` compute container/MySQL/WP/disk status; frontend stubs `getProjectHealth`/`getEnvironmentHealth`/`getEnvironmentDiskUsage` exist; `components/wordpress/DiskUsageBar.jsx` is **imported nowhere**. `WordPressDetail` OverviewTab 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`) computes `update_available`/`latest_version`; the plugin/theme list JSON already carries per-item update flags. The OverviewTab/PluginsTab/ThemesTab parse none of it. `update_themes` service method is missing (only `update_plugins`/`update_wordpress` exist). - **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_renewal` take arbitrary domains and are **never** called by `create_site`; the per-env nginx template listens on `:80` only. `WordPressSite` has no `ssl_status`. The SSL-health methods in `services/security.js` (`getSSLHealth`, `issueWildcardCert`, expiry alerts) have **zero** `.jsx` consumers. - **Do:** Add a per-site "Enable SSL" action + an `ssl_status` field; call `SSLService` on create/domain-attach; use a wildcard cert for `staging.*`/`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`/`SyncModal` send `sanitization_profile_id`; `db_sync_service.apply_sanitization_profile` exists — but `api/environment_pipeline.py` / `EnvironmentPipelineService` never resolve the id; only the boolean `options['sanitize']` is honored. The profile (incl. WooCommerce payment-table stripping) is silently ignored. - **Do:** Resolve `SanitizationProfile` by id in `promote_database`/`sync_from_production` and pass it to `_transform_dump`. - **Reuse:** `apply_sanitization_profile`, `SanitizationProfile` model. - **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_id` and status `rolled_back` exist; `db_sync_service.restore_snapshot` exists; pre-promotion snapshots are captured on **every** promote — but **nothing ever calls restore** and `rolled_back` is never set. The safety net is captured and never used. - **Do:** Add `POST /promotions//rollback` that 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:** `DatabaseSnapshot`s land only in the local `SNAPSHOT_DIR`. `storage_provider_service.upload_file/upload_directory` (S3/B2/MinIO/Wasabi, SSRF-validated, verify-by-MD5) and `backup_service` retention/scheduling/notifications are fully built but bound to the generic `Application` model. `db_sync_service.cleanup_old_snapshots` exists but **no scheduler calls it** and `expires_at` is never set. - **Do:** Hand each completed snapshot to `StorageProviderService`; populate `expires_at`; schedule `cleanup_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.py` and the File Manager + host log viewer exist but are reached only by raw DB-name / manual path; none deep-link to a site's `db_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_commit` are persisted (`connect_repo`) and returned in status, but `auto_deploy` is **never consumed** — inbound git webhooks only deploy generic `Application`s. `git_wordpress_service.rollback_to_commit` and `deploy_from_commit` exist with no route/UI; WP keeps only a single `last_deploy_commit`. - **Do:** Route inbound git webhooks to `GitWordPressService.deploy_from_commit` when `auto_deploy` is set; expose a WP deploy history list + rollback button. - **Reuse:** `git_wordpress_service`, the existing webhook receiver, the `GitDeployment` history 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-Key` middleware authenticates only the RBAC decorators, but **all** of `api/wordpress_sites.py` is 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.create` that 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 the `WordPressSite`. Expose domain + PHP version + admin + "enable SSL/cache" in `CreateSiteModal`. - **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_site` is now the single orchestrator and gained `php_version` / `enable_page_cache` / `enable_object_cache`. The chosen **PHP version is baked into the initial image tag** (concrete `6.4-php-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) *after* `wp core install` (redis already ships in the stack, so no recreate). Cache enablement never fails the create — failures degrade to a non-fatal `warning`; the one-time admin password is still surfaced once. The live create modal (inline in `WordPress.jsx`, not the orphaned `CreateSiteModal.jsx`) gained a **PHP version selector** (Default / 8.1 / 8.2 / 8.3) and **page-cache / object-cache** toggles, threaded through `createForm` → `POST /sites` as `phpVersion` / `enablePageCache` / `enableObjectCache` (PHP version validated server-side against `get_available_php_versions()`). **Mechanism:** declared a hidden `VERSION` template var and switched the image line to bare `${VERSION}` in both `wordpress.yaml` and `wordpress-external-db.yaml` so a concrete tag is written at install — this *also fixes a latent #24 bug* where the unresolved `${VERSION:-…}` literal made `set_php_version` drop the WP-core pin (`set_php_version` now 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 existing `sync_config` JSON column (mirroring the per-site page-cache route). Verified: backend `py_compile`, frontend lint-clean + production build, a 3-lens adversarial review (no blockers), and Docker Hub confirms all `6.4-php8.{1,2,3}-apache` tags exist. *(Deferred: SSL/TLS + custom-domain at create. Managed sites are `http://localhost:PORT` containers 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 via `SiteSSLPanel` (#8) once a domain is routed; the `domains.py` → `NginxService.create_site` primitive 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.open` to `{site.url}/wp-admin`. Grep confirms **zero** login/magic/token/SSO code in the WP API. `create_user` (`:646`) and `reset_password` (`:666`) exist, so managed-admin provisioning is half-done. - **Do (no heavy plugin — WP-CLI bridge):** 1. Mint a one-time URL via `wp package install wp-cli/login-command` then `wp login create --url-only` (or a ~30-line mu-plugin validating an HMAC nonce). 2. Backend route resolves the managed admin tied to the operator's JWT email (create one via `create_user` if absent). 3. Write a `wordpress.admin_login` `AuditLog` entry (the generic `AuditLog` already exists). 4. Resolve a relocated login slug via `wp eval 'echo wp_login_url();'` instead of hardcoding `/wp-admin`. 5. Add `auto_login_enabled` + a per-site permitted-users check. - **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 + `.sql` dump (or an SFTP pull), provision DB + files, run the serialized-safe `search-replace` for the new URL, and register a `WordPressSite`. - **Reuse:** `db_sync_service` search-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 `.sql` MVP. The `import_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 `.zip`** is **safely extracted** and copied into the container. **Security-first extraction** — `_safe_extract_zip` rejects 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_dir` handles the three real-world layouts — a zip **of** wp-content (root has `plugins`/`themes`/`uploads`), a zip **with** `wp-content/` at the root, and a **full-site** zip nested one wrapper folder deep — then `_import_wp_content_zip` `docker cp`s the resolved tree into `/var/www/html/wp-content` and **`chown`s it to `www-data`** (imported files land root-owned). Failures are non-fatal: the DB import still succeeds and the response carries a `warning` + `wp_content_imported: false`. Route `POST /sites/import` gained an optional `wp_content` multipart file (validated `.zip`, saved to its own temp, cleaned up in `finally` alongside the SQL temp); client `importSite({wpContentFile})` + an optional **wp-content archive** file input in the import modal (with a success toast when it lands). Verified: backend `py_compile`, **11 new pytest tests** (zip-slip `../`/deep/absolute rejection, bad-zip rejection, safe extraction, all 3 wp-content layouts + the unrecognizable case, and the `docker cp`+`chown` flow / 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/`.sql` upload path covers the common export case; an SFTP-pull importer is a separate connector.)* The `docker cp`/`chown` step 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 via `production_site_id` and **inherits** the parent's `admin_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_environment` internals, `db_sync_service`. - **Done when:** "Clone" yields an independent site with its own credentials. ### #19 — Multisite detection `[S]` 🟡 — ✅ Done - **Today:** `WordPressSite.multisite` is a decorative boolean (`models/wordpress_site.py:25`), never populated from reality. - **Do:** Populate it via `wp core is-multisite` during 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 optional `client`/`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 `.` with a valid `*.` 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.` scheme is unnecessary given the wildcard base domain.) - **Today:** `EnvironmentDomainService.generate_domain` only emits real subdomains or a `.localhost` fallback; the env nginx template listens on `:80` only. No preview hostname scheme. - **Do:** Add a wildcard/preview hostname (e.g. `.preview.` 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_service` can emit `proxy_cache_path`/`proxy_cache`/`proxy_cache_bypass` zones 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_cache` to 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 existing `flush-cache` (#5). - **Reuse:** `nginx_advanced_service`, `nginx_service` WP 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 flush` with 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`; write `WP_REDIS_HOST`/`WP_REDIS_DATABASE` into `wp-config`; surface enable/status/flush on the site. - **Reuse:** `wp_cli`, `wordpress.yaml` compose 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`/`restartPHPFPM` in `services/system.js`) has **zero** frontend consumers; `php_version` on `Application` is 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. New `WordPressService.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-side `php-conf/zz-serverkit.ini` and **bind-mounts that single file into `/usr/local/etc/php/conf.d/`** so it survives container recreate (a plain `docker exec` write would be lost on the next `compose 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 via `compose up -d` (recreate), a content-only change via `compose_restart('wordpress')` so mod_php re-reads `php.ini`. Partial updates **merge** with the existing drop-in (no directive is dropped), and the mount is injected once (idempotent). Route `POST /sites//php/limits` (`@admin_required`), client `setPhpLimits`, 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:** the `PhpTab` component (the read panel + version switch) was built but **never wired into `WordPressDetail`** — no tab button, no render branch — so the entire PHP panel was invisible; added `'php'` to `VALID_TABS` + a PHP tab + the render branch, so the read panel *and* the new limits editor actually render. Verified: backend `py_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 (the `PhpTab`-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.log` ingest 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_service` aggregation patterns. - **Done when:** The site dashboard shows per-site traffic and error analytics. - **Landed:** **Architectural pivot** — managed WP sites are `wordpress:*-apache` containers on `localhost:PORT` with **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**, making `docker logs` the Docker-correct source. New `WpAnalyticsService.get_traffic(container_name, hours)` pulls `docker logs --tail 20000 --since ` (**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. Route `GET /sites//analytics?hours=` (owner/admin-guarded), client `getSiteAnalytics`, 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** need `wp-content/debug.log` via a WP_DEBUG toggle (#30); **response time / slow pages** need a `%D` LogFormat 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 the `metrics_history` pattern is a clean follow-up.)* Verified: backend `py_compile`, frontend lint-clean + production build, a 3-lens adversarial review (major fixed: docker-logs timeout + memory cap; minor: accurate unavailable note via `returncode`; 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. New `WpAnalyticsService.get_php_errors(container)` does one bounded `docker exec` (a marker-guarded `tail -c 200000` with the same 15s single-worker timeout as `get_traffic`) to distinguish "WP_DEBUG logging off / no log" from "on but clean", then parses WP debug lines (`[time] PHP : `) into severity counts (**fatal**/warning/notice/deprecated — `Parse`/`Recoverable fatal` fold into fatal) + the 25 most-recent entries (messages capped at 300 chars; stack-trace continuation lines skipped). Merged into `GET /sites//analytics` as `php_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: backend `py_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 `%D` LogFormat the official image doesn't emit — and cache hit-ratio, a #22/#23 plugin concern.)* The `docker exec` read 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`; `EnvironmentHealthService` outages don't open incidents. - **Do:** Background-schedule `EnvironmentHealthService` checks to accrue per-site uptime %; auto-create a status-page component from a managed site; bridge `health_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) runs `check_health` for every **running production** site — skipping archived/stopped stacks so an intentional stop is never an outage — which keeps `health_status` fresh **autonomously** (so #27's alerts fire without the browser health card open) and drives bound status-page components. **Schema** (Alembic `010` + the boot-time `_fix_missing_columns` auto-add): `status_components.wordpress_site_id` (→ `wordpress_sites`) and `status_incidents.component_id` (→ `status_components`); the existing `HealthCheck` table is reused as the uptime sample store (no new table). New `StatusPageService.sync_component_from_health` maps the health verdict (healthy→operational, degraded→degraded, unhealthy→major_outage; `unknown` skipped so it never pollutes the %), records a `HealthCheck`, 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 routes `GET/POST/DELETE /sites//status-page` read 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 internal `localhost:port` target. Verified: backend `py_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 public `check_target` leak). 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; the `WorkflowEventBus` `health_check_failed` hook exists; neither knows about WordPress sites. - **Do:** Add WP alert rules (site down / health failed / error spike) routed through `notification_service` via the event bus. - **Reuse:** `notification_service`, `WorkflowEventBus`. - **Done when:** A site outage or error spike pings the configured channel. - **Landed:** `EnvironmentHealthService.check_health` now captures the prior `health_status` and, **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 existing `WorkflowEventBus.emit('health_check_failed')` hook is **preserved** (still fires every failing poll for back-compat with event-trigger workflows); (2) **NEW** — `NotificationService.send_all` dispatches 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 into `info`); fired **off-thread** because `send_all` does blocking HTTP/SMTP and must not stall the health check; (3) **NEW** — `wordpress.site_down` / `wordpress.site_up` added to `EVENT_CATALOG` and emitted via `EventService`, so user webhook subscriptions (incl. a `wordpress.*` wildcard) deliver with HMAC + retry, and the events auto-appear in the event-subscription UI (`GET …/events` returns 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 runs `check_health` autonomously; 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_themes` return 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 `WpVulnerabilityService` reads installed plugin/theme/core slug+version via the **Docker-aware WP-CLI** bridge (`wp core version` + `get_plugins`/`get_themes` directly — *not* the host-filesystem-gated `get_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's `operator` bounds (plugins/themes; core advisories all apply since the feed is version-scoped), extracts CVE id / severity (`cvss3` → `cvss` short-code → score fallback) / fixed-in / reference, and persists results as a new **`WordPressVulnerability`** child model (FK → `wordpress_sites.id`) + a `last_vuln_scan_at` column (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_url` is restricted to http(s) (blocks `javascript:` 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: backend `py_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_service` snapshots, promote pipeline, `restore_snapshot`. - **Done when:** Updates run on a schedule, staging-first, with backup + auto-rollback and a report. - **Landed:** New `WpUpdateService.start_update` runs a **background safe update** (Docker-correct, all via `wp_cli`): record pre-update versions → `wp db export` a DB snapshot (a generous **600s** timeout — added a `timeout` param to `wp_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 call `check_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 update --version= --force --skip-plugins --skip-themes`, so it runs even on a fatally-broken site) + re-import the snapshot → re-check → persist a `WordPressUpdateRun` **report**. 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 model `WordPressUpdateRun` + 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_health` returned `unknown` for *every* production site (its `_get_compose_path` needs a `container_prefix` production sites lack), making uptime/incidents/alerts inert for the primary site type — now falls back to the Application `root_path` compose 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: backend `py_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 calls `wp ... verify-checksums`; no `wp-login.php`/`xmlrpc` rate-limit jail. - **Do:** Per-site scan-target resolution; file integrity via `wp core/plugin/theme verify-checksums`; a Fail2ban jail/filter (or nginx `limit_req` zone) targeting each site's access log for `wp-login.php`/`xmlrpc.php`; per-site `WP_DEBUG`/`SCRIPT_DEBUG` toggle and WP-Cron list/run/disable. - **Reuse:** `security_service` (Fail2ban jails), `wp_cli`, `cron_service`, `nginx_service` WP 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 background `wp 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** — sets `WP_DEBUG`/`SCRIPT_DEBUG` with `WP_DEBUG_DISPLAY=false` and, crucially, **`WP_DEBUG_LOG=/tmp/wp-debug.log`** — *outside* the web root, so the log is never publicly fetchable (the default `wp-content/debug.log` is 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_CRON` status + 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_required` mutations). Verified: backend `py_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`) records `wp-login.php`/`xmlrpc.php` hits with the real client IP. `Fail2banJailService` generates a ServerKit-owned filter and per-site jail (`jail.d/serverkit-.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.php` allows 10 requests/minute with a burst of 10, while `xmlrpc.php` allows 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; `PromoteModal` exposes only coarse code/db/full. - **Do:** Expose files-only / db-only / specific-folders / specific-tables selectors in `PromoteModal`. - **Reuse:** `db_sync_service` `_transform_dump` filters, `environment_pipeline` promote. - **Done when:** A promote can move just selected files/tables. - **Landed:** `PromoteModal` already had **files-only** (`code` type), **db-only** (`database` type), and **specific-folders** (the `include_plugins`/`themes`/`mu_plugins`/`uploads` checkboxes); 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 …/promote` documents+forwards `config.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_service` patterns. - **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_html` **named 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`) mounting `wordpress_html`, injected into the compose stack exactly like #23's redis (`_ensure_*_in_stack` + additive `compose 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_id` FK on `Application`/`WordPressSite`/`Server`; permissions are global with no per-site ACL; white-label lives in `localStorage` and never reads `Workspace.logo_url`/`primary_color`. - **Do:** Add `workspace_id` FKs + 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 **`XL`** multi-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_id` FK migrations on `Application`/`WordPressSite`/`Server` + backfill + scoping *every* list/query; (2) a per-site **ACL** model + enforcement woven through promote/sync/bulk endpoints; (3) moving white-label from `localStorage` to 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_id` rework. New `WpReportsService` aggregates a calendar month's signals into a single persisted, printable report: **uptime % + a daily series + incidents** re-aggregated from the bound status component's `HealthCheck` samples 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 the **`client-*` / `client:*` tag** (#20) — *no* `workspace_id` FK needed. New `WordPressReport` model (Alembic **014**, chains off `013_ai_assistant`; defined in `wordpress_site.py` + imported lazily in the service so the concurrent AI agent's `models/__init__.py` is never touched), routes `GET /sites//reports`, `POST …/reports/generate` (`@admin_required`), `DELETE …/reports/`, client `getReports`/`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 print` isolation, no PDF dependency) and a **Download JSON** export. Verified: backend `py_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-cutting `workspace_id` FKs + 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_id` FK** on `applications` + `servers` (Alembic **015**, chains off `014`, 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** in `WorkspaceService` (`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 an `X-Workspace-Id` header / `?workspace_id=`, and `scope_query` *intersects* 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 into `apps.py` (list + create) and `servers.py` (list + create), replacing the duplicated `if 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: backend `py_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-end `alembic upgrade` to `015` on a fresh DB (columns + both indexes created), and a 2-lens adversarial review (3 findings, all fixed — `is_active` gate on the workspace path, FK-add guarded on the `workspaces` table existing, and the missing `workspace_id` indexes 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 making `scope_query` intersect 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 `WorkspaceSwitcher` in the sidebar reads/writes an `active_workspace_id` in `localStorage`, which the central API client (`services/api/client.js`) now sends ambiently as the **`X-Workspace-Id` header** 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, backend `resolve_workspace_id` was 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: surfacing `workspace_id` in 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 the `WorkspaceSwitcher` into a `workspace_accent` localStorage key, and `ThemeContext` applies it via the existing `applyAccentToDOM` with **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//workspace` (owner-or-admin on the app) and `PUT /servers//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 with `allWorkspaces` (an empty `X-Workspace-Id` header) 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 compared `app.user_id` to the *stringified* `get_jwt_identity()` (int≠str → spurious 403); now uses `user.id`. Verified: backend `py_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 same `allWorkspaces` header 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_id` of their own, so `WordPressService.get_sites(workspace_id=…)` scopes **join-based** through the site's parent `Application.workspace_id`; `list_sites` resolves 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-injecting `api.request`, so it auto-scopes on the ambient `X-Workspace-Id`. Verified: backend `py_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 `role` column). New `ResourceGrant` model (Alembic **016**, unique per user×resource) + `ResourceGrantService` (grant/revoke/list/`user_has_grant`/`granted_ids`). **Enforcement at the cleanest seams:** `WorkspaceService.scope_query` gained `grant_resource_type` so granted apps appear in the apps list (own **OR** granted, then workspace-narrowed); `get_app` and — crucially — the WordPress `_owner_or_admin_app` helper became grant-aware, so **one change opens every WP per-site route** to a grantee. Grant-management API on `apps.py` (`GET/POST/DELETE /apps//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 uses `user.id` (int) not the stringified `get_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-end `alembic upgrade` to `016`. **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 methods `getAppGrants`/`grantAppAccess`/`revokeAppAccess`. Verified: frontend lint-clean + build. **Viewer/editor roles + operate access (shipped):** grants now carry a real **role** — `editor` (view + operate) or `viewer` (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_role` backs the split. **Delete stays owner-or-admin** (an editor can't delete — tested). The grant POST validates `role ∈ {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 compared `app.user_id` to the stringified `get_jwt_identity()` — now `user.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.py` delegates), 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 (now `user.id`). Verified: a cross-blueprint integration test (grantee reaches `builds` deployments; stranger 403), full suite **131 passed**. **`python.py` (shipped):** re-reading showed every mutating endpoint there is independently `@admin_required` (runs before the shared `get_app_or_404` gate), so the gate only governs the 4 read GETs — pointing it at `can_access_app` safely 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_domains` now scopes through the parent `Application` (domains carry no `workspace_id` of their own): it resolves the active workspace and lists the domains of the apps `scope_query` returns (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 via `can_access_app` but the list ignored grants. Since `Domain.application_id` is non-nullable, the admin-no-context path is byte-identical to the old `Domain.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 same `scope_query` (replacing a manual `can_access_app` loop 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** — new `WorkspaceService.effective_role(user, workspace_id)` + `can_write_in_workspace`: reconcile the user's **global** `User.role` with their **per-workspace** `WorkspaceMember.role`, **narrow-only** (mirrors `scope_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 + servers `create` and `…/workspace` reassign): 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 on `GET /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 the `X-Workspace-Id`-injecting client; the 403s surface via the existing toast). Verified: backend `py_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](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 `upstream` load 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 --scale` support 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_CATALOG` has no WP lifecycle events, WP operations emit nothing, the workflow builder has no WP node types, and there's no `serverkit wp` CLI. - **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's `site_down`/`site_up`) — plus a reusable `EventService.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-deploy `backup_completed`). Because the catalog drives the subscription UI (`GET …/events`) and `EventSubscription.matches_event` supports a `wordpress.*` wildcard, these are **immediately subscribable** as outbound webhooks (HMAC + retry) and — via the existing `WorkflowEventBus` event-trigger path — can **fire workflows** (e.g. "on `wordpress.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 the **`serverkit wp` CLI** binary; both are substantial standalone builds on top of #14's API-key-reachable routes.)* Verified: backend `py_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").*