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