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.
**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
+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 |
| 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.
@@ -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();
@@ -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.
</div>
<div className="space-y-2">
{event.orphanedAccounts.map((account) => (