Files
HSAP/platform/web/src/app/hsap-api.ts
Chengfang Lu e72bc061c5 feat: HSAP platform v2 — modular navigation, quality review, audit log, world model simulation
Major changes:
- New frontend (platform/web/): Vite + React 18 + TypeScript + Tailwind
- 4-module navigation: 数据送标 / 模型管理 / 车队管理 / 系统管理
- Data catalog with charts (DMS/ADAS/Lane 3-tab view)
- Quality review workflow (标注质检): Good/Fine/Bad scoring with auto-advance
- Audit enhancements: batch operations, rejection categories, Feishu notifications
- Operation audit log (操作日志)
- World model simulation studio (仿真工坊)
- Dataset version management with snapshots and diff
- ADAS 7-class dataset integration (138K images organized + compressed)
- User management with Feishu integration and pagination
- CRUD/search/filter on all pages, card layout redesign
- PIL-optimized image overlay rendering
- Auto-snapshot on build, in_review workflow stage
- Removed embedded algorithm code (now in workspace)
2026-06-03 11:40:21 +08:00

470 lines
22 KiB
TypeScript

import type { AuthUser, PagedResult, LabelingBatchRow, BatchDelivery } from "@/lib/types";
export type { AuthUser };
const API_BASE = "";
let _token: string | null =
typeof localStorage !== "undefined"
? localStorage.getItem("as_access_token")
: null;
function authHeaders(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (_token) h.Authorization = `Bearer ${_token}`;
return h;
}
export class HsapApiError extends Error {
status: number;
detail?: unknown;
constructor(status: number, message: string, detail?: unknown) {
super(message);
this.status = status;
this.detail = detail;
}
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
cache: "no-store",
headers: { ...authHeaders(), ...((init?.headers as Record<string, string>) || {}) },
});
if (res.status === 401) throw new Error("UNAUTHORIZED");
const text = await res.text();
if (!res.ok) {
let detail: unknown;
try { detail = JSON.parse(text); } catch { /* ignore */ }
throw new HsapApiError(res.status, text || res.statusText, detail);
}
return JSON.parse(text || "{}") as T;
}
async function postJson<T>(url: string, body?: unknown): Promise<T> {
return fetchJson<T>(url, { method: "POST", body: body !== undefined ? JSON.stringify(body) : undefined });
}
async function putJson<T>(url: string, body?: unknown): Promise<T> {
return fetchJson<T>(url, { method: "PUT", body: body !== undefined ? JSON.stringify(body) : undefined });
}
async function patchJson<T>(url: string, body?: unknown): Promise<T> {
return fetchJson<T>(url, { method: "PATCH", body: body !== undefined ? JSON.stringify(body) : undefined });
}
async function deleteJson<T>(url: string): Promise<T> {
return fetchJson<T>(url, { method: "DELETE" });
}
export const hsapApi = {
setToken(token: string | null) {
_token = token;
if (token) localStorage.setItem("as_access_token", token);
else localStorage.removeItem("as_access_token");
},
getToken: () => _token,
authConfig: () =>
fetchJson<{ feishu_enabled: boolean; dev_auth_enabled: boolean }>(`${API_BASE}/api/v1/auth/config`),
me: () => fetchJson<AuthUser>(`${API_BASE}/api/v1/auth/me`),
devLogin: (name?: string) =>
postJson<{ access_token: string; user: AuthUser }>(`${API_BASE}/api/v1/auth/dev/login`, { name: name || "开发用户" }),
health: () => fetchJson<{ status: string; database: string }>(`${API_BASE}/api/v1/health`),
// ── Labeling ──
labelingBatches: (opts?: { stage?: string; offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.stage) p.set("stage", opts.stage);
if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit));
const q = p.toString();
return fetchJson<PagedResult<LabelingBatchRow>>(`${API_BASE}/api/v1/labeling/batches${q ? `?${q}` : ""}`);
},
openLabelingCampaign: (body: { project: string; task: string; batch: string; mode?: string | null; pack?: string | null; location?: string }) =>
postJson(`${API_BASE}/api/v1/labeling/campaigns/open`, body),
labelingAssignees: () =>
fetchJson<{ items: { id: number; name: string; roles: string[] }[] }>(`${API_BASE}/api/v1/labeling/assignees`),
assignLabelingCampaign: (campaignId: string, userId: number | null) =>
patchJson(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/assign`, { user_id: userId }),
getLabelingCampaign: (id: string) =>
fetchJson<LabelingBatchRow & { config_xml?: string }>(`${API_BASE}/api/v1/labeling/campaigns/${id}`),
labelingBootstrap: (campaignId: string) =>
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/bootstrap`),
campaignProgress: (campaignId: string) =>
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/progress`),
labelingTasks: (campaignId: string, offset = 0, limit = 50, opts?: { assignee?: "me" | "all" }) => {
const capped = Math.min(Math.max(1, limit), 100);
const q = new URLSearchParams({ offset: String(offset), limit: String(capped) });
if (opts?.assignee === "me") q.set("assignee", "me");
if (opts?.assignee === "all") q.set("assignee", "all");
return fetchJson<{ tasks: { id: string; data: { image: string } }[]; total: number; hint?: string; my_assigned?: number }>(
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks?${q}`,
);
},
getAnnotation: (campaignId: string, taskId: string) =>
fetchJson<{ task_id: string; result?: unknown }>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/annotations/${taskId}`),
saveAnnotation: (campaignId: string, taskId: string, body: { result?: unknown; annotations?: unknown[] }) =>
putJson(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/annotations/${taskId}`, body),
labelingExport: (campaignId: string) =>
postJson<{ ok: boolean; job?: { id: string } }>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/export`),
submitLabelingCampaign: (campaignId: string) =>
postJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/submit`),
importVendorZip: (campaignId: string, file: File) => {
const form = new FormData();
form.append("file", file);
const h: Record<string, string> = {};
if (_token) h.Authorization = `Bearer ${_token}`;
return fetch(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/import-vendor`, {
method: "POST", headers: h, body: form,
}).then(async (res) => {
if (!res.ok) throw new Error(await res.text());
return res.json();
});
},
listCampaignExportJobs: (campaignId: string, limit = 20) =>
fetchJson<{ items: Record<string, unknown>[] }>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/export-jobs?limit=${limit}`),
labelingRegistryProfiles: () =>
fetchJson<{ profiles: Record<string, unknown>[] }>(`${API_BASE}/api/v1/labeling/registry-profiles`),
labelingAcquireLock: (campaignId: string, taskId: string) =>
postJson<{ ok: boolean; holder?: string }>(
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks/${encodeURIComponent(taskId)}/lock`,
),
labelingReleaseLock: (campaignId: string, taskId: string) =>
deleteJson<{ ok: boolean }>(
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks/${encodeURIComponent(taskId)}/lock`,
),
labelingReleaseLockKeepalive(campaignId: string, taskId: string) {
const url = `${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks/${encodeURIComponent(taskId)}/lock`;
fetch(url, { method: "DELETE", headers: authHeaders(), keepalive: true }).catch(() => {});
},
labelingRenewLock: (campaignId: string, taskId: string) =>
postJson<{ ok: boolean }>(
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/tasks/${encodeURIComponent(taskId)}/lock/renew`,
),
labelingTasksPageLimit: 100,
async labelingTasksAll(
campaignId: string,
opts?: { assignee?: "me" | "all" },
): Promise<{ tasks: { id: string; data: { image: string } }[]; total: number; hint?: string; my_assigned?: number }> {
const page = 100;
let offset = 0;
let total = 0;
let hint: string | undefined;
let myAssigned: number | undefined;
const tasks: { id: string; data: { image: string } }[] = [];
for (;;) {
const res = await this.labelingTasks(campaignId, offset, page, opts);
tasks.push(...(res.tasks || []));
total = res.total;
hint = res.hint ?? hint;
myAssigned = res.my_assigned ?? myAssigned;
offset += page;
if (tasks.length >= total || !(res.tasks?.length)) break;
}
return { tasks, total, hint, my_assigned: myAssigned };
},
// ── Pending / Catalog ──
pending: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/pending`),
pendingGates: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/pending/gates`),
catalog: (refresh = false) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/catalog${refresh ? "?refresh=true" : ""}`),
catalogDms: (task: string, refresh = false) =>
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/catalog/dms/${encodeURIComponent(task)}${refresh ? "?refresh=true" : ""}`),
// ── Data upload ──
uploadDatasetFile: (file: File, project: string, task?: string, mode?: string, onProgress?: (n: number) => void) => {
const formData = new FormData();
formData.append("project", project);
if (task) formData.append("task", task);
if (mode) formData.append("mode", mode);
formData.append("file", file);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${API_BASE}/api/v1/data/upload/file`, true);
if (_token) xhr.setRequestHeader("Authorization", `Bearer ${_token}`);
xhr.upload.onprogress = (evt) => {
if (!onProgress || !evt.lengthComputable) return;
onProgress(Math.round((evt.loaded / evt.total) * 100));
};
xhr.onerror = () => reject(new Error("上传失败"));
xhr.onload = () => {
if (xhr.status === 401) return reject(new Error("UNAUTHORIZED"));
if (xhr.status < 200 || xhr.status >= 300) return reject(new Error(xhr.responseText || `HTTP ${xhr.status}`));
try { resolve(JSON.parse(xhr.responseText || "{}")); } catch { reject(new Error("上传响应解析失败")); }
};
xhr.send(formData);
});
},
listDataCandidates: (opts?: { offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.offset != null) p.set("offset", String(opts.offset));
p.set("limit", String(opts?.limit ?? 20));
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/data/candidates?${p}`);
},
promoteCandidateInbox: (candidateId: string, body?: { batch?: string; mode?: string }) =>
postJson<{ ok: boolean; inbox_path?: string }>(`${API_BASE}/api/v1/data/candidates/${encodeURIComponent(candidateId)}/promote-inbox`, body || {}),
registryTasks: (project = "dms") =>
fetchJson<{ project: string; tasks: Record<string, unknown> }>(`${API_BASE}/api/v1/data/registry-tasks?project=${encodeURIComponent(project)}`),
scanInbox: (project = "dms") =>
fetchJson<{ project: string; items: Record<string, unknown>[]; inbox_path: string }>(`${API_BASE}/api/v1/data/scan-inbox?project=${encodeURIComponent(project)}`),
registerBatch: (body: Record<string, unknown>) => postJson(`${API_BASE}/api/v1/register-batch`, body),
// ── Deliveries ──
listDeliveries: (opts?: { status?: string; mine?: boolean; offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.status) p.set("status", opts.status);
if (opts?.mine) p.set("mine", "1");
if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit));
return fetchJson<PagedResult<BatchDelivery>>(`${API_BASE}/api/v1/deliveries?${p}`);
},
getDelivery: (id: string) => fetchJson<BatchDelivery>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}`),
createDelivery: (body: Record<string, unknown>) => postJson<BatchDelivery>(`${API_BASE}/api/v1/deliveries`, body),
patchDelivery: (id: string, body: Record<string, unknown>) =>
patchJson<BatchDelivery>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}`, body),
submitDelivery: (id: string) =>
postJson<BatchDelivery>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}/submit`),
deleteDelivery: (id: string) => deleteJson<{ ok: boolean }>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}`),
retryDeliveryIngest: (id: string) =>
postJson<{ ok: boolean; job_id?: string }>(`${API_BASE}/api/v1/deliveries/${encodeURIComponent(id)}/retry-ingest`),
// ── Audit ──
listActions: () => fetchJson<{ actions: { id: string; label: string }[] }>(`${API_BASE}/api/v1/actions`),
listApprovals: (opts?: { status?: string; offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.status) p.set("status", opts.status);
if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit));
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/approvals?${p}`);
},
getApproval: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/approvals/${id}`),
getApprovalPreview: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/approvals/${id}/preview`),
listApprovalImages: (id: string, offset = 0, limit = 60) =>
fetchJson<{ total: number; items: { id: string }[] }>(`${API_BASE}/api/v1/approvals/${id}/images?offset=${offset}&limit=${limit}`),
fetchApprovalImageBlob: async (approvalId: string, imageId: string, thumb = true) => {
const q = thumb ? "?thumb=true" : "?thumb=false";
const res = await fetch(`${API_BASE}/api/v1/approvals/${approvalId}/images/${imageId}${q}`, { headers: authHeaders(), cache: "no-store" });
if (!res.ok) throw new Error(await res.text());
return URL.createObjectURL(await res.blob());
},
approvalRejectionCategories: () =>
fetchJson<{ categories: { key: string; label: string }[] }>(`${API_BASE}/api/v1/system/audit/rejection-categories`),
approve: (id: string, comment?: string) => postJson(`${API_BASE}/api/v1/system/audit/${id}/approve`, { comment }),
reject: (id: string, comment?: string, rejectionCategory?: string) =>
postJson(`${API_BASE}/api/v1/system/audit/${id}/reject`, { comment, rejection_category: rejectionCategory || "" }),
batchApprove: (ids: string[]) =>
postJson(`${API_BASE}/api/v1/system/audit/batch-approve`, { ids }),
batchReject: (ids: string[], comment?: string, rejectionCategory?: string) =>
postJson(`${API_BASE}/api/v1/system/audit/batch-reject`, { ids, comment, rejection_category: rejectionCategory || "" }),
submitApproval: (action: string, params: Record<string, unknown>, note?: string) =>
postJson(`${API_BASE}/api/v1/system/audit/submit`, { action, params, note }),
submitBuildBatch: (body: Record<string, unknown>) => postJson(`${API_BASE}/api/v1/system/audit/submit-build-batch`, body),
// ── Jobs ──
listJobs: (opts?: { status?: string; offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.status) p.set("status", opts.status);
if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit));
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/jobs?${p}`).catch(() => ({ items: [], total: 0 }));
},
getJob: (id: string) => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/jobs/${id}`),
// ── Training / Models ──
listTrainingRecords: (opts?: { project?: string; status?: string; offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.project) p.set("project", opts.project);
if (opts?.status) p.set("status", opts.status);
if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit));
const q = p.toString();
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/models/records${q ? `?${q}` : ""}`);
},
getTrainingRecord: (jobId: string) =>
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/records/${encodeURIComponent(jobId)}`),
getModelRegistry: (project = "dms", task?: string) => {
const p = new URLSearchParams({ project });
if (task) p.set("task", task);
return fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/registry?${p}`);
},
createTrainingRecord: (action: string, params: Record<string, unknown>, note?: string) =>
postJson(`${API_BASE}/api/v1/models/records`, { action, params, note }),
trainingActions: () =>
fetchJson<{ actions: { id: string; label: string }[] }>(`${API_BASE}/api/v1/models/actions`),
// ── Fleet ──
fleetMapConfig: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/fleet/map-config`),
fleetLive: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/fleet/live`),
fleetSummary: () => fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/fleet/summary`),
fleetVehicles: () => fetchJson<{ items: Record<string, unknown>[] }>(`${API_BASE}/api/v1/fleet/vehicles`),
fleetCreateVehicle: (body: { plate_no: string; tbox_device_id: string; name?: string; team?: string }) =>
postJson(`${API_BASE}/api/v1/fleet/vehicles`, body),
fleetUpdateVehicle: (id: number, body: Record<string, unknown>) =>
patchJson(`${API_BASE}/api/v1/fleet/vehicles/${id}`, body),
fleetDeleteVehicle: (id: number) => deleteJson(`${API_BASE}/api/v1/fleet/vehicles/${id}`),
fleetRuns: (opts?: { vehicle_id?: number; offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.vehicle_id != null) p.set("vehicle_id", String(opts.vehicle_id));
if (opts?.offset != null) p.set("offset", String(opts.offset));
if (opts?.limit != null) p.set("limit", String(opts.limit));
return fetchJson<PagedResult<Record<string, unknown>>>(`${API_BASE}/api/v1/fleet/runs?${p}`);
},
fleetRunDetail: (runId: number) =>
fetchJson<{ run: Record<string, unknown>; vehicle: Record<string, unknown> | null; milestones: Record<string, unknown>[] }>(
`${API_BASE}/api/v1/fleet/runs/${runId}`),
fleetRunTrack: (runId: number) =>
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/fleet/runs/${runId}/track`),
fleetImportGpx: (vehicleId: number, file: File, note?: string) => {
const form = new FormData();
form.append("vehicle_id", String(vehicleId));
form.append("file", file);
if (note) form.append("note", note);
const h: Record<string, string> = {};
if (_token) h.Authorization = `Bearer ${_token}`;
return fetch(`${API_BASE}/api/v1/fleet/runs/import-gpx`, { method: "POST", headers: h, body: form })
.then(async (res) => { if (!res.ok) throw new Error(await res.text()); return res.json(); });
},
fleetImportCsv: (vehicleId: number, file: File, opts?: { note?: string; project?: string; batch?: string }) => {
const form = new FormData();
form.append("vehicle_id", String(vehicleId));
form.append("file", file);
if (opts?.note) form.append("note", opts.note);
if (opts?.project) form.append("project", opts.project);
if (opts?.batch) form.append("batch", opts.batch);
const h: Record<string, string> = {};
if (_token) h.Authorization = `Bearer ${_token}`;
return fetch(`${API_BASE}/api/v1/fleet/runs/import-csv`, { method: "POST", headers: h, body: form })
.then(async (res) => { if (!res.ok) throw new Error(await res.text()); return res.json(); });
},
fleetReseedDemo: () => postJson(`${API_BASE}/api/v1/fleet/mock/reseed`),
// ── Traces / Agents ──
listTraces: (limit = 50) =>
fetchJson<{ trace_ids: string[] }>(`${API_BASE}/api/v1/traces?limit=${limit}`),
getTrace: (traceId: string) =>
fetchJson<{ trace_id: string; spans: Record<string, unknown>[] }>(`${API_BASE}/api/v1/traces/${traceId}`),
listTools: () =>
fetchJson<{ tools: string[] }>(`${API_BASE}/api/v1/agents/tools`),
invokeAgent: (graph: string, params: Record<string, unknown>) =>
postJson(`${API_BASE}/api/v1/agents/invoke`, { graph, params }),
// ── User management ──
listUsers: (opts?: { search?: string; role?: string; offset?: number; limit?: number }) => {
const p = new URLSearchParams();
if (opts?.search) p.set("search", opts.search);
if (opts?.role) p.set("role", opts.role);
if (opts?.offset != null) p.set("offset", String(opts.offset));
p.set("limit", String(opts?.limit ?? 20));
const q = p.toString();
return fetchJson<{ items: Record<string, unknown>[]; total: number }>(`${API_BASE}/api/v1/auth/users${q ? `?${q}` : ""}`);
},
setUserRoles: (userId: number, roles: string[]) =>
putJson(`${API_BASE}/api/v1/auth/users/${userId}/roles`, { roles }),
syncFeishuUsers: () =>
postJson<{ ok: boolean; created: number; updated: number; total: number }>(`${API_BASE}/api/v1/system/feishu/sync-users`),
// ── Dataset Versions ──
listDatasetVersions: (project = "dms") =>
fetchJson<{ items: Record<string, unknown>[] }>(`${API_BASE}/api/v1/models/datasets?project=${encodeURIComponent(project)}`),
createDatasetSnapshot: (project: string, description: string) =>
postJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/datasets/snapshot`, { project, description }),
getDatasetVersion: (versionId: string, project = "dms") =>
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/datasets/${encodeURIComponent(versionId)}?project=${encodeURIComponent(project)}`),
diffDatasetVersions: (v1: string, v2: string, project = "dms") =>
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/models/datasets/${encodeURIComponent(v2)}/diff?compare=${encodeURIComponent(v1)}&project=${encodeURIComponent(project)}`),
};
export function mediaUrl(path: string): string {
if (path.startsWith("http") || path.startsWith("/api/")) {
return path.startsWith("http") ? path : `${API_BASE || ""}${path}`;
}
return path;
}
export async function fetchLabelingMediaBlob(imageApiPath: string): Promise<string> {
const url = mediaUrl(imageApiPath);
const res = await fetch(url, { headers: authHeaders(), cache: "no-store" });
if (!res.ok) throw new Error(await res.text());
return URL.createObjectURL(await res.blob());
}
export function formatLockConflict(err: unknown): string {
if (err instanceof HsapApiError && err.status === 409) {
const raw = err.detail as Record<string, unknown> | undefined;
const inner = (raw?.detail as Record<string, unknown> | undefined) ?? raw;
const holder = inner?.holder as string | undefined;
return holder ? `该图正由 ${holder} 标注中` : "该图片正被其他用户标注";
}
return String(err);
}
export function hasPermission(user: { permissions: string[] } | null, perm: string): boolean {
if (!user) return false;
if (user.permissions.includes("*")) return true;
return user.permissions.includes(perm);
}