diff --git a/Cargo.lock b/Cargo.lock index 50b344b749..6c41395309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -262,6 +262,7 @@ version = "0.0.1" dependencies = [ "anyhow", "artcraft_api_defs", + "artcraft_client_identity", "backoff", "chrono", "cloudflare_errors", @@ -282,6 +283,13 @@ dependencies = [ "uuid_utils", ] +[[package]] +name = "artcraft_client_identity" +version = "0.0.1" +dependencies = [ + "url", +] + [[package]] name = "artcraft_router" version = "0.0.1" @@ -7018,6 +7026,7 @@ dependencies = [ name = "tauri-plugin-http" version = "2.4.3" dependencies = [ + "artcraft_client_identity", "bytes", "cookie_store 0.21.1", "data-url", diff --git a/Cargo.toml b/Cargo.toml index ee09b17003..8a6f7b1cb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/api_clients/worldlabs_api_client", "crates/api_clients/worldlabs_consumer_client", "crates/desktop/artcraft", + "crates/lib/artcraft_client_identity", "crates/lib/browser_emulation", "crates/lib/build_metadata", "crates/lib/caching/memory_store", @@ -49,6 +50,7 @@ publish = false [workspace.dependencies] artcraft_api_defs = { path = "crates/api_clients/artcraft/artcraft_api_defs" } artcraft_client = { path = "crates/api_clients/artcraft/artcraft_client" } +artcraft_client_identity = { path = "crates/lib/artcraft_client_identity" } artcraft_router = { path = "crates/api_clients/artcraft/artcraft_router" } browser_emulation = { path = "crates/lib/browser_emulation" } cookie_store_wrapper = { path = "crates/lib/cookie_store_wrapper" } diff --git a/_docs/dev_setup.md b/_docs/dev_setup.md index c3f0c536f5..6a61c856db 100644 --- a/_docs/dev_setup.md +++ b/_docs/dev_setup.md @@ -33,6 +33,11 @@ Only after the real frontend socket is bound does it start Tauri with that exact through native Tauri IPC and needs no separate HTTP port. Configuration overrides are passed in memory, without changing the checked-in Tauri config. +The native HTTP bridge normalizes loopback development origins for first-party +API requests so an automatically selected port does not break login or media +loading. See the [desktop session regression checks](../docs/desktop-session-regression.md) +when changing ports, the launcher, or authentication. + JavaScript, TypeScript, React, and CSS changes use Vite hot reload; compatible React component edits use Fast Refresh. Changes that cannot be hot-replaced reload the page. Rust changes use `cargo tauri dev`'s automatic **rebuild and app diff --git a/crates/api_clients/artcraft/artcraft_client/Cargo.toml b/crates/api_clients/artcraft/artcraft_client/Cargo.toml index ec8c064abb..6987761299 100644 --- a/crates/api_clients/artcraft/artcraft_client/Cargo.toml +++ b/crates/api_clients/artcraft/artcraft_client/Cargo.toml @@ -19,6 +19,7 @@ path = "src/lib.rs" # Internal artcraft_api_defs.workspace = true +artcraft_client_identity.workspace = true cloudflare_errors.workspace = true enums.workspace = true errors.workspace = true diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/get_media_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/get_media_file.rs index 7beef4ed4b..4dc6aacb69 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/get_media_file.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/get_media_file.rs @@ -29,7 +29,7 @@ pub async fn get_media_file(api_host: &ApiHost, media_file_token: &MediaFileToke debug!("Requesting {:?}", &url); - let response = http_get_anonymous(url).await?; + let response = http_get_anonymous(api_host, url).await?; let response = filter_bad_response(response).await?; let response_body = &response.text().await .map_err(|err| StorytellerError::Api(ApiError::from(err)))?; diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/legacy_upload_media_file_from_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/legacy_upload_media_file_from_file.rs index 7454297470..9d0b0e1d59 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/legacy_upload_media_file_from_file.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/legacy_upload_media_file_from_file.rs @@ -3,7 +3,8 @@ use crate::error::api_error::ApiError; use crate::error::client_error::ClientError; use crate::error::storyteller_error::StorytellerError; use crate::utils::api_host::ApiHost; -use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::constants::APPLICATION_JSON; +use crate::utils::storyteller_client_builder::storyteller_client_builder; use crate::utils::filter_bad_response::filter_bad_response; use crate::utils::http_get_anonymous::http_get_anonymous; use chrono::{DateTime, Utc}; @@ -11,7 +12,6 @@ use enums::common::generation_provider::GenerationProvider; use uuid_utils::uuid::generate_random_uuid; use log::debug; use reqwest::multipart::{Form, Part}; -use reqwest::Client; use serde_derive::{Deserialize, Serialize}; use std::path::Path; use tokens::tokens::batch_generations::BatchGenerationToken; @@ -56,8 +56,7 @@ pub async fn legacy_upload_media_file_from_file>( //let mut file = File::open(path)?; - let client = Client::builder() - .gzip(true) + let client = storyteller_client_builder(args.api_host) .build() .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; @@ -86,7 +85,6 @@ pub async fn legacy_upload_media_file_from_file>( } let mut request_builder = client.post(url) - .header("User-Agent", USER_AGENT) .header("Accept", APPLICATION_JSON); if let Some(creds) = args.maybe_creds { diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_bytes.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_bytes.rs index f496671b6b..11ee886647 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_bytes.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_bytes.rs @@ -3,7 +3,8 @@ use crate::error::api_error::ApiError; use crate::error::client_error::ClientError; use crate::error::storyteller_error::StorytellerError; use crate::utils::api_host::ApiHost; -use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::constants::APPLICATION_JSON; +use crate::utils::storyteller_client_builder::storyteller_client_builder; use crate::utils::filter_bad_response::filter_bad_response; use crate::utils::http_get_anonymous::http_get_anonymous; use enums::common::generation_provider::GenerationProvider; @@ -11,7 +12,6 @@ use chrono::{DateTime, Utc}; use uuid_utils::uuid::generate_random_uuid; use log::debug; use reqwest::multipart::{Form, Part}; -use reqwest::Client; use serde_derive::{Deserialize, Serialize}; use std::fs::File; use std::io::Read; @@ -72,8 +72,7 @@ pub async fn upload_image_media_file_from_bytes( debug!("Requesting {:?}", &url); - let client = Client::builder() - .gzip(true) + let client = storyteller_client_builder(args.api_host) .build() .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; @@ -95,7 +94,6 @@ pub async fn upload_image_media_file_from_bytes( } let mut request_builder = client.post(url) - .header("User-Agent", USER_AGENT) .header("Accept", APPLICATION_JSON); if let Some(creds) = args.maybe_creds { diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_file.rs index e4902effcf..70105963e9 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_file.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_image_media_file_from_file.rs @@ -3,7 +3,8 @@ use crate::error::api_error::ApiError; use crate::error::client_error::ClientError; use crate::error::storyteller_error::StorytellerError; use crate::utils::api_host::ApiHost; -use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::constants::APPLICATION_JSON; +use crate::utils::storyteller_client_builder::storyteller_client_builder; use crate::utils::filter_bad_response::filter_bad_response; use crate::utils::http_get_anonymous::http_get_anonymous; use chrono::{DateTime, Utc}; @@ -11,7 +12,6 @@ use enums::common::generation_provider::GenerationProvider; use uuid_utils::uuid::generate_random_uuid; use log::debug; use reqwest::multipart::{Form, Part}; -use reqwest::Client; use serde_derive::{Deserialize, Serialize}; use std::path::Path; use tokens::tokens::batch_generations::BatchGenerationToken; @@ -65,8 +65,7 @@ pub async fn upload_image_media_file_from_file_with_idempotency_token>( debug!("Requesting {:?}", &url); - let client = Client::builder() - .gzip(true) + let client = storyteller_client_builder(api_host) .build()?; let file_bytes = std::fs::read(path.as_ref())?; @@ -49,7 +48,6 @@ pub async fn upload_new_engine_asset_from_file>( } let mut request_builder = client.post(url) - .header("User-Agent", USER_AGENT) .header("Accept", APPLICATION_JSON); if let Some(creds) = maybe_creds { diff --git a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_video_media_file_from_file.rs b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_video_media_file_from_file.rs index 7c5a522d15..332a5464aa 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_video_media_file_from_file.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/endpoints/media_files/upload_video_media_file_from_file.rs @@ -1,7 +1,8 @@ use crate::credentials::storyteller_credential_set::StorytellerCredentialSet; use crate::error::api_error::ApiError; use crate::utils::api_host::ApiHost; -use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::constants::APPLICATION_JSON; +use crate::utils::storyteller_client_builder::storyteller_client_builder; use crate::utils::filter_bad_response::filter_bad_response; use crate::utils::http_get_anonymous::http_get_anonymous; use enums::common::generation_provider::GenerationProvider; @@ -9,7 +10,6 @@ use chrono::{DateTime, Utc}; use uuid_utils::uuid::generate_random_uuid; use log::debug; use reqwest::multipart::{Form, Part}; -use reqwest::Client; use serde_derive::{Deserialize, Serialize}; use std::path::Path; use tokens::tokens::batch_generations::BatchGenerationToken; @@ -58,8 +58,7 @@ pub async fn upload_video_media_file_from_file>( debug!("Requesting {:?}", &url); - let client = Client::builder() - .gzip(true) + let client = storyteller_client_builder(args.api_host) .build()?; let file_bytes = std::fs::read(args.path.as_ref())?; @@ -78,7 +77,6 @@ pub async fn upload_video_media_file_from_file>( } let mut request_builder = client.post(url) - .header("User-Agent", USER_AGENT) .header("Accept", APPLICATION_JSON); if let Some(creds) = args.maybe_creds { diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/api_host.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/api_host.rs index f8fd711af3..8ce6f6630b 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/api_host.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/api_host.rs @@ -1,4 +1,6 @@ +use artcraft_client_identity::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN}; + const HTTP_SCHEME: &str = "http"; const HTTPS_SCHEME: &str = "https"; @@ -27,6 +29,15 @@ impl ApiHost { } } + /// The `Origin` the desktop client declares to this API. + pub fn request_origin(&self) -> &'static str { + match self { + ApiHost::Storyteller => ARTCRAFT_DESKTOP_ORIGIN, + ApiHost::FakeYou => ARTCRAFT_DESKTOP_ORIGIN, + ApiHost::Localhost { .. } => ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, + } + } + pub fn scheme(&self) -> &'static str { match self { ApiHost::Storyteller => HTTPS_SCHEME, diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_delete_request.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_delete_request.rs index fbdddaa57d..130be82789 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_delete_request.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_delete_request.rs @@ -3,10 +3,10 @@ use crate::error::api_error::ApiError; use crate::error::client_error::ClientError; use crate::error::storyteller_error::StorytellerError; use crate::utils::api_host::ApiHost; -use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::constants::APPLICATION_JSON; +use crate::utils::storyteller_client_builder::storyteller_client_builder; use crate::utils::filter_bad_response::filter_bad_response; use log::{debug, info}; -use reqwest::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -21,13 +21,11 @@ pub async fn basic_json_delete_request( info!("DELETE {:?}", &url); - let client = Client::builder() - .gzip(true) + let client = storyteller_client_builder(api_host) .build() .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; let mut request_builder = client.delete(url) - .header("User-Agent", USER_AGENT) .header("Accept", APPLICATION_JSON) .header("Content-Type", APPLICATION_JSON); diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_get_request.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_get_request.rs index 2d033bf3bb..70187956c3 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_get_request.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_get_request.rs @@ -3,10 +3,10 @@ use crate::error::api_error::ApiError; use crate::error::client_error::ClientError; use crate::error::storyteller_error::StorytellerError; use crate::utils::api_host::ApiHost; -use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::constants::APPLICATION_JSON; +use crate::utils::storyteller_client_builder::storyteller_client_builder; use crate::utils::filter_bad_response::filter_bad_response; use log::{debug, info}; -use reqwest::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -20,13 +20,11 @@ pub async fn basic_json_get_request( debug!("Requesting {:?}", &url); - let client = Client::builder() - .gzip(true) + let client = storyteller_client_builder(api_host) .build() .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; let mut request_builder = client.get(url) - .header("User-Agent", USER_AGENT) .header("Accept", APPLICATION_JSON); if let Some(creds) = maybe_creds { diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_post_request.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_post_request.rs index b430a7a532..e9dbdecae5 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_post_request.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_json_post_request.rs @@ -3,10 +3,10 @@ use crate::error::api_error::ApiError; use crate::error::client_error::ClientError; use crate::error::storyteller_error::StorytellerError; use crate::utils::api_host::ApiHost; -use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::constants::APPLICATION_JSON; +use crate::utils::storyteller_client_builder::storyteller_client_builder; use crate::utils::filter_bad_response::filter_bad_response; use log::{debug, info}; -use reqwest::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -30,13 +30,11 @@ pub async fn basic_json_post_request( info!("Requesting {:?}", &url); } - let client = Client::builder() - .gzip(true) + let client = storyteller_client_builder(api_host) .build() .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; let mut request_builder = client.post(url) - .header("User-Agent", USER_AGENT) .header("Accept", APPLICATION_JSON) .header("Content-Type", APPLICATION_JSON); diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_query_string_post_request.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_query_string_post_request.rs index 2a15d09730..2e480dc6e0 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/basic_query_string_post_request.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/basic_query_string_post_request.rs @@ -3,11 +3,11 @@ use crate::error::api_error::ApiError; use crate::error::client_error::ClientError; use crate::error::storyteller_error::StorytellerError; use crate::utils::api_host::ApiHost; -use crate::utils::constants::{APPLICATION_JSON, USER_AGENT}; +use crate::utils::constants::APPLICATION_JSON; +use crate::utils::storyteller_client_builder::storyteller_client_builder; use crate::utils::filter_bad_response::filter_bad_response; use errors::AnyhowResult; use log::{debug, info}; -use reqwest::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -26,13 +26,11 @@ pub async fn basic_query_string_post_request( debug!("Requesting {:?}", &url); - let client = Client::builder() - .gzip(true) + let client = storyteller_client_builder(api_host) .build() .map_err(|err| StorytellerError::Client(ClientError::from(err)))?; let mut request_builder = client.post(url) - .header("User-Agent", USER_AGENT) .header("Accept", APPLICATION_JSON); if let Some(creds) = maybe_creds { diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/constants.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/constants.rs index ce9bdc5a18..75d1044564 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/constants.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/constants.rs @@ -1,4 +1 @@ - -pub const USER_AGENT: &str = "storyteller-client/1.0"; - pub const APPLICATION_JSON : &str = "application/json"; diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/http_get_anonymous.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/http_get_anonymous.rs index f4c66d20c7..279f49e01b 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/http_get_anonymous.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/http_get_anonymous.rs @@ -1,14 +1,12 @@ use crate::error::api_error::ApiError; -use reqwest::Client; -const USER_AGENT: &str = "storyteller-client/1.0"; +use crate::utils::api_host::ApiHost; +use crate::utils::storyteller_client_builder::storyteller_client_builder; -pub async fn http_get_anonymous(url: String) -> Result { - let client = Client::builder() - .gzip(true) +pub async fn http_get_anonymous(api_host: &ApiHost, url: String) -> Result { + let client = storyteller_client_builder(api_host) .build()?; let response = client.get(url) - .header("User-Agent", USER_AGENT) .header("Accept", "application/json") //.header("Accept-Encoding", "gzip, deflate, br") .send() diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/login_challenge_client.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/login_challenge_client.rs index b6c8ffe5c7..4d1539494d 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/login_challenge_client.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/login_challenge_client.rs @@ -7,7 +7,7 @@ use serde::de::DeserializeOwned; use url::Url; use crate::utils::api_host::ApiHost; -use crate::utils::constants::USER_AGENT; +use crate::utils::storyteller_client_builder::storyteller_client_builder; /// Native transport for the session bridge. No request/response bodies, cookies, /// or challenge credentials are logged, even at debug level. Never follows redirects. @@ -25,14 +25,13 @@ pub struct LoginChallengeClientError { impl LoginChallengeClient { pub fn new(api_host: &ApiHost) -> Result { - let mut builder = Client::builder(); + let mut builder = storyteller_client_builder(api_host); if matches!(api_host, ApiHost::Localhost { .. }) { builder = builder.no_proxy(); } let client = builder .timeout(Duration::from_secs(15)) .redirect(reqwest::redirect::Policy::none()) - .user_agent(USER_AGENT) .build() .map_err(|_| LoginChallengeClientError::invalid("Unable to initialize login client"))?; Ok(Self { diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs index 8cfd5064ed..c67a22c36f 100644 --- a/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/mod.rs @@ -4,6 +4,7 @@ pub mod basic_json_post_request; pub (crate) mod basic_query_string_post_request; pub (crate) mod filter_bad_response; pub (crate) mod http_get_anonymous; +pub (crate) mod storyteller_client_builder; pub mod api_host; pub mod constants; pub mod status_codes; diff --git a/crates/api_clients/artcraft/artcraft_client/src/utils/storyteller_client_builder.rs b/crates/api_clients/artcraft/artcraft_client/src/utils/storyteller_client_builder.rs new file mode 100644 index 0000000000..074e7aa386 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_client/src/utils/storyteller_client_builder.rs @@ -0,0 +1,17 @@ +use artcraft_client_identity::user_agents::ARTCRAFT_DESKTOP_USER_AGENT; +use reqwest::header::{HeaderMap, HeaderValue, ORIGIN}; +use reqwest::{Client, ClientBuilder}; + +use crate::utils::api_host::ApiHost; + +/// Every request to our API is built from this so the desktop client identifies itself +/// consistently (`User-Agent` and `Origin`). Only use it for Storyteller / ArtCraft APIs. +pub(crate) fn storyteller_client_builder(api_host: &ApiHost) -> ClientBuilder { + let mut headers = HeaderMap::new(); + headers.insert(ORIGIN, HeaderValue::from_static(api_host.request_origin())); + + Client::builder() + .user_agent(ARTCRAFT_DESKTOP_USER_AGENT) + .default_headers(headers) + .gzip(true) +} diff --git a/crates/lib/artcraft_client_identity/Cargo.toml b/crates/lib/artcraft_client_identity/Cargo.toml new file mode 100644 index 0000000000..42b64dcd5e --- /dev/null +++ b/crates/lib/artcraft_client_identity/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "artcraft_client_identity" +edition = "2024" +version = "0.0.1" +publish = false + +[lib] +name = "artcraft_client_identity" +path = "src/lib.rs" + +[dependencies] + +# External +url.workspace = true + +[dev-dependencies] +# (None) diff --git a/crates/lib/artcraft_client_identity/src/destination.rs b/crates/lib/artcraft_client_identity/src/destination.rs new file mode 100644 index 0000000000..b7df16d23b --- /dev/null +++ b/crates/lib/artcraft_client_identity/src/destination.rs @@ -0,0 +1,166 @@ +use url::{Host, Url}; + +use crate::origins::{ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN, ARTCRAFT_DESKTOP_ORIGIN}; +use crate::third_party_provider::{ThirdPartyProvider, THIRD_PARTY_PROVIDERS}; + +/// Hostnames of our deployed APIs (HTTPS only). +const ARTCRAFT_API_HOSTNAMES: [&str; 3] = [ + "api.storyteller.ai", + "api.getartcraft.com", + "api.fakeyou.com", +]; + +/// Domains we own. Subdomains match too (CDNs, uploads, websites). +const ARTCRAFT_DOMAINS: [&str; 4] = [ + "storyteller.ai", + "getartcraft.com", + "fakeyou.com", + "artcraft.ai", +]; + +/// Who a request is going to, which decides how the desktop app identifies itself. +#[derive(Debug, PartialEq, Eq)] +pub enum Destination { + /// Our HTTP API, deployed or running locally, and the `Origin` to declare to it. + ArtcraftApi { origin: &'static str }, + /// Our other hosts: CDNs, uploads, websites. + ArtcraftHost, + /// A known third-party provider. + ThirdParty(&'static ThirdPartyProvider), + /// Anything else. + Unknown, +} + +impl Destination { + pub fn classify(destination: &Url) -> Self { + if is_local_development_api(destination) { + return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN }; + } + let Some(host) = destination.host_str() else { + return Self::Unknown; + }; + if destination.scheme() == "https" && ARTCRAFT_API_HOSTNAMES.contains(&host) { + return Self::ArtcraftApi { origin: ARTCRAFT_DESKTOP_ORIGIN }; + } + if ARTCRAFT_DOMAINS.iter().any(|domain| is_domain_or_subdomain(host, domain)) { + return Self::ArtcraftHost; + } + THIRD_PARTY_PROVIDERS + .iter() + .find(|provider| provider.domains.iter().any(|domain| is_domain_or_subdomain(host, domain))) + .map_or(Self::Unknown, Self::ThirdParty) + } +} + +fn is_local_development_api(destination: &Url) -> bool { + if !matches!(destination.scheme(), "http" | "https") { + return false; + } + match destination.host() { + Some(Host::Domain("localhost")) => true, + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + _ => false, + } +} + +fn is_domain_or_subdomain(host: &str, domain: &str) -> bool { + host == domain + || host + .strip_suffix(domain) + .is_some_and(|prefix| prefix.ends_with('.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + mod artcraft { + use super::*; + + #[test] + fn deployed_apis_get_desktop_origin() { + for destination in [ + "https://api.storyteller.ai/v1/session", + "https://api.getartcraft.com/v1/session", + "https://api.fakeyou.com/v1/session", + ] { + assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "https://desktop.getartcraft.com" }); + } + } + + #[test] + fn local_apis_get_development_origin() { + for destination in [ + "http://localhost:12345/v1/session", + "https://localhost:12345/v1/session", + "http://127.0.0.1:12345/v1/session", + "http://[::1]:12345/v1/session", + ] { + assert_eq!(classify(destination), Destination::ArtcraftApi { origin: "http://localhost" }); + } + } + + #[test] + fn other_owned_hosts_are_not_apis() { + for destination in [ + "https://cdn-2.fakeyou.com/media/a/b.png", + "https://cdn.storyteller.ai/media/a/b.png", + "https://style.storyteller.ai/", + "https://app.getartcraft.com/", + "https://getartcraft.com/", + "http://api.storyteller.ai/v1/session", + ] { + assert_eq!(classify(destination), Destination::ArtcraftHost, "{destination}"); + } + } + } + + mod third_party { + use super::*; + + #[test] + fn providers_get_their_own_origins() { + for (destination, name, origin) in [ + ("https://grok.com/rest/app-chat/conversations/new", "Grok", "https://grok.com"), + ("https://assets.grok.com/users/a/b.mp4", "Grok", "https://grok.com"), + ("https://imagine-public.x.ai/a.png", "Grok", "https://grok.com"), + ("https://www.midjourney.com/api/submit-jobs", "Midjourney", "https://www.midjourney.com"), + ("https://cdn.midjourney.com/a/0_0.png", "Midjourney", "https://www.midjourney.com"), + ("https://sora.chatgpt.com/backend/nf/create", "Sora", "https://sora.chatgpt.com"), + ("https://videos.openai.com/a.mp4", "Sora", "https://sora.chatgpt.com"), + ("https://chatgpt.com/backend-api/sentinel", "ChatGPT", "https://chatgpt.com"), + ("https://api.worldlabs.ai/api/v1/worlds", "World Labs", "https://marble.worldlabs.ai"), + ("https://static.kinovi.ai/a.png", "Kinovi", "https://kinovi.ai"), + ("https://static.seedance2-pro.com/materials/a.png", "Seedance 2 Pro", "https://seedance2-pro.com"), + ("https://v3b.fal.media/files/a.png", "fal", "https://fal.ai"), + ("https://console.gmicloud.ai/api", "GMI Cloud", "https://console.gmicloud.ai"), + ] { + let Destination::ThirdParty(provider) = classify(destination) else { + panic!("{destination} is not a third party"); + }; + assert_eq!((provider.name, provider.origin), (name, origin)); + } + } + + #[test] + fn lookalikes_and_others_are_unknown() { + for destination in [ + "https://api.storyteller.ai.example.com/v1/session", + "https://notstoryteller.ai/", + "https://evilgrok.com/", + "https://grok.com.example.com/", + "https://www.gravatar.com/avatar/a", + "https://storage.googleapis.com/bucket/a.png", + "http://192.168.1.2:12345/v1/session", + "tauri://localhost", + ] { + assert_eq!(classify(destination), Destination::Unknown, "{destination}"); + } + } + } + + fn classify(destination: &str) -> Destination { + Destination::classify(&Url::parse(destination).unwrap()) + } +} diff --git a/crates/lib/artcraft_client_identity/src/lib.rs b/crates/lib/artcraft_client_identity/src/lib.rs new file mode 100644 index 0000000000..5706dd5849 --- /dev/null +++ b/crates/lib/artcraft_client_identity/src/lib.rs @@ -0,0 +1,12 @@ +//! artcraft_client_identity +//! +//! How the ArtCraft desktop app identifies itself (`Origin`, `User-Agent`) to each destination. +//! +//! Our own APIs get the declared ArtCraft desktop identity. Known third-party providers get an +//! identity close to their own website, and never ours. The native third-party Rust clients +//! (Grok, Midjourney, Sora, etc.) set their own headers and do not use this crate. + +pub mod destination; +pub mod origins; +pub mod third_party_provider; +pub mod user_agents; diff --git a/crates/lib/artcraft_client_identity/src/origins.rs b/crates/lib/artcraft_client_identity/src/origins.rs new file mode 100644 index 0000000000..761d827929 --- /dev/null +++ b/crates/lib/artcraft_client_identity/src/origins.rs @@ -0,0 +1,11 @@ +/// `Origin` sent to our deployed APIs. +/// Keep in sync with `actix_cors_configs/src/configs/artcraft_desktop.rs` in artcraft-services. +pub const ARTCRAFT_DESKTOP_ORIGIN: &str = "https://desktop.getartcraft.com"; + +/// `Origin` sent to a locally running API (eg. `http://localhost:12345`). +/// Development CORS accepts any `localhost` origin. +pub const ARTCRAFT_DESKTOP_DEVELOPMENT_ORIGIN: &str = "http://localhost"; + +/// `Origin` packaged builds historically sent everywhere. Now only sent to our non-API hosts +/// (CDNs, websites), where it is left unchanged. +pub const LEGACY_DESKTOP_ORIGIN: &str = "https://studio.storyteller.ai"; diff --git a/crates/lib/artcraft_client_identity/src/third_party_provider.rs b/crates/lib/artcraft_client_identity/src/third_party_provider.rs new file mode 100644 index 0000000000..4224b76c78 --- /dev/null +++ b/crates/lib/artcraft_client_identity/src/third_party_provider.rs @@ -0,0 +1,30 @@ +/// A third-party provider, and the `Origin` of its own website that we present to its hosts. +#[derive(Debug, PartialEq, Eq)] +pub struct ThirdPartyProvider { + pub name: &'static str, + /// Registrable domains; subdomains match too. + pub domains: &'static [&'static str], + pub origin: &'static str, +} + +/// Every third party that gets an invented origin. Origins match what each provider's native +/// Rust client already sends. First match wins, so more specific entries go first. +pub const THIRD_PARTY_PROVIDERS: [ThirdPartyProvider; 9] = [ + ThirdPartyProvider { name: "Grok", domains: &["grok.com", "x.ai"], origin: "https://grok.com" }, + ThirdPartyProvider { name: "Midjourney", domains: &["midjourney.com"], origin: "https://www.midjourney.com" }, + ThirdPartyProvider { + name: "Sora", + domains: &["sora.chatgpt.com", "sora.com", "openai.com", "oaistatic.com"], + origin: "https://sora.chatgpt.com", + }, + ThirdPartyProvider { name: "ChatGPT", domains: &["chatgpt.com"], origin: "https://chatgpt.com" }, + ThirdPartyProvider { + name: "World Labs", + domains: &["worldlabs.ai", "wlt-ai.art"], + origin: "https://marble.worldlabs.ai", + }, + ThirdPartyProvider { name: "Kinovi", domains: &["kinovi.ai"], origin: "https://kinovi.ai" }, + ThirdPartyProvider { name: "Seedance 2 Pro", domains: &["seedance2-pro.com"], origin: "https://seedance2-pro.com" }, + ThirdPartyProvider { name: "fal", domains: &["fal.ai", "fal.run", "fal.media"], origin: "https://fal.ai" }, + ThirdPartyProvider { name: "GMI Cloud", domains: &["gmicloud.ai"], origin: "https://console.gmicloud.ai" }, +]; diff --git a/crates/lib/artcraft_client_identity/src/user_agents.rs b/crates/lib/artcraft_client_identity/src/user_agents.rs new file mode 100644 index 0000000000..d4c54aeab5 --- /dev/null +++ b/crates/lib/artcraft_client_identity/src/user_agents.rs @@ -0,0 +1,23 @@ +/// `User-Agent` sent to our own APIs and hosts. +/// +/// TODO: Change to "artcraft-desktop/1.0" once the backend services can parse it. +pub const ARTCRAFT_DESKTOP_USER_AGENT: &str = "storyteller-client/1.0"; + +/// `User-Agent` sent to third parties: the default of the OS webview Tauri runs on +/// (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux). +/// +/// NB: These are hardcoded snapshots and WILL go out of date as the OS webviews update +/// (especially WebView2's Chrome/Edge version, which auto-updates on Windows). Rust can't read +/// the webview's real `navigator.userAgent`, so refresh these periodically by checking +/// `navigator.userAgent` in the desktop app's devtools on each OS. +#[cfg(target_os = "macos")] +pub const WEBVIEW_USER_AGENT: &str = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)"; + +#[cfg(target_os = "windows")] +pub const WEBVIEW_USER_AGENT: &str = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0"; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +pub const WEBVIEW_USER_AGENT: &str = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko)"; diff --git a/crates/vendor/tauri-plugin-http/Cargo.toml b/crates/vendor/tauri-plugin-http/Cargo.toml index fd5fb7c577..feb1e79e25 100644 --- a/crates/vendor/tauri-plugin-http/Cargo.toml +++ b/crates/vendor/tauri-plugin-http/Cargo.toml @@ -36,6 +36,7 @@ urlpattern = "0.3" regex = "1" [dependencies] +artcraft_client_identity = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tauri = { workspace = true } diff --git a/crates/vendor/tauri-plugin-http/src/artcraft_identity.rs b/crates/vendor/tauri-plugin-http/src/artcraft_identity.rs new file mode 100644 index 0000000000..50789b642c --- /dev/null +++ b/crates/vendor/tauri-plugin-http/src/artcraft_identity.rs @@ -0,0 +1,207 @@ +use artcraft_client_identity::destination::Destination; +use artcraft_client_identity::origins::LEGACY_DESKTOP_ORIGIN; +use artcraft_client_identity::user_agents::{ARTCRAFT_DESKTOP_USER_AGENT, WEBVIEW_USER_AGENT}; +use http::{header, HeaderMap, HeaderValue}; +use url::{Host, Url}; + +/// The `User-Agent` to send when the frontend didn't set one. +pub(crate) fn artcraft_default_user_agent(destination: &Url) -> &'static str { + match Destination::classify(destination) { + Destination::ArtcraftApi { .. } | Destination::ArtcraftHost => ARTCRAFT_DESKTOP_USER_AGENT, + Destination::ThirdParty(_) | Destination::Unknown => WEBVIEW_USER_AGENT, + } +} + +/// Replace the webview's own `Origin` (packaged `tauri://localhost` etc., or a Vite loopback +/// origin at any port) with the identity chosen for the destination. The native HTTP bridge +/// shares the desktop cookie jar, so its identity must not depend on the webview. +pub(crate) fn normalize_artcraft_origin(destination: &Url, headers: &mut HeaderMap) { + let origin = headers.get(header::ORIGIN).and_then(|value| value.to_str().ok()); + // Packaged-app webview origins on macOS and Windows. + let packaged_origin = headers.get(header::ORIGIN).is_none() + || matches!(origin, Some("null" | "http://tauri.localhost" | "tauri://localhost")); + // Development webview origins (Vite on any loopback port). + let webview_origin = packaged_origin || origin.is_some_and(is_loopback_origin); + + let replacement = match Destination::classify(destination) { + // Our APIs (deployed or local) see the declared desktop identity. + Destination::ArtcraftApi { origin } if webview_origin => origin, + // Known third parties see an origin close to their own website. + Destination::ThirdParty(provider) if webview_origin => provider.origin, + // Our CDNs and websites keep the historical packaged-app handling. + Destination::ArtcraftHost if packaged_origin => LEGACY_DESKTOP_ORIGIN, + // Unknown hosts and ordinary website origins are left untouched. + _ => return, + }; + + // HeaderMap is a multimap; insert replaces all existing Origin values. + headers.insert(header::ORIGIN, HeaderValue::from_static(replacement)); +} + +fn is_loopback_origin(origin: &str) -> bool { + let Ok(url) = Url::parse(origin) else { + return false; + }; + if !matches!(url.scheme(), "http" | "https") { + return false; + } + match url.host() { + Some(Host::Domain("localhost")) => true, + Some(Host::Ipv4(address)) => address.is_loopback(), + Some(Host::Ipv6(address)) => address.is_loopback(), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const API: &str = "https://api.storyteller.ai/v1/session"; + const LOCAL_API: &str = "http://localhost:12345/v1/session"; + const CDN: &str = "https://cdn-2.fakeyou.com/media/a/b.png"; + const GCS: &str = "https://storage.googleapis.com/bucket/a.png"; + const GROK: &str = "https://grok.com/rest/app-chat/conversations/new"; + const MIDJOURNEY: &str = "https://www.midjourney.com/api/submit-jobs"; + const UNKNOWN: &str = "https://www.gravatar.com/avatar/a"; + + const DEV_WEBVIEW_ORIGIN: &str = "http://127.0.0.1:5193"; + const PACKAGED_ORIGINS: [Option<&str>; 4] = + [None, Some("null"), Some("http://tauri.localhost"), Some("tauri://localhost")]; + + mod development { + use super::*; + + #[test] + fn normalizes_dynamic_ports_and_loopback_hosts() { + for origin in [ + "http://127.0.0.1:5193", + "http://127.0.0.1:5194", + "http://127.0.0.1:65535", + "http://localhost:5173", + "http://[::1]:5193", + ] { + assert_eq!(normalized(API, Some(origin)), "https://desktop.getartcraft.com"); + assert_eq!(normalized(LOCAL_API, Some(origin)), "http://localhost"); + assert_eq!(normalized(GROK, Some(origin)), "https://grok.com"); + } + assert_eq!( + normalized("https://api.fakeyou.com/v1/session", Some(DEV_WEBVIEW_ORIGIN)), + "https://desktop.getartcraft.com" + ); + } + + #[test] + fn leaves_cdns_and_unknown_destinations_unchanged() { + for destination in [ + CDN, + GCS, + UNKNOWN, + "https://api.storyteller.ai.example.com/v1/session", + "http://api.storyteller.ai/v1/session", + ] { + assert_eq!(normalized(destination, Some(DEV_WEBVIEW_ORIGIN)), DEV_WEBVIEW_ORIGIN, "{destination}"); + } + } + + #[test] + fn leaves_non_webview_origins_unchanged() { + for origin in [ + "https://app.getartcraft.com", + "https://example.com", + "http://localhost.example.com:5193", + "http://192.168.1.2:5193", + "not a URL", + "", + ] { + assert_eq!(normalized(API, Some(origin)), origin); + assert_eq!(normalized(GROK, Some(origin)), origin); + } + } + } + + mod packaged { + use super::*; + + #[test] + fn our_apis_get_desktop_identity() { + for origin in PACKAGED_ORIGINS { + assert_eq!(normalized(API, origin), "https://desktop.getartcraft.com"); + assert_eq!(normalized(LOCAL_API, origin), "http://localhost"); + } + } + + #[test] + fn third_parties_get_their_own_origin() { + for origin in PACKAGED_ORIGINS { + assert_eq!(normalized(GROK, origin), "https://grok.com"); + assert_eq!(normalized(MIDJOURNEY, origin), "https://www.midjourney.com"); + } + } + + #[test] + fn cdns_keep_legacy_origin() { + for origin in PACKAGED_ORIGINS { + assert_eq!(normalized(CDN, origin), "https://studio.storyteller.ai"); + } + } + + #[test] + fn unknown_destinations_never_get_our_origin() { + for destination in [UNKNOWN, GCS] { + assert_eq!(normalized(destination, Some("tauri://localhost")), "tauri://localhost"); + assert_eq!(normalized(destination, Some("null")), "null"); + assert_eq!(normalized_or_missing(destination, None), None); + } + } + + #[test] + fn replaces_duplicate_origin_values_without_changing_session_headers() { + let mut headers = HeaderMap::new(); + headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN)); + headers.append(header::ORIGIN, HeaderValue::from_static(DEV_WEBVIEW_ORIGIN)); + headers.insert(header::COOKIE, HeaderValue::from_static("session=fixture")); + normalize_artcraft_origin(&Url::parse(API).unwrap(), &mut headers); + assert_eq!(headers.get_all(header::ORIGIN).iter().count(), 1); + assert_eq!(headers[header::ORIGIN], "https://desktop.getartcraft.com"); + assert_eq!(headers[header::COOKIE], "session=fixture"); + } + } + + mod user_agent { + use super::*; + + #[test] + fn ours_get_artcraft_user_agent() { + for destination in [API, LOCAL_API, CDN] { + assert_eq!(user_agent(destination), "storyteller-client/1.0"); + } + } + + #[test] + fn third_parties_get_webview_user_agent() { + for destination in [GROK, MIDJOURNEY, GCS, UNKNOWN] { + let user_agent = user_agent(destination); + assert!(user_agent.starts_with("Mozilla/5.0 ("), "{user_agent}"); + assert!(!user_agent.contains("storyteller"), "{user_agent}"); + } + } + + fn user_agent(destination: &str) -> &'static str { + artcraft_default_user_agent(&Url::parse(destination).unwrap()) + } + } + + fn normalized(destination: &str, origin: Option<&str>) -> String { + normalized_or_missing(destination, origin).unwrap() + } + + fn normalized_or_missing(destination: &str, origin: Option<&str>) -> Option { + let mut headers = HeaderMap::new(); + if let Some(origin) = origin { + headers.insert(header::ORIGIN, HeaderValue::from_str(origin).unwrap()); + } + normalize_artcraft_origin(&Url::parse(destination).unwrap(), &mut headers); + headers.get(header::ORIGIN).map(|value| value.to_str().unwrap().to_owned()) + } +} diff --git a/crates/vendor/tauri-plugin-http/src/commands.rs b/crates/vendor/tauri-plugin-http/src/commands.rs index 7093a7270b..d051bec48b 100644 --- a/crates/vendor/tauri-plugin-http/src/commands.rs +++ b/crates/vendor/tauri-plugin-http/src/commands.rs @@ -16,12 +16,11 @@ use tauri::{ use tokio::sync::oneshot::{channel, Receiver, Sender}; use crate::{ + artcraft_identity::{artcraft_default_user_agent, normalize_artcraft_origin}, scope::{Entry, Scope}, Error, Http, Result, }; -const HTTP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); - struct ReqwestResponse(reqwest::Response); impl tauri::Resource for ReqwestResponse {} @@ -269,7 +268,7 @@ pub async fn fetch( builder = builder.cookie_provider(state.cookies_jar.clone()); } - let mut request = builder.build()?.request(method.clone(), url); + let mut request = builder.build()?.request(method.clone(), url.clone()); // POST and PUT requests should always have a 0 length content-length, // if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch @@ -284,7 +283,7 @@ pub async fn fetch( } if !headers.contains_key(header::USER_AGENT) { - headers.append(header::USER_AGENT, HeaderValue::from_str(HTTP_USER_AGENT)?); + headers.append(header::USER_AGENT, HeaderValue::from_static(artcraft_default_user_agent(&url))); } // ensure we have an Origin header set @@ -305,26 +304,7 @@ pub async fn fetch( headers.remove(header::ORIGIN); }; - // NB(bt): In production builds, the origin seems to be "null". This is what our server CORS filter sees: - // [2025-05-01T23:42:35Z WARN actix_cors_configs::configs::fakeyou] Invalid origin: "null" - // [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null" - // [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null" - // ... - // [2025-05-01T23:42:35Z WARN actix_cors_configs::util::netlify_branch_domain_matches] Invalid origin: "null" - // [2025-05-01T23:42:35Z INFO actix_web::middleware::logger] [storyteller-web-85f9b4974-ddvq7] IP=[redact] - // 939322e1095ee5e4-IAD "POST /v1/login HTTP/1.1" 400 42 "-" "tauri-plugin-http/2.4.3" 0.000076 - let mut maybe_origin_header = headers.get(header::ORIGIN); - - let replace_origin_header = maybe_origin_header.is_none() - || maybe_origin_header == Some(&HeaderValue::from_static("null")) // Above error (Mac) - || maybe_origin_header == Some(&HeaderValue::from_static("http://tauri.localhost")) // Windows - || maybe_origin_header == Some(&HeaderValue::from_static("tauri://localhost")); // Mac plausibly causes this - - if replace_origin_header { - // TODO(bt): Make this a sentinel origin, eg. sentinel.storyteller.ai. - // NB: HeadersMap is a multimap, but this should ensure we just have one value. - headers.insert(header::ORIGIN, HeaderValue::from_static("https://studio.storyteller.ai")); - } + normalize_artcraft_origin(&url, &mut headers); if let Some(data) = data { request = request.body(data); diff --git a/crates/vendor/tauri-plugin-http/src/lib.rs b/crates/vendor/tauri-plugin-http/src/lib.rs index 1da8993fba..27074e440a 100644 --- a/crates/vendor/tauri-plugin-http/src/lib.rs +++ b/crates/vendor/tauri-plugin-http/src/lib.rs @@ -12,6 +12,8 @@ use tauri::{ pub use error::{Error, Result}; +mod artcraft_identity; + // NB(bt): We're making everything public so we can access it. pub mod commands; pub mod error; diff --git a/docs/desktop-session-regression.md b/docs/desktop-session-regression.md new file mode 100644 index 0000000000..28a87afb90 --- /dev/null +++ b/docs/desktop-session-regression.md @@ -0,0 +1,189 @@ +# Desktop session regression — September 27, 2026 + +The combined development launcher (`92e6f438c3`, following the performance +change `df707bf8e1`) moved Vite from `localhost:5173` to an available port on +`127.0.0.1`. The vendored HTTP plugin forwarded that webview Origin to the +production API. The API rejected it with **400: Origin is not allowed to make +this request**. + +The native login bridge and credits commands use a different HTTP transport and +continued working. Consequently the login modal correctly recognized the native +session, but Accounts, Library, and page media requests failed. Accounts treated +the error as logged out; its Log In button rechecked the valid native session +and immediately closed the login modal again. + +The fix extends the existing packaged-desktop Origin normalization to loopback +development origins, at any port, for HTTPS requests to `api.storyteller.ai` and +`api.fakeyou.com`. Other development request destinations and non-loopback +website origins retain their headers. Cookie handling and API authorization are +unchanged. A frontend reload alone cannot update this fix: Rust must rebuild. + +## Dedicated desktop origin and rollout + +The desktop app now declares itself per destination. Both transports read the +values from one place, `crates/lib/artcraft_client_identity` +(`Destination::classify`, origins, user agents, third-party provider table): + +| Destination | Origin sent | User-Agent | +|--------------------------------------------------------|-------------------------------------|--------------------------| +| Our APIs (`api.storyteller.ai`, `api.fakeyou.com`, …) | `https://desktop.getartcraft.com` | `storyteller-client/1.0` | +| Local API (`localhost`, `127.0.0.1`, `[::1]`) | `http://localhost` | `storyteller-client/1.0` | +| Our CDNs / sites (`*.storyteller.ai`, `*.fakeyou.com`) | unchanged (`studio.storyteller.ai`) | `storyteller-client/1.0` | +| Named third parties (`THIRD_PARTY_PROVIDERS`) | the provider's own site | OS webview | +| Any other host (incl. `storage.googleapis.com`) | webview origin, untouched | OS webview | + +- The native Rust client (`artcraft_client`) sends the Origin and User-Agent on + every API request via `storyteller_client_builder`; it previously sent no + Origin. +- The "OS webview" User-Agents are hardcoded snapshots in + `artcraft_client_identity/src/user_agents.rs` and go out of date as the + webviews update (notably WebView2's Edge version on Windows). Refresh them + from `navigator.userAgent` in the desktop app's devtools on each OS. +- The vendored HTTP plugin only replaces the webview's own origin (packaged + `tauri://localhost` etc., or a loopback Vite origin), and only sets the + User-Agent when the frontend didn't. +- Native third-party Rust clients (Grok, Midjourney, Sora, World Labs, Kinovi) + set their own headers and are not affected. +- Older desktop releases keep working: the server still allows the studio and + Tauri origins, and requests without an Origin. + +Keep the deployed origin in sync with +`../artcraft-services/crates/lib/actix_cors_configs/src/configs/artcraft_desktop.rs`. +Development servers accept any `localhost` origin. + +Deploy the server allowlist to the APIs **before running or releasing a desktop +build with the new origin**. Otherwise the deployed server will reject desktop +requests until its allowlist is updated. The server retains the studio and +legacy Tauri origins so existing desktop releases continue working. + +Check both repositories: + +```sh +# From artcraft/ +cargo test --offline -p artcraft_client_identity -p tauri-plugin-http --lib + +# From artcraft-services/ +cargo test --offline -p actix_cors_configs --lib +``` + +The server tests verify credentialed GET/POST requests and OPTIONS preflights in +production and development, rejection of lookalike domains/HTTP/alternate ports, +and compatibility with previous desktop origins. After server deployment, repeat +the native checks below using the updated app. + +## Observed native results + +These observations were captured with the earlier studio origin, before the +dedicated desktop origin migration. They do not establish that production has +deployed the new allowlist. + +Checked in the real macOS Tauri dev app at `http://127.0.0.1:5193`, using its +existing session. Temporary probes were removed after verification. No cookies, +tokens, passwords, account identifiers, or media contents are recorded here. + +| Check | Before | After | +|--------------------------------|---------------------------------------|----------------------------| +| Frontend HTTP `/v1/session` | 400, Origin rejected | 200 | +| Shared `UsersApi.GetSession()` | `success: false` | Success, logged in | +| Native vs. frontend identity | Frontend could not resolve an account | Same account | +| Shared media API, page size 1 | Not measured separately | Success, one item returned | + +## Automated checks + +From the repository root: + +```sh +cargo test --offline -p tauri-plugin-http artcraft_origin --lib +./script/artcraft/unix_dev.sh +``` + +In a second terminal, substitute the URL printed by the launcher: + +```sh +cd frontend +npm run test:desktop-session -- http://127.0.0.1:5193 +``` + +The Rust tests cover the default port, alternate/collision ports, IPv4, IPv6, +localhost, packaged origins, duplicate headers, session-header preservation, +and destinations/origins that must remain unchanged. + +The browser check uses the real frontend with an isolated Chrome profile and +mocked IPC. It verifies matching credits and Accounts state, a visible Library +image, media after opening the deferred image page, video/audio page rendering, +and logout followed by sign-in. It blocks remote browser traffic and never uses +your real account or native credentials. It requires installed Google Chrome. +It can also run against a locally served production frontend build. + +These browser mocks cannot validate native transport or server CORS. The +performance fixture deliberately rejects HTTP requests and is **not** an +authentication acceptance test. Run the native check below when changing the +launcher, HTTP bridge, or authentication plumbing. + +Validation on September 27: all 12 HTTP-plugin tests, 20 login-modal tests, and +10 app tests passed. The browser check passed against both the Vite dev server +and a fresh production build. To repeat the production check, serve a build in +one terminal, then pass its printed URL to `test:desktop-session` in another: + +```sh +cd frontend/apps/artcraft +npm run build -- --outDir /tmp/artcraft-session-build +npm run preview -- --outDir /tmp/artcraft-session-build --host 127.0.0.1 --port 6215 +``` + +## Repeat the real native check + +1. Start the combined launcher, with an existing signed-in test session. Verify + Accounts displays that account, My Library loads its media, and Create Image, + Create Video, and Create Audio load their content. +2. Right-click inside the desktop webview, choose **Inspect Element**, and open + **Console**. Run this read-only probe. It prints statuses/counts, not credentials + or account/media contents: + +```js +void (async () => { + const invoke = window.__TAURI_INTERNALS__.invoke; + const host = (await invoke("get_app_info_command")).payload.storyteller_host; + const options = { method: "GET", credentials: "include" }; + const started = performance.now(); + const [native, response] = await Promise.all([ + invoke("storyteller_get_login_session_command"), + window.FetchProxy(`${host}/v1/session`, { ...options }), + ]); + if (!response.ok) { + console.log({ origin: location.origin, sessionStatus: response.status }); + return; + } + const session = await response.json(); + const result = { + origin: location.origin, + sessionStatus: response.status, + nativeLoggedIn: !!native, + frontendLoggedIn: session.logged_in, + sameAccount: native?.user_token === session.user?.user_token, + sessionElapsedMs: performance.now() - started, + }; + if (session.logged_in && session.user) { + const username = encodeURIComponent(session.user.username); + const media = await window.FetchProxy( + `${host}/v1/media_files/list/user/${username}?page_size=1`, { ...options }, + ); + result.mediaStatus = media.status; + if (media.ok) result.mediaCount = (await media.json()).results?.length; + } + console.log(result); +})().catch((error) => console.error("Desktop session probe failed", String(error))); +``` + +3. Repeat with a changed port (`ARTCRAFT_DEV_PORT=6200`) and with the starting + port occupied. For example, keep this listener running in another terminal + before starting the launcher; stop it with Ctrl-C afterward: + +```sh +node -e 'require("node:net").createServer().listen(5193, "127.0.0.1")' +``` + +The launcher should select a later port, and both session transports should +still agree. An empty account may legitimately return zero media; it should +still return HTTP 200. Verify a genuinely signed-out session shows the login +screen using the isolated browser check, or a disposable account/profile. diff --git a/docs/performance.md b/docs/performance.md index 04162b2039..dda6b787cd 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -6,6 +6,11 @@ headless Chrome with mocked Tauri services. They do **not** measure native process launch, WKWebView/WebView2, Rust, disk startup, real account loading, or generation latency. +These timings are not authentication acceptance tests: the benchmark mocks the +native session and rejects frontend HTTP requests. Before accepting a desktop +change, also run the [session and Library checks](desktop-session-regression.md), +including real Tauri HTTP requests when changing the launcher or bridge. + ## Changes and results Seven samples per version; table values are medians. Each sample uses a fresh diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx index b81a06f9bc..88ee789b65 100644 --- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx +++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx @@ -596,6 +596,7 @@ export const TopBar = ({ pageName }: Props) => {