From 3adbb6eddb456dfd7df625ead5add5c1a42e56bf Mon Sep 17 00:00:00 2001 From: coder 0 Date: Mon, 28 Sep 2026 14:16:28 -0600 Subject: [PATCH] Address enterprise login review feedback Signed-off-by: coder 0 Signed-off-by: bb-expert <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz> --- desktop/src-tauri/build.rs | 1 - desktop/src-tauri/src/builderlab.rs | 232 ++---------------- .../src-tauri/src/builderlab_api_config.rs | 16 +- desktop/src-tauri/src/commands/profile.rs | 61 +++-- desktop/src/app/App.tsx | 37 ++- .../communities/hostedCommunityApi.ts | 14 +- .../features/communities/useCommunityInit.ts | 14 +- desktop/src/shared/api/tauriProfiles.ts | 6 +- desktop/src/testing/e2eBridge.ts | 12 +- desktop/tests/e2e/onboarding.spec.ts | 16 +- docs/enterprise-identity-adapter.md | 15 +- 11 files changed, 144 insertions(+), 280 deletions(-) diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 05bcde872c..7d1728b7ea 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -142,7 +142,6 @@ fn main() { std::env::var("BUZZ_BUILD_BUILDERLAB_API_BASE_URL") .ok() .as_deref(), - enterprise_auth_relays.as_deref(), ) .unwrap_or_else(|error| panic!("{error}")); println!( diff --git a/desktop/src-tauri/src/builderlab.rs b/desktop/src-tauri/src/builderlab.rs index 7f033cd560..9882c8d8fa 100644 --- a/desktop/src-tauri/src/builderlab.rs +++ b/desktop/src-tauri/src/builderlab.rs @@ -1,8 +1,4 @@ -use std::{ - collections::{HashMap, VecDeque}, - sync::Mutex, - time::Duration, -}; +use std::{collections::HashMap, sync::Mutex, time::Duration}; use axum::{ extract::{Path, Query, State as AxumState}, @@ -17,8 +13,6 @@ use tokio::{net::TcpListener, sync::oneshot}; use url::Url; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); -const BUILDERLAB_LOGIN_ATTEMPT_PREFIX: &str = "builderlab-login-"; -const CANCELED_LOGIN_ATTEMPT_TOMBSTONE_LIMIT: usize = 64; 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 @@ -152,7 +146,6 @@ pub(crate) struct BuilderlabLogin(Mutex); #[derive(Default)] struct BuilderlabLoginState { pending: Option, - canceled_attempts: VecDeque, } struct PendingLogin { @@ -168,8 +161,6 @@ struct StoredSession { struct LoginExchangeResponse { session_credential: String, expires_at: String, - username: Option, - name: Option, } #[derive(Debug, Serialize)] @@ -177,16 +168,12 @@ struct LoginExchangeResponse { pub(crate) struct BuilderlabAuthInfo { expires_at: String, email: Option, - username: Option, - name: Option, } #[derive(Debug, Deserialize)] struct AuthMeResponse { email: Option, expires_at: String, - username: Option, - name: Option, } struct CallbackState { @@ -253,27 +240,6 @@ fn login_exchange_body(code: &str) -> serde_json::Value { serde_json::json!({ "code": code }) } -fn remember_canceled_attempt(state: &mut BuilderlabLoginState, attempt_id: String) { - if state - .canceled_attempts - .iter() - .any(|canceled| canceled == &attempt_id) - { - return; - } - if state.canceled_attempts.len() >= CANCELED_LOGIN_ATTEMPT_TOMBSTONE_LIMIT { - state.canceled_attempts.pop_front(); - } - state.canceled_attempts.push_back(attempt_id); -} - -fn is_canceled_attempt(state: &BuilderlabLoginState, attempt_id: &str) -> bool { - state - .canceled_attempts - .iter() - .any(|canceled| canceled == attempt_id) -} - fn clear_matching_pending_login(login: &BuilderlabLogin, login_id: &str) -> Result<(), String> { let mut state = login.0.lock().map_err(|error| error.to_string())?; if state @@ -290,30 +256,16 @@ fn register_pending_builderlab_login( login: &BuilderlabLogin, login_id: &str, cancel: oneshot::Sender<()>, - is_guarded_attempt: bool, -) -> Result { +) -> Result<(), String> { let mut state = login.0.lock().map_err(|error| error.to_string())?; - if is_guarded_attempt && is_canceled_attempt(&state, login_id) { - return Ok(false); - } - if state.pending.is_some() { - if is_guarded_attempt - && !state - .pending - .as_ref() - .is_some_and(|pending| pending.id.starts_with(BUILDERLAB_LOGIN_ATTEMPT_PREFIX)) - { - return Err("Builderlab authentication is already in progress".to_owned()); - } - if let Some(previous) = state.pending.take() { - let _ = previous.cancel.send(()); - } + if let Some(previous) = state.pending.take() { + let _ = previous.cancel.send(()); } state.pending = Some(PendingLogin { id: login_id.to_owned(), cancel, }); - Ok(true) + Ok(()) } fn commit_builderlab_login_session( @@ -390,7 +342,6 @@ pub(crate) async fn start_builderlab_login( app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, login: tauri::State<'_, BuilderlabLogin>, - attempt_id: Option, ) -> Result { let listener = TcpListener::bind("127.0.0.1:0") .await @@ -413,21 +364,11 @@ pub(crate) async fn start_builderlab_login( let _ = axum::serve(listener, router).await; }); - let login_id = attempt_id - .clone() - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let is_guarded_attempt = attempt_id.is_some(); + let login_id = uuid::Uuid::new_v4().to_string(); let (cancel_sender, mut cancel_receiver) = oneshot::channel(); - match register_pending_builderlab_login(&login, &login_id, cancel_sender, is_guarded_attempt) { - Ok(true) => {} - Ok(false) => { - server.abort(); - return Err("Builderlab authentication canceled".to_owned()); - } - Err(error) => { - server.abort(); - return Err(error); - } + if let Err(error) = register_pending_builderlab_login(&login, &login_id, cancel_sender) { + server.abort(); + return Err(error); } let login_url = match login_url(&return_to) { @@ -537,9 +478,6 @@ pub(crate) async fn start_builderlab_login( let info = BuilderlabAuthInfo { expires_at: me.expires_at.clone(), email: me.email, - username: normalized_auth_field(me.username) - .or_else(|| normalized_auth_field(exchanged.username)), - name: normalized_auth_field(me.name).or_else(|| normalized_auth_field(exchanged.name)), }; commit_builderlab_login_session(&login, &session, &login_id, exchanged.session_credential)?; Ok(info) @@ -563,8 +501,6 @@ pub(crate) async fn get_builderlab_auth( Ok(me) => Ok(Some(BuilderlabAuthInfo { expires_at: me.expires_at, email: me.email, - username: normalized_auth_field(me.username), - name: normalized_auth_field(me.name), })), Err(error) => { *session @@ -576,39 +512,20 @@ pub(crate) async fn get_builderlab_auth( } } -fn normalized_auth_field(value: Option) -> Option { - let value = value?; - let trimmed = value.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_owned()) -} - -fn cancel_builderlab_login_attempt( - login: &BuilderlabLogin, - attempt_id: Option<&str>, -) -> Result { +fn cancel_builderlab_login_attempt(login: &BuilderlabLogin) -> Result { let mut state = login.0.lock().map_err(|error| error.to_string())?; - if let Some(attempt_id) = attempt_id { - remember_canceled_attempt(&mut state, attempt_id.to_owned()); - } - let should_cancel = match (state.pending.as_ref(), attempt_id) { - (Some(login), Some(attempt_id)) => login.id == attempt_id, - (Some(_), None) => true, - (None, _) => false, + let Some(pending) = state.pending.take() else { + return Ok(false); }; - if should_cancel { - if let Some(pending) = state.pending.take() { - let _ = pending.cancel.send(()); - } - } - Ok(should_cancel) + let _ = pending.cancel.send(()); + Ok(true) } #[tauri::command] pub(crate) fn cancel_builderlab_login( login: tauri::State<'_, BuilderlabLogin>, - attempt_id: Option, ) -> Result<(), String> { - cancel_builderlab_login_attempt(&login, attempt_id.as_deref()).map(|_| ()) + cancel_builderlab_login_attempt(&login).map(|_| ()) } #[tauri::command] @@ -851,75 +768,6 @@ pub(crate) async fn transfer_builderlab_community( mod tests { use super::*; - #[test] - fn cancel_with_attempt_id_only_cancels_matching_pending_login() { - let login = BuilderlabLogin::default(); - let (cancel, mut receiver) = oneshot::channel(); - login.0.lock().unwrap().pending = Some(PendingLogin { - id: "attempt-a".to_owned(), - cancel, - }); - - assert!(!cancel_builderlab_login_attempt(&login, Some("attempt-b")).unwrap()); - assert!(login.0.lock().unwrap().pending.is_some()); - assert!(receiver.try_recv().is_err()); - - assert!(cancel_builderlab_login_attempt(&login, Some("attempt-a")).unwrap()); - assert!(login.0.lock().unwrap().pending.is_none()); - assert!(receiver.try_recv().is_ok()); - } - - #[test] - fn cancel_before_registration_tombstones_builderlab_attempt() { - let login = BuilderlabLogin::default(); - assert!(!cancel_builderlab_login_attempt(&login, Some("builderlab-login-a")).unwrap()); - - let (cancel, _receiver) = oneshot::channel(); - assert!( - !register_pending_builderlab_login(&login, "builderlab-login-a", cancel, true,) - .unwrap() - ); - assert!(login.0.lock().unwrap().pending.is_none()); - } - - #[test] - fn cancel_before_registration_does_not_block_legacy_attempts() { - let login = BuilderlabLogin::default(); - assert!(!cancel_builderlab_login_attempt(&login, Some("legacy-flow")).unwrap()); - - let (cancel, _receiver) = oneshot::channel(); - assert!(register_pending_builderlab_login(&login, "legacy-flow", cancel, false).unwrap()); - assert_eq!( - login - .0 - .lock() - .unwrap() - .pending - .as_ref() - .map(|pending| pending.id.as_str()), - Some("legacy-flow"), - ); - } - - #[test] - fn canceled_attempt_tombstones_are_bounded() { - let login = BuilderlabLogin::default(); - for index in 0..(CANCELED_LOGIN_ATTEMPT_TOMBSTONE_LIMIT + 5) { - let attempt_id = format!("builderlab-login-{index}"); - assert!(!cancel_builderlab_login_attempt(&login, Some(&attempt_id)).unwrap()); - } - - let state = login.0.lock().unwrap(); - assert_eq!( - state.canceled_attempts.len(), - CANCELED_LOGIN_ATTEMPT_TOMBSTONE_LIMIT, - ); - assert_eq!( - state.canceled_attempts.front().map(String::as_str), - Some("builderlab-login-5"), - ); - } - #[test] fn stale_attempt_cannot_commit_session_after_replacement() { let login = BuilderlabLogin::default(); @@ -996,7 +844,7 @@ mod tests { cancel, }); - assert!(cancel_builderlab_login_attempt(&login, None).unwrap()); + assert!(cancel_builderlab_login_attempt(&login).unwrap()); assert!(login.0.lock().unwrap().pending.is_none()); assert!(receiver.try_recv().is_ok()); } @@ -1028,54 +876,6 @@ mod tests { assert_eq!(login.path(), "/api/goose/v1/auth/login"); } - #[test] - fn blank_identity_profile_fields_normalize_to_none() { - assert_eq!(normalized_auth_field(Some(" ".to_owned())), None); - assert_eq!( - normalized_auth_field(Some(" seiler ".to_owned())), - Some("seiler".to_owned()) - ); - } - - #[test] - fn builderlab_auth_json_uses_username_and_name_fields() { - let exchange: LoginExchangeResponse = serde_json::from_value(serde_json::json!({ - "session_credential": "credential", - "expires_at": "2099-01-01T00:00:00Z", - "username": "seiler", - "name": "Brad Seiler", - })) - .unwrap(); - assert_eq!(exchange.username.as_deref(), Some("seiler")); - assert_eq!(exchange.name.as_deref(), Some("Brad Seiler")); - - let me: AuthMeResponse = serde_json::from_value(serde_json::json!({ - "email": "brad@example.com", - "expires_at": "2099-01-01T00:00:00Z", - "username": "seiler", - "name": "Brad Seiler", - })) - .unwrap(); - assert_eq!(me.username.as_deref(), Some("seiler")); - assert_eq!(me.name.as_deref(), Some("Brad Seiler")); - - let info = BuilderlabAuthInfo { - expires_at: "2099-01-01T00:00:00Z".to_owned(), - email: Some("brad@example.com".to_owned()), - username: Some("seiler".to_owned()), - name: Some("Brad Seiler".to_owned()), - }; - assert_eq!( - serde_json::to_value(info).unwrap(), - serde_json::json!({ - "email": "brad@example.com", - "expiresAt": "2099-01-01T00:00:00Z", - "username": "seiler", - "name": "Brad Seiler", - }) - ); - } - #[test] fn login_defaults_to_auth0_login() { let login = login_url("http://127.0.0.1:1234/callback/nonce").unwrap(); diff --git a/desktop/src-tauri/src/builderlab_api_config.rs b/desktop/src-tauri/src/builderlab_api_config.rs index 453bd202ec..b4b996bc70 100644 --- a/desktop/src-tauri/src/builderlab_api_config.rs +++ b/desktop/src-tauri/src/builderlab_api_config.rs @@ -61,10 +61,7 @@ pub(crate) fn validate_builderlab_api_base_url(raw: &str) -> Result, - _enterprise_relays: Option<&str>, -) -> Result { +pub(crate) fn resolve_builderlab_api_base_url(configured: Option<&str>) -> Result { if let Some(configured) = configured { return validate_builderlab_api_base_url(configured); } @@ -101,20 +98,11 @@ mod tests { #[test] fn configured_builderlab_api_base_url_normalizes_trailing_slash() { assert_eq!( - resolve_builderlab_api_base_url(Some(" https://login.example/api/goose/ "), None) - .unwrap(), + resolve_builderlab_api_base_url(Some(" https://login.example/api/goose/ ")).unwrap(), "https://login.example/api/goose", ); } - #[test] - fn hosted_builderlab_api_keeps_default_when_enterprise_auth_uses_separate_adapter() { - assert_eq!( - resolve_builderlab_api_base_url(None, Some("wss://buzz.block.example")).unwrap(), - DEFAULT_BUILDERLAB_API_BASE_URL, - ); - } - #[test] fn enterprise_auth_requires_configured_adapter_base_url() { let error = diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index b48227873a..e8ca09ad8b 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -106,7 +106,9 @@ pub async fn update_profile_at_relay( relay_url: String, expected_pubkey: String, expected_avatar_url: Option, - avatar_url: String, + avatar_url: Option, + display_name: Option, + name: Option, state: State<'_, AppState>, ) -> Result { let signer = capture_expected_signer(&state, &expected_pubkey)?; @@ -129,17 +131,25 @@ pub async fn update_profile_at_relay( let current: Value = prior_event .and_then(|event| serde_json::from_str::(&event.content).ok()) .unwrap_or(Value::Null); - let current_avatar_url = current - .get("picture") - .and_then(Value::as_str) - .map(str::to_string); - if normalized_avatar_url(current_avatar_url.as_deref()) - != normalized_avatar_url(expected_avatar_url.as_deref()) - { - return Err("profile avatar changed before deferred save".to_string()); + if expected_avatar_url.is_some() { + let current_avatar_url = current + .get("picture") + .and_then(Value::as_str) + .map(str::to_string); + if normalized_avatar_url(current_avatar_url.as_deref()) + != normalized_avatar_url(expected_avatar_url.as_deref()) + { + return Err("profile avatar changed before deferred save".to_string()); + } } - let builder = build_deferred_profile_event(¤t, &avatar_url, prior_event)?; + let builder = build_scoped_profile_event( + ¤t, + display_name.as_deref(), + name.as_deref(), + avatar_url.as_deref(), + prior_event, + )?; submit_event_at_with_keys(builder, &state, &api_base_url, &signer).await?; let events = query_relay_at_with_keys(&state, &api_base_url, &[filter], &signer, None).await?; @@ -150,24 +160,33 @@ pub async fn update_profile_at_relay( .unwrap_or_else(|| empty_profile_info(&expected_pubkey))) } -fn build_deferred_profile_event( +fn build_scoped_profile_event( current: &Value, - avatar_url: &str, + display_name: Option<&str>, + name: Option<&str>, + avatar_url: Option<&str>, prior_event: Option<&nostr::Event>, ) -> Result { - let display_name = current.get("display_name").and_then(Value::as_str); - let name = current.get("name").and_then(Value::as_str); + let display_name = normalized_profile_field(display_name) + .or_else(|| current.get("display_name").and_then(Value::as_str)); + let name = + normalized_profile_field(name).or_else(|| current.get("name").and_then(Value::as_str)); + let picture = normalized_profile_field(avatar_url) + .or_else(|| current.get("picture").and_then(Value::as_str)); let about = current.get("about").and_then(Value::as_str); let nip05 = current.get("nip05").and_then(Value::as_str); Ok( - events::build_profile(display_name, name, Some(avatar_url), about, nip05)? - .custom_created_at(monotonic_created_at( - prior_event.map(|event| event.created_at.as_secs() as i64), - )), + events::build_profile(display_name, name, picture, about, nip05)?.custom_created_at( + monotonic_created_at(prior_event.map(|event| event.created_at.as_secs() as i64)), + ), ) } +fn normalized_profile_field(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + fn capture_expected_signer(state: &AppState, expected_pubkey: &str) -> Result { let signer = state.signing_keys()?; if signer.public_key().to_hex() != expected_pubkey { @@ -453,9 +472,11 @@ mod tests { .sign_with_keys(&keys) .expect("sign prior profile"); - let builder = build_deferred_profile_event( + let builder = build_scoped_profile_event( &serde_json::json!({"display_name": "Larry"}), - "https://example.com/avatar.png", + None, + None, + Some("https://example.com/avatar.png"), Some(&prior_event), ) .expect("build deferred profile"); diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 374864f0a0..5efe8cc4e3 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -68,7 +68,7 @@ import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { hydrateChannelHeads } from "@/features/messages/lib/channelHeadCache"; import { useIdentityQuery } from "@/shared/api/hooks"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; -import { getProfile, updateProfile } from "@/shared/api/tauriProfiles"; +import { getProfile, updateProfileAtRelay } from "@/shared/api/tauriProfiles"; import { type AddCommunityDeepLinkPayload, listenForDeepLinks, @@ -550,6 +550,21 @@ function CommunityApp({ const enterpriseProfile = community.isReady ? community.enterpriseProfile : null; + const identityPubkey = community.isReady ? community.identityPubkey : null; + useEffect(() => { + if ( + !transaction || + transaction.stage !== "connecting" || + !("error" in community) || + !community.error + ) { + return; + } + communityOnboarding.update( + { stage: "corporate-profile", error: community.error }, + transaction.id, + ); + }, [community, communityOnboarding, transaction]); useEffect(() => { if (transaction?.stage !== "connecting" || !targetIsReady) return; const transactionId = transaction.id; @@ -558,7 +573,24 @@ function CommunityApp({ profileCheckTransactionRef.current = transactionId; if (enterpriseProfile) { - void updateProfile({ + if (!identityPubkey) { + communityOnboarding.update( + { + stage: "corporate-profile", + error: + "Could not save your corporate profile because the active identity is unavailable", + }, + transactionId, + ); + return; + } + communityOnboarding.update( + { stage: "corporate-profile", error: undefined }, + transactionId, + ); + void updateProfileAtRelay({ + relayUrl, + expectedPubkey: identityPubkey, displayName: enterpriseProfile.displayName, name: enterpriseProfile.username, }) @@ -619,6 +651,7 @@ function CommunityApp({ transaction?.id, transaction?.relayUrl, enterpriseProfile, + identityPubkey, ]); // During "entering" the transaction stays alive as a curtain: the app mounts // underneath (already pointed at the Welcome channel route) while the diff --git a/desktop/src/features/communities/hostedCommunityApi.ts b/desktop/src/features/communities/hostedCommunityApi.ts index 2b61e1fb0f..a3d0733183 100644 --- a/desktop/src/features/communities/hostedCommunityApi.ts +++ b/desktop/src/features/communities/hostedCommunityApi.ts @@ -8,8 +8,6 @@ export const VALID_HOSTED_COMMUNITY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export type BuilderlabAuth = { email?: string; - username?: string | null; - name?: string | null; expiresAt: string; }; @@ -146,20 +144,16 @@ export function getBuilderlabAuth() { return invoke("get_builderlab_auth"); } -export type BuilderlabLoginAttempt = { - attemptId: string; -}; - -export function cancelBuilderlabLogin(options?: BuilderlabLoginAttempt) { - return invoke("cancel_builderlab_login", options); +export function cancelBuilderlabLogin() { + return invoke("cancel_builderlab_login"); } export function clearBuilderlabAuth() { return invoke("clear_builderlab_auth"); } -export function startBuilderlabLogin(options?: BuilderlabLoginAttempt) { - return invoke("start_builderlab_login", options); +export function startBuilderlabLogin() { + return invoke("start_builderlab_login"); } export async function loadHostedCommunityAccount(): Promise { diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index bd4d16b106..990915d361 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -164,7 +164,6 @@ export function useCommunityInit( const enterpriseLoginDecisionRef = useRef< ((allowed: boolean) => void) | null >(null); - const enterpriseProfileRef = useRef(null); const continueEnterpriseLogin = useCallback(() => { const resolve = enterpriseLoginDecisionRef.current; @@ -224,7 +223,6 @@ export function useCommunityInit( async function init() { if (!activeCommunity) { - enterpriseProfileRef.current = null; if (hasInitializedRef.current) { if (prevCommunityIdRef.current) { saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current); @@ -373,7 +371,7 @@ export function useCommunityInit( return; } } - enterpriseProfileRef.current = null; + let enterpriseProfileForResult: EnterpriseProfileSeed | null = null; const enterpriseLoginAttemptId = `enterprise-auth-${ globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` }`; @@ -397,7 +395,7 @@ export function useCommunityInit( }, }, ); - enterpriseProfileRef.current = + enterpriseProfileForResult = authoritativeEnterpriseProfile(enterpriseAuth); ownedEnterpriseLoginAttemptId = null; } catch (error) { @@ -406,11 +404,11 @@ export function useCommunityInit( error instanceof Error ? error.message : "Enterprise login is required for this community"; - setEnterpriseLoginPrompt((prompt) => - prompt ? { ...prompt, error: errorMessage } : prompt, - ); console.error("Enterprise login gate failed:", error); if (!cancelled) { + setEnterpriseLoginPrompt((prompt) => + prompt ? { ...prompt, error: errorMessage } : prompt, + ); setResult({ isReady: false, needsSetup: false, @@ -504,7 +502,7 @@ export function useCommunityInit( needsSetup: false, appliedKey: communityKey, identityPubkey, - enterpriseProfile: enterpriseProfileRef.current, + enterpriseProfile: enterpriseProfileForResult, }); } } diff --git a/desktop/src/shared/api/tauriProfiles.ts b/desktop/src/shared/api/tauriProfiles.ts index c8e52f5169..eea953d05d 100644 --- a/desktop/src/shared/api/tauriProfiles.ts +++ b/desktop/src/shared/api/tauriProfiles.ts @@ -86,8 +86,10 @@ export async function updateProfile( export async function updateProfileAtRelay(input: { relayUrl: string; expectedPubkey: string; - expectedAvatarUrl: string | null; - avatarUrl: string; + expectedAvatarUrl?: string | null; + avatarUrl?: string | null; + displayName?: string | null; + name?: string | null; }): Promise { const profile = await invokeTauri( "update_profile_at_relay", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f192119466..4c39bd5c24 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12974,13 +12974,21 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); - case "update_profile_at_relay": + case "update_profile_at_relay": { + const input = payload as { + avatarUrl?: string | null; + displayName?: string | null; + name?: string | null; + }; return handleUpdateProfile( { - avatarUrl: (payload as { avatarUrl: string }).avatarUrl, + avatarUrl: input.avatarUrl ?? undefined, + displayName: input.displayName ?? undefined, + name: input.name ?? undefined, }, activeConfig, ); + } case "get_user_profile": return handleGetUserProfile( (payload as Parameters[0]) ?? {}, diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index bdc761a960..5e961fbf35 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1729,13 +1729,23 @@ test("authoritative corporate profile save failure stays non-editable and retrie .poll(() => page.evaluate(() => (window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []) - .filter(({ command }) => command === "update_profile") + .filter(({ command }) => command === "update_profile_at_relay") .map(({ payload }) => payload), ), ) .toEqual([ - { displayName: "Brad Seiler", name: "seiler" }, - { displayName: "Brad Seiler", name: "seiler" }, + { + relayUrl, + expectedPubkey: BLANK_TYLER_IDENTITY.pubkey, + displayName: "Brad Seiler", + name: "seiler", + }, + { + relayUrl, + expectedPubkey: BLANK_TYLER_IDENTITY.pubkey, + displayName: "Brad Seiler", + name: "seiler", + }, ]); }); diff --git a/docs/enterprise-identity-adapter.md b/docs/enterprise-identity-adapter.md index 3379b11986..b31512b231 100644 --- a/docs/enterprise-identity-adapter.md +++ b/docs/enterprise-identity-adapter.md @@ -40,12 +40,20 @@ GET {adapter_base}/v1/login/start?return_to={callback_url}&handoff_challenge={ba ``` The adapter authenticates the user however the operator chooses, binds the -completed browser login to `handoff_challenge`, and redirects to: +completed browser login to `handoff_challenge`, and redirects to the exact +`return_to` loopback callback. Desktop always supplies +`http://127.0.0.1:{ephemeral_port}/callback/{nonce}`; adapters should accept +loopback callbacks on any port as recommended by RFC 8252 ยง7.3 and reject +non-loopback callback hosts. Success redirects to: ```text {callback_url}?code={single_use_code} ``` +Failures may redirect to the same callback with `error` and optional +`error_description` query parameters. Desktop surfaces `error_description` when +present, otherwise `error`. + The code alone is not a credential. The adapter MUST accept it only when the exchange presents the matching handoff secret. Browser navigation may follow the adapter's identity-provider redirects; the non-browser adapter calls below must @@ -83,7 +91,10 @@ Success response: `email` and `profile_projection` are optional. `profile_projection` is ignored by Desktop unless `BUZZ_BUILD_ENTERPRISE_PROFILE_PROJECTION` opts into publishing -those fields as the user's public Buzz profile. +those fields as the user's public Buzz profile. The exchange `expires_at` and a +later session-check `expires_at` must describe the same fixed adapter session; +Desktop rejects mismatched values during login so a code exchange cannot commit a +different session than the one verified by `/v1/session`. ## Session check