feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:25:58 +02:00
parent 788439e377
commit f203ca88de
140 changed files with 23802 additions and 2253 deletions

View File

@@ -6,6 +6,7 @@ and audit trail.
"""
import hashlib
import hmac
import json
import logging
import os
@@ -185,13 +186,60 @@ class APIKeyManager:
"""Hash API key for storage using bcrypt."""
return bcrypt.hashpw(api_key.encode(), bcrypt.gensalt()).decode()
@staticmethod
def _is_bcrypt_hash(key_hash: str) -> bool:
"""Return True if the stored hash is in bcrypt format ($2a/$2b/$2y)."""
return key_hash.startswith("$2")
def _verify_key(self, api_key: str, key_hash: str) -> bool:
"""Verify API key against stored hash (supports bcrypt and legacy SHA-256)."""
if key_hash.startswith("$2"):
# bcrypt hash
return bcrypt.checkpw(api_key.encode(), key_hash.encode())
# Legacy SHA-256 fallback
return hashlib.sha256(api_key.encode()).hexdigest() == key_hash
"""Verify API key against stored hash.
Supports both the current bcrypt format and a legacy SHA-256
fallback so pre-bcrypt keys keep working. F.8 security-
hardening note: SHA-256 is a fast hash with no per-hash salt —
a stolen keys.json file would enable offline brute-force on
any legacy entry. We therefore:
1. accept legacy hashes here (so customers aren't locked out), and
2. opportunistically upgrade every legacy hash to bcrypt the
first time it's successfully verified (see
:meth:`_upgrade_legacy_hash`).
Brand-new keys created via :meth:`create_key` are always
bcrypt-hashed — legacy SHA-256 only appears for rows that
existed before the F.4 / F.8 hardening passes.
"""
if self._is_bcrypt_hash(key_hash):
try:
return bcrypt.checkpw(api_key.encode(), key_hash.encode())
except ValueError:
# Truncated / corrupt bcrypt hash — treat as mismatch
# rather than raising.
return False
# Legacy SHA-256 fallback (constant-time compare to avoid
# timing oracles on the legacy-hash path).
expected = hashlib.sha256(api_key.encode()).hexdigest()
return hmac.compare_digest(expected, key_hash)
def _upgrade_legacy_hash(self, key: APIKey, api_key: str) -> bool:
"""Re-hash a legacy SHA-256 entry with bcrypt and persist.
Called from the verify paths the moment a legacy hash is
successfully matched, so the next verify uses bcrypt. Returns
True when an upgrade happened, False otherwise. Errors during
persist are logged but swallowed — the key still validates,
we just try again next time.
"""
if self._is_bcrypt_hash(key.key_hash):
return False
try:
key.key_hash = self._hash_key(api_key)
self._save_keys()
logger.info("Upgraded legacy SHA-256 key hash %s to bcrypt", key.key_id)
return True
except Exception as exc: # pragma: no cover — defensive
logger.warning("Failed to upgrade legacy hash for key %s: %s", key.key_id, exc)
return False
def create_key(
self,
@@ -283,6 +331,9 @@ class APIKeyManager:
for key_id, key in self.keys.items():
if not self._verify_key(api_key, key.key_hash):
continue
# F.8: opportunistically upgrade legacy SHA-256 hashes to bcrypt
# the moment they validate successfully.
self._upgrade_legacy_hash(key, api_key)
# Check if valid (not revoked, not expired)
if not key.is_valid():
@@ -351,6 +402,8 @@ class APIKeyManager:
"""
for key_id, key in self.keys.items():
if self._verify_key(api_key, key.key_hash):
# F.8: upgrade legacy SHA-256 hashes on first successful match.
self._upgrade_legacy_hash(key, api_key)
logger.debug(f"Found API key {key_id} by token")
return key

485
core/capability_probe.py Normal file
View File

@@ -0,0 +1,485 @@
"""F.7e — per-site credential capability probe.
Each plugin's ``probe_capabilities()`` (defined on ``BasePlugin``) knows
how to ask its upstream service what the saved credential can actually
do. This module wraps those calls with:
* Per-site in-memory TTL cache (default 10 min) so the probe doesn't
hammer the upstream service on every dashboard page view.
* A thin wrapper that decrypts the site's credentials, instantiates the
plugin, calls the probe, and normalises the result.
* ``/api/sites/{id}/capabilities`` Starlette handler that the dashboard
UI consumes (wired in ``core/dashboard/routes.py``).
The cache is process-local and not persisted. Workers start cold and
populate on demand; invalidation happens on cache expiry or explicit
site-credential update.
"""
from __future__ import annotations
import logging
import os
import time
from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse
logger = logging.getLogger("mcphub.capability_probe")
# Cache TTL in seconds (default 10 min). Upstream capability rarely
# changes — most drift happens when the operator rotates an
# app_password or changes a Gitea token's scopes, both of which are
# infrequent.
_CACHE_TTL_SECONDS = int(os.environ.get("CAPABILITY_PROBE_TTL", "600"))
class _ProbeCache:
"""Trivial in-memory ``site_id -> (expires_at, payload)`` cache."""
def __init__(self, ttl_seconds: int = _CACHE_TTL_SECONDS) -> None:
self._ttl = ttl_seconds
self._entries: dict[str, tuple[float, dict[str, Any]]] = {}
def get(self, site_id: str) -> dict[str, Any] | None:
entry = self._entries.get(site_id)
if entry is None:
return None
expires_at, payload = entry
if expires_at < time.time():
self._entries.pop(site_id, None)
return None
return payload
def set(self, site_id: str, payload: dict[str, Any]) -> None:
self._entries[site_id] = (time.time() + self._ttl, payload)
def invalidate(self, site_id: str) -> bool:
return self._entries.pop(site_id, None) is not None
_cache = _ProbeCache()
def get_probe_cache() -> _ProbeCache:
return _cache
# ---------------------------------------------------------------------------
# F.7e — tier-fit evaluation: compare probe.granted with what the site's
# selected ``tool_scope`` actually needs.
# ---------------------------------------------------------------------------
# Capability names required for each (plugin_type, tier) pair. Tiers come
# from ``core/tool_access.py``. For plugins whose probe doesn't map 1:1
# onto these names (e.g. Gitea scopes use ``read:repository`` rather than
# ``read``) the fit evaluator's ``_aliased_granted`` helper below
# normalises both sides before comparing.
TIER_REQUIREMENTS: dict[str, dict[str, set[str]]] = {
"wordpress": {
"read": {"read"},
"write": {"edit_posts", "upload_files"},
# WP-admin-tier tools need the same cap as WP's admin area itself.
"admin": {"manage_options"},
},
"woocommerce": {
"read": {"read_products"},
"write": {"write_products"},
"admin": {"write_products"},
},
"gitea": {
"read": {"read:repository"},
"write": {"write:repository"},
"admin": {"admin:repo_hook"},
},
}
# Aliases that plugins may return from their probe which should be
# treated as equivalent to the canonical cap names in TIER_REQUIREMENTS.
# Keeps the adapter implementations honest about what the upstream
# service actually says while letting the evaluator compare sets.
_CAP_ALIASES: dict[str, set[str]] = {
"manage_options": {"administrator", "manage_options"},
"edit_posts": {"edit_posts", "editor", "administrator"},
"upload_files": {"upload_files", "editor", "administrator"},
"read": {"read", "subscriber", "contributor", "author", "editor", "administrator"},
"read_products": {"read_products", "read", "read_write"},
"write_products": {"write_products", "write", "read_write"},
"read_orders": {"read_orders", "read", "read_write"},
"write_orders": {"write_orders", "write", "read_write"},
}
def _cap_matches(required: str, granted: set[str]) -> bool:
"""Return True if ``required`` is satisfied by any cap in ``granted``.
Uses ``_CAP_ALIASES`` to accept role names (WP) and permission
strings (WC) alongside the canonical capability names.
"""
if required in granted:
return True
aliases = _CAP_ALIASES.get(required, {required})
return bool(aliases & granted)
def evaluate_tier_fit(
plugin_type: str,
tier: str | None,
probe_payload: dict[str, Any],
) -> dict[str, Any]:
"""Decide whether ``probe.granted`` covers what ``tier`` requires.
Args:
plugin_type: e.g. "wordpress", "woocommerce", "gitea".
tier: the site's selected ``tool_scope`` preset (read / write /
admin / custom / None).
probe_payload: the dict returned by ``probe_site_capabilities``
(must contain ``probe_available`` and ``granted``).
Returns:
A dict with:
* ``status``: one of ``ok`` | ``warning`` | ``probe_unavailable``
| ``unknown_tier``.
* ``required``: list[str] of caps the tier needs (empty when
the tier is ``custom`` / not in the table).
* ``missing``: list[str] of required caps not present in
``granted``.
* ``reason``: passthrough from the probe when probe is
unavailable, else ``None``.
``custom`` tier always returns ``status='ok'`` because by definition
the caller picked individual tools — we can't check a tier-level
contract.
"""
tier_norm = (tier or "").strip().lower()
if not probe_payload.get("probe_available", False):
return {
"status": "probe_unavailable",
"required": [],
"missing": [],
"reason": probe_payload.get("reason"),
}
if tier_norm in {"", "custom"}:
return {
"status": "ok",
"required": [],
"missing": [],
"reason": None,
}
requirements = (TIER_REQUIREMENTS.get(plugin_type) or {}).get(tier_norm)
if requirements is None:
return {
"status": "unknown_tier",
"required": [],
"missing": [],
"reason": f"no_tier_table_for:{plugin_type}/{tier_norm}",
}
# F.X.fix #5: the probe places WP role names under ``roles`` and
# individual capability strings under ``granted`` — historically we
# only compared against ``granted``, so the ``read`` tier always
# reported ``warning: Missing read`` even for admin users, because
# ``read`` is implied by every role but not emitted as a bare cap
# in the companion's capability map. Union the two sets so the
# alias resolver in ``_cap_matches`` can see roles too.
granted = set(probe_payload.get("granted") or [])
roles = set(probe_payload.get("roles") or [])
effective = granted | roles
missing = sorted(cap for cap in requirements if not _cap_matches(cap, effective))
return {
"status": "warning" if missing else "ok",
"required": sorted(requirements),
"missing": missing,
"reason": None,
}
async def probe_site_capabilities(
site_id: str,
user_id: str,
*,
force: bool = False,
) -> dict[str, Any]:
"""Return the capability probe payload for a user-owned site.
Uses a 10-minute per-site TTL cache unless ``force=True``.
Response shape:
{
"site_id": str,
"plugin_type": str,
"probe_available": bool,
"granted": list[str],
"source": str,
"reason": str | None, # only when probe_available=False
"cached": bool,
# + any plugin-specific extras (roles, plugin_version, ...)
}
"""
from core.database import get_database
from core.encryption import get_credential_encryption
from plugins import registry as plugin_registry
db = get_database()
site = await db.get_site(site_id, user_id)
if site is None:
return {
"site_id": site_id,
"plugin_type": None,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": "site_not_found",
"cached": False,
}
if not force:
cached = _cache.get(site_id)
if cached is not None:
out = dict(cached)
out["cached"] = True
return out
plugin_type = site["plugin_type"]
if not plugin_registry.is_registered(plugin_type):
return {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"plugin_not_registered:{plugin_type}",
"cached": False,
}
try:
encryptor = get_credential_encryption()
credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
except Exception as exc: # noqa: BLE001
logger.warning("capability_probe: decrypt failed for site %s: %s", site_id, exc)
return {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": "credentials_decrypt_failed",
"cached": False,
}
config_dict: dict[str, Any] = {
"site_url": site["url"],
"url": site["url"],
"alias": site["alias"],
"user_id": user_id,
"site_id": site_id,
**credentials,
}
try:
instance = plugin_registry.create_instance(
plugin_type,
project_id=f"probe_{site_id}",
config=config_dict,
)
except Exception as exc: # noqa: BLE001
logger.warning("capability_probe: plugin instantiation failed for %s: %s", site_id, exc)
return {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"plugin_instantiation_failed: {exc}",
"cached": False,
}
try:
result = await instance.probe_credential_capabilities()
except Exception as exc: # noqa: BLE001
logger.warning("capability_probe: probe call raised for site %s: %s", site_id, exc)
result = {
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"probe_call_failed: {exc}",
}
payload: dict[str, Any] = {
"site_id": site_id,
"plugin_type": plugin_type,
"probe_available": bool(result.get("probe_available", False)),
"granted": list(result.get("granted") or []),
"source": result.get("source") or "unavailable",
}
if not payload["probe_available"]:
payload["reason"] = result.get("reason")
# F.X.fix #3: propagate install_hint so the dashboard can render
# the "site unreachable / install companion" prompt without a
# second probe call.
# F.X.fix-pass3: also propagate routes + features so the
# tool-prerequisites resolver in core/tool_access can compute
# tool availability without a second probe call.
# F.X.fix-pass5: also propagate wp_credentials_present so the
# prerequisites resolver can auto-disable WC media tools when
# the site has no WP Application Password configured.
for extra in (
"roles",
"plugin_version",
"install_hint",
"routes",
"features",
"wp_credentials_present",
):
if extra in result:
payload[extra] = result[extra]
# F.X.fix-pass2 — surface the site's configured AI-provider set so
# the badge can show a distinct "no AI provider key" warning
# independent of tier fit. Even an Administrator WP credential
# can't run the AI image tool without a provider key. Cheap: one
# SQLite query over site_provider_keys.
try:
from core.site_api import list_site_providers_set
payload["ai_providers_configured"] = sorted(await list_site_providers_set(site_id))
except Exception as exc: # noqa: BLE001
logger.debug("capability_probe: provider-set lookup skipped for %s: %s", site_id, exc)
payload["ai_providers_configured"] = []
# Cache everything, including the "probe unavailable" answer — a
# missing companion plugin is a stable fact until the operator
# installs it and re-tests the connection.
_cache.set(site_id, payload)
payload_out = dict(payload)
payload_out["cached"] = False
return payload_out
# ---------------------------------------------------------------------------
# Starlette handler: GET /api/sites/{id}/capabilities
# ---------------------------------------------------------------------------
async def api_site_capabilities(request: Request) -> JSONResponse:
"""Return the capability probe for a user-owned site.
Auth: same OAuth user session guard as the other site endpoints.
Query params:
* ``force=1`` — bypass the 10-minute cache.
"""
from core.dashboard.routes import _require_user_session
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
site_id = (request.path_params.get("id") or "").strip()
if not site_id:
return JSONResponse({"ok": False, "error": "invalid_request"}, status_code=400)
force = request.query_params.get("force") in {"1", "true", "True"}
payload = await probe_site_capabilities(
site_id=site_id, user_id=user_session["user_id"], force=force
)
if payload.get("reason") == "site_not_found":
return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
# F.7e: also evaluate fit against the site's currently-selected
# tool_scope tier so the UI can render the badge without needing a
# second request. The caller-supplied ?tier=... query param lets the
# dashboard preview a tier before save.
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_session["user_id"])
site_tier = (site or {}).get("tool_scope")
# Allow the caller to override the tier (e.g. preview before save).
tier_override = request.query_params.get("tier")
tier = tier_override or site_tier
fit = evaluate_tier_fit(
plugin_type=payload.get("plugin_type") or "",
tier=tier,
probe_payload=payload,
)
return JSONResponse(
{
"ok": True,
**payload,
"tier": tier,
"fit": fit,
}
)
async def api_site_capabilities_badge(request: Request):
"""F.X.fix #9 — render the capability-badge template fragment.
Used by the HTMX Re-check button so the badge swaps in place
instead of forcing a full-page reload. Response is HTML (not JSON)
— the caller sets ``hx-swap="outerHTML"`` on the badge element.
"""
from starlette.responses import HTMLResponse
from core.dashboard.routes import _require_user_session, templates
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return HTMLResponse("<div>unauthorized</div>", status_code=401)
site_id = (request.path_params.get("id") or "").strip()
if not site_id:
return HTMLResponse("<div>invalid_request</div>", status_code=400)
force = request.query_params.get("force") in {"1", "true", "True"}
payload = await probe_site_capabilities(
site_id=site_id, user_id=user_session["user_id"], force=force
)
if payload.get("reason") == "site_not_found":
return HTMLResponse("<div>site_not_found</div>", status_code=404)
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_session["user_id"])
if site is None:
return HTMLResponse("<div>site_not_found</div>", status_code=404)
tier = request.query_params.get("tier") or site.get("tool_scope")
fit = evaluate_tier_fit(
plugin_type=payload.get("plugin_type") or "",
tier=tier,
probe_payload=payload,
)
capability_probe = {**payload, "tier": tier, "fit": fit}
# Pull up the companion download URL the same way the page does.
try:
from plugins.wordpress.handlers._companion_hint import COMPANION_DOWNLOAD_URL
companion_download_url: str | None = COMPANION_DOWNLOAD_URL
except Exception: # noqa: BLE001
companion_download_url = None
lang = request.query_params.get("lang") or request.cookies.get("lang") or "en"
return templates.TemplateResponse(
request,
"dashboard/sites/_capability_badge.html",
{
"capability_probe": capability_probe,
"site": site,
"lang": lang,
"companion_download_url": companion_download_url,
},
)

