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:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

View 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,
)