llm / apps /api /main.py
Aswin480
fix(devices): add genuine scannable QR code endpoint and wire JWT auth to device actions
21dbbb4
Raw History Blame Contribute Delete
21.9 kB
"""
Personal Assistant (my-local-bot) - Primary API Entrypoint.
Single-process FastAPI server with SQLite and local RAG.
Production-ready with Security Headers, Audit Ledger, Multi-Agent Supervisors,
Cloud Connectors, Headless Browser Automation, and Resilient Error Handling.
"""
from __future__ import annotations
import os
import uuid
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import backend.compat # noqa: F401
from apps.api.routers.v1 import (
agents,
audit,
auth,
browser,
caldav,
calendar,
career,
connectors,
devices,
rag,
reminders,
settings,
tasks,
training,
voice,
widget,
)
from apps.rag import db as rag_db
from apps.rag.generator import get_model_name, model_is_available
from apps.rag.retriever import get_chunk_count
from backend.agents.supervisor import ensure_agent_tables
from backend.audit.service import audit_service
from backend.browser.service import ensure_browser_tables
from backend.calendar.caldav_service import caldav_service
from backend.career.service import career_service
from backend.security import auth as security_auth
from backend.tasks.service import ensure_tasks_tables
from backend.training.service import TrainingService
class RagStatus(BaseModel):
model_found: bool
chunk_count: int
model_name: str | None = None
class HealthResponse(BaseModel):
status: str
database: bool
postgres: bool = False
rag: RagStatus = RagStatus(model_found=False, chunk_count=0)
@asynccontextmanager
async def lifespan(fastapi_app: FastAPI) -> AsyncGenerator[None, None]:
"""Startup: initialise SQLite database schemas and background services."""
await rag_db.init_db()
await security_auth.ensure_auth_tables()
await ensure_tasks_tables()
await caldav_service.ensure_tables()
await ensure_agent_tables()
await ensure_browser_tables()
await audit_service.ensure_tables()
await career_service.ensure_tables()
await TrainingService.ensure_schema()
# Ensure reminders tables exist and start scheduler
from backend.reminders.service import ( # noqa: PLC0415
ensure_reminders_tables,
start_scheduler,
stop_scheduler,
)
await ensure_reminders_tables()
start_scheduler()
audit_service.record(
actor="system",
action="SERVER_STARTUP",
resource="api_server",
risk_level="LOW",
status="SUCCESS",
details={"version": "0.2.0", "mode": "production-ready"},
)
import asyncio # noqa: PLC0415
async def _safe_prewarm_models() -> None:
"""Gentle background pre-warming of AI models (laptop-friendly, zero initial UI wait)."""
await asyncio.sleep(2.5)
try:
from starlette.concurrency import run_in_threadpool # noqa: PLC0415
from apps.rag.generator import get_llm, model_is_available # noqa: PLC0415
from apps.rag.retriever import get_embed_model # noqa: PLC0415
await run_in_threadpool(get_embed_model)
if model_is_available():
await run_in_threadpool(get_llm)
except Exception:
pass
asyncio.create_task(_safe_prewarm_models())
yield
# Shutdown β€” stop background scheduler gracefully
stop_scheduler()
audit_service.record(
actor="system",
action="SERVER_SHUTDOWN",
resource="api_server",
risk_level="LOW",
status="SUCCESS",
)
app = FastAPI(
title="Personal Assistant (my-local-bot) API",
description="Privacy-First Local Personal Assistant with SQLite and Offline RAG",
version="0.2.0",
lifespan=lifespan,
)
app.state.test_mode_allow_default = False
# ── CORS Middleware ───────────────────────────────────────────────────────────
# Restrict CORS to trusted local origins and private mesh network by default.
# Prevents arbitrary internet websites from executing cross-origin attacks against localhost.
cors_origins_env = os.environ.get("ALLOWED_ORIGINS", "")
allowed_origins = [o.strip() for o in cors_origins_env.split(",") if o.strip()] if cors_origins_env else [
"http://localhost:8000",
"http://127.0.0.1:8000",
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
]
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_origin_regex=r"(https://.*\.vercel\.app|http://(localhost|127\.0\.0\.1|100\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3})(:\d+)?)",
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["*"],
)
# ── Security Headers & Tracing Middleware ────────────────────────────────────
@app.middleware("http")
async def security_and_tracing_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""
Applies security headers and correlation ID (X-Request-ID) to all HTTP responses.
Implements OWASP security recommendations: nosniff, framing protection, CSP.
"""
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = (
"default-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.gstatic.com data: blob:; "
"img-src 'self' data: blob:; connect-src 'self' ws: wss:; worker-src 'self' blob:;"
)
response.headers["Permissions-Policy"] = "camera=(), geolocation=(), microphone=(self)"
return response
# ── Auth Enforcement Middleware ──────────────────────────────────────────────
@app.middleware("http")
async def auth_enforcement_middleware(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
"""
Enforces JWT or Device Token authentication for all non-public endpoints.
Public endpoints:
- /health
- /privacy, /terms
- /api/v1/auth/setup, /api/v1/auth/login, /api/v1/auth/status
- /api/v1/auth/recovery-token, /api/v1/auth/recovery-reset
- Static assets & HTML pages (/, /static/*, /favicon.ico, /manifest.json, /sw.js)
- OpenAPI docs (/docs, /openapi.json, /redoc)
- Device pairing endpoints (verify master password or admin JWT internally)
"""
path = request.url.path
# Allow CORS preflight OPTIONS requests unconditionally
if request.method == "OPTIONS":
return await call_next(request)
# Allow public endpoints
if (
path == "/health"
or path in ["/privacy", "/terms"]
or path.startswith("/api/v1/auth/")
or path in ["/", "/manifest.json", "/sw.js", "/favicon.ico", "/widget.html", "/widget", "/orbit_calendar.html"]
or path.startswith("/api/v1/widget")
or path.startswith("/static/")
or path.startswith("/docs")
or path.startswith("/openapi.json")
or path.startswith("/redoc")
or path in ["/api/v1/devices/pair/request", "/api/v1/devices/pair/verify", "/api/v1/devices/qr"]
or (path.startswith("/api/v1/training/jobs/") and path.endswith("/sync"))
):
return await call_next(request)
# In test mode, allow unauthenticated access for legacy test suite if enabled
if getattr(request.app.state, "test_mode_allow_default", False):
auth_hdr = request.headers.get("Authorization")
x_dev_hdr = request.headers.get("X-Device-Token")
if not auth_hdr and not x_dev_hdr:
return await call_next(request)
# Extract credential token
token = None
auth_hdr = request.headers.get("Authorization")
if auth_hdr and auth_hdr.startswith("Bearer "):
token = auth_hdr[7:].strip()
elif "X-Device-Token" in request.headers:
token = request.headers.get("X-Device-Token", "").strip()
if not token:
return JSONResponse(
status_code=401,
content={
"detail": "Authentication credentials were not provided. Provide a valid Bearer JWT or X-Device-Token."
},
headers={"WWW-Authenticate": "Bearer"},
)
# Check revocation blacklist
if security_auth.is_token_revoked(token):
return JSONResponse(
status_code=401,
content={"detail": "Authentication token has been revoked. Please log in again."},
headers={"WWW-Authenticate": "Bearer"},
)
# 1. Validate as JWT
try:
payload = security_auth.decode_access_token(token)
request.state.auth_user = payload
return await call_next(request)
except Exception:
pass
# 2. Validate as Device Token
from backend.devices.registry import device_registry
rec = device_registry.find_device_by_token(token)
if rec is not None:
request.state.auth_device = rec
return await call_next(request)
return JSONResponse(
status_code=401,
content={"detail": "Invalid or expired authentication credentials."},
headers={"WWW-Authenticate": "Bearer"},
)
# ── Register active API v1 routers ───────────────────────────────────────────
app.include_router(auth.router, prefix="/api/v1")
app.include_router(caldav.router, prefix="/api/v1")
app.include_router(calendar.router, prefix="/api/v1")
app.include_router(devices.router, prefix="/api/v1")
app.include_router(rag.router, prefix="/api/v1")
app.include_router(reminders.router, prefix="/api/v1")
app.include_router(tasks.router, prefix="/api/v1")
app.include_router(voice.router, prefix="/api/v1")
app.include_router(agents.router, prefix="/api/v1")
app.include_router(connectors.router, prefix="/api/v1")
app.include_router(browser.router, prefix="/api/v1")
app.include_router(audit.router, prefix="/api/v1")
app.include_router(settings.router, prefix="/api/v1")
app.include_router(training.router, prefix="/api/v1")
app.include_router(career.router, prefix="/api/v1")
app.include_router(widget.router, prefix="/api/v1")
# ── Custom 404 Exception Handler ─────────────────────────────────────────────
@app.exception_handler(404)
async def custom_404_handler(request: Request, exc: Any) -> Response:
accept = request.headers.get("Accept", "")
if "text/html" in accept and not request.url.path.startswith("/api/"):
return HTMLResponse(
status_code=404,
content="""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 β€” Page Not Found | PABOT</title>
<style>
body { background: #0A0D12; color: #E7EAEE; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; text-align: center; }
.box { background: #12171F; border: 1px solid #242C38; border-radius: 12px; padding: 48px 36px; max-width: 480px; box-shadow: 0 12px 36px rgba(0,0,0,0.4); }
h1 { font-size: 56px; color: #C89B5C; margin: 0 0 8px; font-weight: 700; }
h2 { font-size: 20px; color: #E7EAEE; margin: 0 0 16px; font-weight: 600; }
p { color: #96A1B0; margin-bottom: 28px; font-size: 14px; line-height: 1.6; }
a { display: inline-block; background: #C89B5C; color: #0A0D12; text-decoration: none; padding: 12px 24px; border-radius: 6px; font-weight: 600; font-size: 14px; transition: opacity 0.2s; }
a:hover { opacity: 0.9; }
</style>
</head>
<body>
<div class="box">
<h1>404</h1>
<h2>Resource Not Found</h2>
<p>The requested endpoint or document is not available on this personal assistant server.</p>
<a href="/">Return to Dashboard</a>
</div>
</body>
</html>""",
)
return JSONResponse(
status_code=404,
content={
"detail": f"Resource not found: {request.url.path}",
"status_code": 404,
"path": request.url.path,
},
)
# ── Legal & Privacy Endpoints ────────────────────────────────────────────────
@app.get("/privacy", response_class=HTMLResponse)
async def get_privacy_policy() -> HTMLResponse:
"""Privacy Policy page detailing local-first zero-telemetry architecture and data rights."""
return HTMLResponse(
content="""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Privacy Policy β€” PABOT</title>
<style>
body { background: #0A0D12; color: #E7EAEE; font-family: -apple-system, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
h1 { color: #C89B5C; font-size: 28px; margin-bottom: 8px; }
h2 { color: #E7EAEE; font-size: 18px; margin-top: 24px; border-bottom: 1px solid #242C38; padding-bottom: 6px; }
p, li { color: #96A1B0; font-size: 14px; }
ul { padding-left: 20px; margin-bottom: 16px; }
.badge { display: inline-block; background: rgba(47, 168, 138, 0.2); color: #2FA88A; padding: 4px 10px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-bottom: 20px; }
a { color: #C89B5C; }
</style>
</head>
<body>
<div class="badge">100% LOCAL-FIRST β€’ ZERO EXTERNAL TELEMETRY</div>
<h1>Privacy Policy</h1>
<p>Last updated: October 2026</p>
<h2>1. Core Privacy Invariant</h2>
<p>PABOT operates exclusively within a <strong>Local-First Trust Boundary</strong>. All personal data, notes, calendar events, tasks, chat messages, and vector embeddings reside on your own primary machine in SQLite databases. No data is transmitted to cloud vendors, telemetry brokers, or third parties unless you explicitly configure an external connector (e.g. Gmail, GitHub, Notion) with your own API tokens.</p>
<h2>2. AI Model & Inference</h2>
<p>All language model inference executes locally using offline quantized models (GGUF via llama-cpp-python) and local CPU voice synthesis (Piper ONNX). Your queries and documents are processed entirely on your hardware and never leave your machine.</p>
<h2>3. Data Collection and Retention</h2>
<p>The system stores only the data you provide to manage your personal schedule and knowledge base. You maintain complete sovereignty over your data and can perform point-in-time backups or permanently purge all personal information via <code>POST /api/v1/auth/wipe-data</code>.</p>
<h2>4. Authentication and Device Security</h2>
<p>Passwords are hashed using bcrypt with salt rounds. Device pairing tokens are stored exclusively as deterministic SHA-256 digests. Active sessions can be revoked immediately via the Devices tab or logout endpoint.</p>
<p style="margin-top: 32px;"><a href="/">← Return to Dashboard</a></p>
</body>
</html>"""
)
@app.get("/terms", response_class=HTMLResponse)
async def get_terms_of_service() -> HTMLResponse:
"""Terms of Service describing local usage, human authority, and license."""
return HTMLResponse(
content="""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terms of Service β€” PABOT</title>
<style>
body { background: #0A0D12; color: #E7EAEE; font-family: -apple-system, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 40px 20px; }
h1 { color: #C89B5C; font-size: 28px; margin-bottom: 8px; }
h2 { color: #E7EAEE; font-size: 18px; margin-top: 24px; border-bottom: 1px solid #242C38; padding-bottom: 6px; }
p, li { color: #96A1B0; font-size: 14px; }
ul { padding-left: 20px; margin-bottom: 16px; }
a { color: #C89B5C; }
</style>
</head>
<body>
<h1>Terms of Service</h1>
<p>Last updated: October 2026</p>
<h2>1. Self-Hosted Software License</h2>
<p>PABOT is self-hosted software intended for personal productivity and private information management. The software is provided "as is", without warranty of any kind, express or implied.</p>
<h2>2. Human In-The-Loop Authority</h2>
<p>As established in the system's architectural invariants, the language model provides recommendations and drafting intelligence, while deterministic human authorization is strictly required for any consequential action (such as email dispatch, external form submission, or file overwrites).</p>
<h2>3. Local Storage Responsibility</h2>
<p>Because PABOT is non-custodial and stores data locally on your storage drives, you are responsible for maintaining regular backups using the provided backup tools (<code>scripts/backup.py</code>) and securing access to your host machine.</p>
<p style="margin-top: 32px;"><a href="/">← Return to Dashboard</a></p>
</body>
</html>"""
)
# ── Health & Diagnostics ─────────────────────────────────────────────────────
@app.get("/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
# ── Local SQLite check ────────────────────────────────────────────────
db_healthy = False
try:
import aiosqlite
async with aiosqlite.connect(str(rag_db.APP_DB)) as conn:
async with conn.execute("SELECT 1;") as cursor:
row = await cursor.fetchone()
db_healthy = row is not None and row[0] == 1
except Exception:
db_healthy = False
# ── PostgreSQL check ──────────────────────────────────────────────────
pg_healthy = False
try:
import os
import asyncpg
db_url = os.environ.get("DATABASE_URL", "")
if not db_url:
try:
from dotenv import load_dotenv
load_dotenv()
db_url = os.environ.get("DATABASE_URL", "")
except Exception:
pass
if db_url:
clean_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
conn = await asyncpg.connect(clean_url, timeout=2.0)
pg_healthy = (await conn.fetchval("SELECT 1;") == 1)
await conn.close()
except Exception:
pg_healthy = False
# ── RAG subsystem check ───────────────────────────────────────────────
rag_status = RagStatus(
model_found=model_is_available(),
chunk_count=get_chunk_count(),
model_name=get_model_name() if model_is_available() else None,
)
overall_status = "ok" if (db_healthy or pg_healthy) else "degraded"
return HealthResponse(
status=overall_status,
database=db_healthy,
postgres=pg_healthy,
rag=rag_status,
)
@app.get("/api/v1/system/info")
async def get_system_info() -> dict[str, str]:
return {
"app_name": "my-local-bot",
"version": "0.2.0",
"mode": "production-ready-local-first",
}
# ── Static Web Assets & PWA Serving ──────────────────────────────────────────
public_dir = Path(__file__).resolve().parents[1] / "web" / "public"
if public_dir.exists():
app.mount("/static", StaticFiles(directory=str(public_dir)), name="static")
@app.get("/", include_in_schema=False)
async def serve_dashboard() -> Any:
index_file = public_dir / "index.html"
if index_file.exists():
return FileResponse(
index_file,
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
)
return {"message": "Personal Assistant API running. Static dashboard present."}
@app.get("/widget", include_in_schema=False)
@app.get("/widget.html", include_in_schema=False)
async def serve_widget() -> Any:
widget_file = public_dir / "widget.html"
if widget_file.exists():
return FileResponse(
widget_file,
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
},
)
return FileResponse(public_dir / "index.html")