Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cfca3b7
Add configuration and validation for application settings
nmolham-godaddy Aug 18, 2026
2f4a3a2
Add initial implementation of settings form structure and validation
nmolham-godaddy Aug 18, 2026
8279cab
Inlcude settings section as part of the config definition
nmolham-godaddy Aug 18, 2026
8f86a59
Add build-and-verify script for automated checks and execution
nmolham-godaddy Aug 18, 2026
bcb594a
Add command for registering application settings
nmolham-godaddy Aug 18, 2026
9a9f409
Document adding settings to apps
nmolham-godaddy Aug 18, 2026
9edaff1
Merge branch 'main' into devex-1021/settings-support-for-app-registra…
nmolham-godaddy Aug 18, 2026
3fe7f9d
return settings from createRelease response
nmolham-godaddy Aug 18, 2026
2f77e88
Fixed: missing-presentation error in setting_entry
nmolham-godaddy Aug 18, 2026
5d85182
Add config validate command
nmolham-godaddy Aug 18, 2026
cd8502a
Fail release on invalid manifest, not silent fallback
nmolham-godaddy Aug 19, 2026
f7df051
Merge branch 'main' into devex-1021/settings-support-for-app-registra…
nmolham-godaddy Aug 19, 2026
c4c0115
Fix missing settings warning
nmolham-godaddy Aug 19, 2026
b298b7a
Embed platform guides for CLI discovery
nmolham-godaddy Aug 19, 2026
eb6a857
add JSON parsing for presentation files and validation
nmolham-godaddy Aug 19, 2026
4e41ff2
add support for presentation file in settings validation
nmolham-godaddy Aug 19, 2026
2a71e2b
add presentation_file support to SettingConfig and related tests
nmolham-godaddy Aug 19, 2026
6c752c6
add support for presentation file in add command and related tests
nmolham-godaddy Aug 19, 2026
969de5c
Resolve settings presentation from file or inline
nmolham-godaddy Aug 19, 2026
71f9038
Simplify presentation_from_json description
nmolham-godaddy Aug 19, 2026
d876c90
Update documentation to clarify usage of presentation and presentatio…
nmolham-godaddy Aug 19, 2026
4f63143
Add smoke test for the whole release flow with settings
nmolham-godaddy Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions docs/application-settings.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be better if this was embedded in a guide so that LLMs can discover these instructions without needing to do a web search. I think the whole gddy platform module probably needs a holistic guide with application settings included (or I guess multiple guides is also fine).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, sounds like a good idea, I will do my best effort to implement it

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

b298b7a what do you think? CC @wcole1-godaddy

Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# Application settings

Also available at the terminal, without a repo checkout, via `gddy guide platform-settings` (and `gddy guide platform-overview` for the full app lifecycle).

An application-settings capability lets a GoDaddy Platform Application (GPA) contribute a form to a Commerce-owned settings surface (e.g. `tax-center`) — merchants fill it out, the GPA's own `load`/`save`/`validate` endpoints own the data. `app-registry-api` stores and validates only the registration and presentation metadata; it never sees merchant values. This doc covers the `gddy platform app` side of registering one. For the platform contract itself (lifecycle endpoints, signing, `settings-api` composition), see `app-registry-api`'s `docs/GPA-SETTINGS-REGISTRATION.md` and `docs/SETTINGS.md`.

## Workflow

1. **Add the placement** — `gddy platform app add settings` writes the group/slug/entryPath/order/capabilities/icon fields into `godaddy.toml`:

```bash
gddy platform app add settings \
--group tax-center \
--slug godaddy-tax \
--title "GoDaddy Tax" \
--description "Choose manual tax rules or automatic U.S. ZIP-code tax rates." \
--entry-path /settings/godaddy-tax \
--order 10 \
--capability read --capability write --capability validate \
--icon-name percent --icon-library lucide
```

`entryPath` is relative to the application's registered `proxy_url`, same as action URLs. `--capability` defaults to `read`+`write` server-side if omitted. `--icon-name`/`--icon-library` must be given together or not at all.

2. **Author the form** — this command only writes placement metadata; the actual field/section definitions (`presentation`) aren't flag-driven. Either hand-add a `[settings.presentation]` block directly into the entry `add settings` just wrote, or point it at a JSON file with `--presentation-file <path>` (or by editing `presentationFile` into the entry afterward). The two are mutually exclusive — see Presentation shape below.

3. **Release** — `gddy platform app release --application-id <id> --version <version>` resends every setting in `godaddy.toml`, same as it does for actions/subscriptions/extensions. It rejects any settings entry with neither `presentation` nor `presentationFile`:

