@aoxborrow/registrar-client 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -31
- package/dist/index.cjs +544 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -1
- package/dist/index.d.ts +40 -1
- package/dist/index.js +544 -45
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/registrars/index.ts +2 -0
- package/src/registrars/namecheap.ts +4 -1
- package/src/registrars/namecom.ts +704 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigurationError,
|
|
3
|
+
ConnectionError,
|
|
4
|
+
InvalidResponseError,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
ParsingError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
RegistrarError,
|
|
9
|
+
toRegistrarError,
|
|
10
|
+
} from '../errors';
|
|
11
|
+
import { Feature, type RegistrarFeature } from '../features';
|
|
12
|
+
import { HttpClient, type RequestConfig } from '../http';
|
|
13
|
+
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
14
|
+
import type {
|
|
15
|
+
ConfigField,
|
|
16
|
+
ConnectionResult,
|
|
17
|
+
Contact,
|
|
18
|
+
ContactSet,
|
|
19
|
+
DnsRecord,
|
|
20
|
+
Domain,
|
|
21
|
+
DomainAvailability,
|
|
22
|
+
ListDomainsOptions,
|
|
23
|
+
OperationResult,
|
|
24
|
+
RegisterDomainInput,
|
|
25
|
+
RegistrarCredentials,
|
|
26
|
+
RegistrarOptions,
|
|
27
|
+
RequestOptions,
|
|
28
|
+
TldPricing,
|
|
29
|
+
TransferDomainInput,
|
|
30
|
+
} from '../types';
|
|
31
|
+
import { createDomain, filterDomains, normalizeDomain } from '../utils';
|
|
32
|
+
|
|
33
|
+
// Core v1 schema: https://docs.name.com/api/v1/namecom.api.yaml
|
|
34
|
+
interface NamecomContact {
|
|
35
|
+
firstName?: string | null;
|
|
36
|
+
lastName?: string | null;
|
|
37
|
+
companyName?: string | null;
|
|
38
|
+
email?: string | null;
|
|
39
|
+
phone?: string | null;
|
|
40
|
+
fax?: string | null;
|
|
41
|
+
address1?: string | null;
|
|
42
|
+
address2?: string | null;
|
|
43
|
+
city?: string | null;
|
|
44
|
+
state?: string | null;
|
|
45
|
+
zip?: string | null;
|
|
46
|
+
country?: string | null;
|
|
47
|
+
}
|
|
48
|
+
interface NamecomDomain {
|
|
49
|
+
domainName: string;
|
|
50
|
+
createDate?: string;
|
|
51
|
+
expireDate?: string;
|
|
52
|
+
autorenewEnabled?: boolean;
|
|
53
|
+
locked?: boolean;
|
|
54
|
+
locks?: string[];
|
|
55
|
+
privacyEnabled?: boolean;
|
|
56
|
+
nameservers?: string[];
|
|
57
|
+
contacts?: Partial<Record<keyof ContactSet, NamecomContact>>;
|
|
58
|
+
}
|
|
59
|
+
interface NamecomPricing {
|
|
60
|
+
premium?: boolean;
|
|
61
|
+
purchasePrice?: number | null;
|
|
62
|
+
renewalPrice?: number | null;
|
|
63
|
+
transferPrice?: number | null;
|
|
64
|
+
}
|
|
65
|
+
interface NamecomTldPricing {
|
|
66
|
+
tld: string;
|
|
67
|
+
duration: number;
|
|
68
|
+
registrationPrice?: number | null;
|
|
69
|
+
renewalPrice?: number | null;
|
|
70
|
+
transferInPrice?: number | null;
|
|
71
|
+
}
|
|
72
|
+
interface NamecomRecord {
|
|
73
|
+
id?: number;
|
|
74
|
+
type: string;
|
|
75
|
+
host?: string | null;
|
|
76
|
+
answer: string;
|
|
77
|
+
ttl?: number;
|
|
78
|
+
priority?: number;
|
|
79
|
+
}
|
|
80
|
+
interface NamecomAvailability {
|
|
81
|
+
domainName: string;
|
|
82
|
+
purchasable?: boolean;
|
|
83
|
+
premium?: boolean;
|
|
84
|
+
purchaseType?: string;
|
|
85
|
+
purchasePrice?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Preserve shared typed HTTP errors without exposing response bodies: upstream
|
|
89
|
+
// errors can echo contacts, tokens or EPP codes. Core uses X-RateLimit-Reset.
|
|
90
|
+
class NamecomHttpClient extends HttpClient {
|
|
91
|
+
override async request<T = unknown>(req: RequestConfig): Promise<T> {
|
|
92
|
+
try {
|
|
93
|
+
return await super.request<T>(req);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error instanceof ParsingError) throw new ParsingError('namecom: invalid JSON response');
|
|
96
|
+
if (error instanceof ConnectionError)
|
|
97
|
+
throw new ConnectionError('namecom: network request failed');
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
protected override async toStatusError(response: Response, url: string): Promise<RegistrarError> {
|
|
103
|
+
await response.body?.cancel().catch(() => undefined);
|
|
104
|
+
const error = await super.toStatusError(
|
|
105
|
+
new Response(null, {
|
|
106
|
+
status: response.status,
|
|
107
|
+
headers: response.headers,
|
|
108
|
+
}),
|
|
109
|
+
url
|
|
110
|
+
);
|
|
111
|
+
if (error instanceof RateLimitError) {
|
|
112
|
+
const retryAfter = response.headers.get('retry-after');
|
|
113
|
+
const seconds = retryAfter == null ? NaN : Number(retryAfter);
|
|
114
|
+
const date = retryAfter == null ? NaN : Date.parse(retryAfter);
|
|
115
|
+
const reset = response.headers.get('x-ratelimit-reset');
|
|
116
|
+
error.retryAfter = Number.isFinite(seconds)
|
|
117
|
+
? Math.max(0, seconds)
|
|
118
|
+
: Number.isFinite(date)
|
|
119
|
+
? Math.max(0, (date - Date.now()) / 1000)
|
|
120
|
+
: reset != null && Number.isFinite(Number(reset))
|
|
121
|
+
? Math.max(0, Number(reset) - Date.now() / 1000)
|
|
122
|
+
: undefined;
|
|
123
|
+
}
|
|
124
|
+
return error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class NamecomRegistrar extends BaseRegistrar {
|
|
129
|
+
readonly name = 'namecom';
|
|
130
|
+
static readonly displayName = 'Name.com';
|
|
131
|
+
static readonly website = 'name.com';
|
|
132
|
+
static readonly supportsSandbox = true;
|
|
133
|
+
static readonly configFields: ConfigField[] = [
|
|
134
|
+
{ name: 'username', label: 'Username', type: 'text', required: true },
|
|
135
|
+
{ name: 'apiToken', label: 'API token', type: 'password', required: true },
|
|
136
|
+
];
|
|
137
|
+
static readonly helpText =
|
|
138
|
+
'Get an API token at https://www.name.com/account/settings/api. ' +
|
|
139
|
+
'Accounts with 2FA must enable API Access. Sandbox requires your -test username ' +
|
|
140
|
+
'and a separate sandbox token; select the sandbox environment explicitly.';
|
|
141
|
+
static override readonly extendedFeatures: readonly RegistrarFeature[] = [Feature.GetAuthCode];
|
|
142
|
+
override readonly requiresNameserversFetch = false;
|
|
143
|
+
|
|
144
|
+
constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
|
|
145
|
+
const username = credentials.username ?? '';
|
|
146
|
+
if (username.includes(':'))
|
|
147
|
+
throw new ConfigurationError('namecom: username cannot contain a colon');
|
|
148
|
+
const auth = btoa(
|
|
149
|
+
Array.from(new TextEncoder().encode(`${username}:${credentials.apiToken ?? ''}`), byte =>
|
|
150
|
+
String.fromCharCode(byte)
|
|
151
|
+
).join('')
|
|
152
|
+
);
|
|
153
|
+
const config = {
|
|
154
|
+
baseUrl: selectBaseUrl('namecom', options?.environment, {
|
|
155
|
+
production: 'https://api.name.com/core/v1',
|
|
156
|
+
sandbox: 'https://api.dev.name.com/core/v1',
|
|
157
|
+
}),
|
|
158
|
+
headers: { Authorization: `Basic ${auth}` },
|
|
159
|
+
};
|
|
160
|
+
super(credentials, config, options);
|
|
161
|
+
this.http = new NamecomHttpClient({ ...config, options: this.options });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
|
|
165
|
+
try {
|
|
166
|
+
// hello is a connectivity check; listing verifies account access.
|
|
167
|
+
await this.http.request({ path: '/domains', query: { perPage: 1, page: 1 }, ...opts });
|
|
168
|
+
return { success: true, message: 'Connection successful' };
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
|
|
175
|
+
const { search, detailed: _detailed, ...requestOpts } = opts ?? {};
|
|
176
|
+
const domains = await this.pages<NamecomDomain>('/domains', 'domains', requestOpts);
|
|
177
|
+
return filterDomains(
|
|
178
|
+
domains.map(d => this.toDomain(d)),
|
|
179
|
+
search
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
|
|
184
|
+
return this.toDomain(await this.rawDomain(domainName, opts));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
|
|
188
|
+
return (await this.getDomain(domainName, opts)).nameservers;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
override async getAuthCode(domainName: string, opts?: RequestOptions): Promise<string> {
|
|
192
|
+
const result = await this.http.request<{ authCode?: string }>({
|
|
193
|
+
path: `${domainPath(domainName)}:getAuthCode`,
|
|
194
|
+
...opts,
|
|
195
|
+
});
|
|
196
|
+
if (!result?.authCode) throw new InvalidResponseError('namecom: missing authorization code');
|
|
197
|
+
return result.authCode;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
override async checkAvailability(
|
|
201
|
+
domainNames: string[],
|
|
202
|
+
opts?: RequestOptions
|
|
203
|
+
): Promise<DomainAvailability[]> {
|
|
204
|
+
const results: DomainAvailability[] = [];
|
|
205
|
+
for (let i = 0; i < domainNames.length; i += 50) {
|
|
206
|
+
const res = await this.http.request<{ results?: NamecomAvailability[] }>({
|
|
207
|
+
method: 'POST',
|
|
208
|
+
path: '/domains:checkAvailability',
|
|
209
|
+
body: {
|
|
210
|
+
domainNames: domainNames.slice(i, i + 50).map(normalizeDomain),
|
|
211
|
+
purchaseType: 'registration',
|
|
212
|
+
},
|
|
213
|
+
...opts,
|
|
214
|
+
});
|
|
215
|
+
if (!Array.isArray(res?.results))
|
|
216
|
+
throw new InvalidResponseError('namecom: missing availability results');
|
|
217
|
+
results.push(
|
|
218
|
+
...res.results.map(r => ({
|
|
219
|
+
domainName: r.domainName,
|
|
220
|
+
available:
|
|
221
|
+
(r.purchasable ?? false) && (!r.purchaseType || r.purchaseType === 'registration'),
|
|
222
|
+
premium: r.premium ?? false,
|
|
223
|
+
price: price(r.purchasePrice),
|
|
224
|
+
currency: 'USD',
|
|
225
|
+
// Discovery quotes the TLD's minimum term, which isn't always one year.
|
|
226
|
+
}))
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return results;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
override async getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing> {
|
|
233
|
+
const name = normalizeDomain(tldOrDomain);
|
|
234
|
+
if (name.startsWith('.') || !name.includes('.')) {
|
|
235
|
+
const tld = name.replace(/^\./, '');
|
|
236
|
+
const entries = await this.pages<NamecomTldPricing>('/tldpricing', 'pricing', opts, {
|
|
237
|
+
tlds: tld,
|
|
238
|
+
duration: 1,
|
|
239
|
+
});
|
|
240
|
+
const entry = entries.find(p => p.tld.toLowerCase() === tld && p.duration === 1);
|
|
241
|
+
if (!entry) throw new NotFoundError(`namecom: no one-year pricing for .${tld}`);
|
|
242
|
+
return {
|
|
243
|
+
tld,
|
|
244
|
+
currency: 'USD',
|
|
245
|
+
registration: price(entry.registrationPrice),
|
|
246
|
+
renewal: price(entry.renewalPrice),
|
|
247
|
+
transfer: price(entry.transferInPrice),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const result = await this.domainPricing(name, 1, opts);
|
|
251
|
+
return {
|
|
252
|
+
tld: name.slice(name.indexOf('.') + 1),
|
|
253
|
+
currency: 'USD',
|
|
254
|
+
registration: price(result.purchasePrice),
|
|
255
|
+
renewal: price(result.renewalPrice),
|
|
256
|
+
transfer: price(result.transferPrice),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
override async registerDomain(
|
|
261
|
+
domainName: string,
|
|
262
|
+
input: RegisterDomainInput,
|
|
263
|
+
opts?: RequestOptions
|
|
264
|
+
): Promise<OperationResult> {
|
|
265
|
+
const years = validYears(input.years ?? 1);
|
|
266
|
+
if (!input.contacts.registrant)
|
|
267
|
+
throw new ConfigurationError('namecom: a registrant contact is required');
|
|
268
|
+
const contacts = toContacts(input.contacts);
|
|
269
|
+
const availability = (await this.checkAvailability([domainName], opts)).find(
|
|
270
|
+
result => normalizeDomain(result.domainName) === normalizeDomain(domainName)
|
|
271
|
+
);
|
|
272
|
+
if (!availability?.available)
|
|
273
|
+
return { success: false, message: 'namecom: domain is not available for registration' };
|
|
274
|
+
const pricing = await this.domainPricing(domainName, years, opts);
|
|
275
|
+
const purchasePrice = purchaseQuote(pricing, 'purchasePrice', availability.premium);
|
|
276
|
+
return this.purchase(
|
|
277
|
+
'/domains',
|
|
278
|
+
{
|
|
279
|
+
domain: {
|
|
280
|
+
domainName: normalizeDomain(domainName),
|
|
281
|
+
contacts,
|
|
282
|
+
nameservers: input.nameservers,
|
|
283
|
+
privacyEnabled: input.privacy,
|
|
284
|
+
autorenewEnabled: input.autoRenew,
|
|
285
|
+
},
|
|
286
|
+
purchaseType: 'registration',
|
|
287
|
+
years,
|
|
288
|
+
purchasePrice,
|
|
289
|
+
},
|
|
290
|
+
domainName,
|
|
291
|
+
'registration',
|
|
292
|
+
opts
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
override async renewDomain(
|
|
297
|
+
domainName: string,
|
|
298
|
+
years = 1,
|
|
299
|
+
opts?: RequestOptions
|
|
300
|
+
): Promise<OperationResult> {
|
|
301
|
+
validYears(years);
|
|
302
|
+
const pricing = await this.domainPricing(domainName, years, opts);
|
|
303
|
+
return this.purchase(
|
|
304
|
+
`${domainPath(domainName)}:renew`,
|
|
305
|
+
{
|
|
306
|
+
years,
|
|
307
|
+
purchasePrice: purchaseQuote(pricing, 'renewalPrice'),
|
|
308
|
+
},
|
|
309
|
+
domainName,
|
|
310
|
+
'renewal',
|
|
311
|
+
opts
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
override async transferIn(
|
|
316
|
+
domainName: string,
|
|
317
|
+
input: TransferDomainInput,
|
|
318
|
+
opts?: RequestOptions
|
|
319
|
+
): Promise<OperationResult> {
|
|
320
|
+
if (!input.authCode) throw new ConfigurationError('namecom: an authorization code is required');
|
|
321
|
+
// Core's transfer request accepts none of these. Never silently discard them
|
|
322
|
+
// or perform a paid transfer followed by a potentially failing settings write.
|
|
323
|
+
if (input.years != null || input.contacts != null || input.autoRenew != null) {
|
|
324
|
+
throw new ConfigurationError(
|
|
325
|
+
'namecom: transferIn does not accept years, contacts or autoRenew; the registry determines the term'
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
const pricing = await this.domainPricing(domainName, undefined, opts);
|
|
329
|
+
return this.purchase(
|
|
330
|
+
'/transfers',
|
|
331
|
+
{
|
|
332
|
+
domainName: normalizeDomain(domainName),
|
|
333
|
+
authCode: input.authCode,
|
|
334
|
+
privacyEnabled: input.privacy,
|
|
335
|
+
purchasePrice: purchaseQuote(pricing, 'transferPrice'),
|
|
336
|
+
},
|
|
337
|
+
domainName,
|
|
338
|
+
'transfer',
|
|
339
|
+
opts
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
override setAutoRenew(
|
|
344
|
+
domainName: string,
|
|
345
|
+
enabled: boolean,
|
|
346
|
+
opts?: RequestOptions
|
|
347
|
+
): Promise<OperationResult> {
|
|
348
|
+
return this.patchDomain(domainName, { autorenewEnabled: enabled }, opts);
|
|
349
|
+
}
|
|
350
|
+
override lockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
351
|
+
return this.patchDomain(domainName, { locked: true }, opts);
|
|
352
|
+
}
|
|
353
|
+
override unlockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
354
|
+
return this.patchDomain(domainName, { locked: false }, opts);
|
|
355
|
+
}
|
|
356
|
+
override setPrivacy(
|
|
357
|
+
domainName: string,
|
|
358
|
+
enabled: boolean,
|
|
359
|
+
opts?: RequestOptions
|
|
360
|
+
): Promise<OperationResult> {
|
|
361
|
+
return this.patchDomain(domainName, { privacyEnabled: enabled }, opts);
|
|
362
|
+
}
|
|
363
|
+
override updateNameservers(
|
|
364
|
+
domainName: string,
|
|
365
|
+
nameservers: string[],
|
|
366
|
+
opts?: RequestOptions
|
|
367
|
+
): Promise<OperationResult> {
|
|
368
|
+
return this.mutate(
|
|
369
|
+
{ method: 'POST', path: `${domainPath(domainName)}:setNameservers`, body: { nameservers } },
|
|
370
|
+
opts
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
override async getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet> {
|
|
374
|
+
const result: ContactSet = {};
|
|
375
|
+
const raw = await this.rawDomain(domainName, opts);
|
|
376
|
+
for (const role of CONTACT_ROLES) {
|
|
377
|
+
const c = raw.contacts?.[role];
|
|
378
|
+
if (c)
|
|
379
|
+
result[role] = {
|
|
380
|
+
firstName: c.firstName ?? '',
|
|
381
|
+
lastName: c.lastName ?? '',
|
|
382
|
+
organization: c.companyName ?? undefined,
|
|
383
|
+
email: c.email ?? '',
|
|
384
|
+
phone: c.phone ?? '',
|
|
385
|
+
fax: c.fax ?? undefined,
|
|
386
|
+
address1: c.address1 ?? '',
|
|
387
|
+
address2: c.address2 ?? undefined,
|
|
388
|
+
city: c.city ?? '',
|
|
389
|
+
state: c.state ?? undefined,
|
|
390
|
+
postalCode: c.zip ?? '',
|
|
391
|
+
country: c.country ?? '',
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return result;
|
|
395
|
+
}
|
|
396
|
+
override updateContacts(
|
|
397
|
+
domainName: string,
|
|
398
|
+
contacts: ContactSet,
|
|
399
|
+
opts?: RequestOptions
|
|
400
|
+
): Promise<OperationResult> {
|
|
401
|
+
return this.mutate(
|
|
402
|
+
{
|
|
403
|
+
method: 'POST',
|
|
404
|
+
path: `${domainPath(domainName)}:setContacts`,
|
|
405
|
+
body: { contacts: toContacts(contacts) },
|
|
406
|
+
},
|
|
407
|
+
opts
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
|
|
412
|
+
return (
|
|
413
|
+
await this.pages<NamecomRecord>(`${domainPath(domainName)}/records`, 'records', opts)
|
|
414
|
+
).map(fromRecord);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// No bulk replace endpoint. Reconcile by id: preserve identical records, update
|
|
418
|
+
// existing type/host pairs, create additions, then delete stale records. This
|
|
419
|
+
// avoids duplicate-record errors and does not empty the zone before creation.
|
|
420
|
+
override async setDnsRecords(
|
|
421
|
+
domainName: string,
|
|
422
|
+
records: DnsRecord[],
|
|
423
|
+
opts?: RequestOptions
|
|
424
|
+
): Promise<OperationResult> {
|
|
425
|
+
// Validate all input, then collapse exact duplicates to avoid duplicate-create
|
|
426
|
+
// errors when an identical record already exists in the zone.
|
|
427
|
+
const desired = records
|
|
428
|
+
.map(toRecord)
|
|
429
|
+
.filter((record, index, all) => all.findIndex(other => sameRecord(other, record)) === index);
|
|
430
|
+
try {
|
|
431
|
+
const path = `${domainPath(domainName)}/records`;
|
|
432
|
+
const existing = await this.pages<NamecomRecord>(path, 'records', opts);
|
|
433
|
+
if (existing.some(r => !Number.isInteger(r.id) || r.id! <= 0))
|
|
434
|
+
throw new InvalidResponseError('namecom: DNS response missing record ids');
|
|
435
|
+
const remaining = [...existing];
|
|
436
|
+
const pending: NamecomRecord[] = [];
|
|
437
|
+
for (const record of desired) {
|
|
438
|
+
const index = remaining.findIndex(r => sameRecord(r, record));
|
|
439
|
+
if (index >= 0) remaining.splice(index, 1);
|
|
440
|
+
else if (!pending.some(r => sameRecord(r, record))) pending.push(record);
|
|
441
|
+
}
|
|
442
|
+
for (const record of pending) {
|
|
443
|
+
const index = remaining.findIndex(r => recordKey(r) === recordKey(record));
|
|
444
|
+
const old = index >= 0 ? remaining.splice(index, 1)[0] : undefined;
|
|
445
|
+
await this.http.request({
|
|
446
|
+
...opts,
|
|
447
|
+
retries: 0,
|
|
448
|
+
method: old ? 'PUT' : 'POST',
|
|
449
|
+
path: old ? `${path}/${old.id!}` : path,
|
|
450
|
+
body: record,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
for (const old of remaining) {
|
|
454
|
+
await this.http.request({
|
|
455
|
+
...opts,
|
|
456
|
+
retries: 0,
|
|
457
|
+
method: 'DELETE',
|
|
458
|
+
path: `${path}/${old.id!}`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
return { success: true, message: 'DNS records updated successfully' };
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return {
|
|
464
|
+
success: false,
|
|
465
|
+
message: `${toRegistrarError(error).message}. DNS replacement is not atomic; read the zone before retrying.`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private patchDomain(
|
|
471
|
+
domainName: string,
|
|
472
|
+
body: Record<string, boolean>,
|
|
473
|
+
opts?: RequestOptions
|
|
474
|
+
): Promise<OperationResult> {
|
|
475
|
+
return this.mutate({ method: 'PATCH', path: domainPath(domainName), body }, opts);
|
|
476
|
+
}
|
|
477
|
+
private rawDomain(domainName: string, opts?: RequestOptions): Promise<NamecomDomain> {
|
|
478
|
+
return this.http.request({ path: domainPath(domainName), ...opts });
|
|
479
|
+
}
|
|
480
|
+
private domainPricing(
|
|
481
|
+
domainName: string,
|
|
482
|
+
years: number | undefined,
|
|
483
|
+
opts?: RequestOptions
|
|
484
|
+
): Promise<NamecomPricing> {
|
|
485
|
+
return this.http.request({
|
|
486
|
+
path: `${domainPath(domainName)}:getPricing`,
|
|
487
|
+
query: { years },
|
|
488
|
+
...opts,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
private toDomain(d: NamecomDomain): Domain {
|
|
492
|
+
if (!d?.domainName)
|
|
493
|
+
throw new InvalidResponseError('namecom: domain response missing domainName');
|
|
494
|
+
return createDomain({
|
|
495
|
+
domainName: normalizeDomain(d.domainName),
|
|
496
|
+
registrar: this.name,
|
|
497
|
+
// Core exposes locks, not a lifecycle status or a scheduled renewal date.
|
|
498
|
+
status: (d.locks ?? []).join(','),
|
|
499
|
+
createdDate: d.createDate,
|
|
500
|
+
expirationDate: d.expireDate,
|
|
501
|
+
autoRenew: d.autorenewEnabled ?? false,
|
|
502
|
+
locked: d.locked ?? false,
|
|
503
|
+
privacy: d.privacyEnabled ?? false,
|
|
504
|
+
nameservers: d.nameservers,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
private async pages<T>(
|
|
508
|
+
path: string,
|
|
509
|
+
key: string,
|
|
510
|
+
opts?: RequestOptions,
|
|
511
|
+
query?: RequestConfig['query']
|
|
512
|
+
): Promise<T[]> {
|
|
513
|
+
const result: T[] = [];
|
|
514
|
+
let page = 1;
|
|
515
|
+
while (true) {
|
|
516
|
+
const res = await this.http.request<{ nextPage?: number | null; [key: string]: unknown }>({
|
|
517
|
+
path,
|
|
518
|
+
query: { perPage: 1000, page, ...query },
|
|
519
|
+
...opts,
|
|
520
|
+
});
|
|
521
|
+
const items = res?.[key];
|
|
522
|
+
// Empty protobuf arrays may be omitted, but a malformed collection is an error.
|
|
523
|
+
if (!res || (items != null && !Array.isArray(items)))
|
|
524
|
+
throw new InvalidResponseError('namecom: invalid list response');
|
|
525
|
+
result.push(...((items ?? []) as T[]));
|
|
526
|
+
const next = res.nextPage;
|
|
527
|
+
if (next == null || next === 0) return result;
|
|
528
|
+
if (!Number.isInteger(next) || next <= page)
|
|
529
|
+
throw new InvalidResponseError('namecom: invalid pagination cursor');
|
|
530
|
+
page = next;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
private async mutate(req: RequestConfig, opts?: RequestOptions): Promise<OperationResult> {
|
|
534
|
+
try {
|
|
535
|
+
await this.http.request({ ...req, ...opts, retries: 0 });
|
|
536
|
+
return { success: true, message: 'Domain updated successfully' };
|
|
537
|
+
} catch (error) {
|
|
538
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
private async purchase(
|
|
542
|
+
path: string,
|
|
543
|
+
body: unknown,
|
|
544
|
+
domainName: string,
|
|
545
|
+
operation: string,
|
|
546
|
+
opts?: RequestOptions
|
|
547
|
+
): Promise<OperationResult> {
|
|
548
|
+
try {
|
|
549
|
+
const res = await this.http.request<{
|
|
550
|
+
order?: number;
|
|
551
|
+
domain?: NamecomDomain;
|
|
552
|
+
transfer?: { domainName: string };
|
|
553
|
+
}>({
|
|
554
|
+
method: 'POST',
|
|
555
|
+
path,
|
|
556
|
+
body,
|
|
557
|
+
...opts,
|
|
558
|
+
retries: 0,
|
|
559
|
+
});
|
|
560
|
+
const returnedName = res?.domain?.domainName ?? res?.transfer?.domainName;
|
|
561
|
+
if (
|
|
562
|
+
!Number.isInteger(res?.order) ||
|
|
563
|
+
(res.order ?? 0) <= 0 ||
|
|
564
|
+
!returnedName ||
|
|
565
|
+
normalizeDomain(returnedName) !== normalizeDomain(domainName)
|
|
566
|
+
) {
|
|
567
|
+
throw new InvalidResponseError(
|
|
568
|
+
'namecom: purchase response missing order or matching domain'
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
return {
|
|
572
|
+
success: true,
|
|
573
|
+
message: `Domain ${operation} request accepted (order ${res.order!}); verify final domain/transfer status`,
|
|
574
|
+
};
|
|
575
|
+
} catch (error) {
|
|
576
|
+
const e = toRegistrarError(error);
|
|
577
|
+
// 4xx rejects are definitive; transport, parse, abort and 5xx failures may
|
|
578
|
+
// arrive after the order was charged. Never repeat the paid request here.
|
|
579
|
+
const uncertain =
|
|
580
|
+
e.status < 0 ||
|
|
581
|
+
e.status === 408 ||
|
|
582
|
+
e.status >= 500 ||
|
|
583
|
+
e.name === 'AbortError' ||
|
|
584
|
+
e instanceof ParsingError;
|
|
585
|
+
return {
|
|
586
|
+
success: false,
|
|
587
|
+
message:
|
|
588
|
+
e.message +
|
|
589
|
+
(uncertain
|
|
590
|
+
? '. Outcome unknown; check Name.com orders and domain/transfer status before retrying.'
|
|
591
|
+
: ''),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const CONTACT_ROLES = ['registrant', 'admin', 'tech', 'billing'] as const;
|
|
598
|
+
function toContacts(contacts: ContactSet): Partial<Record<keyof ContactSet, NamecomContact>> {
|
|
599
|
+
const result: Partial<Record<keyof ContactSet, NamecomContact>> = {};
|
|
600
|
+
for (const role of CONTACT_ROLES) {
|
|
601
|
+
const c = contacts[role];
|
|
602
|
+
if (!c) continue;
|
|
603
|
+
// Core requires complete contact objects, including state. Normalize the
|
|
604
|
+
// library's EPP-style +1.555... phone to Core's E.164 +1555... format.
|
|
605
|
+
const required: (keyof Contact)[] = [
|
|
606
|
+
'firstName',
|
|
607
|
+
'lastName',
|
|
608
|
+
'email',
|
|
609
|
+
'phone',
|
|
610
|
+
'address1',
|
|
611
|
+
'city',
|
|
612
|
+
'state',
|
|
613
|
+
'postalCode',
|
|
614
|
+
'country',
|
|
615
|
+
];
|
|
616
|
+
if (required.some(k => !c[k]?.trim()))
|
|
617
|
+
throw new ConfigurationError(`namecom: ${role} must be a complete contact, including state`);
|
|
618
|
+
result[role] = {
|
|
619
|
+
firstName: c.firstName,
|
|
620
|
+
lastName: c.lastName,
|
|
621
|
+
companyName: c.organization,
|
|
622
|
+
email: c.email,
|
|
623
|
+
phone: c.phone.replace('.', ''),
|
|
624
|
+
fax: c.fax?.replace('.', ''),
|
|
625
|
+
address1: c.address1,
|
|
626
|
+
address2: c.address2,
|
|
627
|
+
city: c.city,
|
|
628
|
+
state: c.state,
|
|
629
|
+
zip: c.postalCode,
|
|
630
|
+
country: c.country.toUpperCase(),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
if (Object.keys(result).length === 0)
|
|
634
|
+
throw new ConfigurationError('namecom: at least one contact is required');
|
|
635
|
+
return result;
|
|
636
|
+
}
|
|
637
|
+
function domainPath(domainName: string): string {
|
|
638
|
+
return `/domains/${encodeURIComponent(normalizeDomain(domainName))}`;
|
|
639
|
+
}
|
|
640
|
+
function validYears(years: number): number {
|
|
641
|
+
if (!Number.isInteger(years) || years < 1 || years > 10)
|
|
642
|
+
throw new ConfigurationError('namecom: years must be an integer from 1 to 10');
|
|
643
|
+
return years;
|
|
644
|
+
}
|
|
645
|
+
function price(value: number | null | undefined): number | undefined {
|
|
646
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
647
|
+
}
|
|
648
|
+
function purchaseQuote(
|
|
649
|
+
pricing: NamecomPricing,
|
|
650
|
+
field: 'purchasePrice' | 'renewalPrice' | 'transferPrice',
|
|
651
|
+
premium = false
|
|
652
|
+
): number | undefined {
|
|
653
|
+
const value = price(pricing?.[field]);
|
|
654
|
+
if (value == null)
|
|
655
|
+
throw new RegistrarError(
|
|
656
|
+
`namecom: ${field} unavailable for the requested term; no purchase submitted`
|
|
657
|
+
);
|
|
658
|
+
return premium || pricing.premium ? value : undefined;
|
|
659
|
+
}
|
|
660
|
+
const DNS_TYPES = new Set(['A', 'AAAA', 'ANAME', 'CNAME', 'MX', 'NS', 'SRV', 'TXT']);
|
|
661
|
+
function toRecord(r: DnsRecord): NamecomRecord {
|
|
662
|
+
const type = r.type.toUpperCase();
|
|
663
|
+
if (!DNS_TYPES.has(type))
|
|
664
|
+
throw new ConfigurationError(`namecom: DNS type ${type} is not supported`);
|
|
665
|
+
const ttl = r.ttl ?? 300;
|
|
666
|
+
if (!Number.isInteger(ttl) || ttl < 300)
|
|
667
|
+
throw new ConfigurationError('namecom: DNS TTL must be an integer of at least 300 seconds');
|
|
668
|
+
for (const value of [r.priority, r.weight, r.port]) {
|
|
669
|
+
if (value != null && (!Number.isInteger(value) || value < 0 || value > 65535))
|
|
670
|
+
throw new ConfigurationError('namecom: invalid DNS priority, weight or port');
|
|
671
|
+
}
|
|
672
|
+
if (!r.value.trim()) throw new ConfigurationError('namecom: DNS answer is required');
|
|
673
|
+
return {
|
|
674
|
+
type,
|
|
675
|
+
host: !r.name || r.name === '@' ? '' : r.name,
|
|
676
|
+
answer: type === 'SRV' ? `${r.weight ?? 0} ${r.port ?? 0} ${r.value}` : r.value,
|
|
677
|
+
ttl,
|
|
678
|
+
...(['MX', 'SRV'].includes(type) ? { priority: r.priority ?? 0 } : {}),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function fromRecord(r: NamecomRecord): DnsRecord {
|
|
682
|
+
const type = r.type.toUpperCase();
|
|
683
|
+
const record: DnsRecord = { type, name: r.host || '@', value: r.answer, ttl: r.ttl };
|
|
684
|
+
if (type === 'MX' || type === 'SRV') record.priority = r.priority ?? 0;
|
|
685
|
+
if (type === 'SRV') {
|
|
686
|
+
const match = /^(\d+)\s+(\d+)\s+(.+)$/.exec(r.answer);
|
|
687
|
+
if (!match) throw new InvalidResponseError('namecom: invalid SRV answer');
|
|
688
|
+
record.weight = Number(match[1]);
|
|
689
|
+
record.port = Number(match[2]);
|
|
690
|
+
record.value = match[3];
|
|
691
|
+
}
|
|
692
|
+
return record;
|
|
693
|
+
}
|
|
694
|
+
function recordKey(r: NamecomRecord): string {
|
|
695
|
+
return `${r.type.toUpperCase()}\0${(r.host === '@' ? '' : (r.host ?? '')).toLowerCase()}`;
|
|
696
|
+
}
|
|
697
|
+
function sameRecord(a: NamecomRecord, b: NamecomRecord): boolean {
|
|
698
|
+
return (
|
|
699
|
+
recordKey(a) === recordKey(b) &&
|
|
700
|
+
a.answer === b.answer &&
|
|
701
|
+
(a.ttl ?? 300) === (b.ttl ?? 300) &&
|
|
702
|
+
(a.priority ?? 0) === (b.priority ?? 0)
|
|
703
|
+
);
|
|
704
|
+
}
|