fix(frontend): URL-Normalisierung gegen Doppel-Präfix — Workspace-UI & Permission-Refresh in Produktion repariert

Problem: Axios baseURL '/api/v1' + 16 apiX-Calls mit vollem Präfix
('/api/v1/workspaces/...') ergaben '/api/v1/api/v1/...' → 404 live
(bewiesen per curl + Node). Betroffen: komplette Workspace-UI
(Switcher, Manager, Phase-N-Scope-Editor) + Permission-Refresh.

Fix (Defense-in-Depth):
- normalizeApiUrl() in client.ts: alle apiX-Wrapper strippen redundanten
  '/api/v1'-Präfix — deckt auch DYNAMISCHE Backend-Contract-Endpoints
  (N2-Scope-Editor-Wertquellen) ab, die Call-Sites nicht umschreiben können
- workspaces.ts + useUserPermissions.ts auf relative Pfade gesäubert (16 Calls)

Tests: clientUrl 8/8 neu (Normalisierung + Wrapper-Beweis), tsc clean,
Build OK, Workspace-Regression 3 Dateien grün
This commit is contained in:
Agent Zero
2026-09-08 22:53:11 +02:00
parent 03dd477899
commit 744f2a1dbf
4 changed files with 120 additions and 21 deletions
@@ -0,0 +1,79 @@
/**
* Runtime-Bug fix test: URL normalization in the API client (Phase N hotfix).
*
* The axios client has baseURL '/api/v1'. Calls that pass a FULL prefix
* ('/api/v1/workspaces') would resolve to '/api/v1/api/v1/...' → 404 in
* production (proven via curl + node). The client must normalize URLs by
* stripping a redundant leading '/api/v1' — this also covers DYNAMIC
* endpoints from backend contracts (scope value sources like
* '/api/v1/contact-folders') that the frontend cannot rewrite.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
normalizeApiUrl,
apiGet,
apiClient,
} from '@/api/client';
vi.mock('@/utils/errorLogger', () => ({
logError: vi.fn(),
}));
describe('normalizeApiUrl', () => {
it('strips a redundant leading /api/v1 (baseURL is already /api/v1)', () => {
expect(normalizeApiUrl('/api/v1/workspaces')).toBe('/workspaces');
expect(normalizeApiUrl('/api/v1/auth/me/permissions')).toBe('/auth/me/permissions');
expect(normalizeApiUrl('/api/v1/workspaces/scope-definitions')).toBe(
'/workspaces/scope-definitions',
);
});
it('keeps query strings intact', () => {
expect(normalizeApiUrl('/api/v1/miniapps?host=dashboard')).toBe(
'/miniapps?host=dashboard',
);
expect(normalizeApiUrl('/api/v1/saved-views?entity_type=contact')).toBe(
'/saved-views?entity_type=contact',
);
});
it('leaves relative paths untouched', () => {
expect(normalizeApiUrl('/workspaces')).toBe('/workspaces');
expect(normalizeApiUrl('/contacts?page=1')).toBe('/contacts?page=1');
});
it('leaves other api versions untouched (only v1 is the baseURL)', () => {
expect(normalizeApiUrl('/api/v2/other')).toBe('/api/v2/other');
});
it('does not strip /api/v1 in the middle of a path', () => {
expect(normalizeApiUrl('/workspaces/api/v1')).toBe('/workspaces/api/v1');
});
it('handles empty and root paths', () => {
expect(normalizeApiUrl('/')).toBe('/');
expect(normalizeApiUrl('')).toBe('');
});
});
describe('apiX wrappers normalize full-prefix URLs', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('apiGet strips the double prefix before axios sees it', async () => {
const spy = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } });
await apiGet('/api/v1/workspaces');
expect(spy).toHaveBeenCalledWith('/workspaces', undefined);
expect(spy.mock.calls[0][0]).not.toContain('api/v1/api');
spy.mockRestore();
});
it('dynamic contract endpoints (scope value sources) are normalized too', async () => {
const spy = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: [] });
// This is what useScopeValues does: endpoint comes from the backend contract
await apiGet('/api/v1/contact-folders');
expect(spy).toHaveBeenCalledWith('/contact-folders', undefined);
spy.mockRestore();
});
});
+25 -5
View File
@@ -152,28 +152,48 @@ function extractValidationErrors(data: any): Record<string, string[]> | undefine
return Object.keys(errors).length > 0 ? errors : undefined; return Object.keys(errors).length > 0 ? errors : undefined;
} }
/**
* Normalize an API URL against the axios baseURL ('/api/v1').
*
* Runtime-bug fix (Phase N hotfix): calls that pass a FULL prefix
* ('/api/v1/workspaces') resolved to '/api/v1/api/v1/...' → 404 in
* production (proven via curl + node). This also covers DYNAMIC endpoints
* from backend contracts (e.g. scope value sources like
* '/api/v1/contact-folders') that call sites cannot rewrite.
*/
export function normalizeApiUrl(url: string): string {
if (url.startsWith('/api/v1/')) {
return url.slice('/api/v1'.length);
}
// exact '/api/v1' without trailing path → root
if (url === '/api/v1') {
return '/';
}
return url;
}
export async function apiGet<T>(url: string, config?: AxiosRequestConfig): Promise<T> { export async function apiGet<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.get<T>(url, config); const response = await apiClient.get<T>(normalizeApiUrl(url), config);
return response.data; return response.data;
} }
export async function apiPost<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> { export async function apiPost<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.post<T>(url, data, config); const response = await apiClient.post<T>(normalizeApiUrl(url), data, config);
return response.data; return response.data;
} }
export async function apiPut<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> { export async function apiPut<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.put<T>(url, data, config); const response = await apiClient.put<T>(normalizeApiUrl(url), data, config);
return response.data; return response.data;
} }
export async function apiPatch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> { export async function apiPatch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.patch<T>(url, data, config); const response = await apiClient.patch<T>(normalizeApiUrl(url), data, config);
return response.data; return response.data;
} }
export async function apiDelete<T>(url: string, config?: AxiosRequestConfig): Promise<T> { export async function apiDelete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await apiClient.delete<T>(url, config); const response = await apiClient.delete<T>(normalizeApiUrl(url), config);
return response.data; return response.data;
} }
+15 -15
View File
@@ -41,21 +41,21 @@ export interface WorkspaceContext {
export function useWorkspaces() { export function useWorkspaces() {
return useQuery<{ items: Workspace[]; total: number }>({ return useQuery<{ items: Workspace[]; total: number }>({
queryKey: ['workspaces'], queryKey: ['workspaces'],
queryFn: () => apiGet('/api/v1/workspaces'), queryFn: () => apiGet('/workspaces'),
}); });
} }
export function useMyWorkspaces() { export function useMyWorkspaces() {
return useQuery<{ items: MyWorkspace[]; total: number }>({ return useQuery<{ items: MyWorkspace[]; total: number }>({
queryKey: ['my-workspaces'], queryKey: ['my-workspaces'],
queryFn: () => apiGet('/api/v1/workspaces/my'), queryFn: () => apiGet('/workspaces/my'),
}); });
} }
export function useWorkspaceContext(workspaceId: string | null) { export function useWorkspaceContext(workspaceId: string | null) {
return useQuery<WorkspaceContext>({ return useQuery<WorkspaceContext>({
queryKey: ['workspace-context', workspaceId], queryKey: ['workspace-context', workspaceId],
queryFn: () => apiGet('/api/v1/workspaces/context', { queryFn: () => apiGet('/workspaces/context', {
headers: workspaceId ? { 'X-Workspace-ID': workspaceId } : {}, headers: workspaceId ? { 'X-Workspace-ID': workspaceId } : {},
}), }),
enabled: !!workspaceId, enabled: !!workspaceId,
@@ -66,7 +66,7 @@ export function useCreateWorkspace() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: { name: string; icon?: string; description?: string; is_default?: boolean }) => mutationFn: (data: { name: string; icon?: string; description?: string; is_default?: boolean }) =>
apiPost('/api/v1/workspaces', data), apiPost('/workspaces', data),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['workspaces'] });
qc.invalidateQueries({ queryKey: ['my-workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] });
@@ -78,7 +78,7 @@ export function useUpdateWorkspace() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; icon?: string; description?: string; is_default?: boolean; is_active?: boolean }) => mutationFn: ({ id, ...data }: { id: string; name?: string; icon?: string; description?: string; is_default?: boolean; is_active?: boolean }) =>
apiPut(`/api/v1/workspaces/${id}`, data), apiPut(`/workspaces/${id}`, data),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['workspaces'] });
qc.invalidateQueries({ queryKey: ['my-workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] });
@@ -89,7 +89,7 @@ export function useUpdateWorkspace() {
export function useDeleteWorkspace() { export function useDeleteWorkspace() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (id: string) => apiDelete(`/api/v1/workspaces/${id}`), mutationFn: (id: string) => apiDelete(`/workspaces/${id}`),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['workspaces'] });
qc.invalidateQueries({ queryKey: ['my-workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] });
@@ -101,7 +101,7 @@ export function useSetWorkspaceModules() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ workspaceId, modules }: { workspaceId: string; modules: WorkspaceModule[] }) => mutationFn: ({ workspaceId, modules }: { workspaceId: string; modules: WorkspaceModule[] }) =>
apiPost(`/api/v1/workspaces/${workspaceId}/modules`, { modules }), apiPost(`/workspaces/${workspaceId}/modules`, { modules }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['workspaces'] });
qc.invalidateQueries({ queryKey: ['my-workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] });
@@ -113,7 +113,7 @@ export function useAssignWorkspaceUser() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ workspaceId, userId, role }: { workspaceId: string; userId: string; role?: string }) => mutationFn: ({ workspaceId, userId, role }: { workspaceId: string; userId: string; role?: string }) =>
apiPost(`/api/v1/workspaces/${workspaceId}/users`, { user_id: userId, role: role || 'member' }), apiPost(`/workspaces/${workspaceId}/users`, { user_id: userId, role: role || 'member' }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['workspaces'] });
qc.invalidateQueries({ queryKey: ['my-workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] });
@@ -125,7 +125,7 @@ export function useRemoveWorkspaceUser() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ workspaceId, userId }: { workspaceId: string; userId: string }) => mutationFn: ({ workspaceId, userId }: { workspaceId: string; userId: string }) =>
apiDelete(`/api/v1/workspaces/${workspaceId}/users/${userId}`), apiDelete(`/workspaces/${workspaceId}/users/${userId}`),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workspaces'] }); qc.invalidateQueries({ queryKey: ['workspaces'] });
qc.invalidateQueries({ queryKey: ['my-workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] });
@@ -149,7 +149,7 @@ export interface WorkspaceWidget {
export function useWorkspaceWidgets(workspaceId: string | null) { export function useWorkspaceWidgets(workspaceId: string | null) {
return useQuery<{ items: WorkspaceWidget[]; total: number }>({ return useQuery<{ items: WorkspaceWidget[]; total: number }>({
queryKey: ['workspace-widgets', workspaceId], queryKey: ['workspace-widgets', workspaceId],
queryFn: () => apiGet(`/api/v1/workspaces/${workspaceId}/widgets`), queryFn: () => apiGet(`/workspaces/${workspaceId}/widgets`),
enabled: !!workspaceId, enabled: !!workspaceId,
}); });
} }
@@ -165,7 +165,7 @@ export function useCreateWorkspaceWidget() {
width?: number; width?: number;
height?: number; height?: number;
config?: Record<string, any>; config?: Record<string, any>;
}) => apiPost(`/api/v1/workspaces/${workspaceId}/widgets`, data), }) => apiPost(`/workspaces/${workspaceId}/widgets`, data),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workspace-widgets'] }); qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
qc.invalidateQueries({ queryKey: ['workspace-context'] }); qc.invalidateQueries({ queryKey: ['workspace-context'] });
@@ -184,7 +184,7 @@ export function useUpdateWorkspaceWidget() {
width?: number; width?: number;
height?: number; height?: number;
config?: Record<string, any>; config?: Record<string, any>;
}) => apiPut(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`, data), }) => apiPut(`/workspaces/${workspaceId}/widgets/${widgetId}`, data),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workspace-widgets'] }); qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
qc.invalidateQueries({ queryKey: ['workspace-context'] }); qc.invalidateQueries({ queryKey: ['workspace-context'] });
@@ -196,7 +196,7 @@ export function useDeleteWorkspaceWidget() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ workspaceId, widgetId }: { workspaceId: string; widgetId: string }) => mutationFn: ({ workspaceId, widgetId }: { workspaceId: string; widgetId: string }) =>
apiDelete(`/api/v1/workspaces/${workspaceId}/widgets/${widgetId}`), apiDelete(`/workspaces/${workspaceId}/widgets/${widgetId}`),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workspace-widgets'] }); qc.invalidateQueries({ queryKey: ['workspace-widgets'] });
qc.invalidateQueries({ queryKey: ['workspace-context'] }); qc.invalidateQueries({ queryKey: ['workspace-context'] });
@@ -210,7 +210,7 @@ export function useSetDefaultWorkspace() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (workspaceId: string) => mutationFn: (workspaceId: string) =>
apiPost(`/api/v1/workspaces/${workspaceId}/set-default`), apiPost(`/workspaces/${workspaceId}/set-default`),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['my-workspaces'] }); qc.invalidateQueries({ queryKey: ['my-workspaces'] });
}, },
@@ -247,7 +247,7 @@ export interface WorkspaceScopeDefinitions {
export function useWorkspaceScopeDefinitions() { export function useWorkspaceScopeDefinitions() {
return useQuery<WorkspaceScopeDefinitions>({ return useQuery<WorkspaceScopeDefinitions>({
queryKey: ['workspace-scope-definitions'], queryKey: ['workspace-scope-definitions'],
queryFn: () => apiGet('/api/v1/workspaces/scope-definitions'), queryFn: () => apiGet('/workspaces/scope-definitions'),
}); });
} }
+1 -1
View File
@@ -19,7 +19,7 @@ export function useUserPermissions() {
const { data, isSuccess } = useQuery<PermissionsResponse>({ const { data, isSuccess } = useQuery<PermissionsResponse>({
queryKey: ['user-permissions'], queryKey: ['user-permissions'],
queryFn: () => apiGet<PermissionsResponse>('/api/v1/auth/me/permissions'), queryFn: () => apiGet<PermissionsResponse>('/auth/me/permissions'),
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
retry: 1, retry: 1,