@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aoxborrow/registrar-client",
3
- "version": "0.10.1",
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, and email +
197
- // domain (URL) forwarding. All implemented on the v2 transport and verified in
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 {
@@ -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 read/disable
196
- // (LiveDNS keys), email forwarding, and web/URL forwarding (webredirs;
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 and transfer-out auth code (the `authCode`
303
- // field on the v1 domain-detail response). Forwarding uses the v2
304
- // customer-scoped route /v2/customers/{customerId}/domains/forwards/{fqdn}
305
- // (the old v1 /v1/domains/forwards routes now 404); it requires `customerId`
306
- // to be configured — verified live 2026-08-29. DNSSEC has no dedicated
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 uses the v2 customer-scoped API, which ` +
1050
- 'requires a customer ID. Set `customerId` to your GoDaddy customer UUID ' +
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