fix(federation): close detached-account gaps from final review — presence/hydrate guards, ack re-detect clear, self-delete password (detach spec §4.3/§4.4/§4.6)

This commit is contained in:
Jannis Braun
2026-07-02 19:34:32 +02:00
parent 172398171a
commit 13d050c1bb
10 changed files with 190 additions and 8 deletions
@@ -145,6 +145,66 @@ describe('S2S identity delete — detached account guard', () => {
});
});
describe('S2S presence_update — detached account guard', () => {
it('skips a detached account (acked, status unchanged)', async () => {
const fed = await import('./federation.js');
const event: FederationRelayEvent = {
eventType: 'presence_update',
contextType: 'profile',
messageId: 'm-presence-hijack',
encryptionVersion: 0,
timestamp: Date.now(),
presenceUpdate: {
homeUserId: DETACHED_HOME_UID,
homeInstance: PEER_DOMAIN,
status: 'online', // would flip the sovereign account's presence if not guarded
ts: Date.now(),
},
};
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
fed.processPresenceUpdateEvent(event, PEER_DOMAIN, testDb, accepted, rejected);
// Acked (not rejected) — avoid a sender retry loop.
expect(rejected).toEqual([]);
expect(accepted).toEqual(['m-presence-hijack']);
// The detached row's status is untouched (stays offline).
const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get();
expect(row?.status).toBe('offline');
});
});
describe('hydrateReplicatedUserProfile — detached account guard', () => {
it('leaves a detached row untouched even when fields are empty', async () => {
const fed = await import('./federation.js');
const before = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!;
expect(before.bio).toBeNull();
expect(before.avatarColor).toBeNull();
// A replayed old-homeUserId snapshot from the new incarnation. Without the
// detached guard, hydrate would fill the empty bio / avatarColor fields.
const result = await fed.hydrateReplicatedUserProfile(before, {
username: 'alice',
displayName: 'Hijacked',
bio: 'hijacked bio',
avatarColor: '#ffffff',
avatar: null,
banner: null,
}, testDb);
// No-op return: the row object is returned unchanged.
expect(result.bio).toBeNull();
expect(result.avatarColor).toBeNull();
// And the DB row is untouched.
const after = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!;
expect(after.bio).toBeNull();
expect(after.avatarColor).toBeNull();
expect(after.displayName).toBe('Alice');
});
});
describe('S2S profile_update — detached account guard', () => {
it('skips a detached account (acked, profile unchanged)', async () => {
const fed = await import('./federation.js');
+16
View File
@@ -4962,6 +4962,12 @@ export async function hydrateReplicatedUserProfile(
): Promise<typeof schema.users.$inferSelect> {
if (!profile) return user;
if (!user.homeInstance) return user; // Don't update native users
// Detached accounts are sovereign local accounts: the home domain now belongs
// to a different incarnation, so a relayed snapshot resolved via an old
// homeUserId (tier-1 historical hit) must never fill this row's fields. No-op
// return, mirroring the profile_update / presence_update / identity-delete
// guards (detach spec §4.3).
if (user.federationHomeOrphaned === 1) return user;
const baseUrl = user.homeInstance.startsWith('http') ? user.homeInstance : `https://${user.homeInstance}`;
const buildAbsoluteUrl = (value: string): string => {
@@ -6572,6 +6578,16 @@ export function processPresenceUpdateEvent(
return;
}
// Detached accounts are sovereign: the domain now belongs to a different
// incarnation, which must never flip the established account's presence by
// replaying its old homeUserId. Ack (not reject) — the sender considers this
// identity theirs to update; from our side the update simply no-ops.
if (localUser.federationHomeOrphaned === 1) {
console.log(`[federation] Skipping presence_update for detached account ${localUser.id} (home-orphaned)`);
accepted.push(event.messageId);
return;
}
db.update(schema.users)
.set({ status: payload.status })
.where(eq(schema.users.id, localUser.id))
@@ -198,6 +198,51 @@ describe('POST /api/users/@me/change-password — local rule for detached accoun
});
});
describe('DELETE /api/users/@me — local rule for detached accounts', () => {
it('detached self-delete REQUIRES the local password (local rule)', async () => {
// No password → 400
const missing = await app.inject({
method: 'DELETE',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { username: DETACHED_USERNAME },
});
expect(missing.statusCode).toBe(400);
expect(testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!.isDeleted).toBe(0);
// Wrong password → 403
const wrong = await app.inject({
method: 'DELETE',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { username: DETACHED_USERNAME, password: 'not-the-password' },
});
expect(wrong.statusCode).toBe(403);
expect(testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!.isDeleted).toBe(0);
// Correct password → 200 and the account is tombstoned.
const ok = await app.inject({
method: 'DELETE',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${detachedToken()}` },
payload: { username: DETACHED_USERNAME, password: DETACHED_PASSWORD },
});
expect(ok.statusCode).toBe(200);
expect(testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!.isDeleted).toBe(1);
});
it('non-detached federated self-delete still works JWT-only (no password required)', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/api/users/@me',
headers: { Authorization: `Bearer ${federatedToken()}` },
payload: { username: FEDERATED_USERNAME },
});
expect(res.statusCode).toBe(200);
expect(testDb.select().from(schema.users).where(eq(schema.users.id, FEDERATED_ID)).get()!.isDeleted).toBe(1);
});
});
describe('sanitizeUser — federationHomeOrphaned is self-view only', () => {
it('exposes federationHomeOrphaned only on self-view', () => {
const detachedRow = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get()!;
+7 -2
View File
@@ -121,8 +121,13 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'Username does not match', statusCode: 400 });
}
// Native users must verify password; federated users rely on JWT auth
if (!user.homeInstance) {
// Native users must verify password; non-detached federated users rely on
// JWT auth (their home instance already vouches for them). EXCEPTION:
// detached accounts (federation_home_orphaned = 1) are sovereign local
// accounts with no home verifying anything — they follow the LOCAL rule and
// must supply their local password to self-destruct, mirroring
// change-password (detach spec §4.4).
if (!user.homeInstance || user.federationHomeOrphaned === 1) {
if (!password || typeof password !== 'string') {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}