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_auditto logwp db queryseparately if not already. - New optional table
pending_user_actionsfor queued ops like bulk password reset (deferred to apply-time): - 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}/usersbody{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-resetbody{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-configbody{content}→ lints + writes to worktree.GET /api/v1/sites/{slug}/htaccess→ text.PUT /api/v1/sites/{slug}/htaccessbody{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/cleanupbody{categories: [...]}→ runs cleanup on preview.POST /api/v1/sites/{slug}/db/querybody{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
SiteTabNavpattern (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.tsxPostsTable.tsx,PostEditDrawer.tsxOptionsTable.tsx,WpConfigEditor.tsx,HtaccessEditor.tsx,CronTable.tsxDbHealthCard.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¶
- 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.
- Login-as security: magic-link only valid on preview stack (verify hostname match). Out-of-scope: prod impersonation (requires customer-facing 2FA bypass — never).
- Posts editor depth: v1 plain textarea. Gutenberg block editor integration deferred.