325
core/companion_audit.py Normal file
View File

@@ -0,0 +1,325 @@
"""F.18.7 — Companion audit-hook receiver + per-site secret store.
The ``airano-mcp-bridge`` companion plugin (v2.7.0+) pushes WordPress
action events (post transitions, user events, plugin activations, etc.)
to MCPHub as HMAC-SHA256-signed webhooks. This module provides:
1. ``CompanionAuditSecretStore`` — file-backed map of ``site_url -> secret``.
2. ``verify_companion_signature`` — constant-time HMAC verification.
3. ``handle_companion_audit_request`` — Starlette handler that validates
the signature, appends the event to the audit log, and returns 200.
The store is intentionally independent of the SQLite sites DB so the
webhook can land before a dashboard UI exists; the UI (future work) can
replace ``CompanionAuditSecretStore`` with DB-backed storage without
changing the wire format.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from starlette.requests import Request
from starlette.responses import JSONResponse
logger = logging.getLogger("mcphub.companion_audit")
def _parse_timestamp(raw: Any) -> float | None:
"""Coerce the envelope ``timestamp`` field to epoch seconds.
Accepts either an ISO 8601 string (``2026-04-15T09:00:00Z`` — the
companion plugin's current format via PHP ``gmdate``) or a numeric
epoch value (for forward compatibility with other senders).
Returns None on any parse failure so callers can emit a uniform
401 without leaking which step failed.
"""
if raw is None:
return None
if isinstance(raw, int | float):
return float(raw)
if isinstance(raw, str):
s = raw.strip()
if not s:
return None
# Python 3.11+ fromisoformat handles "Z" suffix; on older
# versions we translate it to +00:00 explicitly.
iso = s.replace("Z", "+00:00") if s.endswith("Z") else s
try:
return datetime.fromisoformat(iso).timestamp()
except ValueError:
pass
# Numeric-string fallback (e.g. "1712345678").
try:
return float(s)
except ValueError:
return None
return None
# Pre-F.20 security sweep: bound incoming payload so a misconfigured
# (or malicious) companion can't queue up unbounded memory with a
# single request. Real audit events are well under 4 KB; 64 KB is a
# generous ceiling that still fits within Starlette's default limit.
_MAX_BODY_BYTES = int(os.environ.get("COMPANION_AUDIT_MAX_BODY", str(64 * 1024)))
# Replay-protection window (seconds). Events whose ``timestamp`` field
# falls outside ``now ± _REPLAY_WINDOW_SECONDS`` are rejected with 401.
# Set to 0 or negative to disable (not recommended). Default 5 minutes
# balances clock-skew tolerance against replay risk.
_REPLAY_WINDOW_SECONDS = int(os.environ.get("COMPANION_AUDIT_REPLAY_WINDOW", "300"))
def _normalise_url(url: str) -> str:
return url.rstrip("/").strip().lower()
class CompanionAuditSecretStore:
"""File-backed ``site_url -> shared_secret`` map.
The file is JSON; keys are normalised (lowercased, trailing slash
stripped). Access is serialised through a lock to keep reads and
writes atomic on a single-process server. Multi-process deployments
(gunicorn workers) can still race on the write path — that's fine
because the dashboard UI will be the only writer and operates in
the master process for this MVP.
"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
self._cache: dict[str, str] | None = None
def _load(self) -> dict[str, str]:
if self._cache is not None:
return self._cache
if not self.path.exists():
self._cache = {}
return self._cache
try:
data = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
logger.warning(
"companion audit secret file %s is not a JSON object; ignoring.",
self.path,
)
self._cache = {}
else:
self._cache = {_normalise_url(str(k)): str(v) for k, v in data.items() if v}
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Could not read companion audit secret file %s: %s", self.path, exc)
self._cache = {}
return self._cache
def _save(self, data: dict[str, str]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(self.path)
try:
os.chmod(self.path, 0o600)
except OSError:
pass
self._cache = dict(data)
def get(self, site_url: str) -> str | None:
with self._lock:
return self._load().get(_normalise_url(site_url))
def set(self, site_url: str, secret: str) -> None:
if not secret or len(secret) < 16:
raise ValueError("companion audit secret must be at least 16 characters")
with self._lock:
data = dict(self._load())
data[_normalise_url(site_url)] = secret
self._save(data)
def delete(self, site_url: str) -> bool:
with self._lock:
data = dict(self._load())
key = _normalise_url(site_url)
if key not in data:
return False
del data[key]
self._save(data)
return True
def list_sites(self) -> list[dict[str, Any]]:
"""List sites with secret metadata only — never returns plaintext."""
with self._lock:
return [
{
"site_url": site_url,
"secret_set": True,
"secret_last4": secret[-4:] if len(secret) >= 4 else "",
}
for site_url, secret in self._load().items()
]
_DEFAULT_STORE_PATH = Path(
os.environ.get(
"COMPANION_AUDIT_SECRETS_PATH",
"/tmp/mcphub-data/companion-audit-secrets.json",
)
)
_default_store: CompanionAuditSecretStore | None = None
def get_companion_audit_store(
path: str | Path | None = None,
) -> CompanionAuditSecretStore:
global _default_store
if path is not None:
return CompanionAuditSecretStore(path)
if _default_store is None:
_default_store = CompanionAuditSecretStore(_DEFAULT_STORE_PATH)
return _default_store
def verify_companion_signature(
body_bytes: bytes, signature_header: str | None, secret: str
) -> bool:
"""Constant-time HMAC-SHA256 verification.
Accepts the PHP side's ``sha256=HEX`` format and a bare-hex variant.
Returns False on any shape error so callers can emit 401 uniformly.
"""
if not signature_header or not secret:
return False
sig = signature_header.strip()
if sig.startswith("sha256="):
sig = sig[len("sha256=") :]
if not sig or not all(c in "0123456789abcdefABCDEF" for c in sig):
return False
expected = hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.lower(), sig.lower())
# Accepted event names — mirror the PHP side. Events outside this list
# are still accepted but tagged so operators can spot rogue sources.
_KNOWN_EVENTS = frozenset(
{
"transition_post_status",
"deleted_post",
"user_register",
"profile_update",
"deleted_user",
"activated_plugin",
"deactivated_plugin",
"switch_theme",
}
)
async def handle_companion_audit_request(request: Request) -> JSONResponse:
"""Starlette handler for ``POST /api/companion-audit``.
Validates the HMAC signature using a per-site secret, parses the
envelope, applies a replay window on the ``timestamp`` field, and
writes an audit-log entry. Returns 200 on success, 400 on shape
errors, 401 on signature/replay failure, 413 on oversized body.
"""
# Pre-F.20 security sweep: bound the incoming body before reading the
# whole thing. Prefer the framework's Content-Length hint; fall back
# to reading up to one byte past the ceiling so we can cleanly 413.
content_length_header = request.headers.get("content-length")
if content_length_header:
try:
content_length = int(content_length_header)
except ValueError:
return JSONResponse({"ok": False, "error": "invalid_length"}, status_code=400)
if content_length > _MAX_BODY_BYTES:
return JSONResponse(
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
status_code=413,
)
# Read raw body — we need the exact bytes for HMAC verification.
body_bytes = await request.body()
if len(body_bytes) > _MAX_BODY_BYTES:
return JSONResponse(
{"ok": False, "error": "body_too_large", "max_bytes": _MAX_BODY_BYTES},
status_code=413,
)
if not body_bytes:
return JSONResponse({"ok": False, "error": "empty_body"}, status_code=400)
site_header = request.headers.get("X-Airano-MCP-Site") or ""
signature = request.headers.get("X-Airano-MCP-Signature")
try:
envelope = json.loads(body_bytes.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
if not isinstance(envelope, dict):
return JSONResponse({"ok": False, "error": "invalid_envelope"}, status_code=400)
site_url = str(envelope.get("site_url") or site_header or "")
if not site_url:
return JSONResponse({"ok": False, "error": "missing_site"}, status_code=400)
store = get_companion_audit_store()
secret = store.get(site_url)
if secret is None:
# Don't leak whether the site exists; same response as a bad sig.
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
if not verify_companion_signature(body_bytes, signature, secret):
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
# Pre-F.20 security sweep: enforce a replay window on the signed
# timestamp. Captured webhooks replayed outside the window are
# rejected with 401 (same opaque response as a bad signature to
# avoid giving attackers a distinguishing oracle).
if _REPLAY_WINDOW_SECONDS > 0:
ts = _parse_timestamp(envelope.get("timestamp"))
if ts is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
now = time.time()
if abs(now - ts) > _REPLAY_WINDOW_SECONDS:
logger.warning(
"companion audit replay rejected site=%s skew=%.1fs",
site_url,
now - ts,
)
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
event_name = str(envelope.get("event") or "unknown")
details = {
"site_url": site_url,
"event": event_name,
"known_event": event_name in _KNOWN_EVENTS,
"timestamp": envelope.get("timestamp"),
"wp_user_id": envelope.get("user_id"),
"plugin_version": envelope.get("plugin_version"),
"data": envelope.get("data"),
}
try:
# Deferred import — avoid pulling audit_log into the store for tests.
from core.audit_log import get_audit_logger
audit_logger = get_audit_logger()
audit_logger.log_system_event(
event=f"companion_audit:{event_name}",
details=details,
)
except Exception as exc: # noqa: BLE001
logger.error("failed to append companion audit event: %s", exc)
return JSONResponse(
{"ok": False, "error": "audit_write_failed"},
status_code=500,
)
return JSONResponse({"ok": True, "event": event_name}, status_code=200)

View File

@@ -7,6 +7,7 @@ Phase K.1: Core Infrastructure
import logging
import os
from datetime import UTC, datetime
from typing import Any
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
@@ -2927,7 +2928,14 @@ async def dashboard_sites_view(request: Request) -> Response:
import json
from core.config_snippets import get_supported_clients
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
from core.site_api import (
SITE_PROVIDERS,
get_user_credential_fields,
get_user_plugin_names,
get_user_site,
list_site_providers_set,
site_supports_provider_keys,
)
from core.tool_access import get_scope_presets_for_plugin
site = await get_user_site(site_id, user_session["user_id"])
@@ -2946,6 +2954,97 @@ async def dashboard_sites_view(request: Request) -> Response:
plugin_names = get_user_plugin_names()
scope_presets = get_scope_presets_for_plugin(site["plugin_type"])
# F.5a.9.x: per-site AI provider keys — only surface the section on
# WordPress/WooCommerce sites (the only consumers of
# wordpress_generate_and_upload_image).
provider_keys_supported = site_supports_provider_keys(site["plugin_type"])
provider_key_rows: list[dict[str, Any]] = []
if provider_keys_supported:
configured = await list_site_providers_set(site_id)
# F.X.fix-pass3: pull each row's default_model so the UI can
# render a "Set default" pill that's pre-highlighted on the
# current selection. One query, all rows.
default_models: dict[str, str | None] = {}
try:
from core.database import get_database
db = get_database()
rows = await db.list_site_provider_keys(site_id)
for r in rows:
default_models[r["provider"]] = r.get("default_model")
except Exception as exc: # noqa: BLE001
logger.debug("default_model lookup skipped site=%s: %s", site_id, exc)
_provider_labels = {
"openai": "OpenAI",
"stability": "Stability AI",
"replicate": "Replicate",
"openrouter": "OpenRouter (Gemini / multi-model)",
}
for provider in SITE_PROVIDERS:
provider_key_rows.append(
{
"provider": provider,
"label": _provider_labels.get(provider, provider.title()),
"status": "set" if provider in configured else "unset",
"default_model": default_models.get(provider),
}
)
# F.20 prep: companion-plugin download hint, shown on WP/WC site
# pages only. See dashboard_service_page for the matching hint on
# the services catalogue.
companion_download_url = None
if site["plugin_type"] in {"wordpress", "woocommerce"}:
companion_download_url = (
"https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
)
# F.7e: run the capability probe server-side so the manage page can
# render the badge on first paint without a second round-trip. Any
# failure falls through to "probe unavailable" so the page always
# loads. The frontend Re-check button hits the same endpoint with
# force=1 to bypass the 10-min cache.
capability_probe = None
try:
from core.capability_probe import evaluate_tier_fit, probe_site_capabilities
probe_payload = await probe_site_capabilities(
site_id=site_id, user_id=user_session["user_id"]
)
fit = evaluate_tier_fit(
plugin_type=site["plugin_type"],
tier=site.get("tool_scope"),
probe_payload=probe_payload,
)
capability_probe = {**probe_payload, "fit": fit}
except Exception as exc: # noqa: BLE001
logger.warning("capability probe render failed for %s: %s", site_id, exc)
capability_probe = {
"probe_available": False,
"granted": [],
"source": "unavailable",
"reason": f"render_error: {exc}",
"fit": {"status": "probe_unavailable", "required": [], "missing": []},
}
# F.X.fix-pass5 — surface which credential fields are *currently
# stored* (not the values themselves) so the form can show a
# "✓ Stored" badge and require an explicit "Clear" action to
# remove an existing value, instead of silently wiping it on a
# blank-input save.
cred_states: dict[str, bool] = {}
try:
from core.encryption import get_credential_encryption
encryptor = get_credential_encryption()
decrypted = encryptor.decrypt_credentials(site["credentials"], site_id)
for field in plugin_fields.get(site["plugin_type"], []):
value = decrypted.get(field["name"])
cred_states[field["name"]] = bool(value and str(value).strip())
except Exception as exc: # noqa: BLE001
logger.debug("cred_states lookup failed for site %s: %s", site_id, exc)
cred_states = {}
return templates.TemplateResponse(
request,
"dashboard/sites/manage.html",
@@ -2962,6 +3061,12 @@ async def dashboard_sites_view(request: Request) -> Response:
"mcp_url": mcp_url,
"clients": get_supported_clients(),
"current_page": "my_sites",
"provider_keys_supported": provider_keys_supported,
"provider_key_rows": provider_key_rows,
"companion_download_url": companion_download_url,
"capability_probe": capability_probe,
"cred_states": cred_states,
"cred_states_json": json.dumps(cred_states),
},
)
@@ -3337,15 +3442,24 @@ async def api_list_site_tools(request: Request) -> Response:
return err
assert site is not None
from core.site_api import list_site_providers_set
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.list_tools_for_site(site["id"], site["plugin_type"])
# F.X.fix #8: expose the site's configured AI-provider set so the
# Tool Access template can render "Configure key for X" CTAs
# without issuing a second /api/sites/{id}/provider-keys call.
try:
configured_providers = sorted(await list_site_providers_set(site["id"]))
except Exception: # noqa: BLE001
configured_providers = []
return JSONResponse(
{
"site_id": site["id"],
"plugin_type": site["plugin_type"],
"tool_scope": site.get("tool_scope", "admin"),
"configured_providers": configured_providers,
"tools": tools,
}
)
@@ -3729,6 +3843,15 @@ async def dashboard_service_page(request: Request) -> Response:
lang = detect_language(accept_language, query_lang)
t = get_translations(lang)
# F.20 prep: dashboard UX hint — surface the companion-plugin download
# link on the WP / WC service pages. The link switches from the GitHub
# raw-download URL to wp.org once the plugin ships (see F.20 scope).
companion_download_url = None
if plugin_type in {"wordpress", "woocommerce"}:
companion_download_url = (
"https://github.com/airano-ir/mcphub/raw/main/" "wordpress-plugin/airano-mcp-bridge.zip"
)
return templates.TemplateResponse(
request,
"dashboard/service.html",
@@ -3740,10 +3863,206 @@ async def dashboard_service_page(request: Request) -> Response:
"display_info": display_info,
"current_page": "services",
"service": data,
"companion_download_url": companion_download_url,
},
)
# ======================================================================
# F.18.8 — Provider Keys Dashboard UI (REMOVED in F.5a.9.x)
# ======================================================================
#
# Per-user AI provider keys and the /dashboard/provider-keys page have
# been replaced by per-site keys defined in each WordPress / WooCommerce
# site's Connection Settings. The site-scoped API is the F.5a.9.x block
# below. The user_provider_keys table is dropped in schema migration v12.
#
# Deleted helpers / handlers:
# _PROVIDER_LABELS, _build_provider_rows,
# dashboard_provider_keys_page,
# api_user_provider_keys_set, api_user_provider_keys_delete.
# ======================================================================
# F.5a.9.x — Per-site AI provider key endpoints
# ======================================================================
async def api_site_provider_keys_list(request: Request) -> Response:
"""GET /api/sites/{id}/provider-keys — list providers with a key stored.
Returns ``{"providers": ["openai", ...]}``. Does not leak ciphertext
or plaintext.
"""
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
site_id = (request.path_params.get("id") or "").strip()
from core.site_api import get_user_site, list_site_providers_set, site_supports_provider_keys
site = await get_user_site(site_id, user_session["user_id"])
if site is None:
return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
if not site_supports_provider_keys(site["plugin_type"]):
return JSONResponse({"ok": False, "error": "plugin_unsupported"}, status_code=400)
providers = sorted(await list_site_providers_set(site_id))
return JSONResponse({"ok": True, "providers": providers})
async def api_site_provider_keys_set(request: Request) -> Response:
"""POST /api/sites/{id}/provider-keys/{provider} — upsert a per-site key.
Body: ``{"api_key": "..."}``. Enforces site ownership, plugin-type gate,
and provider allow-list (site_api.set_site_provider_key).
"""
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
site_id = (request.path_params.get("id") or "").strip()
provider = (request.path_params.get("provider") or "").strip().lower()
try:
body = await request.json()
except Exception:
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
if not isinstance(body, dict):
return JSONResponse({"ok": False, "error": "invalid_body"}, status_code=400)
api_key = str(body.get("api_key") or "").strip()
if len(api_key) < 8:
return JSONResponse(
{
"ok": False,
"error": "key_too_short",
"message": "api_key must be at least 8 characters",
},
status_code=400,
)
from core.site_api import set_site_provider_key
try:
await set_site_provider_key(site_id, user_session["user_id"], provider, api_key)
except ValueError as exc:
return JSONResponse(
{"ok": False, "error": "invalid_request", "message": str(exc)},
status_code=400,
)
except Exception as exc:
logger.error(
"site_provider_keys set failed user=%s site=%s provider=%s: %s",
user_session["user_id"],
site_id,
provider,
exc,
)
return JSONResponse(
{"ok": False, "error": "storage_failed", "message": str(exc)},
status_code=500,
)
return JSONResponse(
{
"ok": True,
"site_id": site_id,
"provider": provider,
"secret_last4": api_key[-4:],
}
)
async def api_site_provider_keys_delete(request: Request) -> Response:
"""DELETE /api/sites/{id}/provider-keys/{provider} — remove a per-site key."""
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
site_id = (request.path_params.get("id") or "").strip()
provider = (request.path_params.get("provider") or "").strip().lower()
from core.site_api import delete_site_provider_key
try:
deleted = await delete_site_provider_key(site_id, user_session["user_id"], provider)
except Exception as exc:
logger.error(
"site_provider_keys delete failed user=%s site=%s provider=%s: %s",
user_session["user_id"],
site_id,
provider,
exc,
)
return JSONResponse(
{"ok": False, "error": "storage_failed", "message": str(exc)},
status_code=500,
)
return JSONResponse({"ok": True, "site_id": site_id, "provider": provider, "deleted": deleted})
async def api_site_provider_default_model(request: Request) -> Response:
"""PATCH /api/sites/{id}/provider-keys/{provider}/default-model.
Body: ``{"model": "<id>"}`` to set, ``{"model": null}`` (or empty
string) to clear. F.X.fix-pass3 — lets the user pin a discovered
OpenRouter image-model id as the implicit default for a site, so
MCP callers don't have to pass ``model=`` every time.
"""
user_session, redirect = _require_user_session(request)
if redirect or user_session is None:
return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
site_id = (request.path_params.get("id") or "").strip()
provider = (request.path_params.get("provider") or "").strip().lower()
try:
body = await request.json()
except Exception:
return JSONResponse({"ok": False, "error": "invalid_json"}, status_code=400)
if not isinstance(body, dict):
return JSONResponse({"ok": False, "error": "invalid_body"}, status_code=400)
raw = body.get("model")
model: str | None
if raw is None:
model = None
elif isinstance(raw, str):
stripped = raw.strip()
model = stripped or None
else:
return JSONResponse({"ok": False, "error": "invalid_model"}, status_code=400)
from core.site_api import get_user_site
site = await get_user_site(site_id, user_session["user_id"])
if site is None:
return JSONResponse({"ok": False, "error": "site_not_found"}, status_code=404)
from core.database import get_database
db = get_database()
updated = await db.set_site_provider_default_model(site_id, provider, model)
if not updated:
return JSONResponse(
{
"ok": False,
"error": "provider_key_not_found",
"message": "Save the provider key first, then set the default model.",
},
status_code=404,
)
return JSONResponse(
{
"ok": True,
"site_id": site_id,
"provider": provider,
"default_model": model,
}
)
def register_dashboard_routes(mcp):
"""
Register dashboard routes with the MCP server.
@@ -3848,6 +4167,15 @@ def register_dashboard_routes(mcp):
mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site)
mcp.custom_route("/api/sites/{id}", methods=["PATCH"])(api_update_site)
# Per-site AI provider keys (F.5a.9.x)
mcp.custom_route("/api/sites/{id}/provider-keys", methods=["GET"])(api_site_provider_keys_list)
mcp.custom_route("/api/sites/{id}/provider-keys/{provider}", methods=["POST"])(
api_site_provider_keys_set
)
mcp.custom_route("/api/sites/{id}/provider-keys/{provider}", methods=["DELETE"])(
api_site_provider_keys_delete
)
# User API Key routes (E.3)
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)
mcp.custom_route("/api/keys", methods=["POST"])(api_create_key)
@@ -3867,4 +4195,11 @@ def register_dashboard_routes(mcp):
dashboard_user_oauth_clients_delete
)
# F.18.8 /dashboard/provider-keys routes removed in F.5a.9.x —
# per-site AI provider keys replace the per-user page. See the
# per-site endpoints registered above:
# GET /api/sites/{id}/provider-keys
# POST /api/sites/{id}/provider-keys/{provider}
# DELETE /api/sites/{id}/provider-keys/{provider}
logger.info("Dashboard routes registered successfully")

View File

@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations
SCHEMA_VERSION = 8
SCHEMA_VERSION = 13
# Initial schema DDL
_SCHEMA_SQL = """\
@@ -114,6 +114,48 @@ CREATE TABLE IF NOT EXISTS site_tool_toggles (
UNIQUE(site_id, tool_name)
);
CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site ON site_tool_toggles(site_id);
-- F.5a.4 user_provider_keys table removed in F.5a.9.x / schema v12 —
-- replaced by site_provider_keys (defined below) which stores AI provider
-- credentials per-site instead of per-user.
-- F.5a.5: chunked-upload sessions (SQLite metadata + disk spill)
CREATE TABLE IF NOT EXISTS upload_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
filename TEXT NOT NULL,
total_bytes INTEGER NOT NULL,
mime TEXT,
sha256 TEXT,
received_bytes INTEGER NOT NULL DEFAULT 0,
next_chunk INTEGER NOT NULL DEFAULT 0,
spill_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status
ON upload_sessions(user_id, status);
CREATE INDEX IF NOT EXISTS idx_upload_sessions_expires
ON upload_sessions(expires_at);
-- F.5a.9.x: per-site AI provider keys (AES-GCM encrypted, reversible).
-- Replaces the per-user user_provider_keys table with a per-site model so
-- each WordPress/WooCommerce site carries its own OpenAI/Stability/Replicate
-- credential in its Connection Settings. Encryption scope:
-- site_provider:{site_id}:{provider}
CREATE TABLE IF NOT EXISTS site_provider_keys (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
key_ciphertext BLOB NOT NULL,
created_at TEXT NOT NULL,
last_used TEXT,
default_model TEXT,
UNIQUE(site_id, provider)
);
CREATE INDEX IF NOT EXISTS idx_site_provider_keys_site
ON site_provider_keys(site_id);
"""
# Migration registry: version -> SQL string
@@ -170,6 +212,75 @@ _MIGRATIONS: dict[int, str] = {
# F.7c: per-site API keys — allow keys scoped to a single site
"ALTER TABLE user_api_keys ADD COLUMN site_id TEXT;\n"
),
9: (
# F.5a.4: per-user AI provider keys (AES-GCM encrypted, reversible —
# distinct from the bcrypt-hashed user_api_keys which authenticate MCP
# clients and cannot be used to call outbound provider APIs).
"CREATE TABLE IF NOT EXISTS user_provider_keys (\n"
" id TEXT PRIMARY KEY,\n"
" user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n"
" provider TEXT NOT NULL,\n"
" key_ciphertext BLOB NOT NULL,\n"
" created_at TEXT NOT NULL,\n"
" last_used TEXT,\n"
" UNIQUE(user_id, provider)\n"
");\n"
"CREATE INDEX IF NOT EXISTS idx_user_provider_keys_user "
"ON user_provider_keys(user_id);\n"
),
10: (
# F.5a.5: chunked upload sessions (SQLite metadata + disk spill)
"CREATE TABLE IF NOT EXISTS upload_sessions (\n"
" id TEXT PRIMARY KEY,\n"
" user_id TEXT NOT NULL,\n"
" filename TEXT NOT NULL,\n"
" total_bytes INTEGER NOT NULL,\n"
" mime TEXT,\n"
" sha256 TEXT,\n"
" received_bytes INTEGER NOT NULL DEFAULT 0,\n"
" next_chunk INTEGER NOT NULL DEFAULT 0,\n"
" spill_path TEXT NOT NULL,\n"
" status TEXT NOT NULL DEFAULT 'open',\n"
" created_at TEXT NOT NULL,\n"
" expires_at TEXT NOT NULL\n"
");\n"
"CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status "
"ON upload_sessions(user_id, status);\n"
"CREATE INDEX IF NOT EXISTS idx_upload_sessions_expires "
"ON upload_sessions(expires_at);\n"
),
11: (
# F.5a.9.x step 1: add per-site provider keys table. The legacy
# per-user user_provider_keys table is dropped in migration 12
# (after the code paths that read it are removed).
"CREATE TABLE IF NOT EXISTS site_provider_keys (\n"
" id TEXT PRIMARY KEY,\n"
" site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n"
" provider TEXT NOT NULL,\n"
" key_ciphertext BLOB NOT NULL,\n"
" created_at TEXT NOT NULL,\n"
" last_used TEXT,\n"
" default_model TEXT,\n"
" UNIQUE(site_id, provider)\n"
");\n"
"CREATE INDEX IF NOT EXISTS idx_site_provider_keys_site "
"ON site_provider_keys(site_id);\n"
),
12: (
# F.5a.9.x step 2: drop the legacy per-user provider-keys store
# now that the resolver, dashboard page, and HTTP endpoints have
# been removed. Confirmed with operator that no users had keys
# stored on the live instance, so no data migration is needed.
"DROP INDEX IF EXISTS idx_user_provider_keys_user;\n"
"DROP TABLE IF EXISTS user_provider_keys;\n"
),
13: (
# F.X.fix-pass3: per-site default image model. Lets the user
# pick a discovered OpenRouter model (e.g. google/gemini-2.5-
# flash-image) as the implicit default for that site, so MCP
# callers don't have to pass `model=...` every time.
"ALTER TABLE site_provider_keys ADD COLUMN default_model TEXT;\n"
),
}
@@ -880,6 +991,104 @@ class Database:
return "admin"
return row["tool_scope"] or "admin"
# ------------------------------------------------------------------
# Site provider keys (F.5a.9.x) — per-site AI provider credentials
# (replaces the F.5a.4 per-user ``*_provider_key`` helpers dropped in v12)
# ------------------------------------------------------------------
async def upsert_site_provider_key(
self,
site_id: str,
provider: str,
key_ciphertext: bytes,
) -> dict[str, Any]:
"""Insert or replace a site's API key for a given AI provider.
Args:
site_id: Site UUID.
provider: Provider identifier (``openai``, ``stability``, ``replicate``).
key_ciphertext: AES-256-GCM encrypted key bytes.
Returns:
The stored row (without plaintext).
"""
key_id = str(uuid.uuid4())
now = _utc_now()
await self.execute(
"INSERT INTO site_provider_keys (id, site_id, provider, key_ciphertext, created_at) "
"VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(site_id, provider) DO UPDATE SET "
"key_ciphertext = excluded.key_ciphertext, created_at = excluded.created_at",
(key_id, site_id, provider, key_ciphertext, now),
)
row = await self.fetchone(
"SELECT id, site_id, provider, created_at, last_used FROM site_provider_keys "
"WHERE site_id = ? AND provider = ?",
(site_id, provider),
)
if row is None:
raise RuntimeError(f"Failed to read back site provider key for {site_id}/{provider}")
return row
async def get_site_provider_key(self, site_id: str, provider: str) -> dict[str, Any] | None:
"""Return the encrypted provider key row for a site (includes ciphertext)."""
return await self.fetchone(
"SELECT * FROM site_provider_keys WHERE site_id = ? AND provider = ?",
(site_id, provider),
)
async def list_site_provider_keys(self, site_id: str) -> list[dict[str, Any]]:
"""List providers a site has keys for (excluding ciphertext)."""
return await self.fetchall(
"SELECT id, site_id, provider, created_at, last_used, default_model "
"FROM site_provider_keys WHERE site_id = ? ORDER BY provider",
(site_id,),
)
async def delete_site_provider_key(self, site_id: str, provider: str) -> bool:
"""Remove a site's provider key. Returns True if a row was deleted."""
cursor = await self.execute(
"DELETE FROM site_provider_keys WHERE site_id = ? AND provider = ?",
(site_id, provider),
)
return cursor.rowcount > 0
async def touch_site_provider_key(self, site_id: str, provider: str) -> None:
"""Update last_used timestamp after a successful provider call."""
await self.execute(
"UPDATE site_provider_keys SET last_used = ? WHERE site_id = ? AND provider = ?",
(_utc_now(), site_id, provider),
)
async def set_site_provider_default_model(
self, site_id: str, provider: str, model: str | None
) -> bool:
"""F.X.fix-pass3 — record the per-site default model for a provider.
``model=None`` clears the default. Returns ``True`` when a row
was actually updated (i.e. the site has a key for the provider);
callers can surface a 404 to the user when the row doesn't exist
rather than silently writing nothing.
"""
cursor = await self.execute(
"UPDATE site_provider_keys SET default_model = ? " "WHERE site_id = ? AND provider = ?",
(model, site_id, provider),
)
return cursor.rowcount > 0
async def get_site_provider_default_model(self, site_id: str, provider: str) -> str | None:
"""Return the default model id for a site's provider, or None."""
row = await self.fetchone(
"SELECT default_model FROM site_provider_keys " "WHERE site_id = ? AND provider = ?",
(site_id, provider),
)
if row is None:
return None
value = row.get("default_model")
if isinstance(value, str) and value:
return value
return None
async def set_site_tool_scope(self, site_id: str, scope: str) -> None:
"""Update the ``tool_scope`` preset for a site.

View File

@@ -178,6 +178,35 @@ class CredentialEncryption:
json_str = self.decrypt(cipherdata, site_id)
return json.loads(json_str)
def encrypt_for_scope(self, plaintext: str, scope: str) -> bytes:
"""Encrypt a plaintext string using an arbitrary HKDF scope string.
Used when the encrypted value is not a per-site credentials blob but
still needs per-key isolation (e.g. per-site AI provider API keys
where scope is ``site_provider:{site_id}:{provider}``).
Args:
plaintext: The string to encrypt.
scope: Scope string used as HKDF info for key derivation. Any
caller reading back the ciphertext must pass the same scope.
Returns:
Encrypted bytes (same wire format as :meth:`encrypt`).
"""
return self.encrypt(plaintext, scope)
def decrypt_for_scope(self, cipherdata: bytes, scope: str) -> str:
"""Decrypt cipherdata produced by :meth:`encrypt_for_scope`.
Args:
cipherdata: Encrypted bytes.
scope: Scope string — must exactly match what was used to encrypt.
Returns:
Original plaintext string.
"""
return self.decrypt(cipherdata, scope)
# Global credential encryption instance
_credential_encryption: CredentialEncryption | None = None

67
core/media_audit.py Normal file
View File

@@ -0,0 +1,67 @@
"""F.5a.6.4 — Audit-log emission for media uploads.
One ``media.upload`` entry is written per **successful** upload regardless
of source (base64 / url / chunked / ai:<provider>). Failures are intentionally
NOT logged here — they surface to the caller as typed ``UploadError`` JSON
and to the dashboard via the existing tool-call audit emitted by the
ToolRouter wrapper. Logging failures twice would double-count error rates.
GDPR: never log raw bytes, base64, prompts, or URLs that may carry tokens.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def log_media_upload(
*,
site: str | None,
user_id: str | None,
mime: str | None,
size_bytes: int,
source: str,
media_id: int | None,
cost_usd: float | None = None,
) -> None:
"""Emit a single ``media.upload`` audit entry. Best-effort — never raises.
Args:
site: Site URL or alias (whichever the handler has on hand).
user_id: Calling user id (None for admin / env-fallback).
mime: Sniffed MIME type after security validation.
size_bytes: Final uploaded byte count (post-optimization).
source: One of ``"base64"``, ``"url"``, ``"chunked"``, ``"ai:<provider>"``.
media_id: WordPress media library id of the resulting attachment.
cost_usd: Provider cost in USD for AI-generated uploads only.
"""
try:
from core.audit_log import get_audit_logger
audit = get_audit_logger()
except Exception: # noqa: BLE001
return
params: dict[str, Any] = {
"site": site,
"mime": mime,
"size_bytes": int(size_bytes),
"source": source,
"media_id": media_id,
}
if cost_usd is not None:
params["cost_usd"] = round(float(cost_usd), 6)
try:
audit.log_tool_call(
tool_name="media.upload",
site=site,
params=params,
result_summary=(f"{source} {size_bytes}B {mime or '?'} -> media_id={media_id}"),
user_id=user_id,
)
except Exception: # noqa: BLE001
logger.debug("media.upload audit emit failed", exc_info=True)

