Files
muxplex/docs/plans/2026-04-01-federation-proxy-rewrite.md
T
2026-04-01 12:33:17 -07:00

2057 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 115)
---
### 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 1622)
---
### 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 ~305377) 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 13581365). 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 + ×
'<div class="sidebar-item-header">' +
'<span class="sidebar-item-name">' + escapedName + '</span>' +
badgeHtml +
closeBtn +
'</div>' +
```
**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 2330)
---
### 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 ~256267):
```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 ~195199):
```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 ~736746):
```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 ~216222)
2. The `window.addEventListener('message', ...)` block that listens for `muxplex-auth-token` postMessage (lines ~224236)
3. `openLoginPopup()` function (lines ~666669)
4. `buildAuthTileHTML()` function (lines ~618628)
5. `formatLastSeen()` function (lines ~635642)
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 ~14731498)
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 `<script>` blocks. If present, delete them.
**Step 2: Run HTML tests**
```bash
cd muxplex && python3 -m pytest muxplex/tests/test_frontend_html.py -x --timeout=30
```
Expected: PASS
**Step 3: Commit**
```bash
cd muxplex && git add muxplex/frontend/index.html && git commit -m "chore: remove popup auth relay script from index.html"
```
---
### Task 29: Update tests referencing removed functions
**Files:**
- Modify: `muxplex/tests/test_api.py`
- Modify: `muxplex/tests/test_auth.py`
- Modify: `muxplex/frontend/tests/test_app.mjs`
- Modify: `muxplex/frontend/tests/test_terminal.mjs`
**Step 1: Search for broken references**
```bash
cd muxplex && grep -rn "sourceUrl\|_sources\|storeFederationToken\|openLoginPopup\|buildAuthTile\|formatLastSeen\|X-Muxplex-Token\|auth/token\|mergeSources\|tagSessions\|buildSources" muxplex/tests/ muxplex/frontend/tests/
```
Fix any remaining references to deleted functions or removed API endpoints.
**Step 2: Run all tests**
```bash
cd muxplex && python3 -m pytest muxplex/tests/ -x --timeout=30
/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: clean up test references to removed functions"
```
---
### Task 30: Final verification
**Files:** None (verification only)
**Step 1: Run ALL tests**
```bash
cd muxplex && python3 -m pytest muxplex/tests/ -x --timeout=30
/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 2: Verify no stale cross-origin references remain**
```bash
cd muxplex && grep -rn "CORSMiddleware\|X-Muxplex-Token\|muxplex.federation_tokens\|openLoginPopup\|storeFederationToken\|/api/auth/token" muxplex/ --include="*.py" --include="*.js" --include="*.html" | grep -v "node_modules\|__pycache__\|.pyc"
```
Expected: No matches (or only comments explaining what was removed)
**Step 3: Final commit**
```bash
cd muxplex && git add -A && git commit -m "chore: federation proxy rewrite complete — all tests pass"
```
---
## Summary
| Phase | Tasks | Description |
|-------|-------|-------------|
| **Phase 1** | 115 | Backend proxy: federation key, Bearer auth, httpx client, `/api/federation/sessions`, WS proxy, `/api/federation/connect`, key generation, settings redaction |
| **Phase 2** | 1622 | Frontend: simplify polling to single endpoint, route terminal through proxy, CSS bug fixes, settings UI |
| **Phase 3** | 2330 | Cleanup: remove CORS, X-Muxplex-Token, `/api/auth/token`, cross-origin frontend code, popup relay |
**Total tasks:** 30
**Estimated time:** 90120 minutes
## Key Files Modified
| File | Changes |
|------|---------|
| `muxplex/settings.py` | `federation_key` default, `load_federation_key()`, `FEDERATION_KEY_PATH` |
| `muxplex/auth.py` | Bearer token check in middleware, `federation_key` param, remove X-Muxplex-Token |
| `muxplex/main.py` | Federation endpoints, httpx client, WS federation proxy, remove CORS, remove `/api/auth/token` |
| `muxplex/cli.py` | `generate-federation-key` command |
| `muxplex/frontend/app.js` | Simplify polling, remove multi-source code, remove cross-origin auth |
| `muxplex/frontend/terminal.js` | Route through federation proxy WS |
| `muxplex/frontend/style.css` | Device badge overlap, left border, sidebar header |
| `muxplex/frontend/index.html` | Remove popup relay script |
## Test Commands Quick Reference
```bash
# Python tests
cd muxplex && python3 -m pytest muxplex/tests/ -x --timeout=30
# JS app tests
/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs
# JS terminal tests
/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_terminal.mjs
```