@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.
@@ -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
+ }