diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 5b7d96d7..a5a748a7 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -40,7 +40,7 @@ interface SanitizedPeer { rotationInProgress: boolean; secretRotatedAt: number | null; autoRotateIntervalDays: number; - needsAttentionReason: string | null; + needsAttentionReason: 'auth_failures' | 'peer_reset_detected' | null; } function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): SanitizedPeer { @@ -57,7 +57,7 @@ function sanitizePeer(row: typeof schema.federationPeers.$inferSelect): Sanitize rotationInProgress: row.pendingHmacSecret !== null, secretRotatedAt: row.secretRotatedAt, autoRotateIntervalDays: row.autoRotateIntervalDays, - needsAttentionReason: row.needsAttentionReason, + needsAttentionReason: row.needsAttentionReason as SanitizedPeer['needsAttentionReason'], }; } diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 3797c93d..bd79236e 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -84,10 +84,12 @@ export class RateLimitError extends Error { export class HttpError extends Error { readonly status: number; - constructor(status: number, message: string) { + readonly body?: unknown; + constructor(status: number, message: string, body?: unknown) { super(message); this.name = 'HttpError'; this.status = status; + this.body = body; } } @@ -369,7 +371,7 @@ export class BackspaceApiClient { throw new RateLimitError(retryAfter); } 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; diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx index 59f0afc9..0badeeab 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx @@ -10,20 +10,26 @@ const { peers, resetEvents, resetPeer, initiatePeering, deleteUser, addToast } = addToast: vi.fn(), })); -vi.mock('../../../api/client', () => ({ - api: { - federation: { - peers, - approvalRequests: vi.fn().mockResolvedValue({ requests: [] }), - resetEvents, - resetPeer, - initiatePeering, +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('../../../api/client'); + return { + ...actual, + api: { + federation: { + peers, + approvalRequests: vi.fn().mockResolvedValue({ requests: [] }), + resetEvents, + resetPeer, + initiatePeering, + }, + admin: { + deleteUser, + }, }, - admin: { - deleteUser, - }, - }, -})); + }; +}); vi.mock('../../../stores/uiStore', () => ({ useUIStore: (sel: (s: { addToast: typeof addToast }) => unknown) => sel({ addToast }), @@ -35,6 +41,7 @@ vi.mock('../../../hooks/useWebSocket', () => ({ })); import { FederationPanel } from './FederationPanel'; +import { HttpError } from '../../../api/client'; const resetPeerFixture = { id: 'p1', @@ -138,7 +145,7 @@ describe('FederationPanel — Reset cleanup', () => { // from the error's `ownedSpaces` payload — the real server 400 shape. resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); 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', statusCode: 400, ownedSpaces: [{ id: 's1', name: 'My Space' }], @@ -166,7 +173,7 @@ describe('FederationPanel — Reset cleanup', () => { peers.mockResolvedValue({ peers: [] }); resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); deleteUser.mockRejectedValue( - Object.assign(new Error('Internal server error'), { statusCode: 400 }), + new HttpError(400, 'Internal server error', { error: 'Internal server error', statusCode: 400 }), ); render(); diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index db915456..7a72e5ed 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -3,7 +3,7 @@ import { useSettingsStore } from '../../../stores/settingsStore'; import { useUIStore } from '../../../stores/uiStore'; import { Toggle } from '../../ui/Toggle'; import { ConfirmDialog } from '../../ui/ConfirmDialog'; -import { api } from '../../../api/client'; +import { api, HttpError } from '../../../api/client'; import { onFederationPeersChanged, onFederationPeerResetDetected } from '../../../hooks/useWebSocket'; import type { InstanceAdminSettings } from '@backspace/shared'; import type { FederationPeer, ApprovalRequest, FederationResetEvent, FederationOrphanedAccount } from '../../../api/client'; @@ -926,7 +926,10 @@ function ResetCleanup() { } } catch (err) { 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) { addToast( `${confirmAction.account.username} owns spaces — transfer ownership first (Space Settings → Ownership).`,