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:
@@ -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');
|
||||
|
||||
@@ -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()!;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -149,6 +149,47 @@ describe('markPeerReset — detection-only reset routing', () => {
|
||||
expect(row.resolvedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('re-detected reset clears a stale acknowledgedAt (dismissed card re-surfaces)', async () => {
|
||||
seedPeer();
|
||||
seedUser('stub-1', { passwordHash: STUB });
|
||||
seedUser('real-1', { passwordHash: '$2b$10$realbcrypthash' });
|
||||
|
||||
const { markPeerReset } = await import('./federationReset.js');
|
||||
|
||||
// First reset detected, then the admin dismisses (acknowledges) the card.
|
||||
markPeerReset('peer-1', ORIGIN, 'E0', 'E1');
|
||||
testDb.update(schema.federationResetEvents)
|
||||
.set({ acknowledgedAt: Date.now() })
|
||||
.where(eq(schema.federationResetEvents.origin, ORIGIN)).run();
|
||||
expect(testDb.select().from(schema.federationResetEvents)
|
||||
.where(eq(schema.federationResetEvents.origin, ORIGIN)).get()!.acknowledgedAt).not.toBeNull();
|
||||
|
||||
// The peer resets AGAIN before the first was resolved — a fresh batch is
|
||||
// detached and needs fresh admin attention, so the dismissal must clear.
|
||||
markPeerReset('peer-1', ORIGIN, 'E0', 'E2');
|
||||
expect(testDb.select().from(schema.federationResetEvents)
|
||||
.where(eq(schema.federationResetEvents.origin, ORIGIN)).get()!.acknowledgedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('a resolved+acknowledged prior reset is re-armed (acknowledgedAt cleared) on a new reset', async () => {
|
||||
seedPeer();
|
||||
seedUser('stub-1', { passwordHash: STUB });
|
||||
|
||||
const { markPeerReset } = await import('./federationReset.js');
|
||||
markPeerReset('peer-1', ORIGIN, 'E0', 'E1');
|
||||
// Simulate the heal resolving the first reset AND the admin dismissing it.
|
||||
testDb.update(schema.federationResetEvents)
|
||||
.set({ resolvedAt: Date.now(), newEpoch: 'E1', acknowledgedAt: Date.now() })
|
||||
.where(eq(schema.federationResetEvents.origin, ORIGIN)).run();
|
||||
|
||||
// Brand-new reset lands (fresh-journal / onConflictDoUpdate branch).
|
||||
markPeerReset('peer-1', ORIGIN, 'E1', 'E2');
|
||||
const row = testDb.select().from(schema.federationResetEvents)
|
||||
.where(eq(schema.federationResetEvents.origin, ORIGIN)).get()!;
|
||||
expect(row.resolvedAt).toBeNull();
|
||||
expect(row.acknowledgedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('matches home_instance stored as a full URL (defensive format match)', async () => {
|
||||
seedPeer();
|
||||
// Legacy straggler stored with the https:// prefix rather than bare domain.
|
||||
|
||||
@@ -113,9 +113,12 @@ export function markPeerReset(peerId: string, origin: string, deadEpoch: string,
|
||||
if (existing && existing.resolvedAt === null) {
|
||||
// Double-reset: keep the ORIGINAL dead_epoch + detected_at (the
|
||||
// incarnation already snapshotted), refresh counts only. Never overwrite
|
||||
// dead_epoch on an unresolved row.
|
||||
// dead_epoch on an unresolved row. Clear `acknowledged_at`: a re-detected
|
||||
// reset is a fresh event that detached a new batch and needs fresh admin
|
||||
// attention — a stale dismissal must not keep the disposition card hidden
|
||||
// (detach spec §4.6).
|
||||
tx.update(schema.federationResetEvents)
|
||||
.set({ stubCount, orphanedAccountCount })
|
||||
.set({ stubCount, orphanedAccountCount, acknowledgedAt: null })
|
||||
.where(eq(schema.federationResetEvents.origin, origin))
|
||||
.run();
|
||||
} else {
|
||||
@@ -140,6 +143,10 @@ export function markPeerReset(peerId: string, origin: string, deadEpoch: string,
|
||||
resolvedAt: null,
|
||||
stubCount,
|
||||
orphanedAccountCount,
|
||||
// Re-arm the admin surface: a fresh reset on a previously
|
||||
// resolved+dismissed origin must clear the old dismissal so the
|
||||
// new detached batch's disposition card re-surfaces (detach §4.6).
|
||||
acknowledgedAt: null,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
+4
@@ -218,6 +218,10 @@ describe('FederationPanel — Reset cleanup', () => {
|
||||
const removeBtn = await screen.findByRole('button', { name: 'Remove' });
|
||||
fireEvent.click(removeBtn);
|
||||
|
||||
// The confirm dialog title uses the current "detached" vocabulary, not the
|
||||
// legacy "Orphaned Account" wording.
|
||||
expect(await screen.findByText('Remove detached account')).toBeInTheDocument();
|
||||
|
||||
const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' });
|
||||
fireEvent.click(confirmBtn);
|
||||
|
||||
|
||||
@@ -1095,7 +1095,7 @@ function ResetCleanup() {
|
||||
isOpen={true}
|
||||
onClose={() => { if (!actionLoading) setConfirmAction(null); }}
|
||||
onConfirm={handleConfirm}
|
||||
title={confirmAction.kind === 'repeer' ? 'Re-establish Federation' : 'Remove Orphaned Account'}
|
||||
title={confirmAction.kind === 'repeer' ? 'Re-establish Federation' : 'Remove detached account'}
|
||||
description={
|
||||
confirmAction.kind === 'repeer'
|
||||
? `This deletes the local peer record and starts a fresh authenticated handshake with ${confirmAction.peer.origin}. The remote must be reachable and (if it does not auto-accept) approve the request.`
|
||||
|
||||
Reference in New Issue
Block a user