""" Flask application factory for ai-eyes2. Usage: from app import create_app app, sock = create_app() This factory pattern allows: - Blueprint registration (Phase 2 tasks P2-T2 through P2-T8) - Test isolation via config_override - Clean extension initialization ADR-009 (simple manager pattern): factory returns app + sock tuple so server.py module-level decorators (@app.route, @sock.route) keep working. """ import logging import os from flask import Flask, g, jsonify, redirect, request from flask_cors import CORS from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_sock import Sock from werkzeug.middleware.proxy_fix import ProxyFix logger = logging.getLogger(__name__) # Match static_files.py and nginx (100 MB) — bulk uploads need full limit _MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB def create_app(config_override: dict = None): """ Create and configure the Flask application. Args: config_override: Optional dict of Flask config values to apply. Primarily used in tests to inject TESTING=True etc. Returns: tuple: (app, sock) — configured Flask app and Flask-Sock instance. """ app = Flask( __name__, # Serve static files from project root (index.html etc.) via explicit routes static_folder=None, ) # Core Flask config secret_key = os.getenv('SECRET_KEY') if not secret_key: import secrets as _secrets secret_key = _secrets.token_hex(32) logger.warning( 'No SECRET_KEY set — generated a random key for this session. ' 'Sessions will NOT persist across restarts. ' 'Set SECRET_KEY in .env for production.' ) app.config['SECRET_KEY'] = secret_key app.config['MAX_CONTENT_LENGTH'] = _MAX_UPLOAD_BYTES # Apply test / caller overrides last so they take precedence if config_override: app.config.update(config_override) # Trust one level of X-Forwarded-* headers (nginx / reverse proxy). # Without this, request.remote_addr is always 127.0.0.1 behind nginx, # breaking per-IP rate limiting (all users share one bucket). app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # Initialize Flask-Sock for WebSocket support sock = Sock(app) # Configure CORS — allow your production domain and any localhost port for dev # Anchored regex prevents partial matches like http://localhostX.evil.com # Add extra origins via CORS_ORIGINS env var (comma-separated, e.g. https://yourdomain.com) _extra_origins = [o.strip() for o in os.getenv('CORS_ORIGINS', '').split(',') if o.strip()] CORS(app, origins=[ r'^http://localhost:\d+$', r'^chrome-extension://', # JamBot Browser Companion extension *_extra_origins, ], supports_credentials=True) # ── Rate limiting ───────────────────────────────────────────────────────── # Per-IP limits protect expensive endpoints from abuse. # Override default via RATELIMIT_DEFAULT env var (e.g. "100 per minute"). # Disable for tests: config_override={'RATELIMIT_ENABLED': False}. limiter = Limiter( get_remote_address, app=app, default_limits=[os.getenv('RATELIMIT_DEFAULT', '200 per minute')], storage_uri='memory://', ) app.limiter = limiter # ── Clerk auth gate ──────────────────────────────────────────────────────── # Auth is only active when CLERK_PUBLISHABLE_KEY is set in .env. # Without it, the app runs fully open (single-user / local mode). _clerk_key = (os.getenv('CLERK_PUBLISHABLE_KEY') or os.getenv('VITE_CLERK_PUBLISHABLE_KEY', '')).strip() _auth_enabled = bool(_clerk_key) # Route-level guards that must agree with THIS gate (not with the import-time # view in services.auth) read it from here — e.g. routes/vault.py # _platform_admin_only (SEC-069). app.config['CLERK_AUTH_ENABLED'] = _auth_enabled # Privileged surfaces — require an admin user (services.auth.ADMIN_USER_IDS), # not just any allowlisted tenant user. ALLOWED_USER_IDS gates the voice app; # these endpoints can rewrite vault credentials, openclaw.json (incl. provider # baseUrl/apiKey), agent workspace files, and inject into the live agent session. _ADMIN_ONLY_PREFIXES = ( '/api/admin/', '/api/vault/', # oauth callback is exempted via _PUBLIC_PREFIXES below '/api/workspace/', '/api/refactor/', '/api/server-stats', '/api/plugins/', # install/uninstall/restart/config (assets exempted below) '/api/plugins', # bare list endpoint '/api/services/', # Service Catalog + per-service health (WO-1.2) — reads # gateway/LLM/TTS/STT descriptors + vault cred status; # admin-only, never public. ) if not _auth_enabled: logger.info('No CLERK_PUBLISHABLE_KEY set — auth disabled (local mode)') # Hosted tenants always set CANVAS_REQUIRE_AUTH=true (JamBot .env template). # If the Clerk key goes missing there (broken .platform-keys.env mount), # the admin surface must fail CLOSED — previously it failed open and every # /api/admin, /api/vault and RPC-proxy endpoint became unauthenticated. # Local / self-hosted installs (no CANVAS_REQUIRE_AUTH) keep the documented # open-access behaviour. if os.getenv('CANVAS_REQUIRE_AUTH', '').strip().lower() == 'true': logger.error('CANVAS_REQUIRE_AUTH=true but no Clerk key — FAILING CLOSED (all non-public routes)') # F-7 (2026-07-15): fail CLOSED for the WHOLE app, not just admin. When a # hosted tenant requires auth (CANVAS_REQUIRE_AUTH=true) but Clerk is # unconfigured (missing/blank key — e.g. a broken .platform-keys.env mount), # `require_auth` below is never registered, so PREVIOUSLY only /admin was # blocked and every other protected route (voice/canvas/session RPC/vault) # was served unauthenticated. Nobody can log in in this state anyway, so we # deny every non-public path (deny-by-default) and serve only the minimal # shell needed to render the login/error page + health probes. This is an # emergency degraded posture; the real fix is restoring the Clerk key. _UNCONF_PUBLIC_EXACT = { '/', '/pi', '/health', '/health/live', '/health/ready', '/favicon.ico', '/sw.js', '/manifest.json', '/api/config', '/api/version', '/api/auth/check', } _UNCONF_PUBLIC_PREFIXES = ('/src/', '/static/', '/images/', '/plugins/') @app.before_request def fail_closed_unconfigured(): path = request.path if path in _UNCONF_PUBLIC_EXACT: return # /src/admin.html is the admin shell — never public even here if path != '/src/admin.html' and any(path.startswith(p) for p in _UNCONF_PUBLIC_PREFIXES): return # everything else is denied: JSON 401 for APIs, redirect to the login shell for pages if path.startswith('/api/') or request.headers.get('X-Requested-With'): return jsonify({ 'error': 'Auth required but Clerk is not configured — service unavailable', 'code': 'auth_unconfigured', }), 503 return redirect('/') else: # Routes that never require authentication: _PUBLIC_PREFIXES = ( '/src/', # static JS/CSS (needed to render the login screen) '/sounds/', '/music/', '/generated_music/', # AI-generated songs — shareable download links (texted/emailed # to recipients with no app login). Non-sensitive (client's own # jingles); the /api/music track list is already public anyway. '/images/', # canvas images (individual pages check their own flag) '/uploads/', # uploaded/generated files — page assets. routes/static_files.py gates it # itself: always-private areas (field-dock/, sms/) are enforced, the rest # follows UPLOADS_AUTH_MODE (shadow/enforce) '/canvas-data/', # processed-song media (audio stems) + fixtures for the Suno Studio editor — non-sensitive, served like /uploads/ (added 2026-06-25) '/static/', # PWA icons, app icons '/pages/', # canvas pages — served without auth (CANVAS_REQUIRE_AUTH opt-in) # '/api/upload' (the POST upload) is an EXACT public path below, not a prefix: as a prefix it # also opened '/api/uploads' (the listing of every filename) to anyone (2026-11-09). '/api/plugins/assets', # Plugin face scripts/CSS — fetched by index.html before login. # All other /api/plugins routes (install/uninstall/restart/config) # are state-changing admin operations and require admin auth below. '/api/vault/oauth/callback/', # OAuth callbacks — redirected from external providers '/api/vault/oauth/relay/', # single-callback OAuth relay (GET only): forwards code+state to # an allow-listed tenant callback; reads no vault, exchanges nothing '/plugins/', # Plugin static assets — face scripts, CSS, previews '/api/theme', # theme config — loaded before Clerk init '/faces/custom/', # custom face HTML — loaded in iframe by face-box '/registry/', # Pinokio registry check-in — accessed by Pinokio, not logged-in user '/checkpoints/', # Pinokio snapshot endpoint — called from /registry/checkin page JS '/openclaw-ui/', # OpenClaw Control UI SPA + assets — proxied to internal gateway '/share/capture/', # Camera Capture share portal — token-gated public access for clients '/u/', # Signed one-time upload links (routes/intake.py) — the TOKEN is the # gate, not Clerk; GET renders the form, POST accepts the upload. Never # add '/api/intake/mint' here — minting stays behind require_auth below # (agent key or Clerk session), only /u/ itself is public. ) # Public for READS ONLY (GET/HEAD/OPTIONS). These prefixes serve config # lists the UI loads before Clerk init, but their state-changing methods # (POST/PUT/PATCH/DELETE) must NOT bypass auth — a prefix opened for a # benign read previously exposed every write sibling under it # (SEC-1/2/3/6/9). Write methods fall through to the normal auth path: # the internal agent key already carves out its allowed non-admin paths # (canvas/tts/suno/music writes the openclaw agent depends on), and # everything else requires a Clerk session. _PUBLIC_READ_PREFIXES = ( '/api/canvas/', # GET manifest/context/pages — writes gated (SEC-2/3/8) '/api/tts/', # GET provider list — clone/default-writes gated (SEC-9) '/api/chat', # LLM proxy — POST gated so anon can't burn the LLM budget (SEC-6) '/api/music', # GET track list — upload/delete gated '/api/faces', # GET face list — enrollment writes gated '/api/custom-faces', # GET manifest — HTML-face writes gated (SEC-3 stored XSS) '/api/icons/', # GET icon library — generate/upload gated (SEC-9) '/api/suno', # GET read actions only (status/list/...) — see _PUBLIC_READ_ACTIONS; # every generation action is gated on GET and POST (SEC-9) '/api/profiles', # GET profile config — activate/save writes gated (SEC-10) '/api/maps/config', # GET Google-Maps JS API key — canvas iframe pages can't # authenticate (window.authFetch not injected there) and the # JS key is browser-exposed BY DESIGN (Google Maps JS loads it # client-side). Security boundary = HTTP-referrer restriction on # the key in Google Cloud (must be *.jam-bot.com). /api/maps/ # directions (POST, billable server-side calls) stays gated. ) # Public-read paths that also run PAID work on GET. `/api/suno` selects its # operation with ?action= on GET as well as POST, so the method-only test # below let an anonymous `GET /api/suno?action=generate` (or jingle, sfx, # extend, cover, stems, ...) spend Suno credits with no login. On these # exact paths only the listed read actions stay public; every other action # falls through to the agent-key / Clerk check, the same gate the POSTs use. # The default action when none is given is the endpoint's own default. _PUBLIC_READ_ACTIONS = { '/api/suno': ('list', frozenset({ 'list', 'list_jingles', 'jingle_styles', 'status', 'credits', })), } # NOTE: /api/stt/ is intentionally NOT public — the Deepgram token # endpoint returns a live secret (SEC-1). All /api/stt/* now requires a # Clerk session or the agent key. _PUBLIC_EXACT = { '/', # main page — hosts the Clerk login gate itself '/pi', # Pi-optimized page — same login gate, different entry point '/health/live', '/health/ready', '/api/auth/check', # Auth check endpoint — does its own token verification '/api/upload', # file upload (POST) — canvas pages lose Clerk JWT on long sessions. # EXACT match only: /api/uploads (the listing) is NOT public. '/api/suno/callback', # Suno's servers POST here from external IPs (no Clerk token) '/api/version', # Version check — loaded before auth to show update banner '/api/config', # Public client config (Clerk publishable key) — admin.html bootstrap '/sw.js', # PWA service worker — browser fetches this before auth '/manifest.json', # PWA manifest — browser fetches this before auth '/favicon.ico', # Browser favicon request — before auth '/ws/clawdbot', # WebSocket — browsers can't send Clerk token in WS headers; # handler secures itself via CLAWDBOT_AUTH_TOKEN to the gateway '/ws/terminal', # Terminal PTY WebSocket — handler self-authenticates via # the __session cookie or X-Agent-Key before spawning a shell '/openclaw-ui', # WebSocket upgrade for OpenClaw Control UI proxy (no trailing slash) '/ws/browse-stream', # WebSocket — co-browsing screencast (#154); handler self-auths # via Clerk token + injects the service key server-side } # Detect whether Clerk auth is configured at startup. # Auth is opt-in: when no key is set, all routes are accessible (README § Authentication). _clerk_key = (os.getenv('CLERK_PUBLISHABLE_KEY') or os.getenv('VITE_CLERK_PUBLISHABLE_KEY', '')).strip() # Internal agent API key — allows openclaw agents to call Flask APIs # without a Clerk JWT. Set AGENT_API_KEY in the container .env. _agent_api_key = os.getenv('AGENT_API_KEY', '').strip() @app.before_request def require_auth(): """Block unauthenticated requests to all non-exempt routes. Skipped entirely when Clerk is not configured (no CLERK_PUBLISHABLE_KEY), matching the documented opt-in auth behaviour. """ if not _clerk_key: return # No Clerk configured — open access (single-user / self-hosted) path = request.path # CSRF guard: state-changing browser requests must come from our own # origin. Cookie (__session) auth makes cross-site request forgery # possible; a mismatched Origin header is the reliable browser signal. # Non-browser clients (agents, server-to-server callbacks like Suno's) # send no Origin header and pass through untouched. if request.method in ('POST', 'PUT', 'DELETE', 'PATCH'): _origin = request.headers.get('Origin', '') if _origin and not request.headers.get('Authorization', '').startswith('Bearer '): from urllib.parse import urlparse _origin_host = urlparse(_origin).netloc if _origin_host and _origin_host != request.host: return jsonify({'error': 'Cross-origin request blocked', 'code': 'csrf_blocked'}), 403 # Always allow health probes and static assets if path in _PUBLIC_EXACT: return # /src/ is public (login-screen assets) EXCEPT the admin shell itself — # /src/admin.html must go through the same admin gate as /admin. if path != '/src/admin.html' and any(path.startswith(p) for p in _PUBLIC_PREFIXES): return # Read-only public prefixes: GET/HEAD/OPTIONS bypass auth (config lists # loaded before Clerk init); write methods fall through to the auth # path below (agent key or Clerk session required). if request.method in ('GET', 'HEAD', 'OPTIONS') and any( path.startswith(p) for p in _PUBLIC_READ_PREFIXES ): _read_actions = _PUBLIC_READ_ACTIONS.get(path.rstrip('/')) if _read_actions is None: return _default_action, _allowed = _read_actions if request.args.get('action', _default_action) in _allowed: return # A paid/state-changing action on a public-read path: fall through. # Canvas pages and images have their own auth logic (public flag) # handled inside canvas_bp — let them through here if path.startswith('/pages/') or path.startswith('/canvas-proxy') or path.startswith('/website-dev'): return # Admin-only surfaces are NEVER reachable with the internal agent key. # The agent key authorizes Docker-network service calls (canvas, # conversation, /api/session/reset, song-tagger, etc.), but a # prompt-injected agent must NOT be able to rewrite vault creds / # openclaw.json provider baseUrl / workspace files, or inject into the # live session via the admin RPC proxy. Without this carve-out the # agent-key `return` short-circuited BEFORE the admin gate below, # defeating the a957449 admin-authz lockdown. Same admin test as the # Clerk-path gate (single source of truth). _is_admin_path = ( path == '/admin' or path.startswith('/admin/') or path == '/src/admin.html' or any(path.startswith(p) for p in _ADMIN_ONLY_PREFIXES) ) # Internal agent API key — openclaw agents calling NON-admin Flask APIs # from inside the Docker network. if not _is_admin_path and _agent_api_key and request.headers.get('X-Agent-Key') == _agent_api_key: return from services.auth import get_token_from_request, verify_clerk_token token = get_token_from_request() user_id = verify_clerk_token(token) if token else None if not user_id: # For API calls return JSON 401; for page navigations redirect to / if path.startswith('/api/') or request.headers.get('X-Requested-With'): # Name what IS accepted (#477): an agent that got here spent hours # cycling gateway tokens / X-API-Key / X-Auth-Token, none of which # this gate reads. auth_required means "no accepted credential was # presented" — never an authorization state like a page lock # (those come back 403 from the route with their own code). return jsonify({ 'error': 'Unauthorized', 'code': 'auth_required', 'accepted_credentials': [ 'Authorization: Bearer ', '__session cookie (Clerk)', 'X-Agent-Key: (tenant agent, non-admin paths only)', ], }), 401 # HTML page request — redirect to root (login gate) return redirect('/') # Stash for downstream routes (e.g. conversation.py reads g.clerk_user_id # to inject a [CURRENT_USER: ...] tag into the gateway message context). g.clerk_user_id = user_id # Admin authorization — being an allowlisted voice user does NOT grant # access to the admin dashboard or privileged APIs. if _is_admin_path: from services.auth import is_admin_user if not is_admin_user(user_id): logger.warning('Admin authz denied: user_id=%s path=%s', user_id, path) if path.startswith('/api/'): return jsonify({'error': 'Admin access required', 'code': 'admin_required'}), 403 return redirect('/') # ── JSON error handler for 413 (file too large) ──────────────────────── @app.errorhandler(413) def handle_413(e): return jsonify({'error': 'File too large (100 MB max)'}), 413 # ── Security headers (P7-T3 security audit) ────────────────────────────── @app.after_request def add_security_headers(response): """Add defensive HTTP security headers to every response.""" response.headers.setdefault('X-Content-Type-Options', 'nosniff') response.headers.setdefault('X-Frame-Options', 'SAMEORIGIN') response.headers.setdefault('X-XSS-Protection', '1; mode=block') response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin') # Allow microphone, camera, and geolocation (site capture GPS tagging) response.headers.setdefault( 'Permissions-Policy', 'camera=(self), microphone=*, geolocation=(self), pointer-lock=*' ) # Hosts this deployment runs its own services on (CSP_TRUSTED_HOSTS, services/csp.py). # Empty on a stock install; never hard-code a deployment's domain here. from services import csp as _csp _trusted = _csp.sources() response.headers.setdefault( 'Content-Security-Policy', "default-src 'self'; " # cdn.tailwindcss.com is REQUIRED — canvas pages load Tailwind via the Play CDN; without it # in script-src the script is blocked and pages render as raw unstyled text. Do NOT re-strip # (recurring regression; memory feedback_canvas_tailwind_cdn). fonts.googleapis.com (style) + # fonts.gstatic.com (font) are needed for the Google-font s canvas pages use. # 'unsafe-eval' + 'wasm-unsafe-eval' are REQUIRED: cdn.tailwindcss.com is the Tailwind PLAY # CDN, which compiles CSS at runtime via eval()/new Function — without unsafe-eval the script # loads but generates NO styles → pages still render as raw text. (canvas.py CSP already had # these; the global one didn't — that was the real cause of the unstyled SEO dashboard.) # maps.googleapis.com + maps.gstatic.com are REQUIRED for the `maps` skill's # canvas pages (Google Maps JS API). Without them the JS is blocked and every # map page renders an empty box — silently, since the only signal is a console # CSP violation. Found 2026-07-24: the maps skill documented these as allowed # but they were never actually in either CSP. Do NOT re-strip. "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https://cdn.jsdelivr.net https://cdn.tailwindcss.com https://*.clerk.accounts.dev https://maps.googleapis.com https://maps.gstatic.com" + _trusted + "; " # jsdelivr serves stylesheets as well as scripts (Leaflet, etc). It was in # script-src but NOT style-src, so a canvas page could load a library's JS # and have its CSS silently blocked — which renders as "the widget is # missing and everything overlaps", not as an obvious error. Found 2026-07-27 # on tenant-f's Leaflet map page. "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " "font-src 'self' data: https://fonts.gstatic.com; " # Map tiles/markers come from maps.gstatic.com, *.googleapis.com (khms tile # shards) and *.ggpht.com (Street View); googleusercontent serves place photos. # (Map tiles: OpenStreetMap + CARTO for Leaflet pages. Until 2026-10-16 a stray ';' # cut these hosts out of img-src and the missing separator after them swallowed # media-src, so neither applied.) "img-src 'self' data: blob: https://img.clerk.com https://images.clerk.dev https://*.clerk.accounts.dev https://lh3.googleusercontent.com https://avatars.githubusercontent.com https://bhaleyart.github.io https://maps.googleapis.com https://maps.gstatic.com https://*.googleapis.com https://*.ggpht.com https://*.googleusercontent.com https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://*.basemaps.cartocdn.com; " "media-src 'self' blob:; " "connect-src 'self' wss: https:; " "frame-src 'self' https://*.clerk.accounts.dev https:" + _trusted + "; " "worker-src 'self' blob:" ) return response # ── CDN cache cleanup (MUST run after flask_cors) ────────────────────── # flask_cors adds Vary:Origin + Access-Control-* to ALL responses, which # causes Cloudflare to mark them cf-cache-status:DYNAMIC (uncacheable). # Canvas media files don't need CORS — strip those headers so CDN caches them. # Inserted at position 0 in after_request list so it runs LAST in LIFO order. def _strip_cdn_blocking_headers(response): _media_exts = ('.mp4', '.webm', '.mp3', '.wav', '.ogg', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.pdf') if request.path.startswith('/pages/') and any(request.path.endswith(e) for e in _media_exts): for h in ['Vary', 'Access-Control-Allow-Origin', 'Access-Control-Allow-Credentials', 'Content-Security-Policy', 'X-Frame-Options', 'Permissions-Policy', 'X-XSS-Protection', 'Referrer-Policy']: response.headers.pop(h, None) return response app.after_request_funcs.setdefault(None, []).insert(0, _strip_cdn_blocking_headers) return app, sock