91
core/media_error_codes.py Normal file
View File

@@ -0,0 +1,91 @@
"""F.5a.6.2 — Stable error-code taxonomy for media upload tools.
Every :class:`plugins.wordpress.handlers._media_security.UploadError` and
:class:`core.upload_sessions.UploadSessionError` raised inside the media
stack MUST use one of the codes listed here (or match the dynamic
``WP_<status>`` pattern for upstream WordPress REST responses).
The accompanying test ``tests/plugins/wordpress/test_media_error_taxonomy.py``
scans the source files for raise-site literals and asserts each one is in
this set — so the contract stays stable as the code evolves.
The full reference is in ``docs/media-error-codes.md``.
"""
from __future__ import annotations
import re
#: All stable upload/media error codes. Keep alphabetical within groups.
MEDIA_ERROR_CODES: frozenset[str] = frozenset(
{
# --- Input / validation ------------------------------------------
"BAD_BASE64",
"BAD_MODE",
"BAD_ROLE",
"BAD_SIZE",
"BAD_SOURCE",
"EMPTY_FILE",
"MEDIA_NOT_FOUND",
"MIME_REJECTED",
"MISSING_FIELD",
"SSRF",
"TOO_LARGE",
"URL_FETCH_FAILED",
# --- WordPress REST upstream -------------------------------------
"WP_413",
"WP_AUTH",
"WP_BAD_RESPONSE",
# F.X.fix-pass4 — WC sites with consumer_key/consumer_secret
# auth need a separate WP Application Password to upload to
# /wp/v2/media. Surfaced by media_attach.py when the user
# hasn't filled wp_username/wp_app_password in Connection
# Settings.
"WP_CREDENTIALS_MISSING",
# WP_<status> (e.g. WP_500) is also allowed — see MEDIA_ERROR_CODE_RE
# --- Companion plugin upload-chunk route (F.5a.7) ----------------
"COMPANION_BAD_RESPONSE",
# COMPANION_<status> (e.g. COMPANION_500) is also allowed — same
# shape as WP_<status>, see _COMPANION_STATUS_RE.
# --- Chunked upload session --------------------------------------
"BAD_STATE",
"CHECKSUM_MISMATCH",
"CHUNK_CHECKSUM",
"CHUNK_ORDER",
"CHUNK_OVERFLOW",
"EXPIRED",
"INCOMPLETE",
"NO_SESSION",
"QUOTA_EXCEEDED",
"SESSION_TOO_LARGE",
# --- AI generation providers -------------------------------------
"GENERATION_FAILED",
"NO_PROVIDER_KEY",
"PROVIDER_AUTH",
"PROVIDER_BAD_REQUEST",
"PROVIDER_BAD_RESPONSE",
"PROVIDER_QUOTA",
"PROVIDER_TIMEOUT",
"PROVIDER_UNAVAILABLE",
"PROVIDER_UNKNOWN",
# --- Rate / policy -----------------------------------------------
"TOOL_RATE_LIMITED",
# --- Catchall ----------------------------------------------------
"INTERNAL",
}
)
#: Dynamic WP REST status codes (e.g. WP_400, WP_500).
_WP_STATUS_RE = re.compile(r"^WP_\d{3}$")
#: Dynamic companion upload-chunk status codes (e.g. COMPANION_400, COMPANION_500).
_COMPANION_STATUS_RE = re.compile(r"^COMPANION_\d{3}$")
def is_valid_code(code: str) -> bool:
"""Return True if ``code`` is a documented media error code."""
return (
code in MEDIA_ERROR_CODES
or bool(_WP_STATUS_RE.match(code))
or bool(_COMPANION_STATUS_RE.match(code))
)

