Skip to content

ADR-0016: WP operator console — users, settings, config files, DB tools

Status: Proposed Date: 2026-05-31 Deciders: Ramón Kamibayashi + Claude Opus 4.7 Related: ADR-0010, ADR-0013, ADR-0014, ADR-0015

Story: Closes the WP-admin parity gap. After ADR-0014 (plugin/theme), the operator still needs to drop into wp-admin for: user management, wp_options editing, wp-config.php tweaks, .htaccess rules, cron inspection, DB cleanup, DB query. Bring all these into WP-Pulse with the preview-first contract.


Decision

Three sub-areas under the existing site detail. Each is a panel mounted via the existing CollapsiblePanel pattern OR a sub-route under /sites/[slug].

E — User & content manager

# Feature UX
E1 User list Table: ID, login, email, display_name, role, last_login, 2FA_enabled. Filterable.
E2 Add / edit / delete user Inline modals. Edits write to local DB via wp user create/update/delete on preview stack.
E3 Login as user (impersonation) Generates a magic-link URL for the local preview that auto-logs-in as the chosen user. Useful for debugging customer reports. Uses wp-cli wp user generate-token or a small mu-plugin that consumes a one-time token.
E4 Bulk password reset Select N users → "Send reset emails on next prod push" — writes a queued action; on Apply to prod, runs the resets. Or immediately on preview for testing.
E5 Posts/pages lite editor Table of posts: title, status, author, modified. Filter by status. Inline actions: draft / publish / trash / restore. Open in a slim Markdown-ish editor (no Gutenberg — too heavy). For complex edits operator hops to wp-admin.

F — Settings & config editors

# Feature UX
F1 wp_options editor Searchable table of rows: option_name, option_value, autoload. Edit-in-place (textarea for serialized values). Change tracking — apply writes via wp option update on preview.
F2 wp-config.php editor Monaco-style editor with PHP highlighting. Linter warnings for: removing WP_CACHE, WP_DEBUG without trailing semicolon, deleting required constants. Pre-apply check: refuse if the result is syntactically invalid (php -l).
F3 .htaccess editor Same shape. Linter detects common Apache directive typos. Pre-apply check: stage to .htaccess.staged, hit a test URL, only promote on 200.
F4 Cron viewer + manual run List of registered events: hook, next_run_at, schedule. Inline "Run now" + "Delete event".

G — Database tools

# Feature UX
G1 Health & cleanup wizard Detects: post revisions > 30 days, expired transients, orphan postmeta, autodraft posts > 7 days old. Each detector reports row count + bytes. Operator selects categories → preview cleanup → apply. Reuses safe wp CLI commands (wp post delete --revision, wp transient delete --expired).
G2 DB query console Same allowlist pattern as F2 wpcli safelist: read-only queries via wp db query (SELECT only, no UPDATE/DELETE/DROP without explicit confirm). Result table downloadable as CSV. Audit logged to wpcli_audit.
G3 Table size dashboard Bar chart: wp_postmeta 45 MB, wp_options 12 MB, wp_posts 3 MB, .... Click bar → drilldown to top 100 largest rows. Source: INFORMATION_SCHEMA.TABLES.

Data model

  • No new tables for E/F/G (everything is WP-state, lives in customer DB).
  • Extend wpcli_audit to log wp db query separately if not already.
  • New optional table pending_user_actions for queued ops like bulk password reset (deferred to apply-time):
    CREATE TABLE pending_user_actions (
      id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
      site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
      action text NOT NULL,         -- "password_reset_email"
      target jsonb NOT NULL,        -- user IDs
      queued_at timestamptz DEFAULT now(),
      applied_at timestamptz
    );
    
  • Alembic 022 (if pending_user_actions is in scope; otherwise no migration).

API surface

Users

  • GET /api/v1/sites/{slug}/users → list with role, 2FA, last_login.
  • POST /api/v1/sites/{slug}/users body {login, email, role, password} → creates on preview.
  • PATCH /api/v1/sites/{slug}/users/{id} body partial fields.
  • DELETE /api/v1/sites/{slug}/users/{id} body {reassign_to: id?}.
  • POST /api/v1/sites/{slug}/users/{id}/impersonate → returns {magic_url} valid 5 min on the preview stack only.
  • POST /api/v1/sites/{slug}/users/bulk-password-reset body {user_ids: [...]} → queues a pending_user_action.

Settings/config

  • GET /api/v1/sites/{slug}/options → searchable list of wp_options rows.
  • PATCH /api/v1/sites/{slug}/options/{name} body {value, autoload?}.
  • GET /api/v1/sites/{slug}/wp-config → text.
  • PUT /api/v1/sites/{slug}/wp-config body {content} → lints + writes to worktree.
  • GET /api/v1/sites/{slug}/htaccess → text.
  • PUT /api/v1/sites/{slug}/htaccess body {content} → lints + writes + smoke-test against a test URL.
  • GET /api/v1/sites/{slug}/cron → list of events.
  • POST /api/v1/sites/{slug}/cron/{hook}/run-now — executes on preview stack.
  • DELETE /api/v1/sites/{slug}/cron/{hook} — removes event.

