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:
mjalmoudy94
2026-10-13 13:54:56 +03:30
parent f1e5df29ce
commit 8066e9d055
12 changed files with 631 additions and 222 deletions
+24 -7
View File
@@ -7,6 +7,7 @@
*/
import type { ApiErrorBody } from "@agy/contracts";
import { getRealtimeClient } from "@/lib/ws";
/**
* Resolves the API base URL.
@@ -89,16 +90,34 @@ export interface RequestOptions {
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, token, signal } = options;
const { method = "GET", body, signal } = options;
// Liveness and readiness probes stay on plain HTTP: they answer during a DB
// outage and are used by deploy orchestration (and the browser status check),
// so they must not depend on the authenticated socket. Every application call
// travels over the WebSocket as an RPC instead (see lib/ws.ts) — one
// connection for both live feeds and request/response.
if (path === "/health" || path === "/health/ready") {
return fetchHealth<T>(path, method, body, signal);
}
const client = getRealtimeClient();
return client.rpc<T>(method, path, body === undefined ? null : body, { signal });
}
/** Plain-HTTP implementation kept only for the two health probes. */
async function fetchHealth<T>(
path: string,
method: string,
body: unknown,
signal?: AbortSignal,
): Promise<T> {
const headers: Record<string, string> = { Accept: "application/json" };
if (body !== undefined) headers["Content-Type"] = "application/json";
const authToken = token ?? getStoredToken();
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const baseUrl = getApiBaseUrl();
let response: Response;
try {
response = await fetch(`${baseUrl}${path}`, {
response = await fetch(`${getApiBaseUrl()}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
@@ -118,8 +137,6 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
const envelope = payload as ApiErrorBody | null;
const code = envelope?.error?.code ?? "http_error";
const message = envelope?.error?.message ?? `خطای سرور (${response.status})`;
// Fall back to the header: a failure raised outside the error handlers
// (a CORS rejection, a proxy error) still carries the id.
const requestId =
envelope?.error?.request_id ?? response.headers.get("X-Request-ID") ?? undefined;
throw new ApiError(response.status, code, message, envelope?.error?.details, requestId);