Files
praxis/server/__main__.py
T
Praxis CI 46b46479ca feat(P01): SLICE-06 P1 integration — wire lifespan + auth + verification swap
- TASK-06-01 __main__.py: SessionMiddleware (signed cookies, D-056) added
  AFTER CORS so it is outermost. slowapi limiter state + 429 exception
  handler registered. The lifespan (TASK-01-03) now also runs the VC key
  migration on first boot.
- TASK-06-02 __main__.py: auth_router mounted (POST /api/operator/login,
  POST /api/operator/logout, GET /api/operator/me) BEFORE the StaticFiles
  mount (routes-before-static constraint). Auth routes use app.state.pg_store
  (503 if no Postgres).
- TASK-06-03 __main__.py: /vc/verify swapped to the two-store path (G-011):
  pg_store for key lookup (active + superseded), SQLite fallback for v0.3
  credentials, SQLite-only if no Postgres. _maybe_migrate_issuer_keys()
  runs once in the lifespan (idempotent, G-027 first-boot, non-fatal on
  failure — v0.3 path intact).
- TASK-06-04 tests/test_p1_auth_integration.py: 4 e2e tests (skip if no
  Postgres) — full auth flow, /me without cookie 401, wrong password 401,
  learner voice loop unaffected (REQ-NFR-MT-01).
- TASK-06-05 tests/test_p1_vc_migration_e2e.py: 5 e2e tests (skip if no
  Postgres) — R-VC-MIG-01 critical (v0.3 VC verifies against archived
  superseded key in Postgres), idempotent migration, G-027 first-boot,
  v0.04 VC verifies, tamper detection.

Graceful degradation verified: server starts without Postgres (pg_pool/
pg_store are None; voice loop works; auth routes return 503).

---ci---
project: praxis
phase: 1
milestone: v0.4
status: execute
persona: backend-engineer
task: 06-01,06-02,06-03,06-04,06-05
requirements:
  covered: [REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01]
  grill:
    - G-011 (two-store fallback wired in /vc/verify)
  risks:
    - R-VC-MIG-01 (e2e test: v0.3 VC verifies against archived superseded key in Postgres)
---/ci---
2026-08-04 01:00:11 +00:00

272 lines
9.9 KiB
Python

