@aoxborrow/registrar-client 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -387,9 +387,9 @@ a provider-specific workflow. Transfers accept `authCode` and optional `privacy`
387
387
  explicit `years`, `contacts` or `autoRenew` are rejected before submission because
388
388
  Core cannot apply them in its transfer request. Update settings after completion.
389
389
 
390
- Every mutation disables automatic retries, including per-request retry overrides.
391
- Transport/parse failures after a purchase report an unknown outcome: inspect
392
- Name.com orders and domain/transfer status before retrying. An accepted order is
390
+ Mutations follow the shared [retry rules](#retries): a request Name.com may have
391
+ received is never re-sent. Transport/parse failures after a purchase report an
392
+ unknown outcome: inspect Name.com orders and domain/transfer status before retrying. An accepted order is
393
393
  reported as accepted, not proof of final registry completion. Read operations
394
394
  retain the shared retry policy and honor `Retry-After` / `X-RateLimit-Reset`.
395
395
  HTTP errors retain typed status information but omit provider response bodies,
@@ -482,8 +482,12 @@ implementations**:
482
482
  Extend `BaseRegistrar`, pass a `baseUrl` + auth headers to `super()`, add the
483
483
  static `displayName` / `configFields` / `helpText` metadata, override the
484
484
  operations the API supports (mapping payloads to the shared types in
485
- `src/types.ts`), and add the class to `src/registrars/index.ts`. The existing
486
- providers under `src/registrars/` are working references.
485
+ `src/types.ts`), and add the class to `src/registrars/index.ts`. Forward the
486
+ `opts` you are given into every `this.http` request: they carry the timeout and
487
+ signal, and tell the [retry rules](#retries) whether the call reads or writes.
488
+ For a REST API set `static safeMethods = REST_SAFE_METHODS`; otherwise wrap any
489
+ lookup a write performs first in `asRead(opts)`. The existing providers under
490
+ `src/registrars/` are working references.
487
491
 
488
492
  **New providers are welcome** — if you've wired up a registrar, open a PR.
489
493
 
@@ -493,6 +497,67 @@ All failures throw a typed subclass of `RegistrarError` (`AuthenticationError`,
493
497
  `NotFoundError`, `RateLimitError`, `TimeoutError`, `NotImplementedError`, …),
494
498
  each carrying an HTTP-style `status`.
495
499
 
500
+ ## Retries
501
+
502
+ Requests retry with exponential backoff (`retries`, default 2). What may be
503
+ re-sent depends on where the failure happened and on whether the call reads or
504
+ writes, and is the same for every provider:
505
+
506
+ HTTP 429 honors valid `Retry-After` delay-seconds and HTTP dates. Missing or
507
+ invalid headers use the provider's fallback or the configured backoff: Dynadot
508
+ waits a conservative 60 seconds, and Name.com waits until its
509
+ `X-RateLimit-Reset` time. An explicit zero or past date remains
510
+ an immediate retry instruction from the server.
511
+
512
+ | Failure | Read | Write |
513
+ | -------------------------------------------------------------------------- | ----- | -------------------------------------- |
514
+ | The registrar never saw it: DNS failure, connection refused, TLS handshake | Retry | Retry |
515
+ | `429`, honoring `Retry-After` | Retry | Retry |
516
+ | Outcome unknown: timeout, connection dropped mid-response, `5xx` | Retry | **Not re-sent.** `OutcomeUnknownError` |
517
+ | Any other response | Fail | Fail |
518
+
519
+ Reads and writes are told apart by feature, not by HTTP method (`FEATURE_CALLS`
520
+ lists every one). The method is no guide: Namecheap sends writes as `GET`, and
521
+ Porkbun sends reads as `POST`. `getAuthCode` counts as a write because some
522
+ registrars regenerate the code.
523
+
524
+ A write often reads first: current DNS records, a price, a zone id. Those
525
+ lookups changed nothing, so they are retried like any read and never reported as
526
+ an unknown outcome. REST providers get this from the HTTP method
527
+ (`static safeMethods = REST_SAFE_METHODS` treats `GET` and `HEAD` as reads);
528
+ providers whose method means nothing wrap the lookup's options in `asRead(opts)`.
529
+
530
+ A write whose outcome is unknown may have been applied, and a second renewal
531
+ charges twice. So the library reports it and leaves the decision to you: re-read
532
+ the domain, then retry if nothing changed. Providers that throw raise
533
+ `OutcomeUnknownError`; providers that return an `OperationResult` set
534
+ `outcome: 'unknown'` on it:
535
+
536
+ ```ts
537
+ const result = await client.renewDomain('example.com', 1);
538
+ if (!result.success && result.outcome === 'unknown') {
539
+ const domain = await client.getDomain('example.com'); // did it go through?
540
+ }
541
+ ```
542
+
543
+ Only positive evidence counts as "never sent": a pre-connection error code from
544
+ Node's `fetch`, or an error a custom transport marked with `markNotSent`.
545
+ Browsers and Workers report network failures opaquely, so there they count as
546
+ unknown.
547
+
548
+ ### Custom transport
549
+
550
+ Pass `fetch` to route every request through your own transport, such as a
551
+ proxy:
552
+
553
+ ```ts
554
+ const registrar = createRegistrar('namecheap', credentials, { fetch: proxiedFetch });
555
+ ```
556
+
557
+ If the transport can fail before the request leaves, for instance a proxy that
558
+ refuses the tunnel, throw `markNotSent(error)` so the failure is retried even
559
+ for writes.
560
+
496
561
  ## Security
497
562
 
498
563
  Credentials are sent as request **headers** wherever the registrar's API
package/dist/index.cjs CHANGED
@@ -57,11 +57,61 @@ var TimeoutError = class extends RetryableRegistrarError {
57
57
  var ConnectionError = class extends RetryableRegistrarError {
58
58
  status = 503;
59
59
  // Service Unavailable
60
- constructor(message) {
60
+ // true when the failure is known to have happened before the request left:
61
+ // DNS, connection refused, TLS handshake, or a proxy that never opened its
62
+ // tunnel. The registrar cannot have acted on it, so it is safe to re-send
63
+ // even for a write.
64
+ notSent;
65
+ constructor(message, details = {}) {
61
66
  super(message);
62
67
  this.name = "ConnectionError";
68
+ this.notSent = details.notSent ?? false;
69
+ }
70
+ };
71
+ var OutcomeUnknownError = class extends RegistrarError {
72
+ status = 504;
73
+ // Gateway Timeout
74
+ feature;
75
+ cause;
76
+ constructor(message, details = {}) {
77
+ super(message);
78
+ this.name = "OutcomeUnknownError";
79
+ this.feature = details.feature;
80
+ this.cause = details.cause;
63
81
  }
64
82
  };
83
+ var NOT_SENT = /* @__PURE__ */ Symbol.for("registrar-client.notSent");
84
+ function markNotSent(error) {
85
+ if (typeof error === "object" && error !== null) {
86
+ Object.defineProperty(error, NOT_SENT, { value: true, enumerable: false });
87
+ }
88
+ return error;
89
+ }
90
+ var PRE_SEND_CODES = /* @__PURE__ */ new Set([
91
+ "ENOTFOUND",
92
+ "EAI_AGAIN",
93
+ "ECONNREFUSED",
94
+ "EHOSTUNREACH",
95
+ "ENETUNREACH",
96
+ "EADDRNOTAVAIL",
97
+ "UND_ERR_CONNECT_TIMEOUT",
98
+ "CERT_HAS_EXPIRED",
99
+ "CERT_NOT_YET_VALID",
100
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
101
+ "SELF_SIGNED_CERT_IN_CHAIN",
102
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
103
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
104
+ "ERR_TLS_CERT_ALTNAME_INVALID"
105
+ ]);
106
+ function wasNotSent(error) {
107
+ for (let e = error, depth = 0; e && typeof e === "object" && depth < 5; depth++) {
108
+ if (e[NOT_SENT] === true) return true;
109
+ const code = e.code;
110
+ if (typeof code === "string" && PRE_SEND_CODES.has(code)) return true;
111
+ e = e.cause;
112
+ }
113
+ return false;
114
+ }
65
115
  var AuthenticationError = class extends RegistrarError {
66
116
  status = 401;
67
117
  // Unauthorized
@@ -228,6 +278,9 @@ function createDomain(data = {}) {
228
278
  deleted: data.deleted ?? false
229
279
  };
230
280
  }
281
+ function asRead(opts) {
282
+ return { ...opts, call: { ...opts?.call, intent: "read" } };
283
+ }
231
284
 
232
285
  // src/client.ts
233
286
  var RegistrarClient = class {
@@ -378,6 +431,7 @@ var DEFAULT_OPTIONS = {
378
431
  var USER_AGENT = "@aoxborrow/registrar-client";
379
432
 
380
433
  // src/http.ts
434
+ var REST_SAFE_METHODS = ["GET", "HEAD"];
381
435
  var HttpClient = class {
382
436
  constructor(config) {
383
437
  this.config = config;
@@ -401,7 +455,20 @@ var HttpClient = class {
401
455
  async requestText(req) {
402
456
  return this.withRetries(req, () => this.send(req));
403
457
  }
404
- // shared retry-with-backoff wrapper around a single-attempt operation
458
+ // Shared retry-with-backoff wrapper around a single-attempt operation.
459
+ //
460
+ // What may be re-sent depends on where the failure happened and on whether
461
+ // the call reads or writes:
462
+ //
463
+ // failure read write
464
+ // never sent (DNS, refused, TLS, proxy stage) retry retry
465
+ // 429 retry retry
466
+ // unknown (timeout, dropped mid-response, 5xx) retry OutcomeUnknownError
467
+ // any other response fail fail
468
+ //
469
+ // A write whose outcome is unknown may have been applied, so it is never
470
+ // re-sent: a second renewal charges twice, a second record-create leaves a
471
+ // duplicate. The caller re-reads the domain instead.
405
472
  async withRetries(req, attemptFn) {
406
473
  const retries = req.retries ?? this.config.options.retries;
407
474
  const backoff = req.backoff ?? this.config.options.backoff;
@@ -411,6 +478,14 @@ var HttpClient = class {
411
478
  return await attemptFn();
412
479
  } catch (error) {
413
480
  lastError = toRegistrarError(error);
481
+ if (this.intentOf(req) === "write" && isOutcomeUnknown(lastError)) {
482
+ const unknown = new OutcomeUnknownError(
483
+ `The registrar did not confirm this change, so it may or may not have been applied. Check the domain before trying again. (${lastError.message})`,
484
+ { feature: req.call?.feature, cause: lastError }
485
+ );
486
+ if (req.call) req.call.outcomeUnknown = unknown;
487
+ throw unknown;
488
+ }
414
489
  if (attempt >= retries || !lastError.shouldRetry()) {
415
490
  throw lastError;
416
491
  }
@@ -420,6 +495,14 @@ var HttpClient = class {
420
495
  }
421
496
  throw lastError ?? new RegistrarError("Request failed");
422
497
  }
498
+ // Whether a request only reads. A method the API reserves for reads wins;
499
+ // otherwise it is whatever the feature being served is, and a request that
500
+ // says nothing is assumed to write.
501
+ intentOf(req) {
502
+ const method = (req.method ?? "GET").toUpperCase();
503
+ if (this.config.safeMethods?.includes(method)) return "read";
504
+ return req.call?.intent ?? "write";
505
+ }
423
506
  // build the full URL for a request, applying query parameters
424
507
  buildUrl(req) {
425
508
  let base;
@@ -462,7 +545,8 @@ var HttpClient = class {
462
545
  try {
463
546
  let response;
464
547
  try {
465
- response = await fetch(url, {
548
+ const doFetch = req.fetch ?? this.config.options.fetch ?? fetch;
549
+ response = await doFetch(url, {
466
550
  method: req.method ?? "GET",
467
551
  headers,
468
552
  body: req.body !== void 0 ? JSON.stringify(req.body) : void 0,
@@ -473,7 +557,9 @@ var HttpClient = class {
473
557
  if (externalSignal?.aborted) throw new AbortError("Request was aborted");
474
558
  throw new TimeoutError(`Request to '${url}' timed out after ${timeout}ms`);
475
559
  }
476
- throw new ConnectionError(`Failed to reach '${url}': ${errorMessage(error)}`);
560
+ throw new ConnectionError(`Failed to reach '${url}': ${errorMessage(error)}`, {
561
+ notSent: wasNotSent(error)
562
+ });
477
563
  }
478
564
  if (!response.ok) {
479
565
  throw await this.toStatusError(response, url);
@@ -481,7 +567,17 @@ var HttpClient = class {
481
567
  if (response.status === 204) {
482
568
  return "";
483
569
  }
484
- return await response.text();
570
+ try {
571
+ return await response.text();
572
+ } catch (error) {
573
+ if (isAbortError(error)) {
574
+ if (externalSignal?.aborted) throw new AbortError("Request was aborted");
575
+ throw new TimeoutError(`Response from '${url}' timed out after ${timeout}ms`);
576
+ }
577
+ throw new ConnectionError(
578
+ `Connection to '${url}' was lost while reading the response: ${errorMessage(error)}`
579
+ );
580
+ }
485
581
  } finally {
486
582
  clearTimeout(timeoutId);
487
583
  externalSignal?.removeEventListener("abort", onAbort);
@@ -503,8 +599,8 @@ var HttpClient = class {
503
599
  case 404:
504
600
  return new NotFoundError(message);
505
601
  case 429: {
506
- const retryAfter = Number(response.headers.get("retry-after"));
507
- return new RateLimitError(message, Number.isFinite(retryAfter) ? retryAfter : void 0);
602
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after")) ?? this.config.rateLimitFallbackSeconds;
603
+ return new RateLimitError(message, retryAfter);
508
604
  }
509
605
  default: {
510
606
  if (response.status >= 500) {
@@ -517,6 +613,23 @@ var HttpClient = class {
517
613
  }
518
614
  }
519
615
  };
616
+ function parseRetryAfter(value) {
617
+ const text2 = value?.trim();
618
+ if (!text2) return void 0;
619
+ if (/^\d+$/.test(text2)) {
620
+ const seconds = Number(text2);
621
+ return Number.isSafeInteger(seconds) ? seconds : void 0;
622
+ }
623
+ const zonedDate = /^(?:[a-z]{3}, \d{2} [a-z]{3} \d{4}|[a-z]+, \d{2}-[a-z]{3}-\d{2}) \d{2}:\d{2}:\d{2} GMT$/i;
624
+ const asctimeDate = /^[a-z]{3} [a-z]{3} (?: \d|\d{2}) \d{2}:\d{2}:\d{2} \d{4}$/i;
625
+ if (!zonedDate.test(text2) && !asctimeDate.test(text2)) return void 0;
626
+ const date = Date.parse(asctimeDate.test(text2) ? `${text2} GMT` : text2);
627
+ return Number.isFinite(date) ? Math.max(0, Math.ceil((date - Date.now()) / 1e3)) : void 0;
628
+ }
629
+ function isOutcomeUnknown(error) {
630
+ if (error instanceof TimeoutError || error instanceof InvalidResponseError) return true;
631
+ return error instanceof ConnectionError && !error.notSent;
632
+ }
520
633
  function isAbortError(error) {
521
634
  return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
522
635
  }
@@ -613,6 +726,33 @@ var CORE_FEATURE_SET = new Set(CORE_FEATURES);
613
726
  function isCoreFeature(feature) {
614
727
  return CORE_FEATURE_SET.has(feature);
615
728
  }
729
+ var FEATURE_CALLS = {
730
+ testConnection: { intent: "read", optsIndex: 0 },
731
+ listDomains: { intent: "read", optsIndex: 0 },
732
+ getDomain: { intent: "read", optsIndex: 1 },
733
+ checkAvailability: { intent: "read", optsIndex: 1 },
734
+ getPricing: { intent: "read", optsIndex: 1 },
735
+ registerDomain: { intent: "write", optsIndex: 2 },
736
+ renewDomain: { intent: "write", optsIndex: 2 },
737
+ setAutoRenew: { intent: "write", optsIndex: 2 },
738
+ transferIn: { intent: "write", optsIndex: 2 },
739
+ updateNameservers: { intent: "write", optsIndex: 2 },
740
+ getNameservers: { intent: "read", optsIndex: 1 },
741
+ lockDomain: { intent: "write", optsIndex: 1 },
742
+ unlockDomain: { intent: "write", optsIndex: 1 },
743
+ setPrivacy: { intent: "write", optsIndex: 2 },
744
+ getContacts: { intent: "read", optsIndex: 1 },
745
+ updateContacts: { intent: "write", optsIndex: 2 },
746
+ getDnsRecords: { intent: "read", optsIndex: 1 },
747
+ setDnsRecords: { intent: "write", optsIndex: 2 },
748
+ getAuthCode: { intent: "write", optsIndex: 1 },
749
+ getDnssec: { intent: "read", optsIndex: 1 },
750
+ disableDnssec: { intent: "write", optsIndex: 1 },
751
+ getEmailForwarding: { intent: "read", optsIndex: 1 },
752
+ setEmailForwarding: { intent: "write", optsIndex: 2 },
753
+ getDomainForwarding: { intent: "read", optsIndex: 1 },
754
+ setDomainForwarding: { intent: "write", optsIndex: 2 }
755
+ };
616
756
 
617
757
  // src/registrar.ts
618
758
  function selectBaseUrl(registrar, environment, urls) {
@@ -638,6 +778,9 @@ var BaseRegistrar = class {
638
778
  static get features() {
639
779
  return [...CORE_FEATURES, ...this.extendedFeatures];
640
780
  }
781
+ // HTTP methods this provider's API reserves for reads; see
782
+ // `HttpClientConfig.safeMethods`. REST providers set `REST_SAFE_METHODS`.
783
+ static safeMethods = [];
641
784
  // instance mirror of the static `features`
642
785
  get features() {
643
786
  return this.constructor.features;
@@ -656,7 +799,39 @@ var BaseRegistrar = class {
656
799
  this.credentials = credentials;
657
800
  this.environment = environment;
658
801
  this.options = { ...DEFAULT_OPTIONS, ...clientOptions };
659
- this.http = new HttpClient({ ...httpConfig, options: this.options });
802
+ this.http = new HttpClient({
803
+ safeMethods: this.constructor.safeMethods,
804
+ ...httpConfig,
805
+ options: this.options
806
+ });
807
+ this.threadFeatureCalls();
808
+ }
809
+ // Wrap each feature method so its `RequestOptions` carry a `FeatureCall`:
810
+ // the read/write intent the HTTP layer needs to decide what may be re-sent,
811
+ // and a slot where it reports a write whose outcome is unknown. Providers
812
+ // already forward their options into every request, so nothing else changes.
813
+ //
814
+ // A feature calling another feature (a write re-reading the domain, say)
815
+ // goes through that feature's own wrapper, so the inner read is still a read.
816
+ // Providers fold most failures into an `OperationResult`; the unknown outcome
817
+ // is copied onto that result so it is not lost along with the error.
818
+ threadFeatureCalls() {
819
+ const self = this;
820
+ for (const [feature, { intent, optsIndex }] of Object.entries(FEATURE_CALLS)) {
821
+ const original = self[feature];
822
+ if (typeof original !== "function") continue;
823
+ self[feature] = (...args) => {
824
+ const call = { intent, feature };
825
+ const next = [...args];
826
+ while (next.length <= optsIndex) next.push(void 0);
827
+ next[optsIndex] = { ...next[optsIndex], call };
828
+ const result = original.apply(this, next);
829
+ if (!(result instanceof Promise)) return result;
830
+ return result.then(
831
+ (value) => call.outcomeUnknown && value !== null && typeof value === "object" && value.success === false ? { ...value, outcome: "unknown" } : value
832
+ );
833
+ };
834
+ }
660
835
  }
661
836
  testConnection(_opts) {
662
837
  return Promise.reject(
@@ -747,6 +922,8 @@ var BaseRegistrar = class {
747
922
  // src/registrars/cloudflare.ts
748
923
  var CloudflareRegistrar = class _CloudflareRegistrar extends BaseRegistrar {
749
924
  name = "cloudflare";
925
+ // a REST API: GET and HEAD never change state
926
+ static safeMethods = REST_SAFE_METHODS;
750
927
  static displayName = "Cloudflare";
751
928
  static website = "cloudflare.com";
752
929
  static helpText = 'Create an API token in your Cloudflare dashboard under My Profile > API Tokens. The token needs "Account.Registrar" read/write permissions. Find your Account ID in the URL or on the Overview page of any zone.';
@@ -1604,6 +1781,8 @@ var SEARCH_BATCH_SIZE = 100;
1604
1781
  var SUPPORTED_DNS_TYPES = /* @__PURE__ */ new Set(["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA"]);
1605
1782
  var DynadotRegistrar = class extends BaseRegistrar {
1606
1783
  name = "dynadot";
1784
+ // a REST API: GET and HEAD never change state
1785
+ static safeMethods = REST_SAFE_METHODS;
1607
1786
  static displayName = "Dynadot";
1608
1787
  static website = "dynadot.com";
1609
1788
  static helpText = "Under Tools > API in your Dynadot account, generate an API Key and API Secret. The account must be unlocked with API access enabled. The key identifies you; the secret signs requests (X-Signature) and is never sent directly.";
@@ -1634,7 +1813,10 @@ var DynadotRegistrar = class extends BaseRegistrar {
1634
1813
  production: "https://api.dynadot.com",
1635
1814
  sandbox: "https://api-sandbox.dynadot.com"
1636
1815
  }),
1637
- headers: { Authorization: `Bearer ${credentials.apiKey}` }
1816
+ headers: { Authorization: `Bearer ${credentials.apiKey}` },
1817
+ // HTTP 429 can omit Retry-After. Avoid exhausting all attempts inside
1818
+ // Dynadot's regular-tier minute window in that case.
1819
+ rateLimitFallbackSeconds: 60
1638
1820
  },
1639
1821
  options
1640
1822
  );
@@ -2355,6 +2537,8 @@ var GANDI_TYPE_TO_FORWARD = {
2355
2537
  };
2356
2538
  var GandiRegistrar = class extends BaseRegistrar {
2357
2539
  name = "gandi";
2540
+ // a REST API: GET and HEAD never change state
2541
+ static safeMethods = REST_SAFE_METHODS;
2358
2542
  static displayName = "Gandi.net";
2359
2543
  static website = "gandi.net";
2360
2544
  static helpText = "Generate a Personal Access Token (PAT) in your Gandi account under Account Settings > Security. The token needs permission to manage domains. (Gandi has deprecated the legacy API Key; use a PAT.)";
@@ -3042,6 +3226,8 @@ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3042
3226
  var PRICE_MICRO_UNITS = 1e6;
3043
3227
  var GoDaddyRegistrar = class _GoDaddyRegistrar extends BaseRegistrar {
3044
3228
  name = "godaddy";
3229
+ // a REST API: GET and HEAD never change state
3230
+ static safeMethods = REST_SAFE_METHODS;
3045
3231
  static displayName = "GoDaddy";
3046
3232
  static website = "godaddy.com";
3047
3233
  static helpText = "Create a Personal Access Token (PAT) at https://developer.godaddy.com and paste it here. The PAT is GoDaddy's modern credential: it unlocks the v3 API (discovery, registration, DNS, nameservers) and also authenticates the legacy v1 API for management operations (renew, transfer, auto-renew, lock, privacy, contacts) \u2014 one token covers everything.";
@@ -3922,6 +4108,8 @@ function dnsFieldsDiffer(a, b) {
3922
4108
  var TOKEN_URL = "https://api.namebright.com/auth/token";
3923
4109
  var NameBrightRegistrar = class extends BaseRegistrar {
3924
4110
  name = "namebright";
4111
+ // a REST API: GET and HEAD never change state
4112
+ static safeMethods = REST_SAFE_METHODS;
3925
4113
  static displayName = "NameBright";
3926
4114
  static website = "namebright.com";
3927
4115
  static helpText = 'API access is not enabled by default \u2014 you must request it from NameBright (contact support / your account manager) before the API Management page is available. Once enabled, create an API Application under my.namebright.com > My Account > API Management. The Client ID is "<accountName>:<applicationName>"; NameBright issues the Client Secret. Note the application also enforces an IP whitelist.';
@@ -3960,7 +4148,8 @@ var NameBrightRegistrar = class extends BaseRegistrar {
3960
4148
  client_id: this.credentials.clientId,
3961
4149
  client_secret: this.credentials.clientSecret
3962
4150
  },
3963
- ...opts
4151
+ // issuing a token changes nothing on the account, whatever it precedes
4152
+ ...asRead(opts)
3964
4153
  });
3965
4154
  if (!res.access_token) {
3966
4155
  throw new AuthenticationError("NameBright: token endpoint returned no access_token");
@@ -4568,9 +4757,10 @@ var NamecheapRegistrar = class extends BaseRegistrar {
4568
4757
  }
4569
4758
  /**
4570
4759
  * Availability via domains.check (batched at 50 per request, Namecheap's cap).
4571
- * The check response only carries a price for *premium* names
4572
- * (`PremiumRegistrationPrice`); regular registration pricing comes from
4573
- * `getPricing`, so `price` is set only for premium results.
4760
+ * The check response only carries prices for *premium* names
4761
+ * (`PremiumRegistrationPrice`, `PremiumRenewalPrice`); regular pricing comes
4762
+ * from `getPricing`, so `price` and `renewalPrice` are set only for premium
4763
+ * results.
4574
4764
  */
4575
4765
  async checkAvailability(domainNames, opts) {
4576
4766
  const results = [];
@@ -4583,12 +4773,16 @@ var NamecheapRegistrar = class extends BaseRegistrar {
4583
4773
  );
4584
4774
  for (const el of ensureArray(cr.DomainCheckResult)) {
4585
4775
  const premium = el["@_IsPremiumName"] === "true";
4586
- const premiumPrice = premium ? Number(el["@_PremiumRegistrationPrice"]) : NaN;
4776
+ const premiumPrice = (attr) => {
4777
+ const n = premium ? Number(attr) : NaN;
4778
+ return Number.isFinite(n) && n > 0 ? n : void 0;
4779
+ };
4587
4780
  results.push({
4588
4781
  domainName: el["@_Domain"] ?? "",
4589
4782
  available: el["@_Available"] === "true",
4590
4783
  premium,
4591
- price: Number.isFinite(premiumPrice) && premiumPrice > 0 ? premiumPrice : void 0
4784
+ price: premiumPrice(el["@_PremiumRegistrationPrice"]),
4785
+ renewalPrice: premiumPrice(el["@_PremiumRenewalPrice"])
4592
4786
  });
4593
4787
  }
4594
4788
  }
@@ -4739,7 +4933,11 @@ var NamecheapRegistrar = class extends BaseRegistrar {
4739
4933
  * has no WhoisGuard allotted (e.g. TLDs that don't offer it).
4740
4934
  */
4741
4935
  async setPrivacy(domainName, enabled, opts) {
4742
- const cr = await this.command("namecheap.domains.getInfo", { DomainName: domainName }, opts);
4936
+ const cr = await this.command(
4937
+ "namecheap.domains.getInfo",
4938
+ { DomainName: domainName },
4939
+ asRead(opts)
4940
+ );
4743
4941
  const id = cr.DomainGetInfoResult?.Whoisguard?.ID;
4744
4942
  if (id == null || String(id) === "0") {
4745
4943
  throw new Error(`${this.name}: ${domainName} has no WhoisGuard subscription to toggle`);
@@ -4979,7 +5177,7 @@ var NamecomHttpClient = class extends HttpClient {
4979
5177
  } catch (error) {
4980
5178
  if (error instanceof ParsingError) throw new ParsingError("namecom: invalid JSON response");
4981
5179
  if (error instanceof ConnectionError)
4982
- throw new ConnectionError("namecom: network request failed");
5180
+ throw new ConnectionError("namecom: network request failed", { notSent: error.notSent });
4983
5181
  throw error;
4984
5182
  }
4985
5183
  }
@@ -4992,18 +5190,19 @@ var NamecomHttpClient = class extends HttpClient {
4992
5190
  }),
4993
5191
  url
4994
5192
  );
4995
- if (error instanceof RateLimitError) {
4996
- const retryAfter = response.headers.get("retry-after");
4997
- const seconds = retryAfter == null ? NaN : Number(retryAfter);
4998
- const date = retryAfter == null ? NaN : Date.parse(retryAfter);
4999
- const reset = response.headers.get("x-ratelimit-reset");
5000
- error.retryAfter = Number.isFinite(seconds) ? Math.max(0, seconds) : Number.isFinite(date) ? Math.max(0, (date - Date.now()) / 1e3) : reset != null && Number.isFinite(Number(reset)) ? Math.max(0, Number(reset) - Date.now() / 1e3) : void 0;
5193
+ if (error instanceof RateLimitError && error.retryAfter === void 0) {
5194
+ const reset = response.headers.get("x-ratelimit-reset")?.trim();
5195
+ if (reset && /^\d+(?:\.\d+)?$/.test(reset)) {
5196
+ error.retryAfter = Math.max(0, Math.ceil(Number(reset) - Date.now() / 1e3));
5197
+ }
5001
5198
  }
5002
5199
  return error;
5003
5200
  }
5004
5201
  };
5005
5202
  var NamecomRegistrar = class extends BaseRegistrar {
5006
5203
  name = "namecom";
5204
+ // a REST API: GET and HEAD never change state
5205
+ static safeMethods = REST_SAFE_METHODS;
5007
5206
  static displayName = "Name.com";
5008
5207
  static website = "name.com";
5009
5208
  static supportsSandbox = true;
@@ -5032,7 +5231,11 @@ var NamecomRegistrar = class extends BaseRegistrar {
5032
5231
  headers: { Authorization: `Basic ${auth}` }
5033
5232
  };
5034
5233
  super(credentials, config, options);
5035
- this.http = new NamecomHttpClient({ ...config, options: this.options });
5234
+ this.http = new NamecomHttpClient({
5235
+ ...config,
5236
+ safeMethods: REST_SAFE_METHODS,
5237
+ options: this.options
5238
+ });
5036
5239
  }
5037
5240
  async testConnection(opts) {
5038
5241
  try {
@@ -5260,7 +5463,6 @@ var NamecomRegistrar = class extends BaseRegistrar {
5260
5463
  const old = index >= 0 ? remaining.splice(index, 1)[0] : void 0;
5261
5464
  await this.http.request({
5262
5465
  ...opts,
5263
- retries: 0,
5264
5466
  method: old ? "PUT" : "POST",
5265
5467
  path: old ? `${path}/${old.id}` : path,
5266
5468
  body: record
@@ -5269,7 +5471,6 @@ var NamecomRegistrar = class extends BaseRegistrar {
5269
5471
  for (const old of remaining) {
5270
5472
  await this.http.request({
5271
5473
  ...opts,
5272
- retries: 0,
5273
5474
  method: "DELETE",
5274
5475
  path: `${path}/${old.id}`
5275
5476
  });
@@ -5333,7 +5534,7 @@ var NamecomRegistrar = class extends BaseRegistrar {
5333
5534
  }
5334
5535
  async mutate(req, opts) {
5335
5536
  try {
5336
- await this.http.request({ ...req, ...opts, retries: 0 });
5537
+ await this.http.request({ ...req, ...opts });
5337
5538
  return { success: true, message: "Domain updated successfully" };
5338
5539
  } catch (error) {
5339
5540
  return { success: false, message: toRegistrarError(error).message };
@@ -5746,7 +5947,7 @@ var NameSiloRegistrar = class extends BaseRegistrar {
5746
5947
  */
5747
5948
  async disableDnssec(domainName, opts) {
5748
5949
  try {
5749
- const res = await this.call("dnsSecListRecords", { domain: domainName }, opts);
5950
+ const res = await this.call("dnsSecListRecords", { domain: domainName }, asRead(opts));
5750
5951
  if (!replyOk(res)) return statusResult2(res);
5751
5952
  for (const d of ensureArray(res.reply?.ds_record)) {
5752
5953
  const del = await this.call(
@@ -5819,7 +6020,7 @@ var NameSiloRegistrar = class extends BaseRegistrar {
5819
6020
  desired.set(f.alias, list);
5820
6021
  }
5821
6022
  const currentAliases = new Set(
5822
- (await this.listEmailForwards(domainName, opts)).map((r) => r.alias).filter(Boolean)
6023
+ (await this.listEmailForwards(domainName, asRead(opts))).map((r) => r.alias).filter(Boolean)
5823
6024
  );
5824
6025
  for (const alias of currentAliases) {
5825
6026
  if (!desired.has(alias)) {
@@ -5910,7 +6111,7 @@ var NameSiloRegistrar = class extends BaseRegistrar {
5910
6111
  * delegation nameservers aren't part of this list (see `updateNameservers`).
5911
6112
  */
5912
6113
  async setDnsRecords(domainName, records, opts) {
5913
- const listRes = await this.call("dnsListRecords", { domain: domainName }, opts);
6114
+ const listRes = await this.call("dnsListRecords", { domain: domainName }, asRead(opts));
5914
6115
  if (!replyOk(listRes)) return { success: false, message: replyDetail(listRes) };
5915
6116
  const existing = ensureArray(listRes.reply?.resource_record);
5916
6117
  const existingByKey = /* @__PURE__ */ new Map();
@@ -6437,7 +6638,7 @@ var PorkbunRegistrar = class extends BaseRegistrar {
6437
6638
  * renewals aren't supported via the API.
6438
6639
  */
6439
6640
  async renewDomain(domainName, _years = 1, opts) {
6440
- const pricing = await this.tldPricing(extractTld(domainName), opts);
6641
+ const pricing = await this.tldPricing(extractTld(domainName), asRead(opts));
6441
6642
  if (pricing.renewal == null) {
6442
6643
  throw new Error(`${this.name}: could not determine the renewal price for ${domainName}`);
6443
6644
  }
@@ -6468,7 +6669,7 @@ var PorkbunRegistrar = class extends BaseRegistrar {
6468
6669
  * aren't supported via the API.
6469
6670
  */
6470
6671
  async transferIn(domainName, input, opts) {
6471
- const pricing = await this.tldPricing(extractTld(domainName), opts);
6672
+ const pricing = await this.tldPricing(extractTld(domainName), asRead(opts));
6472
6673
  if (pricing.transfer == null) {
6473
6674
  throw new Error(`${this.name}: could not determine the transfer price for ${domainName}`);
6474
6675
  }
@@ -6761,6 +6962,8 @@ function toDnsRecord3(r, domainName) {
6761
6962
  var WRITABLE_DNS_TYPES = /* @__PURE__ */ new Set(["A", "AAAA", "CNAME", "NS", "TXT", "MX"]);
6762
6963
  var SpaceshipRegistrar = class extends BaseRegistrar {
6763
6964
  name = "spaceship";
6965
+ // a REST API: GET and HEAD never change state
6966
+ static safeMethods = REST_SAFE_METHODS;
6764
6967
  static displayName = "Spaceship";
6765
6968
  static website = "spaceship.com";
6766
6969
  static helpText = 'Generate your API key and secret in the API Manager at Spaceship using the "New API key" button. The API requires both X-API-Key and X-API-Secret headers.';
@@ -7294,6 +7497,7 @@ exports.ConsentRequiredError = ConsentRequiredError;
7294
7497
  exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
7295
7498
  exports.DynadotRegistrar = DynadotRegistrar;
7296
7499
  exports.EXTENDED_FEATURES = EXTENDED_FEATURES;
7500
+ exports.FEATURE_CALLS = FEATURE_CALLS;
7297
7501
  exports.Feature = Feature;
7298
7502
  exports.GandiRegistrar = GandiRegistrar;
7299
7503
  exports.GoDaddyRegistrar = GoDaddyRegistrar;
@@ -7306,8 +7510,10 @@ exports.NamecheapRegistrar = NamecheapRegistrar;
7306
7510
  exports.NamecomRegistrar = NamecomRegistrar;
7307
7511
  exports.NotFoundError = NotFoundError;
7308
7512
  exports.NotImplementedError = NotImplementedError;
7513
+ exports.OutcomeUnknownError = OutcomeUnknownError;
7309
7514
  exports.ParsingError = ParsingError;
7310
7515
  exports.PorkbunRegistrar = PorkbunRegistrar;
7516
+ exports.REST_SAFE_METHODS = REST_SAFE_METHODS;
7311
7517
  exports.RateLimitError = RateLimitError;
7312
7518
  exports.RegistrarClient = RegistrarClient;
7313
7519
  exports.RegistrarError = RegistrarError;
@@ -7315,6 +7521,7 @@ exports.RetryableRegistrarError = RetryableRegistrarError;
7315
7521
  exports.SpaceshipRegistrar = SpaceshipRegistrar;
7316
7522
  exports.TimeoutError = TimeoutError;
7317
7523
  exports.USER_AGENT = USER_AGENT;
7524
+ exports.asRead = asRead;
7318
7525
  exports.backoffDelay = backoffDelay;
7319
7526
  exports.createDomain = createDomain;
7320
7527
  exports.createRegistrar = createRegistrar;
@@ -7322,6 +7529,7 @@ exports.ensureArray = ensureArray;
7322
7529
  exports.filterDomains = filterDomains;
7323
7530
  exports.isCoreFeature = isCoreFeature;
7324
7531
  exports.listPortfolio = listPortfolio;
7532
+ exports.markNotSent = markNotSent;
7325
7533
  exports.normalizeDomain = normalizeDomain;
7326
7534
  exports.normalizeNameservers = normalizeNameservers;
7327
7535
  exports.parseDate = parseDate;
@@ -7332,5 +7540,6 @@ exports.selectBaseUrl = selectBaseUrl;
7332
7540
  exports.settableForwards = settableForwards;
7333
7541
  exports.sleep = sleep;
7334
7542
  exports.toRegistrarError = toRegistrarError;
7543
+ exports.wasNotSent = wasNotSent;
7335
7544
  //# sourceMappingURL=index.cjs.map
7336
7545
  //# sourceMappingURL=index.cjs.map