@aoxborrow/registrar-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/index.cjs +6798 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +768 -0
- package/dist/index.d.ts +768 -0
- package/dist/index.js +6747 -0
- package/dist/index.js.map +1 -0
- package/package.json +110 -0
- package/src/client.ts +200 -0
- package/src/constants.ts +11 -0
- package/src/errors.ts +192 -0
- package/src/features.ts +104 -0
- package/src/http.ts +230 -0
- package/src/index.ts +39 -0
- package/src/portfolio.ts +65 -0
- package/src/registrar.ts +240 -0
- package/src/registrars/cloudflare.ts +1205 -0
- package/src/registrars/dynadot.ts +1097 -0
- package/src/registrars/gandi.ts +970 -0
- package/src/registrars/godaddy.ts +1342 -0
- package/src/registrars/index.ts +41 -0
- package/src/registrars/namebright.ts +775 -0
- package/src/registrars/namecheap.ts +870 -0
- package/src/registrars/namesilo.ts +997 -0
- package/src/registrars/porkbun.ts +930 -0
- package/src/registrars/spaceship.ts +736 -0
- package/src/types.ts +446 -0
- package/src/utils.ts +135 -0
- package/src/xml.ts +39 -0
|
@@ -0,0 +1,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
|
+
}
|