diff --git a/packages/server/src/utils/federationReset.test.ts b/packages/server/src/utils/federationReset.test.ts index 32e156bf..1bb44bb1 100644 --- a/packages/server/src/utils/federationReset.test.ts +++ b/packages/server/src/utils/federationReset.test.ts @@ -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, quarantines (freeze+rename) real accounts, resolves journal', async () => { + it('genuine reset: soft-tombstones flagged stubs, detaches real accounts (flag only, name kept), resolves journal', async () => { seedPeer(); seedJournal('E0'); // A local native user to be the friendship counterpart. @@ -202,7 +202,7 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => { userId: 'stub-1', friendId: 'local-1', createdAt: Date.now(), }).run(); // Flagged REAL federated account (real bcrypt), no owned space — must survive - // (never deleted) but be quarantined: frozen + renamed to free the handle. + // (never deleted) and be DETACHED: flagged orphaned, username preserved. seedUser('real-1', { passwordHash: '$2b$10$realbcrypthash' }); flag('real-1'); @@ -219,11 +219,11 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => { // Heal flag cleared on the healed stub. expect(stub.federationHealPending).toBe(0); - // Real account NEVER deleted (content preserved) but quarantined: frozen, - // handle freed via rename, heal flag cleared (Phase 2 §6.3b). + // Real account NEVER deleted (content preserved) and DETACHED: orphaned flag + // set, username PRESERVED, heal flag cleared (detach spec §4.2). const real = testDb.select().from(schema.users).where(eq(schema.users.id, 'real-1')).get()!; expect(real.isDeleted).toBe(0); - expect(real.username).toBe('!orphaned:real-1@peer.example'); + expect(real.username).toBe('real-1@peer.example'); // unchanged — no rename expect(real.federationHomeOrphaned).toBe(1); expect(real.federationHealPending).toBe(0); @@ -308,7 +308,7 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => { }); }); -describe('healResetIncarnation — real-account quarantine (Phase 2)', () => { +describe('healResetIncarnation — real-account detach (Phase 2)', () => { const QORIGIN = 'orbit.ddns.net'; let uidCounter = 0; @@ -349,7 +349,7 @@ describe('healResetIncarnation — real-account quarantine (Phase 2)', () => { }).run(); } - it('renames + freezes a flagged real account with NO owned spaces', async () => { + it('detaches a flagged real account with NO owned spaces (flag only, username kept)', async () => { seedJournal({ origin: QORIGIN, deadEpoch: 'E0' }); const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 }); @@ -357,32 +357,47 @@ describe('healResetIncarnation — real-account quarantine (Phase 2)', () => { 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) + expect(row.username).toBe('carol@orbit.ddns.net'); // username PRESERVED — no rename + expect(row.federationHomeOrphaned).toBe(1); // detached + 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 () => { + it('detaches a space-OWNER identically to a non-owner (flag set, username kept)', 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 + seedSpace({ ownerId: uid, name: 'Dave HQ' }); // owns a space — no special case 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.username).toBe('dave@orbit.ddns.net'); // username PRESERVED (owner treated same as non-owner) + expect(row.federationHomeOrphaned).toBe(1); // detached expect(row.federationHealPending).toBe(0); // processed expect(row.isDeleted).toBe(0); - // journal orphaned_account_count reflects the frozen set (1) + // journal orphaned_account_count reflects the detached 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 () => { + it('detaches ALL flagged real accounts and quarantineOrphanedAccounts returns the count', async () => { + const uid1 = seedRealAccount({ homeInstance: QORIGIN, username: 'erin@orbit.ddns.net', healPending: 1 }); + const uid2 = seedRealAccount({ homeInstance: QORIGIN, username: 'frank@orbit.ddns.net', healPending: 1 }); + + const { quarantineOrphanedAccounts } = await import('./federationReset.js'); + const count = quarantineOrphanedAccounts(QORIGIN); + + expect(count).toBe(2); // returns the number of accounts detached + for (const uid of [uid1, uid2]) { + const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!; + expect(row.federationHomeOrphaned).toBe(1); + expect(row.federationHealPending).toBe(0); + } + }); + + it('false-positive branch (same incarnation) does NOT detach real accounts', async () => { seedJournal({ origin: QORIGIN, deadEpoch: 'E0' }); const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 }); @@ -391,7 +406,7 @@ describe('healResetIncarnation — real-account quarantine (Phase 2)', () => { 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.federationHomeOrphaned ?? 0).toBe(0); // NOT detached expect(row.federationHealPending).toBe(0); // flags cleared (false-alarm path) }); }); diff --git a/packages/server/src/utils/federationReset.ts b/packages/server/src/utils/federationReset.ts index eca9b914..767c07f2 100644 --- a/packages/server/src/utils/federationReset.ts +++ b/packages/server/src/utils/federationReset.ts @@ -301,35 +301,28 @@ export function healResetIncarnation(origin: string, newEpoch: string, reason: P } /** - * 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. + * Post-heal DETACH of the dead incarnation's REAL federated accounts (design + * §6.3b, revised by the 2026-07-02 detach spec). 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 — and, unlike the original quarantine, they are NOT frozen or + * renamed either. * - * 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). + * `federation_home_orphaned = 1` marks the account as DETACHED: it operates as + * a sovereign local account from here on. The owner keeps logging in with the + * local password (auth.ts skips only the self-heal path); every S2S surface + * keyed by the home domain excludes detached rows, so the domain's new + * incarnation can never capture, mutate, re-bind, or delete the account. * - * Content (space messages, memberships, reactions) is preserved in all cases. + * Usernames are preserved (first-come-first-served on this instance) and there + * is no space-owner special case — owners simply keep managing their spaces. + * Content is preserved in all cases. No broadcast: nothing visible changes. * - * @returns the number of accounts quarantined (frozen) — used to refresh the - * journal's `orphaned_account_count`. + * @returns the number of accounts detached — 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 }) @@ -342,22 +335,11 @@ export function quarantineOrphanedAccounts(origin: string): number { )) .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 = { - 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(); + if (accounts.length > 0) { + db.update(schema.users) + .set({ federationHomeOrphaned: 1, federationHealPending: 0 }) + .where(inArray(schema.users.id, accounts.map((a) => a.id))) + .run(); } return accounts.length;