@aoxborrow/registrar-client 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aoxborrow/registrar-client",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "One consistent, fully-typed TypeScript interface to many domain registrar APIs (GoDaddy, Cloudflare, Namecheap, Spaceship, Dynadot, Porkbun, and more) behind a single pluggable multi-provider client",
5
5
  "author": {
6
6
  "name": "Aaron Oxborrow",
package/src/errors.ts CHANGED
@@ -75,11 +75,79 @@ export class TimeoutError extends RetryableRegistrarError {
75
75
  // network/connection failure reaching the registrar API
76
76
  export class ConnectionError extends RetryableRegistrarError {
77
77
  public status = 503; // Service Unavailable
78
+ // true when the failure is known to have happened before the request left:
79
+ // DNS, connection refused, TLS handshake, or a proxy that never opened its
80
+ // tunnel. The registrar cannot have acted on it, so it is safe to re-send
81
+ // even for a write.
82
+ public notSent: boolean;
78
83
 
79
- constructor(message: string) {
84
+ constructor(message: string, details: { notSent?: boolean } = {}) {
80
85
  super(message);
81
86
  this.name = 'ConnectionError';
87
+ this.notSent = details.notSent ?? false;
88
+ }
89
+ }
90
+
91
+ // A write failed in a way that leaves its result unknown: the request timed
92
+ // out, the connection dropped mid-response, or the registrar answered 5xx. It
93
+ // may have been applied. Never retried by the library; re-read the domain
94
+ // before trying again.
95
+ export class OutcomeUnknownError extends RegistrarError {
96
+ public status = 504; // Gateway Timeout
97
+ public feature?: string;
98
+ public override cause?: unknown;
99
+
100
+ constructor(message: string, details: { feature?: string; cause?: unknown } = {}) {
101
+ super(message);
102
+ this.name = 'OutcomeUnknownError';
103
+ this.feature = details.feature;
104
+ this.cause = details.cause;
105
+ }
106
+ }
107
+
108
+ const NOT_SENT = Symbol.for('registrar-client.notSent');
109
+
110
+ // For custom `fetch` transports: mark an error as having occurred before the
111
+ // request was sent (proxy unreachable, proxy auth refused, tunnel TLS failed).
112
+ // Returns the same error for `throw markNotSent(err)`.
113
+ export function markNotSent<T>(error: T): T {
114
+ if (typeof error === 'object' && error !== null) {
115
+ Object.defineProperty(error, NOT_SENT, { value: true, enumerable: false });
116
+ }
117
+ return error;
118
+ }
119
+
120
+ // error codes Node's fetch (undici) reports, via `error.cause.code`, for
121
+ // failures that happen before any request bytes are written
122
+ const PRE_SEND_CODES = new Set([
123
+ 'ENOTFOUND',
124
+ 'EAI_AGAIN',
125
+ 'ECONNREFUSED',
126
+ 'EHOSTUNREACH',
127
+ 'ENETUNREACH',
128
+ 'EADDRNOTAVAIL',
129
+ 'UND_ERR_CONNECT_TIMEOUT',
130
+ 'CERT_HAS_EXPIRED',
131
+ 'CERT_NOT_YET_VALID',
132
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
133
+ 'SELF_SIGNED_CERT_IN_CHAIN',
134
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
135
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
136
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
137
+ ]);
138
+
139
+ // Whether a fetch rejection is known to have happened before the request was
140
+ // sent. Only positive evidence counts: an explicit `markNotSent`, or a
141
+ // pre-connection error code. Runtimes that expose no code (browsers, Workers)
142
+ // yield false, which is the safe answer.
143
+ export function wasNotSent(error: unknown): boolean {
144
+ for (let e: unknown = error, depth = 0; e && typeof e === 'object' && depth < 5; depth++) {
145
+ if ((e as Record<symbol, unknown>)[NOT_SENT] === true) return true;
146
+ const code = (e as { code?: unknown }).code;
147
+ if (typeof code === 'string' && PRE_SEND_CODES.has(code)) return true;
148
+ e = (e as { cause?: unknown }).cause;
82
149
  }
150
+ return false;
83
151
  }
84
152
 
85
153
  // registrar API rejected the credentials
package/src/features.ts CHANGED
@@ -102,3 +102,45 @@ const CORE_FEATURE_SET: ReadonlySet<RegistrarFeature> = new Set(CORE_FEATURES);
102
102
  export function isCoreFeature(feature: RegistrarFeature): boolean {
103
103
  return CORE_FEATURE_SET.has(feature);
104
104
  }
105
+
106
+ // How each feature method is served: whether it only reads registrar state or
107
+ // may change it, and which positional argument carries its `RequestOptions`.
108
+ //
109
+ // The intent decides what may be re-sent after a failure (see `HttpClient`). It
110
+ // is keyed by feature rather than by HTTP method because the method says
111
+ // nothing reliable: Namecheap sends every command, writes included, as a GET,
112
+ // and Porkbun sends reads as POSTs.
113
+ //
114
+ // Typed as a total record so adding a feature without classifying it fails to
115
+ // compile. `getAuthCode` is a write: several registrars regenerate the code,
116
+ // invalidating the previous one.
117
+ export const FEATURE_CALLS: Record<
118
+ RegistrarFeature,
119
+ { intent: 'read' | 'write'; optsIndex: number }
120
+ > = {
121
+ testConnection: { intent: 'read', optsIndex: 0 },
122
+ listDomains: { intent: 'read', optsIndex: 0 },
123
+ getDomain: { intent: 'read', optsIndex: 1 },
124
+ checkAvailability: { intent: 'read', optsIndex: 1 },
125
+ getPricing: { intent: 'read', optsIndex: 1 },
126
+ registerDomain: { intent: 'write', optsIndex: 2 },
127
+ renewDomain: { intent: 'write', optsIndex: 2 },
128
+ setAutoRenew: { intent: 'write', optsIndex: 2 },
129
+ transferIn: { intent: 'write', optsIndex: 2 },
130
+ updateNameservers: { intent: 'write', optsIndex: 2 },
131
+ getNameservers: { intent: 'read', optsIndex: 1 },
132
+ lockDomain: { intent: 'write', optsIndex: 1 },
133
+ unlockDomain: { intent: 'write', optsIndex: 1 },
134
+ setPrivacy: { intent: 'write', optsIndex: 2 },
135
+ getContacts: { intent: 'read', optsIndex: 1 },
136
+ updateContacts: { intent: 'write', optsIndex: 2 },
137
+ getDnsRecords: { intent: 'read', optsIndex: 1 },
138
+ setDnsRecords: { intent: 'write', optsIndex: 2 },
139
+ getAuthCode: { intent: 'write', optsIndex: 1 },
140
+ getDnssec: { intent: 'read', optsIndex: 1 },
141
+ disableDnssec: { intent: 'write', optsIndex: 1 },
142
+ getEmailForwarding: { intent: 'read', optsIndex: 1 },
143
+ setEmailForwarding: { intent: 'write', optsIndex: 2 },
144
+ getDomainForwarding: { intent: 'read', optsIndex: 1 },
145
+ setDomainForwarding: { intent: 'write', optsIndex: 2 },
146
+ };
package/src/http.ts CHANGED
@@ -6,13 +6,15 @@ import {
6
6
  ConnectionError,
7
7
  InvalidResponseError,
8
8
  NotFoundError,
9
+ OutcomeUnknownError,
9
10
  ParsingError,
10
11
  RateLimitError,
11
12
  RegistrarError,
12
13
  TimeoutError,
13
14
  toRegistrarError,
15
+ wasNotSent,
14
16
  } from './errors';
15
- import type { RegistrarClientOptions } from './types';
17
+ import type { FeatureCall, RegistrarClientOptions } from './types';
16
18
  import { backoffDelay, sleep } from './utils';
17
19
 
18
20
  // configuration for an HttpClient instance
@@ -23,8 +25,16 @@ export interface HttpClientConfig {
23
25
  headers?: Record<string, string>;
24
26
  // request/retry behavior
25
27
  options: RegistrarClientOptions;
28
+ // HTTP methods this API reserves for requests that never change state. A
29
+ // REST API sets `REST_SAFE_METHODS`, so a lookup made in the middle of a
30
+ // write (a zone list, a price check) is still retried like any read. Leave
31
+ // unset for APIs where the method means nothing: Namecheap and NameSilo send
32
+ // writes as GET. Those mark their nested reads with `asRead` instead.
33
+ safeMethods?: readonly string[];
26
34
  }
27
35
 
36
+ export const REST_SAFE_METHODS: readonly string[] = ['GET', 'HEAD'];
37
+
28
38
  // options for a single request
29
39
  export interface RequestConfig {
30
40
  method?: string;
@@ -40,6 +50,11 @@ export interface RequestConfig {
40
50
  retries?: number;
41
51
  backoff?: number;
42
52
  signal?: AbortSignal;
53
+ fetch?: typeof globalThis.fetch;
54
+ // which feature this request serves and whether it reads or writes; arrives
55
+ // with the caller's `RequestOptions`. A request without one is treated as a
56
+ // write, the safe assumption.
57
+ call?: FeatureCall;
43
58
  }
44
59
 
45
60
  // a small, browser/edge-safe fetch wrapper with timeout, abort linking,
@@ -67,7 +82,20 @@ export class HttpClient {
67
82
  return this.withRetries(req, () => this.send(req));
68
83
  }
69
84
 
70
- // shared retry-with-backoff wrapper around a single-attempt operation
85
+ // Shared retry-with-backoff wrapper around a single-attempt operation.
86
+ //
87
+ // What may be re-sent depends on where the failure happened and on whether
88
+ // the call reads or writes:
89
+ //
90
+ // failure read write
91
+ // never sent (DNS, refused, TLS, proxy stage) retry retry
92
+ // 429 retry retry
93
+ // unknown (timeout, dropped mid-response, 5xx) retry OutcomeUnknownError
94
+ // any other response fail fail
95
+ //
96
+ // A write whose outcome is unknown may have been applied, so it is never
97
+ // re-sent: a second renewal charges twice, a second record-create leaves a
98
+ // duplicate. The caller re-reads the domain instead.
71
99
  protected async withRetries<T>(req: RequestConfig, attemptFn: () => Promise<T>): Promise<T> {
72
100
  const retries = req.retries ?? this.config.options.retries;
73
101
  const backoff = req.backoff ?? this.config.options.backoff;
@@ -79,6 +107,16 @@ export class HttpClient {
79
107
  } catch (error) {
80
108
  lastError = toRegistrarError(error);
81
109
 
110
+ if (this.intentOf(req) === 'write' && isOutcomeUnknown(lastError)) {
111
+ const unknown = new OutcomeUnknownError(
112
+ `The registrar did not confirm this change, so it may or may not have been applied. ` +
113
+ `Check the domain before trying again. (${lastError.message})`,
114
+ { feature: req.call?.feature, cause: lastError }
115
+ );
116
+ if (req.call) req.call.outcomeUnknown = unknown;
117
+ throw unknown;
118
+ }
119
+
82
120
  // out of attempts, or the error is not retryable
83
121
  if (attempt >= retries || !lastError.shouldRetry()) {
84
122
  throw lastError;
@@ -97,6 +135,15 @@ export class HttpClient {
97
135
  throw lastError ?? new RegistrarError('Request failed');
98
136
  }
99
137
 
138
+ // Whether a request only reads. A method the API reserves for reads wins;
139
+ // otherwise it is whatever the feature being served is, and a request that
140
+ // says nothing is assumed to write.
141
+ protected intentOf(req: RequestConfig): 'read' | 'write' {
142
+ const method = (req.method ?? 'GET').toUpperCase();
143
+ if (this.config.safeMethods?.includes(method)) return 'read';
144
+ return req.call?.intent ?? 'write';
145
+ }
146
+
100
147
  // build the full URL for a request, applying query parameters
101
148
  protected buildUrl(req: RequestConfig): string {
102
149
  let base: string;
@@ -146,7 +193,8 @@ export class HttpClient {
146
193
  try {
147
194
  let response: Response;
148
195
  try {
149
- response = await fetch(url, {
196
+ const doFetch = req.fetch ?? this.config.options.fetch ?? fetch;
197
+ response = await doFetch(url, {
150
198
  method: req.method ?? 'GET',
151
199
  headers,
152
200
  body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
@@ -158,7 +206,9 @@ export class HttpClient {
158
206
  if (externalSignal?.aborted) throw new AbortError('Request was aborted');
159
207
  throw new TimeoutError(`Request to '${url}' timed out after ${timeout}ms`);
160
208
  }
161
- throw new ConnectionError(`Failed to reach '${url}': ${errorMessage(error)}`);
209
+ throw new ConnectionError(`Failed to reach '${url}': ${errorMessage(error)}`, {
210
+ notSent: wasNotSent(error),
211
+ });
162
212
  }
163
213
 
164
214
  if (!response.ok) {
@@ -169,7 +219,20 @@ export class HttpClient {
169
219
  if (response.status === 204) {
170
220
  return '';
171
221
  }
172
- return await response.text();
222
+ try {
223
+ return await response.text();
224
+ } catch (error) {
225
+ // The registrar answered, then the body was cut off. The request was
226
+ // certainly sent, so for a write the outcome is unknown; a read can
227
+ // simply be retried.
228
+ if (isAbortError(error)) {
229
+ if (externalSignal?.aborted) throw new AbortError('Request was aborted');
230
+ throw new TimeoutError(`Response from '${url}' timed out after ${timeout}ms`);
231
+ }
232
+ throw new ConnectionError(
233
+ `Connection to '${url}' was lost while reading the response: ${errorMessage(error)}`
234
+ );
235
+ }
173
236
  } finally {
174
237
  clearTimeout(timeoutId);
175
238
  externalSignal?.removeEventListener('abort', onAbort);
@@ -214,6 +277,14 @@ export class HttpClient {
214
277
  }
215
278
  }
216
279
 
280
+ // A retryable failure that leaves a write's result unknown: the request may
281
+ // have reached the registrar. A rate limit is an explicit rejection, and a
282
+ // connection error known to precede sending never reached it.
283
+ function isOutcomeUnknown(error: RegistrarError): boolean {
284
+ if (error instanceof TimeoutError || error instanceof InvalidResponseError) return true;
285
+ return error instanceof ConnectionError && !error.notSent;
286
+ }
287
+
217
288
  // detect an AbortController/fetch abort across environments
218
289
  function isAbortError(error: unknown): boolean {
219
290
  return (
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  export { RegistrarClient } from './client';
4
4
  export { listPortfolio } from './portfolio';
5
5
  export type { DomainSource, PortfolioError, PortfolioResult } from './portfolio';
6
- export { HttpClient } from './http';
6
+ export { HttpClient, REST_SAFE_METHODS } from './http';
7
7
  export type { HttpClientConfig, RequestConfig } from './http';
8
8
 
9
9
  // core registrar abstraction (the `Registrar` interface itself is exported via
@@ -15,7 +15,14 @@ export { registrars, createRegistrar } from './registrars/index';
15
15
  export type { RegistrarName } from './registrars/index';
16
16
 
17
17
  // capability model: feature constants, the core contract, and helpers
18
- export { Feature, CORE_FEATURES, EXTENDED_FEATURES, ALL_FEATURES, isCoreFeature } from './features';
18
+ export {
19
+ Feature,
20
+ CORE_FEATURES,
21
+ EXTENDED_FEATURES,
22
+ ALL_FEATURES,
23
+ FEATURE_CALLS,
24
+ isCoreFeature,
25
+ } from './features';
19
26
  export type { RegistrarFeature } from './features';
20
27
 
21
28
  // bundled providers
package/src/registrar.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_OPTIONS } from './constants';
2
2
  import { ConfigurationError, NotImplementedError } from './errors';
3
3
  import { HttpClient, type HttpClientConfig } from './http';
4
- import { CORE_FEATURES, type RegistrarFeature } from './features';
4
+ import { CORE_FEATURES, FEATURE_CALLS, type RegistrarFeature } from './features';
5
5
  import type {
6
6
  ConnectionResult,
7
7
  ContactSet,
@@ -18,6 +18,7 @@ import type {
18
18
  RegistrarClientOptions,
19
19
  RegistrarCredentials,
20
20
  RegistrarEnvironment,
21
+ FeatureCall,
21
22
  RegistrarOptions,
22
23
  RequestOptions,
23
24
  TldPricing,
@@ -65,6 +66,10 @@ export abstract class BaseRegistrar implements Registrar {
65
66
  return [...CORE_FEATURES, ...this.extendedFeatures];
66
67
  }
67
68
 
69
+ // HTTP methods this provider's API reserves for reads; see
70
+ // `HttpClientConfig.safeMethods`. REST providers set `REST_SAFE_METHODS`.
71
+ static readonly safeMethods: readonly string[] = [];
72
+
68
73
  // instance mirror of the static `features`
69
74
  get features(): readonly RegistrarFeature[] {
70
75
  return (this.constructor as typeof BaseRegistrar).features;
@@ -90,7 +95,47 @@ export abstract class BaseRegistrar implements Registrar {
90
95
  this.credentials = credentials;
91
96
  this.environment = environment;
92
97
  this.options = { ...DEFAULT_OPTIONS, ...clientOptions };
93
- this.http = new HttpClient({ ...httpConfig, options: this.options });
98
+ this.http = new HttpClient({
99
+ safeMethods: (this.constructor as typeof BaseRegistrar).safeMethods,
100
+ ...httpConfig,
101
+ options: this.options,
102
+ });
103
+ this.threadFeatureCalls();
104
+ }
105
+
106
+ // Wrap each feature method so its `RequestOptions` carry a `FeatureCall`:
107
+ // the read/write intent the HTTP layer needs to decide what may be re-sent,
108
+ // and a slot where it reports a write whose outcome is unknown. Providers
109
+ // already forward their options into every request, so nothing else changes.
110
+ //
111
+ // A feature calling another feature (a write re-reading the domain, say)
112
+ // goes through that feature's own wrapper, so the inner read is still a read.
113
+ // Providers fold most failures into an `OperationResult`; the unknown outcome
114
+ // is copied onto that result so it is not lost along with the error.
115
+ private threadFeatureCalls(): void {
116
+ const self = this as unknown as Record<string, unknown>;
117
+ for (const [feature, { intent, optsIndex }] of Object.entries(FEATURE_CALLS)) {
118
+ const original = self[feature];
119
+ if (typeof original !== 'function') continue;
120
+ // Not `async`: a provider that validates its arguments by throwing
121
+ // synchronously keeps doing so.
122
+ self[feature] = (...args: unknown[]): unknown => {
123
+ const call: FeatureCall = { intent, feature };
124
+ const next = [...args];
125
+ while (next.length <= optsIndex) next.push(undefined);
126
+ next[optsIndex] = { ...(next[optsIndex] as RequestOptions | undefined), call };
127
+ const result: unknown = (original as (...a: unknown[]) => unknown).apply(this, next);
128
+ if (!(result instanceof Promise)) return result;
129
+ return result.then((value: unknown) =>
130
+ call.outcomeUnknown &&
131
+ value !== null &&
132
+ typeof value === 'object' &&
133
+ (value as OperationResult).success === false
134
+ ? { ...value, outcome: 'unknown' }
135
+ : value
136
+ );
137
+ };
138
+ }
94
139
  }
95
140
 
96
141
  testConnection(_opts?: RequestOptions): Promise<ConnectionResult> {
@@ -15,6 +15,7 @@ import type {
15
15
  RequestOptions,
16
16
  TldPricing,
17
17
  } from '../types';
18
+ import { REST_SAFE_METHODS } from '../http';
18
19
  import { createDomain, filterDomains, normalizeDomain, settableForwards } from '../utils';
19
20
  import { NotImplementedError, toRegistrarError } from '../errors';
20
21
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
@@ -173,6 +174,8 @@ interface CfDnsRecord {
173
174
  */
174
175
  export class CloudflareRegistrar extends BaseRegistrar {
175
176
  readonly name = 'cloudflare';
177
+ // a REST API: GET and HEAD never change state
178
+ static override readonly safeMethods = REST_SAFE_METHODS;
176
179
 
177
180
  static readonly displayName = 'Cloudflare';
178
181
  static readonly website = 'cloudflare.com';
@@ -17,6 +17,7 @@ import type {
17
17
  TldPricing,
18
18
  TransferDomainInput,
19
19
  } from '../types';
20
+ import { REST_SAFE_METHODS } from '../http';
20
21
  import { createDomain, filterDomains, requireConsent, settableForwards } from '../utils';
21
22
  import { NotFoundError, NotImplementedError, toRegistrarError } from '../errors';
22
23
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
@@ -178,6 +179,8 @@ const SUPPORTED_DNS_TYPES = new Set(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'S
178
179
  */
179
180
  export class DynadotRegistrar extends BaseRegistrar {
180
181
  readonly name = 'dynadot';
182
+ // a REST API: GET and HEAD never change state
183
+ static override readonly safeMethods = REST_SAFE_METHODS;
181
184
 
182
185
  static readonly displayName = 'Dynadot';
183
186
  static readonly website = 'dynadot.com';
@@ -19,6 +19,7 @@ import type {
19
19
  TldPricing,
20
20
  TransferDomainInput,
21
21
  } from '../types';
22
+ import { REST_SAFE_METHODS } from '../http';
22
23
  import { createDomain, filterDomains, settableForwards } from '../utils';
23
24
  import { toRegistrarError } from '../errors';
24
25
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
@@ -159,6 +160,8 @@ const GANDI_TYPE_TO_FORWARD: Record<string, DomainForwardType> = {
159
160
  */
160
161
  export class GandiRegistrar extends BaseRegistrar {
161
162
  readonly name = 'gandi';
163
+ // a REST API: GET and HEAD never change state
164
+ static override readonly safeMethods = REST_SAFE_METHODS;
162
165
 
163
166
  static readonly displayName = 'Gandi.net';
164
167
  static readonly website = 'gandi.net';
@@ -17,6 +17,7 @@ import type {
17
17
  TldPricing,
18
18
  TransferDomainInput,
19
19
  } from '../types';
20
+ import { REST_SAFE_METHODS } from '../http';
20
21
  import { createDomain, filterDomains, settableForwards } from '../utils';
21
22
  import {
22
23
  ConfigurationError,
@@ -267,6 +268,8 @@ interface GdV3Operation {
267
268
  */
268
269
  export class GoDaddyRegistrar extends BaseRegistrar {
269
270
  readonly name = 'godaddy';
271
+ // a REST API: GET and HEAD never change state
272
+ static override readonly safeMethods = REST_SAFE_METHODS;
270
273
 
271
274
  static readonly displayName = 'GoDaddy';
272
275
  static readonly website = 'godaddy.com';
@@ -13,12 +13,13 @@ import type {
13
13
  RequestOptions,
14
14
  TldPricing,
15
15
  } from '../types';
16
- import { createDomain, filterDomains } from '../utils';
16
+ import { asRead, createDomain, filterDomains } from '../utils';
17
17
  import { AuthenticationError, NotImplementedError, toRegistrarError } from '../errors';
18
18
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
19
19
  import { Feature, type RegistrarFeature } from '../features';
20
20
  import type { RegistrarCredentials } from '../types';
21
21
  import type { RequestConfig } from '../http';
22
+ import { REST_SAFE_METHODS } from '../http';
22
23
 
23
24
  const TOKEN_URL = 'https://api.namebright.com/auth/token';
24
25
 
@@ -196,6 +197,8 @@ interface NbAvailability {
196
197
  */
197
198
  export class NameBrightRegistrar extends BaseRegistrar {
198
199
  readonly name = 'namebright';
200
+ // a REST API: GET and HEAD never change state
201
+ static override readonly safeMethods = REST_SAFE_METHODS;
199
202
 
200
203
  static readonly displayName = 'NameBright';
201
204
  static readonly website = 'namebright.com';
@@ -243,7 +246,8 @@ export class NameBrightRegistrar extends BaseRegistrar {
243
246
  client_id: this.credentials.clientId,
244
247
  client_secret: this.credentials.clientSecret,
245
248
  },
246
- ...opts,
249
+ // issuing a token changes nothing on the account, whatever it precedes
250
+ ...asRead(opts),
247
251
  });
248
252
  if (!res.access_token) {
249
253
  throw new AuthenticationError('NameBright: token endpoint returned no access_token');
@@ -18,6 +18,7 @@ import type {
18
18
  TransferDomainInput,
19
19
  } from '../types';
20
20
  import {
21
+ asRead,
21
22
  createDomain,
22
23
  filterDomains,
23
24
  normalizeDomain,
@@ -580,7 +581,11 @@ export class NamecheapRegistrar extends BaseRegistrar {
580
581
  enabled: boolean,
581
582
  opts?: RequestOptions
582
583
  ): Promise<OperationResult> {
583
- const cr = await this.command('namecheap.domains.getInfo', { DomainName: domainName }, opts);
584
+ const cr = await this.command(
585
+ 'namecheap.domains.getInfo',
586
+ { DomainName: domainName },
587
+ asRead(opts)
588
+ );
584
589
  const id = cr.DomainGetInfoResult?.Whoisguard?.ID;
585
590
  if (id == null || String(id) === '0') {
586
591
  throw new Error(`${this.name}: ${domainName} has no WhoisGuard subscription to toggle`);
@@ -9,7 +9,7 @@ import {
9
9
  toRegistrarError,
10
10
  } from '../errors';
11
11
  import { Feature, type RegistrarFeature } from '../features';
12
- import { HttpClient, type RequestConfig } from '../http';
12
+ import { HttpClient, type RequestConfig, REST_SAFE_METHODS } from '../http';
13
13
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
14
14
  import type {
15
15
  ConfigField,
@@ -94,7 +94,7 @@ class NamecomHttpClient extends HttpClient {
94
94
  } catch (error) {
95
95
  if (error instanceof ParsingError) throw new ParsingError('namecom: invalid JSON response');
96
96
  if (error instanceof ConnectionError)
97
- throw new ConnectionError('namecom: network request failed');
97
+ throw new ConnectionError('namecom: network request failed', { notSent: error.notSent });
98
98
  throw error;
99
99
  }
100
100
  }
@@ -127,6 +127,8 @@ class NamecomHttpClient extends HttpClient {
127
127
 
128
128
  export class NamecomRegistrar extends BaseRegistrar {
129
129
  readonly name = 'namecom';
130
+ // a REST API: GET and HEAD never change state
131
+ static override readonly safeMethods = REST_SAFE_METHODS;
130
132
  static readonly displayName = 'Name.com';
131
133
  static readonly website = 'name.com';
132
134
  static readonly supportsSandbox = true;
@@ -158,7 +160,11 @@ export class NamecomRegistrar extends BaseRegistrar {
158
160
  headers: { Authorization: `Basic ${auth}` },
159
161
  };
160
162
  super(credentials, config, options);
161
- this.http = new NamecomHttpClient({ ...config, options: this.options });
163
+ this.http = new NamecomHttpClient({
164
+ ...config,
165
+ safeMethods: REST_SAFE_METHODS,
166
+ options: this.options,
167
+ });
162
168
  }
163
169
 
164
170
  override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
@@ -444,7 +450,6 @@ export class NamecomRegistrar extends BaseRegistrar {
444
450
  const old = index >= 0 ? remaining.splice(index, 1)[0] : undefined;
445
451
  await this.http.request({
446
452
  ...opts,
447
- retries: 0,
448
453
  method: old ? 'PUT' : 'POST',
449
454
  path: old ? `${path}/${old.id!}` : path,
450
455
  body: record,
@@ -453,7 +458,6 @@ export class NamecomRegistrar extends BaseRegistrar {
453
458
  for (const old of remaining) {
454
459
  await this.http.request({
455
460
  ...opts,
456
- retries: 0,
457
461
  method: 'DELETE',
458
462
  path: `${path}/${old.id!}`,
459
463
  });
@@ -532,7 +536,7 @@ export class NamecomRegistrar extends BaseRegistrar {
532
536
  }
533
537
  private async mutate(req: RequestConfig, opts?: RequestOptions): Promise<OperationResult> {
534
538
  try {
535
- await this.http.request({ ...req, ...opts, retries: 0 });
539
+ await this.http.request({ ...req, ...opts });
536
540
  return { success: true, message: 'Domain updated successfully' };
537
541
  } catch (error) {
538
542
  return { success: false, message: toRegistrarError(error).message };
@@ -18,7 +18,7 @@ import type {
18
18
  TldPricing,
19
19
  TransferDomainInput,
20
20
  } from '../types';
21
- import { createDomain, filterDomains, settableForwards } from '../utils';
21
+ import { asRead, createDomain, filterDomains, settableForwards } from '../utils';
22
22
  import { NotFoundError, toRegistrarError } from '../errors';
23
23
  import { ensureArray, parseXml } from '../xml';
24
24
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
@@ -486,7 +486,7 @@ export class NameSiloRegistrar extends BaseRegistrar {
486
486
  opts?: RequestOptions
487
487
  ): Promise<OperationResult> {
488
488
  try {
489
- const res = await this.call('dnsSecListRecords', { domain: domainName }, opts);
489
+ const res = await this.call('dnsSecListRecords', { domain: domainName }, asRead(opts));
490
490
  if (!replyOk(res)) return statusResult(res);
491
491
  for (const d of ensureArray<NsDsRecord>(res.reply?.ds_record)) {
492
492
  const del = await this.call(
@@ -574,7 +574,7 @@ export class NameSiloRegistrar extends BaseRegistrar {
574
574
  desired.set(f.alias, list);
575
575
  }
576
576
  const currentAliases = new Set(
577
- (await this.listEmailForwards(domainName, opts)).map(r => r.alias).filter(Boolean)
577
+ (await this.listEmailForwards(domainName, asRead(opts))).map(r => r.alias).filter(Boolean)
578
578
  );
579
579
 
580
580
  for (const alias of currentAliases) {
@@ -696,7 +696,7 @@ export class NameSiloRegistrar extends BaseRegistrar {
696
696
  records: DnsRecord[],
697
697
  opts?: RequestOptions
698
698
  ): Promise<OperationResult> {
699
- const listRes = await this.call('dnsListRecords', { domain: domainName }, opts);
699
+ const listRes = await this.call('dnsListRecords', { domain: domainName }, asRead(opts));
700
700
  if (!replyOk(listRes)) return { success: false, message: replyDetail(listRes) };
701
701
  const existing = ensureArray(listRes.reply?.resource_record);
702
702
 
@@ -17,7 +17,7 @@ import type {
17
17
  TldPricing,
18
18
  TransferDomainInput,
19
19
  } from '../types';
20
- import { createDomain, filterDomains, normalizeDomain, settableForwards } from '../utils';
20
+ import { asRead, createDomain, filterDomains, normalizeDomain, settableForwards } from '../utils';
21
21
  import { NotFoundError, NotImplementedError, toRegistrarError } from '../errors';
22
22
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
23
23
  import { Feature, type RegistrarFeature } from '../features';
@@ -520,7 +520,7 @@ export class PorkbunRegistrar extends BaseRegistrar {
520
520
  _years = 1,
521
521
  opts?: RequestOptions
522
522
  ): Promise<OperationResult> {
523
- const pricing = await this.tldPricing(extractTld(domainName), opts);
523
+ const pricing = await this.tldPricing(extractTld(domainName), asRead(opts));
524
524
  if (pricing.renewal == null) {
525
525
  throw new Error(`${this.name}: could not determine the renewal price for ${domainName}`);
526
526
  }
@@ -563,7 +563,7 @@ export class PorkbunRegistrar extends BaseRegistrar {
563
563
  input: TransferDomainInput,
564
564
  opts?: RequestOptions
565
565
  ): Promise<OperationResult> {
566
- const pricing = await this.tldPricing(extractTld(domainName), opts);
566
+ const pricing = await this.tldPricing(extractTld(domainName), asRead(opts));
567
567
  if (pricing.transfer == null) {
568
568
  throw new Error(`${this.name}: could not determine the transfer price for ${domainName}`);
569
569
  }
@@ -14,6 +14,7 @@ import type {
14
14
  TldPricing,
15
15
  TransferDomainInput,
16
16
  } from '../types';
17
+ import { REST_SAFE_METHODS } from '../http';
17
18
  import { createDomain, filterDomains, requireConsent, sleep } from '../utils';
18
19
  import { NotImplementedError, toRegistrarError } from '../errors';
19
20
  import { BaseRegistrar, selectBaseUrl } from '../registrar';
@@ -131,6 +132,8 @@ const WRITABLE_DNS_TYPES = new Set(['A', 'AAAA', 'CNAME', 'NS', 'TXT', 'MX']);
131
132
  */
132
133
  export class SpaceshipRegistrar extends BaseRegistrar {
133
134
  readonly name = 'spaceship';
135
+ // a REST API: GET and HEAD never change state
136
+ static override readonly safeMethods = REST_SAFE_METHODS;
134
137
 
135
138
  static readonly displayName = 'Spaceship';
136
139
  static readonly website = 'spaceship.com';