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