Files
smart-org/apps/web/src/lib/api.ts
T
mjalmoudy94 8066e9d055 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.
2026-10-13 13:54:56 +03:30

601 lines
23 KiB
TypeScript

/**
* Typed API client.
*
* One place knows the base URL, the bearer token and the error envelope. Every
* call returns parsed JSON or throws `ApiError`, so components never have to
* inspect status codes.
*/
import type { ApiErrorBody } from "@agy/contracts";
import { getRealtimeClient } from "@/lib/ws";
/**
* Resolves the API base URL.
* In a browser:
* - If NEXT_PUBLIC_API_URL is configured and not pointing to localhost on a remote host, use it.
* - Otherwise default to relative path "" so requests go directly to the current origin (e.g. /api/*)
* behind reverse proxies (Caddy), preventing ERR_CONNECTION_REFUSED on clients.
* In SSR / Node / tests:
* - Fall back to NEXT_PUBLIC_API_URL or "http://localhost:8000".
*/
export function getApiBaseUrl(): string {
const configured = process.env.NEXT_PUBLIC_API_URL?.trim();
if (typeof window !== "undefined") {
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
if (!configured || (!isLocalhost && configured.includes("localhost"))) {
return "";
}
return configured;
}
return configured || "http://localhost:8000";
}
export const API_BASE_URL = getApiBaseUrl();
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly details?: unknown;
/**
* The server's correlation id for this failure. It is also the
* `X-Request-ID` response header, and it identifies one row in the platform
* error console — so a bug report that includes it is immediately actionable.
*/
readonly requestId?: string;
constructor(
status: number,
code: string,
message: string,
details?: unknown,
requestId?: string,
) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.details = details;
this.requestId = requestId;
}
}
const TOKEN_STORAGE_KEY = "agy.access_token";
export function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(TOKEN_STORAGE_KEY);
} catch {
return null;
}
}
export function setStoredToken(token: string | null): void {
if (typeof window === "undefined") return;
try {
if (token) window.localStorage.setItem(TOKEN_STORAGE_KEY, token);
else window.localStorage.removeItem(TOKEN_STORAGE_KEY);
} catch {
/* storage disabled — the session simply will not survive a reload */
}
}
export interface RequestOptions {
method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
body?: unknown;
token?: string | null;
signal?: AbortSignal;
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
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";
let response: Response;
try {
response = await fetch(`${getApiBaseUrl()}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
cache: "no-store",
});
} catch (error) {
throw new ApiError(0, "network_error", "ارتباط با سرور برقرار نشد.", error);
}
if (response.status === 204) return undefined as T;
const text = await response.text();
const payload = text ? (JSON.parse(text) as unknown) : null;
if (!response.ok) {
const envelope = payload as ApiErrorBody | null;
const code = envelope?.error?.code ?? "http_error";
const message = envelope?.error?.message ?? `خطای سرور (${response.status})`;
const requestId =
envelope?.error?.request_id ?? response.headers.get("X-Request-ID") ?? undefined;
throw new ApiError(response.status, code, message, envelope?.error?.details, requestId);
}
return payload as T;
}
export const api = {
health: () => apiRequest<{ status: string }>("/health"),
readiness: () => apiRequest<import("@agy/contracts").ReadinessResponse>("/health/ready"),
signup: (body: { email: string; password: string; name?: string }) =>
apiRequest<import("@agy/contracts").TokenResponse>("/api/auth/signup", {
method: "POST",
body,
}),
login: (body: { email: string; password: string }) =>
apiRequest<import("@agy/contracts").TokenResponse>("/api/auth/login", {
method: "POST",
body,
}),
logout: () => apiRequest<void>("/api/auth/logout", { method: "POST" }),
me: () => apiRequest<import("@agy/contracts").MeResponse>("/api/auth/me"),
listOrganizations: () =>
apiRequest<import("@agy/contracts").OrganizationWithRole[]>("/api/organizations"),
createOrganization: (name: string) =>
apiRequest<import("@agy/contracts").OrganizationWithRole>("/api/organizations", {
method: "POST",
body: { name },
}),
listMembers: (orgId: string) =>
apiRequest<import("@agy/contracts").Member[]>(`/api/organizations/${orgId}/members`),
createInvite: (orgId: string, body: { email: string; role: import("@agy/contracts").Role }) =>
apiRequest<import("@agy/contracts").Invite>(`/api/organizations/${orgId}/invites`, {
method: "POST",
body,
}),
updateMember: (
orgId: string,
memberId: string,
body: {
role?: import("@agy/contracts").Role;
status?: import("@agy/contracts").MembershipStatus;
},
) =>
apiRequest<import("@agy/contracts").Member>(
`/api/organizations/${orgId}/members/${memberId}`,
{ method: "PATCH", body },
),
removeMember: (orgId: string, memberId: string) =>
apiRequest<void>(`/api/organizations/${orgId}/members/${memberId}`, {
method: "DELETE",
}),
listAudit: (orgId: string) =>
apiRequest<import("@agy/contracts").AuditEvent[]>(`/api/organizations/${orgId}/audit`),
// --- projects -------------------------------------------------------------
listProjects: (orgId: string) =>
apiRequest<import("@agy/contracts").Project[]>(`/api/organizations/${orgId}/projects`),
createProject: (orgId: string, body: { name: string; description?: string }) =>
apiRequest<import("@agy/contracts").Project>(`/api/organizations/${orgId}/projects`, {
method: "POST",
body,
}),
getProject: (orgId: string, projectId: string) =>
apiRequest<import("@agy/contracts").Project>(
`/api/organizations/${orgId}/projects/${projectId}`,
),
// --- repository -----------------------------------------------------------
ensureRepo: (orgId: string, projectId: string) =>
apiRequest<import("@agy/contracts").Repository>(
`/api/organizations/${orgId}/projects/${projectId}/repo/ensure`,
{ method: "POST" },
),
getRepo: (orgId: string, projectId: string) =>
apiRequest<import("@agy/contracts").Repository>(
`/api/organizations/${orgId}/projects/${projectId}/repo`,
),
getRepoStatus: (orgId: string, projectId: string) =>
apiRequest<import("@agy/contracts").RepoStatus>(
`/api/organizations/${orgId}/projects/${projectId}/repo/status`,
),
// --- preview domains ------------------------------------------------------
listPreviewDomains: (orgId: string, projectId: string) =>
apiRequest<import("@agy/contracts").PreviewDomain[]>(
`/api/organizations/${orgId}/projects/${projectId}/preview/domains`,
),
createPreviewDomain: (
orgId: string,
projectId: string,
body: { subdomain: string; port?: number },
) =>
apiRequest<import("@agy/contracts").PreviewDomain>(
`/api/organizations/${orgId}/projects/${projectId}/preview/domains`,
{ method: "POST", body },
),
deletePreviewDomain: (orgId: string, projectId: string, domainId: string) =>
apiRequest<void>(
`/api/organizations/${orgId}/projects/${projectId}/preview/domains/${domainId}`,
{ method: "DELETE" },
),
// --- workforce ------------------------------------------------------------
listAgentTemplates: (orgId: string) =>
apiRequest<import("@agy/contracts").AgentTemplate[]>(
`/api/organizations/${orgId}/agent-templates`,
),
listRoutingChoices: (orgId: string) =>
apiRequest<import("@agy/contracts").RoutingChoice[]>(
`/api/organizations/${orgId}/routing-choices`,
),
listEmployees: (orgId: string) =>
apiRequest<import("@agy/contracts").Employee[]>(`/api/organizations/${orgId}/employees`),
createEmployee: (
orgId: string,
body: {
name: string;
role_key: import("@agy/contracts").RoleKey;
template_id?: string | null;
project_id?: string | null;
routing_policy_id?: string | null;
},
) =>
apiRequest<import("@agy/contracts").Employee>(`/api/organizations/${orgId}/employees`, {
method: "POST",
body,
}),
getEmployee: (orgId: string, employeeId: string) =>
apiRequest<import("@agy/contracts").EmployeeDetail>(
`/api/organizations/${orgId}/employees/${employeeId}`,
),
updateEmployee: (
orgId: string,
employeeId: string,
body: {
name?: string;
status?: import("@agy/contracts").EmployeeStatus;
project_id?: string | null;
routing_policy_id?: string | null;
},
) =>
apiRequest<import("@agy/contracts").Employee>(
`/api/organizations/${orgId}/employees/${employeeId}`,
{ method: "PATCH", body },
),
addPromptVersion: (orgId: string, employeeId: string, content: string) =>
apiRequest<import("@agy/contracts").PromptVersion>(
`/api/organizations/${orgId}/employees/${employeeId}/prompt-versions`,
{ method: "POST", body: { content } },
),
// --- manager chat ---------------------------------------------------------
listThreads: (orgId: string) =>
apiRequest<import("@agy/contracts").Thread[]>(`/api/organizations/${orgId}/threads`),
createThread: (orgId: string, body: { title?: string; project_id?: string | null }) =>
apiRequest<import("@agy/contracts").Thread>(`/api/organizations/${orgId}/threads`, {
method: "POST",
body,
}),
getThread: (orgId: string, threadId: string) =>
apiRequest<import("@agy/contracts").ThreadDetail>(
`/api/organizations/${orgId}/threads/${threadId}`,
),
postMessage: (orgId: string, threadId: string, body: { content: string; employee_id?: string | null }) =>
apiRequest<import("@agy/contracts").PostMessageResponse>(
`/api/organizations/${orgId}/threads/${threadId}/messages`,
{ method: "POST", body },
),
// --- tasks & runs ---------------------------------------------------------
listTasks: (orgId: string, status?: string) =>
apiRequest<import("@agy/contracts").Task[]>(
`/api/organizations/${orgId}/tasks${status ? `?status=${status}` : ""}`,
),
createTask: (
orgId: string,
body: {
title: string;
description?: string;
project_id?: string | null;
thread_id?: string | null;
assigned_employee_id?: string | null;
},
) =>
apiRequest<import("@agy/contracts").Task>(`/api/organizations/${orgId}/tasks`, {
method: "POST",
body,
}),
getTask: (orgId: string, taskId: string) =>
apiRequest<import("@agy/contracts").TaskDetail>(
`/api/organizations/${orgId}/tasks/${taskId}`,
),
startRun: (orgId: string, taskId: string, employeeId?: string | null) =>
apiRequest<import("@agy/contracts").RunDetail>(
`/api/organizations/${orgId}/tasks/${taskId}/runs`,
{ method: "POST", body: { employee_id: employeeId ?? null } },
),
getRun: (orgId: string, runId: string) =>
apiRequest<import("@agy/contracts").RunDetail>(`/api/organizations/${orgId}/runs/${runId}`),
cancelRun: (orgId: string, runId: string) =>
apiRequest<import("@agy/contracts").RunDetail>(
`/api/organizations/${orgId}/runs/${runId}/cancel`,
{ method: "POST" },
),
listPendingApprovals: (orgId: string) =>
apiRequest<import("@agy/contracts").ToolCall[]>(
`/api/organizations/${orgId}/approvals/pending`,
),
// --- billing --------------------------------------------------------------
billingSummary: (orgId: string) =>
apiRequest<import("@agy/contracts").BillingSummary>(
`/api/organizations/${orgId}/billing/summary`,
),
billingUsageEvents: (orgId: string, limit = 100) =>
apiRequest<import("@agy/contracts").UsageEvent[]>(
`/api/organizations/${orgId}/billing/usage-events?limit=${limit}`,
),
billingInvoices: (orgId: string) =>
apiRequest<import("@agy/contracts").Invoice[]>(
`/api/organizations/${orgId}/billing/invoices`,
),
billingPayments: (orgId: string) =>
apiRequest<import("@agy/contracts").Payment[]>(
`/api/organizations/${orgId}/billing/payments`,
),
billingAddCredit: (orgId: string, amount: number) =>
apiRequest<import("@agy/contracts").Wallet>(
`/api/organizations/${orgId}/billing/add-credit`,
{ method: "POST", body: { amount } },
),
billingCheckout: (orgId: string, amount: number) =>
apiRequest<{ payment_id: string; checkout_url: string; amount: number }>(
`/api/organizations/${orgId}/billing/checkout`,
{ method: "POST", body: { amount } },
),
// --- tools & approvals ----------------------------------------------------
listTools: (orgId: string) =>
apiRequest<import("@agy/contracts").ToolSpec[]>(`/api/organizations/${orgId}/tools`),
setToolPermission: (orgId: string, employeeId: string, toolKey: string, allowed: boolean) =>
apiRequest<import("@agy/contracts").ToolPermission>(
`/api/organizations/${orgId}/employees/${employeeId}/tools/${encodeURIComponent(toolKey)}`,
{ method: "PUT", body: { allowed } },
),
decideApproval: (orgId: string, toolCallId: string, approve: boolean, note = "") =>
apiRequest<import("@agy/contracts").ToolCall>(
`/api/organizations/${orgId}/approvals/${toolCallId}/decide`,
{ method: "POST", body: { approve, note } },
),
// --- sandbox --------------------------------------------------------------
// Every call here is metered into the usage ledger server-side. The client
// never sends a timeout or an output cap: limits come from policy only.
sandboxTemplates: (orgId: string) =>
apiRequest<import("@agy/contracts").SandboxTemplate[]>(
`/api/organizations/${orgId}/sandbox/templates`,
),
sandboxLimits: (orgId: string, projectId?: string) =>
apiRequest<import("@agy/contracts").SandboxResourceLimit>(
`/api/organizations/${orgId}/sandbox/limits${
projectId ? `?project_id=${encodeURIComponent(projectId)}` : ""
}`,
),
sandboxWorkspaces: (orgId: string, projectId: string) =>
apiRequest<import("@agy/contracts").SandboxWorkspace[]>(
`/api/organizations/${orgId}/sandbox/workspaces?project_id=${encodeURIComponent(projectId)}`,
),
sandboxCreateWorkspace: (orgId: string, body: { project_id: string; template_id: string }) =>
apiRequest<import("@agy/contracts").SandboxWorkspace>(
`/api/organizations/${orgId}/sandbox/workspaces`,
{ method: "POST", body },
),
sandboxDestroyWorkspace: (orgId: string, workspaceId: string) =>
apiRequest<import("@agy/contracts").SandboxWorkspace>(
`/api/organizations/${orgId}/sandbox/workspaces/${workspaceId}`,
{ method: "DELETE" },
),
sandboxStartSession: (orgId: string, workspaceId: string) =>
apiRequest<import("@agy/contracts").SandboxSession>(
`/api/organizations/${orgId}/sandbox/sessions`,
{ method: "POST", body: { workspace_id: workspaceId } },
),
sandboxStopSession: (orgId: string, sessionId: string) =>
apiRequest<import("@agy/contracts").SandboxSession>(
`/api/organizations/${orgId}/sandbox/sessions/${sessionId}/stop`,
{ method: "POST" },
),
sandboxRunCommand: (orgId: string, sessionId: string, command: string) =>
apiRequest<import("@agy/contracts").SandboxCommandRun>(
`/api/organizations/${orgId}/sandbox/sessions/${sessionId}/commands`,
{ method: "POST", body: { command } },
),
sandboxCommands: (orgId: string, sessionId: string) =>
apiRequest<import("@agy/contracts").SandboxCommandRun[]>(
`/api/organizations/${orgId}/sandbox/sessions/${sessionId}/commands`,
),
sandboxReadFile: (orgId: string, sessionId: string, path: string) =>
apiRequest<import("@agy/contracts").SandboxFileRead>(
`/api/organizations/${orgId}/sandbox/sessions/${sessionId}/files/read`,
{ method: "POST", body: { path } },
),
sandboxWriteFile: (orgId: string, sessionId: string, path: string, content: string) =>
apiRequest<void>(
`/api/organizations/${orgId}/sandbox/sessions/${sessionId}/files/write`,
{ method: "POST", body: { path, content } },
),
sandboxSnapshot: (orgId: string, sessionId: string, name: string) =>
apiRequest<import("@agy/contracts").SandboxSnapshot>(
`/api/organizations/${orgId}/sandbox/sessions/${sessionId}/snapshots`,
{ method: "POST", body: { name } },
),
sandboxPreviewUrl: (orgId: string, sessionId: string, port: number) =>
apiRequest<import("@agy/contracts").SandboxPreviewUrl>(
`/api/organizations/${orgId}/sandbox/sessions/${sessionId}/preview-url?port=${port}`,
{ method: "POST" },
),
// --- platform owner -------------------------------------------------------
// agy accounts: OAuth login instead of API keys.
agyAccounts: () =>
apiRequest<import("@agy/contracts").AgyAccount[]>("/api/admin/agy/accounts"),
agyStartLogin: () =>
apiRequest<{ login_id: string; auth_url: string }>(
"/api/admin/agy/accounts/login/start",
{ method: "POST" },
),
agyCompleteLogin: (body: { login_id: string; callback_url: string }) =>
apiRequest<import("@agy/contracts").AgyAccount>(
"/api/admin/agy/accounts/login/complete",
{ method: "POST", body },
),
agySetAccountActive: (accountId: string, active: boolean) =>
apiRequest<import("@agy/contracts").AgyAccount>(
`/api/admin/agy/accounts/${accountId}/${active ? "activate" : "deactivate"}`,
{ method: "POST" },
),
agyDeleteAccount: (accountId: string) =>
apiRequest<null>(`/api/admin/agy/accounts/${accountId}`, { method: "DELETE" }),
agyTestAccount: (accountId: string) =>
apiRequest<{
ok: boolean;
detail: string;
model_internal_name: string;
input_tokens: number;
output_tokens: number;
}>(`/api/admin/agy/accounts/${accountId}/test`, { method: "POST" }),
// Live model catalog of one account — fetched from Google on every call, so
// the model dropdown always matches that subscription's entitlement along with
// its supported reasoning efforts.
agyAccountModels: async (accountId: string) => {
const raw = await apiRequest<(import("@agy/contracts").AgyAccountModel | string)[]>(
`/api/admin/agy/accounts/${accountId}/models`,
);
return raw.map((item): import("@agy/contracts").AgyAccountModel => {
if (typeof item === "string") {
return {
id: item,
display_name: item,
supports_thinking: false,
supported_efforts: [],
};
}
return item;
});
},
adminModels: () => apiRequest<import("@agy/contracts").AdminModel[]>("/api/admin/models"),
adminCreateModel: (body: {
internal_name: string;
display_name_for_admin?: string;
capabilities: import("@agy/contracts").Capability[];
input_price_per_1m?: number;
output_price_per_1m?: number;
reasoning_effort?: string;
}) =>
apiRequest<import("@agy/contracts").AdminModel>("/api/admin/models", {
method: "POST",
body,
}),
adminRoutingPolicies: () =>
apiRequest<import("@agy/contracts").AdminRoutingPolicy[]>("/api/admin/routing-policies"),
adminCreateRoutingPolicy: (body: {
name: string;
mode: import("@agy/contracts").RoutingMode;
description?: string;
is_default?: boolean;
}) =>
apiRequest<import("@agy/contracts").AdminRoutingPolicy>("/api/admin/routing-policies", {
method: "POST",
body,
}),
adminRateCards: () =>
apiRequest<import("@agy/contracts").AdminRateCard[]>("/api/admin/rate-cards"),
adminUpsertRateCard: (body: {
unit: import("@agy/contracts").RateUnit;
name?: string;
customer_price: number;
internal_cost_estimate?: number;
is_active?: boolean;
}) =>
apiRequest<import("@agy/contracts").AdminRateCard>("/api/admin/rate-cards", {
method: "PUT",
body,
}),
adminOrganizations: () =>
apiRequest<import("@agy/contracts").AdminOrganization[]>("/api/admin/organizations"),
adminUsage: () => apiRequest<import("@agy/contracts").AdminUsageRow[]>("/api/admin/usage"),
adminValidateModelChoice: (mode: import("@agy/contracts").RoutingMode) =>
apiRequest<import("@agy/contracts").ModelChoice>(
`/api/admin/validate-model-choice?mode=${encodeURIComponent(mode)}`,
{ method: "POST" },
),
// --- error console --------------------------------------------------------
// The console exists so a failure that already happened can be diagnosed
// without reproducing it. Grouped by fingerprint, so a bug that fired a
// thousand times is one line.
adminErrorSummary: () =>
apiRequest<import("@agy/contracts").ErrorSummary>("/api/admin/errors/summary"),
adminErrorGroups: (query: import("@agy/contracts").ErrorQuery = {}) =>
apiRequest<import("@agy/contracts").ErrorGroup[]>(
`/api/admin/errors/groups${errorQueryString(query)}`,
),
adminErrors: (query: import("@agy/contracts").ErrorQuery = {}) =>
apiRequest<import("@agy/contracts").ErrorListResponse>(
`/api/admin/errors${errorQueryString(query)}`,
),
adminError: (errorId: string) =>
apiRequest<import("@agy/contracts").ErrorEvent>(
`/api/admin/errors/${encodeURIComponent(errorId)}`,
),
adminResolveError: (errorId: string, note = "") =>
apiRequest<import("@agy/contracts").ResolveErrorResponse>(
`/api/admin/errors/${encodeURIComponent(errorId)}/resolve`,
{ method: "POST", body: { note } },
),
adminResolveErrorGroup: (fingerprint: string, note = "") =>
apiRequest<import("@agy/contracts").ResolveErrorResponse>(
`/api/admin/errors/groups/${encodeURIComponent(fingerprint)}/resolve`,
{ method: "POST", body: { note } },
),
adminReopenErrorGroup: (fingerprint: string) =>
apiRequest<import("@agy/contracts").ResolveErrorResponse>(
`/api/admin/errors/groups/${encodeURIComponent(fingerprint)}/reopen`,
{ method: "POST" },
),
};
/** Serialise the console's filters, dropping anything unset. */
function errorQueryString(query: import("@agy/contracts").ErrorQuery): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== "") {
params.set(key, String(value));
}
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}