feat(hooks): iot_gateway_gate extension point + content-negotiated 402
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 <noreply@anthropic.com>
This commit is contained in:
parent
44f12f0e53
commit
594291c830
|
|
@ -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 = (
|
||||
'<!doctype html><html><head><meta charset="utf-8">'
|
||||
'<title>Velxio — upgrade required</title>'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
'<style>body{background:#1e1e1e;color:#ddd;font-family:-apple-system,'
|
||||
'BlinkMacSystemFont,sans-serif;display:flex;min-height:100vh;margin:0;'
|
||||
'align-items:center;justify-content:center;text-align:center}'
|
||||
'.box{max-width:440px;padding:32px}h1{font-size:20px;color:#fff}'
|
||||
'p{color:#aaa;line-height:1.6}a{display:inline-block;margin-top:16px;'
|
||||
'background:#2563eb;color:#fff;padding:10px 20px;border-radius:6px;'
|
||||
'text-decoration:none;font-weight:600}</style></head><body><div class="box">'
|
||||
'<h1>IoT gateway is a Maker feature</h1>'
|
||||
f'<p>{msg} Upgrade to access live ESP32 web servers running in your '
|
||||
'simulated circuit.</p>'
|
||||
f'<a href="https://velxio.dev{upgrade_url}">See plans</a>'
|
||||
'</div></body></html>'
|
||||
)
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue