Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions desktop/src/features/communities/communityStorage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import test from "node:test";

import {
clearCommunityStorage,
initFirstCommunity,
migrateLegacyCommunityStorage,
shouldAutoConnectDefaultRelay,
} from "./communityStorage.ts";

function createMemoryStorage(initial = {}) {
const values = new Map(Object.entries(initial));
return {
values,
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
Expand Down Expand Up @@ -46,6 +49,46 @@ test("migrateLegacyCommunityStorage does not overwrite new community state", ()
assert.equal(storage.getItem("buzz-active-community-id"), "new");
});

test("signed-build relay defaults auto-connect during first-run onboarding", () => {
assert.equal(
shouldAutoConnectDefaultRelay("wss://buzz.block.builderlab.xyz"),
true,
);
assert.equal(shouldAutoConnectDefaultRelay("ws://localhost:3000"), false);
assert.equal(shouldAutoConnectDefaultRelay("ws://127.0.0.1:3000"), false);
assert.equal(shouldAutoConnectDefaultRelay("ws://[::1]:3000"), false);
assert.equal(shouldAutoConnectDefaultRelay("ws://0.0.0.0:3000"), false);
assert.equal(shouldAutoConnectDefaultRelay("http://localhost:3000"), false);
assert.equal(
shouldAutoConnectDefaultRelay("https://relay.example.com"),
false,
);
assert.equal(shouldAutoConnectDefaultRelay("relay.example.com"), false);
assert.equal(shouldAutoConnectDefaultRelay("not a valid relay"), false);
});

test("failed first-community write preserves existing community data", () => {
const storage = createMemoryStorage({
"buzz-communities": '[{"id":"existing"}]',
"buzz-workspaces": '[{"id":"legacy"}]',
"buzz-active-workspace-id": "legacy",
});
storage.setItem = (key, value) => {
if (key === "buzz-communities") {
throw new Error("QuotaExceededError");
}
storage.values.set(key, String(value));
};
globalThis.localStorage = storage;
globalThis.window = { localStorage: storage };

assert.equal(initFirstCommunity("wss://relay.example.com", "pubkey"), null);
assert.equal(storage.getItem("buzz-communities"), '[{"id":"existing"}]');
assert.equal(storage.getItem("buzz-active-community-id"), null);
assert.equal(storage.getItem("buzz-workspaces"), '[{"id":"legacy"}]');
assert.equal(storage.getItem("buzz-active-workspace-id"), "legacy");
});

test("clearCommunityStorage removes new and legacy state", () => {
const storage = createMemoryStorage({
"buzz-communities": "new",
Expand Down
58 changes: 50 additions & 8 deletions desktop/src/features/communities/communityStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,11 @@ export function loadCommunities(): Community[] {
}
}

export function saveCommunities(communities: Community[]): void {
setLocalStorageItemWithRecovery(COMMUNITIES_KEY, JSON.stringify(communities));
export function saveCommunities(communities: Community[]): boolean {
return setLocalStorageItemWithRecovery(
COMMUNITIES_KEY,
JSON.stringify(communities),
);
}

export function clearCommunityStorage(storage: Storage = localStorage): void {
Expand All @@ -97,8 +100,8 @@ export function loadActiveCommunityId(): string | null {
return localStorage.getItem(ACTIVE_COMMUNITY_KEY);
}

export function saveActiveCommunityId(id: string): void {
setLocalStorageItemWithRecovery(ACTIVE_COMMUNITY_KEY, id);
export function saveActiveCommunityId(id: string): boolean {
return setLocalStorageItemWithRecovery(ACTIVE_COMMUNITY_KEY, id);
}

export function normalizeRelayUrl(url: string): string {
Expand All @@ -108,13 +111,29 @@ export function normalizeRelayUrl(url: string): string {
return url;
}

function isLocalRelayHost(hostname: string): boolean {
return ["localhost", "127.0.0.1", "[::1]", "0.0.0.0"].includes(hostname);
}

export function shouldAutoConnectDefaultRelay(relayUrl: string): boolean {
try {
const parsed = new URL(relayUrl);
return (
(parsed.protocol === "ws:" || parsed.protocol === "wss:") &&
!isLocalRelayHost(parsed.hostname)
);
} catch {
return false;
}
}

export function deriveCommunityName(relayUrl: string): string {
try {
const url = new URL(
relayUrl.replace("ws://", "http://").replace("wss://", "https://"),
);
const host = url.hostname;
if (host === "localhost" || host === "127.0.0.1") {
if (isLocalRelayHost(host)) {
return "Local Dev";
}
const parts = host.split(".");
Expand All @@ -136,17 +155,40 @@ export function initFirstCommunity(
relayUrl: string,
pubkey: string,
name?: string,
): Community {
): Community | null {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the auto-created community carries no admission token, so applyCommunity gets token === undefined on the production first-connect. Fine if the compiled default relay admits token-less first connections; if it ever requires one, first-run dead-ends with no token-entry UI. Worth a one-line comment documenting the assumption.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63d9135.

const normalizedUrl = normalizeRelayUrl(relayUrl);
const trimmedName = name?.trim();
const community: Community = {
id: crypto.randomUUID(),
name: trimmedName || deriveCommunityName(normalizedUrl),
relayUrl: normalizedUrl,
// Compiled default relays must admit the first token-less connection; there
// is no invite-token prompt on this auto-connect path.
pubkey,
addedAt: new Date().toISOString(),
};
saveCommunities([community]);
saveActiveCommunityId(community.id);
const previousActiveCommunityId = localStorage.getItem(ACTIVE_COMMUNITY_KEY);
const didSaveActiveCommunity = saveActiveCommunityId(community.id);
if (!didSaveActiveCommunity) {
return null;
}

if (!saveCommunities([community])) {
// A failed setItem leaves the existing communities value untouched. Roll
// back only the active-ID write so inconsistent pre-existing data is never
// destroyed while recovering from a quota failure.
try {
if (previousActiveCommunityId === null) {
localStorage.removeItem(ACTIVE_COMMUNITY_KEY);
} else {
localStorage.setItem(ACTIVE_COMMUNITY_KEY, previousActiveCommunityId);
}
} catch {
// Best effort: persistence is already unavailable, and callers will stay
// on setup instead of reloading.
}
return null;
}

return community;
}
29 changes: 25 additions & 4 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useS
import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";

import { initFirstCommunity } from "./communityStorage";
import {
initFirstCommunity,
shouldAutoConnectDefaultRelay,
} from "./communityStorage";
import type { Community } from "./types";

/**
Expand Down Expand Up @@ -94,12 +97,30 @@ export function useCommunityInit(
try {
const defaultRelayUrl = await getDefaultRelayUrl();

if (isSharedIdentity) {
// Signed builds carry a reviewed production relay. Treat that as the
// user's first community so first-run onboarding proceeds directly
// from machine setup into profile/team setup. Local development keeps
// the explicit add-a-community flow for ws://localhost defaults.
if (
isSharedIdentity ||
shouldAutoConnectDefaultRelay(defaultRelayUrl)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this gate, WelcomeSetup (the join/create/connect-existing first-run wizard) becomes unreachable in every build with a non-local compiled default — i.e., all production builds once the companion release-config PR lands; it survives only as the persist-failure fallback below. The E2E repoint to ws://localhost:3000 implicitly ratifies that ("wizard is dev-only"), which is fine and matches the PR's purpose — but the description only states the localhost half. Please add a sentence saying the inverse out loud: production first-runs never see the community-selection wizard. A reviewer approving this should be approving that sentence, not inferring it. (Users can still add other communities post-first-run via the community rail's add-community flow — verified.)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63d9135.

) {
const identity = await getIdentity();
if (cancelled) return;
initFirstCommunity(defaultRelayUrl, identity.pubkey);
if (!cancelled) {
const community = initFirstCommunity(
defaultRelayUrl,
identity.pubkey,
);
if (community && !cancelled) {
window.location.reload();
return;
}
if (!cancelled) {
setResult({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The persist-failure fallback correctly avoids the reload loop now, but it lands the user on WelcomeSetup silently — no indication that storage is broken. If they then try the manual flow, the same quota failure presumably hits again with no better message. Consider surfacing a storage-error toast/notice here so the failure is recoverable rather than mysterious. Non-blocking.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quota-aware storage helper already emits a one-time "Local storage is full" toast when persistence fails; keeping the setup fallback otherwise unchanged.

isReady: false,
needsSetup: true,
defaultRelayUrl,
});
}
return;
}
Expand Down
66 changes: 53 additions & 13 deletions desktop/tests/e2e/onboarding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ test("first-launch key import continues to machine setup", async ({ page }) => {
await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
});

test("first-community choices route join, create, owner, and member intents", async ({
test("non-local default auto-connects the first community", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
Expand All @@ -577,6 +577,46 @@ test("first-community choices route join, create, owner, and member intents", as
});
await page.goto("/");

await expectIncompleteOnboarding(page);
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
const communities = raw
? (JSON.parse(raw) as Array<{ id: string; relayUrl: string }>)
: [];
return {
activeMatchesCommunity:
communities.length === 1 &&
window.localStorage.getItem("buzz-active-community-id") ===
communities[0]?.id,
relayUrl: communities[0]?.relayUrl ?? null,
};
}),
)
.toEqual({
activeMatchesCommunity: true,
relayUrl: "wss://default.example.com",
});
});

test("first-community choices route join, create, owner, and member intents", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await page.addInitScript((pubkey) => {
window.localStorage.setItem(
`buzz-machine-onboarding-complete.v2:${pubkey}`,
"true",
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(page, undefined, {
relayWsUrl: "ws://localhost:3000",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repointing these specs to ws://localhost:3000 keeps the manual wizard covered, but the flip side is that the new auto-connect branch — the entire point of this PR — now has no behavior-level test: the unit tests only exercise the URL classifier and the persist-failure path. Please add one spec proving a non-local mocked default auto-creates/persists the community and advances to profile setup (and asserts the persisted buzz-communities / active-ID state).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 63d9135.

skipOnboardingSeed: true,
skipCommunitySeed: true,
});
await page.goto("/");

await expect(
page.getByRole("button", { name: /Join a community/ }),
).toBeVisible();
Expand Down Expand Up @@ -657,7 +697,7 @@ test("first-community owner can connect an existing hosted community", async ({
],
},
{
relayWsUrl: "wss://default.example.com",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
Expand Down Expand Up @@ -715,7 +755,7 @@ test("first-community owner can create and connect a hosted community", async ({
page,
{},
{
relayWsUrl: "wss://default.example.com",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
Expand Down Expand Up @@ -789,7 +829,7 @@ test("hosted community address line stays within the card for a long name", asyn
page,
{},
{
relayWsUrl: "wss://default.example.com",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
Expand Down Expand Up @@ -860,7 +900,7 @@ test("first-community reports a created community without a relay address", asyn
},
},
{
relayWsUrl: "wss://default.example.com",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
Expand Down Expand Up @@ -891,7 +931,7 @@ test("first-community X cancels a pending sign-in", async ({ page }) => {
page,
{ builderlabLoginDelayMs: 5_000 },
{
relayWsUrl: "wss://default.example.com",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
Expand Down Expand Up @@ -933,7 +973,7 @@ test("first-community owner can replace a mismatched account identity", async ({
builderlabIdentity: { pubkey_hex: "f".repeat(64) },
},
{
relayWsUrl: "wss://default.example.com",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
Expand Down Expand Up @@ -983,7 +1023,7 @@ test("first-community explains when the local identity belongs to another accoun
builderlabBindError: { code: "pubkey_already_bound" },
},
{
relayWsUrl: "wss://default.example.com",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
Expand Down Expand Up @@ -1024,7 +1064,7 @@ test("back clears Builderlab auth before returning to first-community choices",
builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey },
},
{
relayWsUrl: "wss://default.example.com",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
Expand Down Expand Up @@ -1112,7 +1152,7 @@ test("first-community direct join reaches profile", async ({ page }) => {
);
}, BLANK_TYLER_IDENTITY.pubkey);
await installMockBridge(page, undefined, {
relayWsUrl: "wss://onboarding.communities.buzz.xyz",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
});
Expand Down Expand Up @@ -1167,7 +1207,7 @@ test("first-community direct join cancel returns to request access", async ({
page,
{ applyCommunityDelayMs: 5_000 },
{
relayWsUrl: "wss://onboarding.communities.buzz.xyz",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
skipCommunitySeed: true,
},
Expand Down Expand Up @@ -1298,7 +1338,7 @@ test("connected first-community profile step offers equal-width Next and Back co
id: "txn-profile-step",
source: "first-community",
stage: "profile",
relayUrl: "wss://default.example.com",
relayUrl: "ws://localhost:3000",
communityName: "Default",
communityId: "e2e-default-community",
addedCommunity: true,
Expand Down Expand Up @@ -1346,7 +1386,7 @@ test("connected first-community profile step offers equal-width Next and Back co
],
},
{
relayWsUrl: "wss://default.example.com",
relayWsUrl: "ws://localhost:3000",
skipOnboardingSeed: true,
},
);
Expand Down
Loading