From 5ffc7c565e5a4475fb5e930e3f0886103ff7f576 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:50:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(federation):=20sync=20endpoint=20scopes=20?= =?UTF-8?q?friend=20events=20to=20the=20requesting=20peer,=20pagination-sa?= =?UTF-8?q?fe=20(dead-incarnation=20spec=20=C2=A73.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routes/federation.syncRelevance.test.ts | 62 +++++++++++++++++++ packages/server/src/routes/federation.ts | 58 ++++++++++++++--- 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/packages/server/src/routes/federation.syncRelevance.test.ts b/packages/server/src/routes/federation.syncRelevance.test.ts index 1e271803..a4905724 100644 --- a/packages/server/src/routes/federation.syncRelevance.test.ts +++ b/packages/server/src/routes/federation.syncRelevance.test.ts @@ -103,6 +103,20 @@ function seedDmWithMessage(channelId: string, memberIds: string[], authorId: str }).run(); } +function seedFriendMutation(id: string, ts: number, from: { homeUserId: string; homeInstance: string }, to: { homeUserId: string; homeInstance: string }): void { + testDb.insert(schema.federationMutationLog).values({ + id, entityId: `fr-${id}`, contextId: `fr-ctx-${id}`, + contextType: 'friend', mutationType: 'friend_add', mutatedAt: ts, + payload: JSON.stringify({ + friendship: { + from, to, + fromProfile: { username: 'x' }, toProfile: { username: 'y' }, + createdAt: ts, + }, + }), + }).run(); +} + async function syncPull(app: FastifyInstance, body: object) { const bodyStr = JSON.stringify(body); return app.inject({ @@ -169,3 +183,51 @@ describe('POST /api/federation/sync — DM relevance filter', () => { expect(body.events.map((e: { dmChannelId: string }) => e.dmChannelId)).toEqual(['ch-live']); }); }); + +describe('POST /api/federation/sync — friend relevance filter', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedPeer(); + app = await buildApp(); + }); + + it('returns friend events involving the requester domain; filters unrelated ones', async () => { + seedFriendMutation('f1', 100, + { homeUserId: 'a1', homeInstance: 'https://home.test' }, + { homeUserId: 'b1', homeInstance: 'https://orbit.test' }); // involves requester → returned + seedFriendMutation('f2', 110, + { homeUserId: 'a2', homeInstance: 'https://home.test' }, + { homeUserId: 'c1', homeInstance: 'https://elsewhere.test' }); // unrelated → filtered + const res = await syncPull(app, { sinceTimestamp: 0, contextType: 'friend' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.events).toHaveLength(1); + expect(body.events[0].friendship.to.homeUserId).toBe('b1'); + // Checkpoint advances past the FILTERED row too (pre-filter pagination). + expect(body.checkpoint).toBe(110); + }); + + it('does not qualify an event via a side that resolves to a DETACHED local row', async () => { + seedUser({ id: 'stub-dead', username: 'dead@orbit.test', homeInstance: 'orbit.test', homeUserId: 'dead-home', federationHomeOrphaned: 1 }); + seedFriendMutation('f3', 100, + { homeUserId: 'a1', homeInstance: 'https://home.test' }, + { homeUserId: 'dead-home', homeInstance: 'https://orbit.test' }); + const res = await syncPull(app, { sinceTimestamp: 0, contextType: 'friend' }); + const body = JSON.parse(res.body); + expect(body.events).toEqual([]); + expect(body.checkpoint).toBe(100); // still advances + }); + + it('qualifies a requester-domain side with no local row (receiver guard is the backstop)', async () => { + seedFriendMutation('f4', 100, + { homeUserId: 'a1', homeInstance: 'https://home.test' }, + { homeUserId: 'unknown-home', homeInstance: 'https://orbit.test' }); + const res = await syncPull(app, { sinceTimestamp: 0, contextType: 'friend' }); + const body = JSON.parse(res.body); + expect(body.events).toHaveLength(1); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index b9f4caaf..ab1d905f 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2836,15 +2836,57 @@ export async function federationRoutes(app: FastifyInstance): Promise { // Only populated in the DM branch (friend/profile branches don't need it). let channelFederatedIdMap = new Map(); + // Friend-branch pagination must be computed from PRE-filter rows — + // filtering in place would stall the checkpoint / drop pages (spec §3.2). + let prefilterCount: number | null = null; + let prefilterLastTs: number | null = null; + if (contextTypeFilter === 'friend') { - // ── Friend event sync: no DM channel logic needed ── - mutationRows = rawDb.prepare(` + // ── Friend event sync: relevance-scoped to the requesting peer ── + const fetchedFriendRows = rawDb.prepare(` SELECT id, entity_id, context_id, context_type, mutation_type, mutated_at, payload FROM federation_mutation_log WHERE context_type = 'friend' AND mutated_at > ? ORDER BY mutated_at ASC LIMIT ? `).all(sinceTimestamp, limit) as typeof mutationRows; + + prefilterCount = fetchedFriendRows.length; + prefilterLastTs = fetchedFriendRows.length > 0 + ? fetchedFriendRows[fetchedFriendRows.length - 1]!.mutated_at + : null; + + const peerDomainFriend = extractDomain(peer.origin).toLowerCase(); + const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`; + const localRowStmt = rawDb.prepare(` + SELECT is_deleted, federation_home_orphaned FROM users + WHERE home_user_id = ? AND ${normHome} = ? + `); + + // An event qualifies iff at least one side is homed at the requester's + // domain AND that side, when it resolves to a local row, is live and + // non-detached. A detached/tombstoned row belongs to a dead incarnation + // of the requester, not to the requester (spec §3.2). + const sideQualifies = (side: { homeUserId?: string; homeInstance?: string } | undefined): boolean => { + if (!side?.homeUserId || !side.homeInstance) return false; + if (extractDomain(side.homeInstance).toLowerCase() !== peerDomainFriend) return false; + const local = localRowStmt.get(side.homeUserId, peerDomainFriend) as + { is_deleted: number; federation_home_orphaned: number } | undefined; + if (local && (local.is_deleted === 1 || local.federation_home_orphaned === 1)) return false; + return true; + }; + + mutationRows = fetchedFriendRows.filter((row) => { + if (!row.payload) return false; + let friendship: { from?: { homeUserId?: string; homeInstance?: string }; to?: { homeUserId?: string; homeInstance?: string } } | undefined; + try { + friendship = (JSON.parse(row.payload) as { friendship?: typeof friendship }).friendship; + } catch { + return false; + } + if (!friendship) return false; + return sideQualifies(friendship.from) || sideQualifies(friendship.to); + }); } else if (contextTypeFilter === 'profile') { // ── Profile event sync: no DM channel logic needed ── mutationRows = rawDb.prepare(` @@ -3238,11 +3280,13 @@ export async function federationRoutes(app: FastifyInstance): Promise { }); } - // 6. Compute pagination metadata - const hasMore = mutationRows.length >= limit; - const checkpoint = mutationRows.length > 0 - ? mutationRows[mutationRows.length - 1]!.mutated_at - : sinceTimestamp; + // 6. Compute pagination metadata — from PRE-filter rows when the friend + // branch filtered, so filtered-out events still advance the cursor. + const hasMore = (prefilterCount ?? mutationRows.length) >= limit; + const checkpoint = prefilterLastTs + ?? (mutationRows.length > 0 + ? mutationRows[mutationRows.length - 1]!.mutated_at + : sinceTimestamp); // 7. Update peer last-seen timestamp db.update(schema.federationPeers)