From 459e7195f71219022cdd524d530d4b3744d894a2 Mon Sep 17 00:00:00 2001 From: Milos Stankovic Date: Fri, 28 Aug 2026 18:38:29 +0200 Subject: [PATCH 01/14] feat(app): add native Android extension support --- rust/src/application/client.rs | 60 ++++- rust/src/application/commands/add.rs | 206 +++++++++++++++++- .../application/commands/deploy/extensions.rs | 1 + rust/src/application/commands/deploy/mod.rs | 1 + rust/src/application/commands/init.rs | 1 + rust/src/application/commands/release.rs | 198 ++++++++++++++++- rust/src/application/commands/schemas.rs | 7 + rust/src/config/mod.rs | 153 +++++++++++++ 8 files changed, 619 insertions(+), 8 deletions(-) diff --git a/rust/src/application/client.rs b/rust/src/application/client.rs index f6d99b34..19a36926 100644 --- a/rust/src/application/client.rs +++ b/rust/src/application/client.rs @@ -188,7 +188,7 @@ impl ApplicationClient { pub async fn create_release(&self, input: Value) -> Result { self.query(json!({ - "query": "mutation CreateRelease($input: MutationCreateReleaseInput!) { createRelease(input: $input) { id version description createdAt uiExtensions { id name handle type source target } settings { id groupSlug appSettingSlug entryPath capabilities order title } } }", + "query": "mutation CreateRelease($input: MutationCreateReleaseInput!) { createRelease(input: $input) { id version description createdAt uiExtensions { id name handle type source target } nativeExtensions { id name packageName contact platform } settings { id groupSlug appSettingSlug entryPath capabilities order title } } }", "variables": { "input": input } })) .await @@ -545,6 +545,64 @@ mod tests { ); } + #[tokio::test] + async fn create_release_sends_native_extensions_input_and_selects_them() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(POST) + .path("/v1/apps/app-registry-subgraph") + .header("authorization", "Bearer test-token") + .is_true(|req| { + let body = req.body_string(); + body.contains("CreateRelease") + && body.contains( + "nativeExtensions { id name packageName contact platform }", + ) + && body.contains(r#""platform":"ANDROID""#) + && body.contains(r#""packageName":"com.example.app""#) + }); + then.status(200).json_body(json!({ + "data": { + "createRelease": { + "id": "rel-1", + "version": "1.0.11", + "nativeExtensions": [{ + "id": "ne-1", + "name": "My Display Name", + "packageName": "com.example.app", + "contact": "support@example.com", + "platform": "ANDROID" + }] + } + } + })); + }) + .await; + + let input = json!({ + "applicationId": "app-123", + "version": "1.0.11", + "nativeExtensions": [{ + "platform": "ANDROID", + "name": "My Display Name", + "contact": "support@example.com", + "packageName": "com.example.app" + }] + }); + let data = ApplicationClient::new(server.base_url(), "test-token") + .create_release(input) + .await + .expect("create release"); + + mock.assert_async().await; + assert_eq!(data["createRelease"]["nativeExtensions"][0]["id"], "ne-1"); + assert_eq!( + data["createRelease"]["nativeExtensions"][0]["packageName"], + "com.example.app" + ); + } + #[tokio::test] async fn activate_release_surfaces_graphql_errors() { let server = MockServer::start_async().await; diff --git a/rust/src/application/commands/add.rs b/rust/src/application/commands/add.rs index db8a9925..bec2c8cc 100644 --- a/rust/src/application/commands/add.rs +++ b/rust/src/application/commands/add.rs @@ -6,7 +6,7 @@ use cli_engine::{ }; use serde_json::json; -use super::schemas::{ConfigAction, ConfigSetting, ConfigSubscription}; +use super::schemas::{ConfigAction, ConfigNativeExtension, ConfigSetting, ConfigSubscription}; #[derive(Debug, Clone, clap::Args)] struct ActionArgs { @@ -80,12 +80,41 @@ struct SubscriptionArgs { events: Vec, } +#[derive(Debug, Clone, clap::Args)] +struct NativeExtensionArgs { + /// Display name for the native extension. Falls back to the app `name` + /// in godaddy.toml at `gddy platform app release` time when omitted. + #[arg(long)] + name: Option, + + /// Support contact email written into godaddy.toml as support_contact. + #[arg(long = "support-contact", value_name = "EMAIL")] + support_contact: String, + + /// Android package name written into godaddy.toml as android_package_name. + #[arg(long = "android-package-name", value_name = "PACKAGE")] + android_package_name: String, +} + +fn apply_native_extension( + config: &mut crate::config::Config, + name: Option, + support_contact: String, + android_package_name: String, +) { + config.native_extension = Some(crate::config::NativeExtensionConfig { + name, + support_contact, + android_package_name, + }); +} + pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( GroupSpec::new("add", "Add components to an application").with_long( - "Append actions, webhook subscriptions, or UI extensions to the \ - godaddy.toml manifest in the current directory. Run `gddy platform \ - app deploy` to publish the updated manifest.", + "Append actions, webhook subscriptions, UI extensions, or a native \ + extension to the godaddy.toml manifest in the current directory. Run \ + `gddy platform app deploy` to publish the updated manifest.", ), ) .with_command(RuntimeCommandSpec::new_typed_with_context::< @@ -230,11 +259,180 @@ pub(super) fn group() -> RuntimeGroupSpec { ) }, )) + .with_command(RuntimeCommandSpec::new_typed_with_context::< + NativeExtensionArgs, + _, + _, + _, + >( + CommandSpec::from_args::( + "native-extension", + "Add a native Android extension to godaddy.toml", + ) + .with_long( + "Write a [native_extension] section to the godaddy.toml manifest in \ + the current directory. support_contact and android_package_name are \ + required; name is optional and falls back to the app name at \ + `gddy platform app release` time. This command only edits the local \ + manifest — the native-app draft is created when you run \ + `gddy platform app release`. Re-running this command overwrites the \ + existing [native_extension] section.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_output_schema::() + .no_auth(true), + |ctx, args: NativeExtensionArgs| async move { + let name = args.name; + let support_contact = args.support_contact; + let android_package_name = args.android_package_name; + let path = crate::config::config_path(Some(&ctx.middleware.env)); + let mut config = crate::config::read_config(&path) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + apply_native_extension( + &mut config, + name.clone(), + support_contact.clone(), + android_package_name.clone(), + ); + crate::config::write_config(&path, &config) + .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; + Ok(CommandResult::new(json!({ + "name": name, + "supportContact": support_contact, + "androidPackageName": android_package_name, + })) + .with_next_actions(super::add_config_next_actions(&config.name))) + }, + )) .with_group(super::add_extension::group()) } #[cfg(test)] mod tests { + #[test] + fn native_extension_subcommand_accepts_required_and_optional_flags() { + super::group() + .clap_command() + .try_get_matches_from([ + "add", + "native-extension", + "--name", + "My Display Name", + "--support-contact", + "support@example.com", + "--android-package-name", + "com.example.app", + ]) + .expect("native-extension flags should be accepted"); + } + + #[test] + fn native_extension_subcommand_name_is_optional() { + super::group() + .clap_command() + .try_get_matches_from([ + "add", + "native-extension", + "--support-contact", + "support@example.com", + "--android-package-name", + "com.example.app", + ]) + .expect("--name is optional"); + } + + #[test] + fn native_extension_subcommand_requires_support_contact() { + let err = super::group() + .clap_command() + .try_get_matches_from([ + "add", + "native-extension", + "--android-package-name", + "com.example.app", + ]) + .expect_err("--support-contact is required"); + let msg = err.to_string(); + assert!( + msg.contains("support-contact"), + "unexpected clap error: {msg}" + ); + } + + #[test] + fn native_extension_subcommand_requires_android_package_name() { + let err = super::group() + .clap_command() + .try_get_matches_from([ + "add", + "native-extension", + "--support-contact", + "support@example.com", + ]) + .expect_err("--android-package-name is required"); + let msg = err.to_string(); + assert!( + msg.contains("android-package-name"), + "unexpected clap error: {msg}" + ); + } + + #[test] + fn apply_native_extension_overwrites_and_round_trips_through_toml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = { + // Mirror config::tests::valid_config field-for-field so this test + // does not depend on that helper (it is private to config/mod.rs). + crate::config::Config { + name: "my-app".to_owned(), + client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + description: Some("test".to_owned()), + version: "1.2.3".to_owned(), + url: "https://example.com".to_owned(), + proxy_url: "https://proxy.example.com".to_owned(), + authorization_scopes: vec!["openid".to_owned()], + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions: None, + settings: vec![], + native_extension: None, + } + }; + crate::config::write_config(&path, &config).expect("write base"); + + super::apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + crate::config::write_config(&path, &config).expect("write native_extension"); + + let read_back = crate::config::read_config(&path).expect("read back"); + let native = read_back + .native_extension + .expect("native_extension section written"); + assert_eq!(native.name.as_deref(), Some("My Display Name")); + assert_eq!(native.support_contact, "support@example.com"); + assert_eq!(native.android_package_name, "com.example.app"); + + super::apply_native_extension( + &mut config, + None, + "other@example.com".to_owned(), + "com.example.other".to_owned(), + ); + crate::config::write_config(&path, &config).expect("overwrite"); + let overwritten = crate::config::read_config(&path).expect("read overwrite"); + let native = overwritten.native_extension.expect("still present"); + assert_eq!(native.name, None); + assert_eq!(native.support_contact, "other@example.com"); + assert_eq!(native.android_package_name, "com.example.other"); + } + #[test] fn settings_subcommand_accepts_presentation_file_flag() { super::group() diff --git a/rust/src/application/commands/deploy/extensions.rs b/rust/src/application/commands/deploy/extensions.rs index fff395d6..ecbb6a83 100644 --- a/rust/src/application/commands/deploy/extensions.rs +++ b/rust/src/application/commands/deploy/extensions.rs @@ -293,6 +293,7 @@ mod tests { dependencies: vec![], extensions, settings: vec![], + native_extension: None, } } diff --git a/rust/src/application/commands/deploy/mod.rs b/rust/src/application/commands/deploy/mod.rs index 564227c4..fadc57ae 100644 --- a/rust/src/application/commands/deploy/mod.rs +++ b/rust/src/application/commands/deploy/mod.rs @@ -436,6 +436,7 @@ mod tests { dependencies: vec![], extensions: None, settings: vec![], + native_extension: None, }; let input = super::manifest_metadata_input(&config); diff --git a/rust/src/application/commands/init.rs b/rust/src/application/commands/init.rs index bb95f920..9cdd2496 100644 --- a/rust/src/application/commands/init.rs +++ b/rust/src/application/commands/init.rs @@ -204,6 +204,7 @@ pub(super) fn command() -> RuntimeCommandSpec { dependencies: vec![], extensions: None, settings: vec![], + native_extension: None, }; let cwd = match std::env::current_dir() { Ok(dir) => dir, diff --git a/rust/src/application/commands/release.rs b/rust/src/application/commands/release.rs index a68b82ea..72ba552e 100644 --- a/rust/src/application/commands/release.rs +++ b/rust/src/application/commands/release.rs @@ -184,6 +184,67 @@ fn build_ui_extensions(config: &crate::config::Config) -> cli_engine::Result Option { + let ext = config.native_extension.as_ref()?; + let name = ext + .name + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(config.name.as_str()) + .to_owned(); + Some(NativeExtensionDraft { + name, + support_contact: ext.support_contact.clone(), + android_package_name: ext.android_package_name.clone(), + }) +} + +/// Build the `createRelease` `nativeExtensions` entry from toml-owned fields. +/// +/// `platform` is required (`ANDROID` is the only `NativeExtensionPlatform` +/// variant). Categories are portal-owned and are not part of this input. +/// Unlike core `createGpaRelease`, this includes `packageName` from toml. +fn native_extensions_input(draft: &NativeExtensionDraft) -> Value { + json!([{ + "platform": "ANDROID", + "name": draft.name, + "contact": draft.support_contact, + "packageName": draft.android_package_name, + }]) +} + +/// Attach toml native-extension fields to the GraphQL `createRelease` input. +/// +/// Registry rejects mixing non-empty `uiExtensions` with `nativeExtensions` +/// (`HYBRID_EXTENSIONS_NOT_ALLOWED`). Empty `uiExtensions: []` is allowed. +fn apply_native_extensions( + input: &mut Value, + ui_extensions: &[Value], + draft: Option<&NativeExtensionDraft>, +) -> cli_engine::Result<()> { + if draft.is_some() && !ui_extensions.is_empty() { + return Err(crate::error::GddyError::validation( + "a release cannot mix uiExtensions and a native extension", + ) + .into_cli_error()); + } + if let Some(draft) = draft { + input["nativeExtensions"] = native_extensions_input(draft); + } + Ok(()) +} + #[derive(Debug, Clone, clap::Args)] struct ReleaseArgs { /// Application ID. @@ -225,7 +286,7 @@ pub(super) fn command() -> RuntimeCommandSpec { let config_path = crate::config::config_path(Some(&ctx.middleware.env)); let manifest_dir = config_path.parent().unwrap_or_else(|| Path::new("")); // Pulls actions/subscriptions/uiExtensions/settings from godaddy.toml; see load_manifest. - let (actions, subscriptions, ui_extensions, settings) = + let (actions, subscriptions, ui_extensions, settings, native_extension) = match load_manifest(&config_path)? { Some(config) => { let actions: Vec = config @@ -247,14 +308,22 @@ pub(super) fn command() -> RuntimeCommandSpec { .unwrap_or_default(); let ui_extensions = build_ui_extensions(&config)?; let settings = build_settings(&config, manifest_dir)?; - (actions, subscriptions, ui_extensions, settings) + let native_extension = native_extension_draft(&config); + ( + actions, + subscriptions, + ui_extensions, + settings, + native_extension, + ) } - None => (Vec::new(), Vec::new(), Vec::new(), Vec::new()), + None => (Vec::new(), Vec::new(), Vec::new(), Vec::new(), None), }; input["actions"] = json!(actions); input["subscriptions"] = json!(subscriptions); input["uiExtensions"] = json!(ui_extensions); input["settings"] = json!(settings); + apply_native_extensions(&mut input, &ui_extensions, native_extension.as_ref())?; let client = super::make_client(&ctx).await?; let data = client @@ -563,4 +632,127 @@ authorization_scopes = [] "unexpected error: {err}" ); } + + fn valid_release_config() -> crate::config::Config { + crate::config::Config { + name: "my-app".to_owned(), + client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + description: Some("test".to_owned()), + version: "1.2.3".to_owned(), + url: "https://example.com".to_owned(), + proxy_url: "https://proxy.example.com".to_owned(), + authorization_scopes: vec!["openid".to_owned()], + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions: None, + settings: vec![], + native_extension: None, + } + } + + #[test] + fn native_extension_draft_is_none_when_section_absent() { + let config = valid_release_config(); + assert!(super::native_extension_draft(&config).is_none()); + } + + #[test] + fn native_extension_draft_falls_back_to_config_name_when_name_absent() { + let mut config = valid_release_config(); + config.native_extension = Some(crate::config::NativeExtensionConfig { + name: None, + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + }); + let draft = super::native_extension_draft(&config).expect("draft"); + assert_eq!(draft.name, "my-app"); + assert_eq!(draft.support_contact, "support@example.com"); + assert_eq!(draft.android_package_name, "com.example.app"); + } + + #[test] + fn native_extension_draft_falls_back_to_config_name_when_name_empty() { + let mut config = valid_release_config(); + config.native_extension = Some(crate::config::NativeExtensionConfig { + name: Some(String::new()), + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + }); + let draft = super::native_extension_draft(&config).expect("draft"); + assert_eq!(draft.name, "my-app"); + } + + #[test] + fn native_extension_draft_uses_explicit_name_when_present() { + let mut config = valid_release_config(); + config.native_extension = Some(crate::config::NativeExtensionConfig { + name: Some("My Display Name".to_owned()), + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + }); + let draft = super::native_extension_draft(&config).expect("draft"); + assert_eq!(draft.name, "My Display Name"); + } + + #[test] + fn native_extensions_input_carries_every_toml_field_and_android_platform() { + let draft = super::NativeExtensionDraft { + name: "My Display Name".to_owned(), + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + }; + + let actual = super::native_extensions_input(&draft); + + let entries = actual.as_array().expect("one-element array"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["platform"], "ANDROID"); + assert_eq!(entries[0]["name"], "My Display Name"); + assert_eq!(entries[0]["contact"], "support@example.com"); + assert_eq!(entries[0]["packageName"], "com.example.app"); + // Categories are portal-owned and never travel on createRelease. + assert!(entries[0].get("appCategory").is_none()); + assert!(entries[0].get("merchantCategory").is_none()); + } + + fn sample_draft() -> super::NativeExtensionDraft { + super::NativeExtensionDraft { + name: "My Display Name".to_owned(), + support_contact: "support@example.com".to_owned(), + android_package_name: "com.example.app".to_owned(), + } + } + + #[test] + fn apply_native_extensions_sets_create_release_input_when_draft_present() { + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + super::apply_native_extensions(&mut input, &[], Some(&sample_draft())) + .expect("native-only release"); + let row = &input["nativeExtensions"][0]; + assert_eq!(row["platform"], "ANDROID"); + assert_eq!(row["name"], "My Display Name"); + assert_eq!(row["contact"], "support@example.com"); + assert_eq!(row["packageName"], "com.example.app"); + } + + #[test] + fn apply_native_extensions_omits_key_when_draft_absent() { + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + super::apply_native_extensions(&mut input, &[], None).expect("non-native release"); + assert!(input.get("nativeExtensions").is_none()); + } + + #[test] + fn apply_native_extensions_rejects_nonempty_ui_extensions() { + let mut input = serde_json::json!({ "applicationId": "app-123", "version": "1.0.11" }); + let ui = vec![serde_json::json!({ "name": "Widget", "handle": "widget" })]; + let err = super::apply_native_extensions(&mut input, &ui, Some(&sample_draft())) + .expect_err("hybrid must fail locally"); + assert!( + err.to_string().contains("cannot mix uiExtensions"), + "got: {err}" + ); + assert!(input.get("nativeExtensions").is_none()); + } } diff --git a/rust/src/application/commands/schemas.rs b/rust/src/application/commands/schemas.rs index 2169b230..b13b24f8 100644 --- a/rust/src/application/commands/schemas.rs +++ b/rust/src/application/commands/schemas.rs @@ -55,6 +55,7 @@ output_schema!(ApplicationRelease { "version": "string"; "description": "string", optional; "createdAt": "string"; + "nativeExtensions": "[]object", optional; }); output_schema!(ValidationResult { @@ -90,3 +91,9 @@ output_schema!(ConfigSetting { "slug": "string"; "entryPath": "string"; }); + +output_schema!(ConfigNativeExtension { + "name": "string", optional; + "supportContact": "string"; + "androidPackageName": "string"; +}); diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index f6c855b1..2bfdac74 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -25,6 +25,8 @@ pub struct Config { pub extensions: Option, #[serde(default)] pub settings: Vec, + #[serde(default)] + pub native_extension: Option, } impl Config { @@ -121,6 +123,15 @@ impl Config { } } + if let Some(native) = &self.native_extension { + validate_native_extension( + &mut errors, + "native_extension", + &native.support_contact, + &native.android_package_name, + ); + } + settings::validate_settings(&self.settings, &mut errors); if errors.is_empty() { @@ -245,6 +256,20 @@ fn validate_named_extension( } } +fn validate_native_extension( + errors: &mut Vec, + path: &str, + support_contact: &str, + android_package_name: &str, +) { + require_non_empty(errors, &format!("{path}.support_contact"), support_contact); + require_non_empty( + errors, + &format!("{path}.android_package_name"), + android_package_name, + ); +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActionConfig { pub name: String, @@ -315,6 +340,14 @@ pub struct ExtensionTarget { pub target: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NativeExtensionConfig { + #[serde(default)] + pub name: Option, + pub support_contact: String, + pub android_package_name: String, +} + #[derive(Debug, thiserror::Error)] pub enum ConfigError { #[error("config file not found at {path}")] @@ -464,6 +497,7 @@ mod tests { dependencies: vec![], extensions: None, settings: vec![], + native_extension: None, } } @@ -507,6 +541,125 @@ mod tests { valid_config().validate().expect("valid config should pass"); } + fn native_extension( + name: Option<&str>, + support_contact: &str, + android_package_name: &str, + ) -> NativeExtensionConfig { + NativeExtensionConfig { + name: name.map(str::to_owned), + support_contact: support_contact.to_owned(), + android_package_name: android_package_name.to_owned(), + } + } + + #[test] + fn validate_accepts_native_extension_with_optional_name_omitted() { + let mut config = valid_config(); + config.native_extension = Some(native_extension( + None, + "support@example.com", + "com.example.app", + )); + config + .validate() + .expect("native_extension with no name should pass"); + } + + #[test] + fn validate_rejects_empty_native_extension_support_contact() { + let mut config = valid_config(); + config.native_extension = Some(native_extension(None, "", "com.example.app")); + let err = config + .validate() + .expect_err("empty support_contact must fail"); + assert!( + err.to_string().contains("native_extension.support_contact"), + "{err}" + ); + } + + #[test] + fn validate_rejects_empty_native_extension_android_package_name() { + let mut config = valid_config(); + config.native_extension = Some(native_extension(None, "support@example.com", "")); + let err = config + .validate() + .expect_err("empty android_package_name must fail"); + assert!( + err.to_string() + .contains("native_extension.android_package_name"), + "{err}" + ); + } + + #[test] + fn native_extension_name_omitted_deserializes_as_none() { + let raw = r#" +name = "my-app" +client_id = "550e8400-e29b-41d4-a716-446655440000" +version = "1.2.3" +url = "https://example.com" +proxy_url = "https://proxy.example.com" +authorization_scopes = ["openid"] + +[native_extension] +support_contact = "support@example.com" +android_package_name = "com.example.app" +"#; + let config: Config = toml::from_str(raw).expect("parse"); + config.validate().expect("validate"); + let native = config.native_extension.expect("section present"); + assert_eq!(native.name, None); + assert_eq!(native.support_contact, "support@example.com"); + assert_eq!(native.android_package_name, "com.example.app"); + } + + #[test] + fn native_extension_name_present_round_trips_through_toml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = valid_config(); + config.native_extension = Some(native_extension( + Some("My Display Name"), + "support@example.com", + "com.example.app", + )); + write_config(&path, &config).expect("write"); + let read_back = read_config(&path).expect("read"); + let native = read_back.native_extension.expect("section present"); + assert_eq!(native.name.as_deref(), Some("My Display Name")); + assert_eq!(native.support_contact, "support@example.com"); + assert_eq!(native.android_package_name, "com.example.app"); + } + + #[test] + fn read_config_rejects_native_extension_missing_support_contact() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + std::fs::write( + &path, + r#" +name = "my-app" +client_id = "550e8400-e29b-41d4-a716-446655440000" +version = "1.2.3" +url = "https://example.com" +proxy_url = "https://proxy.example.com" +authorization_scopes = ["openid"] + +[native_extension] +android_package_name = "com.example.app" +"#, + ) + .expect("write"); + let err = read_config(&path) + .expect_err("missing support_contact must fail at parse, not the network"); + assert!( + matches!(err, ConfigError::Parse(_)), + "expected parse error, got {err:?}" + ); + } + #[test] fn validate_rejects_invalid_name() { let mut config = valid_config(); From cd10b2873fe8a3c614230993d09bed3f45094d31 Mon Sep 17 00:00:00 2001 From: cblecken-godaddy <80284905+cblecken-godaddy@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:25:05 -0700 Subject: [PATCH 02/14] feat(platform): register native extensions via DevX Core native-apps API `gddy platform app add native-extension` now registers an Android native extension with DevX Core instead of only writing local config. The new `NativeAppClient` resolves the application id from the app-registry subgraph, then upserts against `/api/v1/native-apps`: GET the native app, PATCH every field when it already exists, otherwise POST the draft and follow up with a `supportEmail` PATCH. - add `rust/src/application/native_app_client.rs` with the DevX Core success/error envelopes and HTTP-level tests asserting the exact create/update/support-email payloads - add `rust/src/config/native_extension.rs` to persist `support_contact` and `android_package_name` in `godaddy..toml` - move the subcommand into `rust/src/application/commands/add/` to keep each module under the 1000-line limit - document the integration and open questions in `docs/proposals/native-extension-devx-core-integration.md` Verified end to end against a local DevX Core + application-service stack: the GET -> PATCH sequence returns `operation: "updated"` with the expected body, and the create POST passes DevX Core's `createNativeAppBodySchema` validation. Co-Authored-By: Claude Opus 5 --- .../native-extension-devx-core-integration.md | 98 ++++ rust/src/application/commands/add.rs | 369 +++++++++---- .../commands/add/native_extension.rs | 477 +++++++++++++++++ rust/src/application/commands/schemas.rs | 2 + rust/src/application/mod.rs | 1 + rust/src/application/native_app_client.rs | 484 ++++++++++++++++++ rust/src/config/mod.rs | 17 +- rust/src/config/native_extension.rs | 96 ++++ rust/src/config/settings_form.rs | 8 +- rust/src/platform/guides/platform-overview.md | 6 +- 10 files changed, 1437 insertions(+), 121 deletions(-) create mode 100644 docs/proposals/native-extension-devx-core-integration.md create mode 100644 rust/src/application/commands/add/native_extension.rs create mode 100644 rust/src/application/native_app_client.rs create mode 100644 rust/src/config/native_extension.rs diff --git a/docs/proposals/native-extension-devx-core-integration.md b/docs/proposals/native-extension-devx-core-integration.md new file mode 100644 index 00000000..055ad7e9 --- /dev/null +++ b/docs/proposals/native-extension-devx-core-integration.md @@ -0,0 +1,98 @@ +# Native extension DevX Core integration plan + +## Goal + +Update `gddy platform app add native-extension` so it registers or updates the +native-app draft through DevX Core while keeping the local `[native_extension]` +manifest section synchronized. + +## Plan + +1. Add a DevX Core native-app client. + - Implement `GET`, `POST`, and `PATCH /api/v1/native-apps/:appId`. + - Reuse `environments::devx_core_url()` and the CLI's standard User-Agent. + - Send the current credential as `Authorization: Bearer `. + - Parse DevX Core success and error envelopes into stable CLI errors. + +2. Enable authentication for `add native-extension`. + - Remove `.no_auth(true)` from the command definition. + - Declare `APP_REGISTRY_READ` and `APP_REGISTRY_WRITE` scopes. + - Resolve the credential lazily through `ctx.credential()`. + +3. Resolve the required identifiers. + - Look up the App Registry application using `config.name`. + - Use the returned application `id`; `config.client_id` is an OAuth client ID + and must not be used as the application ID. + - Obtain `organizationId` through `OnboardingClient::status()` while the + existing DevX Core request schema requires it. + - Follow up with DevX Core to remove `organizationId` from the client request, + because the handler already derives and overrides it from the verified app. + +4. Validate and map the native-extension fields. + - Validate `support_contact` as an email address, matching DevX Core's schema. + - Map the request body as follows: + - `name`: `[native_extension].name`, falling back to the application name. + - `description`: the application description, falling back to an empty + string. + - `supportEmail`: `support_contact`. + - `androidPackageName`: `android_package_name`. + - `appCategory` and `merchantCategory`: empty strings because those fields + remain portal-owned. + - `status`: `draft`. + +5. Make registration idempotent. + - Call `GET /api/v1/native-apps/:appId` first. + - Call `POST` when no native app exists. + - Call `PATCH` when a native app already exists. + - After `POST`, issue the follow-up `PATCH` needed to persist `supportEmail`, + matching the current DevX Portal workaround. + - Preserve DevX Core errors such as an immutable package name after release. + +6. Coordinate local and remote state. + - Build and validate the updated TOML before making the remote request. + - Perform the remote upsert. + - Write `[native_extension]` locally after the remote operation succeeds. + - If the remote operation succeeds but the local write fails, return an error + that explains the remote draft exists and that rerunning the idempotent + command is safe. + +7. Update command documentation and output. + - Remove wording that describes the command as local-only. + - Explain that the command immediately registers the DevX Core native draft. + - Document authentication, application lookup, and rerun behavior. + - Return the application ID and whether the remote operation created or + updated the draft. + +8. Add automated coverage. + - Verify request paths, Bearer headers, payloads, response decoding, and error + mapping in client tests. + - Test application-name-to-ID resolution. + - Test `POST` when absent and `PATCH` when present. + - Test the post-create support-email patch. + - Test invalid email rejection before network access. + - Test that a remote failure leaves the local manifest unchanged. + - Retain the existing argument parsing and TOML round-trip coverage. + +9. Run the required verification from `rust/`. + - `cargo check` + - `cargo clippy -- -D warnings` + - `cargo test` + - `cargo fmt --check` + - `./scripts/check-module-size.sh` + +## Relevant existing code + +- CLI command: `rust/src/application/commands/add.rs` +- CLI App Registry client: `rust/src/application/client.rs` +- CLI DevX Core URL resolution: `rust/src/environments/devx_core.rs` +- CLI onboarding client: `rust/src/onboarding/client.rs` +- Portal reference client: `devx-portal/lib/native-apps.ts` +- DevX Core contract: `developer-ecosystem-core/apis/rest/src/contract.ts` +- DevX Core native-app handler: + `developer-ecosystem-core/apis/rest/src/handlers/native-apps-handler.ts` + +## Out of scope + +- Changing native APK upload or release lifecycle behavior. +- Calling application-service directly from the CLI. +- Moving portal-owned native-app category management into the CLI. diff --git a/rust/src/application/commands/add.rs b/rust/src/application/commands/add.rs index cf940a0d..a73c133d 100644 --- a/rust/src/application/commands/add.rs +++ b/rust/src/application/commands/add.rs @@ -6,7 +6,9 @@ use cli_engine::{ }; use serde_json::json; -use super::schemas::{ConfigAction, ConfigNativeExtension, ConfigSetting, ConfigSubscription}; +use super::schemas::{ConfigAction, ConfigSetting, ConfigSubscription}; + +mod native_extension; #[derive(Debug, Clone, clap::Args)] struct ActionArgs { @@ -80,41 +82,13 @@ struct SubscriptionArgs { events: Vec, } -#[derive(Debug, Clone, clap::Args)] -struct NativeExtensionArgs { - /// Display name for the native extension. Falls back to the app `name` - /// in godaddy.toml at `gddy platform app release` time when omitted. - #[arg(long)] - name: Option, - - /// Support contact email written into godaddy.toml as support_contact. - #[arg(long = "support-contact", value_name = "EMAIL")] - support_contact: String, - - /// Android package name written into godaddy.toml as android_package_name. - #[arg(long = "android-package-name", value_name = "PACKAGE")] - android_package_name: String, -} - -fn apply_native_extension( - config: &mut crate::config::Config, - name: Option, - support_contact: String, - android_package_name: String, -) { - config.native_extension = Some(crate::config::NativeExtensionConfig { - name, - support_contact, - android_package_name, - }); -} - pub(super) fn group() -> RuntimeGroupSpec { RuntimeGroupSpec::new( GroupSpec::new("add", "Add components to an application").with_long( - "Append actions, webhook subscriptions, UI extensions, or a native \ - extension to the godaddy.toml manifest in the current directory. Run \ - `gddy platform app deploy` to publish the updated manifest.", + "Add actions, webhook subscriptions, UI extensions, or a native \ + extension to the godaddy.toml manifest in the current directory. \ + Native extensions are also registered immediately as DevX Core \ + drafts; other components are published by a later deploy or release.", ), ) .with_command(RuntimeCommandSpec::new_typed_with_context::< @@ -260,57 +234,55 @@ pub(super) fn group() -> RuntimeGroupSpec { ) }, )) - .with_command(RuntimeCommandSpec::new_typed_with_context::< - NativeExtensionArgs, - _, - _, - _, - >( - CommandSpec::from_args::( - "native-extension", - "Add a native Android extension to godaddy.toml", - ) - .with_long( - "Write a [native_extension] section to the godaddy.toml manifest in \ - the current directory. support_contact and android_package_name are \ - required; name is optional and falls back to the app name at \ - `gddy platform app release` time. This command only edits the local \ - manifest — the native-app draft is created when you run \ - `gddy platform app release`. Re-running this command overwrites the \ - existing [native_extension] section.", - ) - .with_system("applications") - .with_tier(Tier::Mutate) - .with_output_schema::() - .no_auth(true), - |ctx, args: NativeExtensionArgs| async move { - let name = args.name; - let support_contact = args.support_contact; - let android_package_name = args.android_package_name; - let path = crate::config::config_path(Some(&ctx.middleware.env)); - let mut config = crate::config::read_config(&path) - .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; - apply_native_extension( - &mut config, - name.clone(), - support_contact.clone(), - android_package_name.clone(), - ); - crate::config::write_config(&path, &config) - .map_err(|e| crate::error::GddyError::config(e.to_string()).into_cli_error())?; - Ok(CommandResult::new(json!({ - "name": name, - "supportContact": support_contact, - "androidPackageName": android_package_name, - })) - .with_next_actions(super::add_config_next_actions(&config.name))) - }, - )) + .with_command(native_extension::command()) .with_group(super::add_extension::group()) } #[cfg(test)] mod tests { + use httpmock::{Method, MockServer}; + use serde_json::json; + + fn test_config() -> crate::config::Config { + crate::config::Config { + name: "my-app".to_owned(), + client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + description: Some("test".to_owned()), + version: "1.2.3".to_owned(), + url: "https://example.com".to_owned(), + proxy_url: "https://proxy.example.com".to_owned(), + authorization_scopes: vec!["openid".to_owned()], + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions: None, + settings: vec![], + native_extension: None, + } + } + + fn native_args(support_contact: &str) -> super::native_extension::NativeExtensionArgs { + super::native_extension::NativeExtensionArgs { + name: Some("My Display Name".to_owned()), + support_contact: support_contact.to_owned(), + android_package_name: "com.example.app".to_owned(), + } + } + + fn native_app_json() -> serde_json::Value { + json!({ + "applicationId": "app-registry-id", + "name": "My Display Name", + "description": "test", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft", + "released": false + }) + } + #[test] fn native_extension_subcommand_accepts_required_and_optional_flags() { super::group() @@ -383,28 +355,10 @@ mod tests { fn apply_native_extension_overwrites_and_round_trips_through_toml() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("godaddy.toml"); - let mut config = { - // Mirror config::tests::valid_config field-for-field so this test - // does not depend on that helper (it is private to config/mod.rs). - crate::config::Config { - name: "my-app".to_owned(), - client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), - description: Some("test".to_owned()), - version: "1.2.3".to_owned(), - url: "https://example.com".to_owned(), - proxy_url: "https://proxy.example.com".to_owned(), - authorization_scopes: vec!["openid".to_owned()], - actions: vec![], - subscriptions: None, - dependencies: vec![], - extensions: None, - settings: vec![], - native_extension: None, - } - }; + let mut config = test_config(); crate::config::write_config(&path, &config).expect("write base"); - super::apply_native_extension( + super::native_extension::apply_native_extension( &mut config, Some("My Display Name".to_owned()), "support@example.com".to_owned(), @@ -420,7 +374,7 @@ mod tests { assert_eq!(native.support_contact, "support@example.com"); assert_eq!(native.android_package_name, "com.example.app"); - super::apply_native_extension( + super::native_extension::apply_native_extension( &mut config, None, "other@example.com".to_owned(), @@ -434,6 +388,221 @@ mod tests { assert_eq!(native.android_package_name, "com.example.other"); } + #[test] + fn invalid_support_email_is_rejected_during_local_preparation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + crate::config::write_config(&path, &test_config()).expect("write base config"); + + let error = + super::native_extension::prepare_native_extension(&path, &native_args("not-an-email")) + .expect_err("invalid support email must fail"); + + assert!(error.to_string().contains("valid email address"), "{error}"); + assert!( + crate::config::read_config(&path) + .expect("read unchanged config") + .native_extension + .is_none() + ); + } + + #[test] + fn application_name_lookup_uses_registry_id_not_oauth_client_id() { + let data = json!({ + "application": { + "id": "app-registry-id", + "clientId": "550e8400-e29b-41d4-a716-446655440000", + "name": "my-app" + } + }); + assert_eq!( + super::native_extension::application_id(&data, "my-app").expect("application id"), + "app-registry-id" + ); + } + + #[tokio::test] + async fn sync_resolves_application_and_organization_then_creates_draft() { + let app_registry = MockServer::start_async().await; + let app_lookup = app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph") + .header("authorization", "Bearer test-token") + .is_true(|request| request.body_string().contains(r#""name":"my-app""#)); + then.status(200).json_body(json!({ + "data": { + "application": { + "id": "app-registry-id", + "name": "my-app" + } + } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + let onboarding = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/onboarding/status") + .header("authorization", "Bearer test-token"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + let get = devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ + "organizationId": "550e8400-e29b-41d4-a716-446655440001", + "name": "My Display Name", + "description": "test", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let support_patch = devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ "supportEmail": "support@example.com" })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let mut config = test_config(); + super::native_extension::apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + + let registration = super::native_extension::sync_native_extension( + &config, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + ) + .await + .expect("sync native extension"); + + assert_eq!(registration.application_id, "app-registry-id"); + assert_eq!( + registration.operation, + crate::application::native_app_client::UpsertOperation::Created + ); + app_lookup.assert_async().await; + onboarding.assert_async().await; + get.assert_async().await; + create.assert_async().await; + support_patch.assert_async().await; + } + + #[tokio::test] + async fn remote_failure_leaves_local_manifest_unchanged() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + crate::config::write_config(&path, &test_config()).expect("write base config"); + let prepared = super::native_extension::prepare_native_extension( + &path, + &native_args("support@example.com"), + ) + .expect("prepare native extension"); + + let app_registry = MockServer::start_async().await; + app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph"); + then.status(200).json_body(json!({ + "data": { "application": { "id": "app-registry-id" } } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + devx_core + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id"); + then.status(409).json_body(json!({ + "success": false, + "error": { + "code": "PACKAGE_NAME_IMMUTABLE", + "message": "Package name cannot change after release" + } + })); + }) + .await; + + let error = super::native_extension::sync_native_extension( + &prepared, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + ) + .await + .expect_err("remote update must fail"); + + assert!( + error.to_string().contains("PACKAGE_NAME_IMMUTABLE"), + "{error}" + ); + assert!( + crate::config::read_config(&path) + .expect("read original manifest") + .native_extension + .is_none() + ); + } + #[test] fn settings_subcommand_accepts_presentation_file_flag() { super::group() diff --git a/rust/src/application/commands/add/native_extension.rs b/rust/src/application/commands/add/native_extension.rs new file mode 100644 index 00000000..52d756ee --- /dev/null +++ b/rust/src/application/commands/add/native_extension.rs @@ -0,0 +1,477 @@ +//! `gddy platform app add native-extension` — synchronize a native Android draft. + +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use super::super::schemas::ConfigNativeExtension; +use crate::application::native_app_client::{NativeAppClient, NativeAppInput, UpsertOperation}; +use crate::scopes::{APP_REGISTRY_READ, APP_REGISTRY_WRITE}; + +#[derive(Debug, Clone, clap::Args)] +pub(super) struct NativeExtensionArgs { + /// Display name for the native extension. Falls back to the app `name` + /// in godaddy.toml when omitted. + #[arg(long)] + pub(super) name: Option, + + /// Support contact email written into godaddy.toml as support_contact. + #[arg(long = "support-contact", value_name = "EMAIL")] + pub(super) support_contact: String, + + /// Android package name written into godaddy.toml as android_package_name. + #[arg(long = "android-package-name", value_name = "PACKAGE")] + pub(super) android_package_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct NativeExtensionRegistration { + pub(super) application_id: String, + pub(super) operation: UpsertOperation, +} + +pub(super) fn apply_native_extension( + config: &mut crate::config::Config, + name: Option, + support_contact: String, + android_package_name: String, +) { + config.native_extension = Some(crate::config::NativeExtensionConfig { + name, + support_contact, + android_package_name, + }); +} + +pub(super) fn prepare_native_extension( + path: &std::path::Path, + args: &NativeExtensionArgs, +) -> cli_engine::Result { + let mut config = crate::config::read_config(path) + .map_err(|error| crate::error::GddyError::config(error.to_string()).into_cli_error())?; + apply_native_extension( + &mut config, + args.name.clone(), + args.support_contact.clone(), + args.android_package_name.clone(), + ); + config + .validate() + .map_err(|error| crate::error::GddyError::validation(error.to_string()).into_cli_error())?; + // Serialize before any remote work so an invalid TOML shape cannot leave + // DevX Core ahead of the local manifest. + toml::to_string_pretty(&config) + .map_err(|error| crate::error::GddyError::config(error.to_string()).into_cli_error())?; + Ok(config) +} + +pub(super) fn application_id(data: &serde_json::Value, name: &str) -> cli_engine::Result { + let application = &data["application"]; + if application.is_null() { + return Err(crate::error::GddyError::not_found(format!( + "application {name:?} was not found" + )) + .with_system("applications") + .into_cli_error()); + } + + application["id"] + .as_str() + .filter(|id| !id.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + crate::error::GddyError::unexpected(format!( + "App Registry returned application {name:?} without an id" + )) + .with_system("applications") + .into_cli_error() + }) +} + +pub(super) async fn sync_native_extension( + config: &crate::config::Config, + token: &str, + app_registry_url: &str, + devx_core_url: &str, +) -> cli_engine::Result { + let app_registry = + crate::application::client::ApplicationClient::new(app_registry_url, token.to_owned()); + let application = app_registry + .get_application(&config.name) + .await + .map_err(super::super::client_err)?; + let application_id = application_id(&application, &config.name)?; + + let onboarding = crate::onboarding::OnboardingClient::new(devx_core_url); + let onboarding_status = onboarding.status(token).await.map_err(|error| { + crate::error::GddyError::network(format!( + "Could not obtain the organization for native-app registration: {error}" + )) + .with_system("applications") + .into_cli_error() + })?; + + let native = config + .native_extension + .as_ref() + .expect("native extension is installed during preparation"); + let input = NativeAppInput { + name: native + .name + .as_deref() + .filter(|name| !name.is_empty()) + .unwrap_or(&config.name) + .to_owned(), + description: config.description.clone().unwrap_or_default(), + support_email: native.support_contact.clone(), + app_category: String::new(), + merchant_category: String::new(), + android_package_name: native.android_package_name.clone(), + status: "draft".to_owned(), + }; + let operation = NativeAppClient::new(devx_core_url, token) + .upsert(&application_id, &onboarding_status.org_id, &input) + .await + .map_err(|error| crate::error::GddyError::from(error).into_cli_error())?; + + Ok(NativeExtensionRegistration { + application_id, + operation, + }) +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "native-extension", + "Register a native Android extension draft", + ) + .with_long( + "Write a [native_extension] section to the godaddy.toml manifest in \ + the current directory and immediately create or update its DevX Core \ + native-app draft. The command authenticates, looks up the App Registry \ + application by the manifest's name, and uses that application's ID. \ + support_contact and android_package_name are required; name is optional \ + and falls back to the application name. The local file is written only \ + after the remote draft succeeds, and rerunning safely reconciles either \ + an existing remote draft or an existing local section.", + ) + .with_system("applications") + .with_tier(Tier::Mutate) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) + .with_output_schema::(), + |ctx, args: NativeExtensionArgs| async move { + let path = crate::config::config_path(Some(&ctx.middleware.env)); + let config = prepare_native_extension(&path, &args)?; + let app_registry_url = + crate::application::client::api_url_for_env(&ctx.middleware.env)?; + let devx_core_url = crate::environments::devx_core_url(&ctx.middleware.env) + .ok_or_else(|| { + crate::error::GddyError::config(format!( + "DevX Core URL is not configured for environment {:?}", + ctx.middleware.env + )) + .into_cli_error() + })?; + // Resolve credentials only after all local validation has passed. + let token = ctx.credential().await?.token; + let registration = + sync_native_extension(&config, &token, &app_registry_url, &devx_core_url).await?; + crate::config::write_config(&path, &config).map_err(|error| { + crate::error::GddyError::config(format!( + "The DevX Core native-app draft was {} for application {}, but {} could not be updated: {error}", + registration.operation.as_str(), + registration.application_id, + path.display(), + )) + .with_fix("Rerun this idempotent command to reconcile the local manifest with the existing remote draft.") + .into_cli_error() + })?; + let native = config + .native_extension + .as_ref() + .expect("native extension is installed during preparation"); + Ok(CommandResult::new(json!({ + "applicationId": registration.application_id, + "operation": registration.operation.as_str(), + "name": native.name.as_deref().unwrap_or(&config.name), + "supportContact": native.support_contact, + "androidPackageName": native.android_package_name, + })) + .with_next_actions(super::super::add_config_next_actions(&config.name))) + }, + ) +} + +#[cfg(test)] +mod tests { + use httpmock::{Method, MockServer}; + use serde_json::json; + + use super::*; + + fn test_config() -> crate::config::Config { + crate::config::Config { + name: "my-app".to_owned(), + client_id: "550e8400-e29b-41d4-a716-446655440000".to_owned(), + description: Some("test".to_owned()), + version: "1.2.3".to_owned(), + url: "https://example.com".to_owned(), + proxy_url: "https://proxy.example.com".to_owned(), + authorization_scopes: vec!["openid".to_owned()], + actions: vec![], + subscriptions: None, + dependencies: vec![], + extensions: None, + settings: vec![], + native_extension: None, + } + } + + fn native_args(support_contact: &str) -> NativeExtensionArgs { + NativeExtensionArgs { + name: Some("My Display Name".to_owned()), + support_contact: support_contact.to_owned(), + android_package_name: "com.example.app".to_owned(), + } + } + + fn native_app_json() -> serde_json::Value { + json!({ + "applicationId": "app-registry-id", + "name": "My Display Name", + "description": "test", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft", + "released": false + }) + } + + #[test] + fn apply_overwrites_and_round_trips_through_toml() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + let mut config = test_config(); + crate::config::write_config(&path, &config).expect("write base"); + apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + crate::config::write_config(&path, &config).expect("write native extension"); + + let native = crate::config::read_config(&path) + .expect("read back") + .native_extension + .expect("native extension"); + assert_eq!(native.name.as_deref(), Some("My Display Name")); + assert_eq!(native.support_contact, "support@example.com"); + assert_eq!(native.android_package_name, "com.example.app"); + } + + #[test] + fn invalid_support_email_is_rejected_during_local_preparation() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + crate::config::write_config(&path, &test_config()).expect("write base config"); + + let error = prepare_native_extension(&path, &native_args("not-an-email")) + .expect_err("invalid support email must fail"); + + assert!(error.to_string().contains("valid email address"), "{error}"); + assert!( + crate::config::read_config(&path) + .expect("read unchanged config") + .native_extension + .is_none() + ); + } + + #[test] + fn application_name_lookup_uses_registry_id_not_oauth_client_id() { + let data = json!({ + "application": { + "id": "app-registry-id", + "clientId": "550e8400-e29b-41d4-a716-446655440000", + "name": "my-app" + } + }); + assert_eq!( + application_id(&data, "my-app").expect("application id"), + "app-registry-id" + ); + } + + #[tokio::test] + async fn sync_resolves_application_and_organization_then_creates_draft() { + let app_registry = MockServer::start_async().await; + let app_lookup = app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph") + .header("authorization", "Bearer test-token") + .is_true(|request| request.body_string().contains(r#""name":"my-app""#)); + then.status(200).json_body(json!({ + "data": { "application": { "id": "app-registry-id", "name": "my-app" } } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + let onboarding = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/onboarding/status") + .header("authorization", "Bearer test-token"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + let get = devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = devx_core + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ + "organizationId": "550e8400-e29b-41d4-a716-446655440001", + "name": "My Display Name", + "description": "test", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let support_patch = devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id") + .json_body(json!({ "supportEmail": "support@example.com" })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let mut config = test_config(); + apply_native_extension( + &mut config, + Some("My Display Name".to_owned()), + "support@example.com".to_owned(), + "com.example.app".to_owned(), + ); + + let registration = sync_native_extension( + &config, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + ) + .await + .expect("sync native extension"); + + assert_eq!(registration.application_id, "app-registry-id"); + assert_eq!(registration.operation, UpsertOperation::Created); + app_lookup.assert_async().await; + onboarding.assert_async().await; + get.assert_async().await; + create.assert_async().await; + support_patch.assert_async().await; + } + + #[tokio::test] + async fn remote_failure_leaves_local_manifest_unchanged() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("godaddy.toml"); + crate::config::write_config(&path, &test_config()).expect("write base config"); + let prepared = prepare_native_extension(&path, &native_args("support@example.com")) + .expect("prepare native extension"); + + let app_registry = MockServer::start_async().await; + app_registry + .mock_async(|when, then| { + when.method(Method::POST) + .path("/v1/apps/app-registry-subgraph"); + then.status(200).json_body(json!({ + "data": { "application": { "id": "app-registry-id" } } + })); + }) + .await; + let devx_core = MockServer::start_async().await; + devx_core + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/onboarding/status"); + then.status(200).json_body(json!({ + "success": true, + "data": { + "id": "550e8400-e29b-41d4-a716-446655440001", + "status": "ACTIVE" + } + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-registry-id"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + devx_core + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-registry-id"); + then.status(409).json_body(json!({ + "success": false, + "error": { + "code": "PACKAGE_NAME_IMMUTABLE", + "message": "Package name cannot change after release" + } + })); + }) + .await; + + let error = sync_native_extension( + &prepared, + "test-token", + &app_registry.base_url(), + &devx_core.base_url(), + ) + .await + .expect_err("remote update must fail"); + + assert!( + error.to_string().contains("PACKAGE_NAME_IMMUTABLE"), + "{error}" + ); + assert!( + crate::config::read_config(&path) + .expect("read original manifest") + .native_extension + .is_none() + ); + } +} diff --git a/rust/src/application/commands/schemas.rs b/rust/src/application/commands/schemas.rs index b13b24f8..24fb46b5 100644 --- a/rust/src/application/commands/schemas.rs +++ b/rust/src/application/commands/schemas.rs @@ -93,6 +93,8 @@ output_schema!(ConfigSetting { }); output_schema!(ConfigNativeExtension { + "applicationId": "string"; + "operation": "string"; "name": "string", optional; "supportContact": "string"; "androidPackageName": "string"; diff --git a/rust/src/application/mod.rs b/rust/src/application/mod.rs index 8fbceb11..cc44f893 100644 --- a/rust/src/application/mod.rs +++ b/rust/src/application/mod.rs @@ -1,5 +1,6 @@ pub mod client; mod commands; +pub(crate) mod native_app_client; pub mod public_url; use cli_engine::RuntimeGroupSpec; diff --git a/rust/src/application/native_app_client.rs b/rust/src/application/native_app_client.rs new file mode 100644 index 00000000..af49b5d5 --- /dev/null +++ b/rust/src/application/native_app_client.rs @@ -0,0 +1,484 @@ +//! DevX Core client for native Android application drafts. + +use reqwest::RequestBuilder; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +const NATIVE_APPS_PATH: &str = "/api/v1/native-apps"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NativeAppInput { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) support_email: String, + pub(crate) app_category: String, + pub(crate) merchant_category: String, + pub(crate) android_package_name: String, + pub(crate) status: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NativeApp { + pub(crate) application_id: String, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) support_email: String, + pub(crate) app_category: String, + pub(crate) merchant_category: String, + pub(crate) android_package_name: String, + pub(crate) status: String, + pub(crate) released: bool, + pub(crate) version_name: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpsertOperation { + Created, + Updated, +} + +impl UpsertOperation { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Created => "created", + Self::Updated => "updated", + } + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum NativeAppClientError { + #[error("DevX Core request failed: {0}")] + Network(#[from] reqwest::Error), + #[error("DevX Core returned HTTP {status}: {code}{message}")] + Api { + status: u16, + code: String, + message: DisplayMessage, + }, + #[error("DevX Core returned an invalid HTTP {status} response: {message}")] + InvalidResponse { status: u16, message: String }, +} + +#[derive(Debug)] +pub(crate) struct DisplayMessage(Option); + +impl std::fmt::Display for DisplayMessage { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(message) = &self.0 { + write!(formatter, ": {message}") + } else { + Ok(()) + } + } +} + +impl From for crate::error::GddyError { + fn from(error: NativeAppClientError) -> Self { + match error { + NativeAppClientError::Network(error) => { + Self::network(format!("DevX Core request failed: {error}")) + .with_system("applications") + } + NativeAppClientError::Api { + status, + code, + message, + } => Self::from_http(status, format!("{code}{message}"), "applications"), + NativeAppClientError::InvalidResponse { status, message } => Self::from_http( + status, + format!("invalid DevX Core response: {message}"), + "applications", + ), + } + } +} + +#[derive(Debug, Deserialize)] +struct SuccessEnvelope { + success: bool, + data: T, +} + +#[derive(Debug, Deserialize)] +struct ErrorEnvelope { + error: ErrorPayload, +} + +#[derive(Debug, Deserialize)] +struct ErrorPayload { + code: String, + message: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateNativeAppInput<'a> { + organization_id: &'a str, + #[serde(flatten)] + native_app: &'a NativeAppInput, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SupportEmailInput<'a> { + support_email: &'a str, +} + +pub(crate) struct NativeAppClient { + base_url: String, + token: String, + http: reqwest::Client, +} + +impl NativeAppClient { + pub(crate) fn new(base_url: impl Into, token: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_owned(), + token: token.into(), + http: crate::application::client::make_http_client(), + } + } + + pub(crate) async fn get( + &self, + application_id: &str, + ) -> Result, NativeAppClientError> { + let request = self + .http + .get(self.url(application_id)) + .bearer_auth(&self.token); + self.send(request).await + } + + pub(crate) async fn create( + &self, + application_id: &str, + organization_id: &str, + input: &NativeAppInput, + ) -> Result { + let request = self + .http + .post(self.url(application_id)) + .bearer_auth(&self.token) + .json(&CreateNativeAppInput { + organization_id, + native_app: input, + }); + self.send(request).await + } + + pub(crate) async fn update( + &self, + application_id: &str, + input: &NativeAppInput, + ) -> Result { + let request = self + .http + .patch(self.url(application_id)) + .bearer_auth(&self.token) + .json(input); + self.send(request).await + } + + async fn update_support_email( + &self, + application_id: &str, + support_email: &str, + ) -> Result { + let request = self + .http + .patch(self.url(application_id)) + .bearer_auth(&self.token) + .json(&SupportEmailInput { support_email }); + self.send(request).await + } + + pub(crate) async fn upsert( + &self, + application_id: &str, + organization_id: &str, + input: &NativeAppInput, + ) -> Result { + if self.get(application_id).await?.is_some() { + self.update(application_id, input).await?; + return Ok(UpsertOperation::Updated); + } + + self.create(application_id, organization_id, input).await?; + // DevX Portal currently follows create with this patch because + // application-service does not reliably persist supportEmail on create. + self.update_support_email(application_id, &input.support_email) + .await?; + Ok(UpsertOperation::Created) + } + + fn url(&self, application_id: &str) -> String { + let encoded_id: String = + url::form_urlencoded::byte_serialize(application_id.as_bytes()).collect(); + format!("{}{NATIVE_APPS_PATH}/{encoded_id}", self.base_url) + } + + async fn send( + &self, + request: RequestBuilder, + ) -> Result { + let request = request.build()?; + cli_engine::transport::debug_log_reqwest_request(&request); + let response = self.http.execute(request).await?; + let status = response.status(); + let headers = response.headers().clone(); + let bytes = response.bytes().await?; + cli_engine::transport::debug_log_reqwest_response(status, &headers, &bytes); + + if !status.is_success() { + return Err(api_error(status.as_u16(), &bytes)); + } + + let envelope: SuccessEnvelope = serde_json::from_slice(&bytes).map_err(|error| { + NativeAppClientError::InvalidResponse { + status: status.as_u16(), + message: error.to_string(), + } + })?; + if !envelope.success { + return Err(NativeAppClientError::InvalidResponse { + status: status.as_u16(), + message: "success envelope contained success=false".to_owned(), + }); + } + Ok(envelope.data) + } +} + +fn api_error(status: u16, body: &[u8]) -> NativeAppClientError { + if let Ok(envelope) = serde_json::from_slice::(body) { + return NativeAppClientError::Api { + status, + code: envelope.error.code, + message: DisplayMessage(envelope.error.message), + }; + } + + NativeAppClientError::Api { + status, + code: String::from_utf8_lossy(body).into_owned(), + message: DisplayMessage(None), + } +} + +#[cfg(test)] +mod tests { + use httpmock::{Method, MockServer}; + use serde_json::json; + + use super::*; + + fn input() -> NativeAppInput { + NativeAppInput { + name: "Example Native App".to_owned(), + description: "Example description".to_owned(), + support_email: "support@example.com".to_owned(), + app_category: String::new(), + merchant_category: String::new(), + android_package_name: "com.example.app".to_owned(), + status: "draft".to_owned(), + } + } + + fn native_app_json() -> serde_json::Value { + json!({ + "applicationId": "app-1", + "name": "Example Native App", + "description": "Example description", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft", + "released": false + }) + } + + #[tokio::test] + async fn get_sends_bearer_user_agent_and_decodes_success() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(Method::GET) + .path("/api/v1/native-apps/app-1") + .header("authorization", "Bearer test-token") + .header( + "user-agent", + concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")), + ); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + + let app = NativeAppClient::new(server.base_url(), "test-token") + .get("app-1") + .await + .expect("get native app") + .expect("native app exists"); + + mock.assert_async().await; + assert_eq!(app.application_id, "app-1"); + assert_eq!(app.android_package_name, "com.example.app"); + } + + #[tokio::test] + async fn upsert_creates_when_absent_and_patches_support_email() { + let server = MockServer::start_async().await; + let get = server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = server + .mock_async(|when, then| { + when.method(Method::POST) + .path("/api/v1/native-apps/app-1") + .header("authorization", "Bearer test-token") + .json_body(json!({ + "organizationId": "550e8400-e29b-41d4-a716-446655440000", + "name": "Example Native App", + "description": "Example description", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let support_patch = server + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-1") + .json_body(json!({ "supportEmail": "support@example.com" })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + + let operation = NativeAppClient::new(server.base_url(), "test-token") + .upsert("app-1", "550e8400-e29b-41d4-a716-446655440000", &input()) + .await + .expect("create native app"); + + assert_eq!(operation, UpsertOperation::Created); + get.assert_async().await; + create.assert_async().await; + support_patch.assert_async().await; + } + + #[tokio::test] + async fn upsert_updates_when_present() { + let server = MockServer::start_async().await; + let get = server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let update = server + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-1") + .json_body(json!({ + "name": "Example Native App", + "description": "Example description", + "supportEmail": "support@example.com", + "appCategory": "", + "merchantCategory": "", + "androidPackageName": "com.example.app", + "status": "draft" + })); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + + let operation = NativeAppClient::new(server.base_url(), "test-token") + .upsert("app-1", "ignored-org", &input()) + .await + .expect("update native app"); + + assert_eq!(operation, UpsertOperation::Updated); + get.assert_async().await; + update.assert_async().await; + } + + #[tokio::test] + async fn error_envelope_preserves_service_code_and_message() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(Method::PATCH).path("/api/v1/native-apps/app-1"); + then.status(409).json_body(json!({ + "success": false, + "error": { + "code": "PACKAGE_NAME_IMMUTABLE", + "message": "Package name cannot change after release" + } + })); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .update("app-1", &input()) + .await + .expect_err("immutable package name must fail"); + let message = error.to_string(); + assert!(message.contains("PACKAGE_NAME_IMMUTABLE"), "{message}"); + assert!(message.contains("cannot change after release"), "{message}"); + } + + #[tokio::test] + async fn malformed_success_envelope_is_rejected() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(200) + .json_body(json!({ "success": true, "data": { "unexpected": true } })); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .get("app-1") + .await + .expect_err("invalid native app response must fail"); + assert!(matches!( + error, + NativeAppClientError::InvalidResponse { status: 200, .. } + )); + } + + #[test] + fn url_encodes_application_id_path_segment() { + let client = NativeAppClient::new("https://example.test/", "token"); + assert_eq!( + client.url("app/with space"), + "https://example.test/api/v1/native-apps/app%2Fwith+space" + ); + } +} diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index 868e41ff..24212484 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; +mod native_extension; mod settings; pub(crate) mod settings_form; @@ -124,7 +125,7 @@ impl Config { } if let Some(native) = &self.native_extension { - validate_native_extension( + native_extension::validate( &mut errors, "native_extension", &native.support_contact, @@ -256,20 +257,6 @@ fn validate_named_extension( } } -fn validate_native_extension( - errors: &mut Vec, - path: &str, - support_contact: &str, - android_package_name: &str, -) { - require_non_empty(errors, &format!("{path}.support_contact"), support_contact); - require_non_empty( - errors, - &format!("{path}.android_package_name"), - android_package_name, - ); -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActionConfig { pub name: String, diff --git a/rust/src/config/native_extension.rs b/rust/src/config/native_extension.rs new file mode 100644 index 00000000..c134e966 --- /dev/null +++ b/rust/src/config/native_extension.rs @@ -0,0 +1,96 @@ +//! Validation for `[native_extension]` manifest configuration. + +pub(super) fn validate( + errors: &mut Vec, + path: &str, + support_contact: &str, + android_package_name: &str, +) { + if !is_valid_email(support_contact) { + errors.push(format!( + "{path}.support_contact must be a valid email address (got {support_contact:?})" + )); + } + if android_package_name.is_empty() { + errors.push(format!("{path}.android_package_name must be non-empty")); + } +} + +/// Match the email shape enforced by DevX Core's Zod v3 `email()` validator. +fn is_valid_email(value: &str) -> bool { + if value.starts_with('.') || value.contains("..") { + return false; + } + let Some((local, domain)) = value.split_once('@') else { + return false; + }; + if domain.contains('@') || local.is_empty() { + return false; + } + let Some(last_local) = local.bytes().next_back() else { + return false; + }; + if !local.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'\'' | b'+' | b'-' | b'.') + }) || !(last_local.is_ascii_alphanumeric() || matches!(last_local, b'_' | b'+' | b'-')) + { + return false; + } + + let mut labels = domain.split('.').peekable(); + let Some(first_label) = labels.next() else { + return false; + }; + if labels.peek().is_none() || !is_valid_domain_label(first_label) { + return false; + } + let remaining: Vec<&str> = labels.collect(); + let Some(top_level_domain) = remaining.last() else { + return false; + }; + remaining.iter().all(|label| is_valid_domain_label(label)) + && top_level_domain.len() >= 2 + && top_level_domain + .bytes() + .all(|byte| byte.is_ascii_alphabetic()) +} + +fn is_valid_domain_label(label: &str) -> bool { + label + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +#[cfg(test)] +mod tests { + use super::is_valid_email; + + #[test] + fn rejects_malformed_addresses() { + for value in [ + "", + "not-an-email", + ".support@example.com", + "support..team@example.com", + "support@example", + "support@example.c", + ] { + assert!(!is_valid_email(value), "unexpected valid email: {value:?}"); + } + } + + #[test] + fn accepts_devx_core_email_shape() { + for value in [ + "support@example.com", + "native.app+alerts@sub.example.co.uk", + "team_member@example.io", + ] { + assert!(is_valid_email(value), "unexpected invalid email: {value:?}"); + } + } +} diff --git a/rust/src/config/settings_form.rs b/rust/src/config/settings_form.rs index a8f027e9..879238ac 100644 --- a/rust/src/config/settings_form.rs +++ b/rust/src/config/settings_form.rs @@ -366,10 +366,10 @@ fn validate_field(field: &SettingsFormV1Field, errors: &mut Vec, path: & } match field { SettingsFormV1Field::Select { options, .. } - | SettingsFormV1Field::MultiSelect { options, .. } => { - if options.is_empty() { - errors.push(format!("{path}.options must contain at least one option")); - } + | SettingsFormV1Field::MultiSelect { options, .. } + if options.is_empty() => + { + errors.push(format!("{path}.options must contain at least one option")); } SettingsFormV1Field::ListGroup { item, .. } => { if !is_field_name(&item.id_field) { diff --git a/rust/src/platform/guides/platform-overview.md b/rust/src/platform/guides/platform-overview.md index d3b6fe9c..22b21854 100644 --- a/rust/src/platform/guides/platform-overview.md +++ b/rust/src/platform/guides/platform-overview.md @@ -14,15 +14,17 @@ gddy platform app init --name my-app --url https://example.com --proxy-url https This calls the app-registry API to create the application, then writes `godaddy.toml` (and a per-env secrets file) to the current directory. `url`/`proxy-url` must be publicly resolvable HTTP(S) — localhost, loopback, and private IPs are rejected. Re-run with `--config ` to seed flags from an existing manifest instead of retyping them. Requires the `applications.*:read`/`write` scopes (`--scope` on `gddy auth login`, or a PAT with the same scopes). -## 2. Configure it locally +## 2. Configure it -`gddy platform app add ` appends to `godaddy.toml` without any network call: +Most `gddy platform app add ` commands only append to `godaddy.toml`: - `add action --name --url ` — an HTTP endpoint the platform calls on the app's behalf. - `add subscription --name --url --events ` — a webhook route for platform events; run `gddy platform webhook events` to see valid event types. - `add extension ...` — a UI extension bundle (see that subcommand's own `--help`). - `add settings --group --slug --entry-path ...` — placement metadata for a merchant-facing settings form or link. This only writes placement fields (group/slug/entryPath/order/capabilities/icon); the presentation itself (`[settings.presentation]`) has to be hand-authored in `godaddy.toml` afterward. See the `platform-settings` guide (`gddy guide platform-settings`) for the full presentation shape. +`add native-extension --support-contact --android-package-name [--name ]` is the exception: it authenticates, looks up the manifest application by name, and immediately creates or updates its DevX Core native-app draft before writing `[native_extension]` locally. It requires App Registry read/write scopes. If the remote draft succeeds but the local write fails, fix the local file problem and rerun the same command; the remote upsert is idempotent. + Run `gddy platform app config validate` any time to check the manifest against every rule the API would otherwise enforce (required fields, URL/UUID/semver shapes, settings placement rules) without a network call — it reports every violation found, not just the first. ## 3. Release From 1955ef34567a72fabe8ba0026918bbee1848e82c Mon Sep 17 00:00:00 2001 From: cblecken-godaddy <80284905+cblecken-godaddy@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:29:33 -0700 Subject: [PATCH 03/14] test(platform): cover native-app client failure paths; bound error bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `api_error` put the entire raw response body into the error `code` when the body was not a DevX Core error envelope. `GddyError::from` interpolates that code into the user-facing message, so a gateway returning an HTML error page dumped the whole page into `error.code`. Emit a stable `UPSTREAM_ERROR` code and a whitespace-collapsed, 200-byte-bounded snippet as the message instead, truncating on a char boundary. New tests: - `create_reports_failure_when_support_email_patch_fails` — the follow-up `supportEmail` PATCH is load-bearing (application-service does not persist the field on create), so pin that `upsert` surfaces its failure even though the draft already exists upstream and a retry then reports `Updated` - `non_envelope_error_body_is_reduced_to_a_bounded_snippet` — covers the fix above - `error_envelope_without_message_renders_code_only` and `empty_error_body_carries_no_message` — exercise the `DisplayMessage(None)` branch so a missing message cannot leave a dangling separator Also note in the test module that the pinned `status: "draft"` payloads describe what the CLI sends, not what upstream stores: application-service omits `status` from its PATCH DTO and hardcodes `ACTIVE` on create, so DevX Core's `draft -> INACTIVE` mapping never takes effect. Co-Authored-By: Claude Opus 5 --- rust/src/application/native_app_client.rs | 163 +++++++++++++++++++++- 1 file changed, 161 insertions(+), 2 deletions(-) diff --git a/rust/src/application/native_app_client.rs b/rust/src/application/native_app_client.rs index af49b5d5..52a3ef15 100644 --- a/rust/src/application/native_app_client.rs +++ b/rust/src/application/native_app_client.rs @@ -252,6 +252,14 @@ impl NativeAppClient { } } +/// Fallback error code for responses that are not a DevX Core error envelope — +/// typically a gateway or proxy error page rather than the service itself. +const UPSTREAM_ERROR_CODE: &str = "UPSTREAM_ERROR"; + +/// Upper bound on how much of a non-envelope body is echoed back to the user. +/// Proxies happily return whole HTML pages; those must not land in an error message. +const MAX_BODY_SNIPPET_BYTES: usize = 200; + fn api_error(status: u16, body: &[u8]) -> NativeAppClientError { if let Ok(envelope) = serde_json::from_slice::(body) { return NativeAppClientError::Api { @@ -263,11 +271,32 @@ fn api_error(status: u16, body: &[u8]) -> NativeAppClientError { NativeAppClientError::Api { status, - code: String::from_utf8_lossy(body).into_owned(), - message: DisplayMessage(None), + code: UPSTREAM_ERROR_CODE.to_owned(), + message: DisplayMessage(body_snippet(body)), } } +/// Collapse a non-envelope response body into a short single-line snippet. +/// Returns `None` when the body carries no printable content. +fn body_snippet(body: &[u8]) -> Option { + let text = String::from_utf8_lossy(body); + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return None; + } + + if collapsed.len() <= MAX_BODY_SNIPPET_BYTES { + return Some(collapsed); + } + + // Truncate on a char boundary so multi-byte sequences are never split. + let mut end = MAX_BODY_SNIPPET_BYTES; + while end > 0 && !collapsed.is_char_boundary(end) { + end -= 1; + } + Some(format!("{}…", &collapsed[..end])) +} + #[cfg(test)] mod tests { use httpmock::{Method, MockServer}; @@ -287,6 +316,11 @@ mod tests { } } + /// Note on `status`: these payload assertions pin what the CLI *sends*. + /// Upstream currently discards it — application-service omits `status` from + /// its PATCH DTO and hardcodes `ACTIVE` on create — so DevX Core's + /// `draft -> INACTIVE` mapping never takes effect and a read back reports + /// `active`. Do not read these assertions as proof that drafts work. fn native_app_json() -> serde_json::Value { json!({ "applicationId": "app-1", @@ -452,6 +486,131 @@ mod tests { assert!(message.contains("cannot change after release"), "{message}"); } + /// The follow-up `supportEmail` PATCH exists because application-service does + /// not reliably persist the field on create, so it is the likeliest step to + /// fail. When it does, `upsert` reports the failure even though the draft was + /// already created upstream — a later retry therefore reports `Updated`. + #[tokio::test] + async fn create_reports_failure_when_support_email_patch_fails() { + let server = MockServer::start_async().await; + let get = server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(200) + .json_body(json!({ "success": true, "data": null })); + }) + .await; + let create = server + .mock_async(|when, then| { + when.method(Method::POST).path("/api/v1/native-apps/app-1"); + then.status(200).json_body(json!({ + "success": true, + "data": native_app_json() + })); + }) + .await; + let support_patch = server + .mock_async(|when, then| { + when.method(Method::PATCH) + .path("/api/v1/native-apps/app-1") + .json_body(json!({ "supportEmail": "support@example.com" })); + then.status(500).json_body(json!({ + "success": false, + "error": { "code": "APPLICATION_SERVICE_ERROR", "message": "Update native app: HTTP 500" } + })); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .upsert("app-1", "550e8400-e29b-41d4-a716-446655440000", &input()) + .await + .expect_err("support email patch failure must surface"); + + get.assert_async().await; + create.assert_async().await; + support_patch.assert_async().await; + assert!(matches!( + error, + NativeAppClientError::Api { status: 500, .. } + )); + let message = error.to_string(); + assert!(message.contains("APPLICATION_SERVICE_ERROR"), "{message}"); + } + + /// Gateways and proxies answer with HTML rather than a DevX Core envelope. + /// The page must not be echoed back wholesale as the error code. + #[tokio::test] + async fn non_envelope_error_body_is_reduced_to_a_bounded_snippet() { + let server = MockServer::start_async().await; + let filler = "gateway timed out ".repeat(40); + server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(502) + .header("content-type", "text/html") + .body(format!("\n {filler}\n")); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .get("app-1") + .await + .expect_err("gateway error must fail"); + + let NativeAppClientError::Api { + status, + code, + message, + } = error + else { + panic!("expected an API error, got {error:?}"); + }; + assert_eq!(status, 502); + assert_eq!(code, UPSTREAM_ERROR_CODE); + let rendered = message.to_string(); + assert!(rendered.starts_with(": gateway"), "{rendered}"); + assert!(rendered.ends_with('…'), "{rendered}"); + let max_rendered = MAX_BODY_SNIPPET_BYTES + ": ".len() + '…'.len_utf8(); + assert!( + rendered.len() <= max_rendered, + "snippet not bounded: {} bytes exceeds {max_rendered}", + rendered.len() + ); + } + + #[tokio::test] + async fn error_envelope_without_message_renders_code_only() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(Method::GET).path("/api/v1/native-apps/app-1"); + then.status(403) + .json_body(json!({ "success": false, "error": { "code": "FORBIDDEN" } })); + }) + .await; + + let error = NativeAppClient::new(server.base_url(), "test-token") + .get("app-1") + .await + .expect_err("forbidden must fail"); + + assert_eq!( + error.to_string(), + "DevX Core returned HTTP 403: FORBIDDEN", + "a missing message must not leave a dangling separator" + ); + } + + #[test] + fn empty_error_body_carries_no_message() { + let error = api_error(504, b" \n "); + let NativeAppClientError::Api { code, message, .. } = error else { + panic!("expected an API error"); + }; + assert_eq!(code, UPSTREAM_ERROR_CODE); + assert_eq!(message.to_string(), ""); + } + #[tokio::test] async fn malformed_success_envelope_is_rejected() { let server = MockServer::start_async().await; From 00cb916ca6fd9d888d6f0f8b00a41ef8c36438ea Mon Sep 17 00:00:00 2001 From: Milos Stankovic Date: Wed, 23 Sep 2026 13:20:56 +0200 Subject: [PATCH 04/14] feat(platform): omit nativeExtensions unless the native-apps flag is visible Stable release stays a normal web release when godaddy.toml already has a native_extension section. --- rust/src/platform/app/commands/mod.rs | 5 + rust/src/platform/app/commands/release.rs | 126 +++++++++++++++++++++- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/rust/src/platform/app/commands/mod.rs b/rust/src/platform/app/commands/mod.rs index 93d5ce56..afa208a9 100644 --- a/rust/src/platform/app/commands/mod.rs +++ b/rust/src/platform/app/commands/mod.rs @@ -6,6 +6,11 @@ use cli_engine::{GroupSpec, NextAction, NextActionParam, RuntimeGroupSpec}; use crate::http::api_url_for_env; use crate::next_action::{next_action, required_value}; +/// Feature-flag key for native-app CLI surfaces. `Stage::Experimental`. +/// `platform app add native-extension` declares it. `platform app release` +/// consults it before attaching `nativeExtensions`. +pub(crate) const NATIVE_APPS_FLAG_KEY: &str = "native-apps"; + mod add; mod add_extension; mod config; diff --git a/rust/src/platform/app/commands/release.rs b/rust/src/platform/app/commands/release.rs index 7dd1e90d..62340226 100644 --- a/rust/src/platform/app/commands/release.rs +++ b/rust/src/platform/app/commands/release.rs @@ -223,6 +223,23 @@ fn native_extension_draft(config: &crate::config::Config) -> Option