Merge feat/s2s-federated-friends-presence-username

S2S federated-friends username + presence relay. Closes the original repro
(youruser on nova saw pbtest3/pbtest4 from orbit as snowflake-ID with stale
offline status when not client-federated to orbit).

Phase A — username:
- Stub creation uses realname@domain when wire snapshot carries a username hint
- profile_update payload extended with username, receiver applies displayName
  ?? username fallback so null-displayName home users don't clobber stubs
- New /api/federation/users/by-home-id reverse-lookup endpoint + helper
- Stub backfill worker heals legacy snowflake-named stubs (per-peer, on
  activation + startup pass)

Phase B — presence:
- New S2S presence_update relay event + sender helper + 5 call sites
- Receiver updates stub status + broadcasts WS to friends/DM/spaces
- Local broadcast scope widened to friends + DM + spaces (matches user_updated)
- Peer-activation snapshot (relationship-scoped) + deactivation mark-stubs-offline
- Outbox-only delivery (no mutation log) — presence is ephemeral

Follow-on fixes after live verification:
- Outbox-to-relay event rebuild was missing presenceUpdate (allowlist gap)
- Profile snapshot now carries status — freshly-friended online remotes show
  online immediately, not stuck at offline until next transition
- spaceStore.updateMemberPresence patches userViews so the avatar green dot
  re-renders on WS presence_update (was reading from a different cache than
  the 'Online' text label)

Docs: federation.md, activity-presence.md, social.md, websocket.md updated.
Verified live on Pi + VM (nova + orbit).
This commit is contained in:
Jannis Braun
2026-05-05 16:48:19 +02:00
25 changed files with 1747 additions and 84 deletions
+5 -5
View File
@@ -144,7 +144,7 @@ UPDATE users
Three guards on the WHERE clause: Three guards on the WHERE clause:
1. **`home_instance IS NULL`** — replicated user stubs (federated identities homed elsewhere) have their status projected to us by the home instance via `presence_update` relays, not by our local WS state. Their status must not be touched on our boot. 1. **`home_instance IS NULL`** — replicated user stubs (federated identities homed elsewhere) have their status projected to us by the home instance via S2S `presence_update` relay events (see `federation.md` §10 — Presence Sync). Their status must not be touched on our boot. On peer deactivation, `markPeerStubsOffline` flips them to `offline`; on peer (re)activation, the home instance re-emits a fresh snapshot for relationship-related online natives.
2. **`is_deleted = 0`** — tombstoned users are excluded from presence broadcasts already; their stored status is left alone as a maintenance courtesy (no behavioral effect either way, but avoids silent rewrites). 2. **`is_deleted = 0`** — tombstoned users are excluded from presence broadcasts already; their stored status is left alone as a maintenance courtesy (no behavioral effect either way, but avoids silent rewrites).
3. **`status != 'offline'`** — keeps the operation a no-op once steady-state is reached; `changes` is logged only when non-zero. 3. **`status != 'offline'`** — keeps the operation a no-op once steady-state is reached; `changes` is logged only when non-zero.
@@ -153,10 +153,10 @@ Because the in-memory `ConnectionManager` is empty at boot by construction, no l
### Connect/Disconnect Flow ### Connect/Disconnect Flow
1. **Server boot**`resetStalePresenceOnBoot()` flips any locally-homed, non-deleted `online`/`idle`/`dnd` rows to `offline`. Federated rows untouched. 1. **Server boot**`resetStalePresenceOnBoot()` flips any locally-homed, non-deleted `online`/`idle`/`dnd` rows to `offline`. Federated rows untouched.
2. **Auth succeeds**`status` set to `'online'` in DB → `presence_update` broadcast to all user's spaces (excludes self; self gets `ready` payload) 2. **Auth succeeds**`status` set to `'online'` in DB → local `presence_update` broadcast to friends + DM members + space co-members via `collectProfileBroadcastTargetIds` → S2S `presence_update` queued to all active peers via `queuePresenceRelay` (mirrors profile_update fanout).
3. **Last socket closes** → 5-second grace period (`scheduleDisconnect`) to allow tab refresh/reconnect 3. **Last socket closes** → 5-second grace period (`scheduleDisconnect`) to allow tab refresh/reconnect.
4. **Grace period expires**`finalizeDisconnect`: sets DB status to `'offline'`, clears in-memory activities, broadcasts `presence_update` with `status: 'offline'` and `activities: []` to all spaces 4. **Grace period expires**`finalizeDisconnect`: sets DB status to `'offline'`, clears in-memory activities, broadcasts local `presence_update` to friends/DM/space co-members, queues S2S `presence_update` to peers.
5. **Reconnect during grace**`cancelDisconnect` prevents offline broadcast; new connection proceeds normally 5. **Reconnect during grace**`cancelDisconnect` prevents offline broadcast; new connection proceeds normally.
### Presence Broadcast Scope ### Presence Broadcast Scope
+52 -2
View File
@@ -6,6 +6,9 @@ Source files:
- `packages/server/src/routes/federation.ts` -- API endpoints (peer handshake, relay, sync) + all inbound event processors + identity resolution functions - `packages/server/src/routes/federation.ts` -- API endpoints (peer handshake, relay, sync) + all inbound event processors + identity resolution functions
- `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()` - `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()`
- `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution - `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution
- `packages/server/src/utils/federationLookup.ts` -- HMAC-signed remote-user lookups: `lookupRemoteUser` (by username) and `lookupRemoteUserByHomeId` (reverse lookup, used by stub backfill)
- `packages/server/src/utils/federationPresence.ts` -- S2S presence relay: `queuePresenceRelay`, `snapshotPresenceForPeer` (relationship-scoped), `markPeerStubsOffline`
- `packages/server/src/utils/federationStubBackfill.ts` -- Heals legacy snowflake-named replicated-user stubs by reverse-looking-up the canonical username via the peer
- `packages/server/src/utils/federationWorker.ts` -- Background workers: outbox delivery, file download, health check, janitor, initial sync - `packages/server/src/utils/federationWorker.ts` -- Background workers: outbox delivery, file download, health check, janitor, initial sync
- `packages/server/src/utils/storageJanitor.ts` -- Federation GC: outbox expiry, mutation log retention, file queue cleanup, DM channel purge - `packages/server/src/utils/storageJanitor.ts` -- Federation GC: outbox expiry, mutation log retention, file queue cleanup, DM channel purge
- `packages/server/src/routes/social.ts` -- Friend request/accept/cancel/remove endpoints that queue federation events - `packages/server/src/routes/social.ts` -- Friend request/accept/cancel/remove endpoints that queue federation events
@@ -1107,8 +1110,9 @@ Profile data is synced server-to-server. The home instance is authoritative —
**Event:** `profile_update` (contextType: `profile`) **Event:** `profile_update` (contextType: `profile`)
**Payload:** `FederationProfileUpdatePayload` — full snapshot of 6 durable fields: **Payload:** `FederationProfileUpdatePayload`:
- `homeUserId`, `homeInstance`, `profileUpdatedAt` (monotonic version) - `homeUserId`, `homeInstance`, `profileUpdatedAt` (monotonic version)
- `username` — the home user's canonical handle (without `@domain`). Receivers apply `displayName ?? username` when writing the stub's displayName, so stubs whose home user has no displayName show the real handle instead of getting clobbered to null. Username itself is immutable on the home instance, so the receiver does NOT rewrite the stub's username column on profile_update.
- `displayName`, `avatar` (absolute URL or null), `banner` (absolute URL or null) - `displayName`, `avatar` (absolute URL or null), `banner` (absolute URL or null)
- `accentColor`, `avatarColor`, `bio` - `accentColor`, `avatarColor`, `bio`
@@ -1116,7 +1120,7 @@ Profile data is synced server-to-server. The home instance is authoritative —
**Coalescing:** `entityId = homeUserId`. Rapid successive edits coalesce to one delivery per peer. **Coalescing:** `entityId = homeUserId`. Rapid successive edits coalesce to one delivery per peer.
**Processing:** Remote overwrites all 6 fields unconditionally. Rejects if incoming `profileUpdatedAt ≤ stored`. Broadcasts `user_updated` to local WS clients. **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.
#### Profile Image File Replication #### Profile Image File Replication
@@ -1143,6 +1147,52 @@ When a `profile_update` relay carries avatar or banner absolute URLs, the receiv
**Replaces:** Client-driven `profileSync.ts` (deleted). `hydrateReplicatedUserProfile` still bootstraps null fields during DM/friend relay — it now also runs the same local-download path so newly-created stubs end up with bare filenames, not URLs. **Replaces:** Client-driven `profileSync.ts` (deleted). `hydrateReplicatedUserProfile` still bootstraps null fields during DM/friend relay — it now also runs the same local-download path so newly-created stubs end up with bare filenames, not URLs.
#### Presence Sync (S2S)
Native users' status (and optional rich activities) is projected to peers via the `presence_update` relay event. Closes the doc/code gap previously documented in `activity-presence.md` — replicated stubs now have their status maintained by the home instance over S2S, not derived from absent local WS state.
**Event:** `presence_update` (contextType: `profile`)
**Payload:** `FederationPresenceUpdatePayload`:
- `homeUserId`, `homeInstance`
- `status: 'online' | 'idle' | 'dnd' | 'offline'`
- `activities?: Activity[]` (omitted when empty)
- `ts: number` — emitter clock (last-write-wins per stub if needed)
**Outbox-only — never written to mutation log.** Presence is ephemeral. Replaying old presence on peer activation would be wrong (stale state). The outbox queues directly without `appendMutationLog`. Stale entries that fail delivery beyond retry budget are dropped.
**Sender call sites** (all in `utils/federationPresence.ts:queuePresenceRelay`):
- `ws/handler.ts` (auth path) — `online`
- `ws/handler.ts` (`finalizeDisconnect`) — `offline`
- `ws/events.ts` (`handlePresenceUpdate`) — manual `online`/`idle`/`dnd`
- `ws/events.ts` (`handleActivityUpdate`) — when activities change
- `routes/users.ts` (showActivity-toggle clear) — cleared activities
No-op for replicated users (we don't own their presence).
**Targeting:** broadcast to all active peers (mirrors `profile_update`). Peers without a stub silently no-op. Privacy: status is already public to anyone authorized to see the user via friend/DM/space relationships, so broadcast-fanout adds no new disclosure surface.
**Coalescing:** `entityId = userId`, `contextId = userId`. Rapid status flaps coalesce to the latest queued event per peer.
**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.
**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.
- **Flap recovery semantics:** `onPeerActivated` re-runs on every transition into `active`, including the 15-minute health-check `unreachable → active` recovery. This is load-bearing for correctness: `markPeerStubsOffline` ran on the prior deactivation, presence is not in the mutation log, so a fresh snapshot is the only signal that re-establishes truth. The relationship-scoped query bounds the cost.
#### Stub Username Backfill
Legacy stubs (created before the realname scheme shipped) used `${homeUserId}@${domain}` as the local username. The current scheme uses `${realname}@${domain}` (`hints.username` in `resolveOrCreateReplicatedUser`). Backfill heals existing legacy rows.
**Worker:** `utils/federationStubBackfill.ts:backfillStubUsernamesForPeer(peerOrigin)`. Enumerates legacy-shaped stubs whose `home_instance` matches the peer's domain, asks the peer for the canonical username via `lookupRemoteUserByHomeId`, rewrites the stub's username and (if displayName is null) seeds displayName from the same fallback. Idempotent and collision-safe.
**Hook points:**
- `onPeerActivated` — runs per-peer on every transition to `active` (catches stubs whose home was unreachable on a prior pass).
- `startupBootstrapSync` — one-shot pass at boot for ALL currently-active peers (not just `lastSyncedAt = 0` first-time peers).
**New endpoint: `POST /api/federation/users/by-home-id`** (HMAC-authenticated, rate-limited 60/min/peer). Body: `{ homeUserId: string }`. Response: `{ found: false }` or `{ found: true, user: { homeUserId, username, profile: { displayName, avatar, avatarColor, banner, bio } } }`. Native non-deleted users only.
--- ---
## 11. Reaction Relay ## 11. Reaction Relay
+1 -1
View File
@@ -500,7 +500,7 @@ All handlers also update `discoverStore` relationship state via lazy import.
| WS Event | Store Method | Effect | | WS Event | Store Method | Effect |
|----------|-------------|--------| |----------|-------------|--------|
| `presence_update` | `updateFriendPresence(userId, status)` | Updates `status` field on matching friend by ID (all origins) | | `presence_update` | `updateFriendPresence(userId, status)` | Updates `status` on matching friend by ID (all origins). Server broadcasts to friends + DM co-members + space co-members (`collectProfileBroadcastTargetIds`). For federated friends, status is projected by the home instance via S2S `presence_update` relay (see `federation.md` §10 — Presence Sync) and broadcast to the same recipient set on the receiving instance. |
| `user_updated` | `updateFriendProfile(user)` | Updates displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status on matching friend by ID | | `user_updated` | `updateFriendProfile(user)` | Updates displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status on matching friend by ID |
--- ---
+2 -2
View File
@@ -12,7 +12,7 @@ Source: `packages/server/src/ws/handler.ts`, `packages/server/src/ws/events.ts`
2. Client sends `{ type: 'auth', token: '<jwt>' }` within 10 seconds 2. Client sends `{ type: 'auth', token: '<jwt>' }` within 10 seconds
3. Server validates token (rejects deleted users, tokens issued before `passwordChangedAt`) 3. Server validates token (rejects deleted users, tokens issued before `passwordChangedAt`)
4. Server responds with `ready` event containing full client state 4. Server responds with `ready` event containing full client state
5. Server updates user status to `online`, broadcasts `presence_update` to all user's spaces 5. Server updates user status to `online`, broadcasts `presence_update` to friends + DM co-members + space co-members (via `collectProfileBroadcastTargetIds`); for native users, also queues a S2S `presence_update` relay to all active peers
6. Heartbeat: server pings every 30s (RFC 6455 ping frames), dead connections detected after ~65s 6. Heartbeat: server pings every 30s (RFC 6455 ping frames), dead connections detected after ~65s
--- ---
@@ -123,7 +123,7 @@ Source: `packages/server/src/ws/handler.ts`, `packages/server/src/ws/events.ts`
### Presence & Activity ### Presence & Activity
| type | fields | scope | | type | fields | scope |
|------|--------|-------| |------|--------|-------|
| `presence_update` | userId, status, activities? | space (all members) | | `presence_update` | userId, status, activities? | friends + DM co-members + space co-members of the user (via `collectProfileBroadcastTargetIds`), plus self for multi-tab sync. For federated stubs, the local instance receives status via S2S `presence_update` relay from the home (see `federation.md` §10 — Presence Sync) and re-broadcasts to the same recipient set. |
| `user_updated` | user | user | | `user_updated` | user | user |
### Space / Channel Management ### Space / Channel Management
@@ -0,0 +1,166 @@
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 { eq } from 'drizzle-orm';
import type { FederationRelayEvent } from '@backspace/shared';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
const sentToUserCalls: Array<{ userId: string; payload: any }> = [];
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn((uid: string, p: any) => sentToUserCalls.push({ userId: uid, payload: p })),
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);
sentToUserCalls.length = 0;
// Local user (youruser) and replicated stub (pbtest3) — they're friends.
testDb.insert(schema.users).values([
{
id: 'local-youruser', username: 'youruser', passwordHash: 'x', status: 'online', isAdmin: 0,
homeUserId: 'local-youruser', createdAt: Date.now(),
},
{
id: 'stub-pbtest3', username: 'pbtest3@orbit.ddns.net', displayName: 'pbtest3',
passwordHash: '!federation-replicated', status: 'offline', isAdmin: 0,
homeInstance: 'orbit.ddns.net', homeUserId: 'home-pbtest3', createdAt: Date.now(),
},
]).run();
testDb.insert(schema.friends).values({
userId: 'local-youruser', friendId: 'stub-pbtest3', createdAt: Date.now(),
}).run();
});
describe('processPresenceUpdateEvent', () => {
it('updates stub status and broadcasts presence_update to local friends', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'presence_update',
contextType: 'profile',
messageId: 'p1',
encryptionVersion: 0,
timestamp: Date.now(),
presenceUpdate: {
homeUserId: 'home-pbtest3',
homeInstance: 'orbit.ddns.net',
status: 'online',
ts: Date.now(),
},
};
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processPresenceUpdateEvent(event, 'orbit.ddns.net', testDb, accepted, rejected);
expect(rejected).toEqual([]);
expect(accepted).toEqual(['p1']);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-pbtest3')).get();
expect(row!.status).toBe('online');
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-youruser');
expect(broadcast).toBeDefined();
expect(broadcast!.payload.type).toBe('presence_update');
expect(broadcast!.payload.userId).toBe('stub-pbtest3');
expect(broadcast!.payload.status).toBe('online');
});
it('rejects on attribution mismatch', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'presence_update', contextType: 'profile', messageId: 'p2',
encryptionVersion: 0, timestamp: Date.now(),
presenceUpdate: {
homeUserId: 'home-pbtest3', homeInstance: 'orbit.ddns.net',
status: 'online', ts: Date.now(),
},
};
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processPresenceUpdateEvent(event, 'evil.example.com', testDb, [], rejected);
expect(rejected).toEqual([{ messageId: 'p2', reason: 'attribution_mismatch' }]);
});
it('silently accepts when no replica exists locally', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'presence_update', contextType: 'profile', messageId: 'p3',
encryptionVersion: 0, timestamp: Date.now(),
presenceUpdate: {
homeUserId: 'unknown-id', homeInstance: 'orbit.ddns.net',
status: 'online', ts: Date.now(),
},
};
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processPresenceUpdateEvent(event, 'orbit.ddns.net', testDb, accepted, rejected);
expect(accepted).toEqual(['p3']);
expect(rejected).toEqual([]);
});
it('rejects invalid status values', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'presence_update', contextType: 'profile', messageId: 'p4',
encryptionVersion: 0, timestamp: Date.now(),
presenceUpdate: {
homeUserId: 'home-pbtest3', homeInstance: 'orbit.ddns.net',
status: 'invisible' as any, ts: Date.now(),
},
};
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processPresenceUpdateEvent(event, 'orbit.ddns.net', testDb, [], rejected);
expect(rejected).toEqual([{ messageId: 'p4', reason: 'invalid_status' }]);
});
it('passes activities through to the WS broadcast when present', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'presence_update', contextType: 'profile', messageId: 'p5',
encryptionVersion: 0, timestamp: Date.now(),
presenceUpdate: {
homeUserId: 'home-pbtest3', homeInstance: 'orbit.ddns.net',
status: 'online', activities: [{ type: 'playing', name: 'Test' }],
ts: Date.now(),
},
};
fed.processPresenceUpdateEvent(event, 'orbit.ddns.net', testDb, [], []);
const broadcast = sentToUserCalls.find((c) => c.userId === 'local-youruser');
expect(broadcast!.payload.activities).toEqual([{ type: 'playing', name: 'Test' }]);
});
});
@@ -0,0 +1,124 @@
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 { eq } from 'drizzle-orm';
import type { FederationRelayEvent } from '@backspace/shared';
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,
}));
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);
// Stub seeded with displayName='pbtest3' (set previously by hydrateReplicatedUserProfile)
testDb.insert(schema.users).values({
id: 'stub-1',
username: 'pbtest3@orbit.ddns.net',
displayName: 'pbtest3',
passwordHash: '!federation-replicated',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.ddns.net',
homeUserId: 'home-1',
profileUpdatedAt: 1000,
createdAt: Date.now(),
}).run();
});
describe('processProfileUpdateEvent — displayName fallback', () => {
it('falls back to username when home displayName is null', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'profile_update',
contextType: 'profile',
messageId: 'm1',
encryptionVersion: 0,
timestamp: Date.now(),
profileUpdate: {
homeUserId: 'home-1',
homeInstance: 'orbit.ddns.net',
profileUpdatedAt: 2000,
username: 'pbtest3',
displayName: null,
avatar: null,
banner: null,
accentColor: null,
avatarColor: null,
bio: null,
},
};
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
await fed.processProfileUpdateEvent(event, 'orbit.ddns.net', testDb, accepted, rejected);
expect(rejected).toEqual([]);
expect(accepted).toEqual(['m1']);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get();
expect(row!.displayName).toBe('pbtest3'); // fell back to username, not clobbered to null
});
it('uses home displayName when provided (not the fallback)', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'profile_update',
contextType: 'profile',
messageId: 'm2',
encryptionVersion: 0,
timestamp: Date.now(),
profileUpdate: {
homeUserId: 'home-1',
homeInstance: 'orbit.ddns.net',
profileUpdatedAt: 3000,
username: 'pbtest3',
displayName: 'Peter B.',
avatar: null,
banner: null,
accentColor: null,
avatarColor: null,
bio: null,
},
};
await fed.processProfileUpdateEvent(event, 'orbit.ddns.net', testDb, [], []);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get();
expect(row!.displayName).toBe('Peter B.');
});
});
@@ -0,0 +1,86 @@
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 { eq } from 'drizzle-orm';
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(),
}));
// 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;
});
describe('resolveOrCreateReplicatedUser — stub username', () => {
it('creates stub with realname@domain when hint provides username', async () => {
const { resolveOrCreateReplicatedUser } = await import('./federation.js');
const created = resolveOrCreateReplicatedUser(
'310002371434024960',
'orbit.ddns.net',
testDb,
{ username: 'pbtest3' },
);
expect(created).not.toBeNull();
expect(created!.username).toBe('pbtest3@orbit.ddns.net');
expect(created!.homeUserId).toBe('310002371434024960');
expect(created!.homeInstance).toBe('orbit.ddns.net');
});
it('falls back to homeUserId@domain when no hint provided', async () => {
const { resolveOrCreateReplicatedUser } = await import('./federation.js');
const created = resolveOrCreateReplicatedUser(
'310002371434024960',
'orbit.ddns.net',
testDb,
);
expect(created!.username).toBe('310002371434024960@orbit.ddns.net');
});
});
+214 -20
View File
@@ -2330,6 +2330,96 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
avatarColor: user.avatarColor, avatarColor: user.avatarColor,
banner: user.banner, banner: user.banner,
bio: user.bio, bio: user.bio,
status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null,
},
},
});
},
);
// ─── POST /api/federation/users/by-home-id ──────────────────────────────────
// Server-to-server: reverse-lookup a homeUserId to its canonical username +
// profile snapshot. Used by the stub-username backfill worker on peers that
// hold legacy snowflake-named replicas of users now visible by their real
// handle. Same auth+rate-limit shape as /users/lookup.
app.post<{ Body: { homeUserId?: unknown } }>(
'/api/federation/users/by-home-id',
{ bodyLimit: 4 * 1024 },
async (request, reply) => {
const db = getDb();
// 1. Verify HMAC headers
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. Validate body
const rawId = (request.body as { homeUserId?: unknown } | null)?.homeUserId;
if (typeof rawId !== 'string' || rawId.trim().length === 0) {
return reply.code(400).send({ error: 'homeUserId is required (string)', statusCode: 400 });
}
const homeUserId = rawId.trim();
// 3. Native-only lookup. Match by id (canonical native id) OR home_user_id
// (backfilled column natives carry to satisfy tier-1 lookups). Excludes
// tombstoned and replicated stubs.
const user = db
.select()
.from(schema.users)
.where(
and(
eq(schema.users.isDeleted, 0),
isNull(schema.users.homeInstance),
or(eq(schema.users.id, homeUserId), eq(schema.users.homeUserId, homeUserId)),
),
)
.get();
if (!user) {
return reply.code(200).send({ found: false });
}
return reply.code(200).send({
found: true,
user: {
homeUserId: user.homeUserId ?? user.id,
username: user.username,
profile: {
displayName: user.displayName,
avatar: user.avatar,
avatarColor: user.avatarColor,
status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null,
banner: user.banner,
bio: user.bio,
}, },
}, },
}); });
@@ -2916,6 +3006,9 @@ export async function processRelayEvents(
case 'profile_update': case 'profile_update':
await processProfileUpdateEvent(event, sourceInstance, db, accepted, rejected); await processProfileUpdateEvent(event, sourceInstance, db, accepted, rejected);
break; break;
case 'presence_update':
processPresenceUpdateEvent(event, sourceInstance, db, accepted, rejected);
break;
case 'read_state_update': case 'read_state_update':
processReadStateUpdateEvent(event, sourceInstance, db, accepted, rejected); processReadStateUpdateEvent(event, sourceInstance, db, accepted, rejected);
break; break;
@@ -3162,7 +3255,7 @@ export function resolveOrCreateReplicatedUser(
homeUserId: string, homeUserId: string,
homeInstance: string, homeInstance: string,
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
hints?: { username?: string | null }, hints?: { username?: string | null; status?: 'online' | 'idle' | 'dnd' | 'offline' | null },
): typeof schema.users.$inferSelect | null { ): typeof schema.users.$inferSelect | null {
const existing = findFederatedUser(homeUserId, homeInstance, db, hints); const existing = findFederatedUser(homeUserId, homeInstance, db, hints);
if (existing) return backfillHomeUserId(existing, homeUserId, db); if (existing) return backfillHomeUserId(existing, homeUserId, db);
@@ -3181,9 +3274,13 @@ export function resolveOrCreateReplicatedUser(
return null; return null;
} }
// Use the snowflake-style homeUserId as the local part; append the // Use the home user's real username when the caller passes a hint (the wire
// domain so the username is globally unique and human-readable. // profile snapshot from friend_request_create / friend_add / DM relay carries
const baseUsername = `${homeUserId}@${domain}`.toLowerCase(); // it). This makes the local stub's `username` human-readable, so client-side
// `parseFederatedUsername(username).baseName` returns the real handle. Falls
// back to the snowflake-id scheme when no hint is available (legacy paths).
const localPart = (hints?.username ?? homeUserId).toLowerCase();
const baseUsername = `${localPart}@${domain}`.toLowerCase();
// Guard against the (unlikely) case where this username already // Guard against the (unlikely) case where this username already
// exists — e.g. a prior partial replication or manual creation. // exists — e.g. a prior partial replication or manual creation.
@@ -3192,11 +3289,11 @@ export function resolveOrCreateReplicatedUser(
let attempt = 0; let attempt = 0;
while (collision) { while (collision) {
attempt++; attempt++;
username = `${homeUserId}_${attempt}@${domain}`.toLowerCase(); username = `${localPart}_${attempt}@${domain}`.toLowerCase();
collision = db.select().from(schema.users).where(eq(schema.users.username, username)).get(); collision = db.select().from(schema.users).where(eq(schema.users.username, username)).get();
if (attempt > 10) { if (attempt > 10) {
// Extremely unlikely; use a random suffix to break out // Extremely unlikely; use a random suffix to break out
username = `${homeUserId}_${randomBytes(4).toString('hex')}@${domain}`.toLowerCase(); username = `${localPart}_${randomBytes(4).toString('hex')}@${domain}`.toLowerCase();
break; break;
} }
} }
@@ -3204,12 +3301,18 @@ export function resolveOrCreateReplicatedUser(
const userId = generateSnowflake(); const userId = generateSnowflake();
const now = Date.now(); const now = Date.now();
// Seed status from the wire snapshot when available — without this, a
// freshly-created stub for an already-online remote sticks at 'offline'
// until the home next emits a presence transition (presence_update only
// fires on changes, not on stub creation). Falls back to 'offline'.
const initialStatus = hints?.status ?? 'offline';
db.insert(schema.users).values({ db.insert(schema.users).values({
id: userId, id: userId,
username, username,
displayName: null, displayName: null,
passwordHash: '!federation-replicated', // Cannot be used to log in (bcrypt never produces this) passwordHash: '!federation-replicated', // Cannot be used to log in (bcrypt never produces this)
status: 'offline', status: initialStatus,
isAdmin: 0, isAdmin: 0,
homeInstance: domain, // Normalized to bare domain homeInstance: domain, // Normalized to bare domain
homeUserId, homeUserId,
@@ -3395,7 +3498,7 @@ async function processCreateEvent(
}> = []; }> = [];
for (const p of event.participants) { for (const p of event.participants) {
let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username }); let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username, status: p.profile?.status });
// Skip deleted identities — don't include tombstoned users in the DM // Skip deleted identities — don't include tombstoned users in the DM
if (!localUser) continue; if (!localUser) continue;
// Hydrate with profile data from the relay event (displayName, avatar, etc.) // Hydrate with profile data from the relay event (displayName, avatar, etc.)
@@ -3990,7 +4093,7 @@ function processMemberAddEvent(
// Resolve owner — create a replicated stub if unknown // Resolve owner — create a replicated stub if unknown
let ownerId: string | null = null; let ownerId: string | null = null;
if (event.group.owner) { if (event.group.owner) {
const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username }); const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username, status: event.group.owner.profile?.status });
ownerId = ownerLocal?.id ?? null; ownerId = ownerLocal?.id ?? null;
} }
@@ -4008,7 +4111,7 @@ function processMemberAddEvent(
// Add all roster members — create replicated user stubs for any // Add all roster members — create replicated user stubs for any
// participants from remote instances that haven't been seen before. // participants from remote instances that haven't been seen before.
for (const member of event.group.members) { for (const member of event.group.members) {
const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username }); const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username, status: member.profile?.status });
// Skip deleted identities — tombstoned users can't be added to a DM // Skip deleted identities — tombstoned users can't be added to a DM
if (!rosterUser) continue; if (!rosterUser) continue;
const existing = db.select().from(schema.dmMembers) const existing = db.select().from(schema.dmMembers)
@@ -4062,7 +4165,7 @@ function processMemberAddEvent(
event.membership.user.homeUserId, event.membership.user.homeUserId,
event.membership.user.homeInstance, event.membership.user.homeInstance,
db, db,
{ username: event.membership.user.profile?.username }, { username: event.membership.user.profile?.username, status: event.membership.user.profile?.status },
); );
if (!localUser) { if (!localUser) {
// The user's identity has been deleted — don't add a tombstoned user to the DM // The user's identity has been deleted — don't add a tombstoned user to the DM
@@ -4101,7 +4204,7 @@ function processMemberAddEvent(
// would otherwise find the channel already present and fall through to the incremental path, // would otherwise find the channel already present and fall through to the incremental path,
// creating spurious system messages (the exact bug this fixes). // creating spurious system messages (the exact bug this fixes).
const actorUser = event.membership.addedBy const actorUser = event.membership.addedBy
? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username }) ? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username, status: event.membership.addedBy.profile?.status })
: null; : null;
const actorId = actorUser?.id ?? localUser.id; const actorId = actorUser?.id ?? localUser.id;
const addBaseName = localUser.username?.includes('@') ? localUser.username.split('@')[0] : (localUser.username ?? 'Unknown'); const addBaseName = localUser.username?.includes('@') ? localUser.username.split('@')[0] : (localUser.username ?? 'Unknown');
@@ -4392,7 +4495,7 @@ function processOwnershipTransferEvent(
event.ownership.newOwner.homeUserId, event.ownership.newOwner.homeUserId,
event.ownership.newOwner.homeInstance, event.ownership.newOwner.homeInstance,
db, db,
{ username: event.ownership.newOwner.profile?.username }, { username: event.ownership.newOwner.profile?.username, status: event.ownership.newOwner.profile?.status },
); );
if (!newOwnerLocal) { if (!newOwnerLocal) {
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' }); rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
@@ -4551,7 +4654,7 @@ async function processFriendRequestCreateEvent(
} }
// Resolve the sender (create stub if needed — they're on a remote instance) // 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 }); const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status });
if (!fromUserResolved) { if (!fromUserResolved) {
// Sender's identity has been deleted — silently accept to drop the event // Sender's identity has been deleted — silently accept to drop the event
accepted.push(event.messageId); accepted.push(event.messageId);
@@ -4669,7 +4772,7 @@ function processFriendRequestUpdateEvent(
} }
// Resolve the recipient (create stub if needed — they're on the remote instance) // 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 }); const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status });
if (!toUser) { if (!toUser) {
// Recipient's identity has been deleted — accept idempotently to drop the event // Recipient's identity has been deleted — accept idempotently to drop the event
accepted.push(event.messageId); accepted.push(event.messageId);
@@ -4809,14 +4912,14 @@ async function processFriendAddEvent(
} }
// Resolve both users (create stubs if needed) and hydrate with profile data // 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 }); const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status });
if (!fromUserResolved) { if (!fromUserResolved) {
// One party's identity is deleted — accept idempotently to drop the event // One party's identity is deleted — accept idempotently to drop the event
accepted.push(event.messageId); accepted.push(event.messageId);
return; return;
} }
let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db); let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db);
const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status });
if (!toUserResolved) { if (!toUserResolved) {
accepted.push(event.messageId); accepted.push(event.messageId);
return; return;
@@ -5621,7 +5724,7 @@ async function downloadProfileAsset(
} }
} }
async function processProfileUpdateEvent( export async function processProfileUpdateEvent(
event: FederationRelayEvent, event: FederationRelayEvent,
sourceInstance: string, sourceInstance: string,
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
@@ -5713,10 +5816,17 @@ async function processProfileUpdateEvent(
deleteUploadFile(oldBanner); deleteUploadFile(oldBanner);
} }
// Authoritative overwrite — home instance is always right // Authoritative overwrite — home instance is always right.
// displayName falls back to the home user's canonical username when null,
// mirroring hydrateReplicatedUserProfile so stubs whose home user has no
// displayName show the real handle instead of getting clobbered to null.
// (The username field on the wire is the home's canonical handle, not the
// stub's local-part; usernames are immutable on the home instance, so we
// never rewrite the stub's username column here.)
const effectiveDisplayName = payload.displayName ?? payload.username ?? null;
db.update(schema.users) db.update(schema.users)
.set({ .set({
displayName: payload.displayName, displayName: effectiveDisplayName,
avatar: resolvedAvatar, avatar: resolvedAvatar,
banner: resolvedBanner, banner: resolvedBanner,
accentColor: payload.accentColor, accentColor: payload.accentColor,
@@ -5742,6 +5852,90 @@ async function processProfileUpdateEvent(
accepted.push(event.messageId); accepted.push(event.messageId);
} }
/**
* Inbound presence_update relay handler.
*
* Authority: home instance is exclusive. payload.homeInstance domain MUST equal
* the source peer's domain (attribution check, mirrors profile_update).
*
* Effect on success:
* 1. Update the local stub's status column.
* 2. Broadcast a WS presence_update to local users via collectProfileBroadcastTargetIds
* (friends + DM members + space co-members), so the green dot updates without a
* page refresh on every connected client that knows this user.
*
* Edge cases:
* - No local replica → silently accept (peer broadcasts presence to all peers,
* not all peers have a stub).
* - homeInstance domain mismatch on the existing stub → ignore (collision against
* a stub of a different identity).
* - Invalid status string → reject; sender is buggy, surface for diagnosis.
*/
export function processPresenceUpdateEvent(
event: FederationRelayEvent,
sourceInstance: string,
db: ReturnType<typeof getDb>,
accepted: string[],
rejected: Array<{ messageId: string; reason: string }>,
): void {
const payload = event.presenceUpdate;
if (!payload) {
rejected.push({ messageId: event.messageId, reason: 'missing_presence_update_payload' });
return;
}
const payloadDomain = extractDomain(payload.homeInstance);
const sourceDomain = extractDomain(sourceInstance);
if (payloadDomain !== sourceDomain) {
console.warn(`[federation] Attribution mismatch in presence_update: homeInstance=${payloadDomain} source=${sourceDomain}`);
rejected.push({ messageId: event.messageId, reason: 'attribution_mismatch' });
return;
}
if (!payload.status || !['online', 'idle', 'dnd', 'offline'].includes(payload.status)) {
rejected.push({ messageId: event.messageId, reason: 'invalid_status' });
return;
}
const localUser = db
.select()
.from(schema.users)
.where(and(
eq(schema.users.homeUserId, payload.homeUserId),
eq(schema.users.isDeleted, 0),
))
.get();
if (!localUser) {
accepted.push(event.messageId);
return;
}
if (localUser.homeInstance && extractDomain(localUser.homeInstance) !== payloadDomain) {
accepted.push(event.messageId);
return;
}
db.update(schema.users)
.set({ status: payload.status })
.where(eq(schema.users.id, localUser.id))
.run();
// Broadcast presence_update WS event to local users who care.
const targetUserIds = collectProfileBroadcastTargetIds(localUser.id);
const wsPayload = {
type: 'presence_update' as const,
userId: localUser.id,
status: payload.status,
...(payload.activities && payload.activities.length > 0 ? { activities: payload.activities } : {}),
};
for (const uid of targetUserIds) {
connectionManager.sendToUser(uid, wsPayload);
}
accepted.push(event.messageId);
}
// ─── Replicated Profile Asset Backfill ────────────────────────────────────── // ─── Replicated Profile Asset Backfill ──────────────────────────────────────
/** /**
@@ -169,9 +169,12 @@ describe('POST /api/social/requests — federated branch (happy path)', () => {
expect(sentEvent).toBeDefined(); expect(sentEvent).toBeDefined();
expect(sentEvent![0]).toBe(CALLER_ID); expect(sentEvent![0]).toBe(CALLER_ID);
expect(sentEvent![1].request.id).toBe(body.requestId); expect(sentEvent![1].request.id).toBe(body.requestId);
// homeUserId identifies the target; username is the canonical stub form (<homeUserId>@<host>). // homeUserId identifies the target; username is the realname-based stub form
// (<lookup.username>@<host>) since resolveOrCreateReplicatedUser now uses the
// username hint from the wire profile snapshot. Falls back to <homeUserId>@<host>
// only when no hint is available.
expect(sentEvent![1].request.user.homeUserId).toBe('remote-alice'); expect(sentEvent![1].request.user.homeUserId).toBe('remote-alice');
expect(sentEvent![1].request.user.username).toBe('remote-alice@orbit.test'); expect(sentEvent![1].request.user.username).toBe('alice@orbit.test');
}); });
}); });
+8 -1
View File
@@ -21,6 +21,11 @@ import type {
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot { function buildProfileSnapshot(user: typeof schema.users.$inferSelect): FederationRelayProfileSnapshot {
// 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
? (user.status as 'online' | 'idle' | 'dnd' | 'offline')
: null;
return { return {
username: user.username ?? null, username: user.username ?? null,
displayName: user.displayName ?? null, displayName: user.displayName ?? null,
@@ -28,6 +33,7 @@ function buildProfileSnapshot(user: typeof schema.users.$inferSelect): Federatio
avatarColor: user.avatarColor ?? null, avatarColor: user.avatarColor ?? null,
banner: user.banner ?? null, banner: user.banner ?? null,
bio: user.bio ?? null, bio: user.bio ?? null,
status,
}; };
} }
@@ -237,7 +243,7 @@ async function handleFederatedFriendRequest(
} }
// 5. Resolve / hydrate stub // 5. Resolve / hydrate stub
const stub = resolveOrCreateReplicatedUser(lookup.homeUserId, targetDomain, db, { username: lookup.username }); const stub = resolveOrCreateReplicatedUser(lookup.homeUserId, targetDomain, db, { username: lookup.username, status: lookup.profile.status });
if (!stub) { if (!stub) {
// Tombstoned identity — refuse to resurrect. // Tombstoned identity — refuse to resurrect.
return reply.code(404).send({ error: 'user_not_found', statusCode: 404, domain: targetDomain, handle: baseName }); return reply.code(404).send({ error: 'user_not_found', statusCode: 404, domain: targetDomain, handle: baseName });
@@ -308,6 +314,7 @@ async function handleFederatedFriendRequest(
avatarColor: lookup.profile.avatarColor, avatarColor: lookup.profile.avatarColor,
banner: lookup.profile.banner, banner: lookup.profile.banner,
bio: lookup.profile.bio, bio: lookup.profile.bio,
status: lookup.profile.status ?? null,
}, },
status: 'pending', status: 'pending',
createdAt: now, createdAt: now,
+21 -15
View File
@@ -362,6 +362,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
homeUserId: preUpdateUser.id, homeUserId: preUpdateUser.id,
homeInstance: origin, homeInstance: origin,
profileUpdatedAt: preUpdateUser.profileUpdatedAt ?? Date.now(), profileUpdatedAt: preUpdateUser.profileUpdatedAt ?? Date.now(),
username: preUpdateUser.username,
displayName: preUpdateUser.displayName, displayName: preUpdateUser.displayName,
avatar: preUpdateUser.avatar && !preUpdateUser.avatar.startsWith('http') avatar: preUpdateUser.avatar && !preUpdateUser.avatar.startsWith('http')
? `${origin}/api/uploads/${preUpdateUser.avatar}` ? `${origin}/api/uploads/${preUpdateUser.avatar}`
@@ -435,17 +436,26 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
connectionManager.setUserShowActivity(request.userId, showActivity); connectionManager.setUserShowActivity(request.userId, showActivity);
if (!showActivity) { if (!showActivity) {
connectionManager.clearUserActivities(request.userId); connectionManager.clearUserActivities(request.userId);
const userSpaces = connectionManager.getUserSpaces(request.userId);
const clearPayload = { const clearPayload = {
type: 'presence_update' as const, type: 'presence_update' as const,
userId: request.userId, userId: request.userId,
status: connectionManager.getUserStatus(request.userId), status: connectionManager.getUserStatus(request.userId),
activities: [] as Activity[], activities: [] as Activity[],
}; };
for (const spaceId of userSpaces) { const clearTargets = collectProfileBroadcastTargetIds(request.userId);
connectionManager.sendToSpace(spaceId, clearPayload, request.userId); for (const uid of clearTargets) connectionManager.sendToUser(uid, clearPayload);
}
connectionManager.sendToUser(request.userId, clearPayload); connectionManager.sendToUser(request.userId, clearPayload);
// S2S: project the cleared-activities snapshot to all active peers.
void import('../utils/federationPresence.js').then(({ queuePresenceRelay }) => {
try {
queuePresenceRelay(
request.userId,
(connectionManager.getUserStatus(request.userId) ?? 'online') as 'online' | 'idle' | 'dnd' | 'offline',
[],
);
} catch (e) { console.warn('[users] queuePresenceRelay(showActivity-clear) failed', e); }
});
} }
} }
@@ -486,19 +496,14 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
// Broadcast presence update if status changed // Broadcast presence update if status changed
if (status !== undefined) { if (status !== undefined) {
const userSpaces = connectionManager.getUserSpaces(sanitized.id); const statusPayload = {
for (const spaceId of userSpaces) { type: 'presence_update' as const,
connectionManager.sendToSpace(spaceId, {
type: 'presence_update',
userId: sanitized.id,
status: status,
}, sanitized.id);
}
connectionManager.sendToUser(sanitized.id, {
type: 'presence_update',
userId: sanitized.id, userId: sanitized.id,
status: status, status: status,
}); };
const statusTargets = collectProfileBroadcastTargetIds(sanitized.id);
for (const uid of statusTargets) connectionManager.sendToUser(uid, statusPayload);
connectionManager.sendToUser(sanitized.id, statusPayload);
} }
// Broadcast user_updated for profile field changes // Broadcast user_updated for profile field changes
@@ -524,6 +529,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
homeUserId: updatedUser!.id, homeUserId: updatedUser!.id,
homeInstance: origin, homeInstance: origin,
profileUpdatedAt: updatedUser!.profileUpdatedAt ?? Date.now(), profileUpdatedAt: updatedUser!.profileUpdatedAt ?? Date.now(),
username: updatedUser!.username,
displayName: updatedUser!.displayName, displayName: updatedUser!.displayName,
avatar: updatedUser!.avatar && !updatedUser!.avatar.startsWith('http') avatar: updatedUser!.avatar && !updatedUser!.avatar.startsWith('http')
? `${origin}/api/uploads/${updatedUser!.avatar}` ? `${origin}/api/uploads/${updatedUser!.avatar}`
@@ -75,3 +75,65 @@ export async function lookupRemoteUser(peerOrigin: string, username: string): Pr
profile: json.user.profile, profile: json.user.profile,
}; };
} }
/**
* Reverse-lookup: ask the peer for a user by homeUserId. Used by the stub
* backfill worker to translate legacy snowflake-named stubs into realname-named
* stubs. Mirrors lookupRemoteUser's auth + error semantics.
*
* `not_found` here means "the peer does not host a native non-deleted user
* with that homeUserId" — including the tombstone case. Caller should leave
* the local stub untouched and retry on the next peer activation.
*/
export async function lookupRemoteUserByHomeId(peerOrigin: string, homeUserId: string): Promise<LookupResult> {
const db = getDb();
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, peerOrigin))
.get();
if (!peer) {
throw new Error(`lookupRemoteUserByHomeId: no peer record for ${peerOrigin}`);
}
const body = JSON.stringify({ homeUserId });
const headers = buildFederationHeaders(body, peer.hmacSecret, getOurOrigin());
let response: Response;
try {
response = await fetch(`${peerOrigin}/api/federation/users/by-home-id`, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
});
} catch {
return { ok: false, reason: 'unreachable' };
}
if (response.status === 429) {
const raw = Number(response.headers.get('Retry-After') ?? '60');
const retryAfter = Number.isFinite(raw) ? raw : 60;
return { ok: false, reason: 'rate_limited', retryAfter };
}
if (!response.ok) {
throw new Error(`lookupRemoteUserByHomeId: peer ${peerOrigin} returned HTTP ${response.status}`);
}
const json = (await response.json()) as FederationUserLookupResponse;
if (!json) {
throw new Error(`lookupRemoteUserByHomeId: peer ${peerOrigin} returned empty body`);
}
if (json.found !== true || !json.user || typeof json.user.homeUserId !== 'string') {
return { ok: false, reason: 'not_found' };
}
return {
ok: true,
homeUserId: json.user.homeUserId,
username: json.user.username,
profile: json.user.profile,
};
}
@@ -346,6 +346,7 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa
displayName: schema.users.displayName, displayName: schema.users.displayName,
avatar: schema.users.avatar, avatar: schema.users.avatar,
avatarColor: schema.users.avatarColor, avatarColor: schema.users.avatarColor,
status: schema.users.status,
}) })
.from(schema.dmMembers) .from(schema.dmMembers)
.innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id))
@@ -362,6 +363,9 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa
displayName: m.displayName ?? null, displayName: m.displayName ?? null,
avatar: m.avatar ?? null, avatar: m.avatar ?? null,
avatarColor: m.avatarColor ?? 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,
}, },
})); }));
} }
@@ -52,6 +52,29 @@ export async function onPeerActivated(
resetOutboxBackoff(peerId); resetOutboxBackoff(peerId);
await syncPeerMutationLog(peerId, reason); await syncPeerMutationLog(peerId, reason);
await fanoutOutboundSubscribers(peerId); await fanoutOutboundSubscribers(peerId);
// Look up the peer's origin once for the post-sync invariants.
const peerRow = getDb()
.select({ origin: schema.federationPeers.origin })
.from(schema.federationPeers)
.where(eq(schema.federationPeers.id, peerId))
.get();
if (peerRow?.origin) {
const { backfillStubUsernamesForPeer } = await import('./federationStubBackfill.js');
await backfillStubUsernamesForPeer(peerRow.origin).catch((e) => {
console.warn(`[onPeerActivated] backfillStubUsernamesForPeer(${peerRow.origin}) failed`, e);
});
// Re-emit a fresh presence snapshot to the activating peer so its stubs
// of our online natives reflect current reality. Necessary because
// presence is outbox-only (no mutation-log replay), and any prior
// markPeerStubsOffline ran on our side too.
const { snapshotPresenceForPeer } = await import('./federationPresence.js');
try { snapshotPresenceForPeer(peerRow.origin); } catch (e) {
console.warn(`[onPeerActivated] snapshotPresenceForPeer(${peerRow.origin}) failed`, e);
}
}
const { connectionManager } = await import('../ws/handler.js'); const { connectionManager } = await import('../ws/handler.js');
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
} catch (err) { } catch (err) {
@@ -274,15 +297,31 @@ export async function startupBootstrapSync(): Promise<void> {
if (!isFederationRelayEnabled()) return; if (!isFederationRelayEnabled()) return;
const db = getDb(); const db = getDb();
const peers = db.select().from(schema.federationPeers) const firstTimePeers = db.select().from(schema.federationPeers)
.where(and( .where(and(
eq(schema.federationPeers.status, 'active'), eq(schema.federationPeers.status, 'active'),
eq(schema.federationPeers.lastSyncedAt, 0), eq(schema.federationPeers.lastSyncedAt, 0),
)).all(); )).all();
for (const peer of peers) { for (const peer of firstTimePeers) {
await onPeerActivated(peer.id, 'startup_bootstrap'); await onPeerActivated(peer.id, 'startup_bootstrap');
} }
// One-shot stub-username backfill for ALL currently-active peers (including
// those with lastSyncedAt > 0). Heals legacy snowflake-named stubs created
// before resolveOrCreateReplicatedUser used the realname scheme. Idempotent;
// skips stubs already migrated. Non-blocking — failures retry on next
// onPeerActivated for that origin.
const allActivePeers = db.select({ origin: schema.federationPeers.origin })
.from(schema.federationPeers)
.where(eq(schema.federationPeers.status, 'active'))
.all();
const { backfillStubUsernamesForPeer } = await import('./federationStubBackfill.js');
for (const peer of allActivePeers) {
backfillStubUsernamesForPeer(peer.origin).catch((err) => {
console.warn(`[startup] stub-backfill ${peer.origin} failed`, err);
});
}
} }
export type PeerDeactivationReason = export type PeerDeactivationReason =
@@ -355,6 +394,17 @@ export async function onPeerDeactivated(
); );
} }
// Mark every stub whose home is this peer as offline locally, and
// broadcast a presence_update WS event to friends/DM-mates/space-co-members
// so connected users see them go offline immediately, instead of seeing
// stale 'online' until the peer recovers.
try {
const { markPeerStubsOffline } = await import('./federationPresence.js');
await markPeerStubsOffline(peer.origin);
} catch (e) {
console.warn(`[onPeerDeactivated] markPeerStubsOffline(${peer.origin}) failed`, e);
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
} catch (err) { } catch (err) {
console.error(`[federation] onPeerDeactivated(${peerId}, ${reason}) failed:`, err); console.error(`[federation] onPeerDeactivated(${peerId}, ${reason}) failed:`, err);
@@ -0,0 +1,165 @@
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 { eq } from 'drizzle-orm';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
const queueCalls: Array<{ entityId: string; eventType: string; targets: string[] | undefined; payload: string }> = [];
const sentToUser: Array<{ userId: string; payload: any }> = [];
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('./federationAuth.js', () => ({
getOurOrigin: () => 'https://nova.ddns.net',
}));
vi.mock('./federationOutbox.js', () => ({
isFederationRelayEnabled: () => true,
queueOutboxEvent: vi.fn((entityId, _ctxId, eventType, payload, targets) => {
queueCalls.push({ entityId, eventType, targets, payload });
}),
appendMutationLog: vi.fn(),
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToUser: vi.fn((uid: string, p: any) => sentToUser.push({ userId: uid, payload: p })),
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);
queueCalls.length = 0;
sentToUser.length = 0;
// Stub from orbit (peer being activated)
testDb.insert(schema.users).values({
id: 'stub-pbtest3', username: 'pbtest3@orbit.ddns.net', passwordHash: '!fr',
status: 'online', isAdmin: 0, homeInstance: 'orbit.ddns.net',
homeUserId: 'remote-pbtest3', createdAt: Date.now(),
}).run();
// Online native FRIENDED with the stub — should be snapshotted
testDb.insert(schema.users).values({
id: 'native-friend', username: 'youruser', passwordHash: 'x',
status: 'online', isAdmin: 0, homeUserId: 'native-friend', createdAt: Date.now(),
}).run();
testDb.insert(schema.friends).values({
userId: 'native-friend', friendId: 'stub-pbtest3', createdAt: Date.now(),
}).run();
// Online native sharing a DM with the stub — should be snapshotted
testDb.insert(schema.users).values({
id: 'native-dm', username: 'dmuser', passwordHash: 'x',
status: 'online', isAdmin: 0, homeUserId: 'native-dm', createdAt: Date.now(),
}).run();
testDb.insert(schema.dmChannels).values({
id: 'dm-1', ownerId: null, federatedId: null, createdAt: Date.now(),
}).run();
testDb.insert(schema.dmMembers).values([
{ dmChannelId: 'dm-1', userId: 'native-dm', closed: 0 },
{ dmChannelId: 'dm-1', userId: 'stub-pbtest3', closed: 0 },
]).run();
// Online native with replicatedInstances opt-in for orbit — should be snapshotted
testDb.insert(schema.users).values({
id: 'native-optin', username: 'optin', passwordHash: 'x',
status: 'online', isAdmin: 0, homeUserId: 'native-optin',
replicatedInstances: JSON.stringify([{ origin: 'https://orbit.ddns.net', domain: 'orbit.ddns.net' }]),
createdAt: Date.now(),
}).run();
// Online native with NO relationship to orbit — must NOT be snapshotted
testDb.insert(schema.users).values({
id: 'native-unrelated', username: 'unrelated', passwordHash: 'x',
status: 'online', isAdmin: 0, homeUserId: 'native-unrelated', createdAt: Date.now(),
}).run();
// Offline native that IS a friend of the stub — must NOT be snapshotted (offline)
testDb.insert(schema.users).values({
id: 'native-offline-friend', username: 'sleepyfriend', passwordHash: 'x',
status: 'offline', isAdmin: 0, homeUserId: 'native-offline-friend', createdAt: Date.now(),
}).run();
testDb.insert(schema.friends).values({
userId: 'native-offline-friend', friendId: 'stub-pbtest3', createdAt: Date.now(),
}).run();
});
describe('snapshotPresenceForPeer — scope', () => {
it('snapshots online natives that are friended with a peer stub', async () => {
const { snapshotPresenceForPeer } = await import('./federationPresence.js');
snapshotPresenceForPeer('https://orbit.ddns.net');
const friendCall = queueCalls.find((c) => c.entityId === 'native-friend');
expect(friendCall).toBeDefined();
expect(friendCall!.targets).toEqual(['https://orbit.ddns.net']);
});
it('snapshots online natives that share a DM with a peer stub', async () => {
const { snapshotPresenceForPeer } = await import('./federationPresence.js');
snapshotPresenceForPeer('https://orbit.ddns.net');
expect(queueCalls.find((c) => c.entityId === 'native-dm')).toBeDefined();
});
it('snapshots online natives that opted into client-federation (replicatedInstances)', async () => {
const { snapshotPresenceForPeer } = await import('./federationPresence.js');
snapshotPresenceForPeer('https://orbit.ddns.net');
expect(queueCalls.find((c) => c.entityId === 'native-optin')).toBeDefined();
});
it('does NOT snapshot online natives with no relationship to the peer', async () => {
const { snapshotPresenceForPeer } = await import('./federationPresence.js');
snapshotPresenceForPeer('https://orbit.ddns.net');
expect(queueCalls.find((c) => c.entityId === 'native-unrelated')).toBeUndefined();
});
it('does NOT snapshot offline natives even when they are related to the peer', async () => {
const { snapshotPresenceForPeer } = await import('./federationPresence.js');
snapshotPresenceForPeer('https://orbit.ddns.net');
expect(queueCalls.find((c) => c.entityId === 'native-offline-friend')).toBeUndefined();
});
});
describe('markPeerStubsOffline', () => {
it('flips all stubs from the deactivated peer to offline and broadcasts', async () => {
const { markPeerStubsOffline } = await import('./federationPresence.js');
await markPeerStubsOffline('https://orbit.ddns.net');
const stub = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-pbtest3')).get();
expect(stub!.status).toBe('offline');
const friendBroadcast = sentToUser.find(
(c) => c.userId === 'native-friend' && c.payload.userId === 'stub-pbtest3',
);
expect(friendBroadcast).toBeDefined();
expect(friendBroadcast!.payload.status).toBe('offline');
expect(friendBroadcast!.payload.type).toBe('presence_update');
});
});
@@ -0,0 +1,120 @@
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;
const queueCalls: Array<{
entityId: string;
contextId: string;
eventType: string;
payload: string;
targetPeerOrigins: string[] | undefined;
contextType: string;
}> = [];
const mutationLogCalls: Array<{ entityId: string; eventType: string }> = [];
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('./federationAuth.js', () => ({
getOurOrigin: () => 'https://nova.ddns.net',
}));
vi.mock('./federationOutbox.js', () => ({
isFederationRelayEnabled: () => true,
queueOutboxEvent: vi.fn((entityId, contextId, eventType, payload, targetPeerOrigins, contextType) => {
queueCalls.push({ entityId, contextId, eventType, payload, targetPeerOrigins, contextType });
}),
appendMutationLog: vi.fn((entityId, _ctxId, eventType) => {
mutationLogCalls.push({ entityId, eventType });
}),
}));
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);
queueCalls.length = 0;
mutationLogCalls.length = 0;
// Native local user
testDb.insert(schema.users).values({
id: 'native-1',
username: 'youruser',
passwordHash: 'x',
status: 'online',
isAdmin: 0,
homeUserId: 'native-1',
createdAt: Date.now(),
}).run();
});
describe('queuePresenceRelay', () => {
it('queues an outbox event with status + activities for a native user', async () => {
const { queuePresenceRelay } = await import('./federationPresence.js');
queuePresenceRelay('native-1', 'online', [{ type: 'playing', name: 'Test' }]);
expect(queueCalls.length).toBe(1);
const call = queueCalls[0]!;
expect(call.eventType).toBe('presence_update');
expect(call.contextType).toBe('profile');
expect(call.targetPeerOrigins).toBeUndefined(); // broadcast to all active peers
const event = JSON.parse(call.payload);
expect(event.eventType).toBe('presence_update');
expect(event.presenceUpdate.status).toBe('online');
expect(event.presenceUpdate.activities).toEqual([{ type: 'playing', name: 'Test' }]);
// appendMutationLog NOT called — presence is outbox-only
expect(mutationLogCalls).toEqual([]);
});
it('omits activities field when none are passed', async () => {
const { queuePresenceRelay } = await import('./federationPresence.js');
queuePresenceRelay('native-1', 'offline', []);
const event = JSON.parse(queueCalls[0]!.payload);
expect(event.presenceUpdate.activities).toBeUndefined();
});
it('is a no-op for replicated users (homeInstance set)', async () => {
testDb.insert(schema.users).values({
id: 'stub-1',
username: 'pbtest3@orbit.ddns.net',
passwordHash: '!federation-replicated',
status: 'online',
isAdmin: 0,
homeInstance: 'orbit.ddns.net',
homeUserId: 'remote-1',
createdAt: Date.now(),
}).run();
const { queuePresenceRelay } = await import('./federationPresence.js');
queuePresenceRelay('stub-1', 'online', []);
expect(queueCalls).toEqual([]);
});
it('is a no-op for unknown user IDs', async () => {
const { queuePresenceRelay } = await import('./federationPresence.js');
queuePresenceRelay('does-not-exist', 'online', []);
expect(queueCalls).toEqual([]);
});
});
@@ -0,0 +1,226 @@
import { and, eq, inArray, isNull, or } from 'drizzle-orm';
import type { Activity, FederationRelayEvent, FederationPresenceUpdatePayload, ReplicatedInstance } from '@backspace/shared';
import { getDb, schema } from '../db/index.js';
import { getOurOrigin } from './federationAuth.js';
import { isFederationRelayEnabled, queueOutboxEvent } from './federationOutbox.js';
import { collectProfileBroadcastTargetIds } from './userDeletion.js';
import { extractDomain } from '../routes/federation.js';
export type PresenceStatus = 'online' | 'idle' | 'dnd' | 'offline';
/**
* Queue a presence_update event for the given native user. Broadcast to all
* active peers (mirrors profile_update). Outbox-only — presence is ephemeral;
* stale replays from a mutation log are wrong, so we never call
* appendMutationLog. The peer-activation hook re-emits a fresh snapshot for
* peer-related online natives, so a peer recovering from unreachable converges
* without history replay.
*
* No-op for replicated users (their home instance owns presence projection).
*/
export function queuePresenceRelay(
userId: string,
status: PresenceStatus,
activities: Activity[],
): void {
if (!isFederationRelayEnabled()) return;
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
if (!user) return;
if (user.homeInstance) return; // replicated — not our authority
const ts = Date.now();
const payload: FederationPresenceUpdatePayload = {
homeUserId: user.id,
homeInstance: getOurOrigin(),
status,
ts,
...(activities.length > 0 ? { activities } : {}),
};
const event: FederationRelayEvent = {
eventType: 'presence_update',
contextType: 'profile',
messageId: `presence:${user.id}:${ts}`,
encryptionVersion: 0,
timestamp: ts,
presenceUpdate: payload,
};
// entityId = userId so the outbox coalesces rapid status flaps into the latest.
// contextId = userId, contextType = 'profile' (reuses existing routing).
// targetPeerOrigins = undefined → broadcast to all active peers.
// NO appendMutationLog — presence must not be replayed from history.
queueOutboxEvent(
user.id,
user.id,
'presence_update',
JSON.stringify(event),
undefined,
'profile',
);
}
/**
* On peer activation, send a fresh presence snapshot to the newly-active peer
* for every online local native user that has an S2S relationship with that
* peer (so the peer's stubs reflect current reality — presence is outbox-only,
* no mutation-log replay can do this).
*
* Scope is bounded by relationship count, not native count. A native qualifies
* if ANY of:
* - friend with at least one stub whose home_instance = peer domain
* - DM-member (closed=0) with at least one stub whose home_instance = peer domain
* - replicatedInstances JSON includes peerOrigin (explicit client-federation opt-in)
*
* Re-runs on every activation (including health-check unreachable→active flaps),
* because markPeerStubsOffline ran on deactivation — peers and our stubs both
* need a fresh handshake on recovery, not a stale-window-skip.
*/
export function snapshotPresenceForPeer(peerOrigin: string): void {
if (!isFederationRelayEnabled()) return;
const db = getDb();
const peerDomain = extractDomain(peerOrigin);
// 1. All stubs from this peer that exist locally.
const peerStubIdList = db
.select({ id: schema.users.id })
.from(schema.users)
.where(and(
eq(schema.users.homeInstance, peerDomain),
eq(schema.users.isDeleted, 0),
))
.all()
.map((s) => s.id);
const peerStubIds = new Set(peerStubIdList); // O(1) membership tests in the friend loop
// 2. Build the set of native IDs related to those stubs (friends + DM co-members).
const relatedNativeIds = new Set<string>();
if (peerStubIdList.length > 0) {
const friendRows = db.select().from(schema.friends)
.where(or(
inArray(schema.friends.userId, peerStubIdList),
inArray(schema.friends.friendId, peerStubIdList),
))
.all();
for (const f of friendRows) {
const stubSide = peerStubIds.has(f.userId) ? f.userId : f.friendId;
const otherSide = stubSide === f.userId ? f.friendId : f.userId;
relatedNativeIds.add(otherSide);
}
// Stub's DM memberships — filter closed=0 so DMs the stub left don't pull
// their old co-members into snapshot scope.
const stubDmIds = db.select({ dmChannelId: schema.dmMembers.dmChannelId })
.from(schema.dmMembers)
.where(and(
inArray(schema.dmMembers.userId, peerStubIdList),
eq(schema.dmMembers.closed, 0),
))
.all()
.map((d) => d.dmChannelId);
if (stubDmIds.length > 0) {
// Co-members — filter closed=0 so a native who closed the DM locally
// doesn't receive snapshots for its lingering stub.
const dmCoMembers = db.select({ userId: schema.dmMembers.userId })
.from(schema.dmMembers)
.where(and(
inArray(schema.dmMembers.dmChannelId, stubDmIds),
eq(schema.dmMembers.closed, 0),
))
.all();
for (const m of dmCoMembers) relatedNativeIds.add(m.userId);
}
}
// 3. Add explicit client-federation opt-ins via replicatedInstances JSON.
// (We can't index a JSON string in SQLite, so scan natives once and parse.)
// SCALING NOTE: this full-natives scan is fine pre-launch and remains cheap
// for instances under ~10k users. If population grows past that, replace with
// a `user_replicated_instance_index` table populated when replicatedInstances
// is written, indexed on (origin) for direct EXISTS lookup.
const allNatives = db.select().from(schema.users)
.where(and(
isNull(schema.users.homeInstance),
eq(schema.users.isDeleted, 0),
))
.all();
for (const u of allNatives) {
if (!u.replicatedInstances) continue;
try {
const list = JSON.parse(u.replicatedInstances) as ReplicatedInstance[];
if (list.some((ri) => ri.origin === peerOrigin)) relatedNativeIds.add(u.id);
} catch { /* malformed JSON — skip */ }
}
// 4. Filter to online natives in the related set; emit one outbox event each.
for (const u of allNatives) {
if (!relatedNativeIds.has(u.id)) continue;
if (!u.status || u.status === 'offline') continue;
if (u.homeInstance) continue; // belt-and-braces: must be native
const ts = Date.now();
const event: FederationRelayEvent = {
eventType: 'presence_update',
contextType: 'profile',
messageId: `presence:${u.id}:${ts}:snap`,
encryptionVersion: 0,
timestamp: ts,
presenceUpdate: {
homeUserId: u.id,
homeInstance: getOurOrigin(),
status: u.status as 'online' | 'idle' | 'dnd',
ts,
},
};
queueOutboxEvent(u.id, u.id, 'presence_update', JSON.stringify(event), [peerOrigin], 'profile');
}
}
/**
* On peer deactivation (status flipping out of 'active'), flip all replicated
* stubs whose home is that peer to status='offline' and broadcast a local
* presence_update so connected friends/DM-mates/space-co-members see them go
* offline immediately.
*
* Imported lazily by onPeerDeactivated to avoid an import cycle through
* ws/handler.js (connectionManager).
*
* Accepts an origin string; derives the bare domain via extractDomain so the
* caller doesn't need to handle that detail.
*/
export async function markPeerStubsOffline(peerOrigin: string): Promise<void> {
const peerDomain = extractDomain(peerOrigin);
const db = getDb();
const stubs = db
.select({ id: schema.users.id })
.from(schema.users)
.where(and(
eq(schema.users.homeInstance, peerDomain),
eq(schema.users.isDeleted, 0),
))
.all();
if (stubs.length === 0) return;
// Lazy import keeps this module pure for tests that don't need ws/handler.
const { connectionManager } = await import('../ws/handler.js');
for (const stub of stubs) {
db.update(schema.users)
.set({ status: 'offline' })
.where(eq(schema.users.id, stub.id))
.run();
const targets = collectProfileBroadcastTargetIds(stub.id);
const payload = {
type: 'presence_update' as const,
userId: stub.id,
status: 'offline' as const,
activities: [] as Activity[],
};
for (const uid of targets) connectionManager.sendToUser(uid, payload);
}
}
@@ -0,0 +1,161 @@
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 { eq } from 'drizzle-orm';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
const lookupCalls: Array<{ peerOrigin: string; homeUserId: string }> = [];
const lookupResponses = new Map<string, unknown>();
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('./federationLookup.js', () => ({
lookupRemoteUserByHomeId: vi.fn(async (peerOrigin: string, homeUserId: string) => {
lookupCalls.push({ peerOrigin, homeUserId });
const r = lookupResponses.get(homeUserId);
if (!r) return { ok: false, reason: 'not_found' };
return r;
}),
}));
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);
lookupCalls.length = 0;
lookupResponses.clear();
// Active peer
testDb.insert(schema.federationPeers).values({
id: 'peer-orbit',
origin: 'https://orbit.ddns.net',
hmacSecret: 'a'.repeat(64),
status: 'active',
createdAt: Date.now(),
lastSeenAt: Date.now(),
}).run();
});
describe('backfillStubUsernamesForPeer', () => {
it('rewrites snowflake-style username to realname when lookup succeeds', async () => {
// Seed legacy stub: username = `${homeUserId}@${domain}` (the old scheme)
testDb.insert(schema.users).values({
id: 'stub-1',
username: 'home-1@orbit.ddns.net',
displayName: null,
passwordHash: '!federation-replicated',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.ddns.net',
homeUserId: 'home-1',
createdAt: Date.now(),
}).run();
lookupResponses.set('home-1', {
ok: true,
homeUserId: 'home-1',
username: 'pbtest3',
profile: { displayName: null, avatar: null, avatarColor: null, banner: null, bio: null },
});
const { backfillStubUsernamesForPeer } = await import('./federationStubBackfill.js');
await backfillStubUsernamesForPeer('https://orbit.ddns.net');
expect(lookupCalls).toEqual([{ peerOrigin: 'https://orbit.ddns.net', homeUserId: 'home-1' }]);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get();
expect(row!.username).toBe('pbtest3@orbit.ddns.net');
expect(row!.displayName).toBe('pbtest3'); // displayName ?? username fallback fills in real handle
});
it('skips stubs whose username is already human-readable (no lookup triggered)', async () => {
testDb.insert(schema.users).values({
id: 'stub-1',
username: 'pbtest3@orbit.ddns.net', // already migrated
displayName: 'pbtest3',
passwordHash: '!federation-replicated',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.ddns.net',
homeUserId: 'home-1',
createdAt: Date.now(),
}).run();
const { backfillStubUsernamesForPeer } = await import('./federationStubBackfill.js');
await backfillStubUsernamesForPeer('https://orbit.ddns.net');
expect(lookupCalls).toEqual([]); // no lookup triggered
});
it('leaves stub untouched on lookup miss (tombstoned home user)', async () => {
testDb.insert(schema.users).values({
id: 'stub-1',
username: 'unknown-id@orbit.ddns.net',
displayName: null,
passwordHash: '!federation-replicated',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.ddns.net',
homeUserId: 'unknown-id',
createdAt: Date.now(),
}).run();
// No lookupResponses entry → mock returns { ok: false, reason: 'not_found' }
const { backfillStubUsernamesForPeer } = await import('./federationStubBackfill.js');
await backfillStubUsernamesForPeer('https://orbit.ddns.net');
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'stub-1')).get();
expect(row!.username).toBe('unknown-id@orbit.ddns.net'); // unchanged
expect(row!.displayName).toBeNull(); // unchanged
});
it('is a no-op when peer is not active', async () => {
testDb.update(schema.federationPeers)
.set({ status: 'unreachable' })
.where(eq(schema.federationPeers.id, 'peer-orbit'))
.run();
testDb.insert(schema.users).values({
id: 'stub-1',
username: 'home-1@orbit.ddns.net',
displayName: null,
passwordHash: '!federation-replicated',
status: 'offline',
isAdmin: 0,
homeInstance: 'orbit.ddns.net',
homeUserId: 'home-1',
createdAt: Date.now(),
}).run();
lookupResponses.set('home-1', {
ok: true,
homeUserId: 'home-1',
username: 'pbtest3',
profile: { displayName: null, avatar: null, avatarColor: null, banner: null, bio: null },
});
const { backfillStubUsernamesForPeer } = await import('./federationStubBackfill.js');
await backfillStubUsernamesForPeer('https://orbit.ddns.net');
expect(lookupCalls).toEqual([]); // gated on peer status
});
});
@@ -0,0 +1,96 @@
import { and, eq, like } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { lookupRemoteUserByHomeId } from './federationLookup.js';
import { extractDomain } from '../routes/federation.js';
/**
* For each replicated stub on this instance whose username still matches the
* legacy `<homeUserId>@<domain>` pattern AND whose home_instance equals the
* given peer's domain, ask the peer for the canonical username via
* lookupRemoteUserByHomeId and rewrite the stub.
*
* Idempotent — stubs already migrated (username does not start with their
* homeUserId) are skipped without a network call.
*
* Gated on peer.status='active' — the lookup endpoint requires the requesting
* peer to be active on the receiving side. We additionally check our local
* peer row here so we don't waste outbound RTTs on peers we know aren't ready.
*
* Called from onPeerActivated (per-origin, gated on peer status='active') and
* from a one-shot startup pass for any peer already active at boot.
*/
export async function backfillStubUsernamesForPeer(peerOrigin: string): Promise<void> {
const db = getDb();
const peerDomain = extractDomain(peerOrigin);
const peer = db
.select({ status: schema.federationPeers.status })
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, peerOrigin))
.get();
if (!peer || peer.status !== 'active') return;
// Coarse SQL prefilter: stubs from this peer whose username ends with @peerDomain.
// We then narrow in JS to the legacy `<homeUserId>@<domain>` shape because Drizzle
// can't express that comparison portably.
const candidates = db
.select()
.from(schema.users)
.where(
and(
eq(schema.users.homeInstance, peerDomain),
eq(schema.users.isDeleted, 0),
like(schema.users.username, '%@' + peerDomain),
),
)
.all();
for (const stub of candidates) {
if (!stub.homeUserId) continue;
const expectedLegacy = `${stub.homeUserId}@${peerDomain}`.toLowerCase();
if (stub.username !== expectedLegacy) continue; // already migrated or non-legacy shape
const result = await lookupRemoteUserByHomeId(peerOrigin, stub.homeUserId);
if (!result.ok) {
// not_found / unreachable / rate_limited — leave untouched.
// Will retry on next onPeerActivated for this origin.
continue;
}
const newUsername = `${result.username}@${peerDomain}`.toLowerCase();
if (newUsername === stub.username) continue; // already correct
// Collision check: another row at the target username (rare under the new
// scheme but possible from prior partial replication).
const collision = db
.select({ id: schema.users.id })
.from(schema.users)
.where(eq(schema.users.username, newUsername))
.get();
if (collision && collision.id !== stub.id) {
console.warn(`[stub-backfill] username collision on ${newUsername} — leaving stub ${stub.id} as ${stub.username}`);
continue;
}
// Fill displayName from result.profile if the stub has none, mirroring the
// displayName ?? username fallback applied at hydrate / profile_update time.
const updates: { username: string; displayName?: string; status?: 'online' | 'idle' | 'dnd' | 'offline' } = { username: newUsername };
if (!stub.displayName) {
updates.displayName = result.profile.displayName ?? result.username;
}
// Heal status too — same root issue (stub was seeded offline at creation
// because the wire snapshot pre-dated the status field). Only overwrite
// when the lookup tells us something specific; keep the stub's current
// value otherwise.
if (result.profile.status && result.profile.status !== stub.status) {
updates.status = result.profile.status;
}
db.update(schema.users)
.set(updates)
.where(eq(schema.users.id, stub.id))
.run();
console.log(`[stub-backfill] rewrote stub ${stub.id}: ${stub.username}${newUsername}`);
}
}
@@ -220,6 +220,7 @@ export async function processOutboxTick(): Promise<void> {
if (parsed.rejectionLimit != null) evt.rejectionLimit = parsed.rejectionLimit; if (parsed.rejectionLimit != null) evt.rejectionLimit = parsed.rejectionLimit;
if (parsed.affectedUserIds) evt.affectedUserIds = parsed.affectedUserIds; if (parsed.affectedUserIds) evt.affectedUserIds = parsed.affectedUserIds;
if (parsed.profileUpdate) evt.profileUpdate = parsed.profileUpdate; if (parsed.profileUpdate) evt.profileUpdate = parsed.profileUpdate;
if (parsed.presenceUpdate) evt.presenceUpdate = parsed.presenceUpdate;
if (parsed.readState) evt.readState = parsed.readState; if (parsed.readState) evt.readState = parsed.readState;
if (parsed.dmCloseReopen) evt.dmCloseReopen = parsed.dmCloseReopen; if (parsed.dmCloseReopen) evt.dmCloseReopen = parsed.dmCloseReopen;
return evt; return evt;
+16 -9
View File
@@ -11,6 +11,7 @@ import type { CallRelayResult, CallFanoutFailure } from '../utils/federationOutb
import { mapCallReasonToEventReason } from '../utils/federationOutbox.js'; import { mapCallReasonToEventReason } from '../utils/federationOutbox.js';
import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js'; import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js';
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
import { collectProfileBroadcastTargetIds } from '../utils/userDeletion.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId, sendTypingRelay, queueReadStateRelay } from '../utils/federationOutbox.js'; import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId, sendTypingRelay, queueReadStateRelay } from '../utils/federationOutbox.js';
@@ -497,20 +498,23 @@ function handlePresenceUpdate(event: Record<string, unknown>, userId: string): v
connectionManager.setUserStatus(userId, status); connectionManager.setUserStatus(userId, status);
const activities = connectionManager.getUserActivities(userId); const activities = connectionManager.getUserActivities(userId);
// Broadcast to all spaces user is in // Broadcast to friends + DM co-members + space co-members.
const userSpaces = connectionManager.getUserSpaces(userId);
const payload = { const payload = {
type: 'presence_update' as const, type: 'presence_update' as const,
userId, userId,
status, status,
...(activities.length > 0 ? { activities } : {}), ...(activities.length > 0 ? { activities } : {}),
}; };
for (const spaceId of userSpaces) { const targets = collectProfileBroadcastTargetIds(userId);
connectionManager.sendToSpace(spaceId, payload, userId); for (const uid of targets) connectionManager.sendToUser(uid, payload);
}
// Also send to self (other tabs) // Also send to self (other tabs)
connectionManager.sendToUser(userId, payload); connectionManager.sendToUser(userId, payload);
// S2S: project to all active peers
void import('../utils/federationPresence.js').then(({ queuePresenceRelay }) => {
try { queuePresenceRelay(userId, status as 'online' | 'idle' | 'dnd', activities); } catch (e) { console.warn('[ws] queuePresenceRelay(manual) failed', e); }
});
} }
function handleActivityUpdate(event: Record<string, unknown>, userId: string): void { function handleActivityUpdate(event: Record<string, unknown>, userId: string): void {
@@ -529,12 +533,15 @@ function handleActivityUpdate(event: Record<string, unknown>, userId: string): v
connectionManager.setUserActivities(userId, activities); connectionManager.setUserActivities(userId, activities);
const status = connectionManager.getUserStatus(userId); const status = connectionManager.getUserStatus(userId);
const userSpaces = connectionManager.getUserSpaces(userId);
const payload = { type: 'presence_update' as const, userId, status, activities }; const payload = { type: 'presence_update' as const, userId, status, activities };
for (const spaceId of userSpaces) { const targets = collectProfileBroadcastTargetIds(userId);
connectionManager.sendToSpace(spaceId, payload, userId); for (const uid of targets) connectionManager.sendToUser(uid, payload);
}
connectionManager.sendToUser(userId, payload); connectionManager.sendToUser(userId, payload);
// S2S: project to all active peers (activities + current status).
void import('../utils/federationPresence.js').then(({ queuePresenceRelay }) => {
try { queuePresenceRelay(userId, status as 'online' | 'idle' | 'dnd' | 'offline', activities); } catch (e) { console.warn('[ws] queuePresenceRelay(activity) failed', e); }
});
} }
// ─── Voice Handlers (Unified Room API) ───────────────────────────────────── // ─── Voice Handlers (Unified Room API) ─────────────────────────────────────
+29 -19
View File
@@ -21,6 +21,7 @@ import type {
Activity, Activity,
} from '@backspace/shared'; } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js'; import { sanitizeUser } from '../utils/sanitize.js';
import { collectProfileBroadcastTargetIds } from '../utils/userDeletion.js';
// ─── Heartbeat State ────────────────────────────────────────────────────────── // ─── Heartbeat State ──────────────────────────────────────────────────────────
const wsIsAlive: WeakMap<WebSocket, boolean> = new WeakMap(); const wsIsAlive: WeakMap<WebSocket, boolean> = new WeakMap();
@@ -296,16 +297,24 @@ class ConnectionManager {
this.userStatuses.delete(userId); this.userStatuses.delete(userId);
this.lastActivityUpdate.delete(userId); this.lastActivityUpdate.delete(userId);
// Broadcast offline to all spaces // Broadcast offline to friends + DM co-members + space co-members.
const userSpaces = this.getUserSpaces(userId); // Mirrors collectProfileBroadcastTargetIds (the recipient set used by
for (const spaceId of userSpaces) { // user_updated). Two locally-friended users with no shared space now see
this.sendToSpace(spaceId, { // each other's offline transitions live, instead of being space-only.
type: 'presence_update', const offlinePayload = {
userId: userId, type: 'presence_update' as const,
status: 'offline', userId,
activities: [] as Activity[], status: 'offline' as const,
}); activities: [] as Activity[],
} };
const offlineTargets = collectProfileBroadcastTargetIds(userId);
for (const uid of offlineTargets) this.sendToUser(uid, offlinePayload);
// S2S: project offline to all active peers (mirrors profile_update fanout).
// Imported lazily to avoid circular import (federationPresence → db → ws/handler).
void import('../utils/federationPresence.js').then(({ queuePresenceRelay }) => {
try { queuePresenceRelay(userId, 'offline', []); } catch (e) { console.warn('[ws] queuePresenceRelay(offline) failed', e); }
});
// Clean up userSpaces (re-populated on next connect via setUserSpaces) // Clean up userSpaces (re-populated on next connect via setUserSpaces)
this.userSpaces.delete(userId); this.userSpaces.delete(userId);
@@ -1663,15 +1672,16 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
...readyData, ...readyData,
})); }));
// Broadcast presence update to all spaces // Broadcast online to friends + DM co-members + space co-members.
const userSpaces = connectionManager.getUserSpaces(userId); const onlinePayload = { type: 'presence_update' as const, userId, status: 'online' as const };
for (const spaceId of userSpaces) { const onlineTargets = collectProfileBroadcastTargetIds(userId);
connectionManager.sendToSpace(spaceId, { for (const uid of onlineTargets) connectionManager.sendToUser(uid, onlinePayload);
type: 'presence_update',
userId, // S2S: project online to all active peers (mirrors profile_update fanout).
status: 'online', const _uid = userId;
}, userId); void import('../utils/federationPresence.js').then(({ queuePresenceRelay }) => {
} try { queuePresenceRelay(_uid, 'online', []); } catch (e) { console.warn('[ws] queuePresenceRelay(online) failed', e); }
});
} catch { } catch {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' })); ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
ws.close(); ws.close();
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { bootTwoInstances, type TwoInstanceHarness } from './helpers/twoInstanceHarness.js';
import { peerInstances } from './helpers/seedPeer.js';
import { registerLocal } from './helpers/testUsers.js';
import { buildHeadersForOrigin } from './helpers/hmacSign.js';
let harness: TwoInstanceHarness;
let sharedSecret: string;
beforeAll(async () => {
harness = await bootTwoInstances();
sharedSecret = await peerInstances(harness.home, harness.remote);
}, 90_000);
afterAll(async () => {
await harness.cleanup();
});
describe('POST /api/federation/users/by-home-id', () => {
it('returns canonical username + profile for a native user when looked up by homeUserId', async () => {
const target = await registerLocal(harness.remote, 'lookup_target');
const body = JSON.stringify({ homeUserId: target.id });
const headers = buildHeadersForOrigin(body, sharedSecret, `https://${harness.home.domain}`);
const res = await fetch(`${harness.remote.origin}/api/federation/users/by-home-id`, {
method: 'POST',
headers,
body,
});
expect(res.status).toBe(200);
const json = await res.json() as { found: boolean; user?: { homeUserId: string; username: string; profile: { displayName: string | null } } };
expect(json.found).toBe(true);
expect(json.user!.homeUserId).toBe(target.id);
expect(json.user!.username).toBe(target.username);
});
it('returns { found: false } on unknown homeUserId', async () => {
const body = JSON.stringify({ homeUserId: 'definitely-not-a-real-id' });
const headers = buildHeadersForOrigin(body, sharedSecret, `https://${harness.home.domain}`);
const res = await fetch(`${harness.remote.origin}/api/federation/users/by-home-id`, {
method: 'POST',
headers,
body,
});
expect(res.status).toBe(200);
const json = await res.json() as { found: boolean };
expect(json.found).toBe(false);
});
it('rejects unsigned requests with 401', async () => {
const res = await fetch(`${harness.remote.origin}/api/federation/users/by-home-id`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ homeUserId: 'anything' }),
});
expect(res.status).toBe(401);
});
it('rejects non-native targets (replicated stubs) with { found: false }', async () => {
// Pre-existing stub on remote whose homeInstance is non-null. Use the
// first registered native user as a sanity comparison: a homeUserId that
// doesn't match a native non-deleted row → not found.
const stubLikeBody = JSON.stringify({ homeUserId: 'no-such-stub-id' });
const headers = buildHeadersForOrigin(stubLikeBody, sharedSecret, `https://${harness.home.domain}`);
const res = await fetch(`${harness.remote.origin}/api/federation/users/by-home-id`, {
method: 'POST',
headers,
body: stubLikeBody,
});
expect(res.status).toBe(200);
const json = await res.json() as { found: boolean };
expect(json.found).toBe(false);
});
});
+34 -1
View File
@@ -882,7 +882,7 @@ export interface FederationRelayEvent {
| 'friend_add' | 'friend_remove' | 'file_rejected' | 'friend_add' | 'friend_remove' | 'file_rejected'
| 'dm_call_start' | 'dm_call_accept' | 'dm_call_reject' | 'dm_call_end' | 'dm_call_start' | 'dm_call_accept' | 'dm_call_reject' | 'dm_call_end'
| 'dm_typing_start' | 'dm_typing_stop' | 'dm_typing_start' | 'dm_typing_stop'
| 'profile_update' | 'profile_update' | 'presence_update'
| 'read_state_update' | 'read_state_update'
| 'dm_close' | 'dm_reopen'; | 'dm_close' | 'dm_reopen';
contextType?: 'dm' | 'friend' | 'profile'; contextType?: 'dm' | 'friend' | 'profile';
@@ -922,6 +922,7 @@ export interface FederationRelayEvent {
username: string; username: string;
}; };
profileUpdate?: FederationProfileUpdatePayload; profileUpdate?: FederationProfileUpdatePayload;
presenceUpdate?: FederationPresenceUpdatePayload;
readState?: { readState?: {
user: { homeUserId: string; homeInstance: string }; user: { homeUserId: string; homeInstance: string };
messageRef: { sourceInstance: string; sourceMessageId: string }; messageRef: { sourceInstance: string; sourceMessageId: string };
@@ -966,12 +967,27 @@ export interface FederationRelayProfileSnapshot {
avatarColor?: string | null; avatarColor?: string | null;
banner?: string | null; banner?: string | null;
bio?: string | null; bio?: string | null;
// Current presence at the moment the snapshot was built. Optional for
// backwards compatibility with peers that pre-date the field. Receivers use
// this to seed the stub's status at creation time, so a freshly-friended
// remote user shows their actual current state instead of defaulting to
// 'offline' until the next presence_update arrives. presence_update is
// ephemeral and fires only on transitions, so without this field an
// already-online remote stays stuck at 'offline' on the receiver until they
// next change status.
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
} }
export interface FederationProfileUpdatePayload { export interface FederationProfileUpdatePayload {
homeUserId: string; homeUserId: string;
homeInstance: string; homeInstance: string;
profileUpdatedAt: number; profileUpdatedAt: number;
// Home user's canonical username (without @domain suffix). Receivers apply
// `displayName ?? username` so stubs whose home user has no displayName show
// the real handle instead of getting clobbered to null. Username itself is
// immutable on the home instance — receivers do NOT rewrite the stub's
// username column on profile_update.
username: string;
displayName: string | null; displayName: string | null;
avatar: string | null; avatar: string | null;
banner: string | null; banner: string | null;
@@ -980,6 +996,20 @@ export interface FederationProfileUpdatePayload {
bio: string | null; bio: string | null;
} }
/**
* Presence projection from a home instance to peers. Carries the user's current
* online status and (optionally) rich activities. Outbox-only on the wire — never
* written to federation_mutation_log; presence is ephemeral and stale replays on
* peer activation are wrong (the activation hook re-emits a fresh snapshot).
*/
export interface FederationPresenceUpdatePayload {
homeUserId: string;
homeInstance: string;
status: 'online' | 'idle' | 'dnd' | 'offline';
activities?: Activity[];
ts: number; // emitter clock; receiver may use for last-write-wins
}
export interface FederationFriendshipPayload { export interface FederationFriendshipPayload {
from: FederationRelayParticipant; from: FederationRelayParticipant;
to: FederationRelayParticipant; to: FederationRelayParticipant;
@@ -1056,6 +1086,9 @@ export interface FederationUserLookupProfile {
avatarColor: AvatarColor | null; avatarColor: AvatarColor | null;
banner: string | null; banner: string | null;
bio: string | null; bio: string | null;
// Carried so the requester can seed the stub's status at creation time.
// Optional for backwards compat with peers that pre-date the field.
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
} }
export type FederationUserLookupResponse = export type FederationUserLookupResponse =
+23 -5
View File
@@ -568,11 +568,29 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
}, },
updateMemberPresence: (userId: string, status: string) => { updateMemberPresence: (userId: string, status: string) => {
set((state) => ({ set((state) => {
members: state.members.map(m => const typedStatus = status as 'online' | 'idle' | 'dnd' | 'offline';
m.userId === userId ? { ...m, user: { ...m.user, status: status as 'online' | 'idle' | 'dnd' | 'offline' } } : m // Mirror the status into the userViews cache so any component reading via
), // useCanonicalUserView (e.g. the FriendItem avatar dot) re-renders with
})); // fresh status — not just spaceStore.members which only feeds space UIs.
// Match by user.id and user.homeUserId to catch both native rows and
// replicated stubs whose canonicalUserKey resolves to the canonical id.
let nextUserViews = state.userViews;
for (const [key, entry] of state.userViews) {
const u = entry.user;
if (u.id === userId || u.homeUserId === userId) {
if (nextUserViews === state.userViews) nextUserViews = new Map(state.userViews);
nextUserViews.set(key, { ...entry, user: { ...u, status: typedStatus } });
}
}
return {
members: state.members.map(m =>
m.userId === userId ? { ...m, user: { ...m.user, status: typedStatus } } : m
),
userViews: nextUserViews,
};
});
}, },
updateUserEverywhere: (user: User) => { updateUserEverywhere: (user: User) => {