save_config() ran os.makedirs(SERVERKIT_CONFIG_DIR) unconditionally rather
than the directory of the file it actually writes (cls.CONFIG_FILE). When a
caller overrides CONFIG_FILE (test_storage_secret_encrypted_at_rest points it
at a pytest tmp dir), creating the real config dir failed on the sandboxed CI
runner and — because the surrounding except swallows it and returns
{success: False} — the file was never written, surfacing as a confusing
FileNotFoundError. Now makedirs(dirname(CONFIG_FILE)); production behavior is
unchanged (there dirname(CONFIG_FILE) == SERVERKIT_CONFIG_DIR). 8/8 encryption
tests pass; full backend suite was 241 passed / 1 failed -> now green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Safety dependency scan flagged PyJWT==2.12.1 (5 vulns) and
cryptography==46.0.7 (1 vuln, bundled-OpenSSL). Bump to the Safety-suggested
fixes, which resolve all 6 currently-active (non-policy-ignored) findings.
Verified: installs with no resolver conflicts, the Fernet/encryption tests
pass, the app boots (JWT manager), and the full suite is 232 passed / 12
skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 4 of docs/REMOTE_ACCESS_ROADMAP.md (the buildable/verifiable parts):
- #20 multiple private peers per edge: allocate a per-edge WireGuard listen
port (tunnel_netutil.pick_listen_port) instead of a hardcoded 51820, so one
edge can front many private peers without UDP-port conflicts. (Multiple
services per tunnel already worked since Phase 2.)
- #21 reachability diagnostic: tunnel_netutil.diagnose_reachability classifies
an up-but-never-handshaking tunnel as likely-blocked outbound UDP; surfaced
in the status-refresh response (diagnostic field) and as a hint on the Remote
Access page. The relay data plane itself is deferred.
#22 (client-VPN) and #23 (identity-aware SSO) remain XL milestones, not built.
Verified: 14 backend tests (incl. port-allocator + diagnostic), eslint + build.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-hosted tunneled reverse proxy: a service on a NAT'd host (e.g. a home
Jellyfin box) gets a public HTTPS hostname with no port-forwarding, by pairing
two agents over WireGuard. Implements Phases 0-3 of docs/REMOTE_ACCESS_ROADMAP.md.
Agent (Go):
- new internal/wireguard package: Manager interface + kernel backend (wg/ip)
and a userspace netstack backend (wireguard-go + gVisor) so Windows/macOS
peers need no driver/admin; in-process keygen returns only the public key
(private keys never leave the host)
- wireguard:keygen/interface/peer/status/forward/unforward + firewall
:allow_port/:deny_port commands; wireguard + firewall capability probes
- TCP forwarder (tnet.ListenTCP / net.Listen -> dial localhost) carries
traffic to the real service behind a netstack peer
Backend (Python):
- Tunnel + ExposedService models; tunnel_netutil (subnet allocator, health);
tunnel_broker_service (keygen->up->peer->verify, firewall open, reconcile-on-
reconnect); tunnel_publish_service (forward -> nginx -> DNS -> auth -> cert)
- nginx remote-upstream vhost (WebSocket + proxy_buffering off)
- /api/v1/tunnels CRUD + /<id>/services publish/unpublish
- panel-authoritative reconcile hooked into the agent gateway on reconnect
Frontend (React):
- Remote Access page (tunnels + exposed services, status, teardown) and an
"Expose a Local Service" wizard; new services/api/tunnels.js
Verified: go build (win/linux) + vet + go tests (forwarder + no-leak parsers);
py_compile + pytest (netutil, reconcile, nginx template); create_app boot;
eslint + npm run build. Live multi-host run pending.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Auditing the encryption-at-rest work surfaced one missed read site: sites_https_service
passed the DNS provider api_key/api_secret straight to the cert issuer, but those are
now Fernet-encrypted at rest — so DNS-01 wildcard issuance would have received ciphertext
and failed.
- Add DNSProviderService.decrypted_credentials() as the public, canonical way to read a
DNS provider's usable creds (nothing reads .api_key raw anymore); use it in
sites_https_service.
- Add tests/test_provider_secret_encryption.py — 8 proving tests: ciphertext-at-rest +
plaintext-on-read for DNS / cloud / storage / registrar, the idempotent legacy-plaintext
migration, and the wildcard path now receiving decrypted creds (the regression above).
pytest: 17/17 pass (8 new + 9 existing test_sites_https.py). Full suite: 187 passed; the
lone failure (test_agent_poll_e2e command roundtrip) is a pre-existing flake — the agent
registry in-memory singleton leaks across tests in that file (passes solo), unrelated here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Domain-expiry notifications: a daily scheduler (RegistrarService.notify_expiring)
fires through the notification channels when a registrar domain crosses 30/14/7/1
days or expires, de-duped via a small state file so each crossing alerts once.
- S3 image preview: ImageThumb is now S3-aware (loads objects via short-lived
presigned URLs); threaded through FileCard / PreviewDrawer / FileManager.
- Encryption-health banner: the Connections hub consumes GET /api/v1/connections
and warns if any credential is not encrypted at rest (hidden in the happy path)
— the registry's first real consumer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Encrypt the last plaintext credential store: CloudProvider.api_key_encrypted is
now Fernet-encrypted on write and decrypted in _auth_headers, with an idempotent
encrypt_legacy_secrets() migration run at startup. All five stores (source, DNS,
cloud, registrar, storage) now encrypt identically via app.utils.crypto.
- Add ConnectionRegistry + GET /api/v1/connections: one normalized, secret-free
list of every connected external account (the "what's connected" source of
truth). Frontend api.getAllConnections().
- The full write-side "one model replaces five" migration is deliberately not done
— it would risk five working subsystems for no user-facing benefit. The uniform
encryption + read registry deliver #18's practical value.
Verified by booting create_app('testing') and running the registry live (returns
a list; /api/v1/connections present in the URL map).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Encryption-at-rest (#16/#17) for the two provider stores still in plaintext:
- crypto.py: add decrypt_secret_safe (decrypt with plaintext fallback) + is_encrypted.
- DNS providers: encrypt api_key/api_secret on write; decrypt at point-of-use via
_api_key/_api_secret in the Cloudflare/Route53/DigitalOcean/GoDaddy builders.
- Storage: encrypt secrets in save_config; decrypt in get_config.
- Idempotent encrypt_legacy_secrets() migrations run on startup, encrypting any
pre-existing plaintext in place. Legacy values keep working via the decrypt
fallback, so there is no flag day.
Namecheap registrar:
- RegistrarConnection.config_json column for non-secret extras (username, client_ip).
- RegistrarService: Namecheap XML domains.getList (portfolio + expiry), test, and
MM/DD/YYYY -> ISO date parsing.
- Frontend: namecheap tile is live; the registrar form is provider-aware (GoDaddy
key+secret vs Namecheap key + username + whitelisted IP).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- backend: StorageProviderService.s3_* (browse/read/write/upload/delete/
presigned-get) + /api/v1/files/s3/* routes, reusing the configured backup
bucket — no new credentials.
- frontend: an "S3 bucket" target in the File Manager. UI paths stay slash-rooted
and map to S3 keys server-side, so breadcrumbs/history work unchanged; virtual
folders from prefixes; presigned-URL download. Folders/rename/permissions are
disabled and the local-only sidebar is hidden on S3. TargetPicker gained a
backward-compatible extraOptions prop.
- docs: mark #14/#15 done (Phase 4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a broad Connections expansion: add RegistrarConnection model, RegistrarService and /api/v1/registrars endpoints to surface domain portfolios and expiry. Add EmailRelayService and API routes plus Postfix relay wiring to configure/test/disable outbound SMTP. Extend DNSProviderService with DigitalOcean and GoDaddy implementations and helpers. Add GitLab support to source connection service and routes. Frontend: expand Connections hub, provider catalog, modal bodies, provider icons, RegistrarPortfolio component, API client methods and styles. Add docs/CONNECTIONS_ROADMAP.md describing roadmap and intent.
Introduce GitProviders (provider detection, badges, and RepoProviderStrip) and a shared _git-connect.scss; update Git connect modal and WordPress Git form to use the new UI and provider detection. Replace many page header blocks with PageTopbar and wrap loading states in page-container for consistent layout. Backend: add _is_wp_app helper and use it in wordpress API endpoints; ensure wp-cli login plugin is installed and activated. Minor style/token cleanups and .gitignore addition for docs/UI_CONSISTENCY_AUDIT.md.
One-time operator setup that makes every managed subdomain HTTPS. New
SitesHttpsService.setup creates the *.<base> + <base> A records via a connected
DNS provider and issues a wildcard Let's Encrypt certificate over DNS-01,
reusing that provider's credentials — then flips sites_https_enabled on.
- AdvancedSSLService.issue_wildcard_cert now supports route53 (AWS env creds) as
well as cloudflare, takes an email, installs the matching certbot DNS plugin
best-effort, and returns the cert/key paths.
- SiteDomainService gains https_enabled / wildcard_cert_paths / covers. create_site
installs new subdomain sites at https when enabled, and _write_app_vhost serves
the wildcard cert on vhosts whose every domain it covers (custom domains keep
their own cert).
- NginxService.create_site takes optional ssl_cert/ssl_key and bakes a 443 block +
HTTP->HTTPS redirect (idempotent via full regen).
- sites_base_domain / server_public_ip / sites_https_enabled are now discoverable
settings. Admin endpoints GET/POST /admin/sites-https/{status,setup}, surfaced
in Settings → Site as a "Managed Sites Domain & HTTPS" panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns "point myblog.com at this site" into one action that consumes the URL-swap
tool. New DNSProviderService.find_zone_for_domain (longest zone-suffix match
across connected providers) + ensure_a_record upsert the A record via Cloudflare/
Route53, or return the exact record to add when nothing manages the zone.
WordPressService.attach_custom_domain orchestrates it: auto-create DNS (server IP
from SiteDomainService), optionally obtain a Let's Encrypt cert (the host vhost is
written first so certbot --nginx can validate), then migrate the site URL to the
domain via change_site_url. External steps degrade to warnings; only a requested
migration that errors aborts.
POST /wordpress/sites/<id>/domain (admin). Frontend: "Add Domain" button +
AttachDomainModal — enter a domain, optional HTTPS, and the result shows the
auto-created record or the exact A record to add manually.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Changing a WordPress site's URL is not a single UPDATE: the URL is embedded
across wp_options, post content, and PHP-serialized blobs whose byte-length
prefixes break under a naive REPLACE. This adds a first-class swap tool built on
WP-CLI search-replace (serialization-safe), with the rails to make it routine:
- preview_url_change: dry-run that reports per-pair replacement counts, no writes
- change_site_url: backs the database up first, rewrites with --skip-columns=guid
(full-URL + host-only pairs so scheme/protocol-relative refs are covered),
updates home/siteurl, flushes the object cache + permalinks, then re-points the
primary Domain row and nginx vhost. Rolls the DB back from the backup on
failure. keep_old_redirect keeps the old host resolving so WordPress 301s it.
- POST /sites/<id>/url/preview and /sites/<id>/url (admin-gated)
- "Change URL" modal on the site detail page: live dry-run preview, backup
notice, keep-old-address toggle, then apply.
_write_app_vhost is factored out of _provision_routing and shared by the
re-point path. The pure-SQL search-replace in db_sync_service remains the
offline-dump fallback only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
create_site installed WordPress at http://localhost:<port>, which WordPress
bakes in as its canonical home/siteurl — reachable on the box but useless as a
public site URL. New SiteDomainService resolves <slug>.<base_domain> (config
SITES_BASE_DOMAIN: lvh.me in dev, opt-in in prod, empty falls back to the old
localhost URL). create_site now installs at that host and provisions a primary
Domain row + nginx reverse-proxy vhost (_provision_routing, best-effort so a box
without nginx warns instead of failing). Teardown removes the vhost; clone_site
URL rewriting is generalized via _canonical_site_url so cloning stays correct
with real hostnames.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docker_mysql_get_tables returned [] on both a genuinely empty database and a
container/auth failure, so the explorer rendered "Empty" either way and silently
hid broken connections. It now returns {connected, tables, error}; the API
passes that through, and the source tree shows "No tables yet" for a connected
but empty database vs the real reason when the container exec/auth fails.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- SECURITY.md: spell out that agent.key encryption is off-host/tamper
protection, not confidentiality against local root; 0600 perms are the real
control (L2).
- agent_registry.py: reword the per-message session-token "TODO" to an accepted
limitation (defence-in-depth, not a bug) (L7).
- AGENT_AUDIT.md: record H1/H2/H3 and L1-L7 status, with L4 (cap OR-merge),
L5 (single-worker), L6 (windows .bat) noted as accepted by-design/documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pairing /claim creates a fleet server and /lookup reveals a pending agent's
details; both were @jwt_required() only, so any authenticated user (incl. a
read-only viewer) could use them. Gate both with @developer_required to match
POST /servers. Test: viewer 403, developer clears the gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
agent_plugin_service and server_template_service dispatched agent commands
through get_agent_gateway() — a function that does not exist — inside a bare
try/except. Every call raised ImportError, was swallowed, and left DB rows stuck
in 'installing'/'checking'/'remediating' forever while the UI implied the
feature worked. The agent also has no handlers for these commands.
- Plugin install now reports STATUS_ERROR with "Agent-side plugin support is not
implemented yet" instead of hanging in 'installing'.
- Template drift-check/remediate report 'unknown' + an explanatory
drift_report.error.
- Local-only ops (disable, uninstall, configure) just drop the dead dispatch.
- test_agent_features_honest.py: 3 tests asserting honest states.
Implementing the feature for real (agent handlers + sandbox) remains future
work; this stops it from lying in the meantime.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /<id>/agent/update was @jwt_required() only, so a read-only viewer could
trigger agent binary replacement + service restart across the whole fleet —
inconsistent with every other state-changing server route, which is
@developer_required. Add @developer_required and a test proving a viewer gets
403 while a developer clears the gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The agent subsystem had unit tests but nothing proving the whole protocol
works. This drives the REST poll transport (/connect, /poll, /result) — plain
HTTP that shares the WebSocket gateway's auth, registry, and command-routing
code — through the full loop: a correctly-signed agent authenticates, registers,
receives a command the panel issued via send_command (run on a background
thread since it blocks on the result), and round-trips the result back to the
waiter. Uses a temp-file SQLite DB so the thread and request handlers share
committed rows. 4 tests, all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Socket.IO connect handler only checked that the JWT decoded — it never
confirmed the account still exists or is active, and recorded no identity. The
generic join_room and subscribe_terminal handlers then performed no
authorization, so a read-only viewer (or a deactivated account with an
unexpired token) could attach to a live PTY stream room and watch a privileged
shell.
- connect: resolve the JWT identity to a live, active User and stash
{user_id, role} per sid; reject missing/deactivated accounts.
- subscribe_terminal and join_room (for _terminal: rooms): require
admin/developer, matching the @developer_required on terminal creation.
- disconnect: drop the per-sid identity record.
Job-progress and cloudflared-login streaming (non-terminal server rooms) are
unaffected — that data is already reachable over REST by any authenticated user.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The "Terminal & Logs" page had no terminal. Now a Terminal tab hosts the
xterm console (RemoteTerminal) against paired agent servers, behind a
demo-style target rail (.term-shell: agent list with glowing status
dots, offline rows disabled, first online server auto-selected).
RemoteTerminal was built but never mountable - it imported a
nonexistent SocketContext and listened for a terminal:<sid> event that
nothing emits. Fixed the full output path:
- backend sockets.py: new subscribe_terminal/unsubscribe_terminal
handlers join the browser into the agent gateway stream room
(server_<id>_terminal:<session>) after validating the session via
TerminalService.get_session
- RemoteTerminal: rewired to services/socket.js; consumes server_stream
events filtered by the session channel (agent PTY -> gateway ->
xterm), re-subscribes on socket reconnect, unsubscribes on unmount
VALID_TABS gains terminal (appended - logs stays the default landing
since the shell needs a connected agent; panel-host PTY remains a S5
gap, TerminalService rejects local).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Expose and surface containerised databases and introduce managed app base port control. Backend: add endpoint to list databases across all Docker apps, robustly resolve compose container names, include docker DB user when querying, compute migration needs from live heads and self-heal orphaned alembic_version entries by stamping to head, and add the managed_app_base_port system setting. Template service now honours the managed_app_base_port and WordPress template default HTTP port updated to 8300. Frontend: add react-icons brand icons and DatabaseBrands component, show Docker-hosted DBs under their engine nodes with a docker badge, platform-aware console shortcut label, pass DB user to docker table/query API calls, add API client header merge fix, UI for managing base port in settings, enhanced migration history UI (backup/apply/resync flows, confirm dialog, toasts), and various style tweaks and color additions.
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>
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>
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>
A stale or forbidden X-Workspace-Id (deleted workspace, removed member) now degrades to no-scope instead of 403/404, so an ambient workspace-context header can never break the apps/servers lists. Safe by construction: falling back to no-scope only ever shows the user their own resources and stamps the default workspace on create — it never grants access to, or creates in, a foreign workspace. Also drops the error-tuple from the four call sites.
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>
AI is a core ServerKit primitive (not a plugin): an Intercom-style bubble + slide-in drawer on every dashboard page, with a tools+page-context assistant mode and a plain simple chat mode, SSE streaming, conversation persistence, and guarded write actions (the model proposes; the user approves).
Plugins EXTEND the assistant rather than rebuilding it: backend via app.plugins_sdk.ai (reg.tool + register_context_provider, discovered from a plugin.json ai block with install/enable/disable hooks); frontend via useServerkitAI() and a contributions.ai category (suggested_prompts, tool_renderers). The marketplace gains an ai category for discovery.
Backend: ai_service (Prompture Conversation + ProviderEnvironment + guardrails), ai_tool_registry singleton, ai_tools_builtin (core.* tools), the /api/v1/ai blueprint (SSE via a worker-thread + queue bridge, single-gevent-worker safe; in-thread confirmation gate), AiConversation/AiMessage/AiPendingAction models + migration 013, encrypted ai_* settings. Frontend: AIContext + components/ai/*, AI settings tab. Powered by Prompture (installed editable; requirements pinned).
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>
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>