feat: remove public REST API; route all app calls over WebSocket RPC
The platform no longer exposes a public /api/* REST surface. The original HTTP route handlers are preserved verbatim as the single source of truth inside an internal, never-socket-bound FastAPI app (rpc_app), and are now reached only via RPC-over-WebSocket dispatch. - realtime router: anonymous connections allowed only to /api/auth/login and /api/auth/signup; all other RPCs/subscribes refused until token auth. - RPC dispatch re-injects the bearer token and X-Request-ID, then forwards through httpx.ASGITransport against the internal app (no network hop). - Public app now serves only /, /health, /health/ready and /api/ws; docs/openapi disabled. - Frontend: apiRequest routes /api/* to ws.rpc(); /health probes keep fetch. RealtimeClient gains rpc() with id correlation, timeout and AbortSignal. - Tests repointed to get_rpc_app(); anonymous-auth behaviour covered. Result: zero REST on the public surface; 264 backend tests + frontend typecheck pass.
This commit is contained in:
@@ -32,7 +32,11 @@ from app.infrastructure.db.models import UserRow
|
||||
from app.interfaces.realtime.authz import authorize_channel
|
||||
from app.interfaces.realtime.bus import get_bus
|
||||
from app.interfaces.realtime.manager import Connection, ConnectionManager
|
||||
from app.main import app
|
||||
# The internal "full" app holds every HTTP handler; the public app serves only
|
||||
# /health + /api/ws. The socket and the RPC surface both live on the full app.
|
||||
from app.interfaces.realtime.rpc_app import get_rpc_app
|
||||
|
||||
app = get_rpc_app()
|
||||
|
||||
API_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -331,10 +335,46 @@ def _frames_until(websocket, wanted_type: str, *, max_frames: int = 20) -> dict:
|
||||
raise AssertionError(f"no {wanted_type} event arrived")
|
||||
|
||||
|
||||
def test_socket_rejects_a_missing_token(ws_client):
|
||||
with pytest.raises(Exception), ws_client.websocket_connect("/api/ws"): # noqa: B017, SIM117
|
||||
# No token: the endpoint closes with 1008 before accepting.
|
||||
pass
|
||||
def test_socket_allows_anonymous_then_restricts_actions(ws_client):
|
||||
"""No token opens an anonymous socket, but only login/signup may be called.
|
||||
|
||||
A visitor who has not logged in can reach ``/api/auth/login`` and
|
||||
``/api/auth/signup`` over RPC; every other RPC and any subscribe is refused.
|
||||
"""
|
||||
with ws_client.websocket_connect("/api/ws") as socket:
|
||||
hello = socket.receive_json()
|
||||
assert hello["type"] == "hello"
|
||||
assert hello["user_id"] is None
|
||||
|
||||
# A protected RPC is refused for an anonymous connection.
|
||||
socket.send_json({"action": "rpc", "id": "1", "method": "GET", "path": "/api/organizations"})
|
||||
result = socket.receive_json()
|
||||
assert result["type"] == "rpc_result"
|
||||
assert result["status"] == 401
|
||||
|
||||
# Subscribing is refused until the caller logs in.
|
||||
socket.send_json({"action": "subscribe", "channels": ["org:x"]})
|
||||
refused = socket.receive_json()
|
||||
assert refused["type"] == "error"
|
||||
|
||||
|
||||
def test_anonymous_may_call_login_over_rpc(ws_client):
|
||||
with ws_client.websocket_connect("/api/ws") as socket:
|
||||
socket.receive_json() # hello
|
||||
socket.send_json(
|
||||
{
|
||||
"action": "rpc",
|
||||
"id": "login-1",
|
||||
"method": "POST",
|
||||
"path": "/api/auth/login",
|
||||
"body": {"email": "anon@example.com", "password": "password123"},
|
||||
}
|
||||
)
|
||||
result = socket.receive_json()
|
||||
assert result["type"] == "rpc_result"
|
||||
# No such user -> the handler's own auth dependency returns 401, proving
|
||||
# the RPC reached the real login route through the internal app.
|
||||
assert result["status"] == 401
|
||||
|
||||
|
||||
def test_task_creation_is_pushed_live(ws_client):
|
||||
|
||||
Reference in New Issue
Block a user