"""Flask application factory.""" from __future__ import annotations from pathlib import Path from flask import Flask, abort from flask_cors import CORS from .auth.users import AuthError, UserStore from .config import Config def bootstrap_admin(users: UserStore) -> None: """Ensure a default org + super-admin exists on first run (no self-registration).""" # Use a stable org record lock even before the record exists. Every worker # therefore rechecks the store after the first worker finishes initialization. with users.orgs.record_lock("org-default"): Config.validate_runtime_security(require_bootstrap=not bool(users.users.all())) if users.users.all(): return org = users.orgs.get_or_none("org-default") if org is None: org = users.create_org("Default Organization", org_id="org-default") if users.get_user_or_none("admin") is None: users.create_user( org_id=org["id"], username="admin", password=Config.BOOTSTRAP_ADMIN_PASSWORD, name="Super Admin", role="super_admin", must_setup=True, ) print("[bootstrap] created initial super-admin; first-time setup is required") def create_app() -> Flask: Config.ensure_dirs() app = Flask(__name__) app.config["SECRET_KEY"] = Config.SECRET_KEY app.config["MAX_CONTENT_LENGTH"] = Config.UPLOAD_MAX_MB * 1024 * 1024 if Config.CORS_ORIGINS: CORS(app, resources={r"/api/*": {"origins": list(Config.CORS_ORIGINS)}}) from .api.auth_routes import auth_bp from .api.oauth_routes import oauth_bp from .api.admin_routes import admin_bp from .api.group_routes import groups_bp from .api.chat_routes import chat_bp from .api.me_routes import me_bp from .api.analytics_routes import analytics_bp from .api.helpers import register_error_handlers app.register_blueprint(auth_bp, url_prefix="/api/auth") app.register_blueprint(oauth_bp, url_prefix="/api/auth") app.register_blueprint(admin_bp, url_prefix="/api/admin") app.register_blueprint(groups_bp, url_prefix="/api/groups") app.register_blueprint(chat_bp, url_prefix="/api/chat") app.register_blueprint(me_bp, url_prefix="/api/me") app.register_blueprint(analytics_bp, url_prefix="/api/analytics") register_error_handlers(app) from .auth.users import UserStore from .llm import LLMClient from .llm import LLMError from .services.groups import GroupStore from .services.sessions import SessionStore from .services.trainee import MyPersonaStore app.extensions["user_store"] = UserStore(Config.DATA_DIR) app.extensions["group_store"] = GroupStore(Config.DATA_DIR) app.extensions["session_store"] = SessionStore(Config.DATA_DIR) app.extensions["my_persona_store"] = MyPersonaStore(Config.DATA_DIR) try: app.extensions["llm"] = LLMClient() except LLMError as exc: print(f"[warn] LLM not configured yet (error_type={type(exc).__name__})") app.extensions["llm"] = None bootstrap_admin(app.extensions["user_store"]) @app.get("/health") def health(): return {"status": "ok", "service": Config.APP_NAME} @app.get("/ready") def ready(): """Bounded readiness signal for load balancers; never expose config values.""" stores_ok = all( app.extensions.get(name) is not None for name in ("user_store", "group_store", "session_store") ) config_ok = bool(Config.DATA_DIR and Config.DATA_DIR.exists()) checks = {"stores": "ok" if stores_ok else "failed", "config": "ok" if config_ok else "failed"} status = "ready" if stores_ok and config_ok else "not_ready" return {"status": status, "checks": checks}, (200 if status == "ready" else 503) # Serve built Vue frontend if present (production single-app mode). _register_frontend(app) return app def _register_frontend(app: Flask) -> None: from flask import send_from_directory # repo-root frontend/dist (factory.py -> app/ -> backend/ -> repo root) dist = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist" if not (dist / "index.html").exists(): print(f"[info] frontend build not found at {dist}; API-only mode") return @app.route("/") def index(): return send_from_directory(dist, "index.html") @app.route("/", methods=["GET", "HEAD", "OPTIONS", "POST", "PUT", "DELETE", "PATCH"]) def assets(path: str): # Never let the SPA fallback shadow API/auth routes: return 404 for them. if path == "api" or path.startswith("api/") or path.startswith("health"): abort(404) candidate = dist / path if candidate.is_file(): return send_from_directory(dist, path) # SPA fallback for client-side routes return send_from_directory(dist, "index.html")