Add native Builderlab auth and community client (#2099)

Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bradley Axen
2026-07-18 22:34:41 -07:00
committed by GitHub
co-authored by npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 Claude Opus 4.8
parent af4b7087dc
commit 8908bd6b71
7 changed files with 1535 additions and 8 deletions
+6
View File
@@ -50,6 +50,12 @@ const rules = [
// Do not add to this list; split the file instead. Remove each entry as its
// file is broken up. Tracked as a follow-up.
const overrides = new Map([
// Native Builderlab auth/community commands add a small registration surface
// to the existing Tauri composition root. The implementation lives in
// builderlab.rs; this narrowly ratchets the command wiring while lib.rs is
// queued for a broader composition-root split. Bumped for the
// archive/unarchive/transfer community-management commands (web parity).
["src-tauri/src/lib.rs", 1013],
// persona-events rebase: build_deploy_payload threads `state` for the
// read-time relay-URL workspace fallback while keeping the create-time env
// pin (the credential-leak guard). Load-bearing feature growth from the
+508
View File
@@ -0,0 +1,508 @@
use std::{collections::HashMap, sync::Mutex, time::Duration};
use axum::{
extract::{Path, Query, State as AxumState},
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
Router,
};
use serde::{Deserialize, Serialize};
use tauri_plugin_opener::OpenerExt;
use tokio::{net::TcpListener, sync::oneshot};
use url::Url;
const BUILDERLAB_API_BASE_URL: &str = "https://app.builderlab.xyz/api/goose";
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const BB_SESSION_CREDENTIAL_HEADER: &str = "X-BB-Session-Credential";
// Builderlab enforces an Origin check on the identity bind endpoints. Browsers
// attach this automatically; the desktop reqwest client must set it explicitly
// or challenge/verify fail with `invalid_origin`. It also seeds the challenge
// body's `origin` field so both agree.
const BUILDERLAB_ORIGIN: &str = "https://app.builderlab.xyz";
#[derive(Default)]
pub(crate) struct BuilderlabSession(Mutex<Option<StoredSession>>);
struct StoredSession {
credential: String,
}
#[derive(Debug, Deserialize)]
struct LoginExchangeResponse {
session_credential: String,
expires_at: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BuilderlabAuthInfo {
expires_at: String,
email: Option<String>,
name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AuthMeResponse {
email: Option<String>,
name: Option<String>,
expires_at: String,
}
struct CallbackState {
nonce: String,
sender: Mutex<Option<oneshot::Sender<Result<String, String>>>>,
}
async fn login_callback(
Path(nonce): Path<String>,
Query(query): Query<HashMap<String, String>>,
AxumState(state): AxumState<std::sync::Arc<CallbackState>>,
) -> Response {
if nonce != state.nonce {
return (StatusCode::NOT_FOUND, "Not found").into_response();
}
let result = match query.get("code").filter(|code| !code.is_empty()) {
Some(code) => Ok(code.clone()),
None => Err(query
.get("error_description")
.or_else(|| query.get("error"))
.cloned()
.unwrap_or_else(|| "Authentication callback did not include a code".to_owned())),
};
if let Some(sender) = state
.sender
.lock()
.expect("callback sender poisoned")
.take()
{
let _ = sender.send(result);
}
Html(
"<!doctype html><meta charset=utf-8><title>Buzz authentication complete</title>\
<h1>Authentication complete</h1><p>You can close this window and return to Buzz.</p>",
)
.into_response()
}
fn api_url(path: &str) -> Result<Url, String> {
Url::parse(&format!("{BUILDERLAB_API_BASE_URL}{path}"))
.map_err(|error| format!("invalid Builderlab API URL: {error}"))
}
fn login_url(return_to: &str) -> Result<Url, String> {
let mut login_url = api_url("/v1/auth/login")?;
login_url
.query_pairs_mut()
.append_pair("type", "cli")
.append_pair("product", "buzz")
.append_pair("returnTo", return_to);
Ok(login_url)
}
async fn authenticated_user(
client: &reqwest::Client,
credential: &str,
) -> Result<AuthMeResponse, String> {
let response = client
.get(api_url("/v1/auth/me")?)
.header(BB_SESSION_CREDENTIAL_HEADER, credential)
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|error| format!("Builderlab session check failed: {error}"))?;
if !response.status().is_success() {
return Err(format!(
"Builderlab session check failed with HTTP {}",
response.status()
));
}
response
.json()
.await
.map_err(|error| format!("invalid Builderlab session response: {error}"))
}
#[tauri::command]
pub(crate) async fn start_builderlab_login(
app: tauri::AppHandle,
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<BuilderlabAuthInfo, String> {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|error| format!("could not start local authentication callback: {error}"))?;
let port = listener
.local_addr()
.map_err(|error| format!("could not read local authentication callback: {error}"))?
.port();
let nonce = uuid::Uuid::new_v4().simple().to_string();
let return_to = format!("http://127.0.0.1:{port}/callback/{nonce}");
let (sender, receiver) = oneshot::channel();
let callback_state = std::sync::Arc::new(CallbackState {
nonce: nonce.clone(),
sender: Mutex::new(Some(sender)),
});
let router = Router::new()
.route("/callback/{nonce}", get(login_callback))
.with_state(callback_state);
let server = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
let login_url = login_url(&return_to)?;
if let Err(error) = app.opener().open_url(login_url.as_str(), None::<&str>) {
server.abort();
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());
}
Err(_) => {
server.abort();
return Err("Builderlab authentication timed out".to_owned());
}
};
server.abort();
let response = app_state
.http_client
.post(api_url("/v1/auth/login/exchange")?)
.json(&serde_json::json!({ "code": exchange_code }))
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|error| format!("Builderlab code exchange failed: {error}"))?;
if !response.status().is_success() {
return Err(format!(
"Builderlab code exchange failed with HTTP {}",
response.status()
));
}
let exchanged: LoginExchangeResponse = response
.json()
.await
.map_err(|error| format!("invalid Builderlab code exchange response: {error}"))?;
if exchanged.session_credential.is_empty() {
return Err("Builderlab code exchange returned an empty credential".to_owned());
}
let me = authenticated_user(&app_state.http_client, &exchanged.session_credential).await?;
if exchanged.expires_at != me.expires_at {
return Err("Builderlab session expiry did not match code exchange".to_owned());
}
let info = BuilderlabAuthInfo {
expires_at: me.expires_at.clone(),
email: me.email,
name: me.name,
};
*session.0.lock().map_err(|error| error.to_string())? = Some(StoredSession {
credential: exchanged.session_credential,
});
Ok(info)
}
#[tauri::command]
pub(crate) async fn get_builderlab_auth(
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<Option<BuilderlabAuthInfo>, String> {
let stored = session
.0
.lock()
.map_err(|error| error.to_string())?
.as_ref()
.map(|stored| stored.credential.clone());
let Some(credential) = stored else {
return Ok(None);
};
match authenticated_user(&app_state.http_client, &credential).await {
Ok(me) => Ok(Some(BuilderlabAuthInfo {
expires_at: me.expires_at,
email: me.email,
name: me.name,
})),
Err(error) => {
*session
.0
.lock()
.map_err(|lock_error| lock_error.to_string())? = None;
Err(error)
}
}
}
#[tauri::command]
pub(crate) fn clear_builderlab_auth(
session: tauri::State<'_, BuilderlabSession>,
) -> Result<(), String> {
*session.0.lock().map_err(|error| error.to_string())? = None;
Ok(())
}
#[derive(Debug, Deserialize)]
struct NostrIdentityChallenge {
challenge_id: String,
nonce: String,
verification_code: String,
origin: String,
expires_at: String,
}
async fn authenticated_json(
client: &reqwest::Client,
session: &BuilderlabSession,
method: reqwest::Method,
path: &str,
body: serde_json::Value,
) -> Result<serde_json::Value, String> {
let credential = session
.0
.lock()
.map_err(|error| error.to_string())?
.as_ref()
.map(|stored| stored.credential.clone())
.ok_or_else(|| "Sign in to Builderlab first".to_owned())?;
let response = client
.request(method, api_url(path)?)
.header(BB_SESSION_CREDENTIAL_HEADER, credential)
.header(reqwest::header::ORIGIN, BUILDERLAB_ORIGIN)
.json(&body)
.timeout(Duration::from_secs(60))
.send()
.await
.map_err(|error| format!("Builderlab request failed: {error}"))?;
let status = response.status();
let value: serde_json::Value = response
.json()
.await
.map_err(|error| format!("invalid Builderlab response: {error}"))?;
if !status.is_success() {
// Builderlab error responses carry a structured `{ error: { code,
// message, setup_needed, ... } }` body. Pass those through as `Ok` so the
// frontend's typed handling and friendly per-code messages apply, instead
// of surfacing a raw JSON blob. Only fall back to a plain string when the
// body isn't the expected shape.
if value.get("error").is_some() {
return Ok(value);
}
return Err(format!("Builderlab request failed (HTTP {status})."));
}
Ok(value)
}
#[tauri::command]
pub(crate) async fn get_builderlab_nostr_identity(
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/nostr-identities/current",
serde_json::json!({}),
)
.await
}
#[tauri::command]
pub(crate) async fn bind_builderlab_nostr_identity(
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
let challenge_value = authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/nostr-identities/challenge",
serde_json::json!({ "origin": BUILDERLAB_ORIGIN }),
)
.await?;
// A structured error here (e.g. missing_mapping) arrives as an object with an
// `error` field rather than a challenge — hand it straight back so the
// frontend maps it to a friendly message instead of hitting a deserialize
// failure below.
if challenge_value.get("error").is_some() {
return Ok(challenge_value);
}
let challenge: NostrIdentityChallenge = serde_json::from_value(challenge_value)
.map_err(|error| format!("invalid Nostr identity challenge: {error}"))?;
let keys = app_state.signing_keys()?;
let event = crate::commands::build_nostr_identity_binding_event(
&keys,
&challenge.challenge_id,
&challenge.nonce,
&challenge.verification_code,
&challenge.origin,
&challenge.expires_at,
)?;
authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/nostr-identities/verify",
serde_json::json!({
"challenge_id": challenge.challenge_id,
"nonce": challenge.nonce,
"signed_payload": nostr::JsonUtil::as_json(&event),
}),
)
.await
}
#[tauri::command]
pub(crate) async fn delete_builderlab_nostr_identity(
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/nostr-identities/delete",
serde_json::json!({}),
)
.await
}
#[tauri::command]
pub(crate) async fn list_builderlab_communities(
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/communities/list",
serde_json::json!({}),
)
.await
}
#[tauri::command]
pub(crate) async fn check_builderlab_community_name(
name: String,
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/communities/availability",
serde_json::json!({ "name": name }),
)
.await
}
#[tauri::command]
pub(crate) async fn create_builderlab_community(
name: String,
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/communities",
serde_json::json!({ "name": name }),
)
.await
}
#[tauri::command]
pub(crate) async fn archive_builderlab_community(
community_id: String,
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/communities/archive",
serde_json::json!({ "community_id": community_id }),
)
.await
}
#[tauri::command]
pub(crate) async fn unarchive_builderlab_community(
community_id: String,
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/communities/unarchive",
serde_json::json!({ "community_id": community_id }),
)
.await
}
#[tauri::command]
pub(crate) async fn transfer_builderlab_community(
community_id: String,
transferee_npub: String,
app_state: tauri::State<'_, crate::app_state::AppState>,
session: tauri::State<'_, BuilderlabSession>,
) -> Result<serde_json::Value, String> {
// The Builderlab transfer endpoint expects camelCase keys, unlike the
// archive/unarchive endpoints which take `community_id`; mirror the web
// client's payload exactly.
authenticated_json(
&app_state.http_client,
&session,
reqwest::Method::POST,
"/v1/buzz/communities/transfer",
serde_json::json!({
"communityId": community_id,
"transfereeNpub": transferee_npub,
}),
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn api_paths_stay_on_builderlab_api_origin() {
let login = api_url("/v1/auth/login").unwrap();
assert_eq!(
login.origin().ascii_serialization(),
"https://app.builderlab.xyz"
);
assert_eq!(login.path(), "/api/goose/v1/auth/login");
}
#[test]
fn login_defaults_to_auth0_login() {
let login = login_url("http://127.0.0.1:1234/callback/nonce").unwrap();
let query: HashMap<_, _> = login.query_pairs().into_owned().collect();
assert_eq!(query.get("type").map(String::as_str), Some("cli"));
assert_eq!(query.get("product").map(String::as_str), Some("buzz"));
assert_eq!(
query.get("returnTo").map(String::as_str),
Some("http://127.0.0.1:1234/callback/nonce")
);
assert!(!query.contains_key("screen_hint"));
}
}
+1 -1
View File
@@ -343,7 +343,7 @@ fn nostr_bind_tag(name: &str, value: &str) -> Result<Tag, String> {
Tag::parse(vec![name, value]).map_err(|error| format!("{name} tag failed: {error}"))
}
fn build_nostr_identity_binding_event(
pub(crate) fn build_nostr_identity_binding_event(
keys: &Keys,
challenge_id: &str,
nonce: &str,
+19 -6
View File
@@ -2,6 +2,7 @@
#![recursion_limit = "256"]
mod app_state;
mod archive;
mod builderlab;
mod commands;
mod deep_link;
mod event_sync;
@@ -11,6 +12,8 @@ mod managed_agents;
mod media_proxy;
#[cfg(feature = "mesh-llm")]
mod mesh_llm;
#[cfg(not(feature = "mesh-llm"))]
mod mesh_llm_stubs;
mod migration;
#[cfg(test)]
mod model_tests;
@@ -26,13 +29,8 @@ mod secret_store;
mod shutdown;
mod templates;
mod util;
#[cfg(not(feature = "mesh-llm"))]
mod mesh_llm_stubs;
#[cfg(not(feature = "mesh-llm"))]
use mesh_llm_stubs::*;
use app_state::{build_app_state, resolve_persisted_identity, AppState};
use builderlab::*;
use commands::*;
use deep_link::{
acknowledge_pending_community_deep_link, handle_deep_link_url,
@@ -49,6 +47,8 @@ use huddle::{
set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline,
};
use managed_agents::{backfill_persona_snapshots, ensure_nest, try_regenerate_nest};
#[cfg(not(feature = "mesh-llm"))]
use mesh_llm_stubs::*;
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown};
use shutdown::{is_restart_request, shut_down_app};
@@ -450,6 +450,7 @@ pub fn run() {
.manage(build_app_state())
.manage(ClipboardState::new())
.manage(PendingCommunityDeepLinks::default())
.manage(BuilderlabSession::default())
.manage(commands::pairing::PairingHandle::new())
.setup(move |app| {
let app_handle = app.handle().clone();
@@ -742,6 +743,18 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
take_pending_community_deep_link,
acknowledge_pending_community_deep_link,
start_builderlab_login,
get_builderlab_auth,
clear_builderlab_auth,
get_builderlab_nostr_identity,
bind_builderlab_nostr_identity,
delete_builderlab_nostr_identity,
list_builderlab_communities,
check_builderlab_community_name,
create_builderlab_community,
archive_builderlab_community,
unarchive_builderlab_community,
transfer_builderlab_community,
title_bar_double_click,
get_identity,
get_nsec,
@@ -0,0 +1,989 @@
import * as React from "react";
import { invoke } from "@tauri-apps/api/core";
import {
AlertCircle,
Archive,
ArchiveRestore,
ArrowLeftRight,
CheckCircle2,
ExternalLink,
LoaderCircle,
LogOut,
RefreshCw,
Unlink,
} from "lucide-react";
import { useIdentityQuery } from "@/shared/api/hooks";
import { safeNpub } from "@/shared/lib/nostrUtils";
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { Button, buttonVariants } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
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;
const [auth, setAuth] = React.useState<BuilderlabAuth | null>(null);
const [communities, setCommunities] = React.useState<HostedCommunity[]>([]);
const [identity, setIdentity] = React.useState<NostrIdentity | null>(null);
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 loadAccount = React.useCallback(async () => {
setError(null);
const [identityResponse, communitiesResponse] = await Promise.all([
invoke<IdentityResponse>("get_builderlab_nostr_identity"),
invoke<CommunitiesResponse>("list_builderlab_communities"),
]);
if (
identityResponse.error &&
identityResponse.error.code !== "unauthorized" &&
// `missing_mapping` (setup_needed) just means this account hasn't linked a
// Buzz identity yet — that's the connect-card empty state, not an error to
// surface at the top of the page.
!identityResponse.error.setup_needed
) {
throw new Error(
errorMessage(
identityResponse.error,
identityResponse.correlation_id,
"Could not load the connected Buzz identity.",
),
);
}
if (communitiesResponse.error && !communitiesResponse.error.setup_needed) {
throw new Error(
errorMessage(
communitiesResponse.error,
communitiesResponse.correlation_id,
"Could not load communities.",
),
);
}
setIdentity(identityResponse.identity ?? null);
setCommunities(communitiesResponse.communities ?? []);
}, []);
React.useEffect(() => {
let active = true;
void invoke<BuilderlabAuth | null>("get_builderlab_auth")
.then(async (nextAuth) => {
if (!active) return;
setAuth(nextAuth);
if (nextAuth) await loadAccount();
})
.catch((cause) => {
if (active) setError(String(cause));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [loadAccount]);
// Returns whether the operation completed without throwing so callers (e.g.
// dialogs) can close themselves only on success.
const run = async (label: string, operation: () => Promise<void>) => {
setAction(label);
setError(null);
try {
await operation();
return true;
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
return false;
} finally {
setAction(null);
}
};
const signIn = () =>
run("Signing in…", async () => {
const nextAuth = await invoke<BuilderlabAuth>("start_builderlab_login");
setAuth(nextAuth);
await loadAccount();
});
const signOut = () =>
run("Signing out…", async () => {
await invoke("clear_builderlab_auth");
setAuth(null);
setIdentity(null);
setCommunities([]);
setName("");
setAvailability(null);
});
const connectIdentity = () =>
run("Connecting Buzz identity…", async () => {
const response = await invoke<IdentityResponse>(
"bind_builderlab_nostr_identity",
);
if (response.error) {
throw new Error(
errorMessage(
response.error,
response.correlation_id,
"Could not connect the Buzz identity.",
),
);
}
setIdentity(response.identity ?? null);
await loadAccount();
});
const unpairIdentity = () =>
run("Unpairing identity…", async () => {
const response = await invoke<IdentityResponse>(
"delete_builderlab_nostr_identity",
);
if (response.error) {
throw new Error(
errorMessage(
response.error,
response.correlation_id,
"Could not unpair the Buzz identity.",
),
);
}
setIdentity(null);
await loadAccount();
});
// The Builderlab account can be bound to an npub that differs from the key
// this Desktop is currently signing with (e.g. you signed into an email tied
// to a different test identity). When that happens the community list and
// Connect buttons operate on the *bound* npub's communities, so "Connect"
// would drop you into a relay your local key isn't a member of. Detect it and
// block Connect + Create until the identities match.
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 () => {
// The account is bound to a different npub, so re-binding directly returns
// identity_already_bound. Release the current binding first, then bind
// this device's key. If the local key is reserved by another Builderlab
// account, the bind fails with pubkey_already_bound — surface that instead
// of leaving the swap half-finished silently.
const released = await invoke<IdentityResponse>(
"delete_builderlab_nostr_identity",
);
if (released.error) {
throw new Error(
errorMessage(
released.error,
released.correlation_id,
"Could not release the previously connected Buzz identity.",
),
);
}
const bound = await invoke<IdentityResponse>(
"bind_builderlab_nostr_identity",
);
if (bound.error) {
// Refresh so the UI reflects the now-unbound account before surfacing
// the reason the swap could not complete.
await loadAccount();
throw new Error(
bound.error.code === "pubkey_already_bound"
? "This device's Buzz identity is already reserved by another Builderlab account, so it can't be connected here. Sign in with that account, or transfer the identity there first."
: errorMessage(
bound.error,
bound.correlation_id,
"Could not connect this device's Buzz identity.",
),
);
}
setIdentity(bound.identity ?? null);
await loadAccount();
});
const archiveCommunity = (community: HostedCommunity) => {
if (!community.id) return Promise.resolve(false);
return run("Archiving community…", async () => {
const response = await invoke<CommunityMutationResponse>(
"archive_builderlab_community",
{ communityId: community.id },
);
// Treat a returned archived timestamp as success even if the payload also
// carries a soft error (existing connections may take time to close).
if (response.error && !response.community?.archived_at) {
throw new Error(
errorMessage(
response.error,
response.correlation_id,
"Could not archive the community.",
),
);
}
await loadAccount();
});
};
const unarchiveCommunity = (community: HostedCommunity) => {
if (!community.id) return Promise.resolve(false);
return run("Unarchiving community…", async () => {
const response = await invoke<CommunityMutationResponse>(
"unarchive_builderlab_community",
{ communityId: community.id },
);
if (response.error && response.community?.archived_at !== null) {
throw new Error(
errorMessage(
response.error,
response.correlation_id,
"Could not unarchive the community.",
),
);
}
await loadAccount();
});
};
const transferCommunity = (community: HostedCommunity, npub: string) =>
run("Transferring ownership…", async () => {
const response = await invoke<CommunityMutationResponse>(
"transfer_builderlab_community",
{ communityId: community.id, transfereeNpub: npub },
);
if (response.error) {
throw new Error(
errorMessage(
response.error,
response.correlation_id,
"Could not transfer ownership.",
),
);
}
await loadAccount();
});
const normalizedName = name.trim().toLowerCase();
const validName =
normalizedName.length <= 63 && VALID_NAME.test(normalizedName);
// Debounced typeahead availability check: once the user pauses on a valid
// address, check it ~500ms later so the result is ready before they click
// Create (no separate "check" click). onChange clears the previous result, so
// the indicator reflects the current input while typing.
React.useEffect(() => {
if (!identity || identityMismatch || !normalizedName || !validName) {
setCheckingName(false);
return;
}
let cancelled = false;
setCheckingName(true);
const handle = window.setTimeout(() => {
void (async () => {
try {
const response = await invoke<AvailabilityResponse>(
"check_builderlab_community_name",
{ name: normalizedName },
);
if (cancelled) return;
setAvailability(
response.error ? null : (response.available ?? false),
);
} catch {
if (!cancelled) setAvailability(null);
} finally {
if (!cancelled) setCheckingName(false);
}
})();
}, 500);
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}, [normalizedName, validName, identity, identityMismatch]);
const createCommunity = (event: React.FormEvent) => {
event.preventDefault();
if (
!validName ||
!identity ||
identityMismatch ||
communities.length >= MAX_COMMUNITIES
)
return;
void run("Creating community…", async () => {
const availabilityResponse = await invoke<AvailabilityResponse>(
"check_builderlab_community_name",
{ name: normalizedName },
);
if (availabilityResponse.error || !availabilityResponse.available) {
setAvailability(false);
throw new Error(
errorMessage(
availabilityResponse.error,
availabilityResponse.correlation_id,
"That Buzz address is already taken.",
),
);
}
const response = await invoke<CommunityMutationResponse>(
"create_builderlab_community",
{ name: normalizedName },
);
if (response.error || !response.community) {
throw new Error(
errorMessage(
response.error,
response.correlation_id,
"Could not create the community.",
),
);
}
const url = relayUrl(response.community);
if (!url)
throw new Error("The new community did not return a relay address.");
setName("");
setAvailability(null);
await loadAccount();
if (
!onboarding.start({
source: "add-community",
relayUrl: url,
communityName: response.community.name ?? normalizedName,
})
) {
throw new Error(
"Another community is already being connected. Finish it before connecting this one.",
);
}
});
};
const busy = action != null;
const atCommunityLimit = communities.length >= MAX_COMMUNITIES;
return (
<section className="space-y-6" data-testid="hosted-communities-settings">
<SettingsSectionHeader
title="Hosted communities"
description="Buzz works with any relay. This page is only for relay hosting provided by Block — sign in with a Builderlab account to create and manage Block-hosted communities. Builderlab sign-in is used on this page alone."
/>
{error ? (
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> Checking sign-in
</div>
) : !auth ? (
<div className="rounded-xl border border-border/70 p-5">
<h3 className="font-medium">Sign in to manage hosted communities</h3>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
Authentication opens in your browser and returns securely to Buzz.
You can use every other part of the app without signing in.
</p>
<Button
className="mt-4"
disabled={busy}
onClick={() => void signIn()}
>
{action ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<ExternalLink className="h-4 w-4" />
)}
{action ?? "Sign in with Builderlab"}
</Button>
</div>
) : (
<>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 p-4">
<div>
<p className="text-sm font-medium">
{auth.name || auth.email || "Builderlab account"}
</p>
{auth.name && auth.email ? (
<p className="text-xs text-muted-foreground">{auth.email}</p>
) : null}
</div>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => void signOut()}
>
<LogOut className="h-4 w-4" /> Sign out
</Button>
</div>
{!identity ? (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/5 p-5">
<h3 className="font-medium">
Link this account to your Buzz identity
</h3>
<p className="mt-2 text-sm text-muted-foreground">
This Builderlab account isn&apos;t linked to a Buzz identity
yet. Connect this device&apos;s key to create and own
communities under it Buzz signs a one-time challenge locally,
so your private key never leaves Desktop.
</p>
<Button
className="mt-4"
disabled={busy}
onClick={() => void connectIdentity()}
>
{action ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
{action ?? "Connect Buzz identity"}
</Button>
</div>
) : identityMismatch ? (
<div className="rounded-xl border border-amber-500/50 bg-amber-500/5 p-5">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div>
<h3 className="font-medium">
This account is connected to a different Buzz identity
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Your Builderlab account owns communities under another Buzz
key, so connecting them here would join a relay this device
isn&apos;t a member of. Creating and connecting are paused
until the identities match.
</p>
<dl className="mt-3 space-y-1 text-xs">
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">Account uses</dt>
<dd className="font-mono">
{identity.npub ?? boundPubkey}
</dd>
</div>
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">This device</dt>
<dd className="font-mono">{localNpub ?? localPubkey}</dd>
</div>
</dl>
</div>
</div>
<Button
className="mt-4"
disabled={busy}
onClick={() => void switchToDeviceIdentity()}
>
{action ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
{action ?? "Switch to this device's identity"}
</Button>
</div>
) : (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" /> Buzz
identity connected
{identity.npub ? (
<span className="font-mono text-xs">{identity.npub}</span>
) : null}
</div>
<UnpairIdentityButton
busy={busy}
onConfirm={() => void unpairIdentity()}
/>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="font-medium">
Your communities
<span className="ml-2 text-xs font-normal text-muted-foreground">
{communities.length} of {MAX_COMMUNITIES} used
</span>
</h3>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => void run("Refreshing…", loadAccount)}
>
<RefreshCw className="h-4 w-4" /> Refresh
</Button>
</div>
{communities.length === 0 ? (
<p className="rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
No hosted communities yet.
</p>
) : (
<ul className="space-y-2">
{[...communities]
.sort(
(a, b) =>
Number(Boolean(a.archived_at)) -
Number(Boolean(b.archived_at)),
)
.map((community, index) => (
<CommunityRow
key={community.id ?? community.normalized_host ?? index}
community={community}
busy={busy}
canConnect={!identityMismatch}
onConnect={() => {
const url = relayUrl(community);
if (url)
onboarding.start({
source: "add-community",
relayUrl: url,
communityName: community.name,
});
}}
onArchive={() => void archiveCommunity(community)}
onUnarchive={() => void unarchiveCommunity(community)}
onTransfer={(npub) => transferCommunity(community, npub)}
/>
))}
</ul>
)}
</div>
<form
className="space-y-4 rounded-xl border border-border/70 p-5"
onSubmit={createCommunity}
>
<div>
<h3 className="font-medium">Create a community</h3>
<p className="mt-1 text-sm text-muted-foreground">
Choose the address your team will use to connect.
</p>
</div>
{atCommunityLimit ? (
<p className="text-sm text-muted-foreground">
You&apos;ve reached the limit of {MAX_COMMUNITIES} hosted
communities. Transfer one to free up a slot before creating
another.
</p>
) : null}
<div className="flex max-w-xl items-center gap-2">
<Input
aria-label="Community address"
autoComplete="off"
disabled={
!identity || identityMismatch || 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-muted-foreground">
.{HOST_SUFFIX}
</span>
</div>
{name && !validName ? (
<p className="text-sm text-destructive">
Use lowercase letters, numbers, and single hyphens.
</p>
) : validName && checkingName ? (
<p className="text-sm text-muted-foreground">
Checking availability
</p>
) : availability === false ? (
<p className="text-sm text-destructive">
That address is already taken.
</p>
) : availability === true ? (
<p className="text-sm text-emerald-600">
That address is available.
</p>
) : null}
<Button
disabled={
!identity ||
identityMismatch ||
!validName ||
availability === false ||
checkingName ||
busy ||
atCommunityLimit
}
type="submit"
>
{action ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : null}
{action ?? "Create and connect"}
</Button>
</form>
</>
)}
</section>
);
}
function UnpairIdentityButton({
busy,
onConfirm,
}: {
busy: boolean;
onConfirm: () => void;
}) {
const [open, setOpen] = React.useState(false);
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={busy}
onClick={() => setOpen(true)}
>
<Unlink className="h-4 w-4" /> Unpair identity
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unpair this Buzz identity?</AlertDialogTitle>
<AlertDialogDescription>
Your Builderlab account will no longer be connected to this Buzz
key. You can reconnect any key later, but community actions stay
unavailable until you do.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onConfirm}
>
Unpair identity
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function CommunityRow({
community,
busy,
canConnect,
onConnect,
onArchive,
onUnarchive,
onTransfer,
}: {
community: HostedCommunity;
busy: boolean;
canConnect: boolean;
onConnect: () => void;
onArchive: () => void;
onUnarchive: () => void;
onTransfer: (npub: string) => Promise<boolean>;
}) {
const [confirmArchive, setConfirmArchive] = React.useState(false);
const [confirmUnarchive, setConfirmUnarchive] = React.useState(false);
const [transferOpen, setTransferOpen] = React.useState(false);
const url = relayUrl(community);
const archived = Boolean(community.archived_at);
const displayName = community.name ?? community.slug ?? "Hosted community";
return (
<li
className={`flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 p-4 ${
archived ? "opacity-70" : ""
}`}
>
<div className="min-w-0">
<p className="text-sm font-medium">{displayName}</p>
<p className="text-xs text-muted-foreground">
{community.normalized_host}
{archived ? " · Archived" : ""}
</p>
</div>
{archived ? (
<>
<Button
variant="outline"
size="sm"
disabled={busy || !community.id}
onClick={() => setConfirmUnarchive(true)}
>
<ArchiveRestore className="h-4 w-4" /> Unarchive
</Button>
<AlertDialog
open={confirmUnarchive}
onOpenChange={setConfirmUnarchive}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unarchive {displayName}?</AlertDialogTitle>
<AlertDialogDescription>
This address becomes connectable again. Connections that
closed during archival will not reconnect automatically.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onUnarchive}>
Unarchive
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) : (
<div className="flex flex-wrap items-center gap-2">
{url && canConnect ? (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={onConnect}
>
Connect
</Button>
) : null}
<Button
variant="ghost"
size="sm"
disabled={busy || !community.id}
onClick={() => setTransferOpen(true)}
>
<ArrowLeftRight className="h-4 w-4" /> Transfer
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={busy || !community.id}
onClick={() => setConfirmArchive(true)}
>
<Archive className="h-4 w-4" /> Archive
</Button>
<AlertDialog open={confirmArchive} onOpenChange={setConfirmArchive}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive {displayName}?</AlertDialogTitle>
<AlertDialogDescription>
New and existing connections stop and the address stays
reserved. Archiving can&apos;t be undone from here without
unarchiving, and the community keeps counting toward your
quota it isn&apos;t deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onArchive}
>
Archive
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TransferOwnershipDialog
open={transferOpen}
onOpenChange={setTransferOpen}
communityName={displayName}
busy={busy}
onTransfer={onTransfer}
/>
</div>
)}
</li>
);
}
function TransferOwnershipDialog({
open,
onOpenChange,
communityName,
busy,
onTransfer,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
communityName: string;
busy: boolean;
onTransfer: (npub: string) => Promise<boolean>;
}) {
const [npub, setNpub] = React.useState("");
const npubIsValid = npub.startsWith("npub1") && npub.length >= 50;
React.useEffect(() => {
if (!open) setNpub("");
}, [open]);
const submit = async () => {
if (!npubIsValid) return;
const ok = await onTransfer(npub.trim());
if (ok) onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Transfer ownership</DialogTitle>
<DialogDescription>
Transfer {communityName} to another person. You become a regular
member. The recipient needs a connected Buzz identity first, and
this can&apos;t be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Input
aria-label="Recipient npub"
autoComplete="off"
className="font-mono text-sm"
placeholder="npub1…"
spellCheck={false}
value={npub}
onChange={(event) => setNpub(event.target.value.trim())}
/>
{npub.length > 0 && !npubIsValid ? (
<p className="text-sm text-destructive">
Enter a valid npub that starts with npub1.
</p>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={!npubIsValid || busy}
onClick={() => void submit()}
>
{busy ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
Transfer ownership
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -11,6 +11,7 @@ import {
Keyboard,
LayoutTemplate,
LockKeyhole,
MessagesSquare,
MonitorCog,
Moon,
ShieldAlert,
@@ -64,6 +65,7 @@ import { ModerationQueueCard } from "./ModerationQueueCard";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
import { GlobalAgentConfigSettingsCard } from "./GlobalAgentConfigSettingsCard";
import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard";
import { ProfileSettingsCard } from "./ProfileSettingsCard";
import { UpdateChecker } from "../UpdateChecker";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
@@ -77,6 +79,7 @@ export type SettingsSection =
| "compute"
| "appearance"
| "shortcuts"
| "hosted-communities"
| "community-members"
| "moderation"
| "custom-emoji"
@@ -96,6 +99,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
"compute",
"appearance",
"shortcuts",
"hosted-communities",
"community-members",
"moderation",
"custom-emoji",
@@ -178,6 +182,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Shortcuts",
icon: Keyboard,
},
{
value: "hosted-communities",
label: "Hosted communities",
icon: MessagesSquare,
},
{
value: "community-members",
label: "Community access",
@@ -727,6 +736,8 @@ export function renderSettingsSection(
return <ThemeSettingsCard />;
case "shortcuts":
return <KeyboardShortcutsCard />;
case "hosted-communities":
return <HostedCommunitiesSettingsCard />;
case "community-members":
return (
<CommunityMembersSettingsCard currentPubkey={props.currentPubkey} />
@@ -62,7 +62,7 @@ const settingsNavGroups: Array<{
},
{
label: "Communities",
sections: ["channel-templates", "community-members"],
sections: ["hosted-communities", "channel-templates", "community-members"],
},
{
label: "App",