@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,1097 @@
1
+ import type {
2
+ ConfigField,
3
+ ConnectionResult,
4
+ Contact,
5
+ ContactSet,
6
+ DnsRecord,
7
+ DnssecStatus,
8
+ Domain,
9
+ DomainAvailability,
10
+ DomainForward,
11
+ EmailForward,
12
+ ListDomainsOptions,
13
+ OperationResult,
14
+ RegisterDomainInput,
15
+ RegistrarOptions,
16
+ RequestOptions,
17
+ TldPricing,
18
+ TransferDomainInput,
19
+ } from '../types';
20
+ import { createDomain, filterDomains, requireConsent, settableForwards } from '../utils';
21
+ import { NotFoundError, NotImplementedError, toRegistrarError } from '../errors';
22
+ import { BaseRegistrar, selectBaseUrl } from '../registrar';
23
+ import { Feature, type RegistrarFeature } from '../features';
24
+ import type { RegistrarCredentials } from '../types';
25
+
26
+ // --- RESTful v2 response shapes (snake_case; captured from the live API) ---
27
+
28
+ // the standard envelope: HTTP 200 with a `code` of 200 on success, otherwise an
29
+ // `error.description`. Signature/auth failures come back as real HTTP 4xx.
30
+ interface V2Envelope<T> {
31
+ code?: number;
32
+ message?: string;
33
+ data?: T;
34
+ error?: { code?: string; description?: string };
35
+ }
36
+
37
+ interface V2Nameserver {
38
+ server_name?: string;
39
+ ip_list?: string[];
40
+ }
41
+
42
+ // a DNS record under glue_info (record_value2 carries the MX priority)
43
+ interface V2DnsRecord {
44
+ sub_host?: string;
45
+ record_type?: string;
46
+ record_value1?: string;
47
+ record_value2?: string;
48
+ }
49
+
50
+ // the glue/DNS block: `glue_type` is NAME_SERVERS (external NS), DNS (Dynadot-
51
+ // hosted records), REGISTRAR_FORWARDING (URL redirect), or
52
+ // REGISTRAR_STEALTH_FORWARDING (framed/cloaked redirect). It also carries the
53
+ // per-domain email-forwarding settings inline.
54
+ interface V2GlueInfo {
55
+ glue_type?: string;
56
+ nameserver_list?: V2Nameserver[];
57
+ dns_main_list?: V2DnsRecord[];
58
+ dns_sub_list?: V2DnsRecord[];
59
+ ttl?: string | number;
60
+ // domain (URL) forwarding, present when glue_type is REGISTRAR_FORWARDING /
61
+ // REGISTRAR_STEALTH_FORWARDING. `forward_type` reads back as
62
+ // "permanently"/"temporarily"; stealth carries a `stealth_title` instead.
63
+ forward_url?: string;
64
+ forward_type?: string;
65
+ stealth_title?: string;
66
+ // email forwarding: `email_forward_type` reads back uppercase
67
+ // (MTYPE_NONE/MTYPE_FORWARD/MTYPE_MX); aliases carry username -> destination.
68
+ email_forward_type?: string;
69
+ email_alias_list?: { username?: string; email?: string }[];
70
+ }
71
+
72
+ // one DNS-security record from GET .../dnssec. `algorithm` / `digest_type` read
73
+ // back as human labels with the numeric code in parentheses, e.g. "SHA-256 (2)".
74
+ interface V2DnssecInfo {
75
+ key_tag?: string | number;
76
+ algorithm?: string | number;
77
+ digest_type?: string | number;
78
+ digest?: string;
79
+ }
80
+
81
+ // Dynadot's default nameservers, used to switch a domain off URL forwarding
82
+ // (there is no dedicated "clear forwarding" endpoint).
83
+ const DYNADOT_DEFAULT_NS = ['ns1.dynadot.com', 'ns2.dynadot.com'];
84
+
85
+ interface V2DomainInfo {
86
+ domain_name?: string;
87
+ expiration_date?: number;
88
+ registration_date?: number;
89
+ glue_info?: V2GlueInfo;
90
+ registrant_contact_id?: number;
91
+ admin_contact_id?: number;
92
+ technical_contact_id?: number;
93
+ billing_contact_id?: number;
94
+ locked?: string;
95
+ privacy?: string;
96
+ renew_option?: string;
97
+ status?: string;
98
+ }
99
+
100
+ // a full contact record. Name is one combined field; phone/fax split into a
101
+ // country code + number.
102
+ interface V2Contact {
103
+ contact_id?: number;
104
+ organization?: string;
105
+ name?: string;
106
+ email?: string;
107
+ phone_number?: string;
108
+ phone_cc?: string | number;
109
+ fax_number?: string;
110
+ fax_cc?: string | number;
111
+ address1?: string;
112
+ address2?: string;
113
+ city?: string;
114
+ state?: string;
115
+ zip?: string;
116
+ country?: string;
117
+ }
118
+
119
+ interface V2Price {
120
+ currency?: string;
121
+ unit?: string; // e.g. "(price/1 year)"
122
+ registration_price?: string;
123
+ renewal_price?: string;
124
+ transfer_price?: string;
125
+ restore_price?: string;
126
+ }
127
+
128
+ interface V2SearchResult {
129
+ domain_name?: string;
130
+ available?: string; // "Yes" | "No"
131
+ premium?: string; // "yes" | "no"
132
+ price_list?: V2Price[];
133
+ }
134
+
135
+ // bulk_search is capped conservatively; the endpoint takes a comma-joined list
136
+ const SEARCH_BATCH_SIZE = 100;
137
+
138
+ // Dynadot DNS record types that map cleanly to our generic DnsRecord types.
139
+ // (Dynadot also has forward/stealth/email pseudo-records — those are forwarding
140
+ // features, not generic DNS, so they're excluded here.)
141
+ const SUPPORTED_DNS_TYPES = new Set(['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA']);
142
+
143
+ /**
144
+ * Dynadot Registrar
145
+ * API docs: https://www.dynadot.com/domain/api-document
146
+ *
147
+ * Targets Dynadot's **RESTful v2 API** (`https://api.dynadot.com/restful/v2/…`).
148
+ *
149
+ * ## Authentication
150
+ * Two credentials: an **API key** (the public identifier, sent as
151
+ * `Authorization: Bearer <key>`) and an **API secret** (used only to sign
152
+ * requests). Every request carries an `X-Signature` header — the Base64 HMAC-
153
+ * SHA256, keyed by the secret, of:
154
+ *
155
+ * `apiKey + "\n" + pathAndQuery + "\n" + "" + "\n" + body`
156
+ *
157
+ * The third component is the (optional) `X-Request-Id`; this client signs it as
158
+ * an empty string and does not send the header. Signing uses Web Crypto
159
+ * (`crypto.subtle`), so it stays browser/edge-safe.
160
+ *
161
+ * Enable both keys under Tools > API in a Dynadot account (the account must be
162
+ * unlocked with API access enabled).
163
+ *
164
+ * ## Envelope
165
+ * Responses are `{ code, message, data }`, with `code: 200` on success and an
166
+ * `error.description` otherwise. Note Dynadot returns HTTP 200 even for logical
167
+ * errors (a bad command, a missing domain) — only auth/signature failures are
168
+ * real HTTP 4xx — so success is read from the envelope `code`, not the status.
169
+ *
170
+ * ## Verification
171
+ * Every method's path and payload is verified against a live account. Reads
172
+ * (`testConnection`, `listDomains`, `getDomain`, `getNameservers`,
173
+ * `getContacts`, `getDnsRecords`, `checkAvailability`, `getPricing`) were run
174
+ * against the production account; all writes (`registerDomain`, `transferIn`,
175
+ * `renewDomain`, `setAutoRenew`, `updateNameservers`, `lockDomain`/
176
+ * `unlockDomain`, `setPrivacy`, `setDnsRecords`) were exercised end-to-end in the
177
+ * Dynadot sandbox (`api-sandbox.dynadot.com`), which mirrors the API.
178
+ */
179
+ export class DynadotRegistrar extends BaseRegistrar {
180
+ readonly name = 'dynadot';
181
+
182
+ static readonly displayName = 'Dynadot';
183
+ static readonly helpText =
184
+ 'Under Tools > API in your Dynadot account, generate an API Key and API Secret. ' +
185
+ 'The account must be unlocked with API access enabled. The key identifies you; ' +
186
+ 'the secret signs requests (X-Signature) and is never sent directly.';
187
+ static readonly configFields: ConfigField[] = [
188
+ { name: 'apiKey', label: 'API Key', type: 'password', required: true },
189
+ { name: 'apiSecret', label: 'API Secret', type: 'password', required: true },
190
+ ];
191
+ static readonly supportsSandbox = true; // api-sandbox.dynadot.com mirrors the API
192
+ // On top of core: transfer-out auth code, DNSSEC read/disable, and email +
193
+ // domain (URL) forwarding. All implemented on the v2 transport and verified in
194
+ // the Dynadot sandbox.
195
+ static readonly extendedFeatures: readonly RegistrarFeature[] = [
196
+ Feature.GetAuthCode,
197
+ Feature.GetDnssec,
198
+ Feature.DisableDnssec,
199
+ Feature.GetEmailForwarding,
200
+ Feature.SetEmailForwarding,
201
+ Feature.GetDomainForwarding,
202
+ Feature.SetDomainForwarding,
203
+ ];
204
+
205
+ constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
206
+ super(
207
+ credentials,
208
+ {
209
+ // baseUrl is the host; each request builds the full /restful/v2/… path
210
+ baseUrl: selectBaseUrl('Dynadot', options?.environment, {
211
+ production: 'https://api.dynadot.com',
212
+ sandbox: 'https://api-sandbox.dynadot.com',
213
+ }),
214
+ headers: { Authorization: `Bearer ${credentials.apiKey}` },
215
+ },
216
+ options
217
+ );
218
+ }
219
+
220
+ // sign and issue a request. `pathAndQuery` is the exact request target
221
+ // (path + any query), used verbatim both to sign and to send so the two match.
222
+ private async signedRequest<T>(
223
+ method: string,
224
+ pathAndQuery: string,
225
+ body?: unknown,
226
+ opts?: RequestOptions
227
+ ): Promise<V2Envelope<T>> {
228
+ const bodyStr = body !== undefined ? JSON.stringify(body) : '';
229
+ const stringToSign = `${this.credentials.apiKey}\n${pathAndQuery}\n\n${bodyStr}`;
230
+ const signature = await hmacSha256Base64(this.credentials.apiSecret, stringToSign);
231
+ return this.http.request<V2Envelope<T>>({
232
+ method,
233
+ path: pathAndQuery,
234
+ body,
235
+ // Dynadot requires application/json even on bodyless mutations (e.g. the
236
+ // DELETE for disableDnssec), so set it explicitly rather than relying on
237
+ // the client's body-present default.
238
+ headers: { 'X-Signature': signature, 'Content-Type': 'application/json' },
239
+ ...opts,
240
+ });
241
+ }
242
+
243
+ // issue a request and return its `data`, throwing on a non-2xx envelope code
244
+ private async read<T>(
245
+ method: string,
246
+ pathAndQuery: string,
247
+ body?: unknown,
248
+ opts?: RequestOptions
249
+ ): Promise<T> {
250
+ const env = await this.signedRequest<T>(method, pathAndQuery, body, opts);
251
+ if (!isOk(env.code)) {
252
+ throw new Error(env.error?.description ?? env.message ?? 'Dynadot request failed');
253
+ }
254
+ return env.data as T;
255
+ }
256
+
257
+ // issue a mutating request and map its envelope to an OperationResult. The
258
+ // envelope `code` mirrors the HTTP status, so success spans 2xx (register is
259
+ // 200, transfer_in is 202 Accepted).
260
+ private async mutate(
261
+ method: string,
262
+ pathAndQuery: string,
263
+ body: unknown,
264
+ successMessage: string,
265
+ opts?: RequestOptions
266
+ ): Promise<OperationResult> {
267
+ try {
268
+ const env = await this.signedRequest<unknown>(method, pathAndQuery, body, opts);
269
+ const ok = isOk(env.code);
270
+ return {
271
+ success: ok,
272
+ message: ok ? successMessage : (env.error?.description ?? env.message ?? 'Request failed'),
273
+ };
274
+ } catch (error) {
275
+ return { success: false, message: toRegistrarError(error).message };
276
+ }
277
+ }
278
+
279
+ override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
280
+ try {
281
+ await this.read<{ account_info?: unknown }>(
282
+ 'GET',
283
+ '/restful/v2/accounts/info',
284
+ undefined,
285
+ opts
286
+ );
287
+ return { success: true, message: 'Connection successful' };
288
+ } catch (error) {
289
+ return { success: false, message: toRegistrarError(error).message };
290
+ }
291
+ }
292
+
293
+ override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
294
+ // GET /domains returns the whole account in one response, nameservers inline.
295
+ const { search, ...reqOpts } = opts ?? {};
296
+ const data = await this.read<{ domain_info_list?: V2DomainInfo[] }>(
297
+ 'GET',
298
+ '/restful/v2/domains',
299
+ undefined,
300
+ reqOpts
301
+ );
302
+ const domains = (data.domain_info_list ?? []).map(info => this.toDomain(info));
303
+ return filterDomains(domains, search);
304
+ }
305
+
306
+ override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
307
+ return this.toDomain(await this.domainInfo(domainName, opts));
308
+ }
309
+
310
+ // fetch and unwrap GET /domains/{name} -> data.domain_info (shared by getDomain,
311
+ // getContacts, and the forwarding reads, which all read from domain_info)
312
+ private async domainInfo(domainName: string, opts?: RequestOptions): Promise<V2DomainInfo> {
313
+ const data = await this.read<{ domain_info?: V2DomainInfo }>(
314
+ 'GET',
315
+ `/restful/v2/domains/${encodeURIComponent(domainName)}`,
316
+ undefined,
317
+ opts
318
+ );
319
+ if (!data.domain_info) throw new NotFoundError(`Dynadot: domain '${domainName}' not found`);
320
+ return data.domain_info;
321
+ }
322
+
323
+ override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
324
+ // domain_info carries the nameservers inline (empty for Dynadot-DNS-hosted
325
+ // domains, which have no external NS)
326
+ const domain = await this.getDomain(domainName, opts);
327
+ return domain.nameservers;
328
+ }
329
+
330
+ /**
331
+ * Reads registrant/admin/tech/billing contacts. domain_info carries a numeric
332
+ * contact id per role; this resolves each distinct id via GET /contacts/{id}
333
+ * (roles usually share one contact, so ids are de-duplicated).
334
+ */
335
+ override async getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet> {
336
+ const info = await this.domainInfo(domainName, opts);
337
+
338
+ const roleIds: [keyof ContactSet, string][] = [
339
+ ['registrant', str(info.registrant_contact_id)],
340
+ ['admin', str(info.admin_contact_id)],
341
+ ['tech', str(info.technical_contact_id)],
342
+ ['billing', str(info.billing_contact_id)],
343
+ ];
344
+
345
+ const byId = new Map<string, Contact>();
346
+ for (const [, id] of roleIds) {
347
+ if (!id || id === '0' || byId.has(id)) continue;
348
+ const cdata = await this.read<{ contact?: V2Contact }>(
349
+ 'GET',
350
+ `/restful/v2/contacts/${encodeURIComponent(id)}`,
351
+ undefined,
352
+ opts
353
+ );
354
+ if (cdata.contact) byId.set(id, toContact(cdata.contact));
355
+ }
356
+
357
+ const contacts: ContactSet = {};
358
+ for (const [role, id] of roleIds) {
359
+ const contact = byId.get(id);
360
+ if (contact) contacts[role] = contact;
361
+ }
362
+ return contacts;
363
+ }
364
+
365
+ override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
366
+ const data = await this.read<{ glue_info?: V2GlueInfo }>(
367
+ 'GET',
368
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/records`,
369
+ undefined,
370
+ opts
371
+ );
372
+ const glue = data.glue_info ?? {};
373
+ // only Dynadot-hosted DNS has records; NAME_SERVERS / forwarding domains have none
374
+ if (glue.glue_type !== 'DNS') return [];
375
+ const ttl = glue.ttl != null ? Number(glue.ttl) : undefined;
376
+ return [
377
+ ...(glue.dns_main_list ?? []).map(r => toDnsRecord(r, '@', ttl)),
378
+ ...(glue.dns_sub_list ?? []).map(r => toDnsRecord(r, r.sub_host ?? '', ttl)),
379
+ ];
380
+ }
381
+
382
+ /**
383
+ * Availability via `bulk_search` (comma-joined `domain_name_list`), batched.
384
+ * `show_price=true` returns a `price_list` per name; the first (1-year) entry's
385
+ * registration price is surfaced.
386
+ */
387
+ override async checkAvailability(
388
+ domainNames: string[],
389
+ opts?: RequestOptions
390
+ ): Promise<DomainAvailability[]> {
391
+ const results: DomainAvailability[] = [];
392
+ for (let i = 0; i < domainNames.length; i += SEARCH_BATCH_SIZE) {
393
+ const batch = domainNames.slice(i, i + SEARCH_BATCH_SIZE);
394
+ const list = encodeURIComponent(batch.join(','));
395
+ const data = await this.read<{ domain_result_list?: V2SearchResult[] }>(
396
+ 'GET',
397
+ `/restful/v2/domains/bulk_search?domain_name_list=${list}&show_price=true`,
398
+ undefined,
399
+ opts
400
+ );
401
+ for (const r of data.domain_result_list ?? []) {
402
+ const price = oneYearPrice(r.price_list);
403
+ results.push({
404
+ domainName: r.domain_name ?? '',
405
+ available: r.available === 'Yes',
406
+ premium: r.premium != null ? r.premium.toLowerCase() === 'yes' : undefined,
407
+ price: price?.registration,
408
+ currency: price?.currency,
409
+ period: price ? 1 : undefined,
410
+ });
411
+ }
412
+ }
413
+ return results;
414
+ }
415
+
416
+ /**
417
+ * Pricing for a specific domain (may be premium). Needs a full domain (e.g.
418
+ * "example.com"); a bare TLD throws, since v2 has no standalone TLD-price
419
+ * endpoint.
420
+ *
421
+ * For a domain **already in the account**, the classic `renew` command with
422
+ * `price_check=1` returns the exact renewal fee — premium names included —
423
+ * without renewing. `bulk_search` (the v2 pricing path) only quotes names
424
+ * *available to register*, so it can't price an owned domain's renewal. We try
425
+ * the price check first and fall back to `bulk_search` for names not in the
426
+ * account (e.g. pricing a domain before registering it).
427
+ */
428
+ override async getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing> {
429
+ if (!tldOrDomain.includes('.')) {
430
+ throw new NotImplementedError(
431
+ `${this.name}: getPricing needs a full domain (e.g. "example.com"); ` +
432
+ 'Dynadot v2 has no standalone per-TLD price endpoint'
433
+ );
434
+ }
435
+ const tld = tldOrDomain.slice(tldOrDomain.indexOf('.') + 1);
436
+
437
+ // Owned domain: the price-check renew quote is the exact renewal (premium
438
+ // included). Skips bulk_search, which returns nothing for a registered name.
439
+ const checked = await this.renewPriceCheck(tldOrDomain, opts);
440
+ if (checked) {
441
+ return { tld, currency: checked.currency, renewal: checked.renewal };
442
+ }
443
+
444
+ // Not in the account — fall back to bulk_search for available-name pricing.
445
+ const list = encodeURIComponent(tldOrDomain);
446
+ const data = await this.read<{ domain_result_list?: V2SearchResult[] }>(
447
+ 'GET',
448
+ `/restful/v2/domains/bulk_search?domain_name_list=${list}&show_price=true`,
449
+ undefined,
450
+ opts
451
+ );
452
+ const result = (data.domain_result_list ?? [])[0];
453
+ const price = oneYearPrice(result?.price_list);
454
+ return {
455
+ tld,
456
+ currency: price?.currency ?? 'USD',
457
+ registration: price?.registration,
458
+ renewal: price?.renewal,
459
+ transfer: price?.transfer,
460
+ };
461
+ }
462
+
463
+ /**
464
+ * Renewal price for a domain already in the account, via the classic
465
+ * `/api3.json?command=renew&price_check=1` quote (the RESTful v2 renew endpoint
466
+ * executes immediately and has no price-only mode). Authenticates with the same
467
+ * API key as a query parameter — the classic command is unsigned. `PriceInfo`
468
+ * reads e.g. "Renew Fee: 108.90 in USD, Total: 108.90 in USD, The domain is
469
+ * premium". Returns null when the domain isn't in the account or the fee can't
470
+ * be parsed, so the caller can fall back.
471
+ */
472
+ private async renewPriceCheck(
473
+ domain: string,
474
+ opts?: RequestOptions
475
+ ): Promise<{ renewal: number; currency: string } | null> {
476
+ const params = new URLSearchParams({
477
+ key: this.credentials.apiKey,
478
+ command: 'renew',
479
+ domain,
480
+ duration: '1',
481
+ price_check: '1',
482
+ });
483
+ try {
484
+ const res = await this.http.request<{
485
+ RenewResponse?: { Status?: string; PriceInfo?: string };
486
+ }>({ method: 'GET', path: `/api3.json?${params.toString()}`, ...opts });
487
+ const info = res.RenewResponse;
488
+ if (info?.Status !== 'price_check_success' || !info.PriceInfo) return null;
489
+ const match = info.PriceInfo.match(/Renew Fee:\s*([\d.]+)\s+in\s+([A-Za-z]{3})/);
490
+ if (!match) return null;
491
+ const renewal = Number(match[1]);
492
+ return Number.isNaN(renewal) ? null : { renewal, currency: match[2].toUpperCase() };
493
+ } catch {
494
+ return null;
495
+ }
496
+ }
497
+
498
+ // --- writes (paths + bodies verified against the Dynadot sandbox) ---
499
+
500
+ /**
501
+ * Registers a domain via POST /domains/{name}/register. The body nests the
502
+ * order under a `domain` object; v2 uses the account's default WHOIS contacts,
503
+ * so `input.contacts` is not sent. `privacy` maps to Dynadot's privacy level
504
+ * ("full"/"off"). Spends real money in production.
505
+ */
506
+ override async registerDomain(
507
+ domainName: string,
508
+ input: RegisterDomainInput,
509
+ opts?: RequestOptions
510
+ ): Promise<OperationResult> {
511
+ requireConsent(this.name, input.consent);
512
+ return this.mutate(
513
+ 'POST',
514
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/register`,
515
+ { domain: { duration: input.years ?? 1, privacy: input.privacy ? 'full' : 'off' } },
516
+ `Domain ${domainName} registered successfully`,
517
+ opts
518
+ );
519
+ }
520
+
521
+ /**
522
+ * Transfers a domain in with its auth/EPP code (nested under `domain`, like
523
+ * register). Returns 202 Accepted on success. Contacts come from the account
524
+ * default. Spends real money in production.
525
+ */
526
+ override async transferIn(
527
+ domainName: string,
528
+ input: TransferDomainInput,
529
+ opts?: RequestOptions
530
+ ): Promise<OperationResult> {
531
+ requireConsent(this.name, input.consent);
532
+ return this.mutate(
533
+ 'POST',
534
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/transfer_in`,
535
+ {
536
+ domain: {
537
+ auth_code: input.authCode,
538
+ duration: input.years ?? 1,
539
+ privacy: input.privacy ? 'full' : 'off',
540
+ },
541
+ },
542
+ `Domain ${domainName} transfer requested successfully`,
543
+ opts
544
+ );
545
+ }
546
+
547
+ /**
548
+ * Renews a domain. Dynadot's renew takes both the number of years (`duration`)
549
+ * and the domain's current expiration `year` as a guard against double-renews,
550
+ * so this reads the current expiration first.
551
+ */
552
+ override async renewDomain(
553
+ domainName: string,
554
+ years = 1,
555
+ opts?: RequestOptions
556
+ ): Promise<OperationResult> {
557
+ const domain = await this.getDomain(domainName, opts);
558
+ const year = domain.expirationDate?.getUTCFullYear();
559
+ if (year == null) {
560
+ return { success: false, message: `Dynadot: could not read ${domainName}'s expiration year` };
561
+ }
562
+ return this.mutate(
563
+ 'POST',
564
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/renew`,
565
+ { duration: years, year },
566
+ 'Domain renewed successfully',
567
+ opts
568
+ );
569
+ }
570
+
571
+ // Auto-renew via the renew_option endpoint. The accepted write values are
572
+ // "auto" (auto-renew), "donot" (do not renew), and "reset" (manual renewal) —
573
+ // note these differ from the read values ("auto-renew" etc.).
574
+ override async setAutoRenew(
575
+ domainName: string,
576
+ enabled: boolean,
577
+ opts?: RequestOptions
578
+ ): Promise<OperationResult> {
579
+ return this.mutate(
580
+ 'PUT',
581
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/renew_option`,
582
+ { renew_option: enabled ? 'auto' : 'donot' },
583
+ `Auto-renew ${enabled ? 'enabled' : 'disabled'} successfully`,
584
+ opts
585
+ );
586
+ }
587
+
588
+ // Replace nameservers via PUT .../nameservers with a `nameserver_list` of
589
+ // hostname strings.
590
+ override async updateNameservers(
591
+ domainName: string,
592
+ nameservers: string[],
593
+ opts?: RequestOptions
594
+ ): Promise<OperationResult> {
595
+ if (nameservers.length < 2 || nameservers.length > 13) {
596
+ throw new Error('Dynadot requires 2-13 nameservers');
597
+ }
598
+ return this.mutate(
599
+ 'PUT',
600
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/nameservers`,
601
+ { nameserver_list: nameservers },
602
+ 'Nameservers updated successfully',
603
+ opts
604
+ );
605
+ }
606
+
607
+ // Transfer lock via PUT .../domain_lock with a boolean `lock`.
608
+ override async lockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
609
+ return this.setLock(domainName, true, opts);
610
+ }
611
+
612
+ override async unlockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
613
+ return this.setLock(domainName, false, opts);
614
+ }
615
+
616
+ private setLock(
617
+ domainName: string,
618
+ locked: boolean,
619
+ opts?: RequestOptions
620
+ ): Promise<OperationResult> {
621
+ return this.mutate(
622
+ 'PUT',
623
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/domain_lock`,
624
+ { lock: locked },
625
+ `Domain ${locked ? 'locked' : 'unlocked'} successfully`,
626
+ opts
627
+ );
628
+ }
629
+
630
+ // WHOIS privacy via PUT .../privacy with a `privacy_level` of "full"/"off".
631
+ override async setPrivacy(
632
+ domainName: string,
633
+ enabled: boolean,
634
+ opts?: RequestOptions
635
+ ): Promise<OperationResult> {
636
+ return this.mutate(
637
+ 'PUT',
638
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/privacy`,
639
+ { privacy_level: enabled ? 'full' : 'off' },
640
+ `Privacy ${enabled ? 'enabled' : 'disabled'} successfully`,
641
+ opts
642
+ );
643
+ }
644
+
645
+ /**
646
+ * Updates contact roles. Dynadot contacts are account-level records referenced
647
+ * by id, so this creates a contact per supplied role (`POST /contacts`) and
648
+ * points the domain's roles at the new ids (`PUT /domains/{d}/contacts`). Roles
649
+ * not supplied keep their current contact id (read first and passed through, as
650
+ * the PUT sets all four). Identical contacts across roles are created once.
651
+ * Verified end-to-end in the sandbox.
652
+ */
653
+ override async updateContacts(
654
+ domainName: string,
655
+ contacts: ContactSet,
656
+ opts?: RequestOptions
657
+ ): Promise<OperationResult> {
658
+ if (!contacts.registrant && !contacts.admin && !contacts.tech && !contacts.billing) {
659
+ throw new Error(`${this.name}: updateContacts requires at least one contact role`);
660
+ }
661
+ // read current ids so unspecified roles are preserved (the PUT sets all four)
662
+ const info = (
663
+ await this.read<{ domain_info?: V2DomainInfo }>(
664
+ 'GET',
665
+ `/restful/v2/domains/${encodeURIComponent(domainName)}`,
666
+ undefined,
667
+ opts
668
+ )
669
+ ).domain_info;
670
+ if (!info) throw new NotFoundError(`Dynadot: domain '${domainName}' not found`);
671
+
672
+ const created = new Map<Contact, number>();
673
+ const idFor = async (c: Contact): Promise<number> => {
674
+ let id = created.get(c);
675
+ if (id == null) {
676
+ const data = await this.read<{ contact_id: number }>(
677
+ 'POST',
678
+ '/restful/v2/contacts',
679
+ { contact: toDynadotContact(c) },
680
+ opts
681
+ );
682
+ id = data.contact_id;
683
+ created.set(c, id);
684
+ }
685
+ return id;
686
+ };
687
+
688
+ const body = {
689
+ registrant_contact_id: contacts.registrant
690
+ ? await idFor(contacts.registrant)
691
+ : info.registrant_contact_id,
692
+ admin_contact_id: contacts.admin ? await idFor(contacts.admin) : info.admin_contact_id,
693
+ technical_contact_id: contacts.tech ? await idFor(contacts.tech) : info.technical_contact_id,
694
+ billing_contact_id: contacts.billing
695
+ ? await idFor(contacts.billing)
696
+ : info.billing_contact_id,
697
+ };
698
+ return this.mutate(
699
+ 'PUT',
700
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/contacts`,
701
+ body,
702
+ 'Contacts updated successfully',
703
+ opts
704
+ );
705
+ }
706
+
707
+ /**
708
+ * Replaces the DNS record set via POST /domains/{name}/records. Records split
709
+ * into apex ("main") and subdomain lists, mirroring the read shape; MX priority
710
+ * rides in record_value2. Only generic DNS types are supported. Verified live
711
+ * (add/restore round-trip): the body is accepted and non-forwarding records are
712
+ * fully replaced.
713
+ */
714
+ override async setDnsRecords(
715
+ domainName: string,
716
+ records: DnsRecord[],
717
+ opts?: RequestOptions
718
+ ): Promise<OperationResult> {
719
+ const mainList: V2DnsRecord[] = [];
720
+ const subList: V2DnsRecord[] = [];
721
+ let ttl: number | undefined;
722
+
723
+ for (const r of records) {
724
+ const type = r.type.toUpperCase();
725
+ if (!SUPPORTED_DNS_TYPES.has(type)) {
726
+ throw new Error(`Dynadot: DNS record type '${type}' is not supported`);
727
+ }
728
+ if (ttl == null && r.ttl != null) ttl = r.ttl;
729
+ const entry: V2DnsRecord = { record_type: type.toLowerCase(), record_value1: r.value };
730
+ if (type === 'MX' && r.priority != null) entry.record_value2 = String(r.priority);
731
+
732
+ const isApex = r.name === '@' || r.name === '' || r.name === domainName;
733
+ if (isApex) {
734
+ mainList.push(entry);
735
+ } else {
736
+ subList.push({ ...entry, sub_host: r.name });
737
+ }
738
+ }
739
+
740
+ const glue: V2GlueInfo = { glue_type: 'DNS', dns_main_list: mainList, dns_sub_list: subList };
741
+ if (ttl != null) glue.ttl = String(ttl);
742
+
743
+ return this.mutate(
744
+ 'POST',
745
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/records`,
746
+ glue,
747
+ 'DNS records updated successfully',
748
+ opts
749
+ );
750
+ }
751
+
752
+ // --- extended capabilities ---------------------------------------------
753
+
754
+ /**
755
+ * The transfer authorization (EPP) code via GET .../transfer_auth_code. Reads
756
+ * the current code (Dynadot can also mint a fresh one with `new_code=true`, not
757
+ * used here to avoid a side effect on a read).
758
+ */
759
+ override async getAuthCode(domainName: string, opts?: RequestOptions): Promise<string> {
760
+ const data = await this.read<{ auth_code?: string }>(
761
+ 'GET',
762
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/transfer_auth_code`,
763
+ undefined,
764
+ opts
765
+ );
766
+ return data.auth_code ?? '';
767
+ }
768
+
769
+ /**
770
+ * DNSSEC status via GET .../dnssec, which returns a list of DS records
771
+ * (`dnssec_info_list`); a non-empty list means DNSSEC is enabled. `algorithm`
772
+ * and `digest_type` come back as labels with the numeric code in parentheses
773
+ * (e.g. "SHA-256 (2)"), so parse the code out.
774
+ */
775
+ override async getDnssec(domainName: string, opts?: RequestOptions): Promise<DnssecStatus> {
776
+ const data = await this.read<{ dnssec_info_list?: V2DnssecInfo[] }>(
777
+ 'GET',
778
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/dnssec`,
779
+ undefined,
780
+ opts
781
+ );
782
+ const list = data.dnssec_info_list ?? [];
783
+ return {
784
+ enabled: list.length > 0,
785
+ dsRecords: list.map(d => ({
786
+ keyTag: parseCode(d.key_tag),
787
+ algorithm: parseCode(d.algorithm),
788
+ digestType: parseCode(d.digest_type),
789
+ digest: d.digest ?? '',
790
+ })),
791
+ };
792
+ }
793
+
794
+ // Disable DNSSEC via DELETE .../dnssec (removes the registrar's DS records).
795
+ override disableDnssec(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
796
+ return this.mutate(
797
+ 'DELETE',
798
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/dnssec`,
799
+ undefined,
800
+ 'DNSSEC disabled successfully',
801
+ opts
802
+ );
803
+ }
804
+
805
+ /**
806
+ * Read alias-style email forwarding from domain_info's inline `glue_info`
807
+ * (Dynadot has no standalone endpoint). Only `MTYPE_FORWARD` carries redirect
808
+ * aliases; MX-mode (`MTYPE_MX`) and none return no rules.
809
+ */
810
+ override async getEmailForwarding(
811
+ domainName: string,
812
+ opts?: RequestOptions
813
+ ): Promise<EmailForward[]> {
814
+ const glue = (await this.domainInfo(domainName, opts)).glue_info;
815
+ if ((glue?.email_forward_type ?? '').toUpperCase() !== 'MTYPE_FORWARD') return [];
816
+ return (glue?.email_alias_list ?? [])
817
+ .map(a => ({ alias: a.username ?? '', forwardTo: a.email ?? '' }))
818
+ .filter(f => f.alias && f.forwardTo);
819
+ }
820
+
821
+ /**
822
+ * Replace email forwarding via PUT .../email_forwarding (full replace; an empty
823
+ * list clears it with `mtype_none`). Requires the domain to use Dynadot DNS —
824
+ * the API rejects it when the record is a CNAME.
825
+ */
826
+ override async setEmailForwarding(
827
+ domainName: string,
828
+ forwards: EmailForward[],
829
+ opts?: RequestOptions
830
+ ): Promise<OperationResult> {
831
+ const body =
832
+ forwards.length === 0
833
+ ? { email_forward_type: 'mtype_none', email_alias_list: [], email_exchange_list: [] }
834
+ : {
835
+ email_forward_type: 'mtype_forward',
836
+ email_alias_list: forwards.map(f => ({ username: f.alias, email: f.forwardTo })),
837
+ email_exchange_list: [],
838
+ };
839
+ return this.mutate(
840
+ 'PUT',
841
+ `/restful/v2/domains/${encodeURIComponent(domainName)}/email_forwarding`,
842
+ body,
843
+ 'Email forwarding updated successfully',
844
+ opts
845
+ );
846
+ }
847
+
848
+ /**
849
+ * Read domain (URL) forwarding from domain_info's inline `glue_info`. Dynadot
850
+ * forwards the whole domain, so this returns at most one rule at host "@":
851
+ * REGISTRAR_FORWARDING is a 301/302 redirect (`forward_type`
852
+ * permanently/temporarily). REGISTRAR_STEALTH_FORWARDING (a masked/framed
853
+ * redirect) is reported as read-only `masked` — `setDomainForwarding` rejects it.
854
+ */
855
+ override async getDomainForwarding(
856
+ domainName: string,
857
+ opts?: RequestOptions
858
+ ): Promise<DomainForward[]> {
859
+ const glue = (await this.domainInfo(domainName, opts)).glue_info;
860
+ const type = (glue?.glue_type ?? '').toUpperCase();
861
+ if (!glue?.forward_url) return [];
862
+ if (type === 'REGISTRAR_STEALTH_FORWARDING') {
863
+ return [{ host: '@', url: glue.forward_url, type: 'masked' }];
864
+ }
865
+ if (type === 'REGISTRAR_FORWARDING') {
866
+ const temporary = (glue.forward_type ?? '').toLowerCase().startsWith('temp');
867
+ return [{ host: '@', url: glue.forward_url, type: temporary ? 'temporary' : 'permanent' }];
868
+ }
869
+ return [];
870
+ }
871
+
872
+ /**
873
+ * Set domain (URL) forwarding. Dynadot forwards the entire domain, so at most
874
+ * one rule (host "@") is accepted: `temporary`/`permanent` use standard
875
+ * forwarding (302 vs 301). An empty list clears forwarding — Dynadot has no
876
+ * "off" for it, so we restore its default nameservers, which is the neutral
877
+ * non-forwarding state.
878
+ */
879
+ override async setDomainForwarding(
880
+ domainName: string,
881
+ forwards: DomainForward[],
882
+ opts?: RequestOptions
883
+ ): Promise<OperationResult> {
884
+ const enc = encodeURIComponent(domainName);
885
+ if (forwards.length === 0) {
886
+ return this.mutate(
887
+ 'PUT',
888
+ `/restful/v2/domains/${enc}/nameservers`,
889
+ { nameserver_list: DYNADOT_DEFAULT_NS },
890
+ 'Domain forwarding cleared successfully',
891
+ opts
892
+ );
893
+ }
894
+ if (forwards.length > 1) {
895
+ throw new Error('Dynadot forwards the whole domain; only a single "@" rule is supported');
896
+ }
897
+ const [f] = settableForwards(forwards); // reject masked before any write
898
+ if (f.host && f.host !== '@') {
899
+ throw new Error(
900
+ 'Dynadot forwards the whole domain; per-host forwarding is not supported (use host "@")'
901
+ );
902
+ }
903
+ return this.mutate(
904
+ 'PUT',
905
+ `/restful/v2/domains/${enc}/domain_forwarding`,
906
+ { forward_url: f.url, is_temporary: f.type === 'temporary' },
907
+ 'Domain forwarding updated successfully',
908
+ opts
909
+ );
910
+ }
911
+
912
+ // map a v2 domain_info payload to the normalized Domain shape
913
+ private toDomain(d: V2DomainInfo): Domain {
914
+ return createDomain({
915
+ domainName: d.domain_name,
916
+ registrar: this.name,
917
+ status: d.status ?? '',
918
+ createdDate: d.registration_date ?? null,
919
+ expirationDate: d.expiration_date ?? null,
920
+ renewalDate: d.expiration_date ?? null,
921
+ autoRenew: d.renew_option === 'auto-renew' || d.renew_option === 'auto',
922
+ locked: d.locked === 'Yes',
923
+ privacy: isPrivacyOn(d.privacy),
924
+ nameservers: extractNameservers(d.glue_info),
925
+ });
926
+ }
927
+ }
928
+
929
+ // --- signing ---
930
+
931
+ // Base64-encoded HMAC-SHA256 of `message`, keyed by `secret` (UTF-8). Uses Web
932
+ // Crypto so it runs unchanged in browsers, edge runtimes, and Node.
933
+ async function hmacSha256Base64(secret: string, message: string): Promise<string> {
934
+ const enc = new TextEncoder();
935
+ const key = await crypto.subtle.importKey(
936
+ 'raw',
937
+ enc.encode(secret),
938
+ { name: 'HMAC', hash: 'SHA-256' },
939
+ false,
940
+ ['sign']
941
+ );
942
+ const signature = await crypto.subtle.sign('HMAC', key, enc.encode(message));
943
+ let binary = '';
944
+ for (const byte of new Uint8Array(signature)) binary += String.fromCharCode(byte);
945
+ return btoa(binary);
946
+ }
947
+
948
+ // --- mapping helpers ---
949
+
950
+ // whether a v2 envelope `code` (which mirrors the HTTP status) is a success —
951
+ // 2xx, so it covers 200 and transfer_in's 202 Accepted
952
+ function isOk(code: number | undefined): boolean {
953
+ return code != null && code >= 200 && code < 300;
954
+ }
955
+
956
+ // map a v2 DNS record to the normalized DnsRecord shape
957
+ function toDnsRecord(r: V2DnsRecord, name: string, ttl: number | undefined): DnsRecord {
958
+ const type = (r.record_type ?? '').toUpperCase();
959
+ const record: DnsRecord = { type, name, value: r.record_value1 ?? '' };
960
+ if (ttl != null) record.ttl = ttl;
961
+ // MX priority (distance) rides in record_value2
962
+ if (type === 'MX' && r.record_value2 != null && r.record_value2 !== '') {
963
+ record.priority = Number(r.record_value2);
964
+ }
965
+ return record;
966
+ }
967
+
968
+ // map a v2 contact record to the normalized Contact shape
969
+ function toContact(c: V2Contact): Contact {
970
+ const { firstName, lastName } = splitName(str(c.name));
971
+ const contact: Contact = {
972
+ firstName,
973
+ lastName,
974
+ email: str(c.email),
975
+ phone: joinPhone(c.phone_cc, c.phone_number),
976
+ address1: str(c.address1),
977
+ city: str(c.city),
978
+ postalCode: str(c.zip),
979
+ country: str(c.country),
980
+ };
981
+ const organization = str(c.organization);
982
+ if (organization) contact.organization = organization;
983
+ const fax = joinPhone(c.fax_cc, c.fax_number);
984
+ if (fax) contact.fax = fax;
985
+ const address2 = str(c.address2);
986
+ if (address2) contact.address2 = address2;
987
+ const state = str(c.state);
988
+ if (state) contact.state = state;
989
+ return contact;
990
+ }
991
+
992
+ // whether a v2 privacy string denotes privacy being on. The API reports several
993
+ // values: "Full Privacy"/"Partial Privacy" (on) vs "Privacy Off"/"No Privacy"
994
+ // (off), so key off the level word rather than a simple prefix.
995
+ function isPrivacyOn(privacy: string | undefined): boolean {
996
+ const p = (privacy ?? '').toLowerCase();
997
+ if (!p || p.includes('off') || p.startsWith('no')) return false;
998
+ return p.includes('full') || p.includes('partial') || p.includes('privacy');
999
+ }
1000
+
1001
+ // pick the 1-year price entry (falling back to the first), mapping to numbers
1002
+ function oneYearPrice(
1003
+ prices: V2Price[] | undefined
1004
+ ): { currency?: string; registration?: number; renewal?: number; transfer?: number } | undefined {
1005
+ if (!prices?.length) return undefined;
1006
+ const oneYear = prices.find(p => /\b1\s*year\b/i.test(p.unit ?? '')) ?? prices[0];
1007
+ return {
1008
+ currency: oneYear.currency,
1009
+ registration: toNumber(oneYear.registration_price),
1010
+ renewal: toNumber(oneYear.renewal_price),
1011
+ transfer: toNumber(oneYear.transfer_price),
1012
+ };
1013
+ }
1014
+
1015
+ // pull the external nameserver hostnames from a glue_info block
1016
+ function extractNameservers(glue: V2GlueInfo | undefined): string[] {
1017
+ return (glue?.nameserver_list ?? []).map(ns => str(ns.server_name)).filter(Boolean);
1018
+ }
1019
+
1020
+ // Dynadot stores a single combined name; split on the first space
1021
+ function splitName(name: string): { firstName: string; lastName: string } {
1022
+ const trimmed = name.trim();
1023
+ const space = trimmed.indexOf(' ');
1024
+ if (space === -1) return { firstName: trimmed, lastName: '' };
1025
+ return { firstName: trimmed.slice(0, space), lastName: trimmed.slice(space + 1).trim() };
1026
+ }
1027
+
1028
+ // rejoin a split country code + number as "+cc.number" (e.g. "+1.4805551234")
1029
+ // map the normalized Contact to Dynadot's contact shape (name + split phone).
1030
+ // The inverse of toContact: joins first/last into `name` and splits the
1031
+ // "+cc.number" phone into phone_cc / phone_number.
1032
+ function toDynadotContact(c: Contact): Record<string, unknown> {
1033
+ const phone = splitDynadotPhone(c.phone);
1034
+ const contact: Record<string, unknown> = {
1035
+ name: `${c.firstName} ${c.lastName}`.trim(),
1036
+ email: c.email,
1037
+ phone_cc: phone.cc,
1038
+ phone_number: phone.number,
1039
+ address1: c.address1,
1040
+ city: c.city,
1041
+ zip: c.postalCode,
1042
+ country: c.country,
1043
+ };
1044
+ if (c.organization) contact.organization = c.organization;
1045
+ if (c.address2) contact.address2 = c.address2;
1046
+ if (c.state) contact.state = c.state;
1047
+ if (c.fax) {
1048
+ const fax = splitDynadotPhone(c.fax);
1049
+ contact.fax_cc = fax.cc;
1050
+ contact.fax_number = fax.number;
1051
+ }
1052
+ return contact;
1053
+ }
1054
+
1055
+ // split an international phone ("+1.4805551234") into country code + number,
1056
+ // the inverse of joinPhone. Falls back to all digits as the number.
1057
+ function splitDynadotPhone(phone: string): { cc: string; number: string } {
1058
+ const trimmed = (phone ?? '').trim();
1059
+ const dot = trimmed.indexOf('.');
1060
+ if (trimmed.startsWith('+') && dot > 1) {
1061
+ return {
1062
+ cc: trimmed.slice(1, dot).replace(/\D/g, ''),
1063
+ number: trimmed.slice(dot + 1).replace(/\D/g, ''),
1064
+ };
1065
+ }
1066
+ return { cc: '', number: trimmed.replace(/\D/g, '') };
1067
+ }
1068
+
1069
+ function joinPhone(cc: string | number | undefined, num: string | number | undefined): string {
1070
+ const number = str(num);
1071
+ if (!number) return '';
1072
+ const code = str(cc);
1073
+ return code ? `+${code}.${number}` : number;
1074
+ }
1075
+
1076
+ // stringify a loosely-typed scalar, guarding against objects/arrays
1077
+ function str(value: unknown): string {
1078
+ return typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '';
1079
+ }
1080
+
1081
+ // parse a price string like "10.88" into a number, or undefined
1082
+ function toNumber(value: string | undefined): number | undefined {
1083
+ if (value == null || value === '') return undefined;
1084
+ const n = Number(value);
1085
+ return Number.isFinite(n) ? n : undefined;
1086
+ }
1087
+
1088
+ // Dynadot reports DNSSEC algorithm / digest-type as a label with the numeric
1089
+ // code in parentheses, e.g. "SHA-256 (2)" or "RSA/SHA-256 (8)". Pull out the
1090
+ // code; tolerate a bare number (key_tag) or numeric string too.
1091
+ function parseCode(value: string | number | undefined): number {
1092
+ if (typeof value === 'number') return value;
1093
+ const paren = /\((\d+)\)/.exec(value ?? '');
1094
+ if (paren) return Number(paren[1]);
1095
+ const n = Number(value);
1096
+ return Number.isFinite(n) ? n : 0;
1097
+ }