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();
|
}).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) {
|
async function syncPull(app: FastifyInstance, body: object) {
|
||||||
const bodyStr = JSON.stringify(body);
|
const bodyStr = JSON.stringify(body);
|
||||||
return app.inject({
|
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']);
|
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).
|
// Only populated in the DM branch (friend/profile branches don't need it).
|
||||||
let channelFederatedIdMap = new Map<string, string>();
|
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') {
|
if (contextTypeFilter === 'friend') {
|
||||||
// ── Friend event sync: no DM channel logic needed ──
|
// ── Friend event sync: relevance-scoped to the requesting peer ──
|
||||||
mutationRows = rawDb.prepare(`
|
const fetchedFriendRows = rawDb.prepare(`
|
||||||
SELECT id, entity_id, context_id, context_type, mutation_type, mutated_at, payload
|
SELECT id, entity_id, context_id, context_type, mutation_type, mutated_at, payload
|
||||||
FROM federation_mutation_log
|
FROM federation_mutation_log
|
||||||
WHERE context_type = 'friend' AND mutated_at > ?
|
WHERE context_type = 'friend' AND mutated_at > ?
|
||||||
ORDER BY mutated_at ASC
|
ORDER BY mutated_at ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`).all(sinceTimestamp, limit) as typeof mutationRows;
|
`).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') {
|
} else if (contextTypeFilter === 'profile') {
|
||||||
// ── Profile event sync: no DM channel logic needed ──
|
// ── Profile event sync: no DM channel logic needed ──
|
||||||
mutationRows = rawDb.prepare(`
|
mutationRows = rawDb.prepare(`
|
||||||
@@ -3238,11 +3280,13 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Compute pagination metadata
|
// 6. Compute pagination metadata — from PRE-filter rows when the friend
|
||||||
const hasMore = mutationRows.length >= limit;
|
// branch filtered, so filtered-out events still advance the cursor.
|
||||||
const checkpoint = mutationRows.length > 0
|
const hasMore = (prefilterCount ?? mutationRows.length) >= limit;
|
||||||
|
const checkpoint = prefilterLastTs
|
||||||
|
?? (mutationRows.length > 0
|
||||||
? mutationRows[mutationRows.length - 1]!.mutated_at
|
? mutationRows[mutationRows.length - 1]!.mutated_at
|
||||||
: sinceTimestamp;
|
: sinceTimestamp);
|
||||||
|
|
||||||
// 7. Update peer last-seen timestamp
|
// 7. Update peer last-seen timestamp
|
||||||
db.update(schema.federationPeers)
|
db.update(schema.federationPeers)
|
||||||
|
|||||||
Reference in New Issue
Block a user