from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware # Shared mutable state — updated by cloud_sync.py # Fields: # licensed bool — False only after 72h offline OR after expiry grace passes # locked bool — True once lock_pending is enforced at workday close # lock_pending bool — Cloud requested lock; waiting for workday to close # expires_at str|None — ISO timestamp from cloud # days_until_expiry int|None — negative when expired # grace_expires_at str|None — ISO timestamp of expiry + 5 days # last_sync str|None — ISO timestamp of last successful heartbeat # sync_failed bool # latest_version str|None license_state: dict = { "licensed": True, "locked": False, "lock_pending": False, "expires_at": None, "days_until_expiry": None, "grace_expires_at": None, "last_sync": None, "sync_failed": False, "latest_version": None, } # Paths that bypass all license checks (health probe) EXEMPT_PATHS = {"/api/system/health"} # Paths that are always allowed so the frontend can read license status # and managers can still log in / close the workday when restricted STATUS_ALLOWED_PATHS = { "/api/system/status", "/api/system/sync-license", "/api/auth/login", "/api/auth/me", "/api/business-day/current", "/api/business-day/close", } class LicenseCheckMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): path = request.url.path if path in EXEMPT_PATHS or path in STATUS_ALLOWED_PATHS: return await call_next(request) # Hard block: licensed=False means either 72h offline grace expired # OR expiry grace period (5 days) has passed. In both cases the # business_day router already prevented opening a new workday, so # existing operations can still complete — we only block new ones. # The business_day /open endpoint has its own detailed error message. if not license_state.get("licensed", True): return Response( content='{"detail":"license_expired","code":"LICENSE_EXPIRED"}', status_code=402, media_type="application/json", ) # Hard block: locked=True (lock_pending was enforced at workday close) if license_state.get("locked"): return Response( content='{"detail":"system_locked","code":"SYSTEM_LOCKED"}', status_code=423, media_type="application/json", ) return await call_next(request)