@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,775 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigField,
|
|
3
|
+
ConnectionResult,
|
|
4
|
+
Contact,
|
|
5
|
+
ContactSet,
|
|
6
|
+
DnsRecord,
|
|
7
|
+
Domain,
|
|
8
|
+
DomainAvailability,
|
|
9
|
+
ListDomainsOptions,
|
|
10
|
+
OperationResult,
|
|
11
|
+
RegisterDomainInput,
|
|
12
|
+
RegistrarOptions,
|
|
13
|
+
RequestOptions,
|
|
14
|
+
TldPricing,
|
|
15
|
+
} from '../types';
|
|
16
|
+
import { createDomain, filterDomains } from '../utils';
|
|
17
|
+
import { AuthenticationError, NotImplementedError, toRegistrarError } from '../errors';
|
|
18
|
+
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
19
|
+
import { Feature, type RegistrarFeature } from '../features';
|
|
20
|
+
import type { RegistrarCredentials } from '../types';
|
|
21
|
+
import type { RequestConfig } from '../http';
|
|
22
|
+
|
|
23
|
+
const TOKEN_URL = 'https://api.namebright.com/auth/token';
|
|
24
|
+
|
|
25
|
+
// response from the OAuth2 token endpoint
|
|
26
|
+
interface NbToken {
|
|
27
|
+
access_token?: string;
|
|
28
|
+
token_type?: string;
|
|
29
|
+
expires_in?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// the order returned by purchase/register and purchase/renew
|
|
33
|
+
interface NbOrder {
|
|
34
|
+
OrderId?: number;
|
|
35
|
+
TotalPrice?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// a domain record from GET account/domains. The list endpoint does not return
|
|
39
|
+
// nameservers (those come from account/domains/{domain}/nameservers).
|
|
40
|
+
interface NbDomain {
|
|
41
|
+
DomainName?: string;
|
|
42
|
+
domain?: string;
|
|
43
|
+
Status?: string;
|
|
44
|
+
ExpirationDate?: string;
|
|
45
|
+
RegistrationDate?: string;
|
|
46
|
+
AutoRenew?: boolean;
|
|
47
|
+
Locked?: boolean;
|
|
48
|
+
WhoIsPrivacy?: boolean;
|
|
49
|
+
AuthCode?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// the paged wrapper GET account/domains returns
|
|
53
|
+
interface NbDomainsPage {
|
|
54
|
+
ResultsTotal?: number;
|
|
55
|
+
CurrentPage?: number;
|
|
56
|
+
Domains?: NbDomain[];
|
|
57
|
+
domains?: NbDomain[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// GET account/domains/{domain}/nameservers → { DomainName, NameServers: [...] }.
|
|
61
|
+
// Be lenient about casing and a possible bare array.
|
|
62
|
+
interface NbNameservers {
|
|
63
|
+
DomainName?: string;
|
|
64
|
+
NameServers?: string[];
|
|
65
|
+
Nameservers?: string[];
|
|
66
|
+
nameservers?: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// a contact object inside GET account/domains/{domain}/contacts/all. NameBright
|
|
70
|
+
// splits the phone into a country-code part and the number.
|
|
71
|
+
interface NbContact {
|
|
72
|
+
FirstName?: string;
|
|
73
|
+
LastName?: string;
|
|
74
|
+
Organization?: string;
|
|
75
|
+
Department?: string;
|
|
76
|
+
Email?: string;
|
|
77
|
+
Address1?: string;
|
|
78
|
+
Address2?: string;
|
|
79
|
+
City?: string;
|
|
80
|
+
Region?: string;
|
|
81
|
+
Country?: string;
|
|
82
|
+
PostalCode?: string;
|
|
83
|
+
// NameBright returns the country-code parts as numbers (e.g. 1), not strings
|
|
84
|
+
PhoneCountry?: string | number;
|
|
85
|
+
Phone?: string | number;
|
|
86
|
+
FaxCountry?: string | number;
|
|
87
|
+
Fax?: string | number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// GET account/domains/{domain}/contacts/all. NameBright exposes registrant,
|
|
91
|
+
// administrative, and technical roles only (no billing).
|
|
92
|
+
interface NbContactsResponse {
|
|
93
|
+
DomainName?: string;
|
|
94
|
+
RegistrantContact?: NbContact;
|
|
95
|
+
AdministrativeContact?: NbContact;
|
|
96
|
+
TechnicalContact?: NbContact;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// the per-type DNS host records from GET account/domains/{domain}/hostrecords/all.
|
|
100
|
+
// Each carries a numeric RecordId used to address it for deletion
|
|
101
|
+
// (DELETE account/domains/{domain}/hostrecords/{type}/{RecordId}).
|
|
102
|
+
interface NbARecord {
|
|
103
|
+
Subdomain?: string;
|
|
104
|
+
IPV4Address?: string;
|
|
105
|
+
RecordId?: number;
|
|
106
|
+
}
|
|
107
|
+
interface NbAAAARecord {
|
|
108
|
+
Subdomain?: string;
|
|
109
|
+
IPV6Address?: string;
|
|
110
|
+
RecordId?: number;
|
|
111
|
+
}
|
|
112
|
+
interface NbCNAMERecord {
|
|
113
|
+
Subdomain?: string;
|
|
114
|
+
RedirectDomain?: string;
|
|
115
|
+
RecordId?: number;
|
|
116
|
+
}
|
|
117
|
+
interface NbMXRecord {
|
|
118
|
+
Subdomain?: string;
|
|
119
|
+
MailServer?: string;
|
|
120
|
+
Priority?: number;
|
|
121
|
+
RecordId?: number;
|
|
122
|
+
}
|
|
123
|
+
interface NbTXTRecord {
|
|
124
|
+
Subdomain?: string;
|
|
125
|
+
TextRecord?: string;
|
|
126
|
+
RecordId?: number;
|
|
127
|
+
}
|
|
128
|
+
interface NbSRVRecord {
|
|
129
|
+
Service?: string;
|
|
130
|
+
Protocol?: string;
|
|
131
|
+
Priority?: number;
|
|
132
|
+
Weight?: number;
|
|
133
|
+
Port?: number;
|
|
134
|
+
Target?: string;
|
|
135
|
+
RecordId?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface NbHostRecords {
|
|
139
|
+
DomainName?: string;
|
|
140
|
+
ARecords?: NbARecord[];
|
|
141
|
+
AAAARecords?: NbAAAARecord[];
|
|
142
|
+
CNAMERecords?: NbCNAMERecord[];
|
|
143
|
+
MXRecords?: NbMXRecord[];
|
|
144
|
+
TXTRecords?: NbTXTRecord[];
|
|
145
|
+
SRVRecords?: NbSRVRecord[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// GET purchase/availability/{domain}. NameBright returns a per-domain result
|
|
149
|
+
// with the standard unit price and an optional promotional price.
|
|
150
|
+
interface NbAvailability {
|
|
151
|
+
DomainName?: string;
|
|
152
|
+
ProductTypeName?: string;
|
|
153
|
+
Status?: string;
|
|
154
|
+
UnitPrice?: number;
|
|
155
|
+
Promotion?: {
|
|
156
|
+
PromotionPrice?: number;
|
|
157
|
+
Discount?: number;
|
|
158
|
+
Description?: string;
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* NameBright Registrar
|
|
164
|
+
* API docs: https://api.namebright.com/rest/Help
|
|
165
|
+
*
|
|
166
|
+
* Credentials: create an API Application at
|
|
167
|
+
* https://my.namebright.com/my-account/api-management. Each application has a
|
|
168
|
+
* name and an IP whitelist; NameBright issues a client secret. The OAuth2
|
|
169
|
+
* `client_id` is formatted "<accountName>:<applicationName>".
|
|
170
|
+
*
|
|
171
|
+
* Auth: OAuth2 client-credentials. We POST to the token endpoint for a bearer
|
|
172
|
+
* token (valid ~30 minutes), cache it, and send it as `Authorization: Bearer`
|
|
173
|
+
* on each REST call.
|
|
174
|
+
*
|
|
175
|
+
* The read operations are implemented here (testConnection, listDomains,
|
|
176
|
+
* getDomain, getNameservers, getContacts, getDnsRecords, checkAvailability,
|
|
177
|
+
* getAuthCode). `getPricing` throws NotImplementedError — NameBright has no
|
|
178
|
+
* per-TLD price table.
|
|
179
|
+
*
|
|
180
|
+
* Write operations verified against a live account: lock/unlock, setAutoRenew,
|
|
181
|
+
* and setPrivacy all update the shared `PUT /account/domains/{domain}` endpoint
|
|
182
|
+
* (which takes the full AccountDomain body — we read-merge to avoid clobbering
|
|
183
|
+
* the other flags). setDnsRecords applies a desired-state diff via per-type
|
|
184
|
+
* POST/DELETE on `hostrecords/{type}[/{RecordId}]` (a blind delete-all +
|
|
185
|
+
* re-create trips NameBright's flaky "Duplicate host record" check).
|
|
186
|
+
*
|
|
187
|
+
* updateNameservers (DELETE-all + per-server PUT on `nameservers[/{ns}]`) is
|
|
188
|
+
* built from the documented endpoints but NOT live-verified — replacing the
|
|
189
|
+
* nameservers on a live domain has no safe test path.
|
|
190
|
+
*
|
|
191
|
+
* registerDomain / renewDomain use the `purchase/register` and `purchase/renew`
|
|
192
|
+
* order endpoints. They spend real money (each returns an order with a
|
|
193
|
+
* `TotalPrice`), so they're built from the documented request shape but NOT
|
|
194
|
+
* exercised against the live account. transferIn remains NotImplementedError —
|
|
195
|
+
* NameBright's REST API exposes no transfer-in endpoint.
|
|
196
|
+
*/
|
|
197
|
+
export class NameBrightRegistrar extends BaseRegistrar {
|
|
198
|
+
readonly name = 'namebright';
|
|
199
|
+
|
|
200
|
+
static readonly displayName = 'NameBright';
|
|
201
|
+
static readonly helpText =
|
|
202
|
+
'API access is not enabled by default — you must request it from NameBright ' +
|
|
203
|
+
'(contact support / your account manager) before the API Management page is ' +
|
|
204
|
+
'available. Once enabled, create an API Application under my.namebright.com > ' +
|
|
205
|
+
'My Account > API Management. The Client ID is "<accountName>:<applicationName>"; ' +
|
|
206
|
+
'NameBright issues the Client Secret. Note the application also enforces an IP whitelist.';
|
|
207
|
+
static readonly configFields: ConfigField[] = [
|
|
208
|
+
{ name: 'clientId', label: 'Client ID', type: 'text', required: true },
|
|
209
|
+
{ name: 'clientSecret', label: 'Client Secret', type: 'password', required: true },
|
|
210
|
+
];
|
|
211
|
+
static readonly supportsSandbox = false; // NameBright has no sandbox environment
|
|
212
|
+
// REST/JSON API covering the core lifecycle plus transfer-out auth code (the
|
|
213
|
+
// `AuthCode` field on the domain-detail response). No DNSSEC, forwarding, or
|
|
214
|
+
// push webhooks.
|
|
215
|
+
static readonly extendedFeatures: readonly RegistrarFeature[] = [Feature.GetAuthCode];
|
|
216
|
+
|
|
217
|
+
private token?: string;
|
|
218
|
+
private tokenExpiresAt = 0;
|
|
219
|
+
|
|
220
|
+
constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
|
|
221
|
+
super(
|
|
222
|
+
credentials,
|
|
223
|
+
{
|
|
224
|
+
baseUrl: selectBaseUrl('NameBright', options?.environment, {
|
|
225
|
+
production: 'https://api.namebright.com/rest',
|
|
226
|
+
}),
|
|
227
|
+
},
|
|
228
|
+
options
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// fetch (and cache) an OAuth2 bearer token via the client-credentials grant
|
|
233
|
+
private async getToken(opts?: RequestOptions): Promise<string> {
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
if (this.token && now < this.tokenExpiresAt) return this.token;
|
|
236
|
+
|
|
237
|
+
const res = await this.http.request<NbToken>({
|
|
238
|
+
method: 'POST',
|
|
239
|
+
path: TOKEN_URL,
|
|
240
|
+
body: {
|
|
241
|
+
grant_type: 'client_credentials',
|
|
242
|
+
client_id: this.credentials.clientId,
|
|
243
|
+
client_secret: this.credentials.clientSecret,
|
|
244
|
+
},
|
|
245
|
+
...opts,
|
|
246
|
+
});
|
|
247
|
+
if (!res.access_token) {
|
|
248
|
+
throw new AuthenticationError('NameBright: token endpoint returned no access_token');
|
|
249
|
+
}
|
|
250
|
+
this.token = res.access_token;
|
|
251
|
+
// tokens last ~30 minutes; refresh a minute early
|
|
252
|
+
const ttlMs = (res.expires_in ?? 1800) * 1000;
|
|
253
|
+
this.tokenExpiresAt = now + ttlMs - 60_000;
|
|
254
|
+
return this.token;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// issue an authenticated REST request, attaching a fresh bearer token
|
|
258
|
+
private async authed<T>(
|
|
259
|
+
config: Omit<RequestConfig, 'headers'>,
|
|
260
|
+
opts?: RequestOptions
|
|
261
|
+
): Promise<T> {
|
|
262
|
+
const token = await this.getToken(opts);
|
|
263
|
+
return this.http.request<T>({
|
|
264
|
+
...config,
|
|
265
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
266
|
+
...opts,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
|
|
271
|
+
try {
|
|
272
|
+
// GET /account succeeds only with a valid token
|
|
273
|
+
await this.authed({ path: 'account' }, opts);
|
|
274
|
+
return { success: true, message: 'Connection successful' };
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
|
|
281
|
+
// The list endpoint has no name filter, so `search` is applied client-side.
|
|
282
|
+
// Nameservers are not returned here (see NbDomain).
|
|
283
|
+
const { search, ...reqOpts } = opts ?? {};
|
|
284
|
+
const domains: Domain[] = [];
|
|
285
|
+
const perPage = 100; // domainsPerPage maximum
|
|
286
|
+
let page = 1;
|
|
287
|
+
let hasMore = true;
|
|
288
|
+
|
|
289
|
+
while (hasMore) {
|
|
290
|
+
const data = await this.authed<NbDomainsPage | NbDomain[]>(
|
|
291
|
+
{ path: 'account/domains', query: { page, domainsPerPage: perPage } },
|
|
292
|
+
reqOpts
|
|
293
|
+
);
|
|
294
|
+
// the response may be a bare array or a paged object; be lenient
|
|
295
|
+
const list = Array.isArray(data) ? data : (data?.Domains ?? data?.domains ?? []);
|
|
296
|
+
|
|
297
|
+
for (const d of list) domains.push(this.toDomain(d));
|
|
298
|
+
|
|
299
|
+
hasMore = list.length === perPage;
|
|
300
|
+
page++;
|
|
301
|
+
}
|
|
302
|
+
return filterDomains(domains, search);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Fetch a single domain via GET account/domains/{domain}. This endpoint carries
|
|
307
|
+
* the same core fields as the list endpoint (plus Category/UpgradedDomain/
|
|
308
|
+
* AuthCode, which we don't map) but not nameservers — use `getNameservers` for
|
|
309
|
+
* those, so `nameservers` comes back empty here.
|
|
310
|
+
*/
|
|
311
|
+
override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
|
|
312
|
+
const d = await this.authed<NbDomain>(
|
|
313
|
+
{ path: `account/domains/${encodeURIComponent(domainName)}` },
|
|
314
|
+
opts
|
|
315
|
+
);
|
|
316
|
+
return this.toDomain(d);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Transfer authorization (EPP) code. NameBright carries it as the `AuthCode`
|
|
321
|
+
* field on the domain-detail response (GET account/domains/{domain}), returned
|
|
322
|
+
* synchronously in the body — not email-only.
|
|
323
|
+
*/
|
|
324
|
+
override async getAuthCode(domainName: string, opts?: RequestOptions): Promise<string> {
|
|
325
|
+
const d = await this.authed<NbDomain>(
|
|
326
|
+
{ path: `account/domains/${encodeURIComponent(domainName)}` },
|
|
327
|
+
opts
|
|
328
|
+
);
|
|
329
|
+
return d.AuthCode ?? '';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
|
|
333
|
+
const res = await this.authed<NbNameservers | string[]>(
|
|
334
|
+
{ path: `account/domains/${encodeURIComponent(domainName)}/nameservers` },
|
|
335
|
+
opts
|
|
336
|
+
);
|
|
337
|
+
if (Array.isArray(res)) return res.map(String);
|
|
338
|
+
return (res.NameServers ?? res.Nameservers ?? res.nameservers ?? []).map(String);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Read the registrant/admin/tech contacts via GET
|
|
343
|
+
* account/domains/{domain}/contacts/all. NameBright has no billing contact, so
|
|
344
|
+
* `billing` is always omitted.
|
|
345
|
+
*/
|
|
346
|
+
override async getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet> {
|
|
347
|
+
const res = await this.authed<NbContactsResponse>(
|
|
348
|
+
{ path: `account/domains/${encodeURIComponent(domainName)}/contacts/all` },
|
|
349
|
+
opts
|
|
350
|
+
);
|
|
351
|
+
return {
|
|
352
|
+
registrant: fromNbContact(res.RegistrantContact),
|
|
353
|
+
admin: fromNbContact(res.AdministrativeContact),
|
|
354
|
+
tech: fromNbContact(res.TechnicalContact),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Read DNS host records via GET account/domains/{domain}/hostrecords/all, which
|
|
360
|
+
* groups records by type. NameBright doesn't return a per-record TTL, so `ttl`
|
|
361
|
+
* is left unset.
|
|
362
|
+
*/
|
|
363
|
+
override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
|
|
364
|
+
const res = await this.authed<NbHostRecords>(
|
|
365
|
+
{ path: `account/domains/${encodeURIComponent(domainName)}/hostrecords/all` },
|
|
366
|
+
opts
|
|
367
|
+
);
|
|
368
|
+
return flattenHostRecords(res).map(e => e.record);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* NameBright exposes no per-TLD price table — only a per-domain availability
|
|
373
|
+
* check that returns a registration price (no renewal/transfer prices), so
|
|
374
|
+
* there's no `TldPricing` to return. Mirrors Spaceship's `getPricing`.
|
|
375
|
+
*/
|
|
376
|
+
override getPricing(_tldOrDomain: string, _opts?: RequestOptions): Promise<TldPricing> {
|
|
377
|
+
return Promise.reject(
|
|
378
|
+
new NotImplementedError(
|
|
379
|
+
`${this.name}: getPricing is not available — NameBright exposes no per-TLD pricing ` +
|
|
380
|
+
'endpoint (only a per-domain registration price via checkAvailability)'
|
|
381
|
+
)
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Availability via GET purchase/availability/{domain}. NameBright's endpoint is
|
|
387
|
+
* per-domain, so multiple names are checked sequentially. It reports a `Status`
|
|
388
|
+
* ("AvailableForRegistration") plus a `UnitPrice`, discounted by an optional
|
|
389
|
+
* `Promotion`. NameBright bills in USD (the response carries no currency code).
|
|
390
|
+
*/
|
|
391
|
+
override async checkAvailability(
|
|
392
|
+
domainNames: string[],
|
|
393
|
+
opts?: RequestOptions
|
|
394
|
+
): Promise<DomainAvailability[]> {
|
|
395
|
+
const results: DomainAvailability[] = [];
|
|
396
|
+
for (const domainName of domainNames) {
|
|
397
|
+
const res = await this.authed<NbAvailability>(
|
|
398
|
+
{ path: `purchase/availability/${encodeURIComponent(domainName)}` },
|
|
399
|
+
opts
|
|
400
|
+
);
|
|
401
|
+
const price = res.Promotion?.PromotionPrice ?? res.UnitPrice;
|
|
402
|
+
const result: DomainAvailability = {
|
|
403
|
+
domainName: res.DomainName ?? domainName,
|
|
404
|
+
available: res.Status === 'AvailableForRegistration',
|
|
405
|
+
};
|
|
406
|
+
// NameBright returns UnitPrice 0 for unavailable names; only surface a
|
|
407
|
+
// meaningful (positive) registration price.
|
|
408
|
+
if (typeof price === 'number' && Number.isFinite(price) && price > 0) {
|
|
409
|
+
result.price = price;
|
|
410
|
+
result.currency = 'USD';
|
|
411
|
+
}
|
|
412
|
+
results.push(result);
|
|
413
|
+
}
|
|
414
|
+
return results;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// --- write operations ---------------------------------------------------
|
|
418
|
+
|
|
419
|
+
override lockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
420
|
+
return this.setDomainFlag(domainName, { Locked: true }, 'Domain locked successfully', opts);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
override unlockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
424
|
+
return this.setDomainFlag(domainName, { Locked: false }, 'Domain unlocked successfully', opts);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
override setAutoRenew(
|
|
428
|
+
domainName: string,
|
|
429
|
+
enabled: boolean,
|
|
430
|
+
opts?: RequestOptions
|
|
431
|
+
): Promise<OperationResult> {
|
|
432
|
+
return this.setDomainFlag(
|
|
433
|
+
domainName,
|
|
434
|
+
{ AutoRenew: enabled },
|
|
435
|
+
`Auto-renew ${enabled ? 'enabled' : 'disabled'} successfully`,
|
|
436
|
+
opts
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
override setPrivacy(
|
|
441
|
+
domainName: string,
|
|
442
|
+
enabled: boolean,
|
|
443
|
+
opts?: RequestOptions
|
|
444
|
+
): Promise<OperationResult> {
|
|
445
|
+
return this.setDomainFlag(
|
|
446
|
+
domainName,
|
|
447
|
+
{ WhoIsPrivacy: enabled },
|
|
448
|
+
`WHOIS privacy ${enabled ? 'enabled' : 'disabled'} successfully`,
|
|
449
|
+
opts
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Registers a domain via the `purchase/register` order endpoint. NameBright's
|
|
455
|
+
* request carries only the name + term; nameservers, privacy, auto-renew, and
|
|
456
|
+
* contacts aren't part of registration (they use account defaults and the
|
|
457
|
+
* per-flag/nameserver endpoints afterwards). Spends real money — built from the
|
|
458
|
+
* documented shape, not exercised against the live account.
|
|
459
|
+
*/
|
|
460
|
+
override registerDomain(
|
|
461
|
+
domainName: string,
|
|
462
|
+
input: RegisterDomainInput,
|
|
463
|
+
opts?: RequestOptions
|
|
464
|
+
): Promise<OperationResult> {
|
|
465
|
+
return this.purchase('register', domainName, input.years ?? 1, 'registered', opts);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
override renewDomain(
|
|
469
|
+
domainName: string,
|
|
470
|
+
years = 1,
|
|
471
|
+
opts?: RequestOptions
|
|
472
|
+
): Promise<OperationResult> {
|
|
473
|
+
return this.purchase('renew', domainName, years, 'renewed', opts);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// POST an order to purchase/{register|renew}. Both take the same body and
|
|
477
|
+
// return an order (OrderId + TotalPrice); a returned OrderId signals success.
|
|
478
|
+
private async purchase(
|
|
479
|
+
operation: 'register' | 'renew',
|
|
480
|
+
domainName: string,
|
|
481
|
+
years: number,
|
|
482
|
+
verb: string,
|
|
483
|
+
opts?: RequestOptions
|
|
484
|
+
): Promise<OperationResult> {
|
|
485
|
+
try {
|
|
486
|
+
const order = await this.authed<NbOrder>(
|
|
487
|
+
{
|
|
488
|
+
method: 'POST',
|
|
489
|
+
path: `purchase/${operation}`,
|
|
490
|
+
body: { DomainName: domainName, Years: years, CategoryId: 0, CategoryName: null },
|
|
491
|
+
},
|
|
492
|
+
opts
|
|
493
|
+
);
|
|
494
|
+
if (order?.OrderId == null) {
|
|
495
|
+
return { success: false, message: `NameBright: ${operation} returned no order id` };
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
success: true,
|
|
499
|
+
message: `Domain ${domainName} ${verb} successfully (order ${order.OrderId})`,
|
|
500
|
+
};
|
|
501
|
+
} catch (error) {
|
|
502
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Update one of the boolean domain flags (Locked / AutoRenew / WhoIsPrivacy)
|
|
508
|
+
* via the shared PUT account/domains/{domain}. That endpoint takes the full
|
|
509
|
+
* AccountDomain body, so we first GET the current record and merge the change
|
|
510
|
+
* over it — sending a bare `{ Locked: true }` would otherwise reset the other
|
|
511
|
+
* flags to their defaults. We deliberately drop AuthCode from the round-trip.
|
|
512
|
+
*/
|
|
513
|
+
private async setDomainFlag(
|
|
514
|
+
domainName: string,
|
|
515
|
+
change: Partial<Pick<NbDomain, 'Locked' | 'AutoRenew' | 'WhoIsPrivacy'>>,
|
|
516
|
+
successMessage: string,
|
|
517
|
+
opts?: RequestOptions
|
|
518
|
+
): Promise<OperationResult> {
|
|
519
|
+
try {
|
|
520
|
+
const path = `account/domains/${encodeURIComponent(domainName)}`;
|
|
521
|
+
const current = await this.authed<NbDomain & { Category?: string; UpgradedDomain?: boolean }>(
|
|
522
|
+
{ path },
|
|
523
|
+
opts
|
|
524
|
+
);
|
|
525
|
+
const body = {
|
|
526
|
+
DomainName: current.DomainName,
|
|
527
|
+
Status: current.Status,
|
|
528
|
+
ExpirationDate: current.ExpirationDate,
|
|
529
|
+
Locked: current.Locked ?? false,
|
|
530
|
+
AutoRenew: current.AutoRenew ?? false,
|
|
531
|
+
WhoIsPrivacy: current.WhoIsPrivacy ?? false,
|
|
532
|
+
Category: current.Category,
|
|
533
|
+
UpgradedDomain: current.UpgradedDomain,
|
|
534
|
+
...change,
|
|
535
|
+
};
|
|
536
|
+
await this.authed({ method: 'PUT', path, body }, opts);
|
|
537
|
+
return { success: true, message: successMessage };
|
|
538
|
+
} catch (error) {
|
|
539
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Replace the domain's nameservers. NameBright has no bulk set: remove all
|
|
545
|
+
* existing servers, then PUT each new one (the server is a path segment, with
|
|
546
|
+
* no request body).
|
|
547
|
+
*
|
|
548
|
+
* NOTE: built from the documented endpoints but not live-verified — there is
|
|
549
|
+
* no safe way to test nameserver replacement against a live production domain.
|
|
550
|
+
*/
|
|
551
|
+
override async updateNameservers(
|
|
552
|
+
domainName: string,
|
|
553
|
+
nameservers: string[],
|
|
554
|
+
opts?: RequestOptions
|
|
555
|
+
): Promise<OperationResult> {
|
|
556
|
+
try {
|
|
557
|
+
const base = `account/domains/${encodeURIComponent(domainName)}/nameservers`;
|
|
558
|
+
await this.authed({ method: 'DELETE', path: base }, opts);
|
|
559
|
+
for (const ns of nameservers) {
|
|
560
|
+
await this.authed({ method: 'PUT', path: `${base}/${encodeURIComponent(ns)}` }, opts);
|
|
561
|
+
}
|
|
562
|
+
return { success: true, message: 'Nameservers updated successfully' };
|
|
563
|
+
} catch (error) {
|
|
564
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Replace the domain's DNS host records with `records` (desired-state
|
|
570
|
+
* semantics). NameBright manages records individually — per-type POST to
|
|
571
|
+
* create, DELETE by RecordId to remove — with no bulk set, so we diff against
|
|
572
|
+
* the current records: delete the ones no longer wanted and create the ones
|
|
573
|
+
* that are new, leaving unchanged records in place. (A blind delete-all +
|
|
574
|
+
* re-create trips NameBright's flaky "Duplicate host record" check when a
|
|
575
|
+
* record is deleted and immediately re-posted, so the diff both avoids that
|
|
576
|
+
* and skips needless churn.) NameBright supports no per-record TTL, so `ttl`
|
|
577
|
+
* is ignored.
|
|
578
|
+
*/
|
|
579
|
+
override async setDnsRecords(
|
|
580
|
+
domainName: string,
|
|
581
|
+
records: DnsRecord[],
|
|
582
|
+
opts?: RequestOptions
|
|
583
|
+
): Promise<OperationResult> {
|
|
584
|
+
try {
|
|
585
|
+
const base = `account/domains/${encodeURIComponent(domainName)}/hostrecords`;
|
|
586
|
+
const current = flattenHostRecords(
|
|
587
|
+
await this.authed<NbHostRecords>({ path: `${base}/all` }, opts)
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
const desiredKeys = new Set(records.map(dnsRecordKey));
|
|
591
|
+
const currentKeys = new Set(current.map(e => dnsRecordKey(e.record)));
|
|
592
|
+
|
|
593
|
+
// delete records that are present now but not desired
|
|
594
|
+
for (const e of current) {
|
|
595
|
+
if (e.recordId != null && !desiredKeys.has(dnsRecordKey(e.record))) {
|
|
596
|
+
await this.authed({ method: 'DELETE', path: `${base}/${e.segment}/${e.recordId}` }, opts);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// create desired records that aren't already present
|
|
600
|
+
for (const r of records) {
|
|
601
|
+
if (!currentKeys.has(dnsRecordKey(r))) {
|
|
602
|
+
const { segment, body } = toNbHostRecord(r);
|
|
603
|
+
await this.authed({ method: 'POST', path: `${base}/${segment}`, body }, opts);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return { success: true, message: 'DNS records updated successfully' };
|
|
607
|
+
} catch (error) {
|
|
608
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// map a NameBright domain object (from list or single-domain endpoints) to the
|
|
613
|
+
// normalized Domain shape. Neither endpoint returns nameservers.
|
|
614
|
+
private toDomain(d: NbDomain): Domain {
|
|
615
|
+
return createDomain({
|
|
616
|
+
domainName: d.DomainName ?? d.domain,
|
|
617
|
+
registrar: this.name,
|
|
618
|
+
status: d.Status ?? 'ok',
|
|
619
|
+
createdDate: d.RegistrationDate,
|
|
620
|
+
expirationDate: d.ExpirationDate,
|
|
621
|
+
renewalDate: d.ExpirationDate,
|
|
622
|
+
autoRenew: d.AutoRenew ?? false,
|
|
623
|
+
locked: d.Locked ?? false,
|
|
624
|
+
privacy: d.WhoIsPrivacy ?? false,
|
|
625
|
+
nameservers: [],
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// map a NameBright contact object to the normalized Contact (undefined if the
|
|
631
|
+
// role isn't present). NameBright splits the phone into country code + number.
|
|
632
|
+
function fromNbContact(c: NbContact | undefined): Contact | undefined {
|
|
633
|
+
if (!c) return undefined;
|
|
634
|
+
return {
|
|
635
|
+
firstName: c.FirstName ?? '',
|
|
636
|
+
lastName: c.LastName ?? '',
|
|
637
|
+
organization: c.Organization ? String(c.Organization) : undefined,
|
|
638
|
+
email: c.Email ?? '',
|
|
639
|
+
phone: joinPhone(c.PhoneCountry, c.Phone),
|
|
640
|
+
fax: c.Fax ? joinPhone(c.FaxCountry, c.Fax) : undefined,
|
|
641
|
+
address1: c.Address1 ?? '',
|
|
642
|
+
address2: c.Address2 ? String(c.Address2) : undefined,
|
|
643
|
+
city: c.City ?? '',
|
|
644
|
+
state: c.Region ?? '',
|
|
645
|
+
postalCode: c.PostalCode ?? '',
|
|
646
|
+
country: c.Country ?? '',
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// flatten NameBright's per-type host-record groups into generic DnsRecords,
|
|
651
|
+
// preserving each record's endpoint segment and RecordId so callers can address
|
|
652
|
+
// it for deletion. Order is stable (A, AAAA, CNAME, MX, TXT, SRV).
|
|
653
|
+
interface FlatHostRecord {
|
|
654
|
+
record: DnsRecord;
|
|
655
|
+
segment: string;
|
|
656
|
+
recordId?: number;
|
|
657
|
+
}
|
|
658
|
+
function flattenHostRecords(res: NbHostRecords): FlatHostRecord[] {
|
|
659
|
+
const out: FlatHostRecord[] = [];
|
|
660
|
+
for (const r of res.ARecords ?? []) {
|
|
661
|
+
out.push({
|
|
662
|
+
record: { type: 'A', name: r.Subdomain ?? '@', value: r.IPV4Address ?? '' },
|
|
663
|
+
segment: 'a',
|
|
664
|
+
recordId: r.RecordId,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
for (const r of res.AAAARecords ?? []) {
|
|
668
|
+
out.push({
|
|
669
|
+
record: { type: 'AAAA', name: r.Subdomain ?? '@', value: r.IPV6Address ?? '' },
|
|
670
|
+
segment: 'aaaa',
|
|
671
|
+
recordId: r.RecordId,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
for (const r of res.CNAMERecords ?? []) {
|
|
675
|
+
out.push({
|
|
676
|
+
record: { type: 'CNAME', name: r.Subdomain ?? '@', value: r.RedirectDomain ?? '' },
|
|
677
|
+
segment: 'cname',
|
|
678
|
+
recordId: r.RecordId,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
for (const r of res.MXRecords ?? []) {
|
|
682
|
+
const record: DnsRecord = { type: 'MX', name: r.Subdomain ?? '@', value: r.MailServer ?? '' };
|
|
683
|
+
if (r.Priority != null) record.priority = r.Priority;
|
|
684
|
+
out.push({ record, segment: 'mx', recordId: r.RecordId });
|
|
685
|
+
}
|
|
686
|
+
for (const r of res.TXTRecords ?? []) {
|
|
687
|
+
out.push({
|
|
688
|
+
record: { type: 'TXT', name: r.Subdomain ?? '@', value: r.TextRecord ?? '' },
|
|
689
|
+
segment: 'txt',
|
|
690
|
+
recordId: r.RecordId,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
for (const r of res.SRVRecords ?? []) {
|
|
694
|
+
// NameBright splits SRV into service/protocol; recompose the standard
|
|
695
|
+
// "_service._protocol" name that our generic record carries.
|
|
696
|
+
const name = [r.Service, r.Protocol].filter(Boolean).join('.') || '@';
|
|
697
|
+
const record: DnsRecord = { type: 'SRV', name, value: r.Target ?? '' };
|
|
698
|
+
if (r.Priority != null) record.priority = r.Priority;
|
|
699
|
+
if (r.Weight != null) record.weight = r.Weight;
|
|
700
|
+
if (r.Port != null) record.port = r.Port;
|
|
701
|
+
out.push({ record, segment: 'srv', recordId: r.RecordId });
|
|
702
|
+
}
|
|
703
|
+
return out;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// canonical identity of a DNS record for diffing (ignores TTL, which NameBright
|
|
707
|
+
// doesn't store). Two records with the same key are considered equal.
|
|
708
|
+
function dnsRecordKey(r: DnsRecord): string {
|
|
709
|
+
return [
|
|
710
|
+
r.type.toUpperCase(),
|
|
711
|
+
r.name || '@',
|
|
712
|
+
r.value,
|
|
713
|
+
r.priority ?? '',
|
|
714
|
+
r.weight ?? '',
|
|
715
|
+
r.port ?? '',
|
|
716
|
+
].join('|');
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// map a generic DnsRecord to NameBright's per-type POST endpoint segment and
|
|
720
|
+
// request body. The record's `name` is the subdomain ("@" for the apex); TTL is
|
|
721
|
+
// unsupported by NameBright and ignored. Throws on record types NameBright's
|
|
722
|
+
// host-record API does not expose.
|
|
723
|
+
function toNbHostRecord(r: DnsRecord): { segment: string; body: Record<string, unknown> } {
|
|
724
|
+
const type = r.type.toUpperCase();
|
|
725
|
+
const subdomain = r.name || '@';
|
|
726
|
+
switch (type) {
|
|
727
|
+
case 'A':
|
|
728
|
+
return { segment: 'a', body: { Subdomain: subdomain, IPV4Address: r.value } };
|
|
729
|
+
case 'AAAA':
|
|
730
|
+
return { segment: 'aaaa', body: { Subdomain: subdomain, IPV6Address: r.value } };
|
|
731
|
+
case 'CNAME':
|
|
732
|
+
return { segment: 'cname', body: { Subdomain: subdomain, RedirectDomain: r.value } };
|
|
733
|
+
case 'MX':
|
|
734
|
+
return {
|
|
735
|
+
segment: 'mx',
|
|
736
|
+
body: { Subdomain: subdomain, MailServer: r.value, Priority: r.priority ?? 10 },
|
|
737
|
+
};
|
|
738
|
+
case 'TXT':
|
|
739
|
+
return { segment: 'txt', body: { Subdomain: subdomain, TextRecord: r.value } };
|
|
740
|
+
case 'SRV': {
|
|
741
|
+
// our generic name carries "_service._protocol"; NameBright splits them.
|
|
742
|
+
const [service, protocol] = r.name.split('.');
|
|
743
|
+
return {
|
|
744
|
+
segment: 'srv',
|
|
745
|
+
body: {
|
|
746
|
+
Service: service,
|
|
747
|
+
Protocol: protocol,
|
|
748
|
+
Priority: r.priority ?? 0,
|
|
749
|
+
Weight: r.weight ?? 0,
|
|
750
|
+
Port: r.port ?? 0,
|
|
751
|
+
Target: r.value,
|
|
752
|
+
},
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
default:
|
|
756
|
+
throw new Error(`NameBright: unsupported DNS record type "${r.type}"`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// combine a country-code part and a local number into "+<cc>.<number>" form,
|
|
761
|
+
// leaving an already-complete number untouched when no country code is given.
|
|
762
|
+
// NameBright returns PhoneCountry/FaxCountry as numbers (e.g. 1), so coerce to
|
|
763
|
+
// string before trimming.
|
|
764
|
+
function joinPhone(
|
|
765
|
+
countryCode: string | number | undefined,
|
|
766
|
+
number: string | number | undefined
|
|
767
|
+
): string {
|
|
768
|
+
const num = String(number ?? '').trim();
|
|
769
|
+
const cc = String(countryCode ?? '')
|
|
770
|
+
.trim()
|
|
771
|
+
.replace(/^\+/, '');
|
|
772
|
+
if (!cc) return num;
|
|
773
|
+
if (!num) return cc ? `+${cc}` : '';
|
|
774
|
+
return `+${cc}.${num}`;
|
|
775
|
+
}
|