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.
After creating a WordPress site the post-create banner showed only the admin
credentials, so users had no signal that the site was already reachable at
<slug>.<base> — the exact "preview URL before DNS" gap roadmap #21 tracked,
now unblocked by the base-domain + Phase-5 wildcard-cert stack.
The banner now surfaces the live link on every successful create: a clickable
<slug>.<base> (noting HTTPS when the wildcard cert is set up), or, when the
site only came up on localhost, a nudge to set a managed-sites base domain in
Settings to publish it publicly. Shown even when the automated install did not
finalize (no captured credentials).
Mark #21 done in the roadmap: the wildcard base subdomain is the working,
TLS-valid preview a user relies on while pointing a custom domain, so the
separate *.preview.<panel-domain> scheme is unnecessary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a ServerKit-managed per-site Fail2ban layer to protect WordPress login endpoints. Adds Fail2banJailService (write-side) to generate filter and per-site jail configs, enable/disable jails, reload fail2ban, and unban IPs (Linux-only, best-effort, ownership-guarded). Wire it into WordPress service lifecycle (enable on site create, remove on delete) and WpSecurityService wrapper methods. Expose API endpoints for status, toggle and unban in backend API. Add unit tests for Fail2banJailService (config rendering, naming/safety, lifecycle, graceful degradation). Frontend: Security tab loads brute-force status, can toggle protection, shows ban counts and unban action; API client and styles updated. NginxService gains canonical per-site log/error path helpers. Docs updated to reflect shipped brute-force jail feature.
Finish #25's deferred PHP-fatals signal, sourced from the WP_DEBUG log
(/tmp/wp-debug.log) that #30's Security toggle writes — not the access log
(which can't carry it):
- get_php_errors: one bounded docker exec (marker-guarded tail -c, 15s single-
worker timeout) distinguishes logging-off / no-log from on-but-clean, then
parses [time] PHP <Level>: <msg> into severity counts (fatal/warning/notice/
deprecated; Parse + Recoverable-fatal fold into fatal) + the 25 most-recent
entries (messages capped, stack-trace lines skipped).
- Merged into GET /sites/<id>/analytics as php_errors; a PHP errors panel added
to the Analytics tab with an 'enable WP_DEBUG on the Security tab' empty state.
- Supplies the per-site error signal #27's error-spike rule wanted.
7 new tests (classification, parse/skip-traces, off/empty/unavailable, capping);
full backend suite 218 passed / 12 skipped; frontend lint-clean + production build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish #17's deferred files half on top of the .sql MVP. After the DB import +
search-replace, an optional wp-content/full-site .zip is extracted and copied
into the container:
- _safe_extract_zip rejects any member escaping the temp dir BEFORE writing
(zip-slip: ../ traversal, deep traversal, absolute paths) + bad-zip guard.
- _resolve_wp_content_dir handles the 3 layouts: zip OF wp-content, zip WITH
wp-content/ at root, and full-site zip one wrapper folder deep.
- _import_wp_content_zip docker cp's the resolved tree into
/var/www/html/wp-content and chowns it to www-data. Non-fatal: DB import still
succeeds; response carries a warning + wp_content_imported flag.
- Route accepts an optional wp_content multipart file (.zip, own temp, cleaned
up); client importSite({wpContentFile}) + a file input in the import modal.
11 new tests (zip-slip rejection, layouts, docker cp/chown flow mocked); full
backend suite 211 passed / 12 skipped; frontend lint-clean + production build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish #24's deferred limit-WRITE side, Docker-correct for wordpress:*-apache
(Apache + mod_php):
- set_php_limits: whitelist + re.fullmatch-validate each directive (fullmatch so
a newline-smuggled extra ini line can't pass the $ anchor), write a host-side
php-conf/zz-serverkit.ini and bind-mount that single file into conf.d so it
survives container recreate. File written before the mount is added; new mount
applies via compose up, content-only change via restart so php.ini reloads.
Partial updates merge; mount injected once.
- Route POST /sites/<id>/php/limits (admin), client setPhpLimits, and an Edit
PHP Limits panel in the PHP tab (inputs seeded from live values, partial save).
- Fix latent bug: PhpTab (the #24 read panel) was built but never wired into
WordPressDetail (no tab button / render branch) — the whole PHP panel was
invisible. Added 'php' to VALID_TABS + a tab + render branch.
7 new tests; full backend suite 200 passed / 12 skipped; frontend lint-clean +
production build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#34 is infra-blocked and unverifiable on the Windows dev host, so spec it
rather than build it blind. A capability sweep found the load-balancing,
rate-limit, proxy-cache, per-vhost-log, wildcard-TLS, and S3-upload primitives
already exist (nginx_advanced_service / nginx_service / advanced_ssl_service /
storage_provider_service) — so #34 is wiring + a public base-domain + stateless
WP, not greenfield nginx.
Adds docs/HORIZONTAL_SCALING_SPEC.md: a shared keystone (K0-K4) that also
unblocks #21/#15-TLS/#8/#30-jail/#25, then #34 proper (H0-H4), each phase a
separately verifiable PR with a Windows-vs-real-host split. Links it from the
roadmap #34 entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish #33's two named workspace-scoping slices, backend-only (no schema
migration, no frontend changes):
- Domains: get_domains scopes through the parent Application via scope_query
(own + granted, workspace-narrowed); also surfaces granted apps' domains,
closing a gap vs the per-domain routes. Admin-no-context path unchanged.
- Databases: the app-linked /docker/databases enumeration scopes via
scope_query; host-level MySQL/PG/SQLite stay global system resources
(documented inline).
- Role reconciliation: WorkspaceService.effective_role + can_write_in_workspace
intersect global User.role with per-workspace WorkspaceMember.role,
narrow-only (a workspace role can only reduce capability; system admin never
capped). Enforced at apps/servers create + reassign seams (viewer -> 403,
only when a workspace context is active). Reconciled role surfaced on
GET /workspaces (my_role/my_effective_role).
6 new tests; full backend suite 193 passed / 12 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-reading python.py showed every mutating endpoint is independently
@admin_required (runs before the shared get_app_or_404 gate), so that gate only
governs the 4 read GETs (packages/env/status/gunicorn-config). Pointing it at
can_access_app safely opens those to grantees while the admin-only mutations stay
untouched — no per-method split needed.
With this, every app-related blueprint is grant-aware. Also fixes the int-vs-str
identity comparison in the helper.
Verified: a python read-gate test (stranger 403, grantee passes), full suite
132 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A grant now reaches every app-related blueprint, not just apps.py + WordPress, so
a shared application is fully manageable by its grantees.
- Promoted the access helpers to a shared seam: ResourceGrantService.can_access_app
(owner/admin/any grant -> reads) and can_edit_app (owner/admin/editor grant ->
mutations); apps.py now delegates to them.
- Swept 33 ownership checks across 6 blueprints (categorized read vs mutate via a
parallel mapping workflow): builds.py (12), domains.py (7, incl. SSL + domain
CRUD), deploy.py (6), private_urls.py (5), templates.py (2), databases.py (1).
Reads -> any grant; mutates -> editor grant.
- Fixed the same latent int-vs-str ownership bug in all 33 (now user.id).
Deferred: python.py's single get_app_or_404 helper gates read+mutate+delete
together and needs per-method splitting before it's safe to make grant-aware.
Verified: cross-blueprint integration test (grantee reaches builds deployments;
stranger 403), full suite 131 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the chosen access-level ACL design: a grant now lets the user actually
operate the resource, gated by a real role.
- Grants carry a role: editor (view + operate) or viewer (read-only). grant POST
validates role in {viewer, editor} (default editor); ResourceGrantService gains
grant_role(); the Sharing UI gains a role selector.
- Two access helpers split the app endpoints: _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).
- DELETE stays owner-or-admin (an editor can't delete — tested).
- The same sweep fixed a latent int-vs-str ownership bug across ~14 app endpoints
(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, lint + build.
Still owner-only: env-vars/databases/domains blueprints (follow-up sweep).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the per-site ACL feature with UI on top of the backend (d9a5ec7).
- A per-app 'Share' action in the Workspaces Resources modal opens an inline
sub-view: current grantees with Revoke, plus a grant-access list of users
(the owner and existing grantees are filtered out).
- Client methods getAppGrants / grantAppAccess / revokeAppAccess.
Verified: frontend lint-clean (no new warnings) + production build. The grant
endpoints are covered by backend tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An owner/admin can share a specific application (transitively its WordPress
site / DB / domains) with a specific user who doesn't own it. Access-level for
now; per-grant view/edit roles are reserved via a role column.
- New ResourceGrant model (Alembic 016, unique per user x resource) +
ResourceGrantService (grant/revoke/list/user_has_grant/granted_ids).
- Enforcement at the cleanest seams: scope_query gained grant_resource_type so
granted apps appear in the apps list (own OR granted, then workspace-narrowed);
get_app and 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 (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). Uses user.id (int), not the stringified get_jwt_identity().
Verified: 4 new ACL tests (visibility, get_app, WP route access, management perms
+ idempotency + revoke), full suite 127 passed, end-to-end alembic upgrade to 016.
Deferred: a Sharing UI (next), and broadening grant-access to the generic app
operate endpoints (start/stop/update stay owner-or-admin; delete stays owner-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the reassignment feature symmetrically — the server reassign endpoint
shipped earlier (e89415f) was API-only; now it has UI.
- getServers gains an allWorkspaces bypass (empty X-Workspace-Id header) and a
setServerWorkspace client method (mirrors apps).
- The per-workspace modal is now a 'Resources' modal with both Applications and
Servers sections, each with move-in / move-out.
Verified: frontend lint-clean (no new warnings) + production build. The server
endpoint itself is already covered by backend tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the inconsistency where the workspace switcher filtered apps/servers but
not the WordPress hub.
- WP sites carry no workspace_id of their own, so 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: a new API test (global vs workspace-scoped), full suite 123 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the organizing loop — existing resources were stuck wherever the
backfill put them (Default); now they can be moved into client workspaces.
- PUT /apps/<id>/workspace (owner-or-admin on the app) and
PUT /servers/<id>/workspace (developer+), both requiring the TARGET be a
workspace the caller can access (member or admin). A null target moves the
resource back to Default.
- UI: an 'Apps' management modal on each Workspaces card (mirrors the Members
modal) 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 app ownership check compared app.user_id
to the stringified get_jwt_identity() (int != str -> spurious 403); now uses
user.id.
Verified: 2 new endpoint tests (ownership + target-membership + null->Default +
role gates), full suite 122 passed, frontend lint-clean + production build.
Servers reassignment is API-only for now; the modal is apps-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Selecting a client workspace now recolors the whole panel to that client's brand
— the visible payoff of the workspace effort.
- A workspace's primary_color (already on the model) is now editable via a color
picker added to the Workspaces create modal.
- WorkspaceSwitcher mirrors the active workspace's color into a workspace_accent
localStorage key; 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): 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.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Makes the workspace scoping foundation user-visible — the research flagged the missing 'current workspace' context as the core gap #33 had to close.
- WorkspaceSwitcher (sidebar): self-contained selector that reads/writes an
active_workspace_id in localStorage, fetched from the user's workspaces. Hides
for single-workspace installs and drops a stale selection on load.
- services/api/client.js: sends the active workspace ambiently as X-Workspace-Id
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.
Backend resolve_workspace_id is lenient (prior commit) so a stale header can
never break the apps/servers lists. Verified: frontend lint-clean + production
build; the header->scoping path is covered by the backend API tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cross-cutting backbone the rest of the #33 agency-scale milestone needs,
landed as a contained, narrow-only foundation rather than a 36-route rewrite.
- Nullable workspace_id FK on applications + servers (Alembic 015 off 014) with
an idempotent raw-SQL backfill: create a Default workspace, attach every
workspace-less row, and make every existing user a member so nobody loses
visibility once scoping is activated. Also creates the workspace_id indexes
that upgraded installs would otherwise lack.
- Centralized scope helper in WorkspaceService: ensure_default_workspace,
resolve_workspace_id (validates a requested workspace; membership-checked with
admin bypass; rejects deactivated accounts on the workspace path), and
scope_query.
- Opt-in + narrow-only: a request filters by workspace only when it carries an
X-Workspace-Id header / ?workspace_id=. scope_query INTERSECTS the workspace
filter with the existing per-user ownership filter, so it can only narrow
visibility, never broaden it — with no context the behavior is byte-identical
to before (admin->all, owner->own, servers global). This closes the footgun
where the backfill would otherwise let a non-admin enumerate all resources via
the Default workspace.
- Wired into apps.py + servers.py (list + create): replaces the duplicated
ownership branch and stamps new resources with the active-or-default workspace.
Deferred to follow-on slices: broad route adoption, per-site ACL, the
workspace<->User role reconciliation, server-side branding, and a frontend
workspace selector.
Verified: 10 new pytest tests (scope-narrowing, a can't-broaden-access
regression, deactivated-user gate, raw-SQL backfill against real SQLite +
idempotency), full suite 120 passed, a real end-to-end alembic upgrade to 015 on
a fresh DB (columns + indexes), and a 2-lens adversarial review (3 findings, all
fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The self-contained reports slice of the #33 agency-scale milestone, built
without the high-blast-radius workspace_id/ACL/branding rework.
- WpReportsService aggregates one calendar month per site: uptime % + a daily
series + incidents (re-aggregated from #26 HealthCheck samples for the exact
month), update runs (#29), DatabaseSnapshot backups, and current vulnerability
posture (#28) + health/disk — each labelled persisted-history vs point-in-time.
- Persisted (WordPressReport, Alembic 014 off 013_ai_assistant) + synchronous
upsert per (site, YYYY-MM), so point-in-time sources (vulns, health) are
captured as-of generation while true-historical sources are re-queried per
month. Client grouping derives from the client-* tag (#20) — no workspace_id
FK. Model lives in wordpress_site.py + lazy-imported in the service so the
concurrent AI agent's models/__init__.py is untouched.
- Routes GET /reports, POST /reports/generate (admin), DELETE /reports/<id>;
client getReports/generateReport/deleteReport; a Reports tab with a recharts
daily-uptime chart, summary cards, Print->Save-as-PDF (scoped @media print,
no PDF dep) and JSON export.
- 9 pytest tests; 3-lens adversarial review (4 of 5 findings fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document why the final three roadmap tasks are deferred rather than shipped
rushed/blind, each with the concrete Docker-correct path + the specific blocker:
- #32 per-site SFTP: Docker-correct design is a per-site SFTP sidecar (atmoz/sftp
mounting wordpress_html, injected like #23 redis), but it exposes a
security-sensitive, unverifiable-from-dev file-access surface + a port/container
per site. Deferred for a real-host pass.
- #33 agency scale: XL epic (workspace_id FKs + per-site ACL + RBAC rework +
workspace branding + monthly reports) — cross-cutting, high-blast-radius;
split into its own milestone. The report data sources (#26/#28/#29) now exist.
- #34 horizontal scaling: XL AND blocked on the same missing per-site
reverse-proxy/upstream layer as #21, plus a stateless-WP refactor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add WordPress lifecycle events to EVENT_CATALOG (wordpress.created / deleted /
backup_completed / updated / update_rolled_back / deployed / deploy_failed,
joining #27's site_down/up) + 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).
The catalog drives the subscription UI and EventSubscription.matches_event
supports a wordpress.* wildcard, so these are immediately subscribable as
outbound webhooks (HMAC + retry) and fire event-triggered workflows via the
existing WorkflowEventBus — 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. Verified: py_compile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Background safe update (Docker-correct, via wp_cli): record pre-update versions
-> wp db export a DB snapshot (600s timeout via a new wp_cli timeout param so a
large DB is never truncated; ABORTS if the snapshot fails) -> apply updates only
to components with an available update minus an exclusion list -> a quiet,
side-effect-free health probe (wp eval + HTTP probe; does NOT fire #27 alerts)
-> if the update regressed a previously-healthy site, AUTO-ROLLBACK: version-pin
each updated component back (--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 does not end verified-healthy.
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 + auto_update_schedule/auto_update_exclude
columns (Alembic 012). New Updates tab (run-now, schedule, exclusions, history).
Also fixes a latent #26/#27 bug: EnvironmentHealthService.check_health returned
'unknown' for every production site (_get_compose_path needs a container_prefix
they lack), making uptime/incidents/alerts inert for the primary site type — now
falls back to the Application root_path compose.
Deferred: staging-first promotion (needs a staging env + env-pipeline
orchestration). Verified: backend py_compile, frontend lint-clean (full build was
blocked only by a concurrent agent's in-progress AI import, not #29 code),
2-lens adversarial review (blockers fixed: production health gating, DB-import
timeout; majors fixed: scheduler stampede, snapshot-abort).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A per-site Security tab (Docker-correct, all via the WP-CLI bridge):
- File integrity: a background wp core verify-checksums + wp plugin
verify-checksums --all (in-container, reaches the volume; off the request
thread, UI polls) flagging tampered/unexpected files.
- Debug toggle: WP_DEBUG/SCRIPT_DEBUG with WP_DEBUG_DISPLAY=false and
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.
- WP-Cron: DISABLE_WP_CRON status + due-event list, run-due-now, enable/disable
(with a clear "needs a real system cron" warning).
Routes mirror the per-site pattern (owner-or-admin GET, @admin_required
mutations). Deferred: the brute-force wp-login/xmlrpc rate-limit jail —
Fail2ban/nginx limit_req both need a host-side per-site access log the
localhost:PORT apache-container model does not expose (same gap as #21/#25).
Verified: backend py_compile, frontend lint + build, focused security review
(debug-log web-exposure caught + fixed). Not runtime-exercised on a real host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-reference a site's installed plugin/theme/core slug+version against the
keyless community WPVulnerability API and surface findings per site.
WpVulnerabilityService reads installed versions 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 Docker
sites), queries https://www.wpvulnerability.net/{plugin,theme,core}/ (no key,
1h in-memory cache, descriptive UA, 8s timeout), matches the installed version
against each advisory operator bounds (core advisories all apply — the feed is
version-scoped), and extracts CVE id / severity (cvss3 -> cvss short-code ->
score) / fixed-in / reference.
Persisted 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; a new
Vulnerabilities tab polls for status and renders findings with severity badges,
CVE links, and a summary. On-demand only.
Security (3-lens review): reference_url restricted to http(s) (blocks
javascript: hrefs); feed slug percent-encoded; unrecognized version operator
skips rather than over-flags; the major Docker volume-backed core-read gating
was fixed so normal sites scan. Verified: backend py_compile, frontend lint +
build. Not runtime-exercised on a real host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PromoteModal already had files-only (code), db-only (database), and
specific-folders (include_plugins/themes/mu_plugins/uploads). The only missing
selective-push control was specific-tables, now added to the database section
as two comma-separated inputs:
- Exclude tables — omitted from the promotion entirely
- Truncate tables — structure promoted, rows dropped
Both split into arrays on submit. The backend already honored them end-to-end
(POST .../promote forwards config.exclude_tables/truncate_tables ->
promote_database/promote_full -> clone_options -> _transform_dump), so this is
pure UI wiring, no backend change. Verified: frontend lint + build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A per-site Analytics tab showing traffic and error analytics, computed
on-demand from the site's Apache access log — Docker-correct.
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 does not hold. The official image symlinks Apache's access log to the
container's stdout, so `docker logs` is the source.
New WpAnalyticsService.get_traffic(container_name, hours) pulls
`docker logs --tail 20000 --since <h>` (hard 15s timeout; reads only stdout to
separate the access log from the stderr error_log; returncode-checked so a
stopped/missing/remote-agent container degrades to an accurate note) and parses
the Apache combined log on-demand — no store, collector, or migration. 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 are never surfaced), and a continuous hourly requests/errors
series.
Route GET /sites/<id>/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. Also supplies the 5xx/error-rate signal
#27's "error spike" rule needs.
Deferred (not in 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),
cache hit ratio (#22/#23). History is point-in-time over the retained container
log; a persistent time-series collector is a clean follow-up.
Review fixes (3-lens): docker-logs timeout + memory cap (single-worker safety);
accurate unavailable note via returncode; query-string token stripping.
Verified: backend py_compile, frontend lint + build. Not runtime-exercised on a
real Docker host (Windows dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make a managed WordPress site appear on a status page with a real uptime %
and auto-incidents, driven by an autonomous server-side health poller.
Scheduler: a single-worker-guarded daemon thread (_start_health_check_
scheduler, 300s) runs EnvironmentHealthService.check_health for every RUNNING
production site (skips archived/stopped so an intentional stop is not an
outage). This keeps health_status fresh autonomously — so #27's transition
alerts fire without the browser health card open — and drives bound
status-page components. HealthCheck samples are pruned to 90 days (once/day)
to bound growth.
Schema (Alembic 010 + the boot-time _fix_missing_columns auto-add):
- status_components.wordpress_site_id (-> wordpress_sites)
- status_incidents.component_id (-> status_components)
The existing HealthCheck table is reused as the uptime sample store.
StatusPageService.sync_component_from_health maps the health verdict
(healthy->operational, degraded->degraded, unhealthy->major_outage; unknown
skipped), records a HealthCheck, recomputes a real COUNT-based uptime %
(24h/7d/30d/90d; only fully-healthy checks count), and auto-opens an incident
on ENTERING a major outage / auto-resolves on LEAVING it (operational OR
degraded, so a degraded intermediate never strands the incident).
Routes GET/POST/DELETE /sites/<id>/status-page read the binding + attach
(production-only) / detach (resolves & unlinks the incident first -> no
dangling FK on PostgreSQL, no stale public incident). 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.
Review fixes (3-lens adversarial pass): incident-resolve edge for the
degraded-recovery path; detach FK/orphan cleanup in delete_component; public
check_target leak; production-only attach; uptime-prune; degraded-uptime
semantics documented. Verified: backend py_compile, frontend lint + build.
Not runtime-exercised on a real Docker host (Windows dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route WordPress health transitions to the configured notification channels
and the outbound webhook bus, edge-triggered so an autonomous poller never
spams.
EnvironmentHealthService.check_health now captures the prior health_status
and, ONLY on a state transition (up<->down edge), fires _notify_health_
transition -> _dispatch_health_alert which:
- preserves the existing WorkflowEventBus 'health_check_failed' emit
(still fires every failing poll; back-compat for event-trigger workflows);
- NEW: NotificationService.send_all to every enabled channel
(Discord/Slack/Telegram/email/generic). unhealthy->critical,
degraded->warning (delivered by default), recovery->info (delivered only
to channels that opt into info). Sent off-thread because send_all does
blocking HTTP/SMTP and must not stall the health check;
- NEW: emits wordpress.site_down / wordpress.site_up via EventService, added
to EVENT_CATALOG, so user webhook subscriptions (incl. wordpress.*) deliver
with HMAC + retry and the events auto-appear in the subscription UI.
Edge-triggering is the dedup (no in-memory cooldown, which would be
single-worker-fragile): a continuously-down site alerts once, recovery once.
No schema/migration and no frontend — channel delivery is governed by the
existing Notifications settings. Also seeds #35's WP lifecycle-event registry.
Deferred: the "error spike" rule needs per-site error metrics that #25 will
provide. Verified: backend py_compile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make WordPressService.create_site a single one-click orchestrator. It now
accepts php_version / enable_page_cache / enable_object_cache and:
- Bakes the chosen PHP version into the INITIAL image tag (concrete
6.4-php<x.y>-apache) so the site boots on the right PHP with no
post-create container recreate, then finalizes + _harden_docker_site,
then best-effort enables page cache (#22) and Redis object cache (#23)
AFTER `wp core install` (redis already ships in the stack). 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) gains 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 validated server-side).
Mechanism: declare a hidden VERSION template var and switch the image
line to bare ${VERSION} in wordpress.yaml + 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 read live;
the page-cache flag rides the existing sync_config column.
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 — the same missing public-domain/routing infra
that blocks #21 and that #8 left frontend-only. TLS remains operable
post-create via SiteSSLPanel (#8) once a domain is routed.
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. Not runtime-exercised on a real
Docker host (Windows dev).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase C #24. The roadmap referenced the host PHP-FPM API, but managed sites are
wordpress:*-apache containers (PHP baked into the image tag), so this is done the
Docker-correct way via the wp_cli bridge.
New "PHP" tab on the site detail page showing the LIVE PHP version and key ini
limits (memory_limit, upload_max_filesize, post_max_size, max_execution_time,
max_input_time) read from inside the running container via `wp eval` (read-only,
zero risk). A "Change PHP Version" panel switches PHP by rewriting the compose
image tag to wordpress:<core>-php<x.y>-apache and recreating the container
(DB/files preserved, brief downtime) behind a confirm; supported set 8.1/8.2/8.3.
New routes GET/POST /sites/<id>/php; getPhpInfo/setPhpVersion client methods. The
panel reuses existing app-panel/app-info-grid markup — no new SCSS (no conflict
with the concurrent style session). Application.php_version is left untouched (not
meaningful for Docker sites); the live container value is the source of truth.
Writing arbitrary PHP limits is explicitly deferred (needs a conf.d ini + a
compose bind-mount for durability across recreates).
Verified: backend boots, both /php routes register, service methods load;
frontend builds; changed JS lints with 0 errors. No DB migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase C batch 1 — roadmap #22 and #23. The roadmap assumed nginx fastcgi/proxy
cache + host php-fpm, but managed sites are self-contained wordpress:*-apache
containers on localhost:port with no nginx in front — so both are implemented
the Docker-correct way, entirely in-container via the wp_cli bridge.
#22 Full-page cache via the cache-enabler plugin (Apache disk cache), installed
+ activated through wp_cli with WP-aware skip rules (wp-admin/login, logged-in
+ cart/checkout/account cookies, query strings). New routes GET/POST/DELETE
/sites/<id>/page-cache; the enable flag is stored in WordPressSite.sync_config
(no migration); the existing Purge Cache button now also purges the page cache.
#23 Per-site Redis object cache. enable_object_cache ensures a redis service in
the site's compose stack (NEW sites ship redis via the template; EXISTING sites
get a redis service injected into docker-compose.yml + an additive `compose up
-d` with no downtime), then installs the redis-cache plugin (bundled Predis, no
phpredis needed) and points WP at host 'redis'. Status derived live from
`wp redis status`. New routes GET/POST/DELETE /sites/<id>/object-cache; Purge
Cache now also runs `wp redis flush`.
UI: "Enable Page Cache" and "Enable Object Cache" toggles in the site Quick
Actions, reusing the existing .quick-action-btn class (no new/shared SCSS).
Verified: backend boots, all 6 routes register, all 8 service methods load,
template YAML valid; frontend builds; changed JS lints with 0 errors. No DB
migration. (Container-level behavior not runtime-tested — no Docker here.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase B batch 2 — roadmap #17 and #18 (#21 deferred: blocked without a public base domain).
#17 Existing-site import (.sql-first MVP). New "Import Site" action on the
WordPress hub opens a modal (name + original URL + .sql/.sql.gz dump). The
backend POST /sites/import stands up a fresh WordPress+MySQL Docker stack via
create_site, streams the dump into the container DB via
DatabaseSyncService.import_to_container (root user, gzip-aware), rewrites the URL
(wp option update home/siteurl + wp search-replace --all-tables), flushes caches,
re-detects multisite, and clears the stale generated admin. wp-content-zip and
SFTP pull are explicitly out of scope for the MVP.
#18 Clone a site to a NEW independent top-level site with FRESH credentials. New
WordPressService.clone_site stands up a brand-new stack via create_site,
container-to-container clones the source DB with a URL search-replace, best-effort
copies wp-content via docker cp, then provisions a fresh administrator (new
generated password) so the clone does not share the source's creds (the DB import
overwrites users). POST /sites/<id>/clone + a "Clone" button in the site topbar +
a one-time credentials banner (with an "Open new site" link). No production_site_id
=> truly independent.
Both reuse the existing create/clone/wp-cli primitives; DB creds read from each
stack's .env (root user, literal db name 'wordpress' since the columns are NULL
for Docker sites). FormData upload uses the api client's existing multipart path.
Verified: backend boots, both routes register, all service methods load; frontend
builds; changed JS lints with 0 errors. No DB migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase B batch 1 — roadmap #16, #19, #20.
#16 Passwordless "Auto Login" into wp-admin (the signature managed-WP feature),
no heavy plugin. New WordPressService.create_login_url uses the Docker-aware
wp_cli bridge to install the wp-cli-login package (idempotent) and mint a
one-time magic URL (wp login create --url-only); login slug resolved via
wp eval rather than hardcoding /wp-admin. New POST /sites/<id>/login
(admin-gated) resolves or provisions a managed admin tied to the operator's
panel email (create_user role=administrator), persists admin_user, writes a
'wordpress.admin_login' AuditLog, and returns the URL. A prominent primary
"Auto Login" button in the site topbar opens it; Dashboard remains as fallback.
#19 Multisite is now detected for real via `wp core is-multisite` (new
is_multisite helper): surfaced in get_wordpress_info, set at create_site, and
refreshed+persisted on the single-site detail load (never on the hot list
endpoint).
#20 Site tags/labels for agency organization. New JSON-in-TEXT tags column on
WordPressSite (auto-added on boot by _fix_missing_columns; plus an idempotent
009 Alembic revision for determinism), exposed via to_dict, set via
PATCH /sites/<id>/tags (normalized/deduped). Frontend: tag chips on each site
card and a client-side tag filter bar on the WordPress list.
Verified: backend boots (009 migration applies, tags column live, routes +
methods present); frontend builds; changed JS lints with 0 errors. No frontend
or backend regressions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes roadmap #2, #3, #11, #13, #14.
#2 Canonical Docker create now finalizes the install. create_site waits for the
container to be ready, runs `wp core install` (creates a real admin user) and
applies container-valid hardening via the Docker-aware wp_cli bridge
(DISALLOW_FILE_EDIT, XMLRPC_REQUEST off, shuffle-salts) — NOT host-filesystem
ops, which don't apply to the named-volume model. The generated admin password
is returned and surfaced once in the create page (so the new admin is usable).
Best-effort: site creation still succeeds if the container is slow to finalize.
#3 _enrich_site_data derives the site URL from the primary domain (https when
ssl_enabled, else http), falling back to http://localhost:<port> only when no
domain is attached. Fixes Open Site / Open WP Admin / the SSL panel.
#11 Offsite snapshots + retention. New DatabaseSyncService.upload_snapshot_offsite
(best-effort S3/B2 upload, no-op unless a remote provider + auto_upload are
configured) is called after every snapshot is created (manual + pre-promotion).
New prune_expired_snapshots backfills expires_at (default 30d, tagged snapshots
kept) and deletes expired file+row; an hourly 'snapshot-retention' scheduler
thread runs it. No migration (expires_at/status columns already exist).
#13 Push-to-deploy for WordPress. WebhookService._handle_push_event now fans out
to every WordPressSite whose connected repo+branch matches the push and has
auto_deploy=true, calling GitWordPressService.deploy_from_commit. Repo URLs are
matched scheme/.git/creds-agnostically via a new _normalize_repo_url helper.
Best-effort; never breaks the generic-app webhook path.
#14 WordPress REST is now API-key reachable. All 12 wordpress_sites.py routes
switched from bare @jwt_required() to @auth_required() (accepts API key or JWT),
reading the user via get_current_user().id instead of get_jwt_identity()
(which returns None under an API key). Ownership filters unchanged.
Verified: all backend files compile; the app boots and the retention scheduler,
route swaps, and helpers load; _normalize_repo_url maps ssh/https/creds forms
identically; frontend builds; WordPress.jsx lints clean. No DB migration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements roadmap #6, #7, #8 and surfaces the #4/#10 backends in the UI.
#6 Consolidated Site Health card in the WordPressDetail overview: loads
getProjectHealth + getProjectDiskUsage on mount and shows overall status,
WP/PHP version, container/database/HTTP checks (HealthDot) and the previously
orphaned DiskUsageBar — with loading/error/standalone-None handled gracefully.
#7 WordPress update manager. New update_themes service method + POST
/sites/<id>/themes/update route (registered before /themes/<theme>/activate),
and getWordPressInfo/updatePlugins/updateThemes ApiService methods. OverviewTab
shows a core "Update available" badge + Update button; PluginsTab and ThemesTab
show per-row update badges/buttons (keyed off the WP-CLI `update` field) plus a
bulk "Update all" bar. All update state is computed live via WP-CLI.
#8 Per-site SSL panel. Shows the primary domain and live certificate status
(via GET /ssl/advanced/health/<domain>) and an "Enable SSL" action that issues a
Let's Encrypt cert through the existing /ssl/certificates route. SSL status is
derived live (no DB column / migration); localhost / private-IP / no-domain
sites degrade to an explanatory hint instead of a dead button.
#4 UI: a Danger Zone with Archive/Unarchive (toggles on site.status) and a
Delete Site modal (typed-name confirmation + create-backup checkbox) wired to the
existing archive/unarchive/delete endpoints.
#10 UI: a Promotion History list in the pipeline tab (self-fetched) with a
Rollback button shown only for completed promotions that have a pre-promotion
snapshot, wired to rollbackPromotion via the existing ConfirmDialog.
SCSS added for update badges/bulk bar, health/SSL helpers, and promotion history.
Verified: frontend build passes; backend boots and the themes/update route +
update_themes method register; changed JS files add 0 new lint errors. No DB
migration introduced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements roadmap tasks #5, #9, #10, #12 — wiring existing primitives into the
WordPress site object.
#5 Surface orphaned WP-CLI maintenance actions. Adds flushCache/searchReplace/
harden methods to the WordPress ApiService and Quick Actions UI in
WordPressDetail (Purge Cache, Search & Replace modal with dry-run, Harden behind
a confirm). The backend routes already existed and had no UI.
#9 Stop dropping the sanitization profile. promote_database and
sync_from_production now resolve the selected sanitization_profile_id (via a new
_apply_sanitization_profile_options helper) and merge its rules — anonymize,
table truncation/exclusion, custom search-replace — into the clone options,
instead of honoring only the boolean `sanitize`. User-scoped; composes with the
existing flag; no-ops when no profile is selected.
#10 Close the promotion rollback loop. Adds
EnvironmentPipelineService.rollback_promotion (restores a promotion's
pre_promotion_snapshot into the target environment — container import or host
restore — sets status='rolled_back', locks/unlocks, logs activity) and the route
POST /wordpress/projects/<prod>/promotions/<id>/rollback, plus a
rollbackPromotion ApiService method. Pre-promotion snapshots were captured but
never restorable.
#12 Deep-link DB/Files/Logs from a site. Adds Open Files (File Manager at the
site root), Open Database (DB manager pre-scoped via ?db=, hidden when db_name is
null), and View Logs (global LogsDrawer for the container) to the WordPressDetail
topbar. Databases.jsx MySQLTab now honors a ?db= query param to auto-open the
QueryRunner.
Verified: backend boots and the rollback route registers; frontend builds; the
three changed frontend files lint with 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#1 Collapse the duplicate WordPress hub routes. Both wordpress_bp and
wordpress_sites_bp were mounted at /api/v1/wordpress and both defined
GET/POST /sites, /sites/<id>, environments, plugins, themes and update, so the
second blueprint's handlers were silently shadowed. wordpress_bp (the Docker
stack model the live UI creates against with {name, adminEmail}) now solely
owns the hub surface; wordpress_sites_bp keeps only its unique routes (sync,
snapshots, clone-db, git). Verified 0 path+method collisions in the live URL map.
#4 Backup-before-delete + archive. delete_site(create_backup=True) now captures
a final files+DB backup via the Docker-aware backup_wordpress() (written to
BACKUP_DIR, outside the site root, so it survives rmtree) before teardown, and
returns the backup info. Added reversible archive_site/unarchive_site plus the
POST /sites/<id>/archive and /unarchive routes, and a ?create_backup=false
opt-out on delete. Frontend gains archiveSite/unarchiveSite/deleteSite(opts).
Also fixed a latent bug: _teardown_wp_site called compose_down(remove_volumes=)
but the real parameter is volumes=, which would have raised TypeError.
Also adds docs/WORDPRESS_ROADMAP.md (35-task managed-WordPress roadmap).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>