View File

@@ -35,14 +35,18 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Username",
"type": "text",
"required": True,
"hint": "Your WordPress admin username",
"hint": "Your WordPress admin username (used as the HTTP Basic username for every API call).",
},
{
"name": "app_password",
"label": "Application Password",
"type": "password",
"required": True,
"hint": "WordPress Admin → Users → Profile → Application Passwords",
"hint": (
"WordPress Admin → Users → Profile → Application Passwords. "
"This IS the API credential — no separate API key is needed. "
"Paste the value WP shows once after creation (spaces can stay or be removed)."
),
},
],
"woocommerce": [
@@ -51,14 +55,53 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Consumer Key",
"type": "text",
"required": True,
"hint": "WooCommerce → Settings → Advanced → REST API",
"hint": (
"WooCommerce → Settings → Advanced → REST API → Add Key. "
"Read/Write permission. This pair IS the API auth — no extra API key field exists."
),
},
{
"name": "consumer_secret",
"label": "Consumer Secret",
"type": "password",
"required": True,
"hint": "Shown once when creating the REST API key",
"hint": (
"Shown once when the REST API key is created. "
"Starts with ``cs_``. Save it immediately — WooCommerce will not display it again."
),
},
# F.X.fix-pass4 — optional WP Application Password, only used by
# media tools (upload_and_attach_to_product / attach_media_to_
# product / set_featured_image). WooCommerce's Consumer Key +
# Secret authenticate ``/wc/v3/*`` but NOT ``/wp/v2/media``;
# WordPress core REST media uploads require an Application
# Password from the WP admin user. Leave blank if the store
# never needs MCP-driven image uploads.
{
"name": "wp_username",
"label": "WordPress Username (for media tools)",
"type": "text",
"required": False,
"advanced": True,
"hint": (
"Only required if you want the AI / media tools "
"(upload_and_attach_to_product, attach_media_to_product, "
"set_featured_image, generate_and_upload_image with attach_to_post). "
"Other WC tools work with Consumer Key / Secret alone."
),
},
{
"name": "wp_app_password",
"label": "WordPress Application Password (for media tools)",
"type": "password",
"required": False,
"advanced": True,
"hint": (
"WP Admin → Users → Profile → Application Passwords. "
"WC media uploads hit /wp/v2/media which only accepts "
"Application Passwords (not Consumer Key / Secret). "
"Leave empty if you don't use MCP for image uploads."
),
},
],
"wordpress_advanced": [
@@ -67,21 +110,24 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
"label": "Username",
"type": "text",
"required": True,
"hint": "Your WordPress admin username",
"hint": "Your WordPress admin username (HTTP Basic username for every API call).",
},
{
"name": "app_password",
"label": "Application Password",
"type": "password",
"required": True,
"hint": "WordPress Admin → Users → Profile → Application Passwords",
"hint": (
"WordPress Admin → Users → Profile → Application Passwords. "
"IS the API credential — no separate API key needed."
),
},
{
"name": "container",
"label": "Docker Container Name",
"type": "text",
"required": False,
"hint": "Docker container running WordPress (for WP-CLI access)",
"hint": "Docker container running WordPress (for WP-CLI access). Leave empty if unused.",
},
],
"gitea": [
@@ -654,6 +700,170 @@ async def update_user_site(
return site_dict
# ---------------------------------------------------------------------------
# F.5a.9.x: per-site AI provider keys (OpenAI / Stability / Replicate)
# ---------------------------------------------------------------------------
# Only WordPress / WooCommerce sites can host AI image provider keys — the
# only consumer is ``wordpress_generate_and_upload_image``, which uploads into
# a WordPress media library. Other plugin types never surface the section.
PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES: frozenset[str] = frozenset({"wordpress", "woocommerce"})
# Providers supported for per-site keys. Must stay in sync with
# plugins/ai_image/registry.py ``_PROVIDERS`` (order is preserved to
# drive the UI).
SITE_PROVIDERS: tuple[str, ...] = ("openai", "stability", "replicate", "openrouter")
def site_provider_scope(site_id: str, provider: str) -> str:
"""Return the HKDF scope used for per-site provider-key encryption.
Format: ``site_provider:{site_id}:{provider}`` — distinct from the
per-site credentials scope (bare ``site_id``) so the same master key
never produces the same derived key for both credential types.
"""
return f"site_provider:{site_id}:{provider}"
def site_supports_provider_keys(plugin_type: str) -> bool:
"""Return True if the plugin type can hold AI provider keys."""
return plugin_type in PROVIDER_KEYS_ALLOWED_PLUGIN_TYPES
async def set_site_provider_key(
site_id: str,
user_id: str,
provider: str,
api_key: str,
) -> dict[str, Any]:
"""Encrypt and store an AI provider API key for a user-owned site.
Args:
site_id: Site UUID.
user_id: Owner's UUID (enforces tenant isolation).
provider: One of :data:`SITE_PROVIDERS`.
api_key: The plaintext API key to store.
Returns:
The stored row (without plaintext).
Raises:
ValueError: If the site is not found, the plugin type does not
support provider keys, the provider is unknown, or the key
is empty.
"""
from core.database import get_database
from core.encryption import get_credential_encryption
if provider not in SITE_PROVIDERS:
raise ValueError(
f"Unsupported provider '{provider}'. " f"Supported: {', '.join(SITE_PROVIDERS)}"
)
if not api_key or not api_key.strip():
raise ValueError("API key must not be empty")
db = get_database()
site = await db.get_site(site_id, user_id)
if site is None:
raise ValueError("Site not found")
if not site_supports_provider_keys(site["plugin_type"]):
raise ValueError(
f"Plugin type '{site['plugin_type']}' does not support AI "
f"provider keys (only WordPress/WooCommerce)."
)
encryptor = get_credential_encryption()
ciphertext = encryptor.encrypt_for_scope(
api_key.strip(), site_provider_scope(site_id, provider)
)
row = await db.upsert_site_provider_key(site_id, provider, ciphertext)
logger.info("Stored %s provider key for site %s (user %s)", provider, site_id, user_id)
return row
async def get_site_provider_key(
site_id: str,
provider: str,
) -> str | None:
"""Return the decrypted AI provider key for a site, or None.
Caller is responsible for verifying site ownership — this helper is
also called from the AI tool handler which already trusts the
resolved site_id.
Args:
site_id: Site UUID.
provider: Provider identifier.
Returns:
Decrypted API key string, or None if no key is stored.
"""
from core.database import get_database
from core.encryption import get_credential_encryption
if provider not in SITE_PROVIDERS:
return None
db = get_database()
row = await db.get_site_provider_key(site_id, provider)
if row is None:
return None
encryptor = get_credential_encryption()
try:
return encryptor.decrypt_for_scope(
row["key_ciphertext"], site_provider_scope(site_id, provider)
)
except Exception:
logger.exception(
"Failed to decrypt site provider key (site=%s provider=%s)",
site_id,
provider,
)
return None
async def list_site_providers_set(site_id: str) -> set[str]:
"""Return the set of providers that have a key stored for the site."""
from core.database import get_database
db = get_database()
rows = await db.list_site_provider_keys(site_id)
return {row["provider"] for row in rows}
async def delete_site_provider_key(
site_id: str,
user_id: str,
provider: str,
) -> bool:
"""Remove an AI provider key for a site. Returns True if a row was deleted.
Args:
site_id: Site UUID.
user_id: Owner's UUID — enforces tenant isolation before deletion.
provider: Provider identifier.
"""
from core.database import get_database
db = get_database()
site = await db.get_site(site_id, user_id)
if site is None:
return False
deleted = await db.delete_site_provider_key(site_id, provider)
if deleted:
logger.info(
"Deleted %s provider key for site %s (user %s)",
provider,
site_id,
user_id,
)
return deleted
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
"""Test connectivity to an existing site.

View File

@@ -143,13 +143,13 @@
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<p class="text-sm text-amber-700 dark:text-amber-400">
{% if lang == 'fa' %}
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
را روی سایت وردپرسی خود نصب کنید.
<strong>وردپرس:</strong> برای قابلیت‌های SEO، رسانه، و سایر ابزارهای کمکی، افزونه‌ی همراه
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
را دانلود و روی سایت وردپرسی خود نصب کنید.
{% else %}
<strong>WordPress:</strong> For SEO tools, install the
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
plugin on your WordPress site.
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
companion plugin on your WordPress site.
{% endif %}
</p>
</div>

View File

@@ -259,13 +259,13 @@
<div id="wp-seo-note" style="display:none" class="mt-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<p class="text-sm text-amber-700 dark:text-amber-400">
{% if lang == 'fa' %}
<strong>وردپرس:</strong> برای استفاده از ابزارهای SEO، افزونه
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
را روی سایت وردپرسی خود نصب کنید.
<strong>وردپرس:</strong> برای قابلیت‌های SEO، رسانه، و سایر ابزارهای کمکی، افزونه‌ی همراه
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
را دانلود و روی سایت وردپرسی خود نصب کنید.
{% else %}
<strong>WordPress:</strong> For SEO tools, install the
<a href="https://wordpress.org/plugins/airano-mcp-seo-bridge/" target="_blank" class="underline">Airano MCP SEO Bridge</a>
plugin on your WordPress site.
<strong>WordPress:</strong> For SEO, media, and other helper tools, download and install the
<a href="https://github.com/airano-ir/mcphub/raw/main/wordpress-plugin/airano-mcp-bridge.zip" target="_blank" class="underline">Airano MCP Bridge</a>
companion plugin on your WordPress site.
{% endif %}
</p>
</div>

View File

@@ -134,6 +134,57 @@
{% endif %}
</div>
<!-- F.20 prep: Companion plugin download hint (WP / WC only) -->
{% if companion_download_url %}
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-xl p-5">
<div class="flex items-start gap-3">
<div class="w-10 h-10 bg-indigo-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-indigo-500 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
{% if lang == 'fa' %}
افزونه همراه Airano MCP Bridge (اختیاری اما توصیه‌شده)
{% else %}
Airano MCP Bridge — companion plugin (optional but recommended)
{% endif %}
</h4>
<p class="mt-1 text-xs text-indigo-800/90 dark:text-indigo-300/90 leading-relaxed">
{% if lang == 'fa' %}
نصب افزونه همراه، این قابلیت‌ها را فعال می‌کند: آپلود فایل بزرگ‌تر از
<code class="font-mono">upload_max_filesize</code>، گزارش یکپارچه‌ی سلامت سایت،
purge کش، پاکسازی transient، متاهای bulk، خروجی ساختاریافته، probe قابلیت‌ها،
و webhook لاگ ممیزی. بدون آن، ابزارهای پایه کار می‌کنند اما موارد فوق غیرفعال خواهند بود.
{% else %}
Installing the companion plugin unlocks: uploads larger than
<code class="font-mono">upload_max_filesize</code>, unified site-health snapshot,
cache purge, transient flush, bulk meta writes, structured export, capability probe,
and audit-hook webhooks. Without it, basic tools still work but those features stay disabled.
{% endif %}
</p>
<div class="mt-3 flex flex-wrap items-center gap-3">
<a href="{{ companion_download_url }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{% if lang == 'fa' %}دانلود airano-mcp-bridge.zip{% else %}Download airano-mcp-bridge.zip{% endif %}
</a>
<span class="text-xs text-indigo-700/70 dark:text-indigo-300/70">
{% if lang == 'fa' %}
نصب: WP Admin → افزونه‌ها → افزودن → Upload Plugin → انتخاب فایل → نصب و فعال‌سازی
{% else %}
Install via WP Admin → Plugins → Add New → Upload Plugin → select the zip → Activate
{% endif %}
</span>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Setup Requirements -->
{% if service.credential_fields %}
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">

View File

@@ -0,0 +1,173 @@
{# F.X.fix #9 — capability-badge partial, HTMX-swappable.
Rendered both (a) inline by manage.html on first page load and (b) as
the response body of GET /api/sites/{id}/capabilities/badge when the
Re-check button is clicked. Swapping this element with hx-swap=
"outerHTML" avoids the full-page reload the legacy button triggered.
F.X.fix-pass2 — wording corrected for WordPress: application passwords
inherit ALL capabilities from the user account, so the "credential
tier" framing is misleading. WP-specific branches reference the WP
user's role instead. AI-provider availability is surfaced here too
so removing a provider key gives visible feedback on Re-check.
Required context:
capability_probe — probe payload (with .fit, .granted, .reason,
.ai_providers_configured, optionally .install_hint)
site — site dict (for .plugin_type, .tool_scope)
lang — "fa" | "en"
companion_download_url — string, may be empty
#}
{% set fit_status = capability_probe.fit.status %}
{% set is_wp = site.plugin_type in ['wordpress', 'wordpress_advanced'] %}
{% set is_wc = site.plugin_type == 'woocommerce' %}
{% set is_wp_like = is_wp or is_wc %}
{% set ai_providers = capability_probe.ai_providers_configured or [] %}
{% set ai_provider_missing = is_wp_like and (ai_providers | length == 0) %}
<div id="capability-badge"
class="border rounded-lg px-4 py-3 text-sm
{% if fit_status == 'ok' %}
bg-green-50 dark:bg-green-500/10 border-green-200 dark:border-green-500/30 text-green-800 dark:text-green-300
{% elif fit_status == 'warning' %}
bg-amber-50 dark:bg-amber-500/10 border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-300
{% elif fit_status == 'unknown_tier' %}
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-300
{% else %}
bg-gray-50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400
{% endif %}">
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
{% if fit_status == 'ok' %}
<div class="font-semibold text-sm">
{% if lang == 'fa' %}✓ دسترسی credential برای سطح انتخاب‌شده کافی است{% else %}✓ Credential grants what this tier needs{% endif %}
</div>
{% if capability_probe.granted %}
<div class="mt-1 text-xs opacity-80">
{% if lang == 'fa' %}دسترسی‌های احراز شده:{% else %}Granted:{% endif %}
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
</div>
{% endif %}
{% elif fit_status == 'warning' %}
<div class="font-semibold text-sm">
{% if is_wc %}
{% if lang == 'fa' %}
⚠ سطح دسترسی Consumer Key ووکامرس برای سطح «{{ site.tool_scope }}» کافی نیست
{% else %}
⚠ The WooCommerce REST API key permission is below the selected "{{ site.tool_scope }}" tier
{% endif %}
{% elif is_wp %}
{% if lang == 'fa' %}
⚠ کاربر وردپرس این credential نقش لازم برای سطح «{{ site.tool_scope }}» را ندارد
{% else %}
⚠ The WordPress user behind this application password lacks the role for the "{{ site.tool_scope }}" tier
{% endif %}
{% else %}
{% if lang == 'fa' %}
⚠ credential ذخیره‌شده برای سطح «{{ site.tool_scope }}» کافی نیست
{% else %}
⚠ Saved credential is below the selected "{{ site.tool_scope }}" tier
{% endif %}
{% endif %}
</div>
<div class="mt-1 text-xs opacity-90">
{% if lang == 'fa' %}دسترسی‌های ناموجود:{% else %}Missing:{% endif %}
<code class="font-mono text-xs">{{ capability_probe.fit.missing | join(', ') }}</code>
</div>
{% if capability_probe.granted %}
<div class="mt-1 text-xs opacity-80">
{% if lang == 'fa' %}آنچه در دسترس است:{% else %}Currently granted:{% endif %}
<code class="font-mono text-xs">{{ capability_probe.granted | join(', ') }}</code>
</div>
{% endif %}
<div class="mt-2 text-xs">
{% if is_wc %}
{% if lang == 'fa' %}
کلید ووکامرس (Consumer Key + Secret) دارای فیلد <code class="font-mono">permissions</code> با مقادیر
<code class="font-mono">read</code> یا <code class="font-mono">read_write</code> است. گزینه‌ها:
(۱) سطح پایین‌تر انتخاب کنید؛
(۲) در WP Admin → WooCommerce → Settings → Advanced → REST API یک کلید جدید با Permission مناسب (Read / Write یا Read/Write) بسازید و در Connection Settings ذخیره کنید؛
(۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند.
{% else %}
WooCommerce REST API keys carry a <code class="font-mono">permissions</code> field
(<code class="font-mono">read</code> / <code class="font-mono">read_write</code>). Options:
(1) pick a lower access level;
(2) regenerate the REST key with Read/Write permission in WP Admin → WooCommerce → Settings → Advanced → REST API and re-paste it in Connection Settings;
(3) continue — tools needing missing caps will return 403 at call time.
{% endif %}
{% elif is_wp %}
{% if lang == 'fa' %}
application password در وردپرس همیشه تمام دسترسی‌های کاربر صاحب آن را می‌گیرد و قابل محدودکردن نیست. گزینه‌ها:
(۱) سطح دسترسی پایین‌تری انتخاب کنید؛
(۲) application password را از کاربری با نقش بالاتر (مثلاً Administrator یا Editor) بسازید؛
(۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند.
{% else %}
WordPress application passwords inherit ALL capabilities of the user account and cannot be scoped down.
Options: (1) pick a lower access level;
(2) create the application password from a higher-role user (e.g. Administrator or Editor);
(3) continue — tools needing missing caps will return 403 at call time.
{% endif %}
{% else %}
{% if lang == 'fa' %}
گزینه‌ها: (۱) انتخاب سطح پایین‌تر؛ (۲) استفاده از credential با دسترسی بالاتر؛ (۳) ادامه — ابزارهای نیازمند این دسترسی هنگام فراخوانی ۴۰۳ می‌دهند.
{% else %}
Options: (1) pick a lower tier; (2) use a higher-privileged credential; (3) continue — tools needing missing caps will return 403 at call time.
{% endif %}
{% endif %}
</div>
{% elif fit_status == 'probe_unavailable' %}
<div class="font-semibold text-sm">
{% if lang == 'fa' %} امکان بررسی خودکار credential در این سایت وجود ندارد{% else %} Capability probe is unavailable for this site{% endif %}
</div>
<div class="mt-1 text-xs opacity-80 font-mono">
{{ capability_probe.reason or 'probe_unavailable' }}
</div>
{% if site.plugin_type in ['wordpress', 'woocommerce'] and companion_download_url %}
<div class="mt-2 text-xs">
{% if lang == 'fa' %}
برای فعال‌سازی probe، افزونه
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
را روی وردپرس نصب کنید.
{% else %}
Install the
<a href="{{ companion_download_url }}" class="underline font-medium">Airano MCP Bridge</a>
plugin on your WordPress to enable the probe.
{% endif %}
</div>
{% endif %}
{% elif fit_status == 'unknown_tier' %}
<div class="font-semibold text-sm">
{% if lang == 'fa' %} probe برای این plugin + سطح پیاده‌سازی نشده{% else %} Probe not implemented for this plugin/tier combination{% endif %}
</div>
{% endif %}
{# F.X.fix-pass2 — AI provider status line. Independent of the
tier-fit state: even a perfectly-fit credential cannot run
the AI image tool without an OpenRouter / OpenAI / … key. #}
{% if ai_provider_missing %}
<div class="mt-3 pt-2 border-t border-current/20 text-xs">
<span class="font-semibold">{% if lang == 'fa' %}⚠ هیچ کلید AI Provider تنظیم نشده{% else %}⚠ No AI provider key configured{% endif %}</span>
<span class="opacity-80">
{% if lang == 'fa' %}
— ابزار <code class="font-mono">wordpress_generate_and_upload_image</code> غیرفعال است. کلید را در بخش «تولید تصویر با هوش مصنوعی» بالا ذخیره کنید.
{% else %}
<code class="font-mono">wordpress_generate_and_upload_image</code> is disabled. Add a key in the "AI Image Generation" section above.
{% endif %}
</span>
</div>
{% elif is_wp_like and ai_providers %}
<div class="mt-3 pt-2 border-t border-current/20 text-xs opacity-80">
{% if lang == 'fa' %}کلیدهای AI Provider فعال:{% else %}Configured AI providers:{% endif %}
<code class="font-mono text-xs">{{ ai_providers | join(', ') }}</code>
</div>
{% endif %}
</div>
<button type="button"
hx-get="/api/sites/{{ site.id }}/capabilities/badge?force=1"
hx-target="#capability-badge"
hx-swap="outerHTML"
hx-disabled-elt="this"
class="flex-shrink-0 px-2.5 py-1 rounded border border-current/30 text-xs font-medium hover:bg-white/20 dark:hover:bg-black/20 transition-colors disabled:opacity-50">
{% if lang == 'fa' %}بررسی دوباره{% else %}Re-check{% endif %}
</button>
</div>
</div>

View File

@@ -62,13 +62,20 @@
{% set fields = plugin_fields.get(site.plugin_type, []) %}
{% for field in fields %}
{% if not field.get('advanced', False) %}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
{{ field.label }}{% if field.required %} *{% endif %}
{% set is_stored = cred_states.get(field.name, False) %}
<div data-cred-row="{{ field.name }}">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-2">
<span>{{ field.label }}{% if field.required %} *{% endif %}</span>
{% if is_stored %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300" data-cred-stored>
✓ {% if lang == 'fa' %}ذخیره‌شده{% else %}Stored{% endif %}
</span>
{% endif %}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{% if field.required %}{{ t.keep_existing }}{% else %}{{ t.leave_blank_to_clear }}{% endif %}"
placeholder="{% if is_stored %}{{ t.keep_existing }}{% elif field.required %}{{ t.keep_existing }}{% else %}{% if lang == 'fa' %}اختیاری — خالی بگذارید{% else %}Optional — leave empty{% endif %}{% endif %}"
data-required="{{ field.required | lower }}"
data-stored="{{ is_stored | lower }}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
@@ -81,15 +88,30 @@
{% if has_advanced %}
<details class="mt-2 border border-gray-200 dark:border-gray-600 rounded-lg">
<summary class="cursor-pointer px-4 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none">
Advanced Settings
{% if lang == 'fa' %}تنظیمات پیشرفته{% else %}Advanced Settings{% endif %}
</summary>
<div class="px-4 pb-4 pt-2 space-y-4">
{% for field in fields %}
{% if field.get('advanced', False) %}
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{{ field.label }}</label>
{% set is_stored = cred_states.get(field.name, False) %}
<div data-cred-row="{{ field.name }}">
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1 flex items-center gap-2">
<span>{{ field.label }}</span>
{% if is_stored %}
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300" data-cred-stored>
✓ {% if lang == 'fa' %}ذخیره‌شده{% else %}Stored{% endif %}
</span>
<button type="button"
data-cred-clear="{{ field.name }}"
class="ml-auto inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border border-red-300 dark:border-red-500/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10">
{% if lang == 'fa' %}🗑 پاک کن{% else %}🗑 Clear{% endif %}
</button>
{% endif %}
</label>
<input type="{{ field.type }}" name="cred_{{ field.name }}"
placeholder="{{ t.leave_blank_to_clear }}" data-required="false"
placeholder="{% if is_stored %}{{ t.keep_existing }}{% else %}{% if lang == 'fa' %}اختیاری — خالی بگذارید{% else %}Optional — leave empty{% endif %}{% endif %}"
data-required="false"
data-stored="{{ is_stored | lower }}"
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
{% if field.hint %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ field.hint }}</p>
@@ -118,6 +140,113 @@
</div>
</div>
{% if provider_keys_supported %}
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 1b: AI Image Generation (per-site provider keys) -->
<!-- ═══════════════════════════════════════════════════════════════ -->
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button type="button" onclick="toggleSection('aikeys')" class="w-full flex items-center justify-between p-6 cursor-pointer">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-pink-500/20 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
</div>
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{% if lang == 'fa' %}تولید تصویر با هوش مصنوعی{% else %}AI Image Generation{% endif %}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{% if lang == 'fa' %}
کلید API هر ارائه‌دهنده را تنها برای این سایت ذخیره کنید. بدون کلید، ابزار <code class="font-mono">wordpress_generate_and_upload_image</code> غیرفعال است.
{% else %}
Store an API key per provider, scoped only to this site. Without a key, <code class="font-mono">wordpress_generate_and_upload_image</code> is disabled for this site.
{% endif %}
</p>
</div>
</div>
<svg id="aikeys-chevron" class="w-5 h-5 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="aikeys-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-3">
<div id="aikeys-flash" class="hidden rounded-lg p-3 text-sm"></div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for row in provider_key_rows %}
<div class="py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3" data-site-ai-row="{{ row.provider }}">
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-3 {% if lang == 'fa' %}space-x-reverse{% endif %}">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ row.label }}</h4>
{% if row.status == 'set' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
{% if lang == 'fa' %}تنظیم‌شده{% else %}Set{% endif %}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
{% if lang == 'fa' %}تنظیم نشده{% else %}Unset{% endif %}
</span>
{% endif %}
</div>
</div>
<div class="flex items-center space-x-2 {% if lang == 'fa' %}space-x-reverse{% endif %}">
<form class="flex items-center gap-2"
data-site-ai-set="{{ row.provider }}"
onsubmit="return window.__siteAiKeys.setKey(event, '{{ row.provider }}')">
<input type="password"
name="api_key"
required
minlength="8"
autocomplete="new-password"
placeholder="{% if lang == 'fa' %}کلید API{% else %}New API key{% endif %}"
class="px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-pink-500 focus:border-transparent w-48"/>
<button type="submit"
class="px-3 py-2 rounded-lg bg-pink-600 hover:bg-pink-500 text-white text-sm font-medium transition-colors">
{% if lang == 'fa' %}ذخیره{% else %}Save{% endif %}
</button>
</form>
<button type="button"
data-site-ai-remove="{{ row.provider }}"
onclick="return window.__siteAiKeys.deleteKey('{{ row.provider }}')"
class="px-3 py-2 rounded-lg border border-red-300 dark:border-red-500/50 text-red-600 dark:text-red-400 text-sm font-medium hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors {% if row.status != 'set' %}hidden{% endif %}">
{% if lang == 'fa' %}حذف{% else %}Remove{% endif %}
</button>
</div>
</div>
{% if row.provider == 'openrouter' %}
<details class="ml-0 sm:ml-0 mt-1 text-xs"
data-site-ai-models="openrouter"
data-current-default="{{ row.default_model or '' }}">
<summary class="cursor-pointer inline-block px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 select-none">
{% if lang == 'fa' %}🖼 مدل‌های تصویری در دسترس{% if row.default_model %} — پیش‌فرض: <code class="font-mono">{{ row.default_model }}</code>{% endif %}{% else %}🖼 Available image models{% if row.default_model %} — default: <code class="font-mono">{{ row.default_model }}</code>{% endif %}{% endif %}
</summary>
<div class="mt-2 rounded-lg border border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200">
<div class="text-gray-500 dark:text-gray-400" data-models-placeholder>
{% if lang == 'fa' %}در حال بارگذاری…{% else %}Loading…{% endif %}
</div>
<ul class="space-y-2 hidden" data-models-list></ul>
<p class="mt-2 text-[11px] text-gray-500 dark:text-gray-400" data-models-hint>
{% if lang == 'fa' %}
با کلیک روی «پیش‌فرض» مدل به‌عنوان مدل پیش‌فرض این سایت ذخیره می‌شود — اگر <code class="font-mono">model</code> در فراخوانی <code class="font-mono">wordpress_generate_and_upload_image</code> داده نشود، از این مقدار استفاده می‌شود.
{% else %}
Click "Set default" to make a model the site's default — used when <code class="font-mono">wordpress_generate_and_upload_image</code> is called without an explicit <code class="font-mono">model</code>.
{% endif %}
</p>
</div>
</details>
{% endif %}
{% endfor %}
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 pt-2">
{% if lang == 'fa' %}
کلیدها با AES-256-GCM رمزگذاری می‌شوند و فقط برای همین سایت قابل استفاده‌اند.
{% else %}
Keys are encrypted at rest (AES-256-GCM) and scoped to this site only.
{% endif %}
</p>
</div>
</div>
{% endif %}
<!-- ═══════════════════════════════════════════════════════════════ -->
<!-- Section 2: Tool Access -->
<!-- ═══════════════════════════════════════════════════════════════ -->
@@ -141,6 +270,31 @@
</svg>
</button>
<div id="tools-section" class="hidden border-t border-gray-200 dark:border-gray-700 p-6 space-y-4">
{% if companion_download_url %}
<!-- F.20 prep: companion-plugin hint (compact, WP/WC only) -->
<div class="bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-lg p-3 text-xs text-indigo-800 dark:text-indigo-300">
{% if lang == 'fa' %}
برای قابلیت‌های کامل (آپلود بزرگ، cache purge، site-health، و…) افزونه‌ی
<a href="{{ companion_download_url }}" class="underline font-medium hover:no-underline">Airano MCP Bridge</a>
را در وردپرس نصب کنید (اختیاری).
{% else %}
For full capability (large uploads, cache purge, site-health, bulk meta, audit hooks, …), install the
<a href="{{ companion_download_url }}" class="underline font-medium hover:no-underline">Airano MCP Bridge</a>
plugin on your WordPress (optional).
{% endif %}
</div>
{% endif %}
{# F.7e: capability probe badge — tells the user whether the
saved credential can actually back the selected tier,
before they hit a 403 from a real tool call. #}
{# F.X.fix #9: badge is an HTMX-swappable partial. Re-check
button hits /api/sites/{id}/capabilities/badge and swaps
this element in place — no full-page reload. #}
{% if capability_probe %}
{% include "dashboard/sites/_capability_badge.html" %}
{% endif %}
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
</div>
@@ -303,6 +457,30 @@
}
// ── Connection form ─────────────────────────────────────────────
// F.X.fix-pass5 — Clear-button wiring. Marks the matching input
// with data-clear="1" + visually flashes the row so the user
// knows the next Save will wipe the stored value.
document.querySelectorAll('[data-cred-clear]').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
const fieldName = btn.dataset.credClear;
const input = document.querySelector(`[name="cred_${fieldName}"]`);
if (!input) return;
input.value = '';
input.dataset.clear = '1';
input.classList.add('border-red-400', 'dark:border-red-500');
const row = btn.closest('[data-cred-row]');
const stored = row?.querySelector('[data-cred-stored]');
if (stored) {
stored.textContent = '{% if lang == "fa" %}پس از Save پاک می‌شود{% else %}Will clear on Save{% endif %}';
stored.classList.remove('bg-green-100', 'dark:bg-green-900/40', 'text-green-700', 'dark:text-green-300');
stored.classList.add('bg-red-100', 'dark:bg-red-900/40', 'text-red-700', 'dark:text-red-300');
}
btn.disabled = true;
btn.classList.add('opacity-50');
});
});
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
@@ -313,13 +491,22 @@
const url = document.getElementById('url').value;
const creds = {};
// F.X.fix-pass5 — Empty input means "keep existing"; clearing
// a stored optional value requires an explicit Clear button
// click (which sets data-clear="1" on the input). Old
// behaviour silently wiped optional fields on every save.
if (pluginFields[pluginType]) {
pluginFields[pluginType].forEach(field => {
const input = document.querySelector(`[name="cred_${field.name}"]`);
if (!input) return;
const val = input.value.trim();
if (val) creds[field.name] = val;
else if (input.dataset.required === 'false') creds[field.name] = '';
if (val) {
creds[field.name] = val;
} else if (input.dataset.clear === '1') {
// Explicit clear: send empty string to wipe.
creds[field.name] = '';
}
// else: leave out → server keeps existing value.
});
}
@@ -391,6 +578,11 @@
} catch (_) {}
}
// F.X.fix-pass2 — exposed so the AI-keys set/delete handlers can
// trigger a live refresh of the gating UI after configuring a
// provider key (no full-page reload).
window.loadToolAccess = loadToolAccess;
function renderToolAccess() {
document.getElementById('tool-access-loading').classList.add('hidden');
document.getElementById('tool-access-content').classList.remove('hidden');
@@ -642,16 +834,35 @@
function renderToolRow(tool) {
const inScope = isToolInScope(tool);
// F.X.fix-pass3 — central prerequisite gating. Backend has
// already evaluated `available` + `unavailable_reason` so the
// UI just renders accordingly. Reasons we know how to handle:
// provider_key — needs an AI provider key on this site
// companion_route — needs the Airano MCP Bridge companion plugin
// feature — needs an SEO plugin (Rank Math / Yoast)
const reason = tool.unavailable_reason || null;
const missingProviderKey = reason === 'provider_key';
const missingCompanion = reason === 'companion_route';
const missingSeoPlugin = reason === 'feature';
const missingWpCreds = reason === 'wp_credentials';
const unavailable = !tool.available;
const dimmed = !inScope || unavailable;
const row = document.createElement('div');
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (inScope ? '' : ' opacity-40');
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30' + (dimmed ? ' opacity-40' : '');
const left = document.createElement('div');
left.className = 'flex items-center gap-2 flex-1 min-w-0';
const nameEl = document.createElement('span');
nameEl.className = 'text-sm truncate font-mono ' + (inScope ? 'text-gray-800 dark:text-gray-200' : 'text-gray-400 dark:text-gray-500 line-through');
nameEl.className = 'text-sm truncate font-mono ' + (dimmed ? 'text-gray-400 dark:text-gray-500 line-through' : 'text-gray-800 dark:text-gray-200');
nameEl.textContent = tool.name;
nameEl.title = (tool.description || '') + (inScope ? '' : ' — {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
const titleParts = [tool.description || ''];
if (!inScope) titleParts.push('— {% if lang == "fa" %}خارج از سطح دسترسی{% else %}outside current scope{% endif %}');
if (missingProviderKey) titleParts.push('— {% if lang == "fa" %}نیاز به تنظیم کلید AI Provider{% else %}needs an AI provider key{% endif %}');
if (missingCompanion) titleParts.push('— {% if lang == "fa" %}نیاز به افزونه Airano MCP Bridge{% else %}needs the Airano MCP Bridge companion plugin{% endif %}');
if (missingSeoPlugin) titleParts.push('— {% if lang == "fa" %}نیاز به افزونه‌ی SEO (Rank Math یا Yoast){% else %}needs an SEO plugin (Rank Math or Yoast){% endif %}');
if (missingWpCreds) titleParts.push('— {% if lang == "fa" %}نیاز به Application Password وردپرس{% else %}needs a WordPress Application Password{% endif %}');
nameEl.title = titleParts.join(' ');
left.appendChild(nameEl);
if (!inScope) {
@@ -668,29 +879,74 @@
left.appendChild(badge);
}
if (missingProviderKey) {
const configurePill = document.createElement('button');
configurePill.type = 'button';
configurePill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-pink-100 dark:bg-pink-500/20 text-pink-700 dark:text-pink-300 hover:bg-pink-200 dark:hover:bg-pink-500/30 transition-colors';
configurePill.textContent = '{% if lang == "fa" %}⚙ تنظیم کلید{% else %}⚙ Configure key{% endif %}';
configurePill.addEventListener('click', (ev) => {
ev.stopPropagation();
const section = document.getElementById('aikeys-section');
if (section && section.classList.contains('hidden')) {
toggleSection('aikeys');
}
const target = document.getElementById('aikeys-section');
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
left.appendChild(configurePill);
} else if (missingCompanion) {
const pill = document.createElement('span');
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300';
pill.textContent = '{% if lang == "fa" %}🧩 افزونه‌ی همراه نیاز است{% else %}🧩 needs companion plugin{% endif %}';
left.appendChild(pill);
} else if (missingSeoPlugin) {
const pill = document.createElement('span');
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300';
pill.textContent = '{% if lang == "fa" %}🔍 SEO plugin نیاز است{% else %}🔍 needs SEO plugin{% endif %}';
left.appendChild(pill);
} else if (missingWpCreds) {
const pill = document.createElement('button');
pill.type = 'button';
pill.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-700 dark:text-orange-300 hover:bg-orange-200 dark:hover:bg-orange-500/30 transition-colors';
pill.textContent = '{% if lang == "fa" %}🔑 WP App Password نیاز{% else %}🔑 needs WP App Password{% endif %}';
pill.addEventListener('click', (ev) => {
ev.stopPropagation();
const link = document.createElement('a');
link.href = '/dashboard/sites/' + encodeURIComponent(siteId) + '/edit';
link.click();
});
left.appendChild(pill);
}
const label = document.createElement('label');
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
label.className = 'relative inline-flex items-center flex-shrink-0 ' + (unavailable ? 'cursor-not-allowed' : 'cursor-pointer');
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'sr-only peer';
input.checked = tool.enabled !== false;
input.onchange = async () => {
const enabled = input.checked;
try {
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!r.ok) input.checked = !enabled;
else {
tool.enabled = enabled;
const en = allTools.filter(t => t.enabled !== false).length;
document.getElementById('tool-summary').textContent =
'{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
}
} catch (_) { input.checked = !enabled; }
};
// When prerequisites unmet, force toggle visually OFF regardless
// of the user's saved preference — backend filter already drops
// the tool from the live endpoint.
input.checked = tool.available !== false && tool.enabled !== false;
input.disabled = unavailable;
if (!unavailable) {
input.onchange = async () => {
const enabled = input.checked;
try {
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
if (!r.ok) input.checked = !enabled;
else {
tool.enabled = enabled;
const en = allTools.filter(t => t.enabled !== false).length;
document.getElementById('tool-summary').textContent =
'{% if lang == "fa" %}' + en + ' از ' + allTools.length + ' ابزار فعال{% else %}' + en + ' of ' + allTools.length + ' tools enabled{% endif %}';
}
} catch (_) { input.checked = !enabled; }
};
}
const slider = document.createElement('div');
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
label.appendChild(input);
@@ -759,6 +1015,252 @@
btn.disabled = false;
}
// ── Per-site AI provider keys (F.5a.9.x) ────────────────────────
{% if provider_keys_supported %}
(function () {
const flash = document.getElementById('aikeys-flash');
function show(kind, msg) {
if (!flash) return;
flash.textContent = msg;
flash.className = 'rounded-lg p-3 text-sm ' + (
kind === 'ok'
? 'bg-green-50 dark:bg-green-500/10 text-green-700 dark:text-green-300 border border-green-200 dark:border-green-500/30'
: 'bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-500/30'
);
flash.classList.remove('hidden');
}
// F.X.fix-pass2 — in-place DOM update after set/delete (no
// window.location.reload()), so the AI Image Generation panel
// stays expanded and the scroll position is preserved. Also
// triggers a Tool Access reload + a capability-badge re-check
// so the gating UI reflects the new provider state live.
function _updateRowStatus(provider, isSet) {
const row = document.querySelector('[data-site-ai-row="' + provider + '"]');
if (!row) return;
const badge = row.querySelector('span.inline-flex');
if (badge) {
if (isSet) {
badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300';
badge.textContent = '{% if lang == "fa" %}تنظیم‌شده{% else %}Set{% endif %}';
} else {
badge.className = 'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300';
badge.textContent = '{% if lang == "fa" %}تنظیم نشده{% else %}Unset{% endif %}';
}
}
const removeBtn = document.querySelector('[data-site-ai-remove="' + provider + '"]');
if (removeBtn) {
removeBtn.classList.toggle('hidden', !isSet);
}
const form = document.querySelector('[data-site-ai-set="' + provider + '"]');
if (form) {
const input = form.querySelector('input[name="api_key"]');
if (input) input.value = '';
}
}
function _refreshLinkedUi() {
// Re-fetch tools so the AI tool's gating updates inline.
if (typeof window.loadToolAccess === 'function') {
window.loadToolAccess();
}
// Trigger the HTMX re-check button so the capability badge
// re-renders with the new ai_providers_configured state.
const recheck = document.querySelector('#capability-badge button[hx-get]');
if (recheck && window.htmx) {
window.htmx.trigger(recheck, 'click');
}
}
async function setKey(ev, provider) {
ev.preventDefault();
const form = ev.target;
const fd = new FormData(form);
const apiKey = (fd.get('api_key') || '').toString();
if (!apiKey || apiKey.length < 8) {
show('err', '{% if lang == "fa" %}کلید باید حداقل ۸ کاراکتر باشد{% else %}Key must be at least 8 characters{% endif %}');
return false;
}
try {
const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: apiKey }),
credentials: 'same-origin',
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
show('err', data.message || data.error || 'save failed');
return false;
}
_updateRowStatus(provider, true);
_refreshLinkedUi();
show('ok', '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %} — last4: ' + (data.secret_last4 || ''));
} catch (e) {
show('err', e.message || String(e));
}
return false;
}
async function deleteKey(provider) {
if (!window.confirm('{% if lang == "fa" %}آیا مطمئن هستید؟{% else %}Remove this provider key?{% endif %}')) {
return false;
}
try {
const resp = await fetch('/api/sites/' + encodeURIComponent(siteId) + '/provider-keys/' + encodeURIComponent(provider), {
method: 'DELETE',
credentials: 'same-origin',
});
const data = await resp.json();
if (!resp.ok || !data.ok) {
show('err', data.message || data.error || 'delete failed');
return false;
}
_updateRowStatus(provider, false);
_refreshLinkedUi();
show('ok', '{% if lang == "fa" %}حذف شد{% else %}Removed{% endif %}');
} catch (e) {
show('err', e.message || String(e));
}
return false;
}
// F.X.fix-pass2 — OpenRouter model discovery UI. Lazy-loaded on
// first <details> expand so the panel open doesn't pay the
// network cost unless the user asks. Falls back gracefully
// when no key is set (catalog is public but availability
// flags reflect the operator's account when we pass a key).
async function _loadOpenRouterModels(details) {
if (details.dataset.loaded === '1') return;
details.dataset.loaded = '1';
const placeholder = details.querySelector('[data-models-placeholder]');
const list = details.querySelector('[data-models-list]');
try {
const resp = await fetch(
'/api/providers/openrouter/models?site_id=' + encodeURIComponent(siteId),
{ credentials: 'same-origin' }
);
const data = await resp.json();
if (!resp.ok || !data.ok) {
placeholder.textContent = data.message || data.error || '{% if lang == "fa" %}خطا در دریافت لیست{% else %}Failed to load models{% endif %}';
return;
}
const models = Array.isArray(data.models) ? data.models : [];
if (!models.length) {
placeholder.textContent = '{% if lang == "fa" %}هیچ مدلی یافت نشد{% else %}No image models found{% endif %}';
return;
}
placeholder.classList.add('hidden');
list.classList.remove('hidden');
list.innerHTML = '';
const provider = details.dataset.siteAiModels || 'openrouter';
let currentDefault = details.dataset.currentDefault || '';
const renderRow = (m) => {
const li = document.createElement('li');
li.className = 'flex items-start justify-between gap-2 pb-1 border-b border-gray-200 dark:border-gray-700 last:border-0';
const info = document.createElement('div');
info.className = 'flex-1 min-w-0';
const id = document.createElement('code');
id.className = 'font-mono text-gray-800 dark:text-gray-200 break-all';
id.textContent = m.id;
const nameLine = document.createElement('div');
nameLine.className = 'text-[11px] text-gray-500 dark:text-gray-400 mt-0.5';
const nameBits = [];
if (m.name && m.name !== m.id) nameBits.push(m.name);
if (m.price_per_image_usd != null) {
nameBits.push('$' + Number(m.price_per_image_usd).toFixed(4) + '/img');
}
nameLine.textContent = nameBits.join(' · ');
info.appendChild(id);
if (nameBits.length) info.appendChild(nameLine);
const actions = document.createElement('div');
actions.className = 'flex-shrink-0 flex items-center gap-1';
const isDefault = currentDefault === m.id;
const defaultBtn = document.createElement('button');
defaultBtn.type = 'button';
defaultBtn.className = 'px-2 py-0.5 rounded border text-[11px] transition-colors ' + (
isDefault
? 'border-emerald-300 dark:border-emerald-600 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300'
: 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
);
defaultBtn.textContent = isDefault
? '{% if lang == "fa" %}★ پیش‌فرض{% else %}★ Default{% endif %}'
: '{% if lang == "fa" %}پیش‌فرض{% else %}Set default{% endif %}';
defaultBtn.addEventListener('click', async () => {
const target = isDefault ? null : m.id;
defaultBtn.disabled = true;
const orig = defaultBtn.textContent;
defaultBtn.textContent = '…';
try {
const resp = await fetch(
'/api/sites/' + encodeURIComponent(siteId) +
'/provider-keys/' + encodeURIComponent(provider) +
'/default-model',
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: target }),
credentials: 'same-origin',
}
);
const data = await resp.json();
if (!resp.ok || !data.ok) {
defaultBtn.textContent = orig;
show('err', data.message || data.error || '{% if lang == "fa" %}خطا{% else %}Error{% endif %}');
return;
}
currentDefault = target || '';
details.dataset.currentDefault = currentDefault;
// Re-render the entire list so all rows reflect the new default.
list.innerHTML = '';
for (const x of models) list.appendChild(renderRow(x));
show('ok', '{% if lang == "fa" %}پیش‌فرض ذخیره شد{% else %}Default saved{% endif %}');
} catch (e) {
defaultBtn.textContent = orig;
show('err', e.message || String(e));
} finally {
defaultBtn.disabled = false;
}
});
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'px-2 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-[11px] text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700';
copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}';
copy.addEventListener('click', () => {
navigator.clipboard.writeText(m.id);
copy.textContent = '{% if lang == "fa" %}کپی شد{% else %}Copied{% endif %}';
setTimeout(() => { copy.textContent = '{% if lang == "fa" %}کپی{% else %}Copy{% endif %}'; }, 1500);
});
actions.appendChild(defaultBtn);
actions.appendChild(copy);
li.appendChild(info);
li.appendChild(actions);
return li;
};
for (const m of models) list.appendChild(renderRow(m));
} catch (e) {
placeholder.textContent = e.message || String(e);
}
}
document.querySelectorAll('details[data-site-ai-models]').forEach((d) => {
d.addEventListener('toggle', () => {
if (d.open) _loadOpenRouterModels(d);
});
});
window.__siteAiKeys = { setKey, deleteKey };
})();
{% endif %}
// F.X.fix #9: Re-check is now an HTMX partial swap — see the
// hx-get attribute on the button in _capability_badge.html. No JS
// shim needed here any more.
// ── Init ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadToolAccess();

View File

@@ -403,6 +403,11 @@ class ToolAccessManager:
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
tools = await self.apply_site_toggles(tools, site_id)
# F.X.fix-pass3: filter out tools whose central prerequisites
# are unmet (no AI provider key, missing companion route,
# missing SEO plugin) so the live endpoint never advertises a
# tool that would 100% fail at call time.
tools = await self.apply_prerequisites_filter(tools, site_id)
return tools
async def toggle_tool(
@@ -488,6 +493,19 @@ class ToolAccessManager:
Used by the dashboard API to present the per-site management view.
Does not apply scope filters — the UI decides what to show.
F.X.fix #8: each row also carries ``provider_key_required`` and
``provider_key_configured`` so the Tool Access template can
render AI tools greyed + "Configure key" CTA when the site has
no keys for the required provider, instead of letting the user
enable a tool that errors at call time with ``NO_PROVIDER_KEY``.
F.X.fix-pass3: rows also carry ``available`` + ``unavailable_reason``
derived from a central :data:`_TOOL_PREREQUISITES` resolver so
the UI can auto-grey/disable tools whose requirements (companion
route, SEO plugin, AI key) the site doesn't satisfy. This is the
same data the live MCP endpoint uses to silently filter the
catalog (see :meth:`get_visible_tools`).
Args:
site_id: Site UUID.
plugin_type: Plugin type.
@@ -504,20 +522,235 @@ class ToolAccessManager:
except RuntimeError:
toggles = {}
try:
from core.site_api import list_site_providers_set
configured_providers = await list_site_providers_set(site_id)
except Exception: # noqa: BLE001
configured_providers = set()
# F.X.fix-pass3: pull the cached probe payload so we can
# evaluate prerequisites for SEO / companion-route tools.
# ``probe_site_capabilities`` reuses its 10-min cache so the
# cost is one SQLite query in the common path.
probe_payload: dict[str, Any] | None = None
try:
from core.capability_probe import get_probe_cache
cached = get_probe_cache().get(site_id)
if cached is not None:
probe_payload = cached
except Exception: # noqa: BLE001
probe_payload = None
registry = get_tool_registry()
tools = registry.get_by_plugin_type(plugin_type)
return [
{
"name": t.name,
"description": t.description,
"plugin_type": t.plugin_type,
"category": t.category,
"sensitivity": t.sensitivity,
"required_scope": t.required_scope,
"enabled": toggles.get(t.name, True),
}
for t in tools
]
rows: list[dict[str, Any]] = []
for t in tools:
available, reason = check_tool_prerequisites(
t.name,
probe_payload=probe_payload,
configured_providers=configured_providers,
)
rows.append(
{
"name": t.name,
"description": t.description,
"plugin_type": t.plugin_type,
"category": t.category,
"sensitivity": t.sensitivity,
"required_scope": t.required_scope,
"enabled": toggles.get(t.name, True),
"provider_key_required": _tool_requires_provider_key(t.name),
"provider_key_configured": _tool_has_configured_provider(
t.name, configured_providers
),
"available": available,
"unavailable_reason": reason,
}
)
return rows
async def apply_prerequisites_filter(
self,
tools: list[ToolDefinition],
site_id: str,
) -> list[ToolDefinition]:
"""Drop tools whose central prerequisites are not satisfied.
Mirrors :meth:`list_tools_for_site` but returns ``ToolDefinition``
objects so the live MCP endpoint pipeline (``get_visible_tools``)
can call it inline. Reads the same cached probe + provider-key
set so a fresh probe never gets triggered from this hot path.
"""
try:
from core.site_api import list_site_providers_set
configured_providers = await list_site_providers_set(site_id)
except Exception: # noqa: BLE001
configured_providers = set()
probe_payload: dict[str, Any] | None = None
try:
from core.capability_probe import get_probe_cache
cached = get_probe_cache().get(site_id)
if cached is not None:
probe_payload = cached
except Exception: # noqa: BLE001
probe_payload = None
kept: list[ToolDefinition] = []
for tool in tools:
available, _ = check_tool_prerequisites(
tool.name,
probe_payload=probe_payload,
configured_providers=configured_providers,
)
if available:
kept.append(tool)
return kept
# F.X.fix #8 — tools that need a per-site AI provider key to succeed.
# Today only the AI image generator; expand as more AI tools land.
_PROVIDER_KEY_REQUIRED_TOOLS: frozenset[str] = frozenset({"wordpress_generate_and_upload_image"})
def _tool_requires_provider_key(tool_name: str) -> bool:
return tool_name in _PROVIDER_KEY_REQUIRED_TOOLS
def _tool_has_configured_provider(tool_name: str, configured: set[str]) -> bool:
"""``True`` iff the site has at least one provider key for this tool.
The AI image tool is happy with ANY supported provider key (the
caller picks which one at call time via the ``provider`` arg) so
any non-empty configured set counts.
"""
if not _tool_requires_provider_key(tool_name):
return True
return bool(configured)
# ── F.X.fix-pass3: central tool-prerequisites resolver ───────────────
# Single source of truth for "this tool needs X to work". The resolver
# below decides per-call whether a tool is *available* on a given site
# given the cached probe payload + the site's configured provider keys.
# Three predicate kinds:
#
# provider_key — site needs at least one of the listed AI provider
# keys.
# companion_route — the companion plugin must advertise the named
# route in probe.routes.
# feature_any — the WP probe features must include at least one of
# the listed feature names (e.g. rank_math / yoast).
#
# The resolver's output (``available`` + ``unavailable_reason``) is
# attached to every tool row in ``list_tools_for_site`` AND used by
# ``apply_prerequisites_filter`` to drop unavailable tools from the
# live MCP endpoint, so models calling the endpoint never see a tool
# that is guaranteed to fail at call time.
_TOOL_PREREQUISITES: dict[str, list[dict[str, Any]]] = {
# AI image — any provider key is enough; the caller picks at call time.
"wordpress_generate_and_upload_image": [
{
"kind": "provider_key",
"any_of": ["openai", "stability", "replicate", "openrouter"],
}
],
# Companion-route-backed WP tools.
"wordpress_cache_purge": [{"kind": "companion_route", "name": "cache_purge"}],
"wordpress_bulk_update_meta": [{"kind": "companion_route", "name": "bulk_meta"}],
"wordpress_export_content": [{"kind": "companion_route", "name": "export"}],
"wordpress_site_health": [{"kind": "companion_route", "name": "site_health"}],
"wordpress_transient_flush": [{"kind": "companion_route", "name": "transient_flush"}],
"wordpress_audit_hook_status": [{"kind": "companion_route", "name": "audit_hook"}],
"wordpress_audit_hook_configure": [{"kind": "companion_route", "name": "audit_hook"}],
"wordpress_audit_hook_disable": [{"kind": "companion_route", "name": "audit_hook"}],
"wordpress_regenerate_thumbnails": [
{"kind": "companion_route", "name": "regenerate_thumbnails"}
],
"wordpress_bulk_delete_media": [{"kind": "companion_route", "name": "bulk_meta"}],
"wordpress_bulk_reassign_media": [{"kind": "companion_route", "name": "bulk_meta"}],
# SEO — needs Rank Math (or Yoast in the future). Probe surfaces
# both flags; we accept either.
"wordpress_get_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_update_post_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_get_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_update_product_seo": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
"wordpress_get_internal_links": [{"kind": "feature_any", "names": ["rank_math", "yoast"]}],
# F.X.fix-pass5 — WooCommerce media tools hit /wp/v2/media which
# WC consumer_key + secret can't authenticate. Need a WP App
# Password (wp_username + wp_app_password fields, or legacy
# username + app_password single-credential mode).
"woocommerce_attach_media_to_product": [{"kind": "wp_credentials"}],
"woocommerce_upload_and_attach_to_product": [{"kind": "wp_credentials"}],
"woocommerce_set_featured_image": [{"kind": "wp_credentials"}],
# AI image, exposed on both WP and WC plugins. The WC variant
# needs both an AI provider key AND WP credentials (to upload
# /wp/v2/media); the WP variant only needs the provider key
# since its primary client already uses Application Password.
"woocommerce_generate_and_upload_image": [
{"kind": "provider_key", "any_of": ["openai", "stability", "replicate", "openrouter"]},
{"kind": "wp_credentials"},
],
}
def check_tool_prerequisites(
tool_name: str,
*,
probe_payload: dict[str, Any] | None,
configured_providers: set[str],
) -> tuple[bool, str | None]:
"""Decide whether a tool's prerequisites are satisfied.
Returns ``(available, unavailable_reason)``. ``unavailable_reason``
is one of ``provider_key`` | ``companion_route`` | ``feature``
| ``probe_unknown`` when the tool is unavailable, else ``None``.
A tool with no prerequisites declared is always available — most
of the catalog (plain CRUD over WP REST) needs no companion / no
AI key, so the default of "no entry → True" keeps the rule list
short.
"""
rules = _TOOL_PREREQUISITES.get(tool_name)
if not rules:
return True, None
routes = ((probe_payload or {}).get("routes")) or {}
features = ((probe_payload or {}).get("features")) or {}
for rule in rules:
kind = rule.get("kind")
if kind == "provider_key":
allowed = set(rule.get("any_of") or [])
if allowed and not (allowed & configured_providers):
return False, "provider_key"
elif kind == "companion_route":
name = rule.get("name") or ""
# Probe is authoritative when present; absence of probe data
# (e.g. companion not installed → companion_available=False)
# is also treated as "unavailable".
if not routes.get(name):
return False, "companion_route"
elif kind == "feature_any":
names = list(rule.get("names") or [])
# WP companion's features dict carries booleans like
# rank_math: true / yoast: false. WC has no analogous gate.
if not any(features.get(n) for n in names):
return False, "feature"
elif kind == "wp_credentials":
# F.X.fix-pass5 — WC media tools and the WC AI image tool
# need a WP Application Password to authenticate
# /wp/v2/media. The WC plugin probe surfaces a
# ``wp_credentials_present`` flag; absence is a hard fail.
if not (probe_payload or {}).get("wp_credentials_present"):
return False, "wp_credentials"
return True, None
# Singleton

122
core/tool_rate_limiter.py Normal file
View File

@@ -0,0 +1,122 @@
"""Per-tool, per-user rate limiting (F.5a.6).
Provides a small token-bucket limiter keyed by (user_id, tool_name) for
expensive tools such as AI image generation and chunked-upload finish.
Admin / env-fallback callers (``user_id is None``) are exempt.
Intentionally minimal — separate from :mod:`core.rate_limiter` (which is a
global per-client limiter). This one is scoped to specific tools with
small hourly caps.
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass
from core.rate_limiter import TokenBucket
logger = logging.getLogger(__name__)
# Default caps (per hour per user). Documented in ROADMAP F.5a.6.
DEFAULT_LIMITS: dict[str, int] = {
"wordpress_generate_and_upload_image": 10,
"wordpress_upload_media_chunked_finish": 30,
}
@dataclass
class ToolRateLimitError(Exception):
"""Raised when a per-tool limit is exceeded."""
tool_name: str
limit_per_hour: int
retry_after_seconds: float
def __post_init__(self) -> None:
super().__init__(
f"Per-tool rate limit exceeded for '{self.tool_name}' "
f"({self.limit_per_hour}/hour per user). "
f"Retry in {self.retry_after_seconds:.0f}s."
)
def to_dict(self) -> dict:
return {
"error_code": "TOOL_RATE_LIMITED",
"message": str(self),
"details": {
"tool": self.tool_name,
"limit_per_hour": self.limit_per_hour,
"retry_after_seconds": round(self.retry_after_seconds, 2),
},
}
class PerToolRateLimiter:
"""Token-bucket limiter keyed by (user_id, tool_name).
``check(tool, user_id)`` consumes one token and raises
:class:`ToolRateLimitError` when the user is over quota. Admin / env
callers (``user_id`` is None or empty) are exempt.
"""
def __init__(self, limits: dict[str, int] | None = None) -> None:
self._limits = dict(limits if limits is not None else DEFAULT_LIMITS)
self._buckets: dict[tuple[str, str], TokenBucket] = {}
self._lock = threading.Lock()
def configure(self, tool_name: str, per_hour: int) -> None:
"""Override the per-hour cap for a tool."""
self._limits[tool_name] = per_hour
# Existing buckets keep their old capacity until reset — tests can
# reset() to re-read the new limit.
def reset(self) -> None:
with self._lock:
self._buckets.clear()
def check(self, tool_name: str, user_id: str | None) -> None:
"""Consume one token for (user_id, tool_name). Exempt when user_id is falsy."""
if not user_id:
return
limit = self._limits.get(tool_name)
if limit is None or limit <= 0:
return
key = (user_id, tool_name)
with self._lock:
bucket = self._buckets.get(key)
if bucket is None:
bucket = TokenBucket(capacity=limit, refill_rate=limit / 3600.0)
self._buckets[key] = bucket
if not bucket.consume(1):
wait = bucket.get_wait_time(1)
logger.warning(
"Per-tool rate limit hit: user=%s tool=%s limit=%d/h retry_after=%.1fs",
user_id,
tool_name,
limit,
wait,
)
raise ToolRateLimitError(
tool_name=tool_name, limit_per_hour=limit, retry_after_seconds=wait
)
_limiter: PerToolRateLimiter | None = None
def get_tool_rate_limiter() -> PerToolRateLimiter:
global _limiter
if _limiter is None:
_limiter = PerToolRateLimiter()
return _limiter
def set_tool_rate_limiter(limiter: PerToolRateLimiter | None) -> None:
"""Override the singleton (used by tests)."""
global _limiter
_limiter = limiter

474
core/upload_sessions.py Normal file
View File

@@ -0,0 +1,474 @@
"""Chunked upload session store (F.5a.5).
Server-side buffering for large media uploads: SQLite metadata + disk spill.
Sessions are deterministic (same user+file metadata yields the same
session_id), enabling resumable uploads. Enforces per-user concurrency
quota, per-session byte cap, and a 1h TTL reaped by a background task.
Chunks are appended sequentially; out-of-order or duplicate indexes raise
a typed error. Optional full-payload sha256 (supplied at `start`) is
verified when the session is finalized.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import os
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from core.database import Database, get_database
logger = logging.getLogger(__name__)
# --- Config ----------------------------------------------------------------
DEFAULT_SPILL_DIR = Path(os.environ.get("MCPHUB_UPLOAD_SPILL_DIR", "/tmp/mcphub-uploads"))
SESSION_TTL = timedelta(seconds=int(os.environ.get("MCPHUB_UPLOAD_TTL_SEC", "3600")))
MAX_SESSION_BYTES = int(os.environ.get("MCPHUB_UPLOAD_MAX_BYTES", str(500 * 1024 * 1024)))
MAX_CONCURRENT_PER_USER = int(os.environ.get("MCPHUB_UPLOAD_MAX_CONCURRENT", "10"))
# --- Errors ----------------------------------------------------------------
class UploadSessionError(Exception):
"""Typed session error with stable JSON code."""
def __init__(self, code: str, message: str, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
def to_dict(self) -> dict:
return {"error_code": self.code, "message": self.message, "details": self.details}
# --- Helpers ---------------------------------------------------------------
def _utc_now() -> datetime:
return datetime.now(UTC)
def _iso(dt: datetime) -> str:
return dt.isoformat()
def _parse_iso(value: str) -> datetime:
dt = datetime.fromisoformat(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt
def make_session_id(
user_id: str,
filename: str,
total_bytes: int,
mime: str | None,
sha256: str | None,
) -> str:
"""Deterministic session id — same tuple → same id (enables resume)."""
payload = f"{user_id}|{filename}|{total_bytes}|{mime or ''}|{sha256 or ''}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
def _ensure_spill_dir(spill_dir: Path) -> None:
spill_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(spill_dir, 0o700)
except OSError:
pass
# --- Store -----------------------------------------------------------------
class UploadSession:
"""Runtime view of an upload session row."""
__slots__ = (
"id",
"user_id",
"filename",
"total_bytes",
"mime",
"sha256",
"received_bytes",
"next_chunk",
"spill_path",
"status",
"created_at",
"expires_at",
)
def __init__(self, **kwargs: Any) -> None:
for name in self.__slots__:
setattr(self, name, kwargs.get(name))
@classmethod
def from_row(cls, row: dict[str, Any]) -> UploadSession:
return cls(
id=row["id"],
user_id=row["user_id"],
filename=row["filename"],
total_bytes=int(row["total_bytes"]),
mime=row["mime"],
sha256=row["sha256"],
received_bytes=int(row["received_bytes"]),
next_chunk=int(row["next_chunk"]),
spill_path=Path(row["spill_path"]),
status=row["status"],
created_at=_parse_iso(row["created_at"]),
expires_at=_parse_iso(row["expires_at"]),
)
def to_public_dict(self) -> dict[str, Any]:
return {
"session_id": self.id,
"filename": self.filename,
"total_bytes": self.total_bytes,
"received_bytes": self.received_bytes,
"next_chunk": self.next_chunk,
"status": self.status,
"expires_at": _iso(self.expires_at),
}
class UploadSessionStore:
"""Persistent chunk-upload session store with disk spill."""
def __init__(
self,
*,
db: Database | None = None,
spill_dir: Path | None = None,
ttl: timedelta = SESSION_TTL,
max_session_bytes: int = MAX_SESSION_BYTES,
max_concurrent_per_user: int = MAX_CONCURRENT_PER_USER,
) -> None:
self._db = db
self.spill_dir = Path(spill_dir or DEFAULT_SPILL_DIR)
self.ttl = ttl
self.max_session_bytes = max_session_bytes
self.max_concurrent_per_user = max_concurrent_per_user
self._lock = asyncio.Lock()
_ensure_spill_dir(self.spill_dir)
@property
def db(self) -> Database:
return self._db or get_database()
# -- start -------------------------------------------------------------
async def start(
self,
*,
user_id: str,
filename: str,
total_bytes: int,
mime: str | None = None,
sha256: str | None = None,
) -> UploadSession:
if total_bytes <= 0:
raise UploadSessionError("BAD_SIZE", "total_bytes must be positive.")
if total_bytes > self.max_session_bytes:
raise UploadSessionError(
"SESSION_TOO_LARGE",
f"total_bytes {total_bytes} exceeds limit {self.max_session_bytes}.",
{"max": self.max_session_bytes},
)
session_id = make_session_id(user_id, filename, total_bytes, mime, sha256)
async with self._lock:
existing = await self._get_row(session_id)
if existing is not None:
sess = UploadSession.from_row(existing)
if sess.status == "open" and sess.expires_at > _utc_now():
return sess
# Stale/finished — replace
await self._delete_row(session_id)
_unlink_silent(sess.spill_path)
open_count = await self._count_open_for_user(user_id)
if open_count >= self.max_concurrent_per_user:
raise UploadSessionError(
"QUOTA_EXCEEDED",
f"User has {open_count} open upload sessions "
f"(max {self.max_concurrent_per_user}).",
{"open": open_count, "max": self.max_concurrent_per_user},
)
now = _utc_now()
expires = now + self.ttl
spill_path = self.spill_dir / f"{session_id}.part"
# Touch an empty spill file with 0600 perms
_ensure_spill_dir(self.spill_dir)
with open(spill_path, "wb") as f:
f.truncate(0)
try:
os.chmod(spill_path, 0o600)
except OSError:
pass
await self.db.execute(
"INSERT INTO upload_sessions "
"(id, user_id, filename, total_bytes, mime, sha256, "
" received_bytes, next_chunk, spill_path, status, "
" created_at, expires_at) "
"VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, 'open', ?, ?)",
(
session_id,
user_id,
filename,
total_bytes,
mime,
sha256,
str(spill_path),
_iso(now),
_iso(expires),
),
)
return UploadSession(
id=session_id,
user_id=user_id,
filename=filename,
total_bytes=total_bytes,
mime=mime,
sha256=sha256,
received_bytes=0,
next_chunk=0,
spill_path=spill_path,
status="open",
created_at=now,
expires_at=expires,
)
# -- append chunk ------------------------------------------------------
async def append_chunk(
self,
session_id: str,
index: int,
data: bytes,
*,
chunk_sha256: str | None = None,
) -> UploadSession:
async with self._lock:
sess = await self._require_open(session_id)
if index != sess.next_chunk:
raise UploadSessionError(
"CHUNK_ORDER",
f"Expected chunk index {sess.next_chunk}, got {index}.",
{"expected": sess.next_chunk, "got": index},
)
new_size = sess.received_bytes + len(data)
if new_size > sess.total_bytes:
raise UploadSessionError(
"CHUNK_OVERFLOW",
f"Chunk would exceed declared total_bytes "
f"({new_size} > {sess.total_bytes}).",
{"declared": sess.total_bytes, "would_be": new_size},
)
if chunk_sha256 is not None:
actual = hashlib.sha256(data).hexdigest()
if actual.lower() != chunk_sha256.lower():
raise UploadSessionError(
"CHUNK_CHECKSUM",
"Chunk sha256 does not match supplied value.",
{"expected": chunk_sha256, "actual": actual, "index": index},
)
with open(sess.spill_path, "ab") as f:
f.write(data)
sess.received_bytes = new_size
sess.next_chunk = index + 1
await self.db.execute(
"UPDATE upload_sessions SET received_bytes = ?, next_chunk = ? WHERE id = ?",
(sess.received_bytes, sess.next_chunk, session_id),
)
return sess
# -- finalize ----------------------------------------------------------
async def finalize(self, session_id: str) -> tuple[UploadSession, bytes]:
"""Read the full spill file and verify. Returns (session, bytes).
On checksum mismatch the session is kept (status remains 'open') so
the caller can retry; on success, the row and spill file are removed.
"""
async with self._lock:
sess = await self._require_open(session_id)
if sess.received_bytes != sess.total_bytes:
raise UploadSessionError(
"INCOMPLETE",
f"Received {sess.received_bytes}/{sess.total_bytes} bytes.",
{
"received": sess.received_bytes,
"total": sess.total_bytes,
},
)
with open(sess.spill_path, "rb") as f:
data = f.read()
if sess.sha256:
actual = hashlib.sha256(data).hexdigest()
if actual.lower() != sess.sha256.lower():
raise UploadSessionError(
"CHECKSUM_MISMATCH",
"Assembled sha256 does not match value supplied at start.",
{"expected": sess.sha256, "actual": actual},
)
# Success — drop the session
await self._delete_row(session_id)
_unlink_silent(sess.spill_path)
return sess, data
# -- abort -------------------------------------------------------------
async def abort(self, session_id: str) -> bool:
async with self._lock:
row = await self._get_row(session_id)
if row is None:
return False
sess = UploadSession.from_row(row)
await self._delete_row(session_id)
_unlink_silent(sess.spill_path)
return True
# -- get ---------------------------------------------------------------
async def get(self, session_id: str) -> UploadSession | None:
row = await self._get_row(session_id)
return UploadSession.from_row(row) if row else None
# -- cleanup -----------------------------------------------------------
async def cleanup_expired(self, *, now: datetime | None = None) -> int:
now = now or _utc_now()
rows = await self.db.fetchall(
"SELECT * FROM upload_sessions WHERE expires_at < ?", (_iso(now),)
)
reaped = 0
for row in rows:
sess = UploadSession.from_row(row)
_unlink_silent(sess.spill_path)
await self._delete_row(sess.id)
reaped += 1
if reaped:
logger.info("Reaped %d expired upload session(s)", reaped)
return reaped
# -- internals ---------------------------------------------------------
async def _get_row(self, session_id: str) -> dict[str, Any] | None:
return await self.db.fetchone("SELECT * FROM upload_sessions WHERE id = ?", (session_id,))
async def _delete_row(self, session_id: str) -> None:
await self.db.execute("DELETE FROM upload_sessions WHERE id = ?", (session_id,))
async def _count_open_for_user(self, user_id: str) -> int:
row = await self.db.fetchone(
"SELECT COUNT(*) AS c FROM upload_sessions "
"WHERE user_id = ? AND status = 'open' AND expires_at > ?",
(user_id, _iso(_utc_now())),
)
return int(row["c"]) if row else 0
async def _require_open(self, session_id: str) -> UploadSession:
row = await self._get_row(session_id)
if row is None:
raise UploadSessionError(
"NO_SESSION", f"Session {session_id} not found.", {"session_id": session_id}
)
sess = UploadSession.from_row(row)
if sess.status != "open":
raise UploadSessionError(
"BAD_STATE",
f"Session {session_id} is in state '{sess.status}'.",
{"state": sess.status},
)
if sess.expires_at <= _utc_now():
raise UploadSessionError(
"EXPIRED", f"Session {session_id} has expired.", {"session_id": session_id}
)
return sess
def _unlink_silent(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError as e:
logger.debug("Could not remove spill file %s: %s", path, e)
# --- Singleton + background cleanup ---------------------------------------
_store: UploadSessionStore | None = None
def get_upload_session_store() -> UploadSessionStore:
global _store
if _store is None:
_store = UploadSessionStore()
return _store
def set_upload_session_store(store: UploadSessionStore | None) -> None:
"""Override the singleton (used by tests)."""
global _store
_store = store
class CleanupTask:
"""Periodically reaps expired sessions. Register in server lifespan."""
def __init__(
self,
store: UploadSessionStore | None = None,
interval_seconds: int = 300,
) -> None:
self._store = store
self.interval = interval_seconds
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
self.id = uuid.uuid4().hex[:8]
@property
def store(self) -> UploadSessionStore:
return self._store or get_upload_session_store()
async def start(self) -> None:
if self._task is not None:
return
self._stop.clear()
self._task = asyncio.create_task(self._run(), name=f"upload-cleanup-{self.id}")
async def stop(self) -> None:
if self._task is None:
return
self._stop.set()
self._task.cancel()
try:
await self._task
except (asyncio.CancelledError, Exception):
pass
self._task = None
async def _run(self) -> None:
while not self._stop.is_set():
try:
await self.store.cleanup_expired()
except Exception as e: # noqa: BLE001
logger.warning("Upload-session cleanup error: %s", e)
try:
await asyncio.wait_for(self._stop.wait(), timeout=self.interval)
except TimeoutError:
continue

View File

@@ -70,12 +70,27 @@ def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
return True, ""
def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
def _tools_to_mcp_schema(
tools: list[ToolDefinition],
*,
configured_providers: list[str] | None = None,
) -> list[dict[str, Any]]:
"""Convert ToolDefinition objects into MCP ``tools/list`` response shape.
Strips the auto-injected ``site`` parameter, since user endpoints bind a
single site per alias.
F.X.fix-pass6 — when ``configured_providers`` is given (i.e. the
site has at least one AI provider key configured), narrow the
``provider`` enum on the AI image tool to that subset. The model
only sees providers that will actually succeed, instead of trying
OpenAI / Stability / Replicate and getting NO_PROVIDER_KEY when
only OpenRouter is configured.
"""
ai_image_tools = {
"wordpress_generate_and_upload_image",
"woocommerce_generate_and_upload_image",
}
result = []
for tool_def in tools:
schema = deepcopy(tool_def.input_schema)
@@ -84,6 +99,22 @@ def _tools_to_mcp_schema(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
if "required" in schema and "site" in schema["required"]:
schema["required"] = [r for r in schema["required"] if r != "site"]
if (
configured_providers
and tool_def.name in ai_image_tools
and "properties" in schema
and "provider" in schema["properties"]
):
schema["properties"]["provider"] = {
**schema["properties"]["provider"],
"enum": list(configured_providers),
"description": (
"AI provider to use. This site has the following providers "
f"configured: {', '.join(configured_providers)}. Add more "
"in Connection Settings → AI Image Generation."
),
}
result.append(
{
"name": tool_def.name,
@@ -99,14 +130,41 @@ async def _get_visible_tools_for_site(
key_scopes: list[str],
plugin_type: str,
) -> list[dict[str, Any]]:
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b)."""
"""Return tools/list payload filtered by key scope + site scope + toggles (F.7b).
F.5a.9.x: additionally hide ``wordpress_generate_and_upload_image`` when
the site has no provider API key configured — the tool would fail at
call-time with ``NO_PROVIDER_KEY`` anyway, so hiding it keeps the
surface honest for AI clients.
"""
from core.tool_access import get_tool_access_manager
access = get_tool_access_manager()
tools = await access.get_visible_tools(
site_id=site_id, key_scopes=key_scopes, plugin_type=plugin_type
)
return _tools_to_mcp_schema(tools)
configured_providers: list[str] = []
if plugin_type in {"wordpress", "woocommerce"}:
from core.site_api import list_site_providers_set
configured = await list_site_providers_set(site_id)
if not configured:
tools = [
t
for t in tools
if t.name
not in {
"wordpress_generate_and_upload_image",
"woocommerce_generate_and_upload_image",
}
]
else:
# F.X.fix-pass6 — pass the configured set so the AI image
# tool's `provider` enum can be narrowed at /tools/list time.
configured_providers = sorted(configured)
return _tools_to_mcp_schema(tools, configured_providers=configured_providers)
async def _execute_tool(
@@ -466,10 +524,16 @@ async def user_mcp_handler(request: Request) -> Response:
)
# Build config dict for plugin instantiation
# F.5a.4: pass user_id so plugins can look up per-user secrets.
# F.5a.9.x: pass site_id so the WP AI-media handler can resolve
# per-site provider API keys (replaces per-user keys for
# wordpress_generate_and_upload_image).
config_dict = {
"site_url": site["url"],
"url": site["url"],
"alias": alias,
"user_id": user_id,
"site_id": site["id"],
**credentials,
}