docs: add Claude AI assistant rules and configuration
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s

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:
2026-07-28 19:01:38 -04:00
parent 016d8be71d
commit 4a3c14d4af
40 changed files with 2851 additions and 202 deletions

275
main.py
View File

@@ -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)