fix(federation): carry error body on HttpError so Reset-cleanup owns-spaces copy reaches the UI

Also narrow SanitizedPeer.needsAttentionReason to the shared union.
This commit is contained in:
Jannis Braun
2026-07-02 02:19:13 +02:00
parent 493deefc64
commit 43d1dad1d7
4 changed files with 33 additions and 21 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ interface SanitizedPeer {
rotationInProgress: boolean; rotationInProgress: boolean;
secretRotatedAt: number | null; secretRotatedAt: number | null;
autoRotateIntervalDays: number; autoRotateIntervalDays: number;
needsAttentionReason: string | null; needsAttentionReason: 'auth_failures' | 'peer_reset_detected' | null;
} }
function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): SanitizedPeer { function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): SanitizedPeer {
@@ -57,7 +57,7 @@ function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): Sanitize
rotationInProgress: row.pendingHmacSecret !== null, rotationInProgress: row.pendingHmacSecret !== null,
secretRotatedAt: row.secretRotatedAt, secretRotatedAt: row.secretRotatedAt,
autoRotateIntervalDays: row.autoRotateIntervalDays, autoRotateIntervalDays: row.autoRotateIntervalDays,
needsAttentionReason: row.needsAttentionReason, needsAttentionReason: row.needsAttentionReason as SanitizedPeer['needsAttentionReason'],
}; };
} }
+4 -2
View File
@@ -84,10 +84,12 @@ export class RateLimitError extends Error {
export class HttpError extends Error { export class HttpError extends Error {
readonly status: number; readonly status: number;
constructor(status: number, message: string) { readonly body?: unknown;
constructor(status: number, message: string, body?: unknown) {
super(message); super(message);
this.name = 'HttpError'; this.name = 'HttpError';
this.status = status; this.status = status;
this.body = body;
} }
} }
@@ -369,7 +371,7 @@ export class BackspaceApiClient {
throw new RateLimitError(retryAfter); throw new RateLimitError(retryAfter);
} }
const error = await response.json().catch(() => ({ error: 'Request failed' })); const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new HttpError(response.status, (error as { error: string }).error || `HTTP ${response.status}`); throw new HttpError(response.status, (error as { error?: string }).error || `HTTP ${response.status}`, error);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
@@ -10,7 +10,12 @@ const { peers, resetEvents, resetPeer, initiatePeering, deleteUser, addToast } =
addToast: vi.fn(), addToast: vi.fn(),
})); }));
vi.mock('../../../api/client', () => ({ vi.mock('../../../api/client', async () => {
// Re-export the REAL HttpError so the component's `err instanceof HttpError`
// classifier and the test's constructed rejection share one class identity.
const actual = await vi.importActual<typeof import('../../../api/client')>('../../../api/client');
return {
...actual,
api: { api: {
federation: { federation: {
peers, peers,
@@ -23,7 +28,8 @@ vi.mock('../../../api/client', () => ({
deleteUser, deleteUser,
}, },
}, },
})); };
});
vi.mock('../../../stores/uiStore', () => ({ vi.mock('../../../stores/uiStore', () => ({
useUIStore: (sel: (s: { addToast: typeof addToast }) => unknown) => sel({ addToast }), useUIStore: (sel: (s: { addToast: typeof addToast }) => unknown) => sel({ addToast }),
@@ -35,6 +41,7 @@ vi.mock('../../../hooks/useWebSocket', () => ({
})); }));
import { FederationPanel } from './FederationPanel'; import { FederationPanel } from './FederationPanel';
import { HttpError } from '../../../api/client';
const resetPeerFixture = { const resetPeerFixture = {
id: 'p1', id: 'p1',
@@ -138,7 +145,7 @@ describe('FederationPanel — Reset cleanup', () => {
// from the error's `ownedSpaces` payload — the real server 400 shape. // from the error's `ownedSpaces` payload — the real server 400 shape.
resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
deleteUser.mockRejectedValue( deleteUser.mockRejectedValue(
Object.assign(new Error('User owns spaces — transfer ownership first'), { new HttpError(400, 'User owns spaces — transfer ownership first', {
error: 'User owns spaces — transfer ownership first', error: 'User owns spaces — transfer ownership first',
statusCode: 400, statusCode: 400,
ownedSpaces: [{ id: 's1', name: 'My Space' }], ownedSpaces: [{ id: 's1', name: 'My Space' }],
@@ -166,7 +173,7 @@ describe('FederationPanel — Reset cleanup', () => {
peers.mockResolvedValue({ peers: [] }); peers.mockResolvedValue({ peers: [] });
resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
deleteUser.mockRejectedValue( deleteUser.mockRejectedValue(
Object.assign(new Error('Internal server error'), { statusCode: 400 }), new HttpError(400, 'Internal server error', { error: 'Internal server error', statusCode: 400 }),
); );
render(<FederationPanel />); render(<FederationPanel />);
@@ -3,7 +3,7 @@ import { useSettingsStore } from '../../../stores/settingsStore';
import { useUIStore } from '../../../stores/uiStore'; import { useUIStore } from '../../../stores/uiStore';
import { Toggle } from '../../ui/Toggle'; import { Toggle } from '../../ui/Toggle';
import { ConfirmDialog } from '../../ui/ConfirmDialog'; import { ConfirmDialog } from '../../ui/ConfirmDialog';
import { api } from '../../../api/client'; import { api, HttpError } from '../../../api/client';
import { onFederationPeersChanged, onFederationPeerResetDetected } from '../../../hooks/useWebSocket'; import { onFederationPeersChanged, onFederationPeerResetDetected } from '../../../hooks/useWebSocket';
import type { InstanceAdminSettings } from '@backspace/shared'; import type { InstanceAdminSettings } from '@backspace/shared';
import type { FederationPeer, ApprovalRequest, FederationResetEvent, FederationOrphanedAccount } from '../../../api/client'; import type { FederationPeer, ApprovalRequest, FederationResetEvent, FederationOrphanedAccount } from '../../../api/client';
@@ -926,7 +926,10 @@ function ResetCleanup() {
} }
} catch (err) { } catch (err) {
if (confirmAction.kind === 'remove') { if (confirmAction.kind === 'remove') {
const ownsSpaces = err != null && typeof err === 'object' && 'ownedSpaces' in err; const ownsSpaces =
err instanceof HttpError &&
err.status === 400 &&
Array.isArray((err.body as { ownedSpaces?: unknown } | undefined)?.ownedSpaces);
if (ownsSpaces) { if (ownsSpaces) {
addToast( addToast(
`${confirmAction.account.username} owns spaces — transfer ownership first (Space Settings → Ownership).`, `${confirmAction.account.username} owns spaces — transfer ownership first (Space Settings → Ownership).`,