From 521aff6e52f94df94ac10b1ee187bec809a9afb2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Jul 2026 02:28:35 +0200 Subject: [PATCH] docs(federation/auth/api/db/client): detached-account re-attach flow; reset-cleanup panel mentions re-attach --- docs/systems/auth.md | 2 ++ docs/systems/database.md | 12 ++++++++++++ .../FederationPanel.resetCleanup.test.tsx | 2 ++ .../instanceSettingsPanels/FederationPanel.tsx | 2 ++ 4 files changed, 18 insertions(+) diff --git a/docs/systems/auth.md b/docs/systems/auth.md index daac6ef6..15149923 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -302,6 +302,8 @@ Trade-off: the separate authenticated call can fail independently of the login P Detach is sovereign but not permanent: the legitimate owner who re-created their account on the reset home can re-bind the detached account to the new home identity via `POST /api/users/@me/reattach` (registered in `routes/federation.ts`; see `federation.md` "Peer-Side Re-Attach"). It re-binds **only** on possession of BOTH identities — the session IS the detached account (local password authority, via `authenticate`) AND a one-time proof token minted on the home via `POST /api/auth/attach-proof` verifies with the home peer over signed S2S. Identity is never username-matched (that is the tier-2 hijack). On success the endpoint merges any pre-existing replicated stub for the new identity into the detached row, sets `home_user_id = `, **clears `federation_home_orphaned` (0)**, nulls `profile_updated_at`, and applies the current home profile. Clearing the flag automatically **re-enables** normal federated-account semantics: login self-heal resumes (the epoch guard above runs again) and the S2S binding guards stop excluding the account — live profile/presence sync from the home is restored. The local password hash is kept; a detached tombstone (`is_deleted = 1`) is never re-attachable. +**Proof-mint endpoint — `POST /api/auth/attach-proof`** (`routes/auth.ts`). The home-side half of the exchange, run against the owner's re-created **native** account on the reset home. JWT-authenticated, rate-limited 5/15min. Body `{ targetDomain }` names the peer the caller intends to re-attach on. Guards: the session user must be **native** (`homeInstance` null) — a federated/replicated session cannot mint a proof for itself. Mints a random 256-bit token (`randomBytes(32).toString('hex')`), inserts a `federation_attach_proofs` row `{ homeUserId, targetDomain, createdAt, expiresAt = now + 60s, usedAt: null }`, and returns `{ token }`. The token is **single-use, 60s TTL, and bound to `targetDomain`** — only the named peer can redeem it (verified server-side against the authenticated peer domain, not the request body, at `POST /api/federation/verify-attach-proof`). Each mint opportunistically janitors expired rows (`DELETE ... WHERE expires_at < now`); since the TTL is 60s, any spent (`used_at` set) token is swept on the next mint after it expires, so the table stays bounded without a background worker. + --- ## 5. Password Change diff --git a/docs/systems/database.md b/docs/systems/database.md index 713e2439..6a74dd96 100644 --- a/docs/systems/database.md +++ b/docs/systems/database.md @@ -428,6 +428,18 @@ Instance-epoch self-healing ledger. One row per origin recording a detected fede | orphanedAccountCount | integer NOT NULL | 0 | Count of accounts that could not be re-linked to the new epoch | | acknowledgedAt | integer | | Epoch ms the admin dismissed this reset event from the banner (`POST /api/federation/reset-events/acknowledge`, idempotent); `NULL` while unacknowledged. Purely informational — detach spec §4.6 | +### federation_attach_proofs +One-time proof tokens for **detached-account re-attach** (re-attach spec §3.1). Minted on the owner's re-created **native** account on the reset home instance via `POST /api/auth/attach-proof` (bound to the peer domain the account is detached on), then redeemed exactly once by that peer over signed S2S via `POST /api/federation/verify-attach-proof` to re-bind the detached row to the new home identity. Tokens are single-use (`used_at` set atomically on redemption), short-lived (60s TTL), and peer-domain-bound (verified against the authenticated peer's domain, not the token bearer). Expired rows are janitored opportunistically on each mint, so the table needs no background worker. + +| Column | Type | Default | Notes | +|--------|------|---------|-------| +| token | text PK | | Random 256-bit token (`randomBytes(32).toString('hex')`, 64 hex chars) handed to the target peer | +| homeUserId | text NOT NULL | | The native home account's `users.id` this proof asserts control of | +| targetDomain | text NOT NULL | | Normalized peer domain (lowercase, no scheme/trailing slash) allowed to redeem — checked against the authenticated caller peer, not the request body | +| createdAt | integer NOT NULL | | Epoch ms the token was minted | +| expiresAt | integer NOT NULL | | Epoch ms; `createdAt + 60_000`. Redemption requires `expires_at > now` | +| usedAt | integer | | Epoch ms the token was redeemed; `NULL` while unused. Set atomically via `UPDATE ... WHERE used_at IS NULL ... RETURNING` so a token can never be redeemed twice, even under concurrent verification | + ### peer_approval_requests Queue of peering requests pending admin review when `autoAcceptPeering` is `false`. Holds **both directions**: inbound rows (remote asked to peer with us) and outbound rows (a local user-initiated `ensurePeered` call gated on this side; see [federation.md → Outbound Peering Gate](federation.md#outbound-peering-gate)). UNIQUE on `(origin, direction)` so the same origin may have at most one row per direction simultaneously. Rows expire after 30 days via janitor cleanup. 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 d54f3ab1..db6bda90 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx @@ -298,6 +298,8 @@ describe('FederationPanel — Reset cleanup', () => { // Informational detach copy — not urgent-cleanup language. expect(screen.getAllByText(/detached/i).length).toBeGreaterThan(0); expect(screen.getByText(/existing password/i)).toBeInTheDocument(); + // Re-attach is surfaced as the recovery path (spec §3.5). + expect(screen.getByText(/re-attach a detached account to their new home identity/i)).toBeInTheDocument(); // The fake client-only Keep/frozen affordance is fully gone. expect(screen.queryByRole('button', { name: 'Keep' })).not.toBeInTheDocument(); diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index f167297a..41389816 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -1039,6 +1039,8 @@ function ResetCleanup() { {event.orphanedAccounts.length}{' '} {event.orphanedAccounts.length === 1 ? 'account' : 'accounts'} with local content detached. Detached accounts keep working locally — owners keep access with their existing password. + The owner can re-attach a detached account to their new home identity from that account's + settings (Account → detached notice) when logged into both.
{event.orphanedAccounts.map((account) => (