app.py
Rama main · réplica de lectura del repositorio original.
"""
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/<token> 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 <Clerk session JWT>',
'__session cookie (Clerk)',
'X-Agent-Key: <AGENT_API_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 <link>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