@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,997 @@
1
+ import type {
2
+ ConfigField,
3
+ ConnectionResult,
4
+ Contact,
5
+ ContactSet,
6
+ DnsRecord,
7
+ DnssecStatus,
8
+ Domain,
9
+ DomainAvailability,
10
+ DomainForward,
11
+ DomainForwardType,
12
+ EmailForward,
13
+ ListDomainsOptions,
14
+ OperationResult,
15
+ RegisterDomainInput,
16
+ RegistrarOptions,
17
+ RequestOptions,
18
+ TldPricing,
19
+ TransferDomainInput,
20
+ } from '../types';
21
+ import { createDomain, filterDomains, settableForwards } from '../utils';
22
+ import { NotFoundError, toRegistrarError } from '../errors';
23
+ import { ensureArray, parseXml } from '../xml';
24
+ import { BaseRegistrar, selectBaseUrl } from '../registrar';
25
+ import { Feature, type RegistrarFeature } from '../features';
26
+ import type { RegistrarCredentials } from '../types';
27
+
28
+ // NameSilo returns a `{ request, reply }` envelope for every operation. All
29
+ // calls return HTTP 200; success/failure is signalled by `reply.code` (300 = ok).
30
+ interface NsReply {
31
+ code?: number | string;
32
+ detail?: string;
33
+ domains?: unknown;
34
+ pager?: { page?: number; page_size?: number; total_count?: number };
35
+ // getDomainInfo fields (present only on that call's reply)
36
+ created?: string;
37
+ expires?: string;
38
+ status?: string;
39
+ locked?: string;
40
+ private?: string;
41
+ auto_renew?: string;
42
+ nameservers?: unknown;
43
+ // getDomainInfo also returns the per-role internal contact IDs
44
+ contact_ids?: NsContactIds;
45
+ // contactList returns full contact records (single object or array)
46
+ contact?: NsContact | NsContact[];
47
+ // contactAdd returns the new contact's id
48
+ contact_id?: string | number;
49
+ // dnsListRecords returns resource_record entries (single object or array)
50
+ resource_record?: NsResourceRecord | NsResourceRecord[];
51
+ // dnsSecListRecords returns ds_record entries (single object or array)
52
+ ds_record?: NsDsRecord | NsDsRecord[];
53
+ // listEmailForwards returns addresses entries (single object or array)
54
+ addresses?: NsEmailAddresses | NsEmailAddresses[];
55
+ // getDomainInfo also carries the apex URL-forwarding config
56
+ forward_url?: string;
57
+ forward_type?: string;
58
+ traffic_type?: string;
59
+ // checkRegisterAvailability groups results; each group wraps `domain` entries
60
+ available?: unknown;
61
+ unavailable?: unknown;
62
+ invalid?: unknown;
63
+ // getPrices returns one node per TLD keyed directly on the reply (e.g.
64
+ // reply.com = { registration, renew, transfer }); read via a Record cast
65
+ }
66
+
67
+ // one DS record from dnsSecListRecords (read fields are snake_case)
68
+ interface NsDsRecord {
69
+ digest?: string;
70
+ digest_type?: string | number;
71
+ algorithm?: string | number;
72
+ key_tag?: string | number;
73
+ }
74
+
75
+ // one mailbox from listEmailForwards: `email` is the local part, `forwards_to`
76
+ // is one address or an array of them
77
+ interface NsEmailAddresses {
78
+ email?: string;
79
+ forwards_to?: string | string[];
80
+ }
81
+
82
+ interface NsResponse {
83
+ reply?: NsReply;
84
+ }
85
+
86
+ // getDomainInfo.contact_ids: internal NameSilo IDs per role (strings)
87
+ interface NsContactIds {
88
+ registrant?: string | number;
89
+ administrative?: string | number;
90
+ technical?: string | number;
91
+ billing?: string | number;
92
+ }
93
+
94
+ // a full contact record from contactList (empty XML elements parse to '' or {})
95
+ interface NsContact {
96
+ contact_id?: string | number;
97
+ first_name?: unknown;
98
+ last_name?: unknown;
99
+ company?: unknown;
100
+ address?: unknown;
101
+ address2?: unknown;
102
+ city?: unknown;
103
+ state?: unknown;
104
+ zip?: unknown;
105
+ country?: unknown;
106
+ email?: unknown;
107
+ phone?: unknown;
108
+ fax?: unknown;
109
+ }
110
+
111
+ // a DNS record from dnsListRecords (distance carries the MX priority)
112
+ interface NsResourceRecord {
113
+ record_id?: string | number;
114
+ type?: unknown;
115
+ host?: unknown;
116
+ value?: unknown;
117
+ ttl?: unknown;
118
+ distance?: unknown;
119
+ }
120
+
121
+ // one TLD's price node from getPrices
122
+ interface NsPriceNode {
123
+ registration?: unknown;
124
+ renew?: unknown;
125
+ transfer?: unknown;
126
+ }
127
+
128
+ // a normalized availability entry parsed from a checkRegisterAvailability group
129
+ interface NsAvailEntry {
130
+ name: string;
131
+ price?: number;
132
+ premium?: boolean;
133
+ duration?: number;
134
+ }
135
+
136
+ // a domain entry from listDomains, which may arrive as a bare name or an object
137
+ interface NsDomainEntry {
138
+ domain?: string;
139
+ created?: string;
140
+ expires?: string;
141
+ }
142
+
143
+ /**
144
+ * NameSilo Registrar
145
+ * API docs: https://www.namesilo.com/api-reference
146
+ *
147
+ * Credentials: generate an API key in your NameSilo account under
148
+ * Account Options > API Manager. The key may optionally be restricted to up to
149
+ * five IP addresses there.
150
+ *
151
+ * Note: NameSilo uses a simple HTTP GET API — every call hits
152
+ * `/{operation}` with `version`, `type=json`, and `key` query parameters (the
153
+ * key travels in the URL, per their API design). Responses carry a numeric
154
+ * `reply.code` (300 = success) rather than using HTTP status codes.
155
+ */
156
+ export class NameSiloRegistrar extends BaseRegistrar {
157
+ readonly name = 'namesilo';
158
+
159
+ static readonly displayName = 'NameSilo';
160
+ static readonly helpText =
161
+ 'Generate an API key in your NameSilo account under Account Options > API Manager. ' +
162
+ 'You can optionally restrict the key to specific IP addresses there. For sandbox ' +
163
+ 'testing, use { environment: "sandbox" } (OTE host ote.namesilo.com) — OTE ' +
164
+ 'credentials are not self-service; you must contact NameSilo support to be issued them.';
165
+ static readonly configFields: ConfigField[] = [
166
+ { name: 'apiKey', label: 'API Key', type: 'password', required: true },
167
+ ];
168
+ // NameSilo runs an OTE/test environment at ote.namesilo.com; sandbox keys are
169
+ // issued by emailing NameSilo support (not self-service).
170
+ static readonly supportsSandbox = true;
171
+ // JSON API with broad coverage. Beyond core: DNSSEC read/disable, email
172
+ // forwarding, and domain (URL) forwarding. GetAuthCode is intentionally NOT
173
+ // declared — NameSilo's retrieveAuthCode emails the EPP code to the
174
+ // registrant and never returns it, so it can't satisfy the getAuthCode
175
+ // contract. No webhooks (polling only).
176
+ static readonly extendedFeatures: readonly RegistrarFeature[] = [
177
+ Feature.GetDnssec,
178
+ Feature.DisableDnssec,
179
+ Feature.GetEmailForwarding,
180
+ Feature.SetEmailForwarding,
181
+ Feature.GetDomainForwarding,
182
+ Feature.SetDomainForwarding,
183
+ ];
184
+
185
+ constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
186
+ super(
187
+ credentials,
188
+ {
189
+ baseUrl: selectBaseUrl('NameSilo', options?.environment, {
190
+ production: 'https://www.namesilo.com/api',
191
+ sandbox: 'https://ote.namesilo.com/api',
192
+ }),
193
+ },
194
+ options
195
+ );
196
+ }
197
+
198
+ // issue an operation with the standard auth params merged in
199
+ private call(
200
+ operation: string,
201
+ extra: Record<string, string | number> = {},
202
+ opts?: RequestOptions
203
+ ): Promise<NsResponse> {
204
+ return this.http.request<NsResponse>({
205
+ path: `/${operation}`,
206
+ query: { version: 1, type: 'json', key: this.credentials.apiKey, ...extra },
207
+ ...opts,
208
+ });
209
+ }
210
+
211
+ override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
212
+ try {
213
+ // getAccountBalance is the cheapest read-only call that validates the key
214
+ const res = await this.call('getAccountBalance', {}, opts);
215
+ return replyOk(res)
216
+ ? { success: true, message: 'Connection successful' }
217
+ : { success: false, message: replyDetail(res) };
218
+ } catch (error) {
219
+ return { success: false, message: toRegistrarError(error).message };
220
+ }
221
+ }
222
+
223
+ /**
224
+ * listDomains returns names + created/expires dates only. Nameservers, status,
225
+ * lock, privacy, and auto-renew are NOT in the list response and cannot be
226
+ * batched in — use `getDomain` per domain for those. `search` is applied
227
+ * client-side (NameSilo has no server-side name filter).
228
+ */
229
+ override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
230
+ const { search, ...reqOpts } = opts ?? {};
231
+ const domains: Domain[] = [];
232
+ const perPage = 100; // NameSilo page size
233
+ let page = 1;
234
+ let hasMore = true;
235
+
236
+ while (hasMore) {
237
+ const res = await this.call('listDomains', { page, pageSize: perPage }, reqOpts);
238
+ if (!replyOk(res)) {
239
+ throw new Error(replyDetail(res));
240
+ }
241
+
242
+ const entries = extractDomainEntries(res.reply?.domains);
243
+ for (const entry of entries) {
244
+ domains.push(
245
+ createDomain({
246
+ domainName: entry.domain,
247
+ registrar: this.name,
248
+ status: 'ok', // the list endpoint does not return a per-domain status
249
+ createdDate: entry.created,
250
+ expirationDate: entry.expires,
251
+ renewalDate: entry.expires,
252
+ nameservers: [], // not returned by listDomains (see getDomain)
253
+ })
254
+ );
255
+ }
256
+
257
+ hasMore = entries.length === perPage;
258
+ page++;
259
+ }
260
+ return filterDomains(domains, search);
261
+ }
262
+
263
+ /**
264
+ * getDomainInfo carries the details listDomains omits: status, lock, privacy,
265
+ * auto-renew, and nameservers. This is the only way to get NS for a NameSilo
266
+ * domain (one call per domain).
267
+ */
268
+ override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
269
+ const res = await this.call('getDomainInfo', { domain: domainName }, opts);
270
+ if (!replyOk(res)) {
271
+ throw new NotFoundError(`NameSilo: domain '${domainName}' not found (${replyDetail(res)})`);
272
+ }
273
+ const r = res.reply ?? {};
274
+ return createDomain({
275
+ domainName,
276
+ registrar: this.name,
277
+ status: r.status,
278
+ createdDate: r.created,
279
+ expirationDate: r.expires,
280
+ renewalDate: r.expires,
281
+ autoRenew: isYes(r.auto_renew),
282
+ locked: isYes(r.locked),
283
+ privacy: isYes(r.private),
284
+ nameservers: extractNsHosts(r.nameservers),
285
+ });
286
+ }
287
+
288
+ override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
289
+ const domain = await this.getDomain(domainName, opts);
290
+ return domain.nameservers;
291
+ }
292
+
293
+ /**
294
+ * NameSilo has no per-domain contact read: getDomainInfo returns only the
295
+ * internal contact IDs per role, so we resolve them against the full account
296
+ * contact profiles from `contactList` (one extra call), matching by ID.
297
+ */
298
+ override async getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet> {
299
+ const infoRes = await this.call('getDomainInfo', { domain: domainName }, opts);
300
+ if (!replyOk(infoRes)) {
301
+ throw new NotFoundError(
302
+ `NameSilo: domain '${domainName}' not found (${replyDetail(infoRes)})`
303
+ );
304
+ }
305
+ const ids = infoRes.reply?.contact_ids ?? {};
306
+
307
+ const listRes = await this.call('contactList', {}, opts);
308
+ if (!replyOk(listRes)) {
309
+ throw new Error(replyDetail(listRes));
310
+ }
311
+ const byId = new Map<string, NsContact>();
312
+ for (const c of ensureArray(listRes.reply?.contact)) {
313
+ byId.set(text(c.contact_id), c);
314
+ }
315
+
316
+ const lookup = (id: string | number | undefined): Contact | undefined => {
317
+ const key = text(id);
318
+ return key ? fromNsContact(byId.get(key)) : undefined;
319
+ };
320
+ return {
321
+ registrant: lookup(ids.registrant),
322
+ admin: lookup(ids.administrative),
323
+ tech: lookup(ids.technical),
324
+ billing: lookup(ids.billing),
325
+ };
326
+ }
327
+
328
+ override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
329
+ const res = await this.call('dnsListRecords', { domain: domainName }, opts);
330
+ if (!replyOk(res)) {
331
+ throw new Error(replyDetail(res));
332
+ }
333
+ return ensureArray(res.reply?.resource_record).map(rr => {
334
+ const type = text(rr.type).toUpperCase();
335
+ const record: DnsRecord = {
336
+ type,
337
+ name: text(rr.host),
338
+ value: text(rr.value),
339
+ };
340
+ const ttl = Number(rr.ttl);
341
+ if (Number.isFinite(ttl)) record.ttl = ttl;
342
+ // NameSilo carries the MX priority in `distance`
343
+ const distance = Number(rr.distance);
344
+ if (type === 'MX' && Number.isFinite(distance)) record.priority = distance;
345
+ return record;
346
+ });
347
+ }
348
+
349
+ /**
350
+ * Per-TLD pricing via getPrices, which returns every supported TLD keyed
351
+ * directly on the reply (e.g. reply.com = { registration, renew, transfer }).
352
+ * Prices are already in major USD units. A bare TLD works; a full domain is
353
+ * reduced to its TLD.
354
+ */
355
+ override async getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing> {
356
+ const tld = (
357
+ tldOrDomain.includes('.') ? tldOrDomain.slice(tldOrDomain.indexOf('.') + 1) : tldOrDomain
358
+ ).toLowerCase();
359
+
360
+ const res = await this.call('getPrices', {}, opts);
361
+ if (!replyOk(res)) {
362
+ throw new Error(replyDetail(res));
363
+ }
364
+ const reply = (res.reply ?? {}) as Record<string, unknown>;
365
+ const node = reply[tld];
366
+ if (!node || typeof node !== 'object') {
367
+ throw new NotFoundError(`NameSilo: no pricing found for TLD '${tld}'`);
368
+ }
369
+ const price = node as NsPriceNode;
370
+ return {
371
+ tld,
372
+ currency: 'USD',
373
+ registration: toPrice(price.registration),
374
+ renewal: toPrice(price.renew),
375
+ transfer: toPrice(price.transfer),
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Availability via checkRegisterAvailability (up to 200 domains per request).
381
+ * The reply splits names into `available`, `unavailable`, and `invalid`
382
+ * groups; available entries carry `price`/`premium`/`duration` attributes.
383
+ * Malformed names land in `invalid` and are omitted from the result.
384
+ */
385
+ override async checkAvailability(
386
+ domainNames: string[],
387
+ opts?: RequestOptions
388
+ ): Promise<DomainAvailability[]> {
389
+ const res = await this.call(
390
+ 'checkRegisterAvailability',
391
+ { domains: domainNames.join(',') },
392
+ opts
393
+ );
394
+ if (!replyOk(res)) {
395
+ throw new Error(replyDetail(res));
396
+ }
397
+ const reply = res.reply ?? {};
398
+ const results: DomainAvailability[] = [];
399
+
400
+ for (const entry of extractAvailEntries(reply.available)) {
401
+ const result: DomainAvailability = { domainName: entry.name, available: true };
402
+ if (entry.premium != null) result.premium = entry.premium;
403
+ if (entry.price != null) {
404
+ result.price = entry.price;
405
+ result.currency = 'USD';
406
+ }
407
+ if (entry.duration != null) result.period = entry.duration;
408
+ results.push(result);
409
+ }
410
+ for (const entry of extractAvailEntries(reply.unavailable)) {
411
+ results.push({ domainName: entry.name, available: false });
412
+ }
413
+ return results;
414
+ }
415
+
416
+ override async renewDomain(
417
+ domainName: string,
418
+ years = 1,
419
+ opts?: RequestOptions
420
+ ): Promise<OperationResult> {
421
+ const res = await this.call('renewDomain', { domain: domainName, years }, opts);
422
+ return statusResult(res);
423
+ }
424
+
425
+ override async updateNameservers(
426
+ domainName: string,
427
+ nameservers: string[],
428
+ opts?: RequestOptions
429
+ ): Promise<OperationResult> {
430
+ if (nameservers.length < 2 || nameservers.length > 13) {
431
+ throw new Error('NameSilo requires 2-13 nameservers');
432
+ }
433
+ const query: Record<string, string | number> = { domain: domainName };
434
+ nameservers.forEach((ns, i) => {
435
+ query[`ns${i + 1}`] = ns; // ns1..ns13
436
+ });
437
+ const res = await this.call('changeNameServers', query, opts);
438
+ return statusResult(res);
439
+ }
440
+
441
+ override async lockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
442
+ // 252 = "already locked" — treat as an idempotent success
443
+ const res = await this.call('domainLock', { domain: domainName }, opts);
444
+ return statusResult(res, [252]);
445
+ }
446
+
447
+ override async unlockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
448
+ // 253 = "already unlocked" — treat as an idempotent success
449
+ const res = await this.call('domainUnlock', { domain: domainName }, opts);
450
+ return statusResult(res, [253]);
451
+ }
452
+
453
+ // --- extended capabilities ---------------------------------------------
454
+
455
+ /**
456
+ * DNSSEC status via dnsSecListRecords, which returns zero or more `ds_record`
457
+ * entries (snake_case fields on read). A non-empty list means DNSSEC is on.
458
+ */
459
+ override async getDnssec(domainName: string, opts?: RequestOptions): Promise<DnssecStatus> {
460
+ const res = await this.call('dnsSecListRecords', { domain: domainName }, opts);
461
+ if (!replyOk(res)) throw new Error(replyDetail(res));
462
+ const list = ensureArray<NsDsRecord>(res.reply?.ds_record);
463
+ return {
464
+ enabled: list.length > 0,
465
+ dsRecords: list.map(d => ({
466
+ keyTag: Number(d.key_tag) || 0,
467
+ algorithm: Number(d.algorithm) || 0,
468
+ digestType: Number(d.digest_type) || 0,
469
+ digest: text(d.digest),
470
+ })),
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Disable DNSSEC by deleting every DS record (dnsSecDeleteRecord, once per
476
+ * record — NameSilo has no bulk off). The delete params are camelCase and echo
477
+ * the record's identifying fields.
478
+ */
479
+ override async disableDnssec(
480
+ domainName: string,
481
+ opts?: RequestOptions
482
+ ): Promise<OperationResult> {
483
+ try {
484
+ const res = await this.call('dnsSecListRecords', { domain: domainName }, opts);
485
+ if (!replyOk(res)) return statusResult(res);
486
+ for (const d of ensureArray<NsDsRecord>(res.reply?.ds_record)) {
487
+ const del = await this.call(
488
+ 'dnsSecDeleteRecord',
489
+ {
490
+ domain: domainName,
491
+ digest: text(d.digest),
492
+ keyTag: text(d.key_tag),
493
+ digestType: text(d.digest_type),
494
+ alg: text(d.algorithm),
495
+ },
496
+ opts
497
+ );
498
+ if (!replyOk(del)) return statusResult(del);
499
+ }
500
+ return { success: true, message: 'DNSSEC disabled successfully' };
501
+ } catch (error) {
502
+ return { success: false, message: toRegistrarError(error).message };
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Read the raw email-forward rows via listEmailForwards. NameSilo's `type=json`
508
+ * output for this one call is lossy — it drops the alias (the `<email>` tag) and
509
+ * returns only nested `forwards_to` arrays — so this call is fetched as XML and
510
+ * parsed. The XML `<email>` is the full address (alias@domain); return just the
511
+ * local part, plus its destinations.
512
+ */
513
+ private async listEmailForwards(
514
+ domainName: string,
515
+ opts?: RequestOptions
516
+ ): Promise<{ alias: string; forwardsTo: string[] }[]> {
517
+ const xml = await this.http.requestText({
518
+ path: '/listEmailForwards',
519
+ query: { version: 1, type: 'xml', key: this.credentials.apiKey, domain: domainName },
520
+ ...opts,
521
+ });
522
+ const reply = parseXml<{ namesilo?: NsResponse }>(xml)?.namesilo?.reply;
523
+ if (!reply || Number(reply.code) !== 300) {
524
+ throw new Error(reply?.detail ? text(reply.detail) : 'NameSilo: listEmailForwards failed');
525
+ }
526
+ const suffix = `@${domainName.toLowerCase()}`;
527
+ return ensureArray<NsEmailAddresses>(reply.addresses).map(a => {
528
+ const full = text(a.email);
529
+ const alias = full.toLowerCase().endsWith(suffix)
530
+ ? full.slice(0, full.length - suffix.length)
531
+ : full;
532
+ return { alias, forwardsTo: ensureArray<string>(a.forwards_to).map(text).filter(Boolean) };
533
+ });
534
+ }
535
+
536
+ /**
537
+ * Read email forwarding. Expands each alias's destinations into its own
538
+ * {alias, forwardTo} row.
539
+ */
540
+ override async getEmailForwarding(
541
+ domainName: string,
542
+ opts?: RequestOptions
543
+ ): Promise<EmailForward[]> {
544
+ const rows = await this.listEmailForwards(domainName, opts);
545
+ const out: EmailForward[] = [];
546
+ for (const { alias, forwardsTo } of rows) {
547
+ for (const dest of forwardsTo) {
548
+ if (alias && dest) out.push({ alias, forwardTo: dest });
549
+ }
550
+ }
551
+ return out;
552
+ }
553
+
554
+ /**
555
+ * Replace email forwarding (full replace; empty clears). NameSilo has no bulk
556
+ * endpoint, so diff by mailbox: configureEmailForward upserts one alias with up
557
+ * to 5 destinations (forward1..forward5); deleteEmailForward removes an alias.
558
+ */
559
+ override async setEmailForwarding(
560
+ domainName: string,
561
+ forwards: EmailForward[],
562
+ opts?: RequestOptions
563
+ ): Promise<OperationResult> {
564
+ try {
565
+ const desired = new Map<string, string[]>();
566
+ for (const f of forwards) {
567
+ const list = desired.get(f.alias) ?? [];
568
+ list.push(f.forwardTo);
569
+ desired.set(f.alias, list);
570
+ }
571
+ const currentAliases = new Set(
572
+ (await this.listEmailForwards(domainName, opts)).map(r => r.alias).filter(Boolean)
573
+ );
574
+
575
+ for (const alias of currentAliases) {
576
+ if (!desired.has(alias)) {
577
+ const del = await this.call(
578
+ 'deleteEmailForward',
579
+ { domain: domainName, email: alias },
580
+ opts
581
+ );
582
+ if (!replyOk(del)) return statusResult(del);
583
+ }
584
+ }
585
+ for (const [alias, destinations] of desired) {
586
+ const query: Record<string, string | number> = { domain: domainName, email: alias };
587
+ // NameSilo supports up to 5 destinations: forward1..forward5
588
+ destinations.slice(0, 5).forEach((dest, i) => {
589
+ query[`forward${i + 1}`] = dest;
590
+ });
591
+ const set = await this.call('configureEmailForward', query, opts);
592
+ if (!replyOk(set)) return statusResult(set);
593
+ }
594
+ return { success: true, message: 'Email forwarding updated successfully' };
595
+ } catch (error) {
596
+ return { success: false, message: toRegistrarError(error).message };
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Read the apex URL forwarding from getDomainInfo (`forward_url`/`forward_type`).
602
+ * NameSilo has no forwarding-list endpoint, so only the apex forward is
603
+ * readable — per-subdomain forwards can be set but not enumerated.
604
+ */
605
+ override async getDomainForwarding(
606
+ domainName: string,
607
+ opts?: RequestOptions
608
+ ): Promise<DomainForward[]> {
609
+ const res = await this.call('getDomainInfo', { domain: domainName }, opts);
610
+ if (!replyOk(res)) throw new Error(replyDetail(res));
611
+ // NameSilo keeps the last `forward_url` even after forwarding is switched off,
612
+ // so gate on `traffic_type` — only "Forwarded" means an active apex forward.
613
+ // (A never-forwarded domain also reports forward_url "N/A".)
614
+ if (text(res.reply?.traffic_type) !== 'Forwarded') return [];
615
+ const url = text(res.reply?.forward_url);
616
+ if (!url || url === 'N/A') return [];
617
+ return [{ host: '@', url, type: nsForwardType(res.reply?.forward_type) }];
618
+ }
619
+
620
+ /**
621
+ * Set apex URL forwarding via domainForward (method 301/302/cloaked). NameSilo
622
+ * can't list per-subdomain forwards, so this interface supports the apex forward
623
+ * only (host "@"); an empty list clears it by restoring NameSilo's default
624
+ * nameservers (there is no dedicated "stop forwarding" command).
625
+ */
626
+ override async setDomainForwarding(
627
+ domainName: string,
628
+ forwards: DomainForward[],
629
+ opts?: RequestOptions
630
+ ): Promise<OperationResult> {
631
+ if (forwards.length === 0) {
632
+ return this.updateNameservers(domainName, NAMESILO_DEFAULT_NS, opts);
633
+ }
634
+ if (forwards.length > 1) {
635
+ throw new Error('NameSilo supports a single apex ("@") forward via this interface');
636
+ }
637
+ const [f] = settableForwards(forwards); // reject masked before any write
638
+ if (f.host && f.host !== '@') {
639
+ throw new Error(
640
+ 'NameSilo per-subdomain forwards are not listable, so only the apex ("@") forward is supported'
641
+ );
642
+ }
643
+ const scheme = /^(https?):\/\/(.*)$/i.exec(f.url);
644
+ const protocol = scheme ? scheme[1].toLowerCase() : 'http';
645
+ const address = scheme ? scheme[2] : f.url;
646
+ const res = await this.call(
647
+ 'domainForward',
648
+ { domain: domainName, protocol, address, method: NS_FORWARD_METHOD[f.type] },
649
+ opts
650
+ );
651
+ return statusResult(res);
652
+ }
653
+
654
+ override async setAutoRenew(
655
+ domainName: string,
656
+ enabled: boolean,
657
+ opts?: RequestOptions
658
+ ): Promise<OperationResult> {
659
+ // 300 on change; 280 = "already set to that value" — idempotent success
660
+ const res = await this.call(
661
+ enabled ? 'addAutoRenewal' : 'removeAutoRenewal',
662
+ { domain: domainName },
663
+ opts
664
+ );
665
+ return statusResult(res, [280]);
666
+ }
667
+
668
+ override async setPrivacy(
669
+ domainName: string,
670
+ enabled: boolean,
671
+ opts?: RequestOptions
672
+ ): Promise<OperationResult> {
673
+ // 300 on change; 280 = "already in that state" — idempotent success
674
+ const res = await this.call(
675
+ enabled ? 'addPrivacy' : 'removePrivacy',
676
+ { domain: domainName },
677
+ opts
678
+ );
679
+ return statusResult(res, [280]);
680
+ }
681
+
682
+ /**
683
+ * Replaces the record set. NameSilo has no atomic "set all" endpoint — records
684
+ * are added/deleted individually by id — so this diffs against the current
685
+ * records: it adds records not already present and deletes those no longer in
686
+ * the desired set, leaving unchanged records (and their ids) untouched. The
687
+ * delegation nameservers aren't part of this list (see `updateNameservers`).
688
+ */
689
+ override async setDnsRecords(
690
+ domainName: string,
691
+ records: DnsRecord[],
692
+ opts?: RequestOptions
693
+ ): Promise<OperationResult> {
694
+ const listRes = await this.call('dnsListRecords', { domain: domainName }, opts);
695
+ if (!replyOk(listRes)) return { success: false, message: replyDetail(listRes) };
696
+ const existing = ensureArray(listRes.reply?.resource_record);
697
+
698
+ const existingByKey = new Map<string, string>(); // record key -> record_id
699
+ for (const rr of existing) {
700
+ const k = dnsKey(text(rr.type), text(rr.host), text(rr.value), Number(rr.distance));
701
+ existingByKey.set(k, text(rr.record_id));
702
+ }
703
+
704
+ // add desired records that aren't already present
705
+ const desiredKeys = new Set<string>();
706
+ for (const r of records) {
707
+ const type = r.type.toUpperCase();
708
+ const k = dnsKey(type, r.name || '@', r.value, r.priority);
709
+ desiredKeys.add(k);
710
+ if (existingByKey.has(k)) continue;
711
+ const query: Record<string, string | number> = {
712
+ domain: domainName,
713
+ rrtype: type,
714
+ rrhost: r.name === '@' ? '' : r.name, // NameSilo wants a bare host, empty at the apex
715
+ rrvalue: r.value,
716
+ rrttl: r.ttl ?? 7207, // NameSilo's default TTL
717
+ };
718
+ if (type === 'MX') query.rrdistance = r.priority ?? 10;
719
+ const addRes = await this.call('dnsAddRecord', query, opts);
720
+ if (!replyOk(addRes)) return { success: false, message: replyDetail(addRes) };
721
+ }
722
+
723
+ // delete existing records no longer in the desired set
724
+ for (const [k, rrid] of existingByKey) {
725
+ if (desiredKeys.has(k) || !rrid) continue;
726
+ const delRes = await this.call('dnsDeleteRecord', { domain: domainName, rrid }, opts);
727
+ if (!replyOk(delRes)) return { success: false, message: replyDetail(delRes) };
728
+ }
729
+ return { success: true, message: 'DNS records updated successfully' };
730
+ }
731
+
732
+ /**
733
+ * Updates contact roles. NameSilo contacts are account-level records referenced
734
+ * by id, so this creates a new contact profile per supplied role (`contactAdd`)
735
+ * and points the domain's roles at the new ids (`contactDomainAssociate`).
736
+ * Identical contacts across roles are created once. Roles not supplied keep
737
+ * their current association.
738
+ */
739
+ override async updateContacts(
740
+ domainName: string,
741
+ contacts: ContactSet,
742
+ opts?: RequestOptions
743
+ ): Promise<OperationResult> {
744
+ const roles: [keyof ContactSet, string][] = [
745
+ ['registrant', 'registrant'],
746
+ ['admin', 'administrative'],
747
+ ['tech', 'technical'],
748
+ ['billing', 'billing'],
749
+ ];
750
+ const association: Record<string, string | number> = { domain: domainName };
751
+ const idByContact = new Map<Contact, string>();
752
+ for (const [role, param] of roles) {
753
+ const contact = contacts[role];
754
+ if (!contact) continue;
755
+ let id = idByContact.get(contact);
756
+ if (!id) {
757
+ const addRes = await this.call('contactAdd', toNsContactParams(contact), opts);
758
+ if (!replyOk(addRes)) return { success: false, message: replyDetail(addRes) };
759
+ id = text(addRes.reply?.contact_id);
760
+ if (!id) return { success: false, message: 'NameSilo: contactAdd returned no contact_id' };
761
+ idByContact.set(contact, id);
762
+ }
763
+ association[param] = id;
764
+ }
765
+ if (Object.keys(association).length === 1) {
766
+ throw new Error('NameSilo: updateContacts requires at least one contact role');
767
+ }
768
+ const assocRes = await this.call('contactDomainAssociate', association, opts);
769
+ return statusResult(assocRes);
770
+ }
771
+
772
+ /**
773
+ * Registers a domain. NameSilo registers the domain against the account's
774
+ * default contact profile (use `updateContacts` afterwards to set specific
775
+ * contacts). `private`/`auto_renew` are honored, and initial nameservers are
776
+ * passed when supplied. Spends real money; not exercised against a live account.
777
+ */
778
+ override async registerDomain(
779
+ domainName: string,
780
+ input: RegisterDomainInput,
781
+ opts?: RequestOptions
782
+ ): Promise<OperationResult> {
783
+ const query: Record<string, string | number> = {
784
+ domain: domainName,
785
+ years: input.years ?? 1,
786
+ private: input.privacy ? 1 : 0,
787
+ auto_renew: input.autoRenew ? 1 : 0,
788
+ };
789
+ (input.nameservers ?? []).forEach((ns, i) => {
790
+ query[`ns${i + 1}`] = ns;
791
+ });
792
+ const res = await this.call('registerDomain', query, opts);
793
+ return statusResult(res);
794
+ }
795
+
796
+ /**
797
+ * Transfers a domain in with its EPP auth code. `private`/`auto_renew` are
798
+ * honored; contacts come from the account default. Spends real money; not
799
+ * exercised against a live account.
800
+ */
801
+ override async transferIn(
802
+ domainName: string,
803
+ input: TransferDomainInput,
804
+ opts?: RequestOptions
805
+ ): Promise<OperationResult> {
806
+ const query: Record<string, string | number> = {
807
+ domain: domainName,
808
+ auth: input.authCode,
809
+ private: input.privacy ? 1 : 0,
810
+ auto_renew: input.autoRenew ? 1 : 0,
811
+ };
812
+ const res = await this.call('transferDomain', query, opts);
813
+ return statusResult(res);
814
+ }
815
+ }
816
+
817
+ // safely stringify an unknown XML value: strings/numbers/booleans pass through
818
+ // (trimmed), everything else (e.g. an empty `{}` from a self-closing element)
819
+ // becomes ''. Guards against no-base-to-string on arbitrary objects.
820
+ function text(v: unknown): string {
821
+ if (typeof v === 'string') return v.trim();
822
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
823
+ return '';
824
+ }
825
+
826
+ // a stable identity key for a DNS record: type + relative host (apex as "@") +
827
+ // value, plus the MX distance (priority) which is part of an MX record's identity
828
+ function dnsKey(type: string, host: string, value: string, distance: number | undefined): string {
829
+ const t = type.toUpperCase();
830
+ const dist = t === 'MX' && Number.isFinite(distance) ? String(distance) : '';
831
+ return [t, host || '@', value, dist].join('\x00');
832
+ }
833
+
834
+ // map the normalized Contact to NameSilo's contactAdd parameters (fn/ln/ad/…)
835
+ function toNsContactParams(c: Contact): Record<string, string | number> {
836
+ const params: Record<string, string | number> = {
837
+ fn: c.firstName,
838
+ ln: c.lastName,
839
+ ad: c.address1,
840
+ cy: c.city,
841
+ st: c.state ?? '',
842
+ zp: c.postalCode,
843
+ ct: c.country,
844
+ em: c.email,
845
+ ph: c.phone,
846
+ };
847
+ if (c.organization) params.cp = c.organization;
848
+ if (c.address2) params.ad2 = c.address2;
849
+ if (c.fax) params.fx = c.fax;
850
+ return params;
851
+ }
852
+
853
+ // parse an unknown price-ish value into a positive number, or undefined
854
+ function toPrice(v: unknown): number | undefined {
855
+ if (typeof v !== 'string' && typeof v !== 'number') return undefined;
856
+ const n = Number(v);
857
+ return Number.isFinite(n) ? n : undefined;
858
+ }
859
+
860
+ // map a NameSilo contact record to the normalized Contact shape
861
+ function fromNsContact(c: NsContact | undefined): Contact | undefined {
862
+ if (!c) return undefined;
863
+ const opt = (v: unknown): string | undefined => {
864
+ const s = text(v);
865
+ return s ? s : undefined;
866
+ };
867
+ return {
868
+ firstName: text(c.first_name),
869
+ lastName: text(c.last_name),
870
+ organization: opt(c.company),
871
+ email: text(c.email),
872
+ phone: text(c.phone),
873
+ fax: opt(c.fax),
874
+ address1: text(c.address),
875
+ address2: opt(c.address2),
876
+ city: text(c.city),
877
+ state: text(c.state),
878
+ postalCode: text(c.zip),
879
+ country: text(c.country),
880
+ };
881
+ }
882
+
883
+ // normalize a checkRegisterAvailability group into availability entries. A group
884
+ // is `{ domain: <entry|entry[]> }`; each entry is a bare domain string, or (for
885
+ // available names) an object carrying the name plus price/premium/duration.
886
+ // NameSilo's JSON attribute naming isn't contractually documented, so read the
887
+ // bare, `@`, and `@_` forms defensively (mirroring extractNsHosts' #text guard).
888
+ function extractAvailEntries(group: unknown): NsAvailEntry[] {
889
+ if (!group) return [];
890
+ const raw =
891
+ typeof group === 'object' && !Array.isArray(group) && 'domain' in group
892
+ ? ((group as { domain?: unknown }).domain ?? [])
893
+ : group;
894
+ return ensureArray(raw)
895
+ .map((item): NsAvailEntry => {
896
+ if (typeof item === 'string') return { name: item.trim() };
897
+ if (item && typeof item === 'object') {
898
+ const o = item as Record<string, unknown>;
899
+ const name = text(o['#text'] ?? o.value ?? o.domain);
900
+ const priceRaw = o.price ?? o['@price'] ?? o['@_price'];
901
+ const premiumRaw = o.premium ?? o['@premium'] ?? o['@_premium'];
902
+ const durationRaw = o.duration ?? o['@duration'] ?? o['@_duration'];
903
+ const entry: NsAvailEntry = { name };
904
+ const price = toPrice(priceRaw);
905
+ if (price != null && price > 0) entry.price = price;
906
+ if (premiumRaw != null && text(premiumRaw) !== '') entry.premium = isYes(premiumRaw);
907
+ const duration = toPrice(durationRaw);
908
+ if (duration != null && duration > 0) entry.duration = duration;
909
+ return entry;
910
+ }
911
+ return { name: '' };
912
+ })
913
+ .filter(e => e.name);
914
+ }
915
+
916
+ // NameSilo booleans arrive as "Yes"/"No" (or 1/0) strings
917
+ function isYes(v: unknown): boolean {
918
+ if (typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') return false;
919
+ const s = String(v).trim().toLowerCase();
920
+ return s === 'yes' || s === '1' || s === 'true';
921
+ }
922
+
923
+ // NameSilo's DNS-hosting nameservers (DNSOWL). There is no dedicated "stop
924
+ // forwarding" command; re-pointing the domain at these flips its `traffic_type`
925
+ // from "Forwarded" back to "Custom DNS", which deactivates the apex forward.
926
+ // (The parking servers ns1/ns2.namesilo.com do NOT clear forwarding.)
927
+ const NAMESILO_DEFAULT_NS = ['ns1.dnsowl.com', 'ns2.dnsowl.com', 'ns3.dnsowl.com'];
928
+
929
+ // map our generic (settable) forward type to NameSilo's domainForward `method`
930
+ const NS_FORWARD_METHOD: Record<'temporary' | 'permanent', string> = {
931
+ permanent: '301',
932
+ temporary: '302',
933
+ };
934
+
935
+ // interpret NameSilo's read-side forward_type into our generic type. A masked/
936
+ // cloaked forward is reported as read-only `masked` (setDomainForwarding rejects it).
937
+ function nsForwardType(v: string | undefined): DomainForwardType {
938
+ const s = (v ?? '').toLowerCase();
939
+ if (s.includes('cloak') || s.includes('frame') || s.includes('mask')) return 'masked';
940
+ if (s.includes('302') || s.includes('temp')) return 'temporary';
941
+ return 'permanent';
942
+ }
943
+
944
+ // getDomainInfo returns nameservers as an array of `{ nameserver: "HOST",
945
+ // position: N }`. Some other endpoints wrap them as `{ nameserver: [...] }` or
946
+ // carry the host in `#text`, so all forms are handled defensively.
947
+ function extractNsHosts(ns: unknown): string[] {
948
+ if (!ns) return [];
949
+ const raw =
950
+ typeof ns === 'object' && !Array.isArray(ns) && 'nameserver' in ns
951
+ ? ((ns as { nameserver?: unknown }).nameserver ?? [])
952
+ : ns;
953
+ const list = Array.isArray(raw) ? raw : [raw];
954
+ return list
955
+ .map(item => {
956
+ if (typeof item === 'string') return item;
957
+ if (item && typeof item === 'object') {
958
+ const o = item as Record<string, unknown>;
959
+ if (typeof o.nameserver === 'string') return o.nameserver; // { nameserver: "HOST", position }
960
+ if (typeof o['#text'] === 'string') return o['#text'];
961
+ }
962
+ return '';
963
+ })
964
+ .filter(Boolean);
965
+ }
966
+
967
+ // whether the response's reply.code is in the success family (300)
968
+ function replyOk(res: NsResponse): boolean {
969
+ return Number(res.reply?.code) === 300;
970
+ }
971
+
972
+ // human-readable detail from a reply, for error messages
973
+ function replyDetail(res: NsResponse): string {
974
+ return res.reply?.detail?.trim() || 'Unknown response';
975
+ }
976
+
977
+ // map a reply to an OperationResult; extra codes are also treated as success
978
+ function statusResult(res: NsResponse, okCodes: number[] = []): OperationResult {
979
+ const code = Number(res.reply?.code);
980
+ if (code === 300 || okCodes.includes(code)) {
981
+ return { success: true, message: res.reply?.detail?.trim() || 'success' };
982
+ }
983
+ return { success: false, message: replyDetail(res) };
984
+ }
985
+
986
+ // listDomains may return domains as an array of names, an array of objects, or
987
+ // an object wrapping a `domain` array — normalize all shapes to entry objects
988
+ function extractDomainEntries(domains: unknown): NsDomainEntry[] {
989
+ if (!domains) return [];
990
+ const raw = Array.isArray(domains)
991
+ ? domains
992
+ : typeof domains === 'object' && 'domain' in domains
993
+ ? ((domains as { domain?: unknown }).domain ?? [])
994
+ : [];
995
+ const list = Array.isArray(raw) ? raw : [raw];
996
+ return list.map(item => (typeof item === 'string' ? { domain: item } : (item as NsDomainEntry)));
997
+ }