@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,870 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigField,
|
|
3
|
+
ConnectionResult,
|
|
4
|
+
Contact,
|
|
5
|
+
ContactSet,
|
|
6
|
+
DnsRecord,
|
|
7
|
+
Domain,
|
|
8
|
+
DomainAvailability,
|
|
9
|
+
DomainForward,
|
|
10
|
+
DomainForwardType,
|
|
11
|
+
EmailForward,
|
|
12
|
+
ListDomainsOptions,
|
|
13
|
+
OperationResult,
|
|
14
|
+
RegisterDomainInput,
|
|
15
|
+
RegistrarOptions,
|
|
16
|
+
RequestOptions,
|
|
17
|
+
TldPricing,
|
|
18
|
+
TransferDomainInput,
|
|
19
|
+
} from '../types';
|
|
20
|
+
import {
|
|
21
|
+
createDomain,
|
|
22
|
+
filterDomains,
|
|
23
|
+
normalizeDomain,
|
|
24
|
+
requireConsent,
|
|
25
|
+
settableForwards,
|
|
26
|
+
} from '../utils';
|
|
27
|
+
import { toRegistrarError } from '../errors';
|
|
28
|
+
import { ensureArray, parseXml } from '../xml';
|
|
29
|
+
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
30
|
+
import { Feature, type RegistrarFeature } from '../features';
|
|
31
|
+
import type { RegistrarCredentials } from '../types';
|
|
32
|
+
|
|
33
|
+
// shape of a Namecheap XML response (attributes prefixed with `@_`, text `#text`)
|
|
34
|
+
interface NcError {
|
|
35
|
+
'#text'?: string;
|
|
36
|
+
'@_Number'?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface NcDomainEl {
|
|
40
|
+
'@_Name'?: string;
|
|
41
|
+
'@_Created'?: string;
|
|
42
|
+
'@_Expires'?: string;
|
|
43
|
+
'@_AutoRenew'?: string;
|
|
44
|
+
'@_IsLocked'?: string;
|
|
45
|
+
'@_WhoisGuard'?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// namecheap.domains.getInfo result
|
|
49
|
+
interface NcGetInfoResult {
|
|
50
|
+
'@_Status'?: string;
|
|
51
|
+
'@_DomainName'?: string;
|
|
52
|
+
'DomainDetails'?: { CreatedDate?: string; ExpiredDate?: string };
|
|
53
|
+
'Whoisguard'?: { '@_Enabled'?: string; 'ID'?: string | number };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// namecheap.domains.check result element (attributes only)
|
|
57
|
+
interface NcCheckEl {
|
|
58
|
+
'@_Domain'?: string;
|
|
59
|
+
'@_Available'?: string;
|
|
60
|
+
'@_IsPremiumName'?: string;
|
|
61
|
+
'@_PremiumRegistrationPrice'?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// a contact group in namecheap.domains.getContacts (children are text elements)
|
|
65
|
+
interface NcContactEl {
|
|
66
|
+
OrganizationName?: string;
|
|
67
|
+
FirstName?: string;
|
|
68
|
+
LastName?: string;
|
|
69
|
+
Address1?: string;
|
|
70
|
+
Address2?: string;
|
|
71
|
+
City?: string;
|
|
72
|
+
StateProvince?: string;
|
|
73
|
+
PostalCode?: string;
|
|
74
|
+
Country?: string;
|
|
75
|
+
Phone?: string;
|
|
76
|
+
Fax?: string;
|
|
77
|
+
EmailAddress?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// a <Forward mailbox="alias">destination</Forward> in
|
|
81
|
+
// namecheap.domains.dns.getEmailForwarding
|
|
82
|
+
interface NcForwardEl {
|
|
83
|
+
'@_mailbox'?: string;
|
|
84
|
+
'#text'?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// a host record in namecheap.domains.dns.getHosts (attributes only)
|
|
88
|
+
interface NcHostEl {
|
|
89
|
+
'@_Name'?: string;
|
|
90
|
+
'@_Type'?: string;
|
|
91
|
+
'@_Address'?: string;
|
|
92
|
+
'@_MXPref'?: string;
|
|
93
|
+
'@_TTL'?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// namecheap.users.getPricing is ProductType > ProductCategory > Product > Price
|
|
97
|
+
interface NcPriceEl {
|
|
98
|
+
'@_Duration'?: string;
|
|
99
|
+
'@_DurationType'?: string;
|
|
100
|
+
'@_Price'?: string;
|
|
101
|
+
'@_Currency'?: string;
|
|
102
|
+
}
|
|
103
|
+
interface NcProductEl {
|
|
104
|
+
'@_Name'?: string;
|
|
105
|
+
'Price'?: NcPriceEl | NcPriceEl[];
|
|
106
|
+
}
|
|
107
|
+
interface NcProductCategoryEl {
|
|
108
|
+
'@_Name'?: string;
|
|
109
|
+
'Product'?: NcProductEl | NcProductEl[];
|
|
110
|
+
}
|
|
111
|
+
interface NcProductTypeEl {
|
|
112
|
+
'@_Name'?: string;
|
|
113
|
+
'ProductCategory'?: NcProductCategoryEl | NcProductCategoryEl[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface NcCommandResponse {
|
|
117
|
+
DomainGetListResult?: { Domain?: NcDomainEl | NcDomainEl[] };
|
|
118
|
+
DomainCheckResult?: NcCheckEl | NcCheckEl[];
|
|
119
|
+
DomainGetInfoResult?: NcGetInfoResult;
|
|
120
|
+
DomainDNSGetListResult?: {
|
|
121
|
+
'@_Domain'?: string;
|
|
122
|
+
'@_IsUsingOurDNS'?: string;
|
|
123
|
+
'Nameserver'?: string | string[];
|
|
124
|
+
};
|
|
125
|
+
// the docs show <Host>, but the live API has historically returned <host>
|
|
126
|
+
DomainDNSGetHostsResult?: { Host?: NcHostEl | NcHostEl[]; host?: NcHostEl | NcHostEl[] };
|
|
127
|
+
DomainDNSGetEmailForwardingResult?: {
|
|
128
|
+
'@_Domain'?: string;
|
|
129
|
+
'Forward'?: NcForwardEl | NcForwardEl[];
|
|
130
|
+
};
|
|
131
|
+
DomainContactsResult?: {
|
|
132
|
+
Registrant?: NcContactEl;
|
|
133
|
+
Admin?: NcContactEl;
|
|
134
|
+
Tech?: NcContactEl;
|
|
135
|
+
AuxBilling?: NcContactEl;
|
|
136
|
+
};
|
|
137
|
+
UserGetPricingResult?: { ProductType?: NcProductTypeEl | NcProductTypeEl[] };
|
|
138
|
+
// namecheap.domains.getRegistrarLock — the authoritative per-domain lock state
|
|
139
|
+
DomainGetRegistrarLockResult?: { '@_RegistrarLockStatus'?: string };
|
|
140
|
+
// namecheap.domains.setAutoRenew result (carries its own IsSuccess flag)
|
|
141
|
+
SetAutoRenewResult?: { '@_IsSuccess'?: string };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface NcResponse {
|
|
145
|
+
ApiResponse?: {
|
|
146
|
+
'@_Status'?: string;
|
|
147
|
+
'Errors'?: { Error?: NcError | NcError[] | string };
|
|
148
|
+
'CommandResponse'?: NcCommandResponse;
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Namecheap caps domains.check at 50 domains per request
|
|
153
|
+
const CHECK_BATCH_SIZE = 50;
|
|
154
|
+
// the four contact roles Namecheap requires on every setContacts call, mapped
|
|
155
|
+
// to our ContactSet keys (Namecheap calls the billing role "AuxBilling")
|
|
156
|
+
const NC_CONTACT_ROLES = [
|
|
157
|
+
{ prefix: 'Registrant', key: 'registrant' },
|
|
158
|
+
{ prefix: 'Admin', key: 'admin' },
|
|
159
|
+
{ prefix: 'Tech', key: 'tech' },
|
|
160
|
+
{ prefix: 'AuxBilling', key: 'billing' },
|
|
161
|
+
] as const;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Namecheap Registrar
|
|
165
|
+
* API docs: https://www.namecheap.com/support/api/intro/
|
|
166
|
+
*
|
|
167
|
+
* Credentials: enable API access under Profile > Tools > API Access, generate
|
|
168
|
+
* an API key, and whitelist the IP(s) requests will originate from (Namecheap
|
|
169
|
+
* enforces IP whitelisting). Supply that whitelisted IP as `clientIp`.
|
|
170
|
+
*
|
|
171
|
+
* Namecheap authenticates via query-string parameters (ApiUser, ApiKey,
|
|
172
|
+
* UserName, ClientIp) and responds with XML — including for errors, which come
|
|
173
|
+
* back as `<Errors>` nodes inside a 200 OK envelope, so success is read from the
|
|
174
|
+
* root `Status="OK"` attribute (`isOk`), not the HTTP status. XML is parsed via
|
|
175
|
+
* the shared `parseXml` helper (fast-xml-parser).
|
|
176
|
+
*
|
|
177
|
+
* `setAutoRenew` uses `namecheap.domains.setAutoRenew` (DomainName + IsAutoRenew).
|
|
178
|
+
* The command isn't in Namecheap's published method index but is live and keys on
|
|
179
|
+
* DomainName (an SLD/TLD form is rejected); it returns its own `IsSuccess` flag,
|
|
180
|
+
* which this reads rather than the envelope status.
|
|
181
|
+
*
|
|
182
|
+
* `registerDomain`/`transferIn` are implemented and require per-call `consent`;
|
|
183
|
+
* they spend real money and are documented-but-unverified. Registration sends the
|
|
184
|
+
* full four-role contact set but not yet per-TLD extended attributes (.us, .eu,
|
|
185
|
+
* …), so those TLDs aren't supported for registration.
|
|
186
|
+
*/
|
|
187
|
+
export class NamecheapRegistrar extends BaseRegistrar {
|
|
188
|
+
readonly name = 'namecheap';
|
|
189
|
+
|
|
190
|
+
static readonly displayName = 'Namecheap';
|
|
191
|
+
static readonly helpText =
|
|
192
|
+
'Enable API access in your Namecheap account under Profile > Tools > API Access, ' +
|
|
193
|
+
'generate an API key, and whitelist your request IP(s) in the API settings. ' +
|
|
194
|
+
'Provide the whitelisted IP as the Client IP field.';
|
|
195
|
+
static readonly configFields: ConfigField[] = [
|
|
196
|
+
{ name: 'username', label: 'Username', type: 'text', required: true },
|
|
197
|
+
{ name: 'apiKey', label: 'API Key', type: 'password', required: true },
|
|
198
|
+
{ name: 'clientIp', label: 'Client IP', type: 'text', required: false, default: '0.0.0.0' },
|
|
199
|
+
];
|
|
200
|
+
// Namecheap runs a sandbox at api.sandbox.namecheap.com (separate account at
|
|
201
|
+
// sandbox.namecheap.com with its own API key)
|
|
202
|
+
static readonly supportsSandbox = true;
|
|
203
|
+
// XML API. Beyond core, read+write alias-style email forwarding (via the
|
|
204
|
+
// dedicated dns.getEmailForwarding/setEmailForwarding commands) and URL/domain
|
|
205
|
+
// forwarding (URL/URL301/FRAME host records, read+written through the host set).
|
|
206
|
+
// No DNSSEC or webhooks; auth-code retrieval and glue records are dashboard-
|
|
207
|
+
// gated / unconfirmed, so they're left undeclared until verified live.
|
|
208
|
+
static readonly extendedFeatures: readonly RegistrarFeature[] = [
|
|
209
|
+
Feature.GetEmailForwarding,
|
|
210
|
+
Feature.SetEmailForwarding,
|
|
211
|
+
Feature.GetDomainForwarding,
|
|
212
|
+
Feature.SetDomainForwarding,
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
|
|
216
|
+
super(
|
|
217
|
+
credentials,
|
|
218
|
+
{
|
|
219
|
+
baseUrl: selectBaseUrl('Namecheap', options?.environment, {
|
|
220
|
+
production: 'https://api.namecheap.com/xml.response',
|
|
221
|
+
sandbox: 'https://api.sandbox.namecheap.com/xml.response',
|
|
222
|
+
}),
|
|
223
|
+
},
|
|
224
|
+
options
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// common query parameters (credentials + command) for every Namecheap call
|
|
229
|
+
private baseQuery(command: string, extra: Record<string, string> = {}): Record<string, string> {
|
|
230
|
+
return {
|
|
231
|
+
ApiUser: this.credentials.username,
|
|
232
|
+
ApiKey: this.credentials.apiKey,
|
|
233
|
+
UserName: this.credentials.username,
|
|
234
|
+
// Namecheap validates against whitelisted IPs configured in the account
|
|
235
|
+
ClientIp: this.credentials.clientIp || '0.0.0.0',
|
|
236
|
+
Command: command,
|
|
237
|
+
...extra,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// issue a command and parse the XML response
|
|
242
|
+
private async call(
|
|
243
|
+
command: string,
|
|
244
|
+
extra: Record<string, string>,
|
|
245
|
+
opts?: RequestOptions
|
|
246
|
+
): Promise<NcResponse> {
|
|
247
|
+
const xml = await this.http.requestText({
|
|
248
|
+
path: '',
|
|
249
|
+
query: this.baseQuery(command, extra),
|
|
250
|
+
...opts,
|
|
251
|
+
});
|
|
252
|
+
return parseXml<NcResponse>(xml);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// issue a command and return its CommandResponse, throwing on an error status
|
|
256
|
+
private async command(
|
|
257
|
+
command: string,
|
|
258
|
+
extra: Record<string, string>,
|
|
259
|
+
opts?: RequestOptions
|
|
260
|
+
): Promise<NcCommandResponse> {
|
|
261
|
+
const res = await this.call(command, extra, opts);
|
|
262
|
+
if (!isOk(res)) throw new Error(errorText(res) ?? 'Namecheap API request failed');
|
|
263
|
+
return res.ApiResponse?.CommandResponse ?? {};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
|
|
267
|
+
try {
|
|
268
|
+
const res = await this.call('namecheap.domains.getList', {}, opts);
|
|
269
|
+
return isOk(res)
|
|
270
|
+
? { success: true, message: 'Connection successful' }
|
|
271
|
+
: { success: false, message: errorText(res) ?? 'Unknown error' };
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
|
|
278
|
+
const { search, ...reqOpts } = opts ?? {};
|
|
279
|
+
const domains: Domain[] = [];
|
|
280
|
+
const perPage = 100; // Namecheap PageSize maximum (10..100)
|
|
281
|
+
// `SearchTerm` filters by name substring server-side.
|
|
282
|
+
const searchTerm = search?.trim();
|
|
283
|
+
let page = 1;
|
|
284
|
+
let hasMore = true;
|
|
285
|
+
|
|
286
|
+
while (hasMore) {
|
|
287
|
+
const cr = await this.command(
|
|
288
|
+
'namecheap.domains.getList',
|
|
289
|
+
{
|
|
290
|
+
PageSize: String(perPage),
|
|
291
|
+
Page: String(page),
|
|
292
|
+
...(searchTerm ? { SearchTerm: searchTerm } : {}),
|
|
293
|
+
},
|
|
294
|
+
reqOpts
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const elements = ensureArray(cr.DomainGetListResult?.Domain);
|
|
298
|
+
for (const d of elements) {
|
|
299
|
+
domains.push(
|
|
300
|
+
createDomain({
|
|
301
|
+
domainName: d['@_Name'],
|
|
302
|
+
registrar: this.name,
|
|
303
|
+
status: 'ok', // the list endpoint does not return a per-domain status
|
|
304
|
+
createdDate: d['@_Created'],
|
|
305
|
+
expirationDate: d['@_Expires'],
|
|
306
|
+
renewalDate: d['@_Expires'],
|
|
307
|
+
autoRenew: d['@_AutoRenew'] === 'true',
|
|
308
|
+
locked: d['@_IsLocked'] === 'true',
|
|
309
|
+
privacy: d['@_WhoisGuard'] === 'ENABLED',
|
|
310
|
+
nameservers: [], // the list endpoint does not return nameservers
|
|
311
|
+
})
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
hasMore = elements.length === perPage;
|
|
316
|
+
page++;
|
|
317
|
+
}
|
|
318
|
+
return filterDomains(domains, search);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* getInfo carries status, dates, and WhoisGuard state but not nameservers, so
|
|
323
|
+
* `nameservers` comes back empty here — use `getNameservers` (dns.getList) for
|
|
324
|
+
* those. The registrar transfer-lock flag isn't in getInfo either (its `Status`
|
|
325
|
+
* reflects the domain lifecycle, e.g. "Ok"/"Expired", not the transfer lock),
|
|
326
|
+
* so `locked` is read from the dedicated `getRegistrarLock` command, which is
|
|
327
|
+
* the authoritative real-time source (getList's per-row `IsLocked` can lag —
|
|
328
|
+
* confirmed stale in the sandbox after a lock the API had already applied).
|
|
329
|
+
*/
|
|
330
|
+
override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
|
|
331
|
+
const cr = await this.command('namecheap.domains.getInfo', { DomainName: domainName }, opts);
|
|
332
|
+
const info = cr.DomainGetInfoResult ?? {};
|
|
333
|
+
const status = info['@_Status'] ?? '';
|
|
334
|
+
return createDomain({
|
|
335
|
+
domainName: info['@_DomainName'] ?? domainName,
|
|
336
|
+
registrar: this.name,
|
|
337
|
+
status,
|
|
338
|
+
createdDate: info.DomainDetails?.CreatedDate,
|
|
339
|
+
expirationDate: info.DomainDetails?.ExpiredDate,
|
|
340
|
+
renewalDate: info.DomainDetails?.ExpiredDate,
|
|
341
|
+
locked: await this.getRegistrarLock(domainName, opts),
|
|
342
|
+
privacy: (info.Whoisguard?.['@_Enabled'] ?? '').toLowerCase() === 'true',
|
|
343
|
+
nameservers: [],
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Reads the registrar transfer-lock flag for a single domain via the dedicated
|
|
349
|
+
* `getRegistrarLock` command (`RegistrarLockStatus`). getInfo doesn't expose the
|
|
350
|
+
* lock, and getList's per-row `IsLocked` can lag the real state — verified live:
|
|
351
|
+
* after a lock the API reported as applied, getList still showed `IsLocked=false`
|
|
352
|
+
* while getRegistrarLock correctly returned `RegistrarLockStatus=true`.
|
|
353
|
+
*/
|
|
354
|
+
private async getRegistrarLock(domainName: string, opts?: RequestOptions): Promise<boolean> {
|
|
355
|
+
const cr = await this.command(
|
|
356
|
+
'namecheap.domains.getRegistrarLock',
|
|
357
|
+
{ DomainName: normalizeDomain(domainName) },
|
|
358
|
+
opts
|
|
359
|
+
);
|
|
360
|
+
return cr.DomainGetRegistrarLockResult?.['@_RegistrarLockStatus'] === 'true';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Availability via domains.check (batched at 50 per request, Namecheap's cap).
|
|
365
|
+
* The check response only carries a price for *premium* names
|
|
366
|
+
* (`PremiumRegistrationPrice`); regular registration pricing comes from
|
|
367
|
+
* `getPricing`, so `price` is set only for premium results.
|
|
368
|
+
*/
|
|
369
|
+
override async checkAvailability(
|
|
370
|
+
domainNames: string[],
|
|
371
|
+
opts?: RequestOptions
|
|
372
|
+
): Promise<DomainAvailability[]> {
|
|
373
|
+
const results: DomainAvailability[] = [];
|
|
374
|
+
for (let i = 0; i < domainNames.length; i += CHECK_BATCH_SIZE) {
|
|
375
|
+
const batch = domainNames.slice(i, i + CHECK_BATCH_SIZE);
|
|
376
|
+
const cr = await this.command(
|
|
377
|
+
'namecheap.domains.check',
|
|
378
|
+
{ DomainList: batch.join(',') },
|
|
379
|
+
opts
|
|
380
|
+
);
|
|
381
|
+
for (const el of ensureArray(cr.DomainCheckResult)) {
|
|
382
|
+
const premium = el['@_IsPremiumName'] === 'true';
|
|
383
|
+
const premiumPrice = premium ? Number(el['@_PremiumRegistrationPrice']) : NaN;
|
|
384
|
+
results.push({
|
|
385
|
+
domainName: el['@_Domain'] ?? '',
|
|
386
|
+
available: el['@_Available'] === 'true',
|
|
387
|
+
premium,
|
|
388
|
+
price: Number.isFinite(premiumPrice) && premiumPrice > 0 ? premiumPrice : undefined,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return results;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Per-TLD pricing via users.getPricing — unlike GoDaddy/Dynadot, Namecheap has
|
|
397
|
+
* a real price table, so a bare TLD works (a full domain is reduced to its
|
|
398
|
+
* TLD). Reads the 1-year register/renew/transfer prices.
|
|
399
|
+
*/
|
|
400
|
+
override async getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing> {
|
|
401
|
+
const tld = (
|
|
402
|
+
tldOrDomain.includes('.') ? tldOrDomain.slice(tldOrDomain.indexOf('.') + 1) : tldOrDomain
|
|
403
|
+
).toLowerCase();
|
|
404
|
+
|
|
405
|
+
const cr = await this.command(
|
|
406
|
+
'namecheap.users.getPricing',
|
|
407
|
+
{ ProductType: 'DOMAIN', ProductName: tld },
|
|
408
|
+
opts
|
|
409
|
+
);
|
|
410
|
+
const categories = ensureArray(
|
|
411
|
+
ensureArray(cr.UserGetPricingResult?.ProductType)[0]?.ProductCategory
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// find the 1-year Price for a given action category (REGISTER/RENEW/TRANSFER)
|
|
415
|
+
const findPrice = (action: string): NcPriceEl | undefined => {
|
|
416
|
+
const category = categories.find(c => c['@_Name']?.toUpperCase() === action);
|
|
417
|
+
const product = ensureArray(category?.Product).find(p => p['@_Name']?.toLowerCase() === tld);
|
|
418
|
+
const prices = ensureArray(product?.Price);
|
|
419
|
+
return prices.find(p => p['@_Duration'] === '1') ?? prices[0];
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const register = findPrice('REGISTER');
|
|
423
|
+
const renew = findPrice('RENEW');
|
|
424
|
+
const transfer = findPrice('TRANSFER');
|
|
425
|
+
return {
|
|
426
|
+
tld,
|
|
427
|
+
currency: register?.['@_Currency'] ?? renew?.['@_Currency'] ?? 'USD',
|
|
428
|
+
registration: toPrice(register),
|
|
429
|
+
renewal: toPrice(renew),
|
|
430
|
+
transfer: toPrice(transfer),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Registers a domain via domains.create, which requires all four contact roles
|
|
436
|
+
* (omitted roles fall back to the registrant) and toggles free WhoisGuard when
|
|
437
|
+
* privacy is requested. Note: TLDs that need per-TLD extended attributes (.us,
|
|
438
|
+
* .eu, .ca, …) are not yet supported — those params aren't sent.
|
|
439
|
+
*/
|
|
440
|
+
override async registerDomain(
|
|
441
|
+
domainName: string,
|
|
442
|
+
input: RegisterDomainInput,
|
|
443
|
+
opts?: RequestOptions
|
|
444
|
+
): Promise<OperationResult> {
|
|
445
|
+
requireConsent(this.name, input.consent);
|
|
446
|
+
const registrant = input.contacts.registrant;
|
|
447
|
+
if (!registrant) {
|
|
448
|
+
throw new Error(`${this.name}: registration requires at least a registrant contact`);
|
|
449
|
+
}
|
|
450
|
+
const params: Record<string, string> = {
|
|
451
|
+
DomainName: domainName,
|
|
452
|
+
Years: String(input.years ?? 1),
|
|
453
|
+
};
|
|
454
|
+
for (const { prefix, key } of NC_CONTACT_ROLES) {
|
|
455
|
+
Object.assign(params, toNcContactParams(prefix, input.contacts[key] ?? registrant));
|
|
456
|
+
}
|
|
457
|
+
if (input.nameservers?.length) params.Nameservers = input.nameservers.join(',');
|
|
458
|
+
if (input.privacy) {
|
|
459
|
+
params.AddFreeWhoisguard = 'yes';
|
|
460
|
+
params.WGEnabled = 'yes';
|
|
461
|
+
}
|
|
462
|
+
const res = await this.call('namecheap.domains.create', params, opts);
|
|
463
|
+
return statusResult(res);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Transfers a domain in via domains.transfer.create. Namecheap takes no
|
|
468
|
+
* contacts on transfer (the existing registrant's carry over); it needs the
|
|
469
|
+
* auth code as `EPPCode` and a `Years` of 1. Note: the privacy-enable param is
|
|
470
|
+
* `WGenable` here (lowercase), unlike `WGEnabled` on create.
|
|
471
|
+
*/
|
|
472
|
+
override async transferIn(
|
|
473
|
+
domainName: string,
|
|
474
|
+
input: TransferDomainInput,
|
|
475
|
+
opts?: RequestOptions
|
|
476
|
+
): Promise<OperationResult> {
|
|
477
|
+
requireConsent(this.name, input.consent);
|
|
478
|
+
const params: Record<string, string> = {
|
|
479
|
+
DomainName: domainName,
|
|
480
|
+
Years: String(input.years ?? 1),
|
|
481
|
+
EPPCode: input.authCode,
|
|
482
|
+
};
|
|
483
|
+
if (input.privacy != null) {
|
|
484
|
+
params.AddFreeWhoisguard = input.privacy ? 'yes' : 'no';
|
|
485
|
+
params.WGenable = input.privacy ? 'yes' : 'no';
|
|
486
|
+
}
|
|
487
|
+
const res = await this.call('namecheap.domains.transfer.create', params, opts);
|
|
488
|
+
return statusResult(res);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
override async renewDomain(
|
|
492
|
+
domainName: string,
|
|
493
|
+
years = 1,
|
|
494
|
+
opts?: RequestOptions
|
|
495
|
+
): Promise<OperationResult> {
|
|
496
|
+
const res = await this.call(
|
|
497
|
+
'namecheap.domains.renew',
|
|
498
|
+
{ DomainName: domainName, Years: String(years) },
|
|
499
|
+
opts
|
|
500
|
+
);
|
|
501
|
+
return statusResult(res);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Toggles auto-renew via `namecheap.domains.setAutoRenew`. The command carries
|
|
506
|
+
* its own `IsSuccess` flag inside an otherwise-OK envelope (a malformed request
|
|
507
|
+
* comes back `Status="OK"` with `IsSuccess="false"`), so success is read from
|
|
508
|
+
* that flag, not the envelope status.
|
|
509
|
+
*/
|
|
510
|
+
override async setAutoRenew(
|
|
511
|
+
domainName: string,
|
|
512
|
+
enabled: boolean,
|
|
513
|
+
opts?: RequestOptions
|
|
514
|
+
): Promise<OperationResult> {
|
|
515
|
+
const cr = await this.command(
|
|
516
|
+
'namecheap.domains.setAutoRenew',
|
|
517
|
+
{ DomainName: domainName, IsAutoRenew: enabled ? 'true' : 'false' },
|
|
518
|
+
opts
|
|
519
|
+
);
|
|
520
|
+
if (cr.SetAutoRenewResult?.['@_IsSuccess'] === 'true') {
|
|
521
|
+
return { success: true, message: 'OK' };
|
|
522
|
+
}
|
|
523
|
+
return { success: false, message: `Failed to ${enabled ? 'enable' : 'disable'} auto-renew` };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
|
|
527
|
+
const { sld, tld } = splitDomain(domainName);
|
|
528
|
+
const cr = await this.command('namecheap.domains.dns.getList', { SLD: sld, TLD: tld }, opts);
|
|
529
|
+
return ensureArray(cr.DomainDNSGetListResult?.Nameserver).map(String);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
override async updateNameservers(
|
|
533
|
+
domainName: string,
|
|
534
|
+
nameservers: string[],
|
|
535
|
+
opts?: RequestOptions
|
|
536
|
+
): Promise<OperationResult> {
|
|
537
|
+
if (nameservers.length < 2 || nameservers.length > 12) {
|
|
538
|
+
throw new Error('Namecheap requires 2-12 nameservers');
|
|
539
|
+
}
|
|
540
|
+
const { sld, tld } = splitDomain(domainName);
|
|
541
|
+
const res = await this.call(
|
|
542
|
+
'namecheap.domains.dns.setCustom',
|
|
543
|
+
{ SLD: sld, TLD: tld, Nameservers: nameservers.join(',') },
|
|
544
|
+
opts
|
|
545
|
+
);
|
|
546
|
+
return statusResult(res);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
override async lockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
550
|
+
const res = await this.call(
|
|
551
|
+
'namecheap.domains.setRegistrarLock',
|
|
552
|
+
{ DomainName: domainName, LockAction: 'LOCK' },
|
|
553
|
+
opts
|
|
554
|
+
);
|
|
555
|
+
return statusResult(res);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
override async unlockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
559
|
+
const res = await this.call(
|
|
560
|
+
'namecheap.domains.setRegistrarLock',
|
|
561
|
+
{ DomainName: domainName, LockAction: 'UNLOCK' },
|
|
562
|
+
opts
|
|
563
|
+
);
|
|
564
|
+
return statusResult(res);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Toggles WhoisGuard (Namecheap's domain privacy), which is a separate entity
|
|
569
|
+
* keyed by a numeric `WhoisguardID` (read from getInfo). Enabling additionally
|
|
570
|
+
* needs a `ForwardedToEmail` — the address WhoisGuard-masked mail is relayed to
|
|
571
|
+
* — which is taken from the domain's registrant contact. Throws if the domain
|
|
572
|
+
* has no WhoisGuard allotted (e.g. TLDs that don't offer it).
|
|
573
|
+
*/
|
|
574
|
+
override async setPrivacy(
|
|
575
|
+
domainName: string,
|
|
576
|
+
enabled: boolean,
|
|
577
|
+
opts?: RequestOptions
|
|
578
|
+
): Promise<OperationResult> {
|
|
579
|
+
const cr = await this.command('namecheap.domains.getInfo', { DomainName: domainName }, opts);
|
|
580
|
+
const id = cr.DomainGetInfoResult?.Whoisguard?.ID;
|
|
581
|
+
if (id == null || String(id) === '0') {
|
|
582
|
+
throw new Error(`${this.name}: ${domainName} has no WhoisGuard subscription to toggle`);
|
|
583
|
+
}
|
|
584
|
+
if (!enabled) {
|
|
585
|
+
const res = await this.call(
|
|
586
|
+
'namecheap.whoisguard.disable',
|
|
587
|
+
{ WhoisguardID: String(id) },
|
|
588
|
+
opts
|
|
589
|
+
);
|
|
590
|
+
return statusResult(res);
|
|
591
|
+
}
|
|
592
|
+
// enabling requires a forwarding address — use the registrant's email
|
|
593
|
+
const contacts = await this.getContacts(domainName, opts);
|
|
594
|
+
const email = contacts.registrant?.email || contacts.admin?.email;
|
|
595
|
+
if (!email) {
|
|
596
|
+
throw new Error(
|
|
597
|
+
`${this.name}: cannot enable WhoisGuard without a registrant email to forward to`
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
const res = await this.call(
|
|
601
|
+
'namecheap.whoisguard.enable',
|
|
602
|
+
{ WhoisguardID: String(id), ForwardedToEmail: email },
|
|
603
|
+
opts
|
|
604
|
+
);
|
|
605
|
+
return statusResult(res);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
override async getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet> {
|
|
609
|
+
const cr = await this.command(
|
|
610
|
+
'namecheap.domains.getContacts',
|
|
611
|
+
{ DomainName: domainName },
|
|
612
|
+
opts
|
|
613
|
+
);
|
|
614
|
+
const result = cr.DomainContactsResult ?? {};
|
|
615
|
+
return {
|
|
616
|
+
registrant: fromNcContact(result.Registrant),
|
|
617
|
+
admin: fromNcContact(result.Admin),
|
|
618
|
+
tech: fromNcContact(result.Tech),
|
|
619
|
+
billing: fromNcContact(result.AuxBilling),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Namecheap's setContacts requires all four roles (Registrant/Admin/Tech/
|
|
625
|
+
* AuxBilling) on every call, so any role the caller omits falls back to the
|
|
626
|
+
* registrant. A registrant is therefore mandatory.
|
|
627
|
+
*/
|
|
628
|
+
override async updateContacts(
|
|
629
|
+
domainName: string,
|
|
630
|
+
contacts: ContactSet,
|
|
631
|
+
opts?: RequestOptions
|
|
632
|
+
): Promise<OperationResult> {
|
|
633
|
+
const registrant = contacts.registrant;
|
|
634
|
+
if (!registrant) {
|
|
635
|
+
throw new Error('Namecheap updateContacts requires at least a registrant contact');
|
|
636
|
+
}
|
|
637
|
+
const params: Record<string, string> = { DomainName: domainName };
|
|
638
|
+
for (const { prefix, key } of NC_CONTACT_ROLES) {
|
|
639
|
+
Object.assign(params, toNcContactParams(prefix, contacts[key] ?? registrant));
|
|
640
|
+
}
|
|
641
|
+
const res = await this.call('namecheap.domains.setContacts', params, opts);
|
|
642
|
+
return statusResult(res);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
|
|
646
|
+
const { sld, tld } = splitDomain(domainName);
|
|
647
|
+
const cr = await this.command('namecheap.domains.dns.getHosts', { SLD: sld, TLD: tld }, opts);
|
|
648
|
+
const result = cr.DomainDNSGetHostsResult ?? {};
|
|
649
|
+
// the docs show <Host> but the live API has historically returned <host>
|
|
650
|
+
const hosts = ensureArray(result.Host ?? result.host);
|
|
651
|
+
return hosts.map(h => {
|
|
652
|
+
const type = (h['@_Type'] ?? '').toUpperCase();
|
|
653
|
+
const record: DnsRecord = {
|
|
654
|
+
type,
|
|
655
|
+
name: h['@_Name'] ?? '',
|
|
656
|
+
value: h['@_Address'] ?? '',
|
|
657
|
+
};
|
|
658
|
+
if (h['@_TTL'] != null) record.ttl = Number(h['@_TTL']);
|
|
659
|
+
if (type === 'MX' && h['@_MXPref'] != null) record.priority = Number(h['@_MXPref']);
|
|
660
|
+
return record;
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Replaces the entire record set via dns.setHosts (full-replace: any record
|
|
666
|
+
* not sent is removed). Host params are 1-indexed. `EmailType` is set to `MX`
|
|
667
|
+
* when MX records are present so mail routing follows the records; otherwise
|
|
668
|
+
* it's omitted to leave Namecheap's default.
|
|
669
|
+
*/
|
|
670
|
+
override async setDnsRecords(
|
|
671
|
+
domainName: string,
|
|
672
|
+
records: DnsRecord[],
|
|
673
|
+
opts?: RequestOptions
|
|
674
|
+
): Promise<OperationResult> {
|
|
675
|
+
const { sld, tld } = splitDomain(domainName);
|
|
676
|
+
const params: Record<string, string> = { SLD: sld, TLD: tld };
|
|
677
|
+
let hasMx = false;
|
|
678
|
+
|
|
679
|
+
records.forEach((r, idx) => {
|
|
680
|
+
const n = idx + 1; // Namecheap host params are 1-based
|
|
681
|
+
const type = r.type.toUpperCase();
|
|
682
|
+
params[`HostName${n}`] = r.name || '@';
|
|
683
|
+
params[`RecordType${n}`] = type;
|
|
684
|
+
params[`Address${n}`] = r.value;
|
|
685
|
+
if (type === 'MX') {
|
|
686
|
+
hasMx = true;
|
|
687
|
+
params[`MXPref${n}`] = String(r.priority ?? 10);
|
|
688
|
+
}
|
|
689
|
+
if (r.ttl != null) params[`TTL${n}`] = String(r.ttl);
|
|
690
|
+
});
|
|
691
|
+
if (hasMx) params.EmailType = 'MX';
|
|
692
|
+
|
|
693
|
+
const res = await this.call('namecheap.domains.dns.setHosts', params, opts);
|
|
694
|
+
return statusResult(res);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// --- extended: email forwarding ---
|
|
698
|
+
|
|
699
|
+
override async getEmailForwarding(
|
|
700
|
+
domainName: string,
|
|
701
|
+
opts?: RequestOptions
|
|
702
|
+
): Promise<EmailForward[]> {
|
|
703
|
+
const cr = await this.command(
|
|
704
|
+
'namecheap.domains.dns.getEmailForwarding',
|
|
705
|
+
{ DomainName: domainName },
|
|
706
|
+
opts
|
|
707
|
+
);
|
|
708
|
+
return ensureArray(cr.DomainDNSGetEmailForwardingResult?.Forward)
|
|
709
|
+
.map(f => ({
|
|
710
|
+
alias: (f['@_mailbox'] ?? '').toString(),
|
|
711
|
+
forwardTo: (f['#text'] ?? '').toString().trim(),
|
|
712
|
+
}))
|
|
713
|
+
.filter(f => f.alias && f.forwardTo);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Full-replace via dns.setEmailForwarding: mailbox/forward params are 1-indexed
|
|
718
|
+
* and any alias not sent is removed, so an empty `forwards` clears all
|
|
719
|
+
* forwarding. Requires the domain to use Namecheap's DNS.
|
|
720
|
+
*/
|
|
721
|
+
override async setEmailForwarding(
|
|
722
|
+
domainName: string,
|
|
723
|
+
forwards: EmailForward[],
|
|
724
|
+
opts?: RequestOptions
|
|
725
|
+
): Promise<OperationResult> {
|
|
726
|
+
const params: Record<string, string> = { DomainName: domainName };
|
|
727
|
+
forwards.forEach((f, idx) => {
|
|
728
|
+
const n = idx + 1; // Namecheap forwarding params are 1-based
|
|
729
|
+
params[`MailBox${n}`] = f.alias;
|
|
730
|
+
params[`ForwardTo${n}`] = f.forwardTo;
|
|
731
|
+
});
|
|
732
|
+
const res = await this.call('namecheap.domains.dns.setEmailForwarding', params, opts);
|
|
733
|
+
return statusResult(res);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// --- extended: domain (URL) forwarding ---
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Namecheap has no dedicated domain-forwarding endpoint; URL forwarding lives in
|
|
740
|
+
* the host-record set as URL/URL301/FRAME records, so this reads the hosts and
|
|
741
|
+
* returns just the forwarding ones.
|
|
742
|
+
*/
|
|
743
|
+
override async getDomainForwarding(
|
|
744
|
+
domainName: string,
|
|
745
|
+
opts?: RequestOptions
|
|
746
|
+
): Promise<DomainForward[]> {
|
|
747
|
+
const records = await this.getDnsRecords(domainName, opts);
|
|
748
|
+
return records
|
|
749
|
+
.filter(r => isUrlRecordType(r.type))
|
|
750
|
+
.map(r => ({
|
|
751
|
+
host: r.name || '@',
|
|
752
|
+
url: r.value,
|
|
753
|
+
type: NC_URL_TYPE_TO_FORWARD[r.type.toUpperCase()],
|
|
754
|
+
}));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Because URL forwarding is stored in the host set and dns.setHosts is a full
|
|
759
|
+
* replace, this preserves every non-forwarding record and swaps in the new
|
|
760
|
+
* URL-family records — so an empty `forwards` clears URL forwarding while
|
|
761
|
+
* leaving the rest of the zone intact.
|
|
762
|
+
*/
|
|
763
|
+
override async setDomainForwarding(
|
|
764
|
+
domainName: string,
|
|
765
|
+
forwards: DomainForward[],
|
|
766
|
+
opts?: RequestOptions
|
|
767
|
+
): Promise<OperationResult> {
|
|
768
|
+
const settable = settableForwards(forwards); // reject masked before any write
|
|
769
|
+
const existing = await this.getDnsRecords(domainName, opts);
|
|
770
|
+
const kept = existing.filter(r => !isUrlRecordType(r.type));
|
|
771
|
+
const urlRecords: DnsRecord[] = settable.map(f => ({
|
|
772
|
+
type: NC_FORWARD_TO_URL_TYPE[f.type],
|
|
773
|
+
name: f.host || '@',
|
|
774
|
+
value: f.url,
|
|
775
|
+
}));
|
|
776
|
+
return this.setDnsRecords(domainName, [...kept, ...urlRecords], opts);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Namecheap URL host-record types <-> the normalized DomainForward.type
|
|
781
|
+
const NC_URL_TYPE_TO_FORWARD: Record<string, DomainForwardType> = {
|
|
782
|
+
URL: 'temporary',
|
|
783
|
+
URL301: 'permanent',
|
|
784
|
+
FRAME: 'masked', // read-only; setDomainForwarding rejects it
|
|
785
|
+
};
|
|
786
|
+
const NC_FORWARD_TO_URL_TYPE: Record<'temporary' | 'permanent', string> = {
|
|
787
|
+
temporary: 'URL',
|
|
788
|
+
permanent: 'URL301',
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
// whether a host-record type is one of Namecheap's URL-forwarding pseudo-records
|
|
792
|
+
function isUrlRecordType(type: string): boolean {
|
|
793
|
+
return type.toUpperCase() in NC_URL_TYPE_TO_FORWARD;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// whether the response's root Status attribute is "OK"
|
|
797
|
+
function isOk(res: NcResponse): boolean {
|
|
798
|
+
return res.ApiResponse?.['@_Status'] === 'OK';
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// extract the first error message from an error response, if present
|
|
802
|
+
function errorText(res: NcResponse): string | null {
|
|
803
|
+
const error = res.ApiResponse?.Errors?.Error;
|
|
804
|
+
if (!error) return null;
|
|
805
|
+
const first = ensureArray(error)[0];
|
|
806
|
+
if (typeof first === 'string') return first.trim() || null;
|
|
807
|
+
return first?.['#text']?.trim() ?? null;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// map a Namecheap response's root Status to an OperationResult
|
|
811
|
+
function statusResult(res: NcResponse): OperationResult {
|
|
812
|
+
if (isOk(res)) return { success: true, message: 'OK' };
|
|
813
|
+
return { success: false, message: errorText(res) ?? 'Unknown response' };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// split a domain into second-level label + TLD (the TLD may contain a dot, e.g.
|
|
817
|
+
// "co.uk"). Namecheap's DNS/nameserver commands take SLD and TLD separately.
|
|
818
|
+
function splitDomain(domain: string): { sld: string; tld: string } {
|
|
819
|
+
const dot = domain.indexOf('.');
|
|
820
|
+
return dot === -1
|
|
821
|
+
? { sld: domain, tld: '' }
|
|
822
|
+
: { sld: domain.slice(0, dot), tld: domain.slice(dot + 1) };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// parse a Namecheap price element's Price attribute into a number, or undefined
|
|
826
|
+
function toPrice(price: NcPriceEl | undefined): number | undefined {
|
|
827
|
+
if (!price?.['@_Price']) return undefined;
|
|
828
|
+
const n = Number(price['@_Price']);
|
|
829
|
+
return Number.isFinite(n) ? n : undefined;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// --- contact mapping between Namecheap's shape and the normalized Contact ---
|
|
833
|
+
|
|
834
|
+
function fromNcContact(c: NcContactEl | undefined): Contact | undefined {
|
|
835
|
+
if (!c) return undefined;
|
|
836
|
+
const text = (v: string | undefined): string => (v ?? '').toString();
|
|
837
|
+
return {
|
|
838
|
+
firstName: text(c.FirstName),
|
|
839
|
+
lastName: text(c.LastName),
|
|
840
|
+
organization: c.OrganizationName ? String(c.OrganizationName) : undefined,
|
|
841
|
+
email: text(c.EmailAddress),
|
|
842
|
+
phone: text(c.Phone),
|
|
843
|
+
fax: c.Fax ? String(c.Fax) : undefined,
|
|
844
|
+
address1: text(c.Address1),
|
|
845
|
+
address2: c.Address2 ? String(c.Address2) : undefined,
|
|
846
|
+
city: text(c.City),
|
|
847
|
+
state: text(c.StateProvince),
|
|
848
|
+
postalCode: text(c.PostalCode),
|
|
849
|
+
country: text(c.Country),
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// build the prefixed request params (e.g. RegistrantFirstName) for one role
|
|
854
|
+
function toNcContactParams(prefix: string, c: Contact): Record<string, string> {
|
|
855
|
+
const params: Record<string, string> = {
|
|
856
|
+
[`${prefix}FirstName`]: c.firstName,
|
|
857
|
+
[`${prefix}LastName`]: c.lastName,
|
|
858
|
+
[`${prefix}Address1`]: c.address1,
|
|
859
|
+
[`${prefix}City`]: c.city,
|
|
860
|
+
[`${prefix}StateProvince`]: c.state ?? '',
|
|
861
|
+
[`${prefix}PostalCode`]: c.postalCode,
|
|
862
|
+
[`${prefix}Country`]: c.country,
|
|
863
|
+
[`${prefix}Phone`]: c.phone,
|
|
864
|
+
[`${prefix}EmailAddress`]: c.email,
|
|
865
|
+
};
|
|
866
|
+
if (c.organization) params[`${prefix}OrganizationName`] = c.organization;
|
|
867
|
+
if (c.address2) params[`${prefix}Address2`] = c.address2;
|
|
868
|
+
if (c.fax) params[`${prefix}Fax`] = c.fax;
|
|
869
|
+
return params;
|
|
870
|
+
}
|