feat(federation): reset heal detaches real accounts instead of freeze+rename (detach spec §4.2)
This commit is contained in:
@@ -187,7 +187,7 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
|
|||||||
.where(eq(schema.users.id, id)).run();
|
.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();
|
seedPeer();
|
||||||
seedJournal('E0');
|
seedJournal('E0');
|
||||||
// A local native user to be the friendship counterpart.
|
// 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(),
|
userId: 'stub-1', friendId: 'local-1', createdAt: Date.now(),
|
||||||
}).run();
|
}).run();
|
||||||
// Flagged REAL federated account (real bcrypt), no owned space — must survive
|
// 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' });
|
seedUser('real-1', { passwordHash: '$2b$10$realbcrypthash' });
|
||||||
flag('real-1');
|
flag('real-1');
|
||||||
|
|
||||||
@@ -219,11 +219,11 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => {
|
|||||||
// Heal flag cleared on the healed stub.
|
// Heal flag cleared on the healed stub.
|
||||||
expect(stub.federationHealPending).toBe(0);
|
expect(stub.federationHealPending).toBe(0);
|
||||||
|
|
||||||
// Real account NEVER deleted (content preserved) but quarantined: frozen,
|
// Real account NEVER deleted (content preserved) and DETACHED: orphaned flag
|
||||||
// handle freed via rename, heal flag cleared (Phase 2 §6.3b).
|
// 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()!;
|
const real = testDb.select().from(schema.users).where(eq(schema.users.id, 'real-1')).get()!;
|
||||||
expect(real.isDeleted).toBe(0);
|
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.federationHomeOrphaned).toBe(1);
|
||||||
expect(real.federationHealPending).toBe(0);
|
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';
|
const QORIGIN = 'orbit.ddns.net';
|
||||||
let uidCounter = 0;
|
let uidCounter = 0;
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ describe('healResetIncarnation — real-account quarantine (Phase 2)', () => {
|
|||||||
}).run();
|
}).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' });
|
seedJournal({ origin: QORIGIN, deadEpoch: 'E0' });
|
||||||
const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 });
|
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');
|
healResetIncarnation(QORIGIN, 'E1', 'initiate_accepted');
|
||||||
|
|
||||||
const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!;
|
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.username).toBe('carol@orbit.ddns.net'); // username PRESERVED — no rename
|
||||||
expect(row.federationHomeOrphaned).toBe(1); // frozen
|
expect(row.federationHomeOrphaned).toBe(1); // detached
|
||||||
expect(row.federationHealPending).toBe(0); // processed
|
expect(row.federationHealPending).toBe(0); // processed
|
||||||
expect(row.isDeleted).toBe(0); // NOT deleted (content preserved)
|
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' });
|
seedJournal({ origin: QORIGIN, deadEpoch: 'E0' });
|
||||||
const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'dave@orbit.ddns.net', healPending: 1 });
|
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');
|
const { healResetIncarnation } = await import('./federationReset.js');
|
||||||
healResetIncarnation(QORIGIN, 'E1', 'initiate_accepted');
|
healResetIncarnation(QORIGIN, 'E1', 'initiate_accepted');
|
||||||
|
|
||||||
const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!;
|
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.username).toBe('dave@orbit.ddns.net'); // username PRESERVED (owner treated same as non-owner)
|
||||||
expect(row.federationHomeOrphaned).toBe(1); // frozen
|
expect(row.federationHomeOrphaned).toBe(1); // detached
|
||||||
expect(row.federationHealPending).toBe(0); // processed
|
expect(row.federationHealPending).toBe(0); // processed
|
||||||
expect(row.isDeleted).toBe(0);
|
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)
|
const j = testDb.select().from(schema.federationResetEvents)
|
||||||
.where(eq(schema.federationResetEvents.origin, QORIGIN)).get()!;
|
.where(eq(schema.federationResetEvents.origin, QORIGIN)).get()!;
|
||||||
expect(j.orphanedAccountCount).toBe(1);
|
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' });
|
seedJournal({ origin: QORIGIN, deadEpoch: 'E0' });
|
||||||
const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 });
|
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()!;
|
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.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)
|
expect(row.federationHealPending).toBe(0); // flags cleared (false-alarm path)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
* Post-heal DETACH of the dead incarnation's REAL federated accounts (design
|
||||||
* §6.3b). Called from `healResetIncarnation`'s genuine-reset branch AFTER the stub
|
* §6.3b, revised by the 2026-07-02 detach spec). Called from
|
||||||
* soft-tombstone loop. Real accounts carry non-re-syncable local content and are
|
* `healResetIncarnation`'s genuine-reset branch AFTER the stub soft-tombstone
|
||||||
* NEVER auto-deleted — they are FROZEN and surfaced to the admin.
|
* 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`,
|
* `federation_home_orphaned = 1` marks the account as DETACHED: it operates as
|
||||||
* `passwordHash != REPLICATED_STUB_SENTINEL`, `isDeleted = 0`) for this origin:
|
* a sovereign local account from here on. The owner keeps logging in with the
|
||||||
* - Set `federation_home_orphaned = 1` (FREEZE). This is universal — it is what
|
* local password (auth.ts skips only the self-heal path); every S2S surface
|
||||||
* closes the post-re-peer hijack (the Task-2 epoch guard passes once the
|
* keyed by the home domain excludes detached rows, so the domain's new
|
||||||
* baseline is updated to the new epoch, so the freeze is the only remaining
|
* incarnation can never capture, mutate, re-bind, or delete the account.
|
||||||
* 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.
|
* 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
|
* @returns the number of accounts detached — used to refresh the journal's
|
||||||
* journal's `orphaned_account_count`.
|
* `orphaned_account_count`.
|
||||||
*/
|
*/
|
||||||
export function quarantineOrphanedAccounts(origin: string): number {
|
export function quarantineOrphanedAccounts(origin: string): number {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const domain = extractDomain(origin);
|
|
||||||
|
|
||||||
const accounts = db
|
const accounts = db
|
||||||
.select({ id: schema.users.id })
|
.select({ id: schema.users.id })
|
||||||
@@ -342,22 +335,11 @@ export function quarantineOrphanedAccounts(origin: string): number {
|
|||||||
))
|
))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
for (const acct of accounts) {
|
if (accounts.length > 0) {
|
||||||
const ownsSpace = db
|
db.update(schema.users)
|
||||||
.select({ id: schema.spaces.id })
|
.set({ federationHomeOrphaned: 1, federationHealPending: 0 })
|
||||||
.from(schema.spaces)
|
.where(inArray(schema.users.id, accounts.map((a) => a.id)))
|
||||||
.where(eq(schema.spaces.ownerId, acct.id))
|
.run();
|
||||||
.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;
|
return accounts.length;
|
||||||
|
|||||||
Reference in New Issue
Block a user