feat(federation): sync endpoint scopes friend events to the requesting peer, pagination-safe (dead-incarnation spec §3.2)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user