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