```json
{ "error": { "code": "VALIDATION_ERROR", "message": "settings 'godaddy-tax' has no presentation — add a [settings.presentation] block or a presentationFile before releasing" } }
```

4. **Enable/backfill** — `gddy platform app enable <name> --store-id <storeId>` makes the placement discoverable for a store. Settings (like actions/subscriptions/uiExtensions) are keyed per-release with no inheritance — a store already enabled against an older release doesn't pick up settings added in a newer one until `enable` is re-run for that store.

## Presentation shape

`presentation` is a `settings-form-v1` form: one or more sections, each with one or more fields. Add it as nested tables under the setting's own `[[settings]]` entry:

```toml
[[settings.presentation.sections]]
key = "defaults"
label = "Calculation defaults"

[[settings.presentation.sections.fields]]
type = "select"
key = "calculateUsing"
label = "Calculate using"
required = true
defaultValue = "destination"

[[settings.presentation.sections.fields.options]]
label = "Customer destination"
value = "destination"

[[settings.presentation.sections.fields.options]]
label = "Order origin"
value = "origin"
```

A field `key` becomes a property in the merchant's saved `values` document and must stay stable once merchants have data — don't rename a field key without a GPA-owned migration. Section keys are display-only and safe to change freely.

Supported field types (`type` discriminates the shape — every field needs `type`, `key`, `label`):

- **`text`** / **`textarea`** — string value.
- Optional: `placeholder`, `minLength`, `maxLength`, `defaultValue` (string).
- **`number`** — numeric value.
- Optional: `min`, `max`, `step`, `suffix`, `defaultValue` (number).
- **`boolean`** — true/false value.
- Optional: `defaultValue` (bool).
- **`select`** — one value chosen from `options` (required, non-empty).
- Each option: `value`, `label`, optional `description`. Add more by repeating the `[[...options]]` array-of-tables header — one block per option, at whatever nesting depth the field sits (e.g. `[[settings.presentation.sections.fields.item.fields.options]]` inside a `list-group` item field):
```toml
[[settings.presentation.sections.fields.options]]
label = "United States"
value = "US"

[[settings.presentation.sections.fields.options]]
label = "Canada"
value = "CA"
```
- Optional: `defaultValue` must match one option's `value`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the Markdown parser hit a snag starting on this line. You probably need to fix indentation or some other kind of white space.

@nmolham-godaddy nmolham-godaddy Aug 19, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- **`multi-select`** — array of values chosen from `options` (required, non-empty).
- Optional: `minItems`, `maxItems`, `defaultValue` (array, each entry must match an option).
- **`list-group`** — array of objects, one merchant-added item per array entry.
- `item.idField` names the item's reserved stable-UUID property (renderer-generated, GPA echoes it back unchanged — it can't also be an editable field key).
- `item.titleField` (optional) names a `text`/`textarea`/`number`/`select` field used to label each item in the UI.
- `item.fields` is a list of fields using the same types above — `list-group` may nest one level further (max depth 2), but not deeper.

Every field, section, and `list-group` item field also accepts `description`. Sections additionally accept `visibleWhen = { field = "...", equals = ... }` to show/hide based on another top-level field's value.

Worked example — the full `godaddy.toml` shape for a manual-tax-style GPA with a nested `list-group`:

```toml
[[settings]]
group = "tax-center"
slug = "manual-tax"
title = "Manual Tax"
entryPath = "/settings/manual-tax"
order = 10
capabilities = ["read", "write", "validate"]

[settings.icon]
name = "percent"
library = "lucide"

[[settings.presentation.sections]]
key = "rules"
label = "Tax rules"

[[settings.presentation.sections.fields]]
type = "list-group"
key = "rules"
label = "Rules"
minItems = 1

[settings.presentation.sections.fields.item]
idField = "id"
titleField = "displayName"

[[settings.presentation.sections.fields.item.fields]]
type = "select"
key = "country"
label = "Country"

[[settings.presentation.sections.fields.item.fields.options]]
label = "United States"
value = "US"

[[settings.presentation.sections.fields.item.fields]]
type = "text"
key = "displayName"
label = "Display at checkout"
```

### Referencing a JSON file instead of inline TOML

For a GPA that already keeps its presentation as a JSON fixture (a common shape for existing registry examples), point `presentationFile` at it instead of re-authoring the same form as TOML:

