@aoxborrow/registrar-client 0.1.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/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/index.cjs +6798 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +768 -0
- package/dist/index.d.ts +768 -0
- package/dist/index.js +6747 -0
- package/dist/index.js.map +1 -0
- package/package.json +110 -0
- package/src/client.ts +200 -0
- package/src/constants.ts +11 -0
- package/src/errors.ts +192 -0
- package/src/features.ts +104 -0
- package/src/http.ts +230 -0
- package/src/index.ts +39 -0
- package/src/portfolio.ts +65 -0
- package/src/registrar.ts +240 -0
- package/src/registrars/cloudflare.ts +1205 -0
- package/src/registrars/dynadot.ts +1097 -0
- package/src/registrars/gandi.ts +970 -0
- package/src/registrars/godaddy.ts +1342 -0
- package/src/registrars/index.ts +41 -0
- package/src/registrars/namebright.ts +775 -0
- package/src/registrars/namecheap.ts +870 -0
- package/src/registrars/namesilo.ts +997 -0
- package/src/registrars/porkbun.ts +930 -0
- package/src/registrars/spaceship.ts +736 -0
- package/src/types.ts +446 -0
- package/src/utils.ts +135 -0
- package/src/xml.ts +39 -0
|
@@ -0,0 +1,1342 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigField,
|
|
3
|
+
ConnectionResult,
|
|
4
|
+
Contact,
|
|
5
|
+
ContactSet,
|
|
6
|
+
DnsRecord,
|
|
7
|
+
Domain,
|
|
8
|
+
DomainAvailability,
|
|
9
|
+
DomainForward,
|
|
10
|
+
DomainForwardType,
|
|
11
|
+
ListDomainsOptions,
|
|
12
|
+
OperationResult,
|
|
13
|
+
RegisterDomainInput,
|
|
14
|
+
RegistrationConsent,
|
|
15
|
+
RegistrarOptions,
|
|
16
|
+
RequestOptions,
|
|
17
|
+
TldPricing,
|
|
18
|
+
TransferDomainInput,
|
|
19
|
+
} from '../types';
|
|
20
|
+
import { createDomain, filterDomains, settableForwards } from '../utils';
|
|
21
|
+
import {
|
|
22
|
+
ConfigurationError,
|
|
23
|
+
ConsentRequiredError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
NotImplementedError,
|
|
26
|
+
toRegistrarError,
|
|
27
|
+
} from '../errors';
|
|
28
|
+
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
29
|
+
import { Feature, type RegistrarFeature } from '../features';
|
|
30
|
+
import type { RegistrarCredentials } from '../types';
|
|
31
|
+
|
|
32
|
+
// a mailing address as GoDaddy models it
|
|
33
|
+
interface GoDaddyAddress {
|
|
34
|
+
address1?: string;
|
|
35
|
+
address2?: string;
|
|
36
|
+
city?: string;
|
|
37
|
+
state?: string;
|
|
38
|
+
postalCode?: string;
|
|
39
|
+
country?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// a contact as GoDaddy models it (registrant / admin / tech / billing)
|
|
43
|
+
interface GoDaddyContact {
|
|
44
|
+
nameFirst?: string;
|
|
45
|
+
nameLast?: string;
|
|
46
|
+
organization?: string;
|
|
47
|
+
email?: string;
|
|
48
|
+
phone?: string;
|
|
49
|
+
fax?: string;
|
|
50
|
+
addressMailing?: GoDaddyAddress;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface GoDaddyDomain {
|
|
54
|
+
domain: string;
|
|
55
|
+
status?: string;
|
|
56
|
+
createdAt?: string;
|
|
57
|
+
expires?: string;
|
|
58
|
+
renewDeadline?: string;
|
|
59
|
+
renewAuto?: boolean;
|
|
60
|
+
locked?: boolean;
|
|
61
|
+
privacy?: boolean;
|
|
62
|
+
nameServers?: string[];
|
|
63
|
+
contactRegistrant?: GoDaddyContact;
|
|
64
|
+
contactAdmin?: GoDaddyContact;
|
|
65
|
+
contactTech?: GoDaddyContact;
|
|
66
|
+
contactBilling?: GoDaddyContact;
|
|
67
|
+
authCode?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// one entry from POST /v1/domains/available; `price` is in micro-units of `currency`
|
|
71
|
+
interface GoDaddyAvailability {
|
|
72
|
+
domain: string;
|
|
73
|
+
available?: boolean;
|
|
74
|
+
price?: number;
|
|
75
|
+
currency?: string;
|
|
76
|
+
period?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface GoDaddyAvailabilityResponse {
|
|
80
|
+
domains?: GoDaddyAvailability[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// a DNS record as GoDaddy models it
|
|
84
|
+
interface GoDaddyRecord {
|
|
85
|
+
type: string;
|
|
86
|
+
name: string;
|
|
87
|
+
data: string;
|
|
88
|
+
ttl?: number;
|
|
89
|
+
priority?: number;
|
|
90
|
+
weight?: number;
|
|
91
|
+
port?: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// a legal agreement GoDaddy requires consent to before registering a TLD
|
|
95
|
+
interface GoDaddyAgreement {
|
|
96
|
+
agreementKey: string;
|
|
97
|
+
title?: string;
|
|
98
|
+
url?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// GoDaddy models forwarding as a per-fqdn resource under the v2 customer-scoped
|
|
102
|
+
// route /v2/customers/{customerId}/domains/forwards/{fqdn}. Note the asymmetry
|
|
103
|
+
// between the write and read enums: a write takes REDIRECT_PERMANENT (301) /
|
|
104
|
+
// REDIRECT_TEMPORARY (302) / MASKED (frame), but a read returns the *_REDIRECT
|
|
105
|
+
// spelling (PERMANENT_REDIRECT / TEMPORARY_REDIRECT). `mask` is only meaningful
|
|
106
|
+
// for MASKED. Verified live 2026-08-29.
|
|
107
|
+
interface GoDaddyForward {
|
|
108
|
+
fqdn?: string;
|
|
109
|
+
type?: string;
|
|
110
|
+
url?: string;
|
|
111
|
+
mask?: { title?: string; description?: string; keywords?: string };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// normalized DomainForwardType → the token the v2 write endpoint expects
|
|
115
|
+
const GD_FORWARD_TYPE: Record<'temporary' | 'permanent', string> = {
|
|
116
|
+
permanent: 'REDIRECT_PERMANENT',
|
|
117
|
+
temporary: 'REDIRECT_TEMPORARY',
|
|
118
|
+
};
|
|
119
|
+
// every token a v2 read might return → normalized DomainForwardType. Both the
|
|
120
|
+
// write spelling (REDIRECT_*) and the read spelling (*_REDIRECT) are accepted so
|
|
121
|
+
// the map is robust to either surface.
|
|
122
|
+
const GD_TYPE_TO_FORWARD: Record<string, DomainForwardType> = {
|
|
123
|
+
REDIRECT_PERMANENT: 'permanent',
|
|
124
|
+
REDIRECT_TEMPORARY: 'temporary',
|
|
125
|
+
PERMANENT_REDIRECT: 'permanent',
|
|
126
|
+
TEMPORARY_REDIRECT: 'temporary',
|
|
127
|
+
MASKED: 'masked', // read-only; setDomainForwarding rejects it
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// GoDaddy customerId is a UUID; a raw shopper/account number is not.
|
|
131
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
132
|
+
|
|
133
|
+
// GoDaddy reports availability prices in micro-units (1,000,000 = 1 unit of currency)
|
|
134
|
+
const PRICE_MICRO_UNITS = 1_000_000;
|
|
135
|
+
|
|
136
|
+
// --- v3 API shapes ---------------------------------------------------------
|
|
137
|
+
// The v3 "Domain Lifecycle Management" API models money, domains, DNS, and
|
|
138
|
+
// registration differently from v1. These interfaces cover only the fields we
|
|
139
|
+
// read/write.
|
|
140
|
+
|
|
141
|
+
// v3 "Simple Money": `value` is an integer in the currency's minor units
|
|
142
|
+
// (cents for USD/EUR, whole units for zero-decimal currencies like JPY).
|
|
143
|
+
interface GdV3Money {
|
|
144
|
+
currencyCode?: string;
|
|
145
|
+
value?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface GdV3TermPrice {
|
|
149
|
+
period?: number;
|
|
150
|
+
price?: GdV3Money;
|
|
151
|
+
renewalPrice?: GdV3Money;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface GdV3Availability {
|
|
155
|
+
domain: string;
|
|
156
|
+
available?: boolean;
|
|
157
|
+
prices?: GdV3TermPrice[];
|
|
158
|
+
inventory?: string; // e.g. "STANDARD" | "PREMIUM"
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface GdV3Domain {
|
|
162
|
+
domain: string;
|
|
163
|
+
status?: string;
|
|
164
|
+
createdAt?: string;
|
|
165
|
+
updatedAt?: string;
|
|
166
|
+
expiresAt?: string;
|
|
167
|
+
renewBy?: string;
|
|
168
|
+
autoRenew?: boolean;
|
|
169
|
+
privacy?: boolean;
|
|
170
|
+
transferLock?: boolean;
|
|
171
|
+
nameServers?: string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface GdV3Link {
|
|
175
|
+
href: string;
|
|
176
|
+
rel?: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface GdV3DomainCollection {
|
|
180
|
+
items?: GdV3Domain[];
|
|
181
|
+
links?: GdV3Link[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// one DNS record in a v3 zone; `recordId` is server-assigned and required to
|
|
185
|
+
// update/delete an individual record (v3 has no bulk replace).
|
|
186
|
+
interface GdV3DnsRecord {
|
|
187
|
+
recordId?: string;
|
|
188
|
+
name: string;
|
|
189
|
+
type: string;
|
|
190
|
+
data: string;
|
|
191
|
+
ttl?: number;
|
|
192
|
+
priority?: number;
|
|
193
|
+
weight?: number;
|
|
194
|
+
port?: number;
|
|
195
|
+
service?: string;
|
|
196
|
+
protocol?: string;
|
|
197
|
+
flag?: number;
|
|
198
|
+
tag?: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface GdV3DnsRecordPage {
|
|
202
|
+
items?: GdV3DnsRecord[];
|
|
203
|
+
totalPages?: number;
|
|
204
|
+
links?: GdV3Link[];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface GdV3Fee {
|
|
208
|
+
type: string;
|
|
209
|
+
fee?: GdV3Money;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface GdV3Agreement {
|
|
213
|
+
agreementType: string;
|
|
214
|
+
title?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface GdV3Quote {
|
|
218
|
+
quoteToken?: string;
|
|
219
|
+
requiredAgreements?: GdV3Agreement[];
|
|
220
|
+
fees?: GdV3Fee[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface GdV3Registration {
|
|
224
|
+
registrationId?: string;
|
|
225
|
+
operationId?: string;
|
|
226
|
+
status?: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface GdV3Operation {
|
|
230
|
+
operationId?: string;
|
|
231
|
+
status?: string;
|
|
232
|
+
error?: { code?: string; message?: string };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* GoDaddy Registrar
|
|
237
|
+
* API docs: https://developer.godaddy.com/en/docs/api-users/domains
|
|
238
|
+
*
|
|
239
|
+
* GoDaddy runs two concurrent API surfaces and this provider is a hybrid:
|
|
240
|
+
*
|
|
241
|
+
* - **v3** (Domain Lifecycle Management) — the modern surface, PAT/Bearer only.
|
|
242
|
+
* Covers discovery (availability/suggestions), registration (quote → execute),
|
|
243
|
+
* DNS records, nameservers, and list/get. It has **no** endpoints for the
|
|
244
|
+
* post-registration management operations.
|
|
245
|
+
* - **v1** — the legacy surface, accepts either a PAT or the deprecated
|
|
246
|
+
* `sso-key` credential. It's the only place renew, transfer, auto-renew,
|
|
247
|
+
* lock/unlock, privacy, and contact updates live, and the only surface OTE
|
|
248
|
+
* (the test environment) exposes — OTE has no v3.
|
|
249
|
+
*
|
|
250
|
+
* Routing (`useV3`): v3 is used only in production **with a PAT**. With an
|
|
251
|
+
* sso-key, or against OTE/sandbox (which has no v3), every call falls back to
|
|
252
|
+
* v1. The management operations above are always v1 regardless. This keeps the
|
|
253
|
+
* full contract working in OTE (all-v1) while preferring v3 in production.
|
|
254
|
+
*
|
|
255
|
+
* Credentials: supply `apiToken` (a Personal Access Token) for production/v3, or
|
|
256
|
+
* `apiKey` + `apiSecret` (an OTE Key/Secret) for sandbox testing. The auth
|
|
257
|
+
* header is chosen automatically — `Bearer` when a token is present, else
|
|
258
|
+
* `sso-key`.
|
|
259
|
+
*
|
|
260
|
+
* `registerDomain` / `transferIn` spend real money. In v3, register uses the
|
|
261
|
+
* quote-then-execute flow (price-locked via `quoteToken`, agreements
|
|
262
|
+
* acknowledged in the consent block); v3 takes the contact profile from the
|
|
263
|
+
* account identity and derives `agreedBy` server-side, so only `consent` (the
|
|
264
|
+
* agreement acknowledgement) is required. In v1 (OTE), register uses the legacy
|
|
265
|
+
* purchase flow and requires `consent.agreedBy` = the consenting party's IP.
|
|
266
|
+
* Callers omitting `consent` entirely get a `ConsentRequiredError`.
|
|
267
|
+
*/
|
|
268
|
+
export class GoDaddyRegistrar extends BaseRegistrar {
|
|
269
|
+
readonly name = 'godaddy';
|
|
270
|
+
|
|
271
|
+
static readonly displayName = 'GoDaddy';
|
|
272
|
+
static readonly helpText =
|
|
273
|
+
'Authenticate one of two ways. For production, create a Personal Access ' +
|
|
274
|
+
'Token (PAT) at https://developer.godaddy.com and pass it as `apiToken` — ' +
|
|
275
|
+
'this enables the modern v3 API. For sandbox testing, create OTE ' +
|
|
276
|
+
'(test environment) keys and pass `apiKey` + `apiSecret` with ' +
|
|
277
|
+
'{ environment: "sandbox" }; OTE only exposes the legacy v1 API. The ' +
|
|
278
|
+
'sso-key (API Key/Secret) scheme is deprecated by GoDaddy in 2026. ' +
|
|
279
|
+
'Domain forwarding uses the v2 customer-scoped API — set `customerId` to ' +
|
|
280
|
+
'your GoDaddy customer UUID, or to your numeric shopper/account ID (which ' +
|
|
281
|
+
'the client resolves to the UUID via an sso-key-authenticated lookup).';
|
|
282
|
+
static readonly configFields: ConfigField[] = [
|
|
283
|
+
{ name: 'apiToken', label: 'API Token (PAT)', type: 'password', required: false },
|
|
284
|
+
{ name: 'apiKey', label: 'API Key', type: 'password', required: false },
|
|
285
|
+
{ name: 'apiSecret', label: 'API Secret', type: 'password', required: false },
|
|
286
|
+
{ name: 'customerId', label: 'Customer ID or Shopper ID', type: 'text', required: false },
|
|
287
|
+
];
|
|
288
|
+
// GoDaddy's OTE ("Operational Test Environment") is its sandbox
|
|
289
|
+
static readonly supportsSandbox = true;
|
|
290
|
+
// Beyond core: domain forwarding and transfer-out auth code (the `authCode`
|
|
291
|
+
// field on the v1 domain-detail response). Forwarding uses the v2
|
|
292
|
+
// customer-scoped route /v2/customers/{customerId}/domains/forwards/{fqdn}
|
|
293
|
+
// (the old v1 /v1/domains/forwards routes now 404); it requires `customerId`
|
|
294
|
+
// to be configured — verified live 2026-08-29. DNSSEC has no dedicated
|
|
295
|
+
// endpoint (DS records go through the generic DNS API), and there's no
|
|
296
|
+
// glue-record, email, or push-webhook API.
|
|
297
|
+
static readonly extendedFeatures: readonly RegistrarFeature[] = [
|
|
298
|
+
Feature.GetAuthCode,
|
|
299
|
+
Feature.GetDomainForwarding,
|
|
300
|
+
Feature.SetDomainForwarding,
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
// true → prefer the v3 API (production + PAT). false → all calls use v1
|
|
304
|
+
// (sso-key auth, or OTE/sandbox where v3 does not exist).
|
|
305
|
+
private readonly useV3: boolean;
|
|
306
|
+
|
|
307
|
+
// memoized v2 customerId (UUID) resolution; see resolveCustomerId()
|
|
308
|
+
private customerIdPromise?: Promise<string>;
|
|
309
|
+
|
|
310
|
+
constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
|
|
311
|
+
const authHeader = GoDaddyRegistrar.authHeader(credentials);
|
|
312
|
+
super(
|
|
313
|
+
credentials,
|
|
314
|
+
{
|
|
315
|
+
baseUrl: selectBaseUrl('GoDaddy', options?.environment, {
|
|
316
|
+
production: 'https://api.godaddy.com',
|
|
317
|
+
sandbox: 'https://api.ote-godaddy.com',
|
|
318
|
+
}),
|
|
319
|
+
headers: {
|
|
320
|
+
'Authorization': authHeader,
|
|
321
|
+
'Accept': 'application/json',
|
|
322
|
+
'Content-Type': 'application/json',
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
options
|
|
326
|
+
);
|
|
327
|
+
// v3 requires a PAT and is not deployed to OTE, so only use it in production
|
|
328
|
+
// when a token was supplied.
|
|
329
|
+
this.useV3 = options?.environment !== 'sandbox' && !!credentials.apiToken;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Choose the Authorization header: Bearer PAT if a token is supplied,
|
|
333
|
+
// otherwise the legacy sso-key. Missing credentials produce a header that the
|
|
334
|
+
// API rejects at request time (matching the other providers), rather than
|
|
335
|
+
// throwing at construction.
|
|
336
|
+
private static authHeader(credentials: RegistrarCredentials): string {
|
|
337
|
+
if (credentials.apiToken) return `Bearer ${credentials.apiToken}`;
|
|
338
|
+
return `sso-key ${credentials.apiKey ?? ''}:${credentials.apiSecret ?? ''}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
|
|
342
|
+
try {
|
|
343
|
+
if (this.useV3) {
|
|
344
|
+
await this.http.request<GdV3DomainCollection>({
|
|
345
|
+
path: '/v3/domains/domain-names',
|
|
346
|
+
query: { pageSize: 1 },
|
|
347
|
+
...opts,
|
|
348
|
+
});
|
|
349
|
+
} else {
|
|
350
|
+
await this.http.request<GoDaddyDomain[]>({ path: '/v1/domains', ...opts });
|
|
351
|
+
}
|
|
352
|
+
return { success: true, message: 'Connection successful' };
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
|
|
359
|
+
const { search, ...reqOpts } = opts ?? {};
|
|
360
|
+
const domains = this.useV3
|
|
361
|
+
? await this.listDomainsV3(reqOpts)
|
|
362
|
+
: await this.listDomainsV1(reqOpts);
|
|
363
|
+
return filterDomains(domains, search);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// v3: cursor-paginated collection; follow the rel="next" HATEOAS link.
|
|
367
|
+
private async listDomainsV3(reqOpts: RequestOptions): Promise<Domain[]> {
|
|
368
|
+
const domains: Domain[] = [];
|
|
369
|
+
let path = '/v3/domains/domain-names';
|
|
370
|
+
let query: Record<string, string | number> | undefined = { pageSize: 200 }; // v3 max
|
|
371
|
+
for (;;) {
|
|
372
|
+
const res = await this.http.request<GdV3DomainCollection>({ path, query, ...reqOpts });
|
|
373
|
+
for (const d of res.items ?? []) domains.push(this.toDomainV3(d));
|
|
374
|
+
const next = (res.links ?? []).find(l => l.rel === 'next')?.href;
|
|
375
|
+
if (!next || !res.items?.length) break;
|
|
376
|
+
path = next; // absolute URL; HttpClient passes it through as-is
|
|
377
|
+
query = undefined; // the next link already carries its cursor
|
|
378
|
+
}
|
|
379
|
+
return domains;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// v1: `marker`-paginated array (marker = last domain name seen).
|
|
383
|
+
private async listDomainsV1(reqOpts: RequestOptions): Promise<Domain[]> {
|
|
384
|
+
// status filters exclude expired domains: visible (active), renewable
|
|
385
|
+
// (expiring soon), redemption (grace period). statusGroups repeats in the
|
|
386
|
+
// query string, so it is embedded in the path directly. `includes=nameServers`
|
|
387
|
+
// folds nameservers into this list call (they are otherwise omitted). Its
|
|
388
|
+
// page-size param is literally named `limit`, whose max is 1000.
|
|
389
|
+
const statusGroups = 'statusGroups=VISIBLE&statusGroups=RENEWABLE&statusGroups=REDEMPTION';
|
|
390
|
+
const perPage = 1000; // GoDaddy's maximum page size
|
|
391
|
+
const domains: Domain[] = [];
|
|
392
|
+
let marker: string | undefined;
|
|
393
|
+
for (;;) {
|
|
394
|
+
const markerParam = marker ? `&marker=${encodeURIComponent(marker)}` : '';
|
|
395
|
+
const res = await this.http.request<GoDaddyDomain[]>({
|
|
396
|
+
path: `/v1/domains?limit=${perPage}&includes=nameServers&${statusGroups}${markerParam}`,
|
|
397
|
+
...reqOpts,
|
|
398
|
+
});
|
|
399
|
+
const list = res ?? [];
|
|
400
|
+
for (const d of list) domains.push(this.toDomain(d));
|
|
401
|
+
if (list.length < perPage) break;
|
|
402
|
+
marker = list[list.length - 1]?.domain;
|
|
403
|
+
if (!marker) break;
|
|
404
|
+
}
|
|
405
|
+
return domains;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
|
|
409
|
+
if (this.useV3) {
|
|
410
|
+
const d = await this.http.request<GdV3Domain>({
|
|
411
|
+
path: `/v3/domains/domain-names/${encodeURIComponent(domainName)}`,
|
|
412
|
+
...opts,
|
|
413
|
+
});
|
|
414
|
+
return this.toDomainV3(d);
|
|
415
|
+
}
|
|
416
|
+
const d = await this.http.request<GoDaddyDomain>({
|
|
417
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}`,
|
|
418
|
+
...opts,
|
|
419
|
+
});
|
|
420
|
+
return this.toDomain(d);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
|
|
424
|
+
const domain = await this.getDomain(domainName, opts);
|
|
425
|
+
return domain.nameservers;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
override async checkAvailability(
|
|
429
|
+
domainNames: string[],
|
|
430
|
+
opts?: RequestOptions
|
|
431
|
+
): Promise<DomainAvailability[]> {
|
|
432
|
+
if (this.useV3) {
|
|
433
|
+
// v3 bulk check: POST { domains: [...] }; the response wraps results in
|
|
434
|
+
// `items` and prices are multi-term.
|
|
435
|
+
const res = await this.http.request<{ items?: GdV3Availability[] }>({
|
|
436
|
+
method: 'POST',
|
|
437
|
+
path: '/v3/domains/check-availability',
|
|
438
|
+
body: { domains: domainNames },
|
|
439
|
+
...opts,
|
|
440
|
+
});
|
|
441
|
+
return (res.items ?? []).map(d => {
|
|
442
|
+
const term = pickTerm(d.prices);
|
|
443
|
+
return {
|
|
444
|
+
domainName: d.domain,
|
|
445
|
+
available: d.available ?? false,
|
|
446
|
+
premium: isPremiumInventory(d.inventory),
|
|
447
|
+
price: moneyToMajor(term?.price),
|
|
448
|
+
currency: term?.price?.currencyCode,
|
|
449
|
+
period: term?.period,
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
// v1 bulk check: POST an array of domains. checkType=FULL consults the
|
|
454
|
+
// registry (slower but authoritative) rather than GoDaddy's cache.
|
|
455
|
+
const res = await this.http.request<GoDaddyAvailabilityResponse>({
|
|
456
|
+
method: 'POST',
|
|
457
|
+
path: '/v1/domains/available?checkType=FULL',
|
|
458
|
+
body: domainNames,
|
|
459
|
+
...opts,
|
|
460
|
+
});
|
|
461
|
+
return (res.domains ?? []).map(d => ({
|
|
462
|
+
domainName: d.domain,
|
|
463
|
+
available: d.available ?? false,
|
|
464
|
+
price: d.price != null ? d.price / PRICE_MICRO_UNITS : undefined,
|
|
465
|
+
currency: d.currency,
|
|
466
|
+
period: d.period,
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* GoDaddy has no standalone TLD-pricing endpoint — registration price is
|
|
472
|
+
* returned inline with an availability check. So `getPricing` requires a full
|
|
473
|
+
* domain (e.g. "example.com"), checks its availability, and reports the
|
|
474
|
+
* registration price. A bare TLD throws, since GoDaddy can't price a TLD
|
|
475
|
+
* without a specific name. Only `registration` is known here (GoDaddy's
|
|
476
|
+
* availability response carries no separate renewal/transfer price).
|
|
477
|
+
*/
|
|
478
|
+
override async getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing> {
|
|
479
|
+
if (!tldOrDomain.includes('.')) {
|
|
480
|
+
throw new NotImplementedError(
|
|
481
|
+
`${this.name}: getPricing needs a full domain (e.g. "example.com"); ` +
|
|
482
|
+
'GoDaddy exposes pricing only per-domain via availability, not per-TLD'
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
const tld = tldOrDomain.slice(tldOrDomain.indexOf('.') + 1);
|
|
486
|
+
if (this.useV3) {
|
|
487
|
+
// v3 availability carries both registration and renewal price per term.
|
|
488
|
+
const res = await this.http.request<{ items?: GdV3Availability[] }>({
|
|
489
|
+
method: 'POST',
|
|
490
|
+
path: '/v3/domains/check-availability',
|
|
491
|
+
body: { domains: [tldOrDomain] },
|
|
492
|
+
...opts,
|
|
493
|
+
});
|
|
494
|
+
const term = pickTerm(res.items?.[0]?.prices);
|
|
495
|
+
return {
|
|
496
|
+
tld,
|
|
497
|
+
currency: term?.price?.currencyCode ?? 'USD',
|
|
498
|
+
registration: moneyToMajor(term?.price),
|
|
499
|
+
renewal: moneyToMajor(term?.renewalPrice),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const [result] = await this.checkAvailability([tldOrDomain], opts);
|
|
503
|
+
return {
|
|
504
|
+
tld,
|
|
505
|
+
currency: result?.currency ?? 'USD',
|
|
506
|
+
registration: result?.price,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Registers a domain via GoDaddy's purchase flow. Requires per-call `consent`
|
|
512
|
+
* (with `agreedBy` = the consenting party's IP): this fetches the TLD's
|
|
513
|
+
* agreement keys, then POSTs the purchase with a `consent` block referencing
|
|
514
|
+
* them. GoDaddy requires all four contact roles, so any role the caller omits
|
|
515
|
+
* falls back to the registrant.
|
|
516
|
+
*/
|
|
517
|
+
override async registerDomain(
|
|
518
|
+
domainName: string,
|
|
519
|
+
input: RegisterDomainInput,
|
|
520
|
+
opts?: RequestOptions
|
|
521
|
+
): Promise<OperationResult> {
|
|
522
|
+
const registrant = input.contacts.registrant;
|
|
523
|
+
if (!registrant) {
|
|
524
|
+
throw new Error(`${this.name}: registration requires at least a registrant contact`);
|
|
525
|
+
}
|
|
526
|
+
if (this.useV3) return this.registerDomainV3(domainName, input, opts);
|
|
527
|
+
const tld = domainName.slice(domainName.indexOf('.') + 1);
|
|
528
|
+
const privacy = input.privacy ?? false;
|
|
529
|
+
const consent = await this.buildConsent(input.consent, tld, privacy, false, opts);
|
|
530
|
+
|
|
531
|
+
const body = {
|
|
532
|
+
domain: domainName,
|
|
533
|
+
consent,
|
|
534
|
+
contactRegistrant: toGoDaddyContact(registrant),
|
|
535
|
+
contactAdmin: toGoDaddyContact(input.contacts.admin ?? registrant),
|
|
536
|
+
contactTech: toGoDaddyContact(input.contacts.tech ?? registrant),
|
|
537
|
+
contactBilling: toGoDaddyContact(input.contacts.billing ?? registrant),
|
|
538
|
+
period: input.years ?? 1,
|
|
539
|
+
privacy,
|
|
540
|
+
renewAuto: input.autoRenew ?? false,
|
|
541
|
+
...(input.nameservers ? { nameServers: input.nameservers } : {}),
|
|
542
|
+
};
|
|
543
|
+
return this.mutate(
|
|
544
|
+
{ method: 'POST', path: '/v1/domains/purchase', body },
|
|
545
|
+
`Domain ${domainName} registered successfully`,
|
|
546
|
+
opts
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* v3 registration: quote-then-execute. First POST a quote for the domain/term
|
|
552
|
+
* (price-locked via `quoteToken`, returns the agreements + fees to
|
|
553
|
+
* acknowledge), then POST the registration with the token, a consent block
|
|
554
|
+
* (acknowledging every required agreement + quoted fee), and the contact
|
|
555
|
+
* profile. Registration is async (202), so poll the returned operation to
|
|
556
|
+
* completion. Spends real money; there is no v3 sandbox, so this is
|
|
557
|
+
* documented-but-unverified against a live purchase.
|
|
558
|
+
*/
|
|
559
|
+
private async registerDomainV3(
|
|
560
|
+
domainName: string,
|
|
561
|
+
input: RegisterDomainInput,
|
|
562
|
+
opts?: RequestOptions
|
|
563
|
+
): Promise<OperationResult> {
|
|
564
|
+
if (!input.consent) {
|
|
565
|
+
throw new ConsentRequiredError(
|
|
566
|
+
`${this.name}: registration requires \`consent\` (accepting the registration agreements)`
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
const period = input.years ?? 1;
|
|
570
|
+
const quote = await this.http.request<GdV3Quote>({
|
|
571
|
+
method: 'POST',
|
|
572
|
+
path: '/v3/domains/registration-quotes',
|
|
573
|
+
body: { domain: domainName, period },
|
|
574
|
+
...opts,
|
|
575
|
+
});
|
|
576
|
+
if (!quote.quoteToken) {
|
|
577
|
+
throw new Error(`${this.name}: registration quote did not return a quoteToken`);
|
|
578
|
+
}
|
|
579
|
+
// v3 wants a MINIMAL body for a standard REGISTRY registration:
|
|
580
|
+
// `{ domain, period, quoteToken, consent }`. Two hard-won gotchas:
|
|
581
|
+
// - No `profile` block. Sending one (even just autoRenew/privacy) is
|
|
582
|
+
// rejected `INVALID_BODY`. Contacts come from the account identity
|
|
583
|
+
// (the quote's `resolved.contactSource: "ACCOUNT"`), and autoRenew /
|
|
584
|
+
// nameservers are applied as post-registration steps below.
|
|
585
|
+
// - `acknowledgedFees` must be OMITTED unless the quote carried fees
|
|
586
|
+
// (the array is `minItems: 1`, so `[]` is rejected). `agreedBy` is
|
|
587
|
+
// resolved server-side from the token + caller IP, so it's not sent.
|
|
588
|
+
const consent = {
|
|
589
|
+
agreementTypes: (quote.requiredAgreements ?? []).map(a => a.agreementType),
|
|
590
|
+
agreedAt: input.consent.agreedAt ?? new Date().toISOString(),
|
|
591
|
+
...(quote.fees && quote.fees.length ? { acknowledgedFees: quote.fees } : {}),
|
|
592
|
+
};
|
|
593
|
+
const body = { domain: domainName, period, quoteToken: quote.quoteToken, consent };
|
|
594
|
+
try {
|
|
595
|
+
const reg = await this.http.request<GdV3Registration>({
|
|
596
|
+
method: 'POST',
|
|
597
|
+
path: '/v3/domains/registrations',
|
|
598
|
+
body,
|
|
599
|
+
// v3 requires an Idempotency-Key on every execute endpoint; a retry
|
|
600
|
+
// with the same key returns the original result instead of double-buying
|
|
601
|
+
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
|
602
|
+
...opts,
|
|
603
|
+
});
|
|
604
|
+
if (reg.operationId) await this.pollOperation(reg.operationId, opts);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
607
|
+
}
|
|
608
|
+
// Post-registration: the minimal body can't carry these, so apply the
|
|
609
|
+
// caller's intent now. GoDaddy registers with the account's default
|
|
610
|
+
// auto-renew (typically ON), so always assert the requested value.
|
|
611
|
+
const warnings: string[] = [];
|
|
612
|
+
try {
|
|
613
|
+
await this.setAutoRenew(domainName, input.autoRenew ?? false, opts);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
warnings.push(`auto-renew not applied (${toRegistrarError(error).message})`);
|
|
616
|
+
}
|
|
617
|
+
if (input.nameservers) {
|
|
618
|
+
try {
|
|
619
|
+
await this.updateNameservers(domainName, input.nameservers, opts);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
warnings.push(`nameservers not applied (${toRegistrarError(error).message})`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const suffix = warnings.length ? ` (registered, but ${warnings.join('; ')})` : '';
|
|
625
|
+
return { success: true, message: `Domain ${domainName} registered successfully${suffix}` };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Poll a v3 async operation until it leaves the pending states. GoDaddy
|
|
630
|
+
* reports COMPLETED / SUCCESS on success and FAILED / ERROR (with an `error`
|
|
631
|
+
* body) on failure; a failed operation throws.
|
|
632
|
+
*/
|
|
633
|
+
private async pollOperation(
|
|
634
|
+
operationId: string,
|
|
635
|
+
opts?: RequestOptions,
|
|
636
|
+
attempts = 10,
|
|
637
|
+
delayMs = 1500
|
|
638
|
+
): Promise<void> {
|
|
639
|
+
for (let i = 0; i < attempts; i++) {
|
|
640
|
+
const op = await this.http.request<GdV3Operation>({
|
|
641
|
+
path: `/v3/domains/operations/${encodeURIComponent(operationId)}`,
|
|
642
|
+
...opts,
|
|
643
|
+
});
|
|
644
|
+
const status = (op.status ?? '').toUpperCase();
|
|
645
|
+
if (status === 'COMPLETED' || status === 'SUCCESS' || status === 'SUCCEEDED') return;
|
|
646
|
+
if (status === 'FAILED' || status === 'ERROR') {
|
|
647
|
+
throw new Error(op.error?.message ?? `operation ${operationId} failed`);
|
|
648
|
+
}
|
|
649
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
650
|
+
}
|
|
651
|
+
// still pending after the budget — treat as accepted; the caller can re-check
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Transfers a domain in with its auth code. GoDaddy's transfer body is much
|
|
656
|
+
* smaller than a purchase — just the auth code + a `consent` block (fetched
|
|
657
|
+
* with `forTransfer=true`, since transfer agreements can differ) + optional
|
|
658
|
+
* period/renewAuto/privacy. No contacts: the existing registration's carry
|
|
659
|
+
* over. v3 has no transfer endpoint, so this is always the v1 flow (which is
|
|
660
|
+
* also what OTE exposes for testing).
|
|
661
|
+
*/
|
|
662
|
+
override async transferIn(
|
|
663
|
+
domainName: string,
|
|
664
|
+
input: TransferDomainInput,
|
|
665
|
+
opts?: RequestOptions
|
|
666
|
+
): Promise<OperationResult> {
|
|
667
|
+
const tld = domainName.slice(domainName.indexOf('.') + 1);
|
|
668
|
+
const privacy = input.privacy ?? false;
|
|
669
|
+
const consent = await this.buildConsent(input.consent, tld, privacy, true, opts);
|
|
670
|
+
|
|
671
|
+
const body: Record<string, unknown> = {
|
|
672
|
+
authCode: input.authCode,
|
|
673
|
+
consent,
|
|
674
|
+
privacy,
|
|
675
|
+
renewAuto: input.autoRenew ?? false,
|
|
676
|
+
};
|
|
677
|
+
if (input.years != null) body.period = input.years;
|
|
678
|
+
return this.mutate(
|
|
679
|
+
{ method: 'POST', path: `/v1/domains/${encodeURIComponent(domainName)}/transfer`, body },
|
|
680
|
+
`Domain ${domainName} transfer requested successfully`,
|
|
681
|
+
opts
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Builds the GoDaddy `consent` block shared by register and transfer: validates
|
|
687
|
+
* consent is present (with `agreedBy`), fetches the agreement keys for the TLD
|
|
688
|
+
* (register vs. transfer agreements differ, hence `forTransfer`), and stamps
|
|
689
|
+
* `agreedAt`.
|
|
690
|
+
*/
|
|
691
|
+
private async buildConsent(
|
|
692
|
+
consent: RegistrationConsent | undefined,
|
|
693
|
+
tld: string,
|
|
694
|
+
privacy: boolean,
|
|
695
|
+
forTransfer: boolean,
|
|
696
|
+
opts?: RequestOptions
|
|
697
|
+
): Promise<{ agreementKeys: string[]; agreedAt: string; agreedBy: string }> {
|
|
698
|
+
if (!consent) {
|
|
699
|
+
throw new ConsentRequiredError(
|
|
700
|
+
`${this.name}: this operation requires consent — supply \`consent\` ` +
|
|
701
|
+
'(accepting the registration agreements)'
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
if (!consent.agreedBy) {
|
|
705
|
+
throw new ConsentRequiredError(
|
|
706
|
+
`${this.name}: consent.agreedBy is required and must be the consenting party's IP address`
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const query: Record<string, string | number | boolean> = { tlds: tld, privacy };
|
|
710
|
+
if (forTransfer) query.forTransfer = true;
|
|
711
|
+
const agreements = await this.http.request<GoDaddyAgreement[]>({
|
|
712
|
+
path: '/v1/domains/agreements',
|
|
713
|
+
query,
|
|
714
|
+
...opts,
|
|
715
|
+
});
|
|
716
|
+
const agreementKeys = (agreements ?? []).map(a => a.agreementKey);
|
|
717
|
+
if (agreementKeys.length === 0) {
|
|
718
|
+
throw new ConsentRequiredError(`${this.name}: no agreements were returned for .${tld}`);
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
agreementKeys,
|
|
722
|
+
agreedAt: consent.agreedAt ?? new Date().toISOString(),
|
|
723
|
+
agreedBy: consent.agreedBy,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
override async renewDomain(
|
|
728
|
+
domainName: string,
|
|
729
|
+
years = 1,
|
|
730
|
+
opts?: RequestOptions
|
|
731
|
+
): Promise<OperationResult> {
|
|
732
|
+
return this.mutate(
|
|
733
|
+
{
|
|
734
|
+
method: 'POST',
|
|
735
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/renew`,
|
|
736
|
+
body: { period: years },
|
|
737
|
+
},
|
|
738
|
+
'Domain renewed successfully',
|
|
739
|
+
opts
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
override async setAutoRenew(
|
|
744
|
+
domainName: string,
|
|
745
|
+
enabled: boolean,
|
|
746
|
+
opts?: RequestOptions
|
|
747
|
+
): Promise<OperationResult> {
|
|
748
|
+
return this.mutate(
|
|
749
|
+
{
|
|
750
|
+
method: 'PATCH',
|
|
751
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}`,
|
|
752
|
+
body: { renewAuto: enabled },
|
|
753
|
+
},
|
|
754
|
+
`Auto-renew ${enabled ? 'enabled' : 'disabled'} successfully`,
|
|
755
|
+
opts
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
override async updateNameservers(
|
|
760
|
+
domainName: string,
|
|
761
|
+
nameservers: string[],
|
|
762
|
+
opts?: RequestOptions
|
|
763
|
+
): Promise<OperationResult> {
|
|
764
|
+
if (nameservers.length < 1 || nameservers.length > 13) {
|
|
765
|
+
throw new Error('GoDaddy requires 1-13 nameservers');
|
|
766
|
+
}
|
|
767
|
+
if (this.useV3) {
|
|
768
|
+
// v3 replaces nameservers with a dedicated PUT; the body is a bare array.
|
|
769
|
+
// Like /registrations, this execute endpoint requires an Idempotency-Key.
|
|
770
|
+
return this.mutate(
|
|
771
|
+
{
|
|
772
|
+
method: 'PUT',
|
|
773
|
+
path: `/v3/domains/domain-names/${encodeURIComponent(domainName)}/nameservers`,
|
|
774
|
+
body: nameservers,
|
|
775
|
+
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
|
776
|
+
},
|
|
777
|
+
'Nameservers updated successfully',
|
|
778
|
+
opts
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
return this.mutate(
|
|
782
|
+
{
|
|
783
|
+
method: 'PATCH',
|
|
784
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}`,
|
|
785
|
+
body: { nameServers: nameservers },
|
|
786
|
+
},
|
|
787
|
+
'Nameservers updated successfully',
|
|
788
|
+
opts
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
override async lockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
793
|
+
return this.mutate(
|
|
794
|
+
{
|
|
795
|
+
method: 'PATCH',
|
|
796
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}`,
|
|
797
|
+
body: { locked: true },
|
|
798
|
+
},
|
|
799
|
+
'Domain locked successfully',
|
|
800
|
+
opts
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
override async unlockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
805
|
+
return this.mutate(
|
|
806
|
+
{
|
|
807
|
+
method: 'PATCH',
|
|
808
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}`,
|
|
809
|
+
body: { locked: false },
|
|
810
|
+
},
|
|
811
|
+
'Domain unlocked successfully',
|
|
812
|
+
opts
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Disabling privacy is a simple DELETE. Enabling it is a paid purchase
|
|
818
|
+
* (`POST /v1/domains/{domain}/privacy/purchase`) requiring a consent block and
|
|
819
|
+
* payment, so it's left unimplemented rather than silently spending money.
|
|
820
|
+
*/
|
|
821
|
+
override async setPrivacy(
|
|
822
|
+
domainName: string,
|
|
823
|
+
enabled: boolean,
|
|
824
|
+
opts?: RequestOptions
|
|
825
|
+
): Promise<OperationResult> {
|
|
826
|
+
if (enabled) {
|
|
827
|
+
throw new NotImplementedError(
|
|
828
|
+
`${this.name}: enabling privacy is a paid purchase and is not implemented; ` +
|
|
829
|
+
'only disabling privacy is supported via the API'
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
const encoded = encodeURIComponent(domainName);
|
|
833
|
+
try {
|
|
834
|
+
await this.http.request({
|
|
835
|
+
method: 'DELETE',
|
|
836
|
+
path: `/v1/domains/${encoded}/privacy`,
|
|
837
|
+
...opts,
|
|
838
|
+
});
|
|
839
|
+
return { success: true, message: 'Privacy disabled successfully' };
|
|
840
|
+
} catch (error) {
|
|
841
|
+
const err = toRegistrarError(error);
|
|
842
|
+
// GoDaddy's free privacy ("Free DBP") can't be canceled via DELETE — it
|
|
843
|
+
// returns 409 CONFLICTING_STATUS. The only way to turn it off is to expose
|
|
844
|
+
// the WHOIS via a domain PATCH (`exposeWhois: true`), but GoDaddy requires a
|
|
845
|
+
// full legal consent block on that PATCH (`agreedAt` + `agreedBy` = the
|
|
846
|
+
// consenting party's IP + `agreementKeys`) — the same consent as a
|
|
847
|
+
// registration. setPrivacy can't carry that (and this client is edge-safe
|
|
848
|
+
// with no reliable IP source), so surface a clear, actionable error instead
|
|
849
|
+
// of silently failing. Paid DBP still cancels through the DELETE above.
|
|
850
|
+
if (err.status === 409) {
|
|
851
|
+
return {
|
|
852
|
+
success: false,
|
|
853
|
+
message:
|
|
854
|
+
`${this.name}: this domain uses free WHOIS privacy (Free DBP), which the API ` +
|
|
855
|
+
'only disables by exposing the WHOIS via a consent block (agreedAt + agreedBy IP ' +
|
|
856
|
+
'+ agreementKeys) that setPrivacy cannot supply; disable it in the GoDaddy dashboard.',
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
return { success: false, message: err.message };
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
override async getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet> {
|
|
864
|
+
// v3's domain record omits contacts, so read them from v1 in both modes.
|
|
865
|
+
const d = await this.http.request<GoDaddyDomain>({
|
|
866
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}`,
|
|
867
|
+
...opts,
|
|
868
|
+
});
|
|
869
|
+
return {
|
|
870
|
+
registrant: fromGoDaddyContact(d.contactRegistrant),
|
|
871
|
+
admin: fromGoDaddyContact(d.contactAdmin),
|
|
872
|
+
tech: fromGoDaddyContact(d.contactTech),
|
|
873
|
+
billing: fromGoDaddyContact(d.contactBilling),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
override async updateContacts(
|
|
878
|
+
domainName: string,
|
|
879
|
+
contacts: ContactSet,
|
|
880
|
+
opts?: RequestOptions
|
|
881
|
+
): Promise<OperationResult> {
|
|
882
|
+
// include only the roles the caller supplied
|
|
883
|
+
const body: Record<string, GoDaddyContact> = {};
|
|
884
|
+
if (contacts.registrant) body.contactRegistrant = toGoDaddyContact(contacts.registrant);
|
|
885
|
+
if (contacts.admin) body.contactAdmin = toGoDaddyContact(contacts.admin);
|
|
886
|
+
if (contacts.tech) body.contactTech = toGoDaddyContact(contacts.tech);
|
|
887
|
+
if (contacts.billing) body.contactBilling = toGoDaddyContact(contacts.billing);
|
|
888
|
+
if (Object.keys(body).length === 0) {
|
|
889
|
+
throw new Error('GoDaddy updateContacts requires at least one contact');
|
|
890
|
+
}
|
|
891
|
+
return this.mutate(
|
|
892
|
+
{ method: 'PATCH', path: `/v1/domains/${encodeURIComponent(domainName)}/contacts`, body },
|
|
893
|
+
'Contacts updated successfully',
|
|
894
|
+
opts
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
|
|
899
|
+
if (this.useV3) {
|
|
900
|
+
return (await this.getV3Records(domainName, opts)).map(fromV3Record);
|
|
901
|
+
}
|
|
902
|
+
const records = await this.http.request<GoDaddyRecord[]>({
|
|
903
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/records`,
|
|
904
|
+
...opts,
|
|
905
|
+
});
|
|
906
|
+
return (records ?? []).map(r => ({
|
|
907
|
+
type: r.type,
|
|
908
|
+
name: r.name,
|
|
909
|
+
value: r.data,
|
|
910
|
+
ttl: r.ttl,
|
|
911
|
+
priority: r.priority,
|
|
912
|
+
weight: r.weight,
|
|
913
|
+
port: r.port,
|
|
914
|
+
}));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Replaces the entire record set (full-replace semantics): any record not
|
|
919
|
+
* present in `records` is removed. GoDaddy requires a minimum TTL of 600
|
|
920
|
+
* seconds, so records without an explicit TTL default to 3600.
|
|
921
|
+
*
|
|
922
|
+
* v1 does this in one bulk PUT. v3 has no bulk endpoint — only per-record
|
|
923
|
+
* POST/PUT/DELETE — so the v3 path diffs the desired set against the current
|
|
924
|
+
* one (keyed by type+name+value): delete records no longer wanted, add new
|
|
925
|
+
* ones, and PUT matched records whose TTL/priority/etc. changed.
|
|
926
|
+
*/
|
|
927
|
+
override async setDnsRecords(
|
|
928
|
+
domainName: string,
|
|
929
|
+
records: DnsRecord[],
|
|
930
|
+
opts?: RequestOptions
|
|
931
|
+
): Promise<OperationResult> {
|
|
932
|
+
if (this.useV3) return this.setDnsRecordsV3(domainName, records, opts);
|
|
933
|
+
const body: GoDaddyRecord[] = records.map(r => {
|
|
934
|
+
const record: GoDaddyRecord = {
|
|
935
|
+
type: r.type.toUpperCase(),
|
|
936
|
+
name: r.name,
|
|
937
|
+
data: r.value,
|
|
938
|
+
ttl: r.ttl ?? 3600,
|
|
939
|
+
};
|
|
940
|
+
if (r.priority != null) record.priority = r.priority;
|
|
941
|
+
if (r.weight != null) record.weight = r.weight;
|
|
942
|
+
if (r.port != null) record.port = r.port;
|
|
943
|
+
return record;
|
|
944
|
+
});
|
|
945
|
+
return this.mutate(
|
|
946
|
+
{ method: 'PUT', path: `/v1/domains/${encodeURIComponent(domainName)}/records`, body },
|
|
947
|
+
'DNS records updated successfully',
|
|
948
|
+
opts
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Read every DNS record in a v3 zone, following pagination.
|
|
953
|
+
private async getV3Records(domainName: string, opts?: RequestOptions): Promise<GdV3DnsRecord[]> {
|
|
954
|
+
const zone = encodeURIComponent(domainName);
|
|
955
|
+
const records: GdV3DnsRecord[] = [];
|
|
956
|
+
let path: string | undefined = `/v3/domains/zones/${zone}/dns-records`;
|
|
957
|
+
let query: Record<string, string | number> | undefined = { pageSize: 100 }; // v3 max
|
|
958
|
+
while (path) {
|
|
959
|
+
const page: GdV3DnsRecordPage = await this.http.request<GdV3DnsRecordPage>({
|
|
960
|
+
path,
|
|
961
|
+
query,
|
|
962
|
+
...opts,
|
|
963
|
+
});
|
|
964
|
+
for (const r of page.items ?? []) records.push(r);
|
|
965
|
+
const next = (page.links ?? []).find(l => l.rel === 'next')?.href;
|
|
966
|
+
path = next && page.items?.length ? next : undefined;
|
|
967
|
+
query = undefined;
|
|
968
|
+
}
|
|
969
|
+
return records;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
private async setDnsRecordsV3(
|
|
973
|
+
domainName: string,
|
|
974
|
+
records: DnsRecord[],
|
|
975
|
+
opts?: RequestOptions
|
|
976
|
+
): Promise<OperationResult> {
|
|
977
|
+
const zone = encodeURIComponent(domainName);
|
|
978
|
+
const base = `/v3/domains/zones/${zone}/dns-records`;
|
|
979
|
+
try {
|
|
980
|
+
const current = await this.getV3Records(domainName, opts);
|
|
981
|
+
const currentByKey = new Map(current.map(r => [dnsKeyV3(r), r]));
|
|
982
|
+
const desired = records.map(toV3Record);
|
|
983
|
+
const desiredKeys = new Set(desired.map(dnsKeyV3));
|
|
984
|
+
|
|
985
|
+
// delete records that are no longer desired
|
|
986
|
+
for (const r of current) {
|
|
987
|
+
if (!desiredKeys.has(dnsKeyV3(r)) && r.recordId) {
|
|
988
|
+
await this.http.request({
|
|
989
|
+
method: 'DELETE',
|
|
990
|
+
path: `${base}/${encodeURIComponent(r.recordId)}`,
|
|
991
|
+
...opts,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
// add new records; update matched records whose fields changed
|
|
996
|
+
for (const d of desired) {
|
|
997
|
+
const existing = currentByKey.get(dnsKeyV3(d));
|
|
998
|
+
if (!existing) {
|
|
999
|
+
await this.http.request({ method: 'POST', path: base, body: d, ...opts });
|
|
1000
|
+
} else if (existing.recordId && dnsFieldsDiffer(existing, d)) {
|
|
1001
|
+
await this.http.request({
|
|
1002
|
+
method: 'PUT',
|
|
1003
|
+
path: `${base}/${encodeURIComponent(existing.recordId)}`,
|
|
1004
|
+
body: d,
|
|
1005
|
+
...opts,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return { success: true, message: 'DNS records updated successfully' };
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// --- extended capabilities ---------------------------------------------
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Resolve the configured `customerId` to the UUID the v2 API expects, memoized
|
|
1019
|
+
* for the instance's lifetime. GoDaddy's v2 customer-scoped routes key on a
|
|
1020
|
+
* customer **UUID**, which differs from the numeric shopper/account ID shown in
|
|
1021
|
+
* most dashboards. Callers may configure either:
|
|
1022
|
+
* - a UUID → used directly, and
|
|
1023
|
+
* - a numeric shopper ID → resolved via `GET /v1/shoppers/{id}?includes=customerId`.
|
|
1024
|
+
* The shopper lookup is **sso-key-only** (it rejects a PAT with 401), so
|
|
1025
|
+
* resolving a numeric ID requires `apiKey` + `apiSecret`; supplying the UUID
|
|
1026
|
+
* directly avoids that dependency. Throws a ConfigurationError when
|
|
1027
|
+
* `customerId` is absent, or a clear error when a numeric ID can't be resolved.
|
|
1028
|
+
*/
|
|
1029
|
+
private async resolveCustomerId(opts?: RequestOptions): Promise<string> {
|
|
1030
|
+
const configured = this.credentials.customerId?.trim();
|
|
1031
|
+
if (!configured) {
|
|
1032
|
+
throw new ConfigurationError(
|
|
1033
|
+
`${this.name}: domain forwarding uses the v2 customer-scoped API, which ` +
|
|
1034
|
+
'requires a customer ID. Set `customerId` to your GoDaddy customer UUID ' +
|
|
1035
|
+
'(or your numeric shopper/account ID, which will be resolved to the UUID).'
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
if (UUID_RE.test(configured)) return configured;
|
|
1039
|
+
// numeric shopper ID → resolve to the customer UUID (sso-key only)
|
|
1040
|
+
return (this.customerIdPromise ??= (async () => {
|
|
1041
|
+
if (!this.credentials.apiKey || !this.credentials.apiSecret) {
|
|
1042
|
+
throw new ConfigurationError(
|
|
1043
|
+
`${this.name}: resolving a numeric shopper ID ("${configured}") to a customer ` +
|
|
1044
|
+
'UUID requires an sso-key (apiKey + apiSecret); the shopper lookup rejects a ' +
|
|
1045
|
+
'PAT. Either supply the customer UUID directly as `customerId`, or add sso-key ' +
|
|
1046
|
+
'credentials.'
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
try {
|
|
1050
|
+
const shopper = await this.http.request<{ customerId?: string }>({
|
|
1051
|
+
// resolution is sso-key-only, so send the sso-key header explicitly
|
|
1052
|
+
// rather than the instance default (which may be a Bearer PAT).
|
|
1053
|
+
path: `/v1/shoppers/${encodeURIComponent(configured)}`,
|
|
1054
|
+
query: { includes: 'customerId' },
|
|
1055
|
+
headers: {
|
|
1056
|
+
Authorization: `sso-key ${this.credentials.apiKey}:${this.credentials.apiSecret}`,
|
|
1057
|
+
},
|
|
1058
|
+
...opts,
|
|
1059
|
+
});
|
|
1060
|
+
if (!shopper.customerId) {
|
|
1061
|
+
throw new Error('response did not include a customerId');
|
|
1062
|
+
}
|
|
1063
|
+
return shopper.customerId;
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
this.customerIdPromise = undefined; // allow a later retry
|
|
1066
|
+
throw new ConfigurationError(
|
|
1067
|
+
`${this.name}: could not resolve shopper ID "${configured}" to a customer UUID ` +
|
|
1068
|
+
`(${toRegistrarError(error).message}). Supply the customer UUID directly as ` +
|
|
1069
|
+
'`customerId`.'
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
})());
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Read URL forwarding via the v2 customer-scoped route
|
|
1077
|
+
* GET /v2/customers/{customerId}/domains/forwards/{fqdn}?includeSubs=true.
|
|
1078
|
+
* GoDaddy keys forwarding by fqdn (the domain or a subdomain), so each entry
|
|
1079
|
+
* maps to a DomainForward whose `host` is relative to the apex ("@" = domain).
|
|
1080
|
+
* A domain with no forwarding returns 404, which we treat as an empty list.
|
|
1081
|
+
*/
|
|
1082
|
+
override async getDomainForwarding(
|
|
1083
|
+
domainName: string,
|
|
1084
|
+
opts?: RequestOptions
|
|
1085
|
+
): Promise<DomainForward[]> {
|
|
1086
|
+
const customerId = await this.resolveCustomerId(opts);
|
|
1087
|
+
let forwards: GoDaddyForward[];
|
|
1088
|
+
try {
|
|
1089
|
+
forwards = await this.http.request<GoDaddyForward[]>({
|
|
1090
|
+
path: this.forwardPath(customerId, domainName),
|
|
1091
|
+
query: { includeSubs: true },
|
|
1092
|
+
...opts,
|
|
1093
|
+
});
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
if (error instanceof NotFoundError) return [];
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
return (forwards ?? []).map(f => ({
|
|
1099
|
+
host: fqdnToHost(f.fqdn ?? domainName, domainName),
|
|
1100
|
+
url: f.url ?? '',
|
|
1101
|
+
type: GD_TYPE_TO_FORWARD[(f.type ?? '').toUpperCase()] ?? 'permanent',
|
|
1102
|
+
}));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Replace URL forwarding (full replace; an empty list clears it). GoDaddy has
|
|
1107
|
+
* no bulk endpoint — forwarding is a per-fqdn PUT/DELETE — so we read the
|
|
1108
|
+
* current forwards, DELETE any fqdn no longer wanted, then PUT each desired
|
|
1109
|
+
* rule to /v2/customers/{customerId}/domains/forwards/{fqdn}.
|
|
1110
|
+
*/
|
|
1111
|
+
override async setDomainForwarding(
|
|
1112
|
+
domainName: string,
|
|
1113
|
+
forwards: DomainForward[],
|
|
1114
|
+
opts?: RequestOptions
|
|
1115
|
+
): Promise<OperationResult> {
|
|
1116
|
+
try {
|
|
1117
|
+
const settable = settableForwards(forwards); // reject masked before any write
|
|
1118
|
+
const customerId = await this.resolveCustomerId(opts);
|
|
1119
|
+
const current = await this.getDomainForwarding(domainName, opts);
|
|
1120
|
+
const desiredHosts = new Set(settable.map(f => f.host || '@'));
|
|
1121
|
+
|
|
1122
|
+
for (const existing of current) {
|
|
1123
|
+
if (!desiredHosts.has(existing.host)) {
|
|
1124
|
+
const fqdn = hostToFqdn(existing.host, domainName);
|
|
1125
|
+
await this.http.request({
|
|
1126
|
+
method: 'DELETE',
|
|
1127
|
+
path: this.forwardPath(customerId, fqdn),
|
|
1128
|
+
...opts,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
for (const f of settable) {
|
|
1134
|
+
const fqdn = hostToFqdn(f.host || '@', domainName);
|
|
1135
|
+
const body: GoDaddyForward = { type: GD_FORWARD_TYPE[f.type], url: f.url };
|
|
1136
|
+
await this.http.request({
|
|
1137
|
+
method: 'PUT',
|
|
1138
|
+
path: this.forwardPath(customerId, fqdn),
|
|
1139
|
+
body,
|
|
1140
|
+
...opts,
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
return { success: true, message: 'Domain forwarding updated successfully' };
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// the v2 customer-scoped forwarding resource path for a given fqdn
|
|
1150
|
+
private forwardPath(customerId: string, fqdn: string): string {
|
|
1151
|
+
return `/v2/customers/${encodeURIComponent(customerId)}/domains/forwards/${encodeURIComponent(fqdn)}`;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Transfer authorization (EPP) code. GoDaddy exposes it as the `authCode`
|
|
1156
|
+
* field on the v1 domain-detail response; transfers were never moved to v3,
|
|
1157
|
+
* so this always reads from v1 regardless of the `useV3` routing. Returned
|
|
1158
|
+
* synchronously in the body — not email-only. The domain generally needs to
|
|
1159
|
+
* be unlocked and out of the 60-day post-registration/transfer lock for the
|
|
1160
|
+
* code to be usable for an outbound transfer.
|
|
1161
|
+
*/
|
|
1162
|
+
override async getAuthCode(domainName: string, opts?: RequestOptions): Promise<string> {
|
|
1163
|
+
const d = await this.http.request<GoDaddyDomain>({
|
|
1164
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}`,
|
|
1165
|
+
...opts,
|
|
1166
|
+
});
|
|
1167
|
+
return d.authCode ?? '';
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// map a v1 GoDaddy domain payload to the normalized Domain shape
|
|
1171
|
+
private toDomain(d: GoDaddyDomain): Domain {
|
|
1172
|
+
return createDomain({
|
|
1173
|
+
domainName: d.domain,
|
|
1174
|
+
registrar: this.name,
|
|
1175
|
+
status: d.status,
|
|
1176
|
+
createdDate: d.createdAt,
|
|
1177
|
+
expirationDate: d.expires,
|
|
1178
|
+
renewalDate: d.renewDeadline,
|
|
1179
|
+
autoRenew: d.renewAuto ?? false,
|
|
1180
|
+
locked: d.locked ?? false,
|
|
1181
|
+
privacy: d.privacy ?? false,
|
|
1182
|
+
nameservers: d.nameServers ?? [],
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// map a v3 GoDaddy domain payload to the normalized Domain shape. v3 renames
|
|
1187
|
+
// several fields (transferLock → locked, renewBy → renewalDate, expiresAt →
|
|
1188
|
+
// expirationDate).
|
|
1189
|
+
private toDomainV3(d: GdV3Domain): Domain {
|
|
1190
|
+
return createDomain({
|
|
1191
|
+
domainName: d.domain,
|
|
1192
|
+
registrar: this.name,
|
|
1193
|
+
status: d.status,
|
|
1194
|
+
createdDate: d.createdAt,
|
|
1195
|
+
expirationDate: d.expiresAt,
|
|
1196
|
+
renewalDate: d.renewBy,
|
|
1197
|
+
autoRenew: d.autoRenew ?? false,
|
|
1198
|
+
locked: d.transferLock ?? false,
|
|
1199
|
+
privacy: d.privacy ?? false,
|
|
1200
|
+
nameservers: d.nameServers ?? [],
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
private async mutate(
|
|
1205
|
+
req: { method: string; path: string; body?: unknown; headers?: Record<string, string> },
|
|
1206
|
+
successMessage: string,
|
|
1207
|
+
opts?: RequestOptions
|
|
1208
|
+
): Promise<OperationResult> {
|
|
1209
|
+
try {
|
|
1210
|
+
await this.http.request({ ...req, ...opts });
|
|
1211
|
+
return { success: true, message: successMessage };
|
|
1212
|
+
} catch (error) {
|
|
1213
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// --- contact mapping between GoDaddy's shape and the normalized Contact ---
|
|
1219
|
+
|
|
1220
|
+
function fromGoDaddyContact(c: GoDaddyContact | undefined): Contact | undefined {
|
|
1221
|
+
if (!c) return undefined;
|
|
1222
|
+
const a = c.addressMailing ?? {};
|
|
1223
|
+
return {
|
|
1224
|
+
firstName: c.nameFirst ?? '',
|
|
1225
|
+
lastName: c.nameLast ?? '',
|
|
1226
|
+
organization: c.organization,
|
|
1227
|
+
email: c.email ?? '',
|
|
1228
|
+
phone: c.phone ?? '',
|
|
1229
|
+
fax: c.fax,
|
|
1230
|
+
address1: a.address1 ?? '',
|
|
1231
|
+
address2: a.address2,
|
|
1232
|
+
city: a.city ?? '',
|
|
1233
|
+
state: a.state,
|
|
1234
|
+
postalCode: a.postalCode ?? '',
|
|
1235
|
+
country: a.country ?? '',
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function toGoDaddyContact(c: Contact): GoDaddyContact {
|
|
1240
|
+
return {
|
|
1241
|
+
nameFirst: c.firstName,
|
|
1242
|
+
nameLast: c.lastName,
|
|
1243
|
+
organization: c.organization,
|
|
1244
|
+
email: c.email,
|
|
1245
|
+
phone: c.phone,
|
|
1246
|
+
fax: c.fax,
|
|
1247
|
+
addressMailing: {
|
|
1248
|
+
address1: c.address1,
|
|
1249
|
+
address2: c.address2,
|
|
1250
|
+
city: c.city,
|
|
1251
|
+
state: c.state ?? '',
|
|
1252
|
+
postalCode: c.postalCode,
|
|
1253
|
+
country: c.country,
|
|
1254
|
+
},
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// resolve a forwarding host relative to the apex into a full fqdn, and back.
|
|
1259
|
+
// "@" (or "") denotes the apex domain itself.
|
|
1260
|
+
function hostToFqdn(host: string, domain: string): string {
|
|
1261
|
+
return !host || host === '@' ? domain : `${host}.${domain}`;
|
|
1262
|
+
}
|
|
1263
|
+
function fqdnToHost(fqdn: string, domain: string): string {
|
|
1264
|
+
if (fqdn === domain) return '@';
|
|
1265
|
+
const suffix = `.${domain}`;
|
|
1266
|
+
return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// --- v3 helpers ------------------------------------------------------------
|
|
1270
|
+
|
|
1271
|
+
// Pick the pricing term to report — prefer the 1-year term, else the first.
|
|
1272
|
+
function pickTerm(prices?: GdV3TermPrice[]): GdV3TermPrice | undefined {
|
|
1273
|
+
if (!prices || prices.length === 0) return undefined;
|
|
1274
|
+
return prices.find(p => p.period === 1) ?? prices[0];
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Classify a v3 availability `inventory` value. Standard registry names report
|
|
1278
|
+
// "REGISTRY" (or "STANDARD"); premium/aftermarket names report a PREMIUM-type
|
|
1279
|
+
// value. Undefined when the field is absent.
|
|
1280
|
+
function isPremiumInventory(inventory?: string): boolean | undefined {
|
|
1281
|
+
if (inventory == null) return undefined;
|
|
1282
|
+
return /PREMIUM/i.test(inventory);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Convert v3 "Simple Money" (integer minor units) to major units. Assumes
|
|
1286
|
+
// 2-decimal currencies (USD/EUR/…); zero-decimal currencies like JPY would need
|
|
1287
|
+
// currency-aware scaling, but GoDaddy prices in USD by default.
|
|
1288
|
+
function moneyToMajor(m?: GdV3Money): number | undefined {
|
|
1289
|
+
if (!m || m.value == null) return undefined;
|
|
1290
|
+
return m.value / 100;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// map a v3 DNS record to the normalized DnsRecord shape
|
|
1294
|
+
function fromV3Record(r: GdV3DnsRecord): DnsRecord {
|
|
1295
|
+
return {
|
|
1296
|
+
type: r.type,
|
|
1297
|
+
name: r.name,
|
|
1298
|
+
value: r.data,
|
|
1299
|
+
ttl: r.ttl,
|
|
1300
|
+
priority: r.priority,
|
|
1301
|
+
weight: r.weight,
|
|
1302
|
+
port: r.port,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// map a normalized DnsRecord to a v3 record body. GoDaddy requires a minimum
|
|
1307
|
+
// TTL of 600s, so records without an explicit TTL default to 3600.
|
|
1308
|
+
function toV3Record(r: DnsRecord): GdV3DnsRecord {
|
|
1309
|
+
const out: GdV3DnsRecord = {
|
|
1310
|
+
type: r.type.toUpperCase(),
|
|
1311
|
+
name: r.name || '@',
|
|
1312
|
+
data: r.value,
|
|
1313
|
+
ttl: r.ttl ?? 3600,
|
|
1314
|
+
};
|
|
1315
|
+
if (r.priority != null) out.priority = r.priority;
|
|
1316
|
+
if (r.weight != null) out.weight = r.weight;
|
|
1317
|
+
if (r.port != null) out.port = r.port;
|
|
1318
|
+
return out;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// identity of a record for full-replace diffing: type + name + value, joined by
|
|
1322
|
+
// a unit-separator (U+001F) that can't appear in a hostname or record value —
|
|
1323
|
+
// avoids collisions without embedding a NUL (which would make the file binary).
|
|
1324
|
+
function dnsKeyV3(r: GdV3DnsRecord): string {
|
|
1325
|
+
return `${r.type.toUpperCase()}\u001f${r.name || '@'}\u001f${r.data}`;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// whether two records that share a key differ in a mutable field (TTL/priority/
|
|
1329
|
+
// weight/port), meaning the existing one should be PUT-updated
|
|
1330
|
+
function dnsFieldsDiffer(a: GdV3DnsRecord, b: GdV3DnsRecord): boolean {
|
|
1331
|
+
return (
|
|
1332
|
+
(a.ttl ?? 3600) !== (b.ttl ?? 3600) ||
|
|
1333
|
+
(a.priority ?? null) !== (b.priority ?? null) ||
|
|
1334
|
+
(a.weight ?? null) !== (b.weight ?? null) ||
|
|
1335
|
+
(a.port ?? null) !== (b.port ?? null)
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// NOTE: v3 registration takes contacts from the account identity (the quote's
|
|
1340
|
+
// `resolved.contactSource: "ACCOUNT"`) and rejects a `profile.contacts` block,
|
|
1341
|
+
// so there is no v3 contact mapper — see `registerDomainV3`. v1 uses
|
|
1342
|
+
// `toGoDaddyContact` above.
|