diff --git a/docs/plans/2026-04-01-federation-proxy-rewrite.md b/docs/plans/2026-04-01-federation-proxy-rewrite.md new file mode 100644 index 0000000..0ed5a42 --- /dev/null +++ b/docs/plans/2026-04-01-federation-proxy-rewrite.md @@ -0,0 +1,2057 @@ +# Federation Proxy Rewrite Implementation Plan + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Replace browser-direct cross-origin federation with server-to-server proxy so the browser only talks to its local muxplex instance. + +**Architecture:** The local muxplex server fetches sessions from remote instances on behalf of the browser and proxies WebSocket terminal connections. The browser never makes cross-origin requests — it hits same-origin `/api/federation/*` endpoints. Each remote instance authenticates the local server via a shared federation key (Bearer token, `hmac.compare_digest`), stored in a separate file (`~/.config/muxplex/federation_key`). + +**Tech Stack:** Python 3.11+, FastAPI, httpx (async HTTP client), websockets, Node.js test runner, vanilla JS frontend. + +**Supersedes:** The browser-direct approach in `docs/plans/2026-03-30-multi-device-federation-design.md`. This plan removes CORS, X-Muxplex-Token, popup auth, and multi-source frontend code. + +--- + +## Phase 1: Backend Proxy Endpoints (Tasks 1–15) + +--- + +### Task 1: Add `federation_key` to DEFAULT_SETTINGS + +**Files:** +- Modify: `muxplex/settings.py` +- Test: `muxplex/tests/test_settings.py` + +**Step 1: Write the failing test** + +Add to the bottom of `muxplex/tests/test_settings.py`: + +```python +# ============================================================ +# Federation key in DEFAULT_SETTINGS +# ============================================================ + + +def test_defaults_include_federation_key(): + """DEFAULT_SETTINGS must include 'federation_key' initialised to empty string.""" + assert "federation_key" in DEFAULT_SETTINGS + assert DEFAULT_SETTINGS["federation_key"] == "" +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_settings.py::test_defaults_include_federation_key -x --timeout=30 +``` +Expected: FAIL — `"federation_key" not in DEFAULT_SETTINGS` + +**Step 3: Write minimal implementation** + +In `muxplex/settings.py`, add `"federation_key": ""` to `DEFAULT_SETTINGS`, after `"multi_device_enabled"`: + +```python + "multi_device_enabled": False, + "federation_key": "", +``` + +**Step 4: Run test to verify it passes** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_settings.py::test_defaults_include_federation_key -x --timeout=30 +``` +Expected: PASS + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add federation_key to DEFAULT_SETTINGS" +``` + +--- + +### Task 2: Add `load_federation_key()` function + +**Files:** +- Modify: `muxplex/settings.py` +- Test: `muxplex/tests/test_settings.py` + +Reads federation key from `~/.config/muxplex/federation_key` file (mode 0600). Path configurable via `MUXPLEX_FEDERATION_KEY_FILE` env var. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_settings.py`: + +```python +# ============================================================ +# Federation key file management +# ============================================================ + + +def test_load_federation_key_returns_empty_when_no_file(tmp_path, monkeypatch): + """load_federation_key() returns empty string when key file does not exist.""" + from muxplex.settings import load_federation_key + + monkeypatch.setenv("MUXPLEX_FEDERATION_KEY_FILE", str(tmp_path / "nonexistent")) + assert load_federation_key() == "" + + +def test_load_federation_key_reads_existing_file(tmp_path, monkeypatch): + """load_federation_key() reads and strips the key file contents.""" + from muxplex.settings import load_federation_key + + key_file = tmp_path / "federation_key" + key_file.write_text("my-secret-key\n") + monkeypatch.setenv("MUXPLEX_FEDERATION_KEY_FILE", str(key_file)) + assert load_federation_key() == "my-secret-key" + + +def test_load_federation_key_uses_default_path(tmp_path, monkeypatch): + """load_federation_key() uses ~/.config/muxplex/federation_key when env var is not set.""" + from muxplex.settings import load_federation_key, FEDERATION_KEY_PATH + from pathlib import Path + + monkeypatch.delenv("MUXPLEX_FEDERATION_KEY_FILE", raising=False) + monkeypatch.setattr("muxplex.settings.FEDERATION_KEY_PATH", tmp_path / "federation_key") + key_file = tmp_path / "federation_key" + key_file.write_text("default-path-key\n") + assert load_federation_key() == "default-path-key" +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_settings.py::test_load_federation_key_returns_empty_when_no_file muxplex/tests/test_settings.py::test_load_federation_key_reads_existing_file muxplex/tests/test_settings.py::test_load_federation_key_uses_default_path -x --timeout=30 +``` +Expected: FAIL — `ImportError: cannot import name 'load_federation_key'` + +**Step 3: Write minimal implementation** + +Add to `muxplex/settings.py`, after the existing imports: + +```python +import os +``` + +Then add after `SETTINGS_PATH`: + +```python +FEDERATION_KEY_PATH = Path.home() / ".config" / "muxplex" / "federation_key" +``` + +Then add after the `patch_settings` function: + +```python +def load_federation_key() -> str: + """Load the federation key from its dedicated file. + + Returns empty string if the file does not exist. + Path can be overridden via MUXPLEX_FEDERATION_KEY_FILE env var. + """ + env_path = os.environ.get("MUXPLEX_FEDERATION_KEY_FILE") + path = Path(env_path) if env_path else FEDERATION_KEY_PATH + try: + return path.read_text().strip() + except FileNotFoundError: + return "" +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_settings.py -k "load_federation_key" -x --timeout=30 +``` +Expected: PASS (all 3 tests) + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add load_federation_key() with env var override" +``` + +--- + +### Task 3: Add `generate-federation-key` CLI command + +**Files:** +- Modify: `muxplex/cli.py` +- Test: `muxplex/tests/test_cli.py` + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_cli.py` (follow the existing pattern — look for `test_reset_secret_*` or `test_show_password_*` as reference): + +```python +# ============================================================ +# generate-federation-key +# ============================================================ + + +def test_generate_federation_key_creates_file(tmp_path, monkeypatch, capsys): + """'muxplex generate-federation-key' creates the key file with mode 0600.""" + import stat + import muxplex.settings as settings_mod + + key_file = tmp_path / "federation_key" + monkeypatch.setattr(settings_mod, "FEDERATION_KEY_PATH", key_file) + monkeypatch.delenv("MUXPLEX_FEDERATION_KEY_FILE", raising=False) + + from muxplex.cli import generate_federation_key + generate_federation_key() + + assert key_file.exists() + content = key_file.read_text().strip() + assert len(content) > 20 # secrets.token_urlsafe(32) produces ~43 chars + mode = stat.S_IMODE(key_file.stat().st_mode) + assert mode == 0o600 + + captured = capsys.readouterr() + assert "federation_key" in captured.out.lower() or content in captured.out +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_cli.py::test_generate_federation_key_creates_file -x --timeout=30 +``` +Expected: FAIL — `ImportError: cannot import name 'generate_federation_key'` + +**Step 3: Write minimal implementation** + +In `muxplex/cli.py`, add the function: + +```python +def generate_federation_key() -> None: + """Generate a random federation key and write it to the key file.""" + import muxplex.settings as settings_mod + + path = settings_mod.FEDERATION_KEY_PATH + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + key = _secrets.token_urlsafe(32) + path.write_text(key + "\n") + path.chmod(0o600) + print(f"Federation key written to {path}") + print(f"Key: {key}") +``` + +Then wire it into `main()` — in the subparsers section, add: + +```python + sub.add_parser("generate-federation-key", help="Generate a federation key for server-to-server auth") +``` + +And in the command dispatch section (after `elif args.command == "reset-secret":`): + +```python + elif args.command == "generate-federation-key": + generate_federation_key() +``` + +**Step 4: Run test to verify it passes** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_cli.py::test_generate_federation_key_creates_file -x --timeout=30 +``` +Expected: PASS + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: add 'muxplex generate-federation-key' CLI command" +``` + +--- + +### Task 4: Add Bearer token auth to AuthMiddleware + +**Files:** +- Modify: `muxplex/auth.py` +- Test: `muxplex/tests/test_auth.py` + +Add Bearer token check between cookie check and Basic auth check in `AuthMiddleware.dispatch()`. Uses `hmac.compare_digest()` for timing-safe comparison. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_auth.py`: + +```python +# --------------------------------------------------------------------------- +# Bearer token auth (server-to-server federation) +# --------------------------------------------------------------------------- + + +def test_middleware_valid_bearer_token_passes(): + """Non-localhost request with valid Bearer token passes through.""" + test_app = FastAPI() + test_app.add_middleware( + AuthMiddleware, + auth_mode="password", + secret="test-secret", + ttl_seconds=3600, + password="test-pw", + federation_key="my-federation-key", + ) + + @test_app.get("/protected") + async def protected(): + return PlainTextResponse("OK") + + client = TestClient(test_app, base_url="http://192.168.1.1") + response = client.get("/protected", headers={"Authorization": "Bearer my-federation-key"}) + assert response.status_code == 200 + assert response.text == "OK" + + +def test_middleware_invalid_bearer_token_falls_through(): + """Non-localhost request with wrong Bearer token falls through to redirect/401.""" + test_app = FastAPI() + test_app.add_middleware( + AuthMiddleware, + auth_mode="password", + secret="test-secret", + ttl_seconds=3600, + password="test-pw", + federation_key="correct-key", + ) + + @test_app.get("/protected") + async def protected(): + return PlainTextResponse("OK") + + client = TestClient(test_app, base_url="http://192.168.1.1", follow_redirects=False) + response = client.get("/protected", headers={ + "Authorization": "Bearer wrong-key", + "Accept": "application/json", + }) + assert response.status_code == 401 + + +def test_middleware_bearer_skipped_when_no_federation_key(): + """When federation_key is empty, Bearer check is skipped entirely.""" + test_app = FastAPI() + test_app.add_middleware( + AuthMiddleware, + auth_mode="password", + secret="test-secret", + ttl_seconds=3600, + password="test-pw", + federation_key="", # empty = disabled + ) + + @test_app.get("/protected") + async def protected(): + return PlainTextResponse("OK") + + client = TestClient(test_app, base_url="http://192.168.1.1", follow_redirects=False) + # Bearer with any value should NOT pass when federation_key is empty + response = client.get("/protected", headers={ + "Authorization": "Bearer anything", + "Accept": "application/json", + }) + assert response.status_code == 401 +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_auth.py::test_middleware_valid_bearer_token_passes muxplex/tests/test_auth.py::test_middleware_invalid_bearer_token_falls_through muxplex/tests/test_auth.py::test_middleware_bearer_skipped_when_no_federation_key -x --timeout=30 +``` +Expected: FAIL — `TypeError: AuthMiddleware.__init__() got an unexpected keyword argument 'federation_key'` + +**Step 3: Write minimal implementation** + +In `muxplex/auth.py`, add `import hmac` to the imports and `import logging` if not already present. + +Modify `AuthMiddleware.__init__`: + +```python + def __init__( + self, + app, + auth_mode: str, + secret: str, + ttl_seconds: int, + password: str = "", + federation_key: str = "", + ): + super().__init__(app) + self.auth_mode = auth_mode + self.secret = secret + self.ttl_seconds = ttl_seconds + self.password = password + self.federation_key = federation_key +``` + +In `dispatch()`, add between the cookie check (step 4) and the X-Muxplex-Token check (step 4b): + +```python + # 4a. Bearer token (server-to-server federation) + auth_header = request.headers.get("authorization", "") + if self.federation_key and auth_header.lower().startswith("bearer "): + token = auth_header[7:] # strip "Bearer " prefix + if hmac.compare_digest(token, self.federation_key): + return await call_next(request) + _log.warning("federation: rejected Bearer from %s", client_host) +``` + +Add `_log = logging.getLogger(__name__)` near the top of the auth module (after imports) if not already present. + +Note: The existing step 5 (`Authorization: Basic`) also reads `auth_header`, so after adding the Bearer check, rename the existing `auth_header` variable in the Basic block, OR just reuse it since it's already been read. The simplest approach: move the `auth_header` read up to before step 4a, and use it in both 4a and 5. + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_auth.py -k "bearer" -x --timeout=30 +``` +Expected: PASS (all 3 tests) + +Then run ALL auth tests to verify no regressions: +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_auth.py -x --timeout=30 +``` +Expected: PASS (all tests) + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat: add Bearer token auth for federation in AuthMiddleware" +``` + +--- + +### Task 5: Wire federation key into app startup + +**Files:** +- Modify: `muxplex/main.py` +- Test: `muxplex/tests/test_api.py` + +Load federation key at startup and pass it to `AuthMiddleware`. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_api.py`: + +```python +# --------------------------------------------------------------------------- +# Federation key auth integration +# --------------------------------------------------------------------------- + + +def test_federation_bearer_auth_accepted(client, tmp_path, monkeypatch): + """A request with valid Bearer federation key is accepted.""" + import muxplex.settings as settings_mod + import muxplex.main as main_module + + # Set up a federation key + key_file = tmp_path / "federation_key" + key_file.write_text("test-fed-key\n") + monkeypatch.setattr(settings_mod, "FEDERATION_KEY_PATH", key_file) + monkeypatch.delenv("MUXPLEX_FEDERATION_KEY_FILE", raising=False) + + # The key is loaded at module level, so we need to patch the middleware's stored key + for mw in main_module.app.user_middleware: + if hasattr(mw, 'kwargs') and 'federation_key' in mw.kwargs: + monkeypatch.setitem(mw.kwargs, 'federation_key', 'test-fed-key') + + response = client.get("/api/sessions", headers={"Authorization": "Bearer test-fed-key"}) + assert response.status_code == 200 +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_bearer_auth_accepted -x --timeout=30 +``` +Expected: FAIL (middleware doesn't have `federation_key` yet) + +**Step 3: Write minimal implementation** + +In `muxplex/main.py`, add after `from muxplex.settings import load_settings, patch_settings`: + +```python +from muxplex.settings import load_federation_key +``` + +Add after `_auth_ttl = ...`: + +```python +_federation_key = load_federation_key() +``` + +Modify the `app.add_middleware(AuthMiddleware, ...)` call to include: + +```python +app.add_middleware( + AuthMiddleware, + auth_mode=_auth_mode, + secret=_auth_secret, + ttl_seconds=_auth_ttl, + password=_auth_password, + federation_key=_federation_key, +) +``` + +**Step 4: Run test to verify it passes** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_bearer_auth_accepted -x --timeout=30 +``` +Expected: PASS + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: wire federation key into AuthMiddleware at startup" +``` + +--- + +### Task 6: Add `key` field to remote_instances config + +**Files:** +- Test: `muxplex/tests/test_settings.py` + +This is a documentation/test task. The `remote_instances` list already stores arbitrary dicts. We just need to test that a `key` field round-trips correctly. + +**Step 1: Write the test** + +Add to `muxplex/tests/test_settings.py`: + +```python +def test_remote_instances_with_key_round_trip(tmp_path, monkeypatch): + """remote_instances with key field survive a save/load cycle unchanged.""" + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + + instances = [ + {"url": "http://host1:8088", "name": "Host 1", "key": "secret-key-1"}, + {"url": "http://host2:8088", "name": "Host 2", "key": "secret-key-2"}, + ] + save_settings({"remote_instances": instances}) + result = load_settings() + assert result["remote_instances"] == instances +``` + +**Step 2: Run test** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_settings.py::test_remote_instances_with_key_round_trip -x --timeout=30 +``` +Expected: PASS (dicts are stored as-is) + +**Step 3: Commit** +```bash +cd muxplex && git add muxplex/tests/test_settings.py && git commit -m "test: verify remote_instances key field round-trips" +``` + +--- + +### Task 7: Create `httpx.AsyncClient` in lifespan + +**Files:** +- Modify: `muxplex/main.py` +- Test: `muxplex/tests/test_api.py` + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_api.py`: + +```python +def test_federation_client_exists_on_app_state(client): + """app.state.federation_client must be set during lifespan.""" + from muxplex.main import app + assert hasattr(app.state, "federation_client") + assert app.state.federation_client is not None +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_client_exists_on_app_state -x --timeout=30 +``` +Expected: FAIL — `AttributeError: 'State' object has no attribute 'federation_client'` + +**Step 3: Write minimal implementation** + +Add `import httpx` to the imports in `muxplex/main.py`. + +In the `lifespan()` function, add before `yield`: + +```python + # Federation HTTP client for server-to-server proxy + app.state.federation_client = httpx.AsyncClient( + timeout=5.0, follow_redirects=False + ) +``` + +Add after `yield`, before the poll task cleanup: + +```python + # Shutdown: close federation client + await app.state.federation_client.aclose() +``` + +**Step 4: Run test to verify it passes** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_client_exists_on_app_state -x --timeout=30 +``` +Expected: PASS + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: create httpx.AsyncClient in lifespan for federation" +``` + +--- + +### Task 8: Add `GET /api/federation/sessions` endpoint + +**Files:** +- Modify: `muxplex/main.py` +- Test: `muxplex/tests/test_api.py` + +This is the core proxy endpoint. Fetches `GET /api/sessions` from each remote, merges with local sessions, tags each with `deviceName` and `remoteId`. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_api.py`: + +```python +# --------------------------------------------------------------------------- +# GET /api/federation/sessions +# --------------------------------------------------------------------------- + + +def test_federation_sessions_returns_local_sessions(client, monkeypatch, tmp_path): + """GET /api/federation/sessions includes local sessions tagged with deviceName.""" + import socket + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + monkeypatch.setattr(socket, "gethostname", lambda: "my-laptop") + monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"]) + monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"alpha": "snap"}) + + response = client.get("/api/federation/sessions") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + local = [s for s in data if s.get("deviceName") == "my-laptop"] + assert len(local) == 1 + assert local[0]["name"] == "alpha" + assert local[0]["remoteId"] is None # local sessions have no remoteId + + +def test_federation_sessions_includes_remote_failure_status(client, monkeypatch, tmp_path): + """GET /api/federation/sessions includes status entry for unreachable remote.""" + import json + import muxplex.settings as settings_mod + import httpx + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text(json.dumps({ + "multi_device_enabled": True, + "remote_instances": [ + {"url": "http://unreachable:8088", "name": "Ghost", "key": "k"} + ], + })) + monkeypatch.setattr("muxplex.main.get_session_list", lambda: []) + monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {}) + + # Mock the federation client to raise a connect error + from unittest.mock import AsyncMock + mock_client = AsyncMock() + mock_client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) + from muxplex.main import app + monkeypatch.setattr(app.state, "federation_client", mock_client) + + response = client.get("/api/federation/sessions") + assert response.status_code == 200 + data = response.json() + statuses = [s for s in data if "status" in s] + assert len(statuses) == 1 + assert statuses[0]["deviceName"] == "Ghost" + assert statuses[0]["status"] in ("unreachable", "auth_failed") +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_sessions_returns_local_sessions muxplex/tests/test_api.py::test_federation_sessions_includes_remote_failure_status -x --timeout=30 +``` +Expected: FAIL — 404 (route doesn't exist) + +**Step 3: Write minimal implementation** + +Add to `muxplex/main.py`, before the WebSocket proxy section: + +```python +# --------------------------------------------------------------------------- +# Federation proxy endpoints +# --------------------------------------------------------------------------- + + +@app.get("/api/federation/sessions") +async def federation_sessions() -> list[dict]: + """Aggregate sessions from local instance and all configured remotes. + + Each session is tagged with deviceName and remoteId. + Remote failures are included as status entries. + """ + settings = load_settings() + device_name = settings["device_name"] + + # Local sessions + names = get_session_list() + snapshots = get_snapshots() + state = await read_state() + local_sessions = [] + for name in names: + session_state = state.get("sessions", {}).get(name, {}) + bell = session_state.get("bell", empty_bell()) + local_sessions.append({ + "name": name, + "snapshot": snapshots.get(name, ""), + "bell": bell, + "deviceName": device_name, + "remoteId": None, + }) + + # Remote sessions + remotes = settings.get("remote_instances", []) + if not settings.get("multi_device_enabled") and not remotes: + return local_sessions + + http = app.state.federation_client + + async def fetch_remote(idx: int, remote: dict) -> list[dict]: + url = remote.get("url", "").rstrip("/") + name = remote.get("name", url) + key = remote.get("key", "") + remote_id = str(idx) + headers = {} + if key: + headers["Authorization"] = f"Bearer {key}" + headers["Accept"] = "application/json" + try: + resp = await http.get(f"{url}/api/sessions", headers=headers) + if resp.status_code == 401 or resp.status_code == 403: + return [{"deviceName": name, "remoteId": remote_id, "status": "auth_failed", "lastError": f"HTTP {resp.status_code}"}] + resp.raise_for_status() + sessions = resp.json() + return [ + {**s, "deviceName": name, "remoteId": remote_id} + for s in sessions + ] + except Exception as exc: + return [{"deviceName": name, "remoteId": remote_id, "status": "unreachable", "lastError": str(exc)}] + + tasks = [fetch_remote(i, r) for i, r in enumerate(remotes)] + results = await asyncio.gather(*tasks, return_exceptions=True) + + all_sessions = list(local_sessions) + for result in results: + if isinstance(result, Exception): + continue + all_sessions.extend(result) + + return all_sessions +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_sessions_returns_local_sessions muxplex/tests/test_api.py::test_federation_sessions_includes_remote_failure_status -x --timeout=30 +``` +Expected: PASS + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add GET /api/federation/sessions proxy endpoint" +``` + +--- + +### Task 9: Enhance `GET /api/instance-info` with `federation_enabled` + +**Files:** +- Modify: `muxplex/main.py` +- Test: `muxplex/tests/test_api.py` + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_api.py`: + +```python +def test_instance_info_includes_federation_enabled(client, tmp_path, monkeypatch): + """GET /api/instance-info includes federation_enabled boolean.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + # No federation key file = federation disabled + monkeypatch.setattr(settings_mod, "FEDERATION_KEY_PATH", tmp_path / "nonexistent") + monkeypatch.delenv("MUXPLEX_FEDERATION_KEY_FILE", raising=False) + + response = client.get("/api/instance-info") + assert response.status_code == 200 + data = response.json() + assert "federation_enabled" in data + assert data["federation_enabled"] is False +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_instance_info_includes_federation_enabled -x --timeout=30 +``` +Expected: FAIL — `"federation_enabled" not in data` + +**Step 3: Write minimal implementation** + +Modify the `instance_info()` route in `muxplex/main.py`: + +```python +@app.get("/api/instance-info") +async def instance_info() -> dict: + """Return this instance's display name, version, and federation status.""" + settings = load_settings() + fed_key = load_federation_key() + return { + "name": settings["device_name"], + "version": app.version, + "federation_enabled": bool(fed_key), + } +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py -k "instance_info" -x --timeout=30 +``` +Expected: PASS (all instance-info tests) + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add federation_enabled to instance-info endpoint" +``` + +--- + +### Task 10: Add Bearer token check to WebSocket auth + +**Files:** +- Modify: `muxplex/main.py` +- Test: `muxplex/tests/test_ws_proxy.py` + +The existing `terminal_ws_proxy` checks cookies for auth. Add Bearer token check so the federation proxy server can connect to remote WebSockets. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_ws_proxy.py`: + +```python +def test_ws_bearer_auth_accepted(monkeypatch): + """WebSocket from non-localhost with valid Bearer federation key is not rejected 4001.""" + import muxplex.main as main_module + + # Set federation key on the module + monkeypatch.setattr(main_module, "_federation_key", "ws-fed-key") + + fake_ws = FakeTtydWs(responses=[]) + monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws) + + # TestClient default host "testclient" is non-localhost + with TestClient(app) as c: + try: + with c.websocket_connect( + "/terminal/ws", + headers={"Authorization": "Bearer ws-fed-key"}, + ) as _: + pass + except WebSocketDisconnect as e: + # Should NOT be 4001 (auth rejection) + assert e.code != 4001, f"Bearer auth should be accepted, got close code {e.code}" +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_ws_proxy.py::test_ws_bearer_auth_accepted -x --timeout=30 +``` +Expected: FAIL — `4001` close code (Bearer not checked) + +**Step 3: Write minimal implementation** + +In `muxplex/main.py`, in the `terminal_ws_proxy` function, modify the auth check section. Currently it looks like: + +```python + if host not in ("127.0.0.1", "::1"): + session_cookie = websocket.cookies.get("muxplex_session") + if not session_cookie or not verify_session_cookie( + _auth_secret, session_cookie, _auth_ttl + ): + await websocket.close(code=4001) + return +``` + +Change to: + +```python + if host not in ("127.0.0.1", "::1"): + session_cookie = websocket.cookies.get("muxplex_session") + cookie_ok = session_cookie and verify_session_cookie( + _auth_secret, session_cookie, _auth_ttl + ) + bearer_ok = False + if _federation_key: + auth_header = websocket.headers.get("authorization", "") + if auth_header.lower().startswith("bearer "): + import hmac + bearer_ok = hmac.compare_digest(auth_header[7:], _federation_key) + if not cookie_ok and not bearer_ok: + await websocket.close(code=4001) + return +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_ws_proxy.py -x --timeout=30 +``` +Expected: PASS (all tests including the new one) + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_ws_proxy.py && git commit -m "feat: add Bearer token auth to WebSocket proxy for federation" +``` + +--- + +### Task 11: Add `WS /federation/{remote_id}/terminal/ws` proxy + +**Files:** +- Modify: `muxplex/main.py` +- Test: `muxplex/tests/test_ws_proxy.py` + +New WebSocket endpoint that proxies to a remote instance's `/terminal/ws`. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_ws_proxy.py`: + +```python +def test_federation_ws_proxy_route_exists(): + """The app must have a WebSocket route at /federation/{remote_id}/terminal/ws.""" + from fastapi.routing import APIRoute, APIWebSocketRoute + + ws_routes = [ + r for r in app.routes + if isinstance(r, (APIRoute, APIWebSocketRoute)) + and "/federation/" in r.path + and "/terminal/ws" in r.path + ] + assert len(ws_routes) == 1, f"Expected one federation WS route, found {len(ws_routes)}" +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_ws_proxy.py::test_federation_ws_proxy_route_exists -x --timeout=30 +``` +Expected: FAIL — no matching route + +**Step 3: Write minimal implementation** + +Add to `muxplex/main.py`, after the `federation_sessions` endpoint: + +```python +@app.websocket("/federation/{remote_id}/terminal/ws") +async def federation_terminal_ws_proxy(websocket: WebSocket, remote_id: str) -> None: + """Proxy WebSocket terminal connection to a remote muxplex instance. + + remote_id is the index into the remote_instances list. + Authenticates to the remote using the configured key. + """ + # Auth check (same as terminal_ws_proxy) + host = websocket.client.host if websocket.client else "" + if host not in ("127.0.0.1", "::1"): + session_cookie = websocket.cookies.get("muxplex_session") + cookie_ok = session_cookie and verify_session_cookie( + _auth_secret, session_cookie, _auth_ttl + ) + bearer_ok = False + if _federation_key: + auth_hdr = websocket.headers.get("authorization", "") + if auth_hdr.lower().startswith("bearer "): + import hmac as _hmac + bearer_ok = _hmac.compare_digest(auth_hdr[7:], _federation_key) + if not cookie_ok and not bearer_ok: + await websocket.close(code=4001) + return + + # Look up the remote instance + settings = load_settings() + remotes = settings.get("remote_instances", []) + try: + idx = int(remote_id) + remote = remotes[idx] + except (ValueError, IndexError): + await websocket.close(code=4004) + return + + remote_url = remote.get("url", "").rstrip("/") + remote_key = remote.get("key", "") + ws_url = remote_url.replace("http://", "ws://").replace("https://", "wss://") + "/terminal/ws" + + extra_headers = {} + if remote_key: + extra_headers["Authorization"] = f"Bearer {remote_key}" + + await websocket.accept(subprotocol="tty") + + try: + async with websockets.connect( + ws_url, + subprotocols=[Subprotocol("tty")], + additional_headers=extra_headers, + ) as remote_ws: + + async def client_to_remote() -> None: + try: + while True: + msg = await websocket.receive() + if msg.get("bytes"): + await remote_ws.send(msg["bytes"]) + elif msg.get("text"): + await remote_ws.send(msg["text"]) + except Exception: + pass + + async def remote_to_client() -> None: + try: + async for message in remote_ws: + if isinstance(message, bytes): + await websocket.send_bytes(message) + else: + await websocket.send_text(message) + except Exception: + pass + + await asyncio.gather(client_to_remote(), remote_to_client()) + except Exception: + pass + finally: + try: + await websocket.close() + except Exception: + pass +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_ws_proxy.py -x --timeout=30 +``` +Expected: PASS (all tests) + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_ws_proxy.py && git commit -m "feat: add WS /federation/{remote_id}/terminal/ws proxy endpoint" +``` + +--- + +### Task 12: Add `POST /api/federation/{remote_id}/connect` proxy + +**Files:** +- Modify: `muxplex/main.py` +- Test: `muxplex/tests/test_api.py` + +Proxies the connect POST to a remote instance to spawn its ttyd. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_api.py`: + +```python +def test_federation_connect_proxies_to_remote(client, monkeypatch, tmp_path): + """POST /api/federation/0/connect/my-session proxies connect to the remote.""" + import json + import muxplex.settings as settings_mod + from unittest.mock import AsyncMock + import httpx + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text(json.dumps({ + "multi_device_enabled": True, + "remote_instances": [ + {"url": "http://remote1:8088", "name": "Remote 1", "key": "rkey1"} + ], + })) + + # Mock the federation client's post method + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = lambda: {"active_session": "my-session", "ttyd_port": 7682} + mock_response.raise_for_status = lambda: None + + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + from muxplex.main import app + monkeypatch.setattr(app.state, "federation_client", mock_client) + + response = client.post("/api/federation/0/connect/my-session") + assert response.status_code == 200 + mock_client.post.assert_called_once() + call_url = mock_client.post.call_args[0][0] + assert "remote1:8088" in call_url + assert "my-session" in call_url +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_connect_proxies_to_remote -x --timeout=30 +``` +Expected: FAIL — 404/405 (route doesn't exist) + +**Step 3: Write minimal implementation** + +Add to `muxplex/main.py`, after the `federation_sessions` endpoint: + +```python +@app.post("/api/federation/{remote_id}/connect/{session_name}") +async def federation_connect(remote_id: str, session_name: str) -> dict: + """Proxy a connect request to a remote muxplex instance.""" + settings = load_settings() + remotes = settings.get("remote_instances", []) + try: + idx = int(remote_id) + remote = remotes[idx] + except (ValueError, IndexError): + raise HTTPException(status_code=404, detail=f"Remote instance '{remote_id}' not found") + + remote_url = remote.get("url", "").rstrip("/") + remote_key = remote.get("key", "") + headers = {"Accept": "application/json"} + if remote_key: + headers["Authorization"] = f"Bearer {remote_key}" + + http = app.state.federation_client + try: + resp = await http.post( + f"{remote_url}/api/sessions/{session_name}/connect", + headers=headers, + ) + resp.raise_for_status() + return resp.json() + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Remote connect failed: {exc}") +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_connect_proxies_to_remote -x --timeout=30 +``` +Expected: PASS + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add POST /api/federation/{remote_id}/connect proxy" +``` + +--- + +### Task 13: Add `POST /api/federation/generate-key` endpoint + +**Files:** +- Modify: `muxplex/main.py` +- Test: `muxplex/tests/test_api.py` + +For the settings UI — generates a federation key and returns it. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_api.py`: + +```python +def test_federation_generate_key_creates_file(client, tmp_path, monkeypatch): + """POST /api/federation/generate-key creates key file and returns key.""" + import muxplex.settings as settings_mod + + key_file = tmp_path / "federation_key" + monkeypatch.setattr(settings_mod, "FEDERATION_KEY_PATH", key_file) + monkeypatch.delenv("MUXPLEX_FEDERATION_KEY_FILE", raising=False) + + response = client.post("/api/federation/generate-key") + assert response.status_code == 200 + data = response.json() + assert "key" in data + assert len(data["key"]) > 20 + assert key_file.exists() + assert key_file.read_text().strip() == data["key"] +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_generate_key_creates_file -x --timeout=30 +``` +Expected: FAIL — 404/405 + +**Step 3: Write minimal implementation** + +Add to `muxplex/main.py`: + +```python +@app.post("/api/federation/generate-key") +async def federation_generate_key() -> dict: + """Generate a new federation key and save it to the key file.""" + import secrets as _secrets + from muxplex.settings import FEDERATION_KEY_PATH + + key = _secrets.token_urlsafe(32) + path = FEDERATION_KEY_PATH + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + path.write_text(key + "\n") + path.chmod(0o600) + return {"key": key, "path": str(path)} +``` + +**Step 4: Run test to verify it passes** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_generate_key_creates_file -x --timeout=30 +``` +Expected: PASS + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add POST /api/federation/generate-key endpoint" +``` + +--- + +### Task 14: Redact federation key from settings API + +**Files:** +- Modify: `muxplex/main.py` +- Test: `muxplex/tests/test_api.py` + +`GET /api/settings` must NOT return `federation_key`. Must also redact `key` field from each item in `remote_instances`. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_api.py`: + +```python +def test_get_settings_redacts_federation_key(client, tmp_path, monkeypatch): + """GET /api/settings must not return the federation_key value.""" + import json + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text(json.dumps({"federation_key": "secret-should-not-appear"})) + + response = client.get("/api/settings") + assert response.status_code == 200 + data = response.json() + # federation_key should be absent or empty + assert data.get("federation_key", "") == "" + + +def test_get_settings_redacts_remote_instance_keys(client, tmp_path, monkeypatch): + """GET /api/settings must redact key field from remote_instances.""" + import json + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text(json.dumps({ + "remote_instances": [ + {"url": "http://host1:8088", "name": "Host 1", "key": "secret-key"}, + ], + })) + + response = client.get("/api/settings") + assert response.status_code == 200 + data = response.json() + for inst in data.get("remote_instances", []): + assert "key" not in inst or inst["key"] == "" +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_get_settings_redacts_federation_key muxplex/tests/test_api.py::test_get_settings_redacts_remote_instance_keys -x --timeout=30 +``` +Expected: FAIL — federation_key and remote instance keys are returned as-is + +**Step 3: Write minimal implementation** + +Modify the `get_settings()` route in `muxplex/main.py`: + +```python +@app.get("/api/settings") +async def get_settings() -> dict: + """Return the current settings with sensitive fields redacted.""" + import copy + settings = load_settings() + result = copy.deepcopy(settings) + # Redact federation key + result["federation_key"] = "" + # Redact key field from each remote instance + for inst in result.get("remote_instances", []): + if "key" in inst: + inst["key"] = "" + return result +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py -k "redact" -x --timeout=30 +``` +Expected: PASS + +Then run ALL API tests to verify no regressions: +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py -x --timeout=30 +``` +Expected: PASS + +**Step 5: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: redact federation/remote keys from GET /api/settings" +``` + +--- + +### Task 15: Run full backend test suite + +**Files:** None (verification only) + +**Step 1: Run all Python tests** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/ -x --timeout=30 +``` +Expected: ALL PASS + +**Step 2: Commit (if any fixups needed)** +```bash +cd muxplex && git add -A && git commit -m "chore: Phase 1 complete — all backend proxy tests pass" +``` + +--- + +## Phase 2: Frontend — Switch to Proxy (Tasks 16–22) + +--- + +### Task 16: Simplify `pollSessions()` to single endpoint + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/frontend/tests/test_app.mjs` + +Replace multi-source parallel polling with a single `GET /api/federation/sessions` call when multi-device is enabled. + +**Step 1: Modify `pollSessions()` in `muxplex/frontend/app.js`** + +Replace the entire multi-source polling block (lines ~305–377) with: + +```javascript +async function pollSessions() { + try { + // When multi-device is enabled, use the federation proxy endpoint + // (server merges local + remote sessions). Otherwise, local only. + var endpoint = (_serverSettings && _serverSettings.multi_device_enabled) + ? '/api/federation/sessions' + : '/api/sessions'; + const res = await api('GET', endpoint); + const sessions = await res.json(); + const prev = _currentSessions; + _currentSessions = sessions; + _pollFailCount = 0; + setConnectionStatus('ok'); + renderGrid(sessions); + renderSidebar(sessions, _viewingSession); + handleBellTransitions(prev, sessions); + updateSessionPill(sessions); + updateFaviconBadge(); + } catch (err) { + _pollFailCount++; + setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err'); + } +} +``` + +**Step 2: Write a test for the new behavior** + +Add to `muxplex/frontend/tests/test_app.mjs`: + +```javascript +test('pollSessions uses /api/federation/sessions when multi_device_enabled', async () => { + // This is a structural test — verify pollSessions references the federation endpoint + const source = app.pollSessions.toString(); + assert.ok(source.includes('/api/federation/sessions'), 'pollSessions should reference federation endpoint'); + assert.ok(source.includes('multi_device_enabled'), 'pollSessions should check multi_device_enabled'); +}); +``` + +**Step 3: Run JS tests** + +```bash +/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs +``` +Expected: PASS (may need to add `pollSessions` to module.exports) + +**Step 4: Commit** +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "feat: simplify pollSessions to use federation proxy endpoint" +``` + +--- + +### Task 17: Simplify terminal connection to use proxy path + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/terminal.js` + +When opening a remote session: +1. POST to `/api/federation/{remoteId}/connect/{name}` instead of the remote URL directly +2. WebSocket connects to `ws://localhost/federation/{remoteId}/terminal/ws` (same origin) + +**Step 1: Modify `openSession()` in `app.js`** + +Find the section in `openSession()` that does the remote connect (around line 1358–1365). Change the remote connect logic from: + +```javascript + if (_sourceUrl) { + var remoteConnectUrl = _sourceUrl.replace(/\/+$/, '') + '/api/sessions/' + encodeURIComponent(name) + '/connect'; +``` + +To use the federation proxy: + +```javascript + var _remoteId = opts.remoteId || ''; + if (_remoteId) { + var remoteConnectUrl = '/api/federation/' + encodeURIComponent(_remoteId) + '/connect/' + encodeURIComponent(name); +``` + +Update all callers of `openSession` to pass `remoteId` instead of `sourceUrl`. Search for `sourceUrl` in click handlers and sidebar bindings. + +**Step 2: Modify `connectWebSocket()` in `terminal.js`** + +Change the `connectWebSocket` function signature from `connectWebSocket(name, sourceUrl)` to `connectWebSocket(name, remoteId)`: + +```javascript +function connectWebSocket(name, remoteId) { + var url; + if (remoteId) { + // Remote session: use federation proxy (same origin) + var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + url = proto + '//' + location.host + '/federation/' + encodeURIComponent(remoteId) + '/terminal/ws'; + } else { + // Local session: same origin + var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + url = proto + '//' + location.host + '/terminal/ws'; + } +``` + +Update `openTerminal()` to pass `remoteId` instead of `sourceUrl`: + +```javascript +function openTerminal(sessionName, remoteId) { + // ... existing cleanup code ... + connectWebSocket(sessionName, remoteId); + // ... +} +``` + +Update the `window._openTerminal` call in `app.js` to pass `remoteId`: + +```javascript + if (window._openTerminal) window._openTerminal(name, _remoteId); +``` + +**Step 3: Run JS tests** + +```bash +/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_terminal.mjs +``` +Expected: PASS (tests may need minor updates for the parameter rename) + +**Step 4: Commit** +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/terminal.js muxplex/frontend/tests/ && git commit -m "feat: route remote terminal connections through federation proxy" +``` + +--- + +### Task 18: Fix UI bug — device badge overlapping close button + +**Files:** +- Modify: `muxplex/frontend/style.css` + +**Step 1: Fix the CSS** + +The `×` button is absolutely positioned at `top:8px right:8px`. The `.tile-meta` span needs right-padding to avoid overlap. Find `.tile-meta` in `style.css` (line ~223) and add: + +```css +.tile-meta { + font-size: 11px; + color: var(--text-muted); + padding-right: 24px; /* space for the × close button */ +} +``` + +**Step 2: Verify visually (manual)** + +Open muxplex in a browser with multi-device enabled and verify device badges don't overlap close buttons. + +**Step 3: Commit** +```bash +cd muxplex && git add muxplex/frontend/style.css && git commit -m "fix: prevent device badge from overlapping close button" +``` + +--- + +### Task 19: Fix UI bug — missing left border on non-active sidebar items + +**Files:** +- Modify: `muxplex/frontend/style.css` + +**Step 1: Fix the CSS** + +Find `.sidebar-item` (line ~530) and change: +```css + border-left: 3px solid transparent; /* edge bar — always present, transparent by default */ +``` +to: +```css + border-left: 3px solid var(--border); /* edge bar — matches other borders by default */ +``` + +Do the same for `.session-tile` (line ~174): +```css + border-left: 3px solid var(--border); /* edge bar — matches other borders by default */ +``` + +The bell/active overrides (`.session-tile--edge-bell`, `.sidebar-item--active`) still override this with their colors. + +**Step 2: Run CSS tests** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_frontend_css.py -x --timeout=30 +``` +Expected: PASS + +**Step 3: Commit** +```bash +cd muxplex && git add muxplex/frontend/style.css && git commit -m "fix: make left border visible on non-active tiles and sidebar items" +``` + +--- + +### Task 20: Revert sidebar to single-line header + +**Files:** +- Modify: `muxplex/frontend/app.js` (sidebar rendering function) +- Modify: `muxplex/frontend/style.css` + +**Step 1: Modify sidebar item rendering** + +Find the `buildSidebarItemHTML` or equivalent function in `app.js` that builds the sidebar HTML. Change the two-line stacked header to a single-line format: `name [badge] [×]`. + +The sidebar item header should be a single row with the session name on the left, device badge (if multi-device) inline, and × close button on the right. + +```javascript +// Single-line sidebar header: name + badge + × +'' + +``` + +**Step 2: Update CSS** + +Ensure `.sidebar-item-header` is `display: flex; align-items: center; gap: 6px;` with badge right-aligned and × on hover. + +**Step 3: Run JS tests** + +```bash +/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs +``` +Expected: PASS + +**Step 4: Commit** +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/style.css && git commit -m "fix: revert sidebar to single-line header with inline badge" +``` + +--- + +### Task 21: Add federation key UI to settings + +**Files:** +- Modify: `muxplex/frontend/app.js` (settings panel rendering) + +Add to the Multi-Device settings tab: +1. A "Key" password-masked input per remote instance row +2. A "Generate Federation Key" button that calls `POST /api/federation/generate-key` +3. Show the local federation key (masked) so the user can copy it + +**Step 1: Update `_buildRemoteInstanceRow()` in `app.js`** + +Add a password-type input for the key field after the name input: + +```javascript + var keyInput = document.createElement('input'); + keyInput.type = 'password'; + keyInput.className = 'settings-remote-key'; + keyInput.placeholder = 'Federation key'; + keyInput.value = key || ''; + keyInput.setAttribute('aria-label', 'Remote instance federation key'); + row.appendChild(urlInput); + row.appendChild(nameInput); + row.appendChild(keyInput); + row.appendChild(removeBtn); +``` + +Update `_saveRemoteInstances()` to include the key field: + +```javascript + var keyEl = row.querySelector('.settings-remote-key'); + var key = (keyEl && keyEl.value) ? keyEl.value.trim() : ''; + if (url) { + instances.push({ url: url, name: name, key: key }); + } +``` + +**Step 2: Add "Generate Federation Key" button** + +In the multi-device settings tab rendering, add a button that calls `POST /api/federation/generate-key`: + +```javascript +var genKeyBtn = document.createElement('button'); +genKeyBtn.textContent = 'Generate Federation Key'; +genKeyBtn.className = 'btn btn-secondary'; +genKeyBtn.addEventListener('click', async function() { + try { + var res = await api('POST', '/api/federation/generate-key'); + var data = await res.json(); + // Show the key in a masked input so user can copy it + var keyDisplay = document.getElementById('setting-federation-key-display'); + if (keyDisplay) keyDisplay.value = data.key; + showToast('Federation key generated'); + } catch (err) { + showToast('Failed to generate key'); + } +}); +``` + +**Step 3: Run JS tests** + +```bash +/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs +``` +Expected: PASS + +**Step 4: Commit** +```bash +cd muxplex && git add muxplex/frontend/app.js && git commit -m "feat: add federation key UI to settings panel" +``` + +--- + +### Task 22: Run full test suite for Phase 2 + +**Files:** None (verification only) + +**Step 1: Run all Python tests** +```bash +cd muxplex && python3 -m pytest muxplex/tests/ -x --timeout=30 +``` +Expected: ALL PASS + +**Step 2: Run all JS tests** +```bash +/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs +/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_terminal.mjs +``` +Expected: ALL PASS + +**Step 3: Commit** +```bash +cd muxplex && git add -A && git commit -m "chore: Phase 2 complete — frontend switched to proxy" +``` + +--- + +## Phase 3: Cleanup (Tasks 23–30) + +--- + +### Task 23: Remove CORS middleware + +**Files:** +- Modify: `muxplex/main.py` +- Modify: `muxplex/tests/test_api.py` + +**Step 1: Remove CORS middleware from `main.py`** + +Delete these lines from `muxplex/main.py`: + +```python +from starlette.middleware.cors import CORSMiddleware +``` + +And delete the entire CORS middleware block (lines ~256–267): + +```python +# CORS: allow_origins=["*"] with allow_credentials=True is intentional for +# self-hosted federation. ... +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +``` + +**Step 2: Remove CORS tests from `test_api.py`** + +Delete these test functions: +- `test_cors_preflight_returns_200` +- `test_cors_allows_any_origin` +- `test_cors_allows_credentials` + +**Step 3: Run tests** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py -x --timeout=30 +``` +Expected: PASS + +**Step 4: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "chore: remove CORS middleware — no longer needed with proxy" +``` + +--- + +### Task 24: Remove X-Muxplex-Token auth from middleware + +**Files:** +- Modify: `muxplex/auth.py` +- Modify: `muxplex/tests/test_auth.py` + +**Step 1: Remove the X-Muxplex-Token check from `AuthMiddleware.dispatch()`** + +Delete the `# 4b. X-Muxplex-Token header` block (lines ~195–199): + +```python + # 4b. X-Muxplex-Token header (for cross-origin federation) + token_header = request.headers.get("x-muxplex-token") + if token_header: + if verify_session_cookie(self.secret, token_header, self.ttl_seconds): + return await call_next(request) +``` + +**Step 2: Remove X-Muxplex-Token tests from `test_auth.py`** + +Delete: +- `test_middleware_valid_token_header_passes` +- `test_middleware_invalid_token_header_falls_through_to_redirect` + +**Step 3: Run tests** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_auth.py -x --timeout=30 +``` +Expected: PASS + +**Step 4: Commit** +```bash +cd muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "chore: remove X-Muxplex-Token auth — replaced by Bearer federation key" +``` + +--- + +### Task 25: Remove `/api/auth/token` route + +**Files:** +- Modify: `muxplex/main.py` +- Modify: `muxplex/tests/test_api.py` + +**Step 1: Delete the route from `main.py`** + +Delete the entire `get_auth_token()` function (lines ~736–746): + +```python +@app.get("/api/auth/token") +async def get_auth_token(request: Request): + """Return the current session token for federation relay...""" + ... +``` + +**Step 2: Delete the tests** + +Delete from `test_api.py`: +- `test_get_auth_token_returns_token_when_authenticated` +- `test_get_auth_token_returns_401_when_not_authenticated` + +**Step 3: Run tests** + +```bash +cd muxplex && python3 -m pytest muxplex/tests/test_api.py -x --timeout=30 +``` +Expected: PASS + +**Step 4: Commit** +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "chore: remove /api/auth/token route — no longer needed" +``` + +--- + +### Task 26: Remove cross-origin auth code from frontend + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/tests/test_app.mjs` + +**Step 1: Delete these functions/blocks from `app.js`:** + +1. `storeFederationToken()` function (lines ~216–222) +2. The `window.addEventListener('message', ...)` block that listens for `muxplex-auth-token` postMessage (lines ~224–236) +3. `openLoginPopup()` function (lines ~666–669) +4. `buildAuthTileHTML()` function (lines ~618–628) +5. `formatLastSeen()` function (lines ~635–642) +6. All references to `muxplex.federation_tokens` in `localStorage` (search for `federation_tokens`) +7. The `_sources.forEach(function(source) { if (source.status === 'auth_required') ...` blocks that generate auth tiles + +**Step 2: Simplify `api()` function** + +Remove the `baseUrl` parameter and all cross-origin logic: + +```javascript +async function api(method, path, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + const res = await fetch(path, opts); + if (!res.ok) { + const err = new Error(`HTTP ${res.status}: ${res.statusText}`); + err.status = res.status; + throw err; + } + return res; +} +``` + +**Step 3: Remove from `module.exports`** + +Remove these from the exports block at the bottom of `app.js`: +- `buildSources` +- `tagSessions` +- `mergeSources` +- `buildAuthTileHTML` +- `openLoginPopup` +- `formatLastSeen` +- `storeFederationToken` +- `_setSources` +- `_getSources` + +**Step 4: Update JS tests** + +Delete all tests in `test_app.mjs` that reference the removed functions. Search for: +- `buildSources` +- `tagSessions` +- `mergeSources` +- `buildAuthTileHTML` +- `openLoginPopup` +- `formatLastSeen` +- `storeFederationToken` +- `_setSources` +- `_getSources` + +**Step 5: Run JS tests** + +```bash +/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs +``` +Expected: PASS + +**Step 6: Commit** +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "chore: remove cross-origin auth and multi-source frontend code" +``` + +--- + +### Task 27: Remove `_sources` state management + +**Files:** +- Modify: `muxplex/frontend/app.js` + +**Step 1: Delete remaining `_sources` references** + +1. Delete `let _sources = [];` declaration (line ~136) +2. Delete `buildSources()` function (lines ~1473–1498) +3. Delete all `_sources = buildSources(...)` calls +4. Delete `_saveRemoteInstances()` federation token pruning block (that references `federation_tokens`) +5. Delete `buildOfflineTileHTML()` function if still present +6. Remove the `_sources.length > 1` checks in tile/sidebar rendering that controlled badge visibility. Instead, check if `session.deviceName` exists (the server now sets this). +7. Delete the `_setActiveFilterDevice` test helper if no longer used + +**Step 2: Update badge visibility check** + +Replace instances of: +```javascript +if (_sources.length > 1 && session.deviceName && ds.showDeviceBadges !== false) { +``` +with: +```javascript +if (session.deviceName && ds.showDeviceBadges !== false && _serverSettings && _serverSettings.multi_device_enabled) { +``` + +**Step 3: Run JS tests** + +```bash +/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs +``` +Expected: PASS + +**Step 4: Commit** +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs && git commit -m "chore: remove _sources state management — server handles multi-device" +``` + +--- + +### Task 28: Remove popup relay script from index.html + +**Files:** +- Modify: `muxplex/frontend/index.html` + +**Step 1: Check and remove** + +Search `index.html` for any `postMessage`, `muxplex-auth-token`, or popup relay `