Posts / pages

  • GET /api/v1/sites/{slug}/posts?status=&type=&limit= → paginated list.
  • PATCH /api/v1/sites/{slug}/posts/{id} body {status?, title?, content?}.
  • DELETE /api/v1/sites/{slug}/posts/{id} — trash.

DB tools

  • GET /api/v1/sites/{slug}/db/health → cleanup detectors output {post_revisions: {count, bytes}, expired_transients: {...}, ...}.
  • POST /api/v1/sites/{slug}/db/cleanup body {categories: [...]} → runs cleanup on preview.
  • POST /api/v1/sites/{slug}/db/query body {sql} → returns rows (read-only enforced).
  • GET /api/v1/sites/{slug}/db/tables → array of {table, rows, bytes}.

UI surface

  • New pages:
  • /sites/[slug]/users
  • /sites/[slug]/posts
  • /sites/[slug]/options (wp_options + cron tabs)
  • /sites/[slug]/config (wp-config + .htaccess tabs)
  • /sites/[slug]/db (health + query + tables tabs)
  • Each is mounted as a tab in a new sub-nav on the site detail page (next to "Quarantine plugins"). Use the existing SiteTabNav pattern (it was deprecated in ADR-0012 but a thin version is fine here for sub-views).
  • Components per page (NEW):
  • UsersTable.tsx, UserEditModal.tsx, ImpersonateButton.tsx
  • PostsTable.tsx, PostEditDrawer.tsx
  • OptionsTable.tsx, WpConfigEditor.tsx, HtaccessEditor.tsx, CronTable.tsx
  • DbHealthCard.tsx, DbQueryConsole.tsx, DbTablesBars.tsx

Editor library

Use @monaco-editor/react for wp-config + .htaccess + DB query SQL highlighting. Add to deps (~200 KB gzipped; acceptable for an admin tool).

Preview-first contract

Every mutation here writes to the LOCAL stack (DB or worktree file). They appear in pending-changes: - File edits (wp-config, .htaccess) → files category - DB changes (users, options, posts, cleanup) → new pending-changes category db (extend the existing pending response shape)

Extend /api/v1/sites/{slug}/pending-changes to surface db as a new category:

{
  "categories": {
    "files": {...},
    "quarantine": {...},
    "db": {
      "count": 12,
      "summary": "5 users modified · 4 options changed · 3 posts edited",
      "breakdown": {
        "users": {"added": 1, "modified": 2, "deleted": 0},
        "options": {"modified": 4},
        "posts": {"modified": 3, "deleted": 0}
      }
    }
  }
}

This requires tracking DB mutations made via WP-Pulse — log them in a new pending_db_changes table with rollback metadata (the before-image so revert can replay). Out-of-scope: untracked DB changes (operator runs wp-cli directly on prod).

Migration plan

Alembic 022: - pending_user_actions table - pending_db_changes table:

CREATE TABLE pending_db_changes (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
  kind text NOT NULL,             -- "option|user|post"
  op text NOT NULL,               -- "create|update|delete"
  target_id text,
  before jsonb,
  after jsonb,
  created_at timestamptz DEFAULT now(),
  applied_at timestamptz,
  apply_push_id text REFERENCES pushes_v2(push_id)
);
CREATE INDEX ON pending_db_changes (site_id, applied_at);

Apply-to-prod integration

apply-all-to-prod extends: - categories=["files","quarantine","db"]db category replays pending_db_changes via wp-cli on prod through the agent. - For each replay command: wp option update, wp user update, wp post update — wrap in agent's exec_wpcli kind. - On success, mark pending_db_changes.applied_at + apply_push_id. - On revert: replay before values, marking as a new pending change going the other way.

Rollout

Recommended phasing — single ADR but 3 sub-agents in parallel:

  • Sub-agent E: users + posts
  • Sub-agent F: wp-config + .htaccess + wp_options + cron
  • Sub-agent G: DB health + query + tables

All three need to coordinate on the pending_db_changes table — Sub-agent F & G create it via alembic 022 (one of them owns the migration; agree on F).

Open questions

  1. DB replay on apply: idempotent? If the operator changes an option twice in preview (X → Y → Z), we should ship only "X → Z", not 2 ops. Detect collapsing on apply.
  2. Login-as security: magic-link only valid on preview stack (verify hostname match). Out-of-scope: prod impersonation (requires customer-facing 2FA bypass — never).
  3. Posts editor depth: v1 plain textarea. Gutenberg block editor integration deferred.