@aoxborrow/registrar-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/index.cjs +6798 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +768 -0
- package/dist/index.d.ts +768 -0
- package/dist/index.js +6747 -0
- package/dist/index.js.map +1 -0
- package/package.json +110 -0
- package/src/client.ts +200 -0
- package/src/constants.ts +11 -0
- package/src/errors.ts +192 -0
- package/src/features.ts +104 -0
- package/src/http.ts +230 -0
- package/src/index.ts +39 -0
- package/src/portfolio.ts +65 -0
- package/src/registrar.ts +240 -0
- package/src/registrars/cloudflare.ts +1205 -0
- package/src/registrars/dynadot.ts +1097 -0
- package/src/registrars/gandi.ts +970 -0
- package/src/registrars/godaddy.ts +1342 -0
- package/src/registrars/index.ts +41 -0
- package/src/registrars/namebright.ts +775 -0
- package/src/registrars/namecheap.ts +870 -0
- package/src/registrars/namesilo.ts +997 -0
- package/src/registrars/porkbun.ts +930 -0
- package/src/registrars/spaceship.ts +736 -0
- package/src/types.ts +446 -0
- package/src/utils.ts +135 -0
- package/src/xml.ts +39 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigField,
|
|
3
|
+
ConnectionResult,
|
|
4
|
+
Contact,
|
|
5
|
+
ContactSet,
|
|
6
|
+
DnsRecord,
|
|
7
|
+
DnssecStatus,
|
|
8
|
+
Domain,
|
|
9
|
+
DomainAvailability,
|
|
10
|
+
DomainForward,
|
|
11
|
+
DomainForwardType,
|
|
12
|
+
DsRecord,
|
|
13
|
+
EmailForward,
|
|
14
|
+
ListDomainsOptions,
|
|
15
|
+
OperationResult,
|
|
16
|
+
RegisterDomainInput,
|
|
17
|
+
RegistrarOptions,
|
|
18
|
+
RequestOptions,
|
|
19
|
+
TldPricing,
|
|
20
|
+
TransferDomainInput,
|
|
21
|
+
} from '../types';
|
|
22
|
+
import { createDomain, filterDomains, settableForwards } from '../utils';
|
|
23
|
+
import { toRegistrarError } from '../errors';
|
|
24
|
+
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
25
|
+
import { Feature, type RegistrarFeature } from '../features';
|
|
26
|
+
import type { RegistrarCredentials } from '../types';
|
|
27
|
+
|
|
28
|
+
// A contact as returned under `GET /v5/domain/domains/{fqdn}/contacts` (and
|
|
29
|
+
// nested in the domain-detail `contacts` object). Gandi names fields `given`/
|
|
30
|
+
// `family`/`streetaddr`/`zip`; privacy is `data_obfuscated` on the detail
|
|
31
|
+
// endpoint (older responses carried `extra_parameters.whois_privacy`).
|
|
32
|
+
interface GandiContact {
|
|
33
|
+
given?: string;
|
|
34
|
+
family?: string;
|
|
35
|
+
orgname?: string;
|
|
36
|
+
email?: string;
|
|
37
|
+
phone?: string;
|
|
38
|
+
fax?: string;
|
|
39
|
+
streetaddr?: string;
|
|
40
|
+
city?: string;
|
|
41
|
+
state?: string;
|
|
42
|
+
zip?: string;
|
|
43
|
+
country?: string;
|
|
44
|
+
data_obfuscated?: boolean;
|
|
45
|
+
extra_parameters?: { whois_privacy?: string };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// the four contact roles Gandi exposes; `bill` maps to our `billing`
|
|
49
|
+
interface GandiContacts {
|
|
50
|
+
owner?: GandiContact;
|
|
51
|
+
admin?: GandiContact;
|
|
52
|
+
tech?: GandiContact;
|
|
53
|
+
bill?: GandiContact;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Shape of a domain in `GET /v5/domain/domains` (list) and
|
|
57
|
+
// `GET /v5/domain/domains/{fqdn}` (detail). `status` is an array of EPP
|
|
58
|
+
// statuses; `autorenew` may be a bare boolean or an object; nameservers arrive
|
|
59
|
+
// as `nameserver.hosts` on the list endpoint and as a top-level `nameservers`
|
|
60
|
+
// array on the detail endpoint.
|
|
61
|
+
interface GandiDomain {
|
|
62
|
+
fqdn: string;
|
|
63
|
+
status?: string | string[];
|
|
64
|
+
dates?: {
|
|
65
|
+
created_at?: string;
|
|
66
|
+
registry_created_at?: string;
|
|
67
|
+
registry_ends_at?: string;
|
|
68
|
+
updated_at?: string;
|
|
69
|
+
};
|
|
70
|
+
autorenew?: boolean | { enabled?: boolean };
|
|
71
|
+
contacts?: GandiContacts;
|
|
72
|
+
nameserver?: { current?: string; hosts?: string[] };
|
|
73
|
+
nameservers?: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// one record set from `GET /v5/livedns/domains/{fqdn}/records`; a set can hold
|
|
77
|
+
// multiple values (e.g. several A records for one name), flattened on read.
|
|
78
|
+
interface GandiRecord {
|
|
79
|
+
rrset_type?: string;
|
|
80
|
+
rrset_name?: string;
|
|
81
|
+
rrset_ttl?: number;
|
|
82
|
+
rrset_values?: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// a single price entry within a pricing/check product
|
|
86
|
+
interface GandiPrice {
|
|
87
|
+
min_duration?: number;
|
|
88
|
+
max_duration?: number;
|
|
89
|
+
duration_unit?: string;
|
|
90
|
+
price_before_taxes?: number;
|
|
91
|
+
price_after_taxes?: number;
|
|
92
|
+
type?: string;
|
|
93
|
+
discount?: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// a product in `GET /v5/billing/price/domain` and `GET /v5/domain/check`. Each product
|
|
97
|
+
// carries the `process` (create/renew/transfer) its `prices` apply to; `check`
|
|
98
|
+
// products also carry availability `status`.
|
|
99
|
+
interface GandiProduct {
|
|
100
|
+
name?: string;
|
|
101
|
+
status?: string;
|
|
102
|
+
process?: string;
|
|
103
|
+
prices?: GandiPrice[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// envelope shared by the pricing and check endpoints
|
|
107
|
+
interface GandiPricingResponse {
|
|
108
|
+
currency?: string;
|
|
109
|
+
grid?: string;
|
|
110
|
+
products?: GandiProduct[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// a DNSSEC key from GET /v5/livedns/domains/{fqdn}/keys. `ds` is the full DS
|
|
114
|
+
// RR line ("<owner> <ttl> IN DS <keyTag> <algorithm> <digestType> <digest>").
|
|
115
|
+
interface GandiKey {
|
|
116
|
+
id?: string;
|
|
117
|
+
deleted?: boolean;
|
|
118
|
+
flags?: number;
|
|
119
|
+
algorithm?: number;
|
|
120
|
+
ds?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// an email forward from GET /v5/email/forwards/{domain}: `source` is the local
|
|
124
|
+
// part (mailbox), `destinations` the full addresses it forwards to.
|
|
125
|
+
interface GandiForward {
|
|
126
|
+
source?: string;
|
|
127
|
+
destinations?: string[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// a web-forwarding (URL redirect) entry from GET
|
|
131
|
+
// /v5/domain/domains/{fqdn}/webredirs. `host` is the full FQDN of the source
|
|
132
|
+
// subdomain; `type` is Gandi's redirect kind (http301/http302/cloak).
|
|
133
|
+
interface GandiWebredir {
|
|
134
|
+
host?: string;
|
|
135
|
+
type?: string;
|
|
136
|
+
url?: string;
|
|
137
|
+
protocol?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// map our DomainForwardType to Gandi's webredir `type`, and back.
|
|
141
|
+
const GANDI_FORWARD_TYPE: Record<'temporary' | 'permanent', string> = {
|
|
142
|
+
permanent: 'http301',
|
|
143
|
+
temporary: 'http302',
|
|
144
|
+
};
|
|
145
|
+
const GANDI_TYPE_TO_FORWARD: Record<string, DomainForwardType> = {
|
|
146
|
+
http301: 'permanent',
|
|
147
|
+
http302: 'temporary',
|
|
148
|
+
cloak: 'masked', // read-only; setDomainForwarding rejects it
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Gandi.net Registrar
|
|
153
|
+
* API docs: https://api.gandi.net/docs/
|
|
154
|
+
*
|
|
155
|
+
* Credentials: generate a Personal Access Token (PAT) under Account Settings >
|
|
156
|
+
* Security. The token must have domain management permissions. It is sent as a
|
|
157
|
+
* `Bearer` Authorization header. (Gandi's legacy `Apikey` scheme is deprecated
|
|
158
|
+
* and now returns 403.)
|
|
159
|
+
*/
|
|
160
|
+
export class GandiRegistrar extends BaseRegistrar {
|
|
161
|
+
readonly name = 'gandi';
|
|
162
|
+
|
|
163
|
+
static readonly displayName = 'Gandi.net';
|
|
164
|
+
static readonly helpText =
|
|
165
|
+
'Generate a Personal Access Token (PAT) in your Gandi account under Account ' +
|
|
166
|
+
'Settings > Security. The token needs permission to manage domains. (Gandi has ' +
|
|
167
|
+
'deprecated the legacy API Key; use a PAT.)';
|
|
168
|
+
static readonly configFields: ConfigField[] = [
|
|
169
|
+
{ name: 'apiKey', label: 'Personal Access Token', type: 'password', required: true },
|
|
170
|
+
];
|
|
171
|
+
// Gandi's v5 API offers a sandbox at api.sandbox.gandi.net (separate account)
|
|
172
|
+
static readonly supportsSandbox = true;
|
|
173
|
+
// On top of core: transfer-out auth code (`authinfo`), DNSSEC read/disable
|
|
174
|
+
// (LiveDNS keys), email forwarding, and web/URL forwarding (webredirs;
|
|
175
|
+
// per-subdomain, no apex). Note core `setPrivacy` disabling is a no-op for
|
|
176
|
+
// individual (natural-person) registrants — Gandi keeps WHOIS obfuscation on
|
|
177
|
+
// for GDPR regardless of the request.
|
|
178
|
+
static readonly extendedFeatures: readonly RegistrarFeature[] = [
|
|
179
|
+
Feature.GetAuthCode,
|
|
180
|
+
Feature.GetDnssec,
|
|
181
|
+
Feature.DisableDnssec,
|
|
182
|
+
Feature.GetEmailForwarding,
|
|
183
|
+
Feature.SetEmailForwarding,
|
|
184
|
+
Feature.GetDomainForwarding,
|
|
185
|
+
Feature.SetDomainForwarding,
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
|
|
189
|
+
super(
|
|
190
|
+
credentials,
|
|
191
|
+
{
|
|
192
|
+
baseUrl: selectBaseUrl('Gandi', options?.environment, {
|
|
193
|
+
production: 'https://api.gandi.net/v5',
|
|
194
|
+
sandbox: 'https://api.sandbox.gandi.net/v5',
|
|
195
|
+
}),
|
|
196
|
+
headers: {
|
|
197
|
+
'Authorization': `Bearer ${credentials.apiKey}`,
|
|
198
|
+
'Content-Type': 'application/json',
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
options
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
|
|
206
|
+
try {
|
|
207
|
+
await this.http.request<GandiDomain[]>({ path: '/domain/domains', ...opts });
|
|
208
|
+
return { success: true, message: 'Connection successful' };
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
|
|
215
|
+
const { search, ...reqOpts } = opts ?? {};
|
|
216
|
+
const domains: Domain[] = [];
|
|
217
|
+
const perPage = 1000; // Gandi API maximum page size
|
|
218
|
+
// Gandi's `fqdn` filter is server-side and supports wildcards, so a plain
|
|
219
|
+
// substring search is wrapped in `*...*`.
|
|
220
|
+
const term = search?.trim();
|
|
221
|
+
const fqdn = term ? `*${term}*` : undefined;
|
|
222
|
+
let page = 1;
|
|
223
|
+
|
|
224
|
+
for (;;) {
|
|
225
|
+
const list = await this.http.request<GandiDomain[]>({
|
|
226
|
+
path: '/domain/domains',
|
|
227
|
+
query: { per_page: perPage, page, ...(fqdn ? { fqdn } : {}) },
|
|
228
|
+
...reqOpts,
|
|
229
|
+
});
|
|
230
|
+
if (!list || list.length === 0) break;
|
|
231
|
+
|
|
232
|
+
for (const d of list) domains.push(this.toDomain(d));
|
|
233
|
+
|
|
234
|
+
if (list.length < perPage) break;
|
|
235
|
+
page++;
|
|
236
|
+
}
|
|
237
|
+
return filterDomains(domains, search);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// map Gandi's list/detail domain shape into a normalized Domain, tolerating
|
|
241
|
+
// the field variations documented on GandiDomain.
|
|
242
|
+
private toDomain(d: GandiDomain): Domain {
|
|
243
|
+
const statuses = Array.isArray(d.status) ? d.status : d.status ? [d.status] : [];
|
|
244
|
+
const autoRenew =
|
|
245
|
+
typeof d.autorenew === 'boolean' ? d.autorenew : (d.autorenew?.enabled ?? false);
|
|
246
|
+
return createDomain({
|
|
247
|
+
domainName: d.fqdn,
|
|
248
|
+
registrar: this.name,
|
|
249
|
+
status: statuses.join(','),
|
|
250
|
+
createdDate: d.dates?.created_at ?? d.dates?.registry_created_at,
|
|
251
|
+
expirationDate: d.dates?.registry_ends_at,
|
|
252
|
+
renewalDate: d.dates?.updated_at,
|
|
253
|
+
autoRenew,
|
|
254
|
+
locked: statuses.some(s => /transferprohibited|locked/i.test(s)),
|
|
255
|
+
privacy:
|
|
256
|
+
d.contacts?.owner?.extra_parameters?.whois_privacy === 'enabled' ||
|
|
257
|
+
d.contacts?.owner?.data_obfuscated === true,
|
|
258
|
+
nameservers: d.nameserver?.hosts ?? d.nameservers ?? [],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// fetch a single domain's details (dates, status, nameservers, privacy) via
|
|
263
|
+
// `GET /v5/domain/domains/{fqdn}` and normalize through the shared toDomain.
|
|
264
|
+
override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
|
|
265
|
+
const d = await this.http.request<GandiDomain>({
|
|
266
|
+
path: `/domain/domains/${domainName}`,
|
|
267
|
+
...opts,
|
|
268
|
+
});
|
|
269
|
+
return this.toDomain({ ...d, fqdn: d.fqdn ?? domainName });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// read the nameservers set on a domain. The dedicated endpoint returns a bare
|
|
273
|
+
// JSON array of hostname strings.
|
|
274
|
+
override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
|
|
275
|
+
const res = await this.http.request<string[]>({
|
|
276
|
+
path: `/domain/domains/${domainName}/nameservers`,
|
|
277
|
+
...opts,
|
|
278
|
+
});
|
|
279
|
+
return Array.isArray(res) ? res.map(String) : [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// read the registrant/admin/tech/billing contacts. Gandi keys these
|
|
283
|
+
// owner/admin/tech/bill; `owner` is the registrant and `bill` the billing role.
|
|
284
|
+
override async getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet> {
|
|
285
|
+
const res = await this.http.request<GandiContacts>({
|
|
286
|
+
path: `/domain/domains/${domainName}/contacts`,
|
|
287
|
+
...opts,
|
|
288
|
+
});
|
|
289
|
+
return {
|
|
290
|
+
registrant: fromGandiContact(res.owner),
|
|
291
|
+
admin: fromGandiContact(res.admin),
|
|
292
|
+
tech: fromGandiContact(res.tech),
|
|
293
|
+
billing: fromGandiContact(res.bill),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Read DNS records from LiveDNS (`GET /v5/livedns/domains/{fqdn}/records`).
|
|
299
|
+
* A LiveDNS record set carries an array of values, so each value is emitted as
|
|
300
|
+
* its own normalized DnsRecord. MX values are "priority target" and SRV values
|
|
301
|
+
* "priority weight port target"; those numeric prefixes are split into the
|
|
302
|
+
* dedicated DnsRecord fields, leaving `value` as the target.
|
|
303
|
+
*/
|
|
304
|
+
override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
|
|
305
|
+
const rrsets = await this.http.request<GandiRecord[]>({
|
|
306
|
+
path: `/livedns/domains/${domainName}/records`,
|
|
307
|
+
...opts,
|
|
308
|
+
});
|
|
309
|
+
const records: DnsRecord[] = [];
|
|
310
|
+
for (const rr of rrsets ?? []) {
|
|
311
|
+
const type = (rr.rrset_type ?? '').toUpperCase();
|
|
312
|
+
const name = rr.rrset_name ?? '';
|
|
313
|
+
for (const value of rr.rrset_values ?? []) {
|
|
314
|
+
const record: DnsRecord = { type, name, value };
|
|
315
|
+
if (rr.rrset_ttl != null) record.ttl = rr.rrset_ttl;
|
|
316
|
+
if (type === 'MX') {
|
|
317
|
+
const [priority, ...rest] = value.split(/\s+/);
|
|
318
|
+
const p = Number(priority);
|
|
319
|
+
if (Number.isFinite(p) && rest.length > 0) {
|
|
320
|
+
record.priority = p;
|
|
321
|
+
record.value = rest.join(' ');
|
|
322
|
+
}
|
|
323
|
+
} else if (type === 'SRV') {
|
|
324
|
+
const parts = value.split(/\s+/);
|
|
325
|
+
if (parts.length === 4) {
|
|
326
|
+
record.priority = Number(parts[0]);
|
|
327
|
+
record.weight = Number(parts[1]);
|
|
328
|
+
record.port = Number(parts[2]);
|
|
329
|
+
record.value = parts[3];
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
records.push(record);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return records;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Per-TLD pricing via `GET /v5/billing/price/domain`. `processes` is a repeatable
|
|
340
|
+
* query param and each returned product is tagged with the `process` its prices
|
|
341
|
+
* cover, so a single call fetches register/renew/transfer. A bare TLD is turned
|
|
342
|
+
* into a sample fqdn (`example.<tld>`) since the endpoint keys off a domain
|
|
343
|
+
* name. Gandi returns prices in major currency units already.
|
|
344
|
+
*/
|
|
345
|
+
override async getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing> {
|
|
346
|
+
const tld = (
|
|
347
|
+
tldOrDomain.includes('.') ? tldOrDomain.slice(tldOrDomain.indexOf('.') + 1) : tldOrDomain
|
|
348
|
+
).toLowerCase();
|
|
349
|
+
const name = tldOrDomain.includes('.') ? tldOrDomain : `example.${tld}`;
|
|
350
|
+
|
|
351
|
+
// `processes` must be repeated (create/renew/transfer); the query map can't
|
|
352
|
+
// hold a repeated key, so the params are encoded directly into the path.
|
|
353
|
+
const params = new URLSearchParams({ name });
|
|
354
|
+
for (const process of ['create', 'renew', 'transfer']) {
|
|
355
|
+
params.append('processes', process);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const resp = await this.http.request<GandiPricingResponse>({
|
|
359
|
+
path: `/billing/price/domain?${params.toString()}`,
|
|
360
|
+
...opts,
|
|
361
|
+
});
|
|
362
|
+
const products = resp.products ?? [];
|
|
363
|
+
const priceFor = (process: string): number | undefined =>
|
|
364
|
+
pickPrice(products.find(p => p.process === process)?.prices?.[0]);
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
tld,
|
|
368
|
+
currency: resp.currency ?? 'USD',
|
|
369
|
+
registration: priceFor('create'),
|
|
370
|
+
renewal: priceFor('renew'),
|
|
371
|
+
transfer: priceFor('transfer'),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Availability via `GET /v5/domain/check`, which checks a single name per call,
|
|
377
|
+
* so multiple names are looped. The default `create` product carries the
|
|
378
|
+
* availability `status` and registration price; a status of `available*` means
|
|
379
|
+
* registrable, and a `premium` price type (or `*premium` status) flags premium
|
|
380
|
+
* names.
|
|
381
|
+
*/
|
|
382
|
+
override async checkAvailability(
|
|
383
|
+
domainNames: string[],
|
|
384
|
+
opts?: RequestOptions
|
|
385
|
+
): Promise<DomainAvailability[]> {
|
|
386
|
+
const results: DomainAvailability[] = [];
|
|
387
|
+
for (const domainName of domainNames) {
|
|
388
|
+
const resp = await this.http.request<GandiPricingResponse>({
|
|
389
|
+
path: '/domain/check',
|
|
390
|
+
query: { name: domainName },
|
|
391
|
+
...opts,
|
|
392
|
+
});
|
|
393
|
+
const products = resp.products ?? [];
|
|
394
|
+
const product = products.find(p => p.process === 'create') ?? products[0];
|
|
395
|
+
const status = product?.status ?? '';
|
|
396
|
+
const price = product?.prices?.[0];
|
|
397
|
+
const result: DomainAvailability = {
|
|
398
|
+
domainName: product?.name ?? domainName,
|
|
399
|
+
available: status.startsWith('available'),
|
|
400
|
+
premium: status.includes('premium') || price?.type === 'premium',
|
|
401
|
+
};
|
|
402
|
+
const amount = pickPrice(price);
|
|
403
|
+
if (amount != null) result.price = amount;
|
|
404
|
+
if (resp.currency) result.currency = resp.currency;
|
|
405
|
+
if (price?.duration_unit === 'y' && price.min_duration != null) {
|
|
406
|
+
result.period = price.min_duration;
|
|
407
|
+
}
|
|
408
|
+
results.push(result);
|
|
409
|
+
}
|
|
410
|
+
return results;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
override async renewDomain(
|
|
414
|
+
domainName: string,
|
|
415
|
+
years = 1,
|
|
416
|
+
opts?: RequestOptions
|
|
417
|
+
): Promise<OperationResult> {
|
|
418
|
+
return this.mutate(
|
|
419
|
+
{ method: 'POST', path: `/domain/domains/${domainName}/renew`, body: { duration: years } },
|
|
420
|
+
'Domain renewed successfully',
|
|
421
|
+
opts
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
override async updateNameservers(
|
|
426
|
+
domainName: string,
|
|
427
|
+
nameservers: string[],
|
|
428
|
+
opts?: RequestOptions
|
|
429
|
+
): Promise<OperationResult> {
|
|
430
|
+
if (nameservers.length < 1) {
|
|
431
|
+
throw new Error('At least 1 nameserver is required');
|
|
432
|
+
}
|
|
433
|
+
return this.mutate(
|
|
434
|
+
{ method: 'PUT', path: `/domain/domains/${domainName}/nameservers`, body: { nameservers } },
|
|
435
|
+
'Nameservers updated successfully',
|
|
436
|
+
opts
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The transfer lock lives on the domain's `/status` subresource and is toggled
|
|
441
|
+
// with the `clientTransferProhibited` EPP flag (a boolean) — not by PATCHing a
|
|
442
|
+
// `status` string on the domain object, which 404s. The change is asynchronous
|
|
443
|
+
// (Gandi returns 202 "status change in progress"), so a read-back right after
|
|
444
|
+
// may still show the old state briefly.
|
|
445
|
+
override async lockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
446
|
+
return this.mutate(
|
|
447
|
+
{
|
|
448
|
+
method: 'PATCH',
|
|
449
|
+
path: `/domain/domains/${domainName}/status`,
|
|
450
|
+
body: { clientTransferProhibited: true },
|
|
451
|
+
},
|
|
452
|
+
'Domain locked successfully',
|
|
453
|
+
opts
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
override async unlockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
458
|
+
return this.mutate(
|
|
459
|
+
{
|
|
460
|
+
method: 'PATCH',
|
|
461
|
+
path: `/domain/domains/${domainName}/status`,
|
|
462
|
+
body: { clientTransferProhibited: false },
|
|
463
|
+
},
|
|
464
|
+
'Domain unlocked successfully',
|
|
465
|
+
opts
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// --- extended capabilities ---------------------------------------------
|
|
470
|
+
|
|
471
|
+
// The transfer authorization (EPP) code is the `authinfo` field on the
|
|
472
|
+
// domain-details endpoint (present for domains the account manages).
|
|
473
|
+
override async getAuthCode(domainName: string, opts?: RequestOptions): Promise<string> {
|
|
474
|
+
const d = await this.http.request<{ authinfo?: string }>({
|
|
475
|
+
path: `/domain/domains/${domainName}`,
|
|
476
|
+
...opts,
|
|
477
|
+
});
|
|
478
|
+
return d.authinfo ?? '';
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* DNSSEC status via Gandi LiveDNS keys (GET /livedns/domains/{fqdn}/keys). A
|
|
483
|
+
* non-deleted key means DNSSEC is enabled; each key's `ds` field is the full DS
|
|
484
|
+
* RR line, which we parse into keyTag/algorithm/digestType/digest.
|
|
485
|
+
*/
|
|
486
|
+
override async getDnssec(domainName: string, opts?: RequestOptions): Promise<DnssecStatus> {
|
|
487
|
+
const keys = await this.http.request<GandiKey[]>({
|
|
488
|
+
path: `/livedns/domains/${domainName}/keys`,
|
|
489
|
+
...opts,
|
|
490
|
+
});
|
|
491
|
+
const active = (keys ?? []).filter(k => !k.deleted);
|
|
492
|
+
const dsRecords = active.map(k => parseDsRecord(k.ds)).filter((d): d is DsRecord => d !== null);
|
|
493
|
+
return { enabled: active.length > 0, dsRecords };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Disable DNSSEC by deleting each LiveDNS key (Gandi has no single toggle —
|
|
498
|
+
* removing the keys removes the DS records at the registry).
|
|
499
|
+
*/
|
|
500
|
+
override async disableDnssec(
|
|
501
|
+
domainName: string,
|
|
502
|
+
opts?: RequestOptions
|
|
503
|
+
): Promise<OperationResult> {
|
|
504
|
+
try {
|
|
505
|
+
const keys = await this.http.request<GandiKey[]>({
|
|
506
|
+
path: `/livedns/domains/${domainName}/keys`,
|
|
507
|
+
...opts,
|
|
508
|
+
});
|
|
509
|
+
for (const k of keys ?? []) {
|
|
510
|
+
if (k.deleted || !k.id) continue;
|
|
511
|
+
await this.http.request({
|
|
512
|
+
method: 'DELETE',
|
|
513
|
+
path: `/livedns/domains/${domainName}/keys/${encodeURIComponent(k.id)}`,
|
|
514
|
+
...opts,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return { success: true, message: 'DNSSEC disabled successfully' };
|
|
518
|
+
} catch (error) {
|
|
519
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Read email forwarding via GET /email/forwards/{domain}. A Gandi forward has a
|
|
525
|
+
* `source` (local part) and multiple `destinations`; we expand each destination
|
|
526
|
+
* into its own {alias, forwardTo} row.
|
|
527
|
+
*/
|
|
528
|
+
override async getEmailForwarding(
|
|
529
|
+
domainName: string,
|
|
530
|
+
opts?: RequestOptions
|
|
531
|
+
): Promise<EmailForward[]> {
|
|
532
|
+
const forwards = await this.http.request<GandiForward[]>({
|
|
533
|
+
path: `/email/forwards/${domainName}`,
|
|
534
|
+
...opts,
|
|
535
|
+
});
|
|
536
|
+
const out: EmailForward[] = [];
|
|
537
|
+
for (const f of forwards ?? []) {
|
|
538
|
+
for (const dest of f.destinations ?? []) {
|
|
539
|
+
if (f.source && dest) out.push({ alias: f.source, forwardTo: dest });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Replace email forwarding (full replace; empty clears). Gandi has no bulk
|
|
547
|
+
* endpoint, so diff against the current forwards: group desired rows by source,
|
|
548
|
+
* DELETE sources no longer wanted, PUT changed sources, POST new ones.
|
|
549
|
+
*/
|
|
550
|
+
override async setEmailForwarding(
|
|
551
|
+
domainName: string,
|
|
552
|
+
forwards: EmailForward[],
|
|
553
|
+
opts?: RequestOptions
|
|
554
|
+
): Promise<OperationResult> {
|
|
555
|
+
try {
|
|
556
|
+
const desired = new Map<string, string[]>();
|
|
557
|
+
for (const f of forwards) {
|
|
558
|
+
const list = desired.get(f.alias) ?? [];
|
|
559
|
+
list.push(f.forwardTo);
|
|
560
|
+
desired.set(f.alias, list);
|
|
561
|
+
}
|
|
562
|
+
const current = await this.http.request<GandiForward[]>({
|
|
563
|
+
path: `/email/forwards/${domainName}`,
|
|
564
|
+
...opts,
|
|
565
|
+
});
|
|
566
|
+
const currentSources = new Set(
|
|
567
|
+
(current ?? []).map(f => f.source).filter((s): s is string => !!s)
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
for (const source of currentSources) {
|
|
571
|
+
if (!desired.has(source)) {
|
|
572
|
+
await this.http.request({
|
|
573
|
+
method: 'DELETE',
|
|
574
|
+
path: `/email/forwards/${domainName}/${encodeURIComponent(source)}`,
|
|
575
|
+
...opts,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
for (const [source, destinations] of desired) {
|
|
580
|
+
if (currentSources.has(source)) {
|
|
581
|
+
await this.http.request({
|
|
582
|
+
method: 'PUT',
|
|
583
|
+
path: `/email/forwards/${domainName}/${encodeURIComponent(source)}`,
|
|
584
|
+
body: { destinations },
|
|
585
|
+
...opts,
|
|
586
|
+
});
|
|
587
|
+
} else {
|
|
588
|
+
await this.http.request({
|
|
589
|
+
method: 'POST',
|
|
590
|
+
path: `/email/forwards/${domainName}`,
|
|
591
|
+
body: { source, destinations },
|
|
592
|
+
...opts,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return { success: true, message: 'Email forwarding updated successfully' };
|
|
597
|
+
} catch (error) {
|
|
598
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Read web/URL forwarding via GET /domain/domains/{fqdn}/webredirs. Gandi keys
|
|
604
|
+
* each redirect by the full FQDN of the source subdomain; we normalize that back
|
|
605
|
+
* to a host relative to the apex (e.g. `shop.example.com` -> `shop`).
|
|
606
|
+
*/
|
|
607
|
+
override async getDomainForwarding(
|
|
608
|
+
domainName: string,
|
|
609
|
+
opts?: RequestOptions
|
|
610
|
+
): Promise<DomainForward[]> {
|
|
611
|
+
const redirs = await this.http.request<GandiWebredir[]>({
|
|
612
|
+
path: `/domain/domains/${domainName}/webredirs`,
|
|
613
|
+
...opts,
|
|
614
|
+
});
|
|
615
|
+
return (redirs ?? []).map(r => ({
|
|
616
|
+
host: gandiFqdnToHost(r.host ?? domainName, domainName),
|
|
617
|
+
url: r.url ?? '',
|
|
618
|
+
type: GANDI_TYPE_TO_FORWARD[(r.type ?? '').toLowerCase()] ?? 'permanent',
|
|
619
|
+
}));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Replace web/URL forwarding (full replace; empty clears). Gandi's webredirs
|
|
624
|
+
* endpoint has no update — only create (POST) and delete (DELETE by FQDN) — so
|
|
625
|
+
* this diffs against the current set: delete entries that are gone or changed,
|
|
626
|
+
* then create the desired ones that aren't already present identically.
|
|
627
|
+
*
|
|
628
|
+
* Gandi only supports forwarding a subdomain, not the apex; an apex host ("@")
|
|
629
|
+
* throws rather than silently failing with a 500 from the API.
|
|
630
|
+
*/
|
|
631
|
+
override async setDomainForwarding(
|
|
632
|
+
domainName: string,
|
|
633
|
+
forwards: DomainForward[],
|
|
634
|
+
opts?: RequestOptions
|
|
635
|
+
): Promise<OperationResult> {
|
|
636
|
+
try {
|
|
637
|
+
const settable = settableForwards(forwards); // reject masked before any write
|
|
638
|
+
for (const f of settable) {
|
|
639
|
+
if (!f.host || f.host === '@') {
|
|
640
|
+
throw new Error(
|
|
641
|
+
`${this.name}: web forwarding requires a subdomain host; Gandi cannot forward the apex ("@")`
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
const current = await this.getDomainForwarding(domainName, opts);
|
|
646
|
+
const same = (a: DomainForward, b: DomainForward): boolean =>
|
|
647
|
+
a.host === b.host && a.url === b.url && a.type === b.type;
|
|
648
|
+
|
|
649
|
+
// delete current entries that are no longer wanted, or whose url/type changed
|
|
650
|
+
for (const existing of current) {
|
|
651
|
+
if (!settable.some(f => same(f, existing))) {
|
|
652
|
+
await this.http.request({
|
|
653
|
+
method: 'DELETE',
|
|
654
|
+
path: `/domain/domains/${domainName}/webredirs/${gandiHostToFqdn(existing.host, domainName)}`,
|
|
655
|
+
...opts,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// create desired entries that aren't already present identically
|
|
660
|
+
for (const f of settable) {
|
|
661
|
+
if (!current.some(c => same(c, f))) {
|
|
662
|
+
await this.http.request({
|
|
663
|
+
method: 'POST',
|
|
664
|
+
path: `/domain/domains/${domainName}/webredirs`,
|
|
665
|
+
// `protocol` is intentionally omitted so Gandi applies its default
|
|
666
|
+
// (http). Requesting `https` makes Gandi provision a Let's Encrypt
|
|
667
|
+
// certificate for the source host, which our normalized model doesn't
|
|
668
|
+
// express (and which the sandbox can't do — it 500s).
|
|
669
|
+
body: {
|
|
670
|
+
host: gandiHostToFqdn(f.host, domainName),
|
|
671
|
+
type: GANDI_FORWARD_TYPE[f.type],
|
|
672
|
+
url: f.url,
|
|
673
|
+
},
|
|
674
|
+
...opts,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return { success: true, message: 'Domain forwarding updated successfully' };
|
|
679
|
+
} catch (error) {
|
|
680
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
override async setAutoRenew(
|
|
685
|
+
domainName: string,
|
|
686
|
+
enabled: boolean,
|
|
687
|
+
opts?: RequestOptions
|
|
688
|
+
): Promise<OperationResult> {
|
|
689
|
+
try {
|
|
690
|
+
await this.http.request({
|
|
691
|
+
method: 'PATCH',
|
|
692
|
+
path: `/domain/domains/${domainName}/autorenew`,
|
|
693
|
+
body: { enabled },
|
|
694
|
+
...opts,
|
|
695
|
+
});
|
|
696
|
+
return {
|
|
697
|
+
success: true,
|
|
698
|
+
message: `Auto-renew ${enabled ? 'enabled' : 'disabled'} successfully`,
|
|
699
|
+
};
|
|
700
|
+
} catch (error) {
|
|
701
|
+
// Disabling auto-renew on a domain that never had an autorenew record
|
|
702
|
+
// returns 400 "This product has no autorenew record". That state already
|
|
703
|
+
// means auto-renew is off, so treat it as idempotent success.
|
|
704
|
+
const message = toRegistrarError(error).message;
|
|
705
|
+
if (!enabled && /no autorenew record/i.test(message)) {
|
|
706
|
+
return { success: true, message: 'Auto-renew disabled successfully' };
|
|
707
|
+
}
|
|
708
|
+
return { success: false, message };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* WHOIS privacy on Gandi is the per-contact `data_obfuscated` flag, toggled by
|
|
714
|
+
* PATCHing the domain's owner contact. Enabling works; disabling is accepted
|
|
715
|
+
* (HTTP 202) but is a **no-op for individual (natural-person) registrants** —
|
|
716
|
+
* Gandi keeps their contact obfuscated in WHOIS for GDPR regardless. The
|
|
717
|
+
* request is well-formed, so this reports the registrar's acceptance; whether
|
|
718
|
+
* the state actually changes is governed by Gandi's policy for the contact
|
|
719
|
+
* type. (Verified live against the sandbox.)
|
|
720
|
+
*/
|
|
721
|
+
override async setPrivacy(
|
|
722
|
+
domainName: string,
|
|
723
|
+
enabled: boolean,
|
|
724
|
+
opts?: RequestOptions
|
|
725
|
+
): Promise<OperationResult> {
|
|
726
|
+
return this.mutate(
|
|
727
|
+
{
|
|
728
|
+
method: 'PATCH',
|
|
729
|
+
path: `/domain/domains/${domainName}/contacts`,
|
|
730
|
+
body: { owner: { data_obfuscated: enabled } },
|
|
731
|
+
},
|
|
732
|
+
`WHOIS privacy ${enabled ? 'enabled' : 'disabled'} successfully`,
|
|
733
|
+
opts
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Replaces the entire zone via LiveDNS (`PUT /livedns/domains/{d}/records`),
|
|
739
|
+
* which takes `items` as grouped rrsets. Flat records are regrouped by
|
|
740
|
+
* (name, type); MX/SRV values are re-encoded with their numeric prefixes — the
|
|
741
|
+
* exact inverse of `getDnsRecords`, so a get→set round-trip is lossless.
|
|
742
|
+
*/
|
|
743
|
+
override async setDnsRecords(
|
|
744
|
+
domainName: string,
|
|
745
|
+
records: DnsRecord[],
|
|
746
|
+
opts?: RequestOptions
|
|
747
|
+
): Promise<OperationResult> {
|
|
748
|
+
return this.mutate(
|
|
749
|
+
{
|
|
750
|
+
method: 'PUT',
|
|
751
|
+
path: `/livedns/domains/${domainName}/records`,
|
|
752
|
+
body: { items: toGandiRrsets(records) },
|
|
753
|
+
},
|
|
754
|
+
'DNS records updated successfully',
|
|
755
|
+
opts
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Updates contact roles by PATCHing the domain's contacts. Only supplied roles
|
|
761
|
+
* are sent; `registrant` maps to Gandi's `owner`, `billing` to `bill`. A
|
|
762
|
+
* registrant change can trigger registry verification — not exercised live.
|
|
763
|
+
*/
|
|
764
|
+
override async updateContacts(
|
|
765
|
+
domainName: string,
|
|
766
|
+
contacts: ContactSet,
|
|
767
|
+
opts?: RequestOptions
|
|
768
|
+
): Promise<OperationResult> {
|
|
769
|
+
const body: GandiContacts = {};
|
|
770
|
+
if (contacts.registrant) body.owner = toGandiContact(contacts.registrant);
|
|
771
|
+
if (contacts.admin) body.admin = toGandiContact(contacts.admin);
|
|
772
|
+
if (contacts.tech) body.tech = toGandiContact(contacts.tech);
|
|
773
|
+
if (contacts.billing) body.bill = toGandiContact(contacts.billing);
|
|
774
|
+
if (Object.keys(body).length === 0) {
|
|
775
|
+
throw new Error(`${this.name}: updateContacts requires at least one contact role`);
|
|
776
|
+
}
|
|
777
|
+
return this.mutate(
|
|
778
|
+
{ method: 'PATCH', path: `/domain/domains/${domainName}/contacts`, body },
|
|
779
|
+
'Contacts updated successfully',
|
|
780
|
+
opts
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Registers a domain via `POST /domain/domains`. Requires a registrant (Gandi's
|
|
786
|
+
* `owner`); other roles fall back to it. `data_obfuscated` carries the privacy
|
|
787
|
+
* choice. Spends real money; not exercised against a live account.
|
|
788
|
+
*/
|
|
789
|
+
override async registerDomain(
|
|
790
|
+
domainName: string,
|
|
791
|
+
input: RegisterDomainInput,
|
|
792
|
+
opts?: RequestOptions
|
|
793
|
+
): Promise<OperationResult> {
|
|
794
|
+
const registrant = input.contacts.registrant;
|
|
795
|
+
if (!registrant) {
|
|
796
|
+
throw new Error(`${this.name}: registration requires at least a registrant contact`);
|
|
797
|
+
}
|
|
798
|
+
const owner = toGandiContact(registrant);
|
|
799
|
+
if (input.privacy != null) owner.data_obfuscated = input.privacy;
|
|
800
|
+
const body: Record<string, unknown> = {
|
|
801
|
+
fqdn: domainName,
|
|
802
|
+
duration: input.years ?? 1,
|
|
803
|
+
owner,
|
|
804
|
+
};
|
|
805
|
+
if (input.contacts.admin) body.admin = toGandiContact(input.contacts.admin);
|
|
806
|
+
if (input.contacts.tech) body.tech = toGandiContact(input.contacts.tech);
|
|
807
|
+
if (input.contacts.billing) body.bill = toGandiContact(input.contacts.billing);
|
|
808
|
+
if (input.nameservers && input.nameservers.length > 0) body.nameservers = input.nameservers;
|
|
809
|
+
return this.mutate(
|
|
810
|
+
{ method: 'POST', path: '/domain/domains', body },
|
|
811
|
+
`Domain ${domainName} registered successfully`,
|
|
812
|
+
opts
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Transfers a domain in via `POST /domain/transferin/{d}` with the EPP auth code
|
|
818
|
+
* (`authinfo`) and the new owner. Spends real money; not exercised against a
|
|
819
|
+
* live account.
|
|
820
|
+
*/
|
|
821
|
+
override async transferIn(
|
|
822
|
+
domainName: string,
|
|
823
|
+
input: TransferDomainInput,
|
|
824
|
+
opts?: RequestOptions
|
|
825
|
+
): Promise<OperationResult> {
|
|
826
|
+
const registrant = input.contacts?.registrant;
|
|
827
|
+
if (!registrant) {
|
|
828
|
+
throw new Error(`${this.name}: transfer requires a registrant contact (Gandi's owner)`);
|
|
829
|
+
}
|
|
830
|
+
const owner = toGandiContact(registrant);
|
|
831
|
+
if (input.privacy != null) owner.data_obfuscated = input.privacy;
|
|
832
|
+
const body: Record<string, unknown> = {
|
|
833
|
+
fqdn: domainName,
|
|
834
|
+
authinfo: input.authCode,
|
|
835
|
+
owner,
|
|
836
|
+
};
|
|
837
|
+
if (input.years != null) body.duration = input.years;
|
|
838
|
+
return this.mutate(
|
|
839
|
+
{ method: 'POST', path: `/domain/transferin/${domainName}`, body },
|
|
840
|
+
`Domain ${domainName} transfer requested successfully`,
|
|
841
|
+
opts
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private async mutate(
|
|
846
|
+
req: { method: string; path: string; body?: unknown },
|
|
847
|
+
successMessage: string,
|
|
848
|
+
opts?: RequestOptions
|
|
849
|
+
): Promise<OperationResult> {
|
|
850
|
+
try {
|
|
851
|
+
await this.http.request({ ...req, ...opts });
|
|
852
|
+
return { success: true, message: successMessage };
|
|
853
|
+
} catch (error) {
|
|
854
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// map a Gandi contact into the normalized Contact shape. Gandi has no distinct
|
|
860
|
+
// second address line, so `address2` is left undefined.
|
|
861
|
+
// map the normalized Contact to Gandi's contact shape (given/family/streetaddr/…).
|
|
862
|
+
// `type` is Gandi's contact type: 1 (company) when an organization is set, else
|
|
863
|
+
// 0 (individual).
|
|
864
|
+
function toGandiContact(c: Contact): GandiContact & { type: number } {
|
|
865
|
+
const contact: GandiContact & { type: number } = {
|
|
866
|
+
given: c.firstName,
|
|
867
|
+
family: c.lastName,
|
|
868
|
+
email: c.email,
|
|
869
|
+
phone: c.phone,
|
|
870
|
+
streetaddr: c.address1,
|
|
871
|
+
city: c.city,
|
|
872
|
+
zip: c.postalCode,
|
|
873
|
+
country: c.country,
|
|
874
|
+
type: c.organization ? 1 : 0,
|
|
875
|
+
};
|
|
876
|
+
if (c.organization) contact.orgname = c.organization;
|
|
877
|
+
if (c.fax) contact.fax = c.fax;
|
|
878
|
+
if (c.state) contact.state = toGandiState(c.state, c.country);
|
|
879
|
+
return contact;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Gandi wants the ISO 3166-2 subdivision code for `state` (e.g. `US-CA`), and
|
|
883
|
+
// rejects a bare 2-letter code as "shorter than minimum length 4". The rest of
|
|
884
|
+
// the library stores just the subdivision (`CA`, `ENG`), so prefix it with the
|
|
885
|
+
// country to form the ISO 3166-2 code. A value that already contains a `-`
|
|
886
|
+
// (already `US-CA`) is left as-is.
|
|
887
|
+
function toGandiState(state: string, country: string): string {
|
|
888
|
+
return country && !state.includes('-') ? `${country}-${state}` : state;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// regroup flat DnsRecords into Gandi LiveDNS rrsets (one per name+type, values
|
|
892
|
+
// collected). MX/SRV values are re-encoded with their numeric prefixes — the
|
|
893
|
+
// exact inverse of getDnsRecords — so a get→set round-trip reproduces the zone.
|
|
894
|
+
function toGandiRrsets(records: DnsRecord[]): GandiRecord[] {
|
|
895
|
+
const groups = new Map<string, GandiRecord>();
|
|
896
|
+
for (const r of records) {
|
|
897
|
+
const type = r.type.toUpperCase();
|
|
898
|
+
const name = r.name || '@';
|
|
899
|
+
let value = r.value;
|
|
900
|
+
if (type === 'MX' && r.priority != null) {
|
|
901
|
+
value = `${r.priority} ${r.value}`;
|
|
902
|
+
} else if (type === 'SRV') {
|
|
903
|
+
value = `${r.priority ?? 0} ${r.weight ?? 0} ${r.port ?? 0} ${r.value}`;
|
|
904
|
+
}
|
|
905
|
+
const key = `${name} ${type}`;
|
|
906
|
+
let rrset = groups.get(key);
|
|
907
|
+
if (!rrset) {
|
|
908
|
+
rrset = { rrset_name: name, rrset_type: type, rrset_values: [] };
|
|
909
|
+
groups.set(key, rrset);
|
|
910
|
+
}
|
|
911
|
+
if (rrset.rrset_ttl == null && r.ttl != null) rrset.rrset_ttl = r.ttl;
|
|
912
|
+
rrset.rrset_values!.push(value);
|
|
913
|
+
}
|
|
914
|
+
return [...groups.values()];
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Gandi webredirs are keyed by the full FQDN of the source subdomain, while our
|
|
918
|
+
// DomainForward.host is relative to the apex. These convert between the two.
|
|
919
|
+
function gandiHostToFqdn(host: string, domain: string): string {
|
|
920
|
+
return !host || host === '@' ? domain : `${host}.${domain}`;
|
|
921
|
+
}
|
|
922
|
+
function gandiFqdnToHost(fqdn: string, domain: string): string {
|
|
923
|
+
if (fqdn === domain) return '@';
|
|
924
|
+
const suffix = `.${domain}`;
|
|
925
|
+
return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function fromGandiContact(c: GandiContact | undefined): Contact | undefined {
|
|
929
|
+
if (!c) return undefined;
|
|
930
|
+
return {
|
|
931
|
+
firstName: c.given ?? '',
|
|
932
|
+
lastName: c.family ?? '',
|
|
933
|
+
organization: c.orgname ? String(c.orgname) : undefined,
|
|
934
|
+
email: c.email ?? '',
|
|
935
|
+
phone: c.phone ?? '',
|
|
936
|
+
fax: c.fax ? String(c.fax) : undefined,
|
|
937
|
+
address1: c.streetaddr ?? '',
|
|
938
|
+
city: c.city ?? '',
|
|
939
|
+
state: c.state ? String(c.state) : undefined,
|
|
940
|
+
postalCode: c.zip ?? '',
|
|
941
|
+
country: c.country ?? '',
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// pick a numeric price from a Gandi price entry, preferring the tax-inclusive
|
|
946
|
+
// amount. Gandi reports prices in major currency units (e.g. 15.5), so no
|
|
947
|
+
// cents-to-dollars conversion is needed.
|
|
948
|
+
function pickPrice(price: GandiPrice | undefined): number | undefined {
|
|
949
|
+
if (!price) return undefined;
|
|
950
|
+
const value = price.price_after_taxes ?? price.price_before_taxes;
|
|
951
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// parse a DS resource-record line into its components. Gandi's `ds` field looks
|
|
955
|
+
// like "example.com. 3600 IN DS 50651 13 2 01CC01EE...". Returns null if it
|
|
956
|
+
// doesn't contain a DS record (e.g. a key with no published DS).
|
|
957
|
+
function parseDsRecord(ds: string | undefined): DsRecord | null {
|
|
958
|
+
if (!ds) return null;
|
|
959
|
+
const tokens = ds.trim().split(/\s+/);
|
|
960
|
+
const i = tokens.findIndex(t => t.toUpperCase() === 'DS');
|
|
961
|
+
const parts = i >= 0 ? tokens.slice(i + 1) : tokens;
|
|
962
|
+
if (parts.length < 4) return null;
|
|
963
|
+
const [keyTag, algorithm, digestType, ...digest] = parts;
|
|
964
|
+
return {
|
|
965
|
+
keyTag: Number(keyTag) || 0,
|
|
966
|
+
algorithm: Number(algorithm) || 0,
|
|
967
|
+
digestType: Number(digestType) || 0,
|
|
968
|
+
digest: digest.join(''),
|
|
969
|
+
};
|
|
970
|
+
}
|