Plugin visibility control, UI/UX fixes, unified admin panel, database-backed settings, and security hardening. Highlights: - ENABLED_PLUGINS env var for plugin visibility (F.1) - Admin designation via ADMIN_EMAILS (F.4a) - Master key scope control (F.4b) - Panel unification + settings from UI (F.4c) - exec() removal, shell injection fix, bcrypt migration (F.8) - 5 new test suites Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
827 lines
29 KiB
Python
827 lines
29 KiB
Python
"""
|
|
Supabase REST API Client (Self-Hosted)
|
|
|
|
Handles all HTTP communication with Supabase Self-Hosted APIs.
|
|
All requests go through Kong API Gateway on single base URL.
|
|
|
|
APIs:
|
|
- PostgREST (/rest/v1/) - Database CRUD
|
|
- GoTrue (/auth/v1/) - Authentication
|
|
- Storage (/storage/v1/) - File storage
|
|
- Edge Functions (/functions/v1/) - Serverless
|
|
- postgres-meta (/pg/) - Database admin
|
|
"""
|
|
|
|
import base64
|
|
import logging
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
|
|
class SupabaseClient:
|
|
"""
|
|
Supabase Self-Hosted API client.
|
|
|
|
All requests go through Kong gateway on single base URL.
|
|
Uses JWT-based authentication with anon_key or service_role_key.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
base_url: str,
|
|
anon_key: str,
|
|
service_role_key: str,
|
|
meta_url: str | None = None,
|
|
meta_auth: str | None = None,
|
|
):
|
|
"""
|
|
Initialize Supabase API client.
|
|
|
|
Args:
|
|
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.
|
|
meta_auth: Optional Basic Auth credentials for postgres-meta (username:password).
|
|
When provided, requests to meta_base_url use Basic Auth instead of JWT.
|
|
Recommended when postgres-meta is exposed via a public URL.
|
|
"""
|
|
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("/")
|
|
self.meta_auth = meta_auth
|
|
|
|
# Initialize logger
|
|
self.logger = logging.getLogger(f"SupabaseClient.{base_url}")
|
|
|
|
def _get_headers(
|
|
self, use_service_role: bool = False, additional_headers: dict | None = None
|
|
) -> dict[str, str]:
|
|
"""
|
|
Get request headers with API key authentication.
|
|
|
|
Args:
|
|
use_service_role: Use service_role_key (bypasses RLS)
|
|
additional_headers: Additional headers to include
|
|
|
|
Returns:
|
|
Dict: Headers with authentication
|
|
"""
|
|
# 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,
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
if additional_headers:
|
|
headers.update(additional_headers)
|
|
|
|
return headers
|
|
|
|
def _get_meta_headers(self, additional_headers: dict | None = None) -> dict[str, str]:
|
|
"""Get headers for postgres-meta requests (Basic Auth if configured, else JWT)."""
|
|
headers: dict[str, str] = {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
if self.meta_auth:
|
|
encoded = base64.b64encode(self.meta_auth.encode()).decode()
|
|
headers["Authorization"] = f"Basic {encoded}"
|
|
else:
|
|
key = self.service_role_key
|
|
headers["apikey"] = key
|
|
headers["Authorization"] = f"Bearer {key}"
|
|
if additional_headers:
|
|
headers.update(additional_headers)
|
|
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: 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.
|
|
|
|
Args:
|
|
method: HTTP method
|
|
endpoint: API endpoint (with leading /)
|
|
params: Query parameters
|
|
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
|
|
|
|
Raises:
|
|
Exception: On API errors
|
|
"""
|
|
url = f"{base_url_override or self.base_url}{endpoint}"
|
|
|
|
# Use Basic Auth headers for postgres-meta requests when meta_auth is configured
|
|
if base_url_override and base_url_override == self.meta_base_url:
|
|
headers = self._get_meta_headers(headers_override)
|
|
else:
|
|
headers = self._get_headers(use_service_role, headers_override)
|
|
|
|
# Remove Content-Type for binary data
|
|
if data is not None:
|
|
headers.pop("Content-Type", None)
|
|
|
|
# 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 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}")
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
kwargs = {
|
|
"method": method,
|
|
"url": url,
|
|
"headers": headers,
|
|
}
|
|
|
|
if params:
|
|
kwargs["params"] = params
|
|
if json_data is not None:
|
|
kwargs["json"] = json_data
|
|
if data:
|
|
kwargs["data"] = data
|
|
|
|
async with session.request(**kwargs) as response:
|
|
self.logger.debug(f"Response status: {response.status}")
|
|
|
|
# Handle 204 No Content
|
|
if response.status == 204:
|
|
return {"success": True}
|
|
|
|
# Handle binary responses (file downloads)
|
|
content_type = response.headers.get("Content-Type", "")
|
|
if "application/json" not in content_type and response.status < 400:
|
|
# Return binary data as base64
|
|
binary_data = await response.read()
|
|
return {
|
|
"data": base64.b64encode(binary_data).decode(),
|
|
"content_type": content_type,
|
|
"size": len(binary_data),
|
|
}
|
|
|
|
# Parse JSON response
|
|
try:
|
|
response_data = await response.json()
|
|
except Exception:
|
|
response_text = await response.text()
|
|
if response.status >= 400:
|
|
raise Exception(
|
|
f"Supabase API error (status {response.status}): {response_text}"
|
|
)
|
|
return {"success": True, "message": response_text}
|
|
|
|
# Check for errors
|
|
if response.status >= 400:
|
|
error_msg = self._extract_error_message(response_data)
|
|
raise Exception(f"Supabase API error (status {response.status}): {error_msg}")
|
|
|
|
return response_data
|
|
|
|
def _extract_error_message(self, response_data: Any) -> str:
|
|
"""Extract error message from various response formats."""
|
|
if isinstance(response_data, dict):
|
|
# PostgREST error format — includes code, details, hint
|
|
if "message" in response_data:
|
|
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:
|
|
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)
|
|
# =====================
|
|
|
|
async def query_table(
|
|
self,
|
|
table: str,
|
|
select: str = "*",
|
|
filters: list[dict] | None = None,
|
|
order: str | None = None,
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
use_service_role: bool = False,
|
|
) -> list[dict]:
|
|
"""
|
|
Query data from a table.
|
|
|
|
Args:
|
|
table: Table name
|
|
select: Columns to select
|
|
filters: List of filter conditions
|
|
order: Order by clause (e.g., "created_at.desc")
|
|
limit: Maximum rows
|
|
offset: Offset for pagination
|
|
"""
|
|
params: dict[str, Any] = {"select": select, "limit": limit, "offset": offset}
|
|
|
|
if order:
|
|
params["order"] = order
|
|
|
|
if filters:
|
|
params.update(self._build_filter_params(filters))
|
|
|
|
headers = {}
|
|
|
|
# Request single objects as array
|
|
headers["Accept"] = "application/json"
|
|
|
|
return await self.request(
|
|
"GET",
|
|
f"/rest/v1/{table}",
|
|
params=params,
|
|
headers_override=headers,
|
|
use_service_role=use_service_role,
|
|
)
|
|
|
|
async def insert_rows(
|
|
self,
|
|
table: str,
|
|
rows: list[dict],
|
|
upsert: bool = False,
|
|
on_conflict: str | None = None,
|
|
use_service_role: bool = False,
|
|
) -> 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:
|
|
# 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,
|
|
)
|
|
|
|
async def update_rows(
|
|
self, table: str, data: dict, filters: list[dict], use_service_role: bool = False
|
|
) -> list[dict]:
|
|
"""Update rows matching filters."""
|
|
params = self._build_filter_params(filters)
|
|
headers = {"Prefer": "return=representation"}
|
|
|
|
return await self.request(
|
|
"PATCH",
|
|
f"/rest/v1/{table}",
|
|
params=params,
|
|
json_data=data,
|
|
headers_override=headers,
|
|
use_service_role=use_service_role,
|
|
)
|
|
|
|
async def delete_rows(
|
|
self, table: str, filters: list[dict], use_service_role: bool = False
|
|
) -> list[dict]:
|
|
"""Delete rows matching filters."""
|
|
params = self._build_filter_params(filters)
|
|
headers = {"Prefer": "return=representation"}
|
|
|
|
return await self.request(
|
|
"DELETE",
|
|
f"/rest/v1/{table}",
|
|
params=params,
|
|
headers_override=headers,
|
|
use_service_role=use_service_role,
|
|
)
|
|
|
|
async def execute_rpc(
|
|
self, function_name: str, params: dict | None = None, use_service_role: bool = False
|
|
) -> Any:
|
|
"""Execute a stored procedure/function."""
|
|
return await self.request(
|
|
"POST",
|
|
f"/rest/v1/rpc/{function_name}",
|
|
json_data=params or {},
|
|
use_service_role=use_service_role,
|
|
)
|
|
|
|
async def count_rows(
|
|
self, table: str, filters: list[dict] | None = None, use_service_role: bool = False
|
|
) -> int:
|
|
"""Count rows in a table using HEAD + Content-Range header."""
|
|
filter_params = self._build_filter_params(filters) if filters else {}
|
|
|
|
response_headers = await self._head_request_headers(
|
|
f"/rest/v1/{table}",
|
|
params=filter_params or None,
|
|
headers_override={"Prefer": "count=exact"},
|
|
use_service_role=use_service_role,
|
|
)
|
|
|
|
# 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 via postgres-meta."""
|
|
return await self.request(
|
|
"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 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 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 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 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 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 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 via postgres-meta."""
|
|
return await self.request(
|
|
"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 via postgres-meta."""
|
|
return await self.request(
|
|
"POST",
|
|
"/query",
|
|
json_data={"query": query},
|
|
use_service_role=True,
|
|
base_url_override=self.meta_base_url,
|
|
)
|
|
|
|
# =====================
|
|
# GOTRUE (Auth)
|
|
# =====================
|
|
|
|
async def list_users(self, page: int = 1, per_page: int = 50) -> dict[str, Any]:
|
|
"""List all users (admin)."""
|
|
return await self.request(
|
|
"GET",
|
|
"/auth/v1/admin/users",
|
|
params={"page": page, "per_page": per_page},
|
|
use_service_role=True,
|
|
)
|
|
|
|
async def get_user(self, user_id: str) -> dict[str, Any]:
|
|
"""Get user by ID."""
|
|
return await self.request("GET", f"/auth/v1/admin/users/{user_id}", use_service_role=True)
|
|
|
|
async def create_user(
|
|
self,
|
|
email: str,
|
|
password: str,
|
|
email_confirm: bool = False,
|
|
phone: str | None = None,
|
|
user_metadata: dict | None = None,
|
|
app_metadata: dict | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Create a new user."""
|
|
data = {"email": email, "password": password, "email_confirm": email_confirm}
|
|
if phone:
|
|
data["phone"] = phone
|
|
if user_metadata:
|
|
data["user_metadata"] = user_metadata
|
|
if app_metadata:
|
|
data["app_metadata"] = app_metadata
|
|
|
|
return await self.request(
|
|
"POST", "/auth/v1/admin/users", json_data=data, use_service_role=True
|
|
)
|
|
|
|
async def update_user(
|
|
self,
|
|
user_id: str,
|
|
email: str | None = None,
|
|
password: str | None = None,
|
|
phone: str | None = None,
|
|
email_confirm: bool | None = None,
|
|
phone_confirm: bool | None = None,
|
|
user_metadata: dict | None = None,
|
|
app_metadata: dict | None = None,
|
|
ban_duration: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Update user."""
|
|
data = {}
|
|
if email:
|
|
data["email"] = email
|
|
if password:
|
|
data["password"] = password
|
|
if phone:
|
|
data["phone"] = phone
|
|
if email_confirm is not None:
|
|
data["email_confirm"] = email_confirm
|
|
if phone_confirm is not None:
|
|
data["phone_confirm"] = phone_confirm
|
|
if user_metadata:
|
|
data["user_metadata"] = user_metadata
|
|
if app_metadata:
|
|
data["app_metadata"] = app_metadata
|
|
if ban_duration:
|
|
data["ban_duration"] = ban_duration
|
|
|
|
return await self.request(
|
|
"PUT", f"/auth/v1/admin/users/{user_id}", json_data=data, use_service_role=True
|
|
)
|
|
|
|
async def delete_user(self, user_id: str) -> dict[str, Any]:
|
|
"""Delete a user."""
|
|
return await self.request(
|
|
"DELETE", f"/auth/v1/admin/users/{user_id}", use_service_role=True
|
|
)
|
|
|
|
async def generate_link(
|
|
self, email: str, link_type: str = "magiclink", redirect_to: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Generate magic link or recovery link."""
|
|
data = {"email": email, "type": link_type}
|
|
if redirect_to:
|
|
data["redirect_to"] = redirect_to
|
|
|
|
return await self.request(
|
|
"POST", "/auth/v1/admin/generate_link", json_data=data, use_service_role=True
|
|
)
|
|
|
|
async def list_user_factors(self, user_id: str) -> list[dict]:
|
|
"""List user MFA factors."""
|
|
return await self.request(
|
|
"GET", f"/auth/v1/admin/users/{user_id}/factors", use_service_role=True
|
|
)
|
|
|
|
async def delete_user_factor(self, user_id: str, factor_id: str) -> dict:
|
|
"""Delete a user's MFA factor."""
|
|
return await self.request(
|
|
"DELETE", f"/auth/v1/admin/users/{user_id}/factors/{factor_id}", use_service_role=True
|
|
)
|
|
|
|
# =====================
|
|
# STORAGE
|
|
# =====================
|
|
|
|
async def list_buckets(self) -> list[dict]:
|
|
"""List all storage buckets."""
|
|
return await self.request("GET", "/storage/v1/bucket", use_service_role=True)
|
|
|
|
async def get_bucket(self, bucket_id: str) -> dict:
|
|
"""Get bucket details."""
|
|
return await self.request("GET", f"/storage/v1/bucket/{bucket_id}", use_service_role=True)
|
|
|
|
async def create_bucket(
|
|
self,
|
|
name: str,
|
|
public: bool = False,
|
|
file_size_limit: int | None = None,
|
|
allowed_mime_types: list[str] | None = None,
|
|
) -> dict:
|
|
"""Create a new bucket."""
|
|
data = {"name": name, "public": public}
|
|
if file_size_limit:
|
|
data["file_size_limit"] = file_size_limit
|
|
if allowed_mime_types:
|
|
data["allowed_mime_types"] = allowed_mime_types
|
|
|
|
return await self.request(
|
|
"POST", "/storage/v1/bucket", json_data=data, use_service_role=True
|
|
)
|
|
|
|
async def update_bucket(
|
|
self,
|
|
bucket_id: str,
|
|
public: bool | None = None,
|
|
file_size_limit: int | None = None,
|
|
allowed_mime_types: list[str] | None = None,
|
|
) -> dict:
|
|
"""Update bucket settings."""
|
|
data = {}
|
|
if public is not None:
|
|
data["public"] = public
|
|
if file_size_limit:
|
|
data["file_size_limit"] = file_size_limit
|
|
if allowed_mime_types:
|
|
data["allowed_mime_types"] = allowed_mime_types
|
|
|
|
return await self.request(
|
|
"PUT", f"/storage/v1/bucket/{bucket_id}", json_data=data, use_service_role=True
|
|
)
|
|
|
|
async def delete_bucket(self, bucket_id: str) -> dict:
|
|
"""Delete a bucket."""
|
|
return await self.request(
|
|
"DELETE", f"/storage/v1/bucket/{bucket_id}", use_service_role=True
|
|
)
|
|
|
|
async def empty_bucket(self, bucket_id: str) -> dict:
|
|
"""Empty a bucket (delete all files)."""
|
|
return await self.request(
|
|
"POST",
|
|
f"/storage/v1/bucket/{bucket_id}/empty",
|
|
json_data={},
|
|
use_service_role=True,
|
|
)
|
|
|
|
async def list_files(
|
|
self, bucket: str, path: str = "", limit: int = 100, offset: int = 0
|
|
) -> list[dict]:
|
|
"""List files in a bucket/path."""
|
|
data = {"prefix": path, "limit": limit, "offset": offset}
|
|
return await self.request(
|
|
"POST", f"/storage/v1/object/list/{bucket}", json_data=data, use_service_role=True
|
|
)
|
|
|
|
async def upload_file(
|
|
self,
|
|
bucket: str,
|
|
path: str,
|
|
content: bytes,
|
|
content_type: str = "application/octet-stream",
|
|
upsert: bool = False,
|
|
) -> dict:
|
|
"""Upload a file."""
|
|
headers = {"Content-Type": content_type}
|
|
if upsert:
|
|
headers["x-upsert"] = "true"
|
|
|
|
return await self.request(
|
|
"POST",
|
|
f"/storage/v1/object/{bucket}/{path}",
|
|
data=content,
|
|
headers_override=headers,
|
|
use_service_role=True,
|
|
)
|
|
|
|
async def download_file(self, bucket: str, path: str) -> dict:
|
|
"""Download a file (returns base64)."""
|
|
return await self.request(
|
|
"GET", f"/storage/v1/object/{bucket}/{path}", use_service_role=True
|
|
)
|
|
|
|
async def delete_files(self, bucket: str, paths: list[str]) -> dict:
|
|
"""Delete files from bucket."""
|
|
return await self.request(
|
|
"DELETE",
|
|
f"/storage/v1/object/{bucket}",
|
|
json_data={"prefixes": paths},
|
|
use_service_role=True,
|
|
)
|
|
|
|
async def move_file(self, bucket: str, from_path: str, to_path: str) -> dict:
|
|
"""Move/rename a file."""
|
|
return await self.request(
|
|
"POST",
|
|
"/storage/v1/object/move",
|
|
json_data={"bucketId": bucket, "sourceKey": from_path, "destinationKey": to_path},
|
|
use_service_role=True,
|
|
)
|
|
|
|
async def get_public_url(self, bucket: str, path: str) -> str:
|
|
"""Get public URL for a file."""
|
|
return f"{self.base_url}/storage/v1/object/public/{bucket}/{path}"
|
|
|
|
# =====================
|
|
# EDGE FUNCTIONS
|
|
# =====================
|
|
|
|
async def invoke_function(
|
|
self, function_name: str, body: dict | None = None, method: str = "POST"
|
|
) -> Any:
|
|
"""Invoke an Edge Function."""
|
|
return await self.request(
|
|
method,
|
|
f"/functions/v1/{function_name}",
|
|
json_data=body,
|
|
use_service_role=False, # Use anon key for functions
|
|
)
|
|
|
|
# =====================
|
|
# HEALTH CHECK
|
|
# =====================
|
|
|
|
async def health_check(self) -> dict[str, Any]:
|
|
"""Check Supabase instance health."""
|
|
results = {"healthy": True, "services": {}}
|
|
|
|
# Check PostgREST
|
|
try:
|
|
await self.request("GET", "/rest/v1/", use_service_role=True)
|
|
results["services"]["postgrest"] = "ok"
|
|
except Exception as e:
|
|
# 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:
|
|
await self.request("GET", "/auth/v1/health", use_service_role=False)
|
|
results["services"]["gotrue"] = "ok"
|
|
except Exception as e:
|
|
results["services"]["gotrue"] = f"error: {str(e)}"
|
|
results["healthy"] = False
|
|
|
|
# Check Storage
|
|
try:
|
|
await self.list_buckets()
|
|
results["services"]["storage"] = "ok"
|
|
except Exception as e:
|
|
results["services"]["storage"] = f"error: {str(e)}"
|
|
results["healthy"] = False
|
|
|
|
return results
|