docs(federation/auth/api/db/client): detached-account re-attach flow; reset-cleanup panel mentions re-attach

This commit is contained in:
Jannis Braun
2026-07-03 02:28:35 +02:00
parent 344a429e98
commit 521aff6e52
4 changed files with 18 additions and 0 deletions
+2
View File
@@ -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 = <new homeUserId>`, **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. 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 = <new homeUserId>`, **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 ## 5. Password Change
+12
View File
@@ -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 | | 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 | | 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 ### 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. 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.
@@ -298,6 +298,8 @@ describe('FederationPanel — Reset cleanup', () => {
// Informational detach copy — not urgent-cleanup language. // Informational detach copy — not urgent-cleanup language.
expect(screen.getAllByText(/detached/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/detached/i).length).toBeGreaterThan(0);
expect(screen.getByText(/existing password/i)).toBeInTheDocument(); 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. // The fake client-only Keep/frozen affordance is fully gone.
expect(screen.queryByRole('button', { name: 'Keep' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Keep' })).not.toBeInTheDocument();
@@ -1039,6 +1039,8 @@ function ResetCleanup() {
{event.orphanedAccounts.length}{' '} {event.orphanedAccounts.length}{' '}
{event.orphanedAccounts.length === 1 ? 'account' : 'accounts'} with local content detached. {event.orphanedAccounts.length === 1 ? 'account' : 'accounts'} with local content detached.
Detached accounts keep working locally owners keep access with their existing password. 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.
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{event.orphanedAccounts.map((account) => ( {event.orphanedAccounts.map((account) => (