sync: v3.2.0 — Supabase fixes, site edit, WP plugin rename, black formatting
- Supabase: fix filters, count_rows, upsert, anon_key optional, meta_url field - Dashboard: site edit page + PATCH update route - WP Plugin: rename SEO API Bridge → Airano MCP SEO Bridge - supabase/client.py: black formatting fix - .gitignore: exclude BUILD_REPORT.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,6 +29,7 @@ ENV/
|
||||
htmlcov/
|
||||
.tox/
|
||||
pytest-out.txt
|
||||
scripts/community-build/BUILD_REPORT.md
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
@@ -122,7 +122,11 @@ DASHBOARD_TRANSLATIONS = {
|
||||
"no_sites": "No sites added yet",
|
||||
"add_first_site": "Add your first site to get started",
|
||||
"site_added": "Site added successfully",
|
||||
"site_updated": "Site updated successfully",
|
||||
"site_deleted": "Site deleted",
|
||||
"edit_site": "Edit Site",
|
||||
"updating_site": "Updating site...",
|
||||
"keep_existing": "Leave blank to keep current value",
|
||||
"connection_ok": "Connection OK",
|
||||
"connection_failed": "Connection failed",
|
||||
"credentials": "Credentials",
|
||||
@@ -218,7 +222,11 @@ DASHBOARD_TRANSLATIONS = {
|
||||
"no_sites": "هنوز سایتی اضافه نشده",
|
||||
"add_first_site": "اولین سایت خود را اضافه کنید",
|
||||
"site_added": "سایت با موفقیت اضافه شد",
|
||||
"site_updated": "سایت با موفقیت بروزرسانی شد",
|
||||
"site_deleted": "سایت حذف شد",
|
||||
"edit_site": "ویرایش سایت",
|
||||
"updating_site": "در حال بروزرسانی...",
|
||||
"keep_existing": "خالی بگذارید تا مقدار فعلی حفظ شود",
|
||||
"connection_ok": "اتصال برقرار",
|
||||
"connection_failed": "اتصال ناموفق",
|
||||
"credentials": "مشخصات دسترسی",
|
||||
@@ -2811,6 +2819,82 @@ async def api_test_site(request: Request) -> Response:
|
||||
return JSONResponse({"error": "Internal error"}, status_code=500)
|
||||
|
||||
|
||||
async def dashboard_sites_edit(request: Request) -> Response:
|
||||
"""GET /dashboard/sites/{id}/edit — Render the Edit Site form."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
site_id = request.path_params.get("id", "")
|
||||
|
||||
from core.site_api import get_user_credential_fields, get_user_plugin_names, get_user_site
|
||||
|
||||
site = await get_user_site(site_id, user_session["user_id"])
|
||||
if site is None:
|
||||
return RedirectResponse("/dashboard/sites?error=site_not_found", status_code=302)
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
query_lang = request.query_params.get("lang")
|
||||
lang = detect_language(accept_language, query_lang)
|
||||
t = get_translations(lang)
|
||||
|
||||
import json
|
||||
|
||||
plugin_fields = get_user_credential_fields()
|
||||
plugin_names = get_user_plugin_names()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"dashboard/sites/edit.html",
|
||||
{
|
||||
"request": request,
|
||||
"lang": lang,
|
||||
"t": t,
|
||||
"session": user_session,
|
||||
"site": site,
|
||||
"plugin_fields": plugin_fields,
|
||||
"plugin_fields_json": json.dumps(plugin_fields),
|
||||
"plugin_names": plugin_names,
|
||||
"current_page": "my_sites",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def api_update_site(request: Request) -> Response:
|
||||
"""PATCH /api/sites/{id} — Update site URL and credentials."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
site_id = request.path_params.get("id", "")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
from core.site_api import update_user_site
|
||||
|
||||
try:
|
||||
site = await update_user_site(
|
||||
site_id=site_id,
|
||||
user_id=user_session["user_id"],
|
||||
url=body.get("url", "").strip().rstrip("/"),
|
||||
credentials=body.get("credentials", {}),
|
||||
)
|
||||
return JSONResponse({"site": site, "message": "Site updated"})
|
||||
except ValueError as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=400)
|
||||
except RuntimeError as e:
|
||||
logger.error("Site update failed (runtime): %s", e)
|
||||
return JSONResponse(
|
||||
{"error": "Site storage is not configured. Contact the administrator."},
|
||||
status_code=503,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to update site: %s", e, exc_info=True)
|
||||
return JSONResponse({"error": "Internal error"}, status_code=500)
|
||||
|
||||
|
||||
async def api_create_key(request: Request) -> Response:
|
||||
"""POST /api/keys — Create a new user API key."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
@@ -3090,6 +3174,7 @@ def register_dashboard_routes(mcp):
|
||||
# Site Management routes (E.3)
|
||||
mcp.custom_route("/dashboard/sites", methods=["GET"])(dashboard_sites_list)
|
||||
mcp.custom_route("/dashboard/sites/add", methods=["GET"])(dashboard_sites_add)
|
||||
mcp.custom_route("/dashboard/sites/{id}/edit", methods=["GET"])(dashboard_sites_edit)
|
||||
mcp.custom_route("/dashboard/connect", methods=["GET"])(dashboard_connect_page)
|
||||
|
||||
# Site Management API (E.3)
|
||||
@@ -3097,6 +3182,7 @@ def register_dashboard_routes(mcp):
|
||||
mcp.custom_route("/api/sites", methods=["POST"])(api_create_site)
|
||||
mcp.custom_route("/api/sites/{id}/test", methods=["POST"])(api_test_site)
|
||||
mcp.custom_route("/api/sites/{id}", methods=["DELETE"])(api_delete_site)
|
||||
mcp.custom_route("/api/sites/{id}", methods=["PATCH"])(api_update_site)
|
||||
|
||||
# User API Key routes (E.3)
|
||||
mcp.custom_route("/api/keys", methods=["GET"])(api_list_keys)
|
||||
|
||||
@@ -530,6 +530,30 @@ class Database:
|
||||
(status, status_msg, site_id),
|
||||
)
|
||||
|
||||
async def update_site_credentials(
|
||||
self,
|
||||
site_id: str,
|
||||
user_id: str,
|
||||
url: str,
|
||||
credentials: bytes,
|
||||
) -> bool:
|
||||
"""Update URL and credentials for an existing site.
|
||||
|
||||
Args:
|
||||
site_id: Site UUID.
|
||||
user_id: Owner's UUID (for tenant isolation).
|
||||
url: New base URL for the site.
|
||||
credentials: New AES-256-GCM encrypted credentials blob.
|
||||
|
||||
Returns:
|
||||
True if a row was updated, False if site not found or not owned by user.
|
||||
"""
|
||||
cursor = await self.execute(
|
||||
"UPDATE sites SET url = ?, credentials = ?, status = 'pending' WHERE id = ? AND user_id = ?",
|
||||
(url, credentials, site_id, user_id),
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
async def get_site_by_alias(self, user_id: str, alias: str) -> dict[str, Any] | None:
|
||||
"""Get a site by user ID and alias.
|
||||
|
||||
|
||||
121
core/site_api.py
121
core/site_api.py
@@ -108,14 +108,34 @@ PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
||||
"label": "Service Role Key",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "Supabase Dashboard → Settings → API → service_role key",
|
||||
"hint": (
|
||||
"Supabase Dashboard → Settings → API → service_role key. "
|
||||
"Note: On supabase.com cloud, postgres-meta tools "
|
||||
"(list_tables, execute_sql, get_table_schema, etc.) are not available — "
|
||||
"they only work on self-hosted Supabase."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "anon_key",
|
||||
"label": "Anon Key",
|
||||
"label": "Anon Key (Optional)",
|
||||
"type": "password",
|
||||
"required": True,
|
||||
"hint": "Supabase Dashboard → Settings → API → anon key",
|
||||
"required": False,
|
||||
"hint": (
|
||||
"Supabase Dashboard → Settings → API → anon key. "
|
||||
"Optional — if omitted, service_role_key is used for all calls."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "meta_url",
|
||||
"label": "postgres-meta URL (Optional)",
|
||||
"type": "text",
|
||||
"required": False,
|
||||
"hint": (
|
||||
"Only needed when /pg/ is not exposed through Kong (common on Coolify). "
|
||||
"Enter the direct postgres-meta container URL, e.g.: "
|
||||
"http://supabase-meta-<id>:8080 — "
|
||||
"Leave blank to use the default Kong /pg/ route."
|
||||
),
|
||||
},
|
||||
],
|
||||
"openpanel": [
|
||||
@@ -509,6 +529,99 @@ async def delete_user_site(site_id: str, user_id: str) -> bool:
|
||||
return deleted
|
||||
|
||||
|
||||
async def update_user_site(
|
||||
site_id: str,
|
||||
user_id: str,
|
||||
url: str,
|
||||
credentials: dict[str, str],
|
||||
skip_validation: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Update URL and credentials for an existing site.
|
||||
|
||||
Password fields left blank are preserved from the existing encrypted credentials.
|
||||
Re-validates the connection after update.
|
||||
|
||||
Args:
|
||||
site_id: Site UUID.
|
||||
user_id: Owner's UUID.
|
||||
url: New base URL (required).
|
||||
credentials: Credential dict — blank password fields keep their current value.
|
||||
skip_validation: If True, skip connection test (for testing).
|
||||
|
||||
Returns:
|
||||
The updated site dict (without decrypted credentials).
|
||||
|
||||
Raises:
|
||||
ValueError: If site not found, validation fails, or connection test fails.
|
||||
"""
|
||||
from core.database import get_database
|
||||
from core.encryption import get_credential_encryption
|
||||
|
||||
db = get_database()
|
||||
|
||||
# Verify site ownership
|
||||
existing_site = await db.get_site(site_id, user_id)
|
||||
if existing_site is None:
|
||||
raise ValueError("Site not found")
|
||||
|
||||
plugin_type = existing_site["plugin_type"]
|
||||
|
||||
# Validate URL
|
||||
url = url.strip().rstrip("/")
|
||||
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
||||
raise ValueError("URL must start with http:// or https://")
|
||||
|
||||
# Merge new credentials with existing ones — blank fields keep existing values
|
||||
encryptor = get_credential_encryption()
|
||||
existing_credentials = encryptor.decrypt_credentials(existing_site["credentials"], site_id)
|
||||
|
||||
merged: dict[str, str] = dict(existing_credentials)
|
||||
for key, value in credentials.items():
|
||||
# Only override if the new value is non-empty
|
||||
if value and value.strip():
|
||||
merged[key] = value.strip()
|
||||
# Blank value for a non-required field (e.g. meta_url) → explicitly clear it
|
||||
else:
|
||||
field_defs = {f["name"]: f for f in PLUGIN_CREDENTIAL_FIELDS.get(plugin_type, [])}
|
||||
if key in field_defs and not field_defs[key].get("required", True):
|
||||
merged[key] = ""
|
||||
|
||||
# Strip empty optional values before storing (keep storage clean)
|
||||
merged = {k: v for k, v in merged.items() if v}
|
||||
|
||||
# Validate required fields are still present
|
||||
valid, errors = validate_credentials(plugin_type, merged)
|
||||
if not valid:
|
||||
raise ValueError(f"Missing required credentials: {', '.join(errors)}")
|
||||
|
||||
# Test connection
|
||||
if not skip_validation:
|
||||
ok, msg = await validate_site_connection(plugin_type, url, merged)
|
||||
if not ok:
|
||||
raise ValueError(f"Connection test failed: {msg}")
|
||||
|
||||
# Encrypt merged credentials
|
||||
encrypted = encryptor.encrypt_credentials(merged, site_id)
|
||||
|
||||
# Persist
|
||||
updated = await db.update_site_credentials(site_id, user_id, url, encrypted)
|
||||
if not updated:
|
||||
raise RuntimeError(f"Failed to update site {site_id}")
|
||||
|
||||
# Mark active after successful connection test
|
||||
status_msg = "Connection verified" if not skip_validation else "Updated (not tested)"
|
||||
await db.update_site_status(site_id, "active", status_msg, user_id=user_id)
|
||||
|
||||
result = await db.get_site(site_id, user_id)
|
||||
if result is None:
|
||||
raise RuntimeError(f"Failed to read back updated site {site_id}")
|
||||
|
||||
site_dict = dict(result)
|
||||
site_dict.pop("credentials", None)
|
||||
logger.info("Updated site %s (%s) for user %s", site_id, plugin_type, user_id)
|
||||
return site_dict
|
||||
|
||||
|
||||
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
|
||||
"""Test connectivity to an existing site.
|
||||
|
||||
|
||||
134
core/templates/dashboard/sites/edit.html
Normal file
134
core/templates/dashboard/sites/edit.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ t.edit_site }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ t.edit_site }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.edit_site }}: {{ site.alias }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Info notice about password fields -->
|
||||
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-700 dark:text-blue-300 px-4 py-3 rounded-lg text-sm">
|
||||
{{ t.keep_existing }}
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||
|
||||
<form id="edit-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||
|
||||
<!-- Plugin Type (read-only) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||
<input type="url" id="url" name="url" required value="{{ site.url }}"
|
||||
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">
|
||||
</div>
|
||||
|
||||
<!-- Alias (read-only) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||
<div class="w-full bg-gray-100 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||
{{ site.alias }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credential Fields -->
|
||||
<div id="credential-fields" class="space-y-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>
|
||||
{% set fields = plugin_fields.get(site.plugin_type, []) %}
|
||||
{% for field in fields %}
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{{ field.label }}{% if field.required %} *{% endif %}
|
||||
</label>
|
||||
<input type="{{ field.type }}" name="cred_{{ field.name }}"
|
||||
{% if field.type == 'password' %}placeholder="{{ t.keep_existing }}"{% endif %}
|
||||
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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex items-center gap-4 pt-4">
|
||||
<button type="submit" id="submit-btn"
|
||||
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||
{{ t.save }}
|
||||
</button>
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
{{ t.cancel }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const siteId = "{{ site.id }}";
|
||||
const pluginType = "{{ site.plugin_type }}";
|
||||
const pluginFields = {{ plugin_fields_json | safe }};
|
||||
|
||||
document.getElementById('edit-site-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submit-btn');
|
||||
const errorEl = document.getElementById('error-msg');
|
||||
errorEl.classList.add('hidden');
|
||||
|
||||
btn.textContent = '{{ t.updating_site }}';
|
||||
btn.disabled = true;
|
||||
|
||||
const url = document.getElementById('url').value;
|
||||
|
||||
// Collect credentials — only include non-empty values
|
||||
const creds = {};
|
||||
if (pluginFields[pluginType]) {
|
||||
pluginFields[pluginType].forEach(field => {
|
||||
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||
if (input) creds[field.name] = input.value;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/sites/' + siteId, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url, credentials: creds }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
window.location.href = '/dashboard/sites?msg={{ t.site_updated }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||
} else {
|
||||
errorEl.textContent = data.error || 'Failed to update site';
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.save }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = 'Network error: ' + err.message;
|
||||
errorEl.classList.remove('hidden');
|
||||
btn.textContent = '{{ t.save }}';
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -73,6 +73,10 @@
|
||||
id="test-btn-{{ site.id }}">
|
||||
{{ t.test_connection }}
|
||||
</button>
|
||||
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
{{ t.edit }}
|
||||
</a>
|
||||
<button onclick="deleteSite('{{ site.id }}')"
|
||||
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||
{{ t.delete }}
|
||||
|
||||
23
env.example
23
env.example
@@ -98,11 +98,26 @@ WORDPRESS_SITE1_ALIAS=mysite
|
||||
# N8N_INSTANCE1_API_KEY=your_n8n_api_key
|
||||
# N8N_INSTANCE1_ALIAS=myn8n
|
||||
|
||||
# --- Supabase ---
|
||||
# SUPABASE_PROJECT1_URL=https://xxxxx.supabase.co
|
||||
# SUPABASE_PROJECT1_API_KEY=your_supabase_api_key
|
||||
# SUPABASE_PROJECT1_SERVICE_ROLE=your_service_role_key
|
||||
# --- Supabase (Self-Hosted or Cloud) ---
|
||||
# Works with both self-hosted Supabase and supabase.com cloud projects.
|
||||
# For cloud: URL is https://xxxx.supabase.co
|
||||
# For self-hosted: URL is your Kong gateway (e.g., http://your-server:8000)
|
||||
#
|
||||
# SERVICE_ROLE_KEY is required (bypasses RLS, full admin access).
|
||||
# ANON_KEY is optional — if omitted, SERVICE_ROLE_KEY is used for all calls.
|
||||
#
|
||||
# Note: postgres-meta tools (list_tables, execute_sql, get_table_schema, etc.)
|
||||
# require the /pg/ Kong route or a direct META_URL. These tools do NOT work
|
||||
# on supabase.com cloud (postgres-meta is internal only on cloud).
|
||||
# PostgREST/Auth/Storage/Functions tools work on both cloud and self-hosted.
|
||||
#
|
||||
# SUPABASE_PROJECT1_URL=https://xxxx.supabase.co
|
||||
# SUPABASE_PROJECT1_SERVICE_ROLE_KEY=your_service_role_key
|
||||
# SUPABASE_PROJECT1_ANON_KEY=your_anon_key
|
||||
# SUPABASE_PROJECT1_ALIAS=mysupabase
|
||||
#
|
||||
# Optional: direct postgres-meta URL for self-hosted when /pg/ is not via Kong:
|
||||
# SUPABASE_PROJECT1_META_URL=http://localhost:5555
|
||||
|
||||
# --- OpenPanel ---
|
||||
# OPENPANEL_INSTANCE1_URL=https://openpanel.example.com
|
||||
|
||||
@@ -27,18 +27,30 @@ class SupabaseClient:
|
||||
Uses JWT-based authentication with anon_key or service_role_key.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, anon_key: str, service_role_key: str):
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
anon_key: str,
|
||||
service_role_key: str,
|
||||
meta_url: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize Supabase API client.
|
||||
|
||||
Args:
|
||||
base_url: Supabase instance URL (Kong gateway)
|
||||
anon_key: Public API key (RLS protected)
|
||||
service_role_key: Admin API key (bypasses RLS)
|
||||
base_url: Supabase instance URL (Kong gateway or supabase.co)
|
||||
anon_key: Public API key (RLS protected). Optional — if empty,
|
||||
service_role_key is used for all calls.
|
||||
service_role_key: Admin API key (bypasses RLS). Required.
|
||||
meta_url: Optional direct postgres-meta URL (e.g. http://localhost:5555).
|
||||
When provided, postgres-meta calls hit this URL directly instead of
|
||||
the Kong /pg/ route. Useful when /pg/ is not exposed through Kong.
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.anon_key = anon_key
|
||||
self.service_role_key = service_role_key
|
||||
# postgres-meta base: custom URL or Kong /pg/ prefix
|
||||
self.meta_base_url = (meta_url or f"{self.base_url}/pg").rstrip("/")
|
||||
|
||||
# Initialize logger
|
||||
self.logger = logging.getLogger(f"SupabaseClient.{base_url}")
|
||||
@@ -56,7 +68,8 @@ class SupabaseClient:
|
||||
Returns:
|
||||
Dict: Headers with authentication
|
||||
"""
|
||||
key = self.service_role_key if use_service_role else self.anon_key
|
||||
# If anon_key is not configured, fall back to service_role_key for all calls
|
||||
key = self.service_role_key if (use_service_role or not self.anon_key) else self.anon_key
|
||||
|
||||
headers = {
|
||||
"apikey": key,
|
||||
@@ -70,15 +83,41 @@ class SupabaseClient:
|
||||
|
||||
return headers
|
||||
|
||||
async def _head_request_headers(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
headers_override: dict | None = None,
|
||||
use_service_role: bool = False,
|
||||
base_url_override: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Make HEAD request and return response headers.
|
||||
|
||||
Used for operations like count_rows where the result is in a response
|
||||
header (Content-Range) rather than the body.
|
||||
"""
|
||||
url = f"{base_url_override or self.base_url}{endpoint}"
|
||||
headers = self._get_headers(use_service_role, headers_override)
|
||||
if params:
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.head(url, params=params or None, headers=headers) as response:
|
||||
# Normalise to lowercase so callers can use consistent keys
|
||||
# (aiohttp CIMultiDictProxy is case-insensitive but dict() is not)
|
||||
return {k.lower(): v for k, v in response.headers.items()}
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
json_data: dict | None = None,
|
||||
json_data: Any = None,
|
||||
data: bytes | None = None,
|
||||
headers_override: dict | None = None,
|
||||
use_service_role: bool = False,
|
||||
base_url_override: str | None = None,
|
||||
) -> Any:
|
||||
"""
|
||||
Make authenticated request to Supabase API.
|
||||
@@ -87,10 +126,11 @@ class SupabaseClient:
|
||||
method: HTTP method
|
||||
endpoint: API endpoint (with leading /)
|
||||
params: Query parameters
|
||||
json_data: JSON body data
|
||||
json_data: JSON body data (dict or list)
|
||||
data: Raw binary data (for file uploads)
|
||||
headers_override: Override/add headers
|
||||
use_service_role: Use service_role_key
|
||||
base_url_override: Override base URL (used for postgres-meta calls)
|
||||
|
||||
Returns:
|
||||
API response
|
||||
@@ -98,7 +138,7 @@ class SupabaseClient:
|
||||
Raises:
|
||||
Exception: On API errors
|
||||
"""
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
url = f"{base_url_override or self.base_url}{endpoint}"
|
||||
|
||||
headers = self._get_headers(use_service_role, headers_override)
|
||||
|
||||
@@ -106,10 +146,10 @@ class SupabaseClient:
|
||||
if data is not None:
|
||||
headers.pop("Content-Type", None)
|
||||
|
||||
# Filter None values
|
||||
# Filter None values from params and dict json bodies
|
||||
if params:
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
if json_data:
|
||||
if json_data and isinstance(json_data, dict):
|
||||
json_data = {k: v for k, v in json_data.items() if v is not None}
|
||||
|
||||
self.logger.debug(f"{method} {url}")
|
||||
@@ -167,18 +207,70 @@ class SupabaseClient:
|
||||
def _extract_error_message(self, response_data: Any) -> str:
|
||||
"""Extract error message from various response formats."""
|
||||
if isinstance(response_data, dict):
|
||||
# PostgREST error format
|
||||
# PostgREST error format — includes code, details, hint
|
||||
if "message" in response_data:
|
||||
return response_data["message"]
|
||||
parts = [response_data["message"]]
|
||||
if response_data.get("code"):
|
||||
parts.insert(0, f"[{response_data['code']}]")
|
||||
if response_data.get("details"):
|
||||
parts.append(f"Details: {response_data['details']}")
|
||||
if response_data.get("detail"):
|
||||
parts.append(f"Detail: {response_data['detail']}")
|
||||
if response_data.get("hint"):
|
||||
parts.append(f"Hint: {response_data['hint']}")
|
||||
return " | ".join(parts)
|
||||
# GoTrue error format
|
||||
if "error_description" in response_data:
|
||||
return response_data["error_description"]
|
||||
if "msg" in response_data:
|
||||
return response_data["msg"]
|
||||
# postgres-meta error format — "error" key with optional PG fields
|
||||
if "error" in response_data:
|
||||
return response_data["error"]
|
||||
parts = [str(response_data["error"])]
|
||||
if response_data.get("code"):
|
||||
parts.insert(0, f"[{response_data['code']}]")
|
||||
if response_data.get("hint"):
|
||||
parts.append(f"Hint: {response_data['hint']}")
|
||||
if response_data.get("detail"):
|
||||
parts.append(f"Detail: {response_data['detail']}")
|
||||
if response_data.get("position"):
|
||||
parts.append(f"Position: {response_data['position']}")
|
||||
return " | ".join(parts)
|
||||
return str(response_data)
|
||||
|
||||
def _build_filter_params(self, filters: list[dict]) -> dict[str, str]:
|
||||
"""
|
||||
Convert a filter list to PostgREST query parameters.
|
||||
|
||||
Handles special cases:
|
||||
- ``is`` operator with Python ``None`` → ``is.null``
|
||||
- ``is`` operator with Python bool → ``is.true`` / ``is.false``
|
||||
- ``in`` operator with a list → ``in.(a,b,c)``
|
||||
- Any other operator with ``None`` value is skipped (prevents sending
|
||||
a bare ``col=eq.None`` which PostgREST would reject).
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
for f in filters:
|
||||
col = f.get("column")
|
||||
op = f.get("operator", "eq")
|
||||
val = f.get("value")
|
||||
if not col:
|
||||
continue
|
||||
if val is None:
|
||||
# Only ``is`` / ``not.is`` accept null
|
||||
if op in ("is", "not.is"):
|
||||
val = "null"
|
||||
else:
|
||||
continue
|
||||
elif isinstance(val, bool):
|
||||
# Convert Python bool to lowercase string for PostgREST
|
||||
val = "true" if val else "false"
|
||||
elif op == "in" and isinstance(val, list):
|
||||
# PostgREST ``in`` format: status=in.(active,inactive)
|
||||
val = f"({','.join(str(v) for v in val)})"
|
||||
params[col] = f"{op}.{val}"
|
||||
return params
|
||||
|
||||
# =====================
|
||||
# POSTGREST (Database)
|
||||
# =====================
|
||||
@@ -204,20 +296,15 @@ class SupabaseClient:
|
||||
limit: Maximum rows
|
||||
offset: Offset for pagination
|
||||
"""
|
||||
params = {"select": select, "limit": limit, "offset": offset}
|
||||
params: dict[str, Any] = {"select": select, "limit": limit, "offset": offset}
|
||||
|
||||
if order:
|
||||
params["order"] = order
|
||||
|
||||
# Build filter query string
|
||||
headers = {}
|
||||
if filters:
|
||||
for f in filters:
|
||||
col = f.get("column")
|
||||
op = f.get("operator", "eq")
|
||||
val = f.get("value")
|
||||
if col and val is not None:
|
||||
params[col] = f"{op}.{val}"
|
||||
params.update(self._build_filter_params(filters))
|
||||
|
||||
headers = {}
|
||||
|
||||
# Request single objects as array
|
||||
headers["Accept"] = "application/json"
|
||||
@@ -240,15 +327,18 @@ class SupabaseClient:
|
||||
) -> list[dict]:
|
||||
"""Insert rows into a table."""
|
||||
headers = {"Prefer": "return=representation"}
|
||||
upsert_params: dict[str, str] = {}
|
||||
|
||||
if upsert:
|
||||
headers["Prefer"] = "return=representation,resolution=merge-duplicates"
|
||||
if on_conflict:
|
||||
headers["on-conflict"] = on_conflict
|
||||
# PostgREST expects on_conflict as a query parameter, not a header
|
||||
upsert_params["on_conflict"] = on_conflict
|
||||
|
||||
return await self.request(
|
||||
"POST",
|
||||
f"/rest/v1/{table}",
|
||||
params=upsert_params or None,
|
||||
json_data=rows if isinstance(rows, list) else [rows],
|
||||
headers_override=headers,
|
||||
use_service_role=use_service_role,
|
||||
@@ -258,14 +348,7 @@ class SupabaseClient:
|
||||
self, table: str, data: dict, filters: list[dict], use_service_role: bool = False
|
||||
) -> list[dict]:
|
||||
"""Update rows matching filters."""
|
||||
params = {}
|
||||
for f in filters:
|
||||
col = f.get("column")
|
||||
op = f.get("operator", "eq")
|
||||
val = f.get("value")
|
||||
if col and val is not None:
|
||||
params[col] = f"{op}.{val}"
|
||||
|
||||
params = self._build_filter_params(filters)
|
||||
headers = {"Prefer": "return=representation"}
|
||||
|
||||
return await self.request(
|
||||
@@ -281,14 +364,7 @@ class SupabaseClient:
|
||||
self, table: str, filters: list[dict], use_service_role: bool = False
|
||||
) -> list[dict]:
|
||||
"""Delete rows matching filters."""
|
||||
params = {}
|
||||
for f in filters:
|
||||
col = f.get("column")
|
||||
op = f.get("operator", "eq")
|
||||
val = f.get("value")
|
||||
if col and val is not None:
|
||||
params[col] = f"{op}.{val}"
|
||||
|
||||
params = self._build_filter_params(filters)
|
||||
headers = {"Prefer": "return=representation"}
|
||||
|
||||
return await self.request(
|
||||
@@ -313,94 +389,133 @@ class SupabaseClient:
|
||||
async def count_rows(
|
||||
self, table: str, filters: list[dict] | None = None, use_service_role: bool = False
|
||||
) -> int:
|
||||
"""Count rows in a table."""
|
||||
params = {"select": "count"}
|
||||
"""Count rows in a table using HEAD + Content-Range header."""
|
||||
filter_params = self._build_filter_params(filters) if filters else {}
|
||||
|
||||
if filters:
|
||||
for f in filters:
|
||||
col = f.get("column")
|
||||
op = f.get("operator", "eq")
|
||||
val = f.get("value")
|
||||
if col and val is not None:
|
||||
params[col] = f"{op}.{val}"
|
||||
|
||||
headers = {"Accept": "application/json", "Prefer": "count=exact"}
|
||||
|
||||
result = await self.request(
|
||||
"HEAD",
|
||||
response_headers = await self._head_request_headers(
|
||||
f"/rest/v1/{table}",
|
||||
params=params,
|
||||
headers_override=headers,
|
||||
use_service_role=use_service_role,
|
||||
)
|
||||
|
||||
# Count is in Content-Range header for HEAD requests
|
||||
# Fallback to query approach
|
||||
result = await self.request(
|
||||
"GET",
|
||||
f"/rest/v1/{table}",
|
||||
params={"select": "count", **{k: v for k, v in params.items() if k != "select"}},
|
||||
params=filter_params or None,
|
||||
headers_override={"Prefer": "count=exact"},
|
||||
use_service_role=use_service_role,
|
||||
)
|
||||
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
return result[0].get("count", 0)
|
||||
# PostgREST returns count in Content-Range: 0-N/TOTAL or */TOTAL
|
||||
content_range = response_headers.get("content-range", "")
|
||||
if "/" in content_range:
|
||||
try:
|
||||
total = content_range.split("/")[-1]
|
||||
if total != "*":
|
||||
return int(total)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
# =====================
|
||||
# POSTGRES-META (Admin)
|
||||
# =====================
|
||||
# All postgres-meta requests use self.meta_base_url (default: {base_url}/pg).
|
||||
# Set META_URL env var for direct postgres-meta access (e.g., http://host:5555).
|
||||
# Note: postgres-meta /columns, /policies, /triggers do NOT support filtering
|
||||
# by table_name as a query param — we filter the results in Python instead.
|
||||
|
||||
async def list_tables(self, schema: str = "public") -> list[dict]:
|
||||
"""List all tables in a schema."""
|
||||
"""List all tables via postgres-meta."""
|
||||
return await self.request(
|
||||
"GET", "/pg/tables", params={"include_system_schemas": "false"}, use_service_role=True
|
||||
"GET",
|
||||
"/tables",
|
||||
params={"include_system_schemas": "false"},
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
|
||||
async def get_table_schema(self, table: str, schema: str = "public") -> dict:
|
||||
"""Get table schema/columns."""
|
||||
columns = await self.request(
|
||||
"GET", "/pg/columns", params={"table_name": table}, use_service_role=True
|
||||
"""Get table schema/columns via postgres-meta, filtered by table+schema in Python."""
|
||||
all_columns = await self.request(
|
||||
"GET",
|
||||
"/columns",
|
||||
params={"include_system_schemas": "false"},
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
# postgres-meta /columns does not support table_name filtering — filter here
|
||||
columns = [
|
||||
col
|
||||
for col in (all_columns if isinstance(all_columns, list) else [])
|
||||
if col.get("table") == table and col.get("schema") == schema
|
||||
]
|
||||
return {"table": table, "schema": schema, "columns": columns}
|
||||
|
||||
async def list_schemas(self) -> list[dict]:
|
||||
"""List all database schemas."""
|
||||
return await self.request("GET", "/pg/schemas", use_service_role=True)
|
||||
"""List all database schemas via postgres-meta."""
|
||||
return await self.request(
|
||||
"GET",
|
||||
"/schemas",
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
|
||||
async def list_extensions(self) -> list[dict]:
|
||||
"""List installed extensions."""
|
||||
return await self.request("GET", "/pg/extensions", use_service_role=True)
|
||||
"""List installed extensions via postgres-meta."""
|
||||
return await self.request(
|
||||
"GET",
|
||||
"/extensions",
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
|
||||
async def list_policies(self, table: str | None = None) -> list[dict]:
|
||||
"""List RLS policies."""
|
||||
params = {}
|
||||
if table:
|
||||
params["table_name"] = table
|
||||
return await self.request("GET", "/pg/policies", params=params, use_service_role=True)
|
||||
"""List RLS policies via postgres-meta, optionally filtered by table name."""
|
||||
all_policies = await self.request(
|
||||
"GET",
|
||||
"/policies",
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
# postgres-meta /policies does not support table_name filtering — filter here
|
||||
if table and isinstance(all_policies, list):
|
||||
return [p for p in all_policies if p.get("table") == table]
|
||||
return all_policies
|
||||
|
||||
async def list_roles(self) -> list[dict]:
|
||||
"""List database roles."""
|
||||
return await self.request("GET", "/pg/roles", use_service_role=True)
|
||||
"""List database roles via postgres-meta."""
|
||||
return await self.request(
|
||||
"GET",
|
||||
"/roles",
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
|
||||
async def list_triggers(self, table: str | None = None) -> list[dict]:
|
||||
"""List triggers."""
|
||||
params = {}
|
||||
if table:
|
||||
params["table_name"] = table
|
||||
return await self.request("GET", "/pg/triggers", params=params, use_service_role=True)
|
||||
"""List triggers via postgres-meta, optionally filtered by table name."""
|
||||
all_triggers = await self.request(
|
||||
"GET",
|
||||
"/triggers",
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
# postgres-meta /triggers does not support table_name filtering — filter here
|
||||
if table and isinstance(all_triggers, list):
|
||||
return [t for t in all_triggers if t.get("table") == table]
|
||||
return all_triggers
|
||||
|
||||
async def list_functions(self, schema: str = "public") -> list[dict]:
|
||||
"""List database functions."""
|
||||
"""List database functions via postgres-meta."""
|
||||
return await self.request(
|
||||
"GET", "/pg/functions", params={"schema": schema}, use_service_role=True
|
||||
"GET",
|
||||
"/functions",
|
||||
params={"schema": schema},
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
|
||||
async def execute_sql(self, query: str) -> Any:
|
||||
"""Execute raw SQL query."""
|
||||
"""Execute raw SQL query via postgres-meta."""
|
||||
return await self.request(
|
||||
"POST", "/pg/query", json_data={"query": query}, use_service_role=True
|
||||
"POST",
|
||||
"/query",
|
||||
json_data={"query": query},
|
||||
use_service_role=True,
|
||||
base_url_override=self.meta_base_url,
|
||||
)
|
||||
|
||||
# =====================
|
||||
@@ -655,8 +770,13 @@ class SupabaseClient:
|
||||
await self.request("GET", "/rest/v1/", use_service_role=True)
|
||||
results["services"]["postgrest"] = "ok"
|
||||
except Exception as e:
|
||||
results["services"]["postgrest"] = f"error: {str(e)}"
|
||||
results["healthy"] = False
|
||||
# A 404 on the PostgREST root still confirms connectivity — some versions
|
||||
# return 404 for the root endpoint while working normally for table queries.
|
||||
if "status 404" in str(e).lower() or "404" in str(e):
|
||||
results["services"]["postgrest"] = "ok"
|
||||
else:
|
||||
results["services"]["postgrest"] = f"error: {str(e)}"
|
||||
results["healthy"] = False
|
||||
|
||||
# Check GoTrue
|
||||
try:
|
||||
|
||||
@@ -468,11 +468,15 @@ async def query_table(
|
||||
use_service_role=use_service_role,
|
||||
)
|
||||
|
||||
row_count = len(result) if isinstance(result, list) else 1
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"table": table,
|
||||
"count": len(result) if isinstance(result, list) else 1,
|
||||
# "returned" = rows in this page; use count_rows tool for total
|
||||
"returned": row_count,
|
||||
# True when returned == limit, meaning there are likely more rows
|
||||
"has_more": row_count == limit,
|
||||
"data": result,
|
||||
},
|
||||
indent=2,
|
||||
@@ -500,16 +504,20 @@ async def insert_rows(
|
||||
use_service_role=use_service_role,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"table": table,
|
||||
"inserted": len(result) if isinstance(result, list) else 1,
|
||||
"data": result,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
affected = len(result) if isinstance(result, list) else 1
|
||||
response: dict = {
|
||||
"success": True,
|
||||
"table": table,
|
||||
"inserted" if not upsert else "affected": affected,
|
||||
"data": result,
|
||||
}
|
||||
if upsert:
|
||||
response["operation"] = "upsert"
|
||||
response["note"] = (
|
||||
"PostgREST does not distinguish inserted vs updated rows in upsert mode. "
|
||||
"'affected' = total rows processed (inserted + updated)."
|
||||
)
|
||||
return json.dumps(response, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -827,7 +835,16 @@ async def execute_sql(client: SupabaseClient, query: str) -> str:
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "error": str(e)}, indent=2, ensure_ascii=False)
|
||||
error_str = str(e)
|
||||
# Provide an actionable hint when postgres-meta is unreachable
|
||||
if "status 404" in error_str or "status 502" in error_str or "Cannot connect" in error_str:
|
||||
error_str = (
|
||||
f"postgres-meta unreachable at {client.meta_base_url}. "
|
||||
"If /pg/ is not exposed via Kong, set META_URL env var to the direct "
|
||||
"postgres-meta container URL (e.g., http://supabase-meta:8080). "
|
||||
f"Original: {error_str[:300]}"
|
||||
)
|
||||
return json.dumps({"success": False, "error": error_str}, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
async def get_table_indexes(client: SupabaseClient, table: str, schema: str = "public") -> str:
|
||||
|
||||
@@ -41,8 +41,13 @@ class SupabasePlugin(BasePlugin):
|
||||
|
||||
@staticmethod
|
||||
def get_required_config_keys() -> list[str]:
|
||||
"""Return required configuration keys"""
|
||||
return ["url", "anon_key", "service_role_key"]
|
||||
"""Return required configuration keys.
|
||||
|
||||
anon_key is optional — if not provided, service_role_key is used for all
|
||||
calls (including those that would normally use anon_key). This simplifies
|
||||
configuration for admin/backend use cases where RLS enforcement is not needed.
|
||||
"""
|
||||
return ["url", "service_role_key"]
|
||||
|
||||
def __init__(self, config: dict[str, Any], project_id: str | None = None):
|
||||
"""
|
||||
@@ -50,9 +55,10 @@ class SupabasePlugin(BasePlugin):
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary containing:
|
||||
- url: Supabase instance URL (Kong gateway)
|
||||
- anon_key: Anonymous key (RLS protected)
|
||||
- service_role_key: Service role key (bypasses RLS)
|
||||
- url: Supabase instance URL (Kong gateway or supabase.co)
|
||||
- service_role_key: Admin API key (bypasses RLS). Required.
|
||||
- anon_key: Public API key (RLS protected). Optional.
|
||||
- meta_url: Direct postgres-meta URL. Optional.
|
||||
project_id: Optional project ID (auto-generated if not provided)
|
||||
"""
|
||||
super().__init__(config, project_id=project_id)
|
||||
@@ -60,8 +66,9 @@ class SupabasePlugin(BasePlugin):
|
||||
# Create Supabase API client
|
||||
self.client = SupabaseClient(
|
||||
base_url=config["url"],
|
||||
anon_key=config["anon_key"],
|
||||
anon_key=config.get("anon_key", ""),
|
||||
service_role_key=config["service_role_key"],
|
||||
meta_url=config.get("meta_url"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -67,6 +67,7 @@ from core.dashboard.routes import (
|
||||
api_list_keys,
|
||||
api_list_sites,
|
||||
api_test_site,
|
||||
api_update_site,
|
||||
# E.2: OAuth Social Login routes
|
||||
auth_callback,
|
||||
auth_login_page,
|
||||
@@ -107,6 +108,7 @@ from core.dashboard.routes import (
|
||||
dashboard_settings_page,
|
||||
# E.3: Site Management pages
|
||||
dashboard_sites_add,
|
||||
dashboard_sites_edit,
|
||||
dashboard_sites_list,
|
||||
# Bug C: User OAuth client routes
|
||||
dashboard_user_oauth_clients_create,
|
||||
@@ -4792,6 +4794,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
||||
Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]),
|
||||
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
|
||||
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
|
||||
Route("/dashboard/sites/{id}/edit", dashboard_sites_edit, methods=["GET"]),
|
||||
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
|
||||
# Bug C: User OAuth client routes (must be before /dashboard/connect)
|
||||
Route(
|
||||
@@ -4860,6 +4863,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
||||
Route("/api/sites", api_create_site, methods=["POST"]),
|
||||
Route("/api/sites/{id}/test", api_test_site, methods=["POST"]),
|
||||
Route("/api/sites/{id}", api_delete_site, methods=["DELETE"]),
|
||||
Route("/api/sites/{id}", api_update_site, methods=["PATCH"]),
|
||||
# User API Key routes (E.3)
|
||||
Route("/api/keys", api_list_keys, methods=["GET"]),
|
||||
Route("/api/keys", api_create_key, methods=["POST"]),
|
||||
|
||||
BIN
wordpress-plugin/airano-mcp-seo-bridge.zip
Normal file
BIN
wordpress-plugin/airano-mcp-seo-bridge.zip
Normal file
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
=== SEO API Bridge ===
|
||||
=== Airano MCP SEO Meta Bridge ===
|
||||
Contributors: airano
|
||||
Tags: seo, rest-api, rank-math, yoast, mcp
|
||||
Requires at least: 5.0
|
||||
@@ -12,7 +12,7 @@ Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use w
|
||||
|
||||
== Description ==
|
||||
|
||||
SEO API Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products.
|
||||
Airano MCP SEO Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO meta fields via dedicated REST API endpoints. This enables MCP servers, AI agents, and other applications to read and write SEO metadata programmatically for posts, pages, and WooCommerce products.
|
||||
|
||||
**Features:**
|
||||
|
||||
@@ -26,16 +26,16 @@ SEO API Bridge is a WordPress plugin that exposes Rank Math SEO and Yoast SEO me
|
||||
|
||||
**REST API Endpoints:**
|
||||
|
||||
* `GET/POST /wp-json/seo-api-bridge/v1/posts/{id}/seo` — Post SEO data
|
||||
* `GET/POST /wp-json/seo-api-bridge/v1/pages/{id}/seo` — Page SEO data
|
||||
* `GET/POST /wp-json/seo-api-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
|
||||
* `GET /wp-json/seo-api-bridge/v1/status` — Plugin status and SEO detection
|
||||
* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/posts/{id}/seo` — Post SEO data
|
||||
* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/pages/{id}/seo` — Page SEO data
|
||||
* `GET/POST /wp-json/airano-mcp-seo-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
|
||||
* `GET /wp-json/airano-mcp-seo-bridge/v1/status` — Plugin status and SEO detection
|
||||
|
||||
**Designed for [MCP Hub](https://github.com/airano-ir/mcphub)** — the AI-native management hub for WordPress and self-hosted services.
|
||||
|
||||
== Installation ==
|
||||
|
||||
1. Upload the `seo-api-bridge` folder to `/wp-content/plugins/`
|
||||
1. Upload the `airano-mcp-seo-bridge` folder to `/wp-content/plugins/`
|
||||
2. Activate the plugin through the 'Plugins' menu in WordPress
|
||||
3. Ensure either Rank Math SEO or Yoast SEO is active
|
||||
4. The REST API endpoints are now available
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Plugin Name: SEO API Bridge
|
||||
* Plugin Name: Airano MCP SEO Meta Bridge
|
||||
* Plugin URI: https://github.com/airano-ir/mcphub
|
||||
* Description: Exposes Rank Math SEO and Yoast SEO meta fields via WordPress REST API for use with MCP servers and AI agents. Supports posts, pages, and WooCommerce products with full CRUD operations.
|
||||
* Version: 1.3.0
|
||||
@@ -9,7 +9,7 @@
|
||||
* License: GPL-2.0-or-later
|
||||
* Requires at least: 5.0
|
||||
* Requires PHP: 7.4
|
||||
* Text Domain: seo-api-bridge
|
||||
* Text Domain: airano-mcp-seo-bridge
|
||||
*
|
||||
* Changelog:
|
||||
* 1.3.0 - Added REST API endpoints for posts, pages, and products (GET/POST operations)
|
||||
@@ -24,7 +24,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO API Bridge Main Class
|
||||
* Airano MCP SEO Meta Bridge Main Class
|
||||
*/
|
||||
class SEO_API_Bridge {
|
||||
|
||||
@@ -57,7 +57,7 @@ class SEO_API_Bridge {
|
||||
*/
|
||||
public function register_rest_routes() {
|
||||
// Status endpoint
|
||||
register_rest_route('seo-api-bridge/v1', '/status', [
|
||||
register_rest_route('airano-mcp-seo-bridge/v1', '/status', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_status'],
|
||||
'permission_callback' => function() {
|
||||
@@ -66,7 +66,7 @@ class SEO_API_Bridge {
|
||||
]);
|
||||
|
||||
// Post SEO endpoints
|
||||
register_rest_route('seo-api-bridge/v1', '/posts/(?P<id>\d+)/seo', [
|
||||
register_rest_route('airano-mcp-seo-bridge/v1', '/posts/(?P<id>\d+)/seo', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_post_seo'],
|
||||
@@ -98,7 +98,7 @@ class SEO_API_Bridge {
|
||||
]);
|
||||
|
||||
// Page SEO endpoints
|
||||
register_rest_route('seo-api-bridge/v1', '/pages/(?P<id>\d+)/seo', [
|
||||
register_rest_route('airano-mcp-seo-bridge/v1', '/pages/(?P<id>\d+)/seo', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_page_seo'],
|
||||
@@ -130,7 +130,7 @@ class SEO_API_Bridge {
|
||||
]);
|
||||
|
||||
// Product SEO endpoints (WooCommerce)
|
||||
register_rest_route('seo-api-bridge/v1', '/products/(?P<id>\d+)/seo', [
|
||||
register_rest_route('airano-mcp-seo-bridge/v1', '/products/(?P<id>\d+)/seo', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_product_seo'],
|
||||
@@ -170,7 +170,7 @@ class SEO_API_Bridge {
|
||||
$yoast_active = $this->is_yoast_active();
|
||||
|
||||
$response = [
|
||||
'plugin' => 'SEO API Bridge',
|
||||
'plugin' => 'Airano MCP SEO Meta Bridge',
|
||||
'version' => self::VERSION,
|
||||
'active' => true,
|
||||
'seo_plugins' => [
|
||||
@@ -202,7 +202,7 @@ class SEO_API_Bridge {
|
||||
if ($rank_math_active) $active_plugins[] = 'Rank Math SEO';
|
||||
if ($yoast_active) $active_plugins[] = 'Yoast SEO';
|
||||
|
||||
return 'SEO API Bridge is active and working with ' . implode(' and ', $active_plugins) . '.';
|
||||
return 'Airano MCP SEO Meta Bridge is active and working with ' . implode(' and ', $active_plugins) . '.';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -438,8 +438,12 @@ class SEO_API_Bridge {
|
||||
private function register_fields($fields) {
|
||||
foreach ($this->supported_post_types as $post_type) {
|
||||
foreach ($fields as $meta_key => $args) {
|
||||
$show_in_rest = $args['type'] === 'array'
|
||||
? [ 'schema' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ] ]
|
||||
: true;
|
||||
|
||||
register_post_meta($post_type, $meta_key, [
|
||||
'show_in_rest' => true,
|
||||
'show_in_rest' => $show_in_rest,
|
||||
'single' => $args['single'],
|
||||
'type' => $args['type'],
|
||||
'description' => $args['description'],
|
||||
@@ -686,7 +690,7 @@ class SEO_API_Bridge {
|
||||
|
||||
if (!$rank_math_active && !$yoast_active) {
|
||||
echo '<div class="notice notice-warning is-dismissible">';
|
||||
echo '<p><strong>SEO API Bridge:</strong> ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.', 'seo-api-bridge' ) . '</p>';
|
||||
echo '<p><strong>Airano MCP SEO Meta Bridge:</strong> ' . esc_html__( 'Neither Rank Math SEO nor Yoast SEO is detected. Please install and activate one of these plugins to enable SEO meta field access via REST API.', 'airano-mcp-seo-bridge' ) . '</p>';
|
||||
echo '</div>';
|
||||
} else {
|
||||
$active_plugins = [];
|
||||
@@ -696,11 +700,11 @@ class SEO_API_Bridge {
|
||||
$supported_types = implode(', ', $this->supported_post_types);
|
||||
|
||||
echo '<div class="notice notice-success is-dismissible">';
|
||||
echo '<p><strong>SEO API Bridge v' . esc_html( self::VERSION ) . ':</strong> ' . esc_html( sprintf( 'Successfully registered meta fields for %s.', implode( ' and ', $active_plugins ) ) ) . '</p>';
|
||||
echo '<p><strong>' . esc_html__( 'Supported post types:', 'seo-api-bridge' ) . '</strong> ' . esc_html( $supported_types ) . '</p>';
|
||||
echo '<p><strong>Airano MCP SEO Meta Bridge v' . esc_html( self::VERSION ) . ':</strong> ' . esc_html( sprintf( 'Successfully registered meta fields for %s.', implode( ' and ', $active_plugins ) ) ) . '</p>';
|
||||
echo '<p><strong>' . esc_html__( 'Supported post types:', 'airano-mcp-seo-bridge' ) . '</strong> ' . esc_html( $supported_types ) . '</p>';
|
||||
|
||||
if ($woocommerce_active) {
|
||||
echo '<p><strong>WooCommerce:</strong> ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'seo-api-bridge' ) . '</p>';
|
||||
echo '<p><strong>WooCommerce:</strong> ' . esc_html__( 'Detected and supported. Product SEO fields are available via REST API.', 'airano-mcp-seo-bridge' ) . '</p>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
Binary file not shown.
Reference in New Issue
Block a user