Files
Cyrene_v2/frontend/web/src/lib/api.ts
T
Aska 1ebc82fffd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: JWT过期自动重置 + 用户端登录页 + 系统设置前三个真实实现
agent-111 JWT过期重置+系统设置去占位:
- ws.ts 识别401/过期 markAuthExpired(清token+停重连+expired态),isTokenExpired;wsParse.ts拆出压行371;scheduleReconnect有token未过期才重连
- api.ts REST 401→auth_expired+清token+引导重登;login走skipAuthExpired密码错不误清
- types.ts WsStatus加expired;TopBar/Nav expired显示登录过期;System.tsx去alert占位改4张事实卡+合并Notice

agent-113 用户端登录页:
- components/Login.tsx Miuix登录卡(用户名/密码→login()→setAuthToken);App !getAuthToken()→<Login>;AdminApp LoginGate指向

agent-112 系统设置前三个(owner+审计):
- auth.APIKey.RevokeAll(吊销全部key);auth.JWTService.RotateSecret(≥32校验+并发安全+新密钥不回显)
- gateway admin_system.go: POST revoke-keys/rotate-jwt/backup(VACUUM INTO)/restore(防路径穿越+恢复前自动备份+原子替换+需重启)
- wire.go 传DatabasePath;全仓 build/vet/test绿+交叉编译;api-client v0.17系统设置契约

实机验证: 登录(role=owner)+revoke-keys 200+渠道列表含kook; 全仓绿+前端build绿; .env密码仅登录验证不打印
2026-09-07 13:57:36 +08:00

