diff --git a/desktop/src-tauri/src/builderlab.rs b/desktop/src-tauri/src/builderlab.rs index 4e125c3bc5..dd5400cdaa 100644 --- a/desktop/src-tauri/src/builderlab.rs +++ b/desktop/src-tauri/src/builderlab.rs @@ -24,6 +24,14 @@ const BUILDERLAB_ORIGIN: &str = "https://app.builderlab.xyz"; #[derive(Default)] pub(crate) struct BuilderlabSession(Mutex>); +#[derive(Default)] +pub(crate) struct BuilderlabLogin(Mutex>); + +struct PendingLogin { + id: uuid::Uuid, + cancel: oneshot::Sender<()>, +} + struct StoredSession { credential: String, } @@ -130,6 +138,7 @@ pub(crate) async fn start_builderlab_login( app: tauri::AppHandle, app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, + login: tauri::State<'_, BuilderlabLogin>, ) -> Result { let listener = TcpListener::bind("127.0.0.1:0") .await @@ -158,19 +167,38 @@ pub(crate) async fn start_builderlab_login( return Err(format!("could not open Builderlab authentication: {error}")); } - let exchange_code = match tokio::time::timeout(LOGIN_TIMEOUT, receiver).await { - Ok(Ok(Ok(code))) => code, - Ok(Ok(Err(error))) => { - server.abort(); - return Err(error); - } - Ok(Err(_)) => { - server.abort(); - return Err("local authentication callback stopped unexpectedly".to_owned()); + let login_id = uuid::Uuid::new_v4(); + let (cancel_sender, mut cancel_receiver) = oneshot::channel(); + { + let mut pending = login.0.lock().map_err(|error| error.to_string())?; + if let Some(previous) = pending.take() { + let _ = previous.cancel.send(()); } - Err(_) => { + *pending = Some(PendingLogin { + id: login_id, + cancel: cancel_sender, + }); + } + + let exchange_code = tokio::select! { + result = tokio::time::timeout(LOGIN_TIMEOUT, receiver) => match result { + Ok(Ok(Ok(code))) => code, + Ok(Ok(Err(error))) => { + server.abort(); + return Err(error); + } + Ok(Err(_)) => { + server.abort(); + return Err("local authentication callback stopped unexpectedly".to_owned()); + } + Err(_) => { + server.abort(); + return Err("Builderlab authentication timed out".to_owned()); + } + }, + _ = &mut cancel_receiver => { server.abort(); - return Err("Builderlab authentication timed out".to_owned()); + return Err("Builderlab authentication canceled".to_owned()); } }; server.abort(); @@ -206,6 +234,16 @@ pub(crate) async fn start_builderlab_login( email: me.email, name: me.name, }; + { + let mut pending = login.0.lock().map_err(|error| error.to_string())?; + if pending + .as_ref() + .is_none_or(|pending| pending.id != login_id) + { + return Err("Builderlab authentication canceled".to_owned()); + } + *pending = None; + } *session.0.lock().map_err(|error| error.to_string())? = Some(StoredSession { credential: exchanged.session_credential, }); @@ -242,6 +280,16 @@ pub(crate) async fn get_builderlab_auth( } } +#[tauri::command] +pub(crate) fn cancel_builderlab_login( + login: tauri::State<'_, BuilderlabLogin>, +) -> Result<(), String> { + if let Some(pending) = login.0.lock().map_err(|error| error.to_string())?.take() { + let _ = pending.cancel.send(()); + } + Ok(()) +} + #[tauri::command] pub(crate) fn clear_builderlab_auth( session: tauri::State<'_, BuilderlabSession>, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e2dcac24ff..c668d6a2c0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -426,9 +426,7 @@ pub fn run() { .build() }); - // Only register the updater in release builds that were compiled with a - // real updater configuration. Local unsigned builds omit that config and - // should still launch for debugging. + // Register the updater only in configured release builds; omit it locally. #[cfg(buzz_updater_enabled)] let builder = if cfg!(debug_assertions) { builder @@ -451,6 +449,7 @@ pub fn run() { .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) .manage(BuilderlabSession::default()) + .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) .setup(move |app| { let app_handle = app.handle().clone(); @@ -744,6 +743,7 @@ pub fn run() { take_pending_community_deep_link, acknowledge_pending_community_deep_link, start_builderlab_login, + cancel_builderlab_login, get_builderlab_auth, clear_builderlab_auth, get_builderlab_nostr_identity, diff --git a/desktop/src/features/communities/hostedCommunityApi.ts b/desktop/src/features/communities/hostedCommunityApi.ts new file mode 100644 index 0000000000..16f9f92567 --- /dev/null +++ b/desktop/src/features/communities/hostedCommunityApi.ts @@ -0,0 +1,164 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const HOSTED_COMMUNITY_SUFFIX = "communities.buzz.xyz"; +export const HOSTED_COMMUNITY_LIMIT = 3; +export const VALID_HOSTED_COMMUNITY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export type BuilderlabAuth = { + email?: string; + name?: string; + expiresAt: string; +}; + +export type HostedCommunityApiError = { + code?: string; + message?: string; + setup_needed?: boolean; +}; + +export type HostedNostrIdentity = { + npub?: string; + pubkey_hex?: string; +}; + +export type HostedIdentityResponse = { + identity?: HostedNostrIdentity; + error?: HostedCommunityApiError; + correlation_id?: string; +}; + +export type HostedCommunity = { + id?: string; + name?: string; + slug?: string; + normalized_host?: string; + owner_pubkey?: string; + archived_at?: string | null; +}; + +export type HostedCommunitiesResponse = { + communities?: HostedCommunity[]; + error?: HostedCommunityApiError; + correlation_id?: string; +}; + +export type HostedCommunityAvailabilityResponse = { + available?: boolean; + normalized_host?: string; + error?: HostedCommunityApiError; + correlation_id?: string; +}; + +export type HostedCommunityMutationResponse = { + community?: HostedCommunity; + error?: HostedCommunityApiError; + correlation_id?: string; +}; + +export type HostedCommunityAccount = { + communities: HostedCommunity[]; + identity: HostedNostrIdentity | null; +}; + +export function hostedCommunityErrorMessage( + error: HostedCommunityApiError | undefined, + correlationId: string | undefined, + fallback: string, +) { + const messages: Record = { + missing_mapping: "Connect your Buzz identity before creating a community.", + invalid_name: "Use lowercase letters, numbers, and hyphens.", + taken: "That Buzz address is already taken.", + limit_reached: `You've reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.`, + relay_unavailable: "Community provisioning is temporarily unavailable.", + identity_already_bound: + "This Builderlab account is connected to another Buzz identity.", + pubkey_already_bound: + "This Buzz identity is connected to another Builderlab account.", + not_owner: "Only the community owner can do that.", + transferee_not_registered: + "That person needs a connected Buzz identity before you can transfer ownership to them.", + }; + const message = messages[error?.code ?? ""] ?? error?.message ?? fallback; + return correlationId + ? `${message} Correlation ID: ${correlationId}` + : message; +} + +export function hostedCommunityRelayUrl(community: HostedCommunity) { + const host = community.normalized_host?.trim(); + return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null; +} + +export function getBuilderlabAuth() { + return invoke("get_builderlab_auth"); +} + +export function cancelBuilderlabLogin() { + return invoke("cancel_builderlab_login"); +} + +export function clearBuilderlabAuth() { + return invoke("clear_builderlab_auth"); +} + +export function startBuilderlabLogin() { + return invoke("start_builderlab_login"); +} + +export async function loadHostedCommunityAccount(): Promise { + const [identityResponse, communitiesResponse] = await Promise.all([ + invoke("get_builderlab_nostr_identity"), + invoke("list_builderlab_communities"), + ]); + if ( + identityResponse.error && + identityResponse.error.code !== "unauthorized" && + !identityResponse.error.setup_needed + ) { + throw new Error( + hostedCommunityErrorMessage( + identityResponse.error, + identityResponse.correlation_id, + "Could not load the connected Buzz identity.", + ), + ); + } + if (communitiesResponse.error && !communitiesResponse.error.setup_needed) { + throw new Error( + hostedCommunityErrorMessage( + communitiesResponse.error, + communitiesResponse.correlation_id, + "Could not load communities.", + ), + ); + } + return { + identity: identityResponse.identity ?? null, + communities: communitiesResponse.communities ?? [], + }; +} + +export function bindBuilderlabIdentity() { + return invoke("bind_builderlab_nostr_identity"); +} + +export function deleteBuilderlabIdentity() { + return invoke("delete_builderlab_nostr_identity"); +} + +export function checkHostedCommunityName(name: string) { + return invoke( + "check_builderlab_community_name", + { name }, + ); +} + +export function createHostedCommunity(name: string) { + return invoke( + "create_builderlab_community", + { + name, + }, + ); +} diff --git a/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx new file mode 100644 index 0000000000..46c1286d3f --- /dev/null +++ b/desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx @@ -0,0 +1,498 @@ +import * as React from "react"; +import { AlertCircle, CheckCircle2, LoaderCircle } from "lucide-react"; + +import { + bindBuilderlabIdentity, + cancelBuilderlabLogin, + checkHostedCommunityName, + clearBuilderlabAuth, + createHostedCommunity, + deleteBuilderlabIdentity, + getBuilderlabAuth, + HOSTED_COMMUNITY_LIMIT, + HOSTED_COMMUNITY_SUFFIX, + hostedCommunityErrorMessage, + hostedCommunityRelayUrl, + type BuilderlabAuth, + type HostedCommunity, + type HostedNostrIdentity, + loadHostedCommunityAccount, + startBuilderlabLogin, + VALID_HOSTED_COMMUNITY_NAME, +} from "@/features/communities/hostedCommunityApi"; +import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { safeNpub } from "@/shared/lib/nostrUtils"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { OnboardingFooter } from "@/features/onboarding/ui/OnboardingFooter"; + +export function HostedCommunityOnboarding({ onBack }: { onBack: () => void }) { + const onboarding = useCommunityOnboarding(); + const localPubkey = useIdentityQuery().data?.pubkey ?? null; + const [auth, setAuth] = React.useState(null); + const [identity, setIdentity] = React.useState( + null, + ); + const [communities, setCommunities] = React.useState([]); + const [name, setName] = React.useState(""); + const [availability, setAvailability] = React.useState(null); + const [checkingName, setCheckingName] = React.useState(false); + const [loading, setLoading] = React.useState(true); + const [action, setAction] = React.useState(null); + const [error, setError] = React.useState(null); + const loginAttempt = React.useRef(0); + + const loadAccount = React.useCallback(async () => { + const account = await loadHostedCommunityAccount(); + setIdentity(account.identity); + setCommunities(account.communities); + }, []); + + React.useEffect(() => { + let active = true; + void getBuilderlabAuth() + .then(async (nextAuth) => { + if (!active) return; + setAuth(nextAuth); + if (nextAuth) await loadAccount(); + }) + .catch((cause) => { + if (active) + setError(cause instanceof Error ? cause.message : String(cause)); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [loadAccount]); + + const run = async (label: string, operation: () => Promise) => { + setAction(label); + setError(null); + try { + await operation(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setAction(null); + } + }; + + const signIn = () => { + const attempt = ++loginAttempt.current; + setAction("Signing in…"); + setError(null); + void startBuilderlabLogin() + .then(async (nextAuth) => { + if (loginAttempt.current !== attempt) return; + setAuth(nextAuth); + await loadAccount(); + }) + .catch((cause) => { + if (loginAttempt.current !== attempt) return; + setError(cause instanceof Error ? cause.message : String(cause)); + }) + .finally(() => { + if (loginAttempt.current === attempt) setAction(null); + }); + }; + + const cancelSignIn = () => { + loginAttempt.current += 1; + setAction(null); + setError(null); + void cancelBuilderlabLogin().catch((cause) => { + setError(cause instanceof Error ? cause.message : String(cause)); + }); + }; + + const signOut = () => + run("Signing out…", async () => { + await clearBuilderlabAuth(); + setAuth(null); + setIdentity(null); + setCommunities([]); + setName(""); + setAvailability(null); + }); + + const goBack = () => { + void run("Signing out…", async () => { + await clearBuilderlabAuth(); + onBack(); + }); + }; + + const connectIdentity = () => + run("Connecting identity…", async () => { + const response = await bindBuilderlabIdentity(); + if (response.error) { + throw new Error( + hostedCommunityErrorMessage( + response.error, + response.correlation_id, + "Could not connect the Buzz identity.", + ), + ); + } + setIdentity(response.identity ?? null); + await loadAccount(); + }); + + const boundPubkey = identity?.pubkey_hex ?? null; + const identityMismatch = Boolean( + identity && + boundPubkey && + localPubkey && + boundPubkey.toLowerCase() !== localPubkey.toLowerCase(), + ); + const localNpub = localPubkey ? safeNpub(localPubkey) : null; + + const switchToDeviceIdentity = () => + run("Switching identity…", async () => { + const released = await deleteBuilderlabIdentity(); + if (released.error) { + throw new Error( + hostedCommunityErrorMessage( + released.error, + released.correlation_id, + "Could not disconnect the account's previous Buzz identity.", + ), + ); + } + const bound = await bindBuilderlabIdentity(); + if (bound.error) { + await loadAccount(); + throw new Error( + bound.error.code === "pubkey_already_bound" + ? "This device's Buzz identity belongs to a different Builderlab account and can't be moved from here. Sign out, then sign in with the account that already owns this identity." + : hostedCommunityErrorMessage( + bound.error, + bound.correlation_id, + "Could not connect this device's Buzz identity.", + ), + ); + } + setIdentity(bound.identity ?? null); + await loadAccount(); + }); + + const activeCommunities = communities.filter( + (community) => !community.archived_at && hostedCommunityRelayUrl(community), + ); + const normalizedName = name.trim().toLowerCase(); + const validName = + normalizedName.length <= 63 && + VALID_HOSTED_COMMUNITY_NAME.test(normalizedName); + const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT; + + React.useEffect(() => { + if (!identity || identityMismatch || !normalizedName || !validName) { + setCheckingName(false); + return; + } + let cancelled = false; + setCheckingName(true); + const handle = window.setTimeout(() => { + void checkHostedCommunityName(normalizedName) + .then((response) => { + if (!cancelled) + setAvailability( + response.error ? null : (response.available ?? false), + ); + }) + .catch(() => { + if (!cancelled) setAvailability(null); + }) + .finally(() => { + if (!cancelled) setCheckingName(false); + }); + }, 500); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [identity, identityMismatch, normalizedName, validName]); + + const connect = (community: HostedCommunity, created = false) => { + const relayUrl = hostedCommunityRelayUrl(community); + const retryPrefix = created ? "The community was created, but " : ""; + if (!relayUrl) { + throw new Error( + `${retryPrefix}Builderlab did not return its relay address. Try connecting it again, or contact support if it does not appear in your communities.`, + ); + } + if ( + !onboarding.start({ + source: "first-community", + relayUrl, + communityName: community.name ?? community.slug, + }) + ) { + throw new Error( + `${retryPrefix}onboarding is already in progress for another community. Go back and finish or restart that connection, then connect this community from your owned communities list.`, + ); + } + }; + + const create = (event: React.FormEvent) => { + event.preventDefault(); + if (!validName || !identity || identityMismatch || atCommunityLimit) return; + void run("Creating community…", async () => { + const available = await checkHostedCommunityName(normalizedName); + if (available.error || !available.available) { + setAvailability(false); + throw new Error( + hostedCommunityErrorMessage( + available.error, + available.correlation_id, + "That Buzz address is already taken.", + ), + ); + } + const response = await createHostedCommunity(normalizedName); + if (response.error || !response.community) { + throw new Error( + hostedCommunityErrorMessage( + response.error, + response.correlation_id, + "Could not create the community.", + ), + ); + } + connect(response.community, true); + }); + }; + + const busy = action !== null; + + return ( +
+

