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

191
api/auth.py Normal file
View File

@@ -0,0 +1,191 @@
"""
OIDC authentication endpoints (Casdoor SSO).
GET /auth/login → redirect to Casdoor authorization URL
GET /auth/callback → exchange code for tokens, redirect to UI with token
GET /auth/me → return current user info (requires Bearer token)
GET /auth/silent-refresh → hidden-iframe refresh (re-auth with existing session)
GET /auth/refresh-callback → post the refreshed token to the parent window
GET /auth/logout → redirect to Casdoor logout URL
The dashboard is owner-only; ``/auth/me`` returns ``is_owner`` so a signed-in
non-owner sees an "access denied" screen instead of a bare 401.
"""
import secrets
from urllib.parse import urlencode
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from auth import get_sdk, is_owner, resolve_from_header_or_query
from config import get_settings
from db.database import session_scope
router = APIRouter(prefix="/auth", tags=["auth"])
def _build_casdoor_auth_url(
callback: str,
*,
scope: str = "openid profile email",
state: str | None = None,
prompt: str | None = None,
) -> str:
"""Build the Casdoor authorization URL directly.
The SDK's get_auth_link() doesn't support the ``prompt`` parameter that
silent refresh needs, so build the URL manually.
"""
c = get_settings().casdoor
params = {
"client_id": c.client_id,
"response_type": "code",
"redirect_uri": callback,
"scope": scope,
"state": state or secrets.token_urlsafe(16),
}
if prompt:
params["prompt"] = prompt
return f"{c.endpoint.rstrip('/')}/login/oauth/authorize?{urlencode(params)}"
@router.get("/login")
async def login(request: Request, redirect_uri: str = Query(None)):
"""Redirect the browser to the Casdoor authorization page.
No ``prompt=login`` — an existing Casdoor session auto-redirects back with
a code without showing the login form (silent SSO across *.helu.ca).
"""
if not get_settings().casdoor.enabled:
raise HTTPException(400, "Casdoor SSO is not enabled")
callback = redirect_uri or f"{request.base_url}auth/callback"
return RedirectResponse(url=_build_casdoor_auth_url(callback))
@router.get("/callback")
async def callback(
code: str = Query(...),
state: str = Query(None),
redirect_uri: str = Query(None),
):
"""Exchange the authorization code for tokens.
Redirects to the dashboard with the access token in the URL *fragment*
(``/#token=...``) so the token stays client-side and is stored in
localStorage.
"""
if not get_settings().casdoor.enabled:
raise HTTPException(400, "Casdoor SSO is not enabled")
sdk = get_sdk()
try:
token = await sdk.get_oauth_token(code=code)
except Exception as exc:
raise HTTPException(400, f"Token exchange failed: {exc}") from exc
access_token = token.get("access_token", "")
return RedirectResponse(url=f"/#token={access_token}")
@router.get("/silent-refresh")
async def silent_refresh(request: Request):
"""Start a silent token refresh via hidden iframe (``prompt=none``).
If the Casdoor session is still active, Casdoor redirects back to
``/auth/refresh-callback`` with a fresh code — no login form. Otherwise it
returns an error and the iframe tells the parent to show the login overlay.
"""
if not get_settings().casdoor.enabled:
raise HTTPException(400, "Casdoor SSO is not enabled")
callback = f"{request.base_url}auth/refresh-callback"
return RedirectResponse(url=_build_casdoor_auth_url(callback, prompt="none"))
@router.get("/refresh-callback")
async def refresh_callback(
code: str = Query(None),
error: str = Query(None),
state: str = Query(None),
):
"""Handle the silent-refresh callback inside the hidden iframe.
On success posts the new token to the parent window; on failure posts an
error so the parent shows the login overlay.
"""
if not get_settings().casdoor.enabled:
raise HTTPException(400, "Casdoor SSO is not enabled")
if error or not code:
return HTMLResponse(
'<script>window.parent.postMessage('
'{type:"hold-slayer-refresh",error:true},"*");</script>'
)
sdk = get_sdk()
try:
token = await sdk.get_oauth_token(code=code)
access_token = token.get("access_token", "")
except Exception:
return HTMLResponse(
'<script>window.parent.postMessage('
'{type:"hold-slayer-refresh",error:true},"*");</script>'
)
return HTMLResponse(
f'<script>window.parent.postMessage('
f'{{type:"hold-slayer-refresh",token:"{access_token}"}},"*");</script>'
)
@router.get("/me")
async def me(request: Request):
"""Return the current authenticated user's profile + ``is_owner``.
Resolved manually (not via the ``OwnerUser`` gate) so a signed-in
non-owner gets a 200 with ``is_owner:false`` — the dashboard uses that to
show the "not authorized" screen rather than treating it as a hard 401.
"""
auth_header = request.headers.get("authorization")
q_token = request.query_params.get("token")
async with session_scope() as session:
user = await resolve_from_header_or_query(session, auth_header, q_token)
if user is None:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return JSONResponse(
{
"id": user.id,
"name": user.name,
"display_name": user.display_name,
"email": user.email,
"is_owner": is_owner(user),
}
)
@router.get("/logout")
async def logout(request: Request):
"""Clear the Casdoor session and redirect back to the app.
``post_logout_redirect_uri`` must be absolute — Casdoor won't follow a
relative ``/`` — so it's derived from ``request.base_url`` (works behind
HAProxy/nginx with X-Forwarded-Proto/Host).
"""
c = get_settings().casdoor
if not c.enabled:
return RedirectResponse(url="/")
app_url = str(request.base_url).rstrip("/")
logout_url = (
f"{c.endpoint.rstrip('/')}/login/oauth/logout"
f"?client_id={c.client_id}"
f"&post_logout_redirect_uri={app_url}/auth/login"
)
return RedirectResponse(url=logout_url)

View File

@@ -1,12 +1,12 @@
"""
API Dependencies — Shared dependency injection for all routes.
Auth is not here: the owner gate lives in `auth.py` (`get_current_owner` /
`OwnerUser`), applied as a router-level dependency in main.py.
"""
import secrets
from fastapi import HTTPException, Request
from fastapi import Header, HTTPException, Query, Request
from config import get_settings
from core.gateway import AIPSTNGateway
@@ -24,31 +24,3 @@ def get_routing_service(request: Request):
if routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
return routing
def require_token(
authorization: str | None = Header(default=None),
token: str | None = Query(default=None),
) -> None:
"""
Enforce the static bearer token (API_TOKEN) on REST routes.
A `token` query parameter is accepted alongside the Authorization
header for clients that can't set headers — <audio>/<a> elements
fetching recordings — matching the WebSocket convention.
An empty configured token disables auth; startup refuses that
combination unless the server is bound to loopback.
"""
expected = get_settings().api_token.get_secret_value()
if not expected:
return
supplied = token or ""
if authorization and authorization.lower().startswith("bearer "):
supplied = authorization[7:]
if not secrets.compare_digest(supplied, expected):
raise HTTPException(
status_code=401,
detail="Missing or invalid bearer token",
headers={"WWW-Authenticate": "Bearer"},
)

107
api/tokens.py Normal file
View File

@@ -0,0 +1,107 @@
"""Owner-only CRUD for personal access tokens (PATs).
PATs are long-lived bearer tokens for MCP/CLI clients (Claude Desktop, Cline)
and scripted API consumers that can't refresh a short-lived Casdoor JWT. The
plaintext is shown to the caller exactly once at creation; only its SHA-256
hash is stored.
"""
import secrets
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from auth import PAT_PREFIX, OwnerUser, hash_token
from db.database import PersonalAccessToken, get_db
router = APIRouter(prefix="/api/v1/tokens", tags=["tokens"])
class TokenCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
class TokenOut(BaseModel):
id: str
name: str
token_prefix: str
created_at: str | None = None
last_used_at: str | None = None
expires_at: str | None = None
revoked_at: str | None = None
class TokenCreated(TokenOut):
token: str = Field(..., description="Plaintext token — shown only once. Store it now.")
def _serialize(pat: PersonalAccessToken) -> dict:
return {
"id": pat.id,
"name": pat.name,
"token_prefix": pat.token_prefix,
"created_at": pat.created_at.isoformat() if pat.created_at else None,
"last_used_at": pat.last_used_at.isoformat() if pat.last_used_at else None,
"expires_at": pat.expires_at.isoformat() if pat.expires_at else None,
"revoked_at": pat.revoked_at.isoformat() if pat.revoked_at else None,
}
@router.get("", response_model=list[TokenOut])
async def list_tokens(
user: OwnerUser,
session: AsyncSession = Depends(get_db),
) -> list[dict]:
"""List the owner's personal access tokens (no plaintext)."""
result = await session.execute(
select(PersonalAccessToken)
.where(PersonalAccessToken.user_id == user.id)
.order_by(PersonalAccessToken.created_at.desc())
)
return [_serialize(pat) for pat in result.scalars().all()]
@router.post("", response_model=TokenCreated, status_code=201)
async def create_token(
payload: TokenCreate,
user: OwnerUser,
session: AsyncSession = Depends(get_db),
) -> dict:
"""Mint a new PAT. The plaintext is returned ONCE in the response."""
plaintext = PAT_PREFIX + secrets.token_urlsafe(32)
pat = PersonalAccessToken(
id=uuid.uuid4().hex,
user_id=user.id,
name=payload.name,
token_hash=hash_token(plaintext),
token_prefix=plaintext[: len(PAT_PREFIX) + 4],
)
session.add(pat)
await session.commit()
await session.refresh(pat)
return {**_serialize(pat), "token": plaintext}
@router.delete("/{token_id}", status_code=204)
async def revoke_token(
token_id: str,
user: OwnerUser,
session: AsyncSession = Depends(get_db),
) -> None:
"""Soft-revoke a PAT (sets revoked_at)."""
result = await session.execute(
select(PersonalAccessToken).where(
PersonalAccessToken.id == token_id,
PersonalAccessToken.user_id == user.id,
)
)
pat = result.scalar_one_or_none()
if pat is None:
raise HTTPException(status_code=404, detail="Token not found")
if pat.revoked_at is None:
pat.revoked_at = datetime.now(UTC)
await session.commit()

View File

@@ -1,13 +1,11 @@
"""WebSocket API — Real-time call events and audio classification stream."""
import asyncio
import logging
import secrets
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from api.deps import get_gateway
from config import get_settings
from auth import is_owner, resolve_from_header_or_query
from db.database import session_scope
from models.events import EventType, GatewayEvent
logger = logging.getLogger(__name__)
@@ -17,21 +15,21 @@ router = APIRouter()
async def _authorize(websocket: WebSocket) -> bool:
"""
Check the static bearer token before accepting the socket.
Require the owner before accepting the socket.
Browsers can't set headers on WebSocket connects, so a `token`
query parameter is accepted alongside the Authorization header.
Browsers can't set headers on WebSocket connects, so the Casdoor JWT (or
a PAT) is accepted on the `?token=` query param alongside the Authorization
header — the same narrow fallback the recording download uses. In dev mode
the owner resolves tokenlessly. A non-owner or absent credential closes the
socket with code 4401.
"""
token = get_settings().api_token.get_secret_value()
if not token:
q_token = websocket.query_params.get("token")
auth_header = websocket.headers.get("authorization")
async with session_scope() as session:
user = await resolve_from_header_or_query(session, auth_header, q_token)
if user is not None and is_owner(user):
return True
supplied = websocket.query_params.get("token", "")
auth = websocket.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
supplied = auth[7:]
if secrets.compare_digest(supplied, token):
return True
await websocket.close(code=4401, reason="Missing or invalid bearer token")
await websocket.close(code=4401, reason="Owner authentication required")
return False