From dab8b7674fbbd36086b01181ad34ec7ea9ecc7c9 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 15:57:48 -0700 Subject: [PATCH 01/16] feat: migrate domain & dns commands to the v3 Domains API Migrate the `domain` and `dns` command groups from the v1/v2 Domains API to the v3 Domain Lifecycle Management API, keeping v1 only where v3 has no equivalent yet. - domains-client: regenerate from a merged OAS3 spec (vendored v3 bundle + retained v1 ops, V1-prefixed) via new merge-spec.py / reworked trim-spec.py. - domain: available/suggest/get/quote/purchase/nameservers on v3; list + agreements stay on v1. Split each subcommand into its own module under src/domain/, with shared helpers in common.rs. - Strict two-step registration: `domain quote` locks a price + caches the quote (src/quote_cache.rs); `domain purchase --quote-token` accepts it, records consent, registers, and polls the async operation. - Central OAuth scope registry (src/scopes.rs) the client must mirror. - ISO-4217 minor-unit money formatting (iso_currency); contacts.toml mapped to the v3 Contact shape. - Fixes: suggest --limit panic, register response parsing (writeOnly quoteToken), QUOTE_MISMATCH from not re-sending the quoted profile. Co-Authored-By: Claude Opus 4.8 --- rust/Cargo.lock | 22 + rust/Cargo.toml | 2 + rust/domains-client/openapi/domains.oas3.json | 6310 ++++++++++------- .../openapi/swagger_domains.v3.yaml | 2572 +++++++ rust/domains-client/scripts/merge-spec.py | 224 + .../domains-client/scripts/regenerate-spec.sh | 61 +- rust/domains-client/scripts/trim-spec.py | 117 +- rust/domains-client/src/lib.rs | 771 +- rust/src/contacts/mod.rs | 247 +- rust/src/dns/mod.rs | 330 +- rust/src/domain/agreements.rs | 69 + rust/src/domain/available.rs | 111 + rust/src/domain/common.rs | 396 ++ rust/src/domain/contacts.rs | 70 + rust/src/domain/get.rs | 57 + rust/src/domain/guides/domain-purchase.md | 95 +- rust/src/domain/list.rs | 90 + rust/src/domain/mod.rs | 1795 +---- rust/src/domain/nameservers.rs | 89 + rust/src/domain/purchase.rs | 402 ++ rust/src/domain/quote.rs | 311 + rust/src/domain/suggest.rs | 131 + rust/src/environments/mod.rs | 8 +- rust/src/main.rs | 2 + rust/src/quote_cache.rs | 284 + rust/src/scopes.rs | 105 + 26 files changed, 9638 insertions(+), 5033 deletions(-) create mode 100644 rust/domains-client/openapi/swagger_domains.v3.yaml create mode 100644 rust/domains-client/scripts/merge-spec.py create mode 100644 rust/src/domain/agreements.rs create mode 100644 rust/src/domain/available.rs create mode 100644 rust/src/domain/common.rs create mode 100644 rust/src/domain/contacts.rs create mode 100644 rust/src/domain/get.rs create mode 100644 rust/src/domain/list.rs create mode 100644 rust/src/domain/nameservers.rs create mode 100644 rust/src/domain/purchase.rs create mode 100644 rust/src/domain/quote.rs create mode 100644 rust/src/domain/suggest.rs create mode 100644 rust/src/quote_cache.rs create mode 100644 rust/src/scopes.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ad91049..4188df0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1153,6 +1153,7 @@ dependencies = [ "domains-client", "fancy-regex", "httpmock", + "iso_currency", "open", "phonenumber", "regex", @@ -1160,6 +1161,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "thiserror 2.0.18", "tokio", "toml", @@ -1626,6 +1628,26 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso_country" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20633e788d3948ea7336861fdb09ec247f5dae4267e8f0743fa97de26c28624d" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "iso_currency" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed4b3f0921193400b1df556228bfd917c57c7fa38bda904d552653c5c3b641b" +dependencies = [ + "iso_country", + "proc-macro2", + "quote", +] + [[package]] name = "itertools" version = "0.11.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4e2123f..26caed3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -33,9 +33,11 @@ toml = "0.8" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1", features = ["v4"] } +iso_currency = "0.5.3" [dev-dependencies] httpmock = "0.7" +tempfile = "3" [lints.rust] unsafe_code = "deny" diff --git a/rust/domains-client/openapi/domains.oas3.json b/rust/domains-client/openapi/domains.oas3.json index c50d6d2..eb68b7f 100644 --- a/rust/domains-client/openapi/domains.oas3.json +++ b/rust/domains-client/openapi/domains.oas3.json @@ -1,2470 +1,3956 @@ { - "openapi": "3.0.0", - "info": { - "title": "GoDaddy Domains API (domains list + get + availability + agreements + purchase + v2 register + DNS records subset)", - "version": "1.0.0" + "openapi": "3.0.3", + "info": { + "title": "Domain Lifecycle Management API", + "version": "3.0.0", + "x-visibility": "public", + "description": "The GoDaddy Domain Lifecycle Management API provides comprehensive capabilities\nfor discovering, registering, managing, renewing, transferring, and reselling\ndomain names. This is major version 3, designed for agent-first interactions\nwhile remaining fully usable by direct API clients and resellers.\n\n## Namespace\n\nAll paths are under `/v3/domains/`. The namespace is `domains` (the business capability);\nthe core entity collection is `/domain-names`.\n\n## Key Conventions\n\n**Quote/execute for commercial operations.** Every commercial mutation (register,\nrenew, transfer) requires a `quoteToken` minted by the corresponding quote\ncollection endpoint. Execution without a prior quote is structurally impossible.\nThe token locks the price, resolved settings, and required legal agreements for a\n10-minute TTL. Quote calls are free, read-only, and safe to call speculatively.\n\n**Async commercial operations.** `POST /registrations`, `POST /renewals`, and\n`POST /transfers` each return `202 Accepted` with the concrete entity body\n(`Registration`, `Renewal`, or `Transfer`) and a `Location` header. Poll\n`links[rel=self]` on the returned entity until status is `COMPLETED` or `FAILED`.\nEach concrete resource is also reachable via `GET /operations/{operationId}`;\nthe `operationId` is included in the entity for clients that prefer the abstract\nview. Non-commercial mutations return a `DomainOperation` body.\n\n**Entity-oriented resource model.** Domains are the core entity of this API\n(exposed as `/domain-names` in the path to distinguish the resource collection\nfrom the `domains` namespace prefix). Register, renew, and transfer are\ncommercial actions executed by `POST` to their corresponding top-level\nresource collections (`/registrations`, `/renewals`, `/transfers`); each\naccepts a prior quote and returns an async entity to poll until completion.\nFor commercial execute calls, the target domain is\nexpressed in the request body, not the path. Sub-resources (`contacts`,\n`nameservers`, `privacy`, `auto-renew`, `transfer-lock`, `records`) only exist\nin the context of a specific domain-name instance. The `/check-availability`\ncontroller accepts GET (single domain) or POST (1\u201350 domains) and carries no\npersistent identity.\n\n**Flat, two-level maximum.** No resource path goes deeper than\n`/{collection}/{id}/{sub-resource}` or `/{collection}/{id}/{sub-collection}/{id}`.\n\n**Reseller on-behalf-of.** Resellers pass `X-Shopper-Id`; all operations are\nthen scoped to that shopper. Absent the header, the authenticated entity's own\naccount is used.\n\n## Launch Scope (v3.0)\nStandard TLDs only. TLDs with eligibility requirements (.us, .ca, .eu) return\n`UNSUPPORTED_TLD` until Phase 2.\n", + "contact": { + "name": "GoDaddy Domains Platform", + "x-slack-channel": "#domains_beplat_eng", + "x-visibility": "private" + } + }, + "servers": [ + { + "url": "https://api.ote-godaddy.com", + "description": "Domains API host" + } + ], + "security": [ + { + "oauth2": [] + } + ], + "tags": [ + { + "name": "Discovery", + "description": "Indicative, non-committing operations for finding and checking domains. Use suggestDomains for natural-language queries. For a single known domain, use getDomainAvailability (GET /check-availability); for 1\u201350 domains in one call, use checkAvailability (POST /check-availability). Both availability operations share the same check semantics and Availability result model; locked pricing is established only at quote time. Neither carries a persistent check identity.\n" }, - "paths": { - "/v1/domains": { - "get": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID whose domains are to be retrieved", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Only include results with `status` value in the specified set", - "in": "query", - "name": "statuses", - "required": false, - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "enum": [ - "ACTIVE", - "AWAITING_CLAIM_ACK", - "AWAITING_DOCUMENT_AFTER_TRANSFER", - "AWAITING_DOCUMENT_AFTER_UPDATE_ACCOUNT", - "AWAITING_DOCUMENT_UPLOAD", - "AWAITING_FAILED_TRANSFER_WHOIS_PRIVACY", - "AWAITING_PAYMENT", - "AWAITING_RENEWAL_TRANSFER_IN_COMPLETE", - "AWAITING_TRANSFER_IN_ACK", - "AWAITING_TRANSFER_IN_AUTH", - "AWAITING_TRANSFER_IN_AUTO", - "AWAITING_TRANSFER_IN_WHOIS", - "AWAITING_TRANSFER_IN_WHOIS_FIX", - "AWAITING_VERIFICATION_ICANN", - "AWAITING_VERIFICATION_ICANN_MANUAL", - "CANCELLED", - "CANCELLED_HELD", - "CANCELLED_REDEEMABLE", - "CANCELLED_TRANSFER", - "CONFISCATED", - "DISABLED_SPECIAL", - "EXCLUDED_INVALID_CLAIM_FIREHOSE", - "EXPIRED_REASSIGNED", - "FAILED_BACKORDER_CAPTURE", - "FAILED_DROP_IMMEDIATE_THEN_ADD", - "FAILED_PRE_REGISTRATION", - "FAILED_REDEMPTION", - "FAILED_REDEMPTION_REPORT", - "FAILED_REGISTRATION", - "FAILED_REGISTRATION_FIREHOSE", - "FAILED_RESTORATION_REDEMPTION_MOCK", - "FAILED_SETUP", - "FAILED_TRANSFER_IN", - "FAILED_TRANSFER_IN_BAD_STATUS", - "FAILED_TRANSFER_IN_REGISTRY", - "HELD_COURT_ORDERED", - "HELD_DISPUTED", - "HELD_EXPIRATION_PROTECTION", - "HELD_EXPIRED_REDEMPTION_MOCK", - "HELD_REGISTRAR_ADD", - "HELD_REGISTRAR_REMOVE", - "HELD_SHOPPER", - "HELD_TEMPORARY", - "LOCKED_ABUSE", - "LOCKED_COPYRIGHT", - "LOCKED_REGISTRY", - "LOCKED_SUPER", - "PARKED_AND_HELD", - "PARKED_EXPIRED", - "PARKED_VERIFICATION_ICANN", - "PENDING_ABORT_CANCEL_SETUP", - "PENDING_AGREEMENT_PRE_REGISTRATION", - "PENDING_APPLY_RENEWAL_CREDITS", - "PENDING_BACKORDER_CAPTURE", - "PENDING_BLOCKED_REGISTRY", - "PENDING_CANCEL_REGISTRANT_PROFILE", - "PENDING_COMPLETE_REDEMPTION_WITHOUT_RECEIPT", - "PENDING_COMPLETE_REGISTRANT_PROFILE", - "PENDING_COO", - "PENDING_COO_COMPLETE", - "PENDING_DNS", - "PENDING_DNS_ACTIVE", - "PENDING_DNS_INACTIVE", - "PENDING_DOCUMENT_VALIDATION", - "PENDING_DOCUMENT_VERIFICATION", - "PENDING_DROP_IMMEDIATE", - "PENDING_DROP_IMMEDIATE_THEN_ADD", - "PENDING_EPP_CREATE", - "PENDING_EPP_DELETE", - "PENDING_EPP_UPDATE", - "PENDING_ESCALATION_REGISTRY", - "PENDING_EXPIRATION", - "PENDING_EXPIRATION_RESPONSE", - "PENDING_EXPIRATION_SYNC", - "PENDING_EXPIRED_REASSIGNMENT", - "PENDING_EXPIRE_AUTO_ADD", - "PENDING_EXTEND_REGISTRANT_PROFILE", - "PENDING_FAILED_COO", - "PENDING_FAILED_EPP_CREATE", - "PENDING_FAILED_HELD", - "PENDING_FAILED_PURCHASE_PREMIUM", - "PENDING_FAILED_RECONCILE_FIREHOSE", - "PENDING_FAILED_REDEMPTION_WITHOUT_RECEIPT", - "PENDING_FAILED_RELEASE_PREMIUM", - "PENDING_FAILED_RENEW_EXPIRATION_PROTECTION", - "PENDING_FAILED_RESERVE_PREMIUM", - "PENDING_FAILED_SUBMIT_FIREHOSE", - "PENDING_FAILED_TRANSFER_ACK_PREMIUM", - "PENDING_FAILED_TRANSFER_IN_ACK_PREMIUM", - "PENDING_FAILED_TRANSFER_IN_PREMIUM", - "PENDING_FAILED_TRANSFER_PREMIUM", - "PENDING_FAILED_TRANSFER_SUBMIT_PREMIUM", - "PENDING_FAILED_UNLOCK_PREMIUM", - "PENDING_FAILED_UPDATE_API", - "PENDING_FRAUD_VERIFICATION", - "PENDING_FRAUD_VERIFIED", - "PENDING_GET_CONTACTS", - "PENDING_GET_HOSTS", - "PENDING_GET_NAME_SERVERS", - "PENDING_GET_STATUS", - "PENDING_HOLD_ESCROW", - "PENDING_HOLD_REDEMPTION", - "PENDING_LOCK_CLIENT_REMOVE", - "PENDING_LOCK_DATA_QUALITY", - "PENDING_LOCK_THEN_HOLD_REDEMPTION", - "PENDING_PARKING_DETERMINATION", - "PENDING_PARK_INVALID_WHOIS", - "PENDING_PARK_INVALID_WHOIS_REMOVAL", - "PENDING_PURCHASE_PREMIUM", - "PENDING_RECONCILE", - "PENDING_RECONCILE_FIREHOSE", - "PENDING_REDEMPTION", - "PENDING_REDEMPTION_REPORT", - "PENDING_REDEMPTION_REPORT_COMPLETE", - "PENDING_REDEMPTION_REPORT_SUBMITTED", - "PENDING_REDEMPTION_WITHOUT_RECEIPT", - "PENDING_REDEMPTION_WITHOUT_RECEIPT_MOCK", - "PENDING_RELEASE_PREMIUM", - "PENDING_REMOVAL", - "PENDING_REMOVAL_HELD", - "PENDING_REMOVAL_PARKED", - "PENDING_REMOVAL_UNPARK", - "PENDING_RENEWAL", - "PENDING_RENEW_EXPIRATION_PROTECTION", - "PENDING_RENEW_INFINITE", - "PENDING_RENEW_LOCKED", - "PENDING_RENEW_WITHOUT_RECEIPT", - "PENDING_REPORT_REDEMPTION_WITHOUT_RECEIPT", - "PENDING_RESERVE_PREMIUM", - "PENDING_RESET_VERIFICATION_ICANN", - "PENDING_RESPONSE_FIREHOSE", - "PENDING_RESTORATION", - "PENDING_RESTORATION_INACTIVE", - "PENDING_RESTORATION_REDEMPTION_MOCK", - "PENDING_RETRY_EPP_CREATE", - "PENDING_RETRY_HELD", - "PENDING_SEND_AUTH_CODE", - "PENDING_SETUP", - "PENDING_SETUP_ABANDON", - "PENDING_SETUP_AGREEMENT_LANDRUSH", - "PENDING_SETUP_AGREEMENT_SUNRISE2_A", - "PENDING_SETUP_AGREEMENT_SUNRISE2_B", - "PENDING_SETUP_AGREEMENT_SUNRISE2_C", - "PENDING_SETUP_AUTH", - "PENDING_SETUP_DNS", - "PENDING_SETUP_FAILED", - "PENDING_SETUP_REVIEW", - "PENDING_SETUP_SUNRISE", - "PENDING_SETUP_SUNRISE_PRE", - "PENDING_SETUP_SUNRISE_RESPONSE", - "PENDING_SUBMIT_FAILURE", - "PENDING_SUBMIT_FIREHOSE", - "PENDING_SUBMIT_HOLD_FIREHOSE", - "PENDING_SUBMIT_HOLD_LANDRUSH", - "PENDING_SUBMIT_HOLD_SUNRISE", - "PENDING_SUBMIT_LANDRUSH", - "PENDING_SUBMIT_RESPONSE_FIREHOSE", - "PENDING_SUBMIT_RESPONSE_LANDRUSH", - "PENDING_SUBMIT_RESPONSE_SUNRISE", - "PENDING_SUBMIT_SUCCESS_FIREHOSE", - "PENDING_SUBMIT_SUCCESS_LANDRUSH", - "PENDING_SUBMIT_SUCCESS_SUNRISE", - "PENDING_SUBMIT_SUNRISE", - "PENDING_SUBMIT_WAITING_LANDRUSH", - "PENDING_SUCCESS_PRE_REGISTRATION", - "PENDING_SUSPENDED_DATA_QUALITY", - "PENDING_TRANSFER_ACK_PREMIUM", - "PENDING_TRANSFER_IN", - "PENDING_TRANSFER_IN_ACK", - "PENDING_TRANSFER_IN_ACK_PREMIUM", - "PENDING_TRANSFER_IN_BAD_REGISTRANT", - "PENDING_TRANSFER_IN_CANCEL", - "PENDING_TRANSFER_IN_CANCEL_REGISTRY", - "PENDING_TRANSFER_IN_COMPLETE_ACK", - "PENDING_TRANSFER_IN_DELETE", - "PENDING_TRANSFER_IN_LOCK", - "PENDING_TRANSFER_IN_NACK", - "PENDING_TRANSFER_IN_NOTIFICATION", - "PENDING_TRANSFER_IN_PREMIUM", - "PENDING_TRANSFER_IN_RELEASE", - "PENDING_TRANSFER_IN_RESPONSE", - "PENDING_TRANSFER_IN_UNDERAGE", - "PENDING_TRANSFER_OUT", - "PENDING_TRANSFER_OUT_ACK", - "PENDING_TRANSFER_OUT_NACK", - "PENDING_TRANSFER_OUT_PREMIUM", - "PENDING_TRANSFER_OUT_UNDERAGE", - "PENDING_TRANSFER_OUT_VALIDATION", - "PENDING_TRANSFER_PREMIUM", - "PENDING_TRANSFER_PREMUIM", - "PENDING_TRANSFER_SUBMIT_PREMIUM", - "PENDING_UNLOCK_DATA_QUALITY", - "PENDING_UNLOCK_PREMIUM", - "PENDING_UPDATE", - "PENDING_UPDATED_REGISTRANT_DATA_QUALITY", - "PENDING_UPDATE_ACCOUNT", - "PENDING_UPDATE_API", - "PENDING_UPDATE_API_RESPONSE", - "PENDING_UPDATE_AUTH", - "PENDING_UPDATE_CONTACTS", - "PENDING_UPDATE_CONTACTS_PRIVACY", - "PENDING_UPDATE_DNS", - "PENDING_UPDATE_DNS_SECURITY", - "PENDING_UPDATE_ELIGIBILITY", - "PENDING_UPDATE_EPP_CONTACTS", - "PENDING_UPDATE_MEMBERSHIP", - "PENDING_UPDATE_OWNERSHIP", - "PENDING_UPDATE_OWNERSHIP_AUTH_AUCTION", - "PENDING_UPDATE_OWNERSHIP_HELD", - "PENDING_UPDATE_REGISTRANT", - "PENDING_UPDATE_REPO", - "PENDING_VALIDATION_DATA_QUALITY", - "PENDING_VERIFICATION_FRAUD", - "PENDING_VERIFICATION_STATUS", - "PENDING_VERIFY_REGISTRANT_DATA_QUALITY", - "RESERVED", - "RESERVED_PREMIUM", - "REVERTED", - "SUSPENDED_VERIFICATION_ICANN", - "TRANSFERRED_OUT", - "UNLOCKED_ABUSE", - "UNLOCKED_SUPER", - "UNPARKED_AND_UNHELD", - "UPDATED_OWNERSHIP", - "UPDATED_OWNERSHIP_HELD" - ], - "type": "string" - } - } - }, - { - "description": "Only include results with `status` value in any of the specified groups", - "in": "query", - "name": "statusGroups", - "required": false, - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "enum": [ - "INACTIVE", - "PRE_REGISTRATION", - "REDEMPTION", - "RENEWABLE", - "VERIFICATION_ICANN", - "VISIBLE" - ], - "type": "string" - } - } - }, - { - "description": "Maximum number of domains to return", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 1000 - } - }, - { - "description": "Marker Domain to use as the offset in results", - "in": "query", - "name": "marker", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Optional details to be included in the response", - "in": "query", - "name": "includes", - "required": false, - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "enum": [ - "authCode", - "contacts", - "nameServers" - ], - "type": "string" - } - } - }, - { - "description": "Only include results that have been modified since the specified date", - "in": "query", - "name": "modifiedDate", - "required": false, - "schema": { - "type": "string", - "format": "iso-datetime" - } - } - ], - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DomainSummary" - }, - "type": "array" - } - } - } - } - }, - "operationId": "list", - "summary": "Retrieve a list of Domains for the specified Shopper" + { + "name": "Registration Quotes", + "description": "Quote a domain registration. Returns a locked price, resolved settings, required agreements, and a single-use quoteToken. Free and read-only.\n" + }, + { + "name": "Registrations", + "description": "Top-level registration entity collection. Execute a domain registration by POSTing with a quoteToken, domain, period, and consent. Returns a Registration entity with links to the concrete poll URL (GET /registrations/{registrationId}) and the abstract operation (GET /operations/{operationId}).\n" + }, + { + "name": "Domains", + "description": "The core domain entity collection. Supports listing and reading owned domain records, and cancelling registrations.\n" + }, + { + "name": "Domain Management", + "description": "Non-commercial async mutations on owned domain instances: contacts, nameservers, privacy, auto-renew, and transfer-lock. All sub-resources of /domain-names/{domain-name}. All mutations return a DomainOperation for polling.\n" + }, + { + "name": "Records", + "description": "CRUD operations on DNS records within the GoDaddy-managed zone. Sub-collection of /zones/{zone}. Changes are applied synchronously.\n" + }, + { + "name": "Operations", + "description": "Abstract operation polling. Poll GET /operations/{operationId} for any domain mutation until it reaches COMPLETED or FAILED. Operation IDs are unique across Registration, Renewal, and Transfer \u2014 clients that prefer typed polling can use the concrete resource endpoints instead.\n" + }, + { + "name": "Domains", + "description": "Core domain entity collection. Retrieve registered domains owned by the authenticated account with management details including status, nameservers, privacy, and auto-renew settings.\n" + } + ], + "paths": { + "/v3/domains/suggestions": { + "get": { + "operationId": "suggestDomains", + "tags": [ + "Discovery" + ], + "summary": "Suggest available domains for a query", + "description": "Returns available domain name suggestions for a natural-language query\nor keyword set. All results are available (available-only contract).\nPrices are indicative; the authoritative price and availability check\nis at quote time.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + }, + { + "name": "query", + "in": "query", + "required": false, + "description": "Natural-language query or keywords describing the desired domain, e.g. \"sunrise bakery\". Used to generate creative and keyword-spin suggestions.\n", + "schema": { + "type": "string" + }, + "example": "sunrise bakery" + }, + { + "name": "tlds", + "in": "query", + "required": false, + "description": "Top-level domains to be included in suggestions.", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": [ + "com", + "net", + "shop" + ] + }, + { + "name": "lengthMax", + "in": "query", + "required": false, + "description": "Maximum length of second-level domain.", + "schema": { + "type": "integer", + "minimum": 1 } - }, - "/v1/domains/available": { - "get": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Domain name whose availability is to be checked", - "in": "query", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Optimize for time ('FAST') or accuracy ('FULL')", - "in": "query", - "name": "checkType", - "required": false, - "schema": { - "type": "string", - "enum": [ - "FAST", - "FULL" - ], - "default": "FAST" - } - }, - { - "description": "Whether or not to include domains available for transfer. If set to True, checkType is ignored", - "in": "query", - "name": "forTransfer", - "required": false, - "schema": { - "type": "boolean", - "default": false - } - } - ], - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DomainAvailableResponse" - } - } - } - } - }, - "operationId": "available", - "summary": "Determine whether or not the specified domain is available for purchase" + }, + { + "name": "lengthMin", + "in": "query", + "required": false, + "description": "Minimum length of second-level domain.", + "schema": { + "type": "integer", + "minimum": 1 } - }, - "/v1/domains/suggest": { - "get": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID for which the suggestions are being generated", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain name or set of keywords for which alternative domain names will be suggested", - "in": "query", - "name": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Two-letter ISO country code to be used as a hint for target region

\nNOTE: These are sample values, there are many\nmore", - "in": "query", - "name": "country", - "required": false, - "schema": { - "type": "string", - "format": "iso-country-code", - "enum": [ - "AC", - "AD", - "AE", - "AF", - "AG", - "AI", - "AL", - "AM", - "AO", - "AQ", - "AR", - "AS", - "AT", - "AU", - "AW", - "AX", - "AZ", - "BA", - "BB", - "BD", - "BE", - "BF", - "BG", - "BH", - "BI", - "BJ", - "BM", - "BN", - "BO", - "BQ", - "BR", - "BS", - "BT", - "BV", - "BW", - "BY", - "BZ", - "CA", - "CC", - "CD", - "CF", - "CG", - "CH", - "CI", - "CK", - "CL", - "CM", - "CN", - "CO", - "CR", - "CV", - "CW", - "CX", - "CY", - "CZ", - "DE", - "DJ", - "DK", - "DM", - "DO", - "DZ", - "EC", - "EE", - "EG", - "EH", - "ER", - "ES", - "ET", - "FI", - "FJ", - "FK", - "FM", - "FO", - "FR", - "GA", - "GB", - "GD", - "GE", - "GF", - "GG", - "GH", - "GI", - "GL", - "GM", - "GN", - "GP", - "GQ", - "GR", - "GS", - "GT", - "GU", - "GW", - "GY", - "HK", - "HM", - "HN", - "HR", - "HT", - "HU", - "ID", - "IE", - "IL", - "IM", - "IN", - "IO", - "IQ", - "IS", - "IT", - "JE", - "JM", - "JO", - "JP", - "KE", - "KG", - "KH", - "KI", - "KM", - "KN", - "KR", - "KV", - "KW", - "KY", - "KZ", - "LA", - "LB", - "LC", - "LI", - "LK", - "LR", - "LS", - "LT", - "LU", - "LV", - "LY", - "MA", - "MC", - "MD", - "ME", - "MG", - "MH", - "MK", - "ML", - "MM", - "MN", - "MO", - "MP", - "MQ", - "MR", - "MS", - "MT", - "MU", - "MV", - "MW", - "MX", - "MY", - "MZ", - "NA", - "NC", - "NE", - "NF", - "NG", - "NI", - "NL", - "NO", - "NP", - "NR", - "NU", - "NZ", - "OM", - "PA", - "PE", - "PF", - "PG", - "PH", - "PK", - "PL", - "PM", - "PN", - "PR", - "PS", - "PT", - "PW", - "PY", - "QA", - "RE", - "RO", - "RS", - "RU", - "RW", - "SA", - "SB", - "SC", - "SE", - "SG", - "SH", - "SI", - "SJ", - "SK", - "SL", - "SM", - "SN", - "SO", - "SR", - "ST", - "SV", - "SX", - "SZ", - "TC", - "TD", - "TF", - "TG", - "TH", - "TJ", - "TK", - "TL", - "TM", - "TN", - "TO", - "TP", - "TR", - "TT", - "TV", - "TW", - "TZ", - "UA", - "UG", - "UM", - "US", - "UY", - "UZ", - "VA", - "VC", - "VE", - "VG", - "VI", - "VN", - "VU", - "WF", - "WS", - "YE", - "YT", - "ZA", - "ZM", - "ZW" - ] - } - }, - { - "description": "Name of city to be used as a hint for target region", - "in": "query", - "name": "city", - "required": false, - "schema": { - "type": "string", - "format": "city-name" - } - }, - { - "description": "Sources to be queried

", - "in": "query", - "name": "sources", - "required": false, - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "enum": [ - "CC_TLD", - "EXTENSION", - "KEYWORD_SPIN", - "PREMIUM", - "cctld", - "keywordspin" - ], - "type": "string" - } - } - }, - { - "description": "Top-level domains to be included in suggestions

\nNOTE: These are sample values, there are many\nmore", - "in": "query", - "name": "tlds", - "required": false, - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - { - "description": "Maximum length of second-level domain", - "in": "query", - "name": "lengthMax", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Minimum length of second-level domain", - "in": "query", - "name": "lengthMin", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Maximum number of suggestions to return", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Maximum amount of time, in milliseconds, to wait for responses\nIf elapses, return the results compiled up to that point", - "in": "query", - "name": "waitMs", - "required": false, - "schema": { - "type": "integer", - "format": "integer-positive", - "default": 1000 - } - } - ], - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DomainSuggestion" - }, - "type": "array" - } - } - } - } - }, - "operationId": "suggest", - "summary": "Suggest alternate Domain names based on a seed Domain, a set of keywords, or the shopper's purchase history" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "description": "Maximum number of suggestions in the response. Defaults to 10 when omitted.\n", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 10 } - }, - "/v1/domains/agreements": { - "get": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Unique identifier of the Market used to retrieve/translate Legal Agreements", - "in": "header", - "name": "X-Market-Id", - "required": false, - "schema": { - "type": "string", - "format": "bcp-47", - "default": "en-US" - } - }, - { - "description": "list of TLDs whose legal agreements are to be retrieved", - "in": "query", - "name": "tlds", - "required": true, - "style": "form", - "explode": false, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - { - "description": "Whether or not privacy has been requested", - "in": "query", - "name": "privacy", - "required": true, - "schema": { - "type": "boolean" - } - }, - { - "description": "Whether or not domain tranfer has been requested", - "in": "query", - "name": "forTransfer", - "required": false, - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/LegalAgreement" - }, - "type": "array" - } - } - } + }, + { + "name": "sources", + "in": "query", + "required": false, + "description": "Suggestion source strategies to activate.\n", + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SuggestionSource" + } + }, + "example": [ + "EXTENSION", + "KEYWORD_SPIN" + ] + } + ], + "security": [ + { + "oauth2": [ + "domains.domain:read" + ] + } + ], + "responses": { + "200": { + "description": "Suggested available domains sorted by relevance.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "description": "Available domain suggestions, sorted by relevance. All items are available by contract.\n", + "items": { + "$ref": "#/components/schemas/Suggestion" + } } - }, - "operationId": "agreements", - "summary": "Retrieve the legal agreement(s) required to purchase the specified TLD and add-ons" + } + } + } } - }, - "/v1/domains/purchase": { - "post": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "The Shopper for whom the domain should be purchased", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DomainPurchase" - } - } - }, - "description": "An instance document expected to match the JSON schema returned by `./schema/{tld}`", - "required": true + } + } + } + }, + "/v3/domains/check-availability": { + "get": { + "operationId": "getDomainAvailability", + "tags": [ + "Discovery" + ], + "summary": "Check availability of a single domain", + "description": "Returns an indicative availability result for one domain, including\nper-term pricing when available. Availability is best-effort; the\nauthoritative check is performed at quote time. This operation does\nnot persist the check \u2014 there is no check identity or poll URL.\n\nEquivalent to POST /check-availability with `domains: [domain]` and the\nsame optional criteria (`optimizeFor`, `iscCode`). The response is that\nsingle `Availability` item unwrapped for convenience \u2014 POST returns the\nsame result inside `{ items: [...] }`. Use POST when checking multiple\ndomains in one request.\n\nA domain that cannot be checked is still returned as a `200` with an\n`error` object on the body (the same per-item contract as POST); request-\nlevel failures use the `4xx` responses.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + }, + { + "name": "domain", + "in": "query", + "required": true, + "description": "The domain name to check, in punycode A-label form for IDNs.", + "schema": { + "type": "string" + }, + "example": "example.com" + }, + { + "name": "optimizeFor", + "in": "query", + "required": false, + "description": "Optional. When omitted, defaults to SPEED. Availability is always re-verified authoritatively at quote time regardless of this setting.\n", + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/OptimizationTarget" + } + ], + "default": "SPEED" + }, + "example": "SPEED" + }, + { + "name": "iscCode", + "in": "query", + "required": false, + "description": "Reseller ISC (International Shopper Code) for pricing context. When provided, the indicative prices reflect the applicable reseller rates for this ISC.\n", + "schema": { + "type": "string" + }, + "example": "ISC_PARTNER_001" + } + ], + "security": [ + { + "oauth2": [ + "domains.domain:read" + ] + } + ], + "responses": { + "200": { + "description": "Availability result for the requested domain.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Availability" }, - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DomainPurchaseResponse" - } - } - } + "examples": { + "available": { + "summary": "Available domain with per-term pricing", + "value": { + "domain": "coffee24x7x365.com", + "available": true, + "definitive": false, + "inventory": "REGISTRY", + "prices": [ + { + "term": "YEAR", + "period": 1, + "price": { + "currencyCode": "USD", + "value": 1199 + }, + "renewalPrice": { + "currencyCode": "USD", + "value": 2299 + } + }, + { + "term": "YEAR", + "period": 2, + "price": { + "currencyCode": "USD", + "value": 3098 + }, + "renewalPrice": { + "currencyCode": "USD", + "value": 4598 + } + }, + { + "term": "YEAR", + "period": 3, + "price": { + "currencyCode": "USD", + "value": 4599 + }, + "renewalPrice": { + "currencyCode": "USD", + "value": 6897 + } + }, + { + "term": "YEAR", + "period": 5, + "price": { + "currencyCode": "USD", + "value": 9197 + }, + "renewalPrice": { + "currencyCode": "USD", + "value": 11495 + } + } + ] } - }, - "operationId": "purchase", - "summary": "Purchase and register the specified Domain" + } + } + } } - }, - "/v1/domains/purchase/schema/{tld}": { - "get": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "The Top-Level Domain whose schema should be retrieved", - "in": "path", - "name": "tld", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - } + } + } + }, + "post": { + "operationId": "checkAvailability", + "x-visibility": "private", + "tags": [ + "Discovery" + ], + "summary": "Check availability of one or more specific domains", + "description": "Batch controller for domain availability checking. Accepts 1\u201350 domain\nnames alongside optional check criteria (optimization mode, ISC pricing\ncode). Returns one Availability result per requested domain in input\norder inside `{ items: [...] }`. Domains that cannot be checked carry\nan `error` object on that item.\n\nFor a single domain, GET /check-availability (getDomainAvailability)\noffers the same check semantics and Availability result without a\nrequest body; the response is the lone item unwrapped.\n\nAvailability is best-effort indicative; the authoritative check is\nalways performed at quote time. This controller does not persist the\ncheck \u2014 there is no check identity or poll URL.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AvailabilityCheckCriteria" + }, + "examples": { + "defaultBehavior": { + "summary": "Omit optimizeFor \u2014 defaults to SPEED", + "value": { + "domains": [ + "example.com", + "example.net" + ] + } + }, + "speedCheck": { + "summary": "Check two domains using cached data", + "value": { + "domains": [ + "example.com", + "example.net" + ], + "optimizeFor": "SPEED" + } }, - "operationId": "schema", - "summary": "Retrieve the schema to be submitted when registering a Domain for the specified TLD" + "accuracyCheck": { + "summary": "Live registry check with ISC pricing context", + "value": { + "domains": [ + "example.com" + ], + "optimizeFor": "ACCURACY", + "iscCode": "ISC_PARTNER_001" + } + } + } } + } }, - "/v1/domains/{domain}": { - "get": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID expected to own the specified domain", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain name whose details are to be retrieved", - "in": "path", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } + "security": [ + { + "oauth2": [ + "domains.domain:read" + ] + } + ], + "responses": { + "200": { + "description": "Per-domain availability results in request order. Uncheckable items carry an error object.\n", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "items": { + "type": "array", + "description": "Availability results in the same order as the domains array in the request body.\n", + "items": { + "$ref": "#/components/schemas/Availability" + } } - ], - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "203": { - "description": "Request was partially successful, see verifications.status for further detail", - "content": { - "application/json": { - "schema": { - "type": "object" - } + } + }, + "examples": { + "mixedResults": { + "summary": "One available domain and one uncheckable domain", + "value": { + "items": [ + { + "domain": "example.com", + "available": true, + "definitive": false, + "inventory": "REGISTRY", + "prices": [ + { + "term": "YEAR", + "period": 1, + "price": { + "currencyCode": "USD", + "value": 1999 + }, + "renewalPrice": { + "currencyCode": "USD", + "value": 1999 + } } - } + ] + }, + { + "domain": "invalid..com", + "error": { + "name": "MISMATCH_FORMAT", + "correlationId": "9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c", + "message": "does not conform to the 'domain' format, based on pattern: /^[^.\\s]{1,63}(\\.[^.\\s]{1,63}){1,2}$/" + } + } + ] } - }, - "operationId": "get", - "summary": "Retrieve details for the specified Domain" + } + } + } } - }, - "/v2/customers/{customerId}/domains/register": { - "post": { - "tags": [ - "Domains" - ], - "summary": "Purchase and register the specified Domain", - "parameters": [ - { - "name": "X-Request-Id", - "required": false, - "in": "header", - "description": "A client provided identifier for tracking this request.", - "schema": { - "type": "string" - } - }, - { - "description": "The Customer identifier
Note: For API Resellers, performing actions on behalf of your customers, you need to specify the Subaccount you're operating on behalf of; otherwise use your shopper id.", - "in": "path", - "name": "customerId", - "required": true, - "schema": { - "type": "string" - } - } + } + } + } + }, + "/v3/domains/registration-quotes": { + "post": { + "operationId": "quoteDomainRegistration", + "tags": [ + "Registration Quotes" + ], + "summary": "Quote a single-domain registration (no commitment)", + "description": "Prices the registration, resolves contact and preference settings,\nreturns required legal agreements, and mints a single-use quoteToken\nwith a 10-minute TTL. Free and read-only; safe to call speculatively.\n\nWhen the domain is unavailable, `available: false` is returned with\nno quoteToken \u2014 this is a valid non-error response.\n\nWhen required contact fields are missing, a `422` is returned with\nfield-level details so the agent can collect the missing data and re-quote.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "domain" ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DomainPurchaseV2" - } - } - }, - "description": "An instance document expected to match the JSON schema returned by `./schema/{tld}`", - "required": true - }, - "responses": { - "202": { - "description": "Request Accepted. You may use GET /v2/customers/{customerId}/domains/{domain}/actions/REGISTER to poll status" - } + "properties": { + "domain": { + "type": "string", + "description": "The domain name to quote, in punycode A-label form.", + "example": "example.com" + }, + "period": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 1, + "description": "Registration period in years." + }, + "profileId": { + "description": "ID of a saved registration profile. Omit to fall back to the account-default profile or account identity.\n", + "allOf": [ + { + "$ref": "#/components/schemas/uuid" + } + ] + }, + "profile": { + "description": "One-time inline contacts and purchase preference defaults for this quote. Not persisted.\n", + "allOf": [ + { + "$ref": "#/components/schemas/InlineRegistrationProfile" + } + ] + } + } + }, + "examples": { + "minimal": { + "summary": "Minimal \u2014 derive registrant from account identity", + "value": { + "domain": "example.com" + } }, - "operationId": "register" + "withProfile": { + "summary": "With saved profile ID", + "value": { + "domain": "example.com", + "period": 2, + "profileId": "14514a29-5fce-4624-8d8a-d8abd56015e2" + } + } + } } + } }, - "/v1/domains/{domain}/records": { - "patch": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain whose DNS Records are to be augmented", - "in": "path", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArrayOfDNSRecord" - } - } - }, - "description": "DNS Records to add to whatever currently exists", - "required": true - }, - "responses": { - "200": { - "description": "Request was successful" - } - }, - "operationId": "recordAdd", - "summary": "Add the specified DNS Records to the specified Domain" + "security": [ + { + "oauth2": [ + "domains.domain:read" + ] + } + ], + "responses": { + "200": { + "description": "Registration quote. When available is false, no quoteToken is returned; this is not an error.\n", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } }, - "put": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain whose DNS Records are to be replaced", - "in": "path", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DNSRecord" - }, - "type": "array" - } - } - }, - "description": "DNS Records to replace whatever currently exists", - "required": true - }, - "responses": { - "200": { - "description": "Request was successful" - } - }, - "operationId": "recordReplace", - "summary": "Replace all DNS Records for the specified Domain" - }, - "get": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain whose DNS Records are to be retrieved", - "in": "path", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DNSRecord" - }, - "type": "array" - } - } - } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegistrationQuote" + } + } + } + } + } + } + }, + "/v3/domains/registrations": { + "post": { + "operationId": "registerDomain", + "tags": [ + "Registrations" + ], + "summary": "Register a domain (requires quoteToken)", + "description": "Executes a previously quoted domain registration. **Irreversible once\naccepted; creates a charge.** Requires a valid unexpired quoteToken from\n`quoteDomainRegistration`, an `Idempotency-Key` header, and a consent\nrecord. The target domain and period are in the request body alongside\nthe quoteToken.\n\nIdempotency takes precedence over the single-use check: retrying with\nthe same `Idempotency-Key` replays the original operation even after\nthe token is consumed.\n\nReturns a `Registration` entity. Poll `links[rel=self]`\n(`GET /registrations/{registrationId}`) until status is `COMPLETED` or\n`FAILED`. The `operationId` field is also provided for clients that\nprefer `GET /operations/{operationId}`; both resolve the same resource.\n\nPoll either until status is `COMPLETED` or `FAILED`. The operation is\nfire-and-forget; always poll at least once even if the server completed\nit synchronously.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + }, + { + "$ref": "#/components/parameters/idempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Registration" + }, + "examples": { + "withToken": { + "summary": "Execute with quoteToken and direct consent", + "value": { + "domain": "example.com", + "period": 1, + "quoteToken": "7f3a2b1c-9d8e-4012-a5b6-c1d2e3f4a5b6", + "consent": { + "agreementTypes": [ + "DNRA" + ], + "agreedAt": "2026-06-12T10:02:00Z", + "agreedBy": { + "type": "DIRECT", + "principal": "shopper_123", + "ip": "203.0.113.7" + } } - }, - "operationId": "recordGetAll", - "summary": "Retrieve DNS Records for the specified Domain, optionally with the specified Type and/or Name" + } + } + } } + } }, - "/v1/domains/{domain}/records/{type}": { - "put": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain whose DNS Records are to be replaced", - "in": "path", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "DNS Record Type for which DNS Records are to be replaced", - "in": "path", - "name": "type", - "required": true, - "schema": { - "type": "string", - "enum": [ - "A", - "AAAA", - "CNAME", - "MX", - "NS", - "SOA", - "SRV", - "TXT" - ] - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DNSRecordCreateType" - }, - "type": "array" - } - } - }, - "description": "DNS Records to replace whatever currently exists", - "required": true - }, - "responses": { - "200": { - "description": "Request was successful" - } - }, - "operationId": "recordReplaceType", - "summary": "Replace all DNS Records for the specified Domain with the specified Type" + "security": [ + { + "oauth2": [ + "domains.domain:create" + ] + } + ], + "responses": { + "202": { + "description": "Registration accepted. Poll the self link for status.\n", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + }, + "Location": { + "$ref": "#/components/headers/location" + }, + "Retry-After": { + "$ref": "#/components/headers/retryAfter" + } }, - "get": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain whose DNS Records are to be retrieved", - "in": "path", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "DNS Record Type for which DNS Records are to be retrieved", - "in": "path", - "name": "type", - "required": true, - "schema": { - "type": "string", - "enum": [ - "A", - "AAAA", - "CNAME", - "MX", - "NS", - "SOA", - "SRV", - "TXT" - ] - } - } - ], - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DNSRecord" - }, - "type": "array" - } - } - } - } - }, - "operationId": "recordGetByType", - "summary": "Retrieve DNS Records for the specified Domain, optionally with the specified Type and/or Name" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Registration" + } + } } - }, - "/v1/domains/{domain}/records/{type}/{name}": { - "get": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain whose DNS Records are to be retrieved", - "in": "path", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "DNS Record Type for which DNS Records are to be retrieved", - "in": "path", - "name": "type", - "required": true, - "schema": { - "type": "string", - "enum": [ - "A", - "AAAA", - "CNAME", - "MX", - "NS", - "SOA", - "SRV", - "TXT" - ] - } - }, - { - "description": "DNS Record Name for which DNS Records are to be retrieved", - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "Number of results to skip for pagination", - "in": "query", - "name": "offset", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Maximum number of items to return", - "in": "query", - "name": "limit", - "required": false, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Request was successful", - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DNSRecord" - }, - "type": "array" - } - } - } - } - }, - "operationId": "recordGet", - "summary": "Retrieve DNS Records for the specified Domain, optionally with the specified Type and/or Name" + } + } + } + }, + "/v3/domains/registrations/{registrationId}": { + "get": { + "operationId": "getRegistration", + "tags": [ + "Registrations" + ], + "summary": "Get a registration record", + "description": "Returns a single registration record by its server-assigned registrationId, including the current execution status and the domain expiry date once the registration completes. This is the concrete poll endpoint for registration operations; the abstract equivalent is GET /operations/{operationId}.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + }, + { + "$ref": "#/components/parameters/registrationId" + } + ], + "security": [ + { + "oauth2": [ + "domains.domain:read" + ] + } + ], + "responses": { + "200": { + "description": "Registration record returned.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + }, + "Retry-After": { + "$ref": "#/components/headers/retryAfter" + } }, - "put": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain whose DNS Records are to be replaced", - "in": "path", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "DNS Record Type for which DNS Records are to be replaced", - "in": "path", - "name": "type", - "required": true, - "schema": { - "type": "string", - "enum": [ - "A", - "AAAA", - "CNAME", - "MX", - "NS", - "SOA", - "SRV", - "TXT" - ] - } - }, - { - "description": "DNS Record Name for which DNS Records are to be replaced", - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DNSRecordCreateTypeName" - }, - "type": "array" - } - } - }, - "description": "DNS Records to replace whatever currently exists", - "required": true - }, - "responses": { - "200": { - "description": "Request was successful" - } - }, - "operationId": "recordReplaceTypeName", - "summary": "Replace all DNS Records for the specified Domain with the specified Type and Name" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Registration" + } + } + } + } + } + } + }, + "/v3/domains/domain-names/{domain-name}": { + "get": { + "operationId": "getDomain", + "tags": [ + "Domains" + ], + "summary": "Get a registered domain", + "description": "Returns the management view of a single registered domain owned by the authenticated account, including status, nameservers, privacy and auto-renew preferences, and expiry date.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + }, + { + "$ref": "#/components/parameters/domainNamePath" + } + ], + "security": [ + { + "oauth2": [ + "domains.domain:read" + ] + } + ], + "responses": { + "200": { + "description": "Domain found.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } }, - "delete": { - "tags": [ - "v1" - ], - "parameters": [ - { - "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", - "in": "header", - "name": "X-Shopper-Id", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "Domain whose DNS Records are to be deleted", - "in": "path", - "name": "domain", - "required": true, - "schema": { - "type": "string" - } - }, - { - "description": "DNS Record Type for which DNS Records are to be deleted", - "in": "path", - "name": "type", - "required": true, - "schema": { - "type": "string", - "enum": [ - "A", - "AAAA", - "CNAME", - "MX", - "SRV", - "TXT" - ] - } - }, - { - "description": "DNS Record Name for which DNS Records are to be deleted", - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Request was successful" - } - }, - "operationId": "recordDeleteTypeName", - "summary": "Delete all DNS Records for the specified Domain with the specified Type and Name" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Domain" + } + } } + } } + } }, - "servers": [ - { - "url": "https://api.ote-godaddy.com" + "/v3/domains/domain-names/{domain-name}/nameservers": { + "put": { + "operationId": "updateNameservers", + "tags": [ + "Domain Management" + ], + "summary": "Replace the nameservers for a domain", + "description": "Replaces the authoritative nameservers for the domain with the provided list. Minimum 2, maximum 13. Returns a DomainOperation; propagation to the registry is asynchronous.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + }, + { + "$ref": "#/components/parameters/domainNamePath" + }, + { + "$ref": "#/components/parameters/idempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NameServers" + }, + "examples": { + "nameservers": { + "value": [ + "ns1.example.com", + "ns2.example.com" + ] + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "domains.nameserver:update" + ] + } + ], + "responses": { + "202": { + "description": "Nameserver update accepted; poll the operation for completion.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + }, + "Location": { + "$ref": "#/components/headers/location" + }, + "Retry-After": { + "$ref": "#/components/headers/retryAfter" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DomainOperation" + } + } + } + } } - ], - "components": { - "schemas": { - "Address": { - "properties": { - "address1": { - "format": "street-address", - "type": "string" - }, - "address2": { - "format": "street-address2", - "type": "string" - }, - "city": { - "format": "city-name", - "type": "string" - }, - "country": { - "default": "US", - "description": "Two-letter ISO country code to be used as a hint for target region

\nNOTE: These are sample values, there are many\nmore", - "enum": [ - "AC", - "AD", - "AE", - "AF", - "AG", - "AI", - "AL", - "AM", - "AO", - "AQ", - "AR", - "AS", - "AT", - "AU", - "AW", - "AX", - "AZ", - "BA", - "BB", - "BD", - "BE", - "BF", - "BG", - "BH", - "BI", - "BJ", - "BM", - "BN", - "BO", - "BQ", - "BR", - "BS", - "BT", - "BV", - "BW", - "BY", - "BZ", - "CA", - "CC", - "CD", - "CF", - "CG", - "CH", - "CI", - "CK", - "CL", - "CM", - "CN", - "CO", - "CR", - "CV", - "CW", - "CX", - "CY", - "CZ", - "DE", - "DJ", - "DK", - "DM", - "DO", - "DZ", - "EC", - "EE", - "EG", - "EH", - "ER", - "ES", - "ET", - "FI", - "FJ", - "FK", - "FM", - "FO", - "FR", - "GA", - "GB", - "GD", - "GE", - "GF", - "GG", - "GH", - "GI", - "GL", - "GM", - "GN", - "GP", - "GQ", - "GR", - "GS", - "GT", - "GU", - "GW", - "GY", - "HK", - "HM", - "HN", - "HR", - "HT", - "HU", - "ID", - "IE", - "IL", - "IM", - "IN", - "IO", - "IQ", - "IS", - "IT", - "JE", - "JM", - "JO", - "JP", - "KE", - "KG", - "KH", - "KI", - "KM", - "KN", - "KR", - "KV", - "KW", - "KY", - "KZ", - "LA", - "LB", - "LC", - "LI", - "LK", - "LR", - "LS", - "LT", - "LU", - "LV", - "LY", - "MA", - "MC", - "MD", - "ME", - "MG", - "MH", - "MK", - "ML", - "MM", - "MN", - "MO", - "MP", - "MQ", - "MR", - "MS", - "MT", - "MU", - "MV", - "MW", - "MX", - "MY", - "MZ", - "NA", - "NC", - "NE", - "NF", - "NG", - "NI", - "NL", - "NO", - "NP", - "NR", - "NU", - "NZ", - "OM", - "PA", - "PE", - "PF", - "PG", - "PH", - "PK", - "PL", - "PM", - "PN", - "PR", - "PS", - "PT", - "PW", - "PY", - "QA", - "RE", - "RO", - "RS", - "RU", - "RW", - "SA", - "SB", - "SC", - "SE", - "SG", - "SH", - "SI", - "SJ", - "SK", - "SL", - "SM", - "SN", - "SO", - "SR", - "ST", - "SV", - "SX", - "SZ", - "TC", - "TD", - "TF", - "TG", - "TH", - "TJ", - "TK", - "TL", - "TM", - "TN", - "TO", - "TP", - "TR", - "TT", - "TV", - "TW", - "TZ", - "UA", - "UG", - "UM", - "US", - "UY", - "UZ", - "VA", - "VC", - "VE", - "VG", - "VI", - "VN", - "VU", - "WF", - "WS", - "YE", - "YT", - "ZA", - "ZM", - "ZW" - ], - "format": "iso-country-code", - "type": "string" - }, - "postalCode": { - "description": "Postal or zip code", - "format": "postal-code", - "type": "string" - }, - "state": { - "description": "State or province or territory", - "format": "state-province-territory", - "type": "string" - } + } + }, + "/v3/domains/zones/{zone}/dns-records": { + "post": { + "operationId": "createDNSRecord", + "tags": [ + "Records" + ], + "summary": "Create a DNS record for a zone", + "description": "Creates a new DNS record in the GoDaddy-managed zone. Changes are applied synchronously; no operation polling required.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + }, + { + "$ref": "#/components/parameters/zonePath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DNSRecord" + }, + "examples": { + "aRecord": { + "summary": "Create an A record", + "value": { + "name": "@", + "type": "A", + "data": "192.0.2.1", + "ttl": 3600 + } }, - "required": [ - "address1", - "city", - "state", - "postalCode", - "country" - ] + "mxRecord": { + "summary": "Create an MX record", + "value": { + "name": "@", + "type": "MX", + "data": "mail.example.com.", + "ttl": 3600, + "priority": 10 + } + } + } + } + } + }, + "security": [ + { + "oauth2": [ + "domains.dns:update" + ] + } + ], + "responses": { + "201": { + "description": "DNS record created.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + }, + "Location": { + "$ref": "#/components/headers/location" + } }, - "ArrayOfDNSRecord": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DNSRecord" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DNSRecord" } + } + } + } + } + } + }, + "/v3/domains/operations/{operationId}": { + "get": { + "operationId": "getOperation", + "tags": [ + "Operations" + ], + "summary": "Poll an async domain operation", + "description": "Universal poll endpoint for all asynchronous domain mutations. Returns\nthe current state of the operation. Non-terminal responses include a\n`Retry-After` header.\n\nTerminal statuses:\n- `COMPLETED` \u2014 operation succeeded; `result` contains the final outcome.\n- `FAILED` \u2014 operation terminated with an error; `error` contains detail.\n\nWhile status is non-terminal (`CONFIRMED`, `EXECUTING`), neither\n`result` nor `error` is present. Poll until a terminal status is reached.\n\nThe poll URL is provided in the `Location` header of the initiating 202\nresponse and in `links[rel=self]`. Clients must not construct this URL\nindependently.\n", + "parameters": [ + { + "$ref": "#/components/parameters/xRequestId" + }, + { + "$ref": "#/components/parameters/xShopperId" + }, + { + "$ref": "#/components/parameters/operationId" + } + ], + "security": [ + { + "oauth2": [ + "domains.domain:read" + ] + } + ], + "responses": { + "200": { + "description": "Current operation state.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + }, + "Retry-After": { + "$ref": "#/components/headers/retryAfter" + } }, - "Consent": { - "properties": { - "agreedAt": { - "description": "Timestamp indicating when the end-user consented to these legal agreements", - "format": "iso-datetime", - "type": "string" - }, - "agreedBy": { - "description": "Originating client IP address of the end-user's computer when they consented to these legal agreements", - "type": "string" - }, - "agreementKeys": { - "description": "Unique identifiers of the legal agreements to which the end-user has agreed, as returned from the/domains/agreements endpoint", - "items": { - "type": "string" - }, - "type": "array" - } + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DomainOperation" }, - "required": [ - "agreementKeys", - "agreedBy", - "agreedAt" - ] - }, - "ConsentV2": { - "additionalProperties": false, - "properties": { - "agreementKeys": { - "type": "array", - "items": { - "type": "string" + "examples": { + "executing": { + "summary": "Operation in progress", + "value": { + "operationId": "9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c", + "type": "REGISTER", + "domain": "example.com", + "status": "EXECUTING", + "createdAt": "2026-06-12T10:02:05Z", + "updatedAt": "2026-06-12T10:02:07Z", + "links": [ + { + "rel": "self", + "href": "/v3/domains/operations/9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c" + } + ] + } + }, + "completed": { + "summary": "Registration completed", + "value": { + "operationId": "9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c", + "type": "REGISTER", + "domain": "example.com", + "status": "COMPLETED", + "result": { + "expiresAt": "2027-06-12T10:02:10Z", + "orderId": "ord_xyz789" + }, + "createdAt": "2026-06-12T10:02:05Z", + "updatedAt": "2026-06-12T10:02:10Z", + "links": [ + { + "rel": "self", + "href": "/v3/domains/operations/9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c" }, - "description": "Unique identifiers of the legal agreements to which the end-user has agreed, as returned from the/domains/agreements endpoint" - }, - "price": { - "type": "integer", - "format": "currency-micro-unit", - "description": "Price of the domain excluding taxes or fees. Please use GET /v1/domains/available to retrieve the price and currency for the domain" - }, - "currency": { - "type": "string", - "format": "iso-currency-code", - "default": "USD", - "pattern": "^[A-Z][A-Z][A-Z]$", - "description": "Currency in which the `price` is listed" - }, - "registryPremiumPricing": { - "type": "boolean", - "description": "Only required for hosted registrar if domain is premium. If true indicates that the `price` and `currency` listed are the registry premium price and currency for the domain" - }, - "agreedBy": { - "type": "string", - "description": "Originating client IP address of the end-user's computer when they consented to these legal agreements" - }, - "agreedAt": { - "type": "string", - "format": "iso-datetime", - "description": "Timestamp indicating when the end-user consented to these legal agreements" - }, - "claimToken": { - "description": "The trademark claim token, only needed if the domain has an active trademark claim", - "type": "string" + { + "rel": "domain", + "href": "/v3/domains/domain-names/example.com" + } + ] } + } + } + } + } + } + } + } + }, + "/v1/domains": { + "get": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID whose domains are to be retrieved", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Only include results with `status` value in the specified set", + "in": "query", + "name": "statuses", + "required": false, + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "enum": [ + "ACTIVE", + "AWAITING_CLAIM_ACK", + "AWAITING_DOCUMENT_AFTER_TRANSFER", + "AWAITING_DOCUMENT_AFTER_UPDATE_ACCOUNT", + "AWAITING_DOCUMENT_UPLOAD", + "AWAITING_FAILED_TRANSFER_WHOIS_PRIVACY", + "AWAITING_PAYMENT", + "AWAITING_RENEWAL_TRANSFER_IN_COMPLETE", + "AWAITING_TRANSFER_IN_ACK", + "AWAITING_TRANSFER_IN_AUTH", + "AWAITING_TRANSFER_IN_AUTO", + "AWAITING_TRANSFER_IN_WHOIS", + "AWAITING_TRANSFER_IN_WHOIS_FIX", + "AWAITING_VERIFICATION_ICANN", + "AWAITING_VERIFICATION_ICANN_MANUAL", + "CANCELLED", + "CANCELLED_HELD", + "CANCELLED_REDEEMABLE", + "CANCELLED_TRANSFER", + "CONFISCATED", + "DISABLED_SPECIAL", + "EXCLUDED_INVALID_CLAIM_FIREHOSE", + "EXPIRED_REASSIGNED", + "FAILED_BACKORDER_CAPTURE", + "FAILED_DROP_IMMEDIATE_THEN_ADD", + "FAILED_PRE_REGISTRATION", + "FAILED_REDEMPTION", + "FAILED_REDEMPTION_REPORT", + "FAILED_REGISTRATION", + "FAILED_REGISTRATION_FIREHOSE", + "FAILED_RESTORATION_REDEMPTION_MOCK", + "FAILED_SETUP", + "FAILED_TRANSFER_IN", + "FAILED_TRANSFER_IN_BAD_STATUS", + "FAILED_TRANSFER_IN_REGISTRY", + "HELD_COURT_ORDERED", + "HELD_DISPUTED", + "HELD_EXPIRATION_PROTECTION", + "HELD_EXPIRED_REDEMPTION_MOCK", + "HELD_REGISTRAR_ADD", + "HELD_REGISTRAR_REMOVE", + "HELD_SHOPPER", + "HELD_TEMPORARY", + "LOCKED_ABUSE", + "LOCKED_COPYRIGHT", + "LOCKED_REGISTRY", + "LOCKED_SUPER", + "PARKED_AND_HELD", + "PARKED_EXPIRED", + "PARKED_VERIFICATION_ICANN", + "PENDING_ABORT_CANCEL_SETUP", + "PENDING_AGREEMENT_PRE_REGISTRATION", + "PENDING_APPLY_RENEWAL_CREDITS", + "PENDING_BACKORDER_CAPTURE", + "PENDING_BLOCKED_REGISTRY", + "PENDING_CANCEL_REGISTRANT_PROFILE", + "PENDING_COMPLETE_REDEMPTION_WITHOUT_RECEIPT", + "PENDING_COMPLETE_REGISTRANT_PROFILE", + "PENDING_COO", + "PENDING_COO_COMPLETE", + "PENDING_DNS", + "PENDING_DNS_ACTIVE", + "PENDING_DNS_INACTIVE", + "PENDING_DOCUMENT_VALIDATION", + "PENDING_DOCUMENT_VERIFICATION", + "PENDING_DROP_IMMEDIATE", + "PENDING_DROP_IMMEDIATE_THEN_ADD", + "PENDING_EPP_CREATE", + "PENDING_EPP_DELETE", + "PENDING_EPP_UPDATE", + "PENDING_ESCALATION_REGISTRY", + "PENDING_EXPIRATION", + "PENDING_EXPIRATION_RESPONSE", + "PENDING_EXPIRATION_SYNC", + "PENDING_EXPIRED_REASSIGNMENT", + "PENDING_EXPIRE_AUTO_ADD", + "PENDING_EXTEND_REGISTRANT_PROFILE", + "PENDING_FAILED_COO", + "PENDING_FAILED_EPP_CREATE", + "PENDING_FAILED_HELD", + "PENDING_FAILED_PURCHASE_PREMIUM", + "PENDING_FAILED_RECONCILE_FIREHOSE", + "PENDING_FAILED_REDEMPTION_WITHOUT_RECEIPT", + "PENDING_FAILED_RELEASE_PREMIUM", + "PENDING_FAILED_RENEW_EXPIRATION_PROTECTION", + "PENDING_FAILED_RESERVE_PREMIUM", + "PENDING_FAILED_SUBMIT_FIREHOSE", + "PENDING_FAILED_TRANSFER_ACK_PREMIUM", + "PENDING_FAILED_TRANSFER_IN_ACK_PREMIUM", + "PENDING_FAILED_TRANSFER_IN_PREMIUM", + "PENDING_FAILED_TRANSFER_PREMIUM", + "PENDING_FAILED_TRANSFER_SUBMIT_PREMIUM", + "PENDING_FAILED_UNLOCK_PREMIUM", + "PENDING_FAILED_UPDATE_API", + "PENDING_FRAUD_VERIFICATION", + "PENDING_FRAUD_VERIFIED", + "PENDING_GET_CONTACTS", + "PENDING_GET_HOSTS", + "PENDING_GET_NAME_SERVERS", + "PENDING_GET_STATUS", + "PENDING_HOLD_ESCROW", + "PENDING_HOLD_REDEMPTION", + "PENDING_LOCK_CLIENT_REMOVE", + "PENDING_LOCK_DATA_QUALITY", + "PENDING_LOCK_THEN_HOLD_REDEMPTION", + "PENDING_PARKING_DETERMINATION", + "PENDING_PARK_INVALID_WHOIS", + "PENDING_PARK_INVALID_WHOIS_REMOVAL", + "PENDING_PURCHASE_PREMIUM", + "PENDING_RECONCILE", + "PENDING_RECONCILE_FIREHOSE", + "PENDING_REDEMPTION", + "PENDING_REDEMPTION_REPORT", + "PENDING_REDEMPTION_REPORT_COMPLETE", + "PENDING_REDEMPTION_REPORT_SUBMITTED", + "PENDING_REDEMPTION_WITHOUT_RECEIPT", + "PENDING_REDEMPTION_WITHOUT_RECEIPT_MOCK", + "PENDING_RELEASE_PREMIUM", + "PENDING_REMOVAL", + "PENDING_REMOVAL_HELD", + "PENDING_REMOVAL_PARKED", + "PENDING_REMOVAL_UNPARK", + "PENDING_RENEWAL", + "PENDING_RENEW_EXPIRATION_PROTECTION", + "PENDING_RENEW_INFINITE", + "PENDING_RENEW_LOCKED", + "PENDING_RENEW_WITHOUT_RECEIPT", + "PENDING_REPORT_REDEMPTION_WITHOUT_RECEIPT", + "PENDING_RESERVE_PREMIUM", + "PENDING_RESET_VERIFICATION_ICANN", + "PENDING_RESPONSE_FIREHOSE", + "PENDING_RESTORATION", + "PENDING_RESTORATION_INACTIVE", + "PENDING_RESTORATION_REDEMPTION_MOCK", + "PENDING_RETRY_EPP_CREATE", + "PENDING_RETRY_HELD", + "PENDING_SEND_AUTH_CODE", + "PENDING_SETUP", + "PENDING_SETUP_ABANDON", + "PENDING_SETUP_AGREEMENT_LANDRUSH", + "PENDING_SETUP_AGREEMENT_SUNRISE2_A", + "PENDING_SETUP_AGREEMENT_SUNRISE2_B", + "PENDING_SETUP_AGREEMENT_SUNRISE2_C", + "PENDING_SETUP_AUTH", + "PENDING_SETUP_DNS", + "PENDING_SETUP_FAILED", + "PENDING_SETUP_REVIEW", + "PENDING_SETUP_SUNRISE", + "PENDING_SETUP_SUNRISE_PRE", + "PENDING_SETUP_SUNRISE_RESPONSE", + "PENDING_SUBMIT_FAILURE", + "PENDING_SUBMIT_FIREHOSE", + "PENDING_SUBMIT_HOLD_FIREHOSE", + "PENDING_SUBMIT_HOLD_LANDRUSH", + "PENDING_SUBMIT_HOLD_SUNRISE", + "PENDING_SUBMIT_LANDRUSH", + "PENDING_SUBMIT_RESPONSE_FIREHOSE", + "PENDING_SUBMIT_RESPONSE_LANDRUSH", + "PENDING_SUBMIT_RESPONSE_SUNRISE", + "PENDING_SUBMIT_SUCCESS_FIREHOSE", + "PENDING_SUBMIT_SUCCESS_LANDRUSH", + "PENDING_SUBMIT_SUCCESS_SUNRISE", + "PENDING_SUBMIT_SUNRISE", + "PENDING_SUBMIT_WAITING_LANDRUSH", + "PENDING_SUCCESS_PRE_REGISTRATION", + "PENDING_SUSPENDED_DATA_QUALITY", + "PENDING_TRANSFER_ACK_PREMIUM", + "PENDING_TRANSFER_IN", + "PENDING_TRANSFER_IN_ACK", + "PENDING_TRANSFER_IN_ACK_PREMIUM", + "PENDING_TRANSFER_IN_BAD_REGISTRANT", + "PENDING_TRANSFER_IN_CANCEL", + "PENDING_TRANSFER_IN_CANCEL_REGISTRY", + "PENDING_TRANSFER_IN_COMPLETE_ACK", + "PENDING_TRANSFER_IN_DELETE", + "PENDING_TRANSFER_IN_LOCK", + "PENDING_TRANSFER_IN_NACK", + "PENDING_TRANSFER_IN_NOTIFICATION", + "PENDING_TRANSFER_IN_PREMIUM", + "PENDING_TRANSFER_IN_RELEASE", + "PENDING_TRANSFER_IN_RESPONSE", + "PENDING_TRANSFER_IN_UNDERAGE", + "PENDING_TRANSFER_OUT", + "PENDING_TRANSFER_OUT_ACK", + "PENDING_TRANSFER_OUT_NACK", + "PENDING_TRANSFER_OUT_PREMIUM", + "PENDING_TRANSFER_OUT_UNDERAGE", + "PENDING_TRANSFER_OUT_VALIDATION", + "PENDING_TRANSFER_PREMIUM", + "PENDING_TRANSFER_PREMUIM", + "PENDING_TRANSFER_SUBMIT_PREMIUM", + "PENDING_UNLOCK_DATA_QUALITY", + "PENDING_UNLOCK_PREMIUM", + "PENDING_UPDATE", + "PENDING_UPDATED_REGISTRANT_DATA_QUALITY", + "PENDING_UPDATE_ACCOUNT", + "PENDING_UPDATE_API", + "PENDING_UPDATE_API_RESPONSE", + "PENDING_UPDATE_AUTH", + "PENDING_UPDATE_CONTACTS", + "PENDING_UPDATE_CONTACTS_PRIVACY", + "PENDING_UPDATE_DNS", + "PENDING_UPDATE_DNS_SECURITY", + "PENDING_UPDATE_ELIGIBILITY", + "PENDING_UPDATE_EPP_CONTACTS", + "PENDING_UPDATE_MEMBERSHIP", + "PENDING_UPDATE_OWNERSHIP", + "PENDING_UPDATE_OWNERSHIP_AUTH_AUCTION", + "PENDING_UPDATE_OWNERSHIP_HELD", + "PENDING_UPDATE_REGISTRANT", + "PENDING_UPDATE_REPO", + "PENDING_VALIDATION_DATA_QUALITY", + "PENDING_VERIFICATION_FRAUD", + "PENDING_VERIFICATION_STATUS", + "PENDING_VERIFY_REGISTRANT_DATA_QUALITY", + "RESERVED", + "RESERVED_PREMIUM", + "REVERTED", + "SUSPENDED_VERIFICATION_ICANN", + "TRANSFERRED_OUT", + "UNLOCKED_ABUSE", + "UNLOCKED_SUPER", + "UNPARKED_AND_UNHELD", + "UPDATED_OWNERSHIP", + "UPDATED_OWNERSHIP_HELD" + ], + "type": "string" + } + } + }, + { + "description": "Only include results with `status` value in any of the specified groups", + "in": "query", + "name": "statusGroups", + "required": false, + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "enum": [ + "INACTIVE", + "PRE_REGISTRATION", + "REDEMPTION", + "RENEWABLE", + "VERIFICATION_ICANN", + "VISIBLE" + ], + "type": "string" + } + } + }, + { + "description": "Maximum number of domains to return", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + { + "description": "Marker Domain to use as the offset in results", + "in": "query", + "name": "marker", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Optional details to be included in the response", + "in": "query", + "name": "includes", + "required": false, + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "enum": [ + "authCode", + "contacts", + "nameServers" + ], + "type": "string" + } + } + }, + { + "description": "Only include results that have been modified since the specified date", + "in": "query", + "name": "modifiedDate", + "required": false, + "schema": { + "type": "string", + "format": "iso-datetime" + } + } + ], + "responses": { + "200": { + "description": "Request was successful", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/V1DomainSummary" + }, + "type": "array" + } + } + } + } + }, + "operationId": "list", + "summary": "Retrieve a list of Domains for the specified Shopper" + } + }, + "/v1/domains/agreements": { + "get": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Unique identifier of the Market used to retrieve/translate Legal Agreements", + "in": "header", + "name": "X-Market-Id", + "required": false, + "schema": { + "type": "string", + "format": "bcp-47", + "default": "en-US" + } + }, + { + "description": "list of TLDs whose legal agreements are to be retrieved", + "in": "query", + "name": "tlds", + "required": true, + "style": "form", + "explode": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "description": "Whether or not privacy has been requested", + "in": "query", + "name": "privacy", + "required": true, + "schema": { + "type": "boolean" + } + }, + { + "description": "Whether or not domain tranfer has been requested", + "in": "query", + "name": "forTransfer", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Request was successful", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/V1LegalAgreement" + }, + "type": "array" + } + } + } + } + }, + "operationId": "agreements", + "summary": "Retrieve the legal agreement(s) required to purchase the specified TLD and add-ons" + } + }, + "/v1/domains/{domain}/records": { + "patch": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be augmented", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V1ArrayOfDNSRecord" + } + } + }, + "description": "DNS Records to add to whatever currently exists", + "required": true + }, + "responses": { + "200": { + "description": "Request was successful" + } + }, + "operationId": "recordAdd", + "summary": "Add the specified DNS Records to the specified Domain" + }, + "put": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be replaced", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/V1DNSRecord" }, - "required": [ - "agreementKeys", - "price", - "currency", - "agreedBy", - "agreedAt" - ] - }, - "Contact": { - "properties": { - "addressMailing": { - "$ref": "#/components/schemas/Address" - }, - "email": { - "format": "email", - "type": "string" - }, - "fax": { - "format": "phone", - "type": "string" - }, - "jobTitle": { - "type": "string" - }, - "nameFirst": { - "format": "person-name", - "type": "string" - }, - "nameLast": { - "format": "person-name", - "type": "string" - }, - "nameMiddle": { - "type": "string" - }, - "organization": { - "format": "organization-name", - "type": "string" - }, - "phone": { - "format": "phone", - "type": "string" - } + "type": "array" + } + } + }, + "description": "DNS Records to replace whatever currently exists", + "required": true + }, + "responses": { + "200": { + "description": "Request was successful" + } + }, + "operationId": "recordReplace", + "summary": "Replace all DNS Records for the specified Domain" + }, + "get": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be retrieved", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Request was successful", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/V1DNSRecord" + }, + "type": "array" + } + } + } + } + }, + "operationId": "recordGetAll", + "summary": "Retrieve DNS Records for the specified Domain, optionally with the specified Type and/or Name" + } + }, + "/v1/domains/{domain}/records/{type}": { + "put": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be replaced", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be replaced", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ] + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/V1DNSRecordCreateType" }, - "required": [ - "nameFirst", - "nameLast", - "email", - "phone", - "addressMailing" - ] - }, - "ContactDomainCreate": { - "additionalProperties": false, - "properties": { - "encoding": { - "type": "string", - "default": "ASCII", - "enum": [ - "ASCII", - "UTF-8" - ], - "description": "The encoding of the contact data
" - }, - "nameFirst": { - "type": "string", - "format": "person-name", - "maxLength": 30 - }, - "nameMiddle": { - "type": "string" - }, - "nameLast": { - "type": "string", - "format": "person-name", - "maxLength": 30 - }, - "organization": { - "type": "string", - "format": "organization-name", - "maxLength": 100 - }, - "jobTitle": { - "type": "string" - }, - "email": { - "type": "string", - "format": "email", - "maxLength": 80 - }, - "phone": { - "type": "string", - "format": "phone", - "maxLength": 17 - }, - "fax": { - "type": "string", - "format": "phone", - "maxLength": 17 - }, - "addressMailing": { - "$ref": "#/components/schemas/Address" - }, - "metadata": { - "type": "object", - "description": "The contact eligibility data fields as specified by GET /v2/customers/{customerId}/domains/contacts/schema/{tld}" - } + "type": "array" + } + } + }, + "description": "DNS Records to replace whatever currently exists", + "required": true + }, + "responses": { + "200": { + "description": "Request was successful" + } + }, + "operationId": "recordReplaceType", + "summary": "Replace all DNS Records for the specified Domain with the specified Type" + }, + "get": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be retrieved", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be retrieved", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ] + } + } + ], + "responses": { + "200": { + "description": "Request was successful", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/V1DNSRecord" + }, + "type": "array" + } + } + } + } + }, + "operationId": "recordGetByType", + "summary": "Retrieve DNS Records for the specified Domain, optionally with the specified Type and/or Name" + } + }, + "/v1/domains/{domain}/records/{type}/{name}": { + "get": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be retrieved", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be retrieved", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ] + } + }, + { + "description": "DNS Record Name for which DNS Records are to be retrieved", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Number of results to skip for pagination", + "in": "query", + "name": "offset", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Maximum number of items to return", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Request was successful", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/V1DNSRecord" + }, + "type": "array" + } + } + } + } + }, + "operationId": "recordGet", + "summary": "Retrieve DNS Records for the specified Domain, optionally with the specified Type and/or Name" + }, + "put": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be replaced", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be replaced", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ] + } + }, + { + "description": "DNS Record Name for which DNS Records are to be replaced", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/V1DNSRecordCreateTypeName" }, - "required": [ - "encoding", - "nameFirst", - "nameLast", - "email", - "phone", - "addressMailing" - ] + "type": "array" + } + } + }, + "description": "DNS Records to replace whatever currently exists", + "required": true + }, + "responses": { + "200": { + "description": "Request was successful" + } + }, + "operationId": "recordReplaceTypeName", + "summary": "Replace all DNS Records for the specified Domain with the specified Type and Name" + }, + "delete": { + "tags": [ + "v1" + ], + "parameters": [ + { + "description": "Shopper ID which owns the domain. NOTE: This is only required if you are a Reseller managing a domain purchased outside the scope of your reseller account. For instance, if you're a Reseller, but purchased a Domain via http://www.godaddy.com", + "in": "header", + "name": "X-Shopper-Id", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Domain whose DNS Records are to be deleted", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "DNS Record Type for which DNS Records are to be deleted", + "in": "path", + "name": "type", + "required": true, + "schema": { + "type": "string", + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "SRV", + "TXT" + ] + } + }, + { + "description": "DNS Record Name for which DNS Records are to be deleted", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Request was successful" + } + }, + "operationId": "recordDeleteTypeName", + "summary": "Delete all DNS Records for the specified Domain with the specified Type and Name" + } + } + }, + "components": { + "parameters": { + "xRequestId": { + "name": "X-Request-Id", + "in": "header", + "description": "Optional client-generated request correlation identifier, propagated across services and returned in the response X-Request-Id header.\n", + "required": false, + "schema": { + "type": "string" + } + }, + "xShopperId": { + "name": "X-Shopper-Id", + "in": "header", + "description": "Reseller acting on behalf of a shopper account. When present, all domain operations are scoped to the specified shopper. Absent, the authenticated entity's own account is used. Only valid for reseller OAuth tokens.\n", + "required": false, + "schema": { + "type": "string" + }, + "example": "shopper_123" + }, + "idempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "description": "Client-generated unique key (UUID recommended). Retrying a mutating request with the same Idempotency-Key returns the original response without creating a duplicate side effect. Required on all execute endpoints.\n", + "required": true, + "schema": { + "type": "string" + }, + "example": "9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c" + }, + "domainNamePath": { + "name": "domain-name", + "in": "path", + "description": "The domain name in punycode A-label form (e.g., example.com). For IDNs, use the punycode representation.\n", + "required": true, + "schema": { + "type": "string" + }, + "example": "example.com" + }, + "registrationId": { + "name": "registrationId", + "in": "path", + "description": "Server-assigned registration identifier.", + "required": true, + "schema": { + "$ref": "#/components/schemas/uuid" + } + }, + "operationId": { + "name": "operationId", + "in": "path", + "description": "The server-assigned operation identifier returned in the 202 response of any async domain mutation.\n", + "required": true, + "schema": { + "$ref": "#/components/schemas/uuid" + }, + "example": "9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c" + }, + "zonePath": { + "name": "zone", + "in": "path", + "description": "The domain name in punycode A-label form (e.g., example.com). For IDNs, use the punycode representation.\n", + "required": true, + "schema": { + "type": "string" + }, + "example": "example.com" + } + }, + "headers": { + "xRequestId": { + "description": "Request correlation identifier echoed from the request or server-generated.", + "schema": { + "$ref": "#/components/schemas/uuid" + } + }, + "location": { + "description": "URL of the created or async resource.", + "schema": { + "type": "string", + "format": "uri" + } + }, + "retryAfter": { + "description": "Suggested number of seconds before the client should poll again. Present on 202 responses and non-terminal operation poll responses.\n", + "schema": { + "type": "integer", + "example": 5 + } + } + }, + "responses": { + "400": { + "description": "Malformed request syntax, missing required field, or invalid field type.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error" + } + } + } + }, + "401": { + "description": "Authentication credentials are missing or invalid.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error" + } + } + } + }, + "403": { + "description": "Authenticated identity is not authorized to perform this operation.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error" + } + } + } + }, + "404": { + "description": "The requested resource was not found.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error" + } + } + } + }, + "409": { + "description": "Conflict \u2014 the request cannot be completed in the current state. Used for quote lifecycle errors (quote_expired, quote_mismatch, quote_consumed, consent_principal_mismatch) and domain state conflicts such as domain_already_exists.\n", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error" + } + } + } + }, + "422": { + "description": "Semantically invalid request \u2014 valid structure but violates a business rule, such as an ineligible contact, unsupported TLD, or non-renewable domain status.\n", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error" + } + } + } + }, + "429": { + "description": "Too many requests \u2014 rate limit exceeded.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error" + } + } + } + }, + "503": { + "description": "Service temporarily unavailable.", + "headers": { + "X-Request-Id": { + "$ref": "#/components/headers/xRequestId" + }, + "Retry-After": { + "$ref": "#/components/headers/retryAfter" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error" + } + } + } + } + }, + "schemas": { + "Agreement": { + "title": "Agreement", + "description": "A legal agreement that must be accepted prior to executing a domain operation. Agreements are returned in the quote response and must be acknowledged in the execute request via the consent.agreementTypes array.\n", + "type": "object", + "properties": { + "agreementType": { + "description": "The type of legal agreement. Identifies which agreement text the customer must accept.\n", + "example": "DNRA", + "allOf": [ + { + "$ref": "#/components/schemas/AgreementType" + } + ] + }, + "title": { + "type": "string", + "description": "Human-readable title of the agreement, suitable for display to the customer.\n", + "example": "Domain Name Registration Agreement" + }, + "url": { + "type": "string", + "format": "uri", + "description": "URL to the full legal text of this agreement. Present when available.", + "example": "https://www.godaddy.com/agreements/showdoc?pageid=reg_sa" + } + } + }, + "Availability": { + "title": "Availability", + "description": "The availability check result for a single requested domain. A checkable domain returns the available flag plus optional pricing. A domain that could not be checked carries an error object and no availability fields. Exactly one of available or error is present per item.\nAvailability is best-effort indicative; the authoritative availability check is performed at quote time. definitive: true means the result was confirmed directly with the registry rather than from a cached zone check.\n", + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "The domain name checked, normalized to punycode A-label form.\n", + "example": "example.com" + }, + "unicodeDomain": { + "type": "string", + "description": "The Unicode (U-label) form of the domain. Present only for IDN domains.\n", + "example": "m\u00fcnchen.de" + }, + "available": { + "type": "boolean", + "description": "Whether this domain appears to be available for registration. Best-effort; re-verified at quote time. Present only when the domain was successfully checked (no error).\n" + }, + "definitive": { + "type": "boolean", + "description": "When true, the availability result was confirmed directly with the registry (ACCURACY mode). When false, the result is from a cached zone data check (SPEED mode) and may be stale.\n" + }, + "inventory": { + "description": "The inventory source for this domain. Present when available is true and pricing fields are returned.\n", + "example": "REGISTRY", + "allOf": [ + { + "$ref": "#/components/schemas/InventoryType" + } + ] + }, + "prices": { + "type": "array", + "description": "Indicative pricing offered per registration term length, in ascending order of period. Present when available is true. Prices are best-effort indicative and are locked only at quote time.\n", + "items": { + "$ref": "#/components/schemas/TermPrice" }, - "DNSRecord": { - "properties": { - "data": { - "type": "string" - }, - "name": { - "format": "domain", - "type": "string" - }, - "port": { - "description": "Service port (SRV only)", - "maximum": 65535, - "minimum": 1, - "type": "integer" - }, - "priority": { - "description": "Record priority (MX and SRV only)", - "format": "integer-positive", - "type": "integer" - }, - "protocol": { - "description": "Service protocol (SRV only)", - "type": "string" - }, - "service": { - "description": "Service type (SRV only)", - "type": "string" - }, - "ttl": { - "format": "integer-positive", - "type": "integer" - }, - "type": { - "enum": [ - "A", - "AAAA", - "CNAME", - "MX", - "NS", - "SOA", - "SRV", - "TXT" - ], - "type": "string" - }, - "weight": { - "description": "Record weight (SRV only)", - "format": "integer-positive", - "type": "integer" - } - }, - "required": [ - "type", - "name", - "data" - ] + "nullable": true + }, + "error": { + "description": "Present when this domain could not be checked. One of available or error is present, never both. correlationId is required and should echo the X-Request-Id header value for this request.\nCommon name values (aligned with v2 find API error codes): MISMATCH_FORMAT \u2014 the domain name does not conform to the expected format. UNSUPPORTED_TLD \u2014 the TLD is not supported for this account or check. INVALID_DOMAIN \u2014 the availcheck service reported a syntax error for this domain. ERROR_UNKNOWN \u2014 the check failed and could not be classified further.\n", + "allOf": [ + { + "$ref": "#/components/schemas/error" + } + ] + } + } + }, + "AvailabilityCheckCriteria": { + "title": "Availability Check Criteria", + "description": "Criteria for an availability check. Specifies 1\u201350 domain names and optional parameters that influence how the check is performed. This controller does not persist the check; there is no check identity or poll URL.\n", + "type": "object", + "required": [ + "domains" + ], + "properties": { + "domains": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "items": { + "type": "string" }, - "DNSRecordCreateType": { - "properties": { - "data": { - "type": "string" - }, - "name": { - "format": "domain", - "type": "string" - }, - "port": { - "description": "Service port (SRV only)", - "maximum": 65535, - "minimum": 1, - "type": "integer" - }, - "priority": { - "description": "Record priority (MX and SRV only)", - "format": "integer-positive", - "type": "integer" - }, - "protocol": { - "description": "Service protocol (SRV only)", - "type": "string" - }, - "service": { - "description": "Service type (SRV only)", - "type": "string" - }, - "ttl": { - "format": "integer-positive", - "type": "integer" - }, - "weight": { - "description": "Record weight (SRV only)", - "format": "integer-positive", - "type": "integer" - } - }, - "required": [ - "name", - "data" - ] + "description": "List of 1\u201350 domain names to check, in punycode A-label form for IDNs.\n", + "example": [ + "example.com", + "example.net" + ] + }, + "optimizeFor": { + "default": "SPEED", + "description": "Optional. When omitted, defaults to SPEED. Availability is always re-verified authoritatively at quote time regardless of this setting.\n", + "example": "SPEED", + "allOf": [ + { + "$ref": "#/components/schemas/OptimizationTarget" + } + ] + }, + "iscCode": { + "type": "string", + "description": "Reseller ISC (International Shopper Code) for pricing context. When provided, the indicative prices in the results reflect the applicable reseller rates for this ISC.\n", + "example": "ISC_PARTNER_001" + } + } + }, + "Consent": { + "title": "Consent", + "description": "Customer consent record for a domain operation, capturing which legal agreements were accepted, when, and by whom. This object is self-reported by the caller and treated as supplementary attestation. The server verifies agreedBy.principal against the authenticated identity (auth token + X-Shopper-Id resolution) and rejects with consent_principal_mismatch on disagreement. The persisted consent record is the union of this claimed block and the verified auth context.\n", + "type": "object", + "required": [ + "agreementTypes", + "agreedAt", + "agreedBy" + ], + "properties": { + "agreementTypes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/components/schemas/AgreementType" }, - "DNSRecordCreateTypeName": { - "properties": { - "data": { - "type": "string" - }, - "port": { - "description": "Service port (SRV only)", - "maximum": 65535, - "minimum": 1, - "type": "integer" - }, - "priority": { - "description": "Record priority (MX and SRV only)", - "format": "integer-positive", - "type": "integer" - }, - "protocol": { - "description": "Service protocol (SRV only)", - "type": "string" - }, - "service": { - "description": "Service type (SRV only)", - "type": "string" - }, - "ttl": { - "format": "integer-positive", - "type": "integer" - }, - "weight": { - "description": "Record weight (SRV only)", - "format": "integer-positive", - "type": "integer" - } - }, - "required": [ - "data" - ] + "description": "The agreement types the customer accepted. Must match the agreementType values returned in the corresponding quote's requiredAgreements array.\n", + "example": [ + "DNRA" + ] + }, + "agreedAt": { + "description": "The timestamp at which the principal expressed consent. Should reflect when the customer clicked accept or confirmed the operation, not when the API call was made.\n", + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "agreedBy": { + "$ref": "#/components/schemas/ConsentActor" + } + } + }, + "ConsentActor": { + "title": "Consent Actor", + "description": "Identifies who gave consent and who transmitted it. One uniform schema for all actor types. Self-reported by the caller and treated as supplementary attestation; the server verifies principal against the resolved auth identity (OAuth token + X-Shopper-Id) and rejects with consent_principal_mismatch on disagreement. The persisted consent record is the union of this block and the verified auth context.\nprincipal identifies the account holder whose consent is being recorded. actor identifies the automated or intermediary party that transmitted the consent when different from the principal. For DIRECT, actor is omitted.\n", + "type": "object", + "x-sensitivity": "confidential", + "required": [ + "type", + "principal" + ], + "properties": { + "type": { + "description": "Who transmitted consent relative to the principal.\n", + "example": "DIRECT", + "allOf": [ + { + "$ref": "#/components/schemas/ConsentActorType" + } + ] + }, + "principal": { + "type": "string", + "description": "The shopper or account ID whose consent is being recorded. Must match the shopper resolved from the OAuth token and X-Shopper-Id header; mismatch returns consent_principal_mismatch.\n", + "example": "shopper_123" + }, + "actor": { + "type": "string", + "description": "The automated agent or system that transmitted the consent. Omitted for DIRECT. For AGENT, identifies the specific agent instance. For RESELLER, may identify the reseller when distinct from the OAuth token subject.\n", + "example": "agent:claude/atlas-1" + }, + "ip": { + "type": "string", + "description": "The IP address of the principal at the time consent was expressed, if known. Optional \u2014 an absent IP with a verified principal is preferred over a fabricated one.\n", + "example": "203.0.113.7" + } + } + }, + "DNSRecord": { + "title": "DNS Record", + "description": "A single DNS resource record in the zone for a domain managed by GoDaddy DNS. Supports standard IANA record types plus the GoDaddy ALIAS extension. SOA records are read-only and managed by GoDaddy's authoritative DNS infrastructure. The record is uniquely identified by the combination of name, type, and data.\n", + "type": "object", + "required": [ + "name", + "type", + "data", + "ttl" + ], + "properties": { + "recordId": { + "type": "string", + "description": "Server-assigned stable identifier for this DNS record.\n", + "readOnly": true + }, + "name": { + "type": "string", + "description": "The DNS record name (label), relative to the zone apex. Use @ to represent the zone apex itself. Wildcards (*) are supported for A, AAAA, and CNAME records.\n", + "example": "@" + }, + "type": { + "description": "The DNS resource record type. Together with name and data, uniquely identifies this record in the zone. Determines the expected data format and which optional fields (priority, service, port, etc.) apply. SOA and NS records are read-only.\n", + "example": "A", + "allOf": [ + { + "$ref": "#/components/schemas/DnsRecordType" + } + ] + }, + "data": { + "type": "string", + "description": "The record data, formatted per the record type. For MX: the mail exchange hostname (e.g. \"mail.example.com.\"). Supply priority as the sibling priority field. For SRV: the target hostname; supply priority, weight, port, service, and protocol as sibling fields. For TXT: the text value (quotes are handled by the DNS layer). For A/AAAA: the IP address. For CNAME/ALIAS: the target hostname with trailing dot.\n", + "example": "192.0.2.1" + }, + "ttl": { + "type": "integer", + "minimum": 600, + "maximum": 86400, + "description": "Time-to-live in seconds. Controls how long resolvers may cache this record. Minimum 600 (10 minutes); maximum 86400 (24 hours).\n", + "example": 3600 + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Record priority. Required for MX and SRV records. Lower values indicate higher preference. Omit for all other record types.\n", + "example": 10 + }, + "service": { + "type": "string", + "description": "Service name for SRV and TLSA records, prefixed with an underscore (e.g. `_http`, `_smtp`). Combined with the protocol to form the record name as `_service._proto.name`.\n", + "example": "_http" + }, + "port": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "TCP or UDP port number for the target service. Used in SRV records to direct clients to the correct port, and in TLSA records to identify the service endpoint being certified.\n", + "example": 443 + }, + "weight": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "description": "Relative weight for load distribution among SRV records with equal priority. Higher values increase the probability of selection. Use 0 when only one target exists at a given priority.\n", + "example": 10 + }, + "protocol": { + "type": "string", + "description": "Transport protocol for SRV and TLSA records, prefixed with an underscore. Typically `_tcp` or `_udp`.\n", + "example": "_tcp" + }, + "flag": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "CAA record flag. Certification Authority restriction flags byte (RFC 8659; CAA only) Use 0 for non-critical, 128 for critical (issuer must understand the tag).\n", + "example": 0 + }, + "tag": { + "type": "string", + "description": "CAA record property tag. Common values: `issue` (authorize CA to issue), `issuewild` (wildcard certs), `iodef` (violation reporting URL).\n", + "example": "issue" + } + } + }, + "NameServers": { + "title": "Name Servers", + "description": "Authoritative nameserver hostnames for a domain. ICANN requires a minimum of two nameservers; registries accept up to thirteen.\n", + "type": "array", + "minItems": 2, + "maxItems": 13, + "items": { + "$ref": "#/components/schemas/NameserverHostname" + } + }, + "NameserverHostname": { + "title": "Nameserver Hostname", + "description": "A fully-qualified nameserver hostname in punycode A-label form. Labels are dot-separated; a trailing dot is not required.\n", + "type": "string" + }, + "Domain": { + "title": "Domain", + "description": "A registered domain owned by the authenticated account. Represents the full management view of a domain: registration metadata, lifecycle status, nameservers, privacy and auto-renew preferences.\n", + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "The registered domain name in punycode A-label form. For IDNs, the Unicode (U-label) form is available in the idnDomain field.\n", + "readOnly": true, + "example": "example.com" + }, + "idnDomain": { + "type": "string", + "description": "The Unicode (U-label) representation of the domain. Present only for internationalized domain names (IDNs).\n", + "readOnly": true + }, + "status": { + "description": "The current lifecycle status of this domain.\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/DomainStatus" + } + ] + }, + "expiresAt": { + "description": "The date and time when this domain registration expires. After expiry the domain enters a grace period before deletion.\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "createdAt": { + "description": "The date and time when this domain was first registered with GoDaddy.\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "renewBy": { + "description": "The date and time when this domain must renew on before entering expiry.\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "updatedAt": { + "description": "The date and time when this domain was last updated\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "autoRenew": { + "type": "boolean", + "description": "Whether this domain is set to auto-renew before expiry. When true, the registration is renewed automatically before it expires.\n" + }, + "privacy": { + "type": "boolean", + "description": "Whether WHOIS privacy protection is active for this domain. When true, the registrant's contact details are masked in public WHOIS.\n" + }, + "transferLock": { + "type": "boolean", + "description": "Whether the transfer lock (registrar lock / EPP lock) is active. When true, outbound transfers to another registrar are blocked.\n", + "readOnly": true + }, + "nameServers": { + "type": "array", + "minItems": 2, + "maxItems": 13, + "items": { + "type": "string" }, - "DomainAvailableResponse": { - "properties": { - "available": { - "description": "Whether or not the domain name is available", - "type": "boolean" - }, - "currency": { - "default": "USD", - "description": "Currency in which the `price` is listed. Only returned if tld is offered", - "format": "iso-currency-code", - "type": "string" - }, - "definitive": { - "description": "Whether or not the `available` answer has been definitively verified with the registry", - "type": "boolean" - }, - "domain": { - "description": "Domain name", - "type": "string" - }, - "period": { - "description": "Number of years included in the price. Only returned if tld is offered", - "format": "integer-positive", - "type": "integer" - }, - "price": { - "description": "Price of the domain excluding taxes or fees. Only returned if tld is offered", - "format": "currency-micro-unit", - "type": "integer" - }, - "renewalPrice": { - "description": "Price for renewing the domain excluding taxes or fees. Only returned if tld is offered", - "format": "currency-micro-unit", - "type": "integer" - } - }, - "required": [ - "domain", - "available", - "definitive" - ] + "description": "The current authoritative nameservers for this domain.\n", + "example": [ + "ns01.domaincontrol.com", + "ns02.domaincontrol.com" + ], + "allOf": [ + { + "$ref": "#/components/schemas/NameServers" + } + ], + "nullable": true + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/link-description" }, - "DomainContactsCreateV2": { - "additionalProperties": false, - "type": "object", - "properties": { - "admin": { - "$ref": "#/components/schemas/ContactDomainCreate" - }, - "adminId": { - "description": "Unique identifier of the contact that the user wants to use for the domain admin contact. This can be specified instead of the `admin` property.\n", - "type": "string" - }, - "billing": { - "$ref": "#/components/schemas/ContactDomainCreate" - }, - "billingId": { - "description": "Unique identifier of the contact that the user wants to use for the domain billing contact. This can be specified instead of the `billing` property.\n", - "type": "string" - }, - "registrant": { - "$ref": "#/components/schemas/ContactDomainCreate" - }, - "registrantId": { - "description": "Unique identifier of the contact that the user wants to use for the domain registrant contact. This can be specified instead of the `registrant` property.\n", - "type": "string" - }, - "tech": { - "$ref": "#/components/schemas/ContactDomainCreate" - }, - "techId": { - "description": "Unique identifier of the contact that the user wants to use for the domain tech contact. This can be specified instead of the `tech` property.\n", - "type": "string" - } - } + "description": "HATEOAS link relations for this domain. rel=self \u2014 the canonical URL for this domain record. rel=contacts \u2014 the domain's WHOIS contact records. rel=nameservers \u2014 the domain's authoritative nameservers. rel=dns-records \u2014 the domain's DNS records managed by GoDaddy.\n", + "readOnly": true, + "nullable": true + } + } + }, + "DomainOperation": { + "title": "Domain Operation", + "description": "The abstract operation envelope for all domain mutations, returned by the universal GET /operations/{operationId} endpoint. Concrete specializations \u2014 Registration, Renewal, and Transfer \u2014 are returned directly by their respective POST endpoints and carry the same operationId. Developers who do not need the abstract view can poll the concrete resource (GET /registrations/{id}, etc.) and ignore this type entirely.\nOperation IDs are unique across all concrete types, so either poll path works for any given operation.\nAsync state machine:\n status tracks where the operation is in its lifecycle. Non-terminal values\n (CONFIRMED, EXECUTING) are transient \u2014 poll until a terminal value is reached.\n result and error are mutually exclusive terminal payloads:\n COMPLETED \u2014 operation succeeded; result contains the final outcome data.\n FAILED \u2014 operation terminated; error contains failure detail.\n Neither result nor error is present while status is non-terminal.\n", + "type": "object", + "properties": { + "operationId": { + "description": "Stable, server-assigned identifier for this operation. Unique across all operation types. Use to poll GET /operations/{operationId}. Matches the operationId on the corresponding concrete resource (e.g. Registration).\n", + "readOnly": true, + "example": "9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c", + "allOf": [ + { + "$ref": "#/components/schemas/uuid" + } + ] + }, + "type": { + "description": "The type of operation being tracked. Determines which fields appear in result on COMPLETED and the concrete resource collection (REGISTER \u2192 /registrations, RENEW \u2192 /renewals, TRANSFER_IN \u2192 /transfers).\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/DomainOperationType" + } + ] + }, + "domain": { + "type": "string", + "description": "The domain name this operation applies to.", + "readOnly": true, + "example": "example.com" + }, + "status": { + "description": "Current position in the operation lifecycle. Poll until COMPLETED or FAILED. Non-terminal while CONFIRMED or EXECUTING; terminal values are mutually exclusive with each other and do not revert.\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/DomainOperationStatus" + } + ] + }, + "result": { + "description": "Present when status is COMPLETED. Absent while non-terminal and when status is FAILED.\n", + "allOf": [ + { + "$ref": "#/components/schemas/DomainOperationResult" + } + ], + "readOnly": true + }, + "error": { + "description": "Present when status is FAILED. Absent while non-terminal and when status is COMPLETED.\n", + "allOf": [ + { + "$ref": "#/components/schemas/error" + } + ], + "readOnly": true + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/link-description" }, - "DomainPurchase": { - "properties": { - "consent": { - "$ref": "#/components/schemas/Consent" - }, - "contactAdmin": { - "$ref": "#/components/schemas/Contact" - }, - "contactBilling": { - "$ref": "#/components/schemas/Contact" - }, - "contactRegistrant": { - "$ref": "#/components/schemas/Contact" - }, - "contactTech": { - "$ref": "#/components/schemas/Contact" - }, - "domain": { - "description": "For internationalized domain names with non-ascii characters, the domain name is converted to punycode before format and pattern validation rules are checked", - "format": "domain", - "type": "string" - }, - "nameServers": { - "items": { - "format": "host-name", - "type": "string" - }, - "type": "array" - }, - "period": { - "default": 1, - "format": "integer-positive", - "maximum": 10, - "minimum": 1, - "type": "integer" - }, - "privacy": { - "default": false, - "type": "boolean" - }, - "renewAuto": { - "default": true, - "type": "boolean" - } - }, - "required": [ - "domain", - "consent" - ] + "description": "HATEOAS link relations for this operation. rel=self \u2014 the canonical URL for this abstract operation view. rel=registration, rel=renewal, or rel=transfer \u2014 the same resource viewed through its concrete typed collection. rel=domain \u2014 the domain-name resource affected by this operation.\n", + "readOnly": true, + "nullable": true + }, + "createdAt": { + "description": "Timestamp when this operation was created.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "updatedAt": { + "description": "Timestamp of the most recent status update.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + } + } + }, + "DomainOperationResult": { + "title": "Domain Operation Result", + "description": "The terminal success payload for a completed domain operation. Returned on the parent DomainOperation when status is COMPLETED. Absent for non-terminal statuses (CONFIRMED, EXECUTING) and for FAILED operations.\nOnce status reaches COMPLETED it is terminal: result is populated, remains available on subsequent polls, and status does not revert. Interpret the fields present in result using the parent operation's type:\nREGISTER \u2014 expiresAt, orderId.\n", + "type": "object", + "readOnly": true, + "properties": { + "expiresAt": { + "description": "New domain expiry date. Present for REGISTER and RENEW operations.\n", + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "orderId": { + "type": "string", + "description": "The commerce order ID associated with the charge. Present for commercial operations (REGISTER, RENEW, TRANSFER_IN).\n", + "example": "ord_abc123" + }, + "updatedAt": { + "description": "Timestamp of the completed non-commercial mutation.\n", + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + } + } + }, + "Registration": { + "title": "Registration", + "description": "A domain registration entity created when a POST /registrations request is accepted. Registrations are a top-level resource with their own stable registrationId; the domain relationship is captured in the representation.\nOn POST /registrations, supply the writable fields (domain, period, quoteToken, consent, and optionally profileId/profile). The server returns the full Registration with readOnly fields populated. Poll links[rel=self] until status reaches COMPLETED or FAILED. The same resource is also reachable via GET /operations/{operationId} for clients operating at the abstract level; operationId is included in the representation for that purpose.\n", + "type": "object", + "required": [ + "domain", + "consent" + ], + "properties": { + "registrationId": { + "description": "Server-assigned stable identifier for this registration record.\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/uuid" + } + ] + }, + "domain": { + "type": "string", + "description": "The domain name to register, in punycode A-label form for IDNs. Must match the domain in the quoteToken.\n", + "example": "example.com" + }, + "period": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 1, + "description": "Registration period in years. Must match the period in the quote.", + "example": 1 + }, + "profileId": { + "description": "ID of a saved registration profile to use for contacts and preference defaults. Omit to fall back to the account-default profile for the domain's TLD, then to account identity.\n", + "allOf": [ + { + "$ref": "#/components/schemas/uuid" + } + ] + }, + "profile": { + "description": "One-time inline contacts and purchase preference defaults for this registration. Not persisted. Must match the profile supplied on the quote when a profile was included at quote time.\n", + "allOf": [ + { + "$ref": "#/components/schemas/InlineRegistrationProfile" + } + ] + }, + "quoteToken": { + "description": "The single-use opaque token from the preceding quoteDomainRegistration call. Required. Consumed on first successful execution; idempotent retries with the same Idempotency-Key replay the original operation without re-consuming.\n", + "writeOnly": true, + "example": "7f3a2b1c-9d8e-4012-a5b6-c1d2e3f4a5b6", + "allOf": [ + { + "$ref": "#/components/schemas/uuid" + } + ] + }, + "consent": { + "description": "The customer's consent record for the legal agreements returned in the quote. Must reference the same agreementTypes as the quote.\n", + "allOf": [ + { + "$ref": "#/components/schemas/Consent" + } + ] + }, + "status": { + "description": "Current execution status of this registration.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/DomainOperationStatus" + } + ] + }, + "operationId": { + "description": "Identifier of the DomainOperation tracking this registration. Same value returned by GET /operations/{operationId} for abstract operation tracking.\n", + "readOnly": true, + "example": "9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c", + "allOf": [ + { + "$ref": "#/components/schemas/uuid" + } + ] + }, + "expiresAt": { + "description": "The domain's expiry date once the registration completes. Present only when status is COMPLETED.\n", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "createdAt": { + "description": "Timestamp when this registration was initiated.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "updatedAt": { + "description": "Timestamp of the most recent status update.", + "readOnly": true, + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "links": { + "type": "array", + "items": { + "$ref": "#/components/schemas/link-description" }, - "DomainPurchaseResponse": { - "properties": { - "currency": { - "default": "USD", - "description": "Currency in which the `total` is listed", - "format": "iso-currency-code", - "type": "string" - }, - "itemCount": { - "description": "Number items included in the order", - "format": "integer-positive", - "type": "integer" - }, - "orderId": { - "description": "Unique identifier of the order processed to purchase the domain", - "format": "int64", - "type": "integer" - }, - "total": { - "description": "Total cost of the domain and any selected add-ons", - "format": "currency-micro-unit", - "type": "integer" - } - } + "description": "HATEOAS link relations for this registration. rel=self \u2014 the canonical URL for this registration record. rel=domain \u2014 the registered domain-name resource once the registration is complete.\n", + "readOnly": true + } + } + }, + "InlineRegistrationProfile": { + "title": "Inline Registration Profile", + "description": "A one-time, non-persisted set of contacts and purchase preference defaults supplied inline on a quote or execute request. Use to provide registration data for this transaction without creating or updating a saved registration profile.\nShared by the registration quote and execute request bodies. Every field is optional. Omitted fields account identity or other default values. Provided fields override only what is supplied \u2014 contact roles replace as a whole block; preference fields replace individually.\nThis is not a saved registration profile and is not JSON Patch. Data here applies only to the current quote or registration request.\n", + "type": "object", + "properties": { + "contacts": { + "description": "Contact records for this request. Each role provided replaces that role from the resolved saved profile or account identity. Omitted roles continue to resolve from the saved profile or cascade from registrant.\n", + "allOf": [ + { + "$ref": "#/components/schemas/Contacts" + } + ] + }, + "autoRenew": { + "type": "boolean", + "description": "Auto-renew preference for this registration. Omit to inherit from the resolved saved profile or account defaults.\n" + }, + "privacy": { + "type": "boolean", + "description": "WHOIS privacy preference for this registration. Omit to inherit from the resolved saved profile or account defaults.\n" + }, + "nameServers": { + "description": "Authoritative nameservers for this registration. Omit to inherit from the resolved saved profile or platform defaults.\n", + "example": [ + "ns1.example.com", + "ns2.example.com" + ], + "allOf": [ + { + "$ref": "#/components/schemas/NameServers" + } + ] + } + } + }, + "RegistrationQuote": { + "title": "Registration Quote", + "description": "A price quote for registering a single domain. Contains a locked price, resolved contact and preference settings, required legal agreements, and a short-lived single-use quoteToken that must be presented on the subsequent registration execute call. Execution without a valid quoteToken is structurally impossible.\nWhen available is false, no quoteToken is returned \u2014 this is not an error; it means the domain cannot be registered as requested.\n", + "type": "object", + "properties": { + "quoteToken": { + "description": "Opaque, single-use token with a 10-minute TTL. References the locked price, a hash of this request body, and a hash of the resolved profile values. Absent when available is false. Treat as a capability; do not parse.\n", + "example": "7f3a2b1c-9d8e-4012-a5b6-c1d2e3f4a5b6", + "allOf": [ + { + "$ref": "#/components/schemas/uuid" + } + ] + }, + "expiresAt": { + "description": "The expiry timestamp of the quoteToken. After this time, presenting the token on execute returns quote_expired and a new quote must be obtained.\n", + "allOf": [ + { + "$ref": "#/components/schemas/date-time" + } + ] + }, + "domain": { + "type": "string", + "description": "The domain name being quoted, in punycode A-label form for IDNs.\n", + "example": "example.com" + }, + "available": { + "type": "boolean", + "description": "Whether the domain is available for registration. When false, no quoteToken is returned. The availability check at quote time is authoritative; a name sniped between suggest/availability and quote fails cleanly here.\n" + }, + "price": { + "description": "The locked registration price for the quoted period. Held for the duration of the quoteToken's TTL. Represents the total amount that will be charged on execute.\n", + "allOf": [ + { + "$ref": "#/components/schemas/simple-money" + } + ] + }, + "renewalPrice": { + "description": "Indicative renewal cost at current rates. Not a price guarantee; renewal pricing is locked at time of renewal quote.\n", + "allOf": [ + { + "$ref": "#/components/schemas/simple-money" + } + ] + }, + "period": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "Registration period in years for which the price is quoted.", + "example": 1 + }, + "resolved": { + "description": "The effective contact and preference settings that will be applied on execute. Review before execute to verify registrant, contacts, and preferences match intent.\n", + "allOf": [ + { + "$ref": "#/components/schemas/ResolvedSettings" + } + ] + }, + "requiredAgreements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Agreement" }, - "DomainPurchaseV2": { - "additionalProperties": false, - "properties": { - "domain": { - "type": "string", - "format": "domain", - "pattern": "^[^.]{1,63}.[^.]{2,}$", - "description": "For internationalized domain names with non-ascii characters, the domain name is converted to punycode before format and pattern validation rules are checked" - }, - "consent": { - "$ref": "#/components/schemas/ConsentV2" - }, - "period": { - "type": "integer", - "format": "integer-positive", - "default": 1, - "minimum": 1, - "maximum": 10, - "pattern": "^[0-9]+$" - }, - "nameServers": { - "type": "array", - "items": { - "type": "string", - "format": "host-name" - }, - "maxItems": 2 - }, - "renewAuto": { - "type": "boolean", - "default": true - }, - "privacy": { - "type": "boolean", - "default": false - }, - "contacts": { - "$ref": "#/components/schemas/DomainContactsCreateV2" - }, - "metadata": { - "type": "object", - "description": "The domain eligibility data fields as specified by GET /v2/customers/{customerId}/domains/register/schema/{tld}" - } - }, - "required": [ - "domain", - "consent" - ] + "description": "Legal agreements that must be accepted before executing this quote. The agreementType values from this list must be included in the execute request's consent object.\n", + "nullable": true + }, + "irreversible": { + "type": "boolean", + "description": "Whether executing this quote is irreversible once accepted. Use to calibrate the explicitness of any confirmation step presented before execute.\n", + "example": false + } + } + }, + "ResolvedSettings": { + "title": "Resolved Settings", + "description": "A preview of the effective settings that will be applied if the associated quote is executed. Returned in the quote response to eliminate invisible side effects \u2014 the caller sees exactly whose contact info and which preferences will be used before making a commitment. contactSource names where the registrant contact came from so an agent can be explicit at the confirmation step.\n", + "type": "object", + "properties": { + "profileId": { + "description": "The saved registration profile that was applied, if any. Absent when the registrant was derived from account identity.\n", + "readOnly": true, + "example": "14514a29-5fce-4624-8d8a-d8abd56015e2", + "allOf": [ + { + "$ref": "#/components/schemas/uuid" + } + ] + }, + "contactSource": { + "description": "Indicates where the resolved registrant contact came from. When ACCOUNT, the agent should surface this clearly to the customer before confirmation.\n", + "example": "PROFILE", + "allOf": [ + { + "$ref": "#/components/schemas/ContactSource" + } + ] + }, + "registrantSummary": { + "type": "string", + "description": "A human-readable one-line summary of the resolved registrant, suitable for display in a confirmation prompt. When contactSource is ACCOUNT, the summary is suffixed with \"(account identity)\" to make the derivation explicit.\n", + "x-sensitivity": "confidential", + "example": "Jane Smith / jane@example.com" + }, + "autoRenew": { + "type": "boolean", + "description": "The effective auto-renew setting that will be applied upon registration.\n" + }, + "privacy": { + "type": "boolean", + "description": "The effective WHOIS privacy setting that will be applied upon registration.\n" + }, + "nameServers": { + "description": "The effective nameservers that will be provisioned for the domain.\n", + "example": [ + "ns01.domaincontrol.com", + "ns02.domaincontrol.com" + ], + "allOf": [ + { + "$ref": "#/components/schemas/NameServers" + } + ] + } + } + }, + "Suggestion": { + "title": "Suggestion", + "description": "A single available domain suggestion returned by the suggest endpoint. Availability is implied by presence in the results (available-only contract) and is best-effort \u2014 the availability check at quote time is the authoritative re-check. Indicative pricing may be stale; the locked price is established at quote time only.\n", + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "The suggested domain name in punycode A-label form.\n", + "example": "sunrisebakery.com" + }, + "listPrice": { + "description": "Indicative undiscounted list price for a one-year registration. Promotional pricing and the final locked price resolve at quote time.\n", + "allOf": [ + { + "$ref": "#/components/schemas/simple-money" + } + ] + }, + "renewalPrice": { + "description": "Indicative renewal price at current rates. Not a guarantee.\n", + "allOf": [ + { + "$ref": "#/components/schemas/simple-money" + } + ] + }, + "inventory": { + "description": "The inventory source for this domain. Present when pricing fields are present.\n", + "example": "REGISTRY", + "allOf": [ + { + "$ref": "#/components/schemas/InventoryType" + } + ] + } + } + }, + "TermPrice": { + "title": "Term Price", + "description": "Pricing for one registration term, including sale and list prices for the full term and optional renewal and first-term breakdowns.\n", + "type": "object", + "properties": { + "term": { + "description": "Unit in which period is expressed. Currently only YEAR is supported.\n", + "example": "YEAR", + "allOf": [ + { + "$ref": "#/components/schemas/Term" + } + ] + }, + "period": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "description": "The registration period length, in units of term.\n", + "example": 2 + }, + "price": { + "description": "The price for the full term covered by this price block \u2014 the current registration price for `period` units of term. Discounts or promotions may be reflected in this price.\n", + "allOf": [ + { + "$ref": "#/components/schemas/simple-money" + } + ] + }, + "renewalPrice": { + "description": "Sale price that will apply when the domain renews, for the same term and period. Not a guarantee.\n", + "allOf": [ + { + "$ref": "#/components/schemas/simple-money" + } + ] + } + } + }, + "uuid": { + "description": "A universally unique identifier (UUID) in [RFC-4122 format](https://tools.ietf.org/html/rfc4122).", + "type": "string" + }, + "SuggestionSource": { + "title": "Suggestion Source", + "description": "A suggestion source strategy that generates domain name variations. EXTENSION \u2014 vary the TLD. KEYWORD_SPIN \u2014 rotate keywords. CC_TLD \u2014 vary using country-code TLDs. PREMIUM \u2014 include premium-priced variations.\n", + "type": "string", + "enum": [ + "CC_TLD", + "EXTENSION", + "KEYWORD_SPIN", + "PREMIUM" + ] + }, + "currency-code": { + "type": "string", + "title": "Currency Code", + "description": "A three-character ISO-4217 currency code." + }, + "simple-money": { + "type": "object", + "title": "Simple Money", + "description": "The currency and amount for a financial transaction, such as a balance or payment due. Use for value representations with default transactable-value precision.", + "properties": { + "currencyCode": { + "$ref": "#/components/schemas/currency-code" + }, + "value": { + "type": "integer", + "format": "int64", + "description": "The value, which might represent intergrals for currencies like `JPY` that are not typically fractional; or, with an implied decimal fraction for currencies like `TND` that are subdivided into thousandths. For the implied number of decimal places for a currency code, see [ISO-4217 Currency Codes](https://en.wikipedia.org/wiki/ISO_4217)." + } + } + }, + "InventoryType": { + "title": "Inventory Type", + "description": "The inventory source for a domain name. REGISTRY \u2014 standard registry price inventory. REGISTRY_PREMIUM \u2014 registry premium tier pricing. PREMIUM \u2014 third-party premium domain marketplace.\n", + "type": "string", + "enum": [ + "REGISTRY", + "REGISTRY_PREMIUM", + "PREMIUM" + ] + }, + "error-details": { + "title": "Error Details", + "type": "object", + "description": "The error details. Required for client-side `4XX` errors.", + "properties": { + "field": { + "type": "string", + "description": "The field that caused the error. If the field is in the body, set this value to the JSON pointer to that field. Required for client-side errors. When the offending value was resolved on the caller's behalf and has no request location, this is a source-scoped reference instead of a JSON pointer: \"shopper:\" (from the account identity) or \"profile:\" (from a saved registration profile)." + }, + "value": { + "type": "string", + "description": "The value of the field that caused the error." + }, + "location": { + "type": "string", + "description": "The location of the field that caused the error. Value is `body`, `path`, or `query`.", + "default": "body" + }, + "issue": { + "type": "string", + "description": "The unique fine-grained application-level error code." + }, + "description": { + "type": "string", + "description": "The human-readable description for an issue. The description MAY change over the lifetime of an API, so clients MUST NOT depend on this value." + } + } + }, + "link-description": { + "title": "Link Description", + "type": "object", + "description": "A request-related [HATEOAS link](https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-hyperschema-02).", + "properties": { + "href": { + "description": "The complete target URL, or link, to use in combination with the method to make the related call, as defined by [RFC 6570 - URI Template](https://tools.ietf.org/html/rfc6570), with the addition of the `$`, `(`, and `)` characters for pre-processing. The `href` is the key HATEOAS component that links a completed call with a subsequent call.", + "type": "string", + "format": "uri" + }, + "rel": { + "description": "The [link relation type](https://tools.ietf.org/html/rfc5988#section-4), which is an identifier for a link that unambiguously describes the semantics of the link. For values, see [Link Relationship Types](https://www.iana.org/assignments/link-relations/link-relations.xhtml).", + "type": "string" + }, + "title": { + "description": "The link title.", + "type": "string" + }, + "targetMediaType": { + "description": "The [RFC 2046-defined media type](https://www.ietf.org/rfc/rfc2046.txt) that describes the link target.", + "type": "string" + }, + "targetSchema": { + "description": "The schema that describes the link target." + }, + "method": { + "description": "The method to use to request the link target. For example, for HTTP, this might be `GET` or `DELETE`.", + "type": "string" + }, + "submissionMediaType": { + "description": "The media type with which to submit data with the request.", + "type": "string", + "default": "application/json" + }, + "submissionSchema": { + "description": "The schema that describes the request data." + } + } + }, + "error": { + "type": "object", + "title": "Error", + "description": "The error information.", + "properties": { + "name": { + "type": "string", + "description": "The human-readable, unique name of the error." + }, + "correlationId": { + "type": "string", + "description": "Internal identifier used for correlation purposes." + }, + "message": { + "type": "string", + "description": "The message that describes the error." + }, + "informationLink": { + "type": "string", + "description": "The URI for detailed information related to this error for the developer." + }, + "details": { + "type": "array", + "description": "An array of additional details about the error. Required for client-side `4XX` errors.", + "additionalItems": false, + "items": { + "$ref": "#/components/schemas/error-details" }, - "DomainSuggestion": { - "properties": { - "domain": { - "description": "Suggested domain name", - "type": "string" - } - }, - "required": [ - "domain" - ] + "nullable": true + }, + "links": { + "type": "array", + "description": "An array of error-related HATEOAS links.", + "readOnly": true, + "items": { + "$ref": "#/components/schemas/link-description", + "readOnly": true }, - "DomainSummary": { - "properties": { - "authCode": { - "description": "Authorization code for transferring the Domain", - "type": "string" - }, - "contactAdmin": { - "$ref": "#/components/schemas/Contact" - }, - "contactBilling": { - "$ref": "#/components/schemas/Contact" - }, - "contactRegistrant": { - "$ref": "#/components/schemas/Contact" - }, - "contactTech": { - "$ref": "#/components/schemas/Contact" - }, - "createdAt": { - "description": "Date and time when this domain was created", - "type": "string" - }, - "deletedAt": { - "description": "Date and time when this domain was deleted", - "type": "string" - }, - "transferAwayEligibleAt": { - "description": "Date and time when this domain is eligible to transfer", - "type": "string" - }, - "domain": { - "description": "Name of the domain", - "type": "string" - }, - "domainId": { - "description": "Unique identifier for this Domain", - "format": "double", - "type": "number" - }, - "expirationProtected": { - "description": "Whether or not the domain is protected from expiration", - "type": "boolean" - }, - "expires": { - "description": "Date and time when this domain will expire", - "type": "string" - }, - "exposeWhois": { - "description": "Whether or not the domain contact details should be shown in the WHOIS", - "type": "boolean" - }, - "holdRegistrar": { - "description": "Whether or not the domain is on-hold by the registrar", - "type": "boolean" - }, - "locked": { - "description": "Whether or not the domain is locked to prevent transfers", - "type": "boolean" - }, - "nameServers": { - "description": "Fully-qualified domain names for DNS servers", - "items": { - "format": "host-name", - "type": "string" - }, - "type": "array", - "nullable": true - }, - "privacy": { - "description": "Whether or not the domain has privacy protection", - "type": "boolean" - }, - "registrarCreatedAt": { - "type": "string", - "format": "iso-datetime", - "description": "Date and time when this domain was created by the registrar" - }, - "renewAuto": { - "description": "Whether or not the domain is configured to automatically renew", - "type": "boolean" - }, - "renewDeadline": { - "description": "Date the domain must renew on", - "type": "string" - }, - "renewable": { - "description": "Whether or not the domain is eligble for renewal based on status", - "type": "boolean" - }, - "status": { - "description": "Processing status of the domain
    \n
  • ACTIVE - All is well
  • \n
  • AWAITING* - System is waiting for the end-user to complete an action
  • \n
  • CANCELLED* - Domain has been cancelled, and may or may not be reclaimable
  • \n
  • CONFISCATED - Domain has been confiscated, usually for abuse, chargeback, or fraud
  • \n
  • DISABLED* - Domain has been disabled
  • \n
  • EXCLUDED* - Domain has been excluded from Firehose registration
  • \n
  • EXPIRED* - Domain has expired
  • \n
  • FAILED* - Domain has failed a required action, and the system is no longer retrying
  • \n
  • HELD* - Domain has been placed on hold, and likely requires intervention from Support
  • \n
  • LOCKED* - Domain has been locked, and likely requires intervention from Support
  • \n
  • PARKED* - Domain has been parked, and likely requires intervention from Support
  • \n
  • PENDING* - Domain is working its way through an automated workflow
  • \n
  • RESERVED* - Domain is reserved, and likely requires intervention from Support
  • \n
  • REVERTED - Domain has been reverted, and likely requires intervention from Support
  • \n
  • SUSPENDED* - Domain has been suspended, and likely requires intervention from Support
  • \n
  • TRANSFERRED* - Domain has been transferred out
  • \n
  • UNKNOWN - Domain is in an unknown state
  • \n
  • UNLOCKED* - Domain has been unlocked, and likely requires intervention from Support
  • \n
  • UNPARKED* - Domain has been unparked, and likely requires intervention from Support
  • \n
  • UPDATED* - Domain ownership has been transferred to another account
  • \n
", - "type": "string" - }, - "transferProtected": { - "description": "Whether or not the domain is protected from transfer", - "type": "boolean" - } - } + "nullable": true + } + } + }, + "OptimizationTarget": { + "title": "Optimization Target", + "description": "How an availability check should prioritize speed vs. authoritative accuracy. SPEED \u2014 use cached zone data for a fast response (may be slightly stale). ACCURACY \u2014 perform a live registry check for authoritative availability (higher latency).\n", + "type": "string", + "enum": [ + "SPEED", + "ACCURACY" + ] + }, + "Term": { + "title": "Term", + "description": "The unit of measure for a registration period. YEAR \u2014 registration period expressed in whole years.\n", + "type": "string", + "enum": [ + "YEAR" + ], + "default": "YEAR" + }, + "email-address": { + "description": "A valid, internationalized email address. Note: Up to 64 characters are allowed before and 255 characters are allowed after the @ sign. However, the generally accepted maximum length for an email address is 254 characters. The pattern verifies that an unquoted @ sign exists.", + "type": "string" + }, + "phone": { + "type": "object", + "title": "Phone", + "description": "The phone number, in its canonical international [E.164 numbering plan format](https://www.itu.int/rec/T-REC-E.164/en).", + "properties": { + "countryCode": { + "type": "string", + "description": "The country calling code (CC), in its canonical international [E.164 numbering plan format](https://www.itu.int/rec/T-REC-E.164/en). The combined length of the CC and the national number must not be greater than 15 digits. The national number consists of a national destination code (NDC) and subscriber number (SN)." + }, + "nationalNumber": { + "type": "string", + "description": "The national number, in its canonical international [E.164 numbering plan format](https://www.itu.int/rec/T-REC-E.164/en). The combined length of the country calling code (CC) and the national number must not be greater than 15 digits. The national number consists of a national destination code (NDC) and subscriber number (SN)." + }, + "extensionNumber": { + "type": "string", + "description": "The extension number." + } + } + }, + "country-code": { + "description": "A two-character ISO 3166-1 code that identifies the country or region.", + "type": "string" + }, + "simple-address": { + "type": "object", + "title": "Simple Postal Address (Coarse-Grained)", + "description": "Simple postal address with coarse-grained fields. Do not use for international postal addresses. Use for backward compatibility only. Address does not contain a phone number.", + "properties": { + "line1": { + "type": "string", + "description": "The first line of the address. For example, number or street." + }, + "line2": { + "type": "string", + "description": "The second line of the address. For example, suite or apartment number." + }, + "city": { + "type": "string", + "description": "The city name." + }, + "state": { + "type": "string", + "description": "The [code](https://about.usps.com/who/profile/history/state-abbreviations.htm) for a US state or the equivalent for other countries." + }, + "countryCode": { + "$ref": "#/components/schemas/country-code", + "description": "The [two-character ISO 3166-1 code](https://en.wikipedia.org/wiki/ISO_3166-1) that identifies the country or region. Note: The country code for Great Britain is `GB` and not `UK` as used in the top-level domain names for that country. Use country code `C2` for China for comparable uncontrolled price (CUP) method, bank-card, and cross-border transactions." + }, + "postalCode": { + "type": "string", + "description": "The postal code, which is the zip code or equivalent. Typically required for countries that have a postal code or an equivalent. See [Postal Code](https://en.wikipedia.org/wiki/Postal_code)." + } + }, + "required": [ + "line1", + "city", + "countryCode" + ] + }, + "Contact": { + "title": "Contact", + "description": "An ICANN-required contact record for a domain registration. Covers registrant, administrative, technical, and billing roles. Identity fields are validated at registration-profile save time.\n", + "x-sensitivity": "confidential", + "type": "object", + "required": [ + "firstName", + "lastName", + "email", + "phone", + "address" + ], + "properties": { + "firstName": { + "type": "string", + "description": "The contact's first (given) name.", + "x-sensitivity": "confidential", + "example": "Jane" + }, + "lastName": { + "type": "string", + "description": "The contact's last (family) name.", + "x-sensitivity": "confidential", + "example": "Smith" + }, + "organization": { + "type": "string", + "description": "Organization or company name. Required for contacts acting on behalf of a legal entity. Leave blank for individual registrants.\n", + "x-sensitivity": "confidential", + "example": "Example LLC" + }, + "email": { + "example": "foo@bar.com", + "description": "The contact's email address. Used for registry WHOIS and renewal notifications.\n", + "x-sensitivity": "confidential", + "allOf": [ + { + "$ref": "#/components/schemas/email-address" + } + ] + }, + "phone": { + "description": "The contact's phone number in ITU E.164 format with GoDaddy extension notation: +{country-code}.{local-number}, e.g. +1.4805551234. Required by ICANN for all contact roles.\n", + "x-sensitivity": "confidential", + "allOf": [ + { + "$ref": "#/components/schemas/phone" + } + ] + }, + "address": { + "description": "The contact's mailing address for WHOIS and ICANN records.\n", + "x-sensitivity": "confidential", + "allOf": [ + { + "$ref": "#/components/schemas/simple-address" + } + ] + } + } + }, + "Contacts": { + "title": "Contacts", + "description": "The set of ICANN-required contact roles for a domain registration. Registrant is required; admin, tech, and billing cascade from the registrant when omitted. Merge rule across resolution layers (saved profile, inline registration profile): identity fields replace as a whole block per role.\n", + "x-sensitivity": "confidential", + "type": "object", + "required": [ + "registrant" + ], + "properties": { + "registrant": { + "description": "The legal owner of the domain. Required. The registrant's identity is the authoritative WHOIS record and is bound by ICANN registration agreements.\n", + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + } + ] + }, + "admin": { + "description": "The administrative contact, responsible for managing the domain on behalf of the registrant. Cascades from registrant when omitted.\n", + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + } + ] + }, + "tech": { + "description": "The technical contact, responsible for DNS and nameserver configuration. Cascades from registrant when omitted.\n", + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + } + ] + }, + "billing": { + "description": "The billing contact, receives invoices and renewal notices. Cascades from registrant when omitted.\n", + "allOf": [ + { + "$ref": "#/components/schemas/Contact" + } + ] + } + } + }, + "date-time": { + "description": "A date and time, in [Internet date and time format](https://tools.ietf.org/html/rfc3339#section-5.6). Note: The regular expression provides static schematic guidance but does not reject all invalid dates.", + "type": "string" + }, + "ContactSource": { + "title": "Contact Source", + "description": "Where the resolved registrant contact came from. INLINE \u2014 contact was supplied via an inline registration profile on the request. PROFILE \u2014 contact was resolved from a named or default saved profile. ACCOUNT \u2014 contact was derived from the authenticated principal's account identity (no profile supplied or on file).\n", + "type": "string", + "enum": [ + "INLINE", + "PROFILE", + "ACCOUNT" + ] + }, + "AgreementType": { + "title": "Agreement Type", + "description": "The type of legal agreement that must be accepted prior to executing a domain operation. Additional agreement types may be returned for specific TLDs or product combinations. DNRA \u2014 Domain Name Registration Agreement. DNTA \u2014 Domain Name Transfer Agreement. DNPA \u2014 Domain Name Privacy Agreement. HTTPS_NOTICE \u2014 HTTPS notice acknowledgment for eligible TLDs. AURA \u2014 AU Domain Agreement for .au TLD registrations and transfers. CIRA \u2014 Canadian Internet Registration Authority Agreement for .ca TLD registrations and transfers.\n", + "type": "string" + }, + "ConsentActorType": { + "title": "Consent Actor Type", + "description": "Who transmitted consent on behalf of the principal. DIRECT \u2014 the principal acted directly; actor is omitted. AGENT \u2014 an AI or automation acted on behalf of the principal. RESELLER \u2014 a reseller acted on behalf of a shopper.\n", + "type": "string" + }, + "DomainStatus": { + "title": "Domain Status", + "description": "The lifecycle status of a registered domain. Reflects the domain's current operational state within the registry and GoDaddy's management layer. ACTIVE \u2014 domain is registered and is active. CANCELLED \u2014 domain has been cancelled by the user or system, and is not reclaimable. DELETED_REDEEMABLE \u2014 domain is in ICANN redemption grace period; recovery fees apply. EXPIRED \u2014 registration period has ended; domain is pending deletion or redemption. FAILED - domain registration or transfer error. HELD_REGISTRAR - domain is held at the registrar and cannot be transferred or modified - this is usually the result of a dispute. LOCKED_REGISTRAR \u2014 domain is locked at the registrar - this is usually the result of spam, abuse, etc. OWNERSHIP_CHANGED - domain has been moved to another account. PARKED - domain has been parked. PENDING_REGISTRATION - domain is pending setup at the registry. PENDING_TRANSFER \u2014 an outbound transfer to another registrar is in progress. REPOSSESSED - domain has been confiscated - this is usually the result of a chargeback, fraud, abuse, etc. SUSPENDED \u2014 domain has been administratively suspended by the registry or registrar. TRANSFERRED - domain has been transferred to another registrar.\n", + "type": "string", + "enum": [ + "ACTIVE", + "CANCELLED", + "DELETED_REDEEMABLE", + "EXPIRED", + "FAILED", + "HELD_REGISTRAR", + "LOCKED_REGISTRAR", + "OWNERSHIP_CHANGED", + "PARKED", + "PENDING_REGISTRATION", + "PENDING_TRANSFER", + "REPOSSESSED", + "SUSPENDED", + "TRANSFERRED" + ] + }, + "DomainOperationStatus": { + "title": "Domain Operation Status", + "description": "The execution state of an asynchronous domain operation. CONFIRMED \u2014 operation has been accepted and is queued for execution. EXECUTING \u2014 operation is actively being processed by the registry or downstream systems. COMPLETED \u2014 operation finished successfully; result data is available. FAILED \u2014 operation terminated with an unrecoverable error; error detail is attached.\n", + "type": "string" + }, + "DomainOperationType": { + "title": "Domain Operation Type", + "description": "The type of asynchronous domain operation. Used to distinguish which workflow is being polled on the /operations/{operationId} endpoint. REGISTER \u2014 new domain registration.\n", + "type": "string" + }, + "DnsRecordType": { + "title": "DNS Record Type", + "description": "The type of a DNS resource record. Values correspond to IANA-assigned DNS record type mnemonics. A \u2014 IPv4 address record. AAAA \u2014 IPv6 address record. CAA \u2014 certification authority authorization record. CNAME \u2014 canonical name alias record. MX \u2014 mail exchange record; data is the mail exchange hostname, priority is the sibling priority field. NS \u2014 nameserver delegation record; read-only. SOA \u2014 start of authority record; managed by the registry; read-only. SRV \u2014 service locator record. TXT \u2014 free-form text; used for SPF, DKIM, DMARC, and domain verification.\n", + "type": "string" + }, + "V1Address": { + "properties": { + "address1": { + "format": "street-address", + "type": "string" + }, + "address2": { + "format": "street-address2", + "type": "string" + }, + "city": { + "format": "city-name", + "type": "string" + }, + "country": { + "default": "US", + "description": "Two-letter ISO country code to be used as a hint for target region

\nNOTE: These are sample values, there are many\nmore", + "enum": [ + "AC", + "AD", + "AE", + "AF", + "AG", + "AI", + "AL", + "AM", + "AO", + "AQ", + "AR", + "AS", + "AT", + "AU", + "AW", + "AX", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BM", + "BN", + "BO", + "BQ", + "BR", + "BS", + "BT", + "BV", + "BW", + "BY", + "BZ", + "CA", + "CC", + "CD", + "CF", + "CG", + "CH", + "CI", + "CK", + "CL", + "CM", + "CN", + "CO", + "CR", + "CV", + "CW", + "CX", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "EH", + "ER", + "ES", + "ET", + "FI", + "FJ", + "FK", + "FM", + "FO", + "FR", + "GA", + "GB", + "GD", + "GE", + "GF", + "GG", + "GH", + "GI", + "GL", + "GM", + "GN", + "GP", + "GQ", + "GR", + "GS", + "GT", + "GU", + "GW", + "GY", + "HK", + "HM", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IM", + "IN", + "IO", + "IQ", + "IS", + "IT", + "JE", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KR", + "KV", + "KW", + "KY", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MG", + "MH", + "MK", + "ML", + "MM", + "MN", + "MO", + "MP", + "MQ", + "MR", + "MS", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NC", + "NE", + "NF", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NU", + "NZ", + "OM", + "PA", + "PE", + "PF", + "PG", + "PH", + "PK", + "PL", + "PM", + "PN", + "PR", + "PS", + "PT", + "PW", + "PY", + "QA", + "RE", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SE", + "SG", + "SH", + "SI", + "SJ", + "SK", + "SL", + "SM", + "SN", + "SO", + "SR", + "ST", + "SV", + "SX", + "SZ", + "TC", + "TD", + "TF", + "TG", + "TH", + "TJ", + "TK", + "TL", + "TM", + "TN", + "TO", + "TP", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "UM", + "US", + "UY", + "UZ", + "VA", + "VC", + "VE", + "VG", + "VI", + "VN", + "VU", + "WF", + "WS", + "YE", + "YT", + "ZA", + "ZM", + "ZW" + ], + "format": "iso-country-code", + "type": "string" + }, + "postalCode": { + "description": "Postal or zip code", + "format": "postal-code", + "type": "string" + }, + "state": { + "description": "State or province or territory", + "format": "state-province-territory", + "type": "string" + } + } + }, + "V1ArrayOfDNSRecord": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V1DNSRecord" + } + }, + "V1Contact": { + "properties": { + "addressMailing": { + "$ref": "#/components/schemas/V1Address" + }, + "email": { + "format": "email", + "type": "string" + }, + "fax": { + "format": "phone", + "type": "string" + }, + "jobTitle": { + "type": "string" + }, + "nameFirst": { + "format": "person-name", + "type": "string" + }, + "nameLast": { + "format": "person-name", + "type": "string" + }, + "nameMiddle": { + "type": "string" + }, + "organization": { + "format": "organization-name", + "type": "string" + }, + "phone": { + "format": "phone", + "type": "string" + } + } + }, + "V1DNSRecord": { + "properties": { + "data": { + "type": "string" + }, + "name": { + "format": "domain", + "type": "string" + }, + "port": { + "description": "Service port (SRV only)", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "priority": { + "description": "Record priority (MX and SRV only)", + "format": "integer-positive", + "type": "integer" + }, + "protocol": { + "description": "Service protocol (SRV only)", + "type": "string" + }, + "service": { + "description": "Service type (SRV only)", + "type": "string" + }, + "ttl": { + "format": "integer-positive", + "type": "integer" + }, + "type": { + "enum": [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SOA", + "SRV", + "TXT" + ], + "type": "string" + }, + "weight": { + "description": "Record weight (SRV only)", + "format": "integer-positive", + "type": "integer" + } + } + }, + "V1DNSRecordCreateType": { + "properties": { + "data": { + "type": "string" + }, + "name": { + "format": "domain", + "type": "string" + }, + "port": { + "description": "Service port (SRV only)", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "priority": { + "description": "Record priority (MX and SRV only)", + "format": "integer-positive", + "type": "integer" + }, + "protocol": { + "description": "Service protocol (SRV only)", + "type": "string" + }, + "service": { + "description": "Service type (SRV only)", + "type": "string" + }, + "ttl": { + "format": "integer-positive", + "type": "integer" + }, + "weight": { + "description": "Record weight (SRV only)", + "format": "integer-positive", + "type": "integer" + } + } + }, + "V1DNSRecordCreateTypeName": { + "properties": { + "data": { + "type": "string" + }, + "port": { + "description": "Service port (SRV only)", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "priority": { + "description": "Record priority (MX and SRV only)", + "format": "integer-positive", + "type": "integer" + }, + "protocol": { + "description": "Service protocol (SRV only)", + "type": "string" + }, + "service": { + "description": "Service type (SRV only)", + "type": "string" + }, + "ttl": { + "format": "integer-positive", + "type": "integer" + }, + "weight": { + "description": "Record weight (SRV only)", + "format": "integer-positive", + "type": "integer" + } + }, + "required": [ + "data" + ] + }, + "V1DomainSummary": { + "properties": { + "authCode": { + "description": "Authorization code for transferring the Domain", + "type": "string" + }, + "contactAdmin": { + "$ref": "#/components/schemas/V1Contact" + }, + "contactBilling": { + "$ref": "#/components/schemas/V1Contact" + }, + "contactRegistrant": { + "$ref": "#/components/schemas/V1Contact" + }, + "contactTech": { + "$ref": "#/components/schemas/V1Contact" + }, + "createdAt": { + "description": "Date and time when this domain was created", + "type": "string" + }, + "deletedAt": { + "description": "Date and time when this domain was deleted", + "type": "string" + }, + "transferAwayEligibleAt": { + "description": "Date and time when this domain is eligible to transfer", + "type": "string" + }, + "domain": { + "description": "Name of the domain", + "type": "string" + }, + "domainId": { + "description": "Unique identifier for this Domain", + "format": "double", + "type": "number" + }, + "expirationProtected": { + "description": "Whether or not the domain is protected from expiration", + "type": "boolean" + }, + "expires": { + "description": "Date and time when this domain will expire", + "type": "string" + }, + "exposeWhois": { + "description": "Whether or not the domain contact details should be shown in the WHOIS", + "type": "boolean" + }, + "holdRegistrar": { + "description": "Whether or not the domain is on-hold by the registrar", + "type": "boolean" + }, + "locked": { + "description": "Whether or not the domain is locked to prevent transfers", + "type": "boolean" + }, + "nameServers": { + "description": "Fully-qualified domain names for DNS servers", + "items": { + "format": "host-name", + "type": "string" }, - "LegalAgreement": { - "properties": { - "agreementKey": { - "description": "Unique identifier for the legal agreement", - "type": "string" - }, - "content": { - "description": "Contents of the legal agreement, suitable for embedding", - "type": "string" - }, - "title": { - "description": "Title of the legal agreement", - "type": "string" - }, - "url": { - "description": "URL to a page containing the legal agreement", - "format": "url", - "type": "string" - } - } + "type": "array", + "nullable": true + }, + "privacy": { + "description": "Whether or not the domain has privacy protection", + "type": "boolean" + }, + "registrarCreatedAt": { + "type": "string", + "format": "iso-datetime", + "description": "Date and time when this domain was created by the registrar" + }, + "renewAuto": { + "description": "Whether or not the domain is configured to automatically renew", + "type": "boolean" + }, + "renewDeadline": { + "description": "Date the domain must renew on", + "type": "string" + }, + "renewable": { + "description": "Whether or not the domain is eligble for renewal based on status", + "type": "boolean" + }, + "status": { + "description": "Processing status of the domain
    \n
  • ACTIVE - All is well
  • \n
  • AWAITING* - System is waiting for the end-user to complete an action
  • \n
  • CANCELLED* - Domain has been cancelled, and may or may not be reclaimable
  • \n
  • CONFISCATED - Domain has been confiscated, usually for abuse, chargeback, or fraud
  • \n
  • DISABLED* - Domain has been disabled
  • \n
  • EXCLUDED* - Domain has been excluded from Firehose registration
  • \n
  • EXPIRED* - Domain has expired
  • \n
  • FAILED* - Domain has failed a required action, and the system is no longer retrying
  • \n
  • HELD* - Domain has been placed on hold, and likely requires intervention from Support
  • \n
  • LOCKED* - Domain has been locked, and likely requires intervention from Support
  • \n
  • PARKED* - Domain has been parked, and likely requires intervention from Support
  • \n
  • PENDING* - Domain is working its way through an automated workflow
  • \n
  • RESERVED* - Domain is reserved, and likely requires intervention from Support
  • \n
  • REVERTED - Domain has been reverted, and likely requires intervention from Support
  • \n
  • SUSPENDED* - Domain has been suspended, and likely requires intervention from Support
  • \n
  • TRANSFERRED* - Domain has been transferred out
  • \n
  • UNKNOWN - Domain is in an unknown state
  • \n
  • UNLOCKED* - Domain has been unlocked, and likely requires intervention from Support
  • \n
  • UNPARKED* - Domain has been unparked, and likely requires intervention from Support
  • \n
  • UPDATED* - Domain ownership has been transferred to another account
  • \n
", + "type": "string" + }, + "transferProtected": { + "description": "Whether or not the domain is protected from transfer", + "type": "boolean" + } + } + }, + "V1LegalAgreement": { + "properties": { + "agreementKey": { + "description": "Unique identifier for the legal agreement", + "type": "string" + }, + "content": { + "description": "Contents of the legal agreement, suitable for embedding", + "type": "string" + }, + "title": { + "description": "Title of the legal agreement", + "type": "string" + }, + "url": { + "description": "URL to a page containing the legal agreement", + "format": "url", + "type": "string" + } + } + } + }, + "securitySchemes": { + "oauth2": { + "type": "oauth2", + "description": "GoDaddy OAuth 2.0 access token. The scope(s) listed on each operation are enforced per-operation. A token requires at least one of the scopes listed for that operation.\n", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://api.godaddy.com/v2/oauth2/authorize", + "tokenUrl": "https://api.godaddy.com/v2/oauth2/token", + "scopes": { + "domains.domain:read": "Read domain records, availability, suggestions, quotes, and operations.\n", + "domains.domain:create": "Register domains. Requires a prior quoteToken.\n", + "domains.nameserver:update": "Replace authoritative nameservers for a domain.\n", + "domains.dns:update": "Create, update, and delete DNS zone records.\n" } + } } + } } + } } \ No newline at end of file diff --git a/rust/domains-client/openapi/swagger_domains.v3.yaml b/rust/domains-client/openapi/swagger_domains.v3.yaml new file mode 100644 index 0000000..f4b5614 --- /dev/null +++ b/rust/domains-client/openapi/swagger_domains.v3.yaml @@ -0,0 +1,2572 @@ +openapi: 3.0.3 +info: + title: Domain Lifecycle Management API + version: 3.0.0 + x-visibility: public + description: 'The GoDaddy Domain Lifecycle Management API provides comprehensive capabilities + + for discovering, registering, managing, renewing, transferring, and reselling + + domain names. This is major version 3, designed for agent-first interactions + + while remaining fully usable by direct API clients and resellers. + + + ## Namespace + + + All paths are under `/v3/domains/`. The namespace is `domains` (the business capability); + + the core entity collection is `/domain-names`. + + + ## Key Conventions + + + **Quote/execute for commercial operations.** Every commercial mutation (register, + + renew, transfer) requires a `quoteToken` minted by the corresponding quote + + collection endpoint. Execution without a prior quote is structurally impossible. + + The token locks the price, resolved settings, and required legal agreements for a + + 10-minute TTL. Quote calls are free, read-only, and safe to call speculatively. + + + **Async commercial operations.** `POST /registrations`, `POST /renewals`, and + + `POST /transfers` each return `202 Accepted` with the concrete entity body + + (`Registration`, `Renewal`, or `Transfer`) and a `Location` header. Poll + + `links[rel=self]` on the returned entity until status is `COMPLETED` or `FAILED`. + + Each concrete resource is also reachable via `GET /operations/{operationId}`; + + the `operationId` is included in the entity for clients that prefer the abstract + + view. Non-commercial mutations return a `DomainOperation` body. + + + **Entity-oriented resource model.** Domains are the core entity of this API + + (exposed as `/domain-names` in the path to distinguish the resource collection + + from the `domains` namespace prefix). Register, renew, and transfer are + + commercial actions executed by `POST` to their corresponding top-level + + resource collections (`/registrations`, `/renewals`, `/transfers`); each + + accepts a prior quote and returns an async entity to poll until completion. + + For commercial execute calls, the target domain is + + expressed in the request body, not the path. Sub-resources (`contacts`, + + `nameservers`, `privacy`, `auto-renew`, `transfer-lock`, `records`) only exist + + in the context of a specific domain-name instance. The `/check-availability` + + controller accepts GET (single domain) or POST (1–50 domains) and carries no + + persistent identity. + + + **Flat, two-level maximum.** No resource path goes deeper than + + `/{collection}/{id}/{sub-resource}` or `/{collection}/{id}/{sub-collection}/{id}`. + + + **Reseller on-behalf-of.** Resellers pass `X-Shopper-Id`; all operations are + + then scoped to that shopper. Absent the header, the authenticated entity''s own + + account is used. + + + ## Launch Scope (v3.0) + + Standard TLDs only. TLDs with eligibility requirements (.us, .ca, .eu) return + + `UNSUPPORTED_TLD` until Phase 2. + + ' + contact: + name: GoDaddy Domains Platform + x-slack-channel: '#domains_beplat_eng' + x-visibility: private +servers: +- url: https://api.{environment-subdomain}.com/v3/domains + description: Domain Lifecycle Management API v3 + variables: + environment-subdomain: + default: godaddy + description: Environment subdomain + enum: + - godaddy + - ote-godaddy + - test-godaddy + - dev-godaddy +security: +- oauth2: [] +tags: +- name: Discovery + description: 'Indicative, non-committing operations for finding and checking domains. Use suggestDomains for natural-language + queries. For a single known domain, use getDomainAvailability (GET /check-availability); for 1–50 domains in one call, + use checkAvailability (POST /check-availability). Both availability operations share the same check semantics and Availability + result model; locked pricing is established only at quote time. Neither carries a persistent check identity. + + ' +- name: Registration Quotes + description: 'Quote a domain registration. Returns a locked price, resolved settings, required agreements, and a single-use + quoteToken. Free and read-only. + + ' +- name: Registrations + description: 'Top-level registration entity collection. Execute a domain registration by POSTing with a quoteToken, domain, + period, and consent. Returns a Registration entity with links to the concrete poll URL (GET /registrations/{registrationId}) + and the abstract operation (GET /operations/{operationId}). + + ' +- name: Domains + description: 'The core domain entity collection. Supports listing and reading owned domain records, and cancelling registrations. + + ' +- name: Domain Management + description: 'Non-commercial async mutations on owned domain instances: contacts, nameservers, privacy, auto-renew, and + transfer-lock. All sub-resources of /domain-names/{domain-name}. All mutations return a DomainOperation for polling. + + ' +- name: Records + description: 'CRUD operations on DNS records within the GoDaddy-managed zone. Sub-collection of /zones/{zone}. Changes are + applied synchronously. + + ' +- name: Operations + description: 'Abstract operation polling. Poll GET /operations/{operationId} for any domain mutation until it reaches COMPLETED + or FAILED. Operation IDs are unique across Registration, Renewal, and Transfer — clients that prefer typed polling can + use the concrete resource endpoints instead. + + ' +- name: Domains + description: 'Core domain entity collection. Retrieve registered domains owned by the authenticated account with management + details including status, nameservers, privacy, and auto-renew settings. + + ' +paths: + x-visibility: public + /suggestions: + get: + operationId: suggestDomains + tags: + - Discovery + summary: Suggest available domains for a query + description: 'Returns available domain name suggestions for a natural-language query + + or keyword set. All results are available (available-only contract). + + Prices are indicative; the authoritative price and availability check + + is at quote time. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + - name: query + in: query + required: false + description: 'Natural-language query or keywords describing the desired domain, e.g. "sunrise bakery". Used to generate + creative and keyword-spin suggestions. + + ' + schema: + type: string + maxLength: 100 + example: sunrise bakery + - name: tlds + in: query + required: false + description: Top-level domains to be included in suggestions. + style: form + explode: false + schema: + type: array + items: + type: string + example: + - com + - net + - shop + - name: lengthMax + in: query + required: false + description: Maximum length of second-level domain. + schema: + type: integer + minimum: 1 + - name: lengthMin + in: query + required: false + description: Minimum length of second-level domain. + schema: + type: integer + minimum: 1 + - name: pageSize + in: query + required: false + description: 'Maximum number of suggestions in the response. Defaults to 10 when omitted. + + ' + schema: + type: integer + minimum: 1 + maximum: 50 + default: 10 + - name: sources + in: query + required: false + description: 'Suggestion source strategies to activate. + + ' + style: form + explode: false + schema: + type: array + items: + $ref: '#/components/schemas/SuggestionSource' + example: + - EXTENSION + - KEYWORD_SPIN + security: + - oauth2: + - domains.domain:read + responses: + '200': + description: Suggested available domains sorted by relevance. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + description: 'Available domain suggestions, sorted by relevance. All items are available by contract. + + ' + items: + $ref: '#/components/schemas/Suggestion' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '429': + $ref: '#/components/responses/429' + /check-availability: + get: + operationId: getDomainAvailability + tags: + - Discovery + summary: Check availability of a single domain + description: 'Returns an indicative availability result for one domain, including + + per-term pricing when available. Availability is best-effort; the + + authoritative check is performed at quote time. This operation does + + not persist the check — there is no check identity or poll URL. + + + Equivalent to POST /check-availability with `domains: [domain]` and the + + same optional criteria (`optimizeFor`, `iscCode`). The response is that + + single `Availability` item unwrapped for convenience — POST returns the + + same result inside `{ items: [...] }`. Use POST when checking multiple + + domains in one request. + + + A domain that cannot be checked is still returned as a `200` with an + + `error` object on the body (the same per-item contract as POST); request- + + level failures use the `4xx` responses. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + - name: domain + in: query + required: true + description: The domain name to check, in punycode A-label form for IDNs. + schema: + type: string + example: example.com + - name: optimizeFor + in: query + required: false + description: 'Optional. When omitted, defaults to SPEED. Availability is always re-verified authoritatively at quote + time regardless of this setting. + + ' + schema: + allOf: + - $ref: '#/components/schemas/OptimizationTarget' + default: SPEED + example: SPEED + - name: iscCode + in: query + required: false + description: 'Reseller ISC (International Shopper Code) for pricing context. When provided, the indicative prices + reflect the applicable reseller rates for this ISC. + + ' + schema: + type: string + example: ISC_PARTNER_001 + security: + - oauth2: + - domains.domain:read + responses: + '200': + description: Availability result for the requested domain. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/Availability' + examples: + available: + summary: Available domain with per-term pricing + value: + domain: coffee24x7x365.com + available: true + definitive: false + inventory: REGISTRY + prices: + - term: YEAR + period: 1 + price: + currencyCode: USD + value: 1199 + renewalPrice: + currencyCode: USD + value: 2299 + - term: YEAR + period: 2 + price: + currencyCode: USD + value: 3098 + renewalPrice: + currencyCode: USD + value: 4598 + - term: YEAR + period: 3 + price: + currencyCode: USD + value: 4599 + renewalPrice: + currencyCode: USD + value: 6897 + - term: YEAR + period: 5 + price: + currencyCode: USD + value: 9197 + renewalPrice: + currencyCode: USD + value: 11495 + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '422': + $ref: '#/components/responses/422' + '429': + $ref: '#/components/responses/429' + post: + operationId: checkAvailability + x-visibility: private + tags: + - Discovery + summary: Check availability of one or more specific domains + description: 'Batch controller for domain availability checking. Accepts 1–50 domain + + names alongside optional check criteria (optimization mode, ISC pricing + + code). Returns one Availability result per requested domain in input + + order inside `{ items: [...] }`. Domains that cannot be checked carry + + an `error` object on that item. + + + For a single domain, GET /check-availability (getDomainAvailability) + + offers the same check semantics and Availability result without a + + request body; the response is the lone item unwrapped. + + + Availability is best-effort indicative; the authoritative check is + + always performed at quote time. This controller does not persist the + + check — there is no check identity or poll URL. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AvailabilityCheckCriteria' + examples: + defaultBehavior: + summary: Omit optimizeFor — defaults to SPEED + value: + domains: + - example.com + - example.net + speedCheck: + summary: Check two domains using cached data + value: + domains: + - example.com + - example.net + optimizeFor: SPEED + accuracyCheck: + summary: Live registry check with ISC pricing context + value: + domains: + - example.com + optimizeFor: ACCURACY + iscCode: ISC_PARTNER_001 + security: + - oauth2: + - domains.domain:read + responses: + '200': + description: 'Per-domain availability results in request order. Uncheckable items carry an error object. + + ' + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + description: 'Availability results in the same order as the domains array in the request body. + + ' + items: + $ref: '#/components/schemas/Availability' + examples: + mixedResults: + summary: One available domain and one uncheckable domain + value: + items: + - domain: example.com + available: true + definitive: false + inventory: REGISTRY + prices: + - term: YEAR + period: 1 + price: + currencyCode: USD + value: 1999 + renewalPrice: + currencyCode: USD + value: 1999 + - domain: invalid..com + error: + name: MISMATCH_FORMAT + correlationId: 9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c + message: 'does not conform to the ''domain'' format, based on pattern: /^[^.\s]{1,63}(\.[^.\s]{1,63}){1,2}$/' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '422': + $ref: '#/components/responses/422' + '429': + $ref: '#/components/responses/429' + /registration-quotes: + post: + operationId: quoteDomainRegistration + tags: + - Registration Quotes + summary: Quote a single-domain registration (no commitment) + description: 'Prices the registration, resolves contact and preference settings, + + returns required legal agreements, and mints a single-use quoteToken + + with a 10-minute TTL. Free and read-only; safe to call speculatively. + + + When the domain is unavailable, `available: false` is returned with + + no quoteToken — this is a valid non-error response. + + + When required contact fields are missing, a `422` is returned with + + field-level details so the agent can collect the missing data and re-quote. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - domain + properties: + domain: + type: string + description: The domain name to quote, in punycode A-label form. + example: example.com + period: + type: integer + minimum: 1 + maximum: 10 + default: 1 + description: Registration period in years. + profileId: + description: 'ID of a saved registration profile. Omit to fall back to the account-default profile or account + identity. + + ' + allOf: + - $ref: '#/components/schemas/uuid' + profile: + description: 'One-time inline contacts and purchase preference defaults for this quote. Not persisted. + + ' + allOf: + - $ref: '#/components/schemas/InlineRegistrationProfile' + examples: + minimal: + summary: Minimal — derive registrant from account identity + value: + domain: example.com + withProfile: + summary: With saved profile ID + value: + domain: example.com + period: 2 + profileId: 14514a29-5fce-4624-8d8a-d8abd56015e2 + security: + - oauth2: + - domains.domain:read + responses: + '200': + description: 'Registration quote. When available is false, no quoteToken is returned; this is not an error. + + ' + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrationQuote' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '422': + $ref: '#/components/responses/422' + '429': + $ref: '#/components/responses/429' + /registrations: + post: + operationId: registerDomain + tags: + - Registrations + summary: Register a domain (requires quoteToken) + description: 'Executes a previously quoted domain registration. **Irreversible once + + accepted; creates a charge.** Requires a valid unexpired quoteToken from + + `quoteDomainRegistration`, an `Idempotency-Key` header, and a consent + + record. The target domain and period are in the request body alongside + + the quoteToken. + + + Idempotency takes precedence over the single-use check: retrying with + + the same `Idempotency-Key` replays the original operation even after + + the token is consumed. + + + Returns a `Registration` entity. Poll `links[rel=self]` + + (`GET /registrations/{registrationId}`) until status is `COMPLETED` or + + `FAILED`. The `operationId` field is also provided for clients that + + prefer `GET /operations/{operationId}`; both resolve the same resource. + + + Poll either until status is `COMPLETED` or `FAILED`. The operation is + + fire-and-forget; always poll at least once even if the server completed + + it synchronously. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + - $ref: '#/components/parameters/idempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Registration' + examples: + withToken: + summary: Execute with quoteToken and direct consent + value: + domain: example.com + period: 1 + quoteToken: 7f3a2b1c-9d8e-4012-a5b6-c1d2e3f4a5b6 + consent: + agreementTypes: + - DNRA + agreedAt: '2026-06-12T10:02:00Z' + agreedBy: + type: DIRECT + principal: shopper_123 + ip: 203.0.113.7 + security: + - oauth2: + - domains.domain:create + responses: + '202': + description: 'Registration accepted. Poll the self link for status. + + ' + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + Location: + $ref: '#/components/headers/location' + Retry-After: + $ref: '#/components/headers/retryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/Registration' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '409': + $ref: '#/components/responses/409' + '422': + $ref: '#/components/responses/422' + '429': + $ref: '#/components/responses/429' + /registrations/{registrationId}: + get: + operationId: getRegistration + tags: + - Registrations + summary: Get a registration record + description: 'Returns a single registration record by its server-assigned registrationId, including the current execution + status and the domain expiry date once the registration completes. This is the concrete poll endpoint for registration + operations; the abstract equivalent is GET /operations/{operationId}. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + - $ref: '#/components/parameters/registrationId' + security: + - oauth2: + - domains.domain:read + responses: + '200': + description: Registration record returned. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + Retry-After: + $ref: '#/components/headers/retryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/Registration' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '429': + $ref: '#/components/responses/429' + /domain-names/{domain-name}: + get: + operationId: getDomain + tags: + - Domains + summary: Get a registered domain + description: 'Returns the management view of a single registered domain owned by the authenticated account, including + status, nameservers, privacy and auto-renew preferences, and expiry date. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + - $ref: '#/components/parameters/domainNamePath' + security: + - oauth2: + - domains.domain:read + responses: + '200': + description: Domain found. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/Domain' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '429': + $ref: '#/components/responses/429' + /domain-names/{domain-name}/nameservers: + put: + operationId: updateNameservers + tags: + - Domain Management + summary: Replace the nameservers for a domain + description: 'Replaces the authoritative nameservers for the domain with the provided list. Minimum 2, maximum 13. Returns + a DomainOperation; propagation to the registry is asynchronous. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + - $ref: '#/components/parameters/domainNamePath' + - $ref: '#/components/parameters/idempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NameServers' + examples: + nameservers: + value: + - ns1.example.com + - ns2.example.com + security: + - oauth2: + - domains.nameserver:update + responses: + '202': + description: Nameserver update accepted; poll the operation for completion. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + Location: + $ref: '#/components/headers/location' + Retry-After: + $ref: '#/components/headers/retryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/DomainOperation' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '422': + $ref: '#/components/responses/422' + '429': + $ref: '#/components/responses/429' + /zones/{zone}/dns-records: + post: + operationId: createDNSRecord + tags: + - Records + summary: Create a DNS record for a zone + description: 'Creates a new DNS record in the GoDaddy-managed zone. Changes are applied synchronously; no operation + polling required. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + - $ref: '#/components/parameters/zonePath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DNSRecord' + examples: + aRecord: + summary: Create an A record + value: + name: '@' + type: A + data: 192.0.2.1 + ttl: 3600 + mxRecord: + summary: Create an MX record + value: + name: '@' + type: MX + data: mail.example.com. + ttl: 3600 + priority: 10 + security: + - oauth2: + - domains.dns:update + responses: + '201': + description: DNS record created. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + Location: + $ref: '#/components/headers/location' + content: + application/json: + schema: + $ref: '#/components/schemas/DNSRecord' + '400': + $ref: '#/components/responses/400' + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '409': + $ref: '#/components/responses/409' + '422': + $ref: '#/components/responses/422' + '429': + $ref: '#/components/responses/429' + /operations/{operationId}: + get: + operationId: getOperation + tags: + - Operations + summary: Poll an async domain operation + description: 'Universal poll endpoint for all asynchronous domain mutations. Returns + + the current state of the operation. Non-terminal responses include a + + `Retry-After` header. + + + Terminal statuses: + + - `COMPLETED` — operation succeeded; `result` contains the final outcome. + + - `FAILED` — operation terminated with an error; `error` contains detail. + + + While status is non-terminal (`CONFIRMED`, `EXECUTING`), neither + + `result` nor `error` is present. Poll until a terminal status is reached. + + + The poll URL is provided in the `Location` header of the initiating 202 + + response and in `links[rel=self]`. Clients must not construct this URL + + independently. + + ' + parameters: + - $ref: '#/components/parameters/xRequestId' + - $ref: '#/components/parameters/xShopperId' + - $ref: '#/components/parameters/operationId' + security: + - oauth2: + - domains.domain:read + responses: + '200': + description: Current operation state. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + Retry-After: + $ref: '#/components/headers/retryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/DomainOperation' + examples: + executing: + summary: Operation in progress + value: + operationId: 9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c + type: REGISTER + domain: example.com + status: EXECUTING + createdAt: '2026-06-12T10:02:05Z' + updatedAt: '2026-06-12T10:02:07Z' + links: + - rel: self + href: /v3/domains/operations/9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c + completed: + summary: Registration completed + value: + operationId: 9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c + type: REGISTER + domain: example.com + status: COMPLETED + result: + expiresAt: '2027-06-12T10:02:10Z' + orderId: ord_xyz789 + createdAt: '2026-06-12T10:02:05Z' + updatedAt: '2026-06-12T10:02:10Z' + links: + - rel: self + href: /v3/domains/operations/9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c + - rel: domain + href: /v3/domains/domain-names/example.com + '401': + $ref: '#/components/responses/401' + '403': + $ref: '#/components/responses/403' + '404': + $ref: '#/components/responses/404' + '429': + $ref: '#/components/responses/429' +components: + parameters: + xRequestId: + name: X-Request-Id + in: header + description: 'Optional client-generated request correlation identifier, propagated across services and returned in the + response X-Request-Id header. + + ' + required: false + schema: + type: string + xShopperId: + name: X-Shopper-Id + in: header + description: 'Reseller acting on behalf of a shopper account. When present, all domain operations are scoped to the + specified shopper. Absent, the authenticated entity''s own account is used. Only valid for reseller OAuth tokens. + + ' + required: false + schema: + type: string + example: shopper_123 + idempotencyKey: + name: Idempotency-Key + in: header + description: 'Client-generated unique key (UUID recommended). Retrying a mutating request with the same Idempotency-Key + returns the original response without creating a duplicate side effect. Required on all execute endpoints. + + ' + required: true + schema: + type: string + minLength: 16 + maxLength: 64 + example: 9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c + domainNamePath: + name: domain-name + in: path + description: 'The domain name in punycode A-label form (e.g., example.com). For IDNs, use the punycode representation. + + ' + required: true + schema: + type: string + example: example.com + registrationId: + name: registrationId + in: path + description: Server-assigned registration identifier. + required: true + schema: + $ref: '#/components/schemas/uuid' + operationId: + name: operationId + in: path + description: 'The server-assigned operation identifier returned in the 202 response of any async domain mutation. + + ' + required: true + schema: + $ref: '#/components/schemas/uuid' + example: 9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c + zonePath: + name: zone + in: path + description: 'The domain name in punycode A-label form (e.g., example.com). For IDNs, use the punycode representation. + + ' + required: true + schema: + type: string + example: example.com + headers: + xRequestId: + description: Request correlation identifier echoed from the request or server-generated. + schema: + $ref: '#/components/schemas/uuid' + location: + description: URL of the created or async resource. + schema: + type: string + format: uri + retryAfter: + description: 'Suggested number of seconds before the client should poll again. Present on 202 responses and non-terminal + operation poll responses. + + ' + schema: + type: integer + example: 5 + responses: + '400': + description: Malformed request syntax, missing required field, or invalid field type. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '401': + description: Authentication credentials are missing or invalid. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '403': + description: Authenticated identity is not authorized to perform this operation. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '404': + description: The requested resource was not found. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '409': + description: 'Conflict — the request cannot be completed in the current state. Used for quote lifecycle errors (quote_expired, + quote_mismatch, quote_consumed, consent_principal_mismatch) and domain state conflicts such as domain_already_exists. + + ' + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '422': + description: 'Semantically invalid request — valid structure but violates a business rule, such as an ineligible contact, + unsupported TLD, or non-renewable domain status. + + ' + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '429': + description: Too many requests — rate limit exceeded. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + content: + application/json: + schema: + $ref: '#/components/schemas/error' + '503': + description: Service temporarily unavailable. + headers: + X-Request-Id: + $ref: '#/components/headers/xRequestId' + Retry-After: + $ref: '#/components/headers/retryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/error' + schemas: + Agreement: + title: Agreement + description: 'A legal agreement that must be accepted prior to executing a domain operation. Agreements are returned + in the quote response and must be acknowledged in the execute request via the consent.agreementTypes array. + + ' + type: object + required: + - agreementType + - title + properties: + agreementType: + description: 'The type of legal agreement. Identifies which agreement text the customer must accept. + + ' + example: DNRA + allOf: + - $ref: '#/components/schemas/AgreementType' + title: + type: string + description: 'Human-readable title of the agreement, suitable for display to the customer. + + ' + example: Domain Name Registration Agreement + url: + type: string + format: uri + description: URL to the full legal text of this agreement. Present when available. + example: https://www.godaddy.com/agreements/showdoc?pageid=reg_sa + Availability: + title: Availability + description: 'The availability check result for a single requested domain. A checkable domain returns the available + flag plus optional pricing. A domain that could not be checked carries an error object and no availability fields. + Exactly one of available or error is present per item. + + Availability is best-effort indicative; the authoritative availability check is performed at quote time. definitive: + true means the result was confirmed directly with the registry rather than from a cached zone check. + + ' + type: object + required: + - domain + properties: + domain: + type: string + description: 'The domain name checked, normalized to punycode A-label form. + + ' + example: example.com + unicodeDomain: + type: string + description: 'The Unicode (U-label) form of the domain. Present only for IDN domains. + + ' + example: münchen.de + available: + type: boolean + description: 'Whether this domain appears to be available for registration. Best-effort; re-verified at quote time. + Present only when the domain was successfully checked (no error). + + ' + definitive: + type: boolean + description: 'When true, the availability result was confirmed directly with the registry (ACCURACY mode). When + false, the result is from a cached zone data check (SPEED mode) and may be stale. + + ' + inventory: + description: 'The inventory source for this domain. Present when available is true and pricing fields are returned. + + ' + example: REGISTRY + allOf: + - $ref: '#/components/schemas/InventoryType' + prices: + type: array + description: 'Indicative pricing offered per registration term length, in ascending order of period. Present when + available is true. Prices are best-effort indicative and are locked only at quote time. + + ' + items: + $ref: '#/components/schemas/TermPrice' + error: + description: 'Present when this domain could not be checked. One of available or error is present, never both. correlationId + is required and should echo the X-Request-Id header value for this request. + + Common name values (aligned with v2 find API error codes): MISMATCH_FORMAT — the domain name does not conform + to the expected format. UNSUPPORTED_TLD — the TLD is not supported for this account or check. INVALID_DOMAIN — + the availcheck service reported a syntax error for this domain. ERROR_UNKNOWN — the check failed and could not + be classified further. + + ' + allOf: + - $ref: '#/components/schemas/error' + AvailabilityCheckCriteria: + title: Availability Check Criteria + description: 'Criteria for an availability check. Specifies 1–50 domain names and optional parameters that influence + how the check is performed. This controller does not persist the check; there is no check identity or poll URL. + + ' + type: object + required: + - domains + properties: + domains: + type: array + minItems: 1 + maxItems: 50 + items: + type: string + description: 'List of 1–50 domain names to check, in punycode A-label form for IDNs. + + ' + example: + - example.com + - example.net + optimizeFor: + default: SPEED + description: 'Optional. When omitted, defaults to SPEED. Availability is always re-verified authoritatively at quote + time regardless of this setting. + + ' + example: SPEED + allOf: + - $ref: '#/components/schemas/OptimizationTarget' + iscCode: + type: string + description: 'Reseller ISC (International Shopper Code) for pricing context. When provided, the indicative prices + in the results reflect the applicable reseller rates for this ISC. + + ' + example: ISC_PARTNER_001 + Consent: + title: Consent + description: 'Customer consent record for a domain operation, capturing which legal agreements were accepted, when, + and by whom. This object is self-reported by the caller and treated as supplementary attestation. The server verifies + agreedBy.principal against the authenticated identity (auth token + X-Shopper-Id resolution) and rejects with consent_principal_mismatch + on disagreement. The persisted consent record is the union of this claimed block and the verified auth context. + + ' + type: object + required: + - agreementTypes + - agreedAt + - agreedBy + properties: + agreementTypes: + type: array + minItems: 1 + items: + $ref: '#/components/schemas/AgreementType' + description: 'The agreement types the customer accepted. Must match the agreementType values returned in the corresponding + quote''s requiredAgreements array. + + ' + example: + - DNRA + agreedAt: + description: 'The timestamp at which the principal expressed consent. Should reflect when the customer clicked accept + or confirmed the operation, not when the API call was made. + + ' + allOf: + - $ref: '#/components/schemas/date-time' + agreedBy: + $ref: '#/components/schemas/ConsentActor' + ConsentActor: + title: Consent Actor + description: 'Identifies who gave consent and who transmitted it. One uniform schema for all actor types. Self-reported + by the caller and treated as supplementary attestation; the server verifies principal against the resolved auth identity + (OAuth token + X-Shopper-Id) and rejects with consent_principal_mismatch on disagreement. The persisted consent record + is the union of this block and the verified auth context. + + principal identifies the account holder whose consent is being recorded. actor identifies the automated or intermediary + party that transmitted the consent when different from the principal. For DIRECT, actor is omitted. + + ' + type: object + x-sensitivity: confidential + required: + - type + - principal + properties: + type: + description: 'Who transmitted consent relative to the principal. + + ' + example: DIRECT + allOf: + - $ref: '#/components/schemas/ConsentActorType' + principal: + type: string + description: 'The shopper or account ID whose consent is being recorded. Must match the shopper resolved from the + OAuth token and X-Shopper-Id header; mismatch returns consent_principal_mismatch. + + ' + example: shopper_123 + actor: + type: string + description: 'The automated agent or system that transmitted the consent. Omitted for DIRECT. For AGENT, identifies + the specific agent instance. For RESELLER, may identify the reseller when distinct from the OAuth token subject. + + ' + example: agent:claude/atlas-1 + ip: + type: string + description: 'The IP address of the principal at the time consent was expressed, if known. Optional — an absent + IP with a verified principal is preferred over a fabricated one. + + ' + example: 203.0.113.7 + DNSRecord: + title: DNS Record + description: 'A single DNS resource record in the zone for a domain managed by GoDaddy DNS. Supports standard IANA record + types plus the GoDaddy ALIAS extension. SOA records are read-only and managed by GoDaddy''s authoritative DNS infrastructure. + The record is uniquely identified by the combination of name, type, and data. + + ' + type: object + required: + - name + - type + - data + - ttl + properties: + recordId: + type: string + description: 'Server-assigned stable identifier for this DNS record. + + ' + readOnly: true + name: + type: string + minLength: 1 + maxLength: 255 + description: 'The DNS record name (label), relative to the zone apex. Use @ to represent the zone apex itself. Wildcards + (*) are supported for A, AAAA, and CNAME records. + + ' + example: '@' + type: + description: 'The DNS resource record type. Together with name and data, uniquely identifies this record in the + zone. Determines the expected data format and which optional fields (priority, service, port, etc.) apply. SOA + and NS records are read-only. + + ' + example: A + allOf: + - $ref: '#/components/schemas/DnsRecordType' + data: + type: string + minLength: 1 + maxLength: 512 + description: 'The record data, formatted per the record type. For MX: the mail exchange hostname (e.g. "mail.example.com."). + Supply priority as the sibling priority field. For SRV: the target hostname; supply priority, weight, port, service, + and protocol as sibling fields. For TXT: the text value (quotes are handled by the DNS layer). For A/AAAA: the + IP address. For CNAME/ALIAS: the target hostname with trailing dot. + + ' + example: 192.0.2.1 + ttl: + type: integer + minimum: 600 + maximum: 86400 + description: 'Time-to-live in seconds. Controls how long resolvers may cache this record. Minimum 600 (10 minutes); + maximum 86400 (24 hours). + + ' + example: 3600 + priority: + type: integer + minimum: 0 + maximum: 65535 + description: 'Record priority. Required for MX and SRV records. Lower values indicate higher preference. Omit for + all other record types. + + ' + example: 10 + service: + type: string + description: 'Service name for SRV and TLSA records, prefixed with an underscore (e.g. `_http`, `_smtp`). Combined + with the protocol to form the record name as `_service._proto.name`. + + ' + example: _http + port: + type: integer + minimum: 0 + maximum: 65535 + description: 'TCP or UDP port number for the target service. Used in SRV records to direct clients to the correct + port, and in TLSA records to identify the service endpoint being certified. + + ' + example: 443 + weight: + type: integer + minimum: 0 + maximum: 65535 + description: 'Relative weight for load distribution among SRV records with equal priority. Higher values increase + the probability of selection. Use 0 when only one target exists at a given priority. + + ' + example: 10 + protocol: + type: string + description: 'Transport protocol for SRV and TLSA records, prefixed with an underscore. Typically `_tcp` or `_udp`. + + ' + example: _tcp + flag: + type: integer + minimum: 0 + maximum: 255 + description: 'CAA record flag. Certification Authority restriction flags byte (RFC 8659; CAA only) Use 0 for non-critical, + 128 for critical (issuer must understand the tag). + + ' + example: 0 + tag: + type: string + description: 'CAA record property tag. Common values: `issue` (authorize CA to issue), `issuewild` (wildcard certs), + `iodef` (violation reporting URL). + + ' + example: issue + NameServers: + title: Name Servers + description: 'Authoritative nameserver hostnames for a domain. ICANN requires a minimum of two nameservers; registries + accept up to thirteen. + + ' + type: array + minItems: 2 + maxItems: 13 + items: + $ref: '#/components/schemas/NameserverHostname' + NameserverHostname: + title: Nameserver Hostname + description: 'A fully-qualified nameserver hostname in punycode A-label form. Labels are dot-separated; a trailing dot + is not required. + + ' + type: string + minLength: 1 + maxLength: 253 + Domain: + title: Domain + description: 'A registered domain owned by the authenticated account. Represents the full management view of a domain: + registration metadata, lifecycle status, nameservers, privacy and auto-renew preferences. + + ' + type: object + required: + - domain + - status + - expiresAt + - createdAt + - autoRenew + - privacy + properties: + domain: + type: string + description: 'The registered domain name in punycode A-label form. For IDNs, the Unicode (U-label) form is available + in the idnDomain field. + + ' + readOnly: true + example: example.com + idnDomain: + type: string + description: 'The Unicode (U-label) representation of the domain. Present only for internationalized domain names + (IDNs). + + ' + readOnly: true + status: + description: 'The current lifecycle status of this domain. + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/DomainStatus' + expiresAt: + description: 'The date and time when this domain registration expires. After expiry the domain enters a grace period + before deletion. + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/date-time' + createdAt: + description: 'The date and time when this domain was first registered with GoDaddy. + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/date-time' + renewBy: + description: 'The date and time when this domain must renew on before entering expiry. + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/date-time' + updatedAt: + description: 'The date and time when this domain was last updated + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/date-time' + autoRenew: + type: boolean + description: 'Whether this domain is set to auto-renew before expiry. When true, the registration is renewed automatically + before it expires. + + ' + privacy: + type: boolean + description: 'Whether WHOIS privacy protection is active for this domain. When true, the registrant''s contact details + are masked in public WHOIS. + + ' + transferLock: + type: boolean + description: 'Whether the transfer lock (registrar lock / EPP lock) is active. When true, outbound transfers to + another registrar are blocked. + + ' + readOnly: true + nameServers: + type: array + minItems: 2 + maxItems: 13 + items: + type: string + description: 'The current authoritative nameservers for this domain. + + ' + example: + - ns01.domaincontrol.com + - ns02.domaincontrol.com + allOf: + - $ref: '#/components/schemas/NameServers' + links: + type: array + items: + $ref: '#/components/schemas/link-description' + description: 'HATEOAS link relations for this domain. rel=self — the canonical URL for this domain record. rel=contacts + — the domain''s WHOIS contact records. rel=nameservers — the domain''s authoritative nameservers. rel=dns-records + — the domain''s DNS records managed by GoDaddy. + + ' + readOnly: true + DomainOperation: + title: Domain Operation + description: "The abstract operation envelope for all domain mutations, returned by the universal GET /operations/{operationId}\ + \ endpoint. Concrete specializations — Registration, Renewal, and Transfer — are returned directly by their respective\ + \ POST endpoints and carry the same operationId. Developers who do not need the abstract view can poll the concrete\ + \ resource (GET /registrations/{id}, etc.) and ignore this type entirely.\nOperation IDs are unique across all concrete\ + \ types, so either poll path works for any given operation.\nAsync state machine:\n status tracks where the operation\ + \ is in its lifecycle. Non-terminal values\n (CONFIRMED, EXECUTING) are transient — poll until a terminal value is\ + \ reached.\n result and error are mutually exclusive terminal payloads:\n COMPLETED — operation succeeded; result\ + \ contains the final outcome data.\n FAILED — operation terminated; error contains failure detail.\n Neither result\ + \ nor error is present while status is non-terminal.\n" + type: object + required: + - operationId + - type + - status + properties: + operationId: + description: 'Stable, server-assigned identifier for this operation. Unique across all operation types. Use to poll + GET /operations/{operationId}. Matches the operationId on the corresponding concrete resource (e.g. Registration). + + ' + readOnly: true + example: 9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c + allOf: + - $ref: '#/components/schemas/uuid' + type: + description: 'The type of operation being tracked. Determines which fields appear in result on COMPLETED and the + concrete resource collection (REGISTER → /registrations, RENEW → /renewals, TRANSFER_IN → /transfers). + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/DomainOperationType' + domain: + type: string + description: The domain name this operation applies to. + readOnly: true + example: example.com + status: + description: 'Current position in the operation lifecycle. Poll until COMPLETED or FAILED. Non-terminal while CONFIRMED + or EXECUTING; terminal values are mutually exclusive with each other and do not revert. + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/DomainOperationStatus' + result: + description: 'Present when status is COMPLETED. Absent while non-terminal and when status is FAILED. + + ' + allOf: + - $ref: '#/components/schemas/DomainOperationResult' + readOnly: true + error: + description: 'Present when status is FAILED. Absent while non-terminal and when status is COMPLETED. + + ' + allOf: + - $ref: '#/components/schemas/error' + readOnly: true + links: + type: array + items: + $ref: '#/components/schemas/link-description' + description: 'HATEOAS link relations for this operation. rel=self — the canonical URL for this abstract operation + view. rel=registration, rel=renewal, or rel=transfer — the same resource viewed through its concrete typed collection. + rel=domain — the domain-name resource affected by this operation. + + ' + readOnly: true + createdAt: + description: Timestamp when this operation was created. + readOnly: true + allOf: + - $ref: '#/components/schemas/date-time' + updatedAt: + description: Timestamp of the most recent status update. + readOnly: true + allOf: + - $ref: '#/components/schemas/date-time' + DomainOperationResult: + title: Domain Operation Result + description: 'The terminal success payload for a completed domain operation. Returned on the parent DomainOperation + when status is COMPLETED. Absent for non-terminal statuses (CONFIRMED, EXECUTING) and for FAILED operations. + + Once status reaches COMPLETED it is terminal: result is populated, remains available on subsequent polls, and status + does not revert. Interpret the fields present in result using the parent operation''s type: + + REGISTER — expiresAt, orderId. + + ' + type: object + readOnly: true + properties: + expiresAt: + description: 'New domain expiry date. Present for REGISTER and RENEW operations. + + ' + allOf: + - $ref: '#/components/schemas/date-time' + orderId: + type: string + description: 'The commerce order ID associated with the charge. Present for commercial operations (REGISTER, RENEW, + TRANSFER_IN). + + ' + example: ord_abc123 + updatedAt: + description: 'Timestamp of the completed non-commercial mutation. + + ' + allOf: + - $ref: '#/components/schemas/date-time' + Error: + $ref: '#/components/schemas/error' + Registration: + title: Registration + description: 'A domain registration entity created when a POST /registrations request is accepted. Registrations are + a top-level resource with their own stable registrationId; the domain relationship is captured in the representation. + + On POST /registrations, supply the writable fields (domain, period, quoteToken, consent, and optionally profileId/profile). + The server returns the full Registration with readOnly fields populated. Poll links[rel=self] until status reaches + COMPLETED or FAILED. The same resource is also reachable via GET /operations/{operationId} for clients operating at + the abstract level; operationId is included in the representation for that purpose. + + ' + type: object + required: + - domain + - quoteToken + - consent + properties: + registrationId: + description: 'Server-assigned stable identifier for this registration record. + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/uuid' + domain: + type: string + description: 'The domain name to register, in punycode A-label form for IDNs. Must match the domain in the quoteToken. + + ' + example: example.com + period: + type: integer + minimum: 1 + maximum: 10 + default: 1 + description: Registration period in years. Must match the period in the quote. + example: 1 + profileId: + description: 'ID of a saved registration profile to use for contacts and preference defaults. Omit to fall back + to the account-default profile for the domain''s TLD, then to account identity. + + ' + allOf: + - $ref: '#/components/schemas/uuid' + profile: + description: 'One-time inline contacts and purchase preference defaults for this registration. Not persisted. Must + match the profile supplied on the quote when a profile was included at quote time. + + ' + allOf: + - $ref: '#/components/schemas/InlineRegistrationProfile' + quoteToken: + description: 'The single-use opaque token from the preceding quoteDomainRegistration call. Required. Consumed on + first successful execution; idempotent retries with the same Idempotency-Key replay the original operation without + re-consuming. + + ' + writeOnly: true + example: 7f3a2b1c-9d8e-4012-a5b6-c1d2e3f4a5b6 + allOf: + - $ref: '#/components/schemas/uuid' + consent: + description: 'The customer''s consent record for the legal agreements returned in the quote. Must reference the + same agreementTypes as the quote. + + ' + allOf: + - $ref: '#/components/schemas/Consent' + status: + description: Current execution status of this registration. + readOnly: true + allOf: + - $ref: '#/components/schemas/DomainOperationStatus' + operationId: + description: 'Identifier of the DomainOperation tracking this registration. Same value returned by GET /operations/{operationId} + for abstract operation tracking. + + ' + readOnly: true + example: 9f1c2e7a-4b3d-4e8f-a1c2-3d4e5f6a7b8c + allOf: + - $ref: '#/components/schemas/uuid' + expiresAt: + description: 'The domain''s expiry date once the registration completes. Present only when status is COMPLETED. + + ' + readOnly: true + allOf: + - $ref: '#/components/schemas/date-time' + createdAt: + description: Timestamp when this registration was initiated. + readOnly: true + allOf: + - $ref: '#/components/schemas/date-time' + updatedAt: + description: Timestamp of the most recent status update. + readOnly: true + allOf: + - $ref: '#/components/schemas/date-time' + links: + type: array + items: + $ref: '#/components/schemas/link-description' + description: 'HATEOAS link relations for this registration. rel=self — the canonical URL for this registration record. + rel=domain — the registered domain-name resource once the registration is complete. + + ' + readOnly: true + InlineRegistrationProfile: + title: Inline Registration Profile + description: 'A one-time, non-persisted set of contacts and purchase preference defaults supplied inline on a quote + or execute request. Use to provide registration data for this transaction without creating or updating a saved registration + profile. + + Shared by the registration quote and execute request bodies. Every field is optional. Omitted fields account identity + or other default values. Provided fields override only what is supplied — contact roles replace as a whole block; + preference fields replace individually. + + This is not a saved registration profile and is not JSON Patch. Data here applies only to the current quote or registration + request. + + ' + type: object + properties: + contacts: + description: 'Contact records for this request. Each role provided replaces that role from the resolved saved profile + or account identity. Omitted roles continue to resolve from the saved profile or cascade from registrant. + + ' + allOf: + - $ref: '#/components/schemas/Contacts' + autoRenew: + type: boolean + description: 'Auto-renew preference for this registration. Omit to inherit from the resolved saved profile or account + defaults. + + ' + privacy: + type: boolean + description: 'WHOIS privacy preference for this registration. Omit to inherit from the resolved saved profile or + account defaults. + + ' + nameServers: + description: 'Authoritative nameservers for this registration. Omit to inherit from the resolved saved profile or + platform defaults. + + ' + example: + - ns1.example.com + - ns2.example.com + allOf: + - $ref: '#/components/schemas/NameServers' + RegistrationQuote: + title: Registration Quote + description: 'A price quote for registering a single domain. Contains a locked price, resolved contact and preference + settings, required legal agreements, and a short-lived single-use quoteToken that must be presented on the subsequent + registration execute call. Execution without a valid quoteToken is structurally impossible. + + When available is false, no quoteToken is returned — this is not an error; it means the domain cannot be registered + as requested. + + ' + type: object + required: + - domain + - available + properties: + quoteToken: + description: 'Opaque, single-use token with a 10-minute TTL. References the locked price, a hash of this request + body, and a hash of the resolved profile values. Absent when available is false. Treat as a capability; do not + parse. + + ' + example: 7f3a2b1c-9d8e-4012-a5b6-c1d2e3f4a5b6 + allOf: + - $ref: '#/components/schemas/uuid' + expiresAt: + description: 'The expiry timestamp of the quoteToken. After this time, presenting the token on execute returns quote_expired + and a new quote must be obtained. + + ' + allOf: + - $ref: '#/components/schemas/date-time' + domain: + type: string + description: 'The domain name being quoted, in punycode A-label form for IDNs. + + ' + example: example.com + available: + type: boolean + description: 'Whether the domain is available for registration. When false, no quoteToken is returned. The availability + check at quote time is authoritative; a name sniped between suggest/availability and quote fails cleanly here. + + ' + price: + description: 'The locked registration price for the quoted period. Held for the duration of the quoteToken''s TTL. + Represents the total amount that will be charged on execute. + + ' + allOf: + - $ref: '#/components/schemas/simple-money' + renewalPrice: + description: 'Indicative renewal cost at current rates. Not a price guarantee; renewal pricing is locked at time + of renewal quote. + + ' + allOf: + - $ref: '#/components/schemas/simple-money' + period: + type: integer + minimum: 1 + maximum: 10 + description: Registration period in years for which the price is quoted. + example: 1 + resolved: + description: 'The effective contact and preference settings that will be applied on execute. Review before execute + to verify registrant, contacts, and preferences match intent. + + ' + allOf: + - $ref: '#/components/schemas/ResolvedSettings' + requiredAgreements: + type: array + items: + $ref: '#/components/schemas/Agreement' + description: 'Legal agreements that must be accepted before executing this quote. The agreementType values from + this list must be included in the execute request''s consent object. + + ' + irreversible: + type: boolean + description: 'Whether executing this quote is irreversible once accepted. Use to calibrate the explicitness of any + confirmation step presented before execute. + + ' + example: false + ResolvedSettings: + title: Resolved Settings + description: 'A preview of the effective settings that will be applied if the associated quote is executed. Returned + in the quote response to eliminate invisible side effects — the caller sees exactly whose contact info and which preferences + will be used before making a commitment. contactSource names where the registrant contact came from so an agent can + be explicit at the confirmation step. + + ' + type: object + properties: + profileId: + description: 'The saved registration profile that was applied, if any. Absent when the registrant was derived from + account identity. + + ' + readOnly: true + example: 14514a29-5fce-4624-8d8a-d8abd56015e2 + allOf: + - $ref: '#/components/schemas/uuid' + contactSource: + description: 'Indicates where the resolved registrant contact came from. When ACCOUNT, the agent should surface + this clearly to the customer before confirmation. + + ' + example: PROFILE + allOf: + - $ref: '#/components/schemas/ContactSource' + registrantSummary: + type: string + description: 'A human-readable one-line summary of the resolved registrant, suitable for display in a confirmation + prompt. When contactSource is ACCOUNT, the summary is suffixed with "(account identity)" to make the derivation + explicit. + + ' + x-sensitivity: confidential + example: Jane Smith / jane@example.com + autoRenew: + type: boolean + description: 'The effective auto-renew setting that will be applied upon registration. + + ' + privacy: + type: boolean + description: 'The effective WHOIS privacy setting that will be applied upon registration. + + ' + nameServers: + description: 'The effective nameservers that will be provisioned for the domain. + + ' + example: + - ns01.domaincontrol.com + - ns02.domaincontrol.com + allOf: + - $ref: '#/components/schemas/NameServers' + Suggestion: + title: Suggestion + description: 'A single available domain suggestion returned by the suggest endpoint. Availability is implied by presence + in the results (available-only contract) and is best-effort — the availability check at quote time is the authoritative + re-check. Indicative pricing may be stale; the locked price is established at quote time only. + + ' + type: object + required: + - domain + properties: + domain: + type: string + description: 'The suggested domain name in punycode A-label form. + + ' + example: sunrisebakery.com + listPrice: + description: 'Indicative undiscounted list price for a one-year registration. Promotional pricing and the final + locked price resolve at quote time. + + ' + allOf: + - $ref: '#/components/schemas/simple-money' + renewalPrice: + description: 'Indicative renewal price at current rates. Not a guarantee. + + ' + allOf: + - $ref: '#/components/schemas/simple-money' + inventory: + description: 'The inventory source for this domain. Present when pricing fields are present. + + ' + example: REGISTRY + allOf: + - $ref: '#/components/schemas/InventoryType' + TermPrice: + title: Term Price + description: 'Pricing for one registration term, including sale and list prices for the full term and optional renewal + and first-term breakdowns. + + ' + type: object + required: + - term + - period + - price + properties: + term: + description: 'Unit in which period is expressed. Currently only YEAR is supported. + + ' + example: YEAR + allOf: + - $ref: '#/components/schemas/Term' + period: + type: integer + minimum: 1 + maximum: 10 + description: 'The registration period length, in units of term. + + ' + example: 2 + price: + description: 'The price for the full term covered by this price block — the current registration price for `period` + units of term. Discounts or promotions may be reflected in this price. + + ' + allOf: + - $ref: '#/components/schemas/simple-money' + renewalPrice: + description: 'Sale price that will apply when the domain renews, for the same term and period. Not a guarantee. + + ' + allOf: + - $ref: '#/components/schemas/simple-money' + uuid: + description: A universally unique identifier (UUID) in [RFC-4122 format](https://tools.ietf.org/html/rfc4122). + type: string + format: uuid + SuggestionSource: + title: Suggestion Source + description: 'A suggestion source strategy that generates domain name variations. EXTENSION — vary the TLD. KEYWORD_SPIN + — rotate keywords. CC_TLD — vary using country-code TLDs. PREMIUM — include premium-priced variations. + + ' + type: string + enum: + - CC_TLD + - EXTENSION + - KEYWORD_SPIN + - PREMIUM + currency-code: + type: string + title: Currency Code + description: A three-character ISO-4217 currency code. + minLength: 3 + maxLength: 3 + simple-money: + type: object + title: Simple Money + description: The currency and amount for a financial transaction, such as a balance or payment due. Use for value representations + with default transactable-value precision. + properties: + currencyCode: + $ref: '#/components/schemas/currency-code' + value: + type: integer + format: int64 + description: The value, which might represent intergrals for currencies like `JPY` that are not typically fractional; + or, with an implied decimal fraction for currencies like `TND` that are subdivided into thousandths. For the implied + number of decimal places for a currency code, see [ISO-4217 Currency Codes](https://en.wikipedia.org/wiki/ISO_4217). + required: + - currencyCode + - value + InventoryType: + title: Inventory Type + description: 'The inventory source for a domain name. REGISTRY — standard registry price inventory. REGISTRY_PREMIUM + — registry premium tier pricing. PREMIUM — third-party premium domain marketplace. + + ' + type: string + enum: + - REGISTRY + - REGISTRY_PREMIUM + - PREMIUM + error-details: + title: Error Details + type: object + description: The error details. Required for client-side `4XX` errors. + properties: + field: + type: string + description: 'The field that caused the error. If the field is in the body, set this value to the JSON pointer to + that field. Required for client-side errors. When the offending value was resolved on the caller''s behalf and + has no request location, this is a source-scoped reference instead of a JSON pointer: "shopper:" (from + the account identity) or "profile:" (from a saved registration profile).' + value: + type: string + description: The value of the field that caused the error. + location: + type: string + description: The location of the field that caused the error. Value is `body`, `path`, or `query`. + default: body + issue: + type: string + description: The unique fine-grained application-level error code. + description: + type: string + description: The human-readable description for an issue. The description MAY change over the lifetime of an API, + so clients MUST NOT depend on this value. + required: + - issue + link-description: + title: Link Description + type: object + description: A request-related [HATEOAS link](https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-hyperschema-02). + properties: + href: + description: The complete target URL, or link, to use in combination with the method to make the related call, as + defined by [RFC 6570 - URI Template](https://tools.ietf.org/html/rfc6570), with the addition of the `$`, `(`, + and `)` characters for pre-processing. The `href` is the key HATEOAS component that links a completed call with + a subsequent call. + type: string + format: uri + rel: + description: The [link relation type](https://tools.ietf.org/html/rfc5988#section-4), which is an identifier for + a link that unambiguously describes the semantics of the link. For values, see [Link Relationship Types](https://www.iana.org/assignments/link-relations/link-relations.xhtml). + type: string + title: + description: The link title. + type: string + targetMediaType: + description: The [RFC 2046-defined media type](https://www.ietf.org/rfc/rfc2046.txt) that describes the link target. + type: string + targetSchema: + description: The schema that describes the link target. + method: + description: The method to use to request the link target. For example, for HTTP, this might be `GET` or `DELETE`. + type: string + submissionMediaType: + description: The media type with which to submit data with the request. + type: string + default: application/json + submissionSchema: + description: The schema that describes the request data. + required: + - rel + - href + error: + type: object + title: Error + description: The error information. + properties: + name: + type: string + description: The human-readable, unique name of the error. + correlationId: + type: string + description: Internal identifier used for correlation purposes. + message: + type: string + description: The message that describes the error. + informationLink: + type: string + description: The URI for detailed information related to this error for the developer. + details: + type: array + description: An array of additional details about the error. Required for client-side `4XX` errors. + additionalItems: false + items: + $ref: '#/components/schemas/error-details' + links: + type: array + description: An array of error-related HATEOAS links. + readOnly: true + items: + $ref: '#/components/schemas/link-description' + readOnly: true + required: + - name + - correlationId + - message + OptimizationTarget: + title: Optimization Target + description: 'How an availability check should prioritize speed vs. authoritative accuracy. SPEED — use cached zone + data for a fast response (may be slightly stale). ACCURACY — perform a live registry check for authoritative availability + (higher latency). + + ' + type: string + enum: + - SPEED + - ACCURACY + Term: + title: Term + description: 'The unit of measure for a registration period. YEAR — registration period expressed in whole years. + + ' + type: string + enum: + - YEAR + default: YEAR + email-address: + description: 'A valid, internationalized email address. Note: Up to 64 characters are allowed before and 255 characters + are allowed after the @ sign. However, the generally accepted maximum length for an email address is 254 characters. + The pattern verifies that an unquoted @ sign exists.' + type: string + minLength: 3 + maxLength: 254 + pattern: ^.+@[^"\-].+$ + phone: + type: object + title: Phone + description: The phone number, in its canonical international [E.164 numbering plan format](https://www.itu.int/rec/T-REC-E.164/en). + properties: + countryCode: + type: string + description: The country calling code (CC), in its canonical international [E.164 numbering plan format](https://www.itu.int/rec/T-REC-E.164/en). + The combined length of the CC and the national number must not be greater than 15 digits. The national number + consists of a national destination code (NDC) and subscriber number (SN). + minLength: 1 + maxLength: 3 + pattern: ^[0-9]{1,3}?$ + nationalNumber: + type: string + description: The national number, in its canonical international [E.164 numbering plan format](https://www.itu.int/rec/T-REC-E.164/en). + The combined length of the country calling code (CC) and the national number must not be greater than 15 digits. + The national number consists of a national destination code (NDC) and subscriber number (SN). + minLength: 1 + maxLength: 14 + pattern: ^[0-9]{1,14}?$ + extensionNumber: + type: string + description: The extension number. + minLength: 1 + maxLength: 15 + pattern: ^[0-9]{1,15}?$ + required: + - countryCode + - nationalNumber + country-code: + description: A two-character ISO 3166-1 code that identifies the country or region. + type: string + maxLength: 2 + minLength: 2 + pattern: ^([A-Z]{2}|C2)$ + simple-address: + type: object + title: Simple Postal Address (Coarse-Grained) + description: Simple postal address with coarse-grained fields. Do not use for international postal addresses. Use for + backward compatibility only. Address does not contain a phone number. + properties: + line1: + type: string + description: The first line of the address. For example, number or street. + maxLength: 300 + line2: + type: string + description: The second line of the address. For example, suite or apartment number. + maxLength: 300 + city: + type: string + description: The city name. + maxLength: 300 + state: + type: string + description: The [code](https://about.usps.com/who/profile/history/state-abbreviations.htm) for a US state or the + equivalent for other countries. + maxLength: 300 + countryCode: + $ref: '#/components/schemas/country-code' + description: 'The [two-character ISO 3166-1 code](https://en.wikipedia.org/wiki/ISO_3166-1) that identifies the + country or region. Note: The country code for Great Britain is `GB` and not `UK` as used in the top-level domain + names for that country. Use country code `C2` for China for comparable uncontrolled price (CUP) method, bank-card, + and cross-border transactions.' + postalCode: + type: string + description: The postal code, which is the zip code or equivalent. Typically required for countries that have a + postal code or an equivalent. See [Postal Code](https://en.wikipedia.org/wiki/Postal_code). + maxLength: 60 + required: + - line1 + - city + - countryCode + Contact: + title: Contact + description: 'An ICANN-required contact record for a domain registration. Covers registrant, administrative, technical, + and billing roles. Identity fields are validated at registration-profile save time. + + ' + x-sensitivity: confidential + type: object + required: + - firstName + - lastName + - email + - phone + - address + properties: + firstName: + type: string + minLength: 1 + maxLength: 60 + description: The contact's first (given) name. + x-sensitivity: confidential + example: Jane + lastName: + type: string + minLength: 1 + maxLength: 60 + description: The contact's last (family) name. + x-sensitivity: confidential + example: Smith + organization: + type: string + maxLength: 100 + description: 'Organization or company name. Required for contacts acting on behalf of a legal entity. Leave blank + for individual registrants. + + ' + x-sensitivity: confidential + example: Example LLC + email: + example: foo@bar.com + description: 'The contact''s email address. Used for registry WHOIS and renewal notifications. + + ' + x-sensitivity: confidential + allOf: + - $ref: '#/components/schemas/email-address' + phone: + description: 'The contact''s phone number in ITU E.164 format with GoDaddy extension notation: +{country-code}.{local-number}, + e.g. +1.4805551234. Required by ICANN for all contact roles. + + ' + x-sensitivity: confidential + allOf: + - $ref: '#/components/schemas/phone' + address: + description: 'The contact''s mailing address for WHOIS and ICANN records. + + ' + x-sensitivity: confidential + allOf: + - $ref: '#/components/schemas/simple-address' + Contacts: + title: Contacts + description: 'The set of ICANN-required contact roles for a domain registration. Registrant is required; admin, tech, + and billing cascade from the registrant when omitted. Merge rule across resolution layers (saved profile, inline registration + profile): identity fields replace as a whole block per role. + + ' + x-sensitivity: confidential + type: object + required: + - registrant + properties: + registrant: + description: 'The legal owner of the domain. Required. The registrant''s identity is the authoritative WHOIS record + and is bound by ICANN registration agreements. + + ' + allOf: + - $ref: '#/components/schemas/Contact' + admin: + description: 'The administrative contact, responsible for managing the domain on behalf of the registrant. Cascades + from registrant when omitted. + + ' + allOf: + - $ref: '#/components/schemas/Contact' + tech: + description: 'The technical contact, responsible for DNS and nameserver configuration. Cascades from registrant + when omitted. + + ' + allOf: + - $ref: '#/components/schemas/Contact' + billing: + description: 'The billing contact, receives invoices and renewal notices. Cascades from registrant when omitted. + + ' + allOf: + - $ref: '#/components/schemas/Contact' + date-time: + description: 'A date and time, in [Internet date and time format](https://tools.ietf.org/html/rfc3339#section-5.6). + Note: The regular expression provides static schematic guidance but does not reject all invalid dates.' + type: string + minLength: 20 + maxLength: 64 + pattern: ^[0-9]{4}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])[T,t]([0-1][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)([.][0-9]+)?([Zz]|[+-][0-9]{2}:[0-9]{2})$ + ContactSource: + title: Contact Source + description: 'Where the resolved registrant contact came from. INLINE — contact was supplied via an inline registration + profile on the request. PROFILE — contact was resolved from a named or default saved profile. ACCOUNT — contact was + derived from the authenticated principal''s account identity (no profile supplied or on file). + + ' + type: string + enum: + - INLINE + - PROFILE + - ACCOUNT + AgreementType: + title: Agreement Type + description: 'The type of legal agreement that must be accepted prior to executing a domain operation. Additional agreement + types may be returned for specific TLDs or product combinations. DNRA — Domain Name Registration Agreement. DNTA — + Domain Name Transfer Agreement. DNPA — Domain Name Privacy Agreement. HTTPS_NOTICE — HTTPS notice acknowledgment for + eligible TLDs. AURA — AU Domain Agreement for .au TLD registrations and transfers. CIRA — Canadian Internet Registration + Authority Agreement for .ca TLD registrations and transfers. + + ' + type: string + ConsentActorType: + title: Consent Actor Type + description: 'Who transmitted consent on behalf of the principal. DIRECT — the principal acted directly; actor is omitted. + AGENT — an AI or automation acted on behalf of the principal. RESELLER — a reseller acted on behalf of a shopper. + + ' + type: string + DomainStatus: + title: Domain Status + description: 'The lifecycle status of a registered domain. Reflects the domain''s current operational state within the registry + and GoDaddy''s management layer. ACTIVE — domain is registered and is active. CANCELLED — domain has been cancelled + by the user or system, and is not reclaimable. DELETED_REDEEMABLE — domain is in ICANN redemption grace period; recovery + fees apply. EXPIRED — registration period has ended; domain is pending deletion or redemption. FAILED - domain registration + or transfer error. HELD_REGISTRAR - domain is held at the registrar and cannot be transferred or modified - this is usually + the result of a dispute. LOCKED_REGISTRAR — domain is locked at the registrar - this is usually the result of spam, + abuse, etc. OWNERSHIP_CHANGED - domain has been moved to another account. PARKED - domain has been parked. PENDING_REGISTRATION + - domain is pending setup at the registry. PENDING_TRANSFER — an outbound transfer to another registrar is in progress. + REPOSSESSED - domain has been confiscated - this is usually the result of a chargeback, fraud, abuse, etc. SUSPENDED + — domain has been administratively suspended by the registry or registrar. TRANSFERRED - domain has been transferred + to another registrar. + + ' + type: string + enum: + - ACTIVE + - CANCELLED + - DELETED_REDEEMABLE + - EXPIRED + - FAILED + - HELD_REGISTRAR + - LOCKED_REGISTRAR + - OWNERSHIP_CHANGED + - PARKED + - PENDING_REGISTRATION + - PENDING_TRANSFER + - REPOSSESSED + - SUSPENDED + - TRANSFERRED + DomainOperationStatus: + title: Domain Operation Status + description: 'The execution state of an asynchronous domain operation. CONFIRMED — operation has been accepted and is + queued for execution. EXECUTING — operation is actively being processed by the registry or downstream systems. COMPLETED + — operation finished successfully; result data is available. FAILED — operation terminated with an unrecoverable error; + error detail is attached. + + ' + type: string + DomainOperationType: + title: Domain Operation Type + description: 'The type of asynchronous domain operation. Used to distinguish which workflow is being polled on the /operations/{operationId} + endpoint. REGISTER — new domain registration. + + ' + type: string + DnsRecordType: + title: DNS Record Type + description: 'The type of a DNS resource record. Values correspond to IANA-assigned DNS record type mnemonics. A — IPv4 + address record. AAAA — IPv6 address record. CAA — certification authority authorization record. CNAME — canonical + name alias record. MX — mail exchange record; data is the mail exchange hostname, priority is the sibling priority + field. NS — nameserver delegation record; read-only. SOA — start of authority record; managed by the registry; read-only. + SRV — service locator record. TXT — free-form text; used for SPF, DKIM, DMARC, and domain verification. + + ' + type: string + securitySchemes: + oauth2: + type: oauth2 + description: 'GoDaddy OAuth 2.0 access token. The scope(s) listed on each operation are enforced per-operation. A token + requires at least one of the scopes listed for that operation. + + ' + flows: + authorizationCode: + authorizationUrl: https://api.godaddy.com/v2/oauth2/authorize + tokenUrl: https://api.godaddy.com/v2/oauth2/token + scopes: + domains.domain:read: 'Read domain records, availability, suggestions, quotes, and operations. + + ' + domains.domain:create: 'Register domains. Requires a prior quoteToken. + + ' + domains.nameserver:update: 'Replace authoritative nameservers for a domain. + + ' + domains.dns:update: 'Create, update, and delete DNS zone records. + + ' diff --git a/rust/domains-client/scripts/merge-spec.py b/rust/domains-client/scripts/merge-spec.py new file mode 100644 index 0000000..b92f61e --- /dev/null +++ b/rust/domains-client/scripts/merge-spec.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Merge the retained-v1 OpenAPI 3.0 spec into the vendored v3 spec, producing +the single `domains.oas3.json` the crate's build.rs feeds to progenitor. + +Invoked by `regenerate-spec.sh` as: + + python3 merge-spec.py + +The CLI's `domain`/`dns` commands talk to two API generations at once: v3 (the +Domain Lifecycle Management API) for availability/suggest/get/quote/register and +single DNS-record creation, and v1 for the handful of operations v3 does not yet +serve (list, agreements, DNS record list/set/delete). We generate ONE progenitor +client over ONE host base URL by merging both into a single OAS3 document: + + * **v3 is the base.** Its server is `https://api.{env}.com/v3/domains`, so we + rewrite every v3 path to the absolute `/v3/domains/...` form and pin the + document `servers` to the bare host. One `base_url` (the host) then serves + both `/v3/domains/...` and `/v1/domains/...` requests. + * **v1 is injected with a `V1` name prefix.** v1 and v3 both define `DNSRecord`, + `DnsRecordType`, `DomainStatus`, … with different shapes, so every v1 + component is renamed `V1` (and all its `$ref`s rewritten) before the + merge — v3 keeps the clean, go-forward names. + +Both sides are reduced to their 2xx responses (errors surface via HTTP status + +body, read by the CLI's `api_error`), and `date-time`/`uuid` string formats are +dropped so the generated crate needs neither chrono nor uuid. +""" + +import json +import sys + +import yaml + +EXTERNAL_FORMATS = {"date", "date-time", "uuid", "partial-date-time"} + +# v3 schemas the CLI *constructs* (request bodies) or that are dual-use +# (request + response): keep them strict so required fields stay required at the +# call site. Everything else in v3 components.schemas is a pure read and gets +# relaxed (required dropped, arrays made nullable) to tolerate sparse payloads. +STRICT_V3 = { + "AvailabilityCheckCriteria", # POST /check-availability body + "Registration", # POST /registrations body (and GET response) + "Consent", + "ConsentActor", + "Contact", + "Contacts", + "simple-address", + "InlineRegistrationProfile", + "DNSRecord", # POST /zones/{zone}/dns-records body + "NameServers", # PUT .../nameservers body + "NameserverHostname", +} + + +def strip_external_formats(obj): + """Drop string `format`s that progenitor maps to external crates (chrono/uuid) + and string `pattern`s that make it emit `regress::Regex` validation. The crate + keeps its dependency stack lean (no chrono/uuid/regress); these values pass + straight through as `String`, and the API validates them server-side (the CLI + surfaces any 422 field errors). Mirrors the upstream-v1 stripping in trim-spec.py.""" + if isinstance(obj, dict): + if obj.get("type") == "string": + if obj.get("format") in EXTERNAL_FORMATS: + obj.pop("format") + # Drop string validation so progenitor emits plain `String` (or a thin + # alias for named schemas) instead of a validated newtype per field — + # the API validates these server-side and the CLI passes them through. + obj.pop("pattern", None) + obj.pop("minLength", None) + obj.pop("maxLength", None) + for v in obj.values(): + strip_external_formats(v) + elif isinstance(obj, list): + for v in obj: + strip_external_formats(v) + + +def only_2xx_paths(paths): + for item in paths.values(): + if not isinstance(item, dict): + continue + for method, op in item.items(): + if method not in ("get", "post", "put", "patch", "delete"): + continue + if isinstance(op, dict) and "responses" in op: + op["responses"] = { + code: r + for code, r in op["responses"].items() + if str(code).startswith("2") + } + + +def relax_oas3(defn): + """Make an OAS3 response schema tolerant: drop `required` so each property is + optional, and mark array properties `nullable` so an explicit JSON `null` + deserializes to `None` rather than erroring.""" + if not isinstance(defn, dict): + return + defn.pop("required", None) + for pschema in (defn.get("properties") or {}).values(): + if isinstance(pschema, dict) and pschema.get("type") == "array": + pschema["nullable"] = True + + +def schema_refs(obj): + """Collect the set of `#/components/schemas/` targets referenced in obj.""" + out = set() + if isinstance(obj, dict): + ref = obj.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/schemas/"): + out.add(ref.split("/")[-1]) + for v in obj.values(): + out |= schema_refs(v) + elif isinstance(obj, list): + for v in obj: + out |= schema_refs(v) + return out + + +def prune_unreferenced_schemas(doc): + """Drop component schemas unreachable from the (2xx-stripped) paths and the + other component sections. After stripping error responses this removes the + orphaned error-envelope model whose sanitized Rust name (`Error`) would + otherwise collide with the common `error` schema and make progenitor emit a + self-referential `struct Error(pub Error)`.""" + schemas = doc.get("components", {}).get("schemas", {}) + seed = schema_refs(doc.get("paths", {})) + for section, members in doc.get("components", {}).items(): + if section != "schemas": + seed |= schema_refs(members) + reachable, stack = set(), list(seed) + while stack: + n = stack.pop() + if n in reachable or n not in schemas: + continue + reachable.add(n) + stack += list(schema_refs(schemas[n])) + for name in [k for k in schemas if k not in reachable]: + del schemas[name] + + +def rewrite_refs(obj, rename): + """Rewrite every `#/components/
/` $ref via `rename(section, name)`.""" + if isinstance(obj, dict): + ref = obj.get("$ref") + if isinstance(ref, str) and ref.startswith("#/components/"): + parts = ref.split("/") + if len(parts) == 4: + _, _, section, name = parts + obj["$ref"] = f"#/components/{section}/{rename(section, name)}" + for v in obj.values(): + rewrite_refs(v, rename) + elif isinstance(obj, list): + for v in obj: + rewrite_refs(v, rename) + + +def main(v1_path, v3_path, out_path, host): + v3 = yaml.safe_load(open(v3_path)) + v1 = json.load(open(v1_path)) + + # --- v3: rewrite paths to absolute /v3/domains/..., pin servers to the host. + new_paths = {} + for key, item in v3.get("paths", {}).items(): + if not key.startswith("/"): + continue # drop paths-level extensions (e.g. x-visibility) + new_paths["/v3/domains" + key] = item + v3["paths"] = new_paths + v3["servers"] = [{"url": host, "description": "Domains API host"}] + + only_2xx_paths(v3["paths"]) + strip_external_formats(v3) + + # Relax pure-read v3 schemas; keep constructed/dual-use ones strict. + for name, schema in (v3.get("components", {}).get("schemas") or {}).items(): + if name not in STRICT_V3: + relax_oas3(schema) + + # `Registration` is dual-use: the register request requires `quoteToken`, but + # the register/get *responses* never echo it. progenitor emits one Rust type + # for both directions, so keeping `quoteToken` required makes response + # deserialization fail ("missing field quoteToken"). Mark just that field + # optional — the request handler always supplies it — while the other request + # fields (domain/consent) stay required. + registration = v3.get("components", {}).get("schemas", {}).get("Registration") + if isinstance(registration, dict) and "required" in registration: + registration["required"] = [ + field for field in registration["required"] if field != "quoteToken" + ] + + # --- v1: prefix every component name with V1, rewrite its $refs, then merge. + v1_components = v1.get("components", {}) + rename = lambda section, name: f"V1{name}" # noqa: E731 — tiny local + # Rewrite refs in v1 paths and v1 component bodies first (they reference the + # OLD names; rewrite, then rename the keys). + rewrite_refs(v1.get("paths", {}), rename) + for section in v1_components.values(): + if isinstance(section, dict): + rewrite_refs(section, rename) + + v3.setdefault("components", {}) + for section, members in v1_components.items(): + if not isinstance(members, dict): + continue + dst = v3["components"].setdefault(section, {}) + for name, body in members.items(): + dst[f"V1{name}"] = body + + only_2xx_paths(v1.get("paths", {})) + # v1 paths (/v1/...) never collide with the rewritten v3 paths (/v3/domains/...). + v3["paths"].update(v1.get("paths", {})) + + # Drop schemas left unreferenced once error responses are stripped, so no two + # surviving schemas sanitize to the same Rust type name. + prune_unreferenced_schemas(v3) + + json.dump(v3, open(out_path, "w"), indent=2) + n_paths = len(v3["paths"]) + n_schemas = len(v3.get("components", {}).get("schemas", {})) + print(f" merged -> {out_path}: {n_paths} paths, {n_schemas} schemas") + + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]) diff --git a/rust/domains-client/scripts/regenerate-spec.sh b/rust/domains-client/scripts/regenerate-spec.sh index 8ef16f0..eb08804 100755 --- a/rust/domains-client/scripts/regenerate-spec.sh +++ b/rust/domains-client/scripts/regenerate-spec.sh @@ -1,37 +1,56 @@ #!/usr/bin/env bash -# Regenerate the trimmed OpenAPI 3.0 spec the domains-client build consumes. +# Regenerate the merged OpenAPI 3.0 spec the domains-client build consumes. +# +# The CLI's domain/dns commands span two API generations: +# * v3 — the Domain Lifecycle Management API (availability, suggest, get, +# registration quote→register, single DNS-record create, nameservers). +# * v1 — the operations v3 does not yet serve (list, agreements, DNS record +# list/set/delete). # # Pipeline: # 1. Download the upstream GoDaddy Domains API spec (Swagger 2.0). -# 2. Trim (via trim-spec.py) to the GET /v1/domains (list owned domains) and -# GET /v1/domains/{domain} (get) operations, GET /v1/domains/available + -# GET /v1/domains/suggest, GET /v1/domains/agreements, POST -# /v1/domains/purchase + its GET /v1/domains/purchase/schema/{tld}, the v2 -# POST /v2/customers/{customerId}/domains/register operation, the -# /v1/domains/{domain}/records DNS-record operations, and the transitive -# closure of definitions they reference. -# 3. Convert Swagger 2.0 -> OpenAPI 3.0 with `swagger2openapi` (Node, via npx). +# 2. Trim (trim-spec.py) to the retained v1 operations + their definition closure. +# 3. Convert that v1 subset Swagger 2.0 -> OpenAPI 3.0 with `swagger2openapi`. +# 4. Merge (merge-spec.py) the v1 OAS3 into the vendored v3 spec, producing the +# single domains.oas3.json progenitor consumes (build.rs never hits the network). +# +# The v3 spec (`openapi/swagger_domains.v3.yaml`) is a vendored, self-contained +# bundle. Its source of truth is +# `gdcorp-platform/domains.domain-lifecycle-specification` (v3/schemas), surfaced +# as the pre-bundled `api-spec.yaml` in `gdcorp-domains/api-domain-v3-prototype`. +# That repo is private, so refresh the vendored copy manually (authenticated): +# +# gh api repos/gdcorp-domains/api-domain-v3-prototype/contents/api-spec.yaml \ +# --jq '.content' | base64 -d > openapi/swagger_domains.v3.yaml # -# Run this ONLY when the upstream spec changes; the committed domains.oas3.json -# is what the crate's build.rs feeds to progenitor (the build never hits the -# network). Requires: curl, python3, and Node/npx (for swagger2openapi). +# Run this script ONLY when either upstream spec changes. Requires: curl, +# python3 (with PyYAML), and Node/npx (for swagger2openapi). set -euo pipefail here="$(cd "$(dirname "$0")/.." && pwd)" # domains-client/ openapi_dir="$here/openapi" -v2="$openapi_dir/swagger_domains.v2.json" -trimmed_v2="$openapi_dir/.swagger_domains.trimmed.v2.json" +v2="$openapi_dir/swagger_domains.v2.json" # upstream Swagger 2.0 (v1+v2 source) +v3="$openapi_dir/swagger_domains.v3.yaml" # vendored, self-contained v3 OAS3 bundle +trimmed_v1="$openapi_dir/.swagger_domains.trimmed.v1.json" +v1_oas3="$openapi_dir/.domains.v1.oas3.json" oas3="$openapi_dir/domains.oas3.json" +# The document host. base_url is overridden at runtime by the CLI per environment, +# so this is cosmetic; keep it on the public OTE host. +host="https://api.ote-godaddy.com" + echo "==> Downloading upstream Swagger 2.0 spec" curl -fsSL "https://developer.godaddy.com/swagger/swagger_domains.json" -o "$v2" -echo "==> Trimming to domains list + get + available + suggest + agreements + purchase (+ schema) + v2 register + DNS records and their definition closure" -python3 "$here/scripts/trim-spec.py" "$v2" "$trimmed_v2" +echo "==> Trimming to retained v1 ops (list + agreements + DNS record list/set/delete)" +python3 "$here/scripts/trim-spec.py" "$v2" "$trimmed_v1" + +echo "==> Converting v1 subset Swagger 2.0 -> OpenAPI 3.0" +# Pin the converter so regeneration is deterministic across time. +npx -y swagger2openapi@7.0.8 "$trimmed_v1" -o "$v1_oas3" + +echo "==> Merging v1 OAS3 into the vendored v3 spec" +python3 "$here/scripts/merge-spec.py" "$v1_oas3" "$v3" "$oas3" "$host" -echo "==> Converting Swagger 2.0 -> OpenAPI 3.0" -# Pin the converter so regeneration is deterministic across time (an unpinned -# `npx swagger2openapi` would float to the latest release and can drift/break). -npx -y swagger2openapi@7.0.8 "$trimmed_v2" -o "$oas3" -rm -f "$trimmed_v2" +rm -f "$trimmed_v1" "$v1_oas3" echo "==> Wrote $oas3" diff --git a/rust/domains-client/scripts/trim-spec.py b/rust/domains-client/scripts/trim-spec.py index 40f92a3..9f8d6ee 100755 --- a/rust/domains-client/scripts/trim-spec.py +++ b/rust/domains-client/scripts/trim-spec.py @@ -1,17 +1,25 @@ #!/usr/bin/env python3 -"""Trim the upstream GoDaddy Domains Swagger 2.0 spec to the subset the -domains-client build needs. +"""Trim the upstream GoDaddy Domains Swagger 2.0 spec to the **retained v1** +subset the domains-client build still needs after the v3 migration. Invoked by `regenerate-spec.sh` as: python3 trim-spec.py -Keeps the domains list + get + availability + suggest + agreements + purchase -(+ its per-TLD schema) + v2 register + DNS record operations and the transitive closure -of definitions they reference, -normalizes content types, and relaxes read-only response definitions (see the -inline comments). The output is still Swagger 2.0; the shell script then runs -`swagger2openapi` to produce the OpenAPI 3.0 spec progenitor consumes. +v3 (the Domain Lifecycle Management API) now serves availability, suggestions, +domain get, registration (quote → register), and single DNS-record creation. +The operations v3 does NOT yet cover stay on v1 and are the only ones kept here: + + * `GET /v1/domains` — list the shopper's domains + * `GET /v1/domains/agreements` — legal agreements for a TLD + * `GET /v1/domains/{domain}/records*` — list DNS records (all/by-type/by-name) + * `PUT /v1/domains/{domain}/records/{type}/{name}` — replace a record set (dns set) + * `DELETE /v1/domains/{domain}/records/{type}/{name}` — delete a record set (dns delete) + +Everything kept here is a *read* or a record mutation; the transitive closure of +definitions they reference is kept too. The output is still Swagger 2.0; the +shell script then converts it to OpenAPI 3.0 and `merge-spec.py` folds it into +the v3 spec (prefixing these v1 definitions with `V1` to avoid name clashes). """ import copy @@ -19,22 +27,17 @@ import sys # GET-only operations kept as-is (operationId pinned). `list` retrieves the -# Domains owned by the authenticated shopper; available/suggest are the -# availability endpoints; agreements retrieves the legal agreements a TLD -# requires before purchase (upstream operationId `getAgreement`, pinned here to +# domains owned by the authenticated shopper; `agreements` retrieves the legal +# agreements a TLD requires (upstream operationId `getAgreement`, pinned here to # `agreements` so the generated builder reads `client.agreements()`). AVAIL_OPS = { "/v1/domains": "list", - "/v1/domains/available": "available", - "/v1/domains/suggest": "suggest", "/v1/domains/agreements": "agreements", } -# DNS record operations: keep every method these paths expose. The upstream -# Swagger documents `recordGet` only on the {type}/{name} path even though the -# gateway also routes GET on the bare and {type} paths (see the OAuth scope -# whitelist in gdcorp-domains/api-domain-data, api/oauthscopewhitelist.json); -# we synthesize those two GETs below so the CLI can list all / by-type records. +# DNS record operations. v3 only creates single records, so the read (list) and +# the type+name replace/delete stay on v1. We keep every documented method on +# these paths and synthesize the GET-all / GET-by-type reads below. RECORD_PATHS = [ "/v1/domains/{domain}/records", "/v1/domains/{domain}/records/{type}", @@ -43,9 +46,9 @@ # String `format`s that typify maps to *external* crates (date-time/date -> # chrono, uuid -> uuid). We don't depend on those (and deliberately keep this -# crate's dependency stack lean — see Cargo.toml), and the CLI only passes these -# values straight through to JSON output, so drop the format and let them -# generate as plain `String` (the RFC 3339 / UUID text is unchanged on the wire). +# crate's dependency stack lean), and the CLI only passes these values straight +# through to JSON output, so drop the format and let them generate as plain +# `String` (the RFC 3339 / UUID text is unchanged on the wire). EXTERNAL_FORMATS = {"date", "date-time", "uuid", "partial-date-time"} # Read-only response definitions the CLI only deserializes-and-reprints. The @@ -54,26 +57,12 @@ # cancelled/pending domains) and types `nameServers` as a non-null array while # the API returns JSON `null`. Relax these to tolerant readers. # -# The complement — types the CLI *constructs* (request bodies) or reads via -# non-optional fields (e.g. `if available { … }`) — must stay strict, or call -# sites break. Everything not listed here is relaxed. +# The complement — types the CLI *constructs* (record request bodies) — must stay +# strict, or call sites break. Everything not listed here is relaxed. # NB: these are the *spec* definition names (upper-case `DNS…`), not the # camel-cased Rust type names progenitor emits (`DnsRecord`). STRICT_DEFS = { - "DomainAvailableResponse", # `available`/`suggest` read non-optional fields - "DomainSuggestion", - "DNSRecord", # built by `dns add` - "DNSRecordCreateType", "DNSRecordCreateTypeName", # built by `dns set` - "ArrayOfDNSRecord", # `dns add` request body - "DomainPurchase", # `domain purchase` request body - "Consent", # required sub-object of DomainPurchase - "Contact", # optional contacts; keep required fields strict - "Address", # contact mailing address (constructed for v2 contacts) - "DomainPurchaseV2", # v2 `domain purchase` request body (OAuth path) - "ConsentV2", # required sub-object of DomainPurchaseV2 - "DomainContactsCreateV2", # v2 contacts wrapper - "ContactDomainCreate", # v2 per-role contact (reuses Address) } @@ -152,63 +141,13 @@ def main(src, dst): paths = {} - # Availability + suggest + agreements (GET only). + # Reads kept verbatim (GET only): list + agreements. for p, op_id in AVAIL_OPS.items(): get = d["paths"][p]["get"] get["operationId"] = op_id get["produces"] = ["application/json"] # drop xml/js variants paths[p] = {"get": only_2xx(get)} - # Purchase (POST with JSON body). The body ($ref DomainPurchase) and its - # transitive closure (Consent/Contact/Address) are kept strict below so the - # generated request types keep their required fields. - purchase = d["paths"]["/v1/domains/purchase"]["post"] - purchase["operationId"] = "purchase" - purchase["produces"] = ["application/json"] - purchase["consumes"] = ["application/json"] - paths["/v1/domains/purchase"] = {"post": only_2xx(purchase)} - - # Per-TLD purchase schema (GET). `domain purchase` reads its top-level - # `required` array to preflight-validate requirements before the paid call. - # The upstream JsonSchema/JsonProperty/JsonDataType definitions are loosely - # typed (`"type": "object"` *with* `items`), so instead of pulling them into - # the closure we replace the response with a free-form object — progenitor - # then emits `serde_json::Value`, and we parse only the fields we need. - schema_op = d["paths"]["/v1/domains/purchase/schema/{tld}"]["get"] - schema_op["operationId"] = "schema" - schema_op["produces"] = ["application/json"] - only_2xx(schema_op) - schema_op["responses"]["200"]["schema"] = {"type": "object"} - paths["/v1/domains/purchase/schema/{tld}"] = {"get": schema_op} - - # Get one domain's details (GET). `DomainDetail` embeds the Contact/Address - # types we keep strict for v2 request construction, but as a *read* the API - # can return them sparsely (e.g. privacy-protected domains). Rather than fight - # that strict/relax conflict, take the response as a free-form object — - # progenitor emits `serde_json::Map` — and emit it directly (the command just - # dumps the domain's details). - get_op = d["paths"]["/v1/domains/{domain}"]["get"] - get_op["operationId"] = "get" - get_op["produces"] = ["application/json"] - only_2xx(get_op) - # `get` has two success responses (200 and 203), both DomainDetail; free-form - # every kept 2xx so progenitor sees a single response type (and no DomainDetail - # closure is pulled in). - for resp in get_op["responses"].values(): - resp["schema"] = {"type": "object"} - paths["/v1/domains/{domain}"] = {"get": get_op} - - # v2 register (POST). The OAuth-friendly purchase path: unlike v1 purchase, - # v2 authorizes credit-card payments for OAuth bearer users. Body - # ($ref DomainPurchaseV2) + closure (ConsentV2/DomainContactsCreateV2/ - # ContactDomainCreate, reusing Address) are kept strict below. Upstream has - # no operationId, so pin `register`. The 2xx is a bodyless 202 (async). - register = d["paths"]["/v2/customers/{customerId}/domains/register"]["post"] - register["operationId"] = "register" - register["produces"] = ["application/json"] - register["consumes"] = ["application/json"] - paths["/v2/customers/{customerId}/domains/register"] = {"post": only_2xx(register)} - # DNS record ops: keep all documented methods, success responses only, and # normalize content types (drop xml/js variants; request bodies stay JSON). for p in RECORD_PATHS: @@ -259,7 +198,7 @@ def synth_get(keep_param_names, op_id): out = { "swagger": "2.0", "info": { - "title": "GoDaddy Domains API (domains list + get + availability + agreements + purchase + v2 register + DNS records subset)", + "title": "GoDaddy Domains API (retained v1 subset: list + agreements + DNS record list/set/delete)", "version": d.get("info", {}).get("version", "1.0.0"), }, "host": d.get("host", "api.ote-godaddy.com"), diff --git a/rust/domains-client/src/lib.rs b/rust/domains-client/src/lib.rs index bec1016..3e3a915 100644 --- a/rust/domains-client/src/lib.rs +++ b/rust/domains-client/src/lib.rs @@ -1,11 +1,20 @@ -//! GoDaddy Domains API client (domains list + get + availability + suggest + -//! agreements + purchase + per-TLD purchase schema + v2 register + DNS records). +//! GoDaddy Domains API client, spanning two API generations behind one host: +//! +//! * **v3** — the Domain Lifecycle Management API (`/v3/domains/…`): suggestions, +//! availability (single + batch), domain get, registration (quote → register), +//! async operation polling, single DNS-record create, and nameserver replace. +//! * **v1** — the operations v3 does not yet serve (`/v1/domains/…`): list the +//! shopper's domains, TLD legal agreements, and DNS record list/replace/delete. +//! Their generated types are `V1`-prefixed to avoid clashing with the v3 ones. //! //! The contents of this crate are **generated** by `progenitor` at build time -//! from the vendored OpenAPI 3.0 spec (`openapi/domains.oas3.json`). Construct -//! [`Client`] with [`Client::new_with_client`] to supply a pre-authenticated -//! `reqwest::Client` (the CLI sets the `Authorization: sso-key …`/Bearer header -//! itself). See `scripts/regenerate-spec.sh` to refresh the spec. +//! from the vendored, merged OpenAPI 3.0 spec (`openapi/domains.oas3.json`). +//! Construct [`Client`] with [`Client::new_with_client`] to supply a +//! pre-authenticated `reqwest::Client` (the CLI sets the `Authorization: +//! sso-key …`/Bearer header itself). The v3 operations live under the +//! `/v3/domains` base path, baked into the spec's absolute paths so one host +//! `base_url` serves both generations. See `scripts/regenerate-spec.sh` to +//! refresh and re-merge the spec. //! //! The lint allowances are scoped to the generated module so the hand-written //! code below (`client_with_auth`, `BuildError`) is still linted normally. @@ -63,409 +72,418 @@ pub fn client_with_auth( #[cfg(test)] mod tests { use super::*; - use httpmock::Method::PATCH; // not re-exported by the prelude (unlike GET/PUT/DELETE) use httpmock::prelude::*; use serde_json::json; // These tests exercise the generated request/response wiring against a mock - // server: the query-parameter names (which guard the builder setters → - // wire-parameter mapping at the call sites), the `Authorization`/ - // `x-request-id`/`api-version` headers set by `client_with_auth`, and response - // deserialization. They run entirely offline. + // server: HTTP method + path (v3 lives under /v3/domains/…, v1 under + // /v1/domains/…), the query-parameter / body field names that map the builder + // setters to the wire, the `Authorization` / `x-request-id` / `Idempotency-Key` + // headers, and response deserialization. They run entirely offline. + + fn client_for(server: &MockServer) -> Client { + client_with_auth( + &server.base_url(), + "Bearer tok", + "godaddy-cli/test", + "req-1", + ) + .expect("build client") + } + + // --- v3: discovery ------------------------------------------------------ #[tokio::test] - async fn available_sends_correct_request_and_parses_response() { + async fn suggest_domains_maps_setters_to_named_query_params() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method(GET) - .path("/v1/domains/available") - .query_param("domain", "example.com") - .query_param("checkType", "FULL") - .query_param("forTransfer", "true") - .header("authorization", "sso-key KEY:SECRET") - .header("x-request-id", "req-123") - .header("api-version", "1.0.0"); + .path("/v3/domains/suggestions") + .query_param("query", "coffee") + .query_param("pageSize", "5") + .query_param("tlds", "com") + .header("authorization", "Bearer tok"); + then.status(200) + .json_body(json!({ "items": [{ "domain": "coffeehouse.com" }] })); + }) + .await; + + let resp = client_for(&server) + .suggest_domains() + .query("coffee") + .page_size(5) + .tlds(vec!["com".to_string()]) + .send() + .await + .expect("request succeeds") + .into_inner(); + + mock.assert_async().await; + assert_eq!(resp.items.len(), 1); + assert_eq!(resp.items[0].domain.as_deref(), Some("coffeehouse.com")); + } + + #[tokio::test] + async fn get_domain_availability_single_parses_prices() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/v3/domains/check-availability") + .query_param("domain", "example.com"); then.status(200).json_body(json!({ "domain": "example.com", - "available": false, + "available": true, "definitive": true, - "price": 11_990_000, - "currency": "USD", - "renewalPrice": 21_990_000, - "period": 1 + "prices": [{ "period": 1, "price": { "currencyCode": "USD", "value": 11_990_000 } }] })); }) .await; - let client = client_with_auth( - &server.base_url(), - "sso-key KEY:SECRET", - "godaddy-cli/test", - "req-123", - ) - .expect("build client"); - - let body = client - .available() + let body = client_for(&server) + .get_domain_availability() .domain("example.com") - .check_type(types::AvailableCheckType::Full) - .for_transfer(true) .send() .await .expect("request succeeds") .into_inner(); mock.assert_async().await; - assert_eq!(body.domain, "example.com"); - assert!(!body.available); - assert!(body.definitive); - assert_eq!(body.price, Some(11_990_000)); - assert_eq!(body.currency, "USD"); - assert_eq!(body.renewal_price, Some(21_990_000)); - assert_eq!(body.period, Some(1)); + assert_eq!(body.domain.as_deref(), Some("example.com")); + assert_eq!(body.available, Some(true)); + let prices = body.prices.expect("prices present"); + assert_eq!( + prices[0].price.as_ref().and_then(|m| m.value), + Some(11_990_000) + ); } #[tokio::test] - async fn available_with_bearer_scheme_sets_header() { + async fn check_availability_batch_posts_domains_array() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { - when.method(GET) - .path("/v1/domains/available") - .query_param("domain", "open.dev") - .header("authorization", "Bearer tok-abc"); + when.method(POST) + .path("/v3/domains/check-availability") + .json_body(json!({ "domains": ["a.com", "b.com"], "optimizeFor": "SPEED" })); then.status(200).json_body(json!({ - "domain": "open.dev", - "available": true, - "definitive": true + "items": [ + { "domain": "a.com", "available": true }, + { "domain": "b.com", "available": false } + ] })); }) .await; - let client = client_with_auth( - &server.base_url(), - "Bearer tok-abc", - "godaddy-cli/test", - "req-1", - ) - .expect("build client"); - - let body = client - .available() - .domain("open.dev") + let body = client_for(&server) + .check_availability() + .body(types::AvailabilityCheckCriteria { + domains: vec!["a.com".to_string(), "b.com".to_string()], + optimize_for: types::OptimizationTarget::Speed, + isc_code: None, + }) .send() .await .expect("request succeeds") .into_inner(); mock.assert_async().await; - assert!(body.available); - // Optional fields absent in the response deserialize to None. - assert_eq!(body.price, None); - assert_eq!(body.currency, "USD"); // serde default + assert_eq!(body.items.len(), 2); + assert_eq!(body.items[1].available, Some(false)); } + // --- v3: registration (quote → register → poll) ------------------------- + #[tokio::test] - async fn suggest_maps_positional_args_to_named_query_params() { + async fn quote_registration_posts_body_and_parses_token_and_agreements() { let server = MockServer::start_async().await; - // Asserting each value lands in the correctly *named* query param guards - // the builder setter -> wire-parameter mapping (e.g. that `.city(..)` - // really sends `city=`, not some other param) across spec regenerations. let mock = server .mock_async(|when, then| { - when.method(GET) - .path("/v1/domains/suggest") - .query_param("query", "coffee") - .query_param("city", "Phoenix") - .query_param("country", "US") - .query_param("limit", "5") - .query_param("tlds", "com"); - then.status(200).json_body(json!([ - { "domain": "coffeehouse.com" }, - { "domain": "bestcoffee.com" } - ])); + when.method(POST) + .path("/v3/domains/registration-quotes") + .json_body(json!({ "domain": "example.com", "period": 2 })); + then.status(200).json_body(json!({ + "domain": "example.com", + "available": true, + "quoteToken": "tok-abc", + "period": 2, + "price": { "currencyCode": "USD", "value": 23_980_000 }, + "requiredAgreements": [ + { "agreementType": "REGISTRATION", "title": "Registration Agreement", + "url": "https://x/agr" } + ] + })); }) .await; - let client = client_with_auth( - &server.base_url(), - "Bearer tok", - "godaddy-cli/test", - "req-2", - ) - .expect("build client"); - - let suggestions = client - .suggest() - .query("coffee") - .city("Phoenix") - .country(types::SuggestCountry::Us) - .limit(5) - .tlds(vec!["com".to_string()]) + let quote = client_for(&server) + .quote_domain_registration() + .body(types::QuoteDomainRegistrationBody { + domain: "example.com".to_string(), + period: std::num::NonZeroU64::new(2).expect("nonzero"), + profile: None, + profile_id: None, + }) .send() .await .expect("request succeeds") .into_inner(); mock.assert_async().await; - let domains: Vec<&str> = suggestions.iter().map(|s| s.domain.as_str()).collect(); - assert_eq!(domains, ["coffeehouse.com", "bestcoffee.com"]); + assert_eq!(quote.available, Some(true)); + assert_eq!( + quote.quote_token.as_ref().map(|t| t.as_str()), + Some("tok-abc") + ); + let agreements = quote.required_agreements.expect("agreements"); + assert_eq!( + agreements[0].agreement_type.as_ref().map(|a| a.as_str()), + Some("REGISTRATION") + ); } #[tokio::test] - async fn list_tolerates_sparse_payloads() { - // The published spec marks fields like `contactRegistrant`/`renewDeadline` - // required and types `nameServers` as a non-null array, but the live API - // omits the former and returns `nameServers: null` for many domains - // (cancelled/pending). The generated `DomainSummary` must read these - // without erroring. Payload mirrors a real `GET /v1/domains` response. + async fn register_sends_idempotency_key_and_consent_then_accepts_202() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { - when.method(GET).path("/v1/domains"); - then.status(200).json_body(json!([ - { - "createdAt": "2021-09-24T15:08:06.000Z", - "deletedAt": "2024-11-05T02:30:31.000Z", - "domain": "blahblahblah253.com", - "domainId": 21605119, - "expirationProtected": true, - "expires": "2024-09-24T15:08:06.000Z", - "exposeWhois": false, - "holdRegistrar": false, - "locked": true, - "nameServers": null, - "privacy": true, - "renewAuto": false, - "renewable": false, - "status": "CANCELLED", - "transferProtected": true + when.method(POST) + .path("/v3/domains/registrations") + .header("Idempotency-Key", "idem-123") + .json_body(json!({ + "domain": "example.com", + "period": 1, + "quoteToken": "tok-abc", + "consent": { + "agreedAt": "2026-06-30T00:00:00Z", + "agreedBy": { "type": "SHOPPER", "principal": "shopper-42", "ip": "127.0.0.1" }, + "agreementTypes": ["REGISTRATION"] + } + })); + // The register response does NOT echo `quoteToken` (the token is + // single-use and consumed); the client's `Registration` type must + // parse it anyway — quote_token is optional for exactly this reason. + then.status(202).json_body(json!({ + "domain": "example.com", + "period": 1, + "consent": { + "agreedAt": "2026-06-30T00:00:00Z", + "agreedBy": { "type": "SHOPPER", "principal": "shopper-42" }, + "agreementTypes": ["REGISTRATION"] }, - { - "createdAt": "2020-10-27T13:40:15.463Z", - "domain": "dullreferenceexception.me", - "domainId": 21507912, - "expirationProtected": false, - "exposeWhois": false, - "holdRegistrar": false, - "locked": false, - "nameServers": null, - "privacy": false, - "renewAuto": false, - "renewable": false, - "status": "PENDING_DNS_ACTIVE", - "transferProtected": false - } - ])); + "registrationId": "reg-1", + "operationId": "op-1", + "status": "PENDING" + })); }) .await; - let body = client_for(&server) - .list() + let reg = client_for(&server) + .register_domain() + .idempotency_key("idem-123") + .body(types::Registration { + consent: types::Consent { + agreed_at: types::DateTime("2026-06-30T00:00:00Z".to_string()), + agreed_by: types::ConsentActor { + actor: None, + ip: Some("127.0.0.1".to_string()), + principal: "shopper-42".to_string(), + type_: types::ConsentActorType("SHOPPER".to_string()), + }, + agreement_types: vec![types::AgreementType("REGISTRATION".to_string())], + }, + created_at: None, + domain: "example.com".to_string(), + expires_at: None, + links: vec![], + operation_id: None, + period: std::num::NonZeroU64::new(1).expect("nonzero"), + profile: None, + profile_id: None, + quote_token: Some(types::Uuid("tok-abc".to_string())), + registration_id: None, + status: None, + updated_at: None, + }) .send() .await - .expect("sparse list payload parses") + .expect("202 accepted") .into_inner(); mock.assert_async().await; - assert_eq!(body.len(), 2); - } - - // --- DNS records --------------------------------------------------------- - // - // These guard the spec-generated record operations: the HTTP method + path - // (including the `{domain}`/`{type}`/`{name}` path segments and the - // synthesized list-all GET), the JSON request bodies the builder serializes, - // and response parsing. They run entirely offline against a mock server. - - fn client_for(server: &MockServer) -> Client { - client_with_auth( - &server.base_url(), - "Bearer tok", - "godaddy-cli/test", - "req-rec", - ) - .expect("build client") + assert_eq!(reg.operation_id.as_ref().map(|o| o.as_str()), Some("op-1")); } #[tokio::test] - async fn record_get_all_lists_every_record() { - // No type/name -> the synthesized GET on the bare `/records` path. + async fn get_operation_polls_status() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { - when.method(GET) - .path("/v1/domains/example.com/records") - .header("authorization", "Bearer tok"); - then.status(200).json_body(json!([ - { "type": "A", "name": "www", "data": "1.2.3.4", "ttl": 600 }, - { "type": "TXT", "name": "@", "data": "v=spf1 -all" } - ])); + when.method(GET).path("/v3/domains/operations/op-1"); + then.status(200).json_body(json!({ + "operationId": "op-1", + "type": "REGISTER", + "domain": "example.com", + "status": "COMPLETED" + })); }) .await; - let records = client_for(&server) - .record_get_all() - .domain("example.com") + let op = client_for(&server) + .get_operation() + .operation_id(types::Uuid("op-1".to_string())) .send() .await .expect("request succeeds") .into_inner(); mock.assert_async().await; - assert_eq!(records.len(), 2); - assert_eq!(records[0].type_, types::DnsRecordType::A); - assert_eq!(records[0].name, "www"); - assert_eq!(records[0].data, "1.2.3.4"); - assert_eq!(records[0].ttl, Some(600)); - assert_eq!(records[1].type_, types::DnsRecordType::Txt); + assert_eq!(op.status.as_ref().map(|s| s.as_str()), Some("COMPLETED")); } + // --- v3: domain get + nameservers + dns create -------------------------- + #[tokio::test] - async fn record_get_sends_type_name_and_pagination() { + async fn get_domain_reads_v3_path() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method(GET) - .path("/v1/domains/example.com/records/A/www") - .query_param("limit", "10") - .query_param("offset", "5"); - then.status(200) - .json_body(json!([{ "type": "A", "name": "www", "data": "1.2.3.4" }])); + .path("/v3/domains/domain-names/example.com"); + then.status(200).json_body(json!({ + "domain": "example.com", + "status": "ACTIVE", + "autoRenew": true + })); }) .await; - let records = client_for(&server) - .record_get() - .domain("example.com") - .type_("A") - .name("www") - .limit(10) - .offset(5) + let detail = client_for(&server) + .get_domain() + .domain_name("example.com") .send() .await .expect("request succeeds") .into_inner(); mock.assert_async().await; - assert_eq!(records.len(), 1); - assert_eq!(records[0].data, "1.2.3.4"); + assert_eq!(detail.domain.as_deref(), Some("example.com")); + assert_eq!(detail.auto_renew, Some(true)); } #[tokio::test] - async fn record_add_patches_a_record_array() { + async fn create_dns_record_posts_single_record_to_zone() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { - when.method(PATCH) - .path("/v1/domains/example.com/records") - .json_body(json!([{ "data": "1.2.3.4", "name": "www", "type": "A" }])); - then.status(200); + when.method(POST) + .path("/v3/domains/zones/example.com/dns-records") + .json_body( + json!({ "type": "A", "name": "www", "data": "1.2.3.4", "ttl": 600 }), + ); + then.status(201).json_body( + json!({ "type": "A", "name": "www", "data": "1.2.3.4", "ttl": 600 }), + ); }) .await; - client_for(&server) - .record_add() - .domain("example.com") - .body(vec![types::DnsRecord { + let rec = client_for(&server) + .create_dns_record() + .zone("example.com") + .body(types::DnsRecord { data: "1.2.3.4".to_string(), + flag: None, name: "www".to_string(), - type_: types::DnsRecordType::A, - ttl: None, - priority: None, port: None, - weight: None, + priority: None, protocol: None, + record_id: None, service: None, - }]) + tag: None, + ttl: 600, + type_: types::DnsRecordType("A".to_string()), + weight: None, + }) .send() .await - .expect("request succeeds"); + .expect("request succeeds") + .into_inner(); mock.assert_async().await; + assert_eq!(rec.name, "www"); + assert_eq!(rec.ttl, 600); } #[tokio::test] - async fn record_replace_type_name_puts_the_record_set() { + async fn update_nameservers_puts_hostname_array() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method(PUT) - .path("/v1/domains/example.com/records/A/www") - .json_body(json!([{ "data": "5.6.7.8", "ttl": 600 }])); - then.status(200); + .path("/v3/domains/domain-names/example.com/nameservers") + .header("Idempotency-Key", "idem-9") + .json_body(json!(["ns1.example.net", "ns2.example.net"])); + then.status(202) + .json_body(json!({ "operationId": "op-2", "type": "UPDATE_NAMESERVERS" })); }) .await; - client_for(&server) - .record_replace_type_name() - .domain("example.com") - .type_("A") - .name("www") - .body(vec![types::DnsRecordCreateTypeName { - data: "5.6.7.8".to_string(), - ttl: Some(600), - priority: None, - port: None, - weight: None, - protocol: None, - service: None, - }]) + let op = client_for(&server) + .update_nameservers() + .domain_name("example.com") + .idempotency_key("idem-9") + .body(types::NameServers(vec![ + types::NameserverHostname("ns1.example.net".to_string()), + types::NameserverHostname("ns2.example.net".to_string()), + ])) .send() .await - .expect("request succeeds"); + .expect("202 accepted") + .into_inner(); mock.assert_async().await; + assert_eq!(op.operation_id.as_ref().map(|o| o.as_str()), Some("op-2")); } + // --- retained v1: list + agreements + DNS list/set/delete --------------- + #[tokio::test] - async fn record_delete_type_name_issues_delete_and_accepts_204() { + async fn v1_list_tolerates_sparse_payloads() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { - when.method(DELETE) - .path("/v1/domains/example.com/records/A/www"); - then.status(204); + when.method(GET).path("/v1/domains"); + then.status(200).json_body(json!([ + { "domain": "a.com", "status": "ACTIVE", "nameServers": null }, + { "domain": "b.me", "status": "PENDING_DNS_ACTIVE", "nameServers": null } + ])); }) .await; - client_for(&server) - .record_delete_type_name() - .domain("example.com") - .type_("A") - .name("www") + let body = client_for(&server) + .list() .send() .await - .expect("request succeeds"); + .expect("sparse list parses") + .into_inner(); mock.assert_async().await; + assert_eq!(body.len(), 2); + assert_eq!(body[0].domain.as_deref(), Some("a.com")); } - // --- agreements + purchase ---------------------------------------------- - // - // The legal-agreements GET (the consent prerequisite) and the purchase POST. - // These guard the agreements query-param names, the JSON request body the - // purchase builder serializes (domain + consent + the always-present period/ - // privacy/renewAuto, contacts omitted), and response parsing. Offline. - #[tokio::test] - async fn agreements_sends_query_params_and_parses_list() { + async fn v1_agreements_sends_query_params_and_parses_list() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { when.method(GET) .path("/v1/domains/agreements") .query_param("tlds", "com") - .query_param("privacy", "false") - .query_param("forTransfer", "false"); + .query_param("privacy", "false"); then.status(200).json_body(json!([ - { - "agreementKey": "DNRA", - "title": "Domain Name Registration Agreement", - "url": "https://www.godaddy.com/agreements/showdoc?id=reg_sa", - "content": "full text" - } + { "agreementKey": "DNRA", "title": "Registration Agreement", "url": "https://x" } ])); }) .await; @@ -474,256 +492,123 @@ mod tests { .agreements() .tlds(vec!["com".to_string()]) .privacy(false) - .for_transfer(false) .send() .await .expect("request succeeds") .into_inner(); mock.assert_async().await; - assert_eq!(agreements.len(), 1); assert_eq!(agreements[0].agreement_key.as_deref(), Some("DNRA")); - assert_eq!( - agreements[0].title.as_deref(), - Some("Domain Name Registration Agreement") - ); } #[tokio::test] - async fn purchase_serializes_body_and_parses_order() { + async fn v1_record_get_all_lists_records() { let server = MockServer::start_async().await; - // Contacts are omitted (account defaults) so they don't appear in the - // body; period/privacy/renewAuto always serialize (serde defaults, no - // skip), which guards their wire names (`period`/`privacy`/`renewAuto`). let mock = server .mock_async(|when, then| { - when.method(POST) - .path("/v1/domains/purchase") - .json_body(json!({ - "domain": "example.com", - "consent": { - "agreedAt": "2026-06-17T00:00:00Z", - "agreedBy": "203.0.113.7", - "agreementKeys": ["DNRA"] - }, - "period": 1, - "privacy": false, - "renewAuto": true - })); - then.status(200).json_body(json!({ - "orderId": 1_234_567, - "itemCount": 1, - "total": 11_990_000, - "currency": "USD" - })); + when.method(GET).path("/v1/domains/example.com/records"); + then.status(200).json_body( + json!([{ "type": "A", "name": "www", "data": "1.2.3.4", "ttl": 600 }]), + ); }) .await; - let body = client_for(&server) - .purchase() - .body(types::DomainPurchase { - domain: "example.com".to_string(), - consent: types::Consent { - agreed_at: "2026-06-17T00:00:00Z".to_string(), - agreed_by: "203.0.113.7".to_string(), - agreement_keys: vec!["DNRA".to_string()], - }, - contact_registrant: None, - contact_admin: None, - contact_billing: None, - contact_tech: None, - name_servers: vec![], - period: std::num::NonZeroU64::new(1).expect("nonzero period"), - privacy: false, - renew_auto: true, - }) + let records = client_for(&server) + .record_get_all() + .domain("example.com") .send() .await .expect("request succeeds") .into_inner(); mock.assert_async().await; - assert_eq!(body.order_id, Some(1_234_567)); - assert_eq!(body.item_count, Some(1)); - assert_eq!(body.total, Some(11_990_000)); - assert_eq!(body.currency, "USD"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].data.as_deref(), Some("1.2.3.4")); } #[tokio::test] - async fn schema_fetches_per_tld_requirements_as_free_form_json() { - // The per-TLD purchase schema is returned untyped (a serde_json map), so - // `domain purchase` can read just the top-level `required` array. This - // guards the `{tld}` path param and the free-form response decode. + async fn v1_record_replace_type_name_puts_record_set() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { - when.method(GET).path("/v1/domains/purchase/schema/fun"); - then.status(200).json_body(json!({ - "id": "fun", - "required": ["domain", "consent", "contactRegistrant"], - "properties": {}, - "models": {} - })); + when.method(PUT) + .path("/v1/domains/example.com/records/A/www") + .json_body(json!([{ "data": "5.6.7.8", "ttl": 600 }])); + then.status(200); }) .await; - let schema = client_for(&server) - .schema() - .tld("fun") + client_for(&server) + .record_replace_type_name() + .domain("example.com") + .type_("A") + .name("www") + .body(vec![types::V1dnsRecordCreateTypeName { + data: "5.6.7.8".to_string(), + port: None, + priority: None, + protocol: None, + service: None, + ttl: Some(600), + weight: None, + }]) .send() .await - .expect("request succeeds") - .into_inner(); + .expect("request succeeds"); mock.assert_async().await; - let required: Vec<&str> = schema - .get("required") - .and_then(|v| v.as_array()) - .expect("required array") - .iter() - .filter_map(|v| v.as_str()) - .collect(); - assert!(required.contains(&"contactRegistrant")); } #[tokio::test] - async fn register_v2_posts_to_customer_path_and_accepts_202() { - // The OAuth purchase path: POST to the customer-scoped v2 register with a - // DomainPurchaseV2 body, returning a bodyless 202. This guards the - // `{customerId}` path segment, the serialized request shape the domains - // API receives (consent with price/currency, the v2 contact with its - // ASCII encoding), and the no-body 2xx decode. + async fn v1_record_delete_type_name_issues_delete() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { - when.method(POST) - .path("/v2/customers/cust-123/domains/register") - .json_body(json!({ - "domain": "example.fun", - "consent": { - "agreedAt": "2026-06-18T00:00:00Z", - "agreedBy": "127.0.0.1", - "agreementKeys": ["DNRA"], - "currency": "USD", - "price": 11_990_000 - }, - "period": 1, - "privacy": false, - "renewAuto": true, - "contacts": { - "registrant": { - "addressMailing": { - "address1": "1 A St", - "city": "Tempe", - "country": "US", - "postalCode": "85281", - "state": "AZ" - }, - "email": "a@example.com", - "encoding": "ASCII", - "nameFirst": "Ada", - "nameLast": "Lovelace", - "phone": "+1.4805551212" - } - } - })); - then.status(202); + when.method(DELETE) + .path("/v1/domains/example.com/records/A/www"); + then.status(204); }) .await; client_for(&server) - .register() - .customer_id("cust-123") - .body(types::DomainPurchaseV2 { - domain: "example.fun".to_string(), - consent: types::ConsentV2 { - agreed_at: "2026-06-18T00:00:00Z".to_string(), - agreed_by: "127.0.0.1".to_string(), - agreement_keys: vec!["DNRA".to_string()], - claim_token: None, - currency: "USD".to_string(), - price: 11_990_000, - registry_premium_pricing: None, - }, - contacts: Some(types::DomainContactsCreateV2 { - registrant: Some(types::ContactDomainCreate { - address_mailing: types::Address { - address1: "1 A St".to_string(), - address2: None, - city: "Tempe".to_string(), - country: types::AddressCountry::Us, - postal_code: "85281".to_string(), - state: "AZ".to_string(), - }, - email: "a@example.com".to_string(), - encoding: types::ContactDomainCreateEncoding::Ascii, - fax: None, - job_title: None, - metadata: Default::default(), - name_first: "Ada".to_string(), - name_last: "Lovelace".to_string(), - name_middle: None, - organization: None, - phone: "+1.4805551212".to_string(), - }), - registrant_id: None, - admin: None, - admin_id: None, - billing: None, - billing_id: None, - tech: None, - tech_id: None, - }), - metadata: Default::default(), - name_servers: vec![], - period: std::num::NonZeroU64::new(1).expect("nonzero period"), - privacy: false, - renew_auto: true, - }) + .record_delete_type_name() + .domain("example.com") + .type_("A") + .name("www") .send() .await - .expect("202 accepted"); + .expect("request succeeds"); mock.assert_async().await; } + // Guard the auth-scheme selection retained from the hand-written helper. #[tokio::test] - async fn get_returns_domain_detail_as_free_form_json() { - // `domain get` reads one domain's details. The response is decoded - // free-form (a serde_json map) so it tolerates sparse/privacy-masked - // contacts the typed DomainDetail would reject; the command emits it - // as-is. Guards the `{domain}` path segment and the free-form decode. + async fn sso_key_scheme_sets_authorization_header() { let server = MockServer::start_async().await; let mock = server .mock_async(|when, then| { - when.method(GET).path("/v1/domains/example.com"); - then.status(200).json_body(json!({ - "domain": "example.com", - "domainId": 12345, - "status": "ACTIVE", - "expires": "2027-06-18T00:00:00.000Z", - "renewAuto": true, - "nameServers": ["ns1.example.net", "ns2.example.net"] - })); + when.method(GET) + .path("/v3/domains/check-availability") + .header("authorization", "sso-key KEY:SECRET"); + then.status(200) + .json_body(json!({ "domain": "x.com", "available": true })); }) .await; - let detail = client_for(&server) - .get() - .domain("example.com") - .send() - .await - .expect("request succeeds") - .into_inner(); + client_with_auth( + &server.base_url(), + "sso-key KEY:SECRET", + "godaddy-cli/test", + "req-1", + ) + .expect("build client") + .get_domain_availability() + .domain("x.com") + .send() + .await + .expect("request succeeds"); mock.assert_async().await; - assert_eq!( - detail.get("domain").and_then(|v| v.as_str()), - Some("example.com") - ); - assert_eq!( - detail.get("status").and_then(|v| v.as_str()), - Some("ACTIVE") - ); } } diff --git a/rust/src/contacts/mod.rs b/rust/src/contacts/mod.rs index ab71edf..abffebd 100644 --- a/rust/src/contacts/mod.rs +++ b/rust/src/contacts/mod.rs @@ -45,12 +45,15 @@ pub struct ContactsFile { pub tech: Option, } -/// One contact's details. Field names mirror the Domains API `Contact`/`Address` -/// schema (in snake_case), so a complete entry maps directly to a request -/// contact. The fields the API requires (`name_first`/`name_last`/`email`/`phone` -/// and the mailing-address `address1`/`city`/`state`/`postal_code`/`country`) are -/// mandatory here too — a partial entry fails to parse rather than producing an -/// invalid request. +/// One contact's details. Field names mirror the contact/address fields, so a +/// complete entry maps directly to a v3 request contact. The fields v3 requires +/// (`name_first`/`name_last`/`email`/`phone` and the address +/// `address1`/`city`/`state`/`postal_code`/`country`) are mandatory here too — a +/// partial entry fails to parse rather than producing an invalid request. +/// +/// v3's `Contact` has no middle name, job title, or fax; a `name_middle`/ +/// `job_title`/`fax` key left over from a v1/v2-era file is accepted (serde +/// ignores unknown keys) but not sent. #[derive(Debug, Clone, Deserialize)] pub struct Contact { pub name_first: String, @@ -58,13 +61,7 @@ pub struct Contact { pub email: String, pub phone: String, #[serde(default)] - pub name_middle: Option, - #[serde(default)] pub organization: Option, - #[serde(default)] - pub job_title: Option, - #[serde(default)] - pub fax: Option, pub address1: String, #[serde(default)] pub address2: Option, @@ -96,80 +93,53 @@ impl Role { } impl Contact { - /// Convert to the v2 API contact (`ContactDomainCreate`), validating the - /// country code. Returns a human-readable error (surfaced by - /// `domain purchase`) when the country is not a recognized two-letter ISO - /// code. `encoding` is reported honestly: `ASCII` when every field is ASCII, - /// else `UTF-8`, so accented names/addresses aren't mislabeled. - pub fn to_api(&self, role: Role) -> Result { + /// Convert to the v3 API contact (`Contact`), validating the country code. + /// Returns a human-readable error (surfaced by `domain purchase`/`quote`) when + /// the country is not a recognized two-letter ISO code or the phone can't be + /// parsed. + /// + /// v3's `Contact` is leaner than v2's: it has no middle name, job title, fax, + /// or character-encoding field, and the phone is a structured object + /// (`countryCode`/`nationalNumber`) rather than a dotted `+1.4805551212` + /// string. The `name_middle`/`job_title`/`fax` columns in `contacts.toml` are + /// kept in the file schema for backward compatibility but are not sent to v3. + pub fn to_api(&self, role: Role) -> Result { let country_upper = self.country.to_ascii_uppercase(); - let country = api::AddressCountry::try_from(country_upper.as_str()).map_err(|_| { - format!( - "{} contact in contacts.toml has invalid country {:?} \ - (expected a two-letter ISO code, e.g. US)", - role.label(), - self.country, - ) - })?; - let encoding = if self.is_all_ascii() { - api::ContactDomainCreateEncoding::Ascii - } else { - api::ContactDomainCreateEncoding::Utf8 - }; - // Normalize phone (required) and fax (optional, only when present) to the - // API's `+.` format so common inputs aren't rejected with a - // cryptic 422. - let phone = normalize_phone(&self.phone, &country_upper, "phone", role)?; - let fax = match empty_to_none(&self.fax) { - Some(f) => Some(normalize_phone(&f, &country_upper, "fax", role)?), - None => None, - }; - Ok(api::ContactDomainCreate { - address_mailing: api::Address { - address1: self.address1.clone(), - address2: empty_to_none(&self.address2), + validate_country(&country_upper, role)?; + let phone = to_api_phone(&self.phone, &country_upper, role)?; + Ok(api::Contact { + address: api::SimpleAddress { + line1: self.address1.clone(), + line2: empty_to_none(&self.address2), city: self.city.clone(), - country, - postal_code: self.postal_code.clone(), - state: self.state.clone(), + state: Some(self.state.clone()), + postal_code: Some(self.postal_code.clone()), + country_code: api::CountryCode(country_upper), }, - email: self.email.clone(), - encoding, - fax, - job_title: empty_to_none(&self.job_title), - metadata: Default::default(), - name_first: self.name_first.clone(), - name_last: self.name_last.clone(), - name_middle: empty_to_none(&self.name_middle), + email: api::EmailAddress(self.email.clone()), + first_name: self.name_first.clone(), + last_name: self.name_last.clone(), organization: empty_to_none(&self.organization), phone, }) } +} - /// Whether every populated field is ASCII (drives the `encoding` we report). - fn is_all_ascii(&self) -> bool { - let required = [ - &self.name_first, - &self.name_last, - &self.email, - &self.phone, - &self.address1, - &self.city, - &self.state, - &self.postal_code, - &self.country, - ]; - let optional = [ - &self.name_middle, - &self.organization, - &self.job_title, - &self.fax, - &self.address2, - ]; - required.iter().all(|s| s.is_ascii()) - && optional - .iter() - .all(|o| o.as_deref().is_none_or(str::is_ascii)) +/// Validate a (already upper-cased) country code is a two-letter ISO code (or the +/// special `C2`), preserving the clear early error the v2 path gave before the +/// strict `AddressCountry` enum was dropped from the generated client. +fn validate_country(country_upper: &str, role: Role) -> Result<(), String> { + let ok = country_upper == "C2" + || (country_upper.len() == 2 && country_upper.bytes().all(|b| b.is_ascii_uppercase())); + if ok { + Ok(()) + } else { + Err(format!( + "{} contact in contacts.toml has invalid country {:?} \ + (expected a two-letter ISO code, e.g. US)", + role.label(), + country_upper, + )) } } @@ -187,30 +157,33 @@ fn empty_to_none(value: &Option) -> Option { .map(str::to_owned) } -/// Normalize a user-entered phone or fax number to the Domains API format -/// `+.` (e.g. `+1.4805551212`), matching the -/// API's required pattern `^\+([0-9]){1,3}\.([0-9] ?){5,14}$`. +/// Parse a user-entered phone number into v3's structured `Phone` +/// (`countryCode` = the calling code digits, e.g. `44`; `nationalNumber` = the +/// national digits, e.g. `7793601890`). /// /// `region` is the contact's two-letter ISO country, used as the default region so /// a number typed without a `+` country prefix (e.g. a local `07793 601890`) still /// parses. Returns a human-readable error (surfaced by `domain purchase`, like the /// invalid-country error) when the value can't be parsed as a phone number, rather /// than forwarding it and letting the API reject it opaquely. -fn normalize_phone(raw: &str, region: &str, field: &str, role: Role) -> Result { +fn to_api_phone(raw: &str, region: &str, role: Role) -> Result { let region_id = region.parse::().ok(); let number = phonenumber::parse(region_id, raw).map_err(|_| { format!( - "{} contact in contacts.toml has an unrecognized {} {:?} \ + "{} contact in contacts.toml has an unrecognized phone {:?} \ (expected a phone number like +1.4805551212)", role.label(), - field, raw, ) })?; // `NationalNumber`'s Display preserves significant leading zeros (e.g. Italy), // so this is correct for every region, not just those whose national number is // a plain integer. - Ok(format!("+{}.{}", number.code().value(), number.national())) + Ok(api::Phone { + country_code: Some(number.code().value().to_string()), + national_number: Some(number.national().to_string()), + extension_number: None, + }) } impl ContactsFile { @@ -224,10 +197,10 @@ impl ContactsFile { } } - /// The v2 API contact for a role: `None` when the role is absent (→ omit + /// The v3 API contact for a role: `None` when the role is absent (→ omit /// from the request → account default), or an error when the configured - /// country is invalid. - pub fn to_api(&self, role: Role) -> Result, String> { + /// country or phone is invalid. + pub fn to_api(&self, role: Role) -> Result, String> { self.get(role).map(|c| c.to_api(role)).transpose() } } @@ -272,8 +245,8 @@ pub fn sample_toml() -> &'static str { # To use a role, uncomment its block below and replace the placeholder values. # Required fields per role: name_first, name_last, email, phone, address1, city, # state, postal_code, country (a two-letter ISO code). Optional fields: -# name_middle, organization, job_title, fax, address2. A role you uncomment must -# have all required fields or the file will fail to load. +# organization, address2. A role you uncomment must have all required fields or +# the file will fail to load. # [registrant] # name_first = "Jane" @@ -345,41 +318,23 @@ country = "US" .to_api(Role::Registrant) .expect("valid country") .expect("present"); - assert_eq!(registrant.name_first, "Ada"); - assert_eq!(registrant.name_last, "Lovelace"); - assert_eq!(registrant.address_mailing.city, "Tempe"); - // Lower-case "us" in the file resolves to the uppercased ISO enum variant. - assert_eq!(registrant.address_mailing.country, api::AddressCountry::Us); + assert_eq!(registrant.first_name, "Ada"); + assert_eq!(registrant.last_name, "Lovelace"); + assert_eq!(registrant.address.city, "Tempe"); + assert_eq!(registrant.address.state.as_deref(), Some("AZ")); + assert_eq!(registrant.address.postal_code.as_deref(), Some("85281")); + // Lower-case "us" in the file resolves to the uppercased ISO code. + assert_eq!(registrant.address.country_code.as_str(), "US"); + assert_eq!(registrant.email.as_str(), "ada@example.com"); // An absent role resolves to None (→ account default). assert!(file.to_api(Role::Admin).expect("ok").is_none()); - // All-ASCII contact data is reported as ASCII encoding. - assert_eq!(registrant.encoding, api::ContactDomainCreateEncoding::Ascii); } #[test] - fn to_api_reports_utf8_encoding_for_non_ascii_fields() { - let toml = r#" -[registrant] -name_first = "José" -name_last = "Núñez" -email = "jose@example.com" -phone = "+34.911111111" -address1 = "1 Calle" -city = "Madrid" -state = "M" -postal_code = "28001" -country = "ES" -"#; - let file: ContactsFile = toml::from_str(toml).expect("parses"); - let c = file - .to_api(Role::Registrant) - .expect("valid country") - .expect("present"); - assert_eq!(c.encoding, api::ContactDomainCreateEncoding::Utf8); - } - - #[test] - fn to_api_rejects_unknown_country() { + fn to_api_rejects_malformed_country() { + // A non-two-letter code is rejected early with a clear message. (ISO + // membership beyond the two-letter shape is validated server-side now that + // the strict country enum is gone from the generated client.) let toml = r#" [registrant] name_first = "Ada" @@ -390,18 +345,18 @@ address1 = "1 Bletchley Park" city = "Tempe" state = "AZ" postal_code = "85281" -country = "ZZ" +country = "USA" "#; let file: ContactsFile = toml::from_str(toml).expect("parses"); let err = file .to_api(Role::Registrant) - .expect_err("ZZ is not a valid ISO country"); + .expect_err("USA is not a two-letter code"); assert!(err.contains("invalid country")); assert!(err.contains("registrant")); } /// Build a single-registrant `ContactsFile` from a `[registrant]` TOML body. - fn registrant(body: &str) -> api::ContactDomainCreate { + fn registrant(body: &str) -> api::Contact { let toml = format!("[registrant]\n{body}"); let file: ContactsFile = toml::from_str(&toml).expect("parses"); file.to_api(Role::Registrant) @@ -420,17 +375,26 @@ country = "GB" "#; #[test] - fn gb_phone_common_formats_normalize_to_dotted() { + fn gb_phone_common_formats_parse_to_structured_phone() { // International (no separator), international (spaced), and bare local — - // all the same UK number — normalize to the API's `+44.7793601890`. + // all the same UK number — parse to countryCode `44` + national `7793601890`. for input in ["+447793601890", "+44 7793 601890", "07793 601890"] { let c = registrant(&format!("{GB_BASE}phone = \"{input}\"\n")); - assert_eq!(c.phone, "+44.7793601890", "input {input:?}"); + assert_eq!( + c.phone.country_code.as_deref(), + Some("44"), + "input {input:?}" + ); + assert_eq!( + c.phone.national_number.as_deref(), + Some("7793601890"), + "input {input:?}" + ); } } #[test] - fn us_phone_normalizes_to_dotted() { + fn us_phone_parses_to_structured_phone() { let body = r#"name_first = "Ada" name_last = "Lovelace" email = "ada@example.com" @@ -441,13 +405,16 @@ state = "AZ" postal_code = "85281" country = "US" "#; - assert_eq!(registrant(body).phone, "+1.4805551212"); + let c = registrant(body); + assert_eq!(c.phone.country_code.as_deref(), Some("1")); + assert_eq!(c.phone.national_number.as_deref(), Some("4805551212")); } #[test] - fn already_dotted_phone_is_preserved() { + fn already_dotted_phone_is_parsed() { let c = registrant(&format!("{GB_BASE}phone = \"+1.4805551212\"\n")); - assert_eq!(c.phone, "+1.4805551212"); + assert_eq!(c.phone.country_code.as_deref(), Some("1")); + assert_eq!(c.phone.national_number.as_deref(), Some("4805551212")); } #[test] @@ -470,7 +437,7 @@ country = "US" let c = registrant(&format!( "{GB_BASE}phone = \"+44.7793601890\"\naddress2 = {blank}\n" )); - assert_eq!(c.address_mailing.address2, None, "blank {blank}"); + assert_eq!(c.address.line2, None, "blank {blank}"); } } @@ -479,26 +446,18 @@ country = "US" let c = registrant(&format!( "{GB_BASE}phone = \"+44.7793601890\"\naddress2 = \"Flat 2\"\n" )); - assert_eq!(c.address_mailing.address2.as_deref(), Some("Flat 2")); + assert_eq!(c.address.line2.as_deref(), Some("Flat 2")); } #[test] - fn blank_optional_text_fields_are_omitted() { + fn blank_optional_org_is_omitted() { + // v3's Contact carries only `organization` among the optional text fields; + // job_title/name_middle/fax remain accepted in the file (for compatibility) + // but are not sent. A blank organization must be omitted, not sent as "". let c = registrant(&format!( "{GB_BASE}phone = \"+44.7793601890\"\norganization = \"\"\njob_title = \"\"\nname_middle = \"\"\nfax = \"\"\n" )); assert_eq!(c.organization, None); - assert_eq!(c.job_title, None); - assert_eq!(c.name_middle, None); - assert_eq!(c.fax, None); - } - - #[test] - fn present_fax_is_normalized() { - let c = registrant(&format!( - "{GB_BASE}phone = \"+44.7793601890\"\nfax = \"07793 601891\"\n" - )); - assert_eq!(c.fax.as_deref(), Some("+44.7793601891")); } #[test] diff --git a/rust/src/dns/mod.rs b/rust/src/dns/mod.rs index 3c27307..414eb1b 100644 --- a/rust/src/dns/mod.rs +++ b/rust/src/dns/mod.rs @@ -1,16 +1,19 @@ //! `gddy dns` — manage a domain's DNS records (A, AAAA, CNAME, MX, TXT, …). //! -//! These are the record operations of the same GoDaddy Domains API the -//! [`crate::domain`] commands use (paths under `/v1/domains/{domain}/records`), -//! served by the typed, spec-generated [`domains_client`] crate. Auth, the -//! base-URL/environment resolution, and the sso-key/Bearer scheme selection are -//! shared with `domain` via [`crate::domain::make_client`]. +//! These records span two API generations of the Domains API (see the +//! [`domains_client`] crate), sharing auth/env resolution with `domain` via +//! [`crate::domain::make_client`]: +//! +//! * **`add`** creates a single record via the v3 endpoint +//! (`POST /v3/domains/zones/{zone}/dns-records`); one API call per `--data` +//! value, and `ttl` is required (defaults to 3600 when omitted). +//! * **`list` / `set` / `delete`** use the retained v1 record endpoints +//! (`/v1/domains/{domain}/records…`), which v3 does not yet serve. //! //! Reads (`list`) require the `domains.domain:read` scope; mutations (`add`, //! `set`, `delete`) require `domains.dns:update`. `set` and `delete` are //! [`Tier::Destructive`] — they overwrite or remove existing records — so -//! `--dry-run` short-circuits them with a preview, and they carry the engine's -//! global `--reason` flag for audit (`add` only appends, so it is `Mutate`). +//! `--dry-run` short-circuits them with a preview. use cli_engine::{ CliCoreError, CommandContext, CommandResult, CommandSpec, GroupSpec, Module, @@ -18,13 +21,12 @@ use cli_engine::{ }; use serde_json::{Value, json}; -use crate::domain::{DOMAINS_DNS_UPDATE_SCOPE, DOMAINS_READ_SCOPE, make_client, string_list}; +use crate::domain::{make_client, string_list}; use crate::output_schema::output_schema; +use crate::scopes::{DOMAINS_DNS_UPDATE, DOMAINS_READ}; use domains_client::types; -// Output shapes for the mutating commands (their handlers emit these confirmation -// objects), registered so `--help`/`--schema` list the fields like the reads do. output_schema!(DnsWriteResult { "domain": "string"; "type": "string"; @@ -40,10 +42,12 @@ output_schema!(DnsDeleteResult { "deleted": "bool"; }); -/// The eight DNS record types the Domains API accepts, for help text and error -/// messages. Source of truth for the wire values is the generated -/// [`types::DnsRecordType`]. -const RECORD_TYPES: &str = "A, AAAA, CNAME, MX, NS, SOA, SRV, TXT"; +/// The DNS record types the Domains API accepts. +const RECORD_TYPES: &[&str] = &["A", "AAAA", "CNAME", "MX", "NS", "SOA", "SRV", "TXT"]; +/// Record types `dns delete` can remove — NS/SOA are GoDaddy-managed. +const DELETABLE_TYPES: &[&str] = &["A", "AAAA", "CNAME", "MX", "SRV", "TXT"]; +/// Default TTL (seconds) for `dns add` when `--ttl` is omitted (v3 requires a ttl). +const DEFAULT_TTL: i64 = 3600; fn arg_str(ctx: &CommandContext, key: &str) -> Option { ctx.args @@ -53,37 +57,38 @@ fn arg_str(ctx: &CommandContext, key: &str) -> Option { } /// clap value-parser for `--type`: validate against the record-type set and -/// return the canonical upper-case wire string. -/// -/// Validating in clap (rather than the handler) means invalid input is rejected -/// at parse time — *before* cli-engine's `--dry-run`/auth short-circuits — so -/// e.g. `dns set … --type BOGUS --dry-run` fails instead of reporting success. -/// The handlers can then trust the value and feed it straight to the builders. +/// return the canonical upper-case wire string. Validating in clap (rather than +/// the handler) rejects invalid input at parse time — before `--dry-run`/auth +/// short-circuits. fn parse_type_arg(raw: &str) -> Result { let upper = raw.to_ascii_uppercase(); - types::DnsRecordType::try_from(upper.as_str()) - .map(|_| upper) - .map_err(|_| format!("invalid record type {raw:?}; expected one of {RECORD_TYPES}")) + if RECORD_TYPES.contains(&upper.as_str()) { + Ok(upper) + } else { + Err(format!( + "invalid record type {raw:?}; expected one of {}", + RECORD_TYPES.join(", ") + )) + } } /// Like [`parse_type_arg`], but for `dns delete`: rejects the registry-managed -/// NS/SOA types the records API can't delete (`recordDeleteTypeName`'s type set -/// omits them), with a clear reason — at parse time, so -/// `dns delete … --type NS --dry-run` fails too. +/// NS/SOA types with a clear reason (at parse time). fn parse_deletable_type_arg(raw: &str) -> Result { let upper = raw.to_ascii_uppercase(); - if types::RecordDeleteTypeNameType::try_from(upper.as_str()).is_ok() { + if DELETABLE_TYPES.contains(&upper.as_str()) { return Ok(upper); } - // Distinguish "valid type, just not deletable" from "not a type at all". - if types::DnsRecordType::try_from(upper.as_str()).is_ok() { + if RECORD_TYPES.contains(&upper.as_str()) { Err(format!( "{upper} records can't be deleted (NS and SOA records are managed by GoDaddy); \ - deletable types: A, AAAA, CNAME, MX, SRV, TXT" + deletable types: {}", + DELETABLE_TYPES.join(", ") )) } else { Err(format!( - "invalid record type {raw:?}; expected one of A, AAAA, CNAME, MX, SRV, TXT" + "invalid record type {raw:?}; expected one of {}", + DELETABLE_TYPES.join(", ") )) } } @@ -92,7 +97,7 @@ fn parse_deletable_type_arg(raw: &str) -> Result { struct RecordOptions { ttl: Option, priority: Option, - port: Option, + port: Option, weight: Option, protocol: Option, service: Option, @@ -104,11 +109,7 @@ impl RecordOptions { RecordOptions { ttl: as_i64("ttl"), priority: as_i64("priority"), - // The parser bounds --port to 1..=65535, so a non-zero value always - // fits NonZeroU64; `new` keeps it total without an unwrap. - port: as_i64("port") - .and_then(|p| u64::try_from(p).ok()) - .and_then(std::num::NonZeroU64::new), + port: as_i64("port"), weight: as_i64("weight"), protocol: arg_str(ctx, "protocol"), service: arg_str(ctx, "service"), @@ -116,43 +117,49 @@ impl RecordOptions { } } -/// Build full `DnsRecord`s (type + name + data) for `add` — one per `--data`. -fn dns_records( +/// Build a v3 `DnsRecord` (for `add`) — one per `--data` value. `ttl` is required +/// by v3, so an omitted `--ttl` uses [`DEFAULT_TTL`]. The numeric SRV/MX fields are +/// clamped into the v3 `u16` domain (the clap parsers already bound them). +fn v3_records( name: &str, - ty: types::DnsRecordType, + ty: &str, data: &[String], opts: &RecordOptions, ) -> Vec { + let to_u16 = |v: Option| v.and_then(|n| u16::try_from(n).ok()); data.iter() .map(|d| types::DnsRecord { data: d.clone(), + flag: None, name: name.to_owned(), - type_: ty, - ttl: opts.ttl, - priority: opts.priority, - port: opts.port, - weight: opts.weight, + port: to_u16(opts.port), + priority: to_u16(opts.priority), protocol: opts.protocol.clone(), + record_id: None, service: opts.service.clone(), + tag: None, + ttl: opts.ttl.unwrap_or(DEFAULT_TTL), + type_: types::DnsRecordType(ty.to_owned()), + weight: to_u16(opts.weight), }) .collect() } -/// Build the type+name-relative record bodies for `set` (the type and name come -/// from the URL path, so they are omitted here) — one per `--data`. -fn create_type_name_records( - data: &[String], - opts: &RecordOptions, -) -> Vec { +/// Build the v1 type+name-relative record bodies for `set` (the type and name +/// come from the URL path) — one per `--data`. +fn v1_set_records(data: &[String], opts: &RecordOptions) -> Vec { data.iter() - .map(|d| types::DnsRecordCreateTypeName { + .map(|d| types::V1dnsRecordCreateTypeName { data: d.clone(), - ttl: opts.ttl, + port: opts + .port + .and_then(|p| u64::try_from(p).ok()) + .and_then(std::num::NonZeroU64::new), priority: opts.priority, - port: opts.port, - weight: opts.weight, protocol: opts.protocol.clone(), service: opts.service.clone(), + ttl: opts.ttl, + weight: opts.weight, }) .collect() } @@ -194,13 +201,13 @@ fn with_record_write_args(spec: CommandSpec) -> CommandSpec { .long("ttl") .value_name("SECONDS") .value_parser(clap::value_parser!(i64).range(1..)) - .help("Time-to-live in seconds"), + .help("Time-to-live in seconds (add: defaults to 3600)"), ) .with_arg( clap::Arg::new("priority") .long("priority") .value_name("N") - .value_parser(clap::value_parser!(i64).range(0..)) + .value_parser(clap::value_parser!(i64).range(0..=65535)) .help("Record priority (MX and SRV only)"), ) .with_arg( @@ -214,7 +221,7 @@ fn with_record_write_args(spec: CommandSpec) -> CommandSpec { clap::Arg::new("weight") .long("weight") .value_name("N") - .value_parser(clap::value_parser!(i64).range(0..)) + .value_parser(clap::value_parser!(i64).range(0..=65535)) .help("Record weight (SRV only)"), ) .with_arg( @@ -242,20 +249,19 @@ pub fn module() -> Module { preview the change without writing it.", ), ) - // --- list ------------------------------------------------------- + // --- list (v1) -------------------------------------------------- .with_command(RuntimeCommandSpec::new_with_context( CommandSpec::new("list", "List DNS records for a domain") .with_long( "Retrieves DNS records for a domain. Without filters, returns all \ - record types. Use `--type` to narrow to one record type, and \ - `--name` (requires `--type`) to further narrow to a specific name. \ - Requires the `domains.domain:read` scope.", + record types. Use `--type` to narrow to one record type, and \ + `--name` (requires `--type`) to further narrow to a specific name.", ) .with_system("domain") .with_tier(Tier::Read) .with_default_fields("type,name,data,ttl") - .with_json_schema::() - .with_scopes(&[DOMAINS_READ_SCOPE]) + .with_json_schema::() + .with_scopes(&[DOMAINS_READ]) .with_arg( clap::Arg::new("domain") .value_name("DOMAIN") @@ -273,20 +279,11 @@ pub fn module() -> Module { clap::Arg::new("name") .long("name") .value_name("NAME") - // The API only filters by name within a type, so clap - // enforces `--name` requires `--type` at parse time. .requires("type") .help("Only records with this name (requires --type)"), ), - // Output pagination is the engine's job: the global, client-side - // `--limit`/`--offset` flags slice the returned list. We don't - // expose the API's server-side offset/limit (it would double-skip - // against the client-side `--offset`), so `record_get` is called - // without them. |ctx| async move { let domain = arg_str(&ctx, "domain").unwrap_or_default(); - // `--type` is validated + upper-cased by clap's value parser, - // and `--name` requires `--type`, so these can be used as-is. let type_opt = arg_str(&ctx, "type"); let name_opt = arg_str(&ctx, "name"); @@ -324,8 +321,6 @@ pub fn module() -> Module { .into_inner(), }; - // Serialize each record to an object so `--fields` and the - // default-field projection have `type`/`name`/`data`/`ttl`. let out: Vec = records .iter() .map(serde_json::to_value) @@ -336,7 +331,7 @@ pub fn module() -> Module { Ok(CommandResult::new(json!(out))) }, )) - // --- add -------------------------------------------------------- + // --- add (v3) --------------------------------------------------- .with_command(RuntimeCommandSpec::new_with_context( with_record_write_args( CommandSpec::new( @@ -346,38 +341,38 @@ pub fn module() -> Module { .with_long( "Appends one or more DNS records to a domain without modifying any \ existing records. Pass `--data` once per record value to add \ - multiple records for the same type+name in a single call. \ - Use `dns set` instead if you need to replace the full record \ - set for a type+name.", + multiple records for the same type+name (each is a separate v3 \ + create call). `--ttl` defaults to 3600 when omitted. Use `dns set` \ + to replace the full record set for a type+name.", ) .with_system("domain") .with_tier(Tier::Mutate) .with_default_fields("domain,type,name,records") .with_output_schema::() - .with_scopes(&[DOMAINS_DNS_UPDATE_SCOPE]), + .with_scopes(&[DOMAINS_DNS_UPDATE]), ), |ctx| async move { let domain = arg_str(&ctx, "domain").unwrap_or_default(); - // `--type` is validated + upper-cased by clap's value parser. let record_type = arg_str(&ctx, "type").unwrap_or_default(); let name = arg_str(&ctx, "name").unwrap_or_default(); let data = string_list(&ctx, "data"); - let ty = types::DnsRecordType::try_from(record_type.as_str()) - .expect("--type value parser guarantees a valid record type"); let opts = RecordOptions::from_ctx(&ctx); - let records = dns_records(&name, ty, &data, &opts); + let records = v3_records(&name, &record_type, &data, &opts); let count = records.len(); let client = make_client(&ctx).await?; - client - .record_add() - .domain(domain.as_str()) - .body(records) - .send() - .await - .map_err(|e| { - CliCoreError::message(format!("adding DNS records failed: {e}")) - })?; + // v3 creates a single record per call; add each in turn. + for record in records { + client + .create_dns_record() + .zone(domain.as_str()) + .body(record) + .send() + .await + .map_err(|e| { + CliCoreError::message(format!("adding DNS record failed: {e}")) + })?; + } Ok(CommandResult::new(json!({ "domain": domain, @@ -388,7 +383,7 @@ pub fn module() -> Module { }))) }, )) - // --- set -------------------------------------------------------- + // --- set (v1) --------------------------------------------------- .with_command(RuntimeCommandSpec::new_with_context( with_record_write_args( CommandSpec::new( @@ -397,26 +392,25 @@ pub fn module() -> Module { ) .with_long( "Replaces every DNS record for the given type+name pair with the \ - values supplied via `--data`, discarding any records that were \ - there before. This operation is destructive and irreversible — \ - use `dns list --type --name ` to review the current \ - state first. Use `dns add` to append records without removing \ - existing ones.", + values supplied via `--data`, discarding any records that were \ + there before. This operation is destructive and irreversible — \ + use `dns list --type --name ` to review the current \ + state first. Use `dns add` to append records without removing \ + existing ones.", ) .with_system("domain") .with_tier(Tier::Destructive) .with_default_fields("domain,type,name,records") .with_output_schema::() - .with_scopes(&[DOMAINS_DNS_UPDATE_SCOPE]), + .with_scopes(&[DOMAINS_DNS_UPDATE]), ), |ctx| async move { let domain = arg_str(&ctx, "domain").unwrap_or_default(); - // `--type` is validated + upper-cased by clap's value parser. let record_type = arg_str(&ctx, "type").unwrap_or_default(); let name = arg_str(&ctx, "name").unwrap_or_default(); let data = string_list(&ctx, "data"); let opts = RecordOptions::from_ctx(&ctx); - let records = create_type_name_records(&data, &opts); + let records = v1_set_records(&data, &opts); let count = records.len(); let client = make_client(&ctx).await?; @@ -441,7 +435,7 @@ pub fn module() -> Module { }))) }, )) - // --- delete ----------------------------------------------------- + // --- delete (v1) ------------------------------------------------ .with_command(RuntimeCommandSpec::new_with_context( CommandSpec::new( "delete", @@ -449,16 +443,16 @@ pub fn module() -> Module { ) .with_long( "Removes every DNS record matching the given type+name pair. This \ - operation is destructive and irreversible — all matching records are \ - deleted in one call. NS and SOA records are GoDaddy-managed and \ - cannot be deleted. Use `dns list` to confirm what will be removed \ - before running this command.", + operation is destructive and irreversible — all matching records are \ + deleted in one call. NS and SOA records are GoDaddy-managed and \ + cannot be deleted. Use `dns list` to confirm what will be removed \ + before running this command.", ) .with_system("domain") .with_tier(Tier::Destructive) .with_default_fields("domain,type,name,deleted") .with_output_schema::() - .with_scopes(&[DOMAINS_DNS_UPDATE_SCOPE]) + .with_scopes(&[DOMAINS_DNS_UPDATE]) .with_arg( clap::Arg::new("domain") .value_name("DOMAIN") @@ -482,8 +476,6 @@ pub fn module() -> Module { ), |ctx| async move { let domain = arg_str(&ctx, "domain").unwrap_or_default(); - // `--type` is validated (and NS/SOA rejected) + upper-cased by - // clap's value parser, so it converts to the delete enum cleanly. let record_type = arg_str(&ctx, "type").unwrap_or_default(); let name = arg_str(&ctx, "name").unwrap_or_default(); @@ -528,25 +520,74 @@ mod tests { fn parse_deletable_type_arg_rejects_ns_and_soa_with_clear_message() { assert_eq!(parse_deletable_type_arg("a").expect("valid"), "A"); assert!(parse_deletable_type_arg("TXT").is_ok()); - // NS/SOA are valid types but GoDaddy-managed — rejected as non-deletable - // (not the generated client's opaque conversion error). for ty in ["NS", "soa"] { let err = parse_deletable_type_arg(ty).expect_err("should reject"); assert!(err.contains("managed by GoDaddy"), "got: {err}"); - assert!( - !err.contains("RecordDeleteTypeNameType"), - "leaked type name: {err}" - ); } - // A non-type is rejected as invalid, not as non-deletable. let err = parse_deletable_type_arg("bogus").expect_err("should reject"); assert!(err.contains("invalid record type"), "got: {err}"); } + #[test] + fn v3_records_builds_one_per_data_value_with_default_ttl() { + let opts = RecordOptions { + ttl: None, + priority: None, + port: None, + weight: None, + protocol: None, + service: None, + }; + let recs = v3_records( + "www", + "A", + &["1.2.3.4".to_string(), "5.6.7.8".to_string()], + &opts, + ); + assert_eq!(recs.len(), 2); + assert_eq!(recs[0].name, "www"); + assert_eq!(recs[0].type_.as_str(), "A"); + assert_eq!(recs[0].data, "1.2.3.4"); + // v3 requires a ttl; an omitted --ttl falls back to the default. + assert_eq!(recs[0].ttl, DEFAULT_TTL); + assert_eq!(recs[1].data, "5.6.7.8"); + } + + #[test] + fn v3_records_clamps_numeric_fields_to_u16() { + let opts = RecordOptions { + ttl: Some(600), + priority: Some(10), + port: Some(443), + weight: Some(5), + protocol: Some("tcp".to_string()), + service: Some("sip".to_string()), + }; + let recs = v3_records("_sip", "SRV", &["sip.example.com".to_string()], &opts); + assert_eq!(recs[0].ttl, 600); + assert_eq!(recs[0].priority, Some(10)); + assert_eq!(recs[0].port, Some(443)); + assert_eq!(recs[0].weight, Some(5)); + } + + #[test] + fn v1_set_records_omit_type_and_name() { + let opts = RecordOptions { + ttl: None, + priority: Some(10), + port: None, + weight: None, + protocol: None, + service: None, + }; + let recs = v1_set_records(&["mail.example.com".to_string()], &opts); + assert_eq!(recs.len(), 1); + assert_eq!(recs[0].data, "mail.example.com"); + assert_eq!(recs[0].priority, Some(10)); + } + /// Type/flag validation lives in clap value-parsers, so invalid input is /// rejected at parse time — before auth or `--dry-run` can short-circuit. - /// (Regression guard: these previously validated only in the handler, so - /// `--dry-run` reported success and `--name` without `--type` reached auth.) #[tokio::test] async fn invalid_dns_input_is_rejected_before_auth_or_dry_run() { let cli = || { @@ -557,7 +598,6 @@ mod tests { ) }; let cases: [(&[&str], &str); 3] = [ - // Undeletable type — rejected with the GoDaddy reason, not an auth error. ( &[ "gddy", @@ -571,7 +611,6 @@ mod tests { ], "managed by GoDaddy", ), - // Bogus type on a mutating command. ( &[ "gddy", @@ -587,7 +626,6 @@ mod tests { ], "invalid record type", ), - // `--name` without `--type` on list. ( &["gddy", "dns", "list", "example.com", "--name", "www"], "--type", @@ -608,52 +646,10 @@ mod tests { } } - #[test] - fn dns_records_builds_one_per_data_value() { - let opts = RecordOptions { - ttl: Some(600), - priority: None, - port: None, - weight: None, - protocol: None, - service: None, - }; - let recs = dns_records( - "www", - types::DnsRecordType::A, - &["1.2.3.4".to_string(), "5.6.7.8".to_string()], - &opts, - ); - assert_eq!(recs.len(), 2); - assert_eq!(recs[0].name, "www"); - assert_eq!(recs[0].type_, types::DnsRecordType::A); - assert_eq!(recs[0].data, "1.2.3.4"); - assert_eq!(recs[0].ttl, Some(600)); - assert_eq!(recs[1].data, "5.6.7.8"); - } - - #[test] - fn create_type_name_records_omit_type_and_name() { - let opts = RecordOptions { - ttl: None, - priority: Some(10), - port: None, - weight: None, - protocol: None, - service: None, - }; - let recs = create_type_name_records(&["mail.example.com".to_string()], &opts); - assert_eq!(recs.len(), 1); - assert_eq!(recs[0].data, "mail.example.com"); - assert_eq!(recs[0].priority, Some(10)); - } - - /// Like the `domain` commands, the `dns` commands hit the Domains API and - /// must stay fail-closed: built with no auth provider registered, the - /// engine's default `AuthRequirement::Required` rejects them at credential - /// resolution (exit code 2) before any handler runs. Running each leaf also + /// Like `domain`, the `dns` commands hit the Domains API and must stay + /// fail-closed at auth resolution (exit code 2). Running each leaf also /// exercises clap's command-tree construction (duplicate-subcommand panics - /// only surface in debug builds). + /// surface only in debug builds). #[tokio::test] async fn dns_commands_require_auth() { const AUTH_FAILURE_EXIT: i32 = 2; diff --git a/rust/src/domain/agreements.rs b/rust/src/domain/agreements.rs new file mode 100644 index 0000000..d63f43e --- /dev/null +++ b/rust/src/domain/agreements.rs @@ -0,0 +1,69 @@ +//! `gddy domain agreements` — the legal agreements a TLD requires (v1). + +use cli_engine::{CommandResult, CommandSpec, RuntimeCommandSpec, Tier}; +use serde_json::json; + +use domains_client::types; + +use super::common::{api_error, make_client, string_list}; +use crate::scopes::DOMAINS_READ; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new( + "agreements", + "Show the legal agreements required to register a TLD", + ) + .with_long( + "List the legal agreements you must consent to before registering under \ + a TLD. `domain quote` also returns the agreements specific to a domain; \ + this is the TLD-level view.", + ) + .with_system("domain") + .with_tier(Tier::Read) + .with_default_fields("agreementKey,title,url") + .with_json_schema::() + .with_scopes(&[DOMAINS_READ]) + .with_arg( + clap::Arg::new("tld") + .long("tld") + .value_name("TLD") + .required(true) + .action(clap::ArgAction::Append) + .help("TLD whose agreements to retrieve, e.g. com (repeatable)"), + ) + .with_arg( + clap::Arg::new("privacy") + .long("privacy") + .action(clap::ArgAction::SetTrue) + .help("Retrieve the agreements that apply when privacy is requested"), + ), + |ctx| async move { + let tlds = string_list(&ctx, "tld"); + let privacy = ctx + .args + .get("privacy") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let debug = !ctx.middleware.debug.is_empty(); + let client = make_client(&ctx).await?; + let resp = match client.agreements().tlds(tlds).privacy(privacy).send().await { + Ok(r) => r, + Err(e) => return Err(api_error("retrieving legal agreements", debug, e).await), + }; + let agreements: Vec = resp + .into_inner() + .into_iter() + .map(|a| { + json!({ + "agreementKey": a.agreement_key, + "title": a.title, + "url": a.url, + "content": a.content, + }) + }) + .collect(); + Ok(CommandResult::new(json!(agreements))) + }, + ) +} diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs new file mode 100644 index 0000000..ad19e65 --- /dev/null +++ b/rust/src/domain/available.rs @@ -0,0 +1,111 @@ +//! `gddy domain available` — check whether a domain can be registered (v3). + +use cli_engine::{ + CommandResult, CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, Tier, +}; +use serde_json::json; + +use domains_client::types; + +use super::common::{api_error, format_money, make_client}; +use crate::scopes::DOMAINS_READ; + +/// The headline price for an availability/quote: the entry for a 1-year term if +/// present, else the first listed term. +fn headline_price(prices: &[types::TermPrice]) -> Option<&types::TermPrice> { + let one_year = std::num::NonZeroU64::new(1); + prices + .iter() + .find(|p| p.period == one_year) + .or_else(|| prices.first()) +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("available", "Check whether a domain is available") + .with_long( + "Check whether a domain can be registered, and at what price. \ + --check-type fast trades accuracy for speed; full is authoritative \ + (the `definitive` field tells you which you got).", + ) + .with_system("domain") + .with_tier(Tier::Read) + .with_default_fields("domain,available,definitive,price,currency") + .with_json_schema::() + .with_scopes(&[DOMAINS_READ]) + .with_arg( + clap::Arg::new("domain") + .value_name("DOMAIN") + .required(true) + .help("Domain name to check (e.g. example.com)"), + ) + .with_arg( + clap::Arg::new("check-type") + .long("check-type") + .value_name("TYPE") + .value_parser(["fast", "full"]) + .help("Optimize for speed (fast) or accuracy (full)"), + ), + |ctx| async move { + let domain = ctx + .args + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + // --check-type fast|full → v3 optimizeFor SPEED|ACCURACY. + let optimize_for = match ctx.args.get("check-type").and_then(|v| v.as_str()) { + Some("fast") => Some(types::OptimizationTarget::Speed), + Some("full") => Some(types::OptimizationTarget::Accuracy), + _ => None, + }; + let debug = !ctx.middleware.debug.is_empty(); + let client = make_client(&ctx).await?; + let mut req = client.get_domain_availability().domain(domain.as_str()); + if let Some(opt) = optimize_for { + req = req.optimize_for(opt); + } + let body = match req.send().await { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain availability check", debug, e).await), + }; + + let mut result = json!({ + "domain": body.domain, + "available": body.available, + "definitive": body.definitive, + }); + let prices = body.prices.unwrap_or_default(); + if let Some(term) = headline_price(&prices) { + if let Some(price) = term.price.as_ref().and_then(format_money) { + result["price"] = json!(price); + result["currency"] = json!( + term.price + .as_ref() + .and_then(|m| m.currency_code.as_ref()) + .map(|c| c.to_string()) + ); + } + if let Some(renewal) = term.renewal_price.as_ref().and_then(format_money) { + result["renewalPrice"] = json!(renewal); + } + if let Some(period) = term.period { + result["period"] = json!(period.get()); + } + } + + let cmd = CommandResult::new(result); + if body.available.unwrap_or(false) { + Ok(cmd.with_next_actions(vec![ + NextAction::new("domain quote ", "Price a registration") + .with_param("domain", NextActionParam::required()), + ])) + } else { + Ok(cmd.with_next_actions(vec![ + NextAction::new("domain suggest ", "Find alternatives") + .with_param("query", NextActionParam::required()), + ])) + } + }, + ) +} diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs new file mode 100644 index 0000000..40eb3a7 --- /dev/null +++ b/rust/src/domain/common.rs @@ -0,0 +1,396 @@ +//! Shared helpers for the `domain` command group: the authenticated Domains API +//! client, money formatting, argument helpers, and API-error rendering. Each +//! `gddy domain` subcommand lives in its own sibling module and draws from here. + +use cli_engine::{CliCoreError, CommandContext, Credential, Result}; + +use crate::{auth::SSO_KEY_PROVIDER, environments}; + +use domains_client::types; + +const USER_AGENT: &str = concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")); + +fn map_env_err(e: environments::EnvError) -> CliCoreError { + CliCoreError::message(e.to_string()) +} + +/// The ISO-4217 minor-unit exponent for a currency — how many implied decimal +/// places a [`types::SimpleMoney`] `value` carries (the v3 spec defers money +/// formatting to ISO 4217). Sourced from the `iso_currency` crate's maintained +/// ISO 4217 dataset rather than a hand table, so it stays complete and correct +/// (JPY → 0, USD → 2, KWD → 3, CLF → 4, …). +/// +/// Falls back to 2 for an unrecognized code and for the codes ISO marks with no +/// minor unit (precious metals `XAU`/`XAG`, the IMF SDR `XDR`, `XXX`, test codes) +/// — none of which are spendable currencies that could be a domain price. +fn currency_decimals(code: &str) -> u32 { + iso_currency::Currency::from_code(&code.to_ascii_uppercase()) + .and_then(|c| c.exponent()) + .map_or(2, u32::from) +} + +/// Render a [`types::SimpleMoney`] as a decimal string. v3 money `value`s are in +/// ISO-4217 minor units for the currency (e.g. USD `1199` → `"11.99"`, JPY `1500` +/// → `"1500"`, BHD `1234` → `"1.234"`), NOT the micro-units v1 used. Truncates +/// toward zero (registry prices are whole minor units in practice); the sign is +/// explicit and `unsigned_abs` avoids `i64::MIN` overflow. `None` when the amount +/// is absent. Missing currency defaults to 2 decimals. +pub(super) fn format_money(money: &types::SimpleMoney) -> Option { + let value = money.value?; + let code = money + .currency_code + .as_ref() + .map(|c| c.as_str()) + .unwrap_or(""); + let decimals = currency_decimals(code); + let sign = if value < 0 { "-" } else { "" }; + let abs = value.unsigned_abs(); + if decimals == 0 { + return Some(format!("{sign}{abs}")); + } + let scale = 10u64.pow(decimals); + Some(format!( + "{sign}{}.{:0width$}", + abs / scale, + abs % scale, + width = decimals as usize + )) +} + +/// Pick the `Authorization` header value for a resolved credential: the `sso-key` +/// scheme for the [`SSO_KEY_PROVIDER`] bypass path, otherwise an OAuth `Bearer` +/// token. Pure so the scheme selection is unit-testable without a full context. +fn authorization_header(provider: &str, token: &str) -> String { + if provider == SSO_KEY_PROVIDER { + format!("sso-key {token}") + } else { + format!("Bearer {token}") + } +} + +/// Build a Domains API client for the active environment, choosing the auth +/// scheme from the resolved credential (sso-key for the bypass path, else Bearer). +pub(crate) async fn make_client(ctx: &CommandContext) -> Result { + let cred = ctx.credential().await?; + make_client_with_cred(&ctx.middleware.env, &cred) +} + +/// Build the Domains API client from an already-resolved credential, so callers +/// that need the credential themselves (e.g. `purchase`, for the consent +/// principal) resolve it once and reuse the same token for the requests. +pub(crate) fn make_client_with_cred( + env: &str, + cred: &Credential, +) -> Result { + let domains = environments::resolve_domains(env).map_err(map_env_err)?; + let authorization = authorization_header(&cred.provider, &cred.token); + let request_id = uuid::Uuid::new_v4().to_string(); + domains_client::client_with_auth(&domains.base_url, &authorization, USER_AGENT, &request_id) + .map_err(|e| CliCoreError::message(format!("failed to build domains client: {e}"))) +} + +/// Collect a repeatable string argument into a `Vec` (clap stores it as a JSON +/// array; a lone value arrives as a string). +pub(crate) fn string_list(ctx: &CommandContext, key: &str) -> Vec { + match ctx.args.get(key) { + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(), + Some(serde_json::Value::String(s)) => vec![s.clone()], + _ => Vec::new(), + } +} + +/// Turn a domains-client error into a `CliCoreError`, reading the response body +/// for unexpected (non-2xx) responses so the API's actual message isn't lost +/// (progenitor's `Display` prints only the status). Async because reading the +/// body is async. +pub(super) async fn api_error( + action: &str, + debug: bool, + err: domains_client::Error<()>, +) -> CliCoreError { + match err { + domains_client::Error::UnexpectedResponse(resp) => { + let status = resp.status(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + CliCoreError::message(format_api_error( + action, + status.as_u16(), + &status.to_string(), + &body, + request_id.as_deref(), + debug, + )) + } + other => CliCoreError::message(format!("{action} failed: {other}")), + } +} + +/// Build the user-facing message for an unexpected API response. Pure so the +/// HTTP 402 → payment-method guidance and the `--debug` request-id line are +/// unit-testable. +fn format_api_error( + action: &str, + status: u16, + status_display: &str, + body: &str, + request_id: Option<&str>, + debug: bool, +) -> String { + let body = body.trim(); + let friendly = friendly_field_errors(body); + let mut msg = match &friendly { + Some(detail) => format!("{action} failed (HTTP {status_display}):\n{detail}"), + None if body.is_empty() => format!("{action} failed (HTTP {status_display})"), + None => format!("{action} failed (HTTP {status_display}): {body}"), + }; + if status == 402 { + msg.push_str( + "\n\nThis usually means your account has no usable payment method. Add one with \ + `gddy payments add` (a credit card or Good-as-Gold balance is required for domain \ + purchases), then try again.", + ); + } + if debug { + if friendly.is_some() && !body.is_empty() { + msg.push_str(&format!("\n\nResponse body: {body}")); + } + if let Some(id) = request_id.map(str::trim).filter(|s| !s.is_empty()) { + msg.push_str(&format!("\n\nRequest ID: {id}")); + } + } + msg +} + +/// A Domains API validation error body. Tolerates both the v1 shape +/// (`{"fields":[...]}`) and the v3 shape (`{"error":{"fields":[...]}}`). +#[derive(serde::Deserialize)] +struct ApiErrorBody { + #[serde(default)] + fields: Vec, + #[serde(default)] + error: Option, +} + +#[derive(serde::Deserialize)] +struct ApiErrorEnvelope { + #[serde(default)] + fields: Vec, +} + +#[derive(serde::Deserialize)] +struct ApiFieldError { + #[serde(default)] + code: String, + #[serde(default)] + path: String, +} + +/// Render a structured validation error as a plain-English bullet list, or `None` +/// when `body` isn't a field-level validation error. +fn friendly_field_errors(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok()?; + let fields = if !parsed.fields.is_empty() { + parsed.fields + } else { + parsed.error.map(|e| e.fields).unwrap_or_default() + }; + if fields.is_empty() { + return None; + } + let lines: Vec = fields + .iter() + .map(|f| format!(" • {}", describe_field_error(f))) + .collect(); + Some(format!("some fields are invalid:\n{}", lines.join("\n"))) +} + +fn describe_field_error(f: &ApiFieldError) -> String { + let name = friendly_field_name(&f.path); + let problem = match f.code.as_str() { + "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT" => "is too short", + "LENGTH_OVER" | "MAX_LENGTH" | "TOO_LONG" => "is too long", + "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN" => "is not in the required format", + "REQUIRED" | "MISSING" | "MISSING_REQUIRED_FIELD" => "is required", + _ => "is invalid", + }; + match field_hint(&f.path, &f.code) { + Some(hint) => format!("{name} {problem} ({hint})"), + None => format!("{name} {problem}"), + } +} + +fn field_hint(path: &str, code: &str) -> Option<&'static str> { + let leaf = path.rsplit('.').next().unwrap_or(path); + match (leaf, code) { + ("phone", _) | ("nationalNumber", _) => Some("expected a format like +1.4805551212"), + ("postalCode", "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN") => { + Some("some countries require a specific format, e.g. UK SW1A 2AA") + } + ("line2", "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT") => Some("leave it blank to omit it"), + _ => None, + } +} + +/// Turn an API field path into a human phrase. Falls back to a space-joined, +/// camelCase-split rendering for unrecognized paths. +fn friendly_field_name(path: &str) -> String { + let trimmed = path + .strip_prefix("body.") + .or_else(|| path.strip_prefix("consent.")) + .unwrap_or(path); + let segments: Vec<&str> = trimmed.split('.').filter(|s| !s.is_empty()).collect(); + if let ["contacts", role, rest @ ..] = segments.as_slice() { + let mut parts = vec![humanize_segment(role)]; + parts.extend(rest.iter().map(|s| humanize_segment(s))); + return parts.join(" "); + } + if segments.is_empty() { + return path.to_string(); + } + segments + .iter() + .map(|s| humanize_segment(s)) + .collect::>() + .join(" ") +} + +fn humanize_segment(seg: &str) -> String { + match seg { + "line1" => "line 1".to_string(), + "line2" => "line 2".to_string(), + "postalCode" => "postal code".to_string(), + "countryCode" => "country".to_string(), + "firstName" => "first name".to_string(), + "lastName" => "last name".to_string(), + "nationalNumber" => "phone".to_string(), + "agreedAt" => "agreed-at timestamp".to_string(), + other => split_camel_case(other), + } +} + +fn split_camel_case(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + for (i, ch) in s.char_indices() { + if ch.is_ascii_uppercase() { + if i != 0 { + out.push(' '); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn money(value: Option, currency: &str) -> types::SimpleMoney { + types::SimpleMoney { + value, + currency_code: (!currency.is_empty()) + .then(|| types::CurrencyCode(currency.to_string())), + } + } + + #[test] + fn format_money_uses_iso4217_minor_units_per_currency() { + // v3 `value` is in the currency's ISO-4217 minor units — USD `1199` is + // $11.99, NOT micro-units (the regression that rendered real prices as 0.00). + assert_eq!( + format_money(&money(Some(1199), "USD")).as_deref(), + Some("11.99") + ); + assert_eq!( + format_money(&money(Some(100), "USD")).as_deref(), + Some("1.00") + ); + assert_eq!( + format_money(&money(Some(2050), "USD")).as_deref(), + Some("20.50") + ); + assert_eq!( + format_money(&money(Some(-500), "USD")).as_deref(), + Some("-5.00") + ); + // Zero-decimal (JPY) and three-decimal (BHD) currencies. + assert_eq!( + format_money(&money(Some(1500), "JPY")).as_deref(), + Some("1500") + ); + assert_eq!( + format_money(&money(Some(1234), "BHD")).as_deref(), + Some("1.234") + ); + // Missing amount → None; missing currency defaults to 2 decimals. + assert_eq!(format_money(&money(None, "USD")), None); + assert_eq!( + format_money(&money(Some(1199), "")).as_deref(), + Some("11.99") + ); + } + + #[test] + fn currency_decimals_covers_iso_exceptions() { + assert_eq!(currency_decimals("USD"), 2); + assert_eq!(currency_decimals("EUR"), 2); + assert_eq!(currency_decimals("JPY"), 0); + assert_eq!(currency_decimals("UYI"), 0); + assert_eq!(currency_decimals("KWD"), 3); + assert_eq!(currency_decimals("CLF"), 4); + assert_eq!(currency_decimals("UYW"), 4); + assert_eq!(currency_decimals("XAU"), 2); // no ISO minor unit → default 2 (never a price) + assert_eq!(currency_decimals("ZZZ"), 2); // unknown → default 2 + } + + #[test] + fn authorization_header_picks_scheme_from_provider() { + assert_eq!( + authorization_header(SSO_KEY_PROVIDER, "KEY:SECRET"), + "sso-key KEY:SECRET" + ); + assert_eq!(authorization_header("godaddy", "tok123"), "Bearer tok123"); + } + + #[test] + fn payment_required_error_points_to_payments_add() { + let msg = format_api_error( + "domain purchase", + 402, + "402 Payment Required", + r#"{"code":"INVALID_PAYMENT_INFO","message":"Unable to authorize credit"}"#, + None, + false, + ); + assert!(msg.contains("402 Payment Required"), "{msg}"); + assert!(msg.contains("gddy payments add"), "{msg}"); + } + + #[test] + fn v3_error_envelope_fields_render_as_plain_english() { + // v3 wraps validation detail under an `error` object; the friendly + // renderer must reach into it. + let body = r#"{"error":{"code":"INVALID_BODY","fields":[{"code":"LENGTH_UNDER","path":"contacts.registrant.address.line2"}]}}"#; + let msg = format_api_error( + "domain purchase", + 422, + "422 Unprocessable Entity", + body, + None, + false, + ); + assert!(msg.contains("registrant address line 2"), "{msg}"); + assert!(msg.contains("is too short"), "{msg}"); + assert!(msg.contains("leave it blank to omit"), "{msg}"); + } +} diff --git a/rust/src/domain/contacts.rs b/rust/src/domain/contacts.rs new file mode 100644 index 0000000..c7362fd --- /dev/null +++ b/rust/src/domain/contacts.rs @@ -0,0 +1,70 @@ +//! `gddy domain contacts` — manage the local default-contacts file used by +//! `domain quote`/`purchase`. This is a local-file command group; no API/auth. + +use cli_engine::{ + CliCoreError, CommandResult, CommandSpec, GroupSpec, NextAction, RuntimeCommandSpec, + RuntimeGroupSpec, Tier, +}; +use serde_json::json; + +use crate::contacts; + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new( + GroupSpec::new( + "contacts", + "Manage saved default contacts for domain purchases", + ) + .with_long( + "Manage the optional contacts.toml that supplies registrant/admin/billing/\ + tech contacts for `gddy domain purchase`. When a role is absent the \ + purchase falls back to your account's default contact for that role.", + ), + ) + .with_command(RuntimeCommandSpec::new_with_context( + CommandSpec::new("init", "Write a starter contacts.toml you can edit") + .with_long( + "Scaffold a starter contacts.toml in your config directory with \ + every role commented out (so it's inert until you edit it). Fill \ + in any roles you want to override the account defaults for. Pass \ + --force to overwrite an existing file.", + ) + .with_system("domain") + .with_tier(Tier::Mutate) + .mutates(true) + .no_auth(true) + .with_default_fields("path,action") + .with_arg( + clap::Arg::new("force") + .long("force") + .action(clap::ArgAction::SetTrue) + .help("Overwrite an existing contacts.toml"), + ), + |ctx| async move { + let path = contacts::contacts_path().ok_or_else(|| { + CliCoreError::message("could not determine a config directory for contacts.toml") + })?; + let force = ctx + .args + .get("force") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let existed = path.exists(); + if existed && !force { + return Err(CliCoreError::message(format!( + "{} already exists; pass --force to overwrite", + path.display() + ))); + } + cli_engine::fs::write_string_atomic(&path, contacts::sample_toml())?; + Ok(CommandResult::new(json!({ + "path": path.display().to_string(), + "action": if existed { "overwritten" } else { "created" }, + })) + .with_next_actions(vec![NextAction::new( + "guide domain-purchase", + "Learn how purchase uses these contacts", + )])) + }, + )) +} diff --git a/rust/src/domain/get.rs b/rust/src/domain/get.rs new file mode 100644 index 0000000..0c06535 --- /dev/null +++ b/rust/src/domain/get.rs @@ -0,0 +1,57 @@ +//! `gddy domain get` — show full details for one owned domain (v3). + +use cli_engine::{ + CliCoreError, CommandResult, CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, Tier, +}; + +use domains_client::types; + +use super::common::{api_error, make_client}; +use crate::scopes::DOMAINS_READ; + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("get", "Show full details for one of your domains") + .with_long( + "Show every detail for a single domain in your account (status, \ + expiry, nameservers, and more). Unlike `list`, this shows all fields \ + by default. The domain must be one you own.", + ) + .with_system("domain") + .with_tier(Tier::Read) + .with_json_schema::() + .with_scopes(&[DOMAINS_READ]) + .with_arg( + clap::Arg::new("domain") + .value_name("DOMAIN") + .required(true) + .help("Domain to look up (must be in your account), e.g. example.com"), + ), + |ctx| async move { + let domain = ctx + .args + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let debug = !ctx.middleware.debug.is_empty(); + let client = make_client(&ctx).await?; + let detail = match client + .get_domain() + .domain_name(domain.as_str()) + .send() + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("retrieving domain details", debug, e).await), + }; + let value = serde_json::to_value(&detail).map_err(|e| { + CliCoreError::message(format!("failed to serialize domain details: {e}")) + })?; + Ok(CommandResult::new(value).with_next_actions(vec![ + NextAction::new("dns list ", "View this domain's DNS records") + .with_param("domain", NextActionParam::required()), + ])) + }, + ) +} diff --git a/rust/src/domain/guides/domain-purchase.md b/rust/src/domain/guides/domain-purchase.md index 2907c0a..25f7507 100644 --- a/rust/src/domain/guides/domain-purchase.md +++ b/rust/src/domain/guides/domain-purchase.md @@ -1,54 +1,79 @@ --- -summary: Register a domain with gddy, including consent and saved default contacts +summary: Register a domain with gddy — quote, review, then purchase --- # Buying a domain with `gddy` -`gddy domain purchase ` registers a domain through the GoDaddy Domains -API. A purchase is **paid and not reversible**, so the command has two gates and -records your consent to the registry's legal agreements. +Registering a domain is a **two-step, quote-then-purchase** flow: -Registration completes **asynchronously**: a successful command reports -`status: submitted`, and the domain appears in `gddy domain list` once the -registry finishes processing. +1. `gddy domain quote ` locks a price and returns the legal agreements + and a single-use **quote token** (valid ~10 minutes). +2. `gddy domain purchase --quote-token --agree --confirm` accepts that + quote and registers the domain. -## The flow +A purchase is **paid and not reversible**, so `purchase` has two gates +(`--agree`, `--confirm`) on top of the token. Because the token locks the price +and settings you reviewed, you're charged exactly what the quote showed. -1. **Check availability** (optional): - `gddy domain available example.com` -2. **Review the legal agreements** for the TLD: - `gddy domain agreements --tld com` - (add `--privacy` if you will request privacy protection). -3. **Purchase**, agreeing to those agreements and confirming the charge: - `gddy domain purchase example.com --agree --confirm` +Registration completes **asynchronously**: `purchase` submits the registration +and waits briefly for the registry, reporting the operation's `status` +(`COMPLETED` when done). The domain then appears in `gddy domain list`. -### The two gates +## The flow -- `--agree` is your **consent** to the TLD's legal agreements. Run the command - without it once and it lists the agreements you must accept. The keys of those - agreements are recorded with your purchase. +1. **Find a name** (optional) — if you don't have one in mind, get suggestions + from a seed word or phrase: + ``` + gddy domain suggest "avocado jewelry" + ``` + Narrow with `--tlds com --tlds dev` (repeatable), bound the name length with + `--length-min`/`--length-max`, and cap the count with the global `--limit`. + Suggestions are available-by-contract, but availability is re-checked at + quote time. +2. **Check availability** (optional) — confirm a name and see its price: + `gddy domain available example.com` +3. **Quote it** — this is where you choose the registration settings and review + the terms and price: + ``` + gddy domain quote example.com + ``` + The output shows the price, the required legal agreements, the resolved + contact/preference settings, and the `quoteToken` (with its `expiresAt`). Set + registration options here (they're locked into the token): + - `--period <1-10>` — registration length in years (default 1). + - `--privacy` — add privacy protection. + - `--no-renew` — disable auto-renew (on by default). + - `--nameserver ` — custom nameserver (repeatable); omit for GoDaddy's. +4. **Purchase**, accepting the agreements and confirming the charge: + ``` + gddy domain purchase --quote-token --agree --confirm + ``` + +The quote is cached locally (next to your `contacts.toml`), so **run `quote` and +`purchase` on the same machine**, within the token's ~10-minute lifetime. If the +token has expired or isn't found, just re-run `gddy domain quote`. + +### The three decisions + +- **The quote token** selects *which* quote you're buying — the exact domain, + settings, and locked price you reviewed. +- `--agree` is your **consent** to the quote's legal agreements. Run `purchase` + without it and it lists the agreements you must accept. - `--confirm` acknowledges that the purchase **charges your account**. Without - it, the command stops before buying. Use `--dry-run` to preview without - charging. - -### Other options - -- `--period <1-10>` — registration length in years (default 1). -- `--privacy` — add privacy protection. -- `--no-renew` — disable auto-renew (on by default). -- `--nameserver ` — set a custom nameserver (repeatable); omit to use - GoDaddy's nameservers. -- `--agreed-by ` — the originating IP recorded with your consent. Defaults to - `127.0.0.1`; pass your real public IP if you need accurate consent attribution. + it, the command stops before buying. +- `--agreed-by ` — the originating IP recorded with your consent. Defaults + to `127.0.0.1`; pass your real public IP if you need accurate consent + attribution. ## Contacts A registration has four contact roles: **registrant**, **admin**, **billing**, and **tech**. If you don't supply them, the API uses your GoDaddy account's -default contacts — which is usually what you want. +default contacts — which is usually what you want. Contacts are part of the +quote, so configure them *before* you quote. -If you register many domains and want to set your own contacts once, save them in -a `contacts.toml` in your `gddy` config directory. The quickest start is to +If you register many domains and want to set your own contacts once, save them +in a `contacts.toml` in your `gddy` config directory. The quickest start is to generate a template and edit it: ``` @@ -97,4 +122,4 @@ country = "US" Required fields per role: `name_first`, `name_last`, `email`, `phone`, `address1`, `city`, `state`, `postal_code`, `country`. Optional fields: -`name_middle`, `organization`, `job_title`, `fax`, `address2`. +`organization`, `address2`. diff --git a/rust/src/domain/list.rs b/rust/src/domain/list.rs new file mode 100644 index 0000000..b3d570e --- /dev/null +++ b/rust/src/domain/list.rs @@ -0,0 +1,90 @@ +//! `gddy domain list` — list the domains in the account (v1). + +use cli_engine::{ + CliCoreError, CommandResult, CommandSpec, NextAction, NextActionParam, Result, + RuntimeCommandSpec, Tier, +}; +use serde_json::json; + +use domains_client::types; + +use super::common::{make_client, string_list}; +use crate::scopes::DOMAINS_READ; + +/// Validate `--status` values case-insensitively against the generated +/// `ListStatusesItem` enum (the v1 list API's `DomainStatus` set, e.g. `ACTIVE`). +fn parse_statuses(raw: &[String]) -> Result> { + raw.iter() + .map(|s| { + types::ListStatusesItem::try_from(s.to_uppercase().as_str()) + .map_err(|_| CliCoreError::message(format!("invalid --status {s:?}"))) + }) + .collect() +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("list", "List the domains in your account") + .with_long( + "List the domains registered to your account. Shows domain, status, \ + expiry, and auto-renew by default; use --fields to pick columns and \ + --status to filter (repeatable).", + ) + .with_system("domain") + .with_tier(Tier::Read) + .with_default_fields("domain,status,expires,renewAuto") + .with_json_schema::() + .with_scopes(&[DOMAINS_READ]) + .with_arg( + clap::Arg::new("status") + .long("status") + .value_name("STATUS") + .action(clap::ArgAction::Append) + .help("Only domains with this status, e.g. ACTIVE (repeatable)"), + ), + |ctx| async move { + let statuses = parse_statuses(&string_list(&ctx, "status"))?; + let client = make_client(&ctx).await?; + let mut req = client.list(); + if !statuses.is_empty() { + req = req.statuses(statuses); + } + let resp = req + .send() + .await + .map_err(|e| CliCoreError::message(format!("listing domains failed: {e}")))?; + let domains: Vec = resp + .into_inner() + .iter() + .map(serde_json::to_value) + .collect::>() + .map_err(|e| { + CliCoreError::message(format!("failed to serialize domain list: {e}")) + })?; + Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ + NextAction::new("dns list ", "View a domain's DNS records") + .with_param("domain", NextActionParam::required()), + ])) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::parse_statuses; + use domains_client::types; + + #[test] + fn parse_statuses_is_case_insensitive_and_validates() { + use types::ListStatusesItem; + let parsed = parse_statuses(&["active".to_string(), "CANCELLED".to_string()]) + .expect("valid statuses"); + assert_eq!( + parsed, + vec![ListStatusesItem::Active, ListStatusesItem::Cancelled] + ); + assert!(parse_statuses(&[]).expect("empty ok").is_empty()); + let err = parse_statuses(&["bogus".to_string()]).expect_err("should reject"); + assert!(err.to_string().contains("invalid --status"), "{err}"); + } +} diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index 521c018..44e9044 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -1,1353 +1,69 @@ -//! `gddy domain` — domain availability and suggestions. +//! `gddy domain` — domain discovery, registration, and account domains. //! -//! These endpoints (the GoDaddy Domains API) accept either an sso-key API key or -//! an OAuth bearer token. The HTTP layer is the typed, spec-generated -//! [`domains_client`] crate; the auth scheme is chosen from the credential the -//! [`CompositeAuthProvider`](crate::auth::CompositeAuthProvider) returns for -//! `domain:*` commands — `sso-key` when a key is configured for the environment, -//! otherwise the OAuth bearer token. - -use cli_engine::{ - CliCoreError, CommandContext, CommandResult, CommandSpec, Credential, GroupSpec, Module, - NextAction, NextActionParam, Result, RuntimeCommandSpec, RuntimeGroupSpec, Tier, -}; -use serde_json::json; - -use crate::{ - auth::SSO_KEY_PROVIDER, - contacts::{self, Role}, - environments, - output_schema::output_schema, -}; - -output_schema!(DomainPurchaseResult { - "domain": "string"; - "status": "string"; - "price": "string"; - "currency": "string"; -}); - -// `domain get` returns the full `DomainDetail` as free-form JSON; this documents -// the commonly-projected fields for `--schema` and the default-field table. -output_schema!(DomainDetailResult { - "domain": "string"; - "status": "string"; - "expires": "string"; - "renewAuto": "bool"; - "nameServers": "[]string"; - "privacy": "bool"; - "locked": "bool"; - "createdAt": "string"; -}); - -// `domain schema` emits the free-form per-TLD schema; the projected/default -// field is the `required` list of field names. -output_schema!(DomainSchemaResult { - "required": "[]string"; -}); - -const USER_AGENT: &str = concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")); - -/// OAuth scope the domain availability + suggest endpoints require. -/// -/// Source of truth (undocumented in the published Swagger spec): the GoDaddy -/// domains OAuth scope → endpoint whitelist in `gdcorp-domains/api-domain-data`, -/// `api/oauthscopewhitelist.json`. Both `GET /v1/domains/available` and -/// `GET /v1/domains/suggest` are listed under `domains.domain:read`: -/// -/// -/// Declared on the commands via [`CommandSpec::with_scopes`] so cli-engine's -/// OAuth scope step-up mints a token carrying it. Ignored on the sso-key path -/// (sso-key auth is unscoped). -/// -/// Shared with the `dns` module: DNS record *reads* live under this same scope. -pub(crate) const DOMAINS_READ_SCOPE: &str = "domains.domain:read"; - -/// OAuth scope the DNS record *mutation* endpoints (PATCH/PUT/DELETE under -/// `/v1/domains/{domain}/records`) require. -/// -/// Source of truth (undocumented in the published Swagger spec): the same -/// `gdcorp-domains/api-domain-data` `api/oauthscopewhitelist.json` whitelist — -/// every `PATCH`/`PUT`/`DELETE` on `v1_domains__records*` is listed under -/// `domains.dns:update` (reads stay under [`DOMAINS_READ_SCOPE`]). Consumed by -/// the `dns` add/set/delete commands. -pub(crate) const DOMAINS_DNS_UPDATE_SCOPE: &str = "domains.dns:update"; - -/// OAuth scope the domain *purchase* endpoint requires. `domain purchase` uses -/// the v2 register API (`POST /v2/customers/{customerId}/domains/register`), -/// which — unlike v1 `/v1/domains/purchase` — authorizes card payments for OAuth -/// users. -/// -/// Source of truth (undocumented in the published Swagger spec): the same -/// `gdcorp-domains/api-domain-data` `api/oauthscopewhitelist.json` whitelist -/// lists both `POST.v2_customers__domains_register` and `POST.v1_domains_purchase` -/// under `domains.domain:create` (the legal agreements GET that precedes it stays -/// under [`DOMAINS_READ_SCOPE`]). Consumed by the `domain purchase` command. -pub(crate) const DOMAINS_PURCHASE_SCOPE: &str = "domains.domain:create"; - -fn map_env_err(e: environments::EnvError) -> CliCoreError { - CliCoreError::message(e.to_string()) -} - -/// ("11.99"). Domain prices are returned in micro-units (1 unit = 1_000_000 -/// micros). -/// -/// Truncates (does not round) to whole cents: the API returns whole-cent prices -/// in practice, so the sub-cent digits are always zero; truncating keeps the -/// output a faithful, surprise-free rendering of the raw value rather than -/// inventing a rounded figure. See the `formats_micro_units_to_decimal` test for -/// the defined behavior on a (synthetic) sub-cent input. -/// -/// The sign is formatted explicitly (and `unsigned_abs` avoids `i64::MIN` -/// overflow) so sub-unit negatives like `-500_000` render as `-0.50`, not `0.50`. -fn format_price(micros: Option) -> Option { - micros.map(|m| { - let sign = if m < 0 { "-" } else { "" }; - let abs = m.unsigned_abs(); - format!( - "{sign}{}.{:02}", - abs / 1_000_000, - (abs % 1_000_000) / 10_000 - ) - }) -} - -/// Pick the `Authorization` header value for a resolved credential: the `sso-key` -/// scheme for the [`SSO_KEY_PROVIDER`] bypass path, otherwise an OAuth `Bearer` -/// token. Pure so the scheme selection is unit-testable without a full context. -fn authorization_header(provider: &str, token: &str) -> String { - if provider == SSO_KEY_PROVIDER { - format!("sso-key {token}") - } else { - format!("Bearer {token}") - } -} - -/// Build a Domains API client for the active environment, choosing the auth -/// scheme from the resolved credential (sso-key for the bypass path, else -/// Bearer). The credential is resolved through the registered composite provider. -pub(crate) async fn make_client(ctx: &CommandContext) -> Result { - let cred = ctx.credential().await?; - make_client_with_cred(&ctx.middleware.env, &cred) -} - -/// Build the Domains API client from an already-resolved credential, so callers -/// that need the credential themselves (e.g. `domain purchase`, for the -/// customerId) resolve it once and use the same token for the request. -pub(crate) fn make_client_with_cred( - env: &str, - cred: &Credential, -) -> Result { - let domains = environments::resolve_domains(env).map_err(map_env_err)?; - let authorization = authorization_header(&cred.provider, &cred.token); - let request_id = uuid::Uuid::new_v4().to_string(); - domains_client::client_with_auth(&domains.base_url, &authorization, USER_AGENT, &request_id) - .map_err(|e| CliCoreError::message(format!("failed to build domains client: {e}"))) -} - -pub(crate) fn string_list(ctx: &CommandContext, key: &str) -> Vec { - match ctx.args.get(key) { - Some(serde_json::Value::Array(arr)) => arr - .iter() - .filter_map(|v| v.as_str().map(str::to_owned)) - .collect(), - Some(serde_json::Value::String(s)) => vec![s.clone()], - _ => Vec::new(), - } -} - -/// Validate `--status` values case-insensitively against the generated -/// `ListStatusesItem` enum (the API's `DomainStatus` set, e.g. `ACTIVE`), -/// returning the typed list the `list` builder expects. -fn parse_statuses(raw: &[String]) -> Result> { - raw.iter() - .map(|s| { - domains_client::types::ListStatusesItem::try_from(s.to_uppercase().as_str()) - .map_err(|_| CliCoreError::message(format!("invalid --status {s:?}"))) - }) - .collect() -} - -/// The registrable TLD of a domain: everything after the first label -/// (`example.com` → `com`, `example.co.uk` → `co.uk`). `None` when there is no -/// dot, or when either the first label or the TLD is empty (`.com`, `example.`) -/// — those aren't registrable domains and would otherwise produce invalid -/// downstream lookups (e.g. an empty-TLD schema fetch). -fn registrable_tld(domain: &str) -> Option<&str> { - let (label, tld) = domain.split_once('.')?; - (!label.is_empty() && !tld.is_empty()).then_some(tld) -} - -/// Format a UTC instant as the Domains API's `iso-datetime` for purchase consent -/// (`consent.agreedAt`): RFC 3339 with a literal trailing `Z`, e.g. -/// `2026-06-17T22:34:43Z`. -/// -/// The API enforces `…THH:MM:SS(.fraction)?Z$` and rejects the numeric-offset -/// form (`+00:00`) that `chrono`'s `to_rfc3339()` emits — so we pin the `Z` -/// (`use_z = true`) and drop sub-second digits. -fn iso_datetime(now: chrono::DateTime) -> String { - now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) -} - -/// The GoDaddy customer id (the `{customerId}` path segment the v2 register API -/// requires), taken from the OAuth token's `sub` claim (which cli-engine parses -/// onto the credential). -/// -/// `sub` is a typed subject URN — `customer:` for the customer tokens this -/// command needs — and the v2 path wants the bare uuid, so the `customer:` -/// prefix is stripped. A subject that isn't `customer:`-typed (or an empty one) -/// isn't a customer identity, so it's rejected with a clear error. -fn customer_id(cred: &Credential) -> Result { - cred.sub - .strip_prefix("customer:") - .filter(|uuid| !uuid.is_empty()) - .map(str::to_owned) - .ok_or_else(|| { - CliCoreError::message(format!( - "the OAuth token's subject ({:?}) is not a customer identity; `domain purchase` \ - needs a customer-scoped token", - cred.sub - )) - }) -} - -/// Enforce the purchase gates and return the agreement keys to record as consent. -/// -/// Pure (no I/O) so the gating is unit-testable: it takes the already-fetched -/// legal agreements and the `--agree`/`--confirm` flags. `--agree` is the legal -/// consent gate (its error lists the agreements to review); `--confirm` is the -/// charge gate (the purchase is paid and irreversible). The agreement keys are -/// only returned once both gates pass. -fn purchase_consent_keys( - domain: &str, - tld: &str, - period: u64, - agree: bool, - confirm: bool, - agreements: &[domains_client::types::LegalAgreement], -) -> Result> { - if !agree { - let list = agreements - .iter() - .map(|a| { - let title = a.title.as_deref().unwrap_or("(untitled agreement)"); - match a.url.as_deref() { - Some(url) => format!(" - {title}: {url}"), - None => format!(" - {title}"), - } - }) - .collect::>() - .join("\n"); - return Err(CliCoreError::message(format!( - "registering .{tld} requires agreeing to its legal agreement(s):\n{list}\n\n\ - Review the full text with `gddy domain agreements --tld {tld}`, then re-run with \ - --agree. See `gddy guide domain-purchase`." - ))); - } - - let keys: Vec = agreements - .iter() - .filter_map(|a| a.agreement_key.clone()) - .collect(); - if keys.is_empty() { - return Err(CliCoreError::message(format!( - "no legal agreement keys were returned for .{tld}; cannot record consent for {domain}" - ))); - } - - if !confirm { - return Err(CliCoreError::message(format!( - "purchasing {domain} for {period} year(s) charges your account and cannot be undone; \ - re-run with --confirm to proceed (or --dry-run to preview)" - ))); - } - - Ok(keys) -} - -/// Turn a domains-client error into a `CliCoreError`, reading the response body -/// for unexpected (non-2xx) responses. progenitor's `Error::UnexpectedResponse` -/// `Display` prints only the response status/headers — never the body — so the -/// API's actual message (e.g. a 422's per-field reason) is otherwise lost. This -/// recovers it. Async because reading the body is async, so call sites match on -/// the result rather than using `.map_err`. -async fn api_error(action: &str, debug: bool, err: domains_client::Error<()>) -> CliCoreError { - match err { - domains_client::Error::UnexpectedResponse(resp) => { - let status = resp.status(); - // The server echoes the `x-request-id` we send; read it before the - // body is consumed. Only surfaced under `--debug` (it's the handle - // for correlating a failed request server-side). - let request_id = resp - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); - let body = resp.text().await.unwrap_or_default(); - CliCoreError::message(format_api_error( - action, - status.as_u16(), - &status.to_string(), - &body, - request_id.as_deref(), - debug, - )) - } - other => CliCoreError::message(format!("{action} failed: {other}")), - } -} - -/// Build the user-facing message for an unexpected API response. Pure so the -/// HTTP 402 → payment-method guidance and the `--debug` request-id line are -/// unit-testable. `status_display` is the full status line (e.g. -/// `"402 Payment Required"`); `status` is its numeric code for matching; -/// `request_id` is the response's `x-request-id`, surfaced only when `debug`. -/// -/// A 402 on purchase means payment authorization failed (no usable payment -/// method, or it was declined), so we always point users at `gddy payments add`. -/// The request id stays behind `--debug` to keep ordinary errors clean. -fn format_api_error( - action: &str, - status: u16, - status_display: &str, - body: &str, - request_id: Option<&str>, - debug: bool, -) -> String { - let body = body.trim(); - // When the body is a structured validation error (`INVALID_BODY` with a - // `fields` array), translate each field into plain English rather than pasting - // the raw JSON — whose per-field `message` strings embed the failing regex - // (e.g. a full UK-postcode pattern), which is noise to a user. - let friendly = friendly_field_errors(body); - let mut msg = match &friendly { - Some(detail) => format!("{action} failed (HTTP {status_display}):\n{detail}"), - None if body.is_empty() => format!("{action} failed (HTTP {status_display})"), - None => format!("{action} failed (HTTP {status_display}): {body}"), - }; - if status == 402 { - msg.push_str( - "\n\nThis usually means your account has no usable payment method. Add one with \ - `gddy payments add` (a credit card or Good-as-Gold balance is required for domain \ - purchases), then try again.", - ); - } - if debug { - // When we replaced the raw body with a friendly summary, the original JSON - // is still useful for debugging — surface it (only) here. - if friendly.is_some() && !body.is_empty() { - msg.push_str(&format!("\n\nResponse body: {body}")); - } - if let Some(id) = request_id.map(str::trim).filter(|s| !s.is_empty()) { - msg.push_str(&format!("\n\nRequest ID: {id}")); - } - } - msg -} - -/// A Domains API validation error body: `{"code":..., "message":..., "fields":[...]}`. -/// Only `fields` is needed to build friendly output; the rest is ignored. -#[derive(serde::Deserialize)] -struct ApiErrorBody { - #[serde(default)] - fields: Vec, -} - -/// One entry in a validation error's `fields` array. Only `code` and `path` are -/// deserialized; the API's per-field `message` is intentionally dropped (it embeds -/// raw regex we don't want to surface), and serde tolerates that and any other -/// unknown fields. -#[derive(serde::Deserialize)] -struct ApiFieldError { - #[serde(default)] - code: String, - #[serde(default)] - path: String, -} - -/// Render a structured validation error as a plain-English bullet list, or `None` -/// when `body` isn't a field-level validation error (so the caller falls back to -/// the raw body). `None` also covers non-JSON bodies and JSON without `fields`. -fn friendly_field_errors(body: &str) -> Option { - let parsed = serde_json::from_str::(body).ok()?; - if parsed.fields.is_empty() { - return None; - } - let lines: Vec = parsed - .fields - .iter() - .map(|f| format!(" • {}", describe_field_error(f))) - .collect(); - Some(format!("some fields are invalid:\n{}", lines.join("\n"))) -} - -/// One readable line for a field error, e.g. -/// `registrant mailing address line 2 is too short (leave it blank to omit it)`. -fn describe_field_error(f: &ApiFieldError) -> String { - let name = friendly_field_name(&f.path); - let problem = match f.code.as_str() { - "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT" => "is too short", - "LENGTH_OVER" | "MAX_LENGTH" | "TOO_LONG" => "is too long", - "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN" => "is not in the required format", - "REQUIRED" | "MISSING" | "MISSING_REQUIRED_FIELD" => "is required", - _ => "is invalid", - }; - match field_hint(&f.path, &f.code) { - Some(hint) => format!("{name} {problem} ({hint})"), - None => format!("{name} {problem}"), - } -} - -/// A field-specific hint keyed on the path's leaf and the error code, or `None`. -fn field_hint(path: &str, code: &str) -> Option<&'static str> { - let leaf = path.rsplit('.').next().unwrap_or(path); - match (leaf, code) { - ("phone", _) | ("fax", _) => Some("expected a format like +1.4805551212"), - ("postalCode", "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN") => { - Some("some countries require a specific format, e.g. UK SW1A 2AA") - } - ("address2", "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT") => { - Some("leave it blank to omit it") - } - _ => None, - } -} - -/// Turn an API field path (`body.contacts.registrant.addressMailing.address2`) -/// into a human phrase (`registrant mailing address line 2`). Falls back to a -/// space-joined, camelCase-split rendering for unrecognized paths. -fn friendly_field_name(path: &str) -> String { - let trimmed = path.strip_prefix("body.").unwrap_or(path); - let segments: Vec<&str> = trimmed.split('.').filter(|s| !s.is_empty()).collect(); - // contacts.. — lead with the role so the user knows which - // contact (registrant/admin/billing/tech) the error is about. - if let ["contacts", role, rest @ ..] = segments.as_slice() { - let mut parts = vec![humanize_segment(role)]; - parts.extend(rest.iter().map(|s| humanize_segment(s))); - return parts.join(" "); - } - if segments.is_empty() { - return path.to_string(); - } - segments - .iter() - .map(|s| humanize_segment(s)) - .collect::>() - .join(" ") -} - -/// Humanize a single path segment, mapping the API's contact/address field names -/// to friendly words and otherwise splitting camelCase into lowercase words. -fn humanize_segment(seg: &str) -> String { - match seg { - "addressMailing" => "mailing address".to_string(), - "addressBilling" => "billing address".to_string(), - "address1" => "line 1".to_string(), - "address2" => "line 2".to_string(), - "postalCode" => "postal code".to_string(), - "nameFirst" => "first name".to_string(), - "nameLast" => "last name".to_string(), - "nameMiddle" => "middle name".to_string(), - "jobTitle" => "job title".to_string(), - "agreedAt" => "agreed-at timestamp".to_string(), - other => split_camel_case(other), - } -} - -/// Split a camelCase identifier into space-separated lowercase words -/// (`nationalNumber` → `national number`). Leaves already-lowercase words as-is. -fn split_camel_case(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 4); - for (i, ch) in s.char_indices() { - if ch.is_ascii_uppercase() { - if i != 0 { - out.push(' '); - } - out.push(ch.to_ascii_lowercase()); - } else { - out.push(ch); - } - } - out -} - -/// `DomainPurchase` request fields this CLI can populate (other than the four -/// contacts, which are checked separately), named as the per-TLD schema names -/// them. A `required` field outside this set + the contacts is something we -/// can't supply yet. -/// Non-contact `required`-field names the purchase can satisfy. Spans both the -/// v1 `DomainPurchase` and v2 `DomainPurchaseV2` bodies (the preflight reads the -/// v1 purchase schema, but we tolerate v2 naming too — see -/// [`check_tld_requirements`]). `contacts` is the v2 wrapper object; `metadata` -/// is the v2 free-form eligibility object. -const SENDABLE_PURCHASE_FIELDS: &[&str] = &[ - "domain", - "consent", - "period", - "privacy", - "renewAuto", - "nameServers", - "contacts", - "metadata", -]; - -/// Map a schema `required` field name to a contact role, tolerating both the v1 -/// flat naming (`contactRegistrant`) and the v2 nested naming -/// (`contacts.registrant`, or a bare `registrant`). Case-insensitive. -fn contact_role_for(field: &str) -> Option { - let lower = field.to_ascii_lowercase(); - let role = lower - .strip_prefix("contacts.") - .or_else(|| lower.strip_prefix("contact")) - .unwrap_or(lower.as_str()) - .trim_matches(['.', '_']); - match role { - "registrant" => Some(Role::Registrant), - "admin" => Some(Role::Admin), - "billing" => Some(Role::Billing), - "tech" => Some(Role::Tech), - _ => None, - } -} - -/// Preflight a purchase against the TLD's required fields (the top-level -/// `required` array from `GET /v1/domains/purchase/schema/{tld}`). -/// -/// We deliberately read the **v1** purchase schema even though the request is v2 -/// register: it expresses contact requirements in flat per-role names that map -/// directly to roles (the v2 register schema nests them under `contacts`, which -/// is coarser), and it's a reliable proxy for the v2 TLD requirements. To stay -/// correct if the naming ever shifts to v2 conventions, [`contact_role_for`] -/// accepts both, and [`SENDABLE_PURCHASE_FIELDS`] covers the v2 body fields. -/// -/// Pure (no I/O) so it's unit-testable. `present_contacts` are the roles we will -/// actually send (resolved from contacts.toml). Blocks *before* the paid call -/// when a required contact isn't being sent, or when a required field falls -/// outside what this CLI can supply — per the "block with a clear error" -/// decision, so a doomed purchase is never attempted. (Omitting a contact does -/// **not** reliably fall back to account defaults — e.g. `.fun` rejects a -/// missing registrant — which is why a missing required contact is a hard fail.) -fn check_tld_requirements(tld: &str, required: &[String], present_contacts: &[Role]) -> Result<()> { - for field in required { - if let Some(role) = contact_role_for(field) { - if !present_contacts.contains(&role) { - let label = role.label(); - return Err(CliCoreError::message(format!( - "registering .{tld} requires a {label} contact, but none is configured; \ - add a [{label}] section to contacts.toml (run `gddy domain contacts init` to \ - scaffold one). See `gddy guide domain-purchase`." - ))); - } - continue; - } - if SENDABLE_PURCHASE_FIELDS.contains(&field.as_str()) { - continue; - } - return Err(CliCoreError::message(format!( - "registering .{tld} requires '{field}', which this CLI can't supply yet; \ - inspect the full requirements with `gddy domain schema {tld}`" - ))); - } - Ok(()) -} +//! These commands span two generations of the GoDaddy Domains API behind one +//! host (see the [`domains_client`] crate): +//! +//! * **v3** (Domain Lifecycle Management API) — `available`, `suggest`, `get`, +//! `quote`, `purchase` (quote → register), and `nameservers set`. +//! * **v1** — `list` (the shopper's domains) and `agreements` (a TLD's legal +//! agreements), which v3 does not yet serve. +//! +//! Both accept either an sso-key API key or an OAuth bearer token; the scheme is +//! chosen from the credential the +//! [`CompositeAuthProvider`](crate::auth::CompositeAuthProvider) returns. The v3 +//! registration flow (`purchase`) requires an OAuth customer token (it mints a +//! single-use quote token, records consent, and registers under the customer). +//! +//! Each subcommand lives in its own module; [`common`] holds the shared +//! authenticated client, money formatting, and API-error rendering. + +use cli_engine::{GroupSpec, Module, RuntimeGroupSpec}; + +mod agreements; +mod available; +mod common; +mod contacts; +mod get; +mod list; +mod nameservers; +mod purchase; +mod quote; +mod suggest; + +// Shared with the `dns` module, which builds the same Domains API client and +// reuses the repeatable-argument helper. +pub(crate) use common::{make_client, string_list}; pub fn module() -> Module { Module::new("Domains", |_ctx| { - RuntimeGroupSpec::new(GroupSpec::new( - "domain", - "List your domains, check availability, and get suggestions", - ).with_long( - "Work with domains on your GoDaddy account and the public registry.\n\ + RuntimeGroupSpec::new( + GroupSpec::new( + "domain", + "List your domains, check availability, and register new ones", + ) + .with_long( + "Work with domains on your GoDaddy account and the public registry.\n\ \n\ • list / get — your existing domains and their details\n\ • available / suggest — find a name to register\n\ - • agreements / schema — see what a TLD requires before you buy\n\ + • quote — price a registration and see required agreements\n\ • purchase — register a new domain (charges your account)\n\ + • nameservers set — point a domain at custom nameservers\n\ \n\ Reads need the `domains.domain:read` scope; purchase also needs\n\ `domains.domain:create`. Manage a domain's DNS with `gddy dns`.", - )) - .with_command(RuntimeCommandSpec::new_with_context( - CommandSpec::new("list", "List the domains in your account") - .with_long( - "List the domains registered to your account. Shows domain, status, \ - expiry, and auto-renew by default; use --fields to pick columns and \ - --status to filter (repeatable).", - ) - .with_system("domain") - .with_tier(Tier::Read) - .with_default_fields("domain,status,expires,renewAuto") - .with_json_schema::() - .with_scopes(&[DOMAINS_READ_SCOPE]) - .with_arg( - clap::Arg::new("status") - .long("status") - .value_name("STATUS") - .action(clap::ArgAction::Append) - .help("Only domains with this status, e.g. ACTIVE (repeatable)"), - ), - |ctx| async move { - let statuses = parse_statuses(&string_list(&ctx, "status"))?; - - let client = make_client(&ctx).await?; - let mut req = client.list(); - if !statuses.is_empty() { - req = req.statuses(statuses); - } - let resp = req - .send() - .await - .map_err(|e| CliCoreError::message(format!("listing domains failed: {e}")))?; - - // Emit each summary as an object so `--fields`/default-field - // projection works (default shows domain/status/expires/renewAuto). - let domains: Vec = resp - .into_inner() - .iter() - .map(serde_json::to_value) - .collect::>() - .map_err(|e| { - CliCoreError::message(format!("failed to serialize domain list: {e}")) - })?; - - Ok(CommandResult::new(json!(domains)).with_next_actions(vec![ - NextAction::new("dns list ", "View a domain's DNS records") - .with_param("domain", NextActionParam::required()), - ])) - }, - )) - .with_command(RuntimeCommandSpec::new_with_context( - CommandSpec::new("get", "Show full details for one of your domains") - .with_long( - "Show every detail for a single domain in your account (status, \ - expiry, contacts, nameservers, and more). Unlike `list`, this shows \ - all fields by default. The domain must be one you own.", - ) - .with_system("domain") - .with_tier(Tier::Read) - // No default fields: `get` is a single-domain deep-dive, so show - // every detail by default (an empty selection keeps all fields). - // `list` keeps its abbreviated default; `get` does not. - .with_output_schema::() - .with_scopes(&[DOMAINS_READ_SCOPE]) - .with_arg( - clap::Arg::new("domain") - .value_name("DOMAIN") - .required(true) - .help("Domain to look up (must be in your account), e.g. example.com"), - ), - |ctx| async move { - let domain = ctx - .args - .get("domain") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(); - let debug = !ctx.middleware.debug.is_empty(); - let client = make_client(&ctx).await?; - let detail = match client.get().domain(domain.as_str()).send().await { - Ok(r) => r.into_inner(), - Err(e) => return Err(api_error("retrieving domain details", debug, e).await), - }; - Ok( - CommandResult::new(serde_json::Value::Object(detail)).with_next_actions(vec![ - NextAction::new("dns list ", "View this domain's DNS records") - .with_param("domain", NextActionParam::required()), - ]), - ) - }, - )) - .with_command(RuntimeCommandSpec::new_with_context( - CommandSpec::new("available", "Check whether a domain is available") - .with_long( - "Check whether a domain can be registered, and at what price. \ - --check-type fast trades accuracy for speed; full is authoritative \ - (the `definitive` field tells you which you got). Pass --for-transfer \ - to also report names available to transfer in.", - ) - .with_system("domain") - .with_tier(Tier::Read) - .with_default_fields("domain,available,definitive,price,currency") - .with_json_schema::() - .with_scopes(&[DOMAINS_READ_SCOPE]) - .with_arg( - clap::Arg::new("domain") - .value_name("DOMAIN") - .required(true) - .help("Domain name to check (e.g. example.com)"), - ) - .with_arg( - clap::Arg::new("check-type") - .long("check-type") - .value_name("TYPE") - .value_parser(["fast", "full"]) - .help("Optimize for speed (fast) or accuracy (full)"), - ) - .with_arg( - clap::Arg::new("for-transfer") - .long("for-transfer") - .action(clap::ArgAction::SetTrue) - .help("Also include domains available for transfer"), - ), - |ctx| async move { - let domain = ctx - .args - .get("domain") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(); - let check_type = match ctx.args.get("check-type").and_then(|v| v.as_str()) { - Some(s) => Some( - domains_client::types::AvailableCheckType::try_from( - s.to_uppercase().as_str(), - ) - .map_err(|_| { - CliCoreError::message(format!( - "invalid --check-type {s:?}; expected fast|full" - )) - })?, - ), - None => None, - }; - let for_transfer = ctx - .args - .get("for-transfer") - .and_then(|v| v.as_bool()) - .filter(|&b| b); - - let client = make_client(&ctx).await?; - let mut req = client.available().domain(domain.as_str()); - if let Some(ct) = check_type { - req = req.check_type(ct); - } - if let Some(ft) = for_transfer { - req = req.for_transfer(ft); - } - let resp = req.send().await.map_err(|e| { - CliCoreError::message(format!("domain availability check failed: {e}")) - })?; - let body = resp.into_inner(); - - let mut result = json!({ - "domain": body.domain, - "available": body.available, - "definitive": body.definitive, - }); - if let Some(price) = format_price(body.price) { - result["price"] = json!(price); - result["currency"] = json!(body.currency); - } - if let Some(renewal) = format_price(body.renewal_price) { - result["renewalPrice"] = json!(renewal); - } - if let Some(period) = body.period { - result["period"] = json!(period); - } - - let cmd = CommandResult::new(result); - // If it's taken, point at suggestions for the same seed. - if body.available { - Ok(cmd) - } else { - Ok(cmd.with_next_actions(vec![ - NextAction::new( - "domain suggest ", - "Find alternative available domains", - ) - .with_param("query", NextActionParam::required()), - ])) - } - }, - )) - .with_command(RuntimeCommandSpec::new_with_context( - CommandSpec::new("suggest", "Suggest available domains for a query") - .with_long( - "Suggest available domains from a seed word, phrase, or domain. \ - Narrow results with --tlds (repeatable), cap them with --limit, and \ - bias toward a region with --country/--city. Feed a promising result \ - straight into `gddy domain available`.", - ) - .with_system("domain") - .with_tier(Tier::Read) - .with_default_fields("domain") - .with_json_schema::() - .with_scopes(&[DOMAINS_READ_SCOPE]) - .with_arg( - clap::Arg::new("query") - .value_name("QUERY") - .required(true) - .help("Seed domain or keywords to base suggestions on"), - ) - .with_arg( - clap::Arg::new("tlds") - .long("tlds") - .value_name("TLD") - .action(clap::ArgAction::Append) - .help("Limit suggestions to these TLDs (repeatable)"), - ) - .with_arg( - clap::Arg::new("limit") - .long("limit") - .value_name("N") - .value_parser(clap::value_parser!(i64).range(1..)) - .help("Maximum number of suggestions to return"), - ) - .with_arg( - clap::Arg::new("country") - .long("country") - .value_name("CC") - .help("Two-letter ISO country hint (e.g. US)"), - ) - .with_arg( - clap::Arg::new("city") - .long("city") - .value_name("CITY") - .help("City hint for the target region"), - ), - |ctx| async move { - let query = ctx - .args - .get("query") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(); - let tlds = string_list(&ctx, "tlds"); - let tlds = (!tlds.is_empty()).then_some(tlds); - let limit = ctx.args.get("limit").and_then(|v| v.as_i64()); - let city = ctx.args.get("city").and_then(|v| v.as_str()); - let country = match ctx.args.get("country").and_then(|v| v.as_str()) { - Some(c) => Some( - domains_client::types::SuggestCountry::try_from(c.to_uppercase().as_str()) - .map_err(|_| { - CliCoreError::message(format!("invalid --country {c:?}")) - })?, - ), - None => None, - }; - - let client = make_client(&ctx).await?; - let mut req = client.suggest().query(query.as_str()); - if let Some(n) = limit { - req = req.limit(n); - } - if let Some(t) = tlds { - req = req.tlds(t); - } - if let Some(c) = country { - req = req.country(c); - } - if let Some(city) = city { - req = req.city(city); - } - let resp = req - .send() - .await - .map_err(|e| CliCoreError::message(format!("domain suggestion failed: {e}")))?; - // Emit objects (not bare strings) so the list has a projectable - // `domain` field for `--fields`/default-field rendering. - let suggestions: Vec = resp - .into_inner() - .into_iter() - .map(|s| json!({ "domain": s.domain })) - .collect(); - - Ok( - CommandResult::new(json!(suggestions)).with_next_actions(vec![ - NextAction::new("domain available ", "Check a suggested domain") - .with_param("domain", NextActionParam::required()), - ]), - ) - }, - )) - .with_command(RuntimeCommandSpec::new_with_context( - CommandSpec::new( - "agreements", - "Show the legal agreements required to register a TLD", - ) - .with_long( - "List the legal agreements you must consent to before registering under \ - a TLD. Review these before running `gddy domain purchase --agree`, which \ - records consent to exactly these agreements on your behalf.", - ) - .with_system("domain") - .with_tier(Tier::Read) - .with_default_fields("agreementKey,title,url") - .with_json_schema::() - .with_scopes(&[DOMAINS_READ_SCOPE]) - .with_arg( - clap::Arg::new("tld") - .long("tld") - .value_name("TLD") - .required(true) - .action(clap::ArgAction::Append) - .help("TLD whose agreements to retrieve, e.g. com (repeatable)"), - ) - .with_arg( - clap::Arg::new("privacy") - .long("privacy") - .action(clap::ArgAction::SetTrue) - .help("Retrieve the agreements that apply when privacy is requested"), - ) - .with_arg( - clap::Arg::new("for-transfer") - .long("for-transfer") - .action(clap::ArgAction::SetTrue) - .help("Retrieve the agreements that apply to a transfer"), - ), - |ctx| async move { - let tlds = string_list(&ctx, "tld"); - let privacy = ctx - .args - .get("privacy") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let for_transfer = ctx - .args - .get("for-transfer") - .and_then(|v| v.as_bool()) - .filter(|&b| b); - - let debug = !ctx.middleware.debug.is_empty(); - let client = make_client(&ctx).await?; - let mut req = client.agreements().tlds(tlds).privacy(privacy); - if let Some(ft) = for_transfer { - req = req.for_transfer(ft); - } - let resp = match req.send().await { - Ok(r) => r, - Err(e) => return Err(api_error("retrieving legal agreements", debug, e).await), - }; - - // Emit objects with a projectable shape; `content` (the full - // agreement text) is carried through for `--fields content` but - // hidden by the default fields. - let agreements: Vec = resp - .into_inner() - .into_iter() - .map(|a| { - json!({ - "agreementKey": a.agreement_key, - "title": a.title, - "url": a.url, - "content": a.content, - }) - }) - .collect(); - - Ok(CommandResult::new(json!(agreements))) - }, - )) - .with_command(RuntimeCommandSpec::new_with_context( - CommandSpec::new( - "schema", - "Show a TLD's requirements for registering a domain", - ) - .with_long( - "Show what a TLD requires to register under it — which contact roles are \ - mandatory and any TLD-specific fields (e.g. eligibility for some \ - country-code TLDs). Check this before `gddy domain purchase` so you know \ - which contacts to populate in contacts.toml.", - ) - .with_system("domain") - .with_tier(Tier::Read) - .with_default_fields("required") - .with_output_schema::() - .with_scopes(&[DOMAINS_READ_SCOPE]) - .with_arg( - clap::Arg::new("tld") - .value_name("TLD") - .required(true) - .help("TLD to inspect, e.g. fun"), ), - |ctx| async move { - let tld = ctx - .args - .get("tld") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(); - let debug = !ctx.middleware.debug.is_empty(); - let client = make_client(&ctx).await?; - let schema = match client.schema().tld(tld.as_str()).send().await { - Ok(r) => r.into_inner(), - Err(e) => { - return Err(api_error("retrieving the TLD purchase schema", debug, e).await); - } - }; - Ok(CommandResult::new(serde_json::Value::Object(schema))) - }, - )) - .with_command(RuntimeCommandSpec::new_with_context( - CommandSpec::new("purchase", "Register a domain (paid; charges your account)") - .with_long( - "Register a domain. This charges your GoDaddy account and cannot be \ - undone, so it is gated behind --confirm.\n\ - \n\ - Before it charges, the command checks the TLD's requirements and the \ - domain's availability, then records your consent to the TLD's legal \ - agreements (--agree). Contacts come from your account by default; to \ - override them, define registrant/admin/billing/tech in contacts.toml \ - (scaffold one with `gddy domain contacts init`). A usable payment \ - method must be on file — add one with `gddy payments add`.\n\ - \n\ - Typical flow:\n \ - 1. gddy domain available example.com\n \ - 2. gddy domain agreements --tld com # review what --agree consents to\n \ - 3. gddy domain purchase example.com --agree --confirm\n\ - \n\ - See `gddy guide domain-purchase` for the full walkthrough.", - ) - .with_system("domain") - .with_tier(Tier::Destructive) - .with_default_fields("domain,status,price,currency") - .with_output_schema::() - // The flow reads the TLD schema, availability, and agreements - // (all `domains.domain:read`) before registering (`:create`), so - // the minted token must carry both scopes. - .with_scopes(&[DOMAINS_READ_SCOPE, DOMAINS_PURCHASE_SCOPE]) - .with_arg( - clap::Arg::new("domain") - .value_name("DOMAIN") - .required(true) - .help("Domain to register, e.g. example.com"), - ) - .with_arg( - clap::Arg::new("period") - .long("period") - .value_name("YEARS") - .value_parser(clap::value_parser!(u64).range(1..=10)) - .default_value("1") - .help("Registration length in years (1-10)"), - ) - .with_arg( - clap::Arg::new("privacy") - .long("privacy") - .action(clap::ArgAction::SetTrue) - .help("Add privacy protection to the registration"), - ) - .with_arg( - clap::Arg::new("no-renew") - .long("no-renew") - .action(clap::ArgAction::SetTrue) - .help("Disable auto-renewal (auto-renew is on by default)"), - ) - .with_arg( - clap::Arg::new("nameserver") - .long("nameserver") - .value_name("HOST") - .action(clap::ArgAction::Append) - .help("Custom nameserver (repeatable); omit to use GoDaddy defaults"), - ) - .with_arg( - clap::Arg::new("agree") - .long("agree") - .action(clap::ArgAction::SetTrue) - .help( - "Consent to the TLD's legal agreements (review with \ - `gddy domain agreements` or `gddy guide domain-purchase`)", - ), - ) - .with_arg( - clap::Arg::new("agreed-by") - .long("agreed-by") - .value_name("IP") - .help("Originating IP recorded with your consent (defaults to 127.0.0.1)"), - ) - .with_arg( - clap::Arg::new("confirm") - .long("confirm") - .action(clap::ArgAction::SetTrue) - .help("Confirm the purchase; required because it charges your account"), - ), - |ctx| async move { - let domain = ctx - .args - .get("domain") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_owned(); - let Some(tld) = registrable_tld(&domain).map(str::to_owned) else { - return Err(CliCoreError::message(format!( - "{domain:?} is not a registrable domain (expected e.g. example.com)" - ))); - }; - let period = ctx.args.get("period").and_then(|v| v.as_u64()).unwrap_or(1); - let privacy = ctx - .args - .get("privacy") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let renew_auto = !ctx - .args - .get("no-renew") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let name_servers = string_list(&ctx, "nameserver"); - let agree = ctx - .args - .get("agree") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let confirm = ctx - .args - .get("confirm") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let agreed_by = ctx - .args - .get("agreed-by") - .and_then(|v| v.as_str()) - .unwrap_or("127.0.0.1") - .to_owned(); - - let debug = !ctx.middleware.debug.is_empty(); - - // Validate auth before anything else: purchase goes through the v2 - // register API, which (unlike v1) authorizes card payments for OAuth - // users and needs the customerId from the OAuth token. sso-key auth - // can never succeed here, so fail with that clear message *before* - // touching contacts.toml (whose parse errors would otherwise mask it). - let cred = ctx.credential().await?; - if cred.provider == SSO_KEY_PROVIDER { - return Err(CliCoreError::message( - "`domain purchase` uses the v2 registration API, which requires OAuth \ - authentication; this environment is configured for an sso-key. Re-run \ - against an OAuth environment.", - )); - } - let customer_id = customer_id(&cred)?; - - // Resolve default contacts from local config (still before any - // network call). An absent role is omitted from the request; the - // API uses the account default for roles the TLD doesn't require - // (the preflight below already blocks a missing *required* role, - // since those don't reliably fall back — e.g. `.fun`). - let contacts = - contacts::load().map_err(|e| CliCoreError::message(e.to_string()))?; - let contact_registrant = contacts - .to_api(Role::Registrant) - .map_err(CliCoreError::message)?; - let contact_admin = contacts - .to_api(Role::Admin) - .map_err(CliCoreError::message)?; - let contact_billing = contacts - .to_api(Role::Billing) - .map_err(CliCoreError::message)?; - let contact_tech = contacts.to_api(Role::Tech).map_err(CliCoreError::message)?; - - // Reuse the credential we already resolved (and validated for - // customerId) — a single resolution, same token for the requests. - let client = make_client_with_cred(&ctx.middleware.env, &cred)?; - - // Preflight: the TLD's purchase schema lists which fields it - // requires. Block before the paid call when a required contact - // isn't being sent (or a field we can't supply is required), - // rather than letting the register POST come back 422. - let present_contacts: Vec = [ - (Role::Registrant, &contact_registrant), - (Role::Admin, &contact_admin), - (Role::Billing, &contact_billing), - (Role::Tech, &contact_tech), - ] - .into_iter() - .filter_map(|(role, c)| c.is_some().then_some(role)) - .collect(); - let schema = match client.schema().tld(tld.as_str()).send().await { - Ok(r) => r.into_inner(), - Err(e) => { - return Err(api_error("retrieving the TLD purchase schema", debug, e).await); - } - }; - let required: Vec = schema - .get("required") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(str::to_owned)) - .collect() - }) - .unwrap_or_default(); - check_tld_requirements(&tld, &required, &present_contacts)?; - - // Price + currency: the v2 consent records the price the user - // acknowledged (per the API, sourced from GET /v1/domains/available). - let availability = match client.available().domain(domain.as_str()).send().await { - Ok(r) => r.into_inner(), - Err(e) => { - return Err(api_error("checking domain availability", debug, e).await); - } - }; - // Fail fast on a taken domain: availability can carry a price even - // when `available` is false, so without this the command would - // proceed to a guaranteed-failing paid register. - if !availability.available { - return Err(CliCoreError::message(format!( - "{domain} is not available for registration" - ))); - } - let price = availability.price.ok_or_else(|| { - CliCoreError::message(format!( - "could not determine a price for {domain}; it may be premium or \ - not offered for registration" - )) - })?; - let currency = availability.currency; - - // The legal agreements for the TLD: their keys are the consent - // record, and (when --agree is missing) the list shown to review. - let agreements = match client - .agreements() - .tlds(vec![tld.clone()]) - .privacy(privacy) - .send() - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Err(api_error("retrieving legal agreements", debug, e).await), - }; - - let agreement_keys = - purchase_consent_keys(&domain, &tld, period, agree, confirm, &agreements)?; - - let consent = domains_client::types::ConsentV2 { - agreed_at: iso_datetime(chrono::Utc::now()), - agreed_by, - agreement_keys, - claim_token: None, - currency: currency.clone(), - price, - registry_premium_pricing: None, - }; - // Only send a contacts object when at least one role is configured; - // otherwise omit it so the API uses the account-default contacts. - let contacts_body = domains_client::types::DomainContactsCreateV2 { - registrant: contact_registrant, - registrant_id: None, - admin: contact_admin, - admin_id: None, - billing: contact_billing, - billing_id: None, - tech: contact_tech, - tech_id: None, - }; - let has_contacts = contacts_body.registrant.is_some() - || contacts_body.admin.is_some() - || contacts_body.billing.is_some() - || contacts_body.tech.is_some(); - let period = std::num::NonZeroU64::new(period) - .expect("clap value_parser enforces period >= 1"); - let purchase = domains_client::types::DomainPurchaseV2 { - consent, - contacts: has_contacts.then_some(contacts_body), - domain: domain.clone(), - metadata: Default::default(), - name_servers, - period, - privacy, - renew_auto, - }; - - // 202 Accepted with no body — registration proceeds asynchronously. - if let Err(e) = client - .register() - .customer_id(customer_id.as_str()) - .body(purchase) - .send() - .await - { - let err = api_error("domain purchase", debug, e).await; - if debug { - // Surface the customer-scoped path we POSTed to — the most - // common cause of a register failure is the wrong - // customerId. - return Err(CliCoreError::message(format!( - "{err}\n[debug] request: POST /v2/customers/{customer_id}/domains/register" - ))); - } - return Err(err); - } - - let result = json!({ - "domain": domain, - "status": "submitted", - "price": format_price(Some(price)), - "currency": currency, - }); - Ok( - CommandResult::new(result).with_next_actions(vec![NextAction::new( - "domain list", - "See your registered domains (registration completes asynchronously)", - )]), - ) - }, - )) - .with_group( - RuntimeGroupSpec::new(GroupSpec::new( - "contacts", - "Manage saved default contacts for domain purchases", - ).with_long( - "Manage the optional contacts.toml that supplies registrant/admin/billing/\ - tech contacts for `gddy domain purchase`. When a role is absent the \ - purchase falls back to your account's default contact for that role.", - )) - .with_command(RuntimeCommandSpec::new_with_context( - CommandSpec::new("init", "Write a starter contacts.toml you can edit") - .with_long( - "Scaffold a starter contacts.toml in your config directory with \ - every role commented out (so it's inert until you edit it). Fill \ - in any roles you want to override the account defaults for. Pass \ - --force to overwrite an existing file. Phone numbers are \ - normalized automatically, so common formats are accepted.", - ) - .with_system("domain") - // Local file write, no API call: dry-run aware, no auth. - .with_tier(Tier::Mutate) - .mutates(true) - .no_auth(true) - .with_default_fields("path,action") - .with_arg( - clap::Arg::new("force") - .long("force") - .action(clap::ArgAction::SetTrue) - .help("Overwrite an existing contacts.toml"), - ), - |ctx| async move { - let path = contacts::contacts_path().ok_or_else(|| { - CliCoreError::message( - "could not determine a config directory for contacts.toml", - ) - })?; - let force = ctx - .args - .get("force") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let existed = path.exists(); - if existed && !force { - return Err(CliCoreError::message(format!( - "{} already exists; pass --force to overwrite", - path.display() - ))); - } - cli_engine::fs::write_string_atomic(&path, contacts::sample_toml())?; - - Ok(CommandResult::new(json!({ - "path": path.display().to_string(), - // Base on prior existence, not the flag: `--force` on a - // missing file still creates rather than overwrites. - "action": if existed { "overwritten" } else { "created" }, - })) - .with_next_actions(vec![NextAction::new( - "guide domain-purchase", - "Learn how purchase uses these contacts", - )])) - }, - )), ) + .with_command(list::command()) + .with_command(get::command()) + .with_command(available::command()) + .with_command(suggest::command()) + .with_command(agreements::command()) + .with_command(quote::command()) + .with_command(purchase::command()) + .with_group(nameservers::group()) + .with_group(contacts::group()) }) - // Long-form purchase walkthrough (consent gates + saved default contacts), - // surfaced as `gddy guide domain-purchase`. The command help and the - // missing-`--agree` error both point here. .with_guides_from_markdown([( "domain-purchase.md", include_bytes!("guides/domain-purchase.md").as_slice(), @@ -1356,393 +72,18 @@ pub fn module() -> Module { #[cfg(test)] mod tests { - use super::{ - authorization_header, check_tld_requirements, customer_id, format_api_error, format_price, - iso_datetime, parse_statuses, purchase_consent_keys, registrable_tld, - }; - use crate::auth::SSO_KEY_PROVIDER; - use crate::contacts::Role; - use cli_engine::Credential; use cli_engine::{Cli, CliConfig}; - #[test] - fn formats_micro_units_to_decimal() { - assert_eq!(format_price(Some(11_990_000)).as_deref(), Some("11.99")); - assert_eq!(format_price(Some(1_000_000)).as_deref(), Some("1.00")); - assert_eq!(format_price(Some(20_500_000)).as_deref(), Some("20.50")); - // Negatives keep their sign, including sub-unit amounts. - assert_eq!(format_price(Some(-11_990_000)).as_deref(), Some("-11.99")); - assert_eq!(format_price(Some(-500_000)).as_deref(), Some("-0.50")); - assert_eq!(format_price(None), None); - // Sub-cent micros truncate toward the lower cent (documented behavior): - // 1_005_000 micros = 1.005 -> "1.00", never "1.01". - assert_eq!(format_price(Some(1_005_000)).as_deref(), Some("1.00")); - } - - #[test] - fn authorization_header_picks_scheme_from_provider() { - // sso-key bypass path -> `sso-key KEY:SECRET`. - assert_eq!( - authorization_header(SSO_KEY_PROVIDER, "KEY:SECRET"), - "sso-key KEY:SECRET" - ); - // Any other provider (OAuth/PKCE) -> `Bearer `. - assert_eq!(authorization_header("godaddy", "tok123"), "Bearer tok123"); - } - - #[test] - fn parse_statuses_is_case_insensitive_and_validates() { - use domains_client::types::ListStatusesItem; - let parsed = parse_statuses(&["active".to_string(), "CANCELLED".to_string()]) - .expect("valid statuses"); - assert_eq!( - parsed, - vec![ListStatusesItem::Active, ListStatusesItem::Cancelled] - ); - // Empty input is valid (no filter). - assert!(parse_statuses(&[]).expect("empty ok").is_empty()); - // Unknown status is rejected with a helpful message. - let err = parse_statuses(&["bogus".to_string()]).expect_err("should reject"); - assert!(err.to_string().contains("invalid --status"), "{err}"); - } - - #[test] - fn registrable_tld_takes_everything_after_first_label() { - assert_eq!(registrable_tld("example.com"), Some("com")); - assert_eq!(registrable_tld("example.co.uk"), Some("co.uk")); - // No dot, or an empty label/TLD -> not a registrable domain. - assert_eq!(registrable_tld("localhost"), None); - assert_eq!(registrable_tld(".com"), None); - assert_eq!(registrable_tld("example."), None); - } - - fn agreement( - key: &str, - title: &str, - url: Option<&str>, - ) -> domains_client::types::LegalAgreement { - domains_client::types::LegalAgreement { - agreement_key: Some(key.to_string()), - title: Some(title.to_string()), - url: url.map(str::to_string), - content: None, - } - } - - #[test] - fn purchase_consent_requires_agree_then_confirm() { - let agreements = vec![agreement( - "DNRA", - "Domain Registration Agreement", - Some("https://example.com/dnra"), - )]; - - // Without --agree: error lists the agreement(s) and points to review. - let err = purchase_consent_keys("example.com", "com", 1, false, true, &agreements) - .expect_err("must require --agree"); - let msg = err.to_string(); - assert!(msg.contains("Domain Registration Agreement"), "{msg}"); - assert!(msg.contains("--agree"), "{msg}"); - - // With --agree but not --confirm: error is about the charge. - let err = purchase_consent_keys("example.com", "com", 2, true, false, &agreements) - .expect_err("must require --confirm"); - let msg = err.to_string(); - assert!(msg.contains("--confirm"), "{msg}"); - assert!(msg.contains("2 year(s)"), "{msg}"); - - // Both gates passed: returns the agreement keys to record as consent. - let keys = purchase_consent_keys("example.com", "com", 1, true, true, &agreements) - .expect("both gates satisfied"); - assert_eq!(keys, vec!["DNRA".to_string()]); - } - - #[test] - fn purchase_consent_rejects_when_no_agreement_keys() { - // --agree given, but the API returned no usable keys: consent can't be - // recorded, so the purchase must not proceed even with --confirm. - let err = purchase_consent_keys("example.com", "com", 1, true, true, &[]) - .expect_err("no keys -> error"); - assert!(err.to_string().contains("no legal agreement keys"), "{err}"); - } - - #[test] - fn payment_required_error_points_to_payments_add() { - // Payment guidance is user UX, not debug — it shows without --debug. - let msg = format_api_error( - "domain purchase", - 402, - "402 Payment Required", - r#"{"code":"INVALID_PAYMENT_INFO","message":"Unable to authorize credit"}"#, - None, - false, - ); - assert!(msg.contains("402 Payment Required"), "{msg}"); - assert!(msg.contains("INVALID_PAYMENT_INFO"), "{msg}"); // original body preserved - assert!(msg.contains("gddy payments add"), "{msg}"); - } - - #[test] - fn non_payment_errors_have_no_payment_hint() { - let msg = format_api_error( - "domain purchase", - 422, - "422 Unprocessable Entity", - "body.consent.agreedAt bad format", - None, - false, - ); - assert!(msg.contains("agreedAt"), "{msg}"); - assert!(!msg.contains("payments add"), "{msg}"); - // Empty body omits the trailing colon. - let empty = format_api_error("domain schema", 404, "404 Not Found", "", None, false); - assert!(empty.ends_with("(HTTP 404 Not Found)"), "{empty}"); - } - - #[test] - fn validation_fields_render_as_plain_english_without_regex() { - // The #14 / #33 regression: an INVALID_BODY with a `fields` array must be - // translated, not pasted raw — and the raw per-field regex/message must not - // leak into the default (non-debug) output. - let body = r#"{"code":"INVALID_BODY","fields":[{"code":"LENGTH_UNDER","message":"does not meet minimum length of 1","path":"body.contacts.registrant.addressMailing.address2"}],"message":"Request body doesn't fulfill schema, see details in `fields`"}"#; - let msg = format_api_error( - "domain purchase", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!(msg.contains("registrant mailing address line 2"), "{msg}"); - assert!(msg.contains("is too short"), "{msg}"); - assert!(msg.contains("leave it blank to omit"), "{msg}"); - // Raw path and the schema boilerplate message are suppressed. - assert!(!msg.contains("body.contacts"), "{msg}"); - assert!(!msg.contains("minimum length of 1"), "{msg}"); - assert!(!msg.contains("fulfill schema"), "{msg}"); - } - - #[test] - fn pattern_failure_gives_guidance_not_regex() { - // A postcode PATTERN failure: the API message would carry the full regex; - // we surface guidance instead. - let body = r#"{"code":"INVALID_BODY","fields":[{"code":"PATTERN","message":"must match ^([A-Z]{1,2}\\d[A-Z\\d]? ?\\d[A-Z]{2})$","path":"body.contacts.registrant.addressMailing.postalCode"}]}"#; - let msg = format_api_error( - "domain purchase", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!( - msg.contains("registrant mailing address postal code"), - "{msg}" - ); - assert!(msg.contains("is not in the required format"), "{msg}"); - assert!(msg.contains("SW1A 2AA"), "{msg}"); - assert!(!msg.contains("[A-Z]"), "{msg}"); // no regex leaked - } - - #[test] - fn phone_field_error_gives_dotted_example() { - let body = r#"{"fields":[{"code":"PATTERN","path":"body.contacts.registrant.phone"}]}"#; - let msg = format_api_error("domain purchase", 422, "422", body, None, false); - assert!(msg.contains("registrant phone"), "{msg}"); - assert!(msg.contains("+1.4805551212"), "{msg}"); - } - - #[test] - fn raw_body_recoverable_under_debug_for_field_errors() { - let body = r#"{"fields":[{"code":"LENGTH_UNDER","path":"body.contacts.registrant.addressMailing.address2"}]}"#; - let debug = format_api_error("domain purchase", 422, "422", body, None, true); - assert!(debug.contains("Response body:"), "{debug}"); - assert!(debug.contains("address2"), "{debug}"); // raw json present under --debug - } - - #[test] - fn fieldless_json_body_is_passed_through() { - // No `fields` array -> keep the raw body (existing behavior relied on by the - // 402 path and any non-validation JSON error). - let body = r#"{"code":"INVALID_PAYMENT_INFO","message":"Unable to authorize credit"}"#; - let msg = format_api_error( - "domain purchase", - 402, - "402 Payment Required", - body, - None, - false, - ); - assert!(msg.contains("INVALID_PAYMENT_INFO"), "{msg}"); - assert!(!msg.contains("some fields are invalid"), "{msg}"); - } - - #[test] - fn request_id_is_gated_behind_debug() { - let id = Some("93f95de0-9379-4313-9798-bb8a49874724"); - - // Without --debug: clean output, no request id even when present. - let plain = format_api_error( - "domain purchase", - 402, - "402 Payment Required", - "{}", - id, - false, - ); - assert!(!plain.contains("Request ID"), "{plain}"); - assert!(plain.contains("gddy payments add"), "{plain}"); // payment hint still shows - - // With --debug: request id is appended (plainly, no editorializing). - let debug = format_api_error( - "domain purchase", - 402, - "402 Payment Required", - "{}", - id, - true, - ); - assert!( - debug.contains("Request ID: 93f95de0-9379-4313-9798-bb8a49874724"), - "{debug}" - ); - - // --debug but no/blank id -> still no Request ID line. - let none = format_api_error( - "domain purchase", - 422, - "422 Unprocessable Entity", - "x", - None, - true, - ); - assert!(!none.contains("Request ID"), "{none}"); - let blank = format_api_error( - "domain purchase", - 422, - "422 Unprocessable Entity", - "x", - Some(" "), - true, - ); - assert!(!blank.contains("Request ID"), "{blank}"); - } - - #[test] - fn customer_id_strips_customer_urn_prefix_from_sub() { - // GoDaddy's `sub` is a typed subject URN; the v2 path needs the bare uuid. - let cred = Credential { - sub: "customer:56fd82e4-1c45-4596-865d-317235015b2f".to_string(), - ..Default::default() - }; - assert_eq!( - customer_id(&cred).expect("customer subject"), - "56fd82e4-1c45-4596-865d-317235015b2f" - ); - - // A non-customer subject (or empty) isn't a customer id → clear error. - let shopper = Credential { - sub: "shopper:12345".to_string(), - ..Default::default() - }; - let err = customer_id(&shopper).expect_err("not a customer subject"); - assert!(err.to_string().contains("not a customer identity"), "{err}"); - assert!(customer_id(&Credential::default()).is_err()); - } - - #[test] - fn agreed_at_is_zulu_iso_datetime_not_offset() { - use chrono::TimeZone; - let dt = chrono::Utc - .with_ymd_and_hms(2026, 6, 17, 22, 34, 43) - .single() - .expect("valid instant"); - let s = iso_datetime(dt); - // The API's iso-datetime pattern requires a trailing `Z` and rejects the - // `+00:00` offset form `chrono::to_rfc3339()` would produce. - assert_eq!(s, "2026-06-17T22:34:43Z"); - assert!(!s.contains('+'), "must not use a numeric offset: {s}"); - } - - #[test] - fn tld_preflight_blocks_missing_required_contact() { - // .fun requires a registrant; we're sending no contacts -> block with an - // actionable message before the paid call. - let required = vec![ - "domain".to_string(), - "consent".to_string(), - "contactRegistrant".to_string(), - ]; - let err = check_tld_requirements("fun", &required, &[]).expect_err("registrant required"); - let msg = err.to_string(); - assert!(msg.contains("registrant"), "{msg}"); - assert!(msg.contains("contacts.toml"), "{msg}"); - - // Sending a registrant satisfies it. - check_tld_requirements("fun", &required, &[Role::Registrant]) - .expect("registrant present satisfies the requirement"); - } - - #[test] - fn tld_preflight_recognizes_v1_and_v2_contact_naming() { - // The same role is recognized whether the schema names it flat (v1) or - // nested (v2), so a v1→v2 requirement-naming shift still blocks correctly. - for name in ["contactRegistrant", "contacts.registrant", "registrant"] { - let required = vec!["domain".to_string(), name.to_string()]; - assert!( - check_tld_requirements("fun", &required, &[]).is_err(), - "{name} should require a registrant" - ); - assert!( - check_tld_requirements("fun", &required, &[Role::Registrant]).is_ok(), - "{name} should be satisfied by a registrant" - ); - } - // v2 body fields (the `contacts` wrapper, `metadata`) never block. - let v2 = vec![ - "domain".to_string(), - "contacts".to_string(), - "metadata".to_string(), - ]; - check_tld_requirements("com", &v2, &[]).expect("v2 body fields are sendable"); - } - - #[test] - fn tld_preflight_allows_only_always_sent_fields() { - let required = vec![ - "domain".to_string(), - "consent".to_string(), - "period".to_string(), - "privacy".to_string(), - "renewAuto".to_string(), - ]; - check_tld_requirements("com", &required, &[]).expect("always-sent fields never block"); - } - - #[test] - fn tld_preflight_blocks_unsupplyable_field() { - // A required field outside DomainPurchase -> block and point at the - // schema command (rather than attempt a guaranteed-422 purchase). - let required = vec!["domain".to_string(), "registrySpecificThing".to_string()]; - let err = - check_tld_requirements("example", &required, &[]).expect_err("unknown field blocks"); - let msg = err.to_string(); - assert!(msg.contains("registrySpecificThing"), "{msg}"); - assert!(msg.contains("domain schema"), "{msg}"); - } - /// The `domain` commands call the Domains API, so they must stay fail-closed. - /// Built with **no auth provider registered**, the engine's default + /// Built with no auth provider registered, the engine's default /// `AuthRequirement::Required` must reject them at credential resolution - /// (exit code 2, provider error) before the handler runs — guarding against - /// anyone marking them `no_auth(true)` and letting them run unauthenticated. + /// (exit code 2) before the handler runs. Running each leaf also exercises + /// clap's command-tree construction (duplicate-subcommand panics surface only + /// in debug builds). #[tokio::test] async fn domain_commands_require_auth() { const AUTH_FAILURE_EXIT: i32 = 2; - // No `--env` flag here: the global flag is registered in main.rs, not in - // this minimal test harness, and env is irrelevant since auth resolution - // fails before the handler runs. - let cases: [&[&str]; 7] = [ + let cases: [&[&str]; 8] = [ &["gddy", "domain", "list", "--output", "json"], &["gddy", "domain", "get", "example.com", "--output", "json"], &[ @@ -1763,19 +104,33 @@ mod tests { "--output", "json", ], - &["gddy", "domain", "schema", "fun", "--output", "json"], - // --agree/--confirm present so this would reach the handler if it - // weren't fail-closed: auth must still reject it first. + &["gddy", "domain", "quote", "example.com", "--output", "json"], + // `--quote-token` is required, so pass a dummy one — otherwise clap's + // missing-arg error (exit 2) would mask the auth-provider assertion. &[ "gddy", "domain", "purchase", - "example.com", + "--quote-token", + "dummy-token", "--agree", "--confirm", "--output", "json", ], + &[ + "gddy", + "domain", + "nameservers", + "set", + "example.com", + "--nameserver", + "ns1.example.net", + "--reason", + "test", + "--output", + "json", + ], ]; for args in cases { let cli = Cli::new( diff --git a/rust/src/domain/nameservers.rs b/rust/src/domain/nameservers.rs new file mode 100644 index 0000000..32b8674 --- /dev/null +++ b/rust/src/domain/nameservers.rs @@ -0,0 +1,89 @@ +//! `gddy domain nameservers` — manage a domain's nameservers (v3). + +use cli_engine::{ + CommandResult, CommandSpec, GroupSpec, RuntimeCommandSpec, RuntimeGroupSpec, Tier, +}; +use serde_json::json; + +use domains_client::types; + +use super::common::{api_error, make_client, string_list}; +use crate::output_schema::output_schema; +use crate::scopes::DOMAINS_NAMESERVER_UPDATE; + +output_schema!(NameserversResult { + "domain": "string"; + "nameservers": "[]string"; + "operationId": "string"; + "status": "string"; +}); + +pub(super) fn group() -> RuntimeGroupSpec { + RuntimeGroupSpec::new(GroupSpec::new( + "nameservers", + "Manage a domain's nameservers", + )) + .with_command(RuntimeCommandSpec::new_with_context( + CommandSpec::new("set", "Replace a domain's nameservers") + .with_long( + "Replace the full set of nameservers for a domain you own. Pass \ + --nameserver once per host. This is destructive — it overwrites the \ + existing nameservers — and runs asynchronously.", + ) + .with_system("domain") + .with_tier(Tier::Destructive) + .with_default_fields("domain,nameservers,operationId,status") + .with_output_schema::() + .with_scopes(&[DOMAINS_NAMESERVER_UPDATE]) + .with_arg( + clap::Arg::new("domain") + .value_name("DOMAIN") + .required(true) + .help("Domain whose nameservers to replace (e.g. example.com)"), + ) + .with_arg( + clap::Arg::new("nameserver") + .long("nameserver") + .value_name("HOST") + .required(true) + .action(clap::ArgAction::Append) + .help("Nameserver host (repeatable; replaces the full set)"), + ), + |ctx| async move { + let domain = ctx + .args + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let hosts = string_list(&ctx, "nameserver"); + let debug = !ctx.middleware.debug.is_empty(); + + let client = make_client(&ctx).await?; + let body = types::NameServers( + hosts + .iter() + .map(|h| types::NameserverHostname(h.clone())) + .collect(), + ); + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let op = match client + .update_nameservers() + .domain_name(domain.as_str()) + .idempotency_key(idempotency_key) + .body(body) + .send() + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("updating nameservers", debug, e).await), + }; + Ok(CommandResult::new(json!({ + "domain": domain, + "nameservers": hosts, + "operationId": op.operation_id.map(|o| o.to_string()), + "status": op.status.map(|s| s.to_string()), + }))) + }, + )) +} diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs new file mode 100644 index 0000000..bf22639 --- /dev/null +++ b/rust/src/domain/purchase.rs @@ -0,0 +1,402 @@ +//! `gddy domain purchase` — register a domain by accepting a cached quote (v3). + +use cli_engine::{ + CliCoreError, CommandResult, CommandSpec, Credential, NextAction, NextActionParam, Result, + RuntimeCommandSpec, Tier, +}; +use serde_json::json; + +use domains_client::types; + +use super::common::{api_error, make_client_with_cred}; +use crate::auth::SSO_KEY_PROVIDER; +use crate::output_schema::output_schema; +use crate::quote_cache; +use crate::scopes::{DOMAINS_CREATE, DOMAINS_READ}; + +output_schema!(DomainPurchaseResult { + "domain": "string"; + "status": "string"; + "operationId": "string"; + "price": "string"; + "currency": "string"; +}); + +/// Format a UTC instant as the API's consent timestamp: RFC 3339 with a literal +/// trailing `Z` (e.g. `2026-06-30T22:34:43Z`), sub-second digits dropped. +fn iso_datetime(now: chrono::DateTime) -> String { + now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +/// The GoDaddy account/customer id used as the consent `principal`, taken from the +/// OAuth token's typed `sub` claim (`customer:`). The v3 register endpoint +/// verifies this principal against the authenticated identity, so a non-customer +/// subject is rejected with a clear error before the paid call. +fn consent_principal(cred: &Credential) -> Result { + cred.sub + .strip_prefix("customer:") + .filter(|uuid| !uuid.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + CliCoreError::message(format!( + "the OAuth token's subject ({:?}) is not a customer identity; `domain purchase` \ + needs a customer-scoped token", + cred.sub + )) + }) +} + +/// Whether an async domain-operation status is terminal (no further polling). +/// The non-terminal states are `CONFIRMED`/`EXECUTING`; `COMPLETED`/`FAILED` end it. +fn is_terminal_status(status: &str) -> bool { + matches!(status, "COMPLETED" | "FAILED") +} + +/// Enforce the purchase gates against a cached quote, returning the agreement +/// types to record as consent. Pure (no I/O) so the gating is unit-testable: +/// `--agree` is the legal-consent gate (its error lists the agreements — by their +/// human titles from the quote — to review); `--confirm` is the charge gate (the +/// registration is paid). The agreement types are only returned once both gates +/// pass. `agreement_titles`/`agreement_types` come from the cached quote (the +/// types are what the register call must echo into `consent.agreementTypes`). +fn purchase_consent_types( + domain: &str, + period: u64, + agree: bool, + confirm: bool, + agreement_titles: &[String], + agreement_types: &[String], +) -> Result> { + if !agree { + let list = agreement_titles + .iter() + .map(|t| format!(" - {t}")) + .collect::>() + .join("\n"); + return Err(CliCoreError::message(format!( + "registering {domain} requires agreeing to its legal agreement(s):\n{list}\n\n\ + Re-run with --agree to accept them. See `gddy guide domain-purchase`." + ))); + } + + if agreement_types.is_empty() { + return Err(CliCoreError::message(format!( + "the quote for {domain} recorded no legal agreement types; cannot record consent" + ))); + } + + if !confirm { + return Err(CliCoreError::message(format!( + "registering {domain} for {period} year(s) charges your account and cannot be undone; \ + re-run with --confirm to proceed" + ))); + } + + Ok(agreement_types + .iter() + .map(|t| types::AgreementType(t.clone())) + .collect()) +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new( + "purchase", + "Register a domain from a quote (paid; charges your account)", + ) + .with_long( + "Register a domain by accepting a quote from `gddy domain quote`. This \ + charges your GoDaddy account and cannot be undone, so it is gated behind \ + --confirm.\n\ + \n\ + Registration settings (period, privacy, nameservers, contacts) are fixed \ + at quote time — the quote token locks both those settings and the price. \ + `purchase` accepts the token, records your consent to the quote's legal \ + agreements (--agree), then registers and waits for the registry to \ + finish. A usable payment method must be on file — add one with \ + `gddy payments add`.\n\ + \n\ + Typical flow:\n \ + 1. gddy domain quote example.com # review price + agreements\n \ + 2. gddy domain purchase --quote-token --agree --confirm\n\ + \n\ + The quote is cached locally, so run both on the same machine within the \ + token's ~10-minute lifetime. See `gddy guide domain-purchase`.", + ) + .with_system("domain") + .with_tier(Tier::Destructive) + .with_default_fields("domain,status,operationId,price,currency") + .with_output_schema::() + .with_scopes(&[DOMAINS_READ, DOMAINS_CREATE]) + .with_arg( + clap::Arg::new("quote-token") + .long("quote-token") + .value_name("TOKEN") + .required(true) + .help("The quote token from `gddy domain quote` (locks the price + settings)"), + ) + .with_arg( + clap::Arg::new("agree") + .long("agree") + .action(clap::ArgAction::SetTrue) + .help( + "Consent to the quote's legal agreements (run without it to list \ + them; review with `gddy guide domain-purchase`)", + ), + ) + .with_arg( + clap::Arg::new("agreed-by") + .long("agreed-by") + .value_name("IP") + .help("Originating IP recorded with your consent (defaults to 127.0.0.1)"), + ) + .with_arg( + clap::Arg::new("confirm") + .long("confirm") + .action(clap::ArgAction::SetTrue) + .help("Confirm the purchase; required because it charges your account"), + ), + |ctx| async move { + let quote_token = ctx + .args + .get("quote-token") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let agree = ctx + .args + .get("agree") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let confirm = ctx + .args + .get("confirm") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let agreed_by = ctx + .args + .get("agreed-by") + .and_then(|v| v.as_str()) + .unwrap_or("127.0.0.1") + .to_owned(); + let debug = !ctx.middleware.debug.is_empty(); + + // The v3 register endpoint needs the customer identity from an OAuth + // token (for the consent principal); sso-key auth can never satisfy + // it. Resolve auth *before* consuming the cached quote so a failed + // login never touches the cache. + let cred = ctx.credential().await?; + if cred.provider == SSO_KEY_PROVIDER { + return Err(CliCoreError::message( + "`domain purchase` uses the v3 registration API, which requires OAuth \ + authentication; this environment is configured for an sso-key. Re-run \ + against an OAuth environment.", + )); + } + let principal = consent_principal(&cred)?; + + // Load the quote the user reviewed. Read-only: the entry is only + // removed once the registration succeeds, so an un-`--agree`d run or + // a failed charge leaves the quote reusable. + let cached = match quote_cache::get("e_token) { + quote_cache::Lookup::Found(q) => *q, + quote_cache::Lookup::Expired => { + return Err(CliCoreError::message( + "that quote has expired (quotes last ~10 minutes). Re-run \ + `gddy domain quote ` for a fresh quote and token.", + )); + } + quote_cache::Lookup::Missing => { + return Err(CliCoreError::message( + "no cached quote for that token. Run `gddy domain quote ` \ + first — quotes are cached locally, so quote and purchase must run \ + on the same machine within the token's ~10-minute lifetime.", + )); + } + }; + let domain = cached.domain.clone(); + + let agreement_types = purchase_consent_types( + &domain, + cached.period, + agree, + confirm, + &cached.agreement_titles, + &cached.agreement_types, + )?; + let period_nz = std::num::NonZeroU64::new(cached.period).ok_or_else(|| { + CliCoreError::message("the cached quote has an invalid registration period") + })?; + + let profile = cached.profile.as_ref().and_then(|v| { + serde_json::from_value::(v.clone()).ok() + }); + + let consent = types::Consent { + agreed_at: types::DateTime(iso_datetime(chrono::Utc::now())), + agreed_by: types::ConsentActor { + actor: None, + ip: Some(agreed_by), + principal, + type_: types::ConsentActorType("DIRECT".to_string()), + }, + agreement_types, + }; + let registration = types::Registration { + consent, + created_at: None, + domain: domain.clone(), + expires_at: None, + links: vec![], + operation_id: None, + period: period_nz, + profile, + profile_id: None, + quote_token: Some(types::Uuid(quote_token.clone())), + registration_id: None, + status: None, + updated_at: None, + }; + + let client = make_client_with_cred(&ctx.middleware.env, &cred)?; + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let accepted = match client + .register_domain() + .idempotency_key(idempotency_key) + .body(registration) + .send() + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain purchase", debug, e).await), + }; + // The token is consumed server-side on a successful execute, so drop + // our cached copy too (single-use). + quote_cache::remove("e_token); + let price = cached.price.clone(); + let currency = cached.currency.clone(); + + // Poll the async operation to a terminal state (best-effort, bounded). + let mut status = accepted + .status + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "SUBMITTED".to_string()); + let operation_id = accepted.operation_id.clone(); + if let Some(op_id) = operation_id.as_ref() { + for _ in 0..20 { + if is_terminal_status(&status) { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + match client + .get_operation() + .operation_id(op_id.clone()) + .send() + .await + { + Ok(r) => { + if let Some(s) = r.into_inner().status { + status = s.to_string(); + } + } + // A transient poll failure shouldn't fail the purchase — + // it already succeeded; report the last-known status. + Err(_) => break, + } + } + } + + let result = json!({ + "domain": domain, + "status": status, + "operationId": operation_id.map(|o| o.to_string()), + "price": price, + "currency": currency, + }); + Ok(CommandResult::new(result).with_next_actions(vec![ + NextAction::new("domain get ", "See the registered domain's details") + .with_param("domain", NextActionParam::required()), + ])) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::{consent_principal, is_terminal_status, iso_datetime, purchase_consent_types}; + use cli_engine::Credential; + + #[test] + fn purchase_consent_requires_agree_then_confirm() { + // Titles + types as they'd come from a cached quote. + let titles = vec!["Registration Agreement (https://x)".to_string()]; + let ty = vec!["DNRA".to_string()]; + + let err = purchase_consent_types("example.com", 1, false, true, &titles, &ty) + .expect_err("must require --agree"); + let msg = err.to_string(); + assert!(msg.contains("Registration Agreement"), "{msg}"); + assert!(msg.contains("--agree"), "{msg}"); + + let err = purchase_consent_types("example.com", 2, true, false, &titles, &ty) + .expect_err("must require --confirm"); + let msg = err.to_string(); + assert!(msg.contains("--confirm"), "{msg}"); + assert!(msg.contains("2 year(s)"), "{msg}"); + + let types = purchase_consent_types("example.com", 1, true, true, &titles, &ty) + .expect("both gates satisfied"); + assert_eq!(types.len(), 1); + assert_eq!(types[0].as_str(), "DNRA"); + } + + #[test] + fn purchase_consent_rejects_when_no_agreement_types() { + // --agree given, but the cached quote carried no agreement types. + let err = purchase_consent_types("example.com", 1, true, true, &[], &[]) + .expect_err("no types -> error"); + assert!( + err.to_string().contains("no legal agreement types"), + "{err}" + ); + } + + #[test] + fn terminal_status_detection() { + assert!(is_terminal_status("COMPLETED")); + assert!(is_terminal_status("FAILED")); + assert!(!is_terminal_status("CONFIRMED")); + assert!(!is_terminal_status("EXECUTING")); + assert!(!is_terminal_status("SUBMITTED")); + } + + #[test] + fn consent_principal_strips_customer_urn_prefix() { + let cred = Credential { + sub: "customer:56fd82e4-1c45-4596-865d-317235015b2f".to_string(), + ..Default::default() + }; + assert_eq!( + consent_principal(&cred).expect("customer subject"), + "56fd82e4-1c45-4596-865d-317235015b2f" + ); + let shopper = Credential { + sub: "shopper:12345".to_string(), + ..Default::default() + }; + assert!(consent_principal(&shopper).is_err()); + } + + #[test] + fn agreed_at_is_zulu_iso_datetime_not_offset() { + use chrono::TimeZone; + let dt = chrono::Utc + .with_ymd_and_hms(2026, 6, 30, 22, 34, 43) + .single() + .expect("valid instant"); + let s = iso_datetime(dt); + assert_eq!(s, "2026-06-30T22:34:43Z"); + assert!(!s.contains('+'), "must not use a numeric offset: {s}"); + } +} diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs new file mode 100644 index 0000000..ee9043e --- /dev/null +++ b/rust/src/domain/quote.rs @@ -0,0 +1,311 @@ +//! `gddy domain quote` — price a registration, lock a quote, cache it (v3). + +use cli_engine::{ + CliCoreError, CommandResult, CommandSpec, NextAction, NextActionParam, Result, + RuntimeCommandSpec, Tier, +}; +use serde_json::json; + +use domains_client::types; + +use super::common::{api_error, format_money, make_client, string_list}; +use crate::output_schema::output_schema; +use crate::scopes::DOMAINS_READ; +use crate::{contacts, quote_cache}; + +output_schema!(DomainQuoteResult { + "domain": "string"; + "available": "bool"; + "price": "string"; + "currency": "string"; + "period": "number"; + "quoteToken": "string"; +}); + +/// Build the inline registration profile (contacts + preferences) for a quote, or +/// `None` when there is nothing to override (no configured contacts and the +/// account defaults for privacy/auto-renew/nameservers suffice). When any role is +/// configured in `contacts.toml` the registrant must be present, since v3's +/// `Contacts` requires it. +fn build_profile( + privacy: bool, + renew_auto: bool, + name_servers: &[String], +) -> Result> { + let file = contacts::load().map_err(|e| CliCoreError::message(e.to_string()))?; + let to_api = |role| file.to_api(role).map_err(CliCoreError::message); + let registrant = to_api(contacts::Role::Registrant)?; + let admin = to_api(contacts::Role::Admin)?; + let billing = to_api(contacts::Role::Billing)?; + let tech = to_api(contacts::Role::Tech)?; + + let any_non_registrant = admin.is_some() || billing.is_some() || tech.is_some(); + let contacts_obj = match registrant { + Some(registrant) => Some(types::Contacts { + registrant, + admin, + billing, + tech, + }), + None if any_non_registrant => { + return Err(CliCoreError::message( + "contacts.toml defines a non-registrant contact but no [registrant]; v3 requires a \ + registrant contact when any contact is supplied. Add a [registrant] section or \ + remove the others to use your account defaults.", + )); + } + None => None, + }; + + let name_servers = (!name_servers.is_empty()).then(|| { + types::NameServers( + name_servers + .iter() + .map(|h| types::NameserverHostname(h.clone())) + .collect(), + ) + }); + + Ok(Some(types::InlineRegistrationProfile { + auto_renew: Some(renew_auto), + contacts: contacts_obj, + name_servers, + privacy: Some(privacy), + })) +} + +/// Render a required agreement as a human line ("Title (url)" / "Title"), for the +/// quote's default view and the purchase `--agree` review prompt. +fn agreement_line(a: &types::Agreement) -> String { + let title = a.title.as_deref().unwrap_or("(untitled agreement)"); + match a.url.as_deref() { + Some(url) => format!("{title} ({url})"), + None => title.to_owned(), + } +} + +/// Build the JSON view of a v3 quote. The default (human) view surfaces the +/// locked price, the token + its expiry, a `resolved` settings summary, and an +/// `agreements` scalar joining the required-agreement titles — so the terms are +/// visible without `--output json`. The full structured `requiredAgreements` +/// array is kept for scripting. +fn quote_to_json(quote: &types::RegistrationQuote) -> serde_json::Value { + let mut out = json!({ + "domain": quote.domain, + "available": quote.available, + }); + if let Some(price) = quote.price.as_ref() + && let Some(p) = format_money(price) + { + out["price"] = json!(p); + out["currency"] = json!(price.currency_code.as_ref().map(|c| c.to_string())); + } + if let Some(renewal) = quote.renewal_price.as_ref().and_then(format_money) { + out["renewalPrice"] = json!(renewal); + } + if let Some(period) = quote.period { + out["period"] = json!(period.get()); + } + if let Some(token) = quote.quote_token.as_ref() { + out["quoteToken"] = json!(token.to_string()); + } + if let Some(expires) = quote.expires_at.as_ref() { + out["expiresAt"] = json!(expires.to_string()); + } + if let Some(irreversible) = quote.irreversible { + out["irreversible"] = json!(irreversible); + } + // The effective settings the registration would apply (contacts source, + // privacy, auto-renew, nameservers) — so the user reviews what they're buying. + if let Some(resolved) = quote.resolved.as_ref() + && let Ok(value) = serde_json::to_value(resolved) + { + out["resolved"] = value; + } + if let Some(agreements) = quote.required_agreements.as_ref() { + // Scalar summary for the default table view (nested arrays don't project). + out["agreements"] = json!( + agreements + .iter() + .map(agreement_line) + .collect::>() + .join("; ") + ); + // Full structure for `--output json`. + out["requiredAgreements"] = json!( + agreements + .iter() + .map(|a| json!({ + "agreementType": a.agreement_type.as_ref().map(|t| t.to_string()), + "title": a.title, + "url": a.url, + })) + .collect::>() + ); + } + out +} + +/// Split a quote's required agreements into (types, human-title lines) for the +/// quote cache — the types are echoed into `consent.agreementTypes` at purchase, +/// the titles drive the `--agree` review prompt. +fn agreement_types_and_titles(agreements: &[types::Agreement]) -> (Vec, Vec) { + let types = agreements + .iter() + .filter_map(|a| a.agreement_type.as_ref().map(|t| t.to_string())) + .collect(); + let titles = agreements.iter().map(agreement_line).collect(); + (types, titles) +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("quote", "Price a domain registration and lock a quote") + .with_long( + "Quote a single-domain registration: returns the locked price, the \ + legal agreements you must accept, the contact/preference settings that \ + would apply, and a single-use quote token (valid ~10 minutes). Quoting \ + is free and changes nothing.\n\ + \n\ + The quote — including the registration settings you pass here \ + (--period/--privacy/--no-renew/--nameserver and your contacts.toml) — is \ + saved locally so `gddy domain purchase --quote-token ` buys the \ + exact thing you reviewed, at the price you were quoted.", + ) + .with_system("domain") + .with_tier(Tier::Read) + .with_default_fields( + "domain,available,price,currency,period,quoteToken,expiresAt,agreements", + ) + .with_output_schema::() + .with_scopes(&[DOMAINS_READ]) + .with_arg( + clap::Arg::new("domain") + .value_name("DOMAIN") + .required(true) + .help("Domain to quote, e.g. example.com"), + ) + .with_arg( + clap::Arg::new("period") + .long("period") + .value_name("YEARS") + .value_parser(clap::value_parser!(u64).range(1..=10)) + .default_value("1") + .help("Registration length in years (1-10)"), + ) + .with_arg( + clap::Arg::new("privacy") + .long("privacy") + .action(clap::ArgAction::SetTrue) + .help("Add privacy protection to the registration"), + ) + .with_arg( + clap::Arg::new("no-renew") + .long("no-renew") + .action(clap::ArgAction::SetTrue) + .help("Disable auto-renewal (auto-renew is on by default)"), + ) + .with_arg( + clap::Arg::new("nameserver") + .long("nameserver") + .value_name("HOST") + .action(clap::ArgAction::Append) + .help("Custom nameserver (repeatable); omit to use GoDaddy defaults"), + ), + |ctx| async move { + let domain = ctx + .args + .get("domain") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let period = ctx.args.get("period").and_then(|v| v.as_u64()).unwrap_or(1); + let privacy = ctx + .args + .get("privacy") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let renew_auto = !ctx + .args + .get("no-renew") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let name_servers = string_list(&ctx, "nameserver"); + let debug = !ctx.middleware.debug.is_empty(); + let period_nz = + std::num::NonZeroU64::new(period).expect("clap value_parser enforces period >= 1"); + + // Resolve auth first (fail closed before reading local config), then + // build the profile. The token is bound to a hash of this quoted + // request, so what's quoted here is what `purchase` later registers. + let client = make_client(&ctx).await?; + let profile = build_profile(privacy, renew_auto, &name_servers)?; + let profile_json = profile.as_ref().and_then(|p| serde_json::to_value(p).ok()); + + let quote = match client + .quote_domain_registration() + .body(types::QuoteDomainRegistrationBody { + domain: domain.clone(), + period: period_nz, + profile, + profile_id: None, + }) + .send() + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("quoting registration", debug, e).await), + }; + + let view = quote_to_json("e); + + // Persist the quote so `purchase --quote-token` can reproduce this + // exact request (only possible when it's available and got a token). + let mut next_actions = Vec::new(); + if quote.available.unwrap_or(false) + && let Some(token) = quote.quote_token.as_ref() + { + let token = token.to_string(); + let agreements = quote.required_agreements.clone().unwrap_or_default(); + let (agreement_types, agreement_titles) = agreement_types_and_titles(&agreements); + let cached = quote_cache::CachedQuote { + domain: quote.domain.clone().unwrap_or_else(|| domain.clone()), + period: quote.period.map_or(period, |p| p.get()), + agreement_types, + agreement_titles, + price: view + .get("price") + .and_then(|v| v.as_str()) + .map(str::to_owned), + currency: view + .get("currency") + .and_then(|v| v.as_str()) + .map(str::to_owned), + expires_at: quote.expires_at.as_ref().map(|e| e.to_string()), + profile: profile_json, + }; + if let Err(e) = quote_cache::save(&token, cached) { + // Non-fatal: the quote is still shown, but purchase won't + // find it. Warn so the user knows to re-quote on this host. + tracing::warn!(error = %e, "could not cache the quote for purchase"); + } + next_actions.push( + NextAction::new( + "domain purchase --quote-token --agree --confirm", + "Register at the quoted price (within ~10 minutes)", + ) + .with_param("quote-token", NextActionParam::required()), + ); + } else { + // Not available (or no token was issued): point at discovery, the + // same next step `domain available` offers for a taken name. + next_actions.push( + NextAction::new("domain suggest ", "Find an available alternative") + .with_param("query", NextActionParam::required()), + ); + } + + Ok(CommandResult::new(view).with_next_actions(next_actions)) + }, + ) +} diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs new file mode 100644 index 0000000..cc70763 --- /dev/null +++ b/rust/src/domain/suggest.rs @@ -0,0 +1,131 @@ +//! `gddy domain suggest` — suggest available domains for a query (v3). + +use cli_engine::{ + CommandResult, CommandSpec, NextAction, NextActionParam, RuntimeCommandSpec, Tier, +}; +use serde_json::json; + +use domains_client::types; + +use super::common::{api_error, format_money, make_client, string_list}; +use crate::scopes::DOMAINS_READ; + +/// Convert a count-style flag value to the `NonZeroU64` the suggest query params +/// expect, or `None` when it's absent/zero/negative (so the param is simply +/// omitted). Total — never panics — because the value can arrive as `0` (e.g. the +/// engine's global `--limit` default is `0`), which must mean "unset", not abort. +fn nonzero(n: i64) -> Option { + u64::try_from(n).ok().and_then(std::num::NonZeroU64::new) +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("suggest", "Suggest available domains for a query") + .with_long( + "Suggest available domains from a seed word, phrase, or domain. \ + Narrow results with --tlds (repeatable) and bound the name length \ + with --length-min/--length-max. The global --limit caps how many \ + suggestions are requested and shown.", + ) + .with_system("domain") + .with_tier(Tier::Read) + .with_default_fields("domain") + .with_json_schema::() + .with_scopes(&[DOMAINS_READ]) + .with_arg( + clap::Arg::new("query") + .value_name("QUERY") + .required(true) + .help("Seed domain or keywords to base suggestions on"), + ) + .with_arg( + clap::Arg::new("tlds") + .long("tlds") + .value_name("TLD") + .action(clap::ArgAction::Append) + .help("Limit suggestions to these TLDs (repeatable)"), + ) + // Note: no per-command `--limit` — cli-engine registers a global + // `--limit` (client-side result cap). We reuse its value as the v3 + // `pageSize` below, so a single `--limit` drives both. + .with_arg( + clap::Arg::new("length-min") + .long("length-min") + .value_name("N") + .value_parser(clap::value_parser!(i64).range(1..)) + .help("Only suggest names at least this many characters"), + ) + .with_arg( + clap::Arg::new("length-max") + .long("length-max") + .value_name("N") + .value_parser(clap::value_parser!(i64).range(1..)) + .help("Only suggest names at most this many characters"), + ), + |ctx| async move { + let query = ctx + .args + .get("query") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + let tlds = string_list(&ctx, "tlds"); + // The engine's global `--limit` (0 when unset) doubles as the v3 + // server-side pageSize; `nonzero` maps 0/absent to "no pageSize". + let limit = nonzero(ctx.middleware.limit); + let length_min = ctx.args.get("length-min").and_then(|v| v.as_i64()); + let length_max = ctx.args.get("length-max").and_then(|v| v.as_i64()); + + let debug = !ctx.middleware.debug.is_empty(); + let client = make_client(&ctx).await?; + let mut req = client.suggest_domains().query(query.as_str()); + if let Some(page_size) = limit { + req = req.page_size(page_size); + } + if !tlds.is_empty() { + req = req.tlds(tlds); + } + if let Some(n) = length_min.and_then(nonzero) { + req = req.length_min(n); + } + if let Some(n) = length_max.and_then(nonzero) { + req = req.length_max(n); + } + let resp = match req.send().await { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain suggestion", debug, e).await), + }; + let suggestions: Vec = resp + .items + .into_iter() + .map(|s| { + let mut obj = json!({ "domain": s.domain }); + if let Some(p) = s.list_price.as_ref().and_then(format_money) { + obj["listPrice"] = json!(p); + } + obj + }) + .collect(); + Ok( + CommandResult::new(json!(suggestions)).with_next_actions(vec![ + NextAction::new("domain available ", "Check a suggested domain") + .with_param("domain", NextActionParam::required()), + ]), + ) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::nonzero; + + #[test] + fn nonzero_maps_zero_and_negative_to_none() { + // The engine's global `--limit` arrives as 0 when unset — must be "no + // pageSize", never a panic (regression for the suggest crash). + assert_eq!(nonzero(0), None); + assert_eq!(nonzero(-5), None); + assert_eq!(nonzero(5).map(|n| n.get()), Some(5)); + } +} diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index c931545..4bdb38f 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -33,8 +33,12 @@ use serde::Deserialize; pub const DEFAULT_ENV: &str = "prod"; /// Scopes requested at login by default. The authorization server may grant a -/// subset; commands needing more declare them and the provider steps up. -pub const DEFAULT_OAUTH_SCOPES: &[&str] = &["apps.app-registry:read", "apps.app-registry:write"]; +/// subset; commands needing more declare them and the provider steps up. Drawn +/// from the central [`crate::scopes`] registry (which the OAuth client mirrors). +pub const DEFAULT_OAUTH_SCOPES: &[&str] = &[ + crate::scopes::APP_REGISTRY_READ, + crate::scopes::DOMAINS_READ, +]; pub const REDIRECT_URI: &str = "http://localhost:7443/callback"; pub const APP_ID: &str = "gddy"; diff --git a/rust/src/main.rs b/rust/src/main.rs index e946341..2be7d49 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -11,6 +11,8 @@ mod environments; mod extension; mod output_schema; mod payments; +mod quote_cache; +mod scopes; mod webhook; use std::{process::ExitCode, sync::Arc}; diff --git a/rust/src/quote_cache.rs b/rust/src/quote_cache.rs new file mode 100644 index 0000000..7eea657 --- /dev/null +++ b/rust/src/quote_cache.rs @@ -0,0 +1,284 @@ +//! Local cache of registration quotes, bridging `domain quote` → `domain purchase`. +//! +//! v3's `POST /registrations` needs more than the opaque `quoteToken`: it must +//! re-state the quote's `domain`, `period`, and `consent.agreementTypes` (which the +//! server cross-checks against the quote the token was minted for). The token is +//! opaque ("do not parse") and there is **no GET-quote endpoint**, so those values +//! can't be recovered from the token or re-fetched from the server. `domain quote` +//! therefore stashes the quote here and `domain purchase --quote-token` reads it +//! back — reuniting the token with the data the register body must echo, and +//! letting `purchase` charge the exact price that was reviewed. +//! +//! Entries are single-use (removed on a successful `take`) and expire with the +//! token (~10 min), so the file only ever holds a few short-lived records. It +//! lives beside `contacts.toml`/`environments.toml` +//! (`dirs::config_dir()/gddy/quotes.json`); a quote token is a short-lived, +//! single-use capability, not a long-lived secret. +//! +//! (The domains team is being asked to add a `GET /registration-quotes/{token}` +//! route; if that lands, `purchase` could resolve the quote server-side and this +//! local cache could be retired.) + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// One cached quote: everything `domain purchase` needs to build the register +/// request that the `quoteToken` was minted for. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CachedQuote { + pub domain: String, + pub period: u64, + /// The `agreementType` values from the quote's `requiredAgreements` — echoed + /// into `consent.agreementTypes`, which the server verifies against the quote. + pub agreement_types: Vec, + /// Human-readable agreement titles (+ optional URLs), for the `--agree` review + /// prompt at purchase time. Parallel to `agreement_types`. + #[serde(default)] + pub agreement_titles: Vec, + /// The locked price, pre-formatted for the receipt (e.g. "11.99"). + #[serde(default)] + pub price: Option, + #[serde(default)] + pub currency: Option, + /// RFC 3339 expiry of the quote token (from the quote's `expiresAt`); `None` + /// if the API returned no expiry (then the entry never expires locally). + #[serde(default)] + pub expires_at: Option, + /// The exact `InlineRegistrationProfile` the quote was taken with, serialized. + /// `register` must re-send it verbatim or the server rejects the mismatch + /// (the token binds a hash of the domain/price/profile). `None` when the quote + /// carried no profile. + #[serde(default)] + pub profile: Option, +} + +/// The result of looking a token up in the cache. +pub enum Lookup { + /// The quote was found and removed (single-use). + Found(Box), + /// The token was present but its quote had expired. + Expired, + /// No quote for this token on this machine. + Missing, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct QuoteFile { + #[serde(default)] + quotes: BTreeMap, +} + +/// Path to the local quotes cache, if a config dir can be resolved. Mirrors +/// [`crate::contacts::contacts_path`] (same `gddy/` config directory). +pub fn quotes_path() -> Option { + dirs::config_dir().map(|d| d.join("gddy").join("quotes.json")) +} + +/// Whether a cached quote has expired at `now`. An unparseable or absent +/// `expires_at` is treated as not-expired (the API always returns one in +/// practice; being lenient never wrongly discards a usable quote). +fn is_expired(quote: &CachedQuote, now: chrono::DateTime) -> bool { + match quote.expires_at.as_deref() { + Some(ts) => chrono::DateTime::parse_from_rfc3339(ts) + .map(|exp| exp.with_timezone(&chrono::Utc) <= now) + .unwrap_or(false), + None => false, + } +} + +/// Load the cache file. A missing or unparseable file yields an empty cache — it +/// is only a best-effort bridge, so a corrupt file must never brick `quote`/`purchase`. +fn load(path: &Path) -> QuoteFile { + match std::fs::read_to_string(path) { + Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), + Err(_) => QuoteFile::default(), + } +} + +fn write(path: &Path, file: &QuoteFile) -> cli_engine::Result<()> { + let json = serde_json::to_string_pretty(file).map_err(|e| { + cli_engine::CliCoreError::message(format!("failed to serialize quotes: {e}")) + })?; + cli_engine::fs::write_string_atomic(path, &json) +} + +/// Persist a quote under its token, first pruning any expired entries so the file +/// stays small. Best-effort: callers surface a warning on failure (the quote is +/// still usable interactively; only `purchase --quote-token` depends on it). +pub fn save(token: &str, quote: CachedQuote) -> cli_engine::Result<()> { + let path = quotes_path().ok_or_else(|| { + cli_engine::CliCoreError::message("no config directory for the quote cache") + })?; + save_at(&path, chrono::Utc::now(), token, quote) +} + +fn save_at( + path: &Path, + now: chrono::DateTime, + token: &str, + quote: CachedQuote, +) -> cli_engine::Result<()> { + let mut file = load(path); + file.quotes.retain(|_, q| !is_expired(q, now)); + file.quotes.insert(token.to_owned(), quote); + write(path, &file) +} + +/// Look up the quote for `token` **without** consuming it. Distinguishes an +/// expired quote from one that was never cached here so `purchase` can give a +/// precise error. Read-only on purpose: `purchase` reads the quote to show the +/// agreements (the `--agree` gate) and only [`remove`]s it once the registration +/// actually succeeds, so an aborted/gated attempt leaves the quote reusable. +pub fn get(token: &str) -> Lookup { + let Some(path) = quotes_path() else { + return Lookup::Missing; + }; + get_at(&path, chrono::Utc::now(), token) +} + +fn get_at(path: &Path, now: chrono::DateTime, token: &str) -> Lookup { + let file = load(path); + match file.quotes.get(token) { + None => Lookup::Missing, + Some(q) if is_expired(q, now) => Lookup::Expired, + Some(q) => Lookup::Found(Box::new(q.clone())), + } +} + +/// Consume a quote once its registration has succeeded (single-use). Best-effort: +/// a write failure only risks a stale entry (which expires shortly anyway), so it +/// warns rather than failing the completed purchase. A no-op if the token is absent. +pub fn remove(token: &str) { + let Some(path) = quotes_path() else { + return; + }; + let mut file = load(&path); + if file.quotes.remove(token).is_some() + && let Err(e) = write(&path, &file) + { + tracing::warn!(error = %e, "could not update the quote cache after a purchase"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn at(y: i32, mo: u32, d: u32, h: u32) -> chrono::DateTime { + chrono::Utc + .with_ymd_and_hms(y, mo, d, h, 0, 0) + .single() + .expect("valid instant") + } + + fn quote(domain: &str, expires: Option<&str>) -> CachedQuote { + CachedQuote { + domain: domain.to_owned(), + period: 1, + agreement_types: vec!["DNRA".to_owned()], + agreement_titles: vec!["Registration Agreement".to_owned()], + price: Some("11.99".to_owned()), + currency: Some("USD".to_owned()), + expires_at: expires.map(str::to_owned), + profile: None, + } + } + + fn remove_at(path: &Path, token: &str) { + let mut file = load(path); + if file.quotes.remove(token).is_some() { + write(path, &file).expect("write"); + } + } + + #[test] + fn get_is_read_only_then_remove_consumes() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("quotes.json"); + let now = at(2026, 7, 1, 12); + + save_at( + &path, + now, + "tok-1", + quote("example.com", Some("2026-07-01T12:09:00Z")), + ) + .expect("save"); + + // get returns the quote and, crucially, does NOT consume it (the --agree + // gate reads it before the user confirms), so two reads both succeed. + let mut found = 0; + for _ in 0..2 { + if let Lookup::Found(q) = get_at(&path, now, "tok-1") { + assert_eq!(q.domain, "example.com"); + assert_eq!(q.agreement_types, vec!["DNRA".to_owned()]); + found += 1; + } + } + assert_eq!( + found, 2, + "get must return Found without consuming the quote" + ); + + // remove consumes it (single-use, on successful purchase). + remove_at(&path, "tok-1"); + assert!(matches!(get_at(&path, now, "tok-1"), Lookup::Missing)); + } + + #[test] + fn expired_token_reports_expired_not_found() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("quotes.json"); + // Saved at noon, expires 12:09; read at 13:00 → expired. + save_at( + &path, + at(2026, 7, 1, 12), + "tok-1", + quote("example.com", Some("2026-07-01T12:09:00Z")), + ) + .expect("save"); + assert!(matches!( + get_at(&path, at(2026, 7, 1, 13), "tok-1"), + Lookup::Expired + )); + } + + #[test] + fn unknown_token_is_missing() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("quotes.json"); + assert!(matches!( + get_at(&path, at(2026, 7, 1, 12), "nope"), + Lookup::Missing + )); + } + + #[test] + fn save_prunes_expired_entries() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("quotes.json"); + let early = at(2026, 7, 1, 12); + save_at( + &path, + early, + "old", + quote("old.com", Some("2026-07-01T12:05:00Z")), + ) + .expect("save"); + // A later save (past the first's expiry) should drop the stale entry. + let later = at(2026, 7, 1, 13); + save_at( + &path, + later, + "new", + quote("new.com", Some("2026-07-01T13:09:00Z")), + ) + .expect("save"); + + assert!(matches!(get_at(&path, later, "old"), Lookup::Missing)); + assert!(matches!(get_at(&path, later, "new"), Lookup::Found(_))); + } +} diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs new file mode 100644 index 0000000..594da4c --- /dev/null +++ b/rust/src/scopes.rs @@ -0,0 +1,105 @@ +//! Central registry of every OAuth scope the `gddy` CLI requests. +//! +//! # Why this module exists +//! +//! A command declares the scopes it needs with [`CommandSpec::with_scopes`], and +//! cli-engine's OAuth step-up mints a token carrying them. But a scope the CLI +//! *requests* is only grantable if the CLI's **OAuth client** is *registered* for +//! it — otherwise the authorization server refuses to mint a token carrying the +//! scope and the command fails at auth, with nothing in this codebase to catch +//! it. It is easy to add `.with_scopes(&["some.new:scope"])` to a command by +//! copying a neighbour and never realize the scope is unobtainable. +//! +//! So: **every scope the CLI uses is declared here, once**, and commands draw +//! from these constants rather than spelling out string literals. The [`ALL`] +//! slice is derived from the same declarations, so it is always the complete, +//! authoritative list of scopes the OAuth client must be registered for — a +//! single place to diff against the client's configuration. +//! +//! # Adding a scope (READ THIS) +//! +//! 1. Add a constant to the [`declare_scopes!`] block below. It is automatically +//! included in [`ALL`] — you cannot add a scope constant without registering +//! it in the list. +//! 2. Reference the new constant from the command via `.with_scopes(&[scopes::…])`. +//! 3. **Register the same scope on the CLI's OAuth client**, or it will be +//! ungrantable at runtime. +//! +//! This registry covers the scopes the CLI requests for *itself* (login defaults +//! plus per-command step-up). It intentionally does NOT cover scopes that are +//! user data rather than the CLI's own grants — e.g. the `authorizationScopes` +//! assigned to a third-party app created via `gddy application create`, or the +//! ad-hoc scopes `gddy api call --scope …` derives from the API catalog at +//! runtime. + +/// Declares each scope constant and derives [`ALL`] from the same list, so a +/// scope cannot exist without being part of the authoritative registry. +macro_rules! declare_scopes { + ($( $(#[$doc:meta])* $name:ident => $value:literal ),+ $(,)?) => { + $( $(#[$doc])* pub const $name: &str = $value; )+ + + /// Every OAuth scope the CLI may request, and therefore the exact set its + /// OAuth client must be registered for. Auto-derived from the constants + /// declared in [`declare_scopes!`] — keep the client's registration in sync + /// with this list. + /// + /// Not referenced by production code (the individual constants are what + /// commands use); it exists as the authoritative registry to diff against + /// the OAuth client's configuration and is exercised by the module tests. + #[allow(dead_code)] + pub const ALL: &[&str] = &[ $($name),+ ]; + }; +} + +// DON'T FORGET! If you add a scope here, you must also register it on the CLI's OAuth client. +// +// DEV: https://authz.int.gdcorp.tools/projects/7783/applications/7737/oauth-clients/94488449-5769-4ecf-8bf4-9f8aa83859a3/environment/dev +// TEST: https://authz.int.gdcorp.tools/projects/7783/applications/7737/oauth-clients/e710d8b9-f4e5-4178-b1bf-98dfcd15d4ed/environment/test +// OTE: https://authz.int.gdcorp.tools/projects/7783/applications/7737/oauth-clients/91660d79-c909-426c-b5c8-e0f575e8fcd2/environment/ote +// PROD: https://authz.int.gdcorp.tools/projects/7783/applications/7737/oauth-clients/bc87f347-af82-4892-833f-818f54a0e79e/environment/prod +declare_scopes! { + /// Read the caller's registered applications. Requested at login by default + /// (see [`crate::environments::DEFAULT_OAUTH_SCOPES`]). + APP_REGISTRY_READ => "apps.app-registry:read", + /// Create/update the caller's registered applications. Requested at login by + /// default. + APP_REGISTRY_WRITE => "apps.app-registry:write", + + /// Read domains, availability, suggestions, quotes, and DNS records. + /// (`domain list/get/available/suggest/agreements/quote`, `dns list`.) + DOMAINS_READ => "domains.domain:read", + /// Create/replace/delete DNS records (`dns add/set/delete`). + DOMAINS_DNS_UPDATE => "domains.dns:update", + /// Register a domain — the v3 registration-execute step (`domain purchase`). + DOMAINS_CREATE => "domains.domain:create", + /// Replace a domain's nameservers (`domain nameservers set`). + DOMAINS_NAMESERVER_UPDATE => "domains.nameserver:update", +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every registered scope is well-formed and listed at most once — a + /// duplicate usually means a copy-paste slip that would misrepresent the set + /// the OAuth client must be registered for. + #[test] + fn all_scopes_are_unique_and_wellformed() { + // `ALL` is non-empty by construction (the macro requires one or more + // entries), so this loop always runs at least once. + let mut seen = std::collections::HashSet::new(); + for scope in ALL { + assert!( + seen.insert(*scope), + "duplicate scope in scopes::ALL: {scope:?}" + ); + let split = scope.split_once(':'); + assert!(split.is_some(), "scope {scope:?} must be `resource:action`"); + let (resource, action) = split.expect("checked non-None above"); + assert!( + !resource.is_empty() && !action.is_empty(), + "malformed scope {scope:?} (expected `resource:action`)" + ); + } + } +} From e92f4746ddc5457cb0727fc61a6f64812a37aa3b Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 16:26:15 -0700 Subject: [PATCH 02/16] fix: declare app-registry:write scope on app mutations; tidy build_profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on #76: - application commands: the 7 app-registry-mutating commands (init, update, enable, disable, archive, release, deploy) now declare [apps.app-registry:read, apps.app-registry:write] via with_scopes, so the write scope is requested on demand (OAuth step-up) rather than granted at every login. DEFAULT_OAUTH_SCOPES stays read-only — app-registry writes are a rarely-used operation for most customers. - domain quote: build_profile now returns InlineRegistrationProfile directly (it always produced Some(..)); dropped the misleading Option and corrected the doc comment. Co-Authored-By: Claude Opus 4.8 --- rust/src/application/commands/mod.rs | 11 +++++++++++ rust/src/domain/quote.rs | 23 +++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/rust/src/application/commands/mod.rs b/rust/src/application/commands/mod.rs index ec7b7e8..ae9c511 100644 --- a/rust/src/application/commands/mod.rs +++ b/rust/src/application/commands/mod.rs @@ -6,6 +6,10 @@ use serde_json::json; use crate::application::client::{ApplicationClient, api_url_for_env}; use crate::output_schema::output_schema; +// App-registry mutations declare their scopes so `apps.app-registry:write` is +// requested on demand (OAuth step-up), not granted at every login — it's a +// rarely-used operation for most customers. +use crate::scopes::{APP_REGISTRY_READ, APP_REGISTRY_WRITE}; output_schema!(ApplicationSummary { "id": "string"; @@ -224,6 +228,7 @@ fn init_command() -> RuntimeCommandSpec { ) .with_system("applications") .with_tier(Tier::Mutate) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) .with_output_schema::() .with_arg( clap::Arg::new("name") @@ -388,6 +393,7 @@ fn update_command() -> RuntimeCommandSpec { ) .with_system("applications") .with_tier(Tier::Mutate) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) .with_output_schema::() .with_arg( clap::Arg::new("id") @@ -468,6 +474,7 @@ fn enable_command() -> RuntimeCommandSpec { ) .with_system("applications") .with_tier(Tier::Mutate) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) .with_output_schema::() .with_arg( clap::Arg::new("name") @@ -540,6 +547,7 @@ fn disable_command() -> RuntimeCommandSpec { ) .with_system("applications") .with_tier(Tier::Mutate) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) .with_output_schema::() .with_arg( clap::Arg::new("name") @@ -614,6 +622,7 @@ fn archive_command() -> RuntimeCommandSpec { ) .with_system("applications") .with_tier(Tier::Destructive) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) .with_output_schema::() .with_arg( clap::Arg::new("name") @@ -655,6 +664,7 @@ fn release_command() -> RuntimeCommandSpec { ) .with_system("applications") .with_tier(Tier::Mutate) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) .with_output_schema::() .with_arg( clap::Arg::new("application-id") @@ -712,6 +722,7 @@ fn deploy_command() -> RuntimeCommandSpec { ) .with_system("applications") .with_tier(Tier::Mutate) + .with_scopes(&[APP_REGISTRY_READ, APP_REGISTRY_WRITE]) .with_arg( clap::Arg::new("name") .long("name") diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index ee9043e..27ddacf 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -22,16 +22,16 @@ output_schema!(DomainQuoteResult { "quoteToken": "string"; }); -/// Build the inline registration profile (contacts + preferences) for a quote, or -/// `None` when there is nothing to override (no configured contacts and the -/// account defaults for privacy/auto-renew/nameservers suffice). When any role is -/// configured in `contacts.toml` the registrant must be present, since v3's -/// `Contacts` requires it. +/// Build the inline registration profile (contacts + preferences) sent with a +/// quote. Always returns a profile: `auto_renew` and `privacy` are always set +/// (from the flags/defaults), while `contacts` and `name_servers` are populated +/// only when configured. When any role is set in `contacts.toml` the registrant +/// must be present, since v3's `Contacts` requires it — that's the one error case. fn build_profile( privacy: bool, renew_auto: bool, name_servers: &[String], -) -> Result> { +) -> Result { let file = contacts::load().map_err(|e| CliCoreError::message(e.to_string()))?; let to_api = |role| file.to_api(role).map_err(CliCoreError::message); let registrant = to_api(contacts::Role::Registrant)?; @@ -66,12 +66,12 @@ fn build_profile( ) }); - Ok(Some(types::InlineRegistrationProfile { + Ok(types::InlineRegistrationProfile { auto_renew: Some(renew_auto), contacts: contacts_obj, name_servers, privacy: Some(privacy), - })) + }) } /// Render a required agreement as a human line ("Title (url)" / "Title"), for the @@ -240,14 +240,17 @@ pub(super) fn command() -> RuntimeCommandSpec { // request, so what's quoted here is what `purchase` later registers. let client = make_client(&ctx).await?; let profile = build_profile(privacy, renew_auto, &name_servers)?; - let profile_json = profile.as_ref().and_then(|p| serde_json::to_value(p).ok()); + // Cache the exact profile we quote with: the token binds a hash of the + // domain/price/profile, so `purchase` must re-send this verbatim or the + // register call fails with QUOTE_MISMATCH. + let profile_json = serde_json::to_value(&profile).ok(); let quote = match client .quote_domain_registration() .body(types::QuoteDomainRegistrationBody { domain: domain.clone(), period: period_nz, - profile, + profile: Some(profile), profile_id: None, }) .send() From f0740f1937ef116dd9e2449ef054fcf270ea646c Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 16:32:21 -0700 Subject: [PATCH 03/16] docs: correct quote-cache/dns wording; align v3 money test fixtures Address Copilot re-review on #76 (all doc/test clarity, no behavior change): - quote_cache: module + Lookup::Found docs referenced a 'take' that consumes; the API is read-only get() + remove()-after-success. Reworded to match. - dns v3_records: doc said SRV/MX fields are 'clamped' to u16; they're converted via u16::try_from (out-of-range -> None), and clap already bounds them. - domains-client tests: availability/quote mock 'value's used the v1 micro-unit scale (11_990_000 / 23_980_000); v3 money is ISO-4217 minor units, so use 1199 / 2398 to match format_money and keep the contract clear. Co-Authored-By: Claude Opus 4.8 --- rust/domains-client/src/lib.rs | 11 +++++------ rust/src/dns/mod.rs | 3 ++- rust/src/quote_cache.rs | 7 +++++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/rust/domains-client/src/lib.rs b/rust/domains-client/src/lib.rs index 3e3a915..bcf3cf0 100644 --- a/rust/domains-client/src/lib.rs +++ b/rust/domains-client/src/lib.rs @@ -136,7 +136,8 @@ mod tests { "domain": "example.com", "available": true, "definitive": true, - "prices": [{ "period": 1, "price": { "currencyCode": "USD", "value": 11_990_000 } }] + // v3 money is ISO-4217 minor units: USD 11.99 -> 1199 (not v1 micro-units). + "prices": [{ "period": 1, "price": { "currencyCode": "USD", "value": 1199 } }] })); }) .await; @@ -153,10 +154,7 @@ mod tests { assert_eq!(body.domain.as_deref(), Some("example.com")); assert_eq!(body.available, Some(true)); let prices = body.prices.expect("prices present"); - assert_eq!( - prices[0].price.as_ref().and_then(|m| m.value), - Some(11_990_000) - ); + assert_eq!(prices[0].price.as_ref().and_then(|m| m.value), Some(1199)); } #[tokio::test] @@ -208,7 +206,8 @@ mod tests { "available": true, "quoteToken": "tok-abc", "period": 2, - "price": { "currencyCode": "USD", "value": 23_980_000 }, + // v3 money is ISO-4217 minor units: USD 23.98 (2yr) -> 2398. + "price": { "currencyCode": "USD", "value": 2398 }, "requiredAgreements": [ { "agreementType": "REGISTRATION", "title": "Registration Agreement", "url": "https://x/agr" } diff --git a/rust/src/dns/mod.rs b/rust/src/dns/mod.rs index 414eb1b..b941985 100644 --- a/rust/src/dns/mod.rs +++ b/rust/src/dns/mod.rs @@ -119,7 +119,8 @@ impl RecordOptions { /// Build a v3 `DnsRecord` (for `add`) — one per `--data` value. `ttl` is required /// by v3, so an omitted `--ttl` uses [`DEFAULT_TTL`]. The numeric SRV/MX fields are -/// clamped into the v3 `u16` domain (the clap parsers already bound them). +/// converted into the v3 `u16` domain via `u16::try_from` (any out-of-range value +/// becomes `None`); in practice the clap parsers already bound them to `u16`. fn v3_records( name: &str, ty: &str, diff --git a/rust/src/quote_cache.rs b/rust/src/quote_cache.rs index 7eea657..326f9ab 100644 --- a/rust/src/quote_cache.rs +++ b/rust/src/quote_cache.rs @@ -9,7 +9,9 @@ //! back — reuniting the token with the data the register body must echo, and //! letting `purchase` charge the exact price that was reviewed. //! -//! Entries are single-use (removed on a successful `take`) and expire with the +//! Entries are single-use: [`get`] reads a quote without consuming it (so the +//! `--agree` gate can show its terms before the user confirms), and [`remove`] +//! deletes it only after the registration succeeds. They also expire with the //! token (~10 min), so the file only ever holds a few short-lived records. It //! lives beside `contacts.toml`/`environments.toml` //! (`dirs::config_dir()/gddy/quotes.json`); a quote token is a short-lived, @@ -56,7 +58,8 @@ pub struct CachedQuote { /// The result of looking a token up in the cache. pub enum Lookup { - /// The quote was found and removed (single-use). + /// The quote was found. Not consumed here — [`get`] is read-only; the entry + /// is removed (via [`remove`]) only once the registration succeeds. Found(Box), /// The token was present but its quote had expired. Expired, From bf81135b3cfce619fec9d7920ac1bb3301b95471 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 16:39:21 -0700 Subject: [PATCH 04/16] docs+test: fix APP_REGISTRY_WRITE doc; align consent-actor fixtures to spec Address Copilot re-review round 2 on #76 (docs/tests, no behavior change): - scopes: APP_REGISTRY_WRITE docstring still said 'requested at login by default'; corrected to describe the on-demand step-up (declared per-command by the application mutations), matching the round-1 default-scope change. - domains-client tests: the register fixtures used consent actor type SHOPPER, which isn't a documented ConsentActorType; switched to DIRECT to match what production (domain purchase) sends and the spec's documented values. Not changed: adding CAA to the DNS record-type allowlist. The pre-migration allowlist was A/AAAA/CNAME/MX/NS/SOA/SRV/TXT (no CAA), so this migration preserves parity; proper CAA support also needs --flag/--tag plumbing that's out of scope here. Tracked as a follow-up. Co-Authored-By: Claude Opus 4.8 --- rust/domains-client/src/lib.rs | 6 +++--- rust/src/scopes.rs | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/rust/domains-client/src/lib.rs b/rust/domains-client/src/lib.rs index bcf3cf0..0a9b1b4 100644 --- a/rust/domains-client/src/lib.rs +++ b/rust/domains-client/src/lib.rs @@ -256,7 +256,7 @@ mod tests { "quoteToken": "tok-abc", "consent": { "agreedAt": "2026-06-30T00:00:00Z", - "agreedBy": { "type": "SHOPPER", "principal": "shopper-42", "ip": "127.0.0.1" }, + "agreedBy": { "type": "DIRECT", "principal": "shopper-42", "ip": "127.0.0.1" }, "agreementTypes": ["REGISTRATION"] } })); @@ -268,7 +268,7 @@ mod tests { "period": 1, "consent": { "agreedAt": "2026-06-30T00:00:00Z", - "agreedBy": { "type": "SHOPPER", "principal": "shopper-42" }, + "agreedBy": { "type": "DIRECT", "principal": "shopper-42" }, "agreementTypes": ["REGISTRATION"] }, "registrationId": "reg-1", @@ -288,7 +288,7 @@ mod tests { actor: None, ip: Some("127.0.0.1".to_string()), principal: "shopper-42".to_string(), - type_: types::ConsentActorType("SHOPPER".to_string()), + type_: types::ConsentActorType("DIRECT".to_string()), }, agreement_types: vec![types::AgreementType("REGISTRATION".to_string())], }, diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs index 594da4c..4a3d827 100644 --- a/rust/src/scopes.rs +++ b/rust/src/scopes.rs @@ -61,8 +61,11 @@ declare_scopes! { /// Read the caller's registered applications. Requested at login by default /// (see [`crate::environments::DEFAULT_OAUTH_SCOPES`]). APP_REGISTRY_READ => "apps.app-registry:read", - /// Create/update the caller's registered applications. Requested at login by - /// default. + /// Create/update/archive the caller's registered applications. NOT requested + /// at login by default (it's a rare operation for most customers); the + /// app-registry mutation commands (`application init/update/enable/disable/ + /// archive/release/deploy`) declare it via `with_scopes` so cli-engine + /// requests it on demand (OAuth step-up). APP_REGISTRY_WRITE => "apps.app-registry:write", /// Read domains, availability, suggestions, quotes, and DNS records. From c580fe9e25f7874233fe79c0f7311e760c1a52b5 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 16:47:53 -0700 Subject: [PATCH 05/16] fix: robustness + accurate schemas for domain quote/purchase/available/suggest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot re-review round 3 on #76: - purchase: fail fast when a cached quote's profile can't be deserialized (was silently dropped -> confusing server-side QUOTE_MISMATCH); clear re-quote message instead. - purchase: check the cached quote's agreement types are non-empty *before* the --agree gate, so a corrupt/empty cache yields one accurate 're-quote' error rather than a 'requires agreeing…' prompt with an empty list that a rerun can't satisfy. Regression test added. - domain available / suggest: replace with_json_schema:: (raw API types) with explicit output_schema! matching the transformed JSON the handlers actually emit (formatted price/currency strings, headline term, listPrice), so --schema/help field metadata is accurate. - contacts: validate_country doc clarified — it checks the ISO-3166 alpha-2 *shape* (e.g. ZZ passes), not list membership; the API validates the code. Co-Authored-By: Claude Opus 4.8 --- rust/src/contacts/mod.rs | 9 ++++--- rust/src/domain/available.rs | 16 ++++++++++- rust/src/domain/purchase.rs | 51 +++++++++++++++++++++++++++++------- rust/src/domain/suggest.rs | 13 ++++++--- 4 files changed, 73 insertions(+), 16 deletions(-) diff --git a/rust/src/contacts/mod.rs b/rust/src/contacts/mod.rs index abffebd..c86bb50 100644 --- a/rust/src/contacts/mod.rs +++ b/rust/src/contacts/mod.rs @@ -125,9 +125,12 @@ impl Contact { } } -/// Validate a (already upper-cased) country code is a two-letter ISO code (or the -/// special `C2`), preserving the clear early error the v2 path gave before the -/// strict `AddressCountry` enum was dropped from the generated client. +/// Validate a (already upper-cased) country code has the *shape* of an ISO-3166 +/// alpha-2 code — two ASCII uppercase letters (or the special `C2`). This is a +/// cheap format check, not membership in the ISO-3166 list (e.g. `ZZ` passes); +/// the API validates the actual code server-side. It preserves the clear early +/// error the v2 path gave before the strict `AddressCountry` enum was dropped +/// from the generated client. fn validate_country(country_upper: &str, role: Role) -> Result<(), String> { let ok = country_upper == "C2" || (country_upper.len() == 2 && country_upper.bytes().all(|b| b.is_ascii_uppercase())); diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index ad19e65..865002a 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -8,8 +8,22 @@ use serde_json::json; use domains_client::types; use super::common::{api_error, format_money, make_client}; +use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; +// The handler emits a transformed shape (formatted price/currency strings from +// SimpleMoney, a single headline term) rather than the raw `types::Availability`, +// so `--schema`/help metadata is declared to match what's actually returned. +output_schema!(DomainAvailableResult { + "domain": "string"; + "available": "bool"; + "definitive": "bool"; + "price": "string"; + "currency": "string"; + "renewalPrice": "string"; + "period": "number"; +}); + /// The headline price for an availability/quote: the entry for a 1-year term if /// present, else the first listed term. fn headline_price(prices: &[types::TermPrice]) -> Option<&types::TermPrice> { @@ -31,7 +45,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("domain") .with_tier(Tier::Read) .with_default_fields("domain,available,definitive,price,currency") - .with_json_schema::() + .with_output_schema::() .with_scopes(&[DOMAINS_READ]) .with_arg( clap::Arg::new("domain") diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index bf22639..796e13b 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -67,6 +67,18 @@ fn purchase_consent_types( agreement_titles: &[String], agreement_types: &[String], ) -> Result> { + // Validate the cached quote actually carries agreement types *before* the + // --agree gate: an empty list means a corrupt/outdated cache the command can + // never proceed with, so surface that single accurate error rather than a + // "requires agreeing…" prompt with an empty list that a rerun can't satisfy. + if agreement_types.is_empty() { + return Err(CliCoreError::message(format!( + "the cached quote for {domain} recorded no legal agreement types (the cache is \ + corrupt or from an older CLI version); re-run `gddy domain quote {domain}` for a \ + fresh quote." + ))); + } + if !agree { let list = agreement_titles .iter() @@ -79,12 +91,6 @@ fn purchase_consent_types( ))); } - if agreement_types.is_empty() { - return Err(CliCoreError::message(format!( - "the quote for {domain} recorded no legal agreement types; cannot record consent" - ))); - } - if !confirm { return Err(CliCoreError::message(format!( "registering {domain} for {period} year(s) charges your account and cannot be undone; \ @@ -228,9 +234,24 @@ pub(super) fn command() -> RuntimeCommandSpec { CliCoreError::message("the cached quote has an invalid registration period") })?; - let profile = cached.profile.as_ref().and_then(|v| { - serde_json::from_value::(v.clone()).ok() - }); + // Fail fast if a cached profile won't deserialize: silently dropping + // it would re-send a *different* request than was quoted and surface + // as a confusing server-side QUOTE_MISMATCH. A clear re-quote message + // is better than a mismatched charge attempt. + let profile = match cached.profile.as_ref() { + Some(v) => Some( + serde_json::from_value::(v.clone()).map_err( + |e| { + CliCoreError::message(format!( + "the cached quote for {domain} is corrupt or from an older CLI \ + version (could not read its registration profile: {e}); re-run \ + `gddy domain quote {domain}` for a fresh quote." + )) + }, + )?, + ), + None => None, + }; let consent = types::Consent { agreed_at: types::DateTime(iso_datetime(chrono::Utc::now())), @@ -362,6 +383,18 @@ mod tests { ); } + #[test] + fn empty_agreement_types_is_reported_before_the_agree_gate() { + // Without --agree AND with no cached agreement types, the accurate + // "no agreement types / re-quote" error must win over the "requires + // agreeing…" prompt (which would list nothing and never be satisfiable). + let err = purchase_consent_types("example.com", 1, false, false, &[], &[]) + .expect_err("empty types -> error"); + let msg = err.to_string(); + assert!(msg.contains("no legal agreement types"), "{msg}"); + assert!(!msg.contains("requires agreeing"), "{msg}"); + } + #[test] fn terminal_status_detection() { assert!(is_terminal_status("COMPLETED")); diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index cc70763..9e3ecd7 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -5,11 +5,18 @@ use cli_engine::{ }; use serde_json::json; -use domains_client::types; - use super::common::{api_error, format_money, make_client, string_list}; +use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; +// The handler emits a transformed per-item shape (`listPrice` is a formatted +// string from SimpleMoney, not the raw `types::Suggestion`), so declare the +// schema to match what's actually returned for `--schema`/help. +output_schema!(DomainSuggestResult { + "domain": "string"; + "listPrice": "string"; +}); + /// Convert a count-style flag value to the `NonZeroU64` the suggest query params /// expect, or `None` when it's absent/zero/negative (so the param is simply /// omitted). Total — never panics — because the value can arrive as `0` (e.g. the @@ -30,7 +37,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("domain") .with_tier(Tier::Read) .with_default_fields("domain") - .with_json_schema::() + .with_output_schema::() .with_scopes(&[DOMAINS_READ]) .with_arg( clap::Arg::new("query") From 449a97527cf0fb58e9faf45d5f5f78c6571d68ba Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 17:25:10 -0700 Subject: [PATCH 06/16] docs: mark conditional output-schema fields optional (domain quote/purchase/available/suggest) Address Copilot re-review round 4 on #76 (--schema/help accuracy, no runtime change): - available: price/currency/renewalPrice/period marked optional (emitted only when the API returns a headline price). - suggest: listPrice marked optional. - quote: DomainQuoteResult completed to mirror quote_to_json (added renewalPrice/expiresAt/irreversible/agreements/requiredAgreements/resolved) with the conditional fields marked optional. - purchase: operationId/price/currency marked optional. Deliberately not changing the runtime JSON to omit null keys: whether the CLI should emit explicit null vs. omit absent keys is a CLI-wide output-convention decision (it affects every command's json! output, not just these four), so it belongs in a dedicated change rather than this migration. Marking the schema fields optional already makes --schema/help accurate for both shapes. Co-Authored-By: Claude Opus 4.8 --- rust/src/domain/available.rs | 9 +++++---- rust/src/domain/purchase.rs | 7 ++++--- rust/src/domain/quote.rs | 16 ++++++++++++---- rust/src/domain/suggest.rs | 3 ++- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index 865002a..ea9838a 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -18,10 +18,11 @@ output_schema!(DomainAvailableResult { "domain": "string"; "available": "bool"; "definitive": "bool"; - "price": "string"; - "currency": "string"; - "renewalPrice": "string"; - "period": "number"; + // Present only when the API returns a headline price for the term. + "price": "string", optional; + "currency": "string", optional; + "renewalPrice": "string", optional; + "period": "number", optional; }); /// The headline price for an availability/quote: the entry for a 1-year term if diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index 796e13b..8ebbf3a 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -17,9 +17,10 @@ use crate::scopes::{DOMAINS_CREATE, DOMAINS_READ}; output_schema!(DomainPurchaseResult { "domain": "string"; "status": "string"; - "operationId": "string"; - "price": "string"; - "currency": "string"; + // Present depending on the async operation + the cached quote's receipt. + "operationId": "string", optional; + "price": "string", optional; + "currency": "string", optional; }); /// Format a UTC instant as the API's consent timestamp: RFC 3339 with a literal diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 27ddacf..d669a77 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -13,13 +13,21 @@ use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; use crate::{contacts, quote_cache}; +// Mirrors what `quote_to_json` emits: `domain`/`available` are always present; +// the rest appear only when the API supplies them (available + priced quotes). output_schema!(DomainQuoteResult { "domain": "string"; "available": "bool"; - "price": "string"; - "currency": "string"; - "period": "number"; - "quoteToken": "string"; + "price": "string", optional; + "currency": "string", optional; + "renewalPrice": "string", optional; + "period": "number", optional; + "quoteToken": "string", optional; + "expiresAt": "string", optional; + "irreversible": "bool", optional; + "agreements": "string", optional; + "requiredAgreements": "[]object", optional; + "resolved": "object", optional; }); /// Build the inline registration profile (contacts + preferences) sent with a diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index 9e3ecd7..8e13476 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -14,7 +14,8 @@ use crate::scopes::DOMAINS_READ; // schema to match what's actually returned for `--schema`/help. output_schema!(DomainSuggestResult { "domain": "string"; - "listPrice": "string"; + // Present only when the API returns a formattable list price. + "listPrice": "string", optional; }); /// Convert a count-style flag value to the `NonZeroU64` the suggest query params From 241d164bdcfad678efc362d96c34c84372ac9086 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 17:33:29 -0700 Subject: [PATCH 07/16] docs: fix domain help scopes + contacts flow wording; nameservers schema optionality Address Copilot re-review round 5 on #76 (help/schema accuracy, no runtime change): - domain contacts: group help said contacts.toml is for 'domain purchase' only; the two-step flow reads it at quote time and binds it into the token, so the help now says edit-and-re-quote to change contacts. - domain group help: noted that 'nameservers set' needs domains.nameserver:update (previously only read + create scopes were mentioned). - nameservers: operationId/status marked optional in NameserversResult (they're serialized from Option values from the async operation). Co-Authored-By: Claude Opus 4.8 --- rust/src/domain/contacts.rs | 7 +++++-- rust/src/domain/mod.rs | 3 ++- rust/src/domain/nameservers.rs | 5 +++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/rust/src/domain/contacts.rs b/rust/src/domain/contacts.rs index c7362fd..ad2734c 100644 --- a/rust/src/domain/contacts.rs +++ b/rust/src/domain/contacts.rs @@ -17,8 +17,11 @@ pub(super) fn group() -> RuntimeGroupSpec { ) .with_long( "Manage the optional contacts.toml that supplies registrant/admin/billing/\ - tech contacts for `gddy domain purchase`. When a role is absent the \ - purchase falls back to your account's default contact for that role.", + tech contacts for a registration. These are read at `gddy domain quote` \ + time and bound into the quote token, so `gddy domain purchase` registers \ + with exactly the contacts you quoted — edit contacts.toml and re-quote to \ + change them. When a role is absent the registration falls back to your \ + account's default contact for that role.", ), ) .with_command(RuntimeCommandSpec::new_with_context( diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index 44e9044..4016b4d 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -51,7 +51,8 @@ pub fn module() -> Module { • nameservers set — point a domain at custom nameservers\n\ \n\ Reads need the `domains.domain:read` scope; purchase also needs\n\ - `domains.domain:create`. Manage a domain's DNS with `gddy dns`.", + `domains.domain:create`, and `nameservers set` needs\n\ + `domains.nameserver:update`. Manage a domain's DNS with `gddy dns`.", ), ) .with_command(list::command()) diff --git a/rust/src/domain/nameservers.rs b/rust/src/domain/nameservers.rs index 32b8674..2b0fbf5 100644 --- a/rust/src/domain/nameservers.rs +++ b/rust/src/domain/nameservers.rs @@ -14,8 +14,9 @@ use crate::scopes::DOMAINS_NAMESERVER_UPDATE; output_schema!(NameserversResult { "domain": "string"; "nameservers": "[]string"; - "operationId": "string"; - "status": "string"; + // Present when the async operation returns them (serialized from Options). + "operationId": "string", optional; + "status": "string", optional; }); pub(super) fn group() -> RuntimeGroupSpec { From ea44a8302f4f5e3e52f07d8d702a118c4b41b9dc Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 17:40:25 -0700 Subject: [PATCH 08/16] fix: emit required output fields concretely; clarify is_terminal_status doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot re-review round 6 on #76: - available / quote: the required identity fields (domain, available[, definitive]) were serialized straight from Options and could emit JSON null despite the schema marking them required. Now emit them concretely — domain falls back to the known request domain, missing booleans read as false (matching how availability already drives the next action) — so the output honors its declared required contract. (This is distinct from the deferred null-key-omit convention: it makes required fields non-null, not optional-omission.) - suggest: skip any suggestion item lacking a domain (filter_map) so every emitted object matches the schema and projects cleanly. - purchase: reworded is_terminal_status doc to describe the terminal states (COMPLETED/FAILED) rather than implying CONFIRMED/EXECUTING are the only non-terminal ones (SUBMITTED/PENDING are also non-terminal). Co-Authored-By: Claude Opus 4.8 --- rust/src/domain/available.rs | 9 ++++++--- rust/src/domain/purchase.rs | 3 ++- rust/src/domain/quote.rs | 11 +++++++---- rust/src/domain/suggest.rs | 9 ++++++--- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index ea9838a..74b9611 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -85,10 +85,13 @@ pub(super) fn command() -> RuntimeCommandSpec { Err(e) => return Err(api_error("domain availability check", debug, e).await), }; + // Emit the required identity fields concretely (never JSON null): the + // domain falls back to the known input, and missing booleans read as + // false — matching how availability is treated for the next action. let mut result = json!({ - "domain": body.domain, - "available": body.available, - "definitive": body.definitive, + "domain": body.domain.clone().unwrap_or_else(|| domain.clone()), + "available": body.available.unwrap_or(false), + "definitive": body.definitive.unwrap_or(false), }); let prices = body.prices.unwrap_or_default(); if let Some(term) = headline_price(&prices) { diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index 8ebbf3a..bff3510 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -48,7 +48,8 @@ fn consent_principal(cred: &Credential) -> Result { } /// Whether an async domain-operation status is terminal (no further polling). -/// The non-terminal states are `CONFIRMED`/`EXECUTING`; `COMPLETED`/`FAILED` end it. +/// Only `COMPLETED`/`FAILED` are terminal; every other status (e.g. `SUBMITTED`, +/// `PENDING`, `CONFIRMED`, `EXECUTING`) is treated as still in progress. fn is_terminal_status(status: &str) -> bool { matches!(status, "COMPLETED" | "FAILED") } diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index d669a77..65f506e 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -97,10 +97,13 @@ fn agreement_line(a: &types::Agreement) -> String { /// `agreements` scalar joining the required-agreement titles — so the terms are /// visible without `--output json`. The full structured `requiredAgreements` /// array is kept for scripting. -fn quote_to_json(quote: &types::RegistrationQuote) -> serde_json::Value { +fn quote_to_json(quote: &types::RegistrationQuote, request_domain: &str) -> serde_json::Value { + // Emit the required identity fields concretely (never JSON null): domain + // falls back to the known request domain, and a missing `available` reads as + // false (which is how callers already treat it). let mut out = json!({ - "domain": quote.domain, - "available": quote.available, + "domain": quote.domain.clone().unwrap_or_else(|| request_domain.to_owned()), + "available": quote.available.unwrap_or(false), }); if let Some(price) = quote.price.as_ref() && let Some(p) = format_money(price) @@ -268,7 +271,7 @@ pub(super) fn command() -> RuntimeCommandSpec { Err(e) => return Err(api_error("quoting registration", debug, e).await), }; - let view = quote_to_json("e); + let view = quote_to_json("e, &domain); // Persist the quote so `purchase --quote-token` can reproduce this // exact request (only possible when it's available and got a token). diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index 8e13476..f874a39 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -106,12 +106,15 @@ pub(super) fn command() -> RuntimeCommandSpec { let suggestions: Vec = resp .items .into_iter() - .map(|s| { - let mut obj = json!({ "domain": s.domain }); + .filter_map(|s| { + // Skip any suggestion without a domain so every emitted object + // matches the schema (`domain` required) and projects cleanly. + let domain = s.domain?; + let mut obj = json!({ "domain": domain }); if let Some(p) = s.list_price.as_ref().and_then(format_money) { obj["listPrice"] = json!(p); } - obj + Some(obj) }) .collect(); Ok( From 7ad92c36c39cf16857f683fb92a36c7b10f577b6 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 17:50:05 -0700 Subject: [PATCH 09/16] fix: DNS commands surface API error bodies; prune expired quotes on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot re-review round 7 on #76: - dns list/add/set/delete: reuse domain's api_error helper (promoted to pub(crate)) so non-2xx responses show the server's actual message/body instead of just the status — matching the domain commands and making v3 create (422) failures diagnosable. Previously each site wrapped the error as ' failed: {e}', dropping the body. - quote_cache: prune expired entries on read (get), not only on save, so an abandoned quote whose serialized profile may hold contact PII doesn't linger on disk past its ~10-min expiry. Doc updated; regression test added. Co-Authored-By: Claude Opus 4.8 --- rust/src/dns/mod.rs | 84 +++++++++++++++++++-------------------- rust/src/domain/common.rs | 2 +- rust/src/domain/mod.rs | 2 +- rust/src/quote_cache.rs | 58 ++++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 48 deletions(-) diff --git a/rust/src/dns/mod.rs b/rust/src/dns/mod.rs index b941985..41ecdd6 100644 --- a/rust/src/dns/mod.rs +++ b/rust/src/dns/mod.rs @@ -21,7 +21,7 @@ use cli_engine::{ }; use serde_json::{Value, json}; -use crate::domain::{make_client, string_list}; +use crate::domain::{api_error, make_client, string_list}; use crate::output_schema::output_schema; use crate::scopes::{DOMAINS_DNS_UPDATE, DOMAINS_READ}; @@ -288,38 +288,31 @@ pub fn module() -> Module { let type_opt = arg_str(&ctx, "type"); let name_opt = arg_str(&ctx, "name"); + let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; - let records = match (type_opt.as_deref(), name_opt.as_deref()) { - (None, _) => client - .record_get_all() - .domain(domain.as_str()) - .send() - .await - .map_err(|e| { - CliCoreError::message(format!("listing DNS records failed: {e}")) - })? - .into_inner(), - (Some(record_type), None) => client - .record_get_by_type() - .domain(domain.as_str()) - .type_(record_type) - .send() - .await - .map_err(|e| { - CliCoreError::message(format!("listing DNS records failed: {e}")) - })? - .into_inner(), - (Some(record_type), Some(name)) => client - .record_get() - .domain(domain.as_str()) - .type_(record_type) - .name(name) - .send() - .await - .map_err(|e| { - CliCoreError::message(format!("listing DNS records failed: {e}")) - })? - .into_inner(), + let result = match (type_opt.as_deref(), name_opt.as_deref()) { + (None, _) => client.record_get_all().domain(domain.as_str()).send().await, + (Some(record_type), None) => { + client + .record_get_by_type() + .domain(domain.as_str()) + .type_(record_type) + .send() + .await + } + (Some(record_type), Some(name)) => { + client + .record_get() + .domain(domain.as_str()) + .type_(record_type) + .name(name) + .send() + .await + } + }; + let records = match result { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("listing DNS records", debug, e).await), }; let out: Vec = records @@ -361,18 +354,19 @@ pub fn module() -> Module { let records = v3_records(&name, &record_type, &data, &opts); let count = records.len(); + let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; // v3 creates a single record per call; add each in turn. for record in records { - client + if let Err(e) = client .create_dns_record() .zone(domain.as_str()) .body(record) .send() .await - .map_err(|e| { - CliCoreError::message(format!("adding DNS record failed: {e}")) - })?; + { + return Err(api_error("adding DNS record", debug, e).await); + } } Ok(CommandResult::new(json!({ @@ -414,8 +408,9 @@ pub fn module() -> Module { let records = v1_set_records(&data, &opts); let count = records.len(); + let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; - client + if let Err(e) = client .record_replace_type_name() .domain(domain.as_str()) .type_(record_type.as_str()) @@ -423,9 +418,9 @@ pub fn module() -> Module { .body(records) .send() .await - .map_err(|e| { - CliCoreError::message(format!("replacing DNS records failed: {e}")) - })?; + { + return Err(api_error("replacing DNS records", debug, e).await); + } Ok(CommandResult::new(json!({ "domain": domain, @@ -480,17 +475,18 @@ pub fn module() -> Module { let record_type = arg_str(&ctx, "type").unwrap_or_default(); let name = arg_str(&ctx, "name").unwrap_or_default(); + let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; - client + if let Err(e) = client .record_delete_type_name() .domain(domain.as_str()) .type_(record_type.as_str()) .name(name.as_str()) .send() .await - .map_err(|e| { - CliCoreError::message(format!("deleting DNS records failed: {e}")) - })?; + { + return Err(api_error("deleting DNS records", debug, e).await); + } Ok(CommandResult::new(json!({ "domain": domain, diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index 40eb3a7..ae9a557 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -106,7 +106,7 @@ pub(crate) fn string_list(ctx: &CommandContext, key: &str) -> Vec { /// for unexpected (non-2xx) responses so the API's actual message isn't lost /// (progenitor's `Display` prints only the status). Async because reading the /// body is async. -pub(super) async fn api_error( +pub(crate) async fn api_error( action: &str, debug: bool, err: domains_client::Error<()>, diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index 4016b4d..a34581d 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -32,7 +32,7 @@ mod suggest; // Shared with the `dns` module, which builds the same Domains API client and // reuses the repeatable-argument helper. -pub(crate) use common::{make_client, string_list}; +pub(crate) use common::{api_error, make_client, string_list}; pub fn module() -> Module { Module::new("Domains", |_ctx| { diff --git a/rust/src/quote_cache.rs b/rust/src/quote_cache.rs index 326f9ab..e3944d2 100644 --- a/rust/src/quote_cache.rs +++ b/rust/src/quote_cache.rs @@ -12,7 +12,10 @@ //! Entries are single-use: [`get`] reads a quote without consuming it (so the //! `--agree` gate can show its terms before the user confirms), and [`remove`] //! deletes it only after the registration succeeds. They also expire with the -//! token (~10 min), so the file only ever holds a few short-lived records. It +//! token (~10 min), and expired entries are pruned whenever the cache is read +//! ([`get`]) or written ([`save`]) — so an abandoned quote's data (including a +//! serialized profile that may contain contact PII) doesn't linger past its +//! expiry once any later quote/purchase runs, and the file stays small. It //! lives beside `contacts.toml`/`environments.toml` //! (`dirs::config_dir()/gddy/quotes.json`); a quote token is a short-lived, //! single-use capability, not a long-lived secret. @@ -138,7 +141,25 @@ pub fn get(token: &str) -> Lookup { let Some(path) = quotes_path() else { return Lookup::Missing; }; - get_at(&path, chrono::Utc::now(), token) + let now = chrono::Utc::now(); + // Best-effort: drop any expired entries (whose serialized profile may hold + // contact PII) so an abandoned quote doesn't linger on disk past its expiry. + prune_expired(&path, now); + get_at(&path, now, token) +} + +/// Remove every expired entry from the cache file, rewriting it only if any were +/// dropped. Best-effort — a write failure just leaves stale entries (which are +/// still treated as expired on read and pruned on the next successful write). +fn prune_expired(path: &Path, now: chrono::DateTime) { + let mut file = load(path); + let before = file.quotes.len(); + file.quotes.retain(|_, q| !is_expired(q, now)); + if file.quotes.len() != before + && let Err(e) = write(path, &file) + { + tracing::warn!(error = %e, "could not prune expired quotes from the cache"); + } } fn get_at(path: &Path, now: chrono::DateTime, token: &str) -> Lookup { @@ -284,4 +305,37 @@ mod tests { assert!(matches!(get_at(&path, later, "old"), Lookup::Missing)); assert!(matches!(get_at(&path, later, "new"), Lookup::Found(_))); } + + #[test] + fn prune_expired_removes_stale_entries_from_disk() { + // Bounds PII-at-rest: an expired quote is physically removed on read, + // not just masked as expired, so an abandoned quote can't linger. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("quotes.json"); + let saved = at(2026, 7, 1, 12); + save_at( + &path, + saved, + "old", + quote("old.com", Some("2026-07-01T12:30:00Z")), + ) + .expect("save"); + save_at( + &path, + saved, + "live", + quote("live.com", Some("2026-07-01T14:00:00Z")), + ) + .expect("save"); + + // At 13:00 "old" (expires 12:30) is stale; "live" (expires 14:00) isn't. + prune_expired(&path, at(2026, 7, 1, 13)); + + let remaining = load(&path).quotes; + assert!( + !remaining.contains_key("old"), + "expired entry must be pruned from disk" + ); + assert!(remaining.contains_key("live"), "live entry must remain"); + } } From 9cc222931cb97703d9fb0da4ff73f1c6f90efcd6 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 17:55:51 -0700 Subject: [PATCH 10/16] fix: validate customer UUID before paid purchase; correct contacts to_api doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot re-review round 8 on #76: - purchase: consent_principal now validates the customer subject is a UUID (via the already-present uuid crate) before the paid register call, so a malformed customer: subject fails fast with a clear message instead of an opaque server rejection. Matches the documented customer: format; test added. - contacts: corrected the to_api doc — country validation is an ISO-3166 alpha-2 *shape* check (not list membership), and name_middle/job_title/fax are not struct fields (a leftover such key is simply ignored by serde), not 'kept in the file schema'. Co-Authored-By: Claude Opus 4.8 --- rust/src/contacts/mod.rs | 10 ++++++---- rust/src/domain/purchase.rs | 27 +++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/rust/src/contacts/mod.rs b/rust/src/contacts/mod.rs index c86bb50..afb3795 100644 --- a/rust/src/contacts/mod.rs +++ b/rust/src/contacts/mod.rs @@ -95,14 +95,16 @@ impl Role { impl Contact { /// Convert to the v3 API contact (`Contact`), validating the country code. /// Returns a human-readable error (surfaced by `domain purchase`/`quote`) when - /// the country is not a recognized two-letter ISO code or the phone can't be - /// parsed. + /// the country isn't shaped like an ISO-3166 alpha-2 code (see + /// [`validate_country`] — a format check, not list membership) or the phone + /// can't be parsed. /// /// v3's `Contact` is leaner than v2's: it has no middle name, job title, fax, /// or character-encoding field, and the phone is a structured object /// (`countryCode`/`nationalNumber`) rather than a dotted `+1.4805551212` - /// string. The `name_middle`/`job_title`/`fax` columns in `contacts.toml` are - /// kept in the file schema for backward compatibility but are not sent to v3. + /// string. `name_middle`/`job_title`/`fax` are not part of the struct schema; + /// a leftover such key in a v1/v2-era `contacts.toml` is simply ignored (serde + /// skips unknown keys). pub fn to_api(&self, role: Role) -> Result { let country_upper = self.country.to_ascii_uppercase(); validate_country(&country_upper, role)?; diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index bff3510..7c6b985 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -34,17 +34,28 @@ fn iso_datetime(now: chrono::DateTime) -> String { /// verifies this principal against the authenticated identity, so a non-customer /// subject is rejected with a clear error before the paid call. fn consent_principal(cred: &Credential) -> Result { - cred.sub + let id = cred + .sub .strip_prefix("customer:") - .filter(|uuid| !uuid.is_empty()) - .map(str::to_owned) + .filter(|id| !id.is_empty()) .ok_or_else(|| { CliCoreError::message(format!( "the OAuth token's subject ({:?}) is not a customer identity; `domain purchase` \ needs a customer-scoped token", cred.sub )) - }) + })?; + // The principal is sent to the paid register endpoint; validate it's a + // customer UUID up front so a malformed `customer:` subject fails + // fast with a clear message rather than as an opaque server-side rejection. + if uuid::Uuid::parse_str(id).is_err() { + return Err(CliCoreError::message(format!( + "the OAuth token's customer subject ({:?}) is not a valid UUID; `domain purchase` \ + needs a customer-scoped token", + cred.sub + ))); + } + Ok(id.to_owned()) } /// Whether an async domain-operation status is terminal (no further polling). @@ -421,6 +432,14 @@ mod tests { ..Default::default() }; assert!(consent_principal(&shopper).is_err()); + + // A customer subject that isn't a UUID must fail fast before the paid call. + let not_uuid = Credential { + sub: "customer:12345".to_string(), + ..Default::default() + }; + let err = consent_principal(¬_uuid).expect_err("non-uuid customer subject"); + assert!(err.to_string().contains("not a valid UUID"), "{err}"); } #[test] From fc105d55f17c6f2ad587763a5493b29f3498e3cc Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 18:01:23 -0700 Subject: [PATCH 11/16] fix: domain list surfaces API error bodies via api_error Address Copilot re-review round 9 on #76: domain list mapped request errors to a generic 'listing domains failed: {e}' string, dropping the server response body and the --debug request-id. Switched to the shared api_error helper, so non-2xx failures are as debuggable as the other domain/dns commands. (Last straggler on the old error pattern.) Co-Authored-By: Claude Opus 4.8 --- rust/src/domain/list.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rust/src/domain/list.rs b/rust/src/domain/list.rs index b3d570e..1d494cd 100644 --- a/rust/src/domain/list.rs +++ b/rust/src/domain/list.rs @@ -8,7 +8,7 @@ use serde_json::json; use domains_client::types; -use super::common::{make_client, string_list}; +use super::common::{api_error, make_client, string_list}; use crate::scopes::DOMAINS_READ; /// Validate `--status` values case-insensitively against the generated @@ -43,16 +43,17 @@ pub(super) fn command() -> RuntimeCommandSpec { .help("Only domains with this status, e.g. ACTIVE (repeatable)"), ), |ctx| async move { + let debug = !ctx.middleware.debug.is_empty(); let statuses = parse_statuses(&string_list(&ctx, "status"))?; let client = make_client(&ctx).await?; let mut req = client.list(); if !statuses.is_empty() { req = req.statuses(statuses); } - let resp = req - .send() - .await - .map_err(|e| CliCoreError::message(format!("listing domains failed: {e}")))?; + let resp = match req.send().await { + Ok(r) => r, + Err(e) => return Err(api_error("listing domains", debug, e).await), + }; let domains: Vec = resp .into_inner() .iter() From 2fa3d97dbf8b5e7bd4b0cdc79607f9b31101a999 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 18:06:43 -0700 Subject: [PATCH 12/16] docs: update contacts.toml template header for the quote-time contact flow Address Copilot re-review round 10 on #76: the scaffolded contacts.toml header still said contacts are used only for 'gddy domain purchase'. In the v3 two-step flow they're read at quote time and locked into the token, so the header now says edit-and-re-quote to change them (matching the group help updated earlier). Co-Authored-By: Claude Opus 4.8 --- rust/src/contacts/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rust/src/contacts/mod.rs b/rust/src/contacts/mod.rs index afb3795..35e14c8 100644 --- a/rust/src/contacts/mod.rs +++ b/rust/src/contacts/mod.rs @@ -243,9 +243,10 @@ pub fn load() -> Result { pub fn sample_toml() -> &'static str { r#"# gddy domain-purchase contacts # -# Default contacts for `gddy domain purchase`. Any role you define here is sent -# with the purchase; any role you leave out falls back to your GoDaddy account's -# default contact for that role. +# Default contacts for domain registration. These are read at `gddy domain quote` +# time and locked into the quote token, so `gddy domain purchase` registers with +# exactly the contacts you quoted — edit this file and re-quote to change them. +# Any role you leave out falls back to your GoDaddy account's default for that role. # # To use a role, uncomment its block below and replace the placeholder values. # Required fields per role: name_first, name_last, email, phone, address1, city, From 403aab6e589440c476ea2ffa51ea94cc7931a4c3 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Wed, 1 Jul 2026 18:12:59 -0700 Subject: [PATCH 13/16] fix: treat unparseable quote expiry as expired; doc-link + template polish Address Copilot re-review round 11 on #76: - quote_cache: is_expired now treats an unparseable expires_at as EXPIRED (was not-expired). A malformed timestamp only arises from corruption/manual edit/ old format, so the entry is suspect; expiring it avoids keeping its serialized profile (potential PII) on disk indefinitely. Absent expiry still means never-expires-locally. Test added. - scopes: use the fully-qualified cli_engine::CommandSpec::with_scopes intra-doc link so rustdoc resolves it. - contacts: template header line renamed to 'gddy domain registration contacts' (completing the quote-time-flow wording; body was updated last round). Not changed: the maintainer OAuth-client-registration pointer block in scopes.rs (internal AuthZ console URLs + non-secret project/app/client IDs). It was added intentionally as a maintainer aid; the IDs are identifiers, not secrets, in a private repo. Left for the repo owner to decide whether to genericize. Co-Authored-By: Claude Opus 4.8 --- rust/src/contacts/mod.rs | 2 +- rust/src/quote_cache.rs | 22 ++++++++++++++++++---- rust/src/scopes.rs | 3 ++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/rust/src/contacts/mod.rs b/rust/src/contacts/mod.rs index 35e14c8..63eb718 100644 --- a/rust/src/contacts/mod.rs +++ b/rust/src/contacts/mod.rs @@ -241,7 +241,7 @@ pub fn load() -> Result { /// fall back to the account) until the user deliberately uncomments and fills a /// role — they can't accidentally register a domain with the placeholder values. pub fn sample_toml() -> &'static str { - r#"# gddy domain-purchase contacts + r#"# gddy domain registration contacts # # Default contacts for domain registration. These are read at `gddy domain quote` # time and locked into the quote token, so `gddy domain purchase` registers with diff --git a/rust/src/quote_cache.rs b/rust/src/quote_cache.rs index e3944d2..522f2ea 100644 --- a/rust/src/quote_cache.rs +++ b/rust/src/quote_cache.rs @@ -82,14 +82,17 @@ pub fn quotes_path() -> Option { dirs::config_dir().map(|d| d.join("gddy").join("quotes.json")) } -/// Whether a cached quote has expired at `now`. An unparseable or absent -/// `expires_at` is treated as not-expired (the API always returns one in -/// practice; being lenient never wrongly discards a usable quote). +/// Whether a cached quote has expired at `now`. An *absent* `expires_at` is +/// treated as not-expired (the API always returns one in practice; a quote with +/// no expiry never expires locally). An *unparseable* one is treated as expired: +/// a malformed timestamp only arises from corruption, a manual edit, or an older +/// format, so the entry is already suspect — expiring it avoids keeping its +/// serialized profile (potential PII) on disk indefinitely. fn is_expired(quote: &CachedQuote, now: chrono::DateTime) -> bool { match quote.expires_at.as_deref() { Some(ts) => chrono::DateTime::parse_from_rfc3339(ts) .map(|exp| exp.with_timezone(&chrono::Utc) <= now) - .unwrap_or(false), + .unwrap_or(true), None => false, } } @@ -270,6 +273,17 @@ mod tests { )); } + #[test] + fn unparseable_expiry_is_treated_as_expired() { + // A malformed timestamp (corruption/manual edit/old format) must not keep + // an entry — and its potential PII — alive forever. + let q = quote("x.com", Some("not-a-timestamp")); + assert!(is_expired(&q, at(2026, 7, 1, 12))); + // Absent expiry still means never-expires-locally. + let no_expiry = quote("y.com", None); + assert!(!is_expired(&no_expiry, at(2026, 7, 1, 12))); + } + #[test] fn unknown_token_is_missing() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs index 4a3d827..39c0761 100644 --- a/rust/src/scopes.rs +++ b/rust/src/scopes.rs @@ -2,7 +2,8 @@ //! //! # Why this module exists //! -//! A command declares the scopes it needs with [`CommandSpec::with_scopes`], and +//! A command declares the scopes it needs with +//! [`cli_engine::CommandSpec::with_scopes`], and //! cli-engine's OAuth step-up mints a token carrying them. But a scope the CLI //! *requests* is only grantable if the CLI's **OAuth client** is *registered* for //! it — otherwise the authorization server refuses to mint a token carrying the From 5638e2e66853aa86bcacc45d5d1916ab24a7e5d7 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Thu, 2 Jul 2026 07:53:01 -0700 Subject: [PATCH 14/16] chore: bump cli-engine to 0.3.5 for next-steps human-output footer 0.3.5 renders next_actions as a 'Next steps:' footer in human output (they were only surfaced in JSON before). The domain/dns commands' next actions (quote->purchase, available->quote, list->dns, etc.) now show in the default human view. Co-Authored-By: Claude Opus 4.8 --- rust/Cargo.lock | 4 ++-- rust/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4188df0..5ae5b5f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -560,9 +560,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-engine" -version = "0.3.4" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4500a5c23d3171897dc68633fa37c541114759c93aaa600dad95dea196d6987c" +checksum = "35b4759ac63483df8b4fb0b2a88010d71c830d42eee81405808d58b4ae77a8fa" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 26caed3..70a140c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" async-trait = "0.1" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } clap = { version = "4.5", features = ["std", "string"] } -cli-engine = { features = ["pkce-auth"], version = "0.3.4" } +cli-engine = { features = ["pkce-auth"], version = "0.3.5" } dirs = "6" domains-client = { path = "domains-client" } fancy-regex = "0.14" From 1c221fc0f4a358179488894bce91911bbc5e69a8 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Thu, 2 Jul 2026 07:58:30 -0700 Subject: [PATCH 15/16] fix: fail fast if the quoted profile can't be serialized for the cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot re-review round 12 on #76: domain quote used serde_json::to_value(&profile).ok(), silently dropping a serialization failure and caching a profile-less quote — which would make purchase re-send a different request than was quoted (server-side QUOTE_MISMATCH). Now a serialization failure is a hard error (symmetric with the purchase-side deserialize fail-fast). The cached profile is therefore always present. Co-Authored-By: Claude Opus 4.8 --- rust/src/domain/quote.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 65f506e..7e6ec50 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -253,8 +253,14 @@ pub(super) fn command() -> RuntimeCommandSpec { let profile = build_profile(privacy, renew_auto, &name_servers)?; // Cache the exact profile we quote with: the token binds a hash of the // domain/price/profile, so `purchase` must re-send this verbatim or the - // register call fails with QUOTE_MISMATCH. - let profile_json = serde_json::to_value(&profile).ok(); + // register call fails with QUOTE_MISMATCH. Serializing this in-memory + // struct effectively never fails, but if it did, silently caching a + // profile-less quote would itself cause that mismatch — so fail fast. + let profile_json = serde_json::to_value(&profile).map_err(|e| { + CliCoreError::message(format!( + "could not serialize the registration profile for the quote cache: {e}" + )) + })?; let quote = match client .quote_domain_registration() @@ -296,7 +302,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .and_then(|v| v.as_str()) .map(str::to_owned), expires_at: quote.expires_at.as_ref().map(|e| e.to_string()), - profile: profile_json, + profile: Some(profile_json), }; if let Err(e) = quote_cache::save(&token, cached) { // Non-fatal: the quote is still shown, but purchase won't From 1cfa772db3f468138fa63725a81051295c652a39 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Fri, 3 Jul 2026 09:01:09 -0700 Subject: [PATCH 16/16] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20OAuth-only=20auth=20+=20purchase/dns=20robustness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves reviewer feedback on #76: - Remove sso-key dual-mode auth entirely (Jacob, purchase.rs:208). API keys are deprecated at launch, so the CLI is OAuth-only: dropped CompositeAuthProvider, SSO_KEY_PROVIDER, the Bearer/sso-key header branch, the env api_key/api_secret config + resolution, and the purchase-time sso-key rejection. GoDaddyAuthProvider is the sole provider. (No quote/purchase split needed — no api-key path exists.) - purchase: return a non-zero error on a terminal FAILED operation status instead of reporting success (Jacob, purchase.rs:351). - dns add: attempt every --data record, don't stop at the first failure; report per-record created/failed and exit non-zero if any failed, with a ✓/✗ breakdown (Jacob, dns/mod.rs:368). New pure summarize_add_outcomes helper + tests. - purchase: reuse a single idempotency key across retries — minted at quote time, stored in the cached quote — so a lost-response retry can't double-charge (Swaminathan, purchase.rs:296). - purchase: tracing::info when bounded polling exits non-terminal, pointing at `domain get` (Swaminathan, purchase.rs:321). - quote_cache: distinct Lookup::NoConfigDir so a missing config dir surfaces its own error rather than "no cached quote" (Swaminathan, quote_cache.rs:144). Co-Authored-By: Claude Opus 4.8 --- rust/domains-client/src/lib.rs | 38 +------ rust/src/auth.rs | 149 --------------------------- rust/src/dns/mod.rs | 142 +++++++++++++++++++++++--- rust/src/domain/common.rs | 28 +---- rust/src/domain/mod.rs | 9 +- rust/src/domain/purchase.rs | 54 +++++++--- rust/src/domain/quote.rs | 4 + rust/src/environments/mod.rs | 181 ++------------------------------- rust/src/main.rs | 2 +- rust/src/quote_cache.rs | 13 ++- 10 files changed, 205 insertions(+), 415 deletions(-) diff --git a/rust/domains-client/src/lib.rs b/rust/domains-client/src/lib.rs index 0a9b1b4..7bac1d7 100644 --- a/rust/domains-client/src/lib.rs +++ b/rust/domains-client/src/lib.rs @@ -11,7 +11,7 @@ //! from the vendored, merged OpenAPI 3.0 spec (`openapi/domains.oas3.json`). //! Construct [`Client`] with [`Client::new_with_client`] to supply a //! pre-authenticated `reqwest::Client` (the CLI sets the `Authorization: -//! sso-key …`/Bearer header itself). The v3 operations live under the +//! Bearer ` header itself). The v3 operations live under the //! `/v3/domains` base path, baked into the spec's absolute paths so one host //! `base_url` serves both generations. See `scripts/regenerate-spec.sh` to //! refresh and re-merge the spec. @@ -45,9 +45,9 @@ pub enum BuildError { /// header and `x-request-id`. /// /// `authorization` is the full header value the domain endpoints expect — e.g. -/// `"sso-key :"` (the usual path) or `"Bearer "`. Keeping -/// the `reqwest::Client` construction here means callers never name reqwest's -/// types, so the main crate is unaffected by this crate's reqwest version. +/// `"Bearer "`. Keeping the `reqwest::Client` construction here means +/// callers never name reqwest's types, so the main crate is unaffected by this +/// crate's reqwest version. pub fn client_with_auth( base_url: &str, authorization: &str, @@ -580,34 +580,4 @@ mod tests { mock.assert_async().await; } - - // Guard the auth-scheme selection retained from the hand-written helper. - #[tokio::test] - async fn sso_key_scheme_sets_authorization_header() { - let server = MockServer::start_async().await; - let mock = server - .mock_async(|when, then| { - when.method(GET) - .path("/v3/domains/check-availability") - .header("authorization", "sso-key KEY:SECRET"); - then.status(200) - .json_body(json!({ "domain": "x.com", "available": true })); - }) - .await; - - client_with_auth( - &server.base_url(), - "sso-key KEY:SECRET", - "godaddy-cli/test", - "req-1", - ) - .expect("build client") - .get_domain_availability() - .domain("x.com") - .send() - .await - .expect("request succeeds"); - - mock.assert_async().await; - } } diff --git a/rust/src/auth.rs b/rust/src/auth.rs index ebdb4ff..8580433 100644 --- a/rust/src/auth.rs +++ b/rust/src/auth.rs @@ -129,152 +129,3 @@ impl AuthProvider for GoDaddyAuthProvider { Ok(envs) } } - -/// Stored in [`Credential::provider`] for the sso-key bypass path, so the domain -/// client selects the `sso-key` Authorization scheme instead of Bearer. -pub const SSO_KEY_PROVIDER: &str = "sso-key"; - -/// Auth provider that composes [`GoDaddyAuthProvider`] (OAuth/PKCE) but, for -/// `domain:*` commands whose target environment has an sso-key configured, -/// returns that key instead. -/// -/// The GoDaddy Domains API endpoints accept either an sso-key -/// (`Authorization: sso-key :`) or an OAuth bearer token. This -/// provider uses the sso-key only when one is configured for a `domain:*` -/// command's environment; every other command — and any domain command without a -/// configured key — uses OAuth (including scope step-up). Scoping the bypass to -/// `domain:*` keeps it from affecting unrelated commands. -#[derive(Debug, Default)] -pub struct CompositeAuthProvider { - oauth: GoDaddyAuthProvider, -} - -impl CompositeAuthProvider { - pub fn new() -> Self { - Self { - oauth: GoDaddyAuthProvider::new(), - } - } - - /// Build an sso-key credential, if this is a `domain:*` command and both a - /// key and secret are present. Pure (no process/config access) for testing. - fn sso_key_credential_from( - env: &str, - command: &str, - key: Option<&str>, - secret: Option<&str>, - ) -> Option { - if !command.starts_with("domain:") { - return None; - } - let key = key.map(str::trim).filter(|s| !s.is_empty())?; - let secret = secret.map(str::trim).filter(|s| !s.is_empty())?; - Some(Credential { - token: format!("{key}:{secret}"), - provider: SSO_KEY_PROVIDER.to_owned(), - env: env.to_owned(), - ..Default::default() - }) - } - - /// Resolve the sso-key for a domain command from the environment's config - /// (`_API_KEY`/`_API_SECRET` env vars or the `environments.toml` - /// entry) and turn it into a credential. - fn sso_key_credential(env: &str, command: &str) -> Option { - let domains = environments::resolve_domains(env).ok()?; - Self::sso_key_credential_from( - env, - command, - domains.api_key.as_deref(), - domains.api_secret.as_deref(), - ) - } -} - -#[async_trait] -impl AuthProvider for CompositeAuthProvider { - fn name(&self) -> &str { - self.oauth.name() - } - - async fn get_credential(&self, env: &str, command: &str, tier: &str) -> Result { - if let Some(cred) = Self::sso_key_credential(env, command) { - return Ok(cred); - } - self.oauth.get_credential(env, command, tier).await - } - - async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result { - if let Some(cred) = Self::sso_key_credential(req.env, req.command) { - return Ok(cred); - } - self.oauth.get_credential_for(req).await - } - - async fn status(&self, env: &str) -> Result { - self.oauth.status(env).await - } - - async fn logout(&self, env: &str) -> Result<()> { - self.oauth.logout(env).await - } - - async fn list_environments(&self) -> Result> { - self.oauth.list_environments().await - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sso_key_only_for_domain_commands_with_key_and_secret() { - // domain command + both key/secret -> sso-key credential. - let cred = CompositeAuthProvider::sso_key_credential_from( - "ote", - "domain:available", - Some("KEY"), - Some("SECRET"), - ) - .expect("sso-key credential"); - assert_eq!(cred.token, "KEY:SECRET"); - assert_eq!(cred.provider, SSO_KEY_PROVIDER); - assert_eq!(cred.env, "ote"); - } - - #[test] - fn no_sso_key_for_non_domain_commands() { - assert!( - CompositeAuthProvider::sso_key_credential_from( - "ote", - "application:list", - Some("KEY"), - Some("SECRET"), - ) - .is_none() - ); - } - - #[test] - fn no_sso_key_when_key_or_secret_missing_or_blank() { - assert!( - CompositeAuthProvider::sso_key_credential_from( - "ote", - "domain:suggest", - Some("KEY"), - None - ) - .is_none() - ); - assert!( - CompositeAuthProvider::sso_key_credential_from( - "ote", - "domain:suggest", - Some(" "), - Some("SECRET"), - ) - .is_none() - ); - } -} diff --git a/rust/src/dns/mod.rs b/rust/src/dns/mod.rs index 41ecdd6..57a3132 100644 --- a/rust/src/dns/mod.rs +++ b/rust/src/dns/mod.rs @@ -35,6 +35,18 @@ output_schema!(DnsWriteResult { "action": "string"; }); +// `dns add` creates one v3 record per `--data` value; it reports each outcome +// individually so a partial failure is explicit (see the handler). +output_schema!(DnsAddResult { + "domain": "string"; + "type": "string"; + "name": "string"; + "created": "number"; + "failed": "number"; + "results": "[]object"; + "action": "string"; +}); + output_schema!(DnsDeleteResult { "domain": "string"; "type": "string"; @@ -146,6 +158,58 @@ fn v3_records( .collect() } +/// Summarize the per-record create outcomes of `dns add` (each `--data` value +/// paired with `Ok(())` or an `Err(message)`), preserving input order. Returns +/// the success JSON payload when *every* record was created, or an error message +/// (a non-zero exit) with a per-record breakdown if any failed. Pure — no I/O — +/// so the aggregation and the success/failure decision are unit-testable. +fn summarize_add_outcomes( + domain: &str, + record_type: &str, + name: &str, + outcomes: Vec<(String, Result<(), String>)>, +) -> Result { + let mut results = Vec::with_capacity(outcomes.len()); + let mut failed = 0usize; + for (value, outcome) in &outcomes { + match outcome { + Ok(()) => results.push(json!({ "data": value, "status": "created" })), + Err(err) => { + failed += 1; + results.push(json!({ "data": value, "status": "failed", "error": err })); + } + } + } + let total = results.len(); + let created = total - failed; + + if failed > 0 { + let breakdown = outcomes + .iter() + .map(|(value, outcome)| match outcome { + Ok(()) => format!(" ✓ {value} — created"), + Err(err) => format!(" ✗ {value} — {err}"), + }) + .collect::>() + .join("\n"); + return Err(format!( + "added {created} of {total} DNS record(s) for {name} ({record_type}); {failed} \ + failed:\n{breakdown}\n\nRe-run `gddy dns add` with just the failed value(s), or \ + `gddy dns list --type {record_type} --name {name}` to review the current state." + )); + } + + Ok(json!({ + "domain": domain, + "type": record_type, + "name": name, + "created": created, + "failed": failed, + "results": results, + "action": "add", + })) +} + /// Build the v1 type+name-relative record bodies for `set` (the type and name /// come from the URL path) — one per `--data`. fn v1_set_records(data: &[String], opts: &RecordOptions) -> Vec { @@ -341,8 +405,8 @@ pub fn module() -> Module { ) .with_system("domain") .with_tier(Tier::Mutate) - .with_default_fields("domain,type,name,records") - .with_output_schema::() + .with_default_fields("domain,type,name,created,failed") + .with_output_schema::() .with_scopes(&[DOMAINS_DNS_UPDATE]), ), |ctx| async move { @@ -352,30 +416,34 @@ pub fn module() -> Module { let data = string_list(&ctx, "data"); let opts = RecordOptions::from_ctx(&ctx); let records = v3_records(&name, &record_type, &data, &opts); - let count = records.len(); let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; - // v3 creates a single record per call; add each in turn. - for record in records { - if let Err(e) = client + // v3 creates a single record per call. Attempt every record — + // don't stop at the first failure — and record each outcome, so a + // partial failure is explicit rather than leaving the user unsure + // which of the records were actually created. `data` and `records` + // are parallel (one record per `--data` value). + let mut outcomes = Vec::with_capacity(records.len()); + for (value, record) in data.iter().zip(records) { + let outcome = match client .create_dns_record() .zone(domain.as_str()) .body(record) .send() .await { - return Err(api_error("adding DNS record", debug, e).await); - } + Ok(_) => Ok(()), + Err(e) => Err(api_error("adding DNS record", debug, e).await.to_string()), + }; + outcomes.push((value.clone(), outcome)); } - Ok(CommandResult::new(json!({ - "domain": domain, - "type": record_type, - "name": name, - "records": count, - "action": "add", - }))) + // All-created → success payload; any failure → non-zero error + // with a per-record breakdown. + summarize_add_outcomes(&domain, &record_type, &name, outcomes) + .map(CommandResult::new) + .map_err(CliCoreError::message) }, )) // --- set (v1) --------------------------------------------------- @@ -711,4 +779,48 @@ mod tests { ); } } + + #[test] + fn dns_add_all_created_reports_each_record() { + let payload = summarize_add_outcomes( + "example.com", + "A", + "www", + vec![ + ("1.2.3.4".to_string(), Ok(())), + ("5.6.7.8".to_string(), Ok(())), + ], + ) + .expect("all created -> success payload"); + assert_eq!(payload["created"], 2); + assert_eq!(payload["failed"], 0); + let results = payload["results"].as_array().expect("results array"); + assert_eq!(results.len(), 2); + assert_eq!(results[0]["data"], "1.2.3.4"); + assert_eq!(results[0]["status"], "created"); + assert_eq!(results[1]["data"], "5.6.7.8"); + } + + #[test] + fn dns_add_partial_failure_is_an_error_with_per_record_breakdown() { + // Middle record fails: the command must NOT succeed, and the message must + // make clear which values were created and which failed (and why). + let err = summarize_add_outcomes( + "example.com", + "A", + "www", + vec![ + ("1.2.3.4".to_string(), Ok(())), + ("5.6.7.8".to_string(), Err("422 invalid data".to_string())), + ("9.9.9.9".to_string(), Ok(())), + ], + ) + .expect_err("any failure -> error"); + assert!(err.contains("added 2 of 3"), "{err}"); + assert!(err.contains("1 failed"), "{err}"); + // Per-record breakdown names both the created and the failed values. + assert!(err.contains("✓ 1.2.3.4"), "{err}"); + assert!(err.contains("✗ 5.6.7.8 — 422 invalid data"), "{err}"); + assert!(err.contains("✓ 9.9.9.9"), "{err}"); + } } diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs index ae9a557..5fef901 100644 --- a/rust/src/domain/common.rs +++ b/rust/src/domain/common.rs @@ -4,7 +4,7 @@ use cli_engine::{CliCoreError, CommandContext, Credential, Result}; -use crate::{auth::SSO_KEY_PROVIDER, environments}; +use crate::environments; use domains_client::types; @@ -57,19 +57,8 @@ pub(super) fn format_money(money: &types::SimpleMoney) -> Option { )) } -/// Pick the `Authorization` header value for a resolved credential: the `sso-key` -/// scheme for the [`SSO_KEY_PROVIDER`] bypass path, otherwise an OAuth `Bearer` -/// token. Pure so the scheme selection is unit-testable without a full context. -fn authorization_header(provider: &str, token: &str) -> String { - if provider == SSO_KEY_PROVIDER { - format!("sso-key {token}") - } else { - format!("Bearer {token}") - } -} - -/// Build a Domains API client for the active environment, choosing the auth -/// scheme from the resolved credential (sso-key for the bypass path, else Bearer). +/// Build a Domains API client for the active environment, authenticating with +/// the resolved OAuth bearer token. pub(crate) async fn make_client(ctx: &CommandContext) -> Result { let cred = ctx.credential().await?; make_client_with_cred(&ctx.middleware.env, &cred) @@ -83,7 +72,7 @@ pub(crate) fn make_client_with_cred( cred: &Credential, ) -> Result { let domains = environments::resolve_domains(env).map_err(map_env_err)?; - let authorization = authorization_header(&cred.provider, &cred.token); + let authorization = format!("Bearer {}", cred.token); let request_id = uuid::Uuid::new_v4().to_string(); domains_client::client_with_auth(&domains.base_url, &authorization, USER_AGENT, &request_id) .map_err(|e| CliCoreError::message(format!("failed to build domains client: {e}"))) @@ -353,15 +342,6 @@ mod tests { assert_eq!(currency_decimals("ZZZ"), 2); // unknown → default 2 } - #[test] - fn authorization_header_picks_scheme_from_provider() { - assert_eq!( - authorization_header(SSO_KEY_PROVIDER, "KEY:SECRET"), - "sso-key KEY:SECRET" - ); - assert_eq!(authorization_header("godaddy", "tok123"), "Bearer tok123"); - } - #[test] fn payment_required_error_points_to_payments_add() { let msg = format_api_error( diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index a34581d..689ed5a 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -8,11 +8,10 @@ //! * **v1** — `list` (the shopper's domains) and `agreements` (a TLD's legal //! agreements), which v3 does not yet serve. //! -//! Both accept either an sso-key API key or an OAuth bearer token; the scheme is -//! chosen from the credential the -//! [`CompositeAuthProvider`](crate::auth::CompositeAuthProvider) returns. The v3 -//! registration flow (`purchase`) requires an OAuth customer token (it mints a -//! single-use quote token, records consent, and registers under the customer). +//! All of these authenticate with an OAuth bearer token from +//! [`GoDaddyAuthProvider`](crate::auth::GoDaddyAuthProvider). The v3 registration +//! flow (`purchase`) requires an OAuth customer token (it mints a single-use +//! quote token, records consent, and registers under the customer). //! //! Each subcommand lives in its own module; [`common`] holds the shared //! authenticated client, money formatting, and API-error rendering. diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index 7c6b985..fef50f8 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -9,7 +9,6 @@ use serde_json::json; use domains_client::types; use super::common::{api_error, make_client_with_cred}; -use crate::auth::SSO_KEY_PROVIDER; use crate::output_schema::output_schema; use crate::quote_cache; use crate::scopes::{DOMAINS_CREATE, DOMAINS_READ}; @@ -200,18 +199,10 @@ pub(super) fn command() -> RuntimeCommandSpec { .to_owned(); let debug = !ctx.middleware.debug.is_empty(); - // The v3 register endpoint needs the customer identity from an OAuth - // token (for the consent principal); sso-key auth can never satisfy - // it. Resolve auth *before* consuming the cached quote so a failed - // login never touches the cache. + // The v3 register endpoint needs the customer identity from the OAuth + // token (for the consent principal). Resolve auth *before* consuming + // the cached quote so a failed login never touches the cache. let cred = ctx.credential().await?; - if cred.provider == SSO_KEY_PROVIDER { - return Err(CliCoreError::message( - "`domain purchase` uses the v3 registration API, which requires OAuth \ - authentication; this environment is configured for an sso-key. Re-run \ - against an OAuth environment.", - )); - } let principal = consent_principal(&cred)?; // Load the quote the user reviewed. Read-only: the entry is only @@ -232,6 +223,14 @@ pub(super) fn command() -> RuntimeCommandSpec { on the same machine within the token's ~10-minute lifetime.", )); } + quote_cache::Lookup::NoConfigDir => { + return Err(CliCoreError::message( + "could not locate a config directory to read the quote cache from. \ + `domain purchase` needs the local quote written by `domain quote`; \ + ensure a home/config directory is available (e.g. set HOME or \ + XDG_CONFIG_HOME) and re-run `gddy domain quote `.", + )); + } }; let domain = cached.domain.clone(); @@ -293,7 +292,12 @@ pub(super) fn command() -> RuntimeCommandSpec { }; let client = make_client_with_cred(&ctx.middleware.env, &cred)?; - let idempotency_key = uuid::Uuid::new_v4().to_string(); + // Reuse the quote's idempotency key across attempts (older cache + // entries may lack one — fall back to a fresh key). + let idempotency_key = cached + .idempotency_key + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let accepted = match client .register_domain() .idempotency_key(idempotency_key) @@ -341,6 +345,30 @@ pub(super) fn command() -> RuntimeCommandSpec { } } + // A terminal FAILED status means the registration did not complete — + // report it as an error (non-zero exit), not a success payload. + if status == "FAILED" { + let op = operation_id + .as_ref() + .map(|o| o.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + return Err(CliCoreError::message(format!( + "registration for {domain} failed (operation {op}); no domain was registered. \ + Check `gddy domain get {domain}`, then re-quote to try again." + ))); + } + // Bounded polling gave up before a terminal state (e.g. still + // SUBMITTED/PENDING). It may still complete server-side, so tell the + // user how to check rather than implying it finished. + if operation_id.is_some() && !is_terminal_status(&status) { + tracing::info!( + %domain, + %status, + "registration still in progress after polling; check later with \ + `gddy domain get {domain}`" + ); + } + let result = json!({ "domain": domain, "status": status, diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 7e6ec50..660b846 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -303,6 +303,10 @@ pub(super) fn command() -> RuntimeCommandSpec { .map(str::to_owned), expires_at: quote.expires_at.as_ref().map(|e| e.to_string()), profile: Some(profile_json), + // Mint the register idempotency key now, so every `purchase` + // attempt for this token reuses it (a retry after a lost + // response can't double-charge). + idempotency_key: Some(uuid::Uuid::new_v4().to_string()), }; if let Err(e) = quote_cache::save(&token, cached) { // Non-fatal: the quote is still shown, but purchase won't diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index 4bdb38f..44affbd 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -64,10 +64,7 @@ const BUILTINS: &[Builtin] = &[ ]; /// A fully-resolved environment: everything needed to talk to it. -/// -/// `Debug` is hand-written (not derived) to redact the sso-key fields — see the -/// impl below — so a stray `{:?}`/`?env` never leaks credentials. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ResolvedEnv { pub name: String, pub api_url: String, @@ -82,62 +79,12 @@ pub struct ResolvedEnv { /// Defaults to `account.godaddy.com` for prod and `account.{env}-godaddy.com` /// for other environments; overridable via `_ACCOUNT_URL` or local config. pub account_url: String, - /// Optional sso-key for the domain endpoints (which accept either sso-key or - /// OAuth). When both are set, `domain:*` commands authenticate with - /// `Authorization: sso-key :`; when absent they use the OAuth - /// credential. - pub api_key: Option, - pub api_secret: Option, } /// The domain-command view of a resolved environment. -/// -/// `Debug` is hand-written (see below) to redact the sso-key fields. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct ResolvedDomains { pub base_url: String, - pub api_key: Option, - pub api_secret: Option, -} - -/// `Debug` wrapper that never prints a secret's value: shows `Some()` -/// or `None`, so credential-bearing structs can keep a useful `Debug` without -/// risking leakage via `{:?}` in logs/errors. -struct Redacted<'a>(&'a Option); - -impl std::fmt::Debug for Redacted<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self.0 { - Some(_) => f.write_str("Some()"), - None => f.write_str("None"), - } - } -} - -impl std::fmt::Debug for ResolvedEnv { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ResolvedEnv") - .field("name", &self.name) - .field("api_url", &self.api_url) - .field("client_id", &self.client_id) - .field("auth_url", &self.auth_url) - .field("token_url", &self.token_url) - .field("domains_api_url", &self.domains_api_url) - .field("account_url", &self.account_url) - .field("api_key", &Redacted(&self.api_key)) - .field("api_secret", &Redacted(&self.api_secret)) - .finish() - } -} - -impl std::fmt::Debug for ResolvedDomains { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ResolvedDomains") - .field("base_url", &self.base_url) - .field("api_key", &Redacted(&self.api_key)) - .field("api_secret", &Redacted(&self.api_secret)) - .finish() - } } /// Schema of the local environments file (see [`environments_path`]). @@ -147,7 +94,7 @@ pub struct EnvironmentsFile { pub environments: BTreeMap, } -#[derive(Clone, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct EnvEntry { pub api_url: String, #[serde(default)] @@ -162,28 +109,6 @@ pub struct EnvEntry { /// Override base URL for the account management site (see [`ResolvedEnv::account_url`]). #[serde(default)] pub account_url: Option, - /// sso-key credentials for domain endpoints (see [`ResolvedEnv::api_key`]). - #[serde(default)] - pub api_key: Option, - #[serde(default)] - pub api_secret: Option, -} - -// Hand-written so the sso-key fields are redacted (keeps `EnvironmentsFile`'s -// derived `Debug` working without risking credential leakage). -impl std::fmt::Debug for EnvEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EnvEntry") - .field("api_url", &self.api_url) - .field("client_id", &self.client_id) - .field("auth_url", &self.auth_url) - .field("token_url", &self.token_url) - .field("domains_api_url", &self.domains_api_url) - .field("account_url", &self.account_url) - .field("api_key", &Redacted(&self.api_key)) - .field("api_secret", &Redacted(&self.api_secret)) - .finish() - } } #[derive(Debug, thiserror::Error)] @@ -253,13 +178,6 @@ fn clean_url(raw: &str) -> Option { (!host.is_empty()).then(|| trimmed.to_owned()) } -/// Trims a candidate secret/value and returns it only if non-empty. Keeps the -/// key/secret resolution consistent with `clean_url`'s "blank never clobbers". -fn non_empty(raw: &str) -> Option { - let trimmed = raw.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_owned()) -} - /// Path to the local environments config file, if a config dir can be resolved. /// /// Uses `dirs::config_dir()` which honors `XDG_CONFIG_HOME` (→ `~/.config`) on @@ -340,7 +258,7 @@ fn resolve_with( } // Layer 3: per-env `_*` overrides (highest precedence). Empty values - // are ignored (clean_url for URLs, non_empty for the key/secret). + // are ignored (clean_url normalizes URLs and drops blanks). let prefix = env_prefix(name); if let Some(url) = var(&format!("{prefix}_API_URL")).and_then(|v| clean_url(&v)) { api_url = Some(url); @@ -352,29 +270,6 @@ fn resolve_with( account_url = Some(url); } - // The sso-key is a (key, secret) pair; resolve it atomically from a single - // layer — the env-var pair wins over the file pair — so we never mix layers - // (e.g. an env-var key with a file-provided secret), which would send a bogus - // `sso-key key:secret` and yield confusing 401s. A partial pair (only one of - // the two present in a layer) yields no sso-key, so domain commands fall back - // to OAuth. - let entry = file.environments.get(name); - let (api_key, api_secret) = match ( - var(&format!("{prefix}_API_KEY")).and_then(|v| non_empty(&v)), - var(&format!("{prefix}_API_SECRET")).and_then(|v| non_empty(&v)), - ) { - (Some(k), Some(s)) => (Some(k), Some(s)), - _ => match ( - entry.and_then(|e| e.api_key.as_deref()).and_then(non_empty), - entry - .and_then(|e| e.api_secret.as_deref()) - .and_then(non_empty), - ) { - (Some(k), Some(s)) => (Some(k), Some(s)), - _ => (None, None), - }, - }; - // api_url is already trimmed/normalized by clean_url (and built-ins carry no // trailing slash), so callers concatenating paths never produce `//`. let api_url = api_url.ok_or_else(|| EnvError::Unknown { @@ -396,8 +291,6 @@ fn resolve_with( token_url, domains_api_url, account_url, - api_key, - api_secret, }) } @@ -447,14 +340,12 @@ pub fn resolve(name: &str) -> Result { } } -/// Resolve the domain-command view of an environment: the domains base URL and -/// any sso-key credentials. Thin wrapper over [`resolve`]. +/// Resolve the domain-command view of an environment: its domains base URL. +/// Thin wrapper over [`resolve`]. pub fn resolve_domains(name: &str) -> Result { let env = resolve(name)?; Ok(ResolvedDomains { base_url: env.domains_api_url, - api_key: env.api_key, - api_secret: env.api_secret, }) } @@ -511,8 +402,6 @@ mod tests { token_url: None, domains_api_url: None, account_url: None, - api_key: None, - api_secret: None, } } @@ -589,8 +478,6 @@ mod tests { token_url: Some("https://auth.example.invalid/token".to_owned()), domains_api_url: None, account_url: None, - api_key: None, - api_secret: None, }, ); let env = resolve_with("dev", &file, no_vars).expect("dev resolves"); @@ -655,8 +542,6 @@ mod tests { token_url: Some(" ".to_owned()), // blank domains_api_url: None, account_url: None, - api_key: None, - api_secret: None, }, ); let env = resolve_with("dev", &file, no_vars).expect("dev resolves"); @@ -739,11 +624,10 @@ mod tests { let file = EnvironmentsFile::default(); let env = resolve_with("prod", &file, no_vars).expect("prod resolves"); assert_eq!(env.domains_api_url, env.api_url); - assert!(env.api_key.is_none() && env.api_secret.is_none()); } #[test] - fn domains_url_and_sso_key_from_local_config() { + fn domains_url_from_local_config() { let mut file = EnvironmentsFile::default(); file.environments.insert( "dev".to_owned(), @@ -754,20 +638,16 @@ mod tests { token_url: None, domains_api_url: Some("https://domains.dev.example.invalid".to_owned()), account_url: None, - api_key: Some("KEY".to_owned()), - api_secret: Some("SECRET".to_owned()), }, ); let env = resolve_with("dev", &file, no_vars).expect("dev resolves"); assert_eq!(env.domains_api_url, "https://domains.dev.example.invalid"); - assert_eq!(env.api_key.as_deref(), Some("KEY")); - assert_eq!(env.api_secret.as_deref(), Some("SECRET")); // api_url is unaffected by the domains override. assert_eq!(env.api_url, "https://dev.example.invalid"); } #[test] - fn sso_key_and_domains_url_env_vars_override_config() { + fn domains_url_env_var_overrides_config() { let mut file = EnvironmentsFile::default(); file.environments.insert( "dev".to_owned(), @@ -778,59 +658,14 @@ mod tests { token_url: None, domains_api_url: Some("https://from-file.example.invalid".to_owned()), account_url: None, - api_key: Some("file-key".to_owned()), - api_secret: None, }, ); let var = |k: &str| match k { "DEV_DOMAINS_API_URL" => Some("https://from-env.example.invalid".to_owned()), - "DEV_API_KEY" => Some("env-key".to_owned()), - "DEV_API_SECRET" => Some("env-secret".to_owned()), _ => None, }; let env = resolve_with("dev", &file, var).expect("dev resolves"); assert_eq!(env.domains_api_url, "https://from-env.example.invalid"); - assert_eq!(env.api_key.as_deref(), Some("env-key")); // env beats file - assert_eq!(env.api_secret.as_deref(), Some("env-secret")); - } - - #[test] - fn blank_sso_key_env_var_does_not_clobber_config() { - let mut file = EnvironmentsFile::default(); - let mut e = entry("https://dev.example.invalid"); - e.api_key = Some("file-key".to_owned()); - e.api_secret = Some("file-secret".to_owned()); - file.environments.insert("dev".to_owned(), e); - // A blank env-var key is ignored, so the complete file pair survives. - let var = |k: &str| (k == "DEV_API_KEY").then(|| " ".to_owned()); - let env = resolve_with("dev", &file, var).expect("dev resolves"); - assert_eq!(env.api_key.as_deref(), Some("file-key")); - assert_eq!(env.api_secret.as_deref(), Some("file-secret")); - } - - #[test] - fn sso_key_pair_is_resolved_atomically_per_layer() { - // File has a complete pair; env supplies only the key. The env layer is - // incomplete, so we must NOT mix (env-key + file-secret) — the complete - // file pair is used instead. - let mut file = EnvironmentsFile::default(); - let mut e = entry("https://dev.example.invalid"); - e.api_key = Some("file-key".to_owned()); - e.api_secret = Some("file-secret".to_owned()); - file.environments.insert("dev".to_owned(), e); - let key_only = |k: &str| (k == "DEV_API_KEY").then(|| "env-key".to_owned()); - let env = resolve_with("dev", &file, key_only).expect("dev resolves"); - assert_eq!(env.api_key.as_deref(), Some("file-key")); - assert_eq!(env.api_secret.as_deref(), Some("file-secret")); - - // A partial pair within a single layer (file key, no secret) yields no - // sso-key at all, so domain commands fall back to OAuth. - let mut file2 = EnvironmentsFile::default(); - let mut e2 = entry("https://dev.example.invalid"); - e2.api_key = Some("lonely-key".to_owned()); - file2.environments.insert("dev".to_owned(), e2); - let env2 = resolve_with("dev", &file2, no_vars).expect("dev resolves"); - assert!(env2.api_key.is_none() && env2.api_secret.is_none()); } #[test] diff --git a/rust/src/main.rs b/rust/src/main.rs index 2be7d49..a14e31b 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -29,7 +29,7 @@ async fn main() -> ExitCode { .with_writer(std::io::stderr) .init(); - let auth_provider = Arc::new(auth::CompositeAuthProvider::new()); + let auth_provider = Arc::new(auth::GoDaddyAuthProvider::new()); let cli = Cli::new( CliConfig::new("gddy", "GoDaddy developer CLI", "gddy") diff --git a/rust/src/quote_cache.rs b/rust/src/quote_cache.rs index 522f2ea..b15b7a8 100644 --- a/rust/src/quote_cache.rs +++ b/rust/src/quote_cache.rs @@ -57,6 +57,13 @@ pub struct CachedQuote { /// carried no profile. #[serde(default)] pub profile: Option, + /// Idempotency key for the `register` call, minted once at quote time and + /// reused on every purchase attempt for this token. Reusing it means a retry + /// after a lost/timed-out response is recognized server-side as the same + /// request rather than risking a second charge. `None` only for entries + /// written by an older CLI, where `purchase` falls back to generating one. + #[serde(default)] + pub idempotency_key: Option, } /// The result of looking a token up in the cache. @@ -68,6 +75,9 @@ pub enum Lookup { Expired, /// No quote for this token on this machine. Missing, + /// No config directory could be resolved, so the cache can't be located at + /// all (distinct from a genuinely absent quote — e.g. a stripped container). + NoConfigDir, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -142,7 +152,7 @@ fn save_at( /// actually succeeds, so an aborted/gated attempt leaves the quote reusable. pub fn get(token: &str) -> Lookup { let Some(path) = quotes_path() else { - return Lookup::Missing; + return Lookup::NoConfigDir; }; let now = chrono::Utc::now(); // Best-effort: drop any expired entries (whose serialized profile may hold @@ -211,6 +221,7 @@ mod tests { currency: Some("USD".to_owned()), expires_at: expires.map(str::to_owned), profile: None, + idempotency_key: Some("idem-test".to_owned()), } }