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 配置
|
# Redis 配置
|
||||||
############
|
############
|
||||||
SOCIAL_REDIS__PASSWORD=change-me-redis-password
|
SOCIAL_REDIS__PASSWORD=redis-secure-2026
|
||||||
SOCIAL_REDIS__HOST=localhost
|
SOCIAL_REDIS__HOST=localhost
|
||||||
SOCIAL_REDIS__PORT=6379
|
SOCIAL_REDIS__PORT=6379
|
||||||
SOCIAL_REDIS__DB=0
|
SOCIAL_REDIS__DB=0
|
||||||
@@ -43,20 +43,14 @@ SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY=1
|
|||||||
# SOCIAL_TASKIQ__RESULT_BACKEND_URL=redis://:password@localhost:6379/0
|
# SOCIAL_TASKIQ__RESULT_BACKEND_URL=redis://:password@localhost:6379/0
|
||||||
|
|
||||||
############
|
############
|
||||||
# Supabase(本地 Docker 与阿里云自托管保持同一变量)
|
# Supabase(云模式,后端必需)
|
||||||
############
|
############
|
||||||
# Supabase 栈使用 infra/docker/docker-compose.yml
|
SOCIAL_SUPABASE__PUBLIC_URL=https://your-project.supabase.co
|
||||||
# 仅绑定 127.0.0.1,不对局域网/公网暴露
|
SOCIAL_SUPABASE__ANON_KEY=
|
||||||
|
SOCIAL_SUPABASE__SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
# 基础 URL(本地默认 8000)
|
# Cloud Auth 可选配置(默认值已满足大多数场景)
|
||||||
SOCIAL_SUPABASE__PUBLIC_SCHEME=http
|
SOCIAL_SUPABASE__JWT_AUDIENCE=authenticated
|
||||||
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
|
|
||||||
|
|
||||||
# Postgres 连接信息(后端与 Supabase 共用密码)
|
# Postgres 连接信息(后端与 Supabase 共用密码)
|
||||||
SOCIAL_DATABASE__HOST=localhost
|
SOCIAL_DATABASE__HOST=localhost
|
||||||
@@ -65,70 +59,9 @@ SOCIAL_DATABASE__NAME=postgres
|
|||||||
SOCIAL_DATABASE__USER=postgres
|
SOCIAL_DATABASE__USER=postgres
|
||||||
SOCIAL_DATABASE__PASSWORD=change-me-strong-password
|
SOCIAL_DATABASE__PASSWORD=change-me-strong-password
|
||||||
|
|
||||||
# JWT/Keys(必须替换)
|
# Auth 可选项(云 Supabase Auth 行为控制)
|
||||||
SOCIAL_SUPABASE__JWT_SECRET=change-me-jwt-secret-at-least-32-chars
|
# SOCIAL_SUPABASE__SITE_URL=https://your-app-domain.example
|
||||||
SOCIAL_SUPABASE__ANON_KEY=replace-with-supabase-anon-key
|
# SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS=
|
||||||
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
|
|
||||||
|
|
||||||
############
|
############
|
||||||
# Agent Chat 附件存储配置(仅基础设施变量)
|
# 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",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"supabase_mcp": {
|
"supabase": {
|
||||||
"type": "remote",
|
"type": "local",
|
||||||
"url": "http://localhost:8001/mcp",
|
|
||||||
"oauth": false,
|
|
||||||
"enabled": true,
|
"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 typing import ClassVar, Literal
|
||||||
from urllib.parse import quote
|
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
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -116,14 +123,14 @@ class RedisSettings(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SupabaseSettings(BaseModel):
|
class SupabaseSettings(BaseModel):
|
||||||
public_scheme: str = "http"
|
public_url: AnyHttpUrl
|
||||||
public_host: str = "localhost"
|
|
||||||
kong_http_port: int = 8000
|
|
||||||
site_url: str = "http://localhost:3000"
|
|
||||||
additional_redirect_urls: list[str] = Field(default_factory=list)
|
|
||||||
anon_key: str = "CHANGE_ME"
|
anon_key: str = "CHANGE_ME"
|
||||||
service_role_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")
|
@field_validator("additional_redirect_urls", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -136,15 +143,24 @@ class SupabaseSettings(BaseModel):
|
|||||||
return [str(item).strip() for item in value if str(item).strip()]
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@computed_field
|
@model_validator(mode="after")
|
||||||
@property
|
def compute_defaults(self) -> "SupabaseSettings":
|
||||||
def public_url(self) -> str:
|
base = str(self.public_url).rstrip("/")
|
||||||
return f"{self.public_scheme}://{self.public_host}:{self.kong_http_port}"
|
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
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
return self.public_url
|
return str(self.public_url)
|
||||||
|
|
||||||
|
|
||||||
class StorageSettings(BaseModel):
|
class StorageSettings(BaseModel):
|
||||||
@@ -205,7 +221,7 @@ class Settings(BaseSettings):
|
|||||||
runtime: RuntimeSettings = RuntimeSettings()
|
runtime: RuntimeSettings = RuntimeSettings()
|
||||||
cors: CorsSettings = CorsSettings()
|
cors: CorsSettings = CorsSettings()
|
||||||
redis: RedisSettings = RedisSettings()
|
redis: RedisSettings = RedisSettings()
|
||||||
supabase: SupabaseSettings = SupabaseSettings()
|
supabase: SupabaseSettings = Field()
|
||||||
storage: StorageSettings = StorageSettings()
|
storage: StorageSettings = StorageSettings()
|
||||||
llm: LlmSettings = LlmSettings()
|
llm: LlmSettings = LlmSettings()
|
||||||
agent_runtime: AgentRuntimeSettings = AgentRuntimeSettings()
|
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 typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import jwt
|
|
||||||
from fastapi import Depends, Header, HTTPException
|
from fastapi import Depends, Header, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.auth.jwt_verifier import (
|
||||||
|
JwtVerifier,
|
||||||
|
TokenValidationError,
|
||||||
|
TokenVerifierUnavailableError,
|
||||||
|
)
|
||||||
from core.auth.models import CurrentUser
|
from core.auth.models import CurrentUser
|
||||||
from core.config.settings import config
|
from core.config.settings import config
|
||||||
from core.db import get_db
|
from core.db import get_db
|
||||||
@@ -18,6 +22,7 @@ from v1.users.service import AuthLookupAdapter, UserService
|
|||||||
logger = get_logger("v1.users.dependencies")
|
logger = get_logger("v1.users.dependencies")
|
||||||
|
|
||||||
_auth_gateway: SupabaseAuthGateway | None = None
|
_auth_gateway: SupabaseAuthGateway | None = None
|
||||||
|
_jwt_verifier: JwtVerifier | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_auth_gateway() -> SupabaseAuthGateway:
|
def get_auth_gateway() -> SupabaseAuthGateway:
|
||||||
@@ -27,6 +32,19 @@ def get_auth_gateway() -> SupabaseAuthGateway:
|
|||||||
return _auth_gateway
|
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:
|
def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser:
|
||||||
if not authorization:
|
if not authorization:
|
||||||
logger.warning("JWT validation failed: missing authorization header")
|
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")
|
logger.warning("JWT validation failed: invalid authorization scheme")
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
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:
|
try:
|
||||||
payload = jwt.decode(
|
payload = get_jwt_verifier().verify(token)
|
||||||
token,
|
except HTTPException:
|
||||||
secret,
|
raise
|
||||||
algorithms=["HS256"],
|
except TokenVerifierUnavailableError:
|
||||||
audience="authenticated",
|
logger.error("JWT validation failed: verifier unavailable")
|
||||||
issuer=expected_issuer,
|
raise HTTPException(status_code=503, detail="JWT verifier unavailable")
|
||||||
options={
|
except TokenValidationError as exc:
|
||||||
"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:
|
|
||||||
logger.warning(
|
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
|
raise HTTPException(status_code=401, detail="Unauthorized") from exc
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +1,43 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from core.config import config
|
|
||||||
from core.db.session import AsyncSessionLocal
|
from core.db.session import AsyncSessionLocal
|
||||||
from models.agent_chat_message import AgentChatMessage
|
from models.agent_chat_message import AgentChatMessage
|
||||||
from models.agent_chat_session import AgentChatSession
|
from models.agent_chat_session import AgentChatSession
|
||||||
from models.profile import Profile
|
|
||||||
|
|
||||||
BASE_URL = os.getenv("AGENT_LIVE_BASE_URL", "http://localhost:5775")
|
BASE_URL = os.getenv("AGENT_LIVE_BASE_URL", "http://localhost:5775")
|
||||||
|
|
||||||
|
|
||||||
async def _owner_id() -> UUID:
|
async def _live_access_token(client: httpx.AsyncClient) -> str:
|
||||||
async with AsyncSessionLocal() as session:
|
email = os.getenv("AGENT_LIVE_EMAIL")
|
||||||
owner_id = (await session.execute(select(Profile.id).limit(1))).scalar_one_or_none()
|
password = os.getenv("AGENT_LIVE_PASSWORD")
|
||||||
if owner_id is None:
|
if not email or not password:
|
||||||
pytest.skip("profile owner not found")
|
pytest.fail(
|
||||||
return owner_id
|
"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:
|
assert response.status_code == 200, (
|
||||||
secret = config.supabase.jwt_secret
|
f"live login failed: status={response.status_code}, response={truncated_text!r}"
|
||||||
if not secret:
|
)
|
||||||
pytest.skip("JWT secret not configured")
|
|
||||||
issuer = f"{config.supabase.public_url.rstrip('/')}/auth/v1"
|
token = response.json().get("access_token")
|
||||||
payload = {
|
assert isinstance(token, str) and token
|
||||||
"sub": str(user_id),
|
return token
|
||||||
"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")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -48,11 +46,10 @@ async def test_agent_sse_closed_loop_live() -> None:
|
|||||||
if os.getenv("AGENT_LIVE_INTEGRATION") != "1":
|
if os.getenv("AGENT_LIVE_INTEGRATION") != "1":
|
||||||
pytest.skip("set AGENT_LIVE_INTEGRATION=1 to run live integration test")
|
pytest.skip("set AGENT_LIVE_INTEGRATION=1 to run live integration test")
|
||||||
|
|
||||||
owner_id = await _owner_id()
|
|
||||||
token = _jwt_for(owner_id)
|
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
token = await _live_access_token(client)
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
run_resp = await client.post(
|
run_resp = await client.post(
|
||||||
f"{BASE_URL}/api/v1/agent/runs",
|
f"{BASE_URL}/api/v1/agent/runs",
|
||||||
headers=headers,
|
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"
|
events_url = f"{BASE_URL}/api/v1/agent/runs/{thread_id}/events"
|
||||||
event_names: list[str] = []
|
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.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():
|
async for line in sse_resp.aiter_lines():
|
||||||
if line.startswith("event:"):
|
if line.startswith("event:"):
|
||||||
event_names.append(line.split(":", 1)[1].strip())
|
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
|
assert session_row.total_cost >= 0
|
||||||
|
|
||||||
rows = await session.execute(
|
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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
from pytest import MonkeyPatch
|
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(
|
def test_social_prefixed_supabase_env_populates_settings(
|
||||||
monkeypatch: MonkeyPatch,
|
monkeypatch: MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_SCHEME", "https")
|
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_URL", "https://public.example:8443")
|
||||||
monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_HOST", "public.example")
|
|
||||||
monkeypatch.setenv("SOCIAL_SUPABASE__KONG_HTTP_PORT", "8443")
|
|
||||||
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
|
monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key")
|
||||||
monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-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__SITE_URL", "https://app.example.com")
|
||||||
monkeypatch.setenv(
|
monkeypatch.setenv(
|
||||||
"SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS",
|
"SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS",
|
||||||
@@ -27,10 +26,9 @@ def test_social_prefixed_supabase_env_populates_settings(
|
|||||||
|
|
||||||
settings = 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.anon_key == "anon-key"
|
||||||
assert settings.supabase.service_role_key == "service-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.site_url == "https://app.example.com"
|
||||||
assert settings.supabase.additional_redirect_urls == [
|
assert settings.supabase.additional_redirect_urls == [
|
||||||
"https://a.example.com",
|
"https://a.example.com",
|
||||||
@@ -38,9 +36,63 @@ def test_social_prefixed_supabase_env_populates_settings(
|
|||||||
]
|
]
|
||||||
|
|
||||||
supabase_settings = settings.model_dump()["supabase"]
|
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["anon_key"] == "anon-key"
|
||||||
assert supabase_settings["service_role_key"] == "service-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 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"
|
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: 启动基础设施
|
### Step 1: 启动基础设施
|
||||||
|
|
||||||
```bash
|
```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: 执行迁移与初始化
|
### Step 2: 执行迁移与初始化
|
||||||
|
|
||||||
@@ -114,8 +114,11 @@ set +a
|
|||||||
|
|
||||||
WEB_BASE_URL="http://127.0.0.1:${SOCIAL_WEB__PORT:-5775}"
|
WEB_BASE_URL="http://127.0.0.1:${SOCIAL_WEB__PORT:-5775}"
|
||||||
|
|
||||||
# 基础健康
|
# 基础健康(redis/web;数据库使用云 Supabase Postgres)
|
||||||
curl -fsS http://127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTP_PORT:-8000}/health
|
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 状态
|
# compose 状态
|
||||||
docker compose --env-file .env -f infra/docker/docker-compose.yml ps
|
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"}'
|
-d '{"email":"demo@example.com","password":"secret123"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
通过标准:health 返回 2xx,关键容器 `running`,核心接口返回预期业务状态码。
|
通过标准:redis 健康检查成功,web `/health` 返回 2xx,容器 `running`,核心接口返回预期业务状态码。
|
||||||
|
|
||||||
### L2 可选(Auth/Profile 业务回归)
|
### L2 可选(Auth/Profile 业务回归)
|
||||||
|
|
||||||
@@ -193,31 +196,25 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml exec -T redis
|
|||||||
- 定位:核对 `.env` 中 Supabase JWT 配置与签发方设置。
|
- 定位:核对 `.env` 中 Supabase JWT 配置与签发方设置。
|
||||||
- 修复:修正配置后重启 web 进程并执行 L1/L2 验证。
|
- 修复:修正配置后重启 web 进程并执行 L1/L2 验证。
|
||||||
|
|
||||||
### 4) Auth 邮件模板未生效 / 注册返回超时但邮件已发送
|
### 4) 基础设施容器异常(db/redis)
|
||||||
|
|
||||||
- 症状:
|
- 症状:web 启动失败、迁移失败、任务队列连接报错。
|
||||||
- 收到默认英文模板(非 `infra/mail-templates`)。
|
|
||||||
- `signup/start` 偶发 500 或超时,但邮箱仍收到验证码邮件。
|
|
||||||
- 根因:容器配置漂移(旧容器未按最新 compose/.env 重建),导致:
|
|
||||||
- `supabase-auth` 缺少 `GOTRUE_MAILER_TEMPLATES_*` 环境变量。
|
|
||||||
- `supabase-mail-templates` 仍挂载旧路径。
|
|
||||||
- 定位:
|
- 定位:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker inspect supabase-auth --format '{{ range .Config.Env }}{{ println . }}{{ end }}' | grep GOTRUE_MAILER_TEMPLATES
|
docker compose --env-file .env -f infra/docker/docker-compose.yml ps
|
||||||
docker inspect supabase-mail-templates --format '{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ println }}{{ end }}'
|
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
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
- 复核标准:
|
- 复核标准:`redis` 健康检查通过,L1 核心接口 smoke 无 5xx。
|
||||||
- `docker inspect supabase-auth` 能看到 `GOTRUE_MAILER_TEMPLATES_CONFIRMATION/RECOVERY`。
|
|
||||||
- `supabase-mail-templates` 挂载源为 `infra/mail-templates`。
|
|
||||||
- `POST /api/v1/auth/verifications` 返回 `202` 且耗时恢复正常。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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 | 创建运行时手册,删除 legacy 脚本,统一使用 gunicorn |
|
||||||
| 2026-02-24 | 清理配置:合并 AppSettings 到 WebSettings,删除 Worker 旧配置 (enabled_queues/queues),统一使用 SOCIAL_WEB__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-24 | 新增 dev-app-up 脚本:手动基础设施后,一键 bootstrap + tmux 拉起 web/worker |
|
||||||
| 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 |
|
| 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 |
|
||||||
| 2026-02-25 | Auth 注册切换为 OTP 三段式:signup/start、signup/verify、signup/resend;邮件模板改为纯验证码展示 |
|
| 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-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-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-06 | Web 启动从 gunicorn 迁移为纯 uvicorn,移除 `SOCIAL_WEB__GUNICORN__*` 配置,统一使用 `SOCIAL_WEB__WORKERS` |
|
||||||
|
| 2026-03-09 | 清理本地 Supabase 依赖描述:基础设施启动与巡检统一为 redis/db/web |
|
||||||
|
|||||||
@@ -19,400 +19,6 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
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:
|
init-job:
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
@@ -422,18 +28,17 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
environment:
|
environment:
|
||||||
- PYTHONPATH=/app/backend/src
|
- PYTHONPATH=/app/backend/src
|
||||||
- SOCIAL_DATABASE__HOST=db
|
- SOCIAL_DATABASE__HOST=${SOCIAL_DATABASE__HOST}
|
||||||
- SOCIAL_DATABASE__PORT=5432
|
- 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_DATABASE__PASSWORD=${SOCIAL_DATABASE__PASSWORD}
|
||||||
- SOCIAL_REDIS__HOST=redis
|
- SOCIAL_REDIS__HOST=${SOCIAL_REDIS__HOST}
|
||||||
- SOCIAL_REDIS__PORT=6379
|
- SOCIAL_REDIS__PORT=${SOCIAL_REDIS__PORT}
|
||||||
- SOCIAL_REDIS__PASSWORD=${SOCIAL_REDIS__PASSWORD:-}
|
- 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}
|
- SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-dev}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
working_dir: /app/backend
|
working_dir: /app/backend
|
||||||
command: uv run python -m core.runtime.cli bootstrap
|
command: uv run python -m core.runtime.cli bootstrap
|
||||||
@@ -442,5 +47,3 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis_data:
|
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 " init-data Initialize seed data only"
|
||||||
echo " bootstrap Run migrations + init-data"
|
echo " bootstrap Run migrations + init-data"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Note: Requires Supabase services running (docker compose up -d)"
|
echo "Note: Requires redis service running (docker compose up -d redis)"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user