mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 17:57:43 +02:00
[eric] backend: PUT settings preserves server-owned fields, stale drafts wiped pro bearers
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user