diff --git a/apps/fabro-web/app/routes/auth-login.tsx b/apps/fabro-web/app/routes/auth-login.tsx index 69ec23b57..be4d4d3c4 100644 --- a/apps/fabro-web/app/routes/auth-login.tsx +++ b/apps/fabro-web/app/routes/auth-login.tsx @@ -113,8 +113,7 @@ function DevTokenForm({ {showLocation ? (

- Paste the dev token from your terminal or{" "} - cat ~/.fabro/dev-token. + Paste the dev token from your server terminal or install output.

) : null} diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 54d651231..756e8757f 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -1452,6 +1452,10 @@ pub(crate) struct AuthLoginArgs { #[command(flatten)] pub(crate) server: ServerTargetArgs, + /// Log in with a dev-token instead of browser OAuth + #[arg(long, conflicts_with_all = ["no_browser", "timeout"])] + pub(crate) dev_token: Option, + /// Print the browser URL instead of opening it automatically #[arg(long)] pub(crate) no_browser: bool, diff --git a/lib/crates/fabro-cli/src/commands/auth/login.rs b/lib/crates/fabro-cli/src/commands/auth/login.rs index af2b68010..9c582fdd1 100644 --- a/lib/crates/fabro-cli/src/commands/auth/login.rs +++ b/lib/crates/fabro-cli/src/commands/auth/login.rs @@ -2,9 +2,10 @@ use std::time::Duration; use anyhow::{Context as _, Result, bail}; use chrono::{DateTime, Utc}; -use fabro_client::{AuthEntry, AuthStore, StoredSubject}; +use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, OAuthEntry, StoredSubject}; use fabro_http::header::CONTENT_TYPE; use fabro_util::browser; +use fabro_util::dev_token::validate_dev_token_format; use fabro_util::printer::Printer; use serde::Deserialize; use tokio::time::timeout; @@ -36,6 +37,22 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext base_ctx.require_no_json_override()?; let printer = base_ctx.printer(); + if let Some(token) = args.dev_token.as_ref() { + if !validate_dev_token_format(token) { + bail!("invalid dev-token format"); + } + let target = user_config::resolve_server_target(&args.server, base_ctx.user_settings())?; + AuthStore::default().put( + &target, + AuthEntry::DevToken(DevTokenEntry { + token: token.clone(), + logged_in_at: Utc::now(), + }), + )?; + fabro_util::printerr!(printer, "Logged in to {} with dev-token", target); + return Ok(()); + } + #[cfg(not(unix))] { let _ = (args, printer); @@ -80,7 +97,7 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext }; let tokens = exchange_cli_token(&target, &code, &pkce.verifier, &redirect_uri).await?; - let entry = AuthEntry { + let entry = OAuthEntry { access_token: tokens.access_token, access_token_expires_at: tokens.access_token_expires_at, refresh_token: tokens.refresh_token, @@ -95,7 +112,7 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext logged_in_at: Utc::now(), }; let summary = identity_summary(&entry.subject); - AuthStore::default().put(&target, entry)?; + AuthStore::default().put(&target, AuthEntry::OAuth(entry))?; fabro_util::printerr!(printer, "Logged in to {} as {}", target, summary); Ok(()) } @@ -199,7 +216,7 @@ fn login_failure_message(error_code: &str, error_description: Option<&str>) -> S "GitHub session required. Complete sign-in in the browser and try again.".to_string() } "github_not_configured" => { - "The fabro server does not have GitHub login enabled. Ask the operator to enable it or use a dev-token.".to_string() + "This server uses dev-token auth. Copy the token from the server and run: `fabro auth login --dev-token `".to_string() } "access_denied" => "Authorization denied.".to_string(), "unauthorized" => "Login not permitted.".to_string(), @@ -280,7 +297,7 @@ mod tests { "github_not_configured", Some("GitHub authentication is not enabled on this server") ), - "The fabro server does not have GitHub login enabled. Ask the operator to enable it or use a dev-token." + "This server uses dev-token auth. Copy the token from the server and run: `fabro auth login --dev-token `" ); assert_eq!( login_failure_message("server_error", Some("SESSION_SECRET is not configured")), diff --git a/lib/crates/fabro-cli/src/commands/auth/logout.rs b/lib/crates/fabro-cli/src/commands/auth/logout.rs index 4ccdfd270..52009485e 100644 --- a/lib/crates/fabro-cli/src/commands/auth/logout.rs +++ b/lib/crates/fabro-cli/src/commands/auth/logout.rs @@ -1,5 +1,5 @@ use anyhow::{Result, bail}; -use fabro_client::{AuthEntry, AuthStore}; +use fabro_client::{AuthEntry, AuthStore, OAuthEntry}; use fabro_http::header::AUTHORIZATION; use crate::args::AuthLogoutArgs; @@ -21,8 +21,10 @@ pub(super) async fn logout_command(args: AuthLogoutArgs, base_ctx: &CommandConte let mut warnings = Vec::new(); for (target, entry) in entries { - if let Err(error) = revoke_remote_session(&target, &entry).await { - warnings.push(format_warning(&target, &error.to_string())); + if let AuthEntry::OAuth(entry) = &entry { + if let Err(error) = revoke_remote_session(&target, entry).await { + warnings.push(format_warning(&target, &error.to_string())); + } } store.remove(&target)?; } @@ -40,15 +42,17 @@ pub(super) async fn logout_command(args: AuthLogoutArgs, base_ctx: &CommandConte return Ok(()); }; - if let Err(error) = revoke_remote_session(&target, &entry).await { - fabro_util::printerr!(printer, "{}", format_warning(&target, &error.to_string())); + if let AuthEntry::OAuth(entry) = &entry { + if let Err(error) = revoke_remote_session(&target, entry).await { + fabro_util::printerr!(printer, "{}", format_warning(&target, &error.to_string())); + } } store.remove(&target)?; fabro_util::printerr!(printer, "Logged out from {}.", target); Ok(()) } -async fn revoke_remote_session(target: &ServerTarget, entry: &AuthEntry) -> Result<()> { +async fn revoke_remote_session(target: &ServerTarget, entry: &OAuthEntry) -> Result<()> { let (http_client, base_url) = target.build_public_http_client()?; let response = http_client .post(format!("{base_url}/auth/cli/logout")) diff --git a/lib/crates/fabro-cli/src/commands/auth/status.rs b/lib/crates/fabro-cli/src/commands/auth/status.rs index 692c1f381..f5bf3272f 100644 --- a/lib/crates/fabro-cli/src/commands/auth/status.rs +++ b/lib/crates/fabro-cli/src/commands/auth/status.rs @@ -1,8 +1,9 @@ use anyhow::Result; use chrono::{DateTime, Utc}; -use fabro_client::{AuthEntry, AuthStore}; +use fabro_client::{AuthEntry, AuthStore, OAuthEntry}; use fabro_static::EnvVars; -use fabro_util::dev_token::{read_dev_token_file, validate_dev_token_format}; +use fabro_util::dev_token::validate_dev_token_format; +use fabro_util::printer::Printer; use serde::Serialize; use crate::args::AuthStatusArgs; @@ -20,23 +21,32 @@ enum OAuthState { } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct StatusRow { - server: String, - oauth_state: OAuthState, - access_token_expires_at: DateTime, - refresh_token_expires_at: DateTime, - logged_in_at: DateTime, - login: String, - name: String, - email: String, - idp_issuer: String, - idp_subject: String, +#[serde(tag = "kind")] +enum StatusRow { + #[serde(rename = "oauth")] + OAuth { + server: String, + oauth_state: OAuthState, + access_token_expires_at: DateTime, + refresh_token_expires_at: DateTime, + logged_in_at: DateTime, + login: String, + name: String, + email: String, + idp_issuer: String, + idp_subject: String, + }, + #[serde(rename = "dev-token")] + DevToken { + server: String, + logged_in_at: DateTime, + }, } #[derive(Serialize)] struct StatusOutput { - servers: Vec, - dev_token: &'static str, + servers: Vec, + env_dev_token: &'static str, } pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Result<()> { @@ -49,7 +59,7 @@ pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Res } else { all_rows(&store, now)? }; - let dev_token = if load_dev_token_if_available() { + let env_dev_token = if load_env_dev_token_if_available() { "active" } else { "not_set" @@ -58,14 +68,14 @@ pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Res if ctx.explicit_json_requested() { print_json_pretty(&StatusOutput { servers: rows, - dev_token, + env_dev_token, })?; return Ok(()); } if rows.is_empty() { fabro_util::printerr!(printer, "Not logged in to any servers."); - fabro_util::printerr!(printer, "Dev token: {dev_token}"); + print_env_dev_token_status(env_dev_token, printer); return Ok(()); } @@ -73,44 +83,65 @@ pub(super) fn status_command(args: &AuthStatusArgs, ctx: &CommandContext) -> Res if index > 0 { fabro_util::printerr!(printer, ""); } - fabro_util::printerr!(printer, "{}", row.server); - fabro_util::printerr!( - printer, - " OAuth: {} as {}", - human_state(row.oauth_state), - row.login - ); - fabro_util::printerr!( - printer, - " Name: {}", - if row.name.is_empty() { - "(not set)" - } else { - row.name.as_str() + match row { + StatusRow::OAuth { + server, + oauth_state, + access_token_expires_at, + refresh_token_expires_at, + login, + name, + email, + .. + } => { + fabro_util::printerr!(printer, "{server}"); + fabro_util::printerr!( + printer, + " OAuth: {} as {}", + human_state(*oauth_state), + login + ); + fabro_util::printerr!( + printer, + " Name: {}", + if name.is_empty() { + "(not set)" + } else { + name.as_str() + } + ); + fabro_util::printerr!( + printer, + " Email: {}", + if email.is_empty() { + "(not set)" + } else { + email.as_str() + } + ); + fabro_util::printerr!( + printer, + " Access expires: {}", + access_token_expires_at.to_rfc3339() + ); + fabro_util::printerr!( + printer, + " Refresh expires: {}", + refresh_token_expires_at.to_rfc3339() + ); } - ); - fabro_util::printerr!( - printer, - " Email: {}", - if row.email.is_empty() { - "(not set)" - } else { - row.email.as_str() + StatusRow::DevToken { + server, + logged_in_at, + } => { + fabro_util::printerr!(printer, "{server}"); + fabro_util::printerr!(printer, " Auth: dev-token"); + fabro_util::printerr!(printer, " Logged in: {}", logged_in_at.to_rfc3339()); } - ); - fabro_util::printerr!( - printer, - " Access expires: {}", - row.access_token_expires_at.to_rfc3339() - ); - fabro_util::printerr!( - printer, - " Refresh expires: {}", - row.refresh_token_expires_at.to_rfc3339() - ); + } } fabro_util::printerr!(printer, ""); - fabro_util::printerr!(printer, "Dev token: {dev_token}"); + print_env_dev_token_status(env_dev_token, printer); Ok(()) } @@ -135,21 +166,27 @@ fn filter_rows( } fn status_row(target: &ServerTarget, entry: AuthEntry, now: DateTime) -> StatusRow { - StatusRow { - server: target.to_string(), - oauth_state: oauth_state(&entry, now), - access_token_expires_at: entry.access_token_expires_at, - refresh_token_expires_at: entry.refresh_token_expires_at, - logged_in_at: entry.logged_in_at, - login: entry.subject.login, - name: entry.subject.name, - email: entry.subject.email, - idp_issuer: entry.subject.idp_issuer, - idp_subject: entry.subject.idp_subject, + match entry { + AuthEntry::OAuth(entry) => StatusRow::OAuth { + server: target.to_string(), + oauth_state: oauth_state(&entry, now), + access_token_expires_at: entry.access_token_expires_at, + refresh_token_expires_at: entry.refresh_token_expires_at, + logged_in_at: entry.logged_in_at, + login: entry.subject.login, + name: entry.subject.name, + email: entry.subject.email, + idp_issuer: entry.subject.idp_issuer, + idp_subject: entry.subject.idp_subject, + }, + AuthEntry::DevToken(entry) => StatusRow::DevToken { + server: target.to_string(), + logged_in_at: entry.logged_in_at, + }, } } -fn oauth_state(entry: &AuthEntry, now: DateTime) -> OAuthState { +fn oauth_state(entry: &OAuthEntry, now: DateTime) -> OAuthState { if entry.access_token_expires_at > now { OAuthState::Active } else if entry.refresh_token_expires_at > now { @@ -159,6 +196,17 @@ fn oauth_state(entry: &AuthEntry, now: DateTime) -> OAuthState { } } +fn print_env_dev_token_status(env_dev_token: &str, printer: Printer) { + if env_dev_token == "active" { + fabro_util::printerr!( + printer, + "FABRO_DEV_TOKEN: active (overrides persisted credentials)" + ); + } else { + fabro_util::printerr!(printer, "FABRO_DEV_TOKEN: not_set"); + } +} + fn human_state(state: OAuthState) -> &'static str { match state { OAuthState::Active => "active", @@ -171,24 +219,23 @@ fn human_state(state: OAuthState) -> &'static str { clippy::disallowed_methods, reason = "Auth status reports whether the documented dev-token env source is configured." )] -fn load_dev_token_if_available() -> bool { +fn load_env_dev_token_if_available() -> bool { let env_token = std::env::var(EnvVars::FABRO_DEV_TOKEN) .ok() .filter(|token| validate_dev_token_format(token)); env_token.is_some() - || read_dev_token_file(&fabro_util::Home::from_env().dev_token_path()).is_some() } #[cfg(test)] mod tests { use chrono::Duration; - use fabro_client::{AuthEntry, StoredSubject}; + use fabro_client::{OAuthEntry, StoredSubject}; use super::{OAuthState, human_state, oauth_state}; - fn entry(access_offset_secs: i64, refresh_offset_secs: i64) -> AuthEntry { + fn entry(access_offset_secs: i64, refresh_offset_secs: i64) -> OAuthEntry { let now = chrono::Utc::now(); - AuthEntry { + OAuthEntry { access_token: "access".to_string(), access_token_expires_at: now + Duration::seconds(access_offset_secs), refresh_token: "refresh".to_string(), diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 5e3ac7915..1930812ba 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -21,6 +21,7 @@ use dialoguer::theme::ColorfulTheme; use dialoguer::{MultiSelect, Select}; use fabro_api::types::{CreateSecretRequest, SecretType as ApiSecretType}; use fabro_auth::{AuthCredential, AuthMethod, codex_oauth_config, credential_id_for}; +use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget}; use fabro_config::bind::Bind; use fabro_config::daemon::ServerDaemon; use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir}; @@ -1773,6 +1774,7 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( let install_server_settings = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml)?; // Secrets and auth material + let mut dev_token_for_auth_store = None; { let session_secret = session_secret::generate_session_secret(); fabro_util::printerr!( @@ -1787,15 +1789,11 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( .methods .contains(&ServerAuthMethod::DevToken) { - let token = dev_token::read_or_mint_dev_token_for_install( - &fabro_util::Home::from_env().dev_token_path(), - )?; - dev_token::write_dev_token( - &Storage::new(&storage_dir) - .runtime_directory() - .dev_token_path(), - &token, - )?; + let dev_token_path = Storage::new(&storage_dir) + .runtime_directory() + .dev_token_path(); + let token = dev_token::read_or_mint_dev_token_for_install(&dev_token_path)?; + dev_token_for_auth_store = Some(token.clone()); fabro_util::printerr!( printer, " {} Development token generated", @@ -1825,6 +1823,22 @@ async fn run_install_inner(args: &InstallArgs, ctx: &CommandContext) -> Result<( server_was_running, ) .await?; + if let Some(token) = dev_token_for_auth_store { + let target = ServerTarget::http_url(&args.web_url)?; + if let Err(err) = AuthStore::default().put( + &target, + AuthEntry::DevToken(DevTokenEntry { + token, + logged_in_at: chrono::Utc::now(), + }), + ) { + fabro_util::printerr!( + printer, + " {} Installed successfully, but failed to save CLI auth: {err}", + s.yellow.apply_to("Warning:") + ); + } + } if let Err(err) = write_artifact_store_metadata(&install_server_settings, FABRO_VERSION).await { fabro_util::printerr!( printer, diff --git a/lib/crates/fabro-cli/src/commands/server/mod.rs b/lib/crates/fabro-cli/src/commands/server/mod.rs index 9572643c6..687010146 100644 --- a/lib/crates/fabro-cli/src/commands/server/mod.rs +++ b/lib/crates/fabro-cli/src/commands/server/mod.rs @@ -3,14 +3,16 @@ pub(crate) mod start; pub(crate) mod status; pub(crate) mod stop; +use std::sync::Arc; use std::time::Duration; use anyhow::Result; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use fabro_client::{AuthEntry, AuthStore, DevTokenEntry, ServerTarget}; use fabro_config::bind::{self, Bind, BindRequest}; use fabro_config::user::{active_settings_path, default_storage_dir}; -use fabro_server::install::{self, InstallAppState}; +use fabro_server::install::{self, InstallAppState, InstallFinishHook, InstallFinishInfo}; use fabro_server::serve::{self, ServeArgs}; use fabro_static::EnvVars; use fabro_util::browser; @@ -207,7 +209,8 @@ async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Resu bootstrap.token, &bootstrap.storage_dir, &bootstrap.config_path, - ); + ) + .with_finish_hook(persist_install_dev_token_hook()); install::serve_install_command(bootstrap.bind_request, state, move |bind| { announce_install_mode(bind, &token, styles, printer); Ok(()) @@ -215,6 +218,23 @@ async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Resu .await } +fn persist_install_dev_token_hook() -> InstallFinishHook { + Arc::new(|info: &InstallFinishInfo| { + let Some(token) = &info.dev_token else { + return Ok(()); + }; + let target = ServerTarget::http_url(&info.canonical_url)?; + AuthStore::default().put( + &target, + AuthEntry::DevToken(DevTokenEntry { + token: token.clone(), + logged_in_at: chrono::Utc::now(), + }), + )?; + Ok(()) + }) +} + fn announce_install_mode(bind: &Bind, token: &str, styles: &Styles, printer: Printer) { info!( bind = %bind, diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index b8995681c..eb3dfe725 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -1,45 +1,32 @@ use std::net::IpAddr; use std::path::Path; -use std::sync::Arc; use std::time::Duration; use anyhow::{Context as _, Result, anyhow, bail}; use fabro_client::{ - AuthStore, Credential, CredentialFallback, OAuthSession, ServerTarget, TransportConnector, + AuthEntry, AuthStore, Credential, OAuthSession, ServerTarget, TransportConnector, apply_bearer_token_auth, }; pub(crate) use fabro_client::{Client, RunEventStream}; +use fabro_config::Storage; use fabro_config::bind::Bind; use fabro_static::EnvVars; pub(crate) use fabro_types::RunProjection; use fabro_types::UserSettings; +use fabro_util::dev_token; use fabro_util::dev_token::validate_dev_token_format; -use fabro_util::{Home, dev_token}; use tokio::time::sleep; use crate::args::ServerTargetArgs; use crate::commands::server::start; use crate::user_config::{self, cli_http_client_builder}; -#[derive(Debug)] -struct CliDevTokenFallback; - -impl CredentialFallback for CliDevTokenFallback { - fn resolve(&self) -> Option { - load_cli_dev_token().map(Credential::DevToken) - } -} - fn refreshable_oauth( target: &ServerTarget, credential: Option<&Credential>, ) -> Option { if matches!(credential, Some(Credential::OAuth(_))) { - let session = OAuthSession::new(target.clone(), AuthStore::default()); - if local_dev_token_fallback(target) { - return Some(session.with_fallback(Arc::new(CliDevTokenFallback))); - } - return Some(session); + return Some(OAuthSession::new(target.clone(), AuthStore::default())); } None } @@ -92,22 +79,34 @@ async fn connect_managed_unix_socket_api_client_bundle( active_config_path: &Path, ) -> Result { let target = ServerTarget::unix_socket_path(path)?; - let credential = resolve_target_credential(&target, local_dev_token_fallback(&target))?; - let oauth_session = refreshable_oauth(&target, credential.as_ref()); - let bearer_token = credential.as_ref().map(Credential::bearer_token); + let runtime_token_path = Storage::new(storage_dir) + .runtime_directory() + .dev_token_path(); + let pre_spawn_credential = resolve_target_credential(&target)? + .or_else(|| dev_token::read_dev_token_file(&runtime_token_path).map(Credential::DevToken)); + let pre_spawn_bearer = pre_spawn_credential.as_ref().map(Credential::bearer_token); - let http_client = if let Ok(http_client) = - try_connect_unix_socket_http_client(path, true, bearer_token).await + let (http_client, credential) = if let Ok(http_client) = + try_connect_unix_socket_http_client(path, pre_spawn_bearer).await { - http_client + (http_client, pre_spawn_credential) } else { start::ensure_server_running_on_socket(path, active_config_path, storage_dir) .await .with_context(|| format!("Failed to start fabro server for {}", path.display()))?; - connect_unix_socket_http_client(path, true, bearer_token) + let post_spawn_credential = match resolve_target_credential(&target)? { + Some(credential) => Some(credential), + None => Some(Credential::DevToken( + wait_for_runtime_dev_token(&runtime_token_path).await?, + )), + }; + let post_spawn_bearer = post_spawn_credential.as_ref().map(Credential::bearer_token); + let http_client = connect_unix_socket_http_client(path, post_spawn_bearer) .await - .with_context(|| format!("Failed to connect to fabro server at {}", path.display()))? + .with_context(|| format!("Failed to connect to fabro server at {}", path.display()))?; + (http_client, post_spawn_credential) }; + let oauth_session = refreshable_oauth(&target, credential.as_ref()); build_client( target, @@ -127,12 +126,16 @@ async fn connect_local_api_client_bundle( .with_context(|| format!("Failed to start fabro server for {}", storage_dir.display()))?; match bind { Bind::Unix(path) => { - let http_client = connect_unix_socket_http_client(&path, true, None).await?; + let runtime_token_path = Storage::new(storage_dir) + .runtime_directory() + .dev_token_path(); + let token = wait_for_runtime_dev_token(&runtime_token_path).await?; + let http_client = connect_unix_socket_http_client(&path, Some(&token)).await?; Ok(Client::from_http_client("http://fabro", http_client)) } Bind::Tcp(addr) => { let target = ServerTarget::http_url(format!("http://{addr}"))?; - let credential = resolve_local_tcp_credential(&target)?; + let credential = resolve_target_credential(&target)?; let oauth_session = refreshable_oauth(&target, credential.as_ref()); build_client(target, credential, oauth_session, None).await } @@ -140,7 +143,7 @@ async fn connect_local_api_client_bundle( } async fn connect_target_api_client_bundle(target: &ServerTarget) -> Result { - let credential = resolve_target_credential(target, local_dev_token_fallback(target))?; + let credential = resolve_target_credential(target)?; let oauth_session = refreshable_oauth(target, credential.as_ref()); build_client(target.clone(), credential, oauth_session, None).await } @@ -204,15 +207,6 @@ fn connect_cli_target_transport( Ok((http_client, "http://fabro".to_string())) } -fn local_dev_token_fallback(target: &ServerTarget) -> bool { - target.is_unix_socket() -} - -fn load_cli_dev_token() -> Option { - let env_token = process_env_var(EnvVars::FABRO_DEV_TOKEN); - load_cli_dev_token_from_sources(env_token.as_deref(), &Home::from_env()) -} - #[expect( clippy::disallowed_methods, reason = "Server client authentication supports the documented local dev-token env source." @@ -221,38 +215,29 @@ fn process_env_var(name: &str) -> Option { std::env::var(name).ok() } -fn load_cli_dev_token_from_sources(env_token: Option<&str>, home: &Home) -> Option { - if let Some(token) = env_token.filter(|token| validate_dev_token_format(token)) { - return Some(token.to_owned()); - } - - dev_token::read_dev_token_file(&home.dev_token_path()) -} - -async fn wait_for_cli_dev_token() -> Result { +async fn wait_for_runtime_dev_token(path: &Path) -> Result { let deadline = std::time::Instant::now() + Duration::from_secs(5); while std::time::Instant::now() < deadline { - if let Some(token) = load_cli_dev_token() { + if let Some(token) = dev_token::read_dev_token_file(path) { return Ok(token); } sleep(Duration::from_millis(50)).await; } - bail!("local CLI dev token did not become available"); + bail!( + "runtime dev token did not become available at {}", + path.display() + ); } -async fn build_authed_unix_socket_http_client( +fn build_authed_unix_socket_http_client( path: &Path, - wait_for_cli_dev_token_fallback: bool, bearer_token: Option<&str>, ) -> Result { let builder = cli_http_client_builder().unix_socket(path).no_proxy(); let builder = if let Some(token) = bearer_token { apply_bearer_token_auth(builder, token)? - } else if wait_for_cli_dev_token_fallback { - let token = wait_for_cli_dev_token().await?; - apply_bearer_token_auth(builder, &token)? } else { builder }; @@ -272,37 +257,21 @@ fn build_unix_socket_probe_client(path: &Path) -> Result async fn try_connect_unix_socket_http_client( path: &Path, - wait_for_cli_dev_token_fallback: bool, bearer_token: Option<&str>, ) -> Result { check_server_ready(&build_unix_socket_probe_client(path)?).await?; - build_authed_unix_socket_http_client(path, wait_for_cli_dev_token_fallback, bearer_token).await + build_authed_unix_socket_http_client(path, bearer_token) } async fn connect_unix_socket_http_client( path: &Path, - wait_for_cli_dev_token_fallback: bool, bearer_token: Option<&str>, ) -> Result { wait_for_server_ready(&build_unix_socket_probe_client(path)?).await?; - build_authed_unix_socket_http_client(path, wait_for_cli_dev_token_fallback, bearer_token).await + build_authed_unix_socket_http_client(path, bearer_token) } -fn resolve_oauth_credential( - target: &ServerTarget, - store: &AuthStore, - now: chrono::DateTime, -) -> Result> { - if let Some(entry) = store.get(target)? { - if entry.access_token_expires_at > now || entry.refresh_token_expires_at > now { - return Ok(Some(Credential::OAuth(entry))); - } - } - - Ok(None) -} - -fn resolve_local_tcp_credential_with_store( +fn resolve_target_credential_with_store( target: &ServerTarget, env_token: Option<&str>, store: &AuthStore, @@ -312,43 +281,24 @@ fn resolve_local_tcp_credential_with_store( return Ok(Some(Credential::DevToken(token.to_owned()))); } - resolve_oauth_credential(target, store, now) + let Some(entry) = store.get(target)? else { + return Ok(None); + }; + match entry { + AuthEntry::DevToken(entry) => Ok(Some(Credential::DevToken(entry.token))), + AuthEntry::OAuth(entry) + if entry.access_token_expires_at > now || entry.refresh_token_expires_at > now => + { + Ok(Some(Credential::OAuth(entry))) + } + AuthEntry::OAuth(_) => Ok(None), + } } -fn resolve_local_tcp_credential(target: &ServerTarget) -> Result> { +fn resolve_target_credential(target: &ServerTarget) -> Result> { let env_token = process_env_var(EnvVars::FABRO_DEV_TOKEN); let store = AuthStore::default(); - resolve_local_tcp_credential_with_store( - target, - env_token.as_deref(), - &store, - chrono::Utc::now(), - ) -} - -fn resolve_target_credential( - target: &ServerTarget, - allow_local_dev_token_fallback: bool, -) -> Result> { - let env_token = process_env_var(EnvVars::FABRO_DEV_TOKEN); - let store = AuthStore::default(); - if let Some(credential) = resolve_local_tcp_credential_with_store( - target, - env_token.as_deref(), - &store, - chrono::Utc::now(), - )? { - return Ok(Some(credential)); - } - - if allow_local_dev_token_fallback { - return Ok( - load_cli_dev_token_from_sources(env_token.as_deref(), &Home::from_env()) - .map(Credential::DevToken), - ); - } - - Ok(None) + resolve_target_credential_with_store(target, env_token.as_deref(), &store, chrono::Utc::now()) } #[expect( @@ -394,56 +344,20 @@ async fn wait_for_server_ready(http_client: &fabro_http::HttpClient) -> Result<( } #[cfg(test)] -#[expect( - clippy::disallowed_methods, - reason = "server-client tests stage local dev-token fixtures with sync std::fs::write" -)] mod tests { use chrono::{Duration as ChronoDuration, Utc}; - use fabro_client::{AuthEntry, StoredSubject}; + use fabro_client::{AuthEntry, DevTokenEntry, OAuthEntry, StoredSubject}; use httpmock::Method::{GET, POST}; use serde_json::json; use super::*; - #[test] - fn load_cli_dev_token_prefers_env() { - let temp_home = tempfile::tempdir().unwrap(); - let token_path = temp_home.path().join("dev-token"); - std::fs::write( - &token_path, - "fabro_dev_abababababababababababababababababababababababababababababababab", - ) - .unwrap(); - - let token = load_cli_dev_token_from_sources( - Some("fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"), - &Home::new(temp_home.path()), - ); - - assert_eq!( - token.as_deref(), - Some("fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd") - ); - } - - #[test] - fn load_cli_dev_token_reads_home_file() { - let temp_home = tempfile::tempdir().unwrap(); - let token = "fabro_dev_abababababababababababababababababababababababababababababababab"; - std::fs::write(temp_home.path().join("dev-token"), token).unwrap(); - - let loaded = load_cli_dev_token_from_sources(None, &Home::new(temp_home.path())); - - assert_eq!(loaded.as_deref(), Some(token)); - } - #[test] fn resolve_local_tcp_credential_prefers_valid_env_token() { let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap(); let store = AuthStore::new(tempfile::tempdir().unwrap().path().join("auth.json")); - let credential = resolve_local_tcp_credential_with_store( + let credential = resolve_target_credential_with_store( &target, Some("fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"), &store, @@ -454,6 +368,28 @@ mod tests { assert!(matches!(credential, Some(Credential::DevToken(_)))); } + #[test] + fn resolve_target_credential_uses_persisted_dev_token_entry() { + let dir = tempfile::tempdir().unwrap(); + let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap(); + let store = AuthStore::new(dir.path().join("auth.json")); + let token = "fabro_dev_abababababababababababababababababababababababababababababababab"; + store + .put( + &target, + AuthEntry::DevToken(DevTokenEntry { + token: token.to_string(), + logged_in_at: Utc::now(), + }), + ) + .unwrap(); + + let credential = + resolve_target_credential_with_store(&target, None, &store, Utc::now()).unwrap(); + + assert!(matches!(credential, Some(Credential::DevToken(found)) if found == token)); + } + #[test] fn resolve_local_tcp_credential_uses_live_oauth_entry() { let dir = tempfile::tempdir().unwrap(); @@ -470,8 +406,7 @@ mod tests { ) .unwrap(); - let credential = - resolve_local_tcp_credential_with_store(&target, None, &store, now).unwrap(); + let credential = resolve_target_credential_with_store(&target, None, &store, now).unwrap(); assert!(matches!(credential, Some(Credential::OAuth(_)))); } @@ -492,8 +427,7 @@ mod tests { ) .unwrap(); - let credential = - resolve_local_tcp_credential_with_store(&target, None, &store, now).unwrap(); + let credential = resolve_target_credential_with_store(&target, None, &store, now).unwrap(); assert!(matches!(credential, Some(Credential::OAuth(_)))); } @@ -514,31 +448,11 @@ mod tests { ) .unwrap(); - let credential = - resolve_local_tcp_credential_with_store(&target, None, &store, now).unwrap(); + let credential = resolve_target_credential_with_store(&target, None, &store, now).unwrap(); assert!(credential.is_none()); } - #[test] - fn resolve_local_tcp_credential_does_not_fallback_to_home_dev_token() { - let temp_home = tempfile::tempdir().unwrap(); - let token = "fabro_dev_abababababababababababababababababababababababababababababababab"; - std::fs::write(temp_home.path().join("dev-token"), token).unwrap(); - let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap(); - let store = AuthStore::new(temp_home.path().join("auth.json")); - assert_eq!( - load_cli_dev_token_from_sources(None, &Home::new(temp_home.path())).as_deref(), - Some(token) - ); - - assert!( - resolve_local_tcp_credential_with_store(&target, None, &store, Utc::now()) - .unwrap() - .is_none() - ); - } - #[test] fn bypasses_proxy_for_loopback_http_targets() { assert!(should_bypass_proxy_for_http_target( @@ -556,18 +470,6 @@ mod tests { )); } - #[test] - fn explicit_http_targets_do_not_allow_local_dev_token_fallback() { - let target = ServerTarget::http_url("https://fabro.example.com/api/v1").unwrap(); - assert!(!local_dev_token_fallback(&target)); - } - - #[test] - fn unix_socket_targets_keep_local_dev_token_fallback() { - let target = ServerTarget::unix_socket_path("/tmp/fabro.sock").unwrap(); - assert!(local_dev_token_fallback(&target)); - } - #[tokio::test] async fn connect_server_target_with_bearer_sends_worker_bearer_token() { let server = httpmock::MockServer::start(); @@ -653,7 +555,7 @@ mod tests { access_token_expires_at: chrono::DateTime, refresh_token_expires_at: chrono::DateTime, ) -> AuthEntry { - AuthEntry { + AuthEntry::OAuth(OAuthEntry { access_token: "access-token".to_string(), access_token_expires_at, refresh_token: "refresh-token".to_string(), @@ -666,6 +568,6 @@ mod tests { email: "octocat@example.com".to_string(), }, logged_in_at: Utc::now(), - } + }) } } diff --git a/lib/crates/fabro-cli/tests/it/cmd/auth.rs b/lib/crates/fabro-cli/tests/it/cmd/auth.rs index 6022d254c..82a3a7c9f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/auth.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/auth.rs @@ -1,5 +1,8 @@ use fabro_test::{fabro_snapshot, test_context}; +const DEV_TOKEN: &str = + "fabro_dev_abababababababababababababababababababababababababababababababab"; + #[test] fn help() { let context = test_context!(); @@ -44,19 +47,75 @@ fn login_help() { Usage: fabro auth login [OPTIONS] Options: - --json Output as JSON [env: FABRO_JSON=] - --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-browser Print the browser URL instead of opening it automatically - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --timeout Timeout in seconds waiting for the browser flow to complete [default: 300] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --dev-token Log in with a dev-token instead of browser OAuth + --no-browser Print the browser URL instead of opening it automatically + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --timeout Timeout in seconds waiting for the browser flow to complete [default: 300] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } +#[test] +#[expect( + clippy::disallowed_methods, + reason = "Integration test inspects the CLI auth store fixture synchronously." +)] +fn login_with_dev_token_writes_auth_store_entry() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args([ + "auth", + "login", + "--server", + "http://127.0.0.1:32276", + "--dev-token", + DEV_TOKEN, + ]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Logged in to http://127.0.0.1:32276 with dev-token + "); + + let auth_file = context.home_dir.join(".fabro").join("auth.json"); + let auth: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(auth_file).unwrap()).unwrap(); + let entry = &auth["servers"]["http://127.0.0.1:32276"]; + assert_eq!(entry["kind"], "dev-token"); + assert_eq!(entry["token"], DEV_TOKEN); +} + +#[test] +fn login_with_invalid_dev_token_fails_before_writing_auth_store() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args([ + "auth", + "login", + "--server", + "http://127.0.0.1:32276", + "--dev-token", + "not-a-dev-token", + ]); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: invalid dev-token format + "); + + assert!(!context.home_dir.join(".fabro").join("auth.json").exists()); +} + #[test] fn status_help() { let context = test_context!(); @@ -81,3 +140,21 @@ fn status_help() { ----- stderr ----- "); } + +#[test] +fn status_json_reports_env_dev_token_separately() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["auth", "status", "--json"]) + .env("FABRO_DEV_TOKEN", DEV_TOKEN); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + { + \"servers\": [], + \"env_dev_token\": \"active\" + } + ----- stderr ----- + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index 87a2fbce5..5f829450a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -410,6 +410,7 @@ fn write_auth_entry( serde_json::to_string_pretty(&json!({ "servers": { canonical: { + "kind": "oauth", "access_token": access_token, "access_token_expires_at": (now + ChronoDuration::minutes(10)).to_rfc3339(), "refresh_token": refresh_token, diff --git a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs index 32e7d4dd7..cbbeae77a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/json_global.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/json_global.rs @@ -69,7 +69,7 @@ fn auth_status_ignores_json_output_format_from_home_config() { ); let stderr = output_stderr(&output); assert!(stderr.contains("Not logged in to any servers.")); - assert!(stderr.contains("Dev token:")); + assert!(stderr.contains("FABRO_DEV_TOKEN:")); } #[test] diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index 4642010d1..670b50af1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -14,17 +14,15 @@ const TEST_SESSION_SECRET: &str = fn provision_local_server_auth(context: &fabro_test::TestContext, storage_dir: &std::path::Path) { context.ensure_home_server_auth_methods(); - let server_env_path = Storage::new(storage_dir).runtime_directory().env_path(); + let runtime_directory = Storage::new(storage_dir).runtime_directory(); + let server_env_path = runtime_directory.env_path(); envfile::merge_env_file(&server_env_path, [ ("FABRO_DEV_TOKEN", TEST_DEV_TOKEN), ("SESSION_SECRET", TEST_SESSION_SECRET), ]) .expect("merging server auth into server.env"); - dev_token::write_dev_token( - &context.home_dir.join(".fabro").join("dev-token"), - TEST_DEV_TOKEN, - ) - .expect("writing home dev-token"); + dev_token::write_dev_token(&runtime_directory.dev_token_path(), TEST_DEV_TOKEN) + .expect("writing runtime dev-token"); } #[test] diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs index b5c54475b..8a8385215 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server_start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -32,15 +32,16 @@ fn write_dev_token_server_settings(config_path: &std::path::Path, rest: &str) { .expect("writing dev-token server settings fixture"); } -fn provision_dev_token_auth(home_dir: &std::path::Path, storage_dir: &std::path::Path) { - let server_env_path = Storage::new(storage_dir).runtime_directory().env_path(); +fn provision_dev_token_auth(_home_dir: &std::path::Path, storage_dir: &std::path::Path) { + let runtime_directory = Storage::new(storage_dir).runtime_directory(); + let server_env_path = runtime_directory.env_path(); envfile::merge_env_file(&server_env_path, [ ("FABRO_DEV_TOKEN", TEST_DEV_TOKEN), ("SESSION_SECRET", TEST_SESSION_SECRET), ]) .expect("merging server auth into server.env"); - dev_token::write_dev_token(&home_dir.join(".fabro").join("dev-token"), TEST_DEV_TOKEN) - .expect("writing home dev-token"); + dev_token::write_dev_token(&runtime_directory.dev_token_path(), TEST_DEV_TOKEN) + .expect("writing runtime dev-token"); } #[derive(Clone, Copy, Debug)] diff --git a/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs b/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs index 3e46e80ec..956684d69 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/worker_auth.rs @@ -17,7 +17,7 @@ use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; use chrono::{Duration as ChronoDuration, Utc}; -use fabro_client::{AuthEntry, AuthStore, ServerTarget, StoredSubject}; +use fabro_client::{AuthEntry, AuthStore, OAuthEntry, ServerTarget, StoredSubject}; use fabro_config::{Storage, envfile}; use fabro_store::EventEnvelope; use fabro_test::{apply_test_isolation, expect_reqwest_json, isolated_storage_dir, test_context}; @@ -180,20 +180,23 @@ fn write_submitter_auth(home_dir: &Path, target: &str, access_token: &str) { let target = ServerTarget::http_url(target).unwrap(); let now = Utc::now(); auth_store - .put(&target, AuthEntry { - access_token: access_token.to_string(), - access_token_expires_at: now + ChronoDuration::minutes(10), - refresh_token: "refresh-unused".to_string(), - refresh_token_expires_at: now + ChronoDuration::days(30), - subject: StoredSubject { - idp_issuer: "https://github.com".to_string(), - idp_subject: "12345".to_string(), - login: "octocat".to_string(), - name: "The Octocat".to_string(), - email: "octocat@example.com".to_string(), - }, - logged_in_at: now, - }) + .put( + &target, + AuthEntry::OAuth(OAuthEntry { + access_token: access_token.to_string(), + access_token_expires_at: now + ChronoDuration::minutes(10), + refresh_token: "refresh-unused".to_string(), + refresh_token_expires_at: now + ChronoDuration::days(30), + subject: StoredSubject { + idp_issuer: "https://github.com".to_string(), + idp_subject: "12345".to_string(), + login: "octocat".to_string(), + name: "The Octocat".to_string(), + email: "octocat@example.com".to_string(), + }, + logged_in_at: now, + }), + ) .unwrap(); } diff --git a/lib/crates/fabro-cli/tests/it/scenario/auth.rs b/lib/crates/fabro-cli/tests/it/scenario/auth.rs index 1f763659f..77ad15da4 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/auth.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/auth.rs @@ -151,11 +151,6 @@ fn auth_refresh_failure_clears_local_session() { let context = test_context!(); let server = MockServer::start(); let target = server_target(&server); - context.write_home( - ".fabro/dev-token", - "fabro_dev_abababababababababababababababababababababababababababababababab\n", - ); - server.mock(|when, then| { when.method(POST) .path("/auth/cli/token") diff --git a/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs b/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs index 5906cc6da..8ba42f91e 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs @@ -10,9 +10,8 @@ fn start_status_stop_lifecycle() { ".fabro/settings.toml", "[server.auth]\nmethods = [\"dev-token\"]\n", ); - let server_env_path = fabro_config::Storage::new(&storage_dir) - .runtime_directory() - .env_path(); + let runtime_directory = fabro_config::Storage::new(&storage_dir).runtime_directory(); + let server_env_path = runtime_directory.env_path(); fabro_config::envfile::merge_env_file(&server_env_path, [ ( "FABRO_DEV_TOKEN", @@ -25,7 +24,7 @@ fn start_status_stop_lifecycle() { ]) .unwrap(); fabro_util::dev_token::write_dev_token( - &context.home_dir.join(".fabro").join("dev-token"), + &runtime_directory.dev_token_path(), "fabro_dev_abababababababababababababababababababababababababababababababab", ) .unwrap(); diff --git a/lib/crates/fabro-client/src/auth_store.rs b/lib/crates/fabro-client/src/auth_store.rs index a66455d26..b6bdf8760 100644 --- a/lib/crates/fabro-client/src/auth_store.rs +++ b/lib/crates/fabro-client/src/auth_store.rs @@ -31,7 +31,16 @@ pub struct StoredSubject { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AuthEntry { +#[serde(tag = "kind")] +pub enum AuthEntry { + #[serde(rename = "oauth")] + OAuth(OAuthEntry), + #[serde(rename = "dev-token")] + DevToken(DevTokenEntry), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OAuthEntry { pub access_token: String, pub access_token_expires_at: DateTime, pub refresh_token: String, @@ -40,6 +49,12 @@ pub struct AuthEntry { pub logged_in_at: DateTime, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevTokenEntry { + pub token: String, + pub logged_in_at: DateTime, +} + #[derive(Debug, Error)] pub enum AuthStoreError { #[allow( @@ -364,14 +379,18 @@ mod tests { use chrono::Duration; use fabro_static::EnvVars; - use super::{AuthEntry, AuthStore, StoredSubject, key_for_target}; + use super::{AuthEntry, AuthStore, DevTokenEntry, OAuthEntry, StoredSubject, key_for_target}; #[cfg(unix)] use super::{LockError, classify_lock_error}; use crate::target::ServerTarget; fn entry(login: &str) -> AuthEntry { + AuthEntry::OAuth(oauth_entry(login)) + } + + fn oauth_entry(login: &str) -> OAuthEntry { let now = chrono::Utc::now(); - AuthEntry { + OAuthEntry { access_token: format!("access-{login}"), access_token_expires_at: now + Duration::minutes(10), refresh_token: format!("refresh-{login}"), @@ -401,9 +420,58 @@ mod tests { store.put(&target, entry("octocat")).unwrap(); let saved = store.get(&target).unwrap().unwrap(); + let AuthEntry::OAuth(saved) = saved else { + panic!("expected OAuth entry"); + }; assert_eq!(saved.subject.login, "octocat"); } + #[cfg(unix)] + #[test] + fn round_trips_dev_token_entry() { + let temp = tempfile::tempdir().unwrap(); + let store = AuthStore::new(temp.path().join("auth.json")); + let target = https_target("https://fabro.example.com"); + let now = chrono::Utc::now(); + + store + .put( + &target, + AuthEntry::DevToken(DevTokenEntry { + token: + "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + logged_in_at: now, + }), + ) + .unwrap(); + + let saved = store.get(&target).unwrap().unwrap(); + let AuthEntry::DevToken(saved) = saved else { + panic!("expected dev-token entry"); + }; + assert_eq!( + saved.token, + "fabro_dev_abababababababababababababababababababababababababababababababab" + ); + assert_eq!(saved.logged_in_at, now); + } + + #[test] + fn serializes_explicit_variant_kinds() { + let oauth = serde_json::to_value(AuthEntry::OAuth(oauth_entry("octocat"))).unwrap(); + assert_eq!(oauth["kind"], "oauth"); + + let dev_token = serde_json::to_value(AuthEntry::DevToken(DevTokenEntry { + token: + "fabro_dev_abababababababababababababababababababababababababababababababab" + .to_string(), + logged_in_at: chrono::Utc::now(), + })) + .unwrap(); + assert_eq!(dev_token["kind"], "dev-token"); + } + #[cfg(unix)] #[test] fn round_trips_loopback_http_entry() { @@ -414,6 +482,9 @@ mod tests { store.put(&target, entry("alice")).unwrap(); let saved = store.get(&target).unwrap().unwrap(); + let AuthEntry::OAuth(saved) = saved else { + panic!("expected OAuth entry"); + }; assert_eq!(saved.subject.login, "alice"); } @@ -429,6 +500,9 @@ mod tests { store.put(&target, entry("unix")).unwrap(); let saved = store.get(&target).unwrap().unwrap(); + let AuthEntry::OAuth(saved) = saved else { + panic!("expected OAuth entry"); + }; assert_eq!(saved.subject.login, "unix"); } @@ -507,6 +581,9 @@ mod tests { } let saved = store.get(&target).unwrap().unwrap(); + let AuthEntry::OAuth(saved) = saved else { + panic!("expected OAuth entry"); + }; assert!(matches!(saved.subject.login.as_str(), "alice" | "bob")); } diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index 046ce54a0..bf37165aa 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -29,7 +29,7 @@ use crate::error::{ }; use crate::session::OAuthSession; use crate::target::ServerTarget; -use crate::{AuthEntry, StoredSubject, sse}; +use crate::{AuthEntry, OAuthEntry, StoredSubject, sse}; type TransportFuture = BoxFuture<'static, Result<(fabro_http::HttpClient, String)>>; @@ -361,7 +361,14 @@ impl Client { self.rebuild_with_fallback(oauth_session).await?; return Err(session_expired()); }; - if entry.refresh_token_expires_at <= chrono::Utc::now() { + let oauth_entry = match entry { + AuthEntry::DevToken(entry) => { + self.rebuild_client(Some(entry.token)).await?; + return Ok(()); + } + AuthEntry::OAuth(entry) => entry, + }; + if oauth_entry.refresh_token_expires_at <= chrono::Utc::now() { oauth_session.auth_store.remove(&oauth_session.target)?; self.rebuild_with_fallback(oauth_session).await?; return Err(session_expired()); @@ -369,7 +376,10 @@ impl Client { let (http_client, base_url) = oauth_session.target.build_public_http_client()?; let response = http_client .post(format!("{base_url}/auth/cli/refresh")) - .header(AUTHORIZATION, format!("Bearer {}", entry.refresh_token)) + .header( + AUTHORIZATION, + format!("Bearer {}", oauth_entry.refresh_token), + ) .send() .await?; @@ -378,7 +388,7 @@ impl Client { .json::() .await .context("failed to parse CLI auth refresh response")?; - let entry = AuthEntry { + let entry = OAuthEntry { access_token: tokens.access_token.clone(), access_token_expires_at: tokens.access_token_expires_at, refresh_token: tokens.refresh_token.clone(), @@ -390,11 +400,11 @@ impl Client { name: tokens.subject.name, email: tokens.subject.email, }, - logged_in_at: entry.logged_in_at, + logged_in_at: oauth_entry.logged_in_at, }; oauth_session .auth_store - .put(&oauth_session.target, entry.clone()) + .put(&oauth_session.target, AuthEntry::OAuth(entry.clone())) .context("failed to persist refreshed CLI auth tokens")?; self.rebuild_client(Some(entry.access_token)).await?; return Ok(()); @@ -1480,6 +1490,8 @@ fn add_pr_upgrade_hint(err: anyhow::Error) -> anyhow::Error { #[cfg(test)] mod tests { + use std::sync::Arc; + use chrono::Duration as ChronoDuration; use fabro_util::exit; use httpmock::Method::POST; @@ -1489,12 +1501,12 @@ mod tests { use tokio::net::TcpListener; use super::*; - use crate::AuthStore; use crate::error::tag_with_failure; + use crate::{AuthStore, DevTokenEntry}; - fn oauth_entry(login: &str) -> AuthEntry { + fn oauth_entry(login: &str) -> OAuthEntry { let now = chrono::Utc::now(); - AuthEntry { + OAuthEntry { access_token: format!("access-{login}"), access_token_expires_at: now + ChronoDuration::minutes(10), refresh_token: format!("refresh-{login}"), @@ -1549,7 +1561,9 @@ mod tests { }); let target = ServerTarget::http_url(format!("http://localhost:{port}")).unwrap(); let entry = oauth_entry("octocat"); - auth_store.put(&target, entry.clone()).unwrap(); + auth_store + .put(&target, AuthEntry::OAuth(entry.clone())) + .unwrap(); let client = Client::builder() .target(target.clone()) @@ -1562,6 +1576,9 @@ mod tests { client.refresh_access_token("access-octocat").await.unwrap(); let refreshed = auth_store.get(&target).unwrap().unwrap(); + let AuthEntry::OAuth(refreshed) = refreshed else { + panic!("expected OAuth entry"); + }; assert_eq!(refreshed.access_token, "access-refreshed"); assert_eq!(refreshed.refresh_token, "refresh-refreshed"); server.abort(); @@ -1574,7 +1591,9 @@ mod tests { let auth_store = AuthStore::new(temp.path().join("auth.json")); let target = ServerTarget::http_url(server.base_url()).unwrap(); let entry = oauth_entry("octocat"); - auth_store.put(&target, entry.clone()).unwrap(); + auth_store + .put(&target, AuthEntry::OAuth(entry.clone())) + .unwrap(); let client = Client::builder() .target(target.clone()) @@ -1594,6 +1613,58 @@ mod tests { (temp, client, auth_store, target) } + #[tokio::test] + async fn refresh_access_token_reinstalls_stored_dev_token_without_refresh_request() { + let server = MockServer::start(); + let refresh_mock = server.mock(|when, then| { + when.method(POST).path("/auth/cli/refresh"); + then.status(500); + }); + let temp = tempfile::tempdir().unwrap(); + let auth_store = AuthStore::new(temp.path().join("auth.json")); + let target = ServerTarget::http_url(server.base_url()).unwrap(); + let old_token = + "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; + let stored_token = + "fabro_dev_abababababababababababababababababababababababababababababababab"; + auth_store + .put( + &target, + AuthEntry::DevToken(DevTokenEntry { + token: stored_token.to_string(), + logged_in_at: chrono::Utc::now(), + }), + ) + .unwrap(); + + let seen_tokens = Arc::new(std::sync::Mutex::new(Vec::new())); + let seen_tokens_for_connector = Arc::clone(&seen_tokens); + let base_url = server.base_url(); + let client = Client::builder() + .target(target.clone()) + .credential(Credential::DevToken(old_token.to_string())) + .oauth_session(OAuthSession::new(target.clone(), auth_store.clone())) + .transport_connector(TransportConnector::new(move |bearer_token| { + let seen_tokens = Arc::clone(&seen_tokens_for_connector); + let base_url = base_url.clone(); + async move { + seen_tokens.lock().unwrap().push(bearer_token); + Ok((fabro_http::test_http_client().unwrap(), base_url)) + } + })) + .connect() + .await + .unwrap(); + + client.refresh_access_token(old_token).await.unwrap(); + + assert_eq!(refresh_mock.calls(), 0); + assert_eq!(*seen_tokens.lock().unwrap(), vec![ + Some(old_token.to_string()), + Some(stored_token.to_string()), + ]); + } + #[tokio::test] async fn refresh_access_token_classifies_expired_refresh_tokens() { let server = MockServer::start(); diff --git a/lib/crates/fabro-client/src/credential.rs b/lib/crates/fabro-client/src/credential.rs index 6f14804c3..c0d7456c6 100644 --- a/lib/crates/fabro-client/src/credential.rs +++ b/lib/crates/fabro-client/src/credential.rs @@ -1,12 +1,12 @@ use std::fmt; -use crate::AuthEntry; +use crate::OAuthEntry; #[derive(Clone)] pub enum Credential { DevToken(String), Worker(String), - OAuth(AuthEntry), + OAuth(OAuthEntry), } pub trait CredentialFallback: Send + Sync { diff --git a/lib/crates/fabro-client/src/lib.rs b/lib/crates/fabro-client/src/lib.rs index a2d3e5278..cb48823bc 100644 --- a/lib/crates/fabro-client/src/lib.rs +++ b/lib/crates/fabro-client/src/lib.rs @@ -8,7 +8,9 @@ pub mod session; pub mod sse; pub mod target; -pub use auth_store::{AuthEntry, AuthStore, AuthStoreError, LockError, StoredSubject}; +pub use auth_store::{ + AuthEntry, AuthStore, AuthStoreError, DevTokenEntry, LockError, OAuthEntry, StoredSubject, +}; pub use client::{Client, RunEventStream, TransportConnector, apply_bearer_token_auth}; pub use credential::{Credential, CredentialFallback}; pub use error::{ diff --git a/lib/crates/fabro-server/src/auth/cli_flow.rs b/lib/crates/fabro-server/src/auth/cli_flow.rs index afa47d248..e86edf92f 100644 --- a/lib/crates/fabro-server/src/auth/cli_flow.rs +++ b/lib/crates/fabro-server/src/auth/cli_flow.rs @@ -130,7 +130,7 @@ async fn start( &redirect_uri, state_token, "github_not_configured", - "GitHub authentication is not enabled on this server", + "This server uses dev-token auth. Copy the token from the server and run: `fabro auth login --dev-token `", ); } diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index 8463362bd..bab069159 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -58,8 +58,16 @@ pub struct InstallAppState { finish_in_progress: Arc, upstreams: InstallUpstreamConfig, on_finish: Option>, + finish_hook: Option, } +pub struct InstallFinishInfo { + pub canonical_url: String, + pub dev_token: Option, +} + +pub type InstallFinishHook = Arc anyhow::Result<()> + Send + Sync>; + #[derive(Clone, Debug, Default)] struct InstallUpstreamConfig { provider_base_urls: HashMap, @@ -97,6 +105,7 @@ impl InstallAppState { finish_in_progress: Arc::new(AtomicBool::new(false)), upstreams: InstallUpstreamConfig::default(), on_finish: None, + finish_hook: None, } } @@ -137,6 +146,7 @@ impl InstallAppState { finish_in_progress: Arc::new(AtomicBool::new(false)), upstreams: InstallUpstreamConfig::default(), on_finish: None, + finish_hook: None, } } @@ -148,6 +158,14 @@ impl InstallAppState { } } + #[must_use] + pub fn with_finish_hook(self, finish_hook: InstallFinishHook) -> Self { + Self { + finish_hook: Some(finish_hook), + ..self + } + } + #[must_use] pub fn with_home(mut self, home: Home) -> Self { self.home = Some(home); @@ -1356,9 +1374,10 @@ async fn post_install_finish( secret_type: VaultSecretType::Environment, description: None, }); - let home = state.home.clone().unwrap_or_else(Home::from_env); - let token = match dev_token::read_or_mint_dev_token_for_install(&home.dev_token_path()) - { + let dev_token_path = Storage::new(state.storage_dir.as_ref()) + .runtime_directory() + .dev_token_path(); + let token = match dev_token::read_or_mint_dev_token_for_install(&dev_token_path) { Ok(value) => value, Err(err) => { return install_error_response( @@ -1367,14 +1386,6 @@ async fn post_install_finish( ); } }; - if let Err(err) = dev_token::write_dev_token( - &Storage::new(state.storage_dir.as_ref()) - .runtime_directory() - .dev_token_path(), - &token, - ) { - return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); - } dev_token = Some(token); } GithubInstallState::App(github) => { @@ -1481,6 +1492,16 @@ async fn post_install_finish( *manual_credentials = None; } + if let Some(finish_hook) = state.finish_hook.clone() { + let info = InstallFinishInfo { + canonical_url: server.canonical_url.clone(), + dev_token: dev_token.clone(), + }; + if let Err(err) = finish_hook(&info) { + warn!(error = %err, "install finish hook failed"); + } + } + if let Some(on_finish) = state.on_finish.clone() { info!(restart_url = %server.canonical_url, "install finish succeeded"); info!("install exit scheduled"); diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index 0a516a2b4..f4c0ec8a1 100644 --- a/lib/crates/fabro-server/tests/it/api/install.rs +++ b/lib/crates/fabro-server/tests/it/api/install.rs @@ -13,7 +13,9 @@ use axum::http::{Request, StatusCode}; use fabro_config::{ServerSettingsBuilder, Storage}; use fabro_install::OBJECT_STORE_MANAGED_COMMENT; use fabro_model::Provider; -use fabro_server::install::{InstallAppState, build_install_router}; +use fabro_server::install::{ + InstallAppState, InstallFinishHook, InstallFinishInfo, build_install_router, +}; use fabro_util::{Home, dev_token}; use fabro_vault::Vault; use httpmock::MockServer; @@ -775,6 +777,53 @@ async fn token_install_finish_persists_settings_env_and_vault() { assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token")); } +#[tokio::test] +async fn token_install_finish_invokes_finish_hook_before_response_returns() { + let temp_dir = tempfile::tempdir().unwrap(); + let config_path = temp_dir.path().join("settings.toml"); + let observed = Arc::new(StdMutex::new(None)); + let observed_for_hook = Arc::clone(&observed); + let hook: InstallFinishHook = Arc::new(move |info: &InstallFinishInfo| { + *observed_for_hook.lock().unwrap() = + Some((info.canonical_url.clone(), info.dev_token.clone())); + Ok(()) + }); + let app = build_install_router( + InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path) + .with_finish_hook(hook), + ) + .await; + configure_token_install(&app, "test-install-token").await; + + let finish_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/install/finish") + .header("authorization", "Bearer test-install-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let finish_body = response_json( + finish_response, + StatusCode::ACCEPTED, + "POST /install/finish", + ) + .await; + + let Some((canonical_url, dev_token)) = observed.lock().unwrap().clone() else { + panic!("finish hook should run before response returns"); + }; + assert_eq!(canonical_url, "https://fabro.example.com"); + assert_eq!( + dev_token.as_deref(), + finish_body["dev_token"].as_str(), + "hook receives the same token returned to the browser" + ); +} + #[tokio::test] async fn app_install_finish_omits_dev_token_and_does_not_write_it() { let temp_dir = tempfile::tempdir().unwrap(); @@ -929,7 +978,7 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() { assert!(!server_env.contains("FABRO_DEV_TOKEN=")); assert!( - !home.dev_token_path().exists(), + !home.root().join("dev-token").exists(), "home dev token file should not be created for App installs" ); assert!( @@ -1909,7 +1958,7 @@ async fn install_finish_failure_reports_only_env_keys_actually_removed() { } #[tokio::test] -async fn install_finish_failure_leaves_home_dev_token_mirror_written() { +async fn install_finish_failure_does_not_create_home_dev_token() { let temp_dir = tempfile::tempdir().unwrap(); let home_root = tempfile::tempdir().unwrap(); let home = Home::new(home_root.path().join(".fabro")); @@ -1947,13 +1996,14 @@ async fn install_finish_failure_leaves_home_dev_token_mirror_written() { ) .await; - let home_dev_token = dev_token::read_dev_token_file(&home.dev_token_path()) - .expect("home dev token should exist"); + assert!( + !home.root().join("dev-token").exists(), + "home dev token file should not be created" + ); let storage_dev_token = dev_token::read_dev_token_file(&storage.runtime_directory().dev_token_path()) .expect("storage dev token should exist"); - assert_eq!(home_dev_token, storage_dev_token); let server_env = std::fs::read_to_string(storage.runtime_directory().env_path()).unwrap(); - assert!(server_env.contains(&format!("FABRO_DEV_TOKEN={home_dev_token}"))); + assert!(server_env.contains(&format!("FABRO_DEV_TOKEN={storage_dev_token}"))); } diff --git a/lib/crates/fabro-spa/assets/assets/entry-eynqnh92.js b/lib/crates/fabro-spa/assets/assets/entry-jtmy0vnc.js similarity index 98% rename from lib/crates/fabro-spa/assets/assets/entry-eynqnh92.js rename to lib/crates/fabro-spa/assets/assets/entry-jtmy0vnc.js index bf76e3aa4..f13a80c7c 100644 --- a/lib/crates/fabro-spa/assets/assets/entry-eynqnh92.js +++ b/lib/crates/fabro-spa/assets/assets/entry-jtmy0vnc.js @@ -30,9 +30,9 @@ Error generating stack: `+v0.message+` `+w)}}else if(J.debugStack!=null){var N=J.debugStack;(J=J.owner)&&N&&(X+=` `+s(N))}else break;var F=X}catch(V){F=` Error generating stack: `+V.message+` -`+V.stack}return F}function R0(J,X,K,G,w,N,F){var V=v4;C1(J);try{return J!==null&&J._debugTask?J._debugTask.run(X.bind(null,K,G,w,N,F)):X(K,G,w,N,F)}finally{C1(V)}throw Error("runWithFiberInDEV should never be called in production. This is a bug in React.")}function C1(J){M0.getCurrentStack=J===null?null:B1,A3=!1,v4=J}function X5(J){return typeof Symbol==="function"&&Symbol.toStringTag&&J[Symbol.toStringTag]||J.constructor.name||"Object"}function n5(J){try{return O5(J),!1}catch(X){return!0}}function O5(J){return""+J}function H1(J,X){if(n5(J))return console.error("The provided `%s` attribute is an unsupported type %s. This value must be coerced to a string before using it here.",X,X5(J)),O5(J)}function n6(J,X){if(n5(J))return console.error("The provided `%s` CSS property is an unsupported type %s. This value must be coerced to a string before using it here.",X,X5(J)),O5(J)}function W1(J){if(n5(J))return console.error("Form field values (value, checked, defaultValue, or defaultChecked props) must be strings, not %s. This value must be coerced to a string before using it here.",X5(J)),O5(J)}function N4(J){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u")return!1;var X=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(X.isDisabled)return!0;if(!X.supportsFiber)return console.error("The installed version of React DevTools is too old and will not work with the current version of React. Please update React DevTools. https://react.dev/link/react-devtools"),!0;try{HQ=X.inject(J),F7=X}catch(K){console.error("React instrumentation encountered an error: %o.",K)}return X.checkDCE?!0:!1}function S1(J){if(typeof cl==="function"&&dl(J),F7&&typeof F7.setStrictMode==="function")try{F7.setStrictMode(HQ,J)}catch(X){F3||(F3=!0,console.error("React instrumentation encountered an error: %o",X))}}function L6(J){return J>>>=0,J===0?32:31-(ll(J)/rl|0)|0}function Z7(J){var X=J&42;if(X!==0)return X;switch(J&-J){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return J&261888;case 262144:case 524288:case 1048576:case 2097152:return J&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return J&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return console.error("Should have found matching lanes. This is a bug in React."),J}}function d4(J,X,K){var G=J.pendingLanes;if(G===0)return 0;var w=0,N=J.suspendedLanes,F=J.pingedLanes;J=J.warmLanes;var V=G&134217727;return V!==0?(G=V&~N,G!==0?w=Z7(G):(F&=V,F!==0?w=Z7(F):K||(K=V&~J,K!==0&&(w=Z7(K))))):(V=G&~N,V!==0?w=Z7(V):F!==0?w=Z7(F):K||(K=G&~J,K!==0&&(w=Z7(K)))),w===0?0:X!==0&&X!==w&&(X&N)===0&&(N=w&-w,K=X&-X,N>=K||N===32&&(K&4194048)!==0)?X:w}function f2(J,X){return(J.pendingLanes&~(J.suspendedLanes&~J.pingedLanes)&X)===0}function R6(J,X){switch(J){case 1:case 2:case 4:case 8:case 64:return X+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return X+5000;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return console.error("Should have found matching lanes. This is a bug in React."),-1}}function T6(){var J=ZG;return ZG<<=1,(ZG&62914560)===0&&(ZG=4194304),J}function Y7(J){for(var X=[],K=0;31>K;K++)X.push(J);return X}function C5(J,X){J.pendingLanes|=X,X!==268435456&&(J.suspendedLanes=0,J.pingedLanes=0,J.warmLanes=0)}function $2(J,X,K,G,w,N){var F=J.pendingLanes;J.pendingLanes=K,J.suspendedLanes=0,J.pingedLanes=0,J.warmLanes=0,J.expiredLanes&=K,J.entangledLanes&=K,J.errorRecoveryDisabledLanes&=K,J.shellSuspendCounter=0;var{entanglements:V,expirationTimes:D,hiddenUpdates:E}=J;for(K=F&~K;0"u")return null;try{return J.activeElement||J.body}catch(X){return J.body}}function k0(J){return J.replace(tl,function(X){return"\\"+X.charCodeAt(0).toString(16)+" "})}function l0(J,X){X.checked===void 0||X.defaultChecked===void 0||Nb||(console.error("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",_1()||"A component",X.type),Nb=!0),X.value===void 0||X.defaultValue===void 0||Hb||(console.error("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",_1()||"A component",X.type),Hb=!0)}function s0(J,X,K,G,w,N,F,V){if(J.name="",F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"?(H1(F,"type"),J.type=F):J.removeAttribute("type"),X!=null)if(F==="number"){if(X===0&&J.value===""||J.value!=X)J.value=""+t(X)}else J.value!==""+t(X)&&(J.value=""+t(X));else F!=="submit"&&F!=="reset"||J.removeAttribute("value");X!=null?r0(J,F,t(X)):K!=null?r0(J,F,t(K)):G!=null&&J.removeAttribute("value"),w==null&&N!=null&&(J.defaultChecked=!!N),w!=null&&(J.checked=w&&typeof w!=="function"&&typeof w!=="symbol"),V!=null&&typeof V!=="function"&&typeof V!=="symbol"&&typeof V!=="boolean"?(H1(V,"name"),J.name=""+t(V)):J.removeAttribute("name")}function J1(J,X,K,G,w,N,F,V){if(N!=null&&typeof N!=="function"&&typeof N!=="symbol"&&typeof N!=="boolean"&&(H1(N,"type"),J.type=N),X!=null||K!=null){if(!(N!=="submit"&&N!=="reset"||X!==void 0&&X!==null)){V0(J);return}K=K!=null?""+t(K):"",X=X!=null?""+t(X):K,V||X===J.value||(J.value=X),J.defaultValue=X}G=G!=null?G:w,G=typeof G!=="function"&&typeof G!=="symbol"&&!!G,J.checked=V?J.checked:!!G,J.defaultChecked=!!G,F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"&&(H1(F,"name"),J.name=F),V0(J)}function r0(J,X,K){X==="number"&&S0(J.ownerDocument)===J||J.defaultValue===""+K||(J.defaultValue=""+K)}function p1(J,X){X.value==null&&(typeof X.children==="object"&&X.children!==null?oQ.Children.forEach(X.children,function(K){K==null||typeof K==="string"||typeof K==="number"||typeof K==="bigint"||_b||(_b=!0,console.error("Cannot infer the option value of complex children. Pass a `value` prop or use a plain string as children to