@aoxborrow/registrar-client 0.5.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.
@@ -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
+ }