feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats
Settings fixes: - MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings table (DB > ENV > default), so dashboard/settings changes apply without restart. Sync cache refreshed on every save or delete. - /api/me reports the live DB value for max_sites_per_user. Admin improvements: - Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS). - Admin Overview now shows platform stats: registered users, new users (7d), total user sites, available tools. Plugin cleanup: - Appwrite and Directus plugins removed from the active registry (8 plugins now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase, OpenPanel, Coolify). Plugin code is retained for future re-enabling. - Settings page plugin visibility list updated to match. Mobile onboarding: - Stepper steps on narrow viewports stack vertically with correct full border and rounded corners on each step. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
480
plugins/wordpress_specialist/handlers/site_layout.py
Normal file
480
plugins/wordpress_specialist/handlers/site_layout.py
Normal file
@@ -0,0 +1,480 @@
|
||||
"""F.19.6.B — Site layout surface (menus + widgets + customizer).
|
||||
|
||||
Closes the Settings → Menus, Appearance → Widgets, and Customizer gaps
|
||||
on ``wordpress_specialist``. Sits on the same ``settings`` tier as the
|
||||
F.19.6.A site config surface — same risk class, same dashboard preset,
|
||||
no new tier this round.
|
||||
|
||||
Surface map:
|
||||
|
||||
* **Menus** (3 tools, ``/admin/menus``):
|
||||
``wp_menu_list`` enumerates every nav menu with its theme-location
|
||||
bindings + item count. ``wp_menu_get`` reads one menu's items.
|
||||
``wp_menu_set`` does a full-replace write — items not in the array
|
||||
are deleted, new items are created, existing items (matched by id)
|
||||
are updated. Slug stays frozen so ``theme_location`` mapping survives.
|
||||
|
||||
* **Widgets** (3 tools, ``/admin/widgets/*``):
|
||||
``wp_widget_areas_list`` enumerates registered sidebar areas with
|
||||
their kind (``block`` or ``legacy``). ``wp_widget_get`` reads one
|
||||
area; block-kind areas return parsed block trees + raw HTML for
|
||||
roundtrip, legacy-kind areas return option-keyed settings.
|
||||
``wp_widget_set`` does a full-replace write — block areas accept any
|
||||
block raw HTML; legacy areas accept ``text`` widget settings only
|
||||
this round (other legacy types are read-only).
|
||||
|
||||
* **Customizer** (1 tool, ``/admin/customizer/changeset``):
|
||||
``wp_customizer_changeset`` wraps the customizer changeset queue with
|
||||
a single action enum (``get`` / ``apply`` / ``discard``). Lower
|
||||
priority — most modern themes use the FSE site editor instead of the
|
||||
customizer.
|
||||
|
||||
Security rules (extending S-1…S-21):
|
||||
|
||||
* **S-22** — Nav-menu items reference posts/terms via ``object_id``.
|
||||
Companion dispatches by item ``type``: ``post_type`` checks
|
||||
``read_post`` meta cap; ``taxonomy`` allows public taxonomies and
|
||||
otherwise requires the taxonomy's ``assign_terms`` cap (deliberately
|
||||
NOT ``manage_categories`` — that's a write cap and would refuse
|
||||
routine "add public Category X to footer" flows for editors who
|
||||
don't manage taxonomies); ``custom`` URL items skip the object check.
|
||||
Refusals surface as ``forbidden_object_id`` 403/404.
|
||||
* **S-23** — Widget HTML (block ``content`` and legacy ``text.text``)
|
||||
is sanitised via ``wp_kses_post`` unless the caller has
|
||||
``unfiltered_html``. Mirrors S-13 from F.19.5.
|
||||
* **S-24** — Customizer ``apply`` requires the caller to also hold
|
||||
``customize`` (the cap WP uses for ``/wp-admin/customize.php``).
|
||||
``manage_options`` alone is not enough — same as the WP UI. ``get``
|
||||
and ``discard`` only need ``manage_options``.
|
||||
|
||||
All tools require Airano MCP Bridge v2.17.0+.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from plugins.wordpress.client import WordPressClient
|
||||
|
||||
# Companion admin namespace — same prefix used by every F.19.* surface.
|
||||
_ADMIN_NS = "airano-mcp/v1/admin"
|
||||
|
||||
# Nav-menu item types WP recognises. Anything else is rejected client-side
|
||||
# as a cheap pre-check — the companion's S-22 dispatcher is the binding gate.
|
||||
_MENU_ITEM_TYPES = {"post_type", "taxonomy", "custom"}
|
||||
|
||||
# Customizer changeset actions.
|
||||
_CUSTOMIZER_ACTIONS = {"get", "apply", "discard"}
|
||||
|
||||
|
||||
def get_tool_specifications() -> list[dict[str, Any]]:
|
||||
"""Return tool specs for the F.19.6.B site layout surface."""
|
||||
return [
|
||||
# ───── Menus (3) ─────────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_menu_list",
|
||||
"method_name": "wp_menu_list",
|
||||
"description": (
|
||||
"List every WordPress nav-menu with id / name / slug, "
|
||||
"the theme locations bound to it, and the item count. "
|
||||
"Use to discover menu_id before calling wp_menu_get / "
|
||||
"wp_menu_set. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_menu_get",
|
||||
"method_name": "wp_menu_get",
|
||||
"description": (
|
||||
"Read a single nav-menu's items. Returns "
|
||||
"``{id, name, slug, items: [{id, title, type, object, "
|
||||
"object_id, parent, order, url, target, classes, xfn}]}``. "
|
||||
"``type`` is one of ``post_type`` / ``taxonomy`` / "
|
||||
"``custom``. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"menu_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Menu term id (from wp_menu_list).",
|
||||
},
|
||||
},
|
||||
"required": ["menu_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_menu_set",
|
||||
"method_name": "wp_menu_set",
|
||||
"description": (
|
||||
"Full-replace a nav-menu's items. Pass the complete "
|
||||
"items array — items not in the array are deleted, new "
|
||||
"items are created (omit ``id`` or set it to 0), "
|
||||
"existing items (matched by ``id``) are updated. Slug "
|
||||
"stays frozen so the theme_location mapping survives. "
|
||||
"Optional ``name`` renames the menu. S-22: each item's "
|
||||
"``object_id`` is validated against the caller's read "
|
||||
"permissions — ``post_type`` items require ``read_post`` "
|
||||
"on the target; non-public ``taxonomy`` items require "
|
||||
"the taxonomy's ``assign_terms`` cap; ``custom`` URL "
|
||||
"items skip the object check. Validation runs across "
|
||||
"every item before any mutation, so a refusal mid-array "
|
||||
"leaves the menu untouched. Requires Airano MCP Bridge "
|
||||
"v2.17.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"menu_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Menu term id to overwrite.",
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"description": "Full menu items array.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Existing item id to update; "
|
||||
"omit or 0 to create a new item."
|
||||
),
|
||||
},
|
||||
"title": {"type": "string"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["post_type", "taxonomy", "custom"],
|
||||
},
|
||||
"object": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"post_type slug for ``post_type`` "
|
||||
"items, taxonomy slug for "
|
||||
"``taxonomy`` items; ignored for "
|
||||
"``custom``."
|
||||
),
|
||||
},
|
||||
"object_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": (
|
||||
"Referenced post / term id; " "omit for ``custom`` items."
|
||||
),
|
||||
},
|
||||
"parent": {"type": "integer", "minimum": 0},
|
||||
"order": {"type": "integer", "minimum": 0},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"URL for ``custom`` items; "
|
||||
"ignored for post_type / taxonomy."
|
||||
),
|
||||
},
|
||||
"target": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional rename. Slug stays frozen.",
|
||||
},
|
||||
},
|
||||
"required": ["menu_id", "items"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Widgets (3) ───────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_widget_areas_list",
|
||||
"method_name": "wp_widget_areas_list",
|
||||
"description": (
|
||||
"List every registered sidebar / widget area with id, "
|
||||
"name, theme_location, widget_count, and kind "
|
||||
"(``block`` or ``legacy``). Block areas store widgets "
|
||||
"as block instances; legacy areas use per-widget option "
|
||||
"keys. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {"type": "object", "properties": {}},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_widget_get",
|
||||
"method_name": "wp_widget_get",
|
||||
"description": (
|
||||
"Read one widget area's contents. Returns "
|
||||
"``{area_id, kind, widgets: [...]}``. For ``block`` "
|
||||
"kind, each widget is "
|
||||
"``{id, type:'block', blocks:[...parsed], raw:'<!-- wp:... -->'}`` "
|
||||
"— ``raw`` is the round-trippable HTML; ``blocks`` is "
|
||||
"the parsed tree for inspection. For ``legacy`` kind, "
|
||||
"each widget is "
|
||||
"``{id, type, settings:{...}}`` keyed by the widget's "
|
||||
"option store. Requires Airano MCP Bridge v2.17.0+ and "
|
||||
"manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"area_id": {
|
||||
"type": "string",
|
||||
"description": "Sidebar id (from wp_widget_areas_list).",
|
||||
},
|
||||
},
|
||||
"required": ["area_id"],
|
||||
},
|
||||
"scope": "read",
|
||||
},
|
||||
{
|
||||
"name": "wp_widget_set",
|
||||
"method_name": "wp_widget_set",
|
||||
"description": (
|
||||
"Full-replace a widget area's contents. Block-kind "
|
||||
"areas accept any widget with ``raw`` (block HTML) or "
|
||||
"``blocks`` (block tree, server serialises). Legacy "
|
||||
"areas accept ``text`` widget settings only this round "
|
||||
"— other legacy widget types remain read-only. Caller-"
|
||||
"side ``kind`` is ignored: area kind is determined by "
|
||||
"the area itself; block↔legacy conversion is a theme-"
|
||||
"level decision, not an MCP one. S-23: HTML payloads "
|
||||
"are sanitised via ``wp_kses_post`` unless the caller "
|
||||
"has ``unfiltered_html``. Requires Airano MCP Bridge "
|
||||
"v2.17.0+ and manage_options."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"area_id": {"type": "string"},
|
||||
"widgets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"``block`` for block-kind " "areas; ``text`` for legacy."
|
||||
),
|
||||
},
|
||||
"raw": {
|
||||
"type": "string",
|
||||
"description": ("Block HTML (block kind only)."),
|
||||
},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"Parsed block tree (block kind "
|
||||
"only); used when ``raw`` is "
|
||||
"absent."
|
||||
),
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Widget settings (legacy kind). "
|
||||
"For ``text`` widgets: "
|
||||
"{title, text, filter?, visual?}."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["area_id", "widgets"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
# ───── Customizer (1) ────────────────────────────────────────
|
||||
{
|
||||
"name": "wp_customizer_changeset",
|
||||
"method_name": "wp_customizer_changeset",
|
||||
"description": (
|
||||
"Inspect or commit the pending customizer changeset. "
|
||||
"``action='get'`` returns the pending changeset payload "
|
||||
"(or ``{status:'empty'}`` when nothing is queued). "
|
||||
"``action='apply'`` publishes it; ``action='discard'`` "
|
||||
"trashes it. S-24: ``apply`` requires the caller to "
|
||||
"also hold the ``customize`` cap (same bar as "
|
||||
"``/wp-admin/customize.php``); ``manage_options`` alone "
|
||||
"is not enough. Apply is racy with concurrent edits "
|
||||
"via the customizer UI — this is intentional and "
|
||||
"mirrors WP's own behaviour. Requires Airano MCP "
|
||||
"Bridge v2.17.0+."
|
||||
),
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["get", "apply", "discard"],
|
||||
},
|
||||
},
|
||||
"required": ["action"],
|
||||
},
|
||||
"scope": "settings",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Client-side validation helpers
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _validate_post_id(value: Any, field: str) -> int:
|
||||
"""Validate a post / term id reference.
|
||||
|
||||
Sibling to ``site_config._validate_attachment_id`` — separate so the
|
||||
misnaming in F.19.6.A doesn't propagate. Both are non-negative-int
|
||||
pre-checks; the binding existence + capability gate lives server-side
|
||||
in the companion (S-22 dispatcher for menus, attachment lookup for
|
||||
site identity).
|
||||
"""
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError(f"{field} must be a non-negative integer (got {value!r})")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_menu_item(item: Any, idx: int) -> dict[str, Any]:
|
||||
"""Cheap structural pre-check for a nav-menu item.
|
||||
|
||||
The S-22 capability dispatch is server-side; this only catches
|
||||
obvious shape errors so the caller gets fast feedback without a
|
||||
round-trip. ``custom`` items skip the ``object_id`` check (URL is
|
||||
sanitised by the companion via ``esc_url_raw``).
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"items[{idx}] must be an object")
|
||||
item_type = item.get("type", "custom")
|
||||
if item_type not in _MENU_ITEM_TYPES:
|
||||
raise ValueError(
|
||||
f"items[{idx}].type must be one of {sorted(_MENU_ITEM_TYPES)} " f"(got {item_type!r})"
|
||||
)
|
||||
if item_type != "custom":
|
||||
# post_type + taxonomy require an object_id.
|
||||
object_id = item.get("object_id", 0)
|
||||
_validate_post_id(object_id, f"items[{idx}].object_id")
|
||||
if object_id == 0:
|
||||
raise ValueError(f"items[{idx}].object_id is required for {item_type} items")
|
||||
return item
|
||||
|
||||
|
||||
class SiteLayoutHandler:
|
||||
"""Site layout surface (F.19.6.B) — menus + widgets + customizer.
|
||||
|
||||
Each method returns the parsed JSON envelope from the companion.
|
||||
The plugin.py wrapper serialises for MCP transport. Server errors
|
||||
(403 forbidden_object_id, 400 invalid_action, etc.) are relayed
|
||||
untouched — the companion is the binding gate.
|
||||
"""
|
||||
|
||||
def __init__(self, client: WordPressClient) -> None:
|
||||
self.client = client
|
||||
|
||||
# ── Menus ───────────────────────────────────────────────────────
|
||||
|
||||
async def wp_menu_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/menus",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_menu_get(self, menu_id: int, **_: Any) -> dict[str, Any]:
|
||||
_validate_post_id(menu_id, "menu_id")
|
||||
if menu_id == 0:
|
||||
raise ValueError("menu_id is required (got 0)")
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/menus/{menu_id}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_menu_set(
|
||||
self,
|
||||
menu_id: int,
|
||||
items: list[dict[str, Any]],
|
||||
name: str | None = None,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
_validate_post_id(menu_id, "menu_id")
|
||||
if menu_id == 0:
|
||||
raise ValueError("menu_id is required (got 0)")
|
||||
if not isinstance(items, list):
|
||||
raise ValueError("items must be a list")
|
||||
for idx, item in enumerate(items):
|
||||
_validate_menu_item(item, idx)
|
||||
body: dict[str, Any] = {"items": items}
|
||||
if name is not None:
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("name must be a non-empty string")
|
||||
body["name"] = name
|
||||
return await self.client.put(
|
||||
f"{_ADMIN_NS}/menus/{menu_id}",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Widgets ─────────────────────────────────────────────────────
|
||||
|
||||
async def wp_widget_areas_list(self, **_: Any) -> dict[str, Any]:
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/widgets/areas",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_widget_get(self, area_id: str, **_: Any) -> dict[str, Any]:
|
||||
if not isinstance(area_id, str) or not area_id:
|
||||
raise ValueError("area_id must be a non-empty string")
|
||||
return await self.client.get(
|
||||
f"{_ADMIN_NS}/widgets/{area_id}",
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
async def wp_widget_set(
|
||||
self,
|
||||
area_id: str,
|
||||
widgets: list[dict[str, Any]],
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(area_id, str) or not area_id:
|
||||
raise ValueError("area_id must be a non-empty string")
|
||||
if not isinstance(widgets, list):
|
||||
raise ValueError("widgets must be a list")
|
||||
# Strip caller-side ``kind`` if present — area kind is determined
|
||||
# by the area itself, not the request. Block↔legacy conversion
|
||||
# is a theme-level decision.
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
for idx, w in enumerate(widgets):
|
||||
if not isinstance(w, dict):
|
||||
raise ValueError(f"widgets[{idx}] must be an object")
|
||||
cleaned.append({k: v for k, v in w.items() if k != "kind"})
|
||||
body = {"widgets": cleaned}
|
||||
return await self.client.put(
|
||||
f"{_ADMIN_NS}/widgets/{area_id}",
|
||||
json_data=body,
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
|
||||
# ── Customizer ──────────────────────────────────────────────────
|
||||
|
||||
async def wp_customizer_changeset(
|
||||
self,
|
||||
action: str,
|
||||
**_: Any,
|
||||
) -> dict[str, Any]:
|
||||
if action not in _CUSTOMIZER_ACTIONS:
|
||||
raise ValueError(
|
||||
f"action must be one of {sorted(_CUSTOMIZER_ACTIONS)} " f"(got {action!r})"
|
||||
)
|
||||
return await self.client.post(
|
||||
f"{_ADMIN_NS}/customizer/changeset",
|
||||
json_data={"action": action},
|
||||
use_custom_namespace=True,
|
||||
)
|
||||
Reference in New Issue
Block a user