626 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AdminAutomationRecord,
AdminChannelRecord,
AdminIoTDevice,
AdminLlmConfig,
AdminLlmProvider,
AdminObservabilityMetrics,
AdminPluginRecord,
AdminRagHit,
AdminRagResponse,
AdminTraceSpan,
AdminUserRecord,
ApiErrorShape,
ApiKeyIssueResult,
AuditRecord,
AutomationCreateResult,
AutomationRunResult,
ChannelTestResult,
ChatMessage,
ChatSyncResult,
LlmProviderRequest,
LlmTestRequest,
LlmTestResult,
LoginResult,
MemoryAddRequest,
MemoryAddResult,
MemoryRecord,
MemorySearchRequest,
MemorySearchResult,
Mode,
PersonaRecord,
PersonaUpsertRequest,
RagIndexRequest,
Role,
SessionMeta,
SessionRecord,
} from "./types";
import { getClientId } from "./client";
/**
* REST client for the Cyrene v2 gateway. The gateway wraps every success in
* `{data}` and every failure in `{error:{code,message}}` (Docs/api-client.md).
* We use same-origin relative paths so the same code works behind the Vite dev
* proxy and inside the single-binary gateway on :8080.
*/
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
constructor(status: number, code: string | undefined, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
}
}
const API_BASE = "/api/v1";
const TOKEN_KEY = "cyrene.jwt";
const ROLE_KEY = "cyrene.role";
const API_KEY_KEY = "cyrene.apikey";
export function getAuthToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setAuthToken(token: string | null): void {
if (token) {
localStorage.setItem(TOKEN_KEY, token);
setStoredRole(decodeJwtRole(token));
} else {
localStorage.removeItem(TOKEN_KEY);
setStoredRole(null);
}
}
/** Decode the RBAC role from a JWT payload (client-side only; never verified).
* base64url is converted to base64 before decoding to survive `-`/`_`. */
export function decodeJwtRole(token: string): Role | null {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const payload = JSON.parse(atob(padded)) as Record<string, unknown>;
const r = payload.role;
return r === "owner" || r === "admin" || r === "viewer" ? r : null;
} catch {
return null;
}
}
/** Persisted RBAC role, refreshed when the token changes. Falls back to
* decoding the token so a session created before role persistence still works. */
export function getStoredRole(): Role | null {
const r = localStorage.getItem(ROLE_KEY);
if (r === "owner" || r === "admin" || r === "viewer") return r;
const token = getAuthToken();
return token ? decodeJwtRole(token) : null;
}
export function setStoredRole(role: Role | null): void {
if (role) localStorage.setItem(ROLE_KEY, role);
else localStorage.removeItem(ROLE_KEY);
}
/** True when the JWT's `exp` (unix seconds) is already in the past. Used client-side
* to recognise a handshake rejection as "token expired" before the browser ever
* exposes the 401 (WS handshake failures surface only as a generic close). */
export function isTokenExpired(token: string): boolean {
const parts = token.split(".");
if (parts.length < 2) return false;
try {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const payload = JSON.parse(atob(padded)) as { exp?: number };
const exp = payload.exp;
return typeof exp === "number" && exp > 0 && Math.floor(Date.now() / 1000) >= exp;
} catch {
return false;
}
}
/** Subscribe to an "auth expired" event. The REST layer fires this exactly once
* when a non-login request comes back 401/unauthorized, after already clearing
* the stored token; the WS layer subscribes to stop reconnecting + flag the UI. */
type AuthExpiredHandler = () => void;
const authExpiredHandlers = new Set<AuthExpiredHandler>();
export function onAuthExpired(handler: AuthExpiredHandler): () => void {
authExpiredHandlers.add(handler);
return () => {
authExpiredHandlers.delete(handler);
};
}
function fireAuthExpired(): void {
authExpiredHandlers.forEach((h) => h());
}
/** Optional API key for admin endpoints (X-API-Key). */
export function getApiKey(): string | null {
return localStorage.getItem(API_KEY_KEY);
}
export function setApiKey(key: string | null): void {
if (key) localStorage.setItem(API_KEY_KEY, key);
else localStorage.removeItem(API_KEY_KEY);
}
interface Envelope {
data?: unknown;
error?: ApiErrorShape;
}
/** Result of saving/deleting a channel account config (persisted to channels.json). */
export interface ChannelConfigSaveResult {
persisted: boolean;
path: string;
platform: string;
accountId: string;
}
async function request<T>(
path: string,
init: RequestInit = {},
opts: { skipAuthExpired?: boolean } = {}
): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
headers.set("X-API-Version", "1");
const token = getAuthToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const apiKey = getApiKey();
if (apiKey) headers.set("X-API-Key", apiKey);
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, { ...init, headers });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "网络请求失败";
throw new ApiError(0, "network_error", msg);
}
let body: Envelope;
try {
body = (await res.json()) as Envelope;
} catch {
throw new ApiError(res.status, "bad_response", "服务端返回了无法解析的响应");
}
if (!res.ok || body.error) {
const code = body.error?.code ?? (res.status === 401 ? "unauthorized" : "unknown");
// A 401/unauthorized on a logged-in (non-login) call means the stored token is
// gone/expired: clear it and notify the WS layer so it stops reconnecting, then
// throw a dedicated code so the UI can prompt re-login. The login call itself is
// exempt — a bad password is a per-request failure, not a stale session.
if (!opts.skipAuthExpired && (res.status === 401 || code === "unauthorized")) {
setAuthToken(null);
fireAuthExpired();
throw new ApiError(res.status, "auth_expired", "登录已过期,请重新登录");
}
throw new ApiError(
res.status,
code,
body.error?.message ?? `请求失败(${res.status})`
);
}
return body.data as T;
}
/** POST /api/v1/auth/login — exchanges user/password for a JWT. */
export function login(username: string, password: string): Promise<LoginResult> {
return request<LoginResult>(
"/auth/login",
{
method: "POST",
body: JSON.stringify({ username, password }),
},
{ skipAuthExpired: true }
);
}
/** GET /api/v1/health — liveness probe. */
export function health(): Promise<{ status: string }> {
return request<{ status: string }>("/health");
}
/** POST /api/v1/chat — one synchronous agent turn. The body carries the sender's
* persistent per-browser deviceId (design/16 §4.5) so the persisted user and
* assistant messages are tagged with the concrete client. */
export function chatSync(
sessionId: string,
text: string
): Promise<ChatSyncResult> {
return request<ChatSyncResult>("/chat", {
method: "POST",
body: JSON.stringify({ sessionId, text, deviceId: getClientId() }),
});
}
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel.
* `image` (a base64 data: URI or URL) is attached for the vision path; the
* gateway's chat body currently has no image field, so an unconfigured backend
* simply ignores it. `deviceId` mirrors the client's per-browser id onto the
* persisted message (design/16 §4.5). */
export function chatStream(
sessionId: string,
text: string,
image?: string
): Promise<{ stream: true }> {
return request<{ stream: true }>("/chat", {
method: "POST",
body: JSON.stringify({ sessionId, text, image, stream: true, deviceId: getClientId() }),
});
}
/** One chat message as returned by GET /api/v1/sessions/{id}/messages. Mirrors
* the `chat_messages` row; `mode`/`mood`/`image`/`thinking` are optional. */
export interface MessageDto {
id: string;
sessionId: string;
role: "user" | "assistant" | "system";
content: string;
mode?: Mode;
mood?: string;
image?: string;
thinking?: string;
/** Source-platform tag (design/16 §4.4): "web" or an IM channel platform. */
platform?: string;
/** Concrete client/device that produced the message (design/16 §4.5). */
deviceId?: string;
/** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */
senderName?: string;
createdAt: number;
}
/** Map a wire message into a display ChatMessage (carrying its sessionId). */
function toChatMessage(m: MessageDto): ChatMessage {
return {
id: m.id,
sessionId: m.sessionId,
role: m.role,
content: m.content,
mode: m.mode,
mood: m.mood,
image: m.image,
thinking: m.thinking,
platform: m.platform,
deviceId: m.deviceId,
senderName: m.senderName,
createdAt: m.createdAt,
};
}
/** GET /api/v1/sessions — the main + secondary sessions for this web account. */
export function fetchSessions(): Promise<SessionMeta[]> {
return request<SessionMeta[]>("/sessions");
}
/** POST /api/v1/sessions — creates a new secondary session. */
export function createSubSession(title?: string): Promise<SessionMeta> {
return request<SessionMeta>("/sessions", {
method: "POST",
body: JSON.stringify(title ? { title } : {}),
});
}
/** GET /api/v1/sessions/{id}/messages?before=&limit=&platform= — hot-paginated
* history, optionally filtered to one source platform (design/16 §4.4). The wire
* returns newest-first, but callers render chronologically (oldest first), so we
* reverse here: page 1 is the most recent `limit` messages in display order, and
* `before` (the oldest message's createdAt) walks upward. The Web client omits
* `platform` for the full shared main session; an IM adapter passes its own
* platform to see only its lane. */
export async function fetchMessages(
sessionId: string,
before?: number,
limit = 50,
platform?: string
): Promise<ChatMessage[]> {
const qs = new URLSearchParams();
qs.set("limit", String(limit));
if (before !== undefined) qs.set("before", String(before));
if (platform) qs.set("platform", platform);
const list = await request<MessageDto[]>(
`/sessions/${encodeURIComponent(sessionId)}/messages?${qs.toString()}`
);
return list.map(toChatMessage).reverse();
}
/** DELETE /api/v1/sessions/{id} — removes a secondary session (and its messages). */
export function deleteSession(
sessionId: string
): Promise<{ id: string; deleted: true }> {
return request<{ id: string; deleted: true }>(
`/sessions/${encodeURIComponent(sessionId)}`,
{ method: "DELETE" }
);
}
/** GET /api/v1/personas — persona summaries. */
export function fetchPersonas(): Promise<PersonaRecord[]> {
return request<PersonaRecord[]>("/personas");
}
/** Is this error a "backend has not implemented it yet" case (404/501/403 on a
* not-yet-wired route) rather than a real connectivity problem? */
export function isNotImplemented(err: unknown): boolean {
if (err instanceof ApiError) {
return err.status === 404 || err.status === 501 || err.code === "not_found";
}
return false;
}
/* ----------------------------------------------------------------------------
* Admin API (/api/v1/admin/*). Reference: Docs/api-client.md §2 + design/10.
* Implemented endpoints return real data; the rest are contract-shaped and are
* expected to 404 until the backend wires them — callers show a graceful shell.
* -------------------------------------------------------------------------- */
/** GET /api/v1/admin/personas */
export function adminListPersonas(): Promise<PersonaRecord[]> {
return request<PersonaRecord[]>("/admin/personas");
}
/** GET /api/v1/admin/sessions — the persistent main + secondary sessions (design/16).
* The main session (fixed id) is always present, so the panel is never empty in a
* history-wired deployment. */
export function adminListSessions(): Promise<SessionMeta[]> {
return request<SessionMeta[]>("/admin/sessions");
}
/** GET /api/v1/admin/memories */
export function adminListMemories(): Promise<MemoryRecord[]> {
return request<MemoryRecord[]>("/admin/memories");
}
/** GET /api/v1/admin/plugins */
export function adminListPlugins(): Promise<AdminPluginRecord[]> {
return request<AdminPluginRecord[]>("/admin/plugins");
}
/** POST /api/v1/admin/plugins/policy — owner-only, audited. */
export function adminSetPluginPolicy(
name: string,
allowInGroup: boolean,
allowInStranger: boolean
): Promise<AdminPluginRecord> {
return request<AdminPluginRecord>("/admin/plugins/policy", {
method: "POST",
body: JSON.stringify({ name, allowInGroup, allowInStranger }),
});
}
/** GET /api/v1/admin/channels */
export function adminListChannels(): Promise<AdminChannelRecord[]> {
return request<AdminChannelRecord[]>("/admin/channels");
}
/** GET /api/v1/admin/iot */
export function adminListIoT(): Promise<AdminIoTDevice[]> {
return request<AdminIoTDevice[]>("/admin/iot");
}
// --- Planned M10 endpoints (contract-shaped, backend may 404). ---
/** GET /api/v1/admin/users */
export function adminListUsers(): Promise<AdminUserRecord[]> {
return request<AdminUserRecord[]>("/admin/users");
}
/** GET /api/v1/admin/llm */
export function adminGetLlm(): Promise<AdminLlmConfig> {
return request<AdminLlmConfig>("/admin/llm");
}
/** GET /api/v1/admin/automations */
export function adminListAutomations(): Promise<AdminAutomationRecord[]> {
return request<AdminAutomationRecord[]>("/admin/automations");
}
/** GET /api/v1/admin/rag — lists knowledge documents; when query is present the
* backend also returns matching hits. */
export function adminListRag(query?: string): Promise<AdminRagResponse> {
const qs = query ? `?query=${encodeURIComponent(query)}` : "";
return request<AdminRagResponse>(`/admin/rag${qs}`);
}
/** POST /api/v1/admin/rag/query — runs a real knowledge-base query. */
export function adminSearchRag(
query: string
): Promise<{ query: string; hits: AdminRagHit[] }> {
return request<{ query: string; hits: AdminRagHit[] }>("/admin/rag/query", {
method: "POST",
body: JSON.stringify({ query }),
});
}
/** POST /api/v1/admin/rag/delete — deletes a document. The backend returns a
* clear 501 until M10 lands the write path (upload/chunk/embed/delete). */
export function adminDeleteRag(id: string): Promise<{ id: string }> {
return request<{ id: string }>("/admin/rag/delete", {
method: "POST",
body: JSON.stringify({ id }),
});
}
/** POST /api/v1/admin/rag — imports one knowledge document (chunk + index), so it
* becomes keyword/searchable in the project index. */
export function adminIndexRag(req: RagIndexRequest): Promise<{ name: string }> {
return request<{ name: string }>("/admin/rag", {
method: "POST",
body: JSON.stringify(req),
});
}
/* --- Admin write endpoints (owner-only, audited; real store or honest 501). --- */
/** POST /api/v1/admin/personas — registers a persona (persisted to YAML + registry). */
export function adminCreatePersona(req: PersonaUpsertRequest): Promise<PersonaRecord> {
return request<PersonaRecord>("/admin/personas", {
method: "POST",
body: JSON.stringify(req),
});
}
/** PUT /api/v1/admin/personas/{id} — edits an existing persona. */
export function adminUpdatePersona(id: string, req: PersonaUpsertRequest): Promise<PersonaRecord> {
return request<PersonaRecord>(`/admin/personas/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/users — add user/role. No persistent user store yet, so the
* backend answers honestly with a 501 ("M10"). */
export function adminCreateUser(req: { username: string; role: string }): Promise<AdminUserRecord> {
return request<AdminUserRecord>("/admin/users", {
method: "POST",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/users/apikey — mints a key; the plaintext comes back once. */
export function adminIssueApiKey(role: string): Promise<ApiKeyIssueResult> {
return request<ApiKeyIssueResult>("/admin/users/apikey", {
method: "POST",
body: JSON.stringify({ role }),
});
}
/** DELETE /api/v1/admin/users/apikey/{id} — revokes a key by masked id. */
export function adminRevokeApiKey(id: string): Promise<{ id: string; revoked: boolean }> {
return request<{ id: string; revoked: boolean }>(`/admin/users/apikey/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
/** POST /api/v1/admin/iot/{id}/control — sends a control instruction to a device. */
export function adminIoTControl(id: string, action: string, params?: Record<string, unknown>): Promise<AdminIoTDevice> {
return request<AdminIoTDevice>(`/admin/iot/${encodeURIComponent(id)}/control`, {
method: "POST",
body: JSON.stringify({ action, params }),
});
}
/** POST /api/v1/admin/iot/scenarios — honest 501 (no scenario store yet). */
export function adminIoTScenario(req: { name: string }): Promise<{ name: string }> {
return request<{ name: string }>("/admin/iot/scenarios", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/iot/rules — honest 501 (no IoT rule store yet). */
export function adminIoTRule(req: { name: string; expression: string }): Promise<{ name: string }> {
return request<{ name: string }>("/admin/iot/rules", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/automations — registers a real cron/event automation. */
export function adminCreateAutomation(req: {
name: string;
kind: string;
expression: string;
}): Promise<AutomationCreateResult> {
return request<AutomationCreateResult>("/admin/automations", {
method: "POST",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/automations/{id}/run — manually triggers a rule. */
export function adminRunAutomation(id: string): Promise<AutomationRunResult> {
return request<AutomationRunResult>(`/admin/automations/${encodeURIComponent(id)}/run`, { method: "POST" });
}
/** DELETE /api/v1/admin/automations/{id} — removes a rule (cron or event). */
export function adminDeleteAutomation(id: string): Promise<{ id: string; deleted: boolean }> {
return request<{ id: string; deleted: boolean }>(`/admin/automations/${encodeURIComponent(id)}`, { method: "DELETE" });
}
/** POST /api/v1/admin/llm — saves a provider group to models.json and hot-reloads
* the running LLM stack (design/04 §3.1). The provider is the wire protocol. */
export function adminAddLlmProvider(req: LlmProviderRequest): Promise<{
provider: AdminLlmProvider;
persisted: boolean;
path?: string;
}> {
return request<{ provider: AdminLlmProvider; persisted: boolean; path?: string }>("/admin/llm", {
method: "POST",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/llm/test — performs a real model call and reports the result. */
export function adminTestLlm(req: LlmTestRequest): Promise<LlmTestResult> {
return request<LlmTestResult>("/admin/llm/test", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/channels/open — connects a registered channel adapter. */
export function adminChannelOpen(name: string): Promise<{ name: string; connected: boolean }> {
return request<{ name: string; connected: boolean }>("/admin/channels/open", {
method: "POST",
body: JSON.stringify({ name }),
});
}
/** POST /api/v1/admin/channels/close — disconnects a registered channel adapter. */
export function adminChannelClose(name: string): Promise<{ name: string; connected: boolean }> {
return request<{ name: string; connected: boolean }>("/admin/channels/close", {
method: "POST",
body: JSON.stringify({ name }),
});
}
/** POST /api/v1/admin/channels/test — structural identity test (actor × whitelist). */
export function adminChannelTest(req: {
platform: string;
accountId: string;
isGroup: boolean;
}): Promise<ChannelTestResult> {
return request<ChannelTestResult>("/admin/channels/test", { method: "POST", body: JSON.stringify(req) });
}
/** PUT /api/v1/admin/channels/{platform}/config — save an account config + hot-reload. */
export function adminSaveChannelConfig(
platform: string,
req: {
id?: string;
token?: string;
baseURL?: string;
remoteURL?: string;
listenAddr?: string;
mode?: string;
accessToken?: string;
selfID?: string;
config?: Record<string, unknown>;
allowGroups?: string[];
denyGroups?: string[];
allowUsers?: string[];
denyUsers?: string[];
groupMsgPerMin?: number;
msgPerMin?: number;
maxMessages?: number;
},
): Promise<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config`, {
method: "PUT",
body: JSON.stringify(req),
});
}
/** DELETE /api/v1/admin/channels/{platform}/config?id=… — delete an account + hot-reload. */
export function adminDeleteChannelAccount(platform: string, id: string): Promise<ChannelConfigSaveResult> {
return request<ChannelConfigSaveResult>(`/admin/channels/${platform}/config?id=${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
/** POST /api/v1/admin/plugins/install — honest 501 (needs a plugin source). */
export function adminPluginInstall(req: { name: string; kind: string; description: string }): Promise<AdminPluginRecord> {
return request<AdminPluginRecord>("/admin/plugins/install", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/plugins/uninstall — removes a capability from the registry. */
export function adminPluginUninstall(req: { name: string }): Promise<{ name: string; uninstalled: boolean }> {
return request<{ name: string; uninstalled: boolean }>("/admin/plugins/uninstall", {
method: "POST",
body: JSON.stringify(req),
});
}
/** POST /api/v1/admin/memories/search — real retrieval over the memory store. */
export function adminMemorySearch(req: MemorySearchRequest): Promise<MemorySearchResult> {
return request<MemorySearchResult>("/admin/memories/search", { method: "POST", body: JSON.stringify(req) });
}
/** POST /api/v1/admin/memories — writes one memory item (manual "添加记忆"). */
export function adminAddMemory(req: MemoryAddRequest): Promise<MemoryAddResult> {
return request<MemoryAddResult>("/admin/memories", {
method: "POST",
body: JSON.stringify(req),
});
}
/** GET /api/v1/admin/sessions/{id}/export — exports a session as JSON. */
export function adminExportSession(id: string): Promise<{ session: SessionRecord }> {
return request<{ session: SessionRecord }>(`/admin/sessions/${encodeURIComponent(id)}/export`);
}
/** GET /api/v1/admin/observability/metrics — gateway request metrics. */
export function adminObservabilityMetrics(): Promise<AdminObservabilityMetrics> {
return request<AdminObservabilityMetrics>("/admin/observability/metrics");
}
/** GET /api/v1/admin/observability/trace?limit=N — most recent traced spans. */
export function adminObservabilityTrace(limit?: number): Promise<AdminTraceSpan[]> {
return request<AdminTraceSpan[]>(`/admin/observability/trace${limitQuery(limit)}`);
}
/** GET /api/v1/admin/observability/audit?limit=N — most recent audit entries. */
export function adminObservabilityAudit(limit?: number): Promise<AuditRecord[]> {
return request<AuditRecord[]>(`/admin/observability/audit${limitQuery(limit)}`);
}
/** Builds an optional ?limit=N query suffix (empty when no positive limit). */
function limitQuery(limit?: number): string {
return limit && limit > 0 ? `?limit=${limit}` : "";
}