@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/README.md +10 -7
- package/dist/index.cjs +163 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +163 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/registrars/gandi.ts +91 -3
- package/src/registrars/godaddy.ts +136 -8
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/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 {
|