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
+55 -92
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, api, apiRequest, getStoredToken, setStoredToken } from "@/lib/api";
import { getRealtimeClient } from "@/lib/ws";
describe("token storage", () => {
it("round-trips a token through localStorage", () => {
@@ -25,22 +26,14 @@ describe("ApiError", () => {
});
});
describe("error correlation", () => {
describe("error correlation over rpc", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("reads the request id out of the error envelope", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(
JSON.stringify({
error: { code: "internal_error", message: "خطا", request_id: "req-from-body" },
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
),
),
vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue(
new ApiError(500, "internal_error", "خطا", undefined, "req-from-body"),
);
await expect(apiRequest("/api/whatever")).rejects.toMatchObject({
@@ -49,127 +42,95 @@ describe("error correlation", () => {
});
});
it("falls back to the X-Request-ID header", async () => {
// A failure raised outside the error handlers — a CORS rejection or a proxy
// error — still carries the header, and the user should still get an id.
vi.stubGlobal(
"fetch",
vi.fn(async () =>
new Response(JSON.stringify({ detail: "Disallowed CORS origin" }), {
status: 400,
headers: { "X-Request-ID": "req-from-header" },
}),
),
it("rejects an unauthenticated call with the server's 401", async () => {
vi.spyOn(getRealtimeClient(), "rpc").mockRejectedValue(
new ApiError(401, "unauthenticated", "نیاز به ورود دارید."),
);
await expect(apiRequest("/api/whatever")).rejects.toMatchObject({
requestId: "req-from-header",
await expect(apiRequest("/api/organizations")).rejects.toMatchObject({
status: 401,
code: "unauthenticated",
});
});
});
describe("error console client", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("omits unset filters from the query string", async () => {
const calls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
calls.push(url);
return new Response(JSON.stringify({ items: [], total: 0, limit: 50, offset: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
);
const rpc = vi
.spyOn(getRealtimeClient(), "rpc")
.mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 } as unknown as never);
await api.adminErrors({ status: "open", severity: "critical" });
expect(calls[0]).toContain("status=open");
expect(calls[0]).toContain("severity=critical");
expect(rpc).toHaveBeenCalledTimes(1);
const [method, path] = rpc.mock.calls[0];
expect(method).toBe("GET");
expect(path).toContain("status=open");
expect(path).toContain("severity=critical");
// An empty string means "all", and must not be sent as a filter value.
expect(calls[0]).not.toContain("source=");
expect(calls[0]).not.toContain("search=");
expect(path).not.toContain("source=");
expect(path).not.toContain("search=");
});
it("sends no query string at all when there are no filters", async () => {
const calls: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
calls.push(url);
return new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
);
const rpc = vi
.spyOn(getRealtimeClient(), "rpc")
.mockResolvedValue([] as unknown as never);
await api.adminErrorGroups();
expect(calls[0]).not.toContain("?");
const [, path] = rpc.mock.calls[0];
expect(path).not.toContain("?");
});
});
describe("agy account client", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function stubJson(body: unknown) {
const calls: { method: string; url: string; body: string }[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({
method: init?.method ?? "GET",
url,
body: String(init?.body ?? ""),
});
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
);
return calls;
function stubRpc(body: unknown) {
return vi
.spyOn(getRealtimeClient(), "rpc")
.mockResolvedValue(body as unknown as never);
}
it("posts to the login start endpoint", async () => {
const calls = stubJson({ login_id: "l1", auth_url: "https://accounts.google.com/..." });
const rpc = stubRpc({ login_id: "l1", auth_url: "https://accounts.google.com/..." });
await api.agyStartLogin();
expect(calls[0].method).toBe("POST");
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/start");
const [method, path] = rpc.mock.calls[0];
expect(method).toBe("POST");
expect(path).toContain("/api/admin/agy/accounts/login/start");
});
it("sends the pasted callback URL back verbatim", async () => {
const calls = stubJson({ id: "a1", email: "x@gmail.com" });
const rpc = stubRpc({ id: "a1", email: "x@gmail.com" });
await api.agyCompleteLogin({
login_id: "l1",
callback_url: "http://localhost:51121/oauth-callback?code=c&state=s",
});
expect(calls[0].url).toContain("/api/admin/agy/accounts/login/complete");
expect(calls[0].body).toContain("callback_url");
const [method, path, sentBody] = rpc.mock.calls[0];
expect(path).toContain("/api/admin/agy/accounts/login/complete");
expect(JSON.stringify(sentBody)).toContain("callback_url");
});
it("routes activate/deactivate to the right verb", async () => {
const calls = stubJson({ id: "a1" });
const rpc = stubRpc({ id: "a1" });
await api.agySetAccountActive("a1", false);
await api.agySetAccountActive("a1", true);
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/deactivate");
expect(calls[1].url).toContain("/api/admin/agy/accounts/a1/activate");
expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1/deactivate");
expect(rpc.mock.calls[1][1]).toContain("/api/admin/agy/accounts/a1/activate");
});
it("deletes with the DELETE verb", async () => {
const calls = stubJson(null);
const rpc = stubRpc(null);
await api.agyDeleteAccount("a1");
expect(calls[0].method).toBe("DELETE");
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1");
expect(rpc.mock.calls[0][0]).toBe("DELETE");
expect(rpc.mock.calls[0][1]).toContain("/api/admin/agy/accounts/a1");
});
it("fetches the live model catalog for one account", async () => {
const calls = stubJson([
const rpc = stubRpc([
{
id: "gemini-3-flash",
display_name: "Gemini 3 Flash",
@@ -184,21 +145,23 @@ describe("agy account client", () => {
},
]);
const models = await api.agyAccountModels("a1");
expect(calls[0].method).toBe("GET");
expect(calls[0].url).toContain("/api/admin/agy/accounts/a1/models");
const [method, path] = rpc.mock.calls[0];
expect(method).toBe("GET");
expect(path).toContain("/api/admin/agy/accounts/a1/models");
expect(models.map((m) => m.id)).toContain("gemini-3-pro-high");
expect(models[0].supported_efforts).toEqual(["low", "medium", "high"]);
});
it("creates a model from the cascade — no manual names", async () => {
const calls = stubJson({ id: "m1", internal_name: "gemini-3-pro-high" });
const rpc = stubRpc({ id: "m1", internal_name: "gemini-3-pro-high" });
await api.adminCreateModel({
internal_name: "gemini-3-pro-high",
capabilities: ["text", "reasoning"],
reasoning_effort: "high",
});
expect(calls[0].url).toContain("/api/admin/models");
expect(calls[0].body).toContain("gemini-3-pro-high");
expect(calls[0].body).toContain("reasoning_effort");
const [method, path, sentBody] = rpc.mock.calls[0];
expect(path).toContain("/api/admin/models");
expect(JSON.stringify(sentBody)).toContain("gemini-3-pro-high");
expect(JSON.stringify(sentBody)).toContain("reasoning_effort");
});
});