64 KiB
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:
# ============================================================
# 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
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":
"multi_device_enabled": False,
"federation_key": "",
Step 4: Run test to verify it passes
cd muxplex && python3 -m pytest muxplex/tests/test_settings.py::test_defaults_include_federation_key -x --timeout=30
Expected: PASS
Step 5: Commit
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:
# ============================================================
# 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
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:
import os
Then add after SETTINGS_PATH:
FEDERATION_KEY_PATH = Path.home() / ".config" / "muxplex" / "federation_key"
Then add after the patch_settings function:
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
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
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):
# ============================================================
# 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
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:
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:
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":):
elif args.command == "generate-federation-key":
generate_federation_key()
Step 4: Run test to verify it passes
cd muxplex && python3 -m pytest muxplex/tests/test_cli.py::test_generate_federation_key_creates_file -x --timeout=30
Expected: PASS
Step 5: Commit
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:
# ---------------------------------------------------------------------------
# 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
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__:
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):
# 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
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:
cd muxplex && python3 -m pytest muxplex/tests/test_auth.py -x --timeout=30
Expected: PASS (all tests)
Step 5: Commit
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:
# ---------------------------------------------------------------------------
# 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
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:
from muxplex.settings import load_federation_key
Add after _auth_ttl = ...:
_federation_key = load_federation_key()
Modify the app.add_middleware(AuthMiddleware, ...) call to include:
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
cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_bearer_auth_accepted -x --timeout=30
Expected: PASS
Step 5: Commit
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:
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
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
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:
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
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:
# 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:
# Shutdown: close federation client
await app.state.federation_client.aclose()
Step 4: Run test to verify it passes
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
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:
# ---------------------------------------------------------------------------
# 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
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:
# ---------------------------------------------------------------------------
# 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
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
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:
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
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:
@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
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
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:
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
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:
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:
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
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
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:
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
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:
@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
cd muxplex && python3 -m pytest muxplex/tests/test_ws_proxy.py -x --timeout=30
Expected: PASS (all tests)
Step 5: Commit
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:
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
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:
@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
cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_connect_proxies_to_remote -x --timeout=30
Expected: PASS
Step 5: Commit
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:
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
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:
@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
cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_federation_generate_key_creates_file -x --timeout=30
Expected: PASS
Step 5: Commit
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:
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
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:
@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
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:
cd muxplex && python3 -m pytest muxplex/tests/test_api.py -x --timeout=30
Expected: PASS
Step 5: Commit
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
cd muxplex && python3 -m pytest muxplex/tests/ -x --timeout=30
Expected: ALL PASS
Step 2: Commit (if any fixups needed)
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:
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:
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
/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
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:
- POST to
/api/federation/{remoteId}/connect/{name}instead of the remote URL directly - 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:
if (_sourceUrl) {
var remoteConnectUrl = _sourceUrl.replace(/\/+$/, '') + '/api/sessions/' + encodeURIComponent(name) + '/connect';
To use the federation proxy:
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):
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:
function openTerminal(sessionName, remoteId) {
// ... existing cleanup code ...
connectWebSocket(sessionName, remoteId);
// ...
}
Update the window._openTerminal call in app.js to pass remoteId:
if (window._openTerminal) window._openTerminal(name, _remoteId);
Step 3: Run JS tests
/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
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:
.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
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:
border-left: 3px solid transparent; /* edge bar — always present, transparent by default */
to:
border-left: 3px solid var(--border); /* edge bar — matches other borders by default */
Do the same for .session-tile (line ~174):
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
cd muxplex && python3 -m pytest muxplex/tests/test_frontend_css.py -x --timeout=30
Expected: PASS
Step 3: Commit
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.
// 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
/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs
Expected: PASS
Step 4: Commit
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:
- A "Key" password-masked input per remote instance row
- A "Generate Federation Key" button that calls
POST /api/federation/generate-key - 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:
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:
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:
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
/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs
Expected: PASS
Step 4: Commit
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
cd muxplex && python3 -m pytest muxplex/tests/ -x --timeout=30
Expected: ALL PASS
Step 2: Run all JS tests
/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
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:
from starlette.middleware.cors import CORSMiddleware
And delete the entire CORS middleware block (lines ~256–267):
# 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_200test_cors_allows_any_origintest_cors_allows_credentials
Step 3: Run tests
cd muxplex && python3 -m pytest muxplex/tests/test_api.py -x --timeout=30
Expected: PASS
Step 4: Commit
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):
# 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_passestest_middleware_invalid_token_header_falls_through_to_redirect
Step 3: Run tests
cd muxplex && python3 -m pytest muxplex/tests/test_auth.py -x --timeout=30
Expected: PASS
Step 4: Commit
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):
@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_authenticatedtest_get_auth_token_returns_401_when_not_authenticated
Step 3: Run tests
cd muxplex && python3 -m pytest muxplex/tests/test_api.py -x --timeout=30
Expected: PASS
Step 4: Commit
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:
storeFederationToken()function (lines ~216–222)- The
window.addEventListener('message', ...)block that listens formuxplex-auth-tokenpostMessage (lines ~224–236) openLoginPopup()function (lines ~666–669)buildAuthTileHTML()function (lines ~618–628)formatLastSeen()function (lines ~635–642)- All references to
muxplex.federation_tokensinlocalStorage(search forfederation_tokens) - 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:
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:
buildSourcestagSessionsmergeSourcesbuildAuthTileHTMLopenLoginPopupformatLastSeenstoreFederationToken_setSources_getSources
Step 4: Update JS tests
Delete all tests in test_app.mjs that reference the removed functions. Search for:
buildSourcestagSessionsmergeSourcesbuildAuthTileHTMLopenLoginPopupformatLastSeenstoreFederationToken_setSources_getSources
Step 5: Run JS tests
/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs
Expected: PASS
Step 6: Commit
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
- Delete
let _sources = [];declaration (line ~136) - Delete
buildSources()function (lines ~1473–1498) - Delete all
_sources = buildSources(...)calls - Delete
_saveRemoteInstances()federation token pruning block (that referencesfederation_tokens) - Delete
buildOfflineTileHTML()function if still present - Remove the
_sources.length > 1checks in tile/sidebar rendering that controlled badge visibility. Instead, check ifsession.deviceNameexists (the server now sets this). - Delete the
_setActiveFilterDevicetest helper if no longer used
Step 2: Update badge visibility check
Replace instances of:
if (_sources.length > 1 && session.deviceName && ds.showDeviceBadges !== false) {
with:
if (session.deviceName && ds.showDeviceBadges !== false && _serverSettings && _serverSettings.multi_device_enabled) {
Step 3: Run JS tests
/home/brkrabac/.nvm/versions/node/v24.14.1/bin/node --test muxplex/frontend/tests/test_app.mjs
Expected: PASS
Step 4: Commit
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
cd muxplex && python3 -m pytest muxplex/tests/test_frontend_html.py -x --timeout=30
Expected: PASS
Step 3: Commit
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
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
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
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
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
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
cd muxplex && git add -A && git commit -m "chore: federation proxy rewrite complete — all tests pass"
Summary
| Phase | Tasks | Description |
|---|---|---|
| Phase 1 | 1–15 | Backend proxy: federation key, Bearer auth, httpx client, /api/federation/sessions, WS proxy, /api/federation/connect, key generation, settings redaction |
| Phase 2 | 16–22 | Frontend: simplify polling to single endpoint, route terminal through proxy, CSS bug fixes, settings UI |
| Phase 3 | 23–30 | Cleanup: remove CORS, X-Muxplex-Token, /api/auth/token, cross-origin frontend code, popup relay |
Total tasks: 30 Estimated time: 90–120 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
# 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