@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Oxborrow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,415 @@
1
+ # registrar-client
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@aoxborrow/registrar-client.svg)](https://www.npmjs.com/package/@aoxborrow/registrar-client)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg)](https://nodejs.org)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org)
7
+
8
+ > One consistent, fully-typed TypeScript interface to many domain registrar APIs.
9
+
10
+ Talk to GoDaddy, Cloudflare, Namecheap, Spaceship, Dynadot, Porkbun, and more
11
+ through a single pluggable `Registrar` contract — one typed API instead of a
12
+ different one for every registrar. It speaks both JSON and XML registrar APIs and
13
+ normalizes every provider's responses to the same shared models. The full list of
14
+ supported providers is [below](#providers).
15
+
16
+ **PRs for additional registrars are welcome** — adding one means extending a base
17
+ class and overriding the operations that provider's API supports (see
18
+ [Adding a provider](#adding-a-provider)).
19
+
20
+ Powers **[DomBot](https://dombot.ai)**, a desktop app and MCP server for managing
21
+ a domain portfolio spread across many registrars from one place.
22
+
23
+ Portable by design: its single runtime dependency
24
+ ([`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser),
25
+ pure JS) uses no Node built-ins, so it runs anywhere `fetch` is available —
26
+ Node 20+, Deno, Bun, browsers, and edge runtimes.
27
+
28
+ > **Status:** every bundled provider implements the full 18-method core
29
+ > contract, plus their extended capabilities (auth code, DNSSEC, forwarding).
30
+ > Where a registrar's API genuinely can't do something, that method throws
31
+ > `NotImplementedError` with a specific reason — see
32
+ > [Supported functionality](#supported-functionality). Most read and write paths
33
+ > have been verified against live accounts or provider sandboxes (2026-08). Still
34
+ > treat paid, irreversible operations (register / renew / transfer) as
35
+ > use-at-your-own-risk: some remain documented-but-unrun on funded accounts.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ npm install @aoxborrow/registrar-client
41
+ ```
42
+
43
+ ## Providers
44
+
45
+ | id | Provider | Auth | Sandbox | Notes |
46
+ | ------------ | ---------- | -------------------------- | ------- | -------------------------------------------- |
47
+ | `cloudflare` | Cloudflare | API token + Account ID | — | manages existing domains only |
48
+ | `dynadot` | Dynadot | API key | ✓ | `api-sandbox.dynadot.com`; key in query |
49
+ | `gandi` | Gandi.net | API key | ✓ | `api.sandbox.gandi.net` |
50
+ | `godaddy` | GoDaddy | PAT, or key + secret | ✓ | hybrid v3/v1; sandbox = OTE (v1 only) |
51
+ | `namebright` | NameBright | client ID + secret | — | OAuth2 bearer; production-only |
52
+ | `namecheap` | Namecheap | username + key + client IP | ✓ | XML API; IP allowlisting required |
53
+ | `namesilo` | NameSilo | API key | ✓ | JSON/XML API; key sent in query string |
54
+ | `porkbun` | Porkbun | key + secret | ✓ | sandbox via `pk1_sb_` key; per-domain opt-in |
55
+ | `spaceship` | Spaceship | key + secret | — | |
56
+
57
+ Each provider class exposes static discovery metadata — `displayName`,
58
+ `configFields` (the credential fields it needs), and `helpText` (where to get
59
+ them) — so you can build a credential UI generically.
60
+
61
+ Research notes for additional registrars live in
62
+ [`docs/registrars/`](docs/registrars). Three are documented but **not**
63
+ implemented because they have no usable public API: **Squarespace**
64
+ (reseller-program-gated), **Network Solutions** (partner-gated Partner Protocol /
65
+ SRSplus), and **eDomains** (WHMCS-based, no documented API). They can be added if
66
+ API access becomes available.
67
+
68
+ ## Usage
69
+
70
+ Construct a provider (directly or via the `createRegistrar` factory) and, optionally, wrap it in
71
+ the `RegistrarClient` facade:
72
+
73
+ ```ts
74
+ import { RegistrarClient, createRegistrar } from '@aoxborrow/registrar-client';
75
+
76
+ const client = new RegistrarClient(
77
+ createRegistrar('cloudflare', {
78
+ apiToken: process.env.CF_API_TOKEN!,
79
+ accountId: process.env.CF_ACCOUNT_ID!,
80
+ })
81
+ );
82
+
83
+ await client.testConnection();
84
+ const domains = await client.listDomains();
85
+ const [availability] = await client.checkAvailability(['example.com']);
86
+ const records = await client.getDnsRecords('example.com');
87
+ await client.updateNameservers('example.com', ['ns1.example.net', 'ns2.example.net']);
88
+ ```
89
+
90
+ Every provider implements the same `Registrar` interface — the full core
91
+ contract is `testConnection`, `listDomains`, `getDomain`, `checkAvailability`,
92
+ `getPricing`, `registerDomain`, `renewDomain`, `setAutoRenew`, `transferIn`,
93
+ `updateNameservers`, `getNameservers`, `lockDomain`, `unlockDomain`,
94
+ `setPrivacy`, `getContacts`, `updateContacts`, `getDnsRecords`, and
95
+ `setDnsRecords`. Every provider implements it; where a registrar's API can't
96
+ express a given method, that method throws `NotImplementedError` with a specific
97
+ reason (see [Supported functionality](#supported-functionality)). The gaps are
98
+ API limits, not missing work — e.g. **Cloudflare** has no post-registration write
99
+ endpoints, **Porkbun** exposes no transfer-lock / privacy / contact-read toggles,
100
+ **NameBright** has no transfer-in endpoint, and **Spaceship** / **NameBright**
101
+ have no pricing endpoint. **Namecheap** and **Gandi** are the only providers with
102
+ a real per-TLD `getPricing` table; **GoDaddy** and **Dynadot** price per-domain
103
+ via their availability endpoint.
104
+
105
+ ## Listing domains & portfolios
106
+
107
+ `listDomains` returns the **full account**, paginating internally at each
108
+ provider's maximum page size (so it makes the fewest requests each API allows).
109
+ Options:
110
+
111
+ - `search` — a domain-name substring filter, applied server-side where the API
112
+ supports it (**Namecheap** `SearchTerm`, **Gandi** `fqdn`) and client-side
113
+ everywhere else.
114
+ - `detailed` — fetch full per-domain detail after listing (see below).
115
+
116
+ The usual request options (timeout/retries/signal) still apply.
117
+
118
+ ```ts
119
+ const all = await client.listDomains();
120
+ const acme = await client.listDomains({ search: 'acme' });
121
+ const full = await client.listDomains({ detailed: true });
122
+ ```
123
+
124
+ Several providers' list endpoints return only a **summary** that omits
125
+ `nameservers`, `privacy`, and/or the transfer `locked` flag, which then read as
126
+ their defaults (`[]` / `false`). Nameservers come back in the single list call
127
+ only on **GoDaddy** (via `includes=nameServers`), **Spaceship**, and
128
+ **Dynadot**; **Gandi**, **Namecheap**, **Porkbun**, **NameSilo**, and
129
+ **NameBright** omit them (and **Gandi**/**NameSilo** also omit `privacy`).
130
+ (**Cloudflare** manages nameservers via its Zones API, not the Registrar list.)
131
+
132
+ Pass `detailed: true` and the facade follows up with a `getDomain` per domain
133
+ (bounded concurrency, falling back to `getNameservers` where detail still lacks
134
+ them) and merges the result — so `nameservers`, `privacy`, and `locked` are
135
+ accurate. It costs one extra request per domain, so it's opt-in; leave it off
136
+ for a fast summary, or call `getDomain` / `getNameservers` yourself for
137
+ individual domains.
138
+
139
+ To build a combined view across registrars, `listPortfolio` fans out over many
140
+ providers concurrently with per-registrar error isolation (one provider being
141
+ down never sinks the whole view). Every `Domain` already carries its `registrar`:
142
+
143
+ ```ts
144
+ import { listPortfolio, createRegistrar } from '@aoxborrow/registrar-client';
145
+
146
+ const { domains, errors } = await listPortfolio([
147
+ createRegistrar('godaddy', godaddyCreds),
148
+ createRegistrar('namecheap', namecheapCreds),
149
+ createRegistrar('gandi', gandiCreds),
150
+ ]);
151
+ // domains: Domain[] (each tagged with .registrar); errors: { registrar, error }[]
152
+ ```
153
+
154
+ ## Supported functionality
155
+
156
+ What each provider implements today, read straight from the code. **Core** methods
157
+ are the guaranteed contract; **extended** methods are opt-in per provider.
158
+
159
+ **Legend:** ✓ implemented · ✗ not available via the provider's API (throws
160
+ `NotImplementedError`) · a superscript marks a caveat noted below.
161
+
162
+ | Core method | CF | DY | GA | GD | NB | NC | NS | PB | SP |
163
+ | ------------------- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
164
+ | `testConnection` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
165
+ | `listDomains` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
166
+ | `getDomain` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
167
+ | `checkAvailability` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
168
+ | `getPricing` | ✓ | ✓ᵃ | ✓ | ✓ᵃ | ✗ᵇ | ✓ | ✓ | ✓ | ✗ᵇ |
169
+ | `registerDomain` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
170
+ | `renewDomain` | ✗ᶜ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
171
+ | `setAutoRenew` | ✗ᶜ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
172
+ | `transferIn` | ✗ᶜ | ✓ | ✓ | ✓ | ✗ᵈ | ✓ | ✓ | ✓ | ✓ |
173
+ | `updateNameservers` | ✗ᶜ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
174
+ | `getNameservers` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
175
+ | `lockDomain` | ✗ᶜ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ᵉ | ✓ |
176
+ | `unlockDomain` | ✗ᶜ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ᵉ | ✓ |
177
+ | `setPrivacy` | ✓ | ✓ | ✓ᶠ | ✓ᵍ | ✓ | ✓ | ✓ | ✗ᵉ | ✓ |
178
+ | `getContacts` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ᵉ | ✓ |
179
+ | `updateContacts` | ✗ᶜ | ✓ | ✓ | ✓ | ✗ᵈ | ✓ | ✓ | ✓ | ✓ |
180
+ | `getDnsRecords` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
181
+ | `setDnsRecords` | ✓ | ✓ʰ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ʰ |
182
+
183
+ | Extended method | CF | DY | GA | GD | NB | NC | NS | PB | SP |
184
+ | --------------------- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
185
+ | `getAuthCode` | ✗ | ✓ | ✓ | ✓ | ✓ | ✗ⁱ | ✗ⁱ | ✗ | ✓ |
186
+ | `getDnssec` | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ |
187
+ | `disableDnssec` | ✗ | ✓ | ✓ | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ |
188
+ | `getEmailForwarding` | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
189
+ | `setEmailForwarding` | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
190
+ | `getDomainForwarding` | ✓ | ✓ʲ | ✓ᵏ | ✓ˡ | ✗ | ✓ | ✓ʲ | ✓ | ✗ |
191
+ | `setDomainForwarding` | ✓ | ✓ʲ | ✓ᵏ | ✓ˡ | ✗ | ✓ | ✓ʲ | ✓ | ✗ |
192
+
193
+ `CF` Cloudflare · `DY` Dynadot · `GA` Gandi · `GD` GoDaddy · `NB` NameBright ·
194
+ `NC` Namecheap · `NS` NameSilo · `PB` Porkbun · `SP` Spaceship.
195
+
196
+ **Caveats**
197
+
198
+ - **ᵃ** `getPricing` is per-domain only (pass a full name, not a bare TLD) —
199
+ Dynadot and GoDaddy price via their availability endpoint and have no per-TLD
200
+ price table.
201
+ - **ᵇ** No pricing endpoint at all; a per-domain registration price is still
202
+ available through `checkAvailability`.
203
+ - **ᶜ** Cloudflare Registrar has no post-registration write API — auto-renew,
204
+ privacy, and lock are settable only at registration; nameserver changes require
205
+ Cloudflare support; there is no renew, transfer-in, or contact-update endpoint.
206
+ These throw with a message pointing to the dashboard.
207
+ - **ᵈ** NameBright's REST API has no transfer-in or contact-update endpoint.
208
+ - **ᵉ** Porkbun exposes the transfer-lock and privacy state read-only (settable
209
+ only at registration) and has no WHOIS/contact-read endpoint.
210
+ - **ᶠ** Gandi `setPrivacy(false)` is a no-op for individual registrants — WHOIS
211
+ stays obfuscated for GDPR.
212
+ - **ᵍ** GoDaddy `setPrivacy`: enabling is a paid add-on (not implemented);
213
+ disabling works for paid privacy, but GoDaddy's free privacy ("Free DBP")
214
+ cannot be disabled via the API (returns a clear error pointing to the dashboard).
215
+ - **ʰ** `setDnsRecords` writes cover the common record types; SRV/CAA and other
216
+ sub-field-bearing types throw on write (reads handle every type).
217
+ - **ⁱ** Namecheap and NameSilo only email the EPP/auth code to the registrant;
218
+ it can't be returned synchronously.
219
+ - **ʲ** Dynadot and NameSilo forward the whole domain (a single `@` rule);
220
+ clearing a forward restores the default nameservers.
221
+ - **ᵏ** Gandi web redirects are subdomain-only — the apex (`@`) is rejected.
222
+ - **ˡ** GoDaddy domain forwarding uses the v2 customer-scoped API, so it requires
223
+ a `customerId` (the customer UUID, or a numeric shopper ID the client resolves to
224
+ the UUID via an sso-key-only lookup); the old v1 forwarding route was removed.
225
+
226
+ For the deeper cross-provider notes and per-registrar quirks, see
227
+ [docs/registrars/FEATURES.md](docs/registrars/FEATURES.md).
228
+
229
+ ## Capabilities
230
+
231
+ Registrars don't all support the same operations. **Core** is a fixed contract
232
+ every provider is expected to fulfil (inherited by extending `BaseRegistrar`);
233
+ **extended** capabilities are opt-in and declared per provider. Where a core
234
+ method's API path isn't wired up yet, it throws `NotImplementedError` — the
235
+ contract is the promise, not a claim that every byte is implemented today.
236
+
237
+ Reference capabilities through the `Feature` object (autocomplete +
238
+ find-all-references), and introspect statically — no instance or credentials
239
+ needed, which makes this easy to surface to a UI or an MCP tool:
240
+
241
+ ```ts
242
+ import { registrars, createRegistrar, Feature, CORE_FEATURES } from '@aoxborrow/registrar-client';
243
+
244
+ registrars.dynadot.features; // full set: core + Dynadot's extras
245
+ registrars.dynadot.extendedFeatures; // just the extras
246
+ CORE_FEATURES.includes(Feature.GetDnsRecords); // true — DNS is core
247
+
248
+ const gd = createRegistrar('godaddy', creds);
249
+ gd.supports(Feature.RegisterDomain); // true (core)
250
+ gd.supports(Feature.GetAuthCode); // true (its extended)
251
+ gd.supports(Feature.SetDomainForwarding); // false (not declared)
252
+ ```
253
+
254
+ See [docs/registrars/FEATURES.md](docs/registrars/FEATURES.md) for the full
255
+ feature matrix and the core vs. extended breakdown.
256
+
257
+ ## Forwarding
258
+
259
+ Two opt-in extended capabilities cover forwarding, both modelled as replace-all
260
+ sets:
261
+
262
+ - **Domain (URL) forwarding** — `getDomainForwarding` / `setDomainForwarding`
263
+ take `DomainForward[]` (`{ host, url, type }`). `host` is apex-relative (`@`,
264
+ `www`, …); `type` is `temporary` (302) or `permanent` (301). HTTPS targets work
265
+ wherever the provider issues a certificate for the domain (all supported
266
+ providers do).
267
+ - **Email forwarding** — `getEmailForwarding` / `setEmailForwarding` take
268
+ `EmailForward[]` (`{ alias, forwardTo }`). `alias` is the local part; `*` (or
269
+ `@`) is the catch-all. This is alias→inbox **forwarding**, not mailbox hosting.
270
+
271
+ **Masked/framed forwarding is read-only.** `DomainForwardType` also has a
272
+ `masked` value — a "cloaked" forward where an iframe keeps the source URL in the
273
+ address bar. The library **never creates** one (`setDomainForwarding` rejects
274
+ `masked`): it breaks HTTPS, SEO, and modern browser protections, and several
275
+ providers (e.g. Cloudflare) can't do it at all. But `getDomainForwarding`
276
+ **reports** `masked` truthfully when a provider already has one configured, so
277
+ reading state stays accurate. (One consequence: reading a masked forward and
278
+ writing the same array straight back is rejected — by design.)
279
+
280
+ Providers that expose a native forwarding feature do this in one call.
281
+ **Cloudflare** has no such primitive, so the provider composes it from other
282
+ Cloudflare APIs: domain forwarding writes a Rules redirect plus a proxied
283
+ placeholder DNS record so the edge can apply it, and email forwarding uses Email
284
+ Routing (enabling it adds the required MX/SPF records). Email Routing
285
+ destinations must be verified on the Cloudflare account before a rule activates;
286
+ where the token can manage addresses, `setEmailForwarding` adds any unknown
287
+ destination (sending its verification email) and reports which are still
288
+ pending. See [docs/registrars/cloudflare.md](docs/registrars/cloudflare.md).
289
+
290
+ ## Registering & transferring domains
291
+
292
+ Registering or transferring a domain forms a legal contract with the registry, so
293
+ registrars gate `registerDomain`/`transferIn` behind **consent** to their
294
+ agreements. Consent is supplied **per operation** (matching how the registrar
295
+ APIs model it — there is no "consent once" server state):
296
+
297
+ ```ts
298
+ await client.registerDomain('example.com', {
299
+ years: 1,
300
+ contacts: { registrant: { firstName: 'Ada', lastName: 'Lovelace' /* … */ } },
301
+ consent: { agreedBy: userIpAddress }, // the consenting party's IP
302
+ });
303
+
304
+ await client.transferIn('example.com', {
305
+ authCode: 'EPP-CODE-FROM-LOSING-REGISTRAR',
306
+ consent: { agreedBy: userIpAddress },
307
+ });
308
+ ```
309
+
310
+ You only affirm **who** consents (`agreedBy` — the consenting user's IP address)
311
+ and optionally **when** (`agreedAt`, defaulting to now). The provider fetches the
312
+ specific per-TLD agreement documents itself and attaches them. Omitting `consent`
313
+ throws `ConsentRequiredError` (distinct from `NotImplementedError` — the
314
+ capability exists; you just have to consent).
315
+
316
+ Contacts are supplied on the same call, where the registrar needs them: GoDaddy
317
+ and Spaceship take a full contact set on registration (omitted roles fall back to
318
+ the registrant); Namecheap requires all four roles; Dynadot and Porkbun use the
319
+ account's default WHOIS contact. Transfers carry over the existing contacts, so
320
+ most providers need only the auth code (`TransferDomainInput`).
321
+
322
+ > `registerDomain` is implemented for **every bundled provider**, and `transferIn`
323
+ > for every one whose API has a transfer endpoint (all except **Cloudflare** and
324
+ > **NameBright**). `registerDomain` has been exercised live against several
325
+ > sandboxes and one real account (a live Cloudflare `.dev` registration), but since
326
+ > these operations spend real money, treat any path you haven't run yourself as
327
+ > documented-but-unverified. Known gap: Namecheap registration doesn't yet send
328
+ > per-TLD extended attributes, so TLDs that require them (`.us`, `.eu`, …) aren't
329
+ > registrable there yet.
330
+
331
+ ## Sandbox environments
332
+
333
+ Providers that offer a test environment accept `{ environment: 'sandbox' }` at
334
+ construction, so you can exercise real API calls without touching live domains:
335
+
336
+ ```ts
337
+ const gd = createRegistrar('godaddy', oteCredentials, { environment: 'sandbox' });
338
+ ```
339
+
340
+ Requesting `sandbox` on a provider that has none (`cloudflare`, `namebright`,
341
+ `spaceship`) throws a `ConfigurationError` — a test can never silently hit
342
+ production. Check `SomeRegistrar.supportsSandbox` to discover which do.
343
+
344
+ Some providers key their sandbox off the **credential**, not a separate host.
345
+ **Porkbun**'s sandbox shares the production base URL — a sandbox key is prefixed
346
+ `pk1_sb_`, starts with $1,000 of fake credit, and marks every response with
347
+ `"sandbox": true` (top up / reset via `POST /sandbox/topup` · `POST
348
+ /sandbox/reset`). There, `{ environment: 'sandbox' }` is cosmetic; the `pk1_sb_`
349
+ key is what routes to the test environment.
350
+
351
+ ### Integration tests
352
+
353
+ Real sandbox integration tests live in `test/integration/` and are **credential-
354
+ gated**: each registrar's suite skips unless its env vars are set, so the default
355
+ suite stays offline.
356
+
357
+ ```bash
358
+ cp .env.example .env # fill in sandbox credentials for the registrars you want
359
+ npm run test:integration
360
+ ```
361
+
362
+ They exercise read-only operations (`testConnection`, `listDomains`) against each
363
+ configured sandbox. Unit tests (`npm test`) never make network calls.
364
+
365
+ ## Architecture
366
+
367
+ The package separates the **caller-facing facade** from **provider
368
+ implementations**:
369
+
370
+ - **`RegistrarClient`** — a thin, provider-agnostic facade. Normalizes input and
371
+ delegates to the configured provider.
372
+ - **`Registrar`** — the core interface every backend implements (the 18 core
373
+ operations listed under [Usage](#usage), plus the capability accessors).
374
+ Defined in `src/types.ts`, with shared payload types (`Contact`, `DnsRecord`,
375
+ `DomainAvailability`, `TldPricing`, `RegisterDomainInput`) alongside it.
376
+ - **`BaseRegistrar`** (`src/registrar.ts`) — an abstract base that owns the
377
+ shared HTTP/auth/retry plumbing (via `HttpClient`) and provides
378
+ `NotImplementedError`-rejecting defaults. Concrete providers under
379
+ `src/registrars/` extend it and override only what their API supports.
380
+ - **`HttpClient`** — a small `fetch` wrapper with timeout, `AbortSignal`
381
+ linking, retry-with-backoff, `request`/`requestText` (JSON and raw/XML), and
382
+ typed error mapping. No Node built-ins.
383
+ - **`parseXml` / `ensureArray`** — shared XML helpers (backed by
384
+ `fast-xml-parser`) with one consistent config across XML providers: attributes
385
+ kept, values left as raw strings so providers coerce them explicitly.
386
+ - **`registrars` / `createRegistrar`** — a lookup of the built-in providers by
387
+ id, and a factory to construct one.
388
+
389
+ ### Adding a provider
390
+
391
+ Extend `BaseRegistrar`, pass a `baseUrl` + auth headers to `super()`, add the
392
+ static `displayName` / `configFields` / `helpText` metadata, override the
393
+ operations the API supports (mapping payloads to the shared types in
394
+ `src/types.ts`), and add the class to `src/registrars/index.ts`. The existing
395
+ providers under `src/registrars/` are working references.
396
+
397
+ **New providers are welcome** — if you've wired up a registrar, open a PR.
398
+
399
+ ## Errors
400
+
401
+ All failures throw a typed subclass of `RegistrarError` (`AuthenticationError`,
402
+ `NotFoundError`, `RateLimitError`, `TimeoutError`, `NotImplementedError`, …),
403
+ each carrying an HTTP-style `status`.
404
+
405
+ ## Security
406
+
407
+ Credentials are sent as request **headers** wherever the registrar's API
408
+ supports it. Two providers require query-string authentication by their own API
409
+ design — **Dynadot** (`key`) and **Namecheap** (`ApiUser`/`ApiKey`) — so for
410
+ those the key necessarily appears in the request URL. This is noted in each
411
+ provider's source. Credentials are not interpolated into error messages.
412
+
413
+ ## License
414
+
415
+ MIT © Aaron Oxborrow