@aoxborrow/registrar-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }