@aoxborrow/registrar-client 0.11.0 → 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.11.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",
@@ -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 {