refactor: 迁移本地 Supabase 到云端,使用 JWKS 进行 JWT 验证

- 新增 JwtVerifier 支持 RS256 + JWKS 验证
- 简化 docker-compose,删除本地 Supabase 服务(kong/auth/storage等)
- 删除冗余的 Supabase 配置文件(volumes目录)
- 适配测试用例以支持新配置方式
- 更新运行时文档和迁移计划
This commit is contained in:
qzl
2026-03-09 18:03:04 +08:00
parent 3ac09475ad
commit 6fe2e7b6c3
24 changed files with 825 additions and 1403 deletions
+10 -77
View File
@@ -19,7 +19,7 @@ SOCIAL_WEB__WORKERS=2
############
# Redis 配置
############
SOCIAL_REDIS__PASSWORD=change-me-redis-password
SOCIAL_REDIS__PASSWORD=redis-secure-2026
SOCIAL_REDIS__HOST=localhost
SOCIAL_REDIS__PORT=6379
SOCIAL_REDIS__DB=0
@@ -43,20 +43,14 @@ SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY=1
# SOCIAL_TASKIQ__RESULT_BACKEND_URL=redis://:password@localhost:6379/0
############
# Supabase本地 Docker 与阿里云自托管保持同一变量
# Supabase云模式,后端必需
############
# Supabase 栈使用 infra/docker/docker-compose.yml
# 仅绑定 127.0.0.1,不对局域网/公网暴露
SOCIAL_SUPABASE__PUBLIC_URL=https://your-project.supabase.co
SOCIAL_SUPABASE__ANON_KEY=
SOCIAL_SUPABASE__SERVICE_ROLE_KEY=
# 基础 URL(本地默认 8000
SOCIAL_SUPABASE__PUBLIC_SCHEME=http
SOCIAL_SUPABASE__PUBLIC_HOST=localhost
SOCIAL_SUPABASE__SITE_URL=http://localhost:3000
#######
# 本地 Supabase 端口(只绑定 127.0.0.1
SOCIAL_SUPABASE__KONG_HTTP_PORT=8000
SOCIAL_SUPABASE__KONG_HTTPS_PORT=8443
# Cloud Auth 可选配置(默认值已满足大多数场景
SOCIAL_SUPABASE__JWT_AUDIENCE=authenticated
# Postgres 连接信息(后端与 Supabase 共用密码)
SOCIAL_DATABASE__HOST=localhost
@@ -65,70 +59,9 @@ SOCIAL_DATABASE__NAME=postgres
SOCIAL_DATABASE__USER=postgres
SOCIAL_DATABASE__PASSWORD=change-me-strong-password
# JWT/Keys(必须替换
SOCIAL_SUPABASE__JWT_SECRET=change-me-jwt-secret-at-least-32-chars
SOCIAL_SUPABASE__ANON_KEY=replace-with-supabase-anon-key
SOCIAL_SUPABASE__SERVICE_ROLE_KEY=replace-with-supabase-service-role-key
# Studio 登录
SOCIAL_SUPABASE__DASHBOARD_USERNAME=admin
SOCIAL_SUPABASE__DASHBOARD_PASSWORD=change-me
# 核心加密 Key(必须替换)
SOCIAL_SUPABASE__SECRET_KEY_BASE=change-me-secret-key-base
SOCIAL_SUPABASE__VAULT_ENC_KEY=change-me-vault-enc-key
SOCIAL_SUPABASE__PG_META_CRYPTO_KEY=change-me-pg-meta-crypto-key
#######
# Logflare(本地可用假值,但不要上云)
SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN=change-me-logflare-public
SOCIAL_SUPABASE__LOGFLARE_PRIVATE_ACCESS_TOKEN=change-me-logflare-private
#######
# Pooler
SOCIAL_SUPABASE__POOLER_TENANT_ID=local
SOCIAL_SUPABASE__POOLER_DEFAULT_POOL_SIZE=20
SOCIAL_SUPABASE__POOLER_MAX_CLIENT_CONN=100
SOCIAL_SUPABASE__POOLER_DB_POOL_SIZE=5
#######
# Auth 可选项(默认允许邮箱注册)
SOCIAL_SUPABASE__ENABLE_EMAIL_SIGNUP=true
SOCIAL_SUPABASE__ENABLE_EMAIL_AUTOCONFIRM=false
SOCIAL_SUPABASE__ENABLE_ANONYMOUS_USERS=false
SOCIAL_SUPABASE__ENABLE_PHONE_SIGNUP=false
SOCIAL_SUPABASE__ENABLE_PHONE_AUTOCONFIRM=false
SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS=
SOCIAL_SUPABASE__DISABLE_SIGNUP=false
#######
# SMTP(上云必配,本地可留空)
SOCIAL_SUPABASE__SMTP_ADMIN_EMAIL=
SOCIAL_SUPABASE__SMTP_HOST=
SOCIAL_SUPABASE__SMTP_PORT=
SOCIAL_SUPABASE__SMTP_USER=
SOCIAL_SUPABASE__SMTP_PASS=
SOCIAL_SUPABASE__SMTP_SENDER_NAME=
#######
# Auth 邮件模板 URL(本地默认走 mail-templates 静态服务)
SOCIAL_SUPABASE__MAILER_TEMPLATES_CONFIRMATION=http://mail-templates/confirmation.html
SOCIAL_SUPABASE__MAILER_TEMPLATES_RECOVERY=http://mail-templates/recovery.html
#######
# Auth 邮件主题(仅保留注册确认与重置密码)
SOCIAL_SUPABASE__MAILER_SUBJECTS_CONFIRMATION=请确认你的注册邮箱
SOCIAL_SUPABASE__MAILER_SUBJECTS_RECOVERY=重置你的账户密码
SOCIAL_SUPABASE__MAILER_OTP_LENGTH=6
SOCIAL_SUPABASE__MAILER_OTP_EXP=300
#######
# Storage/Image 可选配置
SOCIAL_SUPABASE__PGRST_DB_SCHEMAS=public
SOCIAL_SUPABASE__FUNCTIONS_VERIFY_JWT=false
SOCIAL_SUPABASE__IMGPROXY_ENABLE_WEBP_DETECTION=true
SOCIAL_SUPABASE__STORAGE_BUCKET_PUBLIC=public
SOCIAL_SUPABASE__STORAGE_BUCKET_PRIVATE=private
# Auth 可选项(云 Supabase Auth 行为控制
# SOCIAL_SUPABASE__SITE_URL=https://your-app-domain.example
# SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS=
############
# Agent Chat 附件存储配置(仅基础设施变量)
-76
View File
@@ -1,76 +0,0 @@
name: Manual Live E2E
on:
workflow_dispatch:
inputs:
run_live_suite:
description: "Run backend live e2e suite"
required: true
default: "true"
type: choice
options:
- "true"
- "false"
jobs:
backend-live-e2e:
if: ${{ inputs.run_live_suite == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
AGENT_LIVE_E2E: "1"
AGENT_LIVE_INTEGRATION: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Setup uv
uses: astral-sh/setup-uv@v3
- name: Restore .env from secret
shell: bash
run: |
if [ -z "${{ secrets.SOCIAL_APP_ENV_FILE }}" ]; then
echo "Missing required secret: SOCIAL_APP_ENV_FILE"
exit 1
fi
printf '%s' "${{ secrets.SOCIAL_APP_ENV_FILE }}" > .env
- name: Install dependencies
run: uv sync
- name: Start local Supabase stack
run: docker compose --env-file .env -f infra/docker/docker-compose.yml up -d
- name: Wait for Postgres
shell: bash
run: |
for i in $(seq 1 30); do
if nc -z 127.0.0.1 5434; then
exit 0
fi
sleep 2
done
echo "Postgres is not ready"
docker compose --env-file .env -f infra/docker/docker-compose.yml ps
exit 1
- name: Apply database migrations
run: uv run alembic -c backend/alembic/alembic.ini upgrade head
- name: Run live E2E tests
run: uv run pytest backend/tests/e2e/test_agent_live_flow.py -m live -v -rs
- name: Dump container logs on failure
if: failure()
run: docker compose --env-file .env -f infra/docker/docker-compose.yml logs --no-color
- name: Shutdown local Supabase stack
if: always()
run: docker compose --env-file .env -f infra/docker/docker-compose.yml down -v
+12 -5
View File
@@ -1,12 +1,19 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"supabase_mcp": {
"type": "remote",
"url": "http://localhost:8001/mcp",
"oauth": false,
"supabase": {
"type": "local",
"enabled": true,
"timeout": 10000
"command": [
"npx",
"-y",
"@aliyun-supabase/mcp-server-supabase@latest",
"--features=aliyun",
"--region-id=cn-shenzhen"
],
"environment": {
"ALIYUN_ACCESS_TOKEN": ""
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Any, cast
import jwt
class TokenValidationError(Exception):
pass
class TokenVerifierUnavailableError(Exception):
pass
class JwtVerifier:
def __init__(self, jwks_url: str, issuer: str, audience: str) -> None:
self._issuer: str = issuer
self._audience: str = audience
self._jwks_client: jwt.PyJWKClient = jwt.PyJWKClient(jwks_url)
def verify(self, token: str) -> dict[str, Any]:
try:
key = self._jwks_client.get_signing_key_from_jwt(token)
except jwt.PyJWKClientConnectionError as exc:
raise TokenVerifierUnavailableError("Unable to fetch JWKS") from exc
except jwt.PyJWKClientError as exc:
raise TokenValidationError("Unable to resolve signing key") from exc
try:
payload = jwt.decode(
token,
key.key,
algorithms=["RS256"],
audience=self._audience,
issuer=self._issuer,
options={"require": ["sub", "aud", "iss", "exp"]},
)
except (
jwt.ExpiredSignatureError,
jwt.InvalidAudienceError,
jwt.InvalidIssuerError,
jwt.InvalidSignatureError,
jwt.DecodeError,
jwt.PyJWTError,
) as exc:
raise TokenValidationError("Token validation failed") from exc
if not isinstance(payload, dict):
raise TokenValidationError("Token payload must be a JSON object")
return cast(dict[str, Any], payload)
+30 -14
View File
@@ -4,7 +4,14 @@ from pathlib import Path
from typing import ClassVar, Literal
from urllib.parse import quote
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
from pydantic import (
AnyHttpUrl,
BaseModel,
Field,
computed_field,
field_validator,
model_validator,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -116,14 +123,14 @@ class RedisSettings(BaseModel):
class SupabaseSettings(BaseModel):
public_scheme: str = "http"
public_host: str = "localhost"
kong_http_port: int = 8000
site_url: str = "http://localhost:3000"
additional_redirect_urls: list[str] = Field(default_factory=list)
public_url: AnyHttpUrl
anon_key: str = "CHANGE_ME"
service_role_key: str = "CHANGE_ME"
jwt_secret: str | None = None
jwt_audience: str = "authenticated"
jwt_issuer: str | None = None
jwks_url: str | None = None
site_url: str | None = None
additional_redirect_urls: list[str] = Field(default_factory=list)
@field_validator("additional_redirect_urls", mode="before")
@classmethod
@@ -136,15 +143,24 @@ class SupabaseSettings(BaseModel):
return [str(item).strip() for item in value if str(item).strip()]
return []
@computed_field
@property
def public_url(self) -> str:
return f"{self.public_scheme}://{self.public_host}:{self.kong_http_port}"
@model_validator(mode="after")
def compute_defaults(self) -> "SupabaseSettings":
base = str(self.public_url).rstrip("/")
if self.jwt_issuer is None:
self.jwt_issuer = f"{base}/auth/v1"
if self.jwks_url is None:
self.jwks_url = f"{self.jwt_issuer}/.well-known/jwks.json"
if self.site_url is None:
self.site_url = "http://localhost:3000"
return self
@computed_field
@property
def url(self) -> str:
return self.public_url
return str(self.public_url)
class StorageSettings(BaseModel):
@@ -205,7 +221,7 @@ class Settings(BaseSettings):
runtime: RuntimeSettings = RuntimeSettings()
cors: CorsSettings = CorsSettings()
redis: RedisSettings = RedisSettings()
supabase: SupabaseSettings = SupabaseSettings()
supabase: SupabaseSettings = Field()
storage: StorageSettings = StorageSettings()
llm: LlmSettings = LlmSettings()
agent_runtime: AgentRuntimeSettings = AgentRuntimeSettings()
@@ -236,4 +252,4 @@ class Settings(BaseSettings):
)
config = Settings()
config = Settings() # type: ignore[reportCallIssue]
+28 -39
View File
@@ -3,10 +3,14 @@ from __future__ import annotations
from typing import Annotated
from uuid import UUID
import jwt
from fastapi import Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.jwt_verifier import (
JwtVerifier,
TokenValidationError,
TokenVerifierUnavailableError,
)
from core.auth.models import CurrentUser
from core.config.settings import config
from core.db import get_db
@@ -18,6 +22,7 @@ from v1.users.service import AuthLookupAdapter, UserService
logger = get_logger("v1.users.dependencies")
_auth_gateway: SupabaseAuthGateway | None = None
_jwt_verifier: JwtVerifier | None = None
def get_auth_gateway() -> SupabaseAuthGateway:
@@ -27,6 +32,19 @@ def get_auth_gateway() -> SupabaseAuthGateway:
return _auth_gateway
def get_jwt_verifier() -> JwtVerifier:
global _jwt_verifier
if _jwt_verifier is None:
jwks_url = config.supabase.jwks_url
issuer = config.supabase.jwt_issuer
audience = config.supabase.jwt_audience
if not jwks_url or not issuer or not audience:
logger.error("JWT validation failed: verifier config not configured")
raise HTTPException(status_code=503, detail="JWT verifier not configured")
_jwt_verifier = JwtVerifier(jwks_url=jwks_url, issuer=issuer, audience=audience)
return _jwt_verifier
def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser:
if not authorization:
logger.warning("JWT validation failed: missing authorization header")
@@ -37,46 +55,17 @@ def get_current_user(authorization: str | None = Header(default=None)) -> Curren
logger.warning("JWT validation failed: invalid authorization scheme")
raise HTTPException(status_code=401, detail="Unauthorized")
secret = config.supabase.jwt_secret
if not secret:
logger.error("JWT validation failed: secret not configured")
raise HTTPException(status_code=503, detail="JWT secret not configured")
supabase_url = config.supabase.public_url.rstrip("/")
expected_issuer = f"{supabase_url}/auth/v1"
try:
payload = jwt.decode(
token,
secret,
algorithms=["HS256"],
audience="authenticated",
issuer=expected_issuer,
options={
"verify_aud": True,
"verify_iss": True,
"verify_exp": True,
"require": ["sub", "aud", "iss", "exp"],
},
)
except jwt.ExpiredSignatureError:
logger.warning("JWT validation failed: token expired")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidAudienceError:
logger.warning("JWT validation failed: invalid audience")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidIssuerError:
logger.warning("JWT validation failed: invalid issuer")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.InvalidSignatureError:
logger.warning("JWT validation failed: invalid signature")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.DecodeError:
logger.warning("JWT validation failed: malformed token")
raise HTTPException(status_code=401, detail="Unauthorized")
except jwt.PyJWTError as exc:
payload = get_jwt_verifier().verify(token)
except HTTPException:
raise
except TokenVerifierUnavailableError:
logger.error("JWT validation failed: verifier unavailable")
raise HTTPException(status_code=503, detail="JWT verifier unavailable")
except TokenValidationError as exc:
logger.warning(
"JWT validation failed: unknown error", error_type=type(exc).__name__
"JWT validation failed",
error_type=type(exc).__name__,
)
raise HTTPException(status_code=401, detail="Unauthorized") from exc
@@ -1,45 +1,43 @@
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
import httpx
import jwt
import pytest
from sqlalchemy import select
from core.config import config
from core.db.session import AsyncSessionLocal
from models.agent_chat_message import AgentChatMessage
from models.agent_chat_session import AgentChatSession
from models.profile import Profile
BASE_URL = os.getenv("AGENT_LIVE_BASE_URL", "http://localhost:5775")
async def _owner_id() -> UUID:
async with AsyncSessionLocal() as session:
owner_id = (await session.execute(select(Profile.id).limit(1))).scalar_one_or_none()
if owner_id is None:
pytest.skip("profile owner not found")
return owner_id
async def _live_access_token(client: httpx.AsyncClient) -> str:
email = os.getenv("AGENT_LIVE_EMAIL")
password = os.getenv("AGENT_LIVE_PASSWORD")
if not email or not password:
pytest.fail(
"AGENT_LIVE_INTEGRATION=1 requires AGENT_LIVE_EMAIL and AGENT_LIVE_PASSWORD"
)
response = await client.post(
f"{BASE_URL}/api/v1/auth/sessions",
json={"email": email, "password": password},
)
response_text = response.text.strip().replace("\n", " ")
truncated_text = response_text[:200]
if len(response_text) > 200:
truncated_text += "..."
def _jwt_for(user_id: UUID) -> str:
secret = config.supabase.jwt_secret
if not secret:
pytest.skip("JWT secret not configured")
issuer = f"{config.supabase.public_url.rstrip('/')}/auth/v1"
payload = {
"sub": str(user_id),
"role": "authenticated",
"aud": "authenticated",
"iss": issuer,
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(minutes=30),
}
return jwt.encode(payload, secret, algorithm="HS256")
assert response.status_code == 200, (
f"live login failed: status={response.status_code}, response={truncated_text!r}"
)
token = response.json().get("access_token")
assert isinstance(token, str) and token
return token
@pytest.mark.asyncio
@@ -48,11 +46,10 @@ async def test_agent_sse_closed_loop_live() -> None:
if os.getenv("AGENT_LIVE_INTEGRATION") != "1":
pytest.skip("set AGENT_LIVE_INTEGRATION=1 to run live integration test")
owner_id = await _owner_id()
token = _jwt_for(owner_id)
async with httpx.AsyncClient(timeout=30.0) as client:
token = await _live_access_token(client)
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=30.0) as client:
run_resp = await client.post(
f"{BASE_URL}/api/v1/agent/runs",
headers=headers,
@@ -76,9 +73,13 @@ async def test_agent_sse_closed_loop_live() -> None:
events_url = f"{BASE_URL}/api/v1/agent/runs/{thread_id}/events"
event_names: list[str] = []
async with client.stream("GET", events_url, headers=headers, timeout=20.0) as sse_resp:
async with client.stream(
"GET", events_url, headers=headers, timeout=20.0
) as sse_resp:
assert sse_resp.status_code == 200
assert sse_resp.headers.get("content-type", "").startswith("text/event-stream")
assert sse_resp.headers.get("content-type", "").startswith(
"text/event-stream"
)
async for line in sse_resp.aiter_lines():
if line.startswith("event:"):
event_names.append(line.split(":", 1)[1].strip())
@@ -94,6 +95,8 @@ async def test_agent_sse_closed_loop_live() -> None:
assert session_row.total_cost >= 0
rows = await session.execute(
select(AgentChatMessage).where(AgentChatMessage.session_id == UUID(thread_id))
select(AgentChatMessage).where(
AgentChatMessage.session_id == UUID(thread_id)
)
)
assert len(list(rows.scalars().all())) >= 1
@@ -0,0 +1,268 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import Any, cast
from uuid import uuid4
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from core.auth.jwt_verifier import (
JwtVerifier,
TokenValidationError,
TokenVerifierUnavailableError,
)
def _set_jwks_client(verifier: JwtVerifier, client: Any) -> None:
cast(Any, verifier)._jwks_client = client
def _build_rsa_key_pair() -> tuple[str, str]:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("utf-8")
public_pem = (
private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode("utf-8")
)
return private_pem, public_pem
def _build_token(*, private_key: str, sub: str, audience: str, issuer: str) -> str:
now = datetime.now(UTC)
payload = {
"sub": sub,
"aud": audience,
"iss": issuer,
"exp": now + timedelta(minutes=5),
}
return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": "kid-1"})
def _build_expired_token(
*, private_key: str, sub: str, audience: str, issuer: str
) -> str:
now = datetime.now(UTC)
payload = {
"sub": sub,
"aud": audience,
"iss": issuer,
"exp": now - timedelta(minutes=1),
}
return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": "kid-1"})
def _build_hs256_token(*, secret: str, sub: str, audience: str, issuer: str) -> str:
now = datetime.now(UTC)
payload = {
"sub": sub,
"aud": audience,
"iss": issuer,
"exp": now + timedelta(minutes=5),
}
return jwt.encode(payload, secret, algorithm="HS256", headers={"kid": "kid-1"})
def test_verify_token_with_jwks_success() -> None:
user_id = uuid4()
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
token = _build_token(
private_key=private_key,
sub=str(user_id),
audience=audience,
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
claims = verifier.verify(token)
assert claims["sub"] == str(user_id)
def test_verify_token_rejects_invalid_issuer() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
token_with_wrong_iss = _build_token(
private_key=private_key,
sub=str(uuid4()),
audience=audience,
issuer="https://wrong-issuer.example.com/auth/v1",
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(token_with_wrong_iss)
def test_verify_token_rejects_hs256_token() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
_, public_key = _build_rsa_key_pair()
hs_token = _build_hs256_token(
secret="test-secret",
sub=str(uuid4()),
audience=audience,
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(hs_token)
def test_verify_token_rejects_expired_token() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
expired_token = _build_expired_token(
private_key=private_key,
sub=str(uuid4()),
audience=audience,
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(expired_token)
def test_verify_token_rejects_invalid_audience() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
wrong_aud_token = _build_token(
private_key=private_key,
sub=str(uuid4()),
audience="anon",
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(wrong_aud_token)
def test_verify_token_rejects_invalid_signature() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, public_key = _build_rsa_key_pair()
valid_token = _build_token(
private_key=private_key,
sub=str(uuid4()),
audience=audience,
issuer=issuer,
)
tampered_token = f"{valid_token}x"
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
_set_jwks_client(
verifier,
SimpleNamespace(
get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key)
),
)
with pytest.raises(TokenValidationError):
verifier.verify(tampered_token)
def test_verify_token_maps_jwks_connection_error() -> None:
audience = "authenticated"
issuer = "https://example.supabase.co/auth/v1"
private_key, _ = _build_rsa_key_pair()
token = _build_token(
private_key=private_key,
sub=str(uuid4()),
audience=audience,
issuer=issuer,
)
verifier = JwtVerifier(
jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json",
issuer=issuer,
audience=audience,
)
def _raise_connection_error(_: str) -> SimpleNamespace:
raise jwt.PyJWKClientConnectionError("network down")
_set_jwks_client(
verifier,
SimpleNamespace(get_signing_key_from_jwt=_raise_connection_error),
)
with pytest.raises(TokenVerifierUnavailableError):
verifier.verify(token)
@@ -1,19 +1,18 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from pytest import MonkeyPatch
from core.config.settings import Settings
from core.config.settings import Settings, SupabaseSettings
def test_social_prefixed_supabase_env_populates_settings(
monkeypatch: MonkeyPatch,
) -> None:
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_SCHEME", "https")
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_HOST", "public.example")
monkeypatch.setenv("SOCIAL_SUPABASE__KONG_HTTP_PORT", "8443")
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_URL", "https://public.example:8443")
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key")
monkeypatch.setenv("SOCIAL_SUPABASE__JWT_SECRET", "jwt-secret")
monkeypatch.setenv("SOCIAL_SUPABASE__SITE_URL", "https://app.example.com")
monkeypatch.setenv(
"SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS",
@@ -27,10 +26,9 @@ def test_social_prefixed_supabase_env_populates_settings(
settings = Settings()
assert settings.supabase.public_url == "https://public.example:8443"
assert str(settings.supabase.public_url) == "https://public.example:8443/"
assert settings.supabase.anon_key == "anon-key"
assert settings.supabase.service_role_key == "service-key"
assert settings.supabase.jwt_secret == "jwt-secret"
assert settings.supabase.site_url == "https://app.example.com"
assert settings.supabase.additional_redirect_urls == [
"https://a.example.com",
@@ -38,9 +36,63 @@ def test_social_prefixed_supabase_env_populates_settings(
]
supabase_settings = settings.model_dump()["supabase"]
assert supabase_settings["public_url"] == "https://public.example:8443"
assert str(supabase_settings["public_url"]) == "https://public.example:8443/"
assert supabase_settings["anon_key"] == "anon-key"
assert supabase_settings["service_role_key"] == "service-key"
assert supabase_settings["jwt_secret"] == "jwt-secret"
assert supabase_settings["site_url"] == "https://app.example.com"
assert "jwt_secret" not in supabase_settings
assert "public_scheme" not in supabase_settings
assert "public_host" not in supabase_settings
assert "kong_http_port" not in supabase_settings
assert settings.database_url == "postgresql+asyncpg://user:pass@db:5432/app"
def test_cloud_supabase_env_populates_settings(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv(
"SOCIAL_SUPABASE__PUBLIC_URL", "https://project.example.supabase.co"
)
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key")
monkeypatch.setenv("SOCIAL_SUPABASE__JWT_AUDIENCE", "authenticated")
settings = Settings()
assert str(settings.supabase.public_url) == "https://project.example.supabase.co/"
assert settings.supabase.jwt_audience == "authenticated"
assert settings.supabase.jwt_issuer == "https://project.example.supabase.co/auth/v1"
assert (
settings.supabase.jwks_url
== "https://project.example.supabase.co/auth/v1/.well-known/jwks.json"
)
supabase_settings = settings.model_dump()["supabase"]
assert "jwt_secret" not in supabase_settings
def test_missing_public_url_raises_validation_error() -> None:
with pytest.raises(ValidationError) as exc_info:
SupabaseSettings()
assert "public_url" in str(exc_info.value)
def test_public_url_with_trailing_slash_normalizes_correctly(
monkeypatch: MonkeyPatch,
) -> None:
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_URL", "https://example.supabase.co/")
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key")
monkeypatch.setenv("SOCIAL_DATABASE__HOST", "db")
monkeypatch.setenv("SOCIAL_DATABASE__PORT", "5432")
monkeypatch.setenv("SOCIAL_DATABASE__NAME", "app")
monkeypatch.setenv("SOCIAL_DATABASE__USER", "user")
monkeypatch.setenv("SOCIAL_DATABASE__PASSWORD", "pass")
settings = Settings()
assert settings.supabase.jwt_issuer == "https://example.supabase.co/auth/v1"
assert (
settings.supabase.jwks_url
== "https://example.supabase.co/auth/v1/.well-known/jwks.json"
)
assert settings.supabase.url == "https://example.supabase.co/"
@@ -0,0 +1,303 @@
# Cloud Supabase Env Cleanup & JWKS Migration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 切换到云 Supabase 后,移除本地自托管 Supabase 基础设施变量与编排,保留 Redis + DB + init-job,并将后端 JWT 验签从 `JWT_SECRET` 改为 JWKS 公钥验签。
**Architecture:** 后端配置收敛到“业务运行所需最小集合”(Supabase URL/anon/service role + DB + Redis)。认证链路采用 JWKS 拉取公钥并按 `kid` 验签,替代共享密钥 HS256。Docker 编排只保留业务依赖(redis、db、init-job),不再编排本地 Supabase 全家桶。
**Tech Stack:** FastAPI, Pydantic Settings, PyJWT (PyJWKClient), Docker Compose, pytest
---
### Task 1: 固化云模式配置契约(先测后改)
**Files:**
- Modify: `backend/tests/unit/test_settings_supabase_env.py`
- Modify: `.env.example`
**Step 1: 写失败测试,定义新 Supabase 配置契约**
```python
def test_social_prefixed_supabase_env_populates_settings(monkeypatch: MonkeyPatch) -> None:
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_URL", "https://project.example.supabase.co")
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key")
monkeypatch.setenv("SOCIAL_SUPABASE__JWT_AUDIENCE", "authenticated")
settings = Settings()
assert settings.supabase.public_url == "https://project.example.supabase.co"
assert settings.supabase.jwt_issuer == "https://project.example.supabase.co/auth/v1"
assert settings.supabase.jwks_url.endswith("/auth/v1/.well-known/jwks.json")
```
**Step 2: 运行测试确认失败**
Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v`
Expected: FAIL`public_url/jwks_url` 字段不存在或断言失败)
**Step 3: 最小改动让测试通过(仅 settings 相关,逻辑改动在后续任务)**
更新 `.env.example` 为云模式最小变量草案(先占位,后续任务会补最终文案):
- `SOCIAL_SUPABASE__PUBLIC_URL=`
- `SOCIAL_SUPABASE__ANON_KEY=`
- `SOCIAL_SUPABASE__SERVICE_ROLE_KEY=`
- `SOCIAL_SUPABASE__JWT_AUDIENCE=authenticated`
- `SOCIAL_SUPABASE__JWT_ISSUER=`(可选,默认由 PUBLIC_URL 推导)
- `SOCIAL_SUPABASE__JWKS_URL=`(可选,默认由 PUBLIC_URL 推导)
**Step 4: 运行测试确认通过**
Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/tests/unit/test_settings_supabase_env.py .env.example
git commit -m "test: define cloud supabase settings contract"
```
### Task 2: 重构 SupabaseSettings(移除 JWT_SECRET 依赖)
**Files:**
- Modify: `backend/src/core/config/settings.py`
- Modify: `backend/tests/unit/test_settings_supabase_env.py`
**Step 1: 写失败测试,约束默认推导行为**
```python
assert settings.supabase.jwt_issuer == "https://project.example.supabase.co/auth/v1"
assert settings.supabase.jwks_url == "https://project.example.supabase.co/auth/v1/.well-known/jwks.json"
assert "jwt_secret" not in settings.model_dump()["supabase"]
```
**Step 2: 运行测试确认失败**
Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v`
Expected: FAIL
**Step 3: 实现最小配置重构**
`SupabaseSettings` 中改为:
- 必填:`public_url`, `anon_key`, `service_role_key`
- 可选:`site_url`, `additional_redirect_urls`
- 新增:`jwt_audience`(默认 `authenticated`)、`jwt_issuer`(默认 `${public_url}/auth/v1`)、`jwks_url`(默认 `${jwt_issuer}/.well-known/jwks.json`
- 删除:`jwt_secret`, `public_scheme`, `public_host`, `kong_http_port`, `kong_https_port`
**Step 4: 运行测试确认通过**
Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/src/core/config/settings.py backend/tests/unit/test_settings_supabase_env.py
git commit -m "refactor: migrate supabase config to cloud jwks fields"
```
### Task 3: 引入 JWKS 验签组件并接入认证依赖
**Files:**
- Create: `backend/src/core/auth/jwt_verifier.py`
- Modify: `backend/src/v1/users/dependencies.py`
- Create: `backend/tests/unit/core/auth/test_jwt_verifier.py`
**Step 1: 先写失败测试(JWT 验签核心行为)**
```python
def test_verify_token_with_jwks_success(...):
claims = verifier.verify(token)
assert claims["sub"] == str(user_id)
def test_verify_token_rejects_invalid_issuer(...):
with pytest.raises(TokenValidationError):
verifier.verify(token_with_wrong_iss)
```
**Step 2: 运行测试确认失败**
Run: `uv run pytest backend/tests/unit/core/auth/test_jwt_verifier.py -v`
Expected: FAIL(模块/类不存在)
**Step 3: 实现最小 JWKS 验签逻辑**
```python
class JwtVerifier:
def __init__(self, jwks_url: str, issuer: str, audience: str) -> None: ...
def verify(self, token: str) -> dict[str, Any]:
key = self._jwks_client.get_signing_key_from_jwt(token)
return jwt.decode(
token,
key.key,
algorithms=["RS256", "ES256"],
audience=self._audience,
issuer=self._issuer,
options={"require": ["sub", "aud", "iss", "exp"]},
)
```
`get_current_user` 中替换原 `jwt_secret + HS256` 验签,统一映射为现有 401/503 语义。
**Step 4: 运行测试确认通过**
Run: `uv run pytest backend/tests/unit/core/auth/test_jwt_verifier.py -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/src/core/auth/jwt_verifier.py backend/src/v1/users/dependencies.py backend/tests/unit/core/auth/test_jwt_verifier.py
git commit -m "feat: validate access tokens via supabase jwks"
```
### Task 4: 回归认证路径与 live 测试兼容
**Files:**
- Modify: `backend/tests/integration/v1/agent/test_sse_flow_live.py`
- Modify: `backend/tests/integration/test_auth_routes.py`(如需)
**Step 1: 写失败测试/调整 live 测试生成 token 方式**
将 live 测试从“本地签发 HS256 token”改为“通过真实登录拿 access token”或“无测试账号时 skip”。
```python
if not os.getenv("AGENT_LIVE_EMAIL") or not os.getenv("AGENT_LIVE_PASSWORD"):
pytest.skip("missing live supabase credentials")
```
**Step 2: 运行相关测试确认失败(或旧逻辑不适配)**
Run: `uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v`
Expected: 在旧代码下不可用/依赖 jwt_secret
**Step 3: 完成最小实现改造**
- 移除 `config.supabase.jwt_secret` 的测试依赖。
- 保持 `@pytest.mark.live` 行为不变,避免影响常规 CI。
**Step 4: 运行测试确认通过(或受控 skip)**
Run: `uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v`
Expected: PASS 或可解释的 SKIP(凭证缺失)
**Step 5: Commit**
```bash
git add backend/tests/integration/v1/agent/test_sse_flow_live.py backend/tests/integration/test_auth_routes.py
git commit -m "test: align live auth flow with cloud supabase tokens"
```
### Task 5: 裁剪 Docker Compose(移除本地 Supabase,保留 Redis/DB/init-job
**Files:**
- Modify: `infra/docker/docker-compose.yml`
**Step 1: 写失败验证(compose 结构断言)**
添加一个轻量脚本化检查(可在本任务临时执行,不必入库):
```bash
docker compose --env-file .env -f infra/docker/docker-compose.yml config
```
在改造前记录当前包含的 Supabase 服务(`studio/kong/auth/rest/...`)作为对照。
**Step 2: 执行检查确认当前状态(基线)**
Run: `docker compose --env-file .env -f infra/docker/docker-compose.yml config`
Expected: 输出包含 Supabase 全家桶服务
**Step 3: 最小实现裁剪**
- 删除服务:`studio/kong/mail-templates/auth/rest/realtime/storage/imgproxy/meta/functions/analytics/vector/supavisor`
- 保留服务:`redis`, `db`, `init-job`
- `init-job` 环境变量移除:`SOCIAL_SUPABASE__ANON_KEY`, `SOCIAL_SUPABASE__SERVICE_ROLE_KEY`, `SOCIAL_SUPABASE__JWT_SECRET`
- `db` 服务切换为业务最小化所需配置(仅数据库启动与健康检查必需)
**Step 4: 运行 compose 校验**
Run: `docker compose --env-file .env -f infra/docker/docker-compose.yml config`
Expected: PASS,且仅保留 redis/db/init-job
**Step 5: Commit**
```bash
git add infra/docker/docker-compose.yml
git commit -m "refactor: remove local supabase stack from compose"
```
### Task 6: 清理环境模板与运行文档
**Files:**
- Modify: `.env.example`
- Modify: `docs/runtime/runtime-runbook.md`
- Modify: `infra/scripts/dev-migrate.sh`
**Step 1: 先写文档/模板检查点(人工可核验)**
定义必须满足:
- `.env.example` 不再包含本地 Supabase 基础设施变量(logflare/pooler/studio/kong/jwt_secret 等)
- 保留并标注后端必需项:`PUBLIC_URL`, `ANON_KEY`, `SERVICE_ROLE_KEY`
- runbook 的健康检查改为 Redis/DB/Web,而非 Kong
**Step 2: 运行基线检查(改造前)**
Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v`
Expected: 作为环境模板改造后的回归基线
**Step 3: 最小实现文档更新**
- `docs/runtime/runtime-runbook.md`:把“启动基础设施”描述改为 `redis + db`
- `infra/scripts/dev-migrate.sh`:将提示从“Requires Supabase services”改为“Requires db/redis services”。
- `.env.example`:按云模式分组,明确前端/后端变量边界。
**Step 4: 运行检查确认通过**
Run: `docker compose --env-file .env -f infra/docker/docker-compose.yml config`
Expected: PASS
**Step 5: Commit**
```bash
git add .env.example docs/runtime/runtime-runbook.md infra/scripts/dev-migrate.sh
git commit -m "docs: update runtime guide for cloud supabase mode"
```
### Task 7: 全量验证与发布前检查
**Files:**
- Modify: `docs/runtime/runtime-runbook.md`(记录验证命令与结果)
**Step 1: 运行静态检查**
Run: `uv run ruff check backend/src backend/tests`
Expected: PASS
**Step 2: 运行类型检查**
Run: `uv run basedpyright`
Expected: PASS
**Step 3: 运行测试(按影响面)**
Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py backend/tests/unit/core/auth/test_jwt_verifier.py -v`
Expected: PASS
Run: `uv run pytest backend/tests/integration/test_users_routes.py backend/tests/integration/test_auth_routes.py -v`
Expected: PASS
**Step 4: 运行运行时门禁验证**
Run: `docker compose --env-file .env -f infra/docker/docker-compose.yml up -d redis db && docker compose --env-file .env -f infra/docker/docker-compose.yml run --rm --build init-job uv run python -m core.runtime.cli bootstrap`
Expected: PASS(迁移 + init-data 成功)
**Step 5: Commit**
```bash
git add docs/runtime/runtime-runbook.md
git commit -m "chore: record cloud supabase migration verification"
```
+19 -21
View File
@@ -28,10 +28,10 @@
### Step 1: 启动基础设施
```bash
docker compose --env-file .env -f infra/docker/docker-compose.yml up -d
docker compose --env-file .env -f infra/docker/docker-compose.yml up -d redis
```
通过标准:`docker compose ... ps` 中 redis/supabase 相关容器为 `running`
通过标准:`docker compose ... ps``redis` 容器为 `running`/`healthy`
### Step 2: 执行迁移与初始化
@@ -114,8 +114,11 @@ set +a
WEB_BASE_URL="http://127.0.0.1:${SOCIAL_WEB__PORT:-5775}"
# 基础健康
curl -fsS http://127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTP_PORT:-8000}/health
# 基础健康redis/web;数据库使用云 Supabase Postgres
docker compose --env-file .env -f infra/docker/docker-compose.yml exec -T redis \
sh -lc 'if [ -n "${REDIS_PASSWORD:-}" ]; then redis-cli -a "${REDIS_PASSWORD}" ping; else redis-cli ping; fi'
curl -fsS "${WEB_BASE_URL}/health"
# compose 状态
docker compose --env-file .env -f infra/docker/docker-compose.yml ps
@@ -126,7 +129,7 @@ curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/sessions" \
-d '{"email":"demo@example.com","password":"secret123"}'
```
通过标准:health 返回 2xx关键容器 `running`,核心接口返回预期业务状态码。
通过标准:redis 健康检查成功,web `/health` 返回 2xx,容器 `running`,核心接口返回预期业务状态码。
### L2 可选(Auth/Profile 业务回归)
@@ -193,31 +196,25 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml exec -T redis
- 定位:核对 `.env` 中 Supabase JWT 配置与签发方设置。
- 修复:修正配置后重启 web 进程并执行 L1/L2 验证。
### 4) Auth 邮件模板未生效 / 注册返回超时但邮件已发送
### 4) 基础设施容器异常(db/redis
- 症状:
- 收到默认英文模板(非 `infra/mail-templates`)。
- `signup/start` 偶发 500 或超时,但邮箱仍收到验证码邮件。
- 根因:容器配置漂移(旧容器未按最新 compose/.env 重建),导致:
- `supabase-auth` 缺少 `GOTRUE_MAILER_TEMPLATES_*` 环境变量。
- `supabase-mail-templates` 仍挂载旧路径。
- 症状:web 启动失败、迁移失败、任务队列连接报错。
- 定位:
```bash
docker inspect supabase-auth --format '{{ range .Config.Env }}{{ println . }}{{ end }}' | grep GOTRUE_MAILER_TEMPLATES
docker inspect supabase-mail-templates --format '{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ println }}{{ end }}'
docker compose --env-file .env -f infra/docker/docker-compose.yml ps
docker compose --env-file .env -f infra/docker/docker-compose.yml logs db --tail=100
docker compose --env-file .env -f infra/docker/docker-compose.yml logs redis --tail=100
```
- 修复:强制重建 auth 和 mail-templates(不改其他服务):
- 修复:按依赖顺序重建基础设施后重新 bootstrap。
```bash
docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force-recreate --no-deps mail-templates auth
docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force-recreate redis
bash infra/scripts/dev-migrate.sh bootstrap
```
- 复核标准:
- `docker inspect supabase-auth` 能看到 `GOTRUE_MAILER_TEMPLATES_CONFIRMATION/RECOVERY`
- `supabase-mail-templates` 挂载源为 `infra/mail-templates`
- `POST /api/v1/auth/verifications` 返回 `202` 且耗时恢复正常。
- 复核标准:`redis` 健康检查通过,L1 核心接口 smoke 无 5xx。
---
@@ -248,7 +245,7 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force-
|------|------|
| 2026-02-24 | 创建运行时手册,删除 legacy 脚本,统一使用 gunicorn |
| 2026-02-24 | 清理配置:合并 AppSettings 到 WebSettings,删除 Worker 旧配置 (enabled_queues/queues),统一使用 SOCIAL_WEB__GUNICORN__* 命名 |
| 2026-02-24 | 开发阶段 compose 暂不编排 web/worker,仅保留 redis/supabase 与 init-job |
| 2026-02-24 | 开发阶段 compose 暂不编排 web/worker,仅保留 redis/db 与 init-job |
| 2026-02-24 | 新增 dev-app-up 脚本:手动基础设施后,一键 bootstrap + tmux 拉起 web/worker |
| 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 |
| 2026-02-25 | Auth 注册切换为 OTP 三段式:signup/start、signup/verify、signup/resend;邮件模板改为纯验证码展示 |
@@ -263,3 +260,4 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force-
| 2026-03-02 | 修正 bootstrap 命令:init-job 需要使用 `uv run python -m core.runtime.cli bootstrap` |
| 2026-03-05 | 新增 Agent Runtime run/resume/events 运维排障流程(Taskiq + Redis + Last-Event-ID |
| 2026-03-06 | Web 启动从 gunicorn 迁移为纯 uvicorn,移除 `SOCIAL_WEB__GUNICORN__*` 配置,统一使用 `SOCIAL_WEB__WORKERS` |
| 2026-03-09 | 清理本地 Supabase 依赖描述:基础设施启动与巡检统一为 redis/db/web |
+7 -404
View File
@@ -19,400 +19,6 @@ services:
timeout: 3s
retries: 5
studio:
container_name: supabase-studio
image: supabase/studio:2025.12.17-sha-43f4f7f
restart: unless-stopped
healthcheck:
test:
["CMD", "node", "-e", "fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})"]
timeout: 10s
interval: 5s
retries: 3
depends_on:
analytics:
condition: service_healthy
environment:
HOSTNAME: "::"
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PORT: 5432
POSTGRES_HOST: db
POSTGRES_DB: postgres
POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD}
PG_META_CRYPTO_KEY: ${SOCIAL_SUPABASE__PG_META_CRYPTO_KEY}
DEFAULT_ORGANIZATION_NAME: ${SOCIAL_SUPABASE__STUDIO_DEFAULT_ORGANIZATION:-Social App}
DEFAULT_PROJECT_NAME: ${SOCIAL_SUPABASE__STUDIO_DEFAULT_PROJECT:-local}
OPENAI_API_KEY: ${SOCIAL_SUPABASE__OPENAI_API_KEY:-}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SOCIAL_SUPABASE__PUBLIC_SCHEME}://${SOCIAL_SUPABASE__PUBLIC_HOST}:${SOCIAL_SUPABASE__KONG_HTTP_PORT}
SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY}
SUPABASE_SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PRIVATE_ACCESS_TOKEN}
LOGFLARE_URL: http://analytics:4000
NEXT_PUBLIC_ENABLE_LOGS: "true"
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
kong:
container_name: supabase-kong
image: kong:2.8.1
restart: unless-stopped
ports:
- "127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTP_PORT:-8000}:8000/tcp"
- "127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTPS_PORT:-8443}:8443/tcp"
volumes:
- ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
depends_on:
analytics:
condition: service_healthy
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
KONG_PROXY_LISTEN: 0.0.0.0:8000, 0.0.0.0:8443 ssl
SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY}
SUPABASE_SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY}
DASHBOARD_USERNAME: ${SOCIAL_SUPABASE__DASHBOARD_USERNAME}
DASHBOARD_PASSWORD: ${SOCIAL_SUPABASE__DASHBOARD_PASSWORD}
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
mail-templates:
container_name: supabase-mail-templates
image: nginx:1.27-alpine
restart: unless-stopped
volumes:
- ../mail-templates:/usr/share/nginx/html:ro
healthcheck:
test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost/confirmation.html && wget --no-verbose --tries=1 --spider http://localhost/recovery.html"]
timeout: 5s
interval: 10s
retries: 3
auth:
container_name: supabase-auth
image: supabase/gotrue:v2.184.0
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
timeout: 5s
interval: 5s
retries: 3
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
mail-templates:
condition: service_healthy
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: ${SOCIAL_SUPABASE__PUBLIC_SCHEME}://${SOCIAL_SUPABASE__PUBLIC_HOST}:${SOCIAL_SUPABASE__KONG_HTTP_PORT}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres
GOTRUE_SITE_URL: ${SOCIAL_SUPABASE__SITE_URL}
GOTRUE_URI_ALLOW_LIST: ${SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS:-}
GOTRUE_DISABLE_SIGNUP: ${SOCIAL_SUPABASE__DISABLE_SIGNUP:-false}
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${SOCIAL_SUPABASE__JWT_EXPIRY:-3600}
GOTRUE_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_SUPABASE__ENABLE_EMAIL_SIGNUP:-true}
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${SOCIAL_SUPABASE__ENABLE_ANONYMOUS_USERS:-false}
GOTRUE_MAILER_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_EMAIL_AUTOCONFIRM:-false}
GOTRUE_SMTP_ADMIN_EMAIL: ${SOCIAL_SUPABASE__SMTP_ADMIN_EMAIL:-}
GOTRUE_SMTP_HOST: ${SOCIAL_SUPABASE__SMTP_HOST:-}
GOTRUE_SMTP_PORT: ${SOCIAL_SUPABASE__SMTP_PORT:-}
GOTRUE_SMTP_USER: ${SOCIAL_SUPABASE__SMTP_USER:-}
GOTRUE_SMTP_PASS: ${SOCIAL_SUPABASE__SMTP_PASS:-}
GOTRUE_SMTP_SENDER_NAME: ${SOCIAL_SUPABASE__SMTP_SENDER_NAME:-}
GOTRUE_MAILER_URLPATHS_INVITE: ${SOCIAL_SUPABASE__MAILER_URLPATHS_INVITE:-/auth/v1/verify}
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_URLPATHS_CONFIRMATION:-/auth/v1/verify}
GOTRUE_MAILER_URLPATHS_RECOVERY: ${SOCIAL_SUPABASE__MAILER_URLPATHS_RECOVERY:-/auth/v1/recover}
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${SOCIAL_SUPABASE__MAILER_URLPATHS_EMAIL_CHANGE:-/auth/v1/verify}
GOTRUE_MAILER_TEMPLATES_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_TEMPLATES_CONFIRMATION:-}
GOTRUE_MAILER_TEMPLATES_RECOVERY: ${SOCIAL_SUPABASE__MAILER_TEMPLATES_RECOVERY:-}
GOTRUE_MAILER_SUBJECTS_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_SUBJECTS_CONFIRMATION:-}
GOTRUE_MAILER_SUBJECTS_RECOVERY: ${SOCIAL_SUPABASE__MAILER_SUBJECTS_RECOVERY:-}
GOTRUE_MAILER_OTP_LENGTH: ${SOCIAL_SUPABASE__MAILER_OTP_LENGTH:-6}
GOTRUE_MAILER_OTP_EXP: ${SOCIAL_SUPABASE__MAILER_OTP_EXP:-300}
GOTRUE_EXTERNAL_PHONE_ENABLED: ${SOCIAL_SUPABASE__ENABLE_PHONE_SIGNUP:-false}
GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_PHONE_AUTOCONFIRM:-false}
rest:
container_name: supabase-rest
image: postgrest/postgrest:v14.1
restart: unless-stopped
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres
PGRST_DB_SCHEMAS: ${SOCIAL_SUPABASE__PGRST_DB_SCHEMAS:-public}
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${SOCIAL_SUPABASE__JWT_EXPIRY:-3600}
command: ["postgrest"]
realtime:
container_name: realtime-dev.supabase-realtime
image: supabase/realtime:v2.68.0
restart: unless-stopped
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${SOCIAL_SUPABASE__ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health"]
timeout: 5s
interval: 30s
retries: 3
start_period: 10s
environment:
PORT: 4000
DB_HOST: db
DB_PORT: 5432
DB_USER: supabase_admin
DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD}
DB_NAME: postgres
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
SECRET_KEY_BASE: ${SOCIAL_SUPABASE__SECRET_KEY_BASE}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
APP_NAME: realtime
SEED_SELF_HOST: "true"
RUN_JANITOR: "true"
storage:
container_name: supabase-storage
image: supabase/storage-api:v1.33.0
restart: unless-stopped
volumes:
- storage_data:/var/lib/storage
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://storage:5000/status"]
timeout: 5s
interval: 5s
retries: 3
depends_on:
db:
condition: service_healthy
rest:
condition: service_started
imgproxy:
condition: service_started
environment:
ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY}
SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY}
POSTGREST_URL: http://rest:3000
PGRST_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
DATABASE_URL: postgres://supabase_storage_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
FILE_SIZE_LIMIT: 52428800
STORAGE_BACKEND: file
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
TENANT_ID: stub
REGION: stub
GLOBAL_S3_BUCKET: stub
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
imgproxy:
container_name: supabase-imgproxy
image: darthsim/imgproxy:v3.30.1
restart: unless-stopped
volumes:
- ./volumes/storage:/var/lib/storage:z
healthcheck:
test: ["CMD", "imgproxy", "health"]
timeout: 5s
interval: 5s
retries: 3
environment:
IMGPROXY_BIND: ":5001"
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_ENABLE_WEBP_DETECTION: ${SOCIAL_SUPABASE__IMGPROXY_ENABLE_WEBP_DETECTION:-true}
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
meta:
container_name: supabase-meta
image: supabase/postgres-meta:v0.95.1
restart: unless-stopped
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: db
PG_META_DB_PORT: 5432
PG_META_DB_NAME: postgres
PG_META_DB_USER: supabase_admin
PG_META_DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD}
CRYPTO_KEY: ${SOCIAL_SUPABASE__PG_META_CRYPTO_KEY}
functions:
container_name: supabase-edge-functions
image: supabase/edge-runtime:v1.69.28
restart: unless-stopped
volumes:
- ./volumes/functions:/home/deno/functions:Z
depends_on:
analytics:
condition: service_healthy
environment:
JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
SUPABASE_URL: http://kong:8000
SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY}
SUPABASE_DB_URL: postgresql://postgres:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres
VERIFY_JWT: "${SOCIAL_SUPABASE__FUNCTIONS_VERIFY_JWT:-false}"
command: ["start", "--main-service", "/home/deno/functions/main"]
analytics:
container_name: supabase-analytics
image: supabase/logflare:1.27.0
restart: unless-stopped
ports:
- "127.0.0.1:${SOCIAL_SUPABASE__ANALYTICS_PORT:-4000}:4000"
healthcheck:
test: ["CMD", "curl", "http://localhost:4000/health"]
timeout: 5s
interval: 5s
retries: 10
depends_on:
db:
condition: service_healthy
environment:
LOGFLARE_NODE_HOST: 127.0.0.1
DB_USERNAME: supabase_admin
DB_DATABASE: _supabase
DB_HOSTNAME: db
DB_PORT: 5432
DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD}
DB_SCHEMA: _analytics
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN}
LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PRIVATE_ACCESS_TOKEN}
LOGFLARE_SINGLE_TENANT: true
LOGFLARE_SUPABASE_MODE: true
POSTGRES_BACKEND_URL: postgresql://supabase_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/_supabase
POSTGRES_BACKEND_SCHEMA: _analytics
LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true
db:
container_name: supabase-db
image: supabase/postgres:15.8.1.085
restart: unless-stopped
ports:
- "127.0.0.1:${SOCIAL_DATABASE__PORT:-5432}:5432"
volumes:
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
- ./volumes/db/data:/var/lib/postgresql/data:Z
- ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
- db-config:/etc/postgresql-custom
healthcheck:
test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 10
depends_on:
vector:
condition: service_healthy
environment:
POSTGRES_HOST: /var/run/postgresql
PGPORT: 5432
POSTGRES_PORT: 5432
PGPASSWORD: ${SOCIAL_DATABASE__PASSWORD}
POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD}
PGDATABASE: postgres
POSTGRES_DB: postgres
JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
JWT_EXP: ${SOCIAL_SUPABASE__JWT_EXPIRY:-3600}
command:
["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", "log_min_messages=fatal"]
vector:
container_name: supabase-vector
image: timberio/vector:0.28.1-alpine
restart: unless-stopped
volumes:
- ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z
- ${SOCIAL_SUPABASE__DOCKER_SOCKET_LOCATION:-/var/run/docker.sock}:/var/run/docker.sock:ro,z
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://vector:9001/health"]
timeout: 5s
interval: 5s
retries: 3
environment:
LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN}
command: ["--config", "/etc/vector/vector.yml"]
security_opt:
- "label=disable"
supavisor:
container_name: supabase-pooler
image: supabase/supavisor:2.7.4
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432"
- "127.0.0.1:${SOCIAL_SUPABASE__POOLER_PROXY_PORT_TRANSACTION:-6543}:6543"
volumes:
- ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
healthcheck:
test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/api/health"]
interval: 10s
timeout: 5s
retries: 5
depends_on:
db:
condition: service_healthy
analytics:
condition: service_healthy
environment:
PORT: 4000
POSTGRES_PORT: 5432
POSTGRES_DB: postgres
POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD}
DATABASE_URL: ecto://supabase_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/_supabase
CLUSTER_POSTGRES: true
SECRET_KEY_BASE: ${SOCIAL_SUPABASE__SECRET_KEY_BASE}
VAULT_ENC_KEY: ${SOCIAL_SUPABASE__VAULT_ENC_KEY}
API_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
METRICS_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET}
REGION: local
ERL_AFLAGS: -proto_dist inet_tcp
POOLER_TENANT_ID: ${SOCIAL_SUPABASE__POOLER_TENANT_ID:-local}
POOLER_DEFAULT_POOL_SIZE: ${SOCIAL_SUPABASE__POOLER_DEFAULT_POOL_SIZE:-20}
POOLER_MAX_CLIENT_CONN: ${SOCIAL_SUPABASE__POOLER_MAX_CLIENT_CONN:-100}
POOLER_POOL_MODE: transaction
DB_POOL_SIZE: ${SOCIAL_SUPABASE__POOLER_DB_POOL_SIZE:-5}
command:
["/bin/sh", "-c", "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server"]
# 开发阶段暂时禁用业务镜像(web/worker)。
# 如需恢复,请从 git 历史恢复以下服务定义:web, worker-critical,
# worker-default, worker-bulk。
init-job:
build:
context: ../..
@@ -422,18 +28,17 @@ services:
restart: "no"
environment:
- PYTHONPATH=/app/backend/src
- SOCIAL_DATABASE__HOST=db
- SOCIAL_DATABASE__PORT=5432
- SOCIAL_DATABASE__HOST=${SOCIAL_DATABASE__HOST}
- SOCIAL_DATABASE__PORT=${SOCIAL_DATABASE__PORT}
- SOCIAL_DATABASE__NAME=${SOCIAL_DATABASE__NAME}
- SOCIAL_DATABASE__USER=${SOCIAL_DATABASE__USER}
- SOCIAL_DATABASE__PASSWORD=${SOCIAL_DATABASE__PASSWORD}
- SOCIAL_REDIS__HOST=redis
- SOCIAL_REDIS__PORT=6379
- SOCIAL_REDIS__HOST=${SOCIAL_REDIS__HOST}
- SOCIAL_REDIS__PORT=${SOCIAL_REDIS__PORT}
- SOCIAL_REDIS__PASSWORD=${SOCIAL_REDIS__PASSWORD:-}
- SOCIAL_SUPABASE__ANON_KEY=${SOCIAL_SUPABASE__ANON_KEY}
- SOCIAL_SUPABASE__SERVICE_ROLE_KEY=${SOCIAL_SUPABASE__SERVICE_ROLE_KEY}
- SOCIAL_SUPABASE__JWT_SECRET=${SOCIAL_SUPABASE__JWT_SECRET}
- SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-dev}
depends_on:
db:
redis:
condition: service_healthy
working_dir: /app/backend
command: uv run python -m core.runtime.cli bootstrap
@@ -442,5 +47,3 @@ services:
volumes:
redis_data:
db-config:
storage_data:
-238
View File
@@ -1,238 +0,0 @@
_format_version: '2.1'
_transform: true
consumers:
- username: DASHBOARD
- username: anon
- username: service_role
keyauth_credentials:
- consumer: anon
key: $SUPABASE_ANON_KEY
- consumer: service_role
key: $SUPABASE_SERVICE_KEY
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
basicauth_credentials:
- consumer: DASHBOARD
username: $DASHBOARD_USERNAME
password: $DASHBOARD_PASSWORD
services:
- name: auth-v1-open
url: http://auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
url: http://auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1-open-authorize
url: http://auth:9999/authorize
routes:
- name: auth-v1-open-authorize
strip_path: true
paths:
- /auth/v1/authorize
plugins:
- name: cors
- name: auth-v1
_comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*'
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: rest-v1
_comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*'
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: graphql-v1
_comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql'
url: http://rest:3000/rpc/graphql
routes:
- name: graphql-v1-all
strip_path: true
paths:
- /graphql/v1
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: request-transformer
config:
add:
headers:
- Content-Profile:graphql_public
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: realtime-v1-ws
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
url: http://realtime-dev.supabase-realtime:4000/socket
protocol: ws
routes:
- name: realtime-v1-ws
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: realtime-v1-rest
_comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*'
url: http://realtime-dev.supabase-realtime:4000/api
protocol: http
routes:
- name: realtime-v1-rest
strip_path: true
paths:
- /realtime/v1/api
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: storage-v1
_comment: 'Storage: /storage/v1/* -> http://storage:5000/*'
url: http://storage:5000/
routes:
- name: storage-v1-all
strip_path: true
paths:
- /storage/v1/
plugins:
- name: cors
- name: functions-v1
_comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*'
url: http://functions:9000/
routes:
- name: functions-v1-all
strip_path: true
paths:
- /functions/v1/
plugins:
- name: cors
- name: analytics-v1
_comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*'
url: http://analytics:4000/
routes:
- name: analytics-v1-all
strip_path: true
paths:
- /analytics/v1/
- name: meta
_comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*'
url: http://meta:8080/
routes:
- name: meta-all
strip_path: true
paths:
- /pg/
plugins:
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- name: mcp-blocker
_comment: 'Block direct access to /api/mcp'
url: http://studio:3000/api/mcp
routes:
- name: mcp-blocker-route
strip_path: true
paths:
- /api/mcp
plugins:
- name: request-termination
config:
status_code: 403
message: "Access is forbidden."
- name: mcp
_comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)'
url: http://studio:3000/api/mcp
routes:
- name: mcp
strip_path: true
paths:
- /mcp
plugins:
- name: cors
- name: ip-restriction
config:
allow:
- 127.0.0.1
- ::1
- 172.19.0.1
deny: []
- name: dashboard
_comment: 'Studio: /* -> http://studio:3000/*'
url: http://studio:3000/
routes:
- name: dashboard-all
strip_path: true
paths:
- /
plugins:
- name: cors
- name: basic-auth
config:
hide_credentials: true
-2
View File
@@ -1,2 +0,0 @@
\set pguser `echo "$POSTGRES_USER"`
CREATE DATABASE _supabase WITH OWNER :pguser;
-4
View File
@@ -1,4 +0,0 @@
\set jwt_secret `echo "$JWT_SECRET"`
\set jwt_exp `echo "$JWT_EXP"`
ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret';
ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp';
-5
View File
@@ -1,5 +0,0 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _analytics;
alter schema _analytics owner to :pguser;
\c postgres
-5
View File
@@ -1,5 +0,0 @@
\set pguser `echo "$POSTGRES_USER"`
\c _supabase
create schema if not exists _supavisor;
alter schema _supavisor owner to :pguser;
\c postgres
-3
View File
@@ -1,3 +0,0 @@
\set pguser `echo "$POSTGRES_USER"`
create schema if not exists _realtime;
alter schema _realtime owner to :pguser;
-7
View File
@@ -1,7 +0,0 @@
-- NOTE: change to your own passwords for production environments
\set pgpass `echo "$POSTGRES_PASSWORD"`
ALTER USER authenticator WITH PASSWORD :'pgpass';
ALTER USER pgbouncer WITH PASSWORD :'pgpass';
ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass';
ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass';
-191
View File
@@ -1,191 +0,0 @@
BEGIN;
CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions;
CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin;
GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role;
CREATE TABLE supabase_functions.migrations (
version text PRIMARY KEY,
inserted_at timestamptz NOT NULL DEFAULT NOW()
);
INSERT INTO supabase_functions.migrations (version) VALUES ('initial');
CREATE TABLE supabase_functions.hooks (
id bigserial PRIMARY KEY,
hook_table_id integer NOT NULL,
hook_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT NOW(),
request_id bigint
);
CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id);
CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name);
COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.';
CREATE FUNCTION supabase_functions.http_request()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
DECLARE
request_id bigint;
payload jsonb;
url text := TG_ARGV[0]::text;
method text := TG_ARGV[1]::text;
headers jsonb DEFAULT '{}'::jsonb;
params jsonb DEFAULT '{}'::jsonb;
timeout_ms integer DEFAULT 1000;
BEGIN
IF url IS NULL OR url = 'null' THEN
RAISE EXCEPTION 'url argument is missing';
END IF;
IF method IS NULL OR method = 'null' THEN
RAISE EXCEPTION 'method argument is missing';
END IF;
IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
headers = '{"Content-Type": "application/json"}'::jsonb;
ELSE
headers = TG_ARGV[2]::jsonb;
END IF;
IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
params = '{}'::jsonb;
ELSE
params = TG_ARGV[3]::jsonb;
END IF;
IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
timeout_ms = 1000;
ELSE
timeout_ms = TG_ARGV[4]::integer;
END IF;
CASE
WHEN method = 'GET' THEN
SELECT http_get INTO request_id FROM net.http_get(
url,
params,
headers,
timeout_ms
);
WHEN method = 'POST' THEN
payload = jsonb_build_object(
'old_record', OLD,
'record', NEW,
'type', TG_OP,
'table', TG_TABLE_NAME,
'schema', TG_TABLE_SCHEMA
);
SELECT http_post INTO request_id FROM net.http_post(
url,
payload,
params,
headers,
timeout_ms
);
ELSE
RAISE EXCEPTION 'method argument % is invalid', method;
END CASE;
INSERT INTO supabase_functions.hooks
(hook_table_id, hook_name, request_id)
VALUES
(TG_RELID, TG_NAME, request_id);
RETURN NEW;
END
$function$;
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_functions_admin'
)
THEN
CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION;
END IF;
END
$$;
GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin;
ALTER USER supabase_functions_admin SET search_path = "supabase_functions";
ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin;
ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin;
ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin;
GRANT supabase_functions_admin TO postgres;
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_roles
WHERE rolname = 'supabase_pg_net_admin'
)
THEN
REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin;
DROP OWNED BY supabase_pg_net_admin;
DROP ROLE supabase_pg_net_admin;
END IF;
END
$$;
DO
$$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_extension
WHERE extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END
$$;
CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access()
RETURNS event_trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_event_trigger_ddl_commands() AS ev
JOIN pg_extension AS ext
ON ev.objid = ext.oid
WHERE ext.extname = 'pg_net'
)
THEN
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
END IF;
END;
$$;
COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net';
DO
$$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_event_trigger
WHERE evtname = 'issue_pg_net_access'
) THEN
CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION')
EXECUTE PROCEDURE extensions.grant_pg_net_access();
END IF;
END
$$;
INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants');
ALTER function supabase_functions.http_request() SECURITY DEFINER;
ALTER function supabase_functions.http_request() SET search_path = supabase_functions;
REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role;
COMMIT;
@@ -1,5 +0,0 @@
Deno.serve(() => new Response("Supabase Edge Functions ready", {
headers: {
"content-type": "text/plain",
},
}))
-237
View File
@@ -1,237 +0,0 @@
api:
enabled: true
address: 0.0.0.0:9001
sources:
docker_host:
type: docker_logs
exclude_containers:
- supabase-vector
transforms:
project_logs:
type: remap
inputs:
- docker_host
source: |-
.project = "default"
.event_message = del(.message)
.appname = del(.container_name)
del(.container_created_at)
del(.container_id)
del(.source_type)
del(.stream)
del(.label)
del(.image)
del(.host)
del(.stream)
router:
type: route
inputs:
- project_logs
route:
kong: '.appname == "supabase-kong"'
auth: '.appname == "supabase-auth"'
rest: '.appname == "supabase-rest"'
realtime: '.appname == "realtime-dev.supabase-realtime"'
storage: '.appname == "supabase-storage"'
functions: '.appname == "supabase-edge-functions"'
db: '.appname == "supabase-db"'
kong_logs:
type: remap
inputs:
- router.kong
source: |-
req, err = parse_nginx_log(.event_message, "combined")
if err == null {
.timestamp = req.timestamp
.metadata.request.headers.referer = req.referer
.metadata.request.headers.user_agent = req.agent
.metadata.request.headers.cf_connecting_ip = req.client
.metadata.request.method = req.method
.metadata.request.path = req.path
.metadata.request.protocol = req.protocol
.metadata.response.status_code = req.status
}
if err != null {
abort
}
kong_err:
type: remap
inputs:
- router.kong
source: |-
.metadata.request.method = "GET"
.metadata.response.status_code = 200
parsed, err = parse_nginx_log(.event_message, "error")
if err == null {
.timestamp = parsed.timestamp
.severity = parsed.severity
.metadata.request.host = parsed.host
.metadata.request.headers.cf_connecting_ip = parsed.client
url, err = split(parsed.request, " ")
if err == null {
.metadata.request.method = url[0]
.metadata.request.path = url[1]
.metadata.request.protocol = url[2]
}
}
if err != null {
abort
}
auth_logs:
type: remap
inputs:
- router.auth
source: |-
parsed, err = parse_json(.event_message)
if err == null {
.metadata.timestamp = parsed.time
.metadata = merge!(.metadata, parsed)
}
rest_logs:
type: remap
inputs:
- router.rest
source: |-
parsed, err = parse_regex(.event_message, r'^(?P<time>.*): (?P<msg>.*)$')
if err == null {
.event_message = parsed.msg
.timestamp = to_timestamp!(parsed.time)
.metadata.host = .project
}
realtime_logs:
type: remap
inputs:
- router.realtime
source: |-
.metadata.project = del(.project)
.metadata.external_id = .metadata.project
parsed, err = parse_regex(.event_message, r'^(?P<time>\d+:\d+:\d+\.\d+) \[(?P<level>\w+)\] (?P<msg>.*)$')
if err == null {
.event_message = parsed.msg
.metadata.level = parsed.level
}
functions_logs:
type: remap
inputs:
- router.functions
source: |-
.metadata.project_ref = del(.project)
storage_logs:
type: remap
inputs:
- router.storage
source: |-
.metadata.project = del(.project)
.metadata.tenantId = .metadata.project
parsed, err = parse_json(.event_message)
if err == null {
.event_message = parsed.msg
.metadata.level = parsed.level
.metadata.timestamp = parsed.time
.metadata.context[0].host = parsed.hostname
.metadata.context[0].pid = parsed.pid
}
db_logs:
type: remap
inputs:
- router.db
source: |-
.metadata.host = "db-default"
.metadata.parsed.timestamp = .timestamp
parsed, err = parse_regex(.event_message, r'.*(?P<level>INFO|NOTICE|WARNING|ERROR|LOG|FATAL|PANIC?):.*', numeric_groups: true)
if err != null || parsed == null {
.metadata.parsed.error_severity = "info"
}
if parsed != null {
.metadata.parsed.error_severity = parsed.level
}
if .metadata.parsed.error_severity == "info" {
.metadata.parsed.error_severity = "log"
}
.metadata.parsed.error_severity = upcase!(.metadata.parsed.error_severity)
sinks:
logflare_auth:
type: http
inputs:
- auth_logs
encoding:
codec: json
method: post
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=gotrue.logs.prod'
logflare_realtime:
type: http
inputs:
- realtime_logs
encoding:
codec: json
method: post
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=realtime.logs.prod'
logflare_rest:
type: http
inputs:
- rest_logs
encoding:
codec: json
method: post
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=postgREST.logs.prod'
logflare_db:
type: http
inputs:
- db_logs
encoding:
codec: json
method: post
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://kong:8000/analytics/v1/api/logs?source_name=postgres.logs'
logflare_functions:
type: http
inputs:
- functions_logs
encoding:
codec: json
method: post
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=deno-relay-logs'
logflare_storage:
type: http
inputs:
- storage_logs
encoding:
codec: json
method: post
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=storage.logs.prod.2'
logflare_kong:
type: http
inputs:
- kong_logs
- kong_err
encoding:
codec: json
method: post
request:
retry_max_duration_secs: 10
headers:
x-api-key: ${LOGFLARE_PUBLIC_ACCESS_TOKEN?LOGFLARE_PUBLIC_ACCESS_TOKEN is required}
uri: 'http://analytics:4000/api/logs?source_name=cloudflare.logs.prod'
-29
View File
@@ -1,29 +0,0 @@
{:ok, _} = Application.ensure_all_started(:supavisor)
{:ok, version} =
case Supavisor.Repo.query!("select version()") do
%{rows: [[ver]]} -> Supavisor.Helpers.parse_pg_version(ver)
_ -> nil
end
params = %{
"external_id" => System.get_env("POOLER_TENANT_ID"),
"db_host" => "db",
"db_port" => System.get_env("POSTGRES_PORT"),
"db_database" => System.get_env("POSTGRES_DB"),
"require_user" => false,
"auth_query" => "SELECT * FROM pgbouncer.get_auth($1)",
"default_max_clients" => System.get_env("POOLER_MAX_CLIENT_CONN"),
"default_pool_size" => System.get_env("POOLER_DEFAULT_POOL_SIZE"),
"default_parameter_status" => %{"server_version" => version},
"users" => [
%{
"db_user" => "pgbouncer",
"db_password" => System.get_env("POSTGRES_PASSWORD"),
"mode_type" => System.get_env("POOLER_POOL_MODE"),
"pool_size" => System.get_env("POOLER_DEFAULT_POOL_SIZE"),
"is_manager" => true
}
]
}
if !Supavisor.Tenants.get_tenant_by_external_id(params["external_id"]) do
{:ok, _} = Supavisor.Tenants.create_tenant(params)
end
+1 -1
View File
@@ -12,7 +12,7 @@ usage() {
echo " init-data Initialize seed data only"
echo " bootstrap Run migrations + init-data"
echo ""
echo "Note: Requires Supabase services running (docker compose up -d)"
echo "Note: Requires redis service running (docker compose up -d redis)"
exit 1
}