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);