Add owned community onboarding (#2123)

Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Bradley Axen
2026-07-19 12:44:18 -07:00
committed by GitHub
co-authored by npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79
parent bf5acabdde
commit cd0e7402d7
9 changed files with 1194 additions and 125 deletions
+59 -11
View File
@@ -24,6 +24,14 @@ const BUILDERLAB_ORIGIN: &str = "https://app.builderlab.xyz";
#[derive(Default)]
pub(crate) struct BuilderlabSession(Mutex<Option<StoredSession>>);
#[derive(Default)]
pub(crate) struct BuilderlabLogin(Mutex<Option<PendingLogin>>);
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<BuilderlabAuthInfo, String> {
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);
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(());
}
Ok(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("local authentication callback stopped unexpectedly".to_owned());
}
Err(_) => {
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>,
+3 -3
View File
@@ -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,
@@ -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<string, string> = {
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<BuilderlabAuth | null>("get_builderlab_auth");
}
export function cancelBuilderlabLogin() {
return invoke<void>("cancel_builderlab_login");
}
export function clearBuilderlabAuth() {
return invoke<void>("clear_builderlab_auth");
}
export function startBuilderlabLogin() {
return invoke<BuilderlabAuth>("start_builderlab_login");
}
export async function loadHostedCommunityAccount(): Promise<HostedCommunityAccount> {
const [identityResponse, communitiesResponse] = await Promise.all([
invoke<HostedIdentityResponse>("get_builderlab_nostr_identity"),
invoke<HostedCommunitiesResponse>("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<HostedIdentityResponse>("bind_builderlab_nostr_identity");
}
export function deleteBuilderlabIdentity() {
return invoke<HostedIdentityResponse>("delete_builderlab_nostr_identity");
}
export function checkHostedCommunityName(name: string) {
return invoke<HostedCommunityAvailabilityResponse>(
"check_builderlab_community_name",
{ name },
);
}
export function createHostedCommunity(name: string) {
return invoke<HostedCommunityMutationResponse>(
"create_builderlab_community",
{
name,
},
);
}
@@ -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<BuilderlabAuth | null>(null);
const [identity, setIdentity] = React.useState<HostedNostrIdentity | null>(
null,
);
const [communities, setCommunities] = React.useState<HostedCommunity[]>([]);
const [name, setName] = React.useState("");
const [availability, setAvailability] = React.useState<boolean | null>(null);
const [checkingName, setCheckingName] = React.useState(false);
const [loading, setLoading] = React.useState(true);
const [action, setAction] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(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<void>) => {
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 (
<div className="flex w-full max-w-[640px] flex-col items-center text-center">
<h1 className="text-title font-normal">Your communities</h1>
<p className="mx-auto mt-3 max-w-[480px] text-sm leading-6 text-foreground/80">
Sign in to connect a community you already own on this machine, or
create a new one.
</p>
<div className="mt-10 w-full space-y-5 text-left">
{error ? (
<div
className="flex items-start gap-2 rounded-xl border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive"
role="alert"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
{loading ? (
<div className="flex justify-center py-10" role="status">
<LoaderCircle className="h-6 w-6 animate-spin" />
<span className="sr-only">Checking Builderlab sign-in</span>
</div>
) : !auth ? (
<section className="rounded-xl bg-white/75 p-6 text-center">
<h2 className="font-medium">Sign in or create an account</h2>
<p className="mt-2 text-sm text-foreground/70">
Builderlab provides Block-hosted Buzz communities. Authentication
opens in your browser and returns here.
</p>
{action === "Signing in…" ? (
<div className="mt-5 flex flex-col items-center gap-3">
<div className="flex items-center gap-2 text-sm text-foreground/70">
<LoaderCircle className="h-4 w-4 animate-spin" />
Waiting for your browser
</div>
<Button onClick={cancelSignIn} variant="outline">
Cancel sign-in
</Button>
</div>
) : (
<Button className="mt-5 rounded-full px-6" onClick={signIn}>
Continue with Builderlab
</Button>
)}
</section>
) : !identity ? (
<section className="rounded-xl bg-white/75 p-6 text-center">
<h2 className="font-medium">Connect this Buzz identity</h2>
<p className="mt-2 text-sm text-foreground/70">
Link this devices Buzz key to{" "}
{auth.email ?? auth.name ?? "your Builderlab account"}. Buzz signs
a one-time challenge locally; your private key never leaves
Desktop.
</p>
<Button
className="mt-5 rounded-full px-6"
disabled={busy}
onClick={() => void connectIdentity()}
>
{busy ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
{action ?? "Connect this Buzz identity"}
</Button>
</section>
) : identityMismatch ? (
<section className="rounded-xl border border-amber-500/50 bg-amber-500/5 p-6">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div>
<h2 className="font-medium">
This account uses a different Buzz identity
</h2>
<p className="mt-2 text-sm text-foreground/70">
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.
</p>
<p className="mt-3 break-all font-mono text-xs text-foreground/60">
Account: {identity.npub ?? boundPubkey}
<br />
This device: {localNpub ?? localPubkey}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
disabled={busy}
onClick={() => void switchToDeviceIdentity()}
>
{action ?? "Use this device's identity"}
</Button>
<Button
disabled={busy}
onClick={() => void signOut()}
variant="outline"
>
Sign in with a different email
</Button>
</div>
</div>
</div>
</section>
) : (
<>
<div className="flex items-center gap-2 rounded-xl bg-white/60 px-4 py-3 text-sm text-foreground/70">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
Signed in{auth.email ? ` as ${auth.email}` : ""} with this Buzz
identity
</div>
{activeCommunities.length > 0 ? (
<section className="rounded-xl bg-white/75 p-5">
<h2 className="font-medium">Connect to one you own</h2>
<ul className="mt-3 space-y-2">
{activeCommunities.map((community, index) => (
<li
className="flex items-center justify-between gap-4 rounded-lg border border-foreground/10 bg-white/50 p-3"
key={community.id ?? community.normalized_host ?? index}
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{community.name ??
community.slug ??
"Hosted community"}
</p>
<p className="truncate text-xs text-foreground/60">
{community.normalized_host}
</p>
</div>
<Button
className="rounded-full"
disabled={busy}
onClick={() =>
void run("Connecting community…", async () => {
connect(community);
})
}
size="sm"
variant="outline"
>
Connect
</Button>
</li>
))}
</ul>
</section>
) : null}
<form className="rounded-xl bg-white/75 p-5" onSubmit={create}>
<h2 className="font-medium">Create a new community</h2>
<p className="mt-1 text-sm text-foreground/70">
Choose the address your team will use to connect.
</p>
<div className="mt-4 flex items-center gap-2">
<Input
aria-label="Community address"
autoComplete="off"
disabled={busy || atCommunityLimit}
maxLength={63}
onChange={(event) => {
setName(event.target.value.toLowerCase());
setAvailability(null);
}}
placeholder="north-star"
spellCheck={false}
value={name}
/>
<span className="shrink-0 text-sm text-foreground/60">
.{HOSTED_COMMUNITY_SUFFIX}
</span>
</div>
{atCommunityLimit ? (
<p className="mt-2 text-sm text-foreground/60">
Youve reached the limit of {HOSTED_COMMUNITY_LIMIT} hosted
communities.
</p>
) : name && !validName ? (
<p className="mt-2 text-sm text-destructive">
Use lowercase letters, numbers, and single hyphens.
</p>
) : checkingName ? (
<p className="mt-2 text-sm text-foreground/60">
Checking availability
</p>
) : availability === false ? (
<p className="mt-2 text-sm text-destructive">
That address is already taken.
</p>
) : availability === true ? (
<p className="mt-2 text-sm text-emerald-600">
That address is available.
</p>
) : null}
<Button
className="mt-4 rounded-full px-6"
disabled={
!validName ||
availability === false ||
checkingName ||
busy ||
atCommunityLimit
}
type="submit"
>
{busy ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
{action ?? "Create and connect"}
</Button>
</form>
</>
)}
</div>
<OnboardingFooter>
<Button
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
disabled={busy}
onClick={goBack}
type="button"
variant="ghost"
>
Back
</Button>
</OnboardingFooter>
</div>
);
}
@@ -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({
</button>
<button
className={COMMUNITY_OPTION_CARD_CLASS}
onClick={() => void openUrl(CREATE_COMMUNITY_URL)}
onClick={() => showPage("owned")}
type="button"
>
<span className="max-w-44">I want to create a community</span>
<span className="max-w-52">
Create or connect to my own community
</span>
</button>
</div>
<OnboardingFooter>
@@ -170,6 +171,14 @@ export function WelcomeSetup({
</Button>
</OnboardingFooter>
</OnboardingSlideTransition>
) : page === "owned" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`owned-${transitionDirection}`}
>
<HostedCommunityOnboarding onBack={() => showPage("welcome")} />
</OnboardingSlideTransition>
) : page === "join" ? (
<OnboardingSlideTransition
className="flex min-h-[calc(100dvh-15.625rem)] w-full flex-col items-center text-center"
@@ -14,6 +14,20 @@ import {
} from "lucide-react";
import { useIdentityQuery } from "@/shared/api/hooks";
import {
HOSTED_COMMUNITY_LIMIT as MAX_COMMUNITIES,
HOSTED_COMMUNITY_SUFFIX as HOST_SUFFIX,
hostedCommunityErrorMessage as errorMessage,
hostedCommunityRelayUrl as relayUrl,
type BuilderlabAuth,
type HostedCommunityAvailabilityResponse as AvailabilityResponse,
type HostedCommunitiesResponse as CommunitiesResponse,
type HostedCommunity,
type HostedCommunityMutationResponse as CommunityMutationResponse,
type HostedIdentityResponse as IdentityResponse,
type HostedNostrIdentity as NostrIdentity,
VALID_HOSTED_COMMUNITY_NAME as VALID_NAME,
} from "@/features/communities/hostedCommunityApi";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import {
@@ -38,91 +52,6 @@ import {
import { Input } from "@/shared/ui/input";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
const HOST_SUFFIX = "communities.buzz.xyz";
const VALID_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const MAX_COMMUNITIES = 3;
type BuilderlabAuth = {
email?: string;
name?: string;
expiresAt: string;
};
type ApiError = {
code?: string;
message?: string;
setup_needed?: boolean;
};
type NostrIdentity = {
npub?: string;
pubkey_hex?: string;
};
type IdentityResponse = {
identity?: NostrIdentity;
error?: ApiError;
correlation_id?: string;
};
type HostedCommunity = {
id?: string;
name?: string;
slug?: string;
normalized_host?: string;
owner_pubkey?: string;
archived_at?: string | null;
};
type CommunitiesResponse = {
communities?: HostedCommunity[];
error?: ApiError;
correlation_id?: string;
};
type AvailabilityResponse = {
available?: boolean;
normalized_host?: string;
error?: ApiError;
correlation_id?: string;
};
type CommunityMutationResponse = {
community?: HostedCommunity;
error?: ApiError;
correlation_id?: string;
};
function errorMessage(
error: ApiError | undefined,
correlationId: string | undefined,
fallback: string,
) {
const messages: Record<string, string> = {
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;
+84
View File
@@ -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<string, RawAcpAuthMethodsResult>;
@@ -8774,6 +8802,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":
+346 -21
View File
@@ -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();
+12
View File
@@ -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<string, unknown>[];
acpRuntimesDelayMs?: number;
acpAuthMethods?: Record<string, { methods: Record<string, unknown>[] }>;