Your communities

+

+ Sign in to connect a community you already own on this machine, or + create a new one. +

+ +
+ {error ? ( +
+ + {error} +
+ ) : null} + + {loading ? ( +
+ + Checking Builderlab sign-in +
+ ) : !auth ? ( +
+

Sign in or create an account

+

+ Builderlab provides Block-hosted Buzz communities. Authentication + opens in your browser and returns here. +

+ {action === "Signing in…" ? ( +
+
+ + Waiting for your browser… +
+ +
+ ) : ( + + )} +
+ ) : !identity ? ( +
+

Connect this Buzz identity

+

+ Link this device’s Buzz key to{" "} + {auth.email ?? auth.name ?? "your Builderlab account"}. Buzz signs + a one-time challenge locally; your private key never leaves + Desktop. +

+ +
+ ) : identityMismatch ? ( +
+
+ +
+

+ This account uses a different Buzz identity +

+

+ This Builderlab account is connected to another Buzz identity. + You can disconnect that identity and reconnect this device, or + sign out to use a different email. +

+

+ Account: {identity.npub ?? boundPubkey} +
+ This device: {localNpub ?? localPubkey} +

+
+ + +
+
+
+
+ ) : ( + <> +
+ + Signed in{auth.email ? ` as ${auth.email}` : ""} with this Buzz + identity +
+ + {activeCommunities.length > 0 ? ( +
+

Connect to one you own

+
    + {activeCommunities.map((community, index) => ( +
  • +
    +

    + {community.name ?? + community.slug ?? + "Hosted community"} +

    +

    + {community.normalized_host} +

    +
    + +
  • + ))} +
+
+ ) : null} + +
+

Create a new community

+

+ Choose the address your team will use to connect. +

+
+ { + setName(event.target.value.toLowerCase()); + setAvailability(null); + }} + placeholder="north-star" + spellCheck={false} + value={name} + /> + + .{HOSTED_COMMUNITY_SUFFIX} + +
+ {atCommunityLimit ? ( +

+ You’ve reached the limit of {HOSTED_COMMUNITY_LIMIT} hosted + communities. +

+ ) : name && !validName ? ( +

+ Use lowercase letters, numbers, and single hyphens. +

+ ) : checkingName ? ( +

+ Checking availability… +

+ ) : availability === false ? ( +

+ That address is already taken. +

+ ) : availability === true ? ( +

+ That address is available. +

+ ) : null} + +
+ + )} +
+ + + + +
+ ); +} diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index 1df1f3d42a..a4e7354ac3 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { Check, Copy } from "lucide-react"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; @@ -25,9 +24,10 @@ import { Input } from "@/shared/ui/input"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome"; +import { HostedCommunityOnboarding } from "@/features/communities/ui/HostedCommunityOnboarding"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; -type WelcomeSetupPage = "welcome" | "join" | "invite"; +type WelcomeSetupPage = "welcome" | "join" | "invite" | "owned"; type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection; type WelcomeSetupProps = { @@ -36,7 +36,6 @@ type WelcomeSetupProps = { onBack: () => void; }; -const CREATE_COMMUNITY_URL = "https://app.builderlab.xyz/signup?returnTo=/buzz"; const COMMUNITY_OPTION_CARD_CLASS = "flex min-h-24 w-full max-w-[352px] items-center justify-center rounded-xl bg-white/75 px-6 py-4 text-center text-sm font-normal leading-6 text-foreground transition-colors duration-150 ease-out hover:bg-white/85 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground/35"; @@ -152,10 +151,12 @@ export function WelcomeSetup({ @@ -170,6 +171,14 @@ export function WelcomeSetup({ + ) : page === "owned" ? ( + + showPage("welcome")} /> + ) : page === "join" ? ( = { - missing_mapping: "Connect your Buzz identity before creating a community.", - invalid_name: "Use lowercase letters, numbers, and hyphens.", - taken: "That Buzz address is already taken.", - limit_reached: `You've reached the limit of ${MAX_COMMUNITIES} hosted communities.`, - relay_unavailable: "Community provisioning is temporarily unavailable.", - identity_already_bound: - "This Builderlab account is connected to another Buzz identity.", - pubkey_already_bound: - "This Buzz identity is connected to another Builderlab account.", - not_owner: "Only the community owner can do that.", - transferee_not_registered: - "That person needs a connected Buzz identity before you can transfer ownership to them.", - }; - const message = messages[error?.code ?? ""] ?? error?.message ?? fallback; - return correlationId - ? `${message} Correlation ID: ${correlationId}` - : message; -} - -function relayUrl(community: HostedCommunity) { - const host = community.normalized_host?.trim(); - return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null; -} - export function HostedCommunitiesSettingsCard() { const onboarding = useCommunityOnboarding(); const localPubkey = useIdentityQuery().data?.pubkey ?? null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 83c84a40c7..5ba3b76c3c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -125,6 +125,34 @@ type MockSearchProfileSeed = { type E2eConfig = { mode?: "mock" | "relay"; mock?: { + /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ + builderlabAuth?: { + email?: string; + name?: string; + expiresAt: string; + } | null; + /** Delay Builderlab login completion so cancellation/retry UI can be tested. */ + builderlabLoginDelayMs?: number; + /** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */ + builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null; + /** Structured error returned when onboarding tries to bind the local identity. */ + builderlabBindError?: { code?: string; message?: string }; + /** Communities owned by the mocked Builderlab account. */ + builderlabCommunities?: Array<{ + id?: string; + name?: string; + slug?: string; + normalized_host?: string; + archived_at?: string | null; + }>; + /** Override the community returned after hosted creation. */ + builderlabCreatedCommunity?: { + id?: string; + name?: string; + slug?: string; + normalized_host?: string; + archived_at?: string | null; + }; acpRuntimesCatalog?: RawAcpRuntimeCatalogEntry[]; acpRuntimesDelayMs?: number; acpAuthMethods?: Record; @@ -8764,6 +8792,62 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload }); switch (command) { + case "get_builderlab_auth": + return activeConfig?.mock?.builderlabAuth ?? null; + case "start_builderlab_login": { + const delayMs = activeConfig?.mock?.builderlabLoginDelayMs ?? 0; + if (delayMs > 0) + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + const nextAuth = activeConfig?.mock?.builderlabAuth ?? { + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }; + if (activeConfig?.mock) activeConfig.mock.builderlabAuth = nextAuth; + return nextAuth; + } + case "cancel_builderlab_login": + return null; + case "clear_builderlab_auth": + if (activeConfig?.mock) activeConfig.mock.builderlabAuth = null; + return null; + case "get_builderlab_nostr_identity": + return activeConfig?.mock?.builderlabIdentity + ? { identity: activeConfig.mock.builderlabIdentity } + : { error: { code: "missing_mapping", setup_needed: true } }; + case "bind_builderlab_nostr_identity": { + if (activeConfig?.mock?.builderlabBindError) + return { error: activeConfig.mock.builderlabBindError }; + const activeIdentity = identity ?? DEFAULT_MOCK_IDENTITY; + const nextIdentity = { + pubkey_hex: activeIdentity.pubkey, + npub: `npub1${activeIdentity.pubkey}`, + }; + if (activeConfig?.mock) + activeConfig.mock.builderlabIdentity = nextIdentity; + return { identity: nextIdentity }; + } + case "delete_builderlab_nostr_identity": + if (activeConfig?.mock) activeConfig.mock.builderlabIdentity = null; + return {}; + case "list_builderlab_communities": + return { + communities: activeConfig?.mock?.builderlabCommunities ?? [], + }; + case "check_builderlab_community_name": + return { + available: true, + normalized_host: `${(payload as { name?: string })?.name ?? "community"}.communities.buzz.xyz`, + }; + case "create_builderlab_community": { + const name = (payload as { name?: string })?.name ?? "community"; + return { + community: activeConfig?.mock?.builderlabCreatedCommunity ?? { + id: `hosted-${name}`, + name, + normalized_host: `${name}.communities.buzz.xyz`, + }, + }; + } case "mesh_installed_models": return mockMeshState.models; case "mesh_node_status": diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 46dd8ea9c5..ee4d1e70b1 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -617,29 +617,20 @@ test("first-community choices expose npub and invite input", async ({ page.getByRole("button", { name: "I have an invite link" }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "I want to create a community" }), + page.getByRole("button", { + name: "Create or connect to my own community", + }), ).toBeVisible(); await page - .getByRole("button", { name: "I want to create a community" }) + .getByRole("button", { name: "Create or connect to my own community" }) .click(); - await expect - .poll(() => - page.evaluate(() => { - const log = ( - window as Window & { - __BUZZ_E2E_COMMAND_LOG__?: Array<{ - command: string; - payload: unknown; - }>; - } - ).__BUZZ_E2E_COMMAND_LOG__; - return log?.find((entry) => entry.command === "plugin:opener|open_url") - ?.payload; - }), - ) - .toMatchObject({ - url: "https://app.builderlab.xyz/signup?returnTo=/buzz", - }); + await expect( + page.getByRole("heading", { name: "Your communities" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Continue with Builderlab" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Back" }).click(); await page.getByRole("button", { name: "Add me to a community" }).click(); await expect(page.getByTestId("welcome-join-npub")).toHaveText( @@ -754,6 +745,338 @@ test("first-community choices expose npub and invite input", async ({ await expect(invalidInviteTip).toHaveCSS("opacity", "0"); }); +test("first-community owner can connect an existing hosted community", 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, + { + builderlabAuth: { + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }, + builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey }, + builderlabCommunities: [ + { + id: "owned-community", + name: "North Star", + normalized_host: "north-star.communities.buzz.xyz", + }, + ], + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await expect(page.getByText("North Star")).toBeVisible(); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), + ), + ) + .toContain('"source":"first-community"'); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), + ), + ) + .toContain("wss://north-star.communities.buzz.xyz"); +}); + +test("first-community owner can create and connect a hosted community", 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, + {}, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await page.getByRole("button", { name: "Continue with Builderlab" }).click(); + await expect( + page.getByRole("heading", { name: "Connect this Buzz identity" }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Connect this Buzz identity" }) + .click(); + await expect(page.getByText("Signed in as owner@example.com")).toBeVisible(); + await page + .getByRole("textbox", { name: "Community address" }) + .fill("bee-lab"); + await expect(page.getByText("That address is available.")).toBeVisible(); + await page.getByRole("button", { name: "Create and connect" }).click(); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-community-onboarding-transaction.v1"), + ), + ) + .toContain("wss://bee-lab.communities.buzz.xyz"); +}); + +test("first-community reports a created community without a relay address", 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, + { + builderlabAuth: { + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }, + builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey }, + builderlabCreatedCommunity: { + id: "hosted-bee-lab", + name: "bee-lab", + }, + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await page + .getByRole("textbox", { name: "Community address" }) + .fill("bee-lab"); + await expect(page.getByText("That address is available.")).toBeVisible(); + await page.getByRole("button", { name: "Create and connect" }).click(); + await expect(page.getByRole("alert")).toContainText( + "The community was created, but Builderlab did not return its relay address.", + ); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toHaveCount(0); +}); + +test("first-community can cancel a closed-browser sign-in and retry", 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, + { builderlabLoginDelayMs: 5_000 }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await page.getByRole("button", { name: "Continue with Builderlab" }).click(); + await expect(page.getByText("Waiting for your browser…")).toBeVisible(); + await page.getByRole("button", { name: "Cancel sign-in" }).click(); + await expect( + page.getByRole("button", { name: "Continue with Builderlab" }), + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? [])) + .toEqual(expect.arrayContaining(["cancel_builderlab_login"])); + + await page.evaluate(() => { + if (window.__BUZZ_E2E_CONFIG__?.mock) + window.__BUZZ_E2E_CONFIG__.mock.builderlabLoginDelayMs = 0; + }); + await page.getByRole("button", { name: "Continue with Builderlab" }).click(); + await expect( + page.getByRole("heading", { name: "Connect this Buzz identity" }), + ).toBeVisible(); +}); + +test("first-community owner can replace a mismatched account identity", 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, + { + builderlabAuth: { + email: "old-owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }, + builderlabIdentity: { pubkey_hex: "f".repeat(64) }, + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await expect( + page.getByRole("heading", { + name: "This account uses a different Buzz identity", + }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Use this device's identity" }) + .click(); + await expect( + page.getByText("Signed in as old-owner@example.com"), + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? [])) + .toEqual( + expect.arrayContaining([ + "delete_builderlab_nostr_identity", + "bind_builderlab_nostr_identity", + ]), + ); +}); + +test("first-community explains when the local identity belongs to another account", 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, + { + builderlabAuth: { + email: "wrong-owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }, + builderlabIdentity: { pubkey_hex: "e".repeat(64) }, + builderlabBindError: { code: "pubkey_already_bound" }, + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await page + .getByRole("button", { name: "Use this device's identity" }) + .click(); + await expect( + page.getByText( + "This device's Buzz identity belongs to a different Builderlab account and can't be moved from here. Sign out, then sign in with the account that already owns this identity.", + ), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Connect this Buzz identity" }), + ).toBeVisible(); +}); + +test("back clears Builderlab auth before returning to first-community choices", 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, + { + builderlabAuth: { + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }, + builderlabIdentity: { pubkey_hex: BLANK_TYLER_IDENTITY.pubkey }, + }, + { + relayWsUrl: "wss://default.example.com", + skipOnboardingSeed: true, + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await page.getByRole("button", { name: "Back" }).click(); + await page + .getByRole("button", { name: "Create or connect to my own community" }) + .click(); + await expect( + page.getByRole("button", { name: "Continue with Builderlab" }), + ).toBeVisible(); +}); + test("first-community shows the scenario cards for localhost", async ({ page, }) => { @@ -781,7 +1104,9 @@ test("first-community shows the scenario cards for localhost", async ({ page.getByRole("button", { name: "I have an invite link" }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "I want to create a community" }), + page.getByRole("button", { + name: "Create or connect to my own community", + }), ).toBeVisible(); await page.getByTestId("welcome-setup-back").click(); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index f4fe926817..4736f39ce3 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -127,6 +127,18 @@ export type MockAgentMemoryListing = { }; type MockBridgeOptions = { + /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ + builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null; + /** Bound Builderlab Nostr identity. Null/omitted = not linked yet. */ + builderlabIdentity?: { npub?: string; pubkey_hex?: string } | null; + /** Communities owned by the mocked Builderlab account. */ + builderlabCommunities?: Array<{ + id?: string; + name?: string; + slug?: string; + normalized_host?: string; + archived_at?: string | null; + }>; acpRuntimesCatalog?: Record[]; acpRuntimesDelayMs?: number; acpAuthMethods?: Record[] }>;