@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,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
+ }