diff --git a/backend/app/api/routes/iot_gateway.py b/backend/app/api/routes/iot_gateway.py index 33793844..3059468a 100644 --- a/backend/app/api/routes/iot_gateway.py +++ b/backend/app/api/routes/iot_gateway.py @@ -10,11 +10,13 @@ URL pattern: /api/gateway/{client_id}/{path} → http://127.0.0.1:{hostfwd_port}/{path} """ +import json import logging import httpx from fastapi import APIRouter, Request, Response +from app.core.hooks import iot_gateway_gate from app.services.esp32_lib_manager import esp_lib_manager router = APIRouter() @@ -27,6 +29,43 @@ logger = logging.getLogger(__name__) ) async def gateway_proxy(client_id: str, path: str, request: Request) -> Response: """Reverse-proxy an HTTP request to the ESP32's web server.""" + # Plan gate (overlay-supplied). OSS image has no gate → allow everyone. + # When the velxio-prod overlay is loaded, the gateway is a Maker+ feature; + # free / anonymous callers get a 402 with an upgrade pointer. + block_detail = await iot_gateway_gate(request) + if block_detail is not None: + # The frontend opens the gateway via window.open(_blank), so a raw + # JSON 402 would dump in a new tab. Content-negotiate: serve a tiny + # HTML upgrade page to browser navigations, JSON to programmatic + # (fetch/XHR) callers. + accepts_html = 'text/html' in (request.headers.get('accept') or '') + upgrade_url = block_detail.get('upgrade_url', '/pricing') + msg = block_detail.get('message', 'This is a paid feature.') + if accepts_html: + html = ( + '' + 'Velxio — upgrade required' + '' + '
' + '

IoT gateway is a Maker feature

' + f'

{msg} Upgrade to access live ESP32 web servers running in your ' + 'simulated circuit.

' + f'See plans' + '
' + ) + return Response(content=html, status_code=402, media_type='text/html') + return Response( + content=json.dumps({'error': 'pro_required', 'detail': block_detail}), + status_code=402, + media_type='application/json', + ) + inst = esp_lib_manager.get_instance(client_id) if not inst or not inst.wifi_enabled or inst.wifi_hostfwd_port == 0: return Response( diff --git a/backend/app/core/hooks.py b/backend/app/core/hooks.py index ea99e747..0034537f 100644 --- a/backend/app/core/hooks.py +++ b/backend/app/core/hooks.py @@ -127,3 +127,34 @@ async def run_lifespan_startup() -> None: await hook() except Exception: logger.exception("lifespan startup hook %r failed (swallowed)", hook) + + +# ── iot_gateway_gate ────────────────────────────────────────────────────────── +# Decides whether a given request may use the private IoT gateway proxy. +# OSS-default: allow everyone (the gateway is a free feature in the open +# image). A private overlay (velxio-prod) registers a real implementation +# that gates it to paid plans + grandfathered users. Returns None to allow, +# or a `detail` dict that the route turns into a 402 response when blocking. + +IotGatewayGateHook = Callable[[Request], Awaitable[Optional[dict]]] + +_iot_gateway_gate_hook: Optional[IotGatewayGateHook] = None + + +def register_iot_gateway_gate(hook: IotGatewayGateHook) -> None: + """Install the IoT-gateway gate. Called by overlays in register_pro.""" + global _iot_gateway_gate_hook + _iot_gateway_gate_hook = hook + + +async def iot_gateway_gate(request: Request) -> Optional[dict]: + """Return None to allow the gateway request, or a detail dict to block + it with 402. No-op (allow) when no overlay is loaded.""" + if _iot_gateway_gate_hook is None: + return None + try: + return await _iot_gateway_gate_hook(request) + except Exception: + # A failing gate must not take the gateway down — fail open. + logger.exception("iot_gateway_gate hook failed (allowing request)") + return None