import os from flask import request from flask_cors import CORS from flask_limiter import Limiter from flask_limiter.util import get_remote_address # RATELIMIT_STORAGE_URI lets tests point the limiter at an in-process # memory:// backend instead of the production Redis instance — Flask- # Limiter reads its storage_uri/enabled flags once at init_app() time, # so this must be set via env var before app.py imports this module, # not by mutating app.config afterwards (see tests/conftest.py). limiter = Limiter( key_func=get_remote_address, storage_uri=os.getenv('RATELIMIT_STORAGE_URI', os.getenv('REDIS_URL', 'redis://bettersight-redis:6379')), default_limits=['200 per hour', '30 per minute'], ) @limiter.request_filter def _exempt_cors_preflight(): """ Browser-generated CORS preflight (OPTIONS) requests must never count against rate limits — discovered when the dashboard's 3s status-poll loop (analysis_store.js) tripped a 429 on the OPTIONS preflight within under a minute: one real GET every 3s is only 20/min, well under the 30/min default, but each cross-origin fetch also fires an OPTIONS preflight first, doubling the counted requests to 40/min. The browser reports a failed preflight as an opaque network error with no response body, which is why the frontend showed the generic "Lost connection" fallback rather than a 429 with a real message. """ return request.method == 'OPTIONS' # CORS was absent from CLAUDE.md entirely, but the Vue dashboard # (api_service.js) calls the Flask API directly from the browser via # Axios — that's a cross-origin request the moment the dashboard and # API are served from different hosts/ports. Confirmed true in local # dev (Vite on :5173, Flask on :5050) via VITE_API_BASE_URL; CLAUDE.md # doesn't pin down the production hostnames, but Pangolin fronting a # separate flask-api container makes a same-origin setup unlikely # there either. Without this, every browser-originated API call is # silently blocked by the browser's CORS preflight check regardless of # X-API-Key being correct. # # ALLOWED_ORIGINS is a comma-separated env var so each environment # (dev/staging/production) lists only its own dashboard origin(s) — # CORS is deliberately not wildcarded ("*") since credentials-bearing # requests (the PocketBase Bearer JWT) require an explicit origin. ALLOWED_ORIGINS = [ origin.strip() for origin in os.getenv('ALLOWED_ORIGINS', 'http://localhost:5173,https://app.bettersight.io').split(',') if origin.strip() ] cors = CORS( resources={r'/*': {'origins': ALLOWED_ORIGINS}}, supports_credentials=True, allow_headers=['Content-Type', 'Authorization', 'X-API-Key', 'X-User-Email'], )