Merge federation orphaned-account detach + sync-filtering + re-attach + DM reconciliation

Detach semantics for accounts whose home instance was reset (sovereign local
account, self-heal disabled, S2S guards exclude detached rows), plus three
follow-ups proven by two-instance e2e on the live boxes:
- dead-incarnation sync filtering (receiver self-homed guard, requester-scoped
  DM/friend sync, deleted-snapshot flag, startup junk sweep)
- owner-initiated re-attach (proof-gated re-bind + stub merge; automatic on
  connect when username+password match, AccountPanel fallback)
- 1-on-1 DM federatedId reconciliation on re-attach (re-key/merge) + startup
  drift heal

Server 1335 tests + web 456 green; e2e verified: reset->re-register->connect
yields zero self-homed junk, correct re-bind, single merged conversation.
This commit is contained in:
Jannis Braun
2026-07-03 13:11:23 +02:00
51 changed files with 12305 additions and 282 deletions
+1 -1
View File
@@ -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
+18 -4
View File
@@ -11,9 +11,12 @@ POST /auth/register { username, password, displayName?, avatarColor?, ho
GET /auth/check-username ?username= → { available, reason? }
GET /auth/check-invite ?token= → CheckInviteResponse
POST /auth/login { username, password } → { token, user }
POST /auth/attach-proof (JWT, rate-limited 5/15min) { targetDomain } → { token } (AttachProofResponse)
```
**`POST /auth/login`** — request/response shape unchanged, but two internal controls from instance-epoch self-healing gate the flow: (1) an account with `federationHomeOrphaned = 1` (home instance factory-reset) is rejected with the generic 401 *before* password verification; (2) the federated password self-heal now runs an **epoch guard** — it re-hashes the stale local password only if the home instance's authenticated epoch (`fetchPeerEpoch`) matches the trusted baseline, failing closed when the epoch differs or can't be determined. No wire-shape change. See `auth.md` §4.
**`POST /auth/attach-proof`** — JWT-authenticated. Mints a one-time 256-bit token (`randomBytes(32).toString('hex')`) for the logged-in **native** user, stored in `federation_attach_proofs` bound to `{ homeUserId, targetDomain, expiresAt = now+60s }`; expired rows are janitored on each mint (the delete targets `expires_at < now`; a used-but-unexpired row lingers until it expires). The token is handed to the peer named by `targetDomain`, which redeems it via `POST /federation/verify-attach-proof` to re-attach the caller's detached account there. See `federation.md` "S2S Detached-Account Re-Attach Proof" and re-attach spec §3.1.
**`POST /auth/login`** — request/response shape unchanged, but two internal controls from instance-epoch self-healing gate the flow: (1) an account with `federationHomeOrphaned = 1` (home instance factory-reset) is **detached** — a sovereign local account whose local password hash is the sole authority; it logs in normally with that local password (the detach pivot removed the old pre-verification freeze), and the flag's only login effect is to permanently disable self-heal (step 7); (2) for non-detached federated accounts, the password self-heal runs an **epoch guard** — it re-hashes the stale local password only if the home instance's authenticated epoch (`fetchPeerEpoch`) matches the trusted baseline, failing closed when the epoch differs or can't be determined. A detached account can be re-bound to the owner's new home identity via `POST /users/@me/reattach` (re-attach spec §3.2), which clears the flag and re-enables normal federated semantics. No wire-shape change. See `auth.md` §4.
**`POST /auth/register` gating** — branches on whether `homeInstance` is set:
- **Federated path** (`homeInstance` set): gated solely by `instance_settings.federatedRegistrationOpen`. `inviteToken` is ignored entirely (not validated, not consumed). 403 `Federated registration is closed on this instance` when closed. Existing federated stubs (relay-created, `passwordHash = '!federation-replicated'`) upgrade in place — login is never blocked by this gate.
@@ -47,7 +50,9 @@ GET /users/:id → { user }
GET /users/:id/mutuals ?homeUserId= → { mutualFriends[], mutualSpaces[] }
```
**Write protection:** If the authenticated user is a replicated user (`homeInstance` is set), the following fields are rejected with 403: `displayName`, `avatar`, `banner`, `accentColor`, `avatarColor`, `bio`. These fields are managed by the home instance via S2S relay.
**Write protection:** If the authenticated user is a replicated user (`homeInstance` is set **and** `federationHomeOrphaned !== 1`), the following fields are rejected with 403: `displayName`, `avatar`, `banner`, `accentColor`, `avatarColor`, `bio`. These fields are managed by the home instance via S2S relay. **Exception — detached accounts** (`federationHomeOrphaned === 1`): a federated account whose home instance was reset/lost is a sovereign local account with no home managing its profile, so it edits these durable fields locally like a native user (detach design §4.4). Detached edits are NOT relayed (the S2S profile-relay path stays gated on `!homeInstance`).
**Self-view flag:** `GET /users/@me`, the login response, and the WS `ready` payload all sanitize the row with `isSelf=true` and include `federationHomeOrphaned: boolean` (detach design §4.7) — self-view only; it is never exposed to other users and never on the deleted/tombstone branch.
## Spaces (`routes/spaces.ts`) — auth required
```
@@ -325,11 +330,14 @@ POST /federation/peer/initiate (admin) { remoteOrigin }
POST /federation/peer/accept (public, IP rate-limited 10/min) { sourceOrigin, challenge, hmacSecret, instanceName?, instanceId?, approvalToken? } → { accepted:true, instanceName, instanceId } (200) | queued (202 + { approvalToken }) | 409 { accepted:false, code:'PEER_EXISTS_RESET_REQUIRED', instanceName, instanceId }
GET /federation/peers (admin) → { peers[] } (no secrets; each peer carries needsAttentionReason)
GET /federation/reset-events (admin) → FederationResetEventsResponse
POST /federation/reset-events/acknowledge (admin) { origin } → { success } (200) | 400 missing origin | 404 unknown origin
DELETE /federation/peers/:id (admin) → { success } + outbox cleanup
POST /federation/relay (HMAC-signed S2S) FederationRelayRequest (+ sourceInstanceId?) → { accepted[], rejected[] }
POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?, dmChannelId?, federatedId?, contextType? } → { events[], hasMore, checkpoint }
POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer) { username } → { found, user? }
POST /federation/epoch (HMAC-signed S2S, HMAC-signed response) {} → { instanceId }
POST /federation/verify-attach-proof (HMAC-signed S2S, HMAC-signed response, rate-limited 60/min/peer) { token } → { valid:true, homeUserId, username } | { valid:false }
POST /users/@me/reattach (JWT as detached account, rate-limited 5/15min) { token } → { success:true, user } (ReattachResponse) | 400/401/403/404/409
```
**`POST /api/federation/peer/accept`** — public, IP-rate-limited. Optional `approvalToken` (64-hex) on the request body proves mutual admin approval; required to promote an `awaiting_approval` row to `active` when the receiver has `autoAcceptPeering=0`. The receiver returns it in the 202 body when queueing the request for admin review (`{ queued: true, message, approvalToken }`); the initiator stores it and the receiver's `/approve` later forwards it back. See `federation.md` §1 "Approval Token Verification" for the full lifecycle and threat model.
@@ -343,7 +351,7 @@ POST /federation/epoch (HMAC-signed S2S, HMAC-signed response) {}
```typescript
type FederationOrphanedAccount = {
id: string;
username: string; // '!orphaned:{uid}@domain' for freed handles; real for space owners
username: string; // preserved original handle (detach spec); legacy rows may carry '!orphaned:{uid}@domain'
displayName: string | null;
avatarColor: string | null;
ownedSpaces: { id: string; name: string }[];
@@ -352,17 +360,23 @@ type FederationOrphanedAccount = {
};
type FederationResetEvent = {
origin: string; deadEpoch: string; newEpoch: string | null;
detectedAt: number; resolvedAt: number | null;
detectedAt: number; resolvedAt: number | null; acknowledgedAt: number | null;
stubCount: number; orphanedAccountCount: number;
orphanedAccounts: FederationOrphanedAccount[];
};
type FederationResetEventsResponse = { events: FederationResetEvent[] };
```
**`POST /api/federation/reset-events/acknowledge`** — admin-only, no S2S. Body `{ origin }`: `400` if missing, `404` if no journal row for that origin, else stamps `acknowledged_at = Date.now()` **only if currently null** (idempotent — a second call keeps the original timestamp) and returns `{ success: true }`. Lets the admin banner be dismissed server-side (Task 7) instead of client-only state; purely informational, detached accounts stay detached (detach spec §4.6).
Disposition actions reuse existing endpoints (no new mutating routes): one-click Re-peer = `POST /peers/:id/reset``POST /peer/initiate`; full-purge Remove = `DELETE /api/admin/users/:id` (owns-spaces → transfer first). **`needsAttentionReason`** (`'auth_failures' | 'peer_reset_detected' | 'repeer_incomplete' | null`) is now included on each `GET /federation/peers` peer object so the client can raise the persistent Reset-cleanup banner only for reset-detected peers and surface an "incomplete Re-peer" warning for `repeer_incomplete`. See `federation.md` "Instance Epoch" and `client-federation.md` §8.
**`POST /api/federation/users/lookup`** — HMAC-authenticated S2S endpoint. Resolves a username on this instance to its canonical `(homeUserId, profile snapshot)`. Used by the cross-instance friend-add flow on the sender's home server before queuing a `friend_request_create` event. Responds to native, non-deleted users only; ignores `discoverable`. Returns `{ found: false, code: 'user_not_found' }` for stubs, tombstoned users, or unknown handles. See `federation.md` §1 "S2S User Lookup" for the full contract.
**`POST /api/users/@me/reattach`** — the owner-initiated detached-account re-attach (re-attach spec §3.2). JWT-authenticated as the detached account but registered in `routes/federation.ts` (consumes the peer HMAC channel + profile machinery). Body `{ token }` (64-hex; else 400). Re-binds the sovereign detached row to the owner's new home identity **only** when both proofs hold: the session IS the detached account AND the token verifies with the home peer over signed S2S (`POST /federation/verify-attach-proof`). Guards: non-detached/native → 403; missing/tombstoned session → 404 (already 401'd at `authenticate`); home not an active peer → 409; proof invalid → 401; new identity held by a **non-stub** local account → 409. On success: merges any pre-existing replicated stub for the new identity into the detached row (repoint+dedupe every `users.id` FK a DM/friend replica can hold, then delete the stub), sets `home_user_id`/`federation_home_orphaned=0`, adopts the new home username base if it differs (collision-suffix), nulls `profile_updated_at`, pulls+applies the home profile (best-effort), and broadcasts `user_updated`. The paired mint endpoint is `POST /api/auth/attach-proof` (see Auth). See `federation.md` "Peer-Side Re-Attach" and re-attach spec §3.23.3.
**`POST /api/federation/verify-attach-proof`** — HMAC-authenticated S2S endpoint on the home instance that redeems a one-time attach-proof token (single-use, bound to the calling peer's domain, HMAC-signed fail-closed response). See `federation.md` "S2S Detached-Account Re-Attach Proof".
**`POST /api/federation/epoch`** — HMAC-authenticated S2S endpoint returning this instance's persistent epoch (`{ instanceId }`). The **request** is HMAC-signed (only a peer holding the shared secret may call it; unknown/revoked peers → 403, bad signature → 401, missing headers → 400) **and the response body is HMAC-signed** with the same secret (`X-Federation-Signature/Timestamp/Nonce` response headers), so the caller can verify the epoch before writing it as the peer's trusted baseline (`federation_peers.peer_instance_id`). The value is already public via `/instance/info`; signing is for baseline-integrity, not confidentiality. Caller: `fetchPeerEpoch(peer)` (`utils/federationEpoch.ts`), which fails safe — 404 (not-yet-upgraded peer), bad/absent response signature, or network/timeout all return `null` (retry next tick). Populates the epoch baseline deterministically via the bounded periodic epoch-refresh. See `federation.md` "Instance Epoch" §3.2.
### Federation Peering Approval Queue
+14 -7
View File
@@ -264,9 +264,9 @@ Validates format (same rules as local registration: 3-32 chars, `/^[a-z0-9_]+$/`
2. Look up user by `username` (trimmed, lowercased)
3. Reject if not found (generic "Invalid username or password")
4. Reject if `isDeleted === 1` ("This account has been deleted")
5. **Reject if `federationHomeOrphaned === 1`** (generic "Invalid username or password") — *before* password verification. This freezes any federated account whose home instance was factory-reset (a new incarnation stood up on the same domain), set by the reset quarantine (§6.3 below / `federation.md` "Instance Epoch"). Because it returns first, it blocks both the local-password path AND the self-heal path — nobody, including a new same-name user on the reset home, can authenticate into the dead-incarnation account. Reversible (admin Keep/Remove, or the real user re-registers into a fresh account).
6. Verify password via bcrypt
7. **If password invalid AND user is federated:** attempt self-healing (see below)
5. Verify password via bcrypt. **`federationHomeOrphaned === 1` no longer short-circuits here.** A federated account whose home instance was reset (a new incarnation stood up on the same domain) is now treated as **detached** — a sovereign LOCAL account whose local password hash is the sole authority (detach design §4.1). Local-hash verification proceeds normally: the correct local password logs in. The flag's only login effect is to permanently disable the self-heal path (step 7). *(Historical note: this check was previously a pre-verification freeze that blocked the local-password path too; the detach re-interpretation removed it so the real owner keeps their account instead of being locked out.)*
6. *(merged into step 5)*
7. **If password invalid AND user is federated:** if `federationHomeOrphaned === 1` (detached), reject immediately with the generic "Invalid username or password" — **no outbound request to the home domain**: there is no trusted home to consult, and re-hashing on the new incarnation's say-so would hand the account to a stranger. Otherwise attempt self-healing (see below).
8. **If password invalid AND user is local:** reject
9. Sign JWT, return `{ token, user }`. **Note:** Login does NOT mutate `users.status`. A successful login does not by itself imply a live connection (the client may never establish a WebSocket due to network failure, mobile background, error path); writing `'online'` here would produce a permanently stuck-online row that no disconnect timer cleans up. The WebSocket auth path (`ws/handler.ts`) is the single source of truth for `status = 'online'`. See `docs/systems/activity-presence.md` "Boot Reset" for the mitigation that runs on server start.
@@ -296,7 +296,13 @@ Before re-hashing, the self-heal confirms the home instance is the **same incarn
- **Baseline on record AND `fetchPeerEpoch` returns `null`** — epoch cannot be determined (peer too old → 404, unreachable, bad/absent response signature, **or the reset peer's desynced secret rejecting our signed request**): **fail closed — refuse self-heal.**
- **Baseline on record AND the epoch matches:** allow.
Trade-off: the separate authenticated call can fail independently of the login POST, so a transient home outage during a legitimate stale-hash login fails closed. This is security-over-availability on a rare, recoverable path (fallback: a normal password change once the home is reachable); trusting an unauthenticated body would re-open the hijack. A reset peer's `null` result also doubles as a reset signal — the guard is correct even before reset-detection has flagged the peer. The direct-login freeze (step 5 of the Login Flow, `federationHomeOrphaned = 1`) is the post-re-peer complement: once the admin re-peers and the baseline updates to the new epoch, the epoch guard alone would pass again, so the freeze is what keeps the dead-incarnation account locked.
Trade-off: the separate authenticated call can fail independently of the login POST, so a transient home outage during a legitimate stale-hash login fails closed. This is security-over-availability on a rare, recoverable path (fallback: a normal password change once the home is reachable); trusting an unauthenticated body would re-open the hijack. A reset peer's `null` result also doubles as a reset signal — the guard is correct even before reset-detection has flagged the peer. This epoch guard covers the **undetected-reset window** — non-detached federated accounts whose home was reset but not yet quarantined. Once the quarantine flags an account as **detached** (`federationHomeOrphaned = 1`), the self-heal path is disabled for it entirely (step 7 of the Login Flow): the detached account is no longer a remote identity that can be self-healed at all, so the epoch comparison never runs for it — the local hash is its only authority. Re-peering the new incarnation therefore cannot re-open self-heal into a detached account.
#### Re-attach: leaving the detached state (re-attach spec §3.2)
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.
---
@@ -313,12 +319,13 @@ Trade-off: the separate authenticated call can fail independently of the login P
| User type | `currentPassword` | Behavior |
|-----------|-------------------|----------|
| Local (`homeInstance` is null) | Required | Verified via bcrypt against stored hash |
| Federated (`homeInstance` set) | Not required | JWT auth is sufficient (home instance already verified the change) |
| Federated (`homeInstance` set, `federationHomeOrphaned !== 1`) | Not required | JWT auth is sufficient (home instance already verified the change) |
| Detached (`homeInstance` set, `federationHomeOrphaned === 1`) | Required | Follows the **local** rule — the home is gone, so nothing external verified the change; the local hash is the sole authority (detach design §4.4) |
**Steps:**
1. Validate `newPassword` is string, min 8 chars
2. Load user from DB
3. If local: require and verify `currentPassword`
3. If local **or detached** (`!homeInstance || federationHomeOrphaned === 1`): require and verify `currentPassword`
4. Hash new password
5. Update `passwordHash` AND `passwordChangedAt = Date.now()` -- this invalidates all prior tokens
6. Sign fresh JWT, return `{ token }`
@@ -379,7 +386,7 @@ When a user changes their password on their home instance, `authStore.changePass
**Pre-checks:**
1. `username` must match stored username (confirmation safeguard)
2. Local users must provide and verify `password`; federated users rely on JWT auth
2. Native local users **and detached accounts** (`federation_home_orphaned = 1`) must provide and verify `password` against the local hash; non-detached federated users rely on JWT auth (their home instance already vouches for them). A detached account is a sovereign local account with no home verifying anything, so it follows the LOCAL rule — the same self-destruct protection as a native account, and mirroring the change-password rule (§5, detach spec §4.4). Condition: `!user.homeInstance || user.federationHomeOrphaned === 1`. Missing password → 400; wrong password → 403.
3. Must not own any spaces (returns 400 with `ownedSpaces` list)
**Client-side flow** (`authStore.deleteAccount()`):
+19 -1
View File
@@ -86,6 +86,18 @@ When a user adds a remote instance via the Connections settings:
The same password is used across all instances. Password changes on the home instance are synced to remote instances automatically.
### Automatic Re-Attach on Connect (`maybeAutoReattach`, re-attach spec §3.4)
When a home instance is reset, its established accounts on peers become **detached** (`federationHomeOrphaned = 1`) — sovereign local accounts nothing from the old domain can re-bind. The owner who re-registers on the reset home under the same username + password would otherwise end up with two permanently forked identities. `maybeAutoReattach(instance)` (exported from `instanceStore.ts`) closes that gap as the **primary** re-link UX, and runs fire-and-forget right after `connectInstance(...)` in **both** `connectToRemote` and `loginToRemote`.
It performs the proof exchange **only** when all hold (else it returns silently — the manual fallback stays available):
1. The just-connected account is detached (`user.federationHomeOrphaned && user.homeInstance`).
2. This client also holds an authenticated session on the account's **home domain** — the primary connection when browsing it (native primary user, host matches), else a `status === 'connected'` secondary instance in `instances`.
3. That home session's username base equals the detached account's username base (case-insensitive, via `parseFederatedUsername`) — the unambiguous "same name" case. A cross-name bind is manual-only (spec §2).
Exchange: `homeSession.api.auth.attachProof(peerHost)``POST /api/auth/attach-proof` mints a one-time token on the home; `instance.api.users.reattach({ token })``POST /api/users/@me/reattach` on the peer verifies it over S2S and re-binds. On success the connection's `user`/`username` and the registry entry are updated, a "re-linked" toast fires, and `syncRegistry()` runs. On failure it only `console.warn`s — the connection itself is never torn down.
### API Client Error Contract
The shared API client (`packages/web/src/api/client.ts:298`) throws `new Error(body.error)` for non-2xx responses. The server's structured error code is on `err.message`; there is **no** `err.body` or `err.code` property. Catch handlers that need to map codes to UI messages should read `err.message` and pass it as both the code and the fallback to `mapServerErrorToMessage` (see `packages/web/src/utils/friendErrors.ts`).
@@ -480,7 +492,13 @@ 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.
### AccountPanel re-attach action (fallback, re-attach spec §3.4)
The owner-facing side of re-attach. `AccountPanel` (`components/modals/settingsPanels/AccountPanel.tsx`) renders the detached-account notice whenever the self user is detached (`federationHomeOrphaned && homeInstance`). Below the informational copy it appends a **"Re-attach to `<homeInstance>`"** action **only** when `instanceStore.instances` also holds a `status === 'connected'` connection whose origin host matches the account's `homeInstance` (`homeConnection`, memoized). This is the explicit fallback for what `maybeAutoReattach` deliberately skips: a different username on the new home (cross-name bind), or a home connection established after the detached connection.
The button is a two-step armed confirm that names both identities — first click arms (`Confirm re-attach as <homeUsername>`), second click mints and exchanges the proof: `homeConnection.api.auth.attachProof(window.location.host)``api.users.reattach({ token })`, then `useAuthStore.getState().setUser(res.user)` clears the flag so the notice disappears. Errors surface inline; without a home-domain connection the notice keeps only its informational copy.
---
+14 -1
View File
@@ -34,7 +34,7 @@ IDs: Snowflake text, permissions: bigint decimal strings
| showActivity | integer NOT NULL | 1 | Rich presence visibility |
| federationRegistryUpdatedAt | integer | 0 | LWW timestamp for federation registry sync |
| federationHealPending | integer | 0 | Instance-epoch self-healing: set when a replicated identity is flagged for re-heal after a peer reset |
| federationHomeOrphaned | integer | 0 | Instance-epoch self-healing (now live, Phase 2): **set** to 1 by `quarantineOrphanedAccounts` on every real account whose home instance was factory-reset (freeze); **read** by the login flow (rejected before password verify — `auth.md` §4) and the `GET /api/federation/reset-events` admin surface. Reversible via admin Keep/Remove |
| federationHomeOrphaned | integer | 0 | Instance-epoch self-healing: **1 = DETACHED / sovereign local account** (its home instance was reset/lost), not "frozen." **Set** to 1 by `quarantineOrphanedAccounts` on every real account from a reset home incarnation (flag-only detach — no rename, no login block). **Read** by: the login flow (self-heal path permanently disabled for detached rows; local-password login still works — `auth.md` §4), `users.ts` (unlocks local profile edit + local change-password), the S2S binding guards (`findFederatedUser` tier-2, `profile_update`, identity-delete all exclude detached rows — `federation.md`), and the `GET /api/federation/reset-events` admin surface. Cleared only by `tombstoneUser` (deletion). Detach spec §3/§4 |
| createdAt | integer NOT NULL | | Epoch ms |
### spaces
@@ -426,6 +426,19 @@ Instance-epoch self-healing ledger. One row per origin recording a detected fede
| resolvedAt | integer | | Epoch ms healing completed; `NULL` while in progress |
| stubCount | integer NOT NULL | 0 | Count of replicated identity stubs affected by the reset |
| 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.
+2
View File
@@ -58,6 +58,8 @@ const federatedId = crypto.randomUUID(); // 36-char UUID with dashes
The format difference (32-char hex vs 36-char UUID with dashes) allows detecting channel type independently of `ownerId`. The self-healing migration uses this: `length(federated_id) = 36 AND federated_id LIKE '________-____-____-____-____________'` identifies group DMs.
**Re-attach re-keys the 1-on-1 `federatedId` (reattach-dm-reconcile spec).** Because the 1-on-1 id derives from the two participants' `home_user_id`s, a participant's `home_user_id` change — the detached-account re-attach flow (`POST /api/users/@me/reattach`, see `federation.md`) — changes the `federatedId` of every 1-on-1 DM that participant is in. Left alone, pre-reattach history would stay under the old-identity channel while new messages compute the new id and split into a parallel channel (one conversation shown twice). Instead, existing channels are **reconciled** by `reconcileDmChannelFederatedId`: **re-keyed in place** when no channel yet holds the new id, or **merged + deleted** into the existing new-identity channel when one does (`idx_dm_federated` is UNIQUE, so a re-key onto an occupied id is impossible). This runs inline in the re-attach transaction for the re-attaching account and as an idempotent startup sweep (`reconcileDriftedDmFederatedIds`) that heals accounts re-attached before the fix shipped. Group DMs (random-UUID `federatedId`, member-independent) are never affected.
---
## 1-on-1 DM Creation
+59 -9
View File
@@ -310,6 +310,32 @@ Admin-initiated paths (`/peer/initiate`, `/approve`) do NOT call `ensurePeered`.
| `/api/federation/identity` | DELETE | HMAC | Delete federated user identity (soft/full mode) |
| `/api/federation/users/lookup` | POST | HMAC, rate-limited 60/min/peer | Resolve a username on this instance to (homeUserId, profile snapshot) for cross-instance friend-request originators |
| `/api/federation/epoch` | POST | HMAC (signed request **and** signed response) | Return this instance's persistent epoch `{ instanceId }`; populates a peer's trusted epoch baseline (`peer_instance_id`) |
| `/api/federation/verify-attach-proof` | POST | HMAC (signed request **and** signed response), rate-limited 60/min/peer | Verify a one-time detached-account re-attach proof token; single-use, bound to the calling peer's domain (re-attach spec §3.1) |
### S2S Detached-Account Re-Attach Proof (`POST /api/federation/verify-attach-proof`)
The home-instance verifier for the detached-account re-attach flow (re-attach spec §3.1). A user who was detached on peer R (its home domain was reset; see the Detached-account guards above) proves control of the still-native home account H, mints a one-time proof token on H via `POST /api/auth/attach-proof` (`randomBytes(32).toString('hex')`, stored in `federation_attach_proofs`, bound to R's domain), and hands it to R. R then calls this endpoint on H to redeem it.
- **Request:** HMAC-signed (same boilerplate as `/users/by-home-id`: missing headers → 401, unknown/inactive peer → 403, rate-limited 60/min/peer → 429, bad signature → 401, nonce replay → 409/401). Body `{ token: string }`.
- **Peer-domain binding is server-side (anti-replay):** the token's `target_domain` must equal the domain of the **authenticated calling peer** (`extractDomain(peer.origin)`), never a value from the request body. A compromised peer cannot redeem a token minted for a different peer.
- **Single-use is atomic:** the claim is a raw `UPDATE federation_attach_proofs SET used_at=? WHERE token=? AND used_at IS NULL AND expires_at>? AND lower(target_domain)=? RETURNING home_user_id`. Only the first concurrent verification can flip `used_at` from NULL, so a token can never be redeemed twice.
- **Re-confirms native identity:** after the claim, the home user must still be native and live (`isDeleted=0 AND home_instance IS NULL`) — a user tombstoned or turned into a replicated stub after mint fails closed.
- **Signed response (epoch pattern):** the body is HMAC-signed with the peer's shared secret (`X-Federation-Signature/Timestamp/Nonce` response headers) so the caller can trust the identity it carries. `{ valid: true, homeUserId, username }` on success; every failure mode (unknown/expired/used/wrong-domain/malformed token, deleted/non-native home user) fails closed to a **still-signed** `{ valid: false }`.
### Peer-Side Re-Attach (`POST /api/users/@me/reattach`)
The owner-initiated exception to the detach invariant (re-attach spec §3.2), on peer R. **JWT-authenticated as the detached account, not S2S** — but registered in `routes/federation.ts` (not `users.ts`) because it consumes federation-internal machinery (`verifyAttachProofWithPeer`, `fetchHomeProfileByHomeId`, `downloadProfileAsset`, the peer HMAC channel). It re-binds the sovereign detached row back to the owner's new home identity, restoring live sync while keeping history. It links **only** when BOTH proofs hold: the session IS the detached account (local password authority, via `authenticate`) AND the one-time token verifies with the home peer over signed S2S (`verifyAttachProofWithPeer`). Identity is never guessed and never username-matched — the latter is exactly the tier-2 hijack the detach branch closed.
- **Body:** `{ token: string }` (64-char hex; else 400). **Response:** `{ success: true, user: User }` (sanitized self-view; `ReattachResponse`).
- **Guards, in order:** (1) session user is a **live detached** federated account (`home_instance` set, `federation_home_orphaned = 1`, `is_deleted = 0`) → else 403; a missing/tombstoned row → 404 (a tombstoned session is already 401'd at `authenticate`, so the handler's 404 is defense-in-depth for a concurrent-delete race — detached tombstones are not re-attachable). (2) The home domain is an **active peer** → else 409 (the proof is only as trustworthy as the S2S channel it verifies over). (3) `verifyAttachProofWithPeer` returns `valid:true` → else 401 (fails closed). (4) If the verified new identity already has a live local row for that domain, it MUST be a replicated stub (`password_hash = '!federation-replicated'`) → a real account holding it is state corruption, aborted with **409 + a `console.error`** (impossible while the detached row holds the username).
- **Effect (one `rawDb.transaction`):** merges any pre-existing stub for the new identity into the detached row (below), sets `home_user_id = <new homeUserId>`, `federation_home_orphaned = 0`, adopts the new home username base if it differs (existing collision-suffix `<base>_<n>@<domain>` scheme; base match keeps the current handle), and **nulls `profile_updated_at`** so the home's next `profile_update` (any version) tier-1 matches and applies. Group-DM authority the old identity held is migrated by `owner_home_user_id` (the S2S authority key, home-domain-normalized). After the transaction, a best-effort `fetchHomeProfileByHomeId` pull applies the current home profile (avatar/banner via `downloadProfileAsset`, fail-open); then `user_updated` is broadcast to friends/DM/space co-members + all self connections (`collectProfileBroadcastTargetIds`).
- **Guard re-enablement:** clearing `federation_home_orphaned` automatically re-enables normal federated-account semantics — login self-heal resumes, and the S2S `profile_update`/`presence_update`/tier-2/identity-delete guards correctly stop firing for this account (they only fire on `federation_home_orphaned = 1`). This is intended, not a guard regression.
**Stub merge (spec §3.3).** By the time the owner re-attaches, R may already hold a replicated stub for the new home identity (from ordinary DM/friend relay, e.g. `youruser_1@<domain>`). Two rows must not share `(homeUserId, homeInstance)`, so the stub is merged into the detached row inside the transaction: every `users.id` FK a replicated stub **can** populate is repointed, with collision rows deduped **before** repoint. Tables (audited against `schema.ts`): `dm_members` (dedupe on `dm_channel_id`), `dm_messages`, `messages`, `dm_reactions` (dedupe on `dm_message_id+emoji`), `reactions` (dedupe on `message_id+emoji`), `friends` (both columns + drop self-rows), `friend_requests` (both columns + drop self-rows), `read_states` (dedupe on `channel_id`), `dm_channels.owner_id` (plain-text column, no FK). Space-scoped FKs (`space_members`, `member_roles`, `*_overrides`, `bans`, `join_requests`, `voice_restrictions`, layouts/folders) and moderator/owner RESTRICT columns are **not** repointed — a DM/friend replica can never hold them. The stub row is then deleted. Only a `'!federation-replicated'` row is ever a merge source (guard 4).
**1-on-1 DM `federatedId` reconciliation (reattach-dm-reconcile spec §3.1–§3.2).** A 1-on-1 DM's identity is `computeFederatedId(homeUserIdA, homeUserIdB)` — a deterministic SHA-256 of the two sorted home user IDs. The re-bind changes the account's `home_user_id`, so **every** 1-on-1 DM it participates in now derives a different `federatedId`: pre-reattach history stays under the OLD-identity channel while post-reattach messages compute the NEW id and land in a parallel channel — one conversation surfaced twice. So, still inside the re-attach transaction (after the re-bind UPDATE), the endpoint enumerates the account's 1-on-1 channels (exactly 2 members, 1-on-1-shaped `federated_id`) and calls `reconcileDmChannelFederatedId(rawDb, channelId)` on each. That helper recomputes the expected id from the members' **current** home identities and, when it differs from the stored id, either **re-keys in place** (no channel already carries the new id) or **merges the drifted channel INTO the existing new-identity channel and deletes it** (`idx_dm_federated` is UNIQUE, so two rows can never share a `federated_id`). Merge moves `dm_messages` (globally-unique snowflake ids; `attachments`/`dm_reactions` follow by `dm_message_id`), dedupes `dm_members` and `read_states` on their composite PKs, then drops the source row. Group DMs (random-UUID `federatedId`) are **skipped** — their id is member-independent, so re-attach never drifts them. After commit, affected local members receive `dm_channel_closed` (merged source) + `dm_channel_created` (full surviving-channel payload) so the split collapses live without a reload.
**Why R-local reconciliation is complete, not partial (spec §2).** A 1-on-1 DM is stored on an instance only if that instance is the home of at least one participant. The detached account is homed at the reset domain, i.e. **not native to R** (the peer where it now lives) — so the *other* participant of every 1-on-1 DM it holds on R is necessarily R-native, and R is that channel's authoritative home. Re-keying locally therefore produces the globally-correct id (the same value the R-native counterpart and the new home-identity compute); the reset home instance holds no old-identity channel. There is no cross-instance residual to relay: R-local reconciliation covers 100% of the account's 1-on-1 DMs.
### S2S Epoch Refresh (`POST /api/federation/epoch`)
@@ -332,7 +358,11 @@ In a single transaction, `markPeerReset`:
**Detection sources (all three wired in this feature):**
- **Inbound handshake** — `/peer/accept` landing on an `active`/`needs_attention` row (`routes/federation.ts`): before the idempotent-200 return, `if (reqInstanceId && existing.peerInstanceId && reqInstanceId !== existing.peerInstanceId) markPeerReset(...)`. The guard still returns 200 and **still does not rekey** — the anti-hijack property is preserved verbatim; detection is layered on top.
- **Reachability probe (`unreachable` peers)** — `probePeerReachable()` (`utils/federationRecovery.ts`) now parses `instanceId` from the `/api/instance/info` response (returning `{ reachable, instanceId }`; a missing/unparseable epoch is `null`, never an error). The shared decision helper `recoverOrDetectReset(peer, result)` — used by both the background recovery tick (`processRecoveryTick`) and the manual recheck endpoint — routes to `markPeerReset` (returning `'reset_detected'`) when the peer has a non-null `peer_instance_id` and the probed epoch differs, and **does NOT call `markPeerRecovered`**. Rationale: a genuinely reset peer's HMAC secret is desynced, so flipping it back to `active` via a reachability probe would resume relay against a dead secret. Only when the probed epoch matches the baseline (or the baseline is null / epoch unknown) does the normal recovery-to-active path run. The manual recheck endpoint returns `{ recovered: false, status: 'needs_attention' }` on `'reset_detected'`.
- **Health-tick probe (`needs_attention` peers)** — `detectResetOnNeedsAttentionPeers()` (`utils/federationRecovery.ts`), called from `processHealthCheckTick` on the 15-minute tick, closes design §4.1's remaining sub-case. A reset peer can reach `needs_attention` via the **auth-failure path** — its HTTP is up but returns 401/403 because the new incarnation has no peer row for us, so `consecutive_auth_failures` crosses `AUTH_FAILURE_THRESHOLD`**without ever transitioning through `unreachable`**. The `unreachable`-only recovery probe therefore never observes its epoch change, so no journal is ever created; a later manual Re-peer would then run `healResetIncarnation` with no journal row → no heal → the split-brain persists. This pass selects peers with `status='needs_attention'` AND `peer_instance_id IS NOT NULL` AND `needs_attention_reason` not already `peer_reset_detected` (those already carry a journal), probes each (`probePeerReachable`, one `/instance/info` GET per qualifying peer per tick), and calls `markPeerReset` on an observed epoch mismatch. **Detection only:** unlike `recoverOrDetectReset`, it NEVER flips a `needs_attention` peer to `active` (a match / unknown / unreachable result is a pure no-op) — that peer's secret is desynced and only an admin-authenticated re-peer restores trust. It touches neither `peer_instance_id` nor `hmac_secret`.
- **`needs_attention`-peer reset probe** — closes design §4.1's remaining sub-case. A reset peer can reach `needs_attention` via the **auth-failure path** — its HTTP is up but returns 401/403 because the new incarnation has no peer row for us, so `consecutive_auth_failures` crosses `AUTH_FAILURE_THRESHOLD`**without ever transitioning through `unreachable`**. The `unreachable`-only 5-second recovery probe therefore never observes its epoch change, so no journal is created; a later manual Re-peer would then run `healResetIncarnation` with no journal row → no heal → the split-brain persists. The shared per-peer unit is **`detectResetForPeer(peer)`** (`utils/federationRecovery.ts`): for a peer with a non-null baseline it runs one `probePeerReachable` (`/instance/info` GET) and calls `markPeerReset` on an observed epoch mismatch. It is invoked from **three** places, so detection latency is near-zero rather than up to a full health-check cycle:
1. **Event-driven, at the transition** — the instant the outbox worker moves a peer to `needs_attention` on the auth-failure threshold (`federationWorker.ts`), it fires `detectResetForPeer` for that peer (fire-and-forget). This is the common live case: the moment the connection is declared broken, the epoch is checked and "Re-peer & heal" surfaces immediately.
2. **Worker-startup sweep**`startFederationWorkers()` runs `detectResetOnNeedsAttentionPeers()` once on boot, catching any peer already parked in `needs_attention` (reset while this instance was down, or transitioned before this probe shipped).
3. **15-minute health-tick backstop**`detectResetOnNeedsAttentionPeers()` also still runs at the end of `processHealthCheckTick` as the periodic safety net. It selects peers with `status='needs_attention'` AND `peer_instance_id IS NOT NULL` AND `needs_attention_reason` not already `peer_reset_detected` (those already carry a journal) and calls `detectResetForPeer` on each.
**Detection only:** unlike `recoverOrDetectReset`, none of these ever flips a `needs_attention` peer to `active` (a match / unknown / unreachable result is a pure no-op) — that peer's secret is desynced and only an admin-authenticated re-peer restores trust. Neither `peer_instance_id` nor `hmac_secret` is touched.
Legacy peers advertise no epoch (`instanceId` null), so detection requires a non-null observed epoch differing from a non-null stored baseline — legacy peers never trigger it, and the existing `auth_failures → needs_attention → manual Reset` path continues unchanged for them.
@@ -361,16 +391,17 @@ Detection (`markPeerReset`) only snapshots + journals + notifies; it destroys no
**Live co-member update.** Around each stub, the heal loop now broadcasts a sanitized `user_updated` so survivors' clients flip the partner to "Deleted User" without a reload — aligning this path with the other three deletion callers (admin/self/identity-delete). For each stub it captures `collectDeletionBroadcastTargets(stub.id).targetUserIds` **before** `tombstoneUser` (which deletes the DM/friend/space rows that set is derived from), then re-reads the tombstoned row and calls `connectionManager.sendToUser(targetId, { type: 'user_updated', user: sanitizeUser(deletedRow) })` for each target.
**Real federated accounts — quarantine (design §6.3b).** A flagged user that is NOT a stub (`password_hash != '!federation-replicated'`) carries real, non-re-syncable local content and is **never** auto-tombstoned. In the genuine-reset branch, after the stub soft-tombstone loop, `healResetIncarnation` calls `quarantineOrphanedAccounts(origin)`:
**Real federated accounts — detach (design §6.3b, revised by the 2026-07-02 orphaned-account-detach spec).** A flagged user that is NOT a stub (`password_hash != '!federation-replicated'`) carries real, non-re-syncable local content and is **never** auto-tombstoned. In the genuine-reset branch, after the stub soft-tombstone loop, `healResetIncarnation` calls `quarantineOrphanedAccounts(origin)`:
- For every flagged real account (`federation_heal_pending = 1`, non-stub, `isDeleted = 0`, `homeInstanceMatch`): set `federation_home_orphaned = 1` (**freeze**) and clear `federation_heal_pending`.
- **The freeze is universal** — applied even to space owners. It is the barrier that closes the *post*-re-peer hijack: once re-peered, the trusted baseline updates to the new epoch, so the login epoch guard reads "match" again and no longer blocks a same-name hijack; only the freeze does. Enforcement is in `auth.ts` (direct login rejects `federation_home_orphaned = 1` before password verify — see `auth.md` §4).
- **The rename is conditional.** A non-owner is renamed `username → '!orphaned:{uid}@{domain}'` to **free the handle**, so a returning same-name user re-registers into a clean fresh account instead of colliding. This defends BOTH the login uniqueness check AND the registration tier-2 stub-resolution upgrade (`findFederatedUser`). A space owner is **not** renamed (renaming an `ownerId`-referenced account orphans the reference) — it stays frozen and is surfaced to the admin to resolve ownership by hand.
- Content (space messages, memberships, reactions) is preserved in all cases. The returned count refreshes the journal's `orphaned_account_count`.
- For every flagged real account (`federation_heal_pending = 1`, non-stub, `isDeleted = 0`, `homeInstanceMatch`): set `federation_home_orphaned = 1` and clear `federation_heal_pending`. **That is all** — this is a flag-only **detach**, not a freeze.
- **`federation_home_orphaned = 1` means "DETACHED / sovereign local account,"** not "frozen." The account was cut loose from its (now-reset) home instance and operates as a purely local account from here on: it logs in with its **local password** (`auth.ts` no longer blocks a detached account before password verify — only the self-heal branch is permanently disabled for it, see `auth.md` §4), and it gains local profile edit + local change-password (`users.ts`). There is **no login freeze**.
- **No rename.** The username is preserved — first-come-first-served on this instance. There is **no `!orphaned:{uid}@{domain}` handle-freeing** and **no space-owner special case**: all real accounts are treated uniformly and owners simply keep managing their spaces.
- **What closes the post-re-peer hijack** is NOT a login freeze. It is the combination of (a) the login self-heal being **permanently disabled** for detached accounts (`auth.ts` returns 401 in the failed-local-password branch without contacting the home domain — a new incarnation can never re-hash its way in) and (b) the S2S identity-binding guards, which **exclude detached rows** on every domain-keyed surface: `findFederatedUser` tier-2 (`federation_home_orphaned = 0` predicate, ~line 3522), the S2S `profile_update` handler (accept-and-skip, ~line 6162), the S2S `presence_update` handler (accept-and-skip, after the domain-collision check), the `hydrateReplicatedUserProfile` fill-empty path (no-op early return alongside the native-user skip), and the S2S `DELETE /api/federation/identity` guard (idempotent 200, no deletion, ~line 2357). Every domain-keyed **mutation** that a tier-1 (`homeUserId`) hit can reach is guarded at its own site — a tier-1 hit on a detached row is a legitimate historical read, but no write is applied. See "S2S Identity Deletion" above and design §4.3.
- Content (space messages, memberships, reactions) and usernames are preserved in all cases. No `user_updated` broadcast is emitted (nothing visible changes). The returned count refreshes the journal's `orphaned_account_count`.
**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.
**Login self-heal epoch guard (design §6.3a).** The federated password self-heal (`auth.ts` §4) 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. This closes the *pre*-re-peer hijack (a reset home accepting a new same-name user's password during the undetected-reset window). The *post*-re-peer window is closed by detach: once an account is detached, its self-heal path is permanently disabled and the S2S binding guards exclude it (above), so the new incarnation has zero influence over it. 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.
@@ -406,6 +437,7 @@ Allows a home instance to remove a user's replicated identity from a remote inst
**Behavior:**
- **Attribution guard:** Rejects with `403` if the user's `homeInstance` doesn't match the `X-Federation-Origin` of the signing peer. Prevents one instance from deleting another instance's users.
- **Detached-account guard:** After the attribution guard, if the resolved user has `federation_home_orphaned = 1` (detached — its home domain was reset and it is now a sovereign local account, see §4.2/§4.3 of the orphaned-account-detach design), returns an idempotent `200 { success: true }` **without deleting**. A new incarnation on the reset domain must never delete an established account by replaying its old `homeUserId`.
- **Idempotent:** Returns `{ success: true }` for already-deleted or nonexistent users (no error).
- **Owned spaces check:** Returns `409` with `{ ownedSpaces: string[] }` if the user owns any spaces on the remote. The user must transfer or delete those spaces before identity removal proceeds.
- **Mode `"soft"`:** Calls `tombstoneUser(uid, { purgeContent: false })` — anonymizes the user row and removes the user from spaces, friends, DM membership (`dm_members`), and read-states. The `purgeContent: false` flag skips only `reactions`, `dm_reactions`, and the user's space `messages` (with attachments + embeds); DM membership cleanup and orphaned-DM purge always run in both modes (per `userDeletion.ts:121-126, 169-202`) because zero-member DM channels are unreachable garbage regardless of authorship retention.
@@ -503,6 +535,7 @@ Two layers of replay protection:
- Three-tier lookup: homeUserId match → domain + username hint match → not found
- Tier 1: delegates to `resolveLocalUser` (fast path)
- Tier 2: uses `extractDomain(homeInstance)` + `hints.username` to match stubs created by the auth registration path (which may have a different homeUserId)
- **Tier 2 excludes detached accounts** (`federation_home_orphaned = 1`): a detached account is sovereign and must never be re-bound to the reset domain's new incarnation via username heuristics — that is exactly how a new same-name user would capture the established account. **Tier 1 (`homeUserId` match) is deliberately NOT excluded:** the new incarnation mints fresh `homeUserId`s, so a tier-1 hit on a detached row is a legitimate historical reference (e.g. an old group-DM attribution relayed by a third instance), not the new incarnation. Mutations are blocked at their own sites (profile_update handler, presence_update handler, `hydrateReplicatedUserProfile` fill-empty, S2S identity delete).
- Side-effect-free — does not modify any records
- When multiple candidates match in tier 2, prefers real accounts over stubs, then most profile data
- **Use when:** Read-only lookup that needs to find users created by either auth or relay path
@@ -519,6 +552,7 @@ Two layers of replay protection:
- Accepts optional `hints: { username?: string | null }` for tier-2 matching
- If not found, creates a stub with `homeInstance` normalized to bare domain via `extractDomain`
- Collision-safe: appends `_1`, `_2`, ..., `_10` suffix if username exists; after 10 attempts, uses `_<random hex>`
- **Self-homed guard:** an instance never creates a replicated stub homed at its own identity domain (`getOurIdentityDomain()`, DOMAIN-derived). A live self-reference resolves at tier 1; a self-domain identity reaching the create path is a dead incarnation and resolves to `null`. Wire snapshots may carry `deleted: true` — such identities also resolve to `null` at the create path (existing rows still resolve for historical attribution).
- **Use when:** You MUST have a valid user ID. Always pass `{ username: profile?.username }` when profile data is available.
**`hydrateReplicatedUserProfile(user, profile, db)`** -- `federation.ts:2041`
@@ -1289,8 +1323,12 @@ When relay events carry `FederationRelayProfileSnapshot` data:
- `processFriendRequestCreateEvent` / `processFriendAddEvent`: hydrates friend profiles
- `social.ts` federated friend-request handler: hydrates the looked-up stub
Snapshots for tombstoned users carry `deleted: true` and no profile fields — the internal `!deleted:<id>` username marker never leaves the instance (`getDmParticipants`, `buildProfileSnapshot`).
`hydrateReplicatedUserProfile` is **best-effort fill-empty only**: it never overwrites an existing avatar/banner/displayName/bio. This protects locally-downloaded bare filenames produced by `processProfileUpdateEvent` (which carries the monotonic `profileUpdatedAt` version) from being clobbered back to absolute URLs on the next DM/friend relay. Authoritative updates flow exclusively through the version-checked `profile_update` event.
**Detached-account guard:** Alongside the native-user skip (`!user.homeInstance → return`), the function also **no-ops on detached rows** (`federation_home_orphaned = 1 → return user`). A detached account retains its `homeInstance` for provenance (design §7), so the native-user skip alone would not catch it; without this guard a DM/friend relay from the reset domain's new incarnation, resolved via an old `homeUserId` tier-1 hit, could fill the sovereign account's empty fields. This is the same domain-keyed mutation class as `profile_update`/`presence_update`, guarded at its own site (design §4.3).
When the function does fill an empty avatar/banner, it calls `downloadProfileAsset` against the user's home instance and stores the resulting **local bare filename**. Only on download failure does it fall back to the absolute URL — matching the behavior of `processProfileUpdateEvent`.
#### Profile Sync (S2S)
@@ -1311,6 +1349,8 @@ Profile data is synced server-to-server. The home instance is authoritative —
**Processing:** Remote overwrites all 6 mutable fields unconditionally; `displayName` falls back to `payload.displayName ?? payload.username`. Rejects if incoming `profileUpdatedAt ≤ stored`. Broadcasts `user_updated` to local WS clients.
**Detached-account guard:** After the domain-collision check and before the version check, if the resolved `localUser` has `federation_home_orphaned = 1` (detached — home domain was reset, now a sovereign local account), the event is **acked (messageId pushed to `accepted`) and skipped without applying**. The reset domain's new incarnation must never overwrite an established account's profile by replaying its old `homeUserId`. Ack rather than reject because the sender legitimately considers the identity theirs to update; from this side the update simply no-ops.
#### Profile Image File Replication
When a `profile_update` relay carries avatar or banner absolute URLs, the receiving instance downloads the image files locally rather than storing remote URLs. This eliminates cross-origin dependencies — avatars render from the local `/api/uploads/` endpoint.
@@ -1365,6 +1405,8 @@ No-op for replicated users (we don't own their presence).
**Receiver:** `processPresenceUpdateEvent` (`routes/federation.ts`). Strict attribution — `payload.homeInstance` domain MUST equal source peer's domain. Resolves the local stub by `homeUserId`, validates the stub's `homeInstance` matches the payload's domain, updates the stub's `status`, and broadcasts a WS `presence_update` to local users via `collectProfileBroadcastTargetIds(stub.id)` — friends, DM members, and space co-members.
**Detached-account guard:** After the domain-collision check and before the status write, if the resolved stub has `federation_home_orphaned = 1` (detached — home domain was reset, now a sovereign local account), the event is **acked (messageId pushed to `accepted`) and skipped without applying**. The reset domain's new incarnation must never flip an established account's presence by replaying its old `homeUserId`. Ack (not reject) mirrors the `profile_update` guard rationale — the sender considers the identity theirs, so we no-op rather than trigger a retry loop.
**Peer lifecycle hooks** (`utils/federationPresence.ts`):
- **`onPeerActivated`** invokes `snapshotPresenceForPeer(origin)` — emits a `presence_update` only for online natives that have an S2S relationship with the peer (friend/DM with a peer-stub, or `replicatedInstances` opt-in for the peer origin). Snapshot work scales with relationship count, not native count.
- **`onPeerDeactivated`** invokes `markPeerStubsOffline(origin)` — flips every stub from that peer to `offline` and broadcasts a local `presence_update` so users see them go offline immediately.
@@ -1522,7 +1564,7 @@ HMAC-authenticated. Returns events from the `federation_mutation_log`.
```
**DM sync:**
- Queries all `dm_channels` with non-null `federated_id` (not soft-deleted)
- Queries `dm_channels` with non-null `federated_id` (not soft-deleted) that have ≥1 relevant member for the requesting peer (see relevance scoping below)
- Joins `federation_mutation_log` with `dm_messages` to reconstruct events
- Only returns locally-created messages (`source_instance IS NULL` via the LEFT JOIN)
- Handles delete mutations separately (message rows don't exist for deletes)
@@ -1533,6 +1575,8 @@ HMAC-authenticated. Returns events from the `federation_mutation_log`.
- Queries `federation_mutation_log WHERE context_type = 'friend'`
- Returns stored payloads directly (friend events carry their complete data)
**Relevance scoping (dead-incarnation filtering):** the DM branch only offers channels having ≥1 member whose local row is homed at the requesting peer's domain with `is_deleted = 0` AND `federation_home_orphaned = 0`. A freshly reset peer therefore receives an empty DM sync — its pre-reset history stays with the detached accounts on this side. The friend branch filters analogously in JS (an event qualifies iff one side is homed at the requester and does not resolve to a detached/tombstoned local row); `checkpoint`/`hasMore` are computed from pre-filter rows so filtered events still advance the cursor.
### Relay Event Processing
The event processing logic is extracted into `processRelayEvents()` (exported from `federation.ts`), shared by both the HTTP relay endpoint and the initial sync worker. This avoids a DNS hairpin issue where the server would HTTP-request itself through public DNS, which fails on networks without hairpin NAT.
@@ -1650,6 +1694,12 @@ All workers are started by `startFederationWorkers()` on server boot and stopped
| Epoch-refresh baseline | Startup + 15min (end of health tick) | active peers w/ `peer_instance_id IS NULL` | 10s per peer | `refreshPeerEpochs` (populate-if-null, self-terminating) |
| Janitor | 1h | -- | -- | `runFederationJanitor` (sync) |
| Startup bootstrap sync | Once at startup | -- | 30s per page | `startupBootstrapSync``onPeerActivated` |
| Dead-incarnation sweep | Once at startup | -- | -- | `sweepDeadIncarnationArtifacts` (sync, idempotent) |
| DM `federatedId` reconciliation | Once at startup | -- | -- | `reconcileDriftedDmFederatedIds` (sync, idempotent) |
`sweepDeadIncarnationArtifacts` — startup, idempotent: deletes DM channels with no native member (with explicit child-row cleanup) and unreferenced replicated stubs homed at this instance's own domain; still-referenced stubs are skipped and logged. It does not rely on FK cascade (must not assume `PRAGMA foreign_keys` is ON): the channel delete explicitly clears its `dm_reactions`/`attachments`/`dm_messages`/`dm_members`/`read_states` rows, and the stub delete explicitly clears its `dm_reactions`/`reactions`/`read_states` rows — and the stub's deletable guard mirrors the non-cascading FKs to `users.id` by hand (a future non-cascading FK to `users.id` needs a matching NOT-EXISTS clause).
`reconcileDriftedDmFederatedIds` — startup, idempotent (reattach-dm-reconcile spec §3.3): in one transaction, iterates every 1-on-1 DM channel (exactly 2 members, 1-on-1-shaped `federated_id`) and calls `reconcileDmChannelFederatedId` on each, re-keying or merging any whose stored id has drifted from its members' current home identities. This **heals accounts re-attached before inline reconciliation shipped** (§3.2) — including the live split-conversation duplicate — without manual DB surgery. On a clean database every channel is a no-op (one hash per 1-on-1 channel); it logs a summary only when it changed something (`[federation] DM federatedId reconciliation: rekeyed N, merged M`). Wired next to `sweepDeadIncarnationArtifacts` in `startFederationWorkers`.
### Janitor Cleanup (`storageJanitor.ts:runFederationJanitor`)
@@ -0,0 +1 @@
ALTER TABLE `federation_reset_events` ADD `acknowledged_at` integer;
@@ -0,0 +1,8 @@
CREATE TABLE `federation_attach_proofs` (
`token` text PRIMARY KEY NOT NULL,
`home_user_id` text NOT NULL,
`target_domain` text NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer NOT NULL,
`used_at` integer
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,20 @@
"when": 1782932926154,
"tag": "0008_cute_sebastian_shaw",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1783011477517,
"tag": "0009_uneven_red_ghost",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1783035334526,
"tag": "0010_broken_blazing_skull",
"breakpoints": true
}
]
}
+14
View File
@@ -404,6 +404,20 @@ export const federationResetEvents = sqliteTable('federation_reset_events', {
resolvedAt: integer('resolved_at'),
stubCount: integer('stub_count').notNull().default(0),
orphanedAccountCount: integer('orphaned_account_count').notNull().default(0),
acknowledgedAt: integer('acknowledged_at'),
});
// One-time proof tokens for detached-account re-attach (re-attach spec §3.1).
// Minted by POST /api/auth/attach-proof for a logged-in native user, verified
// once by a peer via POST /api/federation/verify-attach-proof. Expired/used
// rows are deleted opportunistically on each mint.
export const federationAttachProofs = sqliteTable('federation_attach_proofs', {
token: text('token').primaryKey(),
homeUserId: text('home_user_id').notNull(),
targetDomain: text('target_domain').notNull(),
createdAt: integer('created_at').notNull(),
expiresAt: integer('expires_at').notNull(),
usedAt: integer('used_at'),
});
// SQL-level CHECK constraint enforces (direction='inbound' → hmac_secret NOT NULL).
@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
import { signJwt } from '../utils/auth.js';
setWorkerId(13);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
let app: FastifyInstance;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
// authRoutes → ./federation.js → ../ws/handler.js; stub the connection manager.
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn(),
sendToSpace: vi.fn(),
sendToDmMembers: vi.fn(),
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
evictFederatedCallsForHost: vi.fn(),
federatedCalls: new Map(),
isUserOnline: vi.fn(),
lateBindFederatedCall: vi.fn(),
},
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
async function buildApp(): Promise<FastifyInstance> {
const { authRoutes } = await import('./auth.js');
const f = Fastify();
await f.register(authRoutes);
return f;
}
beforeEach(async () => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
testDb.insert(schema.users).values([
{ id: 'native-1', username: 'youruser', passwordHash: 'x', homeInstance: null, createdAt: 1 },
{ id: 'fed-1', username: 'guest@orbit.test', passwordHash: 'x', homeInstance: 'orbit.test', homeUserId: 'g-home', createdAt: 1 },
]).run();
app = await buildApp();
});
afterEach(async () => {
await app.close();
});
describe('POST /api/auth/attach-proof', () => {
it('mints a one-time token bound to the target domain', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/attach-proof',
headers: { authorization: `Bearer ${signJwt({ userId: 'native-1', username: 'youruser' })}` },
payload: { targetDomain: 'nova.ddns.net' },
});
expect(res.statusCode).toBe(200);
const { token } = JSON.parse(res.body);
expect(token).toMatch(/^[0-9a-f]{64}$/);
const row = testDb.select().from(schema.federationAttachProofs).all()[0]!;
expect(row.homeUserId).toBe('native-1');
expect(row.targetDomain).toBe('nova.ddns.net');
expect(row.usedAt).toBeNull();
expect(row.expiresAt - row.createdAt).toBe(60_000);
});
it('rejects non-native (federated) accounts', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/attach-proof',
headers: { authorization: `Bearer ${signJwt({ userId: 'fed-1', username: 'guest@orbit.test' })}` },
payload: { targetDomain: 'nova.ddns.net' },
});
expect(res.statusCode).toBe(403);
});
it('rejects a missing/invalid targetDomain', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/attach-proof',
headers: { authorization: `Bearer ${signJwt({ userId: 'native-1', username: 'youruser' })}` },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it('rejects a targetDomain that normalizes to empty and inserts no row', async () => {
// "https://" passes the pre-normalization length check but collapses to ""
// after protocol/slash stripping — must 400, never persist an inert row.
const res = await app.inject({
method: 'POST',
url: '/api/auth/attach-proof',
headers: { authorization: `Bearer ${signJwt({ userId: 'native-1', username: 'youruser' })}` },
payload: { targetDomain: 'https://' },
});
expect(res.statusCode).toBe(400);
expect(testDb.select().from(schema.federationAttachProofs).all()).toHaveLength(0);
});
it('rejects unauthenticated requests', async () => {
const res = await app.inject({ method: 'POST', url: '/api/auth/attach-proof', payload: { targetDomain: 'nova.ddns.net' } });
expect(res.statusCode).toBe(401);
});
it('deletes expired rows opportunistically on mint', async () => {
testDb.insert(schema.federationAttachProofs).values({
token: 'e'.repeat(64), homeUserId: 'native-1', targetDomain: 'x.test',
createdAt: 1, expiresAt: 2, usedAt: null,
}).run();
await app.inject({
method: 'POST',
url: '/api/auth/attach-proof',
headers: { authorization: `Bearer ${signJwt({ userId: 'native-1', username: 'youruser' })}` },
payload: { targetDomain: 'nova.ddns.net' },
});
const tokens = testDb.select().from(schema.federationAttachProofs).all().map(r => r.token);
expect(tokens).not.toContain('e'.repeat(64));
expect(tokens).toHaveLength(1);
});
});
@@ -54,13 +54,82 @@ beforeEach(async () => {
app = await buildApp();
});
describe('login: federation_home_orphaned freeze', () => {
it('rejects login for a frozen (orphaned) federated account even with the correct password', async () => {
// Seed a real federated account with a known password, then freeze it.
const passwordHash = await hashPassword('correct-horse');
// ---------------------------------------------------------------------------
// Shared self-heal harness (module scope so both the epoch-guard suite AND the
// detached-account regression test reuse the exact same arrangement).
// ---------------------------------------------------------------------------
// Seed a federated user whose LOCAL hash is stale (does not match the test
// password) plus an active peer row carrying `baselineEpoch` as its recorded
// baseline (null → no baseline on record). `federationHomeOrphaned` is left at
// its default 0 — i.e. a NON-detached account for which self-heal stays enabled.
async function seedStaleUserAndPeer(baselineEpoch: string | null, hmacSecret: string): Promise<void> {
const staleHash = await hashPassword('OLD-password-not-this');
testDb.insert(schema.users).values({
id: 'user-c',
username: 'carol@orbit.ddns.net',
passwordHash: staleHash,
homeInstance: 'orbit.ddns.net',
homeUserId: 'hid',
avatarColor: '#fff',
createdAt: Date.now(),
}).run();
testDb.insert(schema.federationPeers).values({
id: 'peer-k',
origin: 'https://orbit.ddns.net',
hmacSecret,
status: 'active',
peerInstanceId: baselineEpoch,
createdAt: Date.now(),
}).run();
}
// home-login POST → {ok:true}; /api/federation/epoch → signed {instanceId} (or
// 404 when `epochToEcho` is null, exercising the fail-closed "cannot determine"
// branch). The epoch response is HMAC-signed exactly as a real peer would sign
// it, so it round-trips through fetchPeerEpoch's real signature verification.
function makeFetchStub(hmacSecret: string, epochToEcho: string | null): typeof globalThis.fetch {
return (async (url: string | URL | Request): Promise<Response> => {
const u = String(url);
if (u.endsWith('/api/auth/login')) {
return new Response(JSON.stringify({ token: 't', user: {} }), { status: 200 });
}
if (u.endsWith('/api/federation/epoch')) {
if (epochToEcho === null) return new Response('nope', { status: 404 });
const body = JSON.stringify({ instanceId: epochToEcho });
const headers = buildFederationHeaders(body, hmacSecret, 'https://our.origin');
return new Response(body, { status: 200, headers });
}
throw new Error(`unexpected fetch ${u}`);
}) as typeof globalThis.fetch;
}
describe('detached account login (federation_home_orphaned)', () => {
// A detached account (its home instance was reset → a DIFFERENT incarnation now
// owns that domain) is a sovereign LOCAL account: the local password hash is the
// only authority. Local-hash login works normally; the hijackable self-heal path
// is PERMANENTLY disabled so the new incarnation can never re-hash a stranger's
// credentials into this established identity (detach design §4.1).
const seededUsername = 'carol@orbit.ddns.net';
const seededUserId = 'user-detached-1';
let savedFetch: typeof globalThis.fetch;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
savedFetch = globalThis.fetch;
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = savedFetch;
});
async function seedDetached(): Promise<void> {
const passwordHash = await hashPassword('correct-pw');
testDb.insert(schema.users).values({
id: 'user-frozen-1',
username: 'carol@orbit.ddns.net',
id: seededUserId,
username: seededUsername,
passwordHash,
homeInstance: 'orbit.ddns.net',
homeUserId: 'old-home-id',
@@ -68,20 +137,46 @@ describe('login: federation_home_orphaned freeze', () => {
avatarColor: '#fff',
createdAt: Date.now(),
}).run();
}
it('allows login with the correct LOCAL password for a detached account', async () => {
await seedDetached();
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
payload: { username: 'carol@orbit.ddns.net', password: 'correct-horse' },
method: 'POST', url: '/api/auth/login',
payload: { username: seededUsername, password: 'correct-pw' },
});
expect(res.statusCode).toBe(401);
expect(res.json().error).toBe('Invalid username or password');
expect(res.statusCode).toBe(200);
expect(res.json().user.id).toBe(seededUserId);
});
it('allows login for a non-frozen federated account with the correct password (freeze is targeted)', async () => {
// Control: same shape, but federationHomeOrphaned = 0 must authenticate,
// proving the freeze targets the flag rather than all federated accounts.
it('rejects a wrong password for a detached account WITHOUT contacting the home domain', async () => {
await seedDetached();
const res = await app.inject({
method: 'POST', url: '/api/auth/login',
payload: { username: seededUsername, password: 'wrong-pw' },
});
expect(res.statusCode).toBe(401);
// The self-heal path must never fire for detached accounts: no fetch at all.
expect(fetchMock).not.toHaveBeenCalled();
});
it('still allows self-heal for NON-detached federated accounts (regression)', async () => {
// federationHomeOrphaned=0 (seedStaleUserAndPeer default) must NOT take the
// new early-401 branch: stale local hash + home accepts + epoch matches the
// recorded baseline → self-heal → 200. Reuses the epoch-guard MATCH arrangement.
const secret = 'shared-secret-abc';
await seedStaleUserAndPeer('EPOCH-A', secret);
globalThis.fetch = makeFetchStub(secret, 'EPOCH-A'); // home echoes the SAME epoch
const res = await app.inject({
method: 'POST', url: '/api/auth/login',
payload: { username: 'carol@orbit.ddns.net', password: 'the-real-current-password' },
});
expect(res.statusCode).toBe(200);
});
it('allows local-password login for a NON-detached federated account (control)', async () => {
// federationHomeOrphaned=0 with a matching local hash authenticates directly,
// proving the detach handling targets the flag rather than all federated rows.
const passwordHash = await hashPassword('correct-horse');
testDb.insert(schema.users).values({
id: 'user-ok-1',
@@ -93,13 +188,10 @@ describe('login: federation_home_orphaned freeze', () => {
avatarColor: '#fff',
createdAt: Date.now(),
}).run();
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
method: 'POST', url: '/api/auth/login',
payload: { username: 'dave@orbit.ddns.net', password: 'correct-horse' },
});
expect(res.statusCode).toBe(200);
expect(res.json().token).toBeTruthy();
});
@@ -125,50 +217,6 @@ describe('login: self-heal epoch guard', () => {
globalThis.fetch = savedFetch;
});
// Seed a federated user whose LOCAL hash is stale (does not match the test
// password) plus an active peer row carrying `baselineEpoch` as its recorded
// baseline (null → no baseline on record).
async function seedStaleUserAndPeer(baselineEpoch: string | null, hmacSecret: string): Promise<void> {
const staleHash = await hashPassword('OLD-password-not-this');
testDb.insert(schema.users).values({
id: 'user-c',
username: 'carol@orbit.ddns.net',
passwordHash: staleHash,
homeInstance: 'orbit.ddns.net',
homeUserId: 'hid',
avatarColor: '#fff',
createdAt: Date.now(),
}).run();
testDb.insert(schema.federationPeers).values({
id: 'peer-k',
origin: 'https://orbit.ddns.net',
hmacSecret,
status: 'active',
peerInstanceId: baselineEpoch,
createdAt: Date.now(),
}).run();
}
// home-login POST → {ok:true}; /api/federation/epoch → signed {instanceId} (or
// 404 when `epochToEcho` is null, exercising the fail-closed "cannot determine"
// branch). The epoch response is HMAC-signed exactly as a real peer would sign
// it, so it round-trips through fetchPeerEpoch's real signature verification.
function makeFetchStub(hmacSecret: string, epochToEcho: string | null): typeof globalThis.fetch {
return (async (url: string | URL | Request): Promise<Response> => {
const u = String(url);
if (u.endsWith('/api/auth/login')) {
return new Response(JSON.stringify({ token: 't', user: {} }), { status: 200 });
}
if (u.endsWith('/api/federation/epoch')) {
if (epochToEcho === null) return new Response('nope', { status: 404 });
const body = JSON.stringify({ instanceId: epochToEcho });
const headers = buildFederationHeaders(body, hmacSecret, 'https://our.origin');
return new Response(body, { status: 200, headers });
}
throw new Error(`unexpected fetch ${u}`);
}) as typeof globalThis.fetch;
}
it('MATCH → self-heal allowed (login 200)', async () => {
const secret = 'shared-secret-abc';
await seedStaleUserAndPeer('EPOCH-A', secret);
+62 -13
View File
@@ -1,7 +1,8 @@
import type { FastifyInstance } from 'fastify';
import { eq, or } from 'drizzle-orm';
import { eq, or, lt } from 'drizzle-orm';
import { randomBytes } from 'node:crypto';
import { getDb, schema } from '../db/index.js';
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
import { hashPassword, verifyPassword, signJwt, authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { config } from '../config.js';
import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
@@ -380,22 +381,21 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 });
}
// A federated account whose home instance was reset (a new incarnation stood
// up on the same domain) is FROZEN: its identity cannot be cryptographically
// proven continuous across the wipe (design §2 non-goal), so we must never let
// anyone — including a new same-name user on the reset home — authenticate into
// it. Freezing is reversible (admin Keep/Remove, or the real user re-registers
// into a fresh account). This is the enforcement half of the §6.3b quarantine;
// it blocks the local-password path AND, by returning first, the self-heal path.
if (user.federationHomeOrphaned === 1) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
const validPassword = await verifyPassword(password, user.passwordHash);
if (!validPassword) {
// For federated users, try verifying against the home instance.
// If the password is valid there but stale here, self-heal the local hash.
if (user.homeInstance) {
// Detached account (§6.3b detach): its home domain now belongs to a
// DIFFERENT incarnation — there is no trusted home to consult. The
// self-heal path is permanently disabled: re-hashing on the new
// incarnation's say-so would hand this established account to a
// stranger. Local-hash login above remains the only (and sufficient)
// way in — the hash was only ever written by the owner's registration
// or an epoch-gated self-heal against the OLD incarnation.
if (user.federationHomeOrphaned === 1) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}
try {
const homeUsername = user.username.includes('@')
? user.username.split('@')[0]!
@@ -486,4 +486,53 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send(response);
});
// ─── POST /api/auth/attach-proof ──────────────────────────────────────────
// Mint a one-time proof token for detached-account re-attach on a peer
// (re-attach spec §3.1). Native accounts only — the token proves control of
// THIS home identity. 60s TTL, single-use, bound to the target peer domain
// (the verifying peer's domain is checked server-side on D, not trusted from
// the token bearer).
app.post<{ Body: { targetDomain?: unknown } }>('/api/auth/attach-proof', {
preHandler: authenticate,
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
}, async (request, reply) => {
const db = getDb();
const rawTarget = request.body?.targetDomain;
if (typeof rawTarget !== 'string' || rawTarget.trim().length === 0 || rawTarget.length > 255) {
return reply.code(400).send({ error: 'targetDomain is required (string)', statusCode: 400 });
}
const targetDomain = rawTarget.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, '');
// Re-check emptiness AFTER normalization: inputs like "https://" or "/"
// pass the pre-normalization guard but collapse to "" — never persist an
// inert target_domain='' proof row.
if (targetDomain.length === 0) {
return reply.code(400).send({ error: 'targetDomain is required (string)', statusCode: 400 });
}
// Native accounts only — a federated/replicated account has no authority
// to mint proofs for this domain's identities.
if (request.homeInstance) {
return reply.code(403).send({ error: 'Only native accounts can mint attach proofs', statusCode: 403 });
}
const now = Date.now();
// Opportunistic janitor: expired rows have no residual value.
db.delete(schema.federationAttachProofs)
.where(lt(schema.federationAttachProofs.expiresAt, now))
.run();
const token = randomBytes(32).toString('hex');
db.insert(schema.federationAttachProofs).values({
token,
homeUserId: request.userId,
targetDomain,
createdAt: now,
expiresAt: now + 60_000,
usedAt: null,
}).run();
return reply.code(200).send({ token });
});
}
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
let _sf = 1;
vi.mock('../utils/snowflake.js', () => ({
generateSnowflake: () => String(_sf++),
setWorkerId: vi.fn(),
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
// federation.ts also imports connectionManager/ws — stub minimal surface so
// the route module loads at test time. The function under test doesn't touch any of these.
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn(),
sendToSpace: vi.fn(),
sendToDmMembers: vi.fn(),
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
evictFederatedCallsForHost: vi.fn(),
federatedCalls: new Map(),
isUserOnline: vi.fn(),
lateBindFederatedCall: vi.fn(),
},
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
for (const stmt of sql.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
_sf = 1;
});
function seedUser(row: Partial<typeof schema.users.$inferInsert> & { id: string; username: string }): void {
testDb.insert(schema.users).values({
passwordHash: '!federation-replicated', createdAt: 1, ...row,
} as typeof schema.users.$inferInsert).run();
}
describe('sweepDeadIncarnationArtifacts', () => {
beforeEach(() => {
// Junk: self-homed stub (home.test == our domain) + channel with no native member.
seedUser({ id: 'junk-stub', username: 'youruser@home.test@home.test', homeInstance: 'home.test', homeUserId: 'dead-1' });
seedUser({ id: 'remote-stub', username: 'bob@orbit.test', homeInstance: 'orbit.test', homeUserId: 'bob-home' });
testDb.insert(schema.dmChannels).values({ id: 'junk-ch', federatedId: 'fed-junk', createdAt: 1 }).run();
testDb.insert(schema.dmMembers).values([
{ dmChannelId: 'junk-ch', userId: 'junk-stub', closed: 0 },
{ dmChannelId: 'junk-ch', userId: 'remote-stub', closed: 0 },
]).run();
testDb.insert(schema.dmMessages).values({
id: 'junk-msg', dmChannelId: 'junk-ch', userId: 'junk-stub', content: 'jo', createdAt: 1,
}).run();
// Legit: native alice + remote bob channel.
seedUser({ id: 'alice', username: 'alice', passwordHash: 'real-hash', homeInstance: null });
testDb.insert(schema.dmChannels).values({ id: 'live-ch', federatedId: 'fed-live', createdAt: 1 }).run();
testDb.insert(schema.dmMembers).values([
{ dmChannelId: 'live-ch', userId: 'alice', closed: 0 },
{ dmChannelId: 'live-ch', userId: 'remote-stub', closed: 0 },
]).run();
testDb.insert(schema.dmMessages).values({
id: 'live-msg', dmChannelId: 'live-ch', userId: 'remote-stub', content: 'hey', createdAt: 1,
}).run();
// Junk friendship referencing the self-homed stub.
testDb.insert(schema.friends).values({ userId: 'alice', friendId: 'junk-stub', createdAt: 1 }).run();
});
it('removes native-less channels (with contents) and self-homed stubs; keeps legit data', async () => {
const { sweepDeadIncarnationArtifacts } = await import('./federation.js');
sweepDeadIncarnationArtifacts();
expect(testDb.select().from(schema.dmChannels).all().map(c => c.id)).toEqual(['live-ch']);
expect(testDb.select().from(schema.dmMessages).all().map(m => m.id)).toEqual(['live-msg']);
expect(testDb.select().from(schema.dmMembers).all().every(m => m.dmChannelId === 'live-ch')).toBe(true);
const userIds = testDb.select().from(schema.users).all().map(u => u.id).sort();
expect(userIds).toEqual(['alice', 'remote-stub']);
expect(testDb.select().from(schema.friends).all()).toEqual([]);
});
it('is idempotent — second run is a no-op', async () => {
const { sweepDeadIncarnationArtifacts } = await import('./federation.js');
sweepDeadIncarnationArtifacts();
const snapshotUsers = testDb.select().from(schema.users).all();
sweepDeadIncarnationArtifacts();
expect(testDb.select().from(schema.users).all()).toEqual(snapshotUsers);
});
it('skips (does not delete) a self-homed stub that still authors a SPACE message', async () => {
testDb.insert(schema.spaces).values({ id: 's1', name: 'S', ownerId: 'alice', createdAt: 1 }).run();
testDb.insert(schema.channels).values({ id: 'c1', spaceId: 's1', name: 'general', type: 'text', createdAt: 1 }).run();
testDb.insert(schema.messages).values({ id: 'sm1', channelId: 'c1', userId: 'junk-stub', content: 'x', createdAt: 1 }).run();
const { sweepDeadIncarnationArtifacts } = await import('./federation.js');
sweepDeadIncarnationArtifacts();
// Channel cleanup still ran, but the referenced stub survives (logged as skipped).
expect(testDb.select().from(schema.users).all().some(u => u.id === 'junk-stub')).toBe(true);
});
it('never touches DETACHED accounts (homed at the peer, not us)', async () => {
seedUser({ id: 'detached-1', username: 'dave@orbit.test', passwordHash: 'real-hash', homeInstance: 'orbit.test', homeUserId: 'dave-home', federationHomeOrphaned: 1 });
const { sweepDeadIncarnationArtifacts } = await import('./federation.js');
sweepDeadIncarnationArtifacts();
expect(testDb.select().from(schema.users).all().some(u => u.id === 'detached-1')).toBe(true);
});
});
@@ -0,0 +1,243 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
import { signRequest } from '../utils/federationAuth.js';
import type { FederationRelayEvent } from '@backspace/shared';
setWorkerId(9);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
const PEER_ORIGIN = 'https://orbit.test';
const PEER_DOMAIN = 'orbit.test';
const PEER_SECRET = 'a'.repeat(64);
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn(),
sendToSpace: vi.fn(),
sendToDmMembers: vi.fn(),
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
evictFederatedCallsForHost: vi.fn(),
federatedCalls: new Map(),
isUserOnline: vi.fn(),
lateBindFederatedCall: vi.fn(),
forceDisconnectUser: vi.fn(),
},
}));
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedActivePeer(): void {
testDb.insert(schema.federationPeers).values({
id: 'peer-1',
origin: PEER_ORIGIN,
hmacSecret: PEER_SECRET,
status: 'active',
nonceSupported: 1,
createdAt: Date.now(),
lastSeenAt: Date.now(),
consecutiveFailures: 0,
consecutiveAuthFailures: 0,
} as typeof schema.federationPeers.$inferInsert).run();
}
const DETACHED_ID = 'detached-1';
const DETACHED_HOME_UID = 'old-home-uid';
// A REAL federated account whose home domain (orbit.test) was reset. It has been
// detached (federationHomeOrphaned = 1): sovereign local account, never re-bindable
// to the reset domain's new incarnation.
function seedDetachedAccount(): void {
testDb.insert(schema.users).values({
id: DETACHED_ID,
username: 'alice@orbit.test',
displayName: 'Alice',
passwordHash: '$2b$10$abcdefghijklmnopqrstuv', // real bcrypt-like hash
status: 'offline',
isAdmin: 0,
isDeleted: 0,
homeInstance: PEER_DOMAIN,
homeUserId: DETACHED_HOME_UID,
federationHomeOrphaned: 1,
profileUpdatedAt: 1000,
createdAt: Date.now(),
}).run();
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { federationRoutes } = await import('./federation.js');
await app.register(federationRoutes);
await app.ready();
return app;
}
function signedHeaders(body: string): Record<string, string> {
const timestamp = Date.now();
const nonce = randomUUID();
const sig = signRequest(body, PEER_SECRET, timestamp, nonce);
return {
'X-Federation-Origin': PEER_ORIGIN,
'X-Federation-Timestamp': String(timestamp),
'X-Federation-Nonce': nonce,
'X-Federation-Signature': `sha256=${sig}`,
'Content-Type': 'application/json',
};
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedActivePeer();
seedDetachedAccount();
});
describe('S2S identity delete — detached account guard', () => {
it('skips a detached account (idempotent 200, row intact)', async () => {
const app = await buildApp();
const body = JSON.stringify({
homeUserId: DETACHED_HOME_UID,
homeInstance: PEER_DOMAIN,
mode: 'full',
});
const res = await app.inject({
method: 'DELETE',
url: '/api/federation/identity',
headers: signedHeaders(body),
payload: body,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({ success: true });
const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get();
expect(row?.isDeleted).toBe(0); // NOT deleted — detached account is sovereign
await app.close();
});
});
describe('S2S presence_update — detached account guard', () => {
it('skips a detached account (acked, status unchanged)', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'presence_update',
contextType: 'profile',
messageId: 'm-presence-hijack',
encryptionVersion: 0,
timestamp: Date.now(),
presenceUpdate: {
homeUserId: DETACHED_HOME_UID,
homeInstance: PEER_DOMAIN,
status: 'online', // would flip the sovereign account's presence if not guarded
ts: Date.now(),
},
};
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processPresenceUpdateEvent(event, PEER_DOMAIN, testDb, accepted, rejected);
// Acked (not rejected) — avoid a sender retry loop.
expect(rejected).toEqual([]);
expect(accepted).toEqual(['m-presence-hijack']);
// The detached row's status is untouched (stays offline).
const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get();
expect(row?.status).toBe('offline');
});
});
describe('hydrateReplicatedUserProfile — detached account guard', () => {
it('leaves a detached row untouched even when fields are empty', async () => {
const fed = await import('./federation.js');
const before = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!;
expect(before.bio).toBeNull();
expect(before.avatarColor).toBeNull();
// A replayed old-homeUserId snapshot from the new incarnation. Without the
// detached guard, hydrate would fill the empty bio / avatarColor fields.
const result = await fed.hydrateReplicatedUserProfile(before, {
username: 'alice',
displayName: 'Hijacked',
bio: 'hijacked bio',
avatarColor: '#ffffff',
avatar: null,
banner: null,
}, testDb);
// No-op return: the row object is returned unchanged.
expect(result.bio).toBeNull();
expect(result.avatarColor).toBeNull();
// And the DB row is untouched.
const after = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!;
expect(after.bio).toBeNull();
expect(after.avatarColor).toBeNull();
expect(after.displayName).toBe('Alice');
});
});
describe('S2S profile_update — detached account guard', () => {
it('skips a detached account (acked, profile unchanged)', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'profile_update',
contextType: 'profile',
messageId: 'm-hijack',
encryptionVersion: 0,
timestamp: Date.now(),
profileUpdate: {
homeUserId: DETACHED_HOME_UID,
homeInstance: PEER_DOMAIN,
profileUpdatedAt: 999999, // newer than stored 1000 — would apply if not guarded
username: 'alice',
displayName: 'Hijacked',
avatar: null,
banner: null,
accentColor: null,
avatarColor: null,
bio: null,
},
};
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
await fed.processProfileUpdateEvent(event, PEER_DOMAIN, testDb, accepted, rejected);
// Acked (not rejected) — the sender considers this identity theirs to update.
expect(rejected).toEqual([]);
expect(accepted).toEqual(['m-hijack']);
// But the detached row's profile is untouched.
const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get();
expect(row?.displayName).toBe('Alice');
expect(row?.profileUpdatedAt).toBe(1000);
});
});
@@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { computeFederatedId } from '../utils/federationOutbox.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
let _sf = 1;
vi.mock('../utils/snowflake.js', () => ({
generateSnowflake: () => String(_sf++),
setWorkerId: vi.fn(),
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
// federation.ts also imports connectionManager/ws — stub minimal surface so
// the route module loads at test time. The function under test doesn't touch any of these.
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn(),
sendToSpace: vi.fn(),
sendToDmMembers: vi.fn(),
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
evictFederatedCallsForHost: vi.fn(),
federatedCalls: new Map(),
isUserOnline: vi.fn(),
lateBindFederatedCall: vi.fn(),
},
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
for (const stmt of sql.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedUser(id: string, homeUserId: string | null, homeInstance: string | null): void {
testDb.insert(schema.users).values({
id, username: `${id}@x`, passwordHash: 'h',
homeUserId, homeInstance, createdAt: 1,
}).run();
}
function seedChannel(id: string, fedId: string | null, members: string[]): void {
testDb.insert(schema.dmChannels).values({ id, federatedId: fedId, createdAt: 1 }).run();
for (const u of members) testDb.insert(schema.dmMembers).values({ dmChannelId: id, userId: u, closed: 0 }).run();
}
function seedMsg(id: string, chId: string, userId: string, ts: number): void {
testDb.insert(schema.dmMessages).values({ id, dmChannelId: chId, userId, content: 'x', createdAt: ts }).run();
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
_sf = 1;
});
describe('reconcileDmChannelFederatedId', () => {
it('noop when the stored id already matches the members', async () => {
seedUser('a', 'a', null); seedUser('b', 'b-home', 'orbit.test');
const fed = computeFederatedId('a', 'b-home');
seedChannel('ch1', fed, ['a', 'b']);
const { reconcileDmChannelFederatedId } = await import('./federation.js');
const r = reconcileDmChannelFederatedId(sqlite, 'ch1');
expect(r.action).toBe('noop');
expect(testDb.select().from(schema.dmChannels).get()!.federatedId).toBe(fed);
});
it('re-keys in place when a member home id changed and no target exists', async () => {
// member b now has NEW home id 'b-new'; channel still carries the OLD-pair id.
seedUser('a', 'a', null); seedUser('b', 'b-new', 'orbit.test');
const oldFed = computeFederatedId('a', 'b-old');
seedChannel('ch1', oldFed, ['a', 'b']);
const { reconcileDmChannelFederatedId } = await import('./federation.js');
const r = reconcileDmChannelFederatedId(sqlite, 'ch1');
expect(r.action).toBe('rekeyed');
expect(testDb.select().from(schema.dmChannels).get()!.federatedId).toBe(computeFederatedId('a', 'b-new'));
});
it('merges into the target when one already carries the new id', async () => {
seedUser('a', 'a', null); seedUser('b', 'b-new', 'orbit.test');
const oldFed = computeFederatedId('a', 'b-old');
const newFed = computeFederatedId('a', 'b-new');
seedChannel('chOld', oldFed, ['a', 'b']); seedMsg('m1', 'chOld', 'a', 100); seedMsg('m2', 'chOld', 'b', 110);
seedChannel('chNew', newFed, ['a', 'b']); seedMsg('m3', 'chNew', 'a', 120);
const { reconcileDmChannelFederatedId } = await import('./federation.js');
const r = reconcileDmChannelFederatedId(sqlite, 'chOld');
expect(r.action).toBe('merged');
expect(r.targetChannelId).toBe('chNew');
// old channel gone, all 3 messages now on chNew, ordered.
expect(testDb.select().from(schema.dmChannels).all().map(c => c.id)).toEqual(['chNew']);
const msgs = testDb.select().from(schema.dmMessages).all().filter(m => m.dmChannelId === 'chNew').sort((x, y) => x.createdAt - y.createdAt);
expect(msgs.map(m => m.id)).toEqual(['m1', 'm2', 'm3']);
// members deduped, read_states intact.
expect(testDb.select().from(schema.dmMembers).all().filter(m => m.dmChannelId === 'chNew').map(m => m.userId).sort()).toEqual(['a', 'b']);
expect(r.affectedUserIds.sort()).toEqual(['a', 'b']);
});
it('skips group DMs (UUID federatedId / >2 members)', async () => {
seedUser('a', 'a', null); seedUser('b', 'b', null); seedUser('c', 'c', null);
seedChannel('g1', 'c361f0db-d856-2b62-44f5-ed9eba92a67d', ['a', 'b', 'c']);
const { reconcileDmChannelFederatedId } = await import('./federation.js');
expect(reconcileDmChannelFederatedId(sqlite, 'g1').action).toBe('noop');
});
it('skips a channel with an unresolvable member set (not exactly 2)', async () => {
seedUser('a', 'a', null);
seedChannel('ch1', computeFederatedId('a', 'b'), ['a']);
const { reconcileDmChannelFederatedId } = await import('./federation.js');
expect(reconcileDmChannelFederatedId(sqlite, 'ch1').action).toBe('noop');
});
it('dedupes read_states on merge (composite PK user_id+channel_id)', async () => {
seedUser('a', 'a', null); seedUser('b', 'b-new', 'orbit.test');
const oldFed = computeFederatedId('a', 'b-old'); const newFed = computeFederatedId('a', 'b-new');
seedChannel('chOld', oldFed, ['a', 'b']); seedMsg('m1', 'chOld', 'a', 100);
seedChannel('chNew', newFed, ['a', 'b']); seedMsg('m2', 'chNew', 'a', 120);
testDb.insert(schema.readStates).values([
{ userId: 'a', channelId: 'chOld', lastReadMessageId: 'm1', updatedAt: 1 },
{ userId: 'a', channelId: 'chNew', lastReadMessageId: 'm2', updatedAt: 2 },
]).run();
const { reconcileDmChannelFederatedId } = await import('./federation.js');
reconcileDmChannelFederatedId(sqlite, 'chOld');
const rs = testDb.select().from(schema.readStates).all();
expect(rs.filter(r => r.channelId === 'chOld')).toHaveLength(0);
expect(rs.filter(r => r.channelId === 'chNew' && r.userId === 'a')).toHaveLength(1);
});
});
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { computeFederatedId } from '../utils/federationOutbox.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
let _sf = 1;
vi.mock('../utils/snowflake.js', () => ({
generateSnowflake: () => String(_sf++),
setWorkerId: vi.fn(),
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn(),
sendToSpace: vi.fn(),
sendToDmMembers: vi.fn(),
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
evictFederatedCallsForHost: vi.fn(),
federatedCalls: new Map(),
isUserOnline: vi.fn(),
lateBindFederatedCall: vi.fn(),
},
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
for (const stmt of sql.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedUser(id: string, homeUserId: string | null, homeInstance: string | null): void {
testDb.insert(schema.users).values({
id, username: `${id}@x`, passwordHash: 'h',
homeUserId, homeInstance, createdAt: 1,
}).run();
}
function seedChannel(id: string, fedId: string | null, members: string[]): void {
testDb.insert(schema.dmChannels).values({ id, federatedId: fedId, createdAt: 1 }).run();
for (const u of members) testDb.insert(schema.dmMembers).values({ dmChannelId: id, userId: u, closed: 0 }).run();
}
function seedMsg(id: string, chId: string, userId: string, ts: number): void {
testDb.insert(schema.dmMessages).values({ id, dmChannelId: chId, userId, content: 'x', createdAt: ts }).run();
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
_sf = 1;
});
describe('reconcileDriftedDmFederatedIds', () => {
it('heals a drifted 1-on-1 channel and leaves correct ones untouched', async () => {
seedUser('a', 'a', null); seedUser('b', 'b-new', 'orbit.test'); seedUser('c', 'c', null);
// drifted: stored under old pairing, member b now has b-new; target under new pairing exists.
const oldFed = computeFederatedId('a', 'b-old'); const newFed = computeFederatedId('a', 'b-new');
seedChannel('chOld', oldFed, ['a', 'b']); seedMsg('m1', 'chOld', 'a', 100);
seedChannel('chNew', newFed, ['a', 'b']); seedMsg('m2', 'chNew', 'a', 200);
// correct channel untouched
const okFed = computeFederatedId('a', 'c'); seedChannel('chOk', okFed, ['a', 'c']);
const { reconcileDriftedDmFederatedIds } = await import('./federation.js');
reconcileDriftedDmFederatedIds();
expect(testDb.select().from(schema.dmChannels).all().map(c => c.id).sort()).toEqual(['chNew', 'chOk']);
expect(testDb.select().from(schema.dmMessages).all().filter(m => m.dmChannelId === 'chNew').map(m => m.id).sort()).toEqual(['m1', 'm2']);
});
it('is idempotent — second run is a noop', async () => {
seedUser('a', 'a', null); seedUser('b', 'b', null);
seedChannel('ch1', computeFederatedId('a', 'b'), ['a', 'b']);
const { reconcileDriftedDmFederatedIds } = await import('./federation.js');
reconcileDriftedDmFederatedIds();
const before = testDb.select().from(schema.dmChannels).all();
reconcileDriftedDmFederatedIds();
expect(testDb.select().from(schema.dmChannels).all()).toEqual(before);
});
it('does not touch group DMs', async () => {
seedUser('a', 'a', null); seedUser('b', 'b', null); seedUser('c', 'c', null);
seedChannel('g1', 'c361f0db-d856-2b62-44f5-ed9eba92a67d', ['a', 'b', 'c']);
const { reconcileDriftedDmFederatedIds } = await import('./federation.js');
reconcileDriftedDmFederatedIds();
expect(testDb.select().from(schema.dmChannels).all().some(c => c.id === 'g1')).toBe(true);
});
});
@@ -0,0 +1,324 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { eq } from 'drizzle-orm';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
import { signJwt } from '../utils/auth.js';
import { computeFederatedId } from '../utils/federationOutbox.js';
setWorkerId(13);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Mock the Task-4 module so the endpoint never touches the network — the
// re-attach flow's only outbound calls (proof verification + profile fetch)
// go through these two functions.
const verifyMock = vi.fn();
const profileMock = vi.fn();
vi.mock('../utils/federationAttach.js', () => ({
verifyAttachProofWithPeer: (...args: unknown[]) => verifyMock(...args),
fetchHomeProfileByHomeId: (...args: unknown[]) => profileMock(...args),
}));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
let app: FastifyInstance;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
async function buildApp(): Promise<FastifyInstance> {
const { federationRoutes } = await import('./federation.js');
const f = Fastify();
await f.register(federationRoutes);
return f;
}
async function reattach(userId: string, username: string, token = 'a'.repeat(64)) {
return app.inject({
method: 'POST',
url: '/api/users/@me/reattach',
headers: { authorization: `Bearer ${signJwt({ userId, username })}` },
payload: { token },
});
}
beforeEach(async () => {
verifyMock.mockReset();
profileMock.mockReset();
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
// Peer row for the home domain (orbit), ACTIVE.
testDb.insert(schema.federationPeers).values({
id: 'peer-1', origin: 'https://orbit.test', hmacSecret: 's'.repeat(64), status: 'active', createdAt: 1,
}).run();
// The detached account (session user) — old identity dead-home-1.
testDb.insert(schema.users).values({
id: 'detached-1', username: 'youruser@orbit.test', passwordHash: 'local-hash',
homeInstance: 'orbit.test', homeUserId: 'dead-home-1', federationHomeOrphaned: 1,
avatarColor: 'coral', createdAt: 1,
}).run();
// A native friend for broadcast/merge fixtures.
testDb.insert(schema.users).values({
id: 'alice', username: 'alice', passwordHash: 'x', homeInstance: null, createdAt: 1,
}).run();
app = await buildApp();
});
afterEach(async () => {
await app.close();
});
describe('POST /api/users/@me/reattach — guards', () => {
it('403 for a non-detached account', async () => {
testDb.update(schema.users).set({ federationHomeOrphaned: 0 }).where(eq(schema.users.id, 'detached-1')).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(403);
expect(verifyMock).not.toHaveBeenCalled();
});
it('403 for a native account', async () => {
const res = await reattach('alice', 'alice');
expect(res.statusCode).toBe(403);
});
it('rejects a tombstoned session before any re-attach work (authenticate gate)', async () => {
// Detached tombstones are not re-attachable (spec §2). `authenticate` 401s a
// deleted account before the handler runs; the handler's own is_deleted 404
// remains as defense-in-depth for a concurrent-delete race. Either way the
// proof exchange never happens.
testDb.update(schema.users).set({ isDeleted: 1 }).where(eq(schema.users.id, 'detached-1')).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(401);
expect(verifyMock).not.toHaveBeenCalled();
});
it('409 when the home peer is not active', async () => {
testDb.update(schema.federationPeers).set({ status: 'unreachable' }).where(eq(schema.federationPeers.id, 'peer-1')).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(409);
});
it('400 when the token is not 64-char hex', async () => {
const res = await reattach('detached-1', 'youruser@orbit.test', 'not-hex');
expect(res.statusCode).toBe(400);
expect(verifyMock).not.toHaveBeenCalled();
});
it('401 when the proof does not verify', async () => {
verifyMock.mockResolvedValue({ valid: false });
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(401);
// Nothing changed.
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.federationHomeOrphaned).toBe(1);
expect(row.homeUserId).toBe('dead-home-1');
});
});
describe('POST /api/users/@me/reattach — success', () => {
beforeEach(() => {
verifyMock.mockResolvedValue({ valid: true, homeUserId: 'new-home-1', username: 'youruser' });
profileMock.mockResolvedValue({
username: 'youruser',
profile: { displayName: 'Jannis', avatar: null, avatarColor: 'lavender', banner: null, bio: null },
});
});
it('re-binds identity, clears the flag, applies the home profile', async () => {
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.homeUserId).toBe('new-home-1');
expect(row.federationHomeOrphaned).toBe(0);
expect(row.displayName).toBe('Jannis');
expect(row.avatarColor).toBe('lavender');
expect(row.profileUpdatedAt).toBeNull(); // next profile_update always applies
expect(row.username).toBe('youruser@orbit.test'); // same base → no rename
});
it('renames when the new home username base differs (collision-suffix scheme)', async () => {
verifyMock.mockResolvedValue({ valid: true, homeUserId: 'new-home-1', username: 'hans' });
testDb.insert(schema.users).values({
id: 'squatter', username: 'hans@orbit.test', passwordHash: '!federation-replicated',
homeInstance: 'orbit.test', homeUserId: 'other', createdAt: 1,
}).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.username).toBe('hans_1@orbit.test');
});
it('proceeds without a profile when the home profile fetch fails', async () => {
profileMock.mockResolvedValue(null);
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.homeUserId).toBe('new-home-1');
expect(row.avatarColor).toBe('coral'); // untouched
});
it('subsequent S2S profile_update APPLIES after re-attach (guard no longer fires)', async () => {
await reattach('detached-1', 'youruser@orbit.test');
const { processProfileUpdateEvent } = await import('./federation.js');
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
await processProfileUpdateEvent({
eventType: 'profile_update', contextType: 'profile', messageId: 'pu-1',
encryptionVersion: 0, timestamp: Date.now(),
profileUpdate: {
homeUserId: 'new-home-1', homeInstance: 'https://orbit.test',
profileUpdatedAt: Date.now(), username: 'youruser',
displayName: 'NewName', avatar: null, banner: null,
accentColor: null, avatarColor: 'mint', bio: null,
},
} as Parameters<typeof processProfileUpdateEvent>[0], 'https://orbit.test', testDb, accepted, rejected);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.displayName).toBe('NewName');
expect(row.avatarColor).toBe('mint');
});
});
describe('POST /api/users/@me/reattach — stub merge', () => {
beforeEach(() => {
verifyMock.mockResolvedValue({ valid: true, homeUserId: 'new-home-1', username: 'youruser' });
profileMock.mockResolvedValue(null);
// Stub for the NEW identity, created earlier by ordinary relay.
testDb.insert(schema.users).values({
id: 'stub-new', username: 'youruser_1@orbit.test', passwordHash: '!federation-replicated',
homeInstance: 'orbit.test', homeUserId: 'new-home-1', createdAt: 2,
}).run();
// Stub state: a DM with alice (which the detached row is ALSO in → dedupe),
// a message, a friendship with alice (detached row also friends → dedupe).
testDb.insert(schema.dmChannels).values({ id: 'ch-1', federatedId: 'fed-1', createdAt: 1 }).run();
testDb.insert(schema.dmMembers).values([
{ dmChannelId: 'ch-1', userId: 'alice', closed: 0 },
{ dmChannelId: 'ch-1', userId: 'detached-1', closed: 0 },
{ dmChannelId: 'ch-1', userId: 'stub-new', closed: 0 },
]).run();
testDb.insert(schema.dmMessages).values({
id: 'm-stub', dmChannelId: 'ch-1', userId: 'stub-new', content: 'from new incarnation', createdAt: 3,
}).run();
// An attachment the stub uploaded onto its DM message — uploader_id is a
// plain text column (no FK), so it must be repointed explicitly or attribution
// dangles at the deleted stub's id.
testDb.insert(schema.attachments).values({
id: 'att-stub', dmMessageId: 'm-stub', uploaderId: 'stub-new',
filename: 'f.webp', originalName: 'f.webp', mimetype: 'image/webp', size: 100, createdAt: 3,
}).run();
testDb.insert(schema.friends).values([
{ userId: 'alice', friendId: 'detached-1', createdAt: 1 },
{ userId: 'alice', friendId: 'stub-new', createdAt: 2 },
]).run();
});
it('merges the stub into the detached row: repointed, deduped, deleted', async () => {
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
// Stub gone.
expect(testDb.select().from(schema.users).all().some(u => u.id === 'stub-new')).toBe(false);
// Message repointed.
const msg = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, 'm-stub')).get()!;
expect(msg.userId).toBe('detached-1');
// Attachment attribution repointed off the deleted stub.
const att = testDb.select().from(schema.attachments).where(eq(schema.attachments.id, 'att-stub')).get()!;
expect(att.uploaderId).toBe('detached-1');
// Membership deduped (detached row already a member).
const members = testDb.select().from(schema.dmMembers).all().filter(m => m.dmChannelId === 'ch-1');
expect(members.map(m => m.userId).sort()).toEqual(['alice', 'detached-1']);
// Friendship deduped.
const friendRows = testDb.select().from(schema.friends).all();
expect(friendRows).toHaveLength(1);
expect(friendRows[0]!.friendId).toBe('detached-1');
});
it('409 when the new identity is held by a REAL account (not a stub)', async () => {
testDb.update(schema.users).set({ passwordHash: 'real-hash' }).where(eq(schema.users.id, 'stub-new')).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(409);
// Nothing changed on the detached row.
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.homeUserId).toBe('dead-home-1');
});
});
describe('POST /api/users/@me/reattach — 1-on-1 DM channel reconciliation', () => {
beforeEach(() => {
verifyMock.mockResolvedValue({ valid: true, homeUserId: 'new-home-1', username: 'youruser' });
profileMock.mockResolvedValue(null);
// 'alice' is R-native; she has a DM with the detached account under the OLD
// pairing, and a fresh DM under the NEW pairing (created by post-reset relay).
});
it('merges the pre-reattach history channel into the new-identity channel', async () => {
const oldFed = computeFederatedId('alice', 'dead-home-1'); // alice home = her id (native)
const newFed = computeFederatedId('alice', 'new-home-1');
// history channel (old id)
testDb.insert(schema.dmChannels).values({ id: 'ch-old', federatedId: oldFed, createdAt: 1 }).run();
testDb.insert(schema.dmMembers).values([
{ dmChannelId: 'ch-old', userId: 'alice', closed: 0 },
{ dmChannelId: 'ch-old', userId: 'detached-1', closed: 0 },
]).run();
testDb.insert(schema.dmMessages).values([
{ id: 'mo1', dmChannelId: 'ch-old', userId: 'alice', content: 'old1', createdAt: 100 },
{ id: 'mo2', dmChannelId: 'ch-old', userId: 'detached-1', content: 'old2', createdAt: 110 },
]).run();
// fresh channel (new id)
testDb.insert(schema.dmChannels).values({ id: 'ch-new', federatedId: newFed, createdAt: 2 }).run();
testDb.insert(schema.dmMembers).values([
{ dmChannelId: 'ch-new', userId: 'alice', closed: 0 },
{ dmChannelId: 'ch-new', userId: 'detached-1', closed: 0 },
]).run();
testDb.insert(schema.dmMessages).values({ id: 'mn1', dmChannelId: 'ch-new', userId: 'detached-1', content: 'new1', createdAt: 200 }).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
// old channel gone; all history now under ch-new, in order.
expect(testDb.select().from(schema.dmChannels).all().some(c => c.id === 'ch-old')).toBe(false);
const msgs = testDb.select().from(schema.dmMessages).all().filter(m => m.dmChannelId === 'ch-new').sort((a, b) => a.createdAt - b.createdAt);
expect(msgs.map(m => m.id)).toEqual(['mo1', 'mo2', 'mn1']);
});
it('re-keys the history channel in place when no new-identity channel exists yet', async () => {
const oldFed = computeFederatedId('alice', 'dead-home-1');
testDb.insert(schema.dmChannels).values({ id: 'ch-old', federatedId: oldFed, createdAt: 1 }).run();
testDb.insert(schema.dmMembers).values([
{ dmChannelId: 'ch-old', userId: 'alice', closed: 0 },
{ dmChannelId: 'ch-old', userId: 'detached-1', closed: 0 },
]).run();
testDb.insert(schema.dmMessages).values({ id: 'mo1', dmChannelId: 'ch-old', userId: 'alice', content: 'x', createdAt: 100 }).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
const ch = testDb.select().from(schema.dmChannels).all().find(c => c.id === 'ch-old')!;
expect(ch.federatedId).toBe(computeFederatedId('alice', 'new-home-1'));
});
});
@@ -24,6 +24,11 @@ vi.mock('../utils/snowflake.js', () => ({
setWorkerId: vi.fn(),
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
// federation.ts also imports connectionManager/ws — stub minimal surface so
// the route module loads at test time. The function under test doesn't touch any of these.
vi.mock('../ws/handler.js', () => ({
@@ -84,3 +89,63 @@ describe('resolveOrCreateReplicatedUser — stub username', () => {
expect(created!.username).toBe('310002371434024960@orbit.ddns.net');
});
});
describe('resolveOrCreateReplicatedUser — self-homed identity guard', () => {
it('refuses to create a stub homed at our own domain (dead incarnation)', async () => {
const { resolveOrCreateReplicatedUser } = await import('./federation.js');
const result = resolveOrCreateReplicatedUser(
'dead-incarnation-id',
'home.test',
testDb,
{ username: 'youruser' },
);
expect(result).toBeNull();
const rows = testDb.select().from(schema.users).all();
expect(rows).toHaveLength(0);
});
it('refuses self-homed creation regardless of homeInstance URL shape', async () => {
const { resolveOrCreateReplicatedUser } = await import('./federation.js');
expect(resolveOrCreateReplicatedUser('dead-1', 'https://home.test', testDb, { username: 'x' })).toBeNull();
expect(resolveOrCreateReplicatedUser('dead-2', 'HOME.TEST', testDb, { username: 'x' })).toBeNull();
expect(testDb.select().from(schema.users).all()).toHaveLength(0);
});
it('still resolves a LIVE native user referenced by self-domain identity (tier 1)', async () => {
testDb.insert(schema.users).values({
id: 'native-1',
username: 'alice',
passwordHash: 'real-hash',
homeInstance: null,
createdAt: 1,
}).run();
const { resolveOrCreateReplicatedUser } = await import('./federation.js');
const result = resolveOrCreateReplicatedUser('native-1', 'https://home.test', testDb, { username: 'alice' });
expect(result).not.toBeNull();
expect(result!.id).toBe('native-1');
});
it('still creates stubs for remote-domain identities (unchanged behavior)', async () => {
const { resolveOrCreateReplicatedUser } = await import('./federation.js');
const result = resolveOrCreateReplicatedUser('remote-1', 'orbit.ddns.net', testDb, { username: 'bob' });
expect(result).not.toBeNull();
expect(result!.username).toBe('bob@orbit.ddns.net');
});
it('refuses stub creation when the wire snapshot marks the identity deleted', async () => {
const { resolveOrCreateReplicatedUser } = await import('./federation.js');
const result = resolveOrCreateReplicatedUser('remote-del', 'orbit.ddns.net', testDb, { username: null, deleted: true });
expect(result).toBeNull();
expect(testDb.select().from(schema.users).all()).toHaveLength(0);
});
it('a deleted-marked identity that already resolves locally still returns the existing row', async () => {
testDb.insert(schema.users).values({
id: 'stub-1', username: 'old@orbit.ddns.net', passwordHash: '!federation-replicated',
homeInstance: 'orbit.ddns.net', homeUserId: 'remote-del', createdAt: 1,
}).run();
const { resolveOrCreateReplicatedUser } = await import('./federation.js');
const result = resolveOrCreateReplicatedUser('remote-del', 'orbit.ddns.net', testDb, { deleted: true });
expect(result?.id).toBe('stub-1'); // historical attribution stays intact
});
});
@@ -0,0 +1,233 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
import { signRequest } from '../utils/federationAuth.js';
import { randomUUID } from 'node:crypto';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Module-level mutable state. Each beforeEach reassigns sqlite/testDb;
// the getDb getter in the mock closes over the current binding.
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
const PEER_ORIGIN = 'https://orbit.test';
const PEER_SECRET = 'a'.repeat(64);
const PEER_ID = 'peer-orbit';
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { _resetLookupRateBuckets, federationRoutes } = await import('./federation.js');
_resetLookupRateBuckets();
await app.register(federationRoutes);
await app.ready();
return app;
}
function signedHeaders(body: string): Record<string, string> {
const timestamp = Date.now();
const nonce = randomUUID();
const sig = signRequest(body, PEER_SECRET, timestamp, nonce);
return {
'X-Federation-Origin': PEER_ORIGIN,
'X-Federation-Timestamp': String(timestamp),
'X-Federation-Nonce': nonce,
'X-Federation-Signature': `sha256=${sig}`,
'Content-Type': 'application/json',
};
}
function seedPeer(): void {
testDb.insert(schema.federationPeers).values({
id: PEER_ID,
origin: PEER_ORIGIN, // 'https://orbit.test' from the copied harness
hmacSecret: PEER_SECRET, // 'a'.repeat(64) from the copied harness
status: 'active',
createdAt: Date.now(),
}).run();
}
function seedUser(row: Partial<typeof schema.users.$inferInsert> & { id: string; username: string }): void {
testDb.insert(schema.users).values({
passwordHash: '!federation-replicated',
createdAt: 1,
...row,
} as typeof schema.users.$inferInsert).run();
}
/** channel + members + one locally-created message + its mutation-log row */
function seedDmWithMessage(channelId: string, memberIds: string[], authorId: string, ts: number): void {
testDb.insert(schema.dmChannels).values({
id: channelId, federatedId: `fed-${channelId}`, createdAt: 1,
}).run();
for (const uid of memberIds) {
testDb.insert(schema.dmMembers).values({ dmChannelId: channelId, userId: uid, closed: 0 }).run();
}
testDb.insert(schema.dmMessages).values({
id: `msg-${channelId}`, dmChannelId: channelId, userId: authorId, content: 'hi', createdAt: ts,
}).run();
testDb.insert(schema.federationMutationLog).values({
id: `ml-${channelId}`, entityId: `msg-${channelId}`, contextId: channelId,
contextType: 'dm', mutationType: 'create', mutatedAt: ts,
}).run();
}
function seedFriendMutation(id: string, ts: number, from: { homeUserId: string; homeInstance: string }, to: { homeUserId: string; homeInstance: string }): void {
testDb.insert(schema.federationMutationLog).values({
id, entityId: `fr-${id}`, contextId: `fr-ctx-${id}`,
contextType: 'friend', mutationType: 'friend_add', mutatedAt: ts,
payload: JSON.stringify({
friendship: {
from, to,
fromProfile: { username: 'x' }, toProfile: { username: 'y' },
createdAt: ts,
},
}),
}).run();
}
async function syncPull(app: FastifyInstance, body: object) {
const bodyStr = JSON.stringify(body);
return app.inject({
method: 'POST',
url: '/api/federation/sync',
headers: signedHeaders(bodyStr),
payload: bodyStr,
});
}
describe('POST /api/federation/sync — DM relevance filter', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedPeer();
seedUser({ id: 'alice', username: 'alice', passwordHash: 'real-hash', homeInstance: null });
seedUser({ id: 'bob', username: 'bob@orbit.test', homeInstance: 'orbit.test', homeUserId: 'bob-home' });
seedUser({ id: 'carol', username: 'carol@orbit.test', homeInstance: 'orbit.test', homeUserId: 'carol-home', federationHomeOrphaned: 1 });
seedUser({ id: 'dave', username: 'dave@elsewhere.test', homeInstance: 'elsewhere.test', homeUserId: 'dave-home' });
seedDmWithMessage('ch-live', ['alice', 'bob'], 'alice', 100); // live orbit member → offered
seedDmWithMessage('ch-detached', ['alice', 'carol'], 'alice', 110); // only detached orbit member → excluded
seedDmWithMessage('ch-other', ['alice', 'dave'], 'alice', 120); // no orbit member at all → excluded
app = await buildApp();
});
it('only returns events for channels with a live, non-detached member homed at the requester', async () => {
const res = await syncPull(app, { sinceTimestamp: 0 });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
const channelIds = body.events.map((e: { dmChannelId: string }) => e.dmChannelId);
expect(channelIds).toEqual(['ch-live']);
});
it('returns empty DM sync for a reset peer (all requester-domain rows detached)', async () => {
// Flip bob to detached too — simulates the post-reset state.
testDb.update(schema.users).set({ federationHomeOrphaned: 1 }).where(eq(schema.users.id, 'bob')).run();
const res = await syncPull(app, { sinceTimestamp: 0 });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.events).toEqual([]);
expect(body.hasMore).toBe(false);
});
it('excludes a tombstoned requester-domain member from qualifying a channel', async () => {
testDb.update(schema.users).set({ isDeleted: 1 }).where(eq(schema.users.id, 'bob')).run();
const res = await syncPull(app, { sinceTimestamp: 0 });
const body = JSON.parse(res.body);
expect(body.events).toEqual([]);
});
it('federatedId filter on an excluded channel returns empty (inherits relevance check)', async () => {
const res = await syncPull(app, { sinceTimestamp: 0, federatedId: 'fed-ch-other' });
const body = JSON.parse(res.body);
expect(body.events).toEqual([]);
});
it('matches home_instance stored as a full URL too (normalization)', async () => {
testDb.update(schema.users).set({ homeInstance: 'https://orbit.test' }).where(eq(schema.users.id, 'bob')).run();
const res = await syncPull(app, { sinceTimestamp: 0 });
const body = JSON.parse(res.body);
expect(body.events.map((e: { dmChannelId: string }) => e.dmChannelId)).toEqual(['ch-live']);
});
});
describe('POST /api/federation/sync — friend relevance filter', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedPeer();
app = await buildApp();
});
it('returns friend events involving the requester domain; filters unrelated ones', async () => {
seedFriendMutation('f1', 100,
{ homeUserId: 'a1', homeInstance: 'https://home.test' },
{ homeUserId: 'b1', homeInstance: 'https://orbit.test' }); // involves requester → returned
seedFriendMutation('f2', 110,
{ homeUserId: 'a2', homeInstance: 'https://home.test' },
{ homeUserId: 'c1', homeInstance: 'https://elsewhere.test' }); // unrelated → filtered
const res = await syncPull(app, { sinceTimestamp: 0, contextType: 'friend' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.events).toHaveLength(1);
expect(body.events[0].friendship.to.homeUserId).toBe('b1');
// Checkpoint advances past the FILTERED row too (pre-filter pagination).
expect(body.checkpoint).toBe(110);
});
it('does not qualify an event via a side that resolves to a DETACHED local row', async () => {
seedUser({ id: 'stub-dead', username: 'dead@orbit.test', homeInstance: 'orbit.test', homeUserId: 'dead-home', federationHomeOrphaned: 1 });
seedFriendMutation('f3', 100,
{ homeUserId: 'a1', homeInstance: 'https://home.test' },
{ homeUserId: 'dead-home', homeInstance: 'https://orbit.test' });
const res = await syncPull(app, { sinceTimestamp: 0, contextType: 'friend' });
const body = JSON.parse(res.body);
expect(body.events).toEqual([]);
expect(body.checkpoint).toBe(100); // still advances
});
it('qualifies a requester-domain side with no local row (receiver guard is the backstop)', async () => {
seedFriendMutation('f4', 100,
{ homeUserId: 'a1', homeInstance: 'https://home.test' },
{ homeUserId: 'unknown-home', homeInstance: 'https://orbit.test' });
const res = await syncPull(app, { sinceTimestamp: 0, contextType: 'friend' });
const body = JSON.parse(res.body);
expect(body.events).toHaveLength(1);
});
});
+700 -22
View File
@@ -22,6 +22,7 @@ import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActiv
import { getInstanceId, fetchPeerEpoch } from '../utils/federationEpoch.js';
import { probePeerReachable, recoverOrDetectReset } from '../utils/federationRecovery.js';
import { markPeerReset, homeInstanceMatch } from '../utils/federationReset.js';
import { verifyAttachProofWithPeer, fetchHomeProfileByHomeId } from '../utils/federationAttach.js';
import { getDmMessageWithUser } from './dm.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent, ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared';
import { GROUP_DM_NAME_MIN_LENGTH, GROUP_DM_NAME_MAX_LENGTH } from '@backspace/shared/src/constants.js';
@@ -1646,6 +1647,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
resolvedAt: ev.resolvedAt,
stubCount: ev.stubCount,
orphanedAccountCount: ev.orphanedAccountCount,
acknowledgedAt: ev.acknowledgedAt,
orphanedAccounts,
};
});
@@ -1654,6 +1656,37 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
},
);
// ─── POST /api/federation/reset-events/acknowledge ─────────────────────────
// Admin-only: dismiss a reset event from the admin banner. Purely
// informational state — detached accounts stay detached and functional;
// acknowledging just stops the surface from re-listing them (detach spec §4.6).
app.post<{ Body: { origin: string } }>(
'/api/federation/reset-events/acknowledge',
{ preHandler: [authenticate, requireAdmin] },
async (request, reply) => {
const { origin } = request.body;
if (!origin || typeof origin !== 'string') {
return reply.code(400).send({ error: 'origin is required', statusCode: 400 });
}
const db = getDb();
const existing = db
.select()
.from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, origin))
.get();
if (!existing) {
return reply.code(404).send({ error: 'No reset event for this origin', statusCode: 404 });
}
if (existing.acknowledgedAt === null) {
db.update(schema.federationResetEvents)
.set({ acknowledgedAt: Date.now() })
.where(eq(schema.federationResetEvents.origin, origin))
.run();
}
return reply.code(200).send({ success: true });
},
);
// ─── DELETE /api/federation/peers/:id ──────────────────────────────────────
// Admin-only: revoke a federation peer and clean up its outbox.
app.delete<{ Params: { id: string } }>(
@@ -2322,6 +2355,15 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Attribution mismatch: you can only delete users from your own instance', statusCode: 403 });
}
// Detached (home-orphaned) accounts are sovereign local accounts. The
// domain's new incarnation must not delete them by replaying old
// homeUserIds. Idempotent 200: from the caller's perspective this
// identity does not exist here.
if (user.federationHomeOrphaned === 1) {
console.log(`[federation] Ignoring S2S identity delete for detached account ${user.id} from ${fedHeaders.origin}`);
return reply.code(200).send({ success: true });
}
// 5. Check for owned spaces
const ownedSpaces = db.select({ id: schema.spaces.id, name: schema.spaces.name })
.from(schema.spaces)
@@ -2712,6 +2754,332 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
},
);
// ─── POST /api/federation/verify-attach-proof ───────────────────────────────
// Server-to-server: verify a one-time attach-proof token minted by
// /api/auth/attach-proof (re-attach spec §3.1). The token is single-use (an
// atomic claim guarantees only one concurrent verification can win) and is
// bound to the CALLING peer's domain — the binding is checked against the
// authenticated peer row (extractDomain(peer.origin)), NEVER trusted from the
// request body. This is the anti-replay control: a compromised requester
// cannot redeem a token minted for a different peer. The response is HMAC-
// signed (epoch pattern) so the caller can trust the identity it carries; all
// failure modes fail closed to a signed { valid: false }.
app.post<{ Body: { token?: unknown } }>(
'/api/federation/verify-attach-proof',
{ bodyLimit: 4 * 1024 },
async (request, reply) => {
const db = getDb();
const rawDb = getRawDb();
// 1. Verify HMAC headers (mirror by-home-id / users-lookup).
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>);
if (!fedHeaders) {
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 });
}
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, fedHeaders.origin))
.get();
if (!peer || peer.status !== 'active') {
return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 });
}
if (isLookupRateLimited(peer.origin)) {
return reply.code(429).header('Retry-After', '60').send({ error: 'Rate limit exceeded', statusCode: 429 });
}
const bodyString = JSON.stringify(request.body);
if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) {
return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 });
}
// Replay protection
if (fedHeaders.nonce) {
if (isNonceDuplicate(peer.origin, fedHeaders.nonce)) {
return reply.code(409).send({ error: 'Duplicate nonce — possible replay', statusCode: 409 });
}
} else if (peer.nonceSupported) {
return reply.code(401).send({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 });
}
// 2. Sign every downstream response with the peer's shared secret so the
// caller can trust the identity (or the fail-closed verdict) it carries.
const sendSigned = (payload: { valid: false } | { valid: true; homeUserId: string; username: string }): FastifyReply => {
const responseBody = JSON.stringify(payload);
const sigHeaders = buildFederationHeaders(responseBody, peer.hmacSecret, getOurOrigin());
reply.headers({
'X-Federation-Signature': sigHeaders['X-Federation-Signature'],
'X-Federation-Timestamp': sigHeaders['X-Federation-Timestamp'],
'X-Federation-Nonce': sigHeaders['X-Federation-Nonce'],
'Content-Type': 'application/json',
});
return reply.code(200).send(responseBody);
};
// 3. Validate the token shape (64 hex chars, as minted by attach-proof).
const rawToken = (request.body as { token?: unknown } | null)?.token;
if (typeof rawToken !== 'string' || !/^[0-9a-f]{64}$/i.test(rawToken)) {
return sendSigned({ valid: false });
}
// 4. Atomic single-use claim. The domain binding is server-side: the
// token's target_domain must equal the AUTHENTICATED peer's domain, never
// a value from the request body. Concurrent verifications cannot both win
// because only the first UPDATE that flips used_at from NULL matches.
const peerDomain = extractDomain(peer.origin).toLowerCase();
const now = Date.now();
const claimed = rawDb.prepare(`
UPDATE federation_attach_proofs
SET used_at = ?
WHERE token = ? AND used_at IS NULL AND expires_at > ? AND lower(target_domain) = ?
RETURNING home_user_id
`).get(now, rawToken, now, peerDomain) as { home_user_id: string } | undefined;
if (!claimed) {
return sendSigned({ valid: false });
}
// 5. Re-confirm the home user is still native (not tombstoned, not turned
// into a replicated stub) since the token was minted.
const homeUser = db
.select()
.from(schema.users)
.where(
and(
eq(schema.users.id, claimed.home_user_id),
eq(schema.users.isDeleted, 0),
isNull(schema.users.homeInstance),
),
)
.get();
if (!homeUser) {
return sendSigned({ valid: false });
}
return sendSigned({ valid: true, homeUserId: homeUser.id, username: homeUser.username });
},
);
// ─── POST /api/users/@me/reattach ────────────────────────────────────────────
// Owner-initiated exception to the detach invariant (re-attach spec §3.2).
// Requires BOTH identities: the session proves the detached account (local
// password authority), the one-time token — verified with the home peer over
// signed S2S — proves the new home account. Registered here rather than in
// users.ts because it consumes federation-internal machinery (peer HMAC
// channel, profile fetch, asset download). URL path stays /api/users/@me/*.
app.post<{ Body: { token?: unknown } }>('/api/users/@me/reattach', {
preHandler: authenticate,
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
}, async (request, reply) => {
const db = getDb();
const rawDb = getRawDb();
const rawToken = (request.body as { token?: unknown } | null)?.token;
if (typeof rawToken !== 'string' || !/^[0-9a-f]{64}$/i.test(rawToken)) {
return reply.code(400).send({ error: 'token is required (64-char hex)', statusCode: 400 });
}
// Guard 1: session user must be a LIVE detached federated account. A missing
// or tombstoned row is a 404 (nothing to re-attach); a live non-detached /
// native account is a 403 (re-attach is meaningless — it already syncs).
const detached = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!detached || detached.isDeleted === 1) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
if (!detached.homeInstance || detached.federationHomeOrphaned !== 1) {
return reply.code(403).send({ error: 'Only detached accounts can re-attach', statusCode: 403 });
}
// Guard 2: the home domain must be an ACTIVE peer — the proof is only as
// trustworthy as the S2S channel it is verified over.
const homeDomain = extractDomain(detached.homeInstance).toLowerCase();
const normPeer = (origin: string) => extractDomain(origin).toLowerCase();
const peerRow = db.select().from(schema.federationPeers).all()
.find(p => normPeer(p.origin) === homeDomain && p.status === 'active');
if (!peerRow) {
return reply.code(409).send({ error: 'Home instance is not an active peer', statusCode: 409 });
}
// Guard 3: verify the one-time proof with the home instance (fails closed).
const verified = await verifyAttachProofWithPeer(peerRow, rawToken);
if (!verified.valid) {
return reply.code(401).send({ error: 'Attach proof could not be verified', statusCode: 401 });
}
// Guard 4: if the new identity already has a local row for this domain, it
// MUST be a replicated stub (the merge source, §3.3). A real account holding
// it means state corruption — abort loudly, do not merge.
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
const existingRow = rawDb.prepare(`
SELECT id, password_hash FROM users
WHERE home_user_id = ? AND ${normHome} = ? AND is_deleted = 0 AND id != ?
`).get(verified.homeUserId, homeDomain, detached.id) as { id: string; password_hash: string } | undefined;
if (existingRow && existingRow.password_hash !== '!federation-replicated') {
console.error(`[federation] Re-attach conflict: identity ${verified.homeUserId}@${homeDomain} held by non-stub account ${existingRow.id}`);
return reply.code(409).send({ error: 'The new identity is already bound to another account on this instance', statusCode: 409 });
}
// Username: adopt the new home base when it differs (existing collision-suffix
// scheme). Usernames are not identity, so a base match keeps the current handle.
const currentBase = detached.username.includes('@')
? detached.username.slice(0, detached.username.indexOf('@'))
: detached.username;
let newUsername = detached.username;
const newBase = verified.username.toLowerCase();
if (newBase !== currentBase.toLowerCase()) {
let candidate = `${newBase}@${homeDomain}`;
let attempt = 0;
while (rawDb.prepare(`SELECT 1 FROM users WHERE username = ? AND id != ?`).get(candidate, detached.id)) {
attempt++;
candidate = `${newBase}_${attempt}@${homeDomain}`;
if (attempt > 10) {
candidate = `${newBase}_${randomBytes(4).toString('hex')}@${homeDomain}`;
break;
}
}
newUsername = candidate;
}
// Merge + re-bind, atomically. All users.id FK repointing lives here; dedupe
// rows that would collide on a composite PK / unique index BEFORE repointing
// (spec §3.3). The stub row is the only source — a real account holding the
// identity was already rejected by guard 4.
const dmReconcileResults: DmReconcileResult[] = [];
rawDb.transaction(() => {
if (existingRow) {
const stubId = existingRow.id;
const targetId = detached.id;
// dm_members (composite PK dm_channel_id+user_id → dedupe): drop the
// stub's membership where the detached row is already a member.
rawDb.prepare(`DELETE FROM dm_members WHERE user_id = ? AND dm_channel_id IN (SELECT dm_channel_id FROM dm_members WHERE user_id = ?)`).run(stubId, targetId);
rawDb.prepare(`UPDATE dm_members SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// dm_messages / messages (RESTRICT FK, no unique on user_id → straight repoint).
rawDb.prepare(`UPDATE dm_messages SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
rawDb.prepare(`UPDATE messages SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// attachments.uploader_id (plain text column, NO FK, no unique → straight
// repoint). A replicated stub that uploaded a DM/channel attachment would
// otherwise leave uploader_id dangling at the deleted stub's id — broken
// attribution.
rawDb.prepare(`UPDATE attachments SET uploader_id = ? WHERE uploader_id = ?`).run(targetId, stubId);
// dm_reactions (dedupe on dm_message_id+emoji per user).
rawDb.prepare(`DELETE FROM dm_reactions WHERE user_id = ? AND EXISTS (SELECT 1 FROM dm_reactions r2 WHERE r2.user_id = ? AND r2.dm_message_id = dm_reactions.dm_message_id AND r2.emoji = dm_reactions.emoji)`).run(stubId, targetId);
rawDb.prepare(`UPDATE dm_reactions SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// reactions (dedupe on message_id+emoji per user).
rawDb.prepare(`DELETE FROM reactions WHERE user_id = ? AND EXISTS (SELECT 1 FROM reactions r2 WHERE r2.user_id = ? AND r2.message_id = reactions.message_id AND r2.emoji = reactions.emoji)`).run(stubId, targetId);
rawDb.prepare(`UPDATE reactions SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// friends (composite PK user_id+friend_id → dedupe both directions, then
// repoint, then drop any self-friendship the repoint created).
rawDb.prepare(`DELETE FROM friends WHERE user_id = ? AND friend_id IN (SELECT friend_id FROM friends WHERE user_id = ?)`).run(stubId, targetId);
rawDb.prepare(`DELETE FROM friends WHERE friend_id = ? AND user_id IN (SELECT user_id FROM friends WHERE friend_id = ?)`).run(stubId, targetId);
rawDb.prepare(`UPDATE friends SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
rawDb.prepare(`UPDATE friends SET friend_id = ? WHERE friend_id = ?`).run(targetId, stubId);
rawDb.prepare(`DELETE FROM friends WHERE user_id = friend_id`).run();
// friend_requests (unique on neither col alone; repoint both, drop self-rows).
rawDb.prepare(`UPDATE friend_requests SET from_id = ? WHERE from_id = ?`).run(targetId, stubId);
rawDb.prepare(`UPDATE friend_requests SET to_id = ? WHERE to_id = ?`).run(targetId, stubId);
rawDb.prepare(`DELETE FROM friend_requests WHERE from_id = to_id`).run();
// read_states (composite PK user_id+channel_id → dedupe).
rawDb.prepare(`DELETE FROM read_states WHERE user_id = ? AND channel_id IN (SELECT channel_id FROM read_states WHERE user_id = ?)`).run(stubId, targetId);
rawDb.prepare(`UPDATE read_states SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// dm_channels.owner_id (plain text column, NO FK → straight repoint).
rawDb.prepare(`UPDATE dm_channels SET owner_id = ? WHERE owner_id = ?`).run(targetId, stubId);
rawDb.prepare(`DELETE FROM users WHERE id = ?`).run(stubId);
}
// Group-DM ownership continuity: channels the OLD identity owned keep
// authority under the NEW identity (owner_home_user_id is the S2S
// authority key, not a users.id FK).
const normOwnerHome = `lower(replace(replace(coalesce(owner_home_instance, ''), 'https://', ''), 'http://', ''))`;
rawDb.prepare(`UPDATE dm_channels SET owner_home_user_id = ? WHERE owner_home_user_id = ? AND ${normOwnerHome} = ?`)
.run(verified.homeUserId, detached.homeUserId, homeDomain);
// Re-bind. profile_updated_at is nulled so the home's next profile_update
// (any version) tier-1 matches and applies (the accept-and-skip guards
// only fire on federation_home_orphaned = 1).
rawDb.prepare(`UPDATE users SET home_user_id = ?, federation_home_orphaned = 0, username = ?, profile_updated_at = NULL WHERE id = ?`)
.run(verified.homeUserId, newUsername, detached.id);
// Reconcile the account's 1-on-1 DM channels: the home_user_id just
// changed, so every 1-on-1 federatedId derived from it is now stale.
// Re-key or merge each into its new-identity channel so history stays a
// single conversation (reattach-dm-reconcile spec §3.2). Group DMs (UUID
// federatedId / != 2 members) are skipped by the helper.
const oneOnOne = rawDb.prepare(`
SELECT c.id FROM dm_channels c
WHERE c.deleted_at IS NULL
AND c.federated_id IS NOT NULL
AND EXISTS (SELECT 1 FROM dm_members m WHERE m.dm_channel_id = c.id AND m.user_id = ?)
AND (SELECT count(*) FROM dm_members m2 WHERE m2.dm_channel_id = c.id) = 2
`).all(detached.id) as Array<{ id: string }>;
for (const c of oneOnOne) {
// A merge earlier in this loop may have deleted this id — reconcile
// returns noop for a missing/mutated channel, so the loop is convergent.
const result = reconcileDmChannelFederatedId(rawDb, c.id);
if (result.action !== 'noop') dmReconcileResults.push(result);
}
})();
// Best-effort initial profile pull (spec §3.2 step 4). Failure is fine — the
// account is re-attached; the next relay fills the profile.
const home = await fetchHomeProfileByHomeId(peerRow, verified.homeUserId);
if (home) {
let avatar: string | null = null;
let banner: string | null = null;
if (home.profile.avatar) {
const url = home.profile.avatar.startsWith('http') ? home.profile.avatar : `${peerRow.origin}/api/uploads/${home.profile.avatar}`;
avatar = (await downloadProfileAsset(url, peerRow.origin)) ?? url;
}
if (home.profile.banner) {
const url = home.profile.banner.startsWith('http') ? home.profile.banner : `${peerRow.origin}/api/uploads/${home.profile.banner}`;
banner = (await downloadProfileAsset(url, peerRow.origin)) ?? url;
}
db.update(schema.users).set({
displayName: home.profile.displayName ?? home.username,
avatar,
banner,
avatarColor: home.profile.avatarColor ?? detached.avatarColor,
bio: home.profile.bio,
}).where(eq(schema.users.id, detached.id)).run();
}
const updated = db.select().from(schema.users).where(eq(schema.users.id, detached.id)).get()!;
console.log(`[federation] Re-attached account ${updated.id} (${updated.username}): ${detached.homeUserId}${verified.homeUserId} @ ${homeDomain}`);
// Broadcast to friends / DM / space co-members + all self connections.
const targetIds = collectProfileBroadcastTargetIds(updated.id);
targetIds.add(updated.id);
for (const uid of targetIds) {
connectionManager.sendToUser(uid, { type: 'user_updated' as const, user: sanitizeUser(updated, uid === updated.id) });
}
// Push DM-list refresh for reconciled channels to affected local members so
// the merged/re-keyed conversation replaces the split without a reload
// (reattach-dm-reconcile spec §3.4). Reuses existing events, no new type:
// - merged: dm_channel_closed removes the stale source entry; dm_channel_created
// (full DmChannel payload — the client handler reads dmChannel.members) resurfaces
// the surviving target with its merged history.
// - rekeyed: dm_channel_created upserts the channel by id (spaceStore.addDmChannel
// replaces by id), refreshing the now-stale federatedId in place. dm_channel_updated
// would only patch name/icon, not federatedId, so it cannot heal the client here.
for (const r of dmReconcileResults) {
const targetPayload = buildDmChannelPayload(r.targetChannelId, db);
for (const uid of r.affectedUserIds) {
if (r.action === 'merged') {
connectionManager.sendToUser(uid, { type: 'dm_channel_closed' as const, dmChannelId: r.channelId });
}
if (targetPayload) {
connectionManager.sendToUser(uid, { type: 'dm_channel_created' as const, dmChannel: targetPayload });
}
}
}
return reply.code(200).send({ success: true, user: sanitizeUser(updated, true) });
});
// ─── POST /api/federation/sync ──────────────────────────────────────────────
// Server-to-server: checkpoint catch-up sync. A peer calls this after downtime
// to retrieve missed DM mutations from the mutation log.
@@ -2795,15 +3163,57 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// Only populated in the DM branch (friend/profile branches don't need it).
let channelFederatedIdMap = new Map<string, string>();
// Friend-branch pagination must be computed from PRE-filter rows —
// filtering in place would stall the checkpoint / drop pages (spec §3.2).
let prefilterCount: number | null = null;
let prefilterLastTs: number | null = null;
if (contextTypeFilter === 'friend') {
// ── Friend event sync: no DM channel logic needed ──
mutationRows = rawDb.prepare(`
// ── Friend event sync: relevance-scoped to the requesting peer ──
const fetchedFriendRows = rawDb.prepare(`
SELECT id, entity_id, context_id, context_type, mutation_type, mutated_at, payload
FROM federation_mutation_log
WHERE context_type = 'friend' AND mutated_at > ?
ORDER BY mutated_at ASC
LIMIT ?
`).all(sinceTimestamp, limit) as typeof mutationRows;
prefilterCount = fetchedFriendRows.length;
prefilterLastTs = fetchedFriendRows.length > 0
? fetchedFriendRows[fetchedFriendRows.length - 1]!.mutated_at
: null;
const peerDomainFriend = extractDomain(peer.origin).toLowerCase();
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
const localRowStmt = rawDb.prepare(`
SELECT is_deleted, federation_home_orphaned FROM users
WHERE home_user_id = ? AND ${normHome} = ?
`);
// An event qualifies iff at least one side is homed at the requester's
// domain AND that side, when it resolves to a local row, is live and
// non-detached. A detached/tombstoned row belongs to a dead incarnation
// of the requester, not to the requester (spec §3.2).
const sideQualifies = (side: { homeUserId?: string; homeInstance?: string } | undefined): boolean => {
if (!side?.homeUserId || !side.homeInstance) return false;
if (extractDomain(side.homeInstance).toLowerCase() !== peerDomainFriend) return false;
const local = localRowStmt.get(side.homeUserId, peerDomainFriend) as
{ is_deleted: number; federation_home_orphaned: number } | undefined;
if (local && (local.is_deleted === 1 || local.federation_home_orphaned === 1)) return false;
return true;
};
mutationRows = fetchedFriendRows.filter((row) => {
if (!row.payload) return false;
let friendship: { from?: { homeUserId?: string; homeInstance?: string }; to?: { homeUserId?: string; homeInstance?: string } } | undefined;
try {
friendship = (JSON.parse(row.payload) as { friendship?: typeof friendship }).friendship;
} catch {
return false;
}
if (!friendship) return false;
return sideQualifies(friendship.from) || sideQualifies(friendship.to);
});
} else if (contextTypeFilter === 'profile') {
// ── Profile event sync: no DM channel logic needed ──
mutationRows = rawDb.prepare(`
@@ -2819,10 +3229,23 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// Use federated_id: any channel with a federated ID is a federated DM
// that should be synced. The peer's relay endpoint will create the channel
// if it doesn't exist, or match by federated_id if it does.
// Relevance scoping (dead-incarnation spec §3.2): only offer channels
// with at least one LIVE member homed at the requesting peer's domain.
// A reset peer's former users are detached (federation_home_orphaned=1)
// or tombstoned here — their channels are our history, not the new
// incarnation's. Channels not involving the requester at all are none
// of its business either (third-instance over-broadcast).
const peerDomain = extractDomain(peer.origin).toLowerCase();
const sharedChannelRows = rawDb.prepare(`
SELECT id as dm_channel_id, federated_id FROM dm_channels
WHERE federated_id IS NOT NULL AND deleted_at IS NULL
`).all() as Array<{ dm_channel_id: string; federated_id: string }>;
SELECT DISTINCT c.id as dm_channel_id, c.federated_id
FROM dm_channels c
JOIN dm_members m ON m.dm_channel_id = c.id
JOIN users u ON u.id = m.user_id
WHERE c.federated_id IS NOT NULL AND c.deleted_at IS NULL
AND u.is_deleted = 0
AND u.federation_home_orphaned = 0
AND lower(replace(replace(coalesce(u.home_instance, ''), 'https://', ''), 'http://', '')) = ?
`).all(peerDomain) as Array<{ dm_channel_id: string; federated_id: string }>;
const sharedChannelIds = sharedChannelRows.map(r => r.dm_channel_id);
channelFederatedIdMap = new Map<string, string>(
@@ -3184,11 +3607,13 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
});
}
// 6. Compute pagination metadata
const hasMore = mutationRows.length >= limit;
const checkpoint = mutationRows.length > 0
? mutationRows[mutationRows.length - 1]!.mutated_at
: sinceTimestamp;
// 6. Compute pagination metadata — from PRE-filter rows when the friend
// branch filtered, so filtered-out events still advance the cursor.
const hasMore = (prefilterCount ?? mutationRows.length) >= limit;
const checkpoint = prefilterLastTs
?? (mutationRows.length > 0
? mutationRows[mutationRows.length - 1]!.mutated_at
: sinceTimestamp);
// 7. Update peer last-seen timestamp
db.update(schema.federationPeers)
@@ -3391,6 +3816,20 @@ export function extractDomain(homeInstance: string): string {
}
}
/**
* The bare lowercase domain that constitutes this instance's federated
* identity authority. Derives from DOMAIN (identity), falling back to
* getOurOrigin() only when DOMAIN is unset (dev/tests). PUBLIC_ORIGIN is a
* transport override and deliberately NOT consulted first identity
* comparisons must not shift when the transport origin is overridden.
*/
export function getOurIdentityDomain(): string | null {
if (config.domain) return config.domain.toLowerCase();
const origin = getOurOrigin();
if (!origin) return null;
return extractDomain(origin).toLowerCase();
}
/**
* Verify that an acting user's homeInstance is legitimate for this relay.
*
@@ -3478,6 +3917,10 @@ export function findFederatedUser(
and(
eq(schema.users.homeInstance, domain),
eq(schema.users.isDeleted, 0),
// Detached (home-orphaned) accounts are sovereign: never re-bindable to
// the domain's new incarnation via username heuristics — that is exactly
// how a new same-name user would capture the established account.
eq(schema.users.federationHomeOrphaned, 0),
or(
sql`lower(substr(${schema.users.username}, 1, instr(${schema.users.username}, '@') - 1)) = ${hintLower}`,
and(
@@ -3545,15 +3988,35 @@ export function resolveOrCreateReplicatedUser(
homeUserId: string,
homeInstance: string,
db: ReturnType<typeof getDb>,
hints?: { username?: string | null; status?: 'online' | 'idle' | 'dnd' | 'offline' | null },
hints?: { username?: string | null; status?: 'online' | 'idle' | 'dnd' | 'offline' | null; deleted?: boolean | null },
): typeof schema.users.$inferSelect | null {
const existing = findFederatedUser(homeUserId, homeInstance, db, hints);
if (existing) return backfillHomeUserId(existing, homeUserId, db);
// A participant the sender marks as deleted must not materialize as a new
// stub — mirror of the local-tombstone skip below. An existing row still
// resolves above, so historical attribution is unaffected (spec §3.3).
if (hints?.deleted) {
console.log(`[federation] Skipping stub creation for remotely-deleted identity homeUserId=${homeUserId}`);
return null;
}
// Check if this identity was previously deleted — don't resurrect a tombstoned
// user by creating a new stub. The isDeleted=0 filter in findFederatedUser
// already hides the deleted row, so we must query without that filter here.
const domain = extractDomain(homeInstance);
// An instance never hosts a replicated stub homed at itself. A self-domain
// identity that is live resolves at tier 1 above (native id match); one
// that reaches the create path is a dead incarnation from before an
// instance reset (e.g. replayed by a peer's initial sync). Creating a row
// here is what produced the self-homed double-domain junk stubs.
const ourDomain = getOurIdentityDomain();
if (ourDomain && domain.toLowerCase() === ourDomain) {
console.log(`[federation] Refusing self-homed stub for homeUserId=${homeUserId} (${domain}) — dead incarnation of this instance`);
return null;
}
const deletedMatch = db
.select({ id: schema.users.id, isDeleted: schema.users.isDeleted })
.from(schema.users)
@@ -3788,7 +4251,7 @@ async function processCreateEvent(
}> = [];
for (const p of event.participants) {
let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username, status: p.profile?.status });
let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username, status: p.profile?.status, deleted: p.profile?.deleted });
// Skip deleted identities — don't include tombstoned users in the DM
if (!localUser) continue;
// Hydrate with profile data from the relay event (displayName, avatar, etc.)
@@ -4385,7 +4848,7 @@ export async function processMemberAddEvent(
// Resolve owner — create a replicated stub if unknown
let ownerId: string | null = null;
if (event.group.owner) {
const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username, status: event.group.owner.profile?.status });
const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username, status: event.group.owner.profile?.status, deleted: event.group.owner.profile?.deleted });
ownerId = ownerLocal?.id ?? null;
}
@@ -4422,7 +4885,7 @@ export async function processMemberAddEvent(
// Add all roster members — create replicated user stubs for any
// participants from remote instances that haven't been seen before.
for (const member of event.group.members) {
const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username, status: member.profile?.status });
const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username, status: member.profile?.status, deleted: member.profile?.deleted });
// Skip deleted identities — tombstoned users can't be added to a DM
if (!rosterUser) continue;
const existing = db.select().from(schema.dmMembers)
@@ -4476,7 +4939,7 @@ export async function processMemberAddEvent(
event.membership.user.homeUserId,
event.membership.user.homeInstance,
db,
{ username: event.membership.user.profile?.username, status: event.membership.user.profile?.status },
{ username: event.membership.user.profile?.username, status: event.membership.user.profile?.status, deleted: event.membership.user.profile?.deleted },
);
if (!localUser) {
// The user's identity has been deleted — don't add a tombstoned user to the DM
@@ -4515,7 +4978,7 @@ export async function processMemberAddEvent(
// would otherwise find the channel already present and fall through to the incremental path,
// creating spurious system messages (the exact bug this fixes).
const actorUser = event.membership.addedBy
? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username, status: event.membership.addedBy.profile?.status })
? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username, status: event.membership.addedBy.profile?.status, deleted: event.membership.addedBy.profile?.deleted })
: null;
const actorId = actorUser?.id ?? localUser.id;
const addBaseName = localUser.username?.includes('@') ? localUser.username.split('@')[0] : (localUser.username ?? 'Unknown');
@@ -4824,7 +5287,7 @@ export function processOwnershipTransferEvent(
event.ownership.newOwner.homeUserId,
event.ownership.newOwner.homeInstance,
db,
{ username: event.ownership.newOwner.profile?.username, status: event.ownership.newOwner.profile?.status },
{ username: event.ownership.newOwner.profile?.username, status: event.ownership.newOwner.profile?.status, deleted: event.ownership.newOwner.profile?.deleted },
);
if (!newOwnerLocal) {
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
@@ -4917,6 +5380,12 @@ export async function hydrateReplicatedUserProfile(
): Promise<typeof schema.users.$inferSelect> {
if (!profile) return user;
if (!user.homeInstance) return user; // Don't update native users
// Detached accounts are sovereign local accounts: the home domain now belongs
// to a different incarnation, so a relayed snapshot resolved via an old
// homeUserId (tier-1 historical hit) must never fill this row's fields. No-op
// return, mirroring the profile_update / presence_update / identity-delete
// guards (detach spec §4.3).
if (user.federationHomeOrphaned === 1) return user;
const baseUrl = user.homeInstance.startsWith('http') ? user.homeInstance : `https://${user.homeInstance}`;
const buildAbsoluteUrl = (value: string): string => {
@@ -4993,7 +5462,7 @@ async function processFriendRequestCreateEvent(
}
// Resolve the sender (create stub if needed — they're on a remote instance)
const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status });
const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status, deleted: event.friendship.fromProfile?.deleted });
if (!fromUserResolved) {
// Sender's identity has been deleted — silently accept to drop the event
accepted.push(event.messageId);
@@ -5111,7 +5580,7 @@ function processFriendRequestUpdateEvent(
}
// Resolve the recipient (create stub if needed — they're on the remote instance)
const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status });
const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status, deleted: event.friendship.toProfile?.deleted });
if (!toUser) {
// Recipient's identity has been deleted — accept idempotently to drop the event
accepted.push(event.messageId);
@@ -5251,14 +5720,14 @@ async function processFriendAddEvent(
}
// Resolve both users (create stubs if needed) and hydrate with profile data
const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status });
const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status, deleted: event.friendship.fromProfile?.deleted });
if (!fromUserResolved) {
// One party's identity is deleted — accept idempotently to drop the event
accepted.push(event.messageId);
return;
}
let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db);
const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status });
const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status, deleted: event.friendship.toProfile?.deleted });
if (!toUserResolved) {
accepted.push(event.messageId);
return;
@@ -6110,6 +6579,16 @@ export async function processProfileUpdateEvent(
return;
}
// Detached accounts are sovereign: the domain now belongs to a different
// incarnation, which must never overwrite the established account's profile
// by replaying its old homeUserId. Ack (not reject) — the sender considers
// this identity theirs to update; from our side the update simply no-ops.
if (localUser.federationHomeOrphaned === 1) {
console.log(`[federation] Skipping profile_update for detached account ${localUser.id} (home-orphaned)`);
accepted.push(event.messageId);
return;
}
// Version check: reject stale/duplicate events
const storedTs = localUser.profileUpdatedAt ?? 0;
const incomingTs = payload.profileUpdatedAt ?? 0;
@@ -6335,7 +6814,7 @@ export async function processGroupMetadataUpdateEvent(
actorParticipant.homeUserId,
actorParticipant.homeInstance,
db,
{ username: actorParticipant.profile?.username, status: actorParticipant.profile?.status },
{ username: actorParticipant.profile?.username, status: actorParticipant.profile?.status, deleted: actorParticipant.profile?.deleted },
);
actorUserId = actorUser?.id ?? null;
}
@@ -6517,6 +6996,16 @@ export function processPresenceUpdateEvent(
return;
}
// Detached accounts are sovereign: the domain now belongs to a different
// incarnation, which must never flip the established account's presence by
// replaying its old homeUserId. Ack (not reject) — the sender considers this
// identity theirs to update; from our side the update simply no-ops.
if (localUser.federationHomeOrphaned === 1) {
console.log(`[federation] Skipping presence_update for detached account ${localUser.id} (home-orphaned)`);
accepted.push(event.messageId);
return;
}
db.update(schema.users)
.set({ status: payload.status })
.where(eq(schema.users.id, localUser.id))
@@ -6537,6 +7026,195 @@ export function processPresenceUpdateEvent(
accepted.push(event.messageId);
}
// ─── Dead-Incarnation Startup Sweep ─────────────────────────────────────────
export interface DmReconcileResult {
action: 'noop' | 'rekeyed' | 'merged';
channelId: string;
targetChannelId: string;
affectedUserIds: string[];
}
/**
* Reconcile a single 1-on-1 DM channel's deterministic federatedId against its
* members' CURRENT home identities (reattach-dm-reconcile spec §3.1). A 1-on-1
* federatedId is f(sorted home user ids); when a participant's home_user_id
* changes (re-attach), the channel's stored id goes stale and new messages
* compute a different id a split conversation. This re-keys the channel in
* place, or when a channel already carries the correct id (idx_dm_federated is
* UNIQUE, so two rows can't share it) merges this channel INTO that one and
* deletes it.
*
* Idempotent: a correctly-keyed channel is a noop. Group DMs (UUID federatedId
* or member count != 2) are skipped. Must be called inside a transaction.
*/
export function reconcileDmChannelFederatedId(
rawDb: ReturnType<typeof getRawDb>,
channelId: string,
): DmReconcileResult {
const noop: DmReconcileResult = { action: 'noop', channelId, targetChannelId: channelId, affectedUserIds: [] };
const chan = rawDb.prepare(`SELECT id, federated_id FROM dm_channels WHERE id = ? AND deleted_at IS NULL`).get(channelId) as
{ id: string; federated_id: string | null } | undefined;
if (!chan || !chan.federated_id) return noop;
// Only 1-on-1 shape (32 hex). Group DMs use a random UUID.
if (!/^[0-9a-f]{32}$/.test(chan.federated_id)) return noop;
const members = rawDb.prepare(`
SELECT u.id, u.home_user_id FROM dm_members m JOIN users u ON u.id = m.user_id
WHERE m.dm_channel_id = ?
`).all(channelId) as Array<{ id: string; home_user_id: string | null }>;
if (members.length !== 2) return noop;
const homeA = members[0]!.home_user_id || members[0]!.id;
const homeB = members[1]!.home_user_id || members[1]!.id;
const expected = computeFederatedId(homeA, homeB);
if (expected === chan.federated_id) return noop;
const target = rawDb.prepare(`SELECT id FROM dm_channels WHERE federated_id = ? AND deleted_at IS NULL AND id != ?`).get(expected, channelId) as
{ id: string } | undefined;
if (!target) {
rawDb.prepare(`UPDATE dm_channels SET federated_id = ? WHERE id = ?`).run(expected, channelId);
return { action: 'rekeyed', channelId, targetChannelId: channelId, affectedUserIds: members.map(m => m.id) };
}
// Merge source (channelId) INTO target, then delete source.
const targetId = target.id;
const targetMemberIds = (rawDb.prepare(`SELECT user_id FROM dm_members WHERE dm_channel_id = ?`).all(targetId) as Array<{ user_id: string }>).map(r => r.user_id);
const affected = Array.from(new Set([...members.map(m => m.id), ...targetMemberIds]));
// Messages: globally-unique ids, straight move (attachments + dm_reactions
// reference dm_message_id and follow automatically).
rawDb.prepare(`UPDATE dm_messages SET dm_channel_id = ? WHERE dm_channel_id = ?`).run(targetId, channelId);
// Members: drop source rows already present on target (composite PK), repoint the rest.
rawDb.prepare(`DELETE FROM dm_members WHERE dm_channel_id = ? AND user_id IN (SELECT user_id FROM dm_members WHERE dm_channel_id = ?)`).run(channelId, targetId);
rawDb.prepare(`UPDATE dm_members SET dm_channel_id = ? WHERE dm_channel_id = ?`).run(targetId, channelId);
// read_states: keyed by channel_id; dedupe on (user_id, channel_id) then repoint.
rawDb.prepare(`DELETE FROM read_states WHERE channel_id = ? AND user_id IN (SELECT user_id FROM read_states WHERE channel_id = ?)`).run(channelId, targetId);
rawDb.prepare(`UPDATE read_states SET channel_id = ? WHERE channel_id = ?`).run(targetId, channelId);
// Remove the now-empty source channel.
rawDb.prepare(`DELETE FROM dm_channels WHERE id = ?`).run(channelId);
return { action: 'merged', channelId, targetChannelId: targetId, affectedUserIds: affected };
}
/**
* Startup sweep: reconcile any 1-on-1 DM channel whose stored federatedId has
* drifted from its members' current home identities (reattach-dm-reconcile
* spec §3.3). Heals accounts re-attached before inline reconciliation shipped
* (e.g. the live split-conversation duplicate). Idempotent; a noop on a clean DB.
*/
export function reconcileDriftedDmFederatedIds(): void {
const rawDb = getRawDb();
const candidates = rawDb.prepare(`
SELECT c.id FROM dm_channels c
WHERE c.deleted_at IS NULL
AND c.federated_id IS NOT NULL
AND (SELECT count(*) FROM dm_members m WHERE m.dm_channel_id = c.id) = 2
`).all() as Array<{ id: string }>;
if (candidates.length === 0) return;
let rekeyed = 0;
let merged = 0;
rawDb.transaction(() => {
for (const c of candidates) {
// A prior merge in this loop may have deleted this id — reconcile returns
// noop for a missing/mutated channel, so this is safe.
const r = reconcileDmChannelFederatedId(rawDb, c.id);
if (r.action === 'rekeyed') rekeyed++;
else if (r.action === 'merged') merged++;
}
})();
if (rekeyed > 0 || merged > 0) {
console.log(`[federation] DM federatedId reconciliation: rekeyed ${rekeyed}, merged ${merged}`);
}
}
/**
* Remove dead-incarnation artifacts produced by pre-fix initial syncs
* (dead-incarnation spec §3.4): DM channels with no native member, and
* replicated stubs homed at this instance's own domain. Idempotent
* a no-op on a clean database. Synchronous (better-sqlite3), runs once
* at startup from startFederationWorkers.
*
* Child rows are deleted explicitly: FK cascade enforcement cannot be
* assumed ON, and dm_messages.user_id has no cascade anyway.
*/
export function sweepDeadIncarnationArtifacts(): void {
const ourDomain = getOurIdentityDomain();
if (!ourDomain) return;
const rawDb = getRawDb();
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
// ── 1. DM channels with no native member. A legitimate channel always
// involves a native user; native-less channels are sync junk. ──
const junkChannelIds = (rawDb.prepare(`
SELECT c.id FROM dm_channels c
WHERE NOT EXISTS (
SELECT 1 FROM dm_members m JOIN users u ON u.id = m.user_id
WHERE m.dm_channel_id = c.id AND u.home_instance IS NULL
)
`).all() as Array<{ id: string }>).map(r => r.id);
if (junkChannelIds.length > 0) {
const ph = junkChannelIds.map(() => '?').join(',');
rawDb.transaction(() => {
rawDb.prepare(`DELETE FROM dm_reactions WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id IN (${ph}))`).run(...junkChannelIds);
rawDb.prepare(`DELETE FROM attachments WHERE dm_message_id IN (SELECT id FROM dm_messages WHERE dm_channel_id IN (${ph}))`).run(...junkChannelIds);
rawDb.prepare(`DELETE FROM dm_messages WHERE dm_channel_id IN (${ph})`).run(...junkChannelIds);
rawDb.prepare(`DELETE FROM dm_members WHERE dm_channel_id IN (${ph})`).run(...junkChannelIds);
rawDb.prepare(`DELETE FROM read_states WHERE channel_id IN (${ph})`).run(...junkChannelIds);
rawDb.prepare(`DELETE FROM dm_channels WHERE id IN (${ph})`).run(...junkChannelIds);
})();
}
// ── 2. Self-homed replicated stubs. Junk social rows referencing them go
// first; then stubs with no remaining non-cascading references. ──
const stubSelect = `SELECT id FROM users WHERE password_hash = '!federation-replicated' AND ${normHome} = ?`;
const allStubIds = (rawDb.prepare(stubSelect).all(ourDomain) as Array<{ id: string }>).map(r => r.id);
let deletedStubs = 0;
if (allStubIds.length > 0) {
rawDb.transaction(() => {
rawDb.prepare(`DELETE FROM friends WHERE user_id IN (${stubSelect}) OR friend_id IN (${stubSelect})`).run(ourDomain, ourDomain);
rawDb.prepare(`DELETE FROM friend_requests WHERE from_id IN (${stubSelect}) OR to_id IN (${stubSelect})`).run(ourDomain, ourDomain);
// Deletable = no rows left in any table whose FK to users.id does NOT
// cascade, and no surviving dm/space membership or authored message.
const deletable = (rawDb.prepare(`
${stubSelect}
AND NOT EXISTS (SELECT 1 FROM dm_messages WHERE user_id = users.id)
AND NOT EXISTS (SELECT 1 FROM messages WHERE user_id = users.id)
AND NOT EXISTS (SELECT 1 FROM dm_members WHERE user_id = users.id)
AND NOT EXISTS (SELECT 1 FROM space_members WHERE user_id = users.id)
AND NOT EXISTS (SELECT 1 FROM spaces WHERE owner_id = users.id)
AND NOT EXISTS (SELECT 1 FROM bans WHERE banned_by = users.id)
AND NOT EXISTS (SELECT 1 FROM join_requests WHERE decided_by = users.id)
AND NOT EXISTS (SELECT 1 FROM voice_restrictions WHERE moderator_id = users.id)
AND NOT EXISTS (SELECT 1 FROM invite_links WHERE created_by = users.id)
`).all(ourDomain) as Array<{ id: string }>).map(r => r.id);
if (deletable.length > 0) {
const dph = deletable.map(() => '?').join(',');
// Explicit child cleanup for the cascade-declared tables too — FK
// enforcement cannot be assumed ON.
rawDb.prepare(`DELETE FROM dm_reactions WHERE user_id IN (${dph})`).run(...deletable);
rawDb.prepare(`DELETE FROM reactions WHERE user_id IN (${dph})`).run(...deletable);
rawDb.prepare(`DELETE FROM read_states WHERE user_id IN (${dph})`).run(...deletable);
rawDb.prepare(`DELETE FROM users WHERE id IN (${dph})`).run(...deletable);
deletedStubs = deletable.length;
}
})();
}
const skipped = allStubIds.length - deletedStubs;
if (junkChannelIds.length > 0 || allStubIds.length > 0) {
console.log(`[federation] Dead-incarnation sweep: removed ${junkChannelIds.length} channels, ${deletedStubs} self-homed stubs${skipped > 0 ? `, skipped ${skipped} still-referenced stubs` : ''}`);
}
}
// ─── Replicated Profile Asset Backfill ──────────────────────────────────────
/**
@@ -71,12 +71,14 @@ function seedUser(opts: {
avatarColor?: string | null;
banner?: string | null;
bio?: string | null;
passwordHash?: string;
federationHomeOrphaned?: 0 | 1;
}): void {
testDb.insert(schema.users).values({
id: opts.id,
username: opts.username,
displayName: opts.displayName ?? null,
passwordHash: 'x',
passwordHash: opts.passwordHash ?? 'x',
status: 'offline',
isAdmin: 0,
isDeleted: opts.isDeleted ?? 0,
@@ -87,6 +89,7 @@ function seedUser(opts: {
avatarColor: opts.avatarColor ?? null,
banner: opts.banner ?? null,
bio: opts.bio ?? null,
federationHomeOrphaned: opts.federationHomeOrphaned ?? 0,
createdAt: Date.now(),
}).run();
}
@@ -249,3 +252,59 @@ describe('POST /api/federation/users/lookup', () => {
expect(blocked.headers['retry-after']).toBe('60');
});
});
describe('findFederatedUser — detached (home-orphaned) accounts', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
// A REAL federated account whose home domain was reset. It has been detached
// (federationHomeOrphaned = 1): it now owns its identity locally and must
// never be re-captured by the reset domain's new incarnation.
seedUser({
id: 'detached-1',
username: 'alice@peer.example',
homeInstance: 'peer.example',
homeUserId: 'old-home-uid',
passwordHash: '$2b$10$abcdefghijklmnopqrstuv', // real bcrypt-like hash, not a stub
federationHomeOrphaned: 1,
});
});
it('tier-2 never matches a detached (home-orphaned) account', async () => {
const { findFederatedUser } = await import('./federation.js');
// Fresh homeUserId → tier-1 miss; the reset domain replays 'alice' as a hint.
const found = findFederatedUser('new-home-uid', 'peer.example', testDb, { username: 'alice' });
expect(found).toBeUndefined();
});
it('tier-1 (homeUserId) still resolves a detached account for historical references', async () => {
const { findFederatedUser } = await import('./federation.js');
// The original homeUserId is a legitimate historical reference (e.g. an old
// group-DM attribution relayed by a third instance) — tier-1 must still resolve it.
const found = findFederatedUser('old-home-uid', 'peer.example', testDb, { username: 'alice' });
expect(found?.federationHomeOrphaned).toBe(1);
});
it('tier-2 STILL matches a NON-detached same-name federated account (the exclusion clause does not over-filter)', async () => {
const { findFederatedUser } = await import('./federation.js');
// Positive companion to the exclusion test: replace the detached seed with an
// otherwise-identical NON-detached row (federationHomeOrphaned = 0). Same domain,
// same handle base, fresh homeUserId, same hint — the ONLY difference is the flag.
// This locks that the `eq(federationHomeOrphaned, 0)` clause discriminates on the
// flag alone and never withholds a legitimate replicated identity from tier-2.
testDb.delete(schema.users).where(eq(schema.users.id, 'detached-1')).run();
seedUser({
id: 'live-1',
username: 'alice@peer.example',
homeInstance: 'peer.example',
homeUserId: 'legacy-home-uid',
passwordHash: '$2b$10$abcdefghijklmnopqrstuv',
federationHomeOrphaned: 0,
});
// Fresh homeUserId → tier-1 miss; tier-2 must return the non-detached row.
const found = findFederatedUser('new-home-uid', 'peer.example', testDb, { username: 'alice' });
expect(found?.id).toBe('live-1');
expect(found?.federationHomeOrphaned).toBe(0);
});
});
@@ -0,0 +1,205 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
import { signRequest, verifySignature } from '../utils/federationAuth.js';
import { randomUUID } from 'node:crypto';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Module-level mutable state. Each beforeEach reassigns sqlite/testDb;
// the getDb getter in the mock closes over the current binding.
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
const PEER_ORIGIN = 'https://orbit.test';
const PEER_SECRET = 'a'.repeat(64);
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedActivePeer(): void {
testDb.insert(schema.federationPeers).values({
id: 'peer-1',
origin: PEER_ORIGIN,
hmacSecret: PEER_SECRET,
status: 'active',
nonceSupported: 1,
createdAt: Date.now(),
lastSeenAt: Date.now(),
consecutiveFailures: 0,
consecutiveAuthFailures: 0,
} as typeof schema.federationPeers.$inferInsert).run();
}
function seedNativeUser(): void {
testDb.insert(schema.users).values({
id: 'native-1',
username: 'youruser',
displayName: null,
passwordHash: 'x',
status: 'offline',
isAdmin: 0,
isDeleted: 0,
discoverable: 1,
homeInstance: null,
homeUserId: null,
createdAt: 1,
} as typeof schema.users.$inferInsert).run();
}
// Tokens are minted by /api/auth/attach-proof as randomBytes(32).toString('hex')
// — always 64 lowercase hex chars — so the fixture token must be valid hex too.
function seedProof(overrides: Partial<typeof schema.federationAttachProofs.$inferInsert> = {}): string {
const token = 'a1'.repeat(32);
testDb.insert(schema.federationAttachProofs).values({
token,
homeUserId: 'native-1',
targetDomain: 'orbit.test',
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
usedAt: null,
...overrides,
}).run();
return overrides.token ?? token;
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { _resetLookupRateBuckets, federationRoutes } = await import('./federation.js');
_resetLookupRateBuckets();
await app.register(federationRoutes);
await app.ready();
return app;
}
function signedHeaders(body: string): Record<string, string> {
const timestamp = Date.now();
const nonce = randomUUID();
const sig = signRequest(body, PEER_SECRET, timestamp, nonce);
return {
'X-Federation-Origin': PEER_ORIGIN,
'X-Federation-Timestamp': String(timestamp),
'X-Federation-Nonce': nonce,
'X-Federation-Signature': `sha256=${sig}`,
'Content-Type': 'application/json',
};
}
async function verify(app: FastifyInstance, body: object) {
const bodyStr = JSON.stringify(body);
return app.inject({
method: 'POST',
url: '/api/federation/verify-attach-proof',
headers: signedHeaders(bodyStr),
payload: bodyStr,
});
}
describe('POST /api/federation/verify-attach-proof', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
seedActivePeer();
seedNativeUser();
app = await buildApp();
});
it('valid token → identity returned, marked used', async () => {
const token = seedProof();
const res = await verify(app, { token });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body).toEqual({ valid: true, homeUserId: 'native-1', username: 'youruser' });
const row = testDb.select().from(schema.federationAttachProofs).all()[0]!;
expect(row.usedAt).not.toBeNull();
});
it('second verification of the same token → valid:false (single-use)', async () => {
const token = seedProof();
await verify(app, { token });
const res = await verify(app, { token });
expect(JSON.parse(res.body)).toEqual({ valid: false });
});
it('expired token → valid:false', async () => {
const token = seedProof({ expiresAt: Date.now() - 1 });
const res = await verify(app, { token });
expect(JSON.parse(res.body)).toEqual({ valid: false });
});
it('token bound to a DIFFERENT target domain → valid:false (peer-domain binding)', async () => {
const token = seedProof({ targetDomain: 'someone-else.test' });
const res = await verify(app, { token });
expect(JSON.parse(res.body)).toEqual({ valid: false });
});
it('unknown token → valid:false', async () => {
const res = await verify(app, { token: 'f'.repeat(64) });
expect(JSON.parse(res.body)).toEqual({ valid: false });
});
it('home user deleted after mint → valid:false', async () => {
const token = seedProof();
testDb.update(schema.users).set({ isDeleted: 1 }).where(eq(schema.users.id, 'native-1')).run();
const res = await verify(app, { token });
expect(JSON.parse(res.body)).toEqual({ valid: false });
});
it('home user no longer native (homeInstance set) after mint → valid:false', async () => {
const token = seedProof();
testDb.update(schema.users).set({ homeInstance: 'orbit.test' }).where(eq(schema.users.id, 'native-1')).run();
const res = await verify(app, { token });
expect(JSON.parse(res.body)).toEqual({ valid: false });
});
it('unsigned request → 401', async () => {
const res = await app.inject({
method: 'POST', url: '/api/federation/verify-attach-proof',
headers: { 'Content-Type': 'application/json' }, payload: JSON.stringify({ token: 'x' }),
});
expect(res.statusCode).toBe(401);
});
it('response is HMAC-signed (epoch pattern)', async () => {
const token = seedProof();
const res = await verify(app, { token });
const sig = res.headers['x-federation-signature'] as string;
expect(sig).toMatch(/^sha256=/);
const ts = res.headers['x-federation-timestamp'] as string;
expect(ts).toBeDefined();
const nonce = res.headers['x-federation-nonce'] as string;
// Signature must verify against the response body with the shared secret.
expect(verifySignature(res.body, sig.slice('sha256='.length), PEER_SECRET, Number(ts), nonce)).toBe(true);
});
});
@@ -5,6 +5,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { eq } from 'drizzle-orm';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
import { signJwt } from '../utils/auth.js';
@@ -177,3 +178,92 @@ describe('GET /api/federation/reset-events', () => {
expect([401, 403]).toContain(res.statusCode);
});
});
describe('POST /api/federation/reset-events/acknowledge', () => {
function seedEvent(origin: string): void {
testDb.insert(schema.federationResetEvents).values({
origin,
deadEpoch: 'E0',
newEpoch: 'E1',
detectedAt: 1000,
resolvedAt: 2000,
stubCount: 0,
orphanedAccountCount: 0,
}).run();
}
it('stamps acknowledged_at (idempotent) and GET returns it', async () => {
seedEvent('https://peer.example');
const ack = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${adminToken()}` },
payload: { origin: 'https://peer.example' },
});
expect(ack.statusCode).toBe(200);
expect(JSON.parse(ack.body)).toEqual({ success: true });
const first = testDb
.select()
.from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, 'https://peer.example'))
.get();
expect(first?.acknowledgedAt).toBeTypeOf('number');
// Idempotent: second call keeps the original timestamp.
const ack2 = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${adminToken()}` },
payload: { origin: 'https://peer.example' },
});
expect(ack2.statusCode).toBe(200);
const second = testDb
.select()
.from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, 'https://peer.example'))
.get();
expect(second?.acknowledgedAt).toBe(first?.acknowledgedAt);
// GET includes the field.
const get = await app.inject({
method: 'GET',
url: '/api/federation/reset-events',
headers: { authorization: `Bearer ${adminToken()}` },
});
expect(get.statusCode).toBe(200);
expect(JSON.parse(get.body).events[0].acknowledgedAt).toBe(first?.acknowledgedAt);
});
it('returns 404 for an unknown origin', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${adminToken()}` },
payload: { origin: 'https://nope.example' },
});
expect(res.statusCode).toBe(404);
});
it('returns 400 when origin is missing', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${adminToken()}` },
payload: {},
});
expect(res.statusCode).toBe(400);
});
it('requires admin (401/403 for non-admin)', async () => {
seedEvent('https://peer.example');
const res = await app.inject({
method: 'POST',
url: '/api/federation/reset-events/acknowledge',
headers: { authorization: `Bearer ${userToken()}` },
payload: { origin: 'https://peer.example' },
});
expect([401, 403]).toContain(res.statusCode);
});
});
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
let _sf = 1;
vi.mock('../utils/snowflake.js', () => ({
generateSnowflake: () => String(_sf++),
setWorkerId: vi.fn(),
}));
vi.mock('../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../utils/federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
// social.ts imports connectionManager from ws/handler.js — stub the minimal
// surface so the route module loads at test time. buildProfileSnapshot doesn't
// touch any of these.
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn(),
sendToSpace: vi.fn(),
sendToDmMembers: vi.fn(),
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
evictFederatedCallsForHost: vi.fn(),
federatedCalls: new Map(),
isUserOnline: vi.fn(),
lateBindFederatedCall: vi.fn(),
},
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
for (const stmt of sql.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
_sf = 1;
});
describe('buildProfileSnapshot — deleted users', () => {
it('never ships the !deleted: tombstone marker', async () => {
const { buildProfileSnapshot } = await import('./social.js');
const row = {
username: '!deleted:12345', displayName: null, avatar: null, avatarColor: null,
banner: null, bio: null, status: 'offline', homeInstance: null, isDeleted: 1,
} as unknown as Parameters<typeof buildProfileSnapshot>[0];
const snap = buildProfileSnapshot(row);
expect(snap.deleted).toBe(true);
expect(snap.username ?? null).toBeNull();
});
});
+5 -1
View File
@@ -20,7 +20,11 @@ import type {
} from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot {
export function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot {
if (user.isDeleted) {
// Never ship the internal '!deleted:<id>' tombstone marker (spec §3.3).
return { deleted: true };
}
// Only meaningful for native users (us). Replicated stubs carry stale status
// their home owns — emitting it would flap remote UIs on relay receipt.
const status = !user.homeInstance && user.status
@@ -0,0 +1,269 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { eq } from 'drizzle-orm';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
import { signJwt, hashPassword, verifyPassword } from '../utils/auth.js';
import { sanitizeUser } from '../utils/sanitize.js';
setWorkerId(23);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
let app: FastifyInstance;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
// Federation relay is disabled in these tests (no peers seeded) — but the PATCH
// handler's S2S block is also gated on `!homeInstance`, so detached/federated
// accounts never relay regardless. The ws layer is fully mocked.
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn(),
sendToSpace: vi.fn(),
sendToDmMembers: vi.fn(),
setUserShowActivity: vi.fn(),
clearUserActivities: vi.fn(),
getUserStatus: vi.fn(() => 'online'),
forceDisconnectUser: vi.fn(),
},
}));
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
async function buildApp(): Promise<FastifyInstance> {
const { userRoutes } = await import('./users.js');
const f = Fastify({ logger: false });
await f.register(userRoutes);
await f.ready();
return f;
}
// A REAL federated account whose home domain was reset and has been detached
// (federationHomeOrphaned = 1): sovereign local account, manages profile +
// password locally.
const DETACHED_ID = 'detached-1';
const DETACHED_USERNAME = 'alice@orbit.test';
const DETACHED_PASSWORD = 'correct-horse-battery';
// A plain replicated (non-detached) federated account — still write-protected
// and still gets the federated change-password bypass.
const FEDERATED_ID = 'federated-1';
const FEDERATED_USERNAME = 'bob@orbit.test';
let detachedHash = '';
async function seedUsers(): Promise<void> {
detachedHash = await hashPassword(DETACHED_PASSWORD);
testDb.insert(schema.users).values([
{
id: DETACHED_ID,
username: DETACHED_USERNAME,
displayName: 'Alice',
passwordHash: detachedHash,
status: 'offline',
isAdmin: 0,
isDeleted: 0,
homeInstance: 'orbit.test',
homeUserId: 'old-home-uid',
federationHomeOrphaned: 1,
profileUpdatedAt: 1000,
createdAt: Date.now(),
},
{
id: FEDERATED_ID,
username: FEDERATED_USERNAME,
displayName: 'Bob',
passwordHash: 'x',
status: 'offline',
isAdmin: 0,
isDeleted: 0,
homeInstance: 'orbit.test',
homeUserId: 'bob-home-uid',
federationHomeOrphaned: 0,
profileUpdatedAt: 1000,
createdAt: Date.now(),
},
]).run();
}
function detachedToken(): string {
return signJwt({ userId: DETACHED_ID, username: DETACHED_USERNAME });
}
function federatedToken(): string {
return signJwt({ userId: FEDERATED_ID, username: FEDERATED_USERNAME });
}
beforeEach(async () => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
await seedUsers();
app = await buildApp();
});
describe('PATCH /api/users/@me — durable-field write-protection', () => {
it('detached account CAN edit durable profile fields', async () => {
const res = await app.inject({
method: 'PATCH',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { displayName: 'New Name' },
});
expect(res.statusCode).toBe(200);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get();
expect(row?.displayName).toBe('New Name');
});
it('non-detached federated account still CANNOT edit durable profile fields (403)', async () => {
const res = await app.inject({
method: 'PATCH',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${federatedToken()}` },
payload: { displayName: 'Hijacked' },
});
expect(res.statusCode).toBe(403);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, FEDERATED_ID)).get();
expect(row?.displayName).toBe('Bob'); // unchanged
});
});
describe('POST /api/users/@me/change-password — local rule for detached accounts', () => {
it('detached account change-password REQUIRES currentPassword (local rule)', async () => {
// No currentPassword → 400
const missing = await app.inject({
method: 'POST',
url: '/api/users/@me/change-password',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { newPassword: 'brand-new-password' },
});
expect(missing.statusCode).toBe(400);
// Wrong currentPassword → 403
const wrong = await app.inject({
method: 'POST',
url: '/api/users/@me/change-password',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { currentPassword: 'not-the-password', newPassword: 'brand-new-password' },
});
expect(wrong.statusCode).toBe(403);
// Correct currentPassword → 200 and hash actually rotates
const ok = await app.inject({
method: 'POST',
url: '/api/users/@me/change-password',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { currentPassword: DETACHED_PASSWORD, newPassword: 'brand-new-password' },
});
expect(ok.statusCode).toBe(200);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get();
expect(row?.passwordHash).not.toBe(detachedHash);
await expect(verifyPassword('brand-new-password', row!.passwordHash)).resolves.toBe(true);
});
it('non-detached federated account still gets the bypass (no currentPassword → 200)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/users/@me/change-password',
headers: { Authorization: `Bearer ${federatedToken()}` },
payload: { newPassword: 'bob-new-password' },
});
expect(res.statusCode).toBe(200);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, FEDERATED_ID)).get();
await expect(verifyPassword('bob-new-password', row!.passwordHash)).resolves.toBe(true);
});
});
describe('DELETE /api/users/@me — local rule for detached accounts', () => {
it('detached self-delete REQUIRES the local password (local rule)', async () => {
// No password → 400
const missing = await app.inject({
method: 'DELETE',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { username: DETACHED_USERNAME },
});
expect(missing.statusCode).toBe(400);
expect(testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!.isDeleted).toBe(0);
// Wrong password → 403
const wrong = await app.inject({
method: 'DELETE',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { username: DETACHED_USERNAME, password: 'not-the-password' },
});
expect(wrong.statusCode).toBe(403);
expect(testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!.isDeleted).toBe(0);
// Correct password → 200 and the account is tombstoned.
const ok = await app.inject({
method: 'DELETE',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { username: DETACHED_USERNAME, password: DETACHED_PASSWORD },
});
expect(ok.statusCode).toBe(200);
expect(testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!.isDeleted).toBe(1);
});
it('non-detached federated self-delete still works JWT-only (no password required)', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${federatedToken()}` },
payload: { username: FEDERATED_USERNAME },
});
expect(res.statusCode).toBe(200);
expect(testDb.select().from(schema.users).where(eq(schema.users.id, FEDERATED_ID)).get()!.isDeleted).toBe(1);
});
});
describe('sanitizeUser — federationHomeOrphaned is self-view only', () => {
it('exposes federationHomeOrphaned only on self-view', () => {
const detachedRow = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!;
const self = sanitizeUser(detachedRow, true);
expect(self.federationHomeOrphaned).toBe(true);
const other = sanitizeUser(detachedRow);
expect('federationHomeOrphaned' in other).toBe(false);
});
it('non-detached self-view reports federationHomeOrphaned false', () => {
const federatedRow = testDb.select().from(schema.users).where(eq(schema.users.id, FEDERATED_ID)).get()!;
const self = sanitizeUser(federatedRow, true);
expect(self.federationHomeOrphaned).toBe(false);
});
it('tombstone (deleted) self-view never exposes federationHomeOrphaned', () => {
testDb.update(schema.users).set({ isDeleted: 1 }).where(eq(schema.users.id, DETACHED_ID)).run();
const deletedRow = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!;
const self = sanitizeUser(deletedRow, true);
expect('federationHomeOrphaned' in self).toBe(false);
});
});
+15 -7
View File
@@ -77,8 +77,9 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
// Federated users (replicas on this instance) don't need currentPassword —
// their home instance already verified the password change, and JWT auth
// proves identity. The homeInstance field comes from the DB, not the request.
if (!user.homeInstance) {
// proves identity. EXCEPTION: detached accounts (federation_home_orphaned=1)
// have no home verifying anything — they follow the LOCAL rule (detach spec §4.4).
if (!user.homeInstance || user.federationHomeOrphaned === 1) {
// Local users must provide current password
if (!currentPassword || typeof currentPassword !== 'string') {
return reply.code(400).send({ error: 'Current password is required', statusCode: 400 });
@@ -120,8 +121,13 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'Username does not match', statusCode: 400 });
}
// Native users must verify password; federated users rely on JWT auth
if (!user.homeInstance) {
// Native users must verify password; non-detached federated users rely on
// JWT auth (their home instance already vouches for them). EXCEPTION:
// detached accounts (federation_home_orphaned = 1) are sovereign local
// accounts with no home verifying anything — they follow the LOCAL rule and
// must supply their local password to self-destruct, mirroring
// change-password (detach spec §4.4).
if (!user.homeInstance || user.federationHomeOrphaned === 1) {
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
@@ -195,9 +201,11 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
// Write-protection: replicated users cannot update durable profile fields.
// These are managed by the home instance via S2S relay.
if (preUpdateUser.homeInstance) {
// Write-protection: replicated users cannot update durable profile fields
// these are managed by the home instance via S2S relay. EXCEPTION: detached
// accounts (federation_home_orphaned = 1) have no home instance anymore and
// manage their profile locally (detach spec §4.4).
if (preUpdateUser.homeInstance && preUpdateUser.federationHomeOrphaned !== 1) {
const hasDurableField = DURABLE_PROFILE_FIELDS.some(f => (request.body as Record<string, unknown>)[f] !== undefined);
if (hasDurableField) {
return reply.code(403).send({ error: 'Profile fields are managed by your home instance', statusCode: 403 });
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { signRequest } from './federationAuth.js';
const PEER = { origin: 'https://orbit.test', hmacSecret: 'b'.repeat(64) };
function signedResponse(bodyObj: object): Response {
const body = JSON.stringify(bodyObj);
const ts = Date.now();
const nonce = 'resp-nonce';
const sig = signRequest(body, PEER.hmacSecret, ts, nonce);
return new Response(body, {
status: 200,
headers: {
'x-federation-signature': `sha256=${sig}`,
'x-federation-timestamp': String(ts),
'x-federation-nonce': nonce,
},
});
}
afterEach(() => vi.unstubAllGlobals());
describe('verifyAttachProofWithPeer', () => {
it('returns the verified identity for a signed valid:true response', async () => {
vi.stubGlobal('fetch', vi.fn(async () => signedResponse({ valid: true, homeUserId: 'h1', username: 'youruser' })));
const { verifyAttachProofWithPeer } = await import('./federationAttach.js');
const result = await verifyAttachProofWithPeer(PEER, 'a'.repeat(64));
expect(result).toEqual({ valid: true, homeUserId: 'h1', username: 'youruser' });
const call = (fetch as ReturnType<typeof vi.fn>).mock.calls[0]!;
expect(call[0]).toBe('https://orbit.test/api/federation/verify-attach-proof');
expect((call[1] as RequestInit).headers).toHaveProperty('X-Federation-Signature');
});
it('treats an UNSIGNED response as valid:false (never trust unauthenticated bodies)', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ valid: true, homeUserId: 'h1', username: 'youruser' }), { status: 200 })));
const { verifyAttachProofWithPeer } = await import('./federationAttach.js');
expect(await verifyAttachProofWithPeer(PEER, 'a'.repeat(64))).toEqual({ valid: false });
});
it('treats a PRESENT-but-INVALID signature as valid:false (signature must verify against the peer secret)', async () => {
// A 200 response with a well-formed signature header that was computed with
// the WRONG secret — it must NOT verify against the peer's real secret. This
// hardens the core "never trust unauthenticated bodies" gate: a malicious or
// misconfigured peer that returns valid:true with a bogus signature is rejected.
const wrongSignedResponse = (bodyObj: object): Response => {
const body = JSON.stringify(bodyObj);
const ts = Date.now();
const nonce = 'resp-nonce';
const sig = signRequest(body, 'c'.repeat(64) /* wrong secret */, ts, nonce);
return new Response(body, {
status: 200,
headers: {
'x-federation-signature': `sha256=${sig}`,
'x-federation-timestamp': String(ts),
'x-federation-nonce': nonce,
},
});
};
vi.stubGlobal('fetch', vi.fn(async () => wrongSignedResponse({ valid: true, homeUserId: 'h1', username: 'youruser' })));
const { verifyAttachProofWithPeer } = await import('./federationAttach.js');
expect(await verifyAttachProofWithPeer(PEER, 'a'.repeat(64))).toEqual({ valid: false });
});
it('network error → valid:false', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNREFUSED'); }));
const { verifyAttachProofWithPeer } = await import('./federationAttach.js');
expect(await verifyAttachProofWithPeer(PEER, 'a'.repeat(64))).toEqual({ valid: false });
});
});
describe('fetchHomeProfileByHomeId', () => {
it('returns the profile for found:true', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
found: true,
user: { homeUserId: 'h1', username: 'youruser', profile: { displayName: 'J', avatar: 'a.webp', avatarColor: 'coral', banner: null, bio: null } },
}), { status: 200 })));
const { fetchHomeProfileByHomeId } = await import('./federationAttach.js');
const result = await fetchHomeProfileByHomeId(PEER, 'h1');
expect(result?.username).toBe('youruser');
expect(result?.profile.avatar).toBe('a.webp');
});
it('found:false → null', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ found: false }), { status: 200 })));
const { fetchHomeProfileByHomeId } = await import('./federationAttach.js');
expect(await fetchHomeProfileByHomeId(PEER, 'h1')).toBeNull();
});
it('network error → null', async () => {
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNREFUSED'); }));
const { fetchHomeProfileByHomeId } = await import('./federationAttach.js');
expect(await fetchHomeProfileByHomeId(PEER, 'h1')).toBeNull();
});
});
@@ -0,0 +1,109 @@
import { buildFederationHeaders, verifySignature, getOurOrigin } from './federationAuth.js';
export interface PeerForAttach {
origin: string;
hmacSecret: string;
}
/**
* Verify a one-time attach-proof token with the detached account's home
* instance (re-attach spec §3.1). The response body is only trusted when its
* HMAC signature verifies against the shared peer secret — mirrors
* fetchPeerEpoch. Any failure (network, bad status, bad signature, malformed
* body) is treated as { valid: false }: re-attach fails closed.
*/
export async function verifyAttachProofWithPeer(
peer: PeerForAttach,
token: string,
): Promise<{ valid: true; homeUserId: string; username: string } | { valid: false }> {
const body = JSON.stringify({ token });
const headers = buildFederationHeaders(body, peer.hmacSecret, getOurOrigin());
let res: Response;
try {
res = await fetch(`${peer.origin}/api/federation/verify-attach-proof`, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(10_000),
});
} catch {
return { valid: false };
}
if (!res.ok) return { valid: false };
let text: string;
try {
text = await res.text();
} catch {
return { valid: false };
}
// Verify the response signature with the SAME secret and arg order the peer's
// handler signed it with (buildFederationHeaders). A mismatch means we must
// not trust the body — never trust an unauthenticated body (spec §2).
const sig = (res.headers.get('x-federation-signature') ?? '').replace(/^sha256=/, '');
const ts = Number(res.headers.get('x-federation-timestamp'));
const nonce = res.headers.get('x-federation-nonce');
if (!sig || !Number.isFinite(ts) || !verifySignature(text, sig, peer.hmacSecret, ts, nonce)) {
return { valid: false };
}
try {
const parsed = JSON.parse(text) as { valid?: boolean; homeUserId?: string; username?: string };
if (parsed.valid === true && typeof parsed.homeUserId === 'string' && typeof parsed.username === 'string') {
return { valid: true, homeUserId: parsed.homeUserId, username: parsed.username };
}
} catch {
// fall through
}
return { valid: false };
}
/**
* Fetch the home instance's current profile for a native user via the
* existing POST /api/federation/users/by-home-id endpoint. Best-effort:
* null on any failure — re-attach proceeds without an initial profile
* (the next profile_update relay fills it).
*/
export async function fetchHomeProfileByHomeId(
peer: PeerForAttach,
homeUserId: string,
): Promise<{ username: string; profile: { displayName: string | null; avatar: string | null; avatarColor: string | null; banner: string | null; bio: string | null } } | null> {
const body = JSON.stringify({ homeUserId });
const headers = buildFederationHeaders(body, peer.hmacSecret, getOurOrigin());
let res: Response;
try {
res = await fetch(`${peer.origin}/api/federation/users/by-home-id`, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(10_000),
});
} catch {
return null;
}
if (!res.ok) return null;
try {
const parsed = await res.json() as {
found?: boolean;
user?: { username?: string; profile?: { displayName?: string | null; avatar?: string | null; avatarColor?: string | null; banner?: string | null; bio?: string | null } };
};
if (!parsed.found || !parsed.user || typeof parsed.user.username !== 'string' || !parsed.user.profile) return null;
const p = parsed.user.profile;
return {
username: parsed.user.username,
profile: {
displayName: p.displayName ?? null,
avatar: p.avatar ?? null,
avatarColor: p.avatarColor ?? null,
banner: p.banner ?? null,
bio: p.bio ?? null,
},
};
} catch {
return null;
}
}
@@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
let _sf = 1;
vi.mock('./snowflake.js', () => ({
generateSnowflake: () => String(_sf++),
setWorkerId: vi.fn(),
}));
vi.mock('./federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('./federationAuth.js')>();
return { ...actual, getOurOrigin: () => 'https://home.test' };
});
// federationOutbox.ts imports extractDomain from routes/federation.js, which in
// turn imports connectionManager/ws — stub the minimal surface so the module
// graph loads at test time. getDmParticipants doesn't touch any of these.
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn(),
sendToSpace: vi.fn(),
sendToDmMembers: vi.fn(),
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
evictFederatedCallsForHost: vi.fn(),
federatedCalls: new Map(),
isUserOnline: vi.fn(),
lateBindFederatedCall: vi.fn(),
},
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sql = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
for (const stmt of sql.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
_sf = 1;
});
describe('getDmParticipants — deleted members', () => {
it('ships deleted:true and NO username for tombstoned members', async () => {
testDb.insert(schema.users).values([
{ id: 'alice', username: 'alice', passwordHash: 'h', homeInstance: null, createdAt: 1 },
{ id: 'ghost', username: '!deleted:ghost', passwordHash: 'h', homeInstance: null, isDeleted: 1, createdAt: 1 },
]).run();
testDb.insert(schema.dmChannels).values({ id: 'ch1', federatedId: 'fed-ch1', createdAt: 1 }).run();
testDb.insert(schema.dmMembers).values([
{ dmChannelId: 'ch1', userId: 'alice', closed: 0 },
{ dmChannelId: 'ch1', userId: 'ghost', closed: 0 },
]).run();
const { getDmParticipants } = await import('./federationOutbox.js');
const participants = getDmParticipants('ch1');
const ghost = participants.find(p => p.homeUserId === 'ghost')!;
expect(ghost.profile?.deleted).toBe(true);
expect(ghost.profile?.username ?? null).toBeNull();
expect(ghost.profile?.displayName ?? null).toBeNull();
const alice = participants.find(p => p.homeUserId === 'alice')!;
expect(alice.profile?.deleted ?? undefined).toBeUndefined();
expect(alice.profile?.username).toBe('alice');
});
});
+25 -13
View File
@@ -364,6 +364,7 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa
avatar: schema.users.avatar,
avatarColor: schema.users.avatarColor,
status: schema.users.status,
isDeleted: schema.users.isDeleted,
})
.from(schema.dmMembers)
.innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
@@ -372,19 +373,30 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa
const domainOrigin = getOurOrigin();
return members.map(m => ({
homeUserId: m.homeUserId || m.id,
homeInstance: m.homeInstance || domainOrigin,
profile: {
username: m.username ?? null,
displayName: m.displayName ?? null,
avatar: m.avatar ?? null,
avatarColor: m.avatarColor ?? null,
// Only carry presence for native participants — replicated stubs hold
// stale status owned by their home; emitting it would flap remote UIs.
status: !m.homeInstance ? (m.status as 'online' | 'idle' | 'dnd' | 'offline' | null) : null,
},
}));
return members.map(m => {
if (m.isDeleted) {
// Tombstoned member: ship the identity for attribution but no profile
// data — the internal '!deleted:<id>' marker never leaves this instance.
return {
homeUserId: m.homeUserId || m.id,
homeInstance: m.homeInstance || domainOrigin,
profile: { deleted: true },
};
}
return {
homeUserId: m.homeUserId || m.id,
homeInstance: m.homeInstance || domainOrigin,
profile: {
username: m.username ?? null,
displayName: m.displayName ?? null,
avatar: m.avatar ?? null,
avatarColor: m.avatarColor ?? null,
// Only carry presence for native participants — replicated stubs hold
// stale status owned by their home; emitting it would flap remote UIs.
status: !m.homeInstance ? (m.status as 'online' | 'idle' | 'dnd' | 'offline' | null) : null,
},
};
});
}
/**
@@ -234,4 +234,66 @@ describe('federationRecovery primitives', () => {
expect(spy).not.toHaveBeenCalled(); // no trusted baseline → cannot detect a change
});
// ── detectResetForPeer (per-peer unit) ─────────────────────────────────────
// The shared single-peer probe fired the instant a peer crosses into
// needs_attention via the auth-failure path (event-driven, in federationWorker)
// and by the worker-startup sweep — collapsing reset-detection latency from a
// 15-minute health-check cycle to one /instance/info GET.
it('detectResetForPeer returns true and journals the reset when the probed epoch differs', async () => {
seedNeedsAttention('peer-evt', 'auth_failures', 'E0');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"instanceId":"E1"}', { status: 200 }));
const { detectResetForPeer } = await import('./federationRecovery.js');
const detected = await detectResetForPeer({ id: 'peer-evt', origin: 'https://peer.example', peerInstanceId: 'E0' });
expect(detected).toBe(true);
const row = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.id, 'peer-evt')).get()!;
expect(row.status).toBe('needs_attention'); // detection only — never flipped to active
expect(row.needsAttentionReason).toBe('peer_reset_detected');
expect(row.observedPeerInstanceId).toBe('E1');
expect(row.peerInstanceId).toBe('E0'); // trusted baseline untouched
expect(row.hmacSecret).toBe('secret'); // never rekeyed
expect(testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, 'https://peer.example')).get()!.resolvedAt).toBeNull();
expect(onPeerActivated).not.toHaveBeenCalled();
});
it('detectResetForPeer returns false and is a no-op when the probed epoch matches the baseline', async () => {
seedNeedsAttention('peer-evt-same', 'auth_failures', 'E0');
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"instanceId":"E0"}', { status: 200 }));
const { detectResetForPeer } = await import('./federationRecovery.js');
const detected = await detectResetForPeer({ id: 'peer-evt-same', origin: 'https://peer.example', peerInstanceId: 'E0' });
expect(detected).toBe(false);
const row = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.id, 'peer-evt-same')).get()!;
expect(row.needsAttentionReason).toBe('auth_failures'); // unchanged
expect(testDb.select().from(schema.federationResetEvents).all()).toHaveLength(0);
});
it('detectResetForPeer returns false WITHOUT probing when the baseline is null', async () => {
const spy = vi.spyOn(globalThis, 'fetch');
const { detectResetForPeer } = await import('./federationRecovery.js');
const detected = await detectResetForPeer({ id: 'peer-evt-nobase', origin: 'https://peer.example', peerInstanceId: null });
expect(detected).toBe(false);
expect(spy).not.toHaveBeenCalled(); // no baseline → cannot detect a change, no wasted GET
});
it('detectResetForPeer returns false when the peer is unreachable (no false reset)', async () => {
seedNeedsAttention('peer-evt-down', 'auth_failures', 'E0');
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ENOTFOUND'));
const { detectResetForPeer } = await import('./federationRecovery.js');
const detected = await detectResetForPeer({ id: 'peer-evt-down', origin: 'https://peer.example', peerInstanceId: 'E0' });
expect(detected).toBe(false);
expect(testDb.select().from(schema.federationResetEvents).all()).toHaveLength(0);
expect(testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.id, 'peer-evt-down')).get()!.needsAttentionReason).toBe('auth_failures');
});
});
@@ -153,12 +153,44 @@ export async function detectResetOnNeedsAttentionPeers(signal?: AbortSignal): Pr
.all();
for (const peer of peers) {
const result = await probePeerReachable(peer.origin, signal);
// Detection fires ONLY on a reachable peer advertising a non-null epoch that
// differs from the trusted baseline. Everything else (unreachable, unknown
// epoch, or a matching epoch) is a no-op — no recover-to-active from here.
if (result.reachable && result.instanceId && peer.peerInstanceId && result.instanceId !== peer.peerInstanceId) {
markPeerReset(peer.id, peer.origin, peer.peerInstanceId, result.instanceId);
}
await detectResetForPeer(peer, signal);
}
}
/**
* Detection-only epoch probe for a SINGLE `needs_attention` peer — the per-peer
* unit shared by the health-tick sweep (`detectResetOnNeedsAttentionPeers`), the
* worker-startup sweep, and the event-driven probe fired the instant a peer
* crosses into `needs_attention` via the auth-failure path (`federationWorker`).
*
* The auth-failure transition is the case this exists for: a reset peer whose
* HTTP is up but whose HMAC is desynced accrues 401/403s and lands in
* `needs_attention` WITHOUT ever passing through `unreachable`, so the 5-second
* unreachable-only recovery probe never sees it. Before this probe fired at the
* transition, such a peer waited up to a full 15-minute health-check cycle before
* its reset was detected and the admin surface offered "Re-peer & heal". Probing
* at the transition collapses that latency to a single `/instance/info` GET.
*
* Detection fires ONLY on a reachable peer advertising a non-null epoch that
* differs from the trusted baseline (`peer_instance_id`). Everything else —
* unreachable, unknown/absent epoch, a matching epoch, or a null baseline (which
* has nothing to compare against) — is a no-op. NEVER flips a peer to `active`: a
* `needs_attention` peer's HMAC secret is desynced, so a match/unknown means
* "still broken, still needs an admin," not "recovered." On a confirmed epoch
* mismatch it calls `markPeerReset` (snapshot + journal + admin notify) and
* nothing else — the baseline and `hmac_secret` are left untouched.
*
* @returns `true` if a reset was detected and `markPeerReset` was called.
*/
export async function detectResetForPeer(
peer: { id: string; origin: string; peerInstanceId: string | null },
signal?: AbortSignal,
): Promise<boolean> {
if (!peer.peerInstanceId) return false; // no baseline → nothing to compare against
const result = await probePeerReachable(peer.origin, signal);
if (result.reachable && result.instanceId && result.instanceId !== peer.peerInstanceId) {
markPeerReset(peer.id, peer.origin, peer.peerInstanceId, result.instanceId);
return true;
}
return false;
}
@@ -149,6 +149,47 @@ describe('markPeerReset — detection-only reset routing', () => {
expect(row.resolvedAt).toBeNull();
});
it('re-detected reset clears a stale acknowledgedAt (dismissed card re-surfaces)', async () => {
seedPeer();
seedUser('stub-1', { passwordHash: STUB });
seedUser('real-1', { passwordHash: '$2b$10$realbcrypthash' });
const { markPeerReset } = await import('./federationReset.js');
// First reset detected, then the admin dismisses (acknowledges) the card.
markPeerReset('peer-1', ORIGIN, 'E0', 'E1');
testDb.update(schema.federationResetEvents)
.set({ acknowledgedAt: Date.now() })
.where(eq(schema.federationResetEvents.origin, ORIGIN)).run();
expect(testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, ORIGIN)).get()!.acknowledgedAt).not.toBeNull();
// The peer resets AGAIN before the first was resolved — a fresh batch is
// detached and needs fresh admin attention, so the dismissal must clear.
markPeerReset('peer-1', ORIGIN, 'E0', 'E2');
expect(testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, ORIGIN)).get()!.acknowledgedAt).toBeNull();
});
it('a resolved+acknowledged prior reset is re-armed (acknowledgedAt cleared) on a new reset', async () => {
seedPeer();
seedUser('stub-1', { passwordHash: STUB });
const { markPeerReset } = await import('./federationReset.js');
markPeerReset('peer-1', ORIGIN, 'E0', 'E1');
// Simulate the heal resolving the first reset AND the admin dismissing it.
testDb.update(schema.federationResetEvents)
.set({ resolvedAt: Date.now(), newEpoch: 'E1', acknowledgedAt: Date.now() })
.where(eq(schema.federationResetEvents.origin, ORIGIN)).run();
// Brand-new reset lands (fresh-journal / onConflictDoUpdate branch).
markPeerReset('peer-1', ORIGIN, 'E1', 'E2');
const row = testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, ORIGIN)).get()!;
expect(row.resolvedAt).toBeNull();
expect(row.acknowledgedAt).toBeNull();
});
it('matches home_instance stored as a full URL (defensive format match)', async () => {
seedPeer();
// Legacy straggler stored with the https:// prefix rather than bare domain.
@@ -187,7 +228,7 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
.where(eq(schema.users.id, id)).run();
}
it('genuine reset: soft-tombstones flagged stubs, quarantines (freeze+rename) real accounts, resolves journal', async () => {
it('genuine reset: soft-tombstones flagged stubs, detaches real accounts (flag only, name kept), resolves journal', async () => {
seedPeer();
seedJournal('E0');
// A local native user to be the friendship counterpart.
@@ -202,7 +243,7 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
userId: 'stub-1', friendId: 'local-1', createdAt: Date.now(),
}).run();
// Flagged REAL federated account (real bcrypt), no owned space — must survive
// (never deleted) but be quarantined: frozen + renamed to free the handle.
// (never deleted) and be DETACHED: flagged orphaned, username preserved.
seedUser('real-1', { passwordHash: '$2b$10$realbcrypthash' });
flag('real-1');
@@ -219,11 +260,11 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
// Heal flag cleared on the healed stub.
expect(stub.federationHealPending).toBe(0);
// Real account NEVER deleted (content preserved) but quarantined: frozen,
// handle freed via rename, heal flag cleared (Phase 2 §6.3b).
// Real account NEVER deleted (content preserved) and DETACHED: orphaned flag
// set, username PRESERVED, heal flag cleared (detach spec §4.2).
const real = testDb.select().from(schema.users).where(eq(schema.users.id, 'real-1')).get()!;
expect(real.isDeleted).toBe(0);
expect(real.username).toBe('!orphaned:real-1@peer.example');
expect(real.username).toBe('real-1@peer.example'); // unchanged — no rename
expect(real.federationHomeOrphaned).toBe(1);
expect(real.federationHealPending).toBe(0);
@@ -308,7 +349,7 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
});
});
describe('healResetIncarnation — real-account quarantine (Phase 2)', () => {
describe('healResetIncarnation — real-account detach (Phase 2)', () => {
const QORIGIN = 'orbit.ddns.net';
let uidCounter = 0;
@@ -349,7 +390,7 @@ describe('healResetIncarnation — real-account quarantine (Phase 2)', () => {
}).run();
}
it('renames + freezes a flagged real account with NO owned spaces', async () => {
it('detaches a flagged real account with NO owned spaces (flag only, username kept)', async () => {
seedJournal({ origin: QORIGIN, deadEpoch: 'E0' });
const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 });
@@ -357,32 +398,47 @@ describe('healResetIncarnation — real-account quarantine (Phase 2)', () => {
healResetIncarnation(QORIGIN, 'E1', 'initiate_accepted');
const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!;
expect(row.username).toBe(`!orphaned:${uid}@orbit.ddns.net`); // handle freed
expect(row.federationHomeOrphaned).toBe(1); // frozen
expect(row.federationHealPending).toBe(0); // processed
expect(row.isDeleted).toBe(0); // NOT deleted (content preserved)
expect(row.username).toBe('carol@orbit.ddns.net'); // username PRESERVED — no rename
expect(row.federationHomeOrphaned).toBe(1); // detached
expect(row.federationHealPending).toBe(0); // processed
expect(row.isDeleted).toBe(0); // NOT deleted (content preserved)
});
it('freezes but does NOT rename a flagged real account that OWNS a space; surfaces it', async () => {
it('detaches a space-OWNER identically to a non-owner (flag set, username kept)', async () => {
seedJournal({ origin: QORIGIN, deadEpoch: 'E0' });
const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'dave@orbit.ddns.net', healPending: 1 });
seedSpace({ ownerId: uid, name: 'Dave HQ' }); // owns a space
seedSpace({ ownerId: uid, name: 'Dave HQ' }); // owns a space — no special case
const { healResetIncarnation } = await import('./federationReset.js');
healResetIncarnation(QORIGIN, 'E1', 'initiate_accepted');
const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!;
expect(row.username).toBe('dave@orbit.ddns.net'); // NOT renamed (owner)
expect(row.federationHomeOrphaned).toBe(1); // frozen
expect(row.username).toBe('dave@orbit.ddns.net'); // username PRESERVED (owner treated same as non-owner)
expect(row.federationHomeOrphaned).toBe(1); // detached
expect(row.federationHealPending).toBe(0); // processed
expect(row.isDeleted).toBe(0);
// journal orphaned_account_count reflects the frozen set (1)
// journal orphaned_account_count reflects the detached set (1)
const j = testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, QORIGIN)).get()!;
expect(j.orphanedAccountCount).toBe(1);
});
it('false-positive branch (same incarnation) does NOT quarantine real accounts', async () => {
it('detaches ALL flagged real accounts and quarantineOrphanedAccounts returns the count', async () => {
const uid1 = seedRealAccount({ homeInstance: QORIGIN, username: 'erin@orbit.ddns.net', healPending: 1 });
const uid2 = seedRealAccount({ homeInstance: QORIGIN, username: 'frank@orbit.ddns.net', healPending: 1 });
const { quarantineOrphanedAccounts } = await import('./federationReset.js');
const count = quarantineOrphanedAccounts(QORIGIN);
expect(count).toBe(2); // returns the number of accounts detached
for (const uid of [uid1, uid2]) {
const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!;
expect(row.federationHomeOrphaned).toBe(1);
expect(row.federationHealPending).toBe(0);
}
});
it('false-positive branch (same incarnation) does NOT detach real accounts', async () => {
seedJournal({ origin: QORIGIN, deadEpoch: 'E0' });
const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 });
@@ -391,7 +447,7 @@ describe('healResetIncarnation — real-account quarantine (Phase 2)', () => {
const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!;
expect(row.username).toBe('carol@orbit.ddns.net'); // untouched
expect(row.federationHomeOrphaned ?? 0).toBe(0); // NOT frozen
expect(row.federationHomeOrphaned ?? 0).toBe(0); // NOT detached
expect(row.federationHealPending).toBe(0); // flags cleared (false-alarm path)
});
});
+33 -43
View File
@@ -113,9 +113,12 @@ export function markPeerReset(peerId: string, origin: string, deadEpoch: string,
if (existing && existing.resolvedAt === null) {
// Double-reset: keep the ORIGINAL dead_epoch + detected_at (the
// incarnation already snapshotted), refresh counts only. Never overwrite
// dead_epoch on an unresolved row.
// dead_epoch on an unresolved row. Clear `acknowledged_at`: a re-detected
// reset is a fresh event that detached a new batch and needs fresh admin
// attention — a stale dismissal must not keep the disposition card hidden
// (detach spec §4.6).
tx.update(schema.federationResetEvents)
.set({ stubCount, orphanedAccountCount })
.set({ stubCount, orphanedAccountCount, acknowledgedAt: null })
.where(eq(schema.federationResetEvents.origin, origin))
.run();
} else {
@@ -140,6 +143,10 @@ export function markPeerReset(peerId: string, origin: string, deadEpoch: string,
resolvedAt: null,
stubCount,
orphanedAccountCount,
// Re-arm the admin surface: a fresh reset on a previously
// resolved+dismissed origin must clear the old dismissal so the
// new detached batch's disposition card re-surfaces (detach §4.6).
acknowledgedAt: null,
},
})
.run();
@@ -290,10 +297,11 @@ export function healResetIncarnation(origin: string, newEpoch: string, reason: P
.run();
}
// Quarantine the flagged REAL accounts (freeze + free-handle / surface). §6.3b.
// Detach the flagged REAL accounts (flag-only: sovereign local accounts, no
// freeze, no rename — see quarantineOrphanedAccounts docstring). §6.3b.
const orphanedCount = quarantineOrphanedAccounts(origin);
// Resolve the journal and refresh the orphaned-account count to the frozen set.
// Resolve the journal and refresh the orphaned-account count to the detached set.
db.update(schema.federationResetEvents)
.set({ newEpoch, resolvedAt: Date.now(), orphanedAccountCount: orphanedCount })
.where(eq(schema.federationResetEvents.origin, origin))
@@ -301,35 +309,28 @@ export function healResetIncarnation(origin: string, newEpoch: string, reason: P
}
/**
* Post-heal quarantine of the dead incarnation's REAL federated accounts (design
* §6.3b). Called from `healResetIncarnation`'s genuine-reset branch AFTER the stub
* soft-tombstone loop. Real accounts carry non-re-syncable local content and are
* NEVER auto-deleted — they are FROZEN and surfaced to the admin.
* Post-heal DETACH of the dead incarnation's REAL federated accounts (design
* §6.3b, revised by the 2026-07-02 detach spec). Called from
* `healResetIncarnation`'s genuine-reset branch AFTER the stub soft-tombstone
* loop. Real accounts carry non-re-syncable local content and are NEVER
* auto-deleted — and, unlike the original quarantine, they are NOT frozen or
* renamed either.
*
* For every flagged real account (`federation_heal_pending = 1`,
* `passwordHash != REPLICATED_STUB_SENTINEL`, `isDeleted = 0`) for this origin:
* - Set `federation_home_orphaned = 1` (FREEZE). This is universal — it is what
* closes the post-re-peer hijack (the Task-2 epoch guard passes once the
* baseline is updated to the new epoch, so the freeze is the only remaining
* barrier). The direct-login freeze (auth.ts) enforces it.
* - If the account OWNS local spaces: do NOT rename it. Space ownership must be
* resolved by a human (admin Remove → transfer/delete first). Renaming an owner
* would orphan the ownerId reference into a `!orphaned:` handle, confusing to
* members. It stays frozen + surfaced.
* - Otherwise: rename `username → !orphaned:{uid}@{domain}` to FREE the handle so
* a returning same-name user re-registers into a clean fresh account instead of
* colliding (defends BOTH login uniqueness AND the registration tier-2
* stub-resolution upgrade path — see the plan's collision analysis).
* - Clear `federation_heal_pending` (processed).
* `federation_home_orphaned = 1` marks the account as DETACHED: it operates as
* a sovereign local account from here on. The owner keeps logging in with the
* local password (auth.ts skips only the self-heal path); every S2S surface
* keyed by the home domain excludes detached rows, so the domain's new
* incarnation can never capture, mutate, re-bind, or delete the account.
*
* Content (space messages, memberships, reactions) is preserved in all cases.
* Usernames are preserved (first-come-first-served on this instance) and there
* is no space-owner special case — owners simply keep managing their spaces.
* Content is preserved in all cases. No broadcast: nothing visible changes.
*
* @returns the number of accounts quarantined (frozen) — used to refresh the
* journal's `orphaned_account_count`.
* @returns the number of accounts detached — used to refresh the journal's
* `orphaned_account_count`.
*/
export function quarantineOrphanedAccounts(origin: string): number {
const db = getDb();
const domain = extractDomain(origin);
const accounts = db
.select({ id: schema.users.id })
@@ -342,22 +343,11 @@ export function quarantineOrphanedAccounts(origin: string): number {
))
.all();
for (const acct of accounts) {
const ownsSpace = db
.select({ id: schema.spaces.id })
.from(schema.spaces)
.where(eq(schema.spaces.ownerId, acct.id))
.get();
const updates: Record<string, string | number> = {
federationHomeOrphaned: 1,
federationHealPending: 0,
};
if (!ownsSpace) {
// Free the handle only for non-owners.
updates.username = `!orphaned:${acct.id}@${domain}`;
}
db.update(schema.users).set(updates).where(eq(schema.users.id, acct.id)).run();
if (accounts.length > 0) {
db.update(schema.users)
.set({ federationHomeOrphaned: 1, federationHealPending: 0 })
.where(inArray(schema.users.id, accounts.map((a) => a.id)))
.run();
}
return accounts.length;
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
@@ -6,6 +7,12 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as schema from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { setWorkerId } from './snowflake.js';
// resolveOrCreateReplicatedUser (tested below) mints a snowflake for the new
// stub row; the register route mints one for fresh accounts. Both throw if the
// worker id is never initialised. Set it once at module load.
setWorkerId(3);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
@@ -159,3 +166,102 @@ describe('backfillStubUsernamesForPeer', () => {
expect(lookupCalls).toEqual([]); // gated on peer status
});
});
describe('detached account: registration 409 + suffixed stub creation (detach spec §4.3.5)', () => {
// A detached account is a REAL federated user (homeInstance set, real bcrypt-style
// hash — NOT the '!federation-replicated' sentinel) whose home domain was reset
// and which has therefore been detached (federationHomeOrphaned = 1). Task 3's
// tier-2 exclusion makes it un-matchable via the domain+username heuristic, so a
// new same-name identity on the reset domain can neither register OVER the account
// (§4.3.5 → username-uniqueness 409) nor be BOUND to it by relay stub resolution
// (§4.3.4 → the collision guard suffixes a fresh stub instead).
const detachedId = 'detached-1';
const detachedUsername = 'alice@peer.example';
const originalHash = '$2b$10$abcdefghijklmnopqrstuv'; // real bcrypt-like hash, not a stub
function seedDetachedAccount(): void {
testDb.insert(schema.users).values({
id: detachedId,
username: detachedUsername,
displayName: null,
passwordHash: originalHash,
status: 'offline',
isAdmin: 0,
homeInstance: 'peer.example',
homeUserId: 'old-home-uid',
federationHomeOrphaned: 1,
createdAt: Date.now(),
}).run();
}
function seedFederatedRegistrationOpen(): void {
// applyMigrations creates instance_settings but does not seed the id=1 row
// (production does so via migrate.ts:ensureDefaults). The register route reads
// federatedRegistrationOpen from it; without the row the federated path 403s.
testDb.insert(schema.instanceSettings).values({
id: 1,
registrationOpen: 1,
federatedRegistrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
async function buildAuthApp(): Promise<FastifyInstance> {
const { authRoutes } = await import('../routes/auth.js');
const app = Fastify({ logger: false });
await app.register(authRoutes);
await app.ready();
return app;
}
it('federated registration of a same-name user on the reset domain returns 409, detached row untouched', async () => {
seedDetachedAccount();
seedFederatedRegistrationOpen();
const app = await buildAuthApp();
try {
// Fresh homeUserId + the reset domain replaying 'alice' as the handle. Tier-2
// no longer matches the detached row → the stub-upgrade branch is skipped →
// the plain username-uniqueness check on 'alice@peer.example' fires → 409.
const res = await app.inject({
method: 'POST',
url: '/api/auth/register',
payload: {
username: 'alice@peer.example',
password: 'password123',
homeInstance: 'peer.example',
homeUserId: 'new-home-uid',
},
});
expect(res.statusCode).toBe(409);
} finally {
await app.close();
}
const row = testDb.select().from(schema.users).where(eq(schema.users.id, detachedId)).get();
expect(row?.homeUserId).toBe('old-home-uid'); // no backfill
expect(row?.passwordHash).toBe(originalHash); // no credential upgrade/re-hash
expect(row?.username).toBe(detachedUsername); // handle not rebound
expect(row?.federationHomeOrphaned).toBe(1); // still sovereign
});
it('relay stub resolution creates a SUFFIXED stub instead of binding to the detached row', async () => {
seedDetachedAccount();
const { resolveOrCreateReplicatedUser } = await import('../routes/federation.js');
// Fresh homeUserId → tier-1 miss; detached row is tier-2-excluded → no match.
// The collision guard finds 'alice@peer.example' already taken and suffixes.
const stub = resolveOrCreateReplicatedUser('new-home-uid', 'peer.example', testDb, { username: 'alice' });
expect(stub).not.toBeNull();
expect(stub!.id).not.toBe(detachedId);
expect(stub!.username).not.toBe(detachedUsername);
expect(stub!.passwordHash).toBe('!federation-replicated');
expect(stub!.homeUserId).toBe('new-home-uid');
// The detached account is left entirely untouched.
const row = testDb.select().from(schema.users).where(eq(schema.users.id, detachedId)).get();
expect(row?.username).toBe(detachedUsername);
expect(row?.homeUserId).toBe('old-home-uid');
expect(row?.passwordHash).toBe(originalHash);
expect(row?.federationHomeOrphaned).toBe(1);
});
});
+48 -3
View File
@@ -12,8 +12,8 @@ import { connectionManager } from '../ws/handler.js';
import { generateThumbnail } from './thumbnail.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent } from '@backspace/shared';
import { startupBootstrapSync, onPeerDeactivated } from './federationPeerActivation.js';
import { probePeerReachable, recoverOrDetectReset, detectResetOnNeedsAttentionPeers } from './federationRecovery.js';
import { backfillReplicatedProfileAssets } from '../routes/federation.js';
import { probePeerReachable, recoverOrDetectReset, detectResetOnNeedsAttentionPeers, detectResetForPeer } from './federationRecovery.js';
import { backfillReplicatedProfileAssets, sweepDeadIncarnationArtifacts, reconcileDriftedDmFederatedIds } from '../routes/federation.js';
import { invokePermanentFailureCallback } from './federationRollback.js';
import { refreshPeerEpochs, getInstanceId } from './federationEpoch.js';
import fs from 'node:fs';
@@ -347,7 +347,10 @@ export async function processOutboxTick(): Promise<void> {
// needs_attention; bounded retry (AUTH_FAILURE_THRESHOLD) rides out
// transient clock skew and rotation-grace edge races.
const currentRow = db
.select({ consecutiveAuthFailures: schema.federationPeers.consecutiveAuthFailures })
.select({
consecutiveAuthFailures: schema.federationPeers.consecutiveAuthFailures,
peerInstanceId: schema.federationPeers.peerInstanceId,
})
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
@@ -369,6 +372,23 @@ export async function processOutboxTick(): Promise<void> {
`[federation-worker] Peer ${peerOrigin} transitioned to needs_attention after ${decision.newAuthFailures} consecutive ${response.status} responses`,
);
// Event-driven reset detection: a genuinely reset peer reaches
// needs_attention via THIS auth-failure path (HMAC desynced by the new
// incarnation) without ever passing through `unreachable`, so the
// 5-second unreachable-only recovery probe never sees it. Probe its
// epoch NOW — the instant the connection is declared broken — instead of
// waiting up to a full 15-minute health-check cycle for the backstop
// sweep. Detection-only (markPeerReset); never flips back to active.
// Fire-and-forget: a probe failure is a benign no-op the 15-min tick
// retries, and it must not stall the outbox loop.
detectResetForPeer({
id: peerId,
origin: peerOrigin,
peerInstanceId: currentRow?.peerInstanceId ?? null,
}).catch(err =>
console.error('[federation-worker] reset probe on auth-threshold transition failed:', err)
);
const contextMap = buildContextMapForPeer(db, peerId);
if (contextMap.size > 0) {
pushPeerRejectedEvent(
@@ -1275,6 +1295,31 @@ export function startFederationWorkers(): void {
console.error('[federation-worker] Startup bootstrap sync error:', err);
});
// Startup reset-detection sweep: probe every peer already parked in
// `needs_attention` for an epoch change. This catches a peer that was reset
// while this instance was down (so no live transition fired) AND any peer that
// crossed into needs_attention before this build shipped the event-driven
// probe — surfacing "Re-peer & heal" immediately on boot instead of on the
// next 15-minute health-check cycle. Best-effort, detection-only.
detectResetOnNeedsAttentionPeers().catch((err) => {
console.error('[federation-worker] Startup reset-detection sweep error:', err);
});
// Remove dead-incarnation artifacts left by pre-fix initial syncs (spec §3.4).
try {
sweepDeadIncarnationArtifacts();
} catch (err) {
console.error('[federation-worker] Dead-incarnation sweep error:', err);
}
// Heal any 1-on-1 DM channels whose federatedId drifted from their members'
// current identities (reattach-dm-reconcile spec §3.3).
try {
reconcileDriftedDmFederatedIds();
} catch (err) {
console.error('[federation-worker] DM federatedId reconciliation error:', err);
}
// Backfill any replicated user avatars/banners still stored as absolute URLs
// (legacy data from before file replication, or rows whose home was offline
// on a previous attempt). Best-effort and idempotent — safe to re-run.
+6 -1
View File
@@ -54,6 +54,11 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = fal
homeInstance: row.homeInstance ?? null,
homeUserId: row.homeUserId ?? null,
replicatedInstances,
...(isSelf ? { showActivity: row.showActivity !== 0 } : {}),
...(isSelf
? {
showActivity: row.showActivity !== 0,
federationHomeOrphaned: row.federationHomeOrphaned === 1,
}
: {}),
};
}
+28 -1
View File
@@ -27,6 +27,8 @@ export interface User {
homeUserId: string | null;
replicatedInstances: ReplicatedInstance[];
showActivity?: boolean;
/** Self-view only: this federated account's home instance was reset/lost — it now operates as a sovereign local account (detach spec). */
federationHomeOrphaned?: boolean;
}
export interface ReplicatedInstance {
@@ -1044,6 +1046,12 @@ export interface FederationRelayProfileSnapshot {
// already-online remote stays stuck at 'offline' on the receiver until they
// next change status.
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
/**
* The user is tombstoned on the instance that built this snapshot.
* Receivers must not create a new stub for this identity; internal
* '!deleted:<id>' usernames are never shipped (dead-incarnation spec §3.3).
*/
deleted?: boolean | null;
}
export interface FederationProfileUpdatePayload {
@@ -1155,6 +1163,24 @@ export interface FederationSyncResponse {
checkpoint: number;
}
// Detached-account re-attach (re-attach spec §3.13.2).
// Minted on the home instance D for a logged-in native user.
export interface AttachProofResponse {
token: string;
}
// Body of POST /api/users/@me/reattach on the peer R — the one-time proof token
// minted by the home instance, verified with D over signed S2S.
export interface ReattachRequest {
token: string;
}
// Success response of POST /api/users/@me/reattach — the re-bound self-view.
export interface ReattachResponse {
success: true;
user: User;
}
export interface FederationUserLookupRequest {
username: string;
}
@@ -1201,7 +1227,7 @@ export interface FederationPeer {
*/
export interface FederationOrphanedAccount {
id: string;
username: string; // '!orphaned:{uid}@domain' for freed handles; real for space owners
username: string; // preserved original handle (detach spec); legacy rows may carry '!orphaned:{uid}@domain'
displayName: string | null;
avatarColor: string | null;
ownedSpaces: { id: string; name: string }[];
@@ -1219,6 +1245,7 @@ export interface FederationResetEvent {
newEpoch: string | null;
detectedAt: number;
resolvedAt: number | null;
acknowledgedAt: number | null;
stubCount: number;
orphanedAccountCount: number;
orphanedAccounts: FederationOrphanedAccount[];
+12
View File
@@ -68,6 +68,9 @@ import type {
CheckInviteResponse,
SpaceInviteRequest,
SpaceInviteResponse,
AttachProofResponse,
ReattachRequest,
ReattachResponse,
} from '@backspace/shared';
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
@@ -99,6 +102,7 @@ export class BackspaceApiClient {
login: (data: LoginRequest) => Promise<AuthResponse>;
checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>;
checkInvite: (token: string) => Promise<CheckInviteResponse>;
attachProof: (targetDomain: string) => Promise<AttachProofResponse>;
};
readonly users: {
@@ -112,6 +116,7 @@ export class BackspaceApiClient {
getFederationRegistry: () => Promise<{ registry: FederationRegistryEntry[]; updatedAt: number }>;
putFederationRegistry: (data: { registry: FederationRegistryEntry[]; updatedAt: number }) => Promise<{ ok: boolean; updatedAt: number }>;
deleteFederationIdentity: (data: FederationIdentityDeleteRequest) => Promise<FederationIdentityDeleteResponse>;
reattach: (data: ReattachRequest) => Promise<ReattachResponse>;
};
readonly spaceLayout: {
@@ -281,6 +286,7 @@ export class BackspaceApiClient {
ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>;
peers: () => Promise<{ peers: FederationPeer[] }>;
resetEvents: () => Promise<FederationResetEventsResponse>;
acknowledgeResetEvent: (origin: string) => Promise<{ success: boolean }>;
revokePeer: (id: string) => Promise<{ success: boolean }>;
resetPeer: (id: string) => Promise<{ success: boolean }>;
recheckPeer: (id: string) => Promise<{ recovered: boolean; status: string }>;
@@ -386,6 +392,8 @@ export class BackspaceApiClient {
request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false),
checkInvite: (token: string) =>
request<CheckInviteResponse>('GET', `/auth/check-invite?token=${encodeURIComponent(token)}`, undefined, false),
attachProof: (targetDomain: string) =>
request<AttachProofResponse>('POST', '/auth/attach-proof', { targetDomain }),
};
this.users = {
@@ -418,6 +426,8 @@ export class BackspaceApiClient {
request<FederationIdentityDeleteResponse>(
'POST', '/users/@me/federation-identity/delete', data
),
reattach: (data: ReattachRequest) =>
request<ReattachResponse>('POST', '/users/@me/reattach', data),
};
this.spaceLayout = {
@@ -710,6 +720,8 @@ export class BackspaceApiClient {
),
resetEvents: () =>
request<FederationResetEventsResponse>('GET', '/federation/reset-events'),
acknowledgeResetEvent: (origin: string) =>
request<{ success: boolean }>('POST', '/federation/reset-events/acknowledge', { origin }),
revokePeer: (id: string) =>
request<{ success: boolean }>('DELETE', `/federation/peers/${id}`),
resetPeer: (id: string) =>
@@ -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<string, unknown> = {}) {
};
}
function resetEvent(accounts: ReturnType<typeof orphanedAccount>[]) {
function resetEvent(
accounts: ReturnType<typeof orphanedAccount>[],
overrides: Record<string, unknown> = {},
) {
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();
@@ -210,6 +218,10 @@ describe('FederationPanel — Reset cleanup', () => {
const removeBtn = await screen.findByRole('button', { name: 'Remove' });
fireEvent.click(removeBtn);
// The confirm dialog title uses the current "detached" vocabulary, not the
// legacy "Orphaned Account" wording.
expect(await screen.findByText('Remove detached account')).toBeInTheDocument();
const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' });
fireEvent.click(confirmBtn);
@@ -272,4 +284,82 @@ 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(<FederationPanel />);
// 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();
// 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();
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(<FederationPanel />);
// 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(<FederationPanel />);
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(<FederationPanel />);
fireEvent.click(await screen.findByRole('button', { name: /Dismiss/ }));
await waitFor(() =>
expect(addToast).toHaveBeenCalledWith('Network down', 'warning'),
);
});
});
@@ -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<ResetConfirmAction | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [keptIds, setKeptIds] = useState<Set<string>>(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() {
<div className="flex items-center gap-2 mb-1.5">
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider">Reset Cleanup</div>
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-accent-rose/15 text-accent-rose">
{resetPeers.length + eventsWithOrphans.reduce((n, e) => n + e.orphanedAccounts.length, 0)}
{resetPeers.length + eventsWithOrphans.length}
</span>
</div>
@@ -1019,61 +1037,56 @@ 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.
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) => {
const kept = keptIds.has(account.id);
return (
<div key={account.id} className="bg-white/[0.02] rounded-md px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-txt-primary truncate">
{account.displayName || account.username}
</div>
<div className="text-[11px] text-txt-tertiary truncate">{account.username}</div>
<div className="text-[11px] text-txt-tertiary mt-0.5">
{account.spaceMemberCount}{' '}
{account.spaceMemberCount === 1 ? 'membership' : 'memberships'} ·{' '}
{account.messageCount}{' '}
{account.messageCount === 1 ? 'message' : 'messages'}
</div>
{account.ownedSpaces.length > 0 && (
<div className="text-[11px] text-accent-amber mt-0.5 truncate">
Owns: {account.ownedSpaces.map((s) => s.name).join(', ')}
</div>
)}
{event.orphanedAccounts.map((account) => (
<div key={account.id} className="bg-white/[0.02] rounded-md px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-txt-primary truncate">
{account.displayName || account.username}
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
{kept ? (
<span className="text-[11px] text-txt-tertiary italic">Kept frozen</span>
) : (
<button
type="button"
onClick={() =>
setKeptIds((prev) => new Set(prev).add(account.id))
}
className="px-3 py-1.5 text-xs font-medium text-txt-tertiary hover:text-txt-secondary bg-white/[0.04] hover:bg-white/[0.06] rounded transition-colors"
>
Keep
</button>
)}
<button
type="button"
onClick={() =>
setConfirmAction({ kind: 'remove', account, origin: event.origin })
}
disabled={actionLoading}
className="px-3 py-1.5 text-xs font-medium bg-accent-rose/10 text-txt-danger hover:bg-accent-rose/20 rounded transition-colors disabled:opacity-50"
>
Remove
</button>
<div className="text-[11px] text-txt-tertiary truncate">{account.username}</div>
<div className="text-[11px] text-txt-tertiary mt-0.5">
{account.spaceMemberCount}{' '}
{account.spaceMemberCount === 1 ? 'membership' : 'memberships'} ·{' '}
{account.messageCount}{' '}
{account.messageCount === 1 ? 'message' : 'messages'}
</div>
{account.ownedSpaces.length > 0 && (
<div className="text-[11px] text-accent-amber mt-0.5 truncate">
Owns: {account.ownedSpaces.map((s) => s.name).join(', ')}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
<button
type="button"
onClick={() =>
setConfirmAction({ kind: 'remove', account, origin: event.origin })
}
disabled={actionLoading}
className="px-3 py-1.5 text-xs font-medium bg-accent-rose/10 text-txt-danger hover:bg-accent-rose/20 rounded transition-colors disabled:opacity-50"
>
Remove
</button>
</div>
</div>
);
})}
</div>
))}
</div>
<button
type="button"
onClick={() => handleDismiss(event.origin)}
disabled={actionLoading}
className="mt-2 px-3 py-1.5 text-xs font-medium text-txt-tertiary hover:text-txt-secondary bg-white/[0.04] hover:bg-white/[0.06] rounded transition-colors disabled:opacity-50"
>
Dismiss — keep all detached accounts
</button>
</div>
))}
</div>
@@ -1084,7 +1097,7 @@ function ResetCleanup() {
isOpen={true}
onClose={() => { if (!actionLoading) setConfirmAction(null); }}
onConfirm={handleConfirm}
title={confirmAction.kind === 'repeer' ? 'Re-establish Federation' : 'Remove Orphaned Account'}
title={confirmAction.kind === 'repeer' ? 'Re-establish Federation' : 'Remove detached account'}
description={
confirmAction.kind === 'repeer'
? `This deletes the local peer record and starts a fresh authenticated handshake with ${confirmAction.peer.origin}. The remote must be reachable and (if it does not auto-accept) approve the request.`
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import type { User } from '@backspace/shared';
// ── Store mocks ─────────────────────────────────────────────────────────────
// AccountPanel reads its self user from `useAuthStore((s) => s.user)`. We drive
// that user through a mutable fixture and mock the store with a selector-aware
// callable (mirrors the selector-mock idiom used across the web test suite).
let currentUser: User | null = null;
// Instances backing the re-attach fallback action — mutated per test.
let currentInstances: unknown[] = [];
const noop = vi.fn();
const setUserMock = vi.fn();
// The peer re-attach call (primary `api.users.reattach`), asserted by the
// two-step-confirm test.
const mockReattach = vi.fn();
interface AuthState {
user: User | null;
updateProfile: (...args: unknown[]) => unknown;
changePassword: (...args: unknown[]) => unknown;
setUser: (user: User) => void;
}
vi.mock('../../../stores/authStore', () => ({
useAuthStore: Object.assign(
(selector: (s: AuthState) => unknown) =>
selector({ user: currentUser, updateProfile: noop, changePassword: noop, setUser: setUserMock }),
{
getState: (): AuthState => ({ user: currentUser, updateProfile: noop, changePassword: noop, setUser: setUserMock }),
setState: vi.fn(),
subscribe: vi.fn(),
},
),
}));
vi.mock('../../../stores/uiStore', () => ({
useUIStore: Object.assign(
(selector: (s: { addToast: (...args: unknown[]) => void }) => unknown) =>
selector({ addToast: noop }),
{ getState: () => ({ addToast: noop }), setState: vi.fn(), subscribe: vi.fn() },
),
}));
vi.mock('../../../stores/instanceStore', () => ({
useInstanceStore: Object.assign(
(selector: (s: { instances: unknown[] }) => unknown) => selector({ instances: currentInstances }),
{ getState: () => ({ instances: currentInstances }), setState: vi.fn(), subscribe: vi.fn() },
),
}));
vi.mock('../../../stores/transferStore', () => ({
useTransferStore: Object.assign(
(selector: (s: unknown) => unknown) => selector({}),
{ getState: () => ({ startUpload: noop, transfers: new Map() }), setState: vi.fn(), subscribe: vi.fn() },
),
}));
// spaceStore is imported for the post-reattach DM refetch (reloadDmsForOrigin);
// stub it so this isolated render doesn't load the real store's audio import chain.
vi.mock('../../../stores/spaceStore', () => ({
useSpaceStore: Object.assign(
(selector: (s: unknown) => unknown) => selector({}),
{ getState: () => ({ reloadDmsForOrigin: vi.fn().mockResolvedValue(undefined) }), setState: vi.fn(), subscribe: vi.fn() },
),
}));
// api.uploads.url is referenced during render for avatar/banner sources;
// api.users.reattach is the peer call the fallback action fires on confirm.
vi.mock('../../../api/client', () => ({
// reattach is wrapped so the top-level `mockReattach` const is dereferenced
// lazily at call time (vi.mock factories are hoisted above const init).
api: { uploads: { url: (f: string) => `/api/uploads/${f}` }, users: { reattach: (...args: unknown[]) => mockReattach(...args) } },
}));
// Child modals are closed in these render cases; stub them so their transitive
// store imports don't participate in this isolated component render.
vi.mock('../../ui/ImageCropModal', () => ({ ImageCropModal: () => null }));
vi.mock('../DeleteAccountModal', () => ({ DeleteAccountModal: () => null }));
import { AccountPanel } from './AccountPanel';
// ── Fixtures ────────────────────────────────────────────────────────────────
function makeUser(overrides: Partial<User> = {}): User {
return {
id: 'user-self',
username: 'me',
displayName: 'Me',
avatar: null,
banner: null,
accentColor: null,
avatarColor: null,
bio: null,
status: 'online',
customStatus: null,
isAdmin: false,
createdAt: 0,
homeInstance: null,
homeUserId: null,
replicatedInstances: [],
...overrides,
};
}
const NOTICE = /This account is detached from its home instance\./i;
// A connected home-domain instance carrying the proof-mint API surface the
// fallback action calls. Only the fields AccountPanel touches are populated.
function makeHomeConnection(overrides: {
origin?: string;
username?: string;
attachProof?: ReturnType<typeof vi.fn>;
} = {}) {
return {
origin: overrides.origin ?? 'https://orbit.test',
username: overrides.username ?? 'youruser',
status: 'connected' as const,
api: { auth: { attachProof: overrides.attachProof ?? vi.fn() } },
};
}
beforeEach(() => {
cleanup();
currentUser = null;
currentInstances = [];
setUserMock.mockReset();
mockReattach.mockReset();
});
describe('AccountPanel detached-account notice', () => {
it('renders the notice when the account is detached and carries a home instance', () => {
currentUser = makeUser({ federationHomeOrphaned: true, homeInstance: 'old.example.net' });
render(<AccountPanel />);
expect(screen.getByText(NOTICE)).toBeInTheDocument();
// Names the lost home instance so the owner understands what happened.
expect(screen.getByText(/old\.example\.net/)).toBeInTheDocument();
});
it('does not render the notice for a non-detached federated account', () => {
currentUser = makeUser({ federationHomeOrphaned: false, homeInstance: 'live.example.net' });
render(<AccountPanel />);
expect(screen.queryByText(NOTICE)).not.toBeInTheDocument();
});
it('does not render the notice for a normal local account (no home instance)', () => {
currentUser = makeUser({ federationHomeOrphaned: true, homeInstance: null });
render(<AccountPanel />);
expect(screen.queryByText(NOTICE)).not.toBeInTheDocument();
});
});
describe('AccountPanel re-attach fallback action', () => {
it('shows the re-attach action when a connection to the home domain exists', () => {
currentUser = makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' });
currentInstances = [makeHomeConnection()];
render(<AccountPanel />);
expect(screen.getByRole('button', { name: /re-attach to orbit\.test/i })).toBeInTheDocument();
});
it('hides the re-attach action without a home-domain connection', () => {
currentUser = makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' });
currentInstances = [];
render(<AccountPanel />);
expect(screen.queryByRole('button', { name: /re-attach/i })).not.toBeInTheDocument();
// Informational copy still present:
expect(screen.getByText(/detached from its home instance/i)).toBeInTheDocument();
});
it('two-step confirm: first click arms, second click mints proof and calls reattach', async () => {
currentUser = makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' });
const attachProof = vi.fn().mockResolvedValue({ token: 'a'.repeat(64) });
currentInstances = [makeHomeConnection({ attachProof })];
mockReattach.mockResolvedValue({ success: true, user: makeUser({ username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' }) });
render(<AccountPanel />);
fireEvent.click(screen.getByRole('button', { name: /re-attach to orbit\.test/i }));
fireEvent.click(screen.getByRole('button', { name: /confirm re-attach/i }));
await waitFor(() => expect(mockReattach).toHaveBeenCalledWith({ token: 'a'.repeat(64) }));
expect(attachProof).toHaveBeenCalled();
});
});
@@ -1,7 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { useAuthStore } from '../../../stores/authStore';
import { useUIStore } from '../../../stores/uiStore';
import { useInstanceStore } from '../../../stores/instanceStore';
import { useSpaceStore } from '../../../stores/spaceStore';
import { Avatar } from '../../ui/Avatar';
import { ImageCropModal } from '../../ui/ImageCropModal';
import { DeleteAccountModal } from '../DeleteAccountModal';
@@ -77,6 +78,52 @@ export function AccountPanel() {
const instances = useInstanceStore((s) => s.instances);
const changePassword = useAuthStore((s) => s.changePassword);
// ── Detached-account re-attach (fallback path, re-attach spec §3.4) ──
// Explicit action shown only when this client also holds an active connection
// to the account's home domain. Two-step armed confirm names both identities
// before minting the proof. The primary/automatic path lives in instanceStore.
const [reattachArmed, setReattachArmed] = useState(false);
const [reattaching, setReattaching] = useState(false);
const [reattachError, setReattachError] = useState<string | null>(null);
const homeConnection = useMemo(() => {
if (!user?.homeInstance) return null;
const homeDomain = user.homeInstance.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
return instances.find(
(i) => i.status === 'connected'
// Portless hostname — must agree with the server's extractDomain
// (new URL(origin).hostname) so a ported home instance still matches.
&& new URL(i.origin).hostname.toLowerCase() === homeDomain,
) ?? null;
}, [instances, user?.homeInstance]);
const handleReattach = async () => {
if (!homeConnection) return;
if (!reattachArmed) {
setReattachArmed(true);
return;
}
setReattaching(true);
setReattachError(null);
try {
// Target domain = THIS instance (where the detached account lives).
// Portless hostname to match the server's extractDomain contract.
const { token } = await homeConnection.api.auth.attachProof(window.location.hostname);
const res = await api.users.reattach({ token });
useAuthStore.getState().setUser(res.user);
// Re-attach reconciled this (home) account's 1-on-1 DM federatedIds on the
// server; refetch the home DM list so the split conversation collapses
// without a reload.
try { await useSpaceStore.getState().reloadDmsForOrigin(''); } catch { /* non-fatal */ }
addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000);
} catch (err) {
setReattachError(err instanceof Error ? err.message : 'Re-attach failed');
} finally {
setReattaching(false);
setReattachArmed(false);
}
};
if (!user) return null;
const effectiveDisplayName = displayName.trim() || user.username;
@@ -264,6 +311,32 @@ export function AccountPanel() {
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary mb-6">My Account</h2>
{user?.federationHomeOrphaned && user?.homeInstance && (
<div className="rounded-lg bg-accent-amber/10 border border-accent-amber/25 px-3.5 py-3 text-xs text-txt-secondary leading-relaxed mb-4">
<span className="font-medium text-txt-primary">This account is detached from its home instance.</span>{' '}
{user.homeInstance} was reset or is no longer available, so this account now operates locally on
this instance your profile and password are managed here.
{homeConnection && (
<>
{' '}As <span className="font-medium text-txt-primary">{homeConnection.username}</span> on{' '}
{user.homeInstance}, you can re-link this account profile and presence will sync from there again.
<button
type="button"
onClick={handleReattach}
disabled={reattaching}
className="mt-2 block rounded-md bg-accent-amber/20 hover:bg-accent-amber/30 disabled:opacity-50 text-txt-primary px-3 py-1.5 text-xs font-medium transition-colors"
>
{reattaching
? 'Re-attaching…'
: reattachArmed
? `Confirm re-attach as ${homeConnection.username}`
: `Re-attach to ${user.homeInstance}`}
</button>
{reattachError && <div className="mt-1.5 text-accent-rose">{reattachError}</div>}
</>
)}
</div>
)}
{/* ── Profile Customization ── */}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
@@ -0,0 +1,178 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { User } from '@backspace/shared';
import type { BackspaceApiClient } from '../api/client';
// ── Module mocks (mirror instanceStore.failover.test.ts) ─────────────────────
// These stub the side-effecting modules instanceStore pulls in at import time so
// the store loads cleanly under jsdom with no network, audio, or WS activity.
vi.mock('../utils/dmOriginFailover', () => ({
failoverDmOriginsFromDisconnected: vi.fn(),
}));
vi.mock('../hooks/useWebSocket', () => ({
connectInstance: vi.fn(),
disconnectInstance: vi.fn(),
disconnectAllRemote: vi.fn(),
}));
vi.mock('../utils/federationOps', () => ({ clearPasswordSyncTimers: vi.fn() }));
vi.mock('../audio/AudioManager', () => ({
AudioManager: { getInstance: vi.fn().mockReturnValue({ setOutputDevice: vi.fn(), setVolume: vi.fn() }) },
}));
// Primary user is null: the auto-reattach helper must then locate the home
// session through the instances array (the SECONDARY-connection branch), so
// window.location.host is irrelevant to these tests.
vi.mock('./authStore', () => ({
useAuthStore: Object.assign(
(selector: (s: unknown) => unknown) => selector({ user: null, token: null }),
{ getState: () => ({ user: null, token: null }), setState: vi.fn(), subscribe: vi.fn() }
),
}));
import { useInstanceStore, maybeAutoReattach } from './instanceStore';
import type { ConnectedInstance } from './instanceStore';
import { useSpaceStore } from './spaceStore';
function makeInstance(overrides: Partial<ConnectedInstance> & { origin: string }): ConnectedInstance {
return {
label: 'x', token: 't', status: 'connected',
username: overrides.user?.username ?? 'u',
api: { auth: { attachProof: vi.fn() }, users: { reattach: vi.fn() }, dm: { list: vi.fn().mockResolvedValue([]) } } as unknown as BackspaceApiClient,
user: { id: 'id', username: 'u' } as User,
...overrides,
};
}
beforeEach(() => {
useInstanceStore.setState({ instances: [], registry: new Map(), registryUpdatedAt: 0 });
});
describe('maybeAutoReattach', () => {
it('performs the token exchange when all conditions hold (same base, home session present)', async () => {
const homeConn = makeInstance({
origin: 'https://orbit.test',
username: 'youruser',
user: { id: 'new-home-1', username: 'youruser' } as User,
});
const attachProof = vi.fn().mockResolvedValue({ token: 'a'.repeat(64) });
(homeConn.api as unknown as { auth: { attachProof: typeof attachProof } }).auth.attachProof = attachProof;
const updatedUser = { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' } as User;
const reattach = vi.fn().mockResolvedValue({ success: true, user: updatedUser });
const detachedConn = makeInstance({
origin: 'https://nova.test',
user: { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
(detachedConn.api as unknown as { users: { reattach: typeof reattach } }).users.reattach = reattach;
useInstanceStore.setState({ instances: [homeConn, detachedConn] });
await maybeAutoReattach(detachedConn);
expect(attachProof).toHaveBeenCalledWith('nova.test');
expect(reattach).toHaveBeenCalledWith({ token: 'a'.repeat(64) });
const stored = useInstanceStore.getState().instances.find(i => i.origin === 'https://nova.test')!;
expect(stored.user.federationHomeOrphaned).toBe(false);
});
it('refetches the DM list for the connection after a successful re-attach', async () => {
const homeConn = makeInstance({
origin: 'https://orbit.test',
username: 'youruser',
user: { id: 'new-home-1', username: 'youruser' } as User,
});
(homeConn.api as unknown as { auth: { attachProof: ReturnType<typeof vi.fn> } }).auth.attachProof =
vi.fn().mockResolvedValue({ token: 'a'.repeat(64) });
const updatedUser = { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' } as User;
const detachedConn = makeInstance({
origin: 'https://nova.test',
user: { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
(detachedConn.api as unknown as { users: { reattach: ReturnType<typeof vi.fn> } }).users.reattach =
vi.fn().mockResolvedValue({ success: true, user: updatedUser });
const dmRefetchMock = vi.fn().mockResolvedValue(undefined);
const spy = vi.spyOn(useSpaceStore.getState(), 'reloadDmsForOrigin').mockImplementation(dmRefetchMock);
useInstanceStore.setState({ instances: [homeConn, detachedConn] });
await maybeAutoReattach(detachedConn);
expect(dmRefetchMock).toHaveBeenCalledWith('https://nova.test');
spy.mockRestore();
});
it('skips silently on username-base mismatch (cross-name binds are manual-only)', async () => {
const homeConn = makeInstance({ origin: 'https://orbit.test', username: 'hans', user: { id: 'h', username: 'hans' } as User });
const detachedConn = makeInstance({
origin: 'https://nova.test',
user: { id: 'd', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
useInstanceStore.setState({ instances: [homeConn, detachedConn] });
await maybeAutoReattach(detachedConn);
expect((homeConn.api as unknown as { auth: { attachProof: ReturnType<typeof vi.fn> } }).auth.attachProof).not.toHaveBeenCalled();
expect((detachedConn.api as unknown as { users: { reattach: ReturnType<typeof vi.fn> } }).users.reattach).not.toHaveBeenCalled();
});
it('mints the PORTLESS target host for a ported instance origin (matches server extractDomain)', async () => {
// Both instances served on a non-443 port. The server binds/verifies the
// proof against extractDomain(peer.origin) = new URL(origin).hostname, which
// is portless — so the client must mint the portless host too, or the
// exchange 401s forever. homeInstance is stored bare (portless hostname).
const homeConn = makeInstance({
origin: 'https://orbit.test:8443',
username: 'youruser',
user: { id: 'new-home-1', username: 'youruser' } as User,
});
const attachProof = vi.fn().mockResolvedValue({ token: 'a'.repeat(64) });
(homeConn.api as unknown as { auth: { attachProof: typeof attachProof } }).auth.attachProof = attachProof;
const updatedUser = { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' } as User;
const reattach = vi.fn().mockResolvedValue({ success: true, user: updatedUser });
const detachedConn = makeInstance({
origin: 'https://nova.test:8443',
user: { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
(detachedConn.api as unknown as { users: { reattach: typeof reattach } }).users.reattach = reattach;
useInstanceStore.setState({ instances: [homeConn, detachedConn] });
await maybeAutoReattach(detachedConn);
// Portless — 'nova.test', NOT 'nova.test:8443'.
expect(attachProof).toHaveBeenCalledWith('nova.test');
expect(reattach).toHaveBeenCalledWith({ token: 'a'.repeat(64) });
const stored = useInstanceStore.getState().instances.find(i => i.origin === 'https://nova.test:8443')!;
expect(stored.user.federationHomeOrphaned).toBe(false);
});
it('skips when the account is not detached', async () => {
const conn = makeInstance({
origin: 'https://nova.test',
user: { id: 'd', username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' } as User,
});
useInstanceStore.setState({ instances: [conn] });
await maybeAutoReattach(conn);
expect((conn.api as unknown as { users: { reattach: ReturnType<typeof vi.fn> } }).users.reattach).not.toHaveBeenCalled();
});
it('skips when no home-domain session exists', async () => {
const detachedConn = makeInstance({
origin: 'https://nova.test',
user: { id: 'd', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
useInstanceStore.setState({ instances: [detachedConn] });
await maybeAutoReattach(detachedConn);
expect((detachedConn.api as unknown as { users: { reattach: ReturnType<typeof vi.fn> } }).users.reattach).not.toHaveBeenCalled();
});
it('a failed exchange never throws and leaves the connection up', async () => {
const homeConn = makeInstance({ origin: 'https://orbit.test', username: 'youruser', user: { id: 'h', username: 'youruser' } as User });
(homeConn.api as unknown as { auth: { attachProof: ReturnType<typeof vi.fn> } }).auth.attachProof = vi.fn().mockRejectedValue(new Error('boom'));
const detachedConn = makeInstance({
origin: 'https://nova.test',
user: { id: 'd', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User,
});
useInstanceStore.setState({ instances: [homeConn, detachedConn] });
await expect(maybeAutoReattach(detachedConn)).resolves.toBeUndefined();
const stored = useInstanceStore.getState().instances.find(i => i.origin === 'https://nova.test')!;
expect(stored.status).toBe('connected');
expect(stored.user.federationHomeOrphaned).toBe(true); // unchanged; manual path remains
});
});
+79
View File
@@ -18,6 +18,7 @@ import { clearPasswordSyncTimers } from '../utils/federationOps';
// so a static import here does not create an import-time cycle.
import { failoverDmOriginsFromDisconnected } from '../utils/dmOriginFailover';
import { useUIStore } from './uiStore';
import { parseFederatedUsername } from '../utils/identity';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -134,6 +135,78 @@ export function isSelfOrigin(origin: string): boolean {
}
}
// ─── Automatic re-attach (re-attach spec §3.4) ────────────────────────────────
/**
* Automatic re-attach (re-attach spec §3.4): when a just-connected remote
* account is DETACHED and this client also holds an authenticated session on
* the account's home domain under the SAME username base, silently perform
* the proof exchange — the user has proven both identities, so the accounts
* re-link without interaction. Cross-name binds and every ambiguous case fall
* through to the explicit AccountPanel action. Fire-and-forget, non-fatal.
*/
export async function maybeAutoReattach(instance: ConnectedInstance): Promise<void> {
const remoteUser = instance.user;
if (!remoteUser.federationHomeOrphaned || !remoteUser.homeInstance) return;
const homeDomain = remoteUser.homeInstance.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase();
// An authenticated session on the account's home domain: the primary
// connection when we're browsing it, else a connected secondary instance.
const primaryUser = useAuthStore.getState().user;
let homeApi: BackspaceApiClient | null = null;
let homeUsername: string | null = null;
if (primaryUser && !primaryUser.homeInstance && window.location.hostname.toLowerCase() === homeDomain) {
homeApi = api;
homeUsername = primaryUser.username;
} else {
const conn = useInstanceStore.getState().instances.find(
(i) => i.status === 'connected' && new URL(i.origin).hostname.toLowerCase() === homeDomain,
);
if (conn) {
homeApi = conn.api;
homeUsername = conn.username;
}
}
if (!homeApi || !homeUsername) return;
// Unambiguous case only: same username base on both sides (spec §2/§3.4).
const detachedBase = parseFederatedUsername(remoteUser.username).baseName.toLowerCase();
const homeBase = parseFederatedUsername(homeUsername).baseName.toLowerCase();
if (!detachedBase || detachedBase !== homeBase) return;
try {
// Portless hostname — must match the server's extractDomain(peer.origin)
// (new URL(origin).hostname) so the proof's targetDomain binds/verifies on
// a non-443 port too. .host would carry the port and 401 forever.
const targetHost = new URL(instance.origin).hostname;
const { token } = await homeApi.auth.attachProof(targetHost);
const res = await instance.api.users.reattach({ token });
useInstanceStore.setState((state) => ({
instances: state.instances.map((i) =>
i.origin === instance.origin ? { ...i, user: res.user, username: res.user.username } : i,
),
}));
// Registry mirrors the connection's identity — keep the re-bound username in sync.
const registry = upsertRegistryEntry(useInstanceStore.getState().registry, instance.origin, {
origin: instance.origin,
username: res.user.username,
remoteUserId: res.user.id,
});
useInstanceStore.setState({ registry, registryUpdatedAt: Date.now() });
useUIStore.getState().addToast(`Account re-linked with ${homeDomain}`, 'success');
useInstanceStore.getState().syncRegistry().catch(() => {});
// Re-attach reconciled this connection's 1-on-1 DM federatedIds (merge/re-key
// on the server); refetch the DM list so the split conversation collapses
// without a reload. Belt-and-suspenders for the connection that triggered it
// — the server's dm_channel_closed/created events cover the live sidebar too.
try { await useSpaceStore.getState().reloadDmsForOrigin(instance.origin); } catch { /* non-fatal */ }
} catch (err) {
// Non-fatal: the connection works either way; the explicit re-attach
// action in AccountPanel remains available.
console.warn('[federation] Auto re-attach failed:', err);
}
}
// ─── API client resolution ───────────────────────────────────────────────────
// ─── Registry helpers ────────────────────────────────────────────────────────
@@ -355,6 +428,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Open WebSocket connection to the remote instance
connectInstance(origin, response.token);
// Automatic re-attach for detached accounts (re-attach spec §3.4).
maybeAutoReattach(instance).catch(() => {});
// Ensure server-to-server peering for DM relay (non-fatal)
try {
const peerResult = await api.federation.ensurePeered({ remoteOrigin: origin });
@@ -432,6 +508,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Open WebSocket connection to the remote instance
connectInstance(origin, response.token);
// Automatic re-attach for detached accounts (re-attach spec §3.4).
maybeAutoReattach(instance).catch(() => {});
// Sync instance list to all instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
get().syncRegistry().catch(() => {});
+86
View File
@@ -126,6 +126,7 @@ interface SpaceState {
setRoles: (roles: Role[]) => void;
setDmChannels: (channels: DmChannel[]) => void;
addDmChannel: (channel: DmChannel, origin?: string) => void;
reloadDmsForOrigin: (origin: string) => Promise<void>;
removeDmChannel: (id: string) => void;
addDmMember: (dmChannelId: string, user: User) => void;
removeDmMember: (dmChannelId: string, userId: string) => void;
@@ -285,6 +286,91 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
};
}),
// Refetch and replace the DM list for a single origin, mirroring the DM
// portion of populateFromReady (dedup vs other origins by federatedId, origin
// map, last-message map, failover alternatives, userViews). Used after a
// re-attach reconciles this connection's 1-on-1 federatedIds (merge/re-key)
// so the split conversation collapses without a full WS reconnect. Origin ''
// is the home instance. Non-fatal: the caller wraps it in try/catch.
reloadDmsForOrigin: async (origin: string) => {
const client = getApiForOrigin(origin);
const incomingDms = await client.dm.list();
// Normalize remote-origin DM member asset URLs (home origin serves clean paths).
if (origin !== '') {
for (const dm of incomingDms) {
for (const member of dm.members) {
normalizeUserAssets(member, origin);
}
}
}
set((state) => {
// Upsert every DM member into the userViews cache (home + remote).
const { upsertUserView } = get();
for (const dm of incomingDms) {
for (const member of dm.members) {
upsertUserView(member, origin);
}
}
// Dedup vs DMs already loaded from OTHER origins (same federatedId).
const existingFederatedIds = new Map<string, string>();
for (const dm of state.dmChannels) {
if (dm.federatedId && (state.channelOriginMap.get(dm.id) ?? '') !== origin) {
existingFederatedIds.set(dm.federatedId, dm.id);
}
}
const channelOriginMap = new Map(state.channelOriginMap);
const channelLastMessageIds = new Map(state.channelLastMessageIds);
// Drop this origin's stale channel-map entries before repopulating.
for (const dm of state.dmChannels) {
if ((state.channelOriginMap.get(dm.id) ?? '') === origin) {
channelOriginMap.delete(dm.id);
channelLastMessageIds.delete(dm.id);
}
}
const dmAlternatives = new Map<string, Map<string, string>>();
for (const [fid, byOrigin] of state.dmAlternatives) {
dmAlternatives.set(fid, new Map(byOrigin));
}
const filteredDms: DmChannel[] = [];
for (const dm of incomingDms) {
if (dm.federatedId && existingFederatedIds.has(dm.federatedId)) {
continue; // duplicate cross-instance DM — keep the copy from the other origin
}
filteredDms.push(dm);
if (dm.federatedId) existingFederatedIds.set(dm.federatedId, dm.id);
}
for (const dm of filteredDms) {
channelOriginMap.set(dm.id, origin);
if (dm.lastMessage?.id) channelLastMessageIds.set(dm.id, dm.lastMessage.id);
}
// Record every DM's (origin → localChannelId) for failover lookup.
for (const dm of incomingDms) {
if (!dm.federatedId) continue;
let byOrigin = dmAlternatives.get(dm.federatedId);
if (!byOrigin) {
byOrigin = new Map();
dmAlternatives.set(dm.federatedId, byOrigin);
}
byOrigin.set(origin, dm.id);
}
const existingDmsFromOtherOrigins = state.dmChannels.filter(
dm => (state.channelOriginMap.get(dm.id) ?? '') !== origin,
);
const mergedDms = [...existingDmsFromOtherOrigins, ...filteredDms];
const { unreadChannels, currentChannelId } = useChatStore.getState();
const sortedDms = sortDmChannels(mergedDms, unreadChannels, currentChannelId);
return { dmChannels: sortedDms, channelOriginMap, channelLastMessageIds, dmAlternatives };
});
},
upsertUserView: (user, deliveringOrigin) => set((state) => {
const key = canonicalUserKey(user);
const incomingIsHome = isDeliveryFromHome(user, deliveringOrigin);