feat(federation): quarantine real accounts on reset heal (freeze + free-handle)

This commit is contained in:
Jannis Braun
2026-07-02 01:39:26 +02:00
parent e0a0d92fe7
commit 732d146396
2 changed files with 164 additions and 7 deletions
@@ -187,7 +187,7 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
.where(eq(schema.users.id, id)).run();
}
it('genuine reset: soft-tombstones flagged stubs only, leaves real accounts flagged + intact, resolves journal', async () => {
it('genuine reset: soft-tombstones flagged stubs, quarantines (freeze+rename) real accounts, resolves journal', async () => {
seedPeer();
seedJournal('E0');
// A local native user to be the friendship counterpart.
@@ -201,7 +201,8 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
testDb.insert(schema.friends).values({
userId: 'stub-1', friendId: 'local-1', createdAt: Date.now(),
}).run();
// Flagged REAL federated account (real bcrypt) — must survive untouched + still flagged.
// Flagged REAL federated account (real bcrypt), no owned space — must survive
// (never deleted) but be quarantined: frozen + renamed to free the handle.
seedUser('real-1', { passwordHash: '$2b$10$realbcrypthash' });
flag('real-1');
@@ -218,11 +219,13 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
// Heal flag cleared on the healed stub.
expect(stub.federationHealPending).toBe(0);
// Real account UNTOUCHED and STILL flagged (left for Phase 2 quarantine).
// Real account NEVER deleted (content preserved) but quarantined: frozen,
// handle freed via rename, heal flag cleared (Phase 2 §6.3b).
const real = testDb.select().from(schema.users).where(eq(schema.users.id, 'real-1')).get()!;
expect(real.isDeleted).toBe(0);
expect(real.username).toBe('real-1@peer.example');
expect(real.federationHealPending).toBe(1);
expect(real.username).toBe('!orphaned:real-1@peer.example');
expect(real.federationHomeOrphaned).toBe(1);
expect(real.federationHealPending).toBe(0);
// Journal resolved with the freshly-handshaked epoch.
const journal = testDb.select().from(schema.federationResetEvents)
@@ -277,3 +280,91 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
}
});
});
describe('healResetIncarnation — real-account quarantine (Phase 2)', () => {
const QORIGIN = 'orbit.ddns.net';
let uidCounter = 0;
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
vi.clearAllMocks();
uidCounter = 0;
});
afterEach(() => {
sqlite.close();
});
function seedJournal(opts: { origin: string; deadEpoch: string }): void {
testDb.insert(schema.federationResetEvents).values({
origin: opts.origin, deadEpoch: opts.deadEpoch, newEpoch: null,
detectedAt: Date.now(), resolvedAt: null,
stubCount: 0, orphanedAccountCount: 0,
}).run();
}
function seedRealAccount(opts: { homeInstance: string; username: string; healPending: number }): string {
const id = `real-${++uidCounter}`;
testDb.insert(schema.users).values({
id, username: opts.username, passwordHash: '$2b$10$realbcrypthash',
homeInstance: opts.homeInstance, homeUserId: id,
isDeleted: 0, federationHealPending: opts.healPending, createdAt: Date.now(),
}).run();
return id;
}
function seedSpace(opts: { ownerId: string; name: string }): void {
testDb.insert(schema.spaces).values({
id: `space-${opts.ownerId}`, name: opts.name,
ownerId: opts.ownerId, createdAt: Date.now(),
}).run();
}
it('renames + freezes a flagged real account with NO owned spaces', async () => {
seedJournal({ origin: QORIGIN, deadEpoch: 'E0' });
const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 });
const { healResetIncarnation } = await import('./federationReset.js');
healResetIncarnation(QORIGIN, 'E1', 'initiate_accepted');
const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!;
expect(row.username).toBe(`!orphaned:${uid}@orbit.ddns.net`); // handle freed
expect(row.federationHomeOrphaned).toBe(1); // frozen
expect(row.federationHealPending).toBe(0); // processed
expect(row.isDeleted).toBe(0); // NOT deleted (content preserved)
});
it('freezes but does NOT rename a flagged real account that OWNS a space; surfaces it', async () => {
seedJournal({ origin: QORIGIN, deadEpoch: 'E0' });
const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'dave@orbit.ddns.net', healPending: 1 });
seedSpace({ ownerId: uid, name: 'Dave HQ' }); // owns a space
const { healResetIncarnation } = await import('./federationReset.js');
healResetIncarnation(QORIGIN, 'E1', 'initiate_accepted');
const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!;
expect(row.username).toBe('dave@orbit.ddns.net'); // NOT renamed (owner)
expect(row.federationHomeOrphaned).toBe(1); // frozen
expect(row.federationHealPending).toBe(0); // processed
expect(row.isDeleted).toBe(0);
// journal orphaned_account_count reflects the frozen set (1)
const j = testDb.select().from(schema.federationResetEvents)
.where(eq(schema.federationResetEvents.origin, QORIGIN)).get()!;
expect(j.orphanedAccountCount).toBe(1);
});
it('false-positive branch (same incarnation) does NOT quarantine real accounts', async () => {
seedJournal({ origin: QORIGIN, deadEpoch: 'E0' });
const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 });
const { healResetIncarnation } = await import('./federationReset.js');
healResetIncarnation(QORIGIN, 'E0', 'accept_new'); // newEpoch == deadEpoch → false alarm
const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!;
expect(row.username).toBe('carol@orbit.ddns.net'); // untouched
expect(row.federationHomeOrphaned ?? 0).toBe(0); // NOT frozen
expect(row.federationHealPending).toBe(0); // flags cleared (false-alarm path)
});
});
+68 -2
View File
@@ -270,7 +270,6 @@ export function healResetIncarnation(origin: string, newEpoch: string, reason: P
// Clear the heal flag on exactly the stubs we healed, keyed by id.
// `tombstoneUser` has already randomized their `password_hash`, so re-querying
// by the stub sentinel would miss them — the id list is the reliable key.
// Real accounts keep `federation_heal_pending = 1` for Phase 2.
if (stubs.length > 0) {
db.update(schema.users)
.set({ federationHealPending: 0 })
@@ -278,8 +277,75 @@ export function healResetIncarnation(origin: string, newEpoch: string, reason: P
.run();
}
// Quarantine the flagged REAL accounts (freeze + free-handle / surface). §6.3b.
const orphanedCount = quarantineOrphanedAccounts(origin);
// Resolve the journal and refresh the orphaned-account count to the frozen set.
db.update(schema.federationResetEvents)
.set({ newEpoch, resolvedAt: Date.now() })
.set({ newEpoch, resolvedAt: Date.now(), orphanedAccountCount: orphanedCount })
.where(eq(schema.federationResetEvents.origin, origin))
.run();
}
/**
* Post-heal quarantine of the dead incarnation's REAL federated accounts (design
* §6.3b). Called from `healResetIncarnation`'s genuine-reset branch AFTER the stub
* soft-tombstone loop. Real accounts carry non-re-syncable local content and are
* NEVER auto-deleted — they are FROZEN and surfaced to the admin.
*
* For every flagged real account (`federation_heal_pending = 1`,
* `passwordHash != REPLICATED_STUB_SENTINEL`, `isDeleted = 0`) for this origin:
* - Set `federation_home_orphaned = 1` (FREEZE). This is universal — it is what
* closes the post-re-peer hijack (the Task-2 epoch guard passes once the
* baseline is updated to the new epoch, so the freeze is the only remaining
* barrier). The direct-login freeze (auth.ts) enforces it.
* - If the account OWNS local spaces: do NOT rename it. Space ownership must be
* resolved by a human (admin Remove → transfer/delete first). Renaming an owner
* would orphan the ownerId reference into a `!orphaned:` handle, confusing to
* members. It stays frozen + surfaced.
* - Otherwise: rename `username → !orphaned:{uid}@{domain}` to FREE the handle so
* a returning same-name user re-registers into a clean fresh account instead of
* colliding (defends BOTH login uniqueness AND the registration tier-2
* stub-resolution upgrade path — see the plan's collision analysis).
* - Clear `federation_heal_pending` (processed).
*
* Content (space messages, memberships, reactions) is preserved in all cases.
*
* @returns the number of accounts quarantined (frozen) — used to refresh the
* journal's `orphaned_account_count`.
*/
export function quarantineOrphanedAccounts(origin: string): number {
const db = getDb();
const domain = extractDomain(origin);
const accounts = db
.select({ id: schema.users.id })
.from(schema.users)
.where(and(
eq(schema.users.federationHealPending, 1),
eq(schema.users.isDeleted, 0),
sql`${schema.users.passwordHash} != ${REPLICATED_STUB_SENTINEL}`,
homeInstanceMatch(origin),
))
.all();
for (const acct of accounts) {
const ownsSpace = db
.select({ id: schema.spaces.id })
.from(schema.spaces)
.where(eq(schema.spaces.ownerId, acct.id))
.get();
const updates: Record<string, string | number> = {
federationHomeOrphaned: 1,
federationHealPending: 0,
};
if (!ownsSpace) {
// Free the handle only for non-owners.
updates.username = `!orphaned:${acct.id}@${domain}`;
}
db.update(schema.users).set(updates).where(eq(schema.users.id, acct.id)).run();
}
return accounts.length;
}