From ed2aa322cab0c4054bfa99fe046db3c409a15725 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 9 Jun 2026 15:10:55 -0700 Subject: [PATCH] [eric] backend: PUT settings preserves server-owned fields, stale drafts wiped pro bearers --- backend/apps/auth/router.py | 13 ++ backend/apps/nine_router/__init__.py | 2 + backend/apps/nine_router/sync_custom.py | 17 +++ backend/apps/settings/settings.py | 36 +++--- backend/apps/subscription/router.py | 13 ++ backend/tests/test_settings_server_owned.py | 125 ++++++++++++++++++++ 6 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 backend/tests/test_settings_server_owned.py diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index 5fb72b50..dbc892e7 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -47,6 +47,17 @@ def _proxy_url() -> str: return url.rstrip("/") +async def _sync_pro_routing(settings_obj) -> None: + """Mirror connection state into 9Router's Claude lane; sign-in can flip a + paying user into pro mode and sign-out must tear the lane down so a + revoked bearer doesn't linger in the router.""" + try: + from backend.apps.nine_router import sync_pro_routing + await sync_pro_routing(settings_obj) + except Exception as e: + logger.debug("pro routing sync skipped: %s", e) + + def _sync_identity_to_service(settings_obj) -> None: """Push user_id + email + signin_method into the service-sync identify pipeline so every event from this user has the right Person properties.""" @@ -147,6 +158,7 @@ async def signin_activate(body: SigninActivateRequest): await save_settings_async(settings_obj) _sync_identity_to_service(settings_obj) + await _sync_pro_routing(settings_obj) return { "ok": True, @@ -234,4 +246,5 @@ async def signout(): settings_obj.openswarm_usage_cached = None await save_settings_async(settings_obj) _sync_identity_to_service(settings_obj) + await _sync_pro_routing(settings_obj) return {"ok": True} diff --git a/backend/apps/nine_router/__init__.py b/backend/apps/nine_router/__init__.py index a9801614..ce818d3d 100644 --- a/backend/apps/nine_router/__init__.py +++ b/backend/apps/nine_router/__init__.py @@ -46,6 +46,7 @@ from .sync_custom import ( normalize_openai_compat_base_url, sync_custom_providers, sync_openswarm_pro_as_claude, + sync_pro_routing, ) from .oauth import ( exchange_oauth, @@ -81,5 +82,6 @@ __all__ = [ "sync_openrouter_api_key", "sync_custom_providers", "sync_openswarm_pro_as_claude", + "sync_pro_routing", "normalize_openai_compat_base_url", ] diff --git a/backend/apps/nine_router/sync_custom.py b/backend/apps/nine_router/sync_custom.py index 8a1003bf..d09fa22a 100644 --- a/backend/apps/nine_router/sync_custom.py +++ b/backend/apps/nine_router/sync_custom.py @@ -323,3 +323,20 @@ async def sync_openswarm_pro_as_claude(bearer_token: str | None, proxy_url: str logger.info("9Router: removed OpenSwarm Pro → Claude connection") except Exception as e: logger.warning(f"9Router OpenSwarm-Pro Claude sync failed: {e}") + + +async def sync_pro_routing(settings_obj) -> None: + """Mirror the settings' pro-mode state into the 9Router Claude lane. + Call after any flow that changes connection_mode or the bearer + (activate, sign-in, sign-out, disconnect). Never raises.""" + try: + pro = getattr(settings_obj, "connection_mode", None) == "openswarm-pro" + bearer = getattr(settings_obj, "openswarm_bearer_token", None) + proxy = getattr(settings_obj, "openswarm_proxy_url", None) or "https://api.openswarm.com" + active = bool(pro and bearer) + await sync_openswarm_pro_as_claude( + bearer if active else None, + proxy if active else None, + ) + except Exception as e: + logger.warning(f"OpenSwarm-Pro → Claude sync failed: {e}") diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py index da747077..c3c38547 100644 --- a/backend/apps/settings/settings.py +++ b/backend/apps/settings/settings.py @@ -114,11 +114,31 @@ async def get_settings(): return load_settings().model_dump() +# Written only by their dedicated flows (Stripe activate, sign-in, signout, OAuth connects); +# a full-object PUT from a stale renderer snapshot must never revert or forge them. +SERVER_OWNED_FIELDS = ( + "connection_mode", + "openswarm_bearer_token", + "openswarm_proxy_url", + "openswarm_subscription_plan", + "openswarm_subscription_expires", + "openswarm_usage_cached", + "user_id", + "signin_method", + "installation_id", + "claude_subscription_token", + "openai_subscription_token", + "gemini_subscription_token", +) + + @settings.router.put("") async def update_settings(body: AppSettings): from backend.apps.service.client import sync as _sync old = load_settings() + for k in SERVER_OWNED_FIELDS: + setattr(body, k, getattr(old, k, None)) secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key", "claude_subscription_token", "openai_subscription_token", "gemini_subscription_token", @@ -220,22 +240,6 @@ async def update_settings(body: AppSettings): any_keyed_added, )) - # On pro-mode/bearer change, register a `claude` apikey connection in 9Router so CLI WebSearch works on non-Claude primaries. - pro_mode_old = getattr(old, "connection_mode", None) == "openswarm-pro" - pro_mode_new = getattr(body, "connection_mode", None) == "openswarm-pro" - bearer_old = getattr(old, "openswarm_bearer_token", None) - bearer_new = getattr(body, "openswarm_bearer_token", None) - if pro_mode_old != pro_mode_new or bearer_old != bearer_new: - try: - from backend.apps.nine_router import sync_openswarm_pro_as_claude - proxy_url = getattr(body, "openswarm_proxy_url", None) or "https://api.openswarm.com" - await sync_openswarm_pro_as_claude( - bearer_new if pro_mode_new else None, - proxy_url if pro_mode_new else None, - ) - except Exception as e: - logger.warning(f"OpenSwarm-Pro → Claude sync failed: {e}") - return {"ok": True, "settings": body.model_dump()} diff --git a/backend/apps/subscription/router.py b/backend/apps/subscription/router.py index 6aa153ab..8354caf2 100644 --- a/backend/apps/subscription/router.py +++ b/backend/apps/subscription/router.py @@ -36,6 +36,17 @@ def _proxy_url() -> str: return url.rstrip("/") +async def _sync_pro_routing(settings_obj) -> None: + """Mirror connection state into 9Router's Claude lane (WebSearch on + non-Claude primaries). PUT /api/settings no longer carries these fields, + so the state-change endpoints here are the only trigger left.""" + try: + from backend.apps.nine_router import sync_pro_routing + await sync_pro_routing(settings_obj) + except Exception as e: + logger.debug("pro routing sync skipped: %s", e) + + async def _clear_subscription(settings_obj, *, drop_bearer: bool = True) -> None: """Revert to own_key mode and drop OpenSwarm Pro routing state. @@ -57,6 +68,7 @@ async def _clear_subscription(settings_obj, *, drop_bearer: bool = True) -> None settings_obj.openswarm_usage_cached = None await save_settings_async(settings_obj) _sync_subscription_identity(settings_obj) + await _sync_pro_routing(settings_obj) def _sync_subscription_identity(settings_obj) -> None: @@ -155,6 +167,7 @@ async def activate(body: ActivateRequest): await save_settings_async(settings_obj) _sync_subscription_identity(settings_obj) + await _sync_pro_routing(settings_obj) return {"ok": True, "plan": settings_obj.openswarm_subscription_plan} diff --git a/backend/tests/test_settings_server_owned.py b/backend/tests/test_settings_server_owned.py new file mode 100644 index 00000000..3f11fdbf --- /dev/null +++ b/backend/tests/test_settings_server_owned.py @@ -0,0 +1,125 @@ +"""Server-owned settings fields must survive full-object PUTs from the renderer. + +Reproduces the production bug where a Settings save built from a pre-activation +snapshot (the renderer PUTs the ENTIRE AppSettings object) silently wiped +openswarm_bearer_token + connection_mode, disconnecting paying subscribers +minutes after a successful Stripe activation. The fix: subscription/identity +fields are written only by their dedicated endpoints (activate, signin-activate, +signout, disconnect); PUT /api/settings preserves whatever is on disk for them. +""" + +from __future__ import annotations + +import pytest +from unittest.mock import patch, AsyncMock +from fastapi.testclient import TestClient + +from backend.main import app + + +@pytest.fixture +def client(): + import backend.auth as auth_mod + if not auth_mod._TOKEN: + import secrets + auth_mod._TOKEN = secrets.token_urlsafe(32) + return TestClient(app, headers={"Authorization": f"Bearer {auth_mod._TOKEN}"}) + + +@pytest.fixture +def reset_settings(): + from backend.apps.settings.settings import load_settings, _save_settings + + original = load_settings().model_copy(deep=True) + yield + _save_settings(original) + + +def _activate_pro(client, token="repro-bearer-0123456789abcdef"): + """Drive the real /api/subscription/activate with a mocked cloud /api/me.""" + fake_me = AsyncMock() + fake_me.status_code = 200 + fake_me.json = lambda: { + "email": "payer@example.com", + "plan": "pro", + "status": "active", + "current_period_end": 4102444800000, + "usage": {"utilization": 0}, + } + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.get = AsyncMock(return_value=fake_me) + r = client.post("/api/subscription/activate", json={"token": token}) + assert r.status_code == 200, r.text + return token + + +def test_stale_settings_put_cannot_wipe_activation(client, reset_settings): + """The exact production sequence: snapshot settings, activate Pro, PUT the + stale snapshot back (renderer Save of a pre-activation draft). The bearer + and pro mode must survive; the user's editable change must still apply.""" + snapshot = client.get("/api/settings").json() + assert snapshot is not None + + token = _activate_pro(client) + + from backend.apps.settings.settings import load_settings + s = load_settings() + assert s.openswarm_bearer_token == token + assert s.connection_mode == "openswarm-pro" + assert s.openswarm_subscription_plan == "pro" + + stale = dict(snapshot) + stale["user_name"] = "Stale Draft Save" + r = client.put("/api/settings", json=stale) + assert r.status_code == 200 + + s = load_settings() + assert s.user_name == "Stale Draft Save" + assert s.openswarm_bearer_token == token, "stale PUT wiped the bearer" + assert s.connection_mode == "openswarm-pro", "stale PUT reverted connection_mode" + assert s.openswarm_subscription_plan == "pro" + assert s.openswarm_subscription_expires is not None + + body = r.json()["settings"] + assert body["openswarm_bearer_token"] == token + assert body["connection_mode"] == "openswarm-pro" + + +def test_put_cannot_inject_server_owned_fields(client, reset_settings): + """The inverse direction: a client PUT must not be able to SET subscription + state either (it would imply entitlement the cloud never granted).""" + snapshot = client.get("/api/settings").json() + forged = dict(snapshot) + forged["connection_mode"] = "openswarm-pro" + forged["openswarm_bearer_token"] = "forged-bearer-fedcba9876543210" + forged["openswarm_subscription_plan"] = "ultra" + forged["user_id"] = "u-forged" + + r = client.put("/api/settings", json=forged) + assert r.status_code == 200 + + from backend.apps.settings.settings import load_settings + s = load_settings() + assert s.openswarm_bearer_token == snapshot.get("openswarm_bearer_token") + assert s.connection_mode == snapshot.get("connection_mode") + assert s.openswarm_subscription_plan == snapshot.get("openswarm_subscription_plan") + assert s.user_id == snapshot.get("user_id") + + +def test_dedicated_endpoints_still_mutate(client, reset_settings): + """Freezing PUT must not freeze the real owners: disconnect still reverts + routing, and a fresh activate still re-connects afterwards.""" + _activate_pro(client) + + r = client.post("/api/subscription/disconnect") + assert r.status_code == 200 + from backend.apps.settings.settings import load_settings + s = load_settings() + assert s.connection_mode == "own_key" + assert s.openswarm_bearer_token is not None # disconnect keeps sign-in + + _activate_pro(client, token="second-bearer-aaaabbbbccccdddd") + s = load_settings() + assert s.connection_mode == "openswarm-pro" + assert s.openswarm_bearer_token == "second-bearer-aaaabbbbccccdddd"