diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 845f991623..3aeb04996c 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -375,7 +375,10 @@ const overrides = new Map([ // queued to split when ProfileSettingsCard is broken into sub-components. // +20 lines: scroll-position save/restore across avatar editor open/close // to prevent layout shift from the Sign Out section causing a viewport jump. - ["src/features/settings/ui/ProfileSettingsCard.tsx", 1033], + // +11 lines: signout-dev-webview-state — clear localStorage/sessionStorage + // on successful signOut() resolve so dev-build webview state doesn't survive + // a reset and vouch for the fresh key. Comment explains the race/redundancy. + ["src/features/settings/ui/ProfileSettingsCard.tsx", 1044], // keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const // to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix. // +10 (1042 -> 1052): media_fetch_client with redirect::Policy::none() so a diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index c3ab69c786..56b84118ad 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -259,9 +259,11 @@ function AppReady({ } function CommunityApp({ + currentPubkey, onBackToMachineConfig, sharedIdentity, }: { + currentPubkey: string | null; onBackToMachineConfig: () => void; sharedIdentity: boolean; }) { @@ -325,6 +327,7 @@ function CommunityApp({ relayUrl: transaction.relayUrl, token: transaction.token, reposDir: transaction.reposDir, + pubkey: currentPubkey ?? undefined, addedAt: new Date().toISOString(), }); communityOnboarding.update({ @@ -340,6 +343,7 @@ function CommunityApp({ addCommunity, communities, communityOnboarding, + currentPubkey, reconnectCommunity, switchCommunity, ]); @@ -494,7 +498,9 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { const { activeCommunity } = useCommunities(); const communityOnboarding = useCommunityOnboarding(); const machine = useMachineOnboardingState({ - hasConfiguredCommunity: activeCommunity !== null, + activeCommunityPubkey: activeCommunity + ? (activeCommunity.pubkey ?? null) + : undefined, isSharedIdentity: sharedIdentity, }); const [machineInitialPage, setMachineInitialPage] = @@ -548,6 +554,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { if (machine.stage === "ready") { return ( diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 39c13527a7..369241bac2 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -789,7 +789,11 @@ export function AppShell() { isCreateChannelOpen={isCreateChannelOpen} isPresencePending={presenceSession.isPending} onAddCommunity={(community) => { - const id = communitiesHook.addCommunity(community); + const id = communitiesHook.addCommunity({ + ...community, + pubkey: + community.pubkey ?? identityQuery.data?.pubkey, + }); handleSwitchCommunity(id); }} onAddCommunityOpenChange={ diff --git a/desktop/src/features/onboarding/machineOnboarding.test.mjs b/desktop/src/features/onboarding/machineOnboarding.test.mjs new file mode 100644 index 0000000000..ec564d83d1 --- /dev/null +++ b/desktop/src/features/onboarding/machineOnboarding.test.mjs @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + migrateMachineOnboardingCompletion, + readMachineOnboardingCompletion, +} from "./machineOnboarding.ts"; + +// machineOnboarding.ts reads/writes window.localStorage directly, so we inject +// a minimal in-memory storage into globalThis.window before each test and +// restore it afterward. + +function createMemoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + removeItem: (key) => values.delete(key), + clear: () => values.clear(), + key: (index) => Array.from(values.keys())[index] ?? null, + get length() { + return values.size; + }, + }; +} + +function withFakeWindow(initial, fn) { + const prev = globalThis.window; + const storage = createMemoryStorage(initial); + globalThis.window = { + localStorage: storage, + // location.href without a machineOnboarding=1 param → forceMachineOnboarding() returns false + location: { href: "http://localhost/" }, + }; + try { + return fn(storage); + } finally { + globalThis.window = prev; + } +} + +const PUBKEY_A = + "aaaaaa1111112222223333334444445555556666667777778888889999990000aa"; +const PUBKEY_B = + "bbbbbb1111112222223333334444445555556666667777778888889999990000bb"; +const LEGACY_KEY = `buzz-onboarding-complete.v1:${PUBKEY_A}`; +const V2_KEY = `buzz-machine-onboarding-complete.v2:${PUBKEY_A}`; + +// ── Fix A regression case ──────────────────────────────────────────────────── + +test("migrate_mismatched_community_pubkey_does_not_vouch_for_current_key", () => { + // Community pubkey is PUBKEY_B (stale from a previous identity); current + // pubkey is PUBKEY_A (freshly generated after a dev reset). The mismatch + // must NOT grant completion. + withFakeWindow({}, (storage) => { + const result = migrateMachineOnboardingCompletion( + PUBKEY_A, + PUBKEY_B, // activeCommunityPubkey — a different identity's community + false, + ); + assert.equal(result, false, "mismatched pubkey must not vouch"); + assert.equal( + storage.getItem(V2_KEY), + null, + "completion must not be written to storage", + ); + assert.equal( + readMachineOnboardingCompletion(PUBKEY_A), + false, + "readMachineOnboardingCompletion must return false", + ); + }); +}); + +// ── Matching pubkey vouches ────────────────────────────────────────────────── + +test("migrate_matching_community_pubkey_vouches_for_current_key", () => { + // Community pubkey matches current pubkey → legitimate veteran, grant. + withFakeWindow({}, (storage) => { + const result = migrateMachineOnboardingCompletion( + PUBKEY_A, + PUBKEY_A, // same pubkey — community belongs to this identity + false, + ); + assert.equal(result, true, "matching pubkey must vouch"); + assert.equal(storage.getItem(V2_KEY), "true"); + assert.equal(readMachineOnboardingCompletion(PUBKEY_A), true); + }); +}); + +// ── Absent community pubkey — regression case (Thufir pass 1) ─────────────── + +test("migrate_absent_community_pubkey_does_not_vouch_for_fresh_identity", () => { + // Stale current-format community with no pubkey stamp (created before the + // stamp was added, or from an older build) + freshly generated identity + // after a dev reset. The absent pubkey must NOT vouch — this was the + // reachable producer of the half-onboarded state. + withFakeWindow({}, (storage) => { + const result = migrateMachineOnboardingCompletion( + PUBKEY_A, + null, // absent pubkey — could be legacy OR an unstamped modern entry + false, + ); + assert.equal( + result, + false, + "absent pubkey must not vouch for a fresh identity", + ); + assert.equal( + storage.getItem(V2_KEY), + null, + "completion must not be written to storage", + ); + }); +}); + +// ── No community configured ────────────────────────────────────────────────── + +test("migrate_no_community_does_not_vouch_for_current_key", () => { + // `undefined` = no active community at all. + withFakeWindow({}, (storage) => { + const result = migrateMachineOnboardingCompletion( + PUBKEY_A, + undefined, // no community + false, + ); + assert.equal(result, false, "no community must not vouch"); + assert.equal(storage.getItem(V2_KEY), null); + }); +}); + +// ── Legacy onboarding completion still grants (pubkey-scoped) ─────────────── + +test("migrate_legacy_completion_key_still_grants_migration", () => { + withFakeWindow({ [LEGACY_KEY]: "true" }, (storage) => { + const result = migrateMachineOnboardingCompletion( + PUBKEY_A, + undefined, // no community — but legacy key is present + false, + ); + assert.equal(result, true, "legacy completion key must grant migration"); + assert.equal(storage.getItem(V2_KEY), "true"); + }); +}); + +// ── Shared identity grants regardless of community ─────────────────────────── + +test("migrate_shared_identity_grants_regardless_of_community", () => { + withFakeWindow({}, () => { + const result = migrateMachineOnboardingCompletion( + PUBKEY_A, + undefined, // no community + true, // isSharedIdentity + ); + assert.equal(result, true, "shared identity must always grant"); + }); +}); + +// ── Already-completed v2 key returns true without re-writing ───────────────── + +test("migrate_already_completed_pubkey_returns_true_immediately", () => { + withFakeWindow({ [V2_KEY]: "true" }, (storage) => { + // Pass mismatched community to prove early-exit, not community vouching. + const result = migrateMachineOnboardingCompletion( + PUBKEY_A, + PUBKEY_B, + false, + ); + assert.equal(result, true, "already-completed pubkey must return true"); + // Value was already there; the function should not have touched it + // (but a redundant write is also acceptable — just verify it's still true). + assert.equal(storage.getItem(V2_KEY), "true"); + }); +}); diff --git a/desktop/src/features/onboarding/machineOnboarding.ts b/desktop/src/features/onboarding/machineOnboarding.ts index a85e5abec1..8bc4cadaa9 100644 --- a/desktop/src/features/onboarding/machineOnboarding.ts +++ b/desktop/src/features/onboarding/machineOnboarding.ts @@ -36,15 +36,27 @@ function clearMachineOnboardingCompletion(pubkey: string | null) { } function forceMachineOnboarding() { - if (!import.meta.env.DEV || typeof window === "undefined") return false; + if (!import.meta.env?.DEV || typeof window === "undefined") return false; return ( new URL(window.location.href).searchParams.get("machineOnboarding") === "1" ); } -function migrateMachineOnboardingCompletion( +/** @internal Exported for unit testing only. */ +export function migrateMachineOnboardingCompletion( pubkey: string, - hasConfiguredCommunity: boolean, + /** + * The `pubkey` field of the active community from localStorage, or + * `undefined` if no community is configured. Community-creation paths stamp + * the current identity's pubkey on write; absent pubkey (`null`) therefore + * indicates a legacy entry that pre-dates the stamp and is NOT treated as a + * voucher. Pass `undefined` when there is no active community at all. + * + * Using the community's own pubkey prevents a freshly generated post-reset + * key from being vouched for by a stale community entry that survived the + * webview wipe. + */ + activeCommunityPubkey: string | null | undefined, isSharedIdentity: boolean, ) { if (forceMachineOnboarding()) return false; @@ -54,9 +66,20 @@ function migrateMachineOnboardingCompletion( window.localStorage.getItem( completionKey(LEGACY_ONBOARDING_COMPLETION_STORAGE_KEY, pubkey), ) === "true"; + + // A community entry vouches for the current pubkey only when its recorded + // pubkey matches. Absent pubkey (legacy entries predating the stamp) and + // no community at all (undefined) do not vouch — after community creation + // paths stamp pubkey on write, absent means the entry pre-dates the stamp + // and cannot be trusted to identify which identity created it. + const communityVouchesForPubkey = + activeCommunityPubkey !== undefined && + activeCommunityPubkey !== null && + activeCommunityPubkey === pubkey; + if ( !completedLegacyOnboarding && - !hasConfiguredCommunity && + !communityVouchesForPubkey && !isSharedIdentity ) { return false; @@ -74,10 +97,10 @@ function identitySettled(status: QueryStatus, isFetching: boolean) { } export function useMachineOnboardingState({ - hasConfiguredCommunity, + activeCommunityPubkey, isSharedIdentity, }: { - hasConfiguredCommunity: boolean; + activeCommunityPubkey: string | null | undefined; isSharedIdentity: boolean; }) { const queryClient = useQueryClient(); @@ -130,7 +153,7 @@ export function useMachineOnboardingState({ if ( migrateMachineOnboardingCompletion( currentPubkey, - hasConfiguredCommunity, + activeCommunityPubkey, isSharedIdentity, ) ) { @@ -139,7 +162,7 @@ export function useMachineOnboardingState({ setEvaluatedPubkey(currentPubkey); }, [ currentPubkey, - hasConfiguredCommunity, + activeCommunityPubkey, identityLost, identityQuery.status, isSharedIdentity, diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 0e90d87e3f..3849e904c4 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -1009,13 +1009,27 @@ export function ProfileSettingsCard({ onClick={() => { setIsSignOutPending(true); // Keep the pending state if signOut() resolves before restart. - signOut().catch((err: unknown) => { - setIsSignOutPending(false); - setIsSignOutOpen(false); - toast.error( - err instanceof Error ? err.message : "Sign out failed.", - ); - }); + signOut() + .then(() => { + // Clear web storage for this origin on the success path + // only. This covers dev builds where the Rust webview wipe + // targets the .app-bundle WebKit dir (missing in `tauri + // dev`), preventing stale community config from vouching + // for the fresh key on next boot. In production the Rust + // wipe already handles this; the clear here is redundant + // but harmless. The restart may race this clear — that is + // acceptable; Fix A (pubkey-scoped heuristic) is the + // correctness gate. + window.localStorage.clear(); + window.sessionStorage.clear(); + }) + .catch((err: unknown) => { + setIsSignOutPending(false); + setIsSignOutOpen(false); + toast.error( + err instanceof Error ? err.message : "Sign out failed.", + ); + }); }} > {isSignOutPending ? "Signing out…" : "Delete My Data"} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 71bb7ba6e3..83e3f8fd18 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -580,14 +580,44 @@ async function seedOnboardingCompletionForKnownIdentities( ); } -async function seedDefaultCommunity(page: Page, relayWsUrl?: string) { +async function seedDefaultCommunity( + page: Page, + fallbackPubkey: string, + relayWsUrl?: string, +) { await page.addInitScript( - ({ relayUrl }) => { + ({ fallback, identityOverrideKey, relayUrl }) => { + // If seedActiveIdentity() ran before this script (the normal ordering), + // use its pubkey so the community matches the active identity and + // migrateMachineOnboardingCompletion's strict voucher accepts it. + // Mirror readStoredIdentityOverride()'s shape validation: require all + // three fields to be non-empty strings; fall through to fallback on + // malformed JSON, stored null, or partial objects. + let overridePubkey: string | undefined; + try { + const overrideRaw = window.localStorage.getItem(identityOverrideKey); + if (overrideRaw !== null) { + const parsed: unknown = JSON.parse(overrideRaw); + if ( + parsed !== null && + typeof parsed === "object" && + typeof (parsed as Record).pubkey === "string" && + typeof (parsed as Record).privateKey === + "string" && + typeof (parsed as Record).username === "string" + ) { + overridePubkey = (parsed as { pubkey: string }).pubkey; + } + } + } catch { + // malformed entry — fall through to fallback + } const communityId = "e2e-default-community"; const community = { id: communityId, name: "E2E Test", relayUrl, + pubkey: overridePubkey ?? fallback, addedAt: new Date().toISOString(), }; window.localStorage.setItem( @@ -596,7 +626,11 @@ async function seedDefaultCommunity(page: Page, relayWsUrl?: string) { ); window.localStorage.setItem("buzz-active-community-id", communityId); }, - { relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL }, + { + fallback: fallbackPubkey, + identityOverrideKey: "buzz:e2e-identity-override.v1", + relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL, + }, ); } @@ -619,8 +653,13 @@ export async function installBridge(page: Page, options: BridgeOptions) { // Most specs seed a community so useCommunityInit doesn't show WelcomeSetup. // skipOnboardingSeed only controls the onboarding-completion flag. + // The community is stamped with the active identity's pubkey so the strict + // migrateMachineOnboardingCompletion voucher recognises it. The init script + // reads the seedActiveIdentity override key (if present) and falls back to + // the bridge identity's pubkey or DEFAULT_MOCK_PUBKEY for mock mode. if (!options.skipCommunitySeed) { - await seedDefaultCommunity(page, options.relayWsUrl); + const activePubkey = identity?.pubkey ?? DEFAULT_MOCK_PUBKEY; + await seedDefaultCommunity(page, activePubkey, options.relayWsUrl); } if (!options.skipOnboardingSeed) { await seedOnboardingCompletionForKnownIdentities(page, options.relayWsUrl);