From 349f49824b5d197e8fc48b5ee77a61776c00d642 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 8 Apr 2026 20:50:46 -0700 Subject: [PATCH] feat: add GET /api/settings/sync federation endpoint --- muxplex/main.py | 39 +++++++++++++++++------ muxplex/tests/test_api.py | 66 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/muxplex/main.py b/muxplex/main.py index e32ea9b..6b85f9b 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -64,7 +64,12 @@ from muxplex.state import ( save_state, state_lock, ) -from muxplex.settings import load_federation_key, load_settings, patch_settings +from muxplex.settings import ( + get_syncable_settings, + load_federation_key, + load_settings, + patch_settings, +) from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT # --------------------------------------------------------------------------- @@ -140,9 +145,8 @@ async def _run_poll_cycle() -> None: if active_remote_id is not None: settings = load_settings() remote_instances = settings.get("remote_instances", []) - if ( - isinstance(active_remote_id, int) - and 0 <= active_remote_id < len(remote_instances) + if isinstance(active_remote_id, int) and 0 <= active_remote_id < len( + remote_instances ): remote = remote_instances[active_remote_id] remote_url: str = remote.get("url", "").rstrip("/") @@ -157,13 +161,13 @@ async def _run_poll_cycle() -> None: and view_mode == "fullscreen" and (now - last_interaction_at) < 60 ): - bell_clear_url = ( - f"{remote_url}/api/sessions/{viewing_session}/bell/clear" - ) + bell_clear_url = f"{remote_url}/api/sessions/{viewing_session}/bell/clear" try: await _federation_client.post( bell_clear_url, - headers={"Authorization": f"Bearer {remote_key}"} if remote_key else {}, + headers={"Authorization": f"Bearer {remote_key}"} + if remote_key + else {}, ) except Exception as exc: _log.warning( @@ -720,6 +724,21 @@ async def update_settings(request: Request) -> dict: return result +@app.get("/api/settings/sync") +async def get_settings_sync() -> dict: + """Return syncable settings + timestamp for federation sync. + + Authenticated via federation Bearer token (same auth middleware as all other + non-exempt endpoints). Returns only the keys in SYNCABLE_KEYS plus the + settings_updated_at timestamp; infrastructure keys (host, port, federation_key, + etc.) are never included. + """ + syncable = get_syncable_settings() + ts = syncable.get("settings_updated_at", 0.0) + settings = {k: v for k, v in syncable.items() if k != "settings_updated_at"} + return {"settings": settings, "settings_updated_at": ts} + + @app.get("/api/instance-info") async def instance_info() -> dict: """Return this instance's display name and version. @@ -916,7 +935,9 @@ async def federation_terminal_ws_proxy(websocket: WebSocket, remote_id: int) -> async with websockets.connect( ws_url, subprotocols=[Subprotocol("tty")], - additional_headers={"Authorization": f"Bearer {remote_key}"} if remote_key else {}, + additional_headers={"Authorization": f"Bearer {remote_key}"} + if remote_key + else {}, ssl=ssl_context, ) as remote_ws: diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index 724d9d8..bb95258 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -2802,6 +2802,72 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session( ) +# --------------------------------------------------------------------------- +# GET /api/settings/sync (task-7) +# --------------------------------------------------------------------------- + + +def test_settings_sync_returns_200(client, tmp_path, monkeypatch): + """GET /api/settings/sync returns HTTP 200.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + response = client.get("/api/settings/sync") + assert response.status_code == 200 + + +def test_settings_sync_response_has_settings_and_timestamp( + client, tmp_path, monkeypatch +): + """GET /api/settings/sync returns {settings: dict, settings_updated_at: float}.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + response = client.get("/api/settings/sync") + assert response.status_code == 200 + data = response.json() + assert "settings" in data, ( + f"Response must have 'settings' key, got: {list(data.keys())}" + ) + assert "settings_updated_at" in data, ( + f"Response must have 'settings_updated_at' key, got: {list(data.keys())}" + ) + assert isinstance(data["settings"], dict), ( + f"'settings' must be a dict, got: {type(data['settings'])}" + ) + assert isinstance(data["settings_updated_at"], float), ( + f"'settings_updated_at' must be a float, got: {type(data['settings_updated_at'])}" + ) + + +def test_settings_sync_excludes_infrastructure_keys(client, tmp_path, monkeypatch): + """GET /api/settings/sync settings dict must not contain infrastructure keys.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + response = client.get("/api/settings/sync") + assert response.status_code == 200 + settings = response.json()["settings"] + infra_keys = ( + "host", + "port", + "auth", + "session_ttl", + "tls_cert", + "tls_key", + "device_name", + "federation_key", + "remote_instances", + "multi_device_enabled", + "new_session_template", + "delete_session_template", + ) + for key in infra_keys: + assert key not in settings, ( + f"Infrastructure key '{key}' must not appear in /api/settings/sync response" + ) + + def test_federation_auth_headers_guard_empty_key(): """Every federation Authorization header construction must guard against empty key.