diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 0c58d337..a5127fc4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -49,6 +49,9 @@ jobs: - name: Test run: cargo test + - name: Smoke test + run: ./scripts/smoke-test.sh + - name: Check module size limits working-directory: . run: ./rust/scripts/check-module-size.sh diff --git a/docs/application-settings.md b/docs/application-settings.md index 71a800de..6c7e21d9 100644 --- a/docs/application-settings.md +++ b/docs/application-settings.md @@ -146,15 +146,34 @@ presentationFile = "fixtures/manual-tax-registry-presentation.json" The referenced file must be the complete API presentation object — `type` (`"form"`), `schemaVersion` (`"settings-form-v1"`), and `sections` — the same shape `createRelease.settings[].presentation` expects, so an existing fixture can be reused verbatim. The path is relative to the directory containing the `godaddy.toml` being released, not the shell's working directory. `presentation` and `presentationFile` are mutually exclusive; both forms run through the same field/section validation and produce an identical release payload. The file itself is only opened at `release` — like inline `presentation`, it's optional at `add settings`/`config validate` time — and a missing, unreadable, malformed, or wrong-`type`/`schemaVersion` file fails the release with a `VALIDATION_ERROR` naming the resolved path. +## Link presentation (`settings-link-v1`) + +For a GPA that must own its own configuration page or provider authorization flow instead of a native form, `presentation` can instead be a link: + +```toml +[[settings]] +group = "payment-methods" +slug = "paypal-payments" +entryPath = "/settings/paypal" +capabilities = ["read", "open"] + +[settings.presentation] +label = "Configure PayPal" +openMode = "new-window" +``` + +A link presentation requires exactly the `read` and `open` capabilities — no other combination is valid, and `open` is rejected on a form presentation. `label` must be non-empty; `openMode` currently only accepts `"new-window"`. `--presentation-file` also accepts a link's full API object (`type: "link"`, `schemaVersion: "settings-link-v1"`, `label`, `openMode`). See `app-registry-api`'s `docs/SETTINGS.md` for the full lifecycle contract this registers into. + ## What the CLI validates locally vs. server-side `gddy platform app add settings`/`release` catch cheap, structural problems before any network call: - `group`/`slug` match the platform's slug pattern (`lowercase-with-dashes`). - `entryPath` is a route-safe path (`/`-prefixed, no query string/fragment/`..`), and doesn't overlap another setting's `entryPath` in the same manifest. -- `capabilities` are a subset of `read`, `write`, `validate`, `test`, `delete`. +- `capabilities` are a subset of `read`, `write`, `validate`, `test`, `delete`, `open`, with `open` only valid — and required — alongside `read` on a link presentation. - `icon.library` is one of `ux`, `lucide`, `commerce`. - Every field/section `key` matches the platform's key pattern, `select`/`multi-select` have at least one option, and no two fields/sections share a key. +- A link presentation's `label` is non-empty and `openMode` is `"new-window"`. - `presentation` and `presentationFile` aren't both set on the same entry — checked as soon as the manifest is touched, not just at release. Deeper semantics stay server-validated — bounds consistency (`maxLength ≥ minLength`), a `defaultValue` actually matching a registered option or satisfying bounds, and `list-group` nesting depth. A rejection there surfaces as a `release` API error, not a local one. diff --git a/rust/scripts/smoke-test.sh b/rust/scripts/smoke-test.sh index dd9975a9..925a10ca 100755 --- a/rust/scripts/smoke-test.sh +++ b/rust/scripts/smoke-test.sh @@ -392,6 +392,76 @@ else fi check_valid "A22 config validate accepts the CLI-added placement-only entry" +write_manifest <<'EOF' +[[settings]] +group = "payment-methods" +slug = "paypal-payments" +entryPath = "/settings/paypal" +capabilities = ["read", "write"] + +[settings.presentation] +label = "Configure PayPal" +openMode = "new-window" +EOF +check_invalid "A23 rejects settings-link-v1 without exactly read+open capabilities" "requires exactly the read and open capabilities" + +write_manifest <<'EOF' +[[settings]] +group = "payment-methods" +slug = "paypal-payments" +entryPath = "/settings/paypal" +capabilities = ["read", "open"] + +[settings.presentation] +label = "" +openMode = "new-window" +EOF +check_invalid "A24 rejects a settings-link-v1 presentation with an empty label" "label must not be empty" + +write_manifest <<'EOF' +[[settings]] +group = "payment-methods" +slug = "paypal-payments" +entryPath = "/settings/paypal" +capabilities = ["read", "open"] + +[settings.presentation] +label = "Configure PayPal" +openMode = "same-window" +EOF +check_invalid "A25 rejects a settings-link-v1 presentation with a non-new-window openMode" "openMode must be" + +write_manifest <<'EOF' +[[settings]] +group = "tax-center" +slug = "manual-tax" +entryPath = "/settings/manual-tax" +capabilities = ["read", "write", "open"] + +[[settings.presentation.sections]] +key = "defaults" +label = "Defaults" + +[[settings.presentation.sections.fields]] +type = "boolean" +key = "flag" +label = "Flag" +EOF +check_invalid "A26 rejects the open capability on a settings-form-v1 presentation" "only valid for a settings-link-v1" + +write_manifest <<'EOF' +[[settings]] +group = "payment-methods" +slug = "paypal-payments" +entryPath = "/settings/paypal" +capabilities = ["read", "open"] + +[settings.presentation] +label = "Configure PayPal" +openMode = "new-window" +EOF +check_valid "A27 config validate accepts a well-formed settings-link-v1 entry" + echo "=== Group B: release-time behavior (mocked network) ===" cat >fixtures/manual-tax.json <<'EOF' @@ -812,6 +882,32 @@ else fail "C3b release did not echo extended field options as expected: $release_out" fi +write_manifest <<'EOF' + +[[settings]] +group = "payment-methods" +slug = "paypal-payments" +title = "PayPal Payments" +entryPath = "/settings/paypal" +capabilities = ["read", "open"] + +[settings.presentation] +label = "Configure PayPal" +openMode = "new-window" +EOF +release_out="$(gddy platform app release --application-id smoke-app-id --version 0.0.1 2>&1)" +if echo "$release_out" | jq -e ' + .data.settings[0].presentation.type == "link" + and .data.settings[0].presentation.schemaVersion == "settings-link-v1" + and .data.settings[0].presentation.label == "Configure PayPal" + and .data.settings[0].presentation.openMode == "new-window" + and .data.settings[0].capabilities == ["read","open"] + ' >/dev/null 2>&1; then + pass "C4 release echoes a settings-link-v1 entry with type/schemaVersion/label/openMode" +else + fail "C4 release did not echo the link entry as expected: $release_out" +fi + echo "=== Group D: server error responses (mocked network) ===" write_manifest <<'EOF' diff --git a/rust/src/application/commands/add.rs b/rust/src/application/commands/add.rs index db8a9925..dfd99d39 100644 --- a/rust/src/application/commands/add.rs +++ b/rust/src/application/commands/add.rs @@ -46,7 +46,7 @@ struct SettingsArgs { order: Option, /// One or more lifecycle capabilities (read, write, validate, test, - /// delete). Defaults to read+write server-side when omitted. + /// delete, open) — a link presentation requires exactly read+open. #[arg(long = "capability", value_name = "CAPABILITY", num_args = 1..)] capabilities: Vec, @@ -181,10 +181,11 @@ pub(super) fn group() -> RuntimeGroupSpec { "Register the placement metadata for an application-settings \ capability in the godaddy.toml manifest in the current directory. \ This command only writes group/slug/entryPath/order/capabilities/icon \ - — it cannot author the settings-form-v1 form itself. After running \ - it, hand-add a [settings.presentation] block (sections and fields) \ - to the written entry; `gddy platform app release` rejects a \ - settings entry with no presentation.", + — it cannot author the settings-form-v1 form or settings-link-v1 \ + link itself. After running it, hand-add a [settings.presentation] \ + block to the written entry — sections and fields for a form, or a \ + label and openMode for a link; `gddy platform app release` rejects \ + a settings entry with no presentation.", ) .with_system("applications") .with_tier(Tier::Mutate) diff --git a/rust/src/application/commands/release.rs b/rust/src/application/commands/release.rs index e9c03e14..3485297b 100644 --- a/rust/src/application/commands/release.rs +++ b/rust/src/application/commands/release.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use super::schemas::ApplicationRelease; use crate::config::settings_form::{ - SettingsFormV1Presentation, presentation_from_json, validate_presentation, + SettingPresentation, presentation_from_json, validate_presentation, }; use crate::next_action::next_action; use crate::scopes::{APP_REGISTRY_READ, APP_REGISTRY_WRITE}; @@ -38,7 +38,7 @@ fn ui_extension_entry( fn resolve_presentation( setting: &crate::config::SettingConfig, manifest_dir: &Path, -) -> cli_engine::Result { +) -> cli_engine::Result { match (&setting.presentation, &setting.presentation_file) { (Some(_), Some(_)) => Err(crate::error::GddyError::validation(format!( "setting '{}' has both presentation and presentationFile — provide only one", @@ -81,7 +81,12 @@ fn setting_entry( ) -> cli_engine::Result { let presentation = resolve_presentation(setting, manifest_dir)?; let mut errors = Vec::new(); - validate_presentation(&presentation, &mut errors, "presentation"); + validate_presentation( + &presentation, + &setting.capabilities, + &mut errors, + "presentation", + ); if !errors.is_empty() { return Err(crate::error::GddyError::validation(format!( "setting '{}' presentation is invalid: {}", @@ -93,8 +98,16 @@ fn setting_entry( let mut presentation_json = serde_json::to_value(&presentation) .map_err(|e| cli_engine::CliCoreError::message(e.to_string()))?; if let Value::Object(map) = &mut presentation_json { - map.insert("type".to_owned(), json!("form")); - map.insert("schemaVersion".to_owned(), json!("settings-form-v1")); + match &presentation { + SettingPresentation::Form(_) => { + map.insert("type".to_owned(), json!("form")); + map.insert("schemaVersion".to_owned(), json!("settings-form-v1")); + } + SettingPresentation::Link(_) => { + map.insert("type".to_owned(), json!("link")); + map.insert("schemaVersion".to_owned(), json!("settings-link-v1")); + } + } } let mut entry = json!({ @@ -342,9 +355,12 @@ mod tests { } } - fn boolean_presentation() -> crate::config::settings_form::SettingsFormV1Presentation { - use crate::config::settings_form::{SettingsFormV1Field, SettingsFormV1Section}; - crate::config::settings_form::SettingsFormV1Presentation { + fn boolean_presentation() -> crate::config::settings_form::SettingPresentation { + use crate::config::settings_form::{ + SettingPresentation, SettingsFormV1Field, SettingsFormV1Presentation, + SettingsFormV1Section, + }; + SettingPresentation::Form(SettingsFormV1Presentation { sections: vec![SettingsFormV1Section { key: "defaults".to_owned(), label: "Defaults".to_owned(), @@ -358,14 +374,23 @@ mod tests { default_value: Some(true), }], }], - } + }) + } + + fn link_presentation() -> crate::config::settings_form::SettingPresentation { + use crate::config::settings_form::{SettingPresentation, SettingsLinkV1Presentation}; + SettingPresentation::Link(SettingsLinkV1Presentation { + label: "Configure PayPal".to_owned(), + open_mode: "new-window".to_owned(), + }) } - fn list_group_presentation() -> crate::config::settings_form::SettingsFormV1Presentation { + fn list_group_presentation() -> crate::config::settings_form::SettingPresentation { use crate::config::settings_form::{ - ChoiceOption, ListGroupItem, SelectValue, SettingsFormV1Field, SettingsFormV1Section, + ChoiceOption, ListGroupItem, SelectValue, SettingPresentation, SettingsFormV1Field, + SettingsFormV1Presentation, SettingsFormV1Section, }; - crate::config::settings_form::SettingsFormV1Presentation { + SettingPresentation::Form(SettingsFormV1Presentation { sections: vec![SettingsFormV1Section { key: "rules".to_owned(), label: "Rules".to_owned(), @@ -396,7 +421,7 @@ mod tests { }, }], }], - } + }) } #[test] @@ -442,6 +467,48 @@ mod tests { ); } + #[test] + fn setting_entry_maps_link_placement_and_presentation() { + let mut setting = crate::config::SettingConfig { + group: "payment-methods".to_owned(), + slug: "paypal-payments".to_owned(), + title: None, + description: None, + entry_path: "/settings/paypal".to_owned(), + order: None, + capabilities: vec!["read".to_owned(), "open".to_owned()], + icon: None, + metadata: None, + presentation_file: None, + presentation: None, + }; + setting.presentation = Some(link_presentation()); + let entry = super::setting_entry(&setting, std::path::Path::new("")).expect("entry builds"); + assert_eq!(entry["groupSlug"], "payment-methods"); + assert_eq!(entry["appSettingSlug"], "paypal-payments"); + assert_eq!(entry["entryPath"], "/settings/paypal"); + assert_eq!(entry["presentation"]["type"], "link"); + assert_eq!(entry["presentation"]["schemaVersion"], "settings-link-v1"); + assert_eq!(entry["presentation"]["label"], "Configure PayPal"); + assert_eq!(entry["presentation"]["openMode"], "new-window"); + assert_eq!(entry["capabilities"], serde_json::json!(["read", "open"])); + } + + #[test] + fn setting_entry_rejects_link_with_wrong_capabilities() { + let mut setting = placement_only_setting(); + setting.entry_path = "/settings/paypal".to_owned(); + setting.capabilities = vec!["read".to_owned(), "write".to_owned()]; + setting.presentation = Some(link_presentation()); + let err = super::setting_entry(&setting, std::path::Path::new("")) + .expect_err("wrong capabilities must be rejected"); + assert!( + err.to_string() + .contains("requires exactly the read and open capabilities"), + "{err}" + ); + } + #[test] fn setting_entry_includes_optional_fields_when_present() { let mut setting = placement_only_setting(); diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index f6c855b1..ea6b8838 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -446,7 +446,7 @@ pub fn write_env_file( #[cfg(test)] mod tests { use super::settings_form::{ - SettingsFormV1Field, SettingsFormV1Presentation, SettingsFormV1Section, + SettingPresentation, SettingsFormV1Field, SettingsFormV1Presentation, SettingsFormV1Section, }; use super::*; @@ -763,7 +763,7 @@ mod tests { }), metadata: None, presentation_file: None, - presentation: Some(SettingsFormV1Presentation { + presentation: Some(SettingPresentation::Form(SettingsFormV1Presentation { sections: vec![SettingsFormV1Section { key: "defaults".to_owned(), label: "Defaults".to_owned(), @@ -777,19 +777,20 @@ mod tests { default_value: Some(true), }], }], - }), + })), }); write_config(&path, &config).expect("write config with setting"); let read_back = read_config(&path).expect("read config with setting"); assert_eq!(read_back.settings.len(), 1); assert_eq!(read_back.settings[0].entry_path, "/settings/godaddy-tax"); - let SettingsFormV1Field::Boolean { default_value, .. } = &read_back.settings[0] + let SettingPresentation::Form(form) = read_back.settings[0] .presentation .as_ref() .expect("presentation") - .sections[0] - .fields[0] else { + unreachable!("expected form presentation"); + }; + let SettingsFormV1Field::Boolean { default_value, .. } = &form.sections[0].fields[0] else { unreachable!("expected boolean field"); }; assert_eq!(default_value, &Some(true)); diff --git a/rust/src/config/settings.rs b/rust/src/config/settings.rs index 7925b275..33d45b53 100644 --- a/rust/src/config/settings.rs +++ b/rust/src/config/settings.rs @@ -3,9 +3,9 @@ use serde::{Deserialize, Serialize}; -use super::settings_form::{SettingsFormV1Presentation, validate_presentation}; +use super::settings_form::{SettingPresentation, validate_presentation}; -const ALLOWED_CAPABILITIES: &[&str] = &["read", "write", "validate", "test", "delete"]; +const ALLOWED_CAPABILITIES: &[&str] = &["read", "write", "validate", "test", "delete", "open"]; const ALLOWED_ICON_LIBRARIES: &[&str] = &["ux", "lucide", "commerce"]; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -30,13 +30,10 @@ pub struct SettingConfig { /// directory at release time. Mutually exclusive with `presentation`. #[serde(default)] pub presentation_file: Option, - /// The `settings-form-v1` form shape. `None` until hand-added to - /// `godaddy.toml` — `gddy platform app add settings` can only write the - /// placement fields above; `release` rejects a settings entry with no - /// presentation instead of `Config::validate()`, so a placement-only - /// entry still parses/writes/validates fine for every other command. + /// The `settings-form-v1` or `settings-link-v1` shape; `None` until + /// hand-added — `release` rejects a settings entry with no presentation. #[serde(default)] - pub presentation: Option, + pub presentation: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -140,7 +137,12 @@ pub(super) fn validate_settings(settings: &[SettingConfig], errors: &mut Vec SettingConfig { SettingConfig { @@ -247,7 +250,9 @@ mod tests { fn validate_settings_rejects_both_presentation_and_presentation_file() { let mut s = setting("godaddy-tax", "/settings/godaddy-tax"); s.presentation_file = Some("presentation.json".to_owned()); - s.presentation = Some(SettingsFormV1Presentation { sections: vec![] }); + s.presentation = Some(SettingPresentation::Form(SettingsFormV1Presentation { + sections: vec![], + })); let mut errors = Vec::new(); validate_settings(&[s], &mut errors); assert!( @@ -258,6 +263,54 @@ mod tests { ); } + #[test] + fn validate_settings_accepts_well_formed_link_setting() { + let mut s = setting("paypal-payments", "/settings/paypal"); + s.capabilities = vec!["read".to_owned(), "open".to_owned()]; + s.presentation = Some(SettingPresentation::Link(SettingsLinkV1Presentation { + label: "Configure PayPal".to_owned(), + open_mode: "new-window".to_owned(), + })); + let mut errors = Vec::new(); + validate_settings(&[s], &mut errors); + assert!(errors.is_empty(), "{errors:?}"); + } + + #[test] + fn validate_settings_rejects_link_setting_with_wrong_capabilities() { + let mut s = setting("paypal-payments", "/settings/paypal"); + s.capabilities = vec!["read".to_owned(), "write".to_owned()]; + s.presentation = Some(SettingPresentation::Link(SettingsLinkV1Presentation { + label: "Configure PayPal".to_owned(), + open_mode: "new-window".to_owned(), + })); + let mut errors = Vec::new(); + validate_settings(&[s], &mut errors); + assert!( + errors + .iter() + .any(|e| e.contains("requires exactly the read and open capabilities")), + "{errors:?}" + ); + } + + #[test] + fn validate_settings_rejects_form_setting_with_open_capability() { + let mut s = setting("godaddy-tax", "/settings/godaddy-tax"); + s.capabilities = vec!["read".to_owned(), "open".to_owned()]; + s.presentation = Some(SettingPresentation::Form(SettingsFormV1Presentation { + sections: vec![], + })); + let mut errors = Vec::new(); + validate_settings(&[s], &mut errors); + assert!( + errors + .iter() + .any(|e| e.contains("only valid for a settings-link-v1")), + "{errors:?}" + ); + } + #[test] fn validate_settings_rejects_overlapping_entry_paths() { let mut errors = Vec::new(); diff --git a/rust/src/config/settings_form.rs b/rust/src/config/settings_form.rs index 03a62229..a8f027e9 100644 --- a/rust/src/config/settings_form.rs +++ b/rust/src/config/settings_form.rs @@ -179,6 +179,24 @@ pub enum SelectValue { Bool(bool), } +/// The `settings-link-v1` shape — for a GPA that owns its own configuration +/// page or provider authorization flow instead of a native form. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettingsLinkV1Presentation { + pub label: String, + pub open_mode: String, +} + +/// A setting's presentation — `sections` vs `label`+`openMode` are +/// structurally disjoint, so untagged matching is unambiguous. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SettingPresentation { + Form(SettingsFormV1Presentation), + Link(SettingsLinkV1Presentation), +} + /// True when `key` matches the same `fieldNamePattern` the API uses: /// `^[A-Za-z][A-Za-z0-9_]*$`. fn is_field_name(key: &str) -> bool { @@ -190,12 +208,59 @@ fn is_field_name(key: &str) -> bool { chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } -/// Structural validation for a `presentation` block: field-name shape, -/// non-empty choice options, and the two uniqueness checks -/// `SettingsFormV1Presentation`'s own Zod `superRefine` runs (unique section -/// keys, unique top-level field keys across sections). Bounds/default -/// consistency and `list-group` depth are left to the API. +/// Validates a presentation's own shape plus its capabilities, mirroring the +/// API's `validatePresentationCapabilities` refinement in `application-setting.ts`. pub(crate) fn validate_presentation( + presentation: &SettingPresentation, + capabilities: &[String], + errors: &mut Vec, + path: &str, +) { + match presentation { + SettingPresentation::Form(form) => { + if capabilities.iter().any(|c| c == "open") { + errors.push(format!( + "{path}: the \"open\" capability is only valid for a settings-link-v1 presentation" + )); + } + validate_form_presentation(form, errors, path); + } + SettingPresentation::Link(link) => { + let capability_set: HashSet<&str> = capabilities.iter().map(String::as_str).collect(); + if capabilities.len() != 2 + || !capability_set.contains("read") + || !capability_set.contains("open") + { + errors.push(format!( + "{path}: a settings-link-v1 presentation requires exactly the read and open capabilities" + )); + } + validate_link_presentation(link, errors, path); + } + } +} + +/// Structural validation for a link presentation: non-empty `label`, and +/// `openMode` matching the only value the API accepts today. +fn validate_link_presentation( + presentation: &SettingsLinkV1Presentation, + errors: &mut Vec, + path: &str, +) { + if presentation.label.trim().is_empty() { + errors.push(format!("{path}.label must not be empty")); + } + if presentation.open_mode != "new-window" { + errors.push(format!( + "{path}.openMode must be \"new-window\" (got {:?})", + presentation.open_mode + )); + } +} + +/// Structural validation for a form block: field-name shape, non-empty +/// choice options, and unique section/field keys. +fn validate_form_presentation( presentation: &SettingsFormV1Presentation, errors: &mut Vec, path: &str, @@ -233,33 +298,63 @@ pub(crate) fn validate_presentation( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct PresentationFileDocument { - #[serde(default)] - r#type: Option, +struct FormPresentationFileDocument { #[serde(default)] schema_version: Option, sections: Vec, } -/// Parses a `presentationFile`'s JSON (full API object: `type`, -/// `schemaVersion`, `sections`). -pub(crate) fn presentation_from_json(content: &str) -> Result { - let doc: PresentationFileDocument = serde_json::from_str(content).map_err(|e| e.to_string())?; - if let Some(t) = &doc.r#type - && t != "form" - { - return Err(format!("type must be \"form\" (got {t:?})")); - } - if let Some(v) = &doc.schema_version - && v != "settings-form-v1" - { - return Err(format!( - "schemaVersion must be \"settings-form-v1\" (got {v:?})" - )); +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LinkPresentationFileDocument { + #[serde(default)] + schema_version: Option, + label: String, + open_mode: String, +} + +/// Parses a `presentationFile`'s JSON, dispatching on `type` (defaults to +/// `"form"` when absent, matching existing fixtures that never wrote it). +pub(crate) fn presentation_from_json(content: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(content).map_err(|e| e.to_string())?; + let kind = match value.get("type") { + None | Some(serde_json::Value::Null) => "form".to_owned(), + Some(serde_json::Value::String(s)) => s.clone(), + Some(other) => return Err(format!("type must be a string (got {other:?})")), + }; + + match kind.as_str() { + "form" => { + let doc: FormPresentationFileDocument = + serde_json::from_value(value).map_err(|e| e.to_string())?; + if let Some(v) = &doc.schema_version + && v != "settings-form-v1" + { + return Err(format!( + "schemaVersion must be \"settings-form-v1\" (got {v:?})" + )); + } + Ok(SettingPresentation::Form(SettingsFormV1Presentation { + sections: doc.sections, + })) + } + "link" => { + let doc: LinkPresentationFileDocument = + serde_json::from_value(value).map_err(|e| e.to_string())?; + if let Some(v) = &doc.schema_version + && v != "settings-link-v1" + { + return Err(format!( + "schemaVersion must be \"settings-link-v1\" (got {v:?})" + )); + } + Ok(SettingPresentation::Link(SettingsLinkV1Presentation { + label: doc.label, + open_mode: doc.open_mode, + })) + } + other => Err(format!("type must be \"form\" or \"link\" (got {other:?})")), } - Ok(SettingsFormV1Presentation { - sections: doc.sections, - }) } fn validate_field(field: &SettingsFormV1Field, errors: &mut Vec, path: &str) { @@ -336,26 +431,36 @@ mod tests { assert!(!is_field_name("has-dash")); } + fn form(sections: Vec) -> SettingPresentation { + SettingPresentation::Form(SettingsFormV1Presentation { sections }) + } + + fn link(label: &str, open_mode: &str) -> SettingPresentation { + SettingPresentation::Link(SettingsLinkV1Presentation { + label: label.to_owned(), + open_mode: open_mode.to_owned(), + }) + } + #[test] fn validate_presentation_accepts_well_formed() { - let presentation = SettingsFormV1Presentation { - sections: vec![section("defaults", vec![text_field("calculateUsing")])], - }; + let presentation = form(vec![section( + "defaults", + vec![text_field("calculateUsing")], + )]); let mut errors = Vec::new(); - validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + validate_presentation(&presentation, &[], &mut errors, "settings[0].presentation"); assert!(errors.is_empty(), "{errors:?}"); } #[test] fn validate_presentation_rejects_duplicate_section_keys() { - let presentation = SettingsFormV1Presentation { - sections: vec![ - section("defaults", vec![text_field("a")]), - section("defaults", vec![text_field("b")]), - ], - }; + let presentation = form(vec![ + section("defaults", vec![text_field("a")]), + section("defaults", vec![text_field("b")]), + ]); let mut errors = Vec::new(); - validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + validate_presentation(&presentation, &[], &mut errors, "settings[0].presentation"); assert!( errors .iter() @@ -366,14 +471,12 @@ mod tests { #[test] fn validate_presentation_rejects_duplicate_field_keys_across_sections() { - let presentation = SettingsFormV1Presentation { - sections: vec![ - section("a", vec![text_field("shared")]), - section("b", vec![text_field("shared")]), - ], - }; + let presentation = form(vec![ + section("a", vec![text_field("shared")]), + section("b", vec![text_field("shared")]), + ]); let mut errors = Vec::new(); - validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + validate_presentation(&presentation, &[], &mut errors, "settings[0].presentation"); assert!( errors .iter() @@ -384,11 +487,9 @@ mod tests { #[test] fn validate_presentation_rejects_bad_field_key() { - let presentation = SettingsFormV1Presentation { - sections: vec![section("defaults", vec![text_field("bad-key")])], - }; + let presentation = form(vec![section("defaults", vec![text_field("bad-key")])]); let mut errors = Vec::new(); - validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + validate_presentation(&presentation, &[], &mut errors, "settings[0].presentation"); assert!( errors.iter().any(|e| e.contains("must match")), "{errors:?}" @@ -405,17 +506,131 @@ mod tests { options: vec![], default_value: None, }; - let presentation = SettingsFormV1Presentation { - sections: vec![section("defaults", vec![field])], - }; + let presentation = form(vec![section("defaults", vec![field])]); let mut errors = Vec::new(); - validate_presentation(&presentation, &mut errors, "settings[0].presentation"); + validate_presentation(&presentation, &[], &mut errors, "settings[0].presentation"); assert!( errors.iter().any(|e| e.contains("options must contain")), "{errors:?}" ); } + #[test] + fn validate_presentation_rejects_open_capability_on_form() { + let presentation = form(vec![section("defaults", vec![text_field("a")])]); + let mut errors = Vec::new(); + validate_presentation( + &presentation, + &["read".to_owned(), "open".to_owned()], + &mut errors, + "settings[0].presentation", + ); + assert!( + errors + .iter() + .any(|e| e.contains("only valid for a settings-link-v1")), + "{errors:?}" + ); + } + + #[test] + fn validate_presentation_accepts_well_formed_link() { + let presentation = link("Configure PayPal", "new-window"); + let mut errors = Vec::new(); + validate_presentation( + &presentation, + &["read".to_owned(), "open".to_owned()], + &mut errors, + "settings[0].presentation", + ); + assert!(errors.is_empty(), "{errors:?}"); + } + + #[test] + fn validate_presentation_rejects_link_without_exact_read_open_capabilities() { + let presentation = link("Configure PayPal", "new-window"); + let mut errors = Vec::new(); + validate_presentation( + &presentation, + &["read".to_owned(), "write".to_owned()], + &mut errors, + "settings[0].presentation", + ); + assert!( + errors + .iter() + .any(|e| e.contains("requires exactly the read and open capabilities")), + "{errors:?}" + ); + } + + #[test] + fn validate_presentation_rejects_link_with_empty_label() { + let presentation = link("", "new-window"); + let mut errors = Vec::new(); + validate_presentation( + &presentation, + &["read".to_owned(), "open".to_owned()], + &mut errors, + "settings[0].presentation", + ); + assert!( + errors.iter().any(|e| e.contains("label must not be empty")), + "{errors:?}" + ); + } + + #[test] + fn validate_presentation_rejects_link_with_wrong_open_mode() { + let presentation = link("Configure PayPal", "same-window"); + let mut errors = Vec::new(); + validate_presentation( + &presentation, + &["read".to_owned(), "open".to_owned()], + &mut errors, + "settings[0].presentation", + ); + assert!( + errors.iter().any(|e| e.contains("openMode must be")), + "{errors:?}" + ); + } + + #[test] + fn presentation_from_json_parses_link() { + let json = r#"{ + "type": "link", + "schemaVersion": "settings-link-v1", + "label": "Configure PayPal", + "openMode": "new-window" + }"#; + let presentation = presentation_from_json(json).expect("valid link presentation json"); + let SettingPresentation::Link(link) = presentation else { + unreachable!("expected link presentation"); + }; + assert_eq!(link.label, "Configure PayPal"); + assert_eq!(link.open_mode, "new-window"); + } + + #[test] + fn presentation_from_json_rejects_wrong_link_schema_version() { + let json = r#"{ + "type": "link", + "schemaVersion": "settings-link-v2", + "label": "Configure PayPal", + "openMode": "new-window" + }"#; + let err = presentation_from_json(json).expect_err("wrong schemaVersion must be rejected"); + assert!(err.contains("schemaVersion"), "{err}"); + } + + #[test] + fn presentation_from_json_rejects_non_string_type() { + let json = r#"{"type": 123, "sections": []}"#; + let err = presentation_from_json(json).expect_err("non-string type must be rejected"); + assert!(err.contains("type must be a string"), "{err}"); + } + #[test] fn toml_round_trip_preserves_nested_list_group_and_default_value() { let toml_src = r#" diff --git a/rust/src/platform/guides/platform-overview.md b/rust/src/platform/guides/platform-overview.md index 6d477617..d3b6fe9c 100644 --- a/rust/src/platform/guides/platform-overview.md +++ b/rust/src/platform/guides/platform-overview.md @@ -21,7 +21,7 @@ This calls the app-registry API to create the application, then writes `godaddy. - `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. This only writes placement fields (group/slug/entryPath/order/capabilities/icon); the form 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 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. 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. @@ -59,5 +59,5 @@ Makes the application (and everything in its latest release — actions, subscri ## See also -- `gddy guide platform-settings` — the `settings-form-v1` presentation shape in depth. +- `gddy guide platform-settings` — the `settings-form-v1`/`settings-link-v1` presentation shapes in depth. - `docs/application-settings.md` in this repo — the same settings content, plus a pointer to `app-registry-api`'s platform-contract docs for the Commerce-side settings surface. diff --git a/rust/src/platform/guides/platform-settings.md b/rust/src/platform/guides/platform-settings.md index a2f1dc62..4068e5ee 100644 --- a/rust/src/platform/guides/platform-settings.md +++ b/rust/src/platform/guides/platform-settings.md @@ -126,9 +126,27 @@ presentationFile = "fixtures/manual-tax-registry-presentation.json" The referenced file must be the complete API presentation object — `type` (`"form"`), `schemaVersion` (`"settings-form-v1"`), and `sections` — the same shape `createRelease.settings[].presentation` expects, so an existing fixture can be reused verbatim. The path is relative to the directory containing the `godaddy.toml` being released, not the shell's working directory. `presentation` and `presentationFile` are mutually exclusive; both forms run through the same field/section validation and produce an identical release payload. The file is only opened at `release` — like inline `presentation`, it's optional at `add settings`/`config validate` time — and a missing, unreadable, malformed, or wrong-`type`/`schemaVersion` file fails the release with a `VALIDATION_ERROR` naming the resolved path. +### Link presentation (`settings-link-v1`) + +For a GPA that must own its own configuration page or provider authorization flow instead of a native form, `presentation` can instead be a link: + +```toml +[[settings]] +group = "payment-methods" +slug = "paypal-payments" +entryPath = "/settings/paypal" +capabilities = ["read", "open"] + +[settings.presentation] +label = "Configure PayPal" +openMode = "new-window" +``` + +A link presentation requires exactly the `read` and `open` capabilities — no other combination is valid, and `open` is rejected on a form presentation. `label` must be non-empty; `openMode` currently only accepts `"new-window"`. `--presentation-file` also accepts a link's full API object (`type: "link"`, `schemaVersion: "settings-link-v1"`, `label`, `openMode`). See `app-registry-api`'s `docs/SETTINGS.md` for the full lifecycle contract this registers into (App Registry never stores a merchant-specific launch URL — it publishes the placement and lets Settings API request one via the GPA's `open` route at merchant-invoke time). + ## What the CLI validates locally vs. server-side -`gddy platform app add settings`/`release` catch cheap, structural problems before any network call: `group`/`slug` match the platform's slug pattern (`lowercase-with-dashes`); `entryPath` is a route-safe path (`/`-prefixed, no query string/fragment/`..`) and doesn't overlap another setting's `entryPath` in the same manifest; `capabilities` are a subset of `read`, `write`, `validate`, `test`, `delete`; `icon.library` is one of `ux`, `lucide`, `commerce`; every field/section `key` matches the platform's key pattern, `select`/`multi-select` have at least one option, and no two fields/sections share a key; `presentation` and `presentationFile` aren't both set on the same entry — checked as soon as the manifest is touched, not just at release. +`gddy platform app add settings`/`release` catch cheap, structural problems before any network call: `group`/`slug` match the platform's slug pattern (`lowercase-with-dashes`); `entryPath` is a route-safe path (`/`-prefixed, no query string/fragment/`..`) and doesn't overlap another setting's `entryPath` in the same manifest; `capabilities` are a subset of `read`, `write`, `validate`, `test`, `delete`, `open`, with `open` only valid — and required — alongside `read` on a link presentation; `icon.library` is one of `ux`, `lucide`, `commerce`; every field/section `key` matches the platform's key pattern, `select`/`multi-select` have at least one option, and no two fields/sections share a key; a link presentation's `label` is non-empty and `openMode` is `"new-window"`; `presentation` and `presentationFile` aren't both set on the same entry — checked as soon as the manifest is touched, not just at release. Deeper semantics stay server-validated — bounds consistency (`maxLength ≥ minLength`), a `defaultValue` actually matching a registered option or satisfying bounds, and `list-group` nesting depth. A rejection there surfaces as a `release` API error, not a local one.