docs: add Claude AI assistant rules and configuration
Add comprehensive rule documentation for AI-assisted development covering authentication surfaces, outbound-call safety invariants, and other project conventions to guide Claude's understanding of critical system behaviors.
This commit is contained in:
275
main.py
275
main.py
@@ -12,17 +12,21 @@ Usage:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api import call_flows, call_history, calls, devices, routing, websocket
|
||||
from api.deps import require_token
|
||||
from api import auth as auth_router
|
||||
from api import call_flows, call_history, calls, devices, routing, tokens, websocket
|
||||
from auth import get_current_owner, init_jwks_client, is_owner, resolve_from_header_or_query
|
||||
from config import Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway, build_sip_engine
|
||||
from db.database import close_db, init_db
|
||||
from db.database import close_db, init_db, session_scope
|
||||
from mcp_server.server import create_mcp_server
|
||||
from models.call import CallMode
|
||||
from services.audio_classifier import AudioClassifier
|
||||
@@ -106,16 +110,38 @@ def _check_startup_config(settings: Settings) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
token = settings.api_token.get_secret_value()
|
||||
if not token and settings.host not in ("127.0.0.1", "localhost", "::1"):
|
||||
loopback = settings.host in ("127.0.0.1", "localhost", "::1")
|
||||
if settings.casdoor.enabled:
|
||||
c = settings.casdoor
|
||||
missing = [
|
||||
name
|
||||
for name, val in (
|
||||
("CASDOOR_ENDPOINT", c.endpoint),
|
||||
("CASDOOR_CLIENT_ID", c.client_id),
|
||||
("CASDOOR_CLIENT_SECRET", c.client_secret.get_secret_value()),
|
||||
("OWNER_NAME", settings.owner_name),
|
||||
)
|
||||
if not val
|
||||
]
|
||||
if missing:
|
||||
logger.critical(
|
||||
"\n"
|
||||
"❌ CASDOOR_ENABLED=true but required settings are missing:\n"
|
||||
f" {', '.join(missing)}\n"
|
||||
" Set them in .env (Casdoor app credentials + the owner's "
|
||||
"Casdoor username), or set CASDOOR_ENABLED=false with HOST=127.0.0.1 "
|
||||
"for tokenless local development."
|
||||
)
|
||||
sys.exit(1)
|
||||
elif not loopback:
|
||||
logger.critical(
|
||||
"\n"
|
||||
"❌ API_TOKEN is not set but HOST binds beyond loopback "
|
||||
"❌ CASDOOR_ENABLED=false but HOST binds beyond loopback "
|
||||
f"({settings.host}).\n"
|
||||
" Every surface (REST, WebSocket, MCP make_call) would be open "
|
||||
"to the network.\n"
|
||||
" Set API_TOKEN in .env (e.g. `openssl rand -hex 32`), or set "
|
||||
"HOST=127.0.0.1 for tokenless local development."
|
||||
" Every surface (REST, WebSocket, MCP make_call) would resolve "
|
||||
"to the dev owner — open to the network.\n"
|
||||
" Set CASDOOR_ENABLED=true (with the Casdoor + OWNER_NAME settings), "
|
||||
"or set HOST=127.0.0.1 for tokenless local development."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -126,6 +152,10 @@ async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
_check_startup_config(settings)
|
||||
|
||||
# Prefetch Casdoor's JWKS so the first authenticated request doesn't pay
|
||||
# the network round-trip (no-op when SSO is disabled).
|
||||
init_jwks_client()
|
||||
|
||||
# The MCP session manager lives in the mounted sub-app's lifespan;
|
||||
# without entering it, every /mcp request 500s.
|
||||
async with mcp_http_app.lifespan(app):
|
||||
@@ -214,7 +244,11 @@ async def lifespan(app: FastAPI):
|
||||
display_port = int(sys.argv[i + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
auth_state = "bearer token required" if settings.api_token.get_secret_value() else "auth disabled (loopback)"
|
||||
auth_state = (
|
||||
f"Casdoor SSO (owner: {settings.owner_name or 'UNSET'})"
|
||||
if settings.casdoor.enabled
|
||||
else "dev-owner (loopback, no auth)"
|
||||
)
|
||||
logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]")
|
||||
logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
|
||||
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
|
||||
@@ -236,12 +270,92 @@ def _get_gateway_instance() -> AIPSTNGateway | None:
|
||||
return getattr(app.state, "gateway", None)
|
||||
|
||||
|
||||
mcp = create_mcp_server(
|
||||
_get_gateway_instance,
|
||||
api_token=get_settings().api_token.get_secret_value(),
|
||||
)
|
||||
mcp = create_mcp_server(_get_gateway_instance)
|
||||
mcp_http_app = mcp.http_app(path="/")
|
||||
|
||||
|
||||
def _public_base_url(scope_or_request) -> str:
|
||||
"""Resolve this service's public base URL (scheme + host), no trailing slash.
|
||||
|
||||
Precedence: explicit PUBLIC_BASE_URL override → X-Forwarded-Proto/Host
|
||||
(nginx/HAProxy) → Host header → localhost. Accepts either a FastAPI
|
||||
``Request`` or a raw ASGI ``scope`` so the ASGI MCP guard and the FastAPI
|
||||
discovery endpoints share one implementation.
|
||||
"""
|
||||
settings = get_settings()
|
||||
if settings.public_base_url:
|
||||
return settings.public_base_url.rstrip("/")
|
||||
|
||||
if hasattr(scope_or_request, "headers"):
|
||||
headers = {k.lower(): v for k, v in scope_or_request.headers.items()}
|
||||
default_scheme = getattr(scope_or_request.url, "scheme", None) or "http"
|
||||
else:
|
||||
headers = {
|
||||
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope_or_request.get("headers", [])
|
||||
}
|
||||
default_scheme = scope_or_request.get("scheme", "http")
|
||||
|
||||
proto = (headers.get("x-forwarded-proto") or default_scheme).split(",", 1)[0].strip()
|
||||
host = (headers.get("x-forwarded-host") or headers.get("host") or "localhost")
|
||||
host = host.split(",", 1)[0].strip()
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
def _owner_only_mcp(inner_app):
|
||||
"""Wrap the mounted MCP ASGI app to require an owner bearer token.
|
||||
|
||||
MCP tools reach state via the FastMCP lifespan context, not FastAPI's
|
||||
dependency system, so ``Depends`` can't gate ``/mcp``. Instead we read the
|
||||
ASGI scope's ``Authorization`` header, resolve it (Casdoor JWT or PAT)
|
||||
against a fresh DB session, and short-circuit non-owner requests with
|
||||
401/403. In dev mode this resolves to the dev owner, so local development
|
||||
keeps working without a token.
|
||||
"""
|
||||
|
||||
async def _send_status(send, scope, status: int, body: bytes) -> None:
|
||||
base = _public_base_url(scope)
|
||||
resource_metadata_url = f"{base}/.well-known/oauth-protected-resource/mcp"
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": status,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(
|
||||
b"www-authenticate",
|
||||
f'Bearer realm="hold-slayer-mcp", '
|
||||
f'resource_metadata="{resource_metadata_url}"'.encode(),
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
async def app(scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await inner_app(scope, receive, send)
|
||||
return
|
||||
|
||||
authorization = None
|
||||
for name, value in scope.get("headers", []):
|
||||
if name == b"authorization":
|
||||
authorization = value.decode("latin-1")
|
||||
break
|
||||
|
||||
async with session_scope() as session:
|
||||
user = await resolve_from_header_or_query(session, authorization, None)
|
||||
if user is None:
|
||||
await _send_status(send, scope, 401, b'{"detail":"Not authenticated"}')
|
||||
return
|
||||
if not is_owner(user):
|
||||
await _send_status(send, scope, 403, b'{"detail":"Owner access required"}')
|
||||
return
|
||||
|
||||
await inner_app(scope, receive, send)
|
||||
|
||||
return app
|
||||
|
||||
app = FastAPI(
|
||||
title="Hold Slayer Gateway",
|
||||
description=(
|
||||
@@ -258,9 +372,13 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
# === API Routes ===
|
||||
# Every protected surface is gated to the owner (Casdoor JWT or PAT). The
|
||||
# unauthenticated OIDC endpoints live on the /auth router (login/callback/…).
|
||||
# call_history must register before calls: both live under /api/v1/calls and
|
||||
# calls' GET /{call_id} would otherwise capture the literal path "history".
|
||||
_auth = [Depends(require_token)]
|
||||
_auth = [Depends(get_current_owner)]
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(tokens.router, dependencies=_auth)
|
||||
app.include_router(
|
||||
call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth
|
||||
)
|
||||
@@ -270,11 +388,128 @@ app.include_router(
|
||||
)
|
||||
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
|
||||
app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth)
|
||||
# WebSocket endpoints check the token themselves (query param or header)
|
||||
# WebSocket endpoints check the owner themselves (query param or header)
|
||||
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
||||
|
||||
# === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) ===
|
||||
app.mount("/mcp", mcp_http_app)
|
||||
# === MCP (streamable HTTP; clients connect to /mcp/ with a PAT or JWT) ===
|
||||
# The ASGI guard resolves the bearer to the owner before the inner app runs.
|
||||
app.mount("/mcp", _owner_only_mcp(mcp_http_app))
|
||||
|
||||
# In-memory store of dynamically registered OAuth clients (RFC 7591). MCP
|
||||
# clients re-register each session; the real gate is the bearer token.
|
||||
_registered_clients: dict[str, dict] = {}
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-protected-resource", include_in_schema=False)
|
||||
@app.get("/.well-known/oauth-protected-resource/mcp", include_in_schema=False)
|
||||
async def oauth_protected_resource_metadata(request: Request):
|
||||
"""RFC 9728 Protected Resource Metadata — points MCP clients at the AS.
|
||||
|
||||
``resource`` advertises ``{base}/mcp`` (not the bare origin) because recent
|
||||
``mcp-remote`` versions verify it matches the URL they connected to.
|
||||
"""
|
||||
base = _public_base_url(request)
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": f"{base}/mcp",
|
||||
"authorization_servers": [base],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": f"{base}/docs",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-authorization-server", include_in_schema=False)
|
||||
async def oauth_authorization_server_metadata(request: Request):
|
||||
"""RFC 8414 Authorization Server Metadata.
|
||||
|
||||
When Casdoor SSO is on, the real authorization server is Casdoor — advertise
|
||||
its endpoints. In dev mode there's no OAuth server; clients supply a PAT
|
||||
directly in their MCP configuration.
|
||||
"""
|
||||
base = _public_base_url(request)
|
||||
settings = get_settings()
|
||||
if settings.casdoor.enabled:
|
||||
casdoor_base = settings.casdoor.endpoint.rstrip("/")
|
||||
return JSONResponse(
|
||||
{
|
||||
"issuer": casdoor_base,
|
||||
"authorization_endpoint": f"{casdoor_base}/login/oauth/authorize",
|
||||
"token_endpoint": f"{casdoor_base}/api/login/oauth/access_token",
|
||||
"jwks_uri": f"{casdoor_base}/.well-known/jwks",
|
||||
"registration_endpoint": f"{base}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
||||
"scopes_supported": ["openid", "profile", "email"],
|
||||
}
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"issuer": base,
|
||||
"authorization_endpoint": f"{base}/auth/login",
|
||||
"token_endpoint": f"{base}/auth/callback",
|
||||
"registration_endpoint": f"{base}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/register", include_in_schema=False)
|
||||
async def oauth_dynamic_registration(request: Request):
|
||||
"""RFC 7591 Dynamic Client Registration — accept any well-formed request.
|
||||
|
||||
Registered clients are held in memory (ephemeral); the real security gate
|
||||
is the bearer token (PAT or Casdoor JWT) on every /mcp request.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "invalid_client_metadata",
|
||||
"error_description": "Request body must be valid JSON.",
|
||||
},
|
||||
)
|
||||
|
||||
redirect_uris = body.get("redirect_uris")
|
||||
if not redirect_uris or not isinstance(redirect_uris, list):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "invalid_redirect_uri",
|
||||
"error_description": "redirect_uris is required and must be a non-empty list.",
|
||||
},
|
||||
)
|
||||
|
||||
client_id = secrets.token_hex(16)
|
||||
now = int(time.time())
|
||||
_registered_clients[client_id] = {
|
||||
"client_id": client_id,
|
||||
"client_id_issued_at": now,
|
||||
"redirect_uris": redirect_uris,
|
||||
"grant_types": body.get("grant_types", ["authorization_code"]),
|
||||
"response_types": body.get("response_types", ["code"]),
|
||||
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "none"),
|
||||
"client_name": body.get("client_name"),
|
||||
"scope": body.get("scope"),
|
||||
}
|
||||
logger.info("Registered OAuth client %s (name=%s)", client_id, body.get("client_name"))
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"client_id": client_id,
|
||||
"client_id_issued_at": now,
|
||||
"redirect_uris": redirect_uris,
|
||||
"grant_types": _registered_clients[client_id]["grant_types"],
|
||||
"response_types": _registered_clients[client_id]["response_types"],
|
||||
"token_endpoint_auth_method": _registered_clients[client_id][
|
||||
"token_endpoint_auth_method"
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/status", tags=["System"], dependencies=_auth)
|
||||
|
||||
Reference in New Issue
Block a user