@aoxborrow/registrar-client 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/index.cjs +276 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -3
- package/dist/index.d.ts +35 -3
- package/dist/index.js +276 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/features.ts +9 -0
- package/src/registrar.ts +13 -0
- package/src/registrars/dynadot.ts +137 -3
- package/src/registrars/gandi.ts +91 -3
- package/src/registrars/godaddy.ts +136 -8
- package/src/types.ts +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aoxborrow/registrar-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "One consistent, fully-typed TypeScript interface to many domain registrar APIs (GoDaddy, Cloudflare, Namecheap, Spaceship, Dynadot, Porkbun, and more) behind a single pluggable multi-provider client",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Aaron Oxborrow",
|
package/src/features.ts
CHANGED
|
@@ -48,6 +48,9 @@ export const Feature = {
|
|
|
48
48
|
|
|
49
49
|
// --- extended (opt-in; declared per provider) ---
|
|
50
50
|
GetAuthCode: 'getAuthCode', // retrieve transfer auth/EPP code (transfer out)
|
|
51
|
+
GetTransferOut: 'getTransferOut', // read an outbound transfer another registrar requested
|
|
52
|
+
ApproveTransferOut: 'approveTransferOut', // release a pending outbound transfer now
|
|
53
|
+
RejectTransferOut: 'rejectTransferOut', // refuse a pending outbound transfer
|
|
51
54
|
GetDnssec: 'getDnssec', // read whether DNSSEC is enabled (DS / key records)
|
|
52
55
|
DisableDnssec: 'disableDnssec', // turn DNSSEC off (no enable / key management)
|
|
53
56
|
GetEmailForwarding: 'getEmailForwarding', // read alias-style email forwarding rules
|
|
@@ -85,6 +88,9 @@ export const CORE_FEATURES = [
|
|
|
85
88
|
// of these via its static `extendedFeatures`.
|
|
86
89
|
export const EXTENDED_FEATURES = [
|
|
87
90
|
Feature.GetAuthCode,
|
|
91
|
+
Feature.GetTransferOut,
|
|
92
|
+
Feature.ApproveTransferOut,
|
|
93
|
+
Feature.RejectTransferOut,
|
|
88
94
|
Feature.GetDnssec,
|
|
89
95
|
Feature.DisableDnssec,
|
|
90
96
|
Feature.GetEmailForwarding,
|
|
@@ -137,6 +143,9 @@ export const FEATURE_CALLS: Record<
|
|
|
137
143
|
getDnsRecords: { intent: 'read', optsIndex: 1 },
|
|
138
144
|
setDnsRecords: { intent: 'write', optsIndex: 2 },
|
|
139
145
|
getAuthCode: { intent: 'write', optsIndex: 1 },
|
|
146
|
+
getTransferOut: { intent: 'read', optsIndex: 1 },
|
|
147
|
+
approveTransferOut: { intent: 'write', optsIndex: 1 },
|
|
148
|
+
rejectTransferOut: { intent: 'write', optsIndex: 1 },
|
|
140
149
|
getDnssec: { intent: 'read', optsIndex: 1 },
|
|
141
150
|
disableDnssec: { intent: 'write', optsIndex: 1 },
|
|
142
151
|
getEmailForwarding: { intent: 'read', optsIndex: 1 },
|
package/src/registrar.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
RequestOptions,
|
|
24
24
|
TldPricing,
|
|
25
25
|
TransferDomainInput,
|
|
26
|
+
TransferOut,
|
|
26
27
|
} from './types';
|
|
27
28
|
|
|
28
29
|
// pick the base URL for the requested environment. Throws if `sandbox` is
|
|
@@ -276,6 +277,18 @@ export abstract class BaseRegistrar implements Registrar {
|
|
|
276
277
|
return this.notImplemented('getAuthCode');
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
getTransferOut(_domainName: string, _opts?: RequestOptions): Promise<TransferOut | null> {
|
|
281
|
+
return this.notImplemented('getTransferOut');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
approveTransferOut(_domainName: string, _opts?: RequestOptions): Promise<OperationResult> {
|
|
285
|
+
return this.notImplemented('approveTransferOut');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
rejectTransferOut(_domainName: string, _opts?: RequestOptions): Promise<OperationResult> {
|
|
289
|
+
return this.notImplemented('rejectTransferOut');
|
|
290
|
+
}
|
|
291
|
+
|
|
279
292
|
getDnssec(_domainName: string, _opts?: RequestOptions): Promise<DnssecStatus> {
|
|
280
293
|
return this.notImplemented('getDnssec');
|
|
281
294
|
}
|
|
@@ -16,6 +16,8 @@ import type {
|
|
|
16
16
|
RequestOptions,
|
|
17
17
|
TldPricing,
|
|
18
18
|
TransferDomainInput,
|
|
19
|
+
TransferOut,
|
|
20
|
+
TransferOutStatus,
|
|
19
21
|
} from '../types';
|
|
20
22
|
import { REST_SAFE_METHODS } from '../http';
|
|
21
23
|
import { createDomain, filterDomains, requireConsent, settableForwards } from '../utils';
|
|
@@ -79,6 +81,19 @@ interface V2DnssecInfo {
|
|
|
79
81
|
digest?: string;
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
// one order from GET .../transfer_status. `transfer_status` is a display label
|
|
85
|
+
// ("None", "Pre Sale Domain", …), and unset dates read back as -1. A
|
|
86
|
+
// transfer-away order waiting on the owner reads "None", with no
|
|
87
|
+
// expiration_date (production, 2026-10-31).
|
|
88
|
+
interface V2TransferStatus {
|
|
89
|
+
order_id?: string | number;
|
|
90
|
+
transfer_status?: string;
|
|
91
|
+
failed_reason?: string;
|
|
92
|
+
expiration_date?: number;
|
|
93
|
+
order_created_date?: number;
|
|
94
|
+
order_completed_date?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
82
97
|
// Dynadot's default nameservers, used to switch a domain off URL forwarding
|
|
83
98
|
// (there is no dedicated "clear forwarding" endpoint).
|
|
84
99
|
const DYNADOT_DEFAULT_NS = ['ns1.dynadot.com', 'ns2.dynadot.com'];
|
|
@@ -193,11 +208,16 @@ export class DynadotRegistrar extends BaseRegistrar {
|
|
|
193
208
|
{ name: 'apiSecret', label: 'API Secret', type: 'password', required: true },
|
|
194
209
|
];
|
|
195
210
|
static readonly supportsSandbox = true; // api-sandbox.dynadot.com mirrors the API
|
|
196
|
-
// On top of core: transfer-out auth code, DNSSEC read/disable,
|
|
197
|
-
// domain (URL) forwarding. All implemented on the v2 transport and
|
|
198
|
-
// the Dynadot sandbox
|
|
211
|
+
// On top of core: transfer-out auth code and approval, DNSSEC read/disable,
|
|
212
|
+
// and email + domain (URL) forwarding. All implemented on the v2 transport and
|
|
213
|
+
// verified in the Dynadot sandbox (transfer-out approval only up to "no such
|
|
214
|
+
// order": the sandbox can't create an outbound transfer; a real pending
|
|
215
|
+
// one's read was checked in production).
|
|
199
216
|
static readonly extendedFeatures: readonly RegistrarFeature[] = [
|
|
200
217
|
Feature.GetAuthCode,
|
|
218
|
+
Feature.GetTransferOut,
|
|
219
|
+
Feature.ApproveTransferOut,
|
|
220
|
+
Feature.RejectTransferOut,
|
|
201
221
|
Feature.GetDnssec,
|
|
202
222
|
Feature.DisableDnssec,
|
|
203
223
|
Feature.GetEmailForwarding,
|
|
@@ -781,6 +801,83 @@ export class DynadotRegistrar extends BaseRegistrar {
|
|
|
781
801
|
return data.auth_code ?? '';
|
|
782
802
|
}
|
|
783
803
|
|
|
804
|
+
/**
|
|
805
|
+
* The domain's latest outbound transfer via GET .../transfer_status with
|
|
806
|
+
* `transfer_type=transfer_away`, which lists its transfer-away orders. Dynadot
|
|
807
|
+
* answers 404 when there are none (and for a domain not in the account), read
|
|
808
|
+
* here as null. An open order wins over a newer finished one, so approve and
|
|
809
|
+
* reject act on the order that can still be answered.
|
|
810
|
+
*/
|
|
811
|
+
override async getTransferOut(
|
|
812
|
+
domainName: string,
|
|
813
|
+
opts?: RequestOptions
|
|
814
|
+
): Promise<TransferOut | null> {
|
|
815
|
+
let env: V2Envelope<{ domain_transfer_status_list?: V2TransferStatus[] }>;
|
|
816
|
+
try {
|
|
817
|
+
env = await this.signedRequest(
|
|
818
|
+
'GET',
|
|
819
|
+
`/restful/v2/domains/${encodeURIComponent(domainName)}/transfer_status?transfer_type=transfer_away`,
|
|
820
|
+
undefined,
|
|
821
|
+
opts
|
|
822
|
+
);
|
|
823
|
+
} catch (error) {
|
|
824
|
+
if (error instanceof NotFoundError) return null;
|
|
825
|
+
throw error;
|
|
826
|
+
}
|
|
827
|
+
if (env.code === 404) return null;
|
|
828
|
+
if (!isOk(env.code)) {
|
|
829
|
+
throw new Error(env.error?.description ?? env.message ?? 'Dynadot request failed');
|
|
830
|
+
}
|
|
831
|
+
const transfers = (env.data?.domain_transfer_status_list ?? [])
|
|
832
|
+
.map(toTransferOut)
|
|
833
|
+
.sort((a, b) => (b.requestedDate?.getTime() ?? 0) - (a.requestedDate?.getTime() ?? 0));
|
|
834
|
+
return transfers.find(t => OPEN_TRANSFER_OUT.has(t.status)) ?? transfers[0] ?? null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Approves the pending outbound transfer, releasing the domain now instead of
|
|
839
|
+
* after Dynadot's 5-day wait. Answers the order `getTransferOut` reports.
|
|
840
|
+
*/
|
|
841
|
+
override async approveTransferOut(
|
|
842
|
+
domainName: string,
|
|
843
|
+
opts?: RequestOptions
|
|
844
|
+
): Promise<OperationResult> {
|
|
845
|
+
return this.authorizeTransferAway(domainName, true, opts);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Denies the pending outbound transfer; the domain stays in the account.
|
|
849
|
+
override async rejectTransferOut(
|
|
850
|
+
domainName: string,
|
|
851
|
+
opts?: RequestOptions
|
|
852
|
+
): Promise<OperationResult> {
|
|
853
|
+
return this.authorizeTransferAway(domainName, false, opts);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// POST /orders/{order_id}/authorize_transfer_away with `approve` true or
|
|
857
|
+
// false. Dynadot answers 409 when the order is no longer waiting on the owner.
|
|
858
|
+
private async authorizeTransferAway(
|
|
859
|
+
domainName: string,
|
|
860
|
+
approve: boolean,
|
|
861
|
+
opts?: RequestOptions
|
|
862
|
+
): Promise<OperationResult> {
|
|
863
|
+
let transfer: TransferOut | null;
|
|
864
|
+
try {
|
|
865
|
+
transfer = await this.getTransferOut(domainName, opts);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
868
|
+
}
|
|
869
|
+
if (!transfer?.id) {
|
|
870
|
+
return { success: false, message: `No transfer-out of ${domainName} to answer` };
|
|
871
|
+
}
|
|
872
|
+
return this.mutate(
|
|
873
|
+
'POST',
|
|
874
|
+
`/restful/v2/orders/${encodeURIComponent(transfer.id)}/authorize_transfer_away`,
|
|
875
|
+
{ domain_name: domainName, approve },
|
|
876
|
+
`Transfer of ${domainName} ${approve ? 'approved' : 'rejected'}`,
|
|
877
|
+
opts
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
784
881
|
/**
|
|
785
882
|
* DNSSEC status via GET .../dnssec, which returns a list of DS records
|
|
786
883
|
* (`dnssec_info_list`); a non-empty list means DNSSEC is enabled. `algorithm`
|
|
@@ -962,6 +1059,43 @@ async function hmacSha256Base64(secret: string, message: string): Promise<string
|
|
|
962
1059
|
|
|
963
1060
|
// --- mapping helpers ---
|
|
964
1061
|
|
|
1062
|
+
// transfer-out states that can still be approved or rejected
|
|
1063
|
+
const OPEN_TRANSFER_OUT: ReadonlySet<TransferOutStatus> = new Set(['pending', 'unknown']);
|
|
1064
|
+
|
|
1065
|
+
// map a transfer-away order to the normalized TransferOut shape
|
|
1066
|
+
function toTransferOut(t: V2TransferStatus): TransferOut {
|
|
1067
|
+
const rawStatus = t.transfer_status ?? '';
|
|
1068
|
+
let status = transferOutStatus(rawStatus);
|
|
1069
|
+
if (status === 'unknown' && msDate(t.order_completed_date)) status = 'completed';
|
|
1070
|
+
return {
|
|
1071
|
+
status,
|
|
1072
|
+
rawStatus,
|
|
1073
|
+
id: str(t.order_id) || undefined,
|
|
1074
|
+
requestedDate: msDate(t.order_created_date),
|
|
1075
|
+
expirationDate: msDate(t.expiration_date),
|
|
1076
|
+
completedDate: msDate(t.order_completed_date),
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Dynadot documents its transfer statuses only as display labels, and none for
|
|
1081
|
+
// transfer-away; a waiting one reads "None", the rest are unseen. So match on
|
|
1082
|
+
// words. Negatives first: "Not Approved" is a rejection, and pending before
|
|
1083
|
+
// approved: "Waiting For Approval" still waits.
|
|
1084
|
+
function transferOutStatus(label: string): TransferOutStatus {
|
|
1085
|
+
const s = label.toLowerCase();
|
|
1086
|
+
if (/den(y|ied)|reject|refus|not approved|nack/.test(s)) return 'rejected';
|
|
1087
|
+
if (/cancel|fail|expire|time ?out|abort/.test(s)) return 'cancelled';
|
|
1088
|
+
if (/complete|transferr?ed|success/.test(s)) return 'completed';
|
|
1089
|
+
if (/pend|wait|await|need|request|none|new|process|hold|review/.test(s)) return 'pending';
|
|
1090
|
+
if (/approv|authori[sz]|accept/.test(s)) return 'approved';
|
|
1091
|
+
return 'unknown';
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// a Unix-millisecond date, or null for Dynadot's unset (-1 / 0 / missing)
|
|
1095
|
+
function msDate(ms: number | undefined): Date | null {
|
|
1096
|
+
return typeof ms === 'number' && ms > 0 ? new Date(ms) : null;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
965
1099
|
// whether a v2 envelope `code` (which mirrors the HTTP status) is a success —
|
|
966
1100
|
// 2xx, so it covers 200 and transfer_in's 202 Accepted
|
|
967
1101
|
function isOk(code: number | undefined): boolean {
|
package/src/registrars/gandi.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
RequestOptions,
|
|
20
20
|
TldPricing,
|
|
21
21
|
TransferDomainInput,
|
|
22
|
+
TransferOut,
|
|
22
23
|
} from '../types';
|
|
23
24
|
import { REST_SAFE_METHODS } from '../http';
|
|
24
25
|
import {
|
|
@@ -78,6 +79,8 @@ interface GandiDomain {
|
|
|
78
79
|
contacts?: GandiContacts;
|
|
79
80
|
nameserver?: { current?: string; hosts?: string[] };
|
|
80
81
|
nameservers?: string[];
|
|
82
|
+
// the transfer auth code (detail endpoint only)
|
|
83
|
+
authinfo?: string;
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
// one record set from `GET /v5/livedns/domains/{fqdn}/records`; a set can hold
|
|
@@ -192,9 +195,9 @@ export class GandiRegistrar extends BaseRegistrar {
|
|
|
192
195
|
];
|
|
193
196
|
// Gandi's v5 API offers a sandbox at api.sandbox.gandi.net (separate account)
|
|
194
197
|
static readonly supportsSandbox = true;
|
|
195
|
-
// On top of core: transfer-out auth code (`authinfo`), DNSSEC
|
|
196
|
-
// (LiveDNS keys), email forwarding, and web/URL forwarding
|
|
197
|
-
// per host, apex included). Note core `setPrivacy` disabling is a no-op for
|
|
198
|
+
// On top of core: transfer-out auth code (`authinfo`) and approval, DNSSEC
|
|
199
|
+
// read/disable (LiveDNS keys), email forwarding, and web/URL forwarding
|
|
200
|
+
// (webredirs; per host, apex included). Note core `setPrivacy` disabling is a no-op for
|
|
198
201
|
// individual (natural-person) registrants — Gandi keeps WHOIS obfuscation on
|
|
199
202
|
// for GDPR regardless of the request.
|
|
200
203
|
static readonly extendedFeatures: readonly RegistrarFeature[] = [
|
|
@@ -205,6 +208,9 @@ export class GandiRegistrar extends BaseRegistrar {
|
|
|
205
208
|
Feature.SetEmailForwarding,
|
|
206
209
|
Feature.GetDomainForwarding,
|
|
207
210
|
Feature.SetDomainForwarding,
|
|
211
|
+
Feature.GetTransferOut,
|
|
212
|
+
Feature.ApproveTransferOut,
|
|
213
|
+
Feature.RejectTransferOut,
|
|
208
214
|
];
|
|
209
215
|
|
|
210
216
|
constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
|
|
@@ -500,6 +506,75 @@ export class GandiRegistrar extends BaseRegistrar {
|
|
|
500
506
|
return d.authinfo ?? '';
|
|
501
507
|
}
|
|
502
508
|
|
|
509
|
+
/**
|
|
510
|
+
* The domain's pending outbound transfer. Gandi has no transfer-out resource
|
|
511
|
+
* to read: a domain in the account carries the documented `pendingTransfer`
|
|
512
|
+
* EPP status while another registrar's request waits (inbound transfers live
|
|
513
|
+
* under /domain/transferin, so on a domain you hold it's outbound — inferred,
|
|
514
|
+
* not documented). Gandi reports no dates for it, and nothing once it's
|
|
515
|
+
* answered or done, so this reads `pending` or null.
|
|
516
|
+
*/
|
|
517
|
+
override async getTransferOut(
|
|
518
|
+
domainName: string,
|
|
519
|
+
opts?: RequestOptions
|
|
520
|
+
): Promise<TransferOut | null> {
|
|
521
|
+
const d = await this.http.request<GandiDomain>({
|
|
522
|
+
path: `/domain/domains/${domainName}`,
|
|
523
|
+
...opts,
|
|
524
|
+
});
|
|
525
|
+
return pendingTransferOut(d);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Approves the pending outbound transfer, releasing the domain now instead of
|
|
530
|
+
* after Gandi's five days: POST .../transferout with `accept: true` and the
|
|
531
|
+
* domain's current auth code, which Gandi requires back (→ 202).
|
|
532
|
+
*/
|
|
533
|
+
override async approveTransferOut(
|
|
534
|
+
domainName: string,
|
|
535
|
+
opts?: RequestOptions
|
|
536
|
+
): Promise<OperationResult> {
|
|
537
|
+
return this.answerTransferOut(domainName, true, opts);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Declines the pending outbound transfer (`accept: false`, no reason); the
|
|
541
|
+
// domain stays in the account.
|
|
542
|
+
override async rejectTransferOut(
|
|
543
|
+
domainName: string,
|
|
544
|
+
opts?: RequestOptions
|
|
545
|
+
): Promise<OperationResult> {
|
|
546
|
+
return this.answerTransferOut(domainName, false, opts);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// One read for both the pending status and the auth code the answer needs.
|
|
550
|
+
private async answerTransferOut(
|
|
551
|
+
domainName: string,
|
|
552
|
+
accept: boolean,
|
|
553
|
+
opts?: RequestOptions
|
|
554
|
+
): Promise<OperationResult> {
|
|
555
|
+
let d: GandiDomain;
|
|
556
|
+
try {
|
|
557
|
+
d = await this.http.request<GandiDomain>({
|
|
558
|
+
path: `/domain/domains/${domainName}`,
|
|
559
|
+
...opts,
|
|
560
|
+
});
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
563
|
+
}
|
|
564
|
+
if (!pendingTransferOut(d)) {
|
|
565
|
+
return { success: false, message: `No transfer-out of ${domainName} to answer` };
|
|
566
|
+
}
|
|
567
|
+
return this.mutate(
|
|
568
|
+
{
|
|
569
|
+
method: 'POST',
|
|
570
|
+
path: `/domain/domains/${domainName}/transferout`,
|
|
571
|
+
body: { accept, authinfo: d.authinfo ?? '' },
|
|
572
|
+
},
|
|
573
|
+
`Transfer of ${domainName} ${accept ? 'approved' : 'rejected'}`,
|
|
574
|
+
opts
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
503
578
|
/**
|
|
504
579
|
* DNSSEC status via Gandi LiveDNS keys (GET /livedns/domains/{fqdn}/keys). A
|
|
505
580
|
* non-deleted key means DNSSEC is enabled; each key's `ds` field is the full DS
|
|
@@ -939,6 +1014,19 @@ export class GandiRegistrar extends BaseRegistrar {
|
|
|
939
1014
|
}
|
|
940
1015
|
}
|
|
941
1016
|
|
|
1017
|
+
// A pending outbound transfer, from the domain's `pendingTransfer` EPP status.
|
|
1018
|
+
function pendingTransferOut(d: GandiDomain): TransferOut | null {
|
|
1019
|
+
const statuses = Array.isArray(d.status) ? d.status : d.status ? [d.status] : [];
|
|
1020
|
+
if (!statuses.includes('pendingTransfer')) return null;
|
|
1021
|
+
return {
|
|
1022
|
+
status: 'pending',
|
|
1023
|
+
rawStatus: 'pendingTransfer',
|
|
1024
|
+
requestedDate: null,
|
|
1025
|
+
expirationDate: null,
|
|
1026
|
+
completedDate: null,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
942
1030
|
// map a Gandi contact into the normalized Contact shape. Gandi has no distinct
|
|
943
1031
|
// second address line, so `address2` is left undefined.
|
|
944
1032
|
// map the normalized Contact to Gandi's contact shape (given/family/streetaddr/…).
|
|
@@ -16,9 +16,11 @@ import type {
|
|
|
16
16
|
RequestOptions,
|
|
17
17
|
TldPricing,
|
|
18
18
|
TransferDomainInput,
|
|
19
|
+
TransferOut,
|
|
20
|
+
TransferOutStatus,
|
|
19
21
|
} from '../types';
|
|
20
22
|
import { REST_SAFE_METHODS } from '../http';
|
|
21
|
-
import { createDomain, filterDomains, settableForwards } from '../utils';
|
|
23
|
+
import { createDomain, filterDomains, parseDate, settableForwards } from '../utils';
|
|
22
24
|
import {
|
|
23
25
|
ConfigurationError,
|
|
24
26
|
ConsentRequiredError,
|
|
@@ -112,6 +114,19 @@ interface GoDaddyForward {
|
|
|
112
114
|
mask?: { title?: string; description?: string; keywords?: string };
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
// One entry of the v2 recent-actions list,
|
|
118
|
+
// GET /v2/customers/{customerId}/domains/{domain}/actions (`[]` when there are
|
|
119
|
+
// none, verified live 2026-10-31). An outbound transfer shows as a TRANSFER_OUT
|
|
120
|
+
// action while it's in progress; answering it logs TRANSFER_OUT_ACCEPT or
|
|
121
|
+
// TRANSFER_OUT_REJECT. `status` is ACCEPTED / AWAITING / PENDING / SUCCESS /
|
|
122
|
+
// FAILED / CANCELLED.
|
|
123
|
+
interface GoDaddyAction {
|
|
124
|
+
type?: string;
|
|
125
|
+
status?: string;
|
|
126
|
+
createdAt?: string;
|
|
127
|
+
completedAt?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
115
130
|
// normalized DomainForwardType → the token the v2 write endpoint expects
|
|
116
131
|
const GD_FORWARD_TYPE: Record<'temporary' | 'permanent', string> = {
|
|
117
132
|
permanent: 'REDIRECT_PERMANENT',
|
|
@@ -299,17 +314,22 @@ export class GoDaddyRegistrar extends BaseRegistrar {
|
|
|
299
314
|
];
|
|
300
315
|
// GoDaddy's OTE ("Operational Test Environment") is its sandbox
|
|
301
316
|
static readonly supportsSandbox = true;
|
|
302
|
-
// Beyond core: domain forwarding
|
|
303
|
-
// field on the v1 domain-detail response)
|
|
304
|
-
// customer-scoped route
|
|
305
|
-
//
|
|
306
|
-
//
|
|
317
|
+
// Beyond core: domain forwarding, transfer-out auth code (the `authCode`
|
|
318
|
+
// field on the v1 domain-detail response) and transfer-out approval.
|
|
319
|
+
// Forwarding uses the v2 customer-scoped route
|
|
320
|
+
// /v2/customers/{customerId}/domains/forwards/{fqdn} (the old v1
|
|
321
|
+
// /v1/domains/forwards routes now 404); it requires `customerId` to be
|
|
322
|
+
// configured — verified live 2026-08-29. Transfer-out approval is v2
|
|
323
|
+
// customer-scoped too, so it needs `customerId` as well. DNSSEC has no dedicated
|
|
307
324
|
// endpoint (DS records go through the generic DNS API), and there's no
|
|
308
325
|
// glue-record, email, or push-webhook API.
|
|
309
326
|
static readonly extendedFeatures: readonly RegistrarFeature[] = [
|
|
310
327
|
Feature.GetAuthCode,
|
|
311
328
|
Feature.GetDomainForwarding,
|
|
312
329
|
Feature.SetDomainForwarding,
|
|
330
|
+
Feature.GetTransferOut,
|
|
331
|
+
Feature.ApproveTransferOut,
|
|
332
|
+
Feature.RejectTransferOut,
|
|
313
333
|
];
|
|
314
334
|
|
|
315
335
|
// true → prefer the v3 API (production + PAT). false → all calls use v1
|
|
@@ -1046,8 +1066,9 @@ export class GoDaddyRegistrar extends BaseRegistrar {
|
|
|
1046
1066
|
const configured = this.credentials.customerId?.trim();
|
|
1047
1067
|
if (!configured) {
|
|
1048
1068
|
throw new ConfigurationError(
|
|
1049
|
-
`${this.name}: domain forwarding
|
|
1050
|
-
'requires a customer ID. Set `customerId` to
|
|
1069
|
+
`${this.name}: domain forwarding and transfer-out approval use the v2 ` +
|
|
1070
|
+
'customer-scoped API, which requires a customer ID. Set `customerId` to ' +
|
|
1071
|
+
'your GoDaddy customer UUID ' +
|
|
1051
1072
|
'(or your numeric shopper/account ID, which will be resolved to the UUID).'
|
|
1052
1073
|
);
|
|
1053
1074
|
}
|
|
@@ -1167,6 +1188,71 @@ export class GoDaddyRegistrar extends BaseRegistrar {
|
|
|
1167
1188
|
return `/v2/customers/${encodeURIComponent(customerId)}/domains/forwards/${encodeURIComponent(fqdn)}`;
|
|
1168
1189
|
}
|
|
1169
1190
|
|
|
1191
|
+
// the v2 customer-scoped path for a domain
|
|
1192
|
+
private domainPathV2(customerId: string, domainName: string): string {
|
|
1193
|
+
return `/v2/customers/${encodeURIComponent(customerId)}/domains/${encodeURIComponent(domainName)}`;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* The domain's latest outbound transfer, read from its recent actions
|
|
1198
|
+
* (GET /v2/customers/{customerId}/domains/{domain}/actions): the TRANSFER_OUT
|
|
1199
|
+
* action, and an accept or reject logged after it. Null when there is no
|
|
1200
|
+
* TRANSFER_OUT on record. GoDaddy reports no expiry; left alone, it releases
|
|
1201
|
+
* the domain within seven days. Shapes from the published v2 spec; a real
|
|
1202
|
+
* transfer-out is not yet seen live.
|
|
1203
|
+
*/
|
|
1204
|
+
override async getTransferOut(
|
|
1205
|
+
domainName: string,
|
|
1206
|
+
opts?: RequestOptions
|
|
1207
|
+
): Promise<TransferOut | null> {
|
|
1208
|
+
const customerId = await this.resolveCustomerId(opts);
|
|
1209
|
+
const actions = await this.http.request<GoDaddyAction[]>({
|
|
1210
|
+
path: `${this.domainPathV2(customerId, domainName)}/actions`,
|
|
1211
|
+
...opts,
|
|
1212
|
+
});
|
|
1213
|
+
return toTransferOut(Array.isArray(actions) ? actions : []);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Approves the pending outbound transfer, releasing the domain now
|
|
1218
|
+
* (POST .../transferOutAccept → 202). GoDaddy answers 409 when none is
|
|
1219
|
+
* pending.
|
|
1220
|
+
*/
|
|
1221
|
+
override async approveTransferOut(
|
|
1222
|
+
domainName: string,
|
|
1223
|
+
opts?: RequestOptions
|
|
1224
|
+
): Promise<OperationResult> {
|
|
1225
|
+
return this.answerTransferOut(domainName, 'transferOutAccept', 'approved', opts);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Rejects the pending outbound transfer (POST .../transferOutReject, with no
|
|
1229
|
+
// reason); the domain stays in the account.
|
|
1230
|
+
override async rejectTransferOut(
|
|
1231
|
+
domainName: string,
|
|
1232
|
+
opts?: RequestOptions
|
|
1233
|
+
): Promise<OperationResult> {
|
|
1234
|
+
return this.answerTransferOut(domainName, 'transferOutReject', 'rejected', opts);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
private async answerTransferOut(
|
|
1238
|
+
domainName: string,
|
|
1239
|
+
action: 'transferOutAccept' | 'transferOutReject',
|
|
1240
|
+
answer: string,
|
|
1241
|
+
opts?: RequestOptions
|
|
1242
|
+
): Promise<OperationResult> {
|
|
1243
|
+
let customerId: string;
|
|
1244
|
+
try {
|
|
1245
|
+
customerId = await this.resolveCustomerId(opts);
|
|
1246
|
+
} catch (error) {
|
|
1247
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
1248
|
+
}
|
|
1249
|
+
return this.mutate(
|
|
1250
|
+
{ method: 'POST', path: `${this.domainPathV2(customerId, domainName)}/${action}` },
|
|
1251
|
+
`Transfer of ${domainName} ${answer}`,
|
|
1252
|
+
opts
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1170
1256
|
/**
|
|
1171
1257
|
* Transfer authorization (EPP) code. GoDaddy exposes it as the `authCode`
|
|
1172
1258
|
* field on the v1 domain-detail response; transfers were never moved to v3,
|
|
@@ -1231,6 +1317,48 @@ export class GoDaddyRegistrar extends BaseRegistrar {
|
|
|
1231
1317
|
}
|
|
1232
1318
|
}
|
|
1233
1319
|
|
|
1320
|
+
// --- transfer-out mapping ---
|
|
1321
|
+
|
|
1322
|
+
// GoDaddy's recent actions → the domain's latest transfer-out. The TRANSFER_OUT
|
|
1323
|
+
// action carries the transfer; while it's open, the latest accept or reject
|
|
1324
|
+
// logged after it (unless that failed) says how it was answered.
|
|
1325
|
+
function toTransferOut(actions: GoDaddyAction[]): TransferOut | null {
|
|
1326
|
+
const time = (a: GoDaddyAction) => parseDate(a.createdAt)?.getTime() ?? 0;
|
|
1327
|
+
const byLatest = (types: string[]) =>
|
|
1328
|
+
actions.filter(a => types.includes(a.type ?? '')).sort((a, b) => time(b) - time(a));
|
|
1329
|
+
const transfer = byLatest(['TRANSFER_OUT'])[0];
|
|
1330
|
+
if (!transfer) return null;
|
|
1331
|
+
const answer = byLatest(['TRANSFER_OUT_ACCEPT', 'TRANSFER_OUT_REJECT']).find(
|
|
1332
|
+
a => time(a) >= time(transfer) && !/^(FAILED|CANCELLED)$/i.test(a.status ?? '')
|
|
1333
|
+
);
|
|
1334
|
+
const rejected = answer?.type === 'TRANSFER_OUT_REJECT';
|
|
1335
|
+
const rawStatus = transfer.status ?? '';
|
|
1336
|
+
let status: TransferOutStatus;
|
|
1337
|
+
switch (rawStatus.toUpperCase()) {
|
|
1338
|
+
case 'ACCEPTED':
|
|
1339
|
+
case 'AWAITING':
|
|
1340
|
+
case 'PENDING':
|
|
1341
|
+
status = !answer ? 'pending' : rejected ? 'rejected' : 'approved';
|
|
1342
|
+
break;
|
|
1343
|
+
case 'SUCCESS':
|
|
1344
|
+
status = 'completed';
|
|
1345
|
+
break;
|
|
1346
|
+
case 'FAILED':
|
|
1347
|
+
case 'CANCELLED':
|
|
1348
|
+
status = rejected ? 'rejected' : 'cancelled';
|
|
1349
|
+
break;
|
|
1350
|
+
default:
|
|
1351
|
+
status = 'unknown';
|
|
1352
|
+
}
|
|
1353
|
+
return {
|
|
1354
|
+
status,
|
|
1355
|
+
rawStatus,
|
|
1356
|
+
requestedDate: parseDate(transfer.createdAt),
|
|
1357
|
+
expirationDate: null,
|
|
1358
|
+
completedDate: status === 'completed' ? parseDate(transfer.completedAt) : null,
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1234
1362
|
// --- contact mapping between GoDaddy's shape and the normalized Contact ---
|
|
1235
1363
|
|
|
1236
1364
|
function fromGoDaddyContact(c: GoDaddyContact | undefined): Contact | undefined {
|
package/src/types.ts
CHANGED
|
@@ -343,6 +343,33 @@ export interface DnssecStatus {
|
|
|
343
343
|
dsRecords: DsRecord[];
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
// Where an outbound transfer stands at the losing registrar. A transfer-out
|
|
347
|
+
// starts when another registrar requests the domain with its auth code:
|
|
348
|
+
// pending — waiting on the owner: approve it to release the domain now, or
|
|
349
|
+
// reject it. Left alone, most registrars release it after 5 days.
|
|
350
|
+
// approved — released to the gaining registrar, not yet completed
|
|
351
|
+
// rejected — refused by the owner or the registrar
|
|
352
|
+
// completed — the domain has left the account
|
|
353
|
+
// cancelled — withdrawn, failed or timed out on the gaining side
|
|
354
|
+
// unknown — a status label this library doesn't recognise (see `rawStatus`)
|
|
355
|
+
export type TransferOutStatus =
|
|
356
|
+
'pending' | 'approved' | 'rejected' | 'completed' | 'cancelled' | 'unknown';
|
|
357
|
+
|
|
358
|
+
// An outbound transfer of a domain, as the losing registrar reports it.
|
|
359
|
+
export interface TransferOut {
|
|
360
|
+
status: TransferOutStatus;
|
|
361
|
+
// the registrar's own status label, verbatim
|
|
362
|
+
rawStatus: string;
|
|
363
|
+
// the registrar's reference for the transfer (Dynadot: the order id)
|
|
364
|
+
id?: string;
|
|
365
|
+
// the registrar taking the domain, when reported
|
|
366
|
+
gainingRegistrar?: string;
|
|
367
|
+
requestedDate: Date | null;
|
|
368
|
+
// when the transfer request lapses or is released without action
|
|
369
|
+
expirationDate: Date | null;
|
|
370
|
+
completedDate: Date | null;
|
|
371
|
+
}
|
|
372
|
+
|
|
346
373
|
// The core interface every registrar implementation fulfils. `BaseRegistrar`
|
|
347
374
|
// (src/registrar.ts) provides the shared plumbing and `NotImplementedError`
|
|
348
375
|
// defaults; concrete providers under src/registrars/ extend it.
|
|
@@ -478,6 +505,17 @@ export interface Registrar {
|
|
|
478
505
|
// domain away to another registrar
|
|
479
506
|
getAuthCode(domainName: string, opts?: RequestOptions): Promise<string>;
|
|
480
507
|
|
|
508
|
+
// read the domain's most recent outbound transfer (one another registrar
|
|
509
|
+
// requested with the auth code); null when there is none on record
|
|
510
|
+
getTransferOut(domainName: string, opts?: RequestOptions): Promise<TransferOut | null>;
|
|
511
|
+
|
|
512
|
+
// approve a pending outbound transfer, releasing the domain to the gaining
|
|
513
|
+
// registrar now instead of after the registrar's waiting period
|
|
514
|
+
approveTransferOut(domainName: string, opts?: RequestOptions): Promise<OperationResult>;
|
|
515
|
+
|
|
516
|
+
// reject (deny) a pending outbound transfer; the domain stays put
|
|
517
|
+
rejectTransferOut(domainName: string, opts?: RequestOptions): Promise<OperationResult>;
|
|
518
|
+
|
|
481
519
|
// read DNSSEC status for a domain (whether it's enabled, plus any DS records)
|
|
482
520
|
getDnssec(domainName: string, opts?: RequestOptions): Promise<DnssecStatus>;
|
|
483
521
|
|