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

64 KiB
Raw Blame History

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:

# ============================================================
# 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 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:

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:

  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:

    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:

  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:

  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 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:

from starlette.middleware.cors import CORSMiddleware

And delete the entire CORS middleware block (lines ~256267):

# 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

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 ~195199):

        # 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

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 ~736746):

@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

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:

  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:

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

/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

  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:

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 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

# 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