""" 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="""
The requested endpoint or document is not available on this personal assistant server.
Return to DashboardLast updated: October 2026
PABOT operates exclusively within a Local-First Trust Boundary. 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.
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.
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 POST /api/v1/auth/wipe-data.
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.
""" ) @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="""Last updated: October 2026
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.
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).
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 (scripts/backup.py) and securing access to your host machine.