feat(federation): sync endpoint scopes friend events to the requesting peer, pagination-safe (dead-incarnation spec §3.2)

This commit is contained in:
Jannis Braun
2026-07-03 00:50:31 +02:00
parent bd40058613
commit 5ffc7c565e
2 changed files with 113 additions and 7 deletions
@@ -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);
});
});
+51 -7
View File
@@ -2836,15 +2836,57 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// Only populated in the DM branch (friend/profile branches don't need it).
let channelFederatedIdMap = new Map<string, string>();
// Friend-branch pagination must be computed from PRE-filter rows —
// filtering in place would stall the checkpoint / drop pages (spec §3.2).
let prefilterCount: number | null = null;
let prefilterLastTs: number | null = null;
if (contextTypeFilter === 'friend') {
// ── Friend event sync: no DM channel logic needed ──
mutationRows = rawDb.prepare(`
// ── Friend event sync: relevance-scoped to the requesting peer ──
const fetchedFriendRows = rawDb.prepare(`
SELECT id, entity_id, context_id, context_type, mutation_type, mutated_at, payload
FROM federation_mutation_log
WHERE context_type = 'friend' AND mutated_at > ?
ORDER BY mutated_at ASC
LIMIT ?
`).all(sinceTimestamp, limit) as typeof mutationRows;
prefilterCount = fetchedFriendRows.length;
prefilterLastTs = fetchedFriendRows.length > 0
? fetchedFriendRows[fetchedFriendRows.length - 1]!.mutated_at
: null;
const peerDomainFriend = extractDomain(peer.origin).toLowerCase();
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
const localRowStmt = rawDb.prepare(`
SELECT is_deleted, federation_home_orphaned FROM users
WHERE home_user_id = ? AND ${normHome} = ?
`);
// An event qualifies iff at least one side is homed at the requester's
// domain AND that side, when it resolves to a local row, is live and
// non-detached. A detached/tombstoned row belongs to a dead incarnation
// of the requester, not to the requester (spec §3.2).
const sideQualifies = (side: { homeUserId?: string; homeInstance?: string } | undefined): boolean => {
if (!side?.homeUserId || !side.homeInstance) return false;
if (extractDomain(side.homeInstance).toLowerCase() !== peerDomainFriend) return false;
const local = localRowStmt.get(side.homeUserId, peerDomainFriend) as
{ is_deleted: number; federation_home_orphaned: number } | undefined;
if (local && (local.is_deleted === 1 || local.federation_home_orphaned === 1)) return false;
return true;
};
mutationRows = fetchedFriendRows.filter((row) => {
if (!row.payload) return false;
let friendship: { from?: { homeUserId?: string; homeInstance?: string }; to?: { homeUserId?: string; homeInstance?: string } } | undefined;
try {
friendship = (JSON.parse(row.payload) as { friendship?: typeof friendship }).friendship;
} catch {
return false;
}
if (!friendship) return false;
return sideQualifies(friendship.from) || sideQualifies(friendship.to);
});
} else if (contextTypeFilter === 'profile') {
// ── Profile event sync: no DM channel logic needed ──
mutationRows = rawDb.prepare(`
@@ -3238,11 +3280,13 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
});
}
// 6. Compute pagination metadata
const hasMore = mutationRows.length >= limit;
const checkpoint = mutationRows.length > 0
? mutationRows[mutationRows.length - 1]!.mutated_at
: sinceTimestamp;
// 6. Compute pagination metadata — from PRE-filter rows when the friend
// branch filtered, so filtered-out events still advance the cursor.
const hasMore = (prefilterCount ?? mutationRows.length) >= limit;
const checkpoint = prefilterLastTs
?? (mutationRows.length > 0
? mutationRows[mutationRows.length - 1]!.mutated_at
: sinceTimestamp);
// 7. Update peer last-seen timestamp
db.update(schema.federationPeers)