@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/src/types.ts ADDED
@@ -0,0 +1,446 @@
1
+ // Shared, provider-agnostic types for the registrar client.
2
+
3
+ import type { RegistrarFeature } from './features';
4
+
5
+ // client-level request options
6
+ export interface RegistrarClientOptions {
7
+ // request timeout in milliseconds
8
+ timeout: number;
9
+ // number of retry attempts for retryable errors
10
+ retries: number;
11
+ // base delay for exponential backoff, in milliseconds
12
+ backoff: number;
13
+ // optional AbortSignal to cancel in-flight requests
14
+ signal?: AbortSignal;
15
+ }
16
+
17
+ // per-request options that can override client-level options
18
+ export type RequestOptions = Partial<RegistrarClientOptions>;
19
+
20
+ // options for `listDomains`. Extends `RequestOptions` so timeout/retries/signal
21
+ // still flow through a single argument. `listDomains` always returns the full
22
+ // account, paginating internally at each provider's maximum page size.
23
+ export interface ListDomainsOptions extends RequestOptions {
24
+ // case-insensitive substring to match against the domain name. Applied
25
+ // server-side where the API supports it (Namecheap `SearchTerm`, Gandi
26
+ // `fqdn`) and client-side otherwise.
27
+ search?: string;
28
+ // Fetch full per-domain detail after listing. Several providers' list
29
+ // endpoints return only a summary and omit fields like `nameservers`,
30
+ // `privacy`, or the transfer `locked` flag, which then read as their defaults
31
+ // (`[]` / `false`). With `detailed: true`, the client follows up with a
32
+ // `getDomain` call per domain (bounded concurrency) and merges the detail,
33
+ // so those fields are accurate. Costs one extra request per domain, so it is
34
+ // opt-in — leave it off for a fast summary listing. Honored by the
35
+ // `RegistrarClient` facade (and `listPortfolio` over facade sources).
36
+ detailed?: boolean;
37
+ }
38
+
39
+ // which API environment a provider targets. "sandbox" covers registrar test
40
+ // environments (GoDaddy calls theirs "OTE") so integration tests never touch
41
+ // real domains. Requesting "sandbox" on a provider without one throws.
42
+ export type RegistrarEnvironment = 'production' | 'sandbox';
43
+
44
+ // options passed when constructing a registrar provider
45
+ export interface RegistrarOptions extends Partial<RegistrarClientOptions> {
46
+ environment?: RegistrarEnvironment;
47
+ }
48
+
49
+ // result of a connection/credential test
50
+ export interface ConnectionResult {
51
+ success: boolean;
52
+ message: string;
53
+ }
54
+
55
+ // result of a mutating operation (renew, nameserver update, lock/unlock)
56
+ export interface OperationResult {
57
+ success: boolean;
58
+ message: string;
59
+ }
60
+
61
+ // a configuration field a registrar needs (used to build credential UIs generically)
62
+ export interface ConfigField {
63
+ // property name on the credentials object
64
+ name: string;
65
+ // human-readable label
66
+ label: string;
67
+ // input type
68
+ type: 'text' | 'password' | 'select';
69
+ // whether the field is required
70
+ required: boolean;
71
+ // allowed values for `select` fields
72
+ options?: string[];
73
+ // default value
74
+ default?: string;
75
+ }
76
+
77
+ // normalized, cross-registrar view of a domain
78
+ export interface Domain {
79
+ domainName: string;
80
+ // id of the registrar that owns this record, e.g. "cloudflare"
81
+ registrar: string;
82
+ // registry/registrar status, always lowercased
83
+ status: string;
84
+ createdDate: Date | null;
85
+ expirationDate: Date | null;
86
+ renewalDate: Date | null;
87
+ autoRenew: boolean;
88
+ locked: boolean;
89
+ privacy: boolean;
90
+ nameservers: string[];
91
+ // when this record was fetched
92
+ syncedAt: Date;
93
+ // whether the domain has been removed at the registrar
94
+ deleted: boolean;
95
+ }
96
+
97
+ // loose input accepted by `createDomain`; providers pass raw-ish API values
98
+ export interface DomainInput {
99
+ domainName?: string;
100
+ registrar?: string;
101
+ status?: string;
102
+ createdDate?: Date | string | number | null;
103
+ expirationDate?: Date | string | number | null;
104
+ renewalDate?: Date | string | number | null;
105
+ autoRenew?: boolean;
106
+ locked?: boolean;
107
+ privacy?: boolean;
108
+ // accepts string[], objects with a `hosts` array, or objects with ServerName
109
+ nameservers?: unknown;
110
+ syncedAt?: Date;
111
+ deleted?: boolean;
112
+ }
113
+
114
+ // A normalized registrant/admin/tech/billing contact, mapped to and from each
115
+ // registrar's own contact shape.
116
+ export interface Contact {
117
+ firstName: string;
118
+ lastName: string;
119
+ organization?: string;
120
+ email: string;
121
+ // international phone format, e.g. "+1.4805551234"
122
+ phone: string;
123
+ fax?: string;
124
+ address1: string;
125
+ address2?: string;
126
+ city: string;
127
+ // state / province / region; may be empty where the country has none
128
+ state?: string;
129
+ postalCode: string;
130
+ // ISO 3166-1 alpha-2 country code, e.g. "US"
131
+ country: string;
132
+ }
133
+
134
+ // The set of contacts on a domain. `registrant` is the legal owner; most
135
+ // registrars also track admin, tech, and billing roles (often the same person).
136
+ export interface ContactSet {
137
+ registrant?: Contact;
138
+ admin?: Contact;
139
+ tech?: Contact;
140
+ billing?: Contact;
141
+ }
142
+
143
+ // A single DNS record in provider-agnostic form.
144
+ export interface DnsRecord {
145
+ // record type, uppercased: "A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", …
146
+ type: string;
147
+ // host/subdomain relative to the zone apex; "@" denotes the apex
148
+ name: string;
149
+ // record data / value (target host, IP address, text, etc.)
150
+ value: string;
151
+ // time-to-live in seconds
152
+ ttl?: number;
153
+ // priority, for MX and SRV records
154
+ priority?: number;
155
+ // SRV-only fields
156
+ weight?: number;
157
+ port?: number;
158
+ }
159
+
160
+ // Result of an availability check for a single domain.
161
+ export interface DomainAvailability {
162
+ domainName: string;
163
+ available: boolean;
164
+ // whether the name is a premium/aftermarket registration
165
+ premium?: boolean;
166
+ // registration price in major currency units (e.g. 11.99), when the API reports it
167
+ price?: number;
168
+ // ISO 4217 currency code, e.g. "USD"
169
+ currency?: string;
170
+ // the registration period the price covers, in years
171
+ period?: number;
172
+ }
173
+
174
+ // Pricing for a TLD (or a specific domain), in major currency units. Fields are
175
+ // optional because not every registrar exposes all three price points.
176
+ export interface TldPricing {
177
+ // the TLD the prices apply to, without a leading dot, e.g. "com"
178
+ tld: string;
179
+ // ISO 4217 currency code, e.g. "USD"
180
+ currency: string;
181
+ registration?: number;
182
+ renewal?: number;
183
+ transfer?: number;
184
+ }
185
+
186
+ // Consent to a registrar's registration agreements, supplied per registration.
187
+ // Registering a domain forms a legal contract with the registry, so providers
188
+ // that require it reject `registerDomain` without this. The provider fetches the
189
+ // specific per-TLD agreement documents itself; the caller only affirms who is
190
+ // consenting (and optionally when).
191
+ export interface RegistrationConsent {
192
+ // identifier of the consenting party. Registrars that record an "agreed by"
193
+ // value expect the consenting user's IP address here (e.g. GoDaddy).
194
+ agreedBy: string;
195
+ // ISO 8601 timestamp of when consent was given; defaults to the current time.
196
+ agreedAt?: string;
197
+ }
198
+
199
+ // Input for transferring a domain in. `authCode` (the EPP/auth code from the
200
+ // losing registrar) is always required; the rest mirror registration and are
201
+ // used by the registrars that need them (GoDaddy/Spaceship want contacts +
202
+ // consent, Namecheap/Dynadot rely on account defaults and just need the code).
203
+ export interface TransferDomainInput {
204
+ // the EPP / authorization code obtained from the current registrar
205
+ authCode: string;
206
+ // registration length to add as part of the transfer, in years
207
+ years?: number;
208
+ // contacts for the transferred domain, where the registrar requires them
209
+ contacts?: ContactSet;
210
+ // consent to the registrar's transfer agreements, where required (e.g. GoDaddy)
211
+ consent?: RegistrationConsent;
212
+ // enable WHOIS privacy on transfer, where supported
213
+ privacy?: boolean;
214
+ // enable auto-renew on transfer
215
+ autoRenew?: boolean;
216
+ }
217
+
218
+ // Input for registering a new domain.
219
+ export interface RegisterDomainInput {
220
+ // registration length in years (defaults to 1)
221
+ years?: number;
222
+ // contacts for the registration; every registrar requires at least a registrant
223
+ contacts: ContactSet;
224
+ // initial nameservers; omitting them uses the registrar's defaults
225
+ nameservers?: string[];
226
+ // enable WHOIS privacy at registration, where supported
227
+ privacy?: boolean;
228
+ // enable auto-renew at registration
229
+ autoRenew?: boolean;
230
+ // consent to the registrar's registration agreements; required by providers
231
+ // that gate registration behind an explicit legal agreement (e.g. GoDaddy).
232
+ // Omitting it where required throws `ConsentRequiredError`.
233
+ consent?: RegistrationConsent;
234
+ }
235
+
236
+ // credentials for a registrar, as a flat string map keyed by `ConfigField.name`
237
+ export type RegistrarCredentials = Record<string, string>;
238
+
239
+ // --- extended-feature payloads ---
240
+
241
+ // An alias-style email forward: mail sent to `alias`@domain is redirected to
242
+ // `forwardTo`. This is redirect-only forwarding — no real mailbox is provisioned.
243
+ export interface EmailForward {
244
+ // the local part of the source address at the domain, e.g. "hello" for
245
+ // hello@example.com. Where the registrar supports it, "@" or "*" denotes a
246
+ // catch-all.
247
+ alias: string;
248
+ // the destination address mail is forwarded to
249
+ forwardTo: string;
250
+ }
251
+
252
+ // The redirect style for a DomainForward:
253
+ // - "temporary" temporary (302) redirect; the destination URL shows in the bar
254
+ // - "permanent" permanent (301) redirect; the destination URL shows in the bar
255
+ // - "masked" masked/framed/"cloaked" redirect: an iframe keeps the source
256
+ // URL in the address bar. READ-ONLY — `getDomainForwarding` reports
257
+ // it for forwards a provider already has, but `setDomainForwarding`
258
+ // rejects it. Masking breaks HTTPS, SEO, and modern browser
259
+ // protections, and several registrars (e.g. Cloudflare) can't do
260
+ // it at all, so the library never creates one.
261
+ export type DomainForwardType = 'temporary' | 'permanent' | 'masked';
262
+
263
+ // A URL/domain forwarding rule: requests to `host` at the domain are redirected
264
+ // to `url`.
265
+ export interface DomainForward {
266
+ // the source host at the domain, relative to the apex; "@" denotes the apex,
267
+ // "www" the www subdomain, etc.
268
+ host: string;
269
+ // the destination URL requests are forwarded to
270
+ url: string;
271
+ // the redirect style
272
+ type: DomainForwardType;
273
+ }
274
+
275
+ // A DNSSEC delegation-signer (DS) record published at the parent zone. Fields
276
+ // mirror the DS RR (RFC 4034). Registrars that expose DNSSEC only as DNSKEY data
277
+ // may not populate every field; `digest` is the hex delegation digest.
278
+ export interface DsRecord {
279
+ // the key tag identifying the DNSKEY this DS refers to
280
+ keyTag: number;
281
+ // DNSSEC algorithm number (e.g. 8 = RSA/SHA-256, 13 = ECDSA P-256)
282
+ algorithm: number;
283
+ // digest type (1 = SHA-1, 2 = SHA-256, 4 = SHA-384)
284
+ digestType: number;
285
+ // the delegation digest, hex-encoded
286
+ digest: string;
287
+ }
288
+
289
+ // DNSSEC status for a domain. `enabled` is the headline signal (is DNSSEC turned
290
+ // on at the registrar); `dsRecords` carries the published DS records when the
291
+ // registrar exposes them (may be empty even when enabled).
292
+ export interface DnssecStatus {
293
+ enabled: boolean;
294
+ dsRecords: DsRecord[];
295
+ }
296
+
297
+ // The core interface every registrar implementation fulfils. `BaseRegistrar`
298
+ // (src/registrar.ts) provides the shared plumbing and `NotImplementedError`
299
+ // defaults; concrete providers under src/registrars/ extend it.
300
+ export interface Registrar {
301
+ // a short identifier for the provider, e.g. "cloudflare"
302
+ readonly name: string;
303
+
304
+ // the API environment this instance targets
305
+ readonly environment: RegistrarEnvironment;
306
+
307
+ // the capabilities this provider supports (core contract + its extended features)
308
+ readonly features: readonly RegistrarFeature[];
309
+
310
+ // whether this provider supports a given capability
311
+ supports(feature: RegistrarFeature): boolean;
312
+
313
+ // verify the configured credentials work
314
+ testConnection(opts?: RequestOptions): Promise<ConnectionResult>;
315
+
316
+ // list domains in the account (capped and optionally filtered via `opts`)
317
+ listDomains(opts?: ListDomainsOptions): Promise<Domain[]>;
318
+
319
+ // fetch a single domain's details
320
+ getDomain(domainName: string, opts?: RequestOptions): Promise<Domain>;
321
+
322
+ // check whether one or more domains are available to register
323
+ checkAvailability(domainNames: string[], opts?: RequestOptions): Promise<DomainAvailability[]>;
324
+
325
+ // look up pricing for a TLD (or a specific domain)
326
+ getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing>;
327
+
328
+ // register a new domain
329
+ registerDomain(
330
+ domainName: string,
331
+ input: RegisterDomainInput,
332
+ opts?: RequestOptions
333
+ ): Promise<OperationResult>;
334
+
335
+ // renew a domain for `years` years
336
+ renewDomain(domainName: string, years?: number, opts?: RequestOptions): Promise<OperationResult>;
337
+
338
+ // toggle auto-renew for a domain
339
+ setAutoRenew(
340
+ domainName: string,
341
+ enabled: boolean,
342
+ opts?: RequestOptions
343
+ ): Promise<OperationResult>;
344
+
345
+ // transfer a domain into the account using its auth/EPP code (+ any contacts /
346
+ // consent the registrar requires, via `TransferDomainInput`)
347
+ transferIn(
348
+ domainName: string,
349
+ input: TransferDomainInput,
350
+ opts?: RequestOptions
351
+ ): Promise<OperationResult>;
352
+
353
+ // replace the nameservers for a domain
354
+ updateNameservers(
355
+ domainName: string,
356
+ nameservers: string[],
357
+ opts?: RequestOptions
358
+ ): Promise<OperationResult>;
359
+
360
+ // read the nameservers currently set on a domain
361
+ getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]>;
362
+
363
+ // lock a domain against transfers
364
+ lockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult>;
365
+
366
+ // unlock a domain
367
+ unlockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult>;
368
+
369
+ // toggle WHOIS privacy for a domain
370
+ setPrivacy(domainName: string, enabled: boolean, opts?: RequestOptions): Promise<OperationResult>;
371
+
372
+ // read the registrant/admin/tech/billing contacts for a domain
373
+ getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet>;
374
+
375
+ // update the contacts for a domain
376
+ updateContacts(
377
+ domainName: string,
378
+ contacts: ContactSet,
379
+ opts?: RequestOptions
380
+ ): Promise<OperationResult>;
381
+
382
+ // read the DNS records for a domain
383
+ getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]>;
384
+
385
+ // replace the DNS records for a domain
386
+ setDnsRecords(
387
+ domainName: string,
388
+ records: DnsRecord[],
389
+ opts?: RequestOptions
390
+ ): Promise<OperationResult>;
391
+
392
+ // --- extended capabilities ---
393
+ //
394
+ // Opt-in operations beyond the core contract. A provider genuinely supports one
395
+ // only if it declares the matching Feature (see `features`/`supports`); every
396
+ // other provider inherits BaseRegistrar's default, which rejects with
397
+ // NotImplementedError. Callers should gate on `supports(...)` before use.
398
+
399
+ // read the alias-style email forwarding rules for a domain
400
+ getEmailForwarding(domainName: string, opts?: RequestOptions): Promise<EmailForward[]>;
401
+
402
+ // replace the alias-style email forwarding rules for a domain (full replace: any
403
+ // rule not included is removed; an empty array clears all forwarding)
404
+ setEmailForwarding(
405
+ domainName: string,
406
+ forwards: EmailForward[],
407
+ opts?: RequestOptions
408
+ ): Promise<OperationResult>;
409
+
410
+ // read the URL/domain forwarding rules for a domain
411
+ getDomainForwarding(domainName: string, opts?: RequestOptions): Promise<DomainForward[]>;
412
+
413
+ // replace the URL/domain forwarding rules for a domain (full replace: any rule
414
+ // not included is removed; an empty array clears all forwarding)
415
+ setDomainForwarding(
416
+ domainName: string,
417
+ forwards: DomainForward[],
418
+ opts?: RequestOptions
419
+ ): Promise<OperationResult>;
420
+
421
+ // retrieve the transfer authorization (EPP/auth) code used to transfer the
422
+ // domain away to another registrar
423
+ getAuthCode(domainName: string, opts?: RequestOptions): Promise<string>;
424
+
425
+ // read DNSSEC status for a domain (whether it's enabled, plus any DS records)
426
+ getDnssec(domainName: string, opts?: RequestOptions): Promise<DnssecStatus>;
427
+
428
+ // disable/turn off DNSSEC for a domain (removes the registrar's DS records at
429
+ // the parent). Enabling DNSSEC is intentionally not part of this interface.
430
+ disableDnssec(domainName: string, opts?: RequestOptions): Promise<OperationResult>;
431
+ }
432
+
433
+ // static side of a registrar class: the constructor plus discovery metadata
434
+ // (display name, the credential fields it needs, where to get them, and whether
435
+ // it offers a sandbox/test environment).
436
+ export interface RegistrarConstructor {
437
+ new (credentials: RegistrarCredentials, options?: RegistrarOptions): Registrar;
438
+ readonly displayName: string;
439
+ readonly configFields: ConfigField[];
440
+ readonly helpText: string;
441
+ readonly supportsSandbox: boolean;
442
+ // extended capabilities this provider opts into, beyond the core contract
443
+ readonly extendedFeatures: readonly RegistrarFeature[];
444
+ // the full capability set: the core contract plus `extendedFeatures`
445
+ readonly features: readonly RegistrarFeature[];
446
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,135 @@
1
+ import type { DomainForward, Domain, DomainInput, RegistrationConsent } from './types';
2
+ import { ConsentRequiredError } from './errors';
3
+
4
+ // sleep helper for retry backoff
5
+ export function sleep(ms: number): Promise<void> {
6
+ return new Promise(resolve => setTimeout(resolve, ms));
7
+ }
8
+
9
+ // a DomainForward that is safe to write: masked/framed forwarding is read-only.
10
+ export type SettableDomainForward = DomainForward & { type: 'temporary' | 'permanent' };
11
+
12
+ export const MASKED_FORWARD_UNSUPPORTED =
13
+ 'masked/framed forwarding is not supported by this library; use "temporary" or "permanent"';
14
+
15
+ // Guard for `setDomainForwarding`: reject any masked/framed forward up front (so
16
+ // no partial write happens) and narrow the list to the settable types. Providers
17
+ // call this before writing.
18
+ export function settableForwards(forwards: DomainForward[]): SettableDomainForward[] {
19
+ for (const f of forwards) {
20
+ if (f.type === 'masked') throw new Error(MASKED_FORWARD_UNSUPPORTED);
21
+ }
22
+ return forwards as SettableDomainForward[];
23
+ }
24
+
25
+ // guard for register/transfer: registration forms a legal contract with the
26
+ // registry, so a provider must be given explicit consent. Throws
27
+ // ConsentRequiredError (distinct from NotImplementedError) when it's absent.
28
+ export function requireConsent(
29
+ provider: string,
30
+ consent: RegistrationConsent | undefined
31
+ ): RegistrationConsent {
32
+ if (!consent) {
33
+ throw new ConsentRequiredError(
34
+ `${provider}: this operation requires consent — supply \`consent\` ` +
35
+ '(accepting the registrar’s registration agreements)'
36
+ );
37
+ }
38
+ return consent;
39
+ }
40
+
41
+ // filter a domain list by a case-insensitive substring of the domain name.
42
+ // Providers that filter server-side still call this — the re-filter is a
43
+ // harmless no-op there — so every provider applies `search` consistently.
44
+ export function filterDomains(domains: Domain[], search?: string): Domain[] {
45
+ const q = search?.trim().toLowerCase();
46
+ if (!q) return domains;
47
+ return domains.filter(d => d.domainName.toLowerCase().includes(q));
48
+ }
49
+
50
+ // normalize a domain name: trim, lowercase, strip a single trailing dot
51
+ export function normalizeDomain(domain: string): string {
52
+ return domain.trim().toLowerCase().replace(/\.$/, '');
53
+ }
54
+
55
+ // exponential backoff delay: backoff * 2^attempt
56
+ export function backoffDelay(backoff: number, attempt: number): number {
57
+ return backoff * Math.pow(2, attempt);
58
+ }
59
+
60
+ // parse a variety of date representations into a Date (or null)
61
+ // handles ISO strings, epoch millis (as number or numeric string), and Date
62
+ export function parseDate(value: Date | string | number | null | undefined): Date | null {
63
+ if (value == null || value === '') return null;
64
+ if (value instanceof Date) return isNaN(value.getTime()) ? null : value;
65
+
66
+ // numeric epoch millis (e.g. Dynadot returns ms timestamps)
67
+ if (typeof value === 'number') {
68
+ const d = new Date(value);
69
+ return isNaN(d.getTime()) ? null : d;
70
+ }
71
+
72
+ // numeric string that looks like an epoch-millis timestamp
73
+ const asNumber = Number(value);
74
+ if (!isNaN(asNumber) && asNumber > 1_000_000_000_000) {
75
+ const d = new Date(asNumber);
76
+ return isNaN(d.getTime()) ? null : d;
77
+ }
78
+
79
+ const d = new Date(value);
80
+ return isNaN(d.getTime()) ? null : d;
81
+ }
82
+
83
+ // normalize nameservers into a flat array of hostname strings.
84
+ // accepts: string[], { hosts: string[] } (Spaceship), or objects with a
85
+ // ServerName property (Dynadot), falling back to String() for anything else.
86
+ // Hostnames are trimmed and lowercased (they're case-insensitive, but providers
87
+ // return mixed case), and blanks are dropped.
88
+ export function normalizeNameservers(nameservers: unknown): string[] {
89
+ if (!nameservers) return [];
90
+
91
+ let raw: unknown[];
92
+
93
+ // object with a `hosts` array (Spaceship shape)
94
+ if (
95
+ typeof nameservers === 'object' &&
96
+ !Array.isArray(nameservers) &&
97
+ Array.isArray((nameservers as { hosts?: unknown }).hosts)
98
+ ) {
99
+ raw = (nameservers as { hosts: unknown[] }).hosts;
100
+ } else if (Array.isArray(nameservers)) {
101
+ raw = nameservers;
102
+ } else {
103
+ return [];
104
+ }
105
+
106
+ return raw
107
+ .map(ns => {
108
+ if (typeof ns === 'string') return ns;
109
+ if (ns && typeof ns === 'object' && 'ServerName' in ns) {
110
+ return String(ns.ServerName);
111
+ }
112
+ return String(ns);
113
+ })
114
+ .map(host => host.trim().toLowerCase())
115
+ .filter(Boolean);
116
+ }
117
+
118
+ // build a normalized Domain from loose provider input:
119
+ // lowercases status, normalizes nameservers, and coerces dates.
120
+ export function createDomain(data: DomainInput = {}): Domain {
121
+ return {
122
+ domainName: data.domainName ?? '',
123
+ registrar: data.registrar ?? '',
124
+ status: (data.status ?? '').toLowerCase(),
125
+ createdDate: parseDate(data.createdDate),
126
+ expirationDate: parseDate(data.expirationDate),
127
+ renewalDate: parseDate(data.renewalDate),
128
+ autoRenew: data.autoRenew ?? false,
129
+ locked: data.locked ?? false,
130
+ privacy: data.privacy ?? false,
131
+ nameservers: normalizeNameservers(data.nameservers),
132
+ syncedAt: data.syncedAt ?? new Date(),
133
+ deleted: data.deleted ?? false,
134
+ };
135
+ }
package/src/xml.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { XMLParser, type X2jOptions } from 'fast-xml-parser';
2
+ import { ParsingError } from './errors';
3
+
4
+ // Shared XML parsing for registrar APIs that speak XML (e.g. Namecheap, Enom).
5
+ // fast-xml-parser is pure JS with no Node built-ins, so this stays safe in
6
+ // browsers, Cloudflare Workers, Deno, Bun, and Node.
7
+
8
+ // default options applied to all registrar XML responses:
9
+ // - attributes are kept (prefixed with `@_`)
10
+ // - values are left as raw strings; providers coerce them explicitly, so a
11
+ // ZIP like "01234" or a flag like "true" never changes type unexpectedly
12
+ const DEFAULT_XML_OPTIONS: Partial<X2jOptions> = {
13
+ ignoreAttributes: false,
14
+ attributeNamePrefix: '@_',
15
+ textNodeName: '#text',
16
+ parseAttributeValue: false,
17
+ parseTagValue: false,
18
+ trimValues: true,
19
+ };
20
+
21
+ // parse an XML string into a plain object, throwing ParsingError on failure
22
+ export function parseXml<T = unknown>(xml: string, options?: Partial<X2jOptions>): T {
23
+ try {
24
+ const parser = new XMLParser({ ...DEFAULT_XML_OPTIONS, ...options });
25
+ return parser.parse(xml) as T;
26
+ } catch (error) {
27
+ throw new ParsingError(
28
+ `Failed to parse XML: ${error instanceof Error ? error.message : String(error)}`
29
+ );
30
+ }
31
+ }
32
+
33
+ // coerce a value that may be a single item or an array into an array.
34
+ // XML parsers collapse a single repeated element into one object; this
35
+ // normalizes both cases to an array.
36
+ export function ensureArray<T>(value: T | T[] | undefined | null): T[] {
37
+ if (value == null) return [];
38
+ return Array.isArray(value) ? value : [value];
39
+ }