```toml
[[settings]]
group = "tax-center"
slug = "manual-tax"
entryPath = "/settings/manual-tax"
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.

## 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.

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.

## Gotchas

- **No release inheritance.** `release` resends every current setting from `godaddy.toml`; leaving one out doesn't archive it globally, but any store enabled against the *new* release loses it.
- **Existing stores don't auto-upgrade.** Adding settings to a release only affects stores enabled *after* that release goes active — re-run `gddy platform app enable <name> --store-id <storeId>` per store to backfill.
- **`presentation`/`presentationFile` is mandatory before release, not before `add settings`.** A placement-only entry parses and works fine for every other command (`add action`, `info`, `validate`, `deploy`) — it only fails at `release`, with the message shown above.
91 changes: 91 additions & 0 deletions rust/examples/smoke_mock_server.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! Mock `app-registry-api` for `rust/scripts/smoke-test.sh`. Dev-only —
//! must stay a Cargo example, never a `[[bin]]` (uses the `httpmock` dev-dependency).
#![allow(clippy::print_stdout)]

use std::thread;
use std::time::Duration;

use httpmock::{HttpMockRequest, HttpMockResponse, MockServer};
use serde_json::{Value, json};

const GRAPHQL_PATH: &str = "/v1/apps/app-registry-subgraph";

/// `applicationId` values that select a canned error response instead of
/// the normal success echo, so the smoke test can drive real HTTP/GraphQL
/// error handling without a second mock process.
const GRAPHQL_ERROR_APPLICATION_ID: &str = "smoke-graphql-error-id";
const HTTP_500_APPLICATION_ID: &str = "smoke-http-500-id";
const HTTP_401_APPLICATION_ID: &str = "smoke-http-401-id";

fn create_release_response(req: &HttpMockRequest) -> HttpMockResponse {
let body: Value = match serde_json::from_slice(&req.body_bytes()) {
Ok(v) => v,
Err(e) => {
return HttpMockResponse::builder()
.status(400)
.body(format!("smoke mock: request body is not JSON: {e}"))
.build();
}
};
let query = body["query"].as_str().unwrap_or_default();
if !query.contains("CreateRelease") {
return HttpMockResponse::builder()
.status(400)
.body(format!(
"smoke mock: only CreateRelease is mocked, got query: {query}"
))
.build();
}

let input = &body["variables"]["input"];
match input["applicationId"].as_str() {
Some(HTTP_500_APPLICATION_ID) => {
return HttpMockResponse::builder()
.status(500)
.body("smoke mock: internal server error")
.build();
}
Some(HTTP_401_APPLICATION_ID) => {
return HttpMockResponse::builder()
.status(401)
.body("smoke mock: unauthorized")
.build();
}
Some(GRAPHQL_ERROR_APPLICATION_ID) => {
return HttpMockResponse::builder()
.status(200)
.header("content-type", "application/json")
.body(
json!({ "data": null, "errors": [{ "message": "release not found" }] })
.to_string(),
)
.build();
}
_ => {}
}
let release = json!({
"id": "smoke-release-id",
"version": input.get("version").cloned().unwrap_or(json!("0.0.0")),
"description": input.get("description").cloned().unwrap_or(Value::Null),
"createdAt": "2026-01-01T00:00:00Z",
"uiExtensions": input.get("uiExtensions").cloned().unwrap_or(json!([])),
"settings": input.get("settings").cloned().unwrap_or(json!([])),
});
HttpMockResponse::builder()
.status(200)
.header("content-type", "application/json")
.body(json!({ "data": { "createRelease": release } }).to_string())
.build()
}

fn main() {
let server = MockServer::start();
server.mock(|when, then| {
when.method("POST").path(GRAPHQL_PATH);
then.respond_with(create_release_response);
});
println!("PORT={}", server.port());
loop {
thread::sleep(Duration::from_secs(3600));
}
}
36 changes: 36 additions & 0 deletions rust/scripts/build-and-verify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Run check/clippy/fmt/module-size (no tests), then build and run the `gddy`
# binary. Any arguments passed to this script are forwarded to `gddy` at the
# end (e.g. `./scripts/build-and-verify.sh platform app --help`); with no
# arguments it just runs `gddy --help` as a smoke check.
set -euo pipefail

rust_root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$rust_root"

step() {
echo
echo "==> $1"
}

step "cargo check"
cargo check

step "cargo clippy -- -D warnings"
cargo clippy -- -D warnings

step "cargo fmt --check"
cargo fmt --check

step "check-module-size.sh"
./scripts/check-module-size.sh

step "cargo build"
cargo build

step "gddy ${*:-\"--help\"}"
if [ "$#" -gt 0 ]; then
./target/debug/gddy "$@"
else
./target/debug/gddy --help
fi
Loading