feat: add PUT /api/settings/sync federation endpoint with newer-wins logic

This commit is contained in:
Brian Krabach
2026-04-08 21:03:23 -07:00
parent 349f49824b
commit a0ad4921cf
2 changed files with 158 additions and 1 deletions
+37 -1
View File
@@ -30,7 +30,7 @@ import websockets
from websockets.typing import Subprotocol
from fastapi import FastAPI, Form, HTTPException, Request, WebSocket
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator
from starlette.responses import RedirectResponse
@@ -65,6 +65,7 @@ from muxplex.state import (
state_lock,
)
from muxplex.settings import (
apply_synced_settings,
get_syncable_settings,
load_federation_key,
load_settings,
@@ -359,6 +360,11 @@ class CreateSessionPayload(BaseModel):
return stripped
class SettingsSyncPayload(BaseModel):
settings: dict
settings_updated_at: float
# ---------------------------------------------------------------------------
# Frontend directory + hostname
# ---------------------------------------------------------------------------
@@ -739,6 +745,36 @@ async def get_settings_sync() -> dict:
return {"settings": settings, "settings_updated_at": ts}
@app.put("/api/settings/sync")
async def put_settings_sync(payload: SettingsSyncPayload):
"""Accept synced settings from a remote server (newer-wins).
Compares the incoming timestamp against the local settings_updated_at.
If the incoming timestamp is strictly newer, applies only the syncable
keys via apply_synced_settings() and returns 200 with the final state.
If the incoming timestamp is equal to or older than the local one, returns
409 (Conflict) with the current local state so the caller can see what
this instance has.
"""
current = load_settings()
local_ts: float = current.get("settings_updated_at", 0.0)
if payload.settings_updated_at > local_ts:
apply_synced_settings(payload.settings, payload.settings_updated_at)
syncable = get_syncable_settings()
ts = syncable.get("settings_updated_at", 0.0)
settings_out = {k: v for k, v in syncable.items() if k != "settings_updated_at"}
return {"settings": settings_out, "settings_updated_at": ts}
else:
syncable = get_syncable_settings()
ts = syncable.get("settings_updated_at", 0.0)
settings_out = {k: v for k, v in syncable.items() if k != "settings_updated_at"}
return JSONResponse(
status_code=409,
content={"settings": settings_out, "settings_updated_at": ts},
)
@app.get("/api/instance-info")
async def instance_info() -> dict:
"""Return this instance's display name and version.
+121
View File
@@ -2915,3 +2915,124 @@ def test_federation_auth_headers_guard_empty_key():
"use `{...} if key else {}` to skip the header when key is empty:\n"
+ "\n".join(f" {line}" for line in offending)
)
# ---------------------------------------------------------------------------
# PUT /api/settings/sync (task-8)
# ---------------------------------------------------------------------------
def test_put_settings_sync_applies_when_newer(client, tmp_path, monkeypatch):
"""PUT /api/settings/sync applies settings when incoming timestamp is newer."""
import json
import muxplex.settings as settings_mod
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
# Set local timestamp to something old
settings_path.write_text(json.dumps({"settings_updated_at": 1000.0}))
response = client.put(
"/api/settings/sync",
json={"settings": {"fontSize": 20}, "settings_updated_at": 2000.0},
)
assert response.status_code == 200
data = response.json()
assert "settings" in data, f"Response must have 'settings' key, got: {data}"
assert "settings_updated_at" in data, (
f"Response must have 'settings_updated_at' key, got: {data}"
)
assert data["settings_updated_at"] == 2000.0, (
f"Timestamp must be 2000.0, got: {data['settings_updated_at']}"
)
assert data["settings"]["fontSize"] == 20, (
f"fontSize must be 20, got: {data['settings'].get('fontSize')}"
)
def test_put_settings_sync_rejects_when_older(client, tmp_path, monkeypatch):
"""PUT /api/settings/sync returns 409 when incoming timestamp is older."""
import json
import muxplex.settings as settings_mod
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
# Set local timestamp to something newer than the incoming
settings_path.write_text(json.dumps({"settings_updated_at": 2000.0}))
response = client.put(
"/api/settings/sync",
json={"settings": {"fontSize": 18}, "settings_updated_at": 1000.0},
)
assert response.status_code == 409
data = response.json()
assert "settings" in data, f"409 body must have 'settings' key, got: {data}"
assert "settings_updated_at" in data, (
f"409 body must have 'settings_updated_at' key, got: {data}"
)
# Should return local state, which has the newer timestamp
assert data["settings_updated_at"] == 2000.0, (
f"409 body must return local timestamp 2000.0, got: {data['settings_updated_at']}"
)
def test_put_settings_sync_rejects_when_equal(client, tmp_path, monkeypatch):
"""PUT /api/settings/sync returns 409 when timestamps are equal."""
import json
import muxplex.settings as settings_mod
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
# Set local timestamp
settings_path.write_text(json.dumps({"settings_updated_at": 1500.0}))
response = client.put(
"/api/settings/sync",
json={"settings": {"fontSize": 16}, "settings_updated_at": 1500.0},
)
assert response.status_code == 409, (
f"Equal timestamps must return 409, got {response.status_code}"
)
data = response.json()
assert data["settings_updated_at"] == 1500.0, (
f"409 body must return local timestamp 1500.0, got: {data.get('settings_updated_at')}"
)
def test_put_settings_sync_ignores_nonsyncable_keys(client, tmp_path, monkeypatch):
"""PUT /api/settings/sync does not apply non-syncable keys like host."""
import json
import muxplex.settings as settings_mod
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
# Start with default settings (host defaults to "127.0.0.1")
settings_path.write_text(json.dumps({"settings_updated_at": 0.0}))
response = client.put(
"/api/settings/sync",
json={
"settings": {"fontSize": 20, "host": "evil.com"},
"settings_updated_at": 9999999999.0,
},
)
assert response.status_code == 200
# Verify fontSize (syncable) was changed
local = settings_mod.load_settings()
assert local["fontSize"] == 20, (
f"Syncable key 'fontSize' must be updated to 20, got: {local['fontSize']}"
)
# Verify host (non-syncable) was NOT changed
assert local["host"] == "127.0.0.1", (
f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}"
)