From 5ad8aefaffc2ed26506cf829076a9e5c46b2dd31 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:06:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(federation):=20reset-cleanup=20panel=20?= =?UTF-8?q?=E2=80=94=20informational=20detach=20copy,=20real=20server-side?= =?UTF-8?q?=20Dismiss,=20Keep=20removed=20(detach=20spec=20=C2=A74.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/systems/admin.md | 2 +- docs/systems/client-federation.md | 2 +- docs/systems/federation.md | 2 +- .../FederationPanel.resetCleanup.test.tsx | 88 +++++++++++- .../FederationPanel.tsx | 135 ++++++++++-------- 5 files changed, 162 insertions(+), 67 deletions(-) diff --git a/docs/systems/admin.md b/docs/systems/admin.md index 192bda3c..23cdf381 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -536,7 +536,7 @@ Manages: federation peers list, pending approval requests (inbound + outbound), - **Reset cleanup section** (`ResetCleanup`, instance-epoch self-healing §6.4) — the highest-priority attention surface, rendered above the peer list; returns `null` when there is nothing to clean up. Fetches `api.federation.peers()` + `api.federation.resetEvents()` and subscribes to `onFederationPeerResetDetected` (the `federation_peer_reset_detected` WS event) to refetch live. Two stacked surfaces: - **Reset-detected banner** — one persistent accent-rose banner per peer with `status === 'needs_attention' && needsAttentionReason === 'peer_reset_detected'`, distinguishing a wiped-and-reinstalled peer from a generic auth-failure peer. Its **Re-peer** button runs the existing one-click flow in order: `api.federation.resetPeer(id)` **then** `api.federation.initiatePeering({ remoteOrigin })` — resetting the stale local record *before* the fresh handshake so activation heals stale friendships/DMs against the new incarnation (warning-variant ConfirmDialog). **The outcome is now surfaced honestly, not always as success:** if `initiatePeering` resolves with `verified === false` (or the returned peer is `needs_attention`), the toast is a **warning** — "Re-peer incomplete — {peer} still holds stale peering for you. Its admin must reset their side, then Re-peer again." — because the handshake could not be cryptographically verified (the remote still holds a conflicting row; see `federation.md` "Trust re-establishment contract"). If it rejects with `409 PEER_EXISTS_RESET_REQUIRED`, the same "ask the remote admin to reset their side" warning is shown. Only a verified activation shows the green "Re-peering initiated" success toast. This means the common one-side reset recovers in one click, while a bidirectional-stale peering tells the admin exactly that the **other** side must reset once. - - **Orphaned-accounts list** (from `GET /reset-events`, per origin with `orphanedAccounts.length > 0`) — each real account frozen by the reset quarantine, shown with its owned-spaces / membership / message counts. Per-row actions: **Keep** (default no-op resting/frozen state — the account stays `federationHomeOrphaned = 1`) and **Remove** (danger ConfirmDialog → `api.admin.deleteUser(id)`, i.e. the existing `DELETE /api/admin/users/:id` full purge). A Remove on a space-owning account returns the existing `409 { ownedSpaces }`; the UI surfaces a "transfer ownership first" toast rather than deleting. + - **Detached-accounts card** (from `GET /reset-events`, per origin with `orphanedAccounts.length > 0 && acknowledgedAt === null` — acknowledged events are filtered client-side, the endpoint keeps returning them for audit) — neutral-tier (`bg-white/[0.02]`, no rose/urgency styling; the rose banner is reserved for the actionable reset-detected surface). Copy is informational, not urgent-cleanup: "{origin} was reset — N replicated identities auto-cleaned, N accounts with local content detached. Detached accounts keep working locally — owners keep access with their existing password." Each detached real account is shown with its owned-spaces / membership / message counts. Actions: a per-account **Remove** (danger ConfirmDialog → `api.admin.deleteUser(id)`, i.e. the existing `DELETE /api/admin/users/:id` full purge, for accounts that truly are abandoned; a Remove on a space-owning account returns the existing `409 { ownedSpaces }` → "transfer ownership first" toast rather than deleting) and a per-event **Dismiss — keep all detached accounts** footer button that calls `api.federation.acknowledgeResetEvent(origin)` (`POST /api/federation/reset-events/acknowledge`) then re-fetches — a real, server-side acknowledgement (not the old client-only "Keep") that hides the card and drops the event from the badge count without touching any account. The badge counts unacknowledged events-with-orphans plus reset-detected peers. #### StoragePanel diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index 141479d7..628cf488 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -480,7 +480,7 @@ This slice is intentionally separate from `instanceStore` because the data is pe Modeled on the peering-approval surface above, the FederationPanel's `ResetCleanup` component (`admin.md` "FederationPanel") is the admin surface for a factory-reset peer. It fetches `api.federation.peers()` + `api.federation.resetEvents()` (`GET /api/federation/reset-events`) and subscribes to `onFederationPeerResetDetected(cb)` — the client handler for the `federation_peer_reset_detected` admin WS event (`useWebSocket.ts`) — to refetch live. Two surfaces: - **Reset-detected banner** — one persistent accent-rose banner per peer with `status === 'needs_attention' && needsAttentionReason === 'peer_reset_detected'` (the `needsAttentionReason` field distinguishes a reset from a generic auth-failure, and now also `'repeer_incomplete'`). **Re-peer** runs `resetPeer(id)` **then** `initiatePeering({ remoteOrigin })` — reset-before-handshake so activation heals the stale graph against the new incarnation. **The result is surfaced honestly:** `initiatePeering` now returns `{ peer, verified }`; when `verified === false` (or the peer comes back `needs_attention`), or when it rejects with `409 PEER_EXISTS_RESET_REQUIRED`, the toast is a **warning** telling the admin the remote still holds stale peering and its admin must reset the **other** side, then Re-peer again — rather than a false success. A cryptographically-verified activation shows the success toast. The common one-side reset recovers in one click; a bidirectional-stale case names the side that must act. See `federation.md` "Trust re-establishment contract". -- **Orphaned-accounts list** — real accounts frozen by the server-side reset quarantine (`FederationOrphanedAccount`: owned-spaces / membership / message counts). **Keep** is the no-op frozen resting state; **Remove** reuses `api.admin.deleteUser(id)` (`DELETE /api/admin/users/:id`, full purge). A Remove on a space owner surfaces the existing `409 { ownedSpaces }` as a "transfer ownership first" toast instead of deleting. +- **Detached-accounts card** — informational, neutral-tier surface (no rose/urgency styling) for the reset incarnation's real accounts that now operate as sovereign local accounts (`FederationOrphanedAccount`: owned-spaces / membership / message counts). Copy: detached accounts keep working locally and owners sign in with their existing password. Cards render only for unacknowledged events (`orphanedAccounts.length > 0 && acknowledgedAt === null`; the endpoint still returns acknowledged events for audit). Per-account **Remove** reuses `api.admin.deleteUser(id)` (`DELETE /api/admin/users/:id`, full purge) for genuinely-abandoned accounts — a Remove on a space owner surfaces the existing `409 { ownedSpaces }` as a "transfer ownership first" toast instead of deleting. A per-event **Dismiss** footer calls `api.federation.acknowledgeResetEvent(origin)` (`POST /api/federation/reset-events/acknowledge`) then re-fetches — a real server-side acknowledgement (replacing the old client-only "Keep") that hides the card and removes the event from the badge count without touching any account. --- diff --git a/docs/systems/federation.md b/docs/systems/federation.md index c6b85ffd..0d09fe6b 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -370,7 +370,7 @@ Detection (`markPeerReset`) only snapshots + journals + notifies; it destroys no **Login self-heal epoch guard (design §6.3a).** The federated password self-heal (`auth.ts` §4) now gates re-hashing on the home instance's current epoch, read via the authenticated `fetchPeerEpoch(peer)` (HMAC-signed both ways): no baseline on record → allow (legacy); baseline differs from the fetched epoch → refuse; epoch can't be determined (`fetchPeerEpoch` null — 404/unreachable/bad-sig/desynced secret) → **fail closed/refuse**; match → allow. Closes the *pre*-re-peer hijack (a reset home accepting a new same-name user's password); the universal quarantine freeze closes the post-re-peer window. Full three-way in `auth.md` §4. -**Reset-events admin surface (`GET /api/federation/reset-events`).** Admin-only, read-only. Returns the durable `federation_reset_events` journal joined with each origin's current orphaned real accounts (`federation_home_orphaned = 1`, `homeInstanceMatch`), each with `ownedSpaces`, `spaceMemberCount`, and authored-`messageCount` for disposition. Response type `FederationResetEventsResponse` (`{ events: FederationResetEvent[] }`, each event carrying `orphanedAccounts: FederationOrphanedAccount[]`). Disposition actions reuse existing endpoints — one-click Re-peer (`/peers/:id/reset` → `/peer/initiate`) and full-purge Remove (`DELETE /api/admin/users/:id`, owns-spaces → transfer first). See `admin.md` "FederationPanel" and `client-federation.md` §8. +**Reset-events admin surface (`GET /api/federation/reset-events`).** Admin-only. Returns the durable `federation_reset_events` journal (each event carrying a nullable `acknowledgedAt`) joined with each origin's current detached real accounts (`federation_home_orphaned = 1`, `homeInstanceMatch`), each with `ownedSpaces`, `spaceMemberCount`, and authored-`messageCount` for disposition. Response type `FederationResetEventsResponse` (`{ events: FederationResetEvent[] }`, each event carrying `orphanedAccounts: FederationOrphanedAccount[]`). The endpoint returns **all** events (including acknowledged ones, for audit); the client filters to `acknowledgedAt === null`. Disposition actions — one-click Re-peer (`/peers/:id/reset` → `/peer/initiate`), full-purge Remove (`DELETE /api/admin/users/:id`, owns-spaces → transfer first) for genuinely-abandoned detached accounts, and a non-destructive **Dismiss** (`POST /api/federation/reset-events/acknowledge` with `{ origin }`, idempotent — sets `acknowledged_at`) that hides the card without touching the accounts (detached accounts keep working locally). See `admin.md` "FederationPanel" and `client-federation.md` §8. **`needsAttentionReason` on the peer API.** `GET /api/federation/peers` returns `needsAttentionReason: 'auth_failures' | 'peer_reset_detected' | 'repeer_incomplete' | null` per peer, so the admin UI distinguishes a reset-detected peer (persistent Reset-cleanup banner + one-click Re-peer) from a generic auth-failure peer (plain "Reset Peering") and from a peer whose Re-peer could not be cryptographically verified (`repeer_incomplete` — see "Trust re-establishment contract"). `repeer_incomplete` is a nullable-TEXT value only; no schema migration. 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 2aed34b2..a5424306 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -const { peers, resetEvents, resetPeer, initiatePeering, deleteUser, addToast } = vi.hoisted(() => ({ +const { peers, resetEvents, acknowledgeResetEvent, resetPeer, initiatePeering, deleteUser, addToast } = vi.hoisted(() => ({ peers: vi.fn(), resetEvents: vi.fn(), + acknowledgeResetEvent: vi.fn(), resetPeer: vi.fn(), initiatePeering: vi.fn(), deleteUser: vi.fn(), @@ -21,6 +22,7 @@ vi.mock('../../../api/client', async () => { peers, approvalRequests: vi.fn().mockResolvedValue({ requests: [] }), resetEvents, + acknowledgeResetEvent, resetPeer, initiatePeering, }, @@ -73,16 +75,21 @@ function orphanedAccount(overrides: Record = {}) { }; } -function resetEvent(accounts: ReturnType[]) { +function resetEvent( + accounts: ReturnType[], + overrides: Record = {}, +) { return { origin: 'https://peer.example', deadEpoch: 'epoch-old', newEpoch: 'epoch-new', detectedAt: Date.now(), resolvedAt: null, + acknowledgedAt: null, stubCount: 3, orphanedAccountCount: accounts.length, orphanedAccounts: accounts, + ...overrides, }; } @@ -90,6 +97,7 @@ describe('FederationPanel — Reset cleanup', () => { beforeEach(() => { peers.mockReset(); resetEvents.mockReset(); + acknowledgeResetEvent.mockReset(); resetPeer.mockReset(); initiatePeering.mockReset(); deleteUser.mockReset(); @@ -272,4 +280,80 @@ describe('FederationPanel — Reset cleanup', () => { 'warning', ); }); + + it('renders the detached-accounts card with Dismiss + Remove and informational copy, no Keep/frozen', async () => { + peers.mockResolvedValue({ peers: [] }); + resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); + + render(); + + // Both real actions are present. + await screen.findByRole('button', { name: /Dismiss/ }); + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + + // Informational detach copy — not urgent-cleanup language. + expect(screen.getAllByText(/detached/i).length).toBeGreaterThan(0); + expect(screen.getByText(/existing password/i)).toBeInTheDocument(); + + // The fake client-only Keep/frozen affordance is fully gone. + expect(screen.queryByRole('button', { name: 'Keep' })).not.toBeInTheDocument(); + expect(screen.queryByText(/frozen/i)).not.toBeInTheDocument(); + // No "orphaned" urgency wording in the detached-accounts copy. + expect(screen.queryByText(/with local content orphaned/i)).not.toBeInTheDocument(); + }); + + it('does not render an acknowledged event and excludes it from the badge count', async () => { + peers.mockResolvedValue({ peers: [] }); + resetEvents.mockResolvedValue({ + events: [resetEvent([orphanedAccount()], { acknowledgedAt: 1234 })], + }); + + render(); + + // Give effects a chance to run, then assert the whole section stays absent. + await waitFor(() => expect(resetEvents).toHaveBeenCalled()); + await waitFor(() => + expect(screen.queryByText('Reset Cleanup')).not.toBeInTheDocument(), + ); + expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument(); + }); + + it('dismisses an event via the acknowledge API and re-fetches', async () => { + peers.mockResolvedValue({ peers: [] }); + // First load: unacknowledged. After acknowledge, re-fetch returns it acknowledged. + resetEvents + .mockResolvedValueOnce({ events: [resetEvent([orphanedAccount()])] }) + .mockResolvedValue({ events: [resetEvent([orphanedAccount()], { acknowledgedAt: 1234 })] }); + acknowledgeResetEvent.mockResolvedValue({ success: true }); + + render(); + + const dismissBtn = await screen.findByRole('button', { name: /Dismiss/ }); + fireEvent.click(dismissBtn); + + await waitFor(() => + expect(acknowledgeResetEvent).toHaveBeenCalledWith('https://peer.example'), + ); + // fetchAll re-runs after acknowledge (peers + resetEvents both hit twice). + await waitFor(() => expect(resetEvents).toHaveBeenCalledTimes(2)); + // The card disappears once the re-fetch marks the event acknowledged. + await waitFor(() => + expect(screen.queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument(), + ); + }); + + it('surfaces an error toast when dismiss fails', async () => { + peers.mockResolvedValue({ peers: [] }); + resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); + acknowledgeResetEvent.mockRejectedValue(new Error('Network down')); + + render(); + + fireEvent.click(await screen.findByRole('button', { name: /Dismiss/ })); + + await waitFor(() => + expect(addToast).toHaveBeenCalledWith('Network down', 'warning'), + ); + }); }); diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index 5bce34f1..45dc456f 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -815,15 +815,21 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) = // ─── Reset Cleanup ────────────────────────────────────────────────────────── // -// Highest-priority admin attention surface for the instance-epoch self-healing -// flow (§6.4). Two stacked surfaces: +// Admin attention surface for the instance-epoch self-healing flow (§6.4) and +// the orphaned-account detach flow (detach spec §4.6). Two stacked surfaces: // 1. A persistent accent-rose banner per peer detected as reset // (status === 'needs_attention' && needsAttentionReason === 'peer_reset_detected'), // with a one-click Re-peer (resetPeer → initiatePeering) that triggers the -// server-side heal on activation. -// 2. Per-origin lists of the dead incarnation's orphaned real accounts with -// Keep (no-op resting/frozen state) and Remove (full purge via the existing -// admin delete) actions. +// server-side heal on activation. This one is genuinely actionable, so it +// keeps the rose/danger styling. +// 2. Per-origin, informational cards for the dead incarnation's detached real +// accounts. Detachment is not a failure state: these accounts keep working +// locally and their owners sign in with the same password. The card offers a +// real, server-side Dismiss (acknowledgeResetEvent — hides the card without +// touching the accounts) and a per-account Remove (full purge via the existing +// admin delete) for the ones that truly are abandoned. Neutral tier styling — +// no urgency. Acknowledged events are filtered out client-side (the endpoint +// keeps returning them for audit). function peerName(peer: FederationPeer): string { if (peer.instanceName) return peer.instanceName; @@ -853,7 +859,6 @@ function ResetCleanup() { const [loading, setLoading] = useState(false); const [confirmAction, setConfirmAction] = useState(null); const [actionLoading, setActionLoading] = useState(false); - const [keptIds, setKeptIds] = useState>(new Set()); const fetchAll = useCallback(async () => { setLoading(true); @@ -924,11 +929,6 @@ function ResetCleanup() { const { account } = confirmAction; await api.admin.deleteUser(account.id); addToast(`Removed ${account.username} and all their content`, 'success', 3000); - setKeptIds((prev) => { - const next = new Set(prev); - next.delete(account.id); - return next; - }); await fetchAll(); } } catch (err) { @@ -962,7 +962,25 @@ function ResetCleanup() { } }; - const eventsWithOrphans = events.filter((e) => e.orphanedAccounts.length > 0); + // Dismiss the detached-accounts card without touching the accounts — the event + // stays in the DB (acknowledged) for audit but stops surfacing to the admin. + const handleDismiss = async (origin: string) => { + setActionLoading(true); + try { + await api.federation.acknowledgeResetEvent(origin); + await fetchAll(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Failed to dismiss', 'warning'); + } finally { + setActionLoading(false); + } + }; + + // Only unacknowledged events with detached accounts surface a card. Dismissed + // (acknowledged) events are filtered out here and drop off the badge count. + const eventsWithOrphans = events.filter( + (e) => e.orphanedAccounts.length > 0 && e.acknowledgedAt === null, + ); // Render nothing when there is no reset-detected peer and no orphaned account — // exactly as PendingApprovals returns null when empty (loading also renders null). @@ -973,7 +991,7 @@ function ResetCleanup() {
Reset Cleanup
- {resetPeers.length + eventsWithOrphans.reduce((n, e) => n + e.orphanedAccounts.length, 0)} + {resetPeers.length + eventsWithOrphans.length}
@@ -1019,61 +1037,54 @@ function ResetCleanup() { was reset — {event.stubCount} replicated{' '} {event.stubCount === 1 ? 'identity' : 'identities'} auto-cleaned,{' '} {event.orphanedAccounts.length}{' '} - {event.orphanedAccounts.length === 1 ? 'account' : 'accounts'} with local content orphaned. + {event.orphanedAccounts.length === 1 ? 'account' : 'accounts'} with local content detached. + Detached accounts keep working locally — owners keep access with their existing password.
- {event.orphanedAccounts.map((account) => { - const kept = keptIds.has(account.id); - return ( -
-
-
-
- {account.displayName || account.username} -
-
{account.username}
-
- {account.spaceMemberCount}{' '} - {account.spaceMemberCount === 1 ? 'membership' : 'memberships'} ·{' '} - {account.messageCount}{' '} - {account.messageCount === 1 ? 'message' : 'messages'} -
- {account.ownedSpaces.length > 0 && ( -
- Owns: {account.ownedSpaces.map((s) => s.name).join(', ')} -
- )} + {event.orphanedAccounts.map((account) => ( +
+
+
+
+ {account.displayName || account.username}
-
- {kept ? ( - Kept — frozen - ) : ( - - )} - +
{account.username}
+
+ {account.spaceMemberCount}{' '} + {account.spaceMemberCount === 1 ? 'membership' : 'memberships'} ·{' '} + {account.messageCount}{' '} + {account.messageCount === 1 ? 'message' : 'messages'}
+ {account.ownedSpaces.length > 0 && ( +
+ Owns: {account.ownedSpaces.map((s) => s.name).join(', ')} +
+ )} +
+
+
- ); - })} +
+ ))}
+
))}