docs(milestone): complete v0.4-operator-tier — v0.1.9 tagged, milestone release, merged to main

v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete.

Phases:
  ✓ P0  pre-execution (planning)        → v0.1.6
  ✓ P1  operator foundation (Postgres+auth+VC migration) → v0.1.7
  ✓ P2  cohort dashboard + aggregation   → v0.1.8
  ✓ P3  final review + ship              → v0.1.9 (= v0.4 milestone release)

Requirements covered (8/8):
  REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline),
  REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard),
  REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC),
  REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h)

Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041

Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass
Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward)
Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill)

---ci---
project: praxis
phase: 3
milestone: v0.4
status: complete
phase_role: final
milestone_complete: true
milestone_merged_to_main: true
tag: v0.1.9
requirements:
  covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02]
  partial: []
---/ci---
This commit is contained in:
Praxis CI
2026-08-04 11:58:44 +00:00
parent d0f37e151e
commit f2a12f9fed
76 changed files with 12504 additions and 578 deletions
+166 -15
View File
@@ -14,6 +14,7 @@ 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
@@ -27,14 +28,32 @@ try:
except ImportError: # pragma: no cover
pass
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
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.cohort.nightly import NightlyScheduler
from server.operator.cohort import router as cohort_router
from server.operator.credentials import router as credentials_router
from server.operator.failure_patterns import router as failure_router
from server.operator.mastery import router as mastery_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
from starlette.responses import FileResponse
from starlette.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
_store = PraxisStore()
@@ -47,6 +66,64 @@ 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)
nightly = NightlyScheduler()
app.state.nightly_scheduler = nightly
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()
# v0.4 P2 (D-054, REQ-NFR-DASH-02): start the nightly reconciliation
# scheduler at 03:00 CT. Cancelled on shutdown.
await nightly.start(app.state.pg_store)
logger.info("Nightly cohort reconciliation scheduler started (03:00 CT)")
try:
yield
finally:
await nightly.stop()
finally:
await pool.close()
logger.info("Postgres pool closed")
class WebRTCOffer(BaseModel):
"""Client→server WebRTC offer (SDP + type)."""
@@ -54,13 +131,19 @@ class WebRTCOffer(BaseModel):
type: str = "offer"
app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0")
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")
@@ -123,28 +206,96 @@ async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]:
@app.get("/vc/verify/{credential_id}")
async def vc_verify(credential_id: str) -> dict[str, Any]:
"""Public, unauthenticated VC verification endpoint (D-043).
"""Public, unauthenticated VC verification endpoint (D-043, G-011).
Returns {valid, status, issuer, credential, mastery, credentialTier,
verifiedAt}. 404 if the credential id is not found. No PII beyond what
the credential asserts.
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()
result = await verify_credential(_store, credential_id)
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
# ── 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.
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)
# ── Operator API cohort endpoints (TASK-10-02, D-053, D-057) ──────────
# Auth-gated via Depends(current_operator) inside each router. Mounted
# BEFORE the SPA StaticFiles fallback so /api/operator/* is matched by the
# API routers, not the SPA fallback.
app.include_router(cohort_router)
app.include_router(mastery_router)
app.include_router(failure_router)
app.include_router(credentials_router)
# ── SPA StaticFiles fallback (G-041 binding, TASK-10-01, R-DASH-03/05) ─
# Custom StaticFiles subclass that returns index.html for non-file paths
# (SPA client-side routing). G-041 OVERRIDES the plan's catch-all route —
# a @app.get("/{path:path}") catch-all before StaticFiles would shadow
# asset serving (assertion 8 in TASK-10-04). This subclass serves assets
# normally (JS/CSS) and falls back to index.html for client-side routes
# (/operator/dashboard, /operator/login). API routes registered above take
# precedence over the mount.
class SpaStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
except (StarletteHTTPException, HTTPException) as e:
if getattr(e, "status_code", None) == 404:
import os
index = os.path.join(self.directory, "index.html")
if os.path.isfile(index):
return FileResponse(index)
raise
# Mount client/dist at "/" AFTER all API routes so they take precedence.
# html=True serves index.html for "/" (SPA root). The SpaStaticFiles
# subclass serves index.html for unknown paths (React Router routes).
_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}")
app.mount("/", SpaStaticFiles(directory=_CLIENT_DIST, html=True), name="spa")
logger.info(f"Serving client from {_CLIENT_DIST} (SPA fallback enabled)")
else:
logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode")