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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user