diff --git a/docs/plans/2026-04-01-federation-proxy-rewrite.md b/docs/plans/2026-04-01-federation-proxy-rewrite.md deleted file mode 100644 index 0ed5a42..0000000 --- a/docs/plans/2026-04-01-federation-proxy-rewrite.md +++ /dev/null @@ -1,2057 +0,0 @@ -# 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 `