From 1d353c994b7eb052f8b486a38a8334dd809e2b12 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 15:46:26 +0200 Subject: [PATCH 01/11] fix(federation): create replicated-user stubs with realname@domain when hint provided When friend_request_create / friend_add / DM relay carries a profile snapshot, the canonical-username hint is now used as the stub's local-part. Stubs created purely from S2S (no client-federation) now display the human-readable handle, not the homeUserId snowflake. Falls back to the snowflake-id scheme only when no hint is available. --- .../routes/federation.resolveOrCreate.test.ts | 86 +++++++++++++++++++ packages/server/src/routes/federation.ts | 14 +-- 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/routes/federation.resolveOrCreate.test.ts diff --git a/packages/server/src/routes/federation.resolveOrCreate.test.ts b/packages/server/src/routes/federation.resolveOrCreate.test.ts new file mode 100644 index 00000000..e6c550c0 --- /dev/null +++ b/packages/server/src/routes/federation.resolveOrCreate.test.ts @@ -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>; +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'); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 6173d5ba..f4d188e8 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -3181,9 +3181,13 @@ export function resolveOrCreateReplicatedUser( return null; } - // Use the snowflake-style homeUserId as the local part; append the - // domain so the username is globally unique and human-readable. - const baseUsername = `${homeUserId}@${domain}`.toLowerCase(); + // Use the home user's real username when the caller passes a hint (the wire + // profile snapshot from friend_request_create / friend_add / DM relay carries + // 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 // exists — e.g. a prior partial replication or manual creation. @@ -3192,11 +3196,11 @@ export function resolveOrCreateReplicatedUser( let attempt = 0; while (collision) { attempt++; - username = `${homeUserId}_${attempt}@${domain}`.toLowerCase(); + username = `${localPart}_${attempt}@${domain}`.toLowerCase(); collision = db.select().from(schema.users).where(eq(schema.users.username, username)).get(); if (attempt > 10) { // 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; } } From 097eb9a2ef3790732abd1b1496d928edf8989350 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 15:48:56 +0200 Subject: [PATCH 02/11] feat(federation): preserve effective displayName across profile_update relay FederationProfileUpdatePayload gains `username`: the home user's canonical handle. Receiver applies displayName ?? username so stubs whose home user has no displayName show the real handle instead of getting clobbered to null. Mirrors the existing fallback in hydrateReplicatedUserProfile. Username itself is immutable on the home instance, so the receiver does not rewrite the stub's username column on profile_update. --- .../routes/federation.profileUpdate.test.ts | 124 ++++++++++++++++++ packages/server/src/routes/federation.ts | 13 +- packages/server/src/routes/users.ts | 2 + packages/shared/src/types.ts | 6 + 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/routes/federation.profileUpdate.test.ts diff --git a/packages/server/src/routes/federation.profileUpdate.test.ts b/packages/server/src/routes/federation.profileUpdate.test.ts new file mode 100644 index 00000000..d2b408a1 --- /dev/null +++ b/packages/server/src/routes/federation.profileUpdate.test.ts @@ -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>; +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.'); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index f4d188e8..5b5d2ebe 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -5625,7 +5625,7 @@ async function downloadProfileAsset( } } -async function processProfileUpdateEvent( +export async function processProfileUpdateEvent( event: FederationRelayEvent, sourceInstance: string, db: ReturnType, @@ -5717,10 +5717,17 @@ async function processProfileUpdateEvent( 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) .set({ - displayName: payload.displayName, + displayName: effectiveDisplayName, avatar: resolvedAvatar, banner: resolvedBanner, accentColor: payload.accentColor, diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 9d82e6a1..5a1f4b18 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -362,6 +362,7 @@ export async function userRoutes(app: FastifyInstance): Promise { homeUserId: preUpdateUser.id, homeInstance: origin, profileUpdatedAt: preUpdateUser.profileUpdatedAt ?? Date.now(), + username: preUpdateUser.username, displayName: preUpdateUser.displayName, avatar: preUpdateUser.avatar && !preUpdateUser.avatar.startsWith('http') ? `${origin}/api/uploads/${preUpdateUser.avatar}` @@ -524,6 +525,7 @@ export async function userRoutes(app: FastifyInstance): Promise { homeUserId: updatedUser!.id, homeInstance: origin, profileUpdatedAt: updatedUser!.profileUpdatedAt ?? Date.now(), + username: updatedUser!.username, displayName: updatedUser!.displayName, avatar: updatedUser!.avatar && !updatedUser!.avatar.startsWith('http') ? `${origin}/api/uploads/${updatedUser!.avatar}` diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 43096358..64b87c3a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -972,6 +972,12 @@ export interface FederationProfileUpdatePayload { homeUserId: string; homeInstance: string; 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; avatar: string | null; banner: string | null; From b2faf5afaafd08427b46ec91adf37329c397760b Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 15:52:07 +0200 Subject: [PATCH 03/11] feat(federation): add /users/by-home-id reverse lookup for stub backfill HMAC-authenticated, rate-limited (60/min/peer) endpoint that resolves a homeUserId on this instance to its canonical username + profile snapshot. Native non-deleted users only. Mirrors /users/lookup's auth shape. Adds lookupRemoteUserByHomeId to federationLookup.ts as the client-side helper. Used by the upcoming stub-backfill worker on peers that hold legacy snowflake-named replicas of users now visible by their real handle. --- packages/server/src/routes/federation.ts | 88 +++++++++++++++++++ packages/server/src/utils/federationLookup.ts | 62 +++++++++++++ .../server/test/federation-by-home-id.test.ts | 74 ++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 packages/server/test/federation-by-home-id.test.ts diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 5b5d2ebe..fbf244cf 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2336,6 +2336,94 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── 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); + 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, + banner: user.banner, + bio: user.bio, + }, + }, + }); + }, + ); + // ─── POST /api/federation/sync ────────────────────────────────────────────── // Server-to-server: checkpoint catch-up sync. A peer calls this after downtime // to retrieve missed DM mutations from the mutation log. diff --git a/packages/server/src/utils/federationLookup.ts b/packages/server/src/utils/federationLookup.ts index d8b81476..8487f942 100644 --- a/packages/server/src/utils/federationLookup.ts +++ b/packages/server/src/utils/federationLookup.ts @@ -75,3 +75,65 @@ export async function lookupRemoteUser(peerOrigin: string, username: string): Pr 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 { + 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, + }; +} diff --git a/packages/server/test/federation-by-home-id.test.ts b/packages/server/test/federation-by-home-id.test.ts new file mode 100644 index 00000000..a608371d --- /dev/null +++ b/packages/server/test/federation-by-home-id.test.ts @@ -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); + }); +}); From bdbc90ebd2b99b44414fb3f93b27f686042d8173 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 15:57:37 +0200 Subject: [PATCH 04/11] feat(federation): backfill snowflake-named replicated-user stubs Heals existing legacy stubs (created when resolveOrCreateReplicatedUser used homeUserId@domain) by asking the peer for the canonical username via lookupRemoteUserByHomeId and rewriting the local row. Idempotent and collision-safe. Wired into onPeerActivated (per-origin) so future peer flaps re-attempt for stubs whose home was unreachable on a prior pass, and into a one-shot pass at startupBootstrapSync for all currently-active peers (not just first-time lastSyncedAt=0 peers). --- .../src/utils/federationPeerActivation.ts | 34 +++- .../src/utils/federationStubBackfill.test.ts | 161 ++++++++++++++++++ .../src/utils/federationStubBackfill.ts | 89 ++++++++++ 3 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/utils/federationStubBackfill.test.ts create mode 100644 packages/server/src/utils/federationStubBackfill.ts diff --git a/packages/server/src/utils/federationPeerActivation.ts b/packages/server/src/utils/federationPeerActivation.ts index afe32f78..55e5cd64 100644 --- a/packages/server/src/utils/federationPeerActivation.ts +++ b/packages/server/src/utils/federationPeerActivation.ts @@ -52,6 +52,20 @@ export async function onPeerActivated( resetOutboxBackoff(peerId); await syncPeerMutationLog(peerId, reason); 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); + }); + } + const { connectionManager } = await import('../ws/handler.js'); connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); } catch (err) { @@ -274,15 +288,31 @@ export async function startupBootstrapSync(): Promise { if (!isFederationRelayEnabled()) return; const db = getDb(); - const peers = db.select().from(schema.federationPeers) + const firstTimePeers = db.select().from(schema.federationPeers) .where(and( eq(schema.federationPeers.status, 'active'), eq(schema.federationPeers.lastSyncedAt, 0), )).all(); - for (const peer of peers) { + for (const peer of firstTimePeers) { 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 = diff --git a/packages/server/src/utils/federationStubBackfill.test.ts b/packages/server/src/utils/federationStubBackfill.test.ts new file mode 100644 index 00000000..3b3fb3ef --- /dev/null +++ b/packages/server/src/utils/federationStubBackfill.test.ts @@ -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>; +let sqlite: Database.Database; +let testDb: TestDb; + +const lookupCalls: Array<{ peerOrigin: string; homeUserId: string }> = []; +const lookupResponses = new Map(); + +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 + }); +}); diff --git a/packages/server/src/utils/federationStubBackfill.ts b/packages/server/src/utils/federationStubBackfill.ts new file mode 100644 index 00000000..288e864b --- /dev/null +++ b/packages/server/src/utils/federationStubBackfill.ts @@ -0,0 +1,89 @@ +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 `@` 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 { + 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 `@` 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 } = { username: newUsername }; + if (!stub.displayName) { + updates.displayName = result.profile.displayName ?? result.username; + } + + 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}`); + } +} From 613424e1c704dd5746e7a09d2c7e1e9d0dc4b052 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 16:01:28 +0200 Subject: [PATCH 05/11] feat(federation): queue S2S presence_update on auth/disconnect/status/activity changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New FederationPresenceUpdatePayload + queuePresenceRelay() helper. Five WS sites now project the native user's status (and optional activities) to all active peers via the outbox: WS auth-success, finalizeDisconnect, manual presence_update, activity_update, showActivity-toggle clear. Outbox-only (no mutation-log entry) — presence is ephemeral; the upcoming peer-activation hook re-emits a fresh snapshot so peers recovering from unreachable converge without history replay. No-op for replicated users. --- packages/server/src/routes/users.ts | 11 ++ .../src/utils/federationPresence.test.ts | 120 ++++++++++++++++++ .../server/src/utils/federationPresence.ts | 61 +++++++++ packages/server/src/ws/events.ts | 10 ++ packages/server/src/ws/handler.ts | 12 ++ packages/shared/src/types.ts | 17 ++- 6 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/utils/federationPresence.test.ts create mode 100644 packages/server/src/utils/federationPresence.ts diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 5a1f4b18..654600a9 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -447,6 +447,17 @@ export async function userRoutes(app: FastifyInstance): Promise { connectionManager.sendToSpace(spaceId, clearPayload, request.userId); } 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); } + }); } } diff --git a/packages/server/src/utils/federationPresence.test.ts b/packages/server/src/utils/federationPresence.test.ts new file mode 100644 index 00000000..6dd2b234 --- /dev/null +++ b/packages/server/src/utils/federationPresence.test.ts @@ -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>; +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([]); + }); +}); diff --git a/packages/server/src/utils/federationPresence.ts b/packages/server/src/utils/federationPresence.ts new file mode 100644 index 00000000..83e3b2d9 --- /dev/null +++ b/packages/server/src/utils/federationPresence.ts @@ -0,0 +1,61 @@ +import { eq } from 'drizzle-orm'; +import type { Activity, FederationRelayEvent, FederationPresenceUpdatePayload } from '@backspace/shared'; +import { getDb, schema } from '../db/index.js'; +import { getOurOrigin } from './federationAuth.js'; +import { isFederationRelayEnabled, queueOutboxEvent } from './federationOutbox.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', + ); +} diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 6aaa859c..7fb191c2 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -511,6 +511,11 @@ function handlePresenceUpdate(event: Record, userId: string): v // Also send to self (other tabs) 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, userId: string): void { @@ -535,6 +540,11 @@ function handleActivityUpdate(event: Record, userId: string): v connectionManager.sendToSpace(spaceId, payload, userId); } 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) ───────────────────────────────────── diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index c730af2e..59618974 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -307,6 +307,12 @@ class ConnectionManager { }); } + // 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) this.userSpaces.delete(userId); @@ -1672,6 +1678,12 @@ export async function registerWebSocket(app: FastifyInstance): Promise { status: 'online', }, userId); } + + // S2S: project online to all active peers (mirrors profile_update fanout). + const _uid = userId; + void import('../utils/federationPresence.js').then(({ queuePresenceRelay }) => { + try { queuePresenceRelay(_uid, 'online', []); } catch (e) { console.warn('[ws] queuePresenceRelay(online) failed', e); } + }); } catch { ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' })); ws.close(); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 64b87c3a..00e4666a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -882,7 +882,7 @@ export interface FederationRelayEvent { | 'friend_add' | 'friend_remove' | 'file_rejected' | 'dm_call_start' | 'dm_call_accept' | 'dm_call_reject' | 'dm_call_end' | 'dm_typing_start' | 'dm_typing_stop' - | 'profile_update' + | 'profile_update' | 'presence_update' | 'read_state_update' | 'dm_close' | 'dm_reopen'; contextType?: 'dm' | 'friend' | 'profile'; @@ -922,6 +922,7 @@ export interface FederationRelayEvent { username: string; }; profileUpdate?: FederationProfileUpdatePayload; + presenceUpdate?: FederationPresenceUpdatePayload; readState?: { user: { homeUserId: string; homeInstance: string }; messageRef: { sourceInstance: string; sourceMessageId: string }; @@ -986,6 +987,20 @@ export interface FederationProfileUpdatePayload { 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 { from: FederationRelayParticipant; to: FederationRelayParticipant; From 53fe7d2b53b614138adbfdcb3cfddfe5862c3e95 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 16:03:18 +0200 Subject: [PATCH 06/11] feat(federation): process inbound presence_update relay events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processPresenceUpdateEvent updates the local stub's status and broadcasts a WS presence_update to friends + DM members + space co-members of that stub via collectProfileBroadcastTargetIds. Closes the doc/code drift in activity-presence.md:147 — federated stubs now have their status projected by the home instance as documented. Strict attribution: payload.homeInstance domain must equal source peer domain. Silently no-ops when no local replica exists (peer broadcast fanout covers all peers, not all hold a stub). --- .../routes/federation.presenceUpdate.test.ts | 166 ++++++++++++++++++ packages/server/src/routes/federation.ts | 87 +++++++++ 2 files changed, 253 insertions(+) create mode 100644 packages/server/src/routes/federation.presenceUpdate.test.ts diff --git a/packages/server/src/routes/federation.presenceUpdate.test.ts b/packages/server/src/routes/federation.presenceUpdate.test.ts new file mode 100644 index 00000000..69fd7d8d --- /dev/null +++ b/packages/server/src/routes/federation.presenceUpdate.test.ts @@ -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>; +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' }]); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index fbf244cf..3d3e9079 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -3004,6 +3004,9 @@ export async function processRelayEvents( case 'profile_update': await processProfileUpdateEvent(event, sourceInstance, db, accepted, rejected); break; + case 'presence_update': + processPresenceUpdateEvent(event, sourceInstance, db, accepted, rejected); + break; case 'read_state_update': processReadStateUpdateEvent(event, sourceInstance, db, accepted, rejected); break; @@ -5841,6 +5844,90 @@ export async function processProfileUpdateEvent( 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, + 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 ────────────────────────────────────── /** From ad1a0f71640891ef616a1a0017e22de0c2178161 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 16:06:45 +0200 Subject: [PATCH 07/11] fix(presence): broadcast presence_update to friends + DM members + space members Six WS sites that previously broadcast presence_update to spaces only now use collectProfileBroadcastTargetIds (the same recipient set as user_updated): - ws/handler.ts finalizeDisconnect (offline) - ws/handler.ts auth path (online) - ws/events.ts handlePresenceUpdate (manual idle/dnd/online) - ws/events.ts handleActivityUpdate (rich activity changes) - routes/users.ts showActivity-toggle clear - routes/users.ts status PATCH Friends with no shared space + DM-only co-members now see each other's online/offline transitions live, matching user_updated semantics. Federated stub presence broadcasts (Task B3) use the same helper, so cross-instance recipients are uniform. Updates one assertion in social.federated.test.ts that asserted the old snowflake-style stub username (now realname-based per A1). --- .../src/routes/social.federated.test.ts | 7 ++-- packages/server/src/routes/users.ts | 23 +++++------- packages/server/src/ws/events.ts | 15 ++++---- packages/server/src/ws/handler.ts | 36 +++++++++---------- 4 files changed, 36 insertions(+), 45 deletions(-) diff --git a/packages/server/src/routes/social.federated.test.ts b/packages/server/src/routes/social.federated.test.ts index 82643963..2318fa0f 100644 --- a/packages/server/src/routes/social.federated.test.ts +++ b/packages/server/src/routes/social.federated.test.ts @@ -169,9 +169,12 @@ describe('POST /api/social/requests — federated branch (happy path)', () => { expect(sentEvent).toBeDefined(); expect(sentEvent![0]).toBe(CALLER_ID); expect(sentEvent![1].request.id).toBe(body.requestId); - // homeUserId identifies the target; username is the canonical stub form (@). + // homeUserId identifies the target; username is the realname-based stub form + // (@) since resolveOrCreateReplicatedUser now uses the + // username hint from the wire profile snapshot. Falls back to @ + // only when no hint is available. 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'); }); }); diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 654600a9..b0b9238b 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -436,16 +436,14 @@ export async function userRoutes(app: FastifyInstance): Promise { connectionManager.setUserShowActivity(request.userId, showActivity); if (!showActivity) { connectionManager.clearUserActivities(request.userId); - const userSpaces = connectionManager.getUserSpaces(request.userId); const clearPayload = { type: 'presence_update' as const, userId: request.userId, status: connectionManager.getUserStatus(request.userId), activities: [] as Activity[], }; - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, clearPayload, request.userId); - } + const clearTargets = collectProfileBroadcastTargetIds(request.userId); + for (const uid of clearTargets) connectionManager.sendToUser(uid, clearPayload); connectionManager.sendToUser(request.userId, clearPayload); // S2S: project the cleared-activities snapshot to all active peers. @@ -498,19 +496,14 @@ export async function userRoutes(app: FastifyInstance): Promise { // Broadcast presence update if status changed if (status !== undefined) { - const userSpaces = connectionManager.getUserSpaces(sanitized.id); - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, { - type: 'presence_update', - userId: sanitized.id, - status: status, - }, sanitized.id); - } - connectionManager.sendToUser(sanitized.id, { - type: 'presence_update', + const statusPayload = { + type: 'presence_update' as const, userId: sanitized.id, 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 diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 7fb191c2..8fb3ecad 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -11,6 +11,7 @@ import type { CallRelayResult, CallFanoutFailure } from '../utils/federationOutb import { mapCallReasonToEventReason } from '../utils/federationOutbox.js'; import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js'; import { sanitizeUser } from '../utils/sanitize.js'; +import { collectProfileBroadcastTargetIds } from '../utils/userDeletion.js'; import { deleteAttachmentFiles } from '../utils/fileCleanup.js'; import { resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js'; import { appendMutationLog, queueOutboxEvent, queueDmRelay, getGroupDmTargetOrigins, sendCallRelay, computeFederatedId, sendTypingRelay, queueReadStateRelay } from '../utils/federationOutbox.js'; @@ -497,17 +498,15 @@ function handlePresenceUpdate(event: Record, userId: string): v connectionManager.setUserStatus(userId, status); const activities = connectionManager.getUserActivities(userId); - // Broadcast to all spaces user is in - const userSpaces = connectionManager.getUserSpaces(userId); + // Broadcast to friends + DM co-members + space co-members. const payload = { type: 'presence_update' as const, userId, status, ...(activities.length > 0 ? { activities } : {}), }; - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, payload, userId); - } + const targets = collectProfileBroadcastTargetIds(userId); + for (const uid of targets) connectionManager.sendToUser(uid, payload); // Also send to self (other tabs) connectionManager.sendToUser(userId, payload); @@ -534,11 +533,9 @@ function handleActivityUpdate(event: Record, userId: string): v connectionManager.setUserActivities(userId, activities); const status = connectionManager.getUserStatus(userId); - const userSpaces = connectionManager.getUserSpaces(userId); const payload = { type: 'presence_update' as const, userId, status, activities }; - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, payload, userId); - } + const targets = collectProfileBroadcastTargetIds(userId); + for (const uid of targets) connectionManager.sendToUser(uid, payload); connectionManager.sendToUser(userId, payload); // S2S: project to all active peers (activities + current status). diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 59618974..bd51a7c7 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -21,6 +21,7 @@ import type { Activity, } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; +import { collectProfileBroadcastTargetIds } from '../utils/userDeletion.js'; // ─── Heartbeat State ────────────────────────────────────────────────────────── const wsIsAlive: WeakMap = new WeakMap(); @@ -296,16 +297,18 @@ class ConnectionManager { this.userStatuses.delete(userId); this.lastActivityUpdate.delete(userId); - // Broadcast offline to all spaces - const userSpaces = this.getUserSpaces(userId); - for (const spaceId of userSpaces) { - this.sendToSpace(spaceId, { - type: 'presence_update', - userId: userId, - status: 'offline', - activities: [] as Activity[], - }); - } + // Broadcast offline to friends + DM co-members + space co-members. + // Mirrors collectProfileBroadcastTargetIds (the recipient set used by + // user_updated). Two locally-friended users with no shared space now see + // each other's offline transitions live, instead of being space-only. + const offlinePayload = { + type: 'presence_update' as const, + userId, + 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). @@ -1669,15 +1672,10 @@ export async function registerWebSocket(app: FastifyInstance): Promise { ...readyData, })); - // Broadcast presence update to all spaces - const userSpaces = connectionManager.getUserSpaces(userId); - for (const spaceId of userSpaces) { - connectionManager.sendToSpace(spaceId, { - type: 'presence_update', - userId, - status: 'online', - }, userId); - } + // Broadcast online to friends + DM co-members + space co-members. + const onlinePayload = { type: 'presence_update' as const, userId, status: 'online' as const }; + const onlineTargets = collectProfileBroadcastTargetIds(userId); + for (const uid of onlineTargets) connectionManager.sendToUser(uid, onlinePayload); // S2S: project online to all active peers (mirrors profile_update fanout). const _uid = userId; From 1cb151d3a9f2c91b329392d66724a961196671de Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 16:12:22 +0200 Subject: [PATCH 08/11] feat(federation): peer-lifecycle presence hooks (snapshot on activate, mark-offline on deactivate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onPeerActivated re-emits a presence_update for every relationship-related online native to the activating peer (relationship = friend with peer-stub / DM-mate with peer-stub / replicatedInstances opt-in for peer origin). Scope bounded by relationship count, not native count — flap recovery cost stays proportional to actual interaction surface. onPeerDeactivated flips every replicated stub from that peer to offline and broadcasts a local presence_update so connected friends/DM-mates/space-co-members see them disappear immediately, instead of seeing stale online until next signal. Re-snapshot on every activation (incl. health-check unreachable→active flap) is load-bearing for correctness — markPeerStubsOffline ran on the prior deactivation and presence is not in the mutation log. --- .../src/utils/federationPeerActivation.ts | 20 +++ .../federationPresence.peerLifecycle.test.ts | 165 +++++++++++++++++ .../server/src/utils/federationPresence.ts | 169 +++++++++++++++++- 3 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/utils/federationPresence.peerLifecycle.test.ts diff --git a/packages/server/src/utils/federationPeerActivation.ts b/packages/server/src/utils/federationPeerActivation.ts index 55e5cd64..da131d12 100644 --- a/packages/server/src/utils/federationPeerActivation.ts +++ b/packages/server/src/utils/federationPeerActivation.ts @@ -64,6 +64,15 @@ export async function onPeerActivated( 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'); @@ -385,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 }); } catch (err) { console.error(`[federation] onPeerDeactivated(${peerId}, ${reason}) failed:`, err); diff --git a/packages/server/src/utils/federationPresence.peerLifecycle.test.ts b/packages/server/src/utils/federationPresence.peerLifecycle.test.ts new file mode 100644 index 00000000..f4dc974a --- /dev/null +++ b/packages/server/src/utils/federationPresence.peerLifecycle.test.ts @@ -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>; +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'); + }); +}); diff --git a/packages/server/src/utils/federationPresence.ts b/packages/server/src/utils/federationPresence.ts index 83e3b2d9..9cf67f61 100644 --- a/packages/server/src/utils/federationPresence.ts +++ b/packages/server/src/utils/federationPresence.ts @@ -1,8 +1,10 @@ -import { eq } from 'drizzle-orm'; -import type { Activity, FederationRelayEvent, FederationPresenceUpdatePayload } from '@backspace/shared'; +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'; @@ -59,3 +61,166 @@ export function queuePresenceRelay( '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(); + 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 { + 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); + } +} From ba0f8637f5b85494473afe75c446c38119539d1f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 16:19:03 +0200 Subject: [PATCH 09/11] docs(federation): document username on profile_update + new presence_update relay + stub backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - federation.md §10: extend FederationProfileUpdatePayload with username, document receiver fallback. Add Presence Sync sub-section: event shape, sender call sites, outbox-only (no mutation log) policy, peer-lifecycle hooks, flap recovery semantics. Add Stub Username Backfill sub-section + new /api/federation/users/by-home-id endpoint. - activity-presence.md: resolve drift — line 147 previously claimed S2S presence_update relays existed but the code didn't ship them. Now points to federation.md §10 which describes the actually-implemented mechanism. Connect/ Disconnect Flow updated to reflect collectProfileBroadcastTargetIds recipient set + S2S queueing. - social.md / websocket.md: presence_update recipient column now reflects friends + DM + space co-members (matches user_updated), plus federated stub presence sourced via S2S. --- docs/systems/activity-presence.md | 10 +++--- docs/systems/federation.md | 54 +++++++++++++++++++++++++++++-- docs/systems/social.md | 2 +- docs/systems/websocket.md | 4 +-- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/docs/systems/activity-presence.md b/docs/systems/activity-presence.md index f9513f9e..38d7446b 100644 --- a/docs/systems/activity-presence.md +++ b/docs/systems/activity-presence.md @@ -144,7 +144,7 @@ UPDATE users 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). 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 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) -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 -5. **Reconnect during grace** → `cancelDisconnect` prevents offline broadcast; new connection proceeds normally +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. +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. ### Presence Broadcast Scope diff --git a/docs/systems/federation.md b/docs/systems/federation.md index b544016e..06b475e0 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -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/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/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/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 @@ -1107,8 +1110,9 @@ Profile data is synced server-to-server. The home instance is authoritative — **Event:** `profile_update` (contextType: `profile`) -**Payload:** `FederationProfileUpdatePayload` — full snapshot of 6 durable fields: +**Payload:** `FederationProfileUpdatePayload`: - `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) - `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. -**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 @@ -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. +#### 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 diff --git a/docs/systems/social.md b/docs/systems/social.md index 61f1c60d..8c962a4a 100644 --- a/docs/systems/social.md +++ b/docs/systems/social.md @@ -500,7 +500,7 @@ All handlers also update `discoverStore` relationship state via lazy import. | 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 | --- diff --git a/docs/systems/websocket.md b/docs/systems/websocket.md index fa32e8a3..456daa57 100644 --- a/docs/systems/websocket.md +++ b/docs/systems/websocket.md @@ -12,7 +12,7 @@ Source: `packages/server/src/ws/handler.ts`, `packages/server/src/ws/events.ts` 2. Client sends `{ type: 'auth', token: '' }` within 10 seconds 3. Server validates token (rejects deleted users, tokens issued before `passwordChangedAt`) 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 --- @@ -123,7 +123,7 @@ Source: `packages/server/src/ws/handler.ts`, `packages/server/src/ws/events.ts` ### Presence & Activity | 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 | ### Space / Channel Management From d2bd7987c17689136365658f6c790a247fca5eec Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 16:28:07 +0200 Subject: [PATCH 10/11] fix(federation): include presenceUpdate in outbox-to-relay event rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outbox worker rebuilds FederationRelayEvent objects from stored JSON via an allowlist of known fields. presenceUpdate was missed when presence_update events shipped, so peers received events with eventType='presence_update' but no payload — rejected with missing_presence_update_payload on every tick. --- packages/server/src/utils/federationWorker.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 66909640..fdd82e7c 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -220,6 +220,7 @@ export async function processOutboxTick(): Promise { if (parsed.rejectionLimit != null) evt.rejectionLimit = parsed.rejectionLimit; if (parsed.affectedUserIds) evt.affectedUserIds = parsed.affectedUserIds; if (parsed.profileUpdate) evt.profileUpdate = parsed.profileUpdate; + if (parsed.presenceUpdate) evt.presenceUpdate = parsed.presenceUpdate; if (parsed.readState) evt.readState = parsed.readState; if (parsed.dmCloseReopen) evt.dmCloseReopen = parsed.dmCloseReopen; return evt; From 1effb1c53f2f11463ae6ac79b212568dbb641abf Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 5 May 2026 16:44:22 +0200 Subject: [PATCH 11/11] fix: live presence on freshly-friended remotes + green dot in same session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-on bugs from the initial S2S presence rollout: (1) New friend stuck offline until they reload: presence_update fires only on transitions, so a remote user already online when their stub is created locally never receives a relay event seeding their actual status. The stub defaulted to 'offline' at creation and stayed there until the next transition. Fix: extend FederationRelayProfileSnapshot + FederationUserLookupProfile with status. Sender-side buildProfileSnapshot, getDmParticipants, and lookup endpoint responses populate it for native users only (replicated stubs hold stale status owned elsewhere). resolveOrCreateReplicatedUser uses hints.status to seed the new row's status column. Threaded through every call site (DM participants, group bootstrap, friend events, ownership transfer). Stub backfill worker also heals existing rows whose status was stuck at 'offline' from creation. (2) 'Online' text updates but green avatar dot stays grey on the same page: spaceStore.updateMemberPresence patches members[] (which feeds space UIs) but never patches userViews — the cache useCanonicalUserView reads from. The Avatar in FriendItem reads canonical.status; the text reads friend.status (socialStore). Two sources, one stale until full user_updated arrives. Fix: updateMemberPresence now mirrors status into matching userViews entries, so canonical-view consumers re-render with fresh status the moment the WS event lands. --- packages/server/src/routes/federation.ts | 32 ++++++++++++------- packages/server/src/routes/social.ts | 9 +++++- packages/server/src/utils/federationOutbox.ts | 4 +++ .../src/utils/federationStubBackfill.ts | 9 +++++- packages/shared/src/types.ts | 12 +++++++ packages/web/src/stores/spaceStore.ts | 28 +++++++++++++--- 6 files changed, 75 insertions(+), 19 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 3d3e9079..60357276 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2330,6 +2330,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { avatarColor: user.avatarColor, banner: user.banner, bio: user.bio, + status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null, }, }, }); @@ -2416,6 +2417,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { displayName: user.displayName, avatar: user.avatar, avatarColor: user.avatarColor, + status: user.status as 'online' | 'idle' | 'dnd' | 'offline' | null, banner: user.banner, bio: user.bio, }, @@ -3253,7 +3255,7 @@ export function resolveOrCreateReplicatedUser( homeUserId: string, homeInstance: string, db: ReturnType, - hints?: { username?: string | null }, + hints?: { username?: string | null; status?: 'online' | 'idle' | 'dnd' | 'offline' | null }, ): typeof schema.users.$inferSelect | null { const existing = findFederatedUser(homeUserId, homeInstance, db, hints); if (existing) return backfillHomeUserId(existing, homeUserId, db); @@ -3299,12 +3301,18 @@ export function resolveOrCreateReplicatedUser( const userId = generateSnowflake(); 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({ id: userId, username, displayName: null, passwordHash: '!federation-replicated', // Cannot be used to log in (bcrypt never produces this) - status: 'offline', + status: initialStatus, isAdmin: 0, homeInstance: domain, // Normalized to bare domain homeUserId, @@ -3490,7 +3498,7 @@ async function processCreateEvent( }> = []; 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 if (!localUser) continue; // Hydrate with profile data from the relay event (displayName, avatar, etc.) @@ -4085,7 +4093,7 @@ function processMemberAddEvent( // Resolve owner — create a replicated stub if unknown let ownerId: string | null = null; if (event.group.owner) { - const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username }); + 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; } @@ -4103,7 +4111,7 @@ function processMemberAddEvent( // Add all roster members — create replicated user stubs for any // participants from remote instances that haven't been seen before. for (const member of event.group.members) { - const rosterUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username }); + 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 if (!rosterUser) continue; const existing = db.select().from(schema.dmMembers) @@ -4157,7 +4165,7 @@ function processMemberAddEvent( event.membership.user.homeUserId, event.membership.user.homeInstance, db, - { username: event.membership.user.profile?.username }, + { username: event.membership.user.profile?.username, status: event.membership.user.profile?.status }, ); if (!localUser) { // The user's identity has been deleted — don't add a tombstoned user to the DM @@ -4196,7 +4204,7 @@ function processMemberAddEvent( // would otherwise find the channel already present and fall through to the incremental path, // creating spurious system messages (the exact bug this fixes). const actorUser = event.membership.addedBy - ? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username }) + ? resolveOrCreateReplicatedUser(event.membership.addedBy.homeUserId, event.membership.addedBy.homeInstance, db, { username: event.membership.addedBy.profile?.username, status: event.membership.addedBy.profile?.status }) : null; const actorId = actorUser?.id ?? localUser.id; const addBaseName = localUser.username?.includes('@') ? localUser.username.split('@')[0] : (localUser.username ?? 'Unknown'); @@ -4487,7 +4495,7 @@ function processOwnershipTransferEvent( event.ownership.newOwner.homeUserId, event.ownership.newOwner.homeInstance, db, - { username: event.ownership.newOwner.profile?.username }, + { username: event.ownership.newOwner.profile?.username, status: event.ownership.newOwner.profile?.status }, ); if (!newOwnerLocal) { rejected.push({ messageId: event.messageId, reason: 'participant_not_found' }); @@ -4646,7 +4654,7 @@ async function processFriendRequestCreateEvent( } // Resolve the sender (create stub if needed — they're on a remote instance) - const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); + const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status }); if (!fromUserResolved) { // Sender's identity has been deleted — silently accept to drop the event accepted.push(event.messageId); @@ -4764,7 +4772,7 @@ function processFriendRequestUpdateEvent( } // Resolve the recipient (create stub if needed — they're on the remote instance) - const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); + const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status }); if (!toUser) { // Recipient's identity has been deleted — accept idempotently to drop the event accepted.push(event.messageId); @@ -4904,14 +4912,14 @@ async function processFriendAddEvent( } // Resolve both users (create stubs if needed) and hydrate with profile data - const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); + const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username, status: event.friendship.fromProfile?.status }); if (!fromUserResolved) { // One party's identity is deleted — accept idempotently to drop the event accepted.push(event.messageId); return; } let fromUser = await hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db); - const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); + const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username, status: event.friendship.toProfile?.status }); if (!toUserResolved) { accepted.push(event.messageId); return; diff --git a/packages/server/src/routes/social.ts b/packages/server/src/routes/social.ts index 6244626d..39b07331 100644 --- a/packages/server/src/routes/social.ts +++ b/packages/server/src/routes/social.ts @@ -21,6 +21,11 @@ import type { import { sanitizeUser } from '../utils/sanitize.js'; 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 { username: user.username ?? null, displayName: user.displayName ?? null, @@ -28,6 +33,7 @@ function buildProfileSnapshot(user: typeof schema.users.$inferSelect): Federatio avatarColor: user.avatarColor ?? null, banner: user.banner ?? null, bio: user.bio ?? null, + status, }; } @@ -237,7 +243,7 @@ async function handleFederatedFriendRequest( } // 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) { // Tombstoned identity — refuse to resurrect. 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, banner: lookup.profile.banner, bio: lookup.profile.bio, + status: lookup.profile.status ?? null, }, status: 'pending', createdAt: now, diff --git a/packages/server/src/utils/federationOutbox.ts b/packages/server/src/utils/federationOutbox.ts index cac6f5cf..1b286b43 100644 --- a/packages/server/src/utils/federationOutbox.ts +++ b/packages/server/src/utils/federationOutbox.ts @@ -346,6 +346,7 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa displayName: schema.users.displayName, avatar: schema.users.avatar, avatarColor: schema.users.avatarColor, + status: schema.users.status, }) .from(schema.dmMembers) .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) @@ -362,6 +363,9 @@ export function getDmParticipants(dmChannelId: string): FederationRelayParticipa displayName: m.displayName ?? null, avatar: m.avatar ?? null, avatarColor: m.avatarColor ?? null, + // Only carry presence for native participants — replicated stubs hold + // stale status owned by their home; emitting it would flap remote UIs. + status: !m.homeInstance ? (m.status as 'online' | 'idle' | 'dnd' | 'offline' | null) : null, }, })); } diff --git a/packages/server/src/utils/federationStubBackfill.ts b/packages/server/src/utils/federationStubBackfill.ts index 288e864b..7d2c9466 100644 --- a/packages/server/src/utils/federationStubBackfill.ts +++ b/packages/server/src/utils/federationStubBackfill.ts @@ -74,10 +74,17 @@ export async function backfillStubUsernamesForPeer(peerOrigin: string): Promise< // 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 } = { username: newUsername }; + 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) diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 00e4666a..bb35d68d 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -967,6 +967,15 @@ export interface FederationRelayProfileSnapshot { avatarColor?: string | null; banner?: 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 { @@ -1077,6 +1086,9 @@ export interface FederationUserLookupProfile { avatarColor: AvatarColor | null; banner: 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 = diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index cc66305d..2b6df0e2 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -568,11 +568,29 @@ export const useSpaceStore = create((set, get) => ({ }, updateMemberPresence: (userId: string, status: string) => { - set((state) => ({ - members: state.members.map(m => - m.userId === userId ? { ...m, user: { ...m.user, status: status as 'online' | 'idle' | 'dnd' | 'offline' } } : m - ), - })); + set((state) => { + const typedStatus = status as 'online' | 'idle' | 'dnd' | 'offline'; + // 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) => {