From 594291c830329cb3a1bcfb30788b6d433098bf59 Mon Sep 17 00:00:00 2001 From: David Montero Date: Fri, 29 May 2026 19:44:30 +0200 Subject: [PATCH] feat(hooks): iot_gateway_gate extension point + content-negotiated 402 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a generic gating hook so a private overlay can restrict the IoT gateway proxy to paid plans without the OSS image carrying any plan logic. register_iot_gateway_gate() installs an async callback that returns None to allow or a detail dict to block; the OSS default (no overlay) allows everyone, and a failing gate fails OPEN so the gateway can never be taken down by a buggy overlay. gateway_proxy() calls the gate first. When blocked it content- negotiates the 402: browsers (Accept: text/html — the frontend opens the gateway via window.open) get a small styled upgrade page with a link to /pricing; programmatic fetch/XHR callers get the JSON detail. No behaviour change for the open-source image — the gate is a no-op there. Co-Authored-By: Claude Opus 4.7 --- backend/app/api/routes/iot_gateway.py | 39 +++++++++++++++++++++++++++ backend/app/core/hooks.py | 31 +++++++++++++++++++++ 2 files changed, 70 insertions(+) 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