[eric] backend: PUT settings preserves server-owned fields, stale drafts wiped pro bearers

This commit is contained in:
ciregenz
2026-06-09 15:10:55 -07:00
parent d0b23ea616
commit ed2aa322ca
6 changed files with 190 additions and 16 deletions
+13
View File
@@ -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}
+2
View File
@@ -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",
]
+17
View File
@@ -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}")
+20 -16
View File
@@ -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()}
+13
View File
@@ -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}
+125
View File
@@ -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"