refactor: 迁移本地 Supabase 到云端,使用 JWKS 进行 JWT 验证
- 新增 JwtVerifier 支持 RS256 + JWKS 验证 - 简化 docker-compose,删除本地 Supabase 服务(kong/auth/storage等) - 删除冗余的 Supabase 配置文件(volumes目录) - 适配测试用例以支持新配置方式 - 更新运行时文档和迁移计划
This commit is contained in:
+10
-77
@@ -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 附件存储配置(仅基础设施变量)
|
||||
|
||||
@@ -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
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -1,2 +0,0 @@
|
||||
\set pguser `echo "$POSTGRES_USER"`
|
||||
CREATE DATABASE _supabase WITH OWNER :pguser;
|
||||
@@ -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';
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
\set pguser `echo "$POSTGRES_USER"`
|
||||
create schema if not exists _realtime;
|
||||
alter schema _realtime owner to :pguser;
|
||||
@@ -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';
|
||||
@@ -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",
|
||||
},
|
||||
}))
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user