mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
Address enterprise login review feedback
Signed-off-by: coder 0 <d97ebdbb198c7237c94f84ea8bb8a73583ea067407eebd0062abbb3962527fb1@buzz.block.builderlab.xyz> Signed-off-by: bb-expert <51cde9442e46eac81c83ec71552708c3b0cb96a88ff8e1581d8fb4de281afbf3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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<BuilderlabLoginState>);
|
||||
#[derive(Default)]
|
||||
struct BuilderlabLoginState {
|
||||
pending: Option<PendingLogin>,
|
||||
canceled_attempts: VecDeque<String>,
|
||||
}
|
||||
|
||||
struct PendingLogin {
|
||||
@@ -168,8 +161,6 @@ struct StoredSession {
|
||||
struct LoginExchangeResponse {
|
||||
session_credential: String,
|
||||
expires_at: String,
|
||||
username: Option<String>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -177,16 +168,12 @@ struct LoginExchangeResponse {
|
||||
pub(crate) struct BuilderlabAuthInfo {
|
||||
expires_at: String,
|
||||
email: Option<String>,
|
||||
username: Option<String>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthMeResponse {
|
||||
email: Option<String>,
|
||||
expires_at: String,
|
||||
username: Option<String>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
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<bool, String> {
|
||||
) -> 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<String>,
|
||||
) -> Result<BuilderlabAuthInfo, String> {
|
||||
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<String>) -> Option<String> {
|
||||
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<bool, String> {
|
||||
fn cancel_builderlab_login_attempt(login: &BuilderlabLogin) -> Result<bool, String> {
|
||||
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<String>,
|
||||
) -> 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();
|
||||
|
||||
@@ -61,10 +61,7 @@ pub(crate) fn validate_builderlab_api_base_url(raw: &str) -> Result<String, Stri
|
||||
validate_api_base_url(raw, ApiBaseUrlKind::Builderlab)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_builderlab_api_base_url(
|
||||
configured: Option<&str>,
|
||||
_enterprise_relays: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
pub(crate) fn resolve_builderlab_api_base_url(configured: Option<&str>) -> Result<String, String> {
|
||||
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 =
|
||||
|
||||
@@ -106,7 +106,9 @@ pub async fn update_profile_at_relay(
|
||||
relay_url: String,
|
||||
expected_pubkey: String,
|
||||
expected_avatar_url: Option<String>,
|
||||
avatar_url: String,
|
||||
avatar_url: Option<String>,
|
||||
display_name: Option<String>,
|
||||
name: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ProfileInfo, String> {
|
||||
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::<Value>(&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<nostr::EventBuilder, String> {
|
||||
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<nostr::Keys, String> {
|
||||
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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<BuilderlabAuth | null>("get_builderlab_auth");
|
||||
}
|
||||
|
||||
export type BuilderlabLoginAttempt = {
|
||||
attemptId: string;
|
||||
};
|
||||
|
||||
export function cancelBuilderlabLogin(options?: BuilderlabLoginAttempt) {
|
||||
return invoke<void>("cancel_builderlab_login", options);
|
||||
export function cancelBuilderlabLogin() {
|
||||
return invoke<void>("cancel_builderlab_login");
|
||||
}
|
||||
|
||||
export function clearBuilderlabAuth() {
|
||||
return invoke<void>("clear_builderlab_auth");
|
||||
}
|
||||
|
||||
export function startBuilderlabLogin(options?: BuilderlabLoginAttempt) {
|
||||
return invoke<BuilderlabAuth>("start_builderlab_login", options);
|
||||
export function startBuilderlabLogin() {
|
||||
return invoke<BuilderlabAuth>("start_builderlab_login");
|
||||
}
|
||||
|
||||
export async function loadHostedCommunityAccount(): Promise<HostedCommunityAccount> {
|
||||
|
||||
@@ -164,7 +164,6 @@ export function useCommunityInit(
|
||||
const enterpriseLoginDecisionRef = useRef<
|
||||
((allowed: boolean) => void) | null
|
||||
>(null);
|
||||
const enterpriseProfileRef = useRef<EnterpriseProfileSeed | null>(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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Profile> {
|
||||
const profile = await invokeTauri<RawProfile>(
|
||||
"update_profile_at_relay",
|
||||
|
||||
@@ -12974,13 +12974,21 @@ export function maybeInstallE2eTauriMocks() {
|
||||
payload as Parameters<typeof handleUpdateProfile>[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<typeof handleGetUserProfile>[0]) ?? {},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user