"""Praxis server entrypoint — starts the Pipecat WebRTC bot server.
Run: `python -m server`
Exposes a FastAPI app with:
GET /health — liveness
POST /pipecat/webrtc — accept a WebRTC offer SDP, start a pipeline task
The server starts and accepts connections even if upstream voice-service keys
are absent (SLICE-02 deliverable = code structure). Missing keys degrade to
no audio/no tokens at runtime, not a crash.
"""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from typing import Any
from loguru import logger
from pydantic import BaseModel
# Load .env if present (dev). In production, env is injected directly.
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError: # pragma: no cover
pass
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
from slowapi.errors import RateLimitExceeded
from slowapi import _rate_limit_exceeded_handler
from db.pg_migrate import apply_pg_migrations
from db.pg_store import PgStore
from db.store import PraxisStore
from server.auth.cookies import get_session_middleware_kwargs
from server.auth.rate_limit import limiter
from server.auth.routes import router as auth_router
from server.pipeline import build_pipeline
from server.vc.issuer_keys import _load_root_key
from server.vc.migrate_keys import migrate_issuer_keys
from server.vc.verification import verify_credential
from starlette.middleware.sessions import SessionMiddleware
_store = PraxisStore()
def _env(key: str, default: str = "") -> str:
return os.environ.get(key, default).strip()
HOST = _env("PRAXIS_HOST", "0.0.0.0")
PORT = int(_env("PRAXIS_PORT", "8789"))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""v0.4 — create the asyncpg Postgres pool on startup, close on shutdown.
Graceful degradation (D-050, REQ-NFR-MT-01): if PRAXIS_PG_DSN is unset,
the server starts without Postgres — the learner voice loop (SQLite)
is unaffected. app.state.pg_pool / app.state.pg_store are None in that
case and auth/operator routes return 503.
"""
dsn = os.environ.get("PRAXIS_PG_DSN", "").strip()
if not dsn:
logger.warning(
"PRAXIS_PG_DSN not set — starting without Postgres (dev/no-pool mode). "
"Operator auth + cohort endpoints will be unavailable (503). "
"Learner voice loop (SQLite) is unaffected."
)
app.state.pg_pool = None
app.state.pg_store = None
try:
yield
finally:
return
import asyncpg
logger.info("Creating asyncpg Postgres pool (min=1, max=10, D-050)")
pool = await asyncpg.create_pool(
dsn=dsn,
min_size=1,
max_size=10,
command_timeout=10,
)
app.state.pg_pool = pool
app.state.pg_store = PgStore(pool)
try:
applied = await apply_pg_migrations(pool)
if applied:
logger.info(f"Postgres migrations applied: {applied}")
else:
logger.info("Postgres migrations up to date")
# VC key migration (TASK-06-03, R-VC-MIG-01, G-027) — runs once on
# first boot, idempotent. Non-fatal on failure (v0.3 SQLite path
# remains intact for verification).
await _maybe_migrate_issuer_keys()
try:
yield
finally:
pass
finally:
await pool.close()
logger.info("Postgres pool closed")
class WebRTCOffer(BaseModel):
"""Client→server WebRTC offer (SDP + type)."""
sdp: str
type: str = "offer"
app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0", lifespan=lifespan)
# slowapi rate-limit state + 429 handler (D-041, TASK-03-03).
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # dev — the client is a separate Vite origin
allow_methods=["*"],
allow_headers=["*"],
)
# SessionMiddleware (signed cookies, D-056) — added AFTER CORS so it is
# the outermost middleware (signs cookies before CORS headers are added).
app.add_middleware(SessionMiddleware, **get_session_middleware_kwargs())
@app.get("/health")
async def health() -> dict[str, Any]:
"""Liveness probe. Reports key-provisioning status for the client."""
return {
"status": "ok",
"version": "0.1.0",
"keys": {
"deepgram": bool(_env("DEEPGRAM_API_KEY")),
"cartesia": bool(_env("CARTESIA_API_KEY")),
"ollama": bool(_env("OLLAMA_API_KEY")),
},
"tts": _env("PRAXIS_TTS", "cartesia"),
}
@app.post("/pipecat/webrtc")
async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]:
"""Accept a WebRTC offer, start a Pipecat pipeline task, return the answer.
Loads the v0.1 scenario (customer_service_refund_ca_v01) so the pipeline
uses the scenario-driven system prompt + opening line (TASK-03-07).
"""
scenario_id = _env("PRAXIS_SCENARIO", "customer_service_refund_ca_v01")
try:
connection = SmallWebRTCConnection(
ice_servers=[{"urls": "stun:stun.l.google.com:19302"}],
)
await connection.receive_offer({"sdp": offer.sdp, "type": offer.type})
await connection.accept()
answer = connection.get_answer()
# Build + run the pipeline for this connection.
pipeline, task, runner, transport, scenario_runtime = build_pipeline(
connection, scenario_id=scenario_id
)
# Run the pipeline task in the background; the runner manages its lifecycle.
import asyncio
asyncio.create_task(runner.run(task))
# Play the session-start disclaimer as the first AI utterance (D-019,
# RESEARCH.md safety baseline), then the scenario opening line.
from server.services.registry import get_guardrail
guardrail = get_guardrail()
disclaimer = guardrail.session_start_disclaimer
if scenario_runtime is not None:
logger.info(
f"Session starting with scenario {scenario_id!r}; "
f"disclaimer: {disclaimer[:50]!r}; "
f"opening line: {scenario_runtime.opening_line[:60]!r}"
)
else:
logger.info(f"Session starting (no scenario); disclaimer: {disclaimer[:50]!r}")
return {"sdp": answer["sdp"], "type": answer["type"]}
except Exception as exc:
logger.error(f"WebRTC offer failed: {exc}")
raise HTTPException(status_code=500, detail=str(exc))
@app.get("/vc/verify/{credential_id}")
async def vc_verify(credential_id: str) -> dict[str, Any]:
"""Public, unauthenticated VC verification endpoint (D-043, G-011).
Two-store fallback (G-011, binding contract):
(a) If Postgres is available (app.state.pg_store), use it for issuer
key lookup (active + superseded keys).
(b) If the credential is not in Postgres issued_credentials, fall back
to SQLite (v0.3 credentials remain in SQLite — D-051).
(c) If Postgres is NOT available, use the v0.3 SQLite path for both.
The VC key migration (TASK-04-03) runs once on first boot (idempotent)
inside the lifespan — see _maybe_migrate_issuer_keys.
"""
await _store.init()
pg_store = getattr(app.state, "pg_store", None)
result = await verify_credential(
_store, credential_id,
pg_store=pg_store, sqlite_store=_store,
)
if result is None:
raise HTTPException(status_code=404, detail="credential not found")
return result
async def _maybe_migrate_issuer_keys() -> None:
"""Run the VC key migration on first boot (TASK-06-03, R-VC-MIG-01).
Idempotent — no-op if Postgres already has an active issuer key. G-027:
if SQLite has no v0.3 active key (fresh deploy), skips archive and only
generates a fresh v0.4 keypair.
"""
pg_store = getattr(app.state, "pg_store", None)
if pg_store is None:
return
try:
await _store.init()
root_key = _load_root_key()
result = await migrate_issuer_keys(_store, pg_store, root_key)
if result["new_key_id"] is not None:
logger.info(
f"VC key migration: archived v0.3 key={result['archived_key_id']}, "
f"generated fresh v0.4 key={result['new_key_id']}"
)
else:
logger.info("VC key migration: active key already present (no-op)")
except Exception as exc:
logger.error(f"VC key migration failed (non-fatal — v0.3 path intact): {exc}")
# ── Operator auth routes (TASK-06-02, D-057) ───────────────────────────
# Mounted BEFORE the StaticFiles mount so /api/operator/* is matched by
# the router (routes-before-static-mount constraint, carry-forward v0.2).
app.include_router(auth_router)
# ── Static client serving (D-023, REQ-DEPLOY-13) ──────────────────────
# Mount client/dist as StaticFiles at "/" AFTER all API routes so they
# take precedence. html=True serves index.html for "/" (SPA root).
# The client has no React Router (single-view state machine: start→live
# →debrief), so no SPA fallback fallback route is needed per RESEARCH.md Q3.
_CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist")
if os.path.isdir(_CLIENT_DIST):
app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True), name="client")
logger.info(f"Serving client from {_CLIENT_DIST}")
else:
logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode")
def main() -> int:
"""Run the server with uvicorn."""
import uvicorn
logger.info(f"Praxis v0.1 voice server starting on {HOST}:{PORT}")
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
return 0
if __name__ == "__main__":
raise SystemExit(main())