@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,1205 @@
1
+ import type {
2
+ ConfigField,
3
+ ConnectionResult,
4
+ Contact,
5
+ ContactSet,
6
+ DnsRecord,
7
+ Domain,
8
+ DomainAvailability,
9
+ DomainForward,
10
+ EmailForward,
11
+ ListDomainsOptions,
12
+ OperationResult,
13
+ RegisterDomainInput,
14
+ RegistrarOptions,
15
+ RequestOptions,
16
+ TldPricing,
17
+ } from '../types';
18
+ import { createDomain, filterDomains, normalizeDomain, settableForwards } from '../utils';
19
+ import { NotImplementedError, toRegistrarError } from '../errors';
20
+ import { BaseRegistrar, selectBaseUrl } from '../registrar';
21
+ import { Feature, type RegistrarFeature } from '../features';
22
+ import type { RegistrarCredentials } from '../types';
23
+
24
+ // Cloudflare's standard response envelope
25
+ interface CfEnvelope<T> {
26
+ success: boolean;
27
+ errors?: { code?: number; message?: string }[];
28
+ result?: T;
29
+ }
30
+
31
+ // a domain object from the Cloudflare Registrar API
32
+ interface CfDomain {
33
+ name: string;
34
+ // registrar domains report lifecycle under last_known_status (e.g.
35
+ // "registrationActive") — there is no plain `status` field
36
+ last_known_status?: string;
37
+ registered_at?: string;
38
+ expires_at?: string;
39
+ auto_renew?: boolean;
40
+ locked?: boolean;
41
+ privacy?: boolean;
42
+ name_servers?: string[];
43
+ // WHOIS contacts, keyed by role, on the registrar domain payload
44
+ contacts?: {
45
+ registrant?: CfContact;
46
+ administrator?: CfContact;
47
+ technical?: CfContact;
48
+ billing?: CfContact;
49
+ };
50
+ }
51
+
52
+ // a WHOIS contact as the Cloudflare Registrar API returns it
53
+ interface CfContact {
54
+ first_name?: string;
55
+ last_name?: string;
56
+ organization?: string;
57
+ email?: string;
58
+ phone?: string;
59
+ fax?: string;
60
+ address?: string;
61
+ address2?: string;
62
+ city?: string;
63
+ state?: string;
64
+ zip?: string;
65
+ country?: string;
66
+ }
67
+
68
+ // a zone object from the Cloudflare Zones API. Nameservers and DNS records live
69
+ // on the Zones API, not the Registrar API, so several read methods resolve the
70
+ // domain to its zone first.
71
+ interface CfZone {
72
+ id: string;
73
+ name: string;
74
+ name_servers?: string[];
75
+ }
76
+
77
+ // pricing (major currency units, as strings) on a domain-check / domain-search entry
78
+ interface CfPricing {
79
+ currency?: string;
80
+ registration_cost?: string;
81
+ renewal_cost?: string;
82
+ }
83
+
84
+ // one entry from the registrar domain-check / domain-search result. `registrable`
85
+ // is the authoritative availability flag; `reason` explains a false (e.g.
86
+ // "domain_premium", "extension_not_supported_via_api").
87
+ interface CfDomainCheck {
88
+ name: string;
89
+ registrable: boolean;
90
+ tier?: string;
91
+ reason?: string;
92
+ pricing?: CfPricing;
93
+ }
94
+
95
+ // the registration resource returned by POST /registrar/registrations and the
96
+ // registration-status endpoint. `state` is the lifecycle; `completed` is a
97
+ // convenience flag; registry details hang off context.registration.
98
+ interface CfRegistration {
99
+ domain_name?: string;
100
+ state?: 'in_progress' | 'succeeded' | 'failed' | 'action_required' | 'blocked';
101
+ completed?: boolean;
102
+ reason?: string;
103
+ }
104
+
105
+ // a rule in the http_request_dynamic_redirect phase ruleset (Single Redirects).
106
+ // A domain forward is a static redirect: action "redirect" with a literal
107
+ // target_url.value and a host-matching expression.
108
+ interface CfRedirectRule {
109
+ id?: string;
110
+ expression?: string;
111
+ description?: string;
112
+ action?: string;
113
+ action_parameters?: {
114
+ from_value?: {
115
+ target_url?: { value?: string; expression?: string };
116
+ status_code?: number;
117
+ preserve_query_string?: boolean;
118
+ };
119
+ };
120
+ }
121
+
122
+ interface CfRuleset {
123
+ id?: string;
124
+ rules?: CfRedirectRule[];
125
+ }
126
+
127
+ // an Email Routing rule: a matcher (literal "to" address, or "all" for the
128
+ // catch-all) paired with a forward action carrying the destination address(es).
129
+ interface CfEmailRule {
130
+ id?: string;
131
+ name?: string;
132
+ enabled?: boolean;
133
+ matchers?: { type: string; field?: string; value?: string }[];
134
+ actions?: { type: string; value?: string[] }[];
135
+ }
136
+
137
+ interface CfEmailSettings {
138
+ enabled?: boolean;
139
+ status?: string;
140
+ }
141
+
142
+ // a DNS record from the Cloudflare Zones DNS API. For SRV records, weight/port
143
+ // are carried on a nested `data` object; `content` holds the primary value for
144
+ // every type, and `priority` is populated for MX/SRV.
145
+ interface CfDnsRecord {
146
+ // record id, present on records read from the API (needed to delete them)
147
+ id?: string;
148
+ type: string;
149
+ name: string;
150
+ content?: string;
151
+ ttl?: number;
152
+ priority?: number;
153
+ data?: {
154
+ weight?: number;
155
+ port?: number;
156
+ priority?: number;
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Cloudflare Registrar
162
+ * API docs: https://developers.cloudflare.com/api/resources/registrar/
163
+ *
164
+ * Credentials: create an API token under My Profile > API Tokens with
165
+ * "Account.Registrar" read/write permission. The Account ID is shown on the
166
+ * Overview page of any zone (and in the dashboard URL).
167
+ *
168
+ * Registration is supported via the Registrar API's registrations endpoint
169
+ * (beta): at-cost, standard-tier, API-supported TLDs only — premium names and
170
+ * unsupported extensions are rejected by the availability check. Renewals and
171
+ * transfers-in are not yet exposed by the Cloudflare API. Registrar is at-cost,
172
+ * so `registerDomain` spends real money.
173
+ */
174
+ export class CloudflareRegistrar extends BaseRegistrar {
175
+ readonly name = 'cloudflare';
176
+
177
+ static readonly displayName = 'Cloudflare';
178
+ static readonly helpText =
179
+ 'Create an API token in your Cloudflare dashboard under My Profile > API Tokens. ' +
180
+ 'The token needs "Account.Registrar" read/write permissions. Find your Account ID ' +
181
+ 'in the URL or on the Overview page of any zone.';
182
+ static readonly configFields: ConfigField[] = [
183
+ { name: 'apiToken', label: 'API Token', type: 'password', required: true },
184
+ { name: 'accountId', label: 'Account ID', type: 'text', required: true },
185
+ ];
186
+ static readonly supportsSandbox = false; // Cloudflare Registrar has no test environment
187
+ // No extended capabilities. Cloudflare is held to the core contract like any
188
+ // provider. Reads, registration, availability/pricing, privacy, and DNS are
189
+ // implemented (DNS routes through the Zones API); auto-renew, lock, nameserver,
190
+ // renew, and contact updates are not — Cloudflare's API has no post-registration
191
+ // update endpoint and the legacy edit endpoint (EOL 2026-09-27) rejects those
192
+ // fields. Those methods stay `NotImplementedError`. See docs/registrars/cloudflare.md.
193
+ // Domain forwarding (URL redirects via the Rules/Rulesets API + a proxied
194
+ // placeholder DNS record) and email forwarding (Email Routing) are supported
195
+ // through Cloudflare APIs outside the Registrar API. Masked/framed forwarding
196
+ // is not offered by the library. See docs/registrars/cloudflare.md.
197
+ static readonly extendedFeatures: readonly RegistrarFeature[] = [
198
+ Feature.GetDomainForwarding,
199
+ Feature.SetDomainForwarding,
200
+ Feature.GetEmailForwarding,
201
+ Feature.SetEmailForwarding,
202
+ ];
203
+
204
+ constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
205
+ super(
206
+ credentials,
207
+ {
208
+ baseUrl: selectBaseUrl('Cloudflare', options?.environment, {
209
+ production: 'https://api.cloudflare.com/client/v4',
210
+ }),
211
+ headers: {
212
+ 'Authorization': `Bearer ${credentials.apiToken}`,
213
+ 'Content-Type': 'application/json',
214
+ },
215
+ },
216
+ options
217
+ );
218
+ }
219
+
220
+ private get accountPath(): string {
221
+ return `/accounts/${this.credentials.accountId}/registrar/domains`;
222
+ }
223
+
224
+ override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
225
+ try {
226
+ const res = await this.http.request<CfEnvelope<CfDomain[]>>({
227
+ path: this.accountPath,
228
+ ...opts,
229
+ });
230
+ return res.success
231
+ ? { success: true, message: 'Connection successful' }
232
+ : { success: false, message: res.errors?.[0]?.message ?? 'Request failed' };
233
+ } catch (error) {
234
+ return { success: false, message: toRegistrarError(error).message };
235
+ }
236
+ }
237
+
238
+ override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
239
+ // The Registrar list endpoint has no name filter, so `search` is applied
240
+ // client-side. It also does not return nameservers (those live on the Zones
241
+ // API); `nameservers` therefore reflects only what the list response carries.
242
+ const { search, ...reqOpts } = opts ?? {};
243
+ const domains: Domain[] = [];
244
+ const perPage = 200; // Cloudflare API maximum page size
245
+ let page = 0; // the Registrar list endpoint is 0-indexed (result_info.page starts at 0)
246
+ let hasMore = true;
247
+
248
+ while (hasMore) {
249
+ const res = await this.http.request<CfEnvelope<CfDomain[]>>({
250
+ path: this.accountPath,
251
+ query: { per_page: perPage, page },
252
+ ...reqOpts,
253
+ });
254
+ if (!res.success) {
255
+ throw new Error(res.errors?.[0]?.message ?? 'API request failed');
256
+ }
257
+ const list = res.result ?? [];
258
+ for (const d of list) domains.push(this.toDomain(d));
259
+ hasMore = list.length === perPage;
260
+ page++;
261
+ }
262
+ return filterDomains(domains, search);
263
+ }
264
+
265
+ // map a Cloudflare Registrar domain payload to the normalized Domain shape
266
+ private toDomain(d: CfDomain): Domain {
267
+ return createDomain({
268
+ domainName: d.name,
269
+ registrar: this.name,
270
+ status: d.last_known_status,
271
+ createdDate: d.registered_at,
272
+ expirationDate: d.expires_at,
273
+ renewalDate: d.expires_at,
274
+ autoRenew: d.auto_renew ?? false,
275
+ locked: d.locked ?? false,
276
+ privacy: d.privacy ?? true, // Cloudflare includes WHOIS privacy by default
277
+ nameservers: d.name_servers ?? [],
278
+ });
279
+ }
280
+
281
+ override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
282
+ // Registrar API: GET /accounts/{account_id}/registrar/domains/{domain}
283
+ const res = await this.http.request<CfEnvelope<CfDomain>>({
284
+ path: `${this.accountPath}/${encodeURIComponent(domainName)}`,
285
+ ...opts,
286
+ });
287
+ if (!res.success || !res.result) {
288
+ throw new Error(res.errors?.[0]?.message ?? `Domain ${domainName} not found`);
289
+ }
290
+ return this.toDomain(res.result);
291
+ }
292
+
293
+ /**
294
+ * Nameservers come from the Zones API, not the Registrar API: a domain must be
295
+ * an active zone in the account for its Cloudflare-assigned nameservers to be
296
+ * known. GET /zones?name={domain} → the zone's `name_servers`.
297
+ */
298
+ override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
299
+ const zone = await this.findZone(domainName, opts);
300
+ if (!zone) {
301
+ throw new Error(
302
+ `${this.name}: ${domainName} is not a zone in this account; nameservers are only ` +
303
+ 'available via the Zones API for domains added as zones'
304
+ );
305
+ }
306
+ return zone.name_servers ?? [];
307
+ }
308
+
309
+ /**
310
+ * DNS records live on the Zones API. Two-step: resolve the domain to its
311
+ * zone_id via GET /zones?name={domain}, then GET /zones/{zone_id}/dns_records.
312
+ */
313
+ override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
314
+ const zone = await this.findZone(domainName, opts);
315
+ if (!zone) {
316
+ throw new Error(
317
+ `${this.name}: ${domainName} is not a zone in this account; DNS records are managed ` +
318
+ 'through the Zones API for domains added as zones'
319
+ );
320
+ }
321
+ const res = await this.http.request<CfEnvelope<CfDnsRecord[]>>({
322
+ path: `/zones/${encodeURIComponent(zone.id)}/dns_records`,
323
+ ...opts,
324
+ });
325
+ if (!res.success) {
326
+ throw new Error(res.errors?.[0]?.message ?? 'Failed to list DNS records');
327
+ }
328
+ return (res.result ?? []).map(toDnsRecord);
329
+ }
330
+
331
+ /**
332
+ * Replace the zone's DNS records with the provided set (Zones API). The
333
+ * Registrar API has no DNS surface, so the domain must be an active zone in the
334
+ * account. Cloudflare has no bulk "replace" call, so this deletes the zone's
335
+ * existing records and recreates them from `records`. The implicit SOA and the
336
+ * zone's Cloudflare nameservers are not part of the editable record set and are
337
+ * left untouched; an apex NS record in `records` is skipped (Cloudflare manages
338
+ * it).
339
+ */
340
+ override async setDnsRecords(
341
+ domainName: string,
342
+ records: DnsRecord[],
343
+ opts?: RequestOptions
344
+ ): Promise<OperationResult> {
345
+ try {
346
+ const zone = await this.findZone(domainName, opts);
347
+ if (!zone) {
348
+ return {
349
+ success: false,
350
+ message: `${domainName} is not a zone in this account; DNS is managed via the Zones API`,
351
+ };
352
+ }
353
+ const dnsPath = `/zones/${encodeURIComponent(zone.id)}/dns_records`;
354
+
355
+ const list = await this.http.request<CfEnvelope<CfDnsRecord[]>>({ path: dnsPath, ...opts });
356
+ if (!list.success) {
357
+ return {
358
+ success: false,
359
+ message: list.errors?.[0]?.message ?? 'Failed to list DNS records',
360
+ };
361
+ }
362
+
363
+ for (const rec of list.result ?? []) {
364
+ if (!rec.id) continue;
365
+ const del = await this.http.request<CfEnvelope<unknown>>({
366
+ method: 'DELETE',
367
+ path: `${dnsPath}/${encodeURIComponent(rec.id)}`,
368
+ ...opts,
369
+ });
370
+ if (!del.success) {
371
+ return {
372
+ success: false,
373
+ message: del.errors?.[0]?.message ?? 'Failed to delete DNS record',
374
+ };
375
+ }
376
+ }
377
+
378
+ for (const record of records) {
379
+ const type = record.type.toUpperCase();
380
+ const name = toFqdn(record.name, zone.name);
381
+ if (type === 'NS' && name === zone.name) continue; // apex NS is Cloudflare-managed
382
+ const create = await this.http.request<CfEnvelope<unknown>>({
383
+ method: 'POST',
384
+ path: dnsPath,
385
+ body: toCfDnsBody(record, type, name),
386
+ ...opts,
387
+ });
388
+ if (!create.success) {
389
+ return {
390
+ success: false,
391
+ message: create.errors?.[0]?.message ?? `Failed to create ${type} record`,
392
+ };
393
+ }
394
+ }
395
+ return { success: true, message: 'DNS records updated successfully' };
396
+ } catch (error) {
397
+ return { success: false, message: toRegistrarError(error).message };
398
+ }
399
+ }
400
+
401
+ /**
402
+ * WHOIS contacts come inline on the registrar domain payload, keyed by role
403
+ * (registrant / administrator / technical / billing). GET the domain and map
404
+ * each present role to the normalized ContactSet.
405
+ */
406
+ override async getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet> {
407
+ const res = await this.http.request<CfEnvelope<CfDomain>>({
408
+ path: `${this.accountPath}/${encodeURIComponent(domainName)}`,
409
+ ...opts,
410
+ });
411
+ if (!res.success || !res.result) {
412
+ throw new Error(res.errors?.[0]?.message ?? `Domain ${domainName} not found`);
413
+ }
414
+ const c = res.result.contacts ?? {};
415
+ const contacts: ContactSet = {};
416
+ if (c.registrant) contacts.registrant = toContact(c.registrant);
417
+ if (c.administrator) contacts.admin = toContact(c.administrator);
418
+ if (c.technical) contacts.tech = toContact(c.technical);
419
+ if (c.billing) contacts.billing = toContact(c.billing);
420
+ return contacts;
421
+ }
422
+
423
+ private get registrarPath(): string {
424
+ return `/accounts/${this.credentials.accountId}/registrar`;
425
+ }
426
+
427
+ // authoritative availability + pricing for a batch of names (max 20 per call).
428
+ // POST /registrar/domain-check is the source of truth (unlike domain-search).
429
+ private async domainCheck(
430
+ domainNames: string[],
431
+ opts?: RequestOptions
432
+ ): Promise<CfDomainCheck[]> {
433
+ const out: CfDomainCheck[] = [];
434
+ for (let i = 0; i < domainNames.length; i += 20) {
435
+ const batch = domainNames.slice(i, i + 20).map(normalizeDomain);
436
+ const res = await this.http.request<CfEnvelope<{ domains?: CfDomainCheck[] }>>({
437
+ method: 'POST',
438
+ path: `${this.registrarPath}/domain-check`,
439
+ body: { domains: batch },
440
+ ...opts,
441
+ });
442
+ if (!res.success) {
443
+ throw new Error(res.errors?.[0]?.message ?? 'Domain check failed');
444
+ }
445
+ out.push(...(res.result?.domains ?? []));
446
+ }
447
+ return out;
448
+ }
449
+
450
+ /**
451
+ * Real-time pricing via the registrar domain-check endpoint. Cloudflare has no
452
+ * per-TLD price list — pricing is per name (a premium name prices differently
453
+ * from a standard one) — so a full domain gives an accurate quote; a bare TLD
454
+ * is probed with a neutral standard label and returns representative pricing.
455
+ * Transfers are not available via the Cloudflare API, so `transfer` is omitted.
456
+ */
457
+ override async getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing> {
458
+ const input = tldOrDomain.replace(/^\.+/, '').toLowerCase();
459
+ const domain = input.includes('.') ? input : `registrar-client-pricing-probe.${input}`;
460
+ const tld = domain.slice(domain.indexOf('.') + 1);
461
+ const [entry] = await this.domainCheck([domain], opts);
462
+ if (!entry?.pricing) {
463
+ throw new Error(
464
+ `${this.name}: no pricing available for ${tldOrDomain}${
465
+ entry?.reason ? ` (${entry.reason})` : ''
466
+ } — pass a specific standard-tier domain for an accurate quote`
467
+ );
468
+ }
469
+ const pricing: TldPricing = { tld, currency: entry.pricing.currency ?? 'USD' };
470
+ const reg = toPrice(entry.pricing.registration_cost);
471
+ const renew = toPrice(entry.pricing.renewal_cost);
472
+ if (reg != null) pricing.registration = reg;
473
+ if (renew != null) pricing.renewal = renew;
474
+ return pricing;
475
+ }
476
+
477
+ /**
478
+ * Availability + pricing via POST /registrar/domain-check (the authoritative
479
+ * check — domain-search is discovery-only). A name is `available` when the API
480
+ * reports it registrable; premium names and unsupported extensions come back
481
+ * registrable:false with a `reason`. Batches of 20 are sent per the API limit.
482
+ */
483
+ override async checkAvailability(
484
+ domainNames: string[],
485
+ opts?: RequestOptions
486
+ ): Promise<DomainAvailability[]> {
487
+ const checks = await this.domainCheck(domainNames, opts);
488
+ const byName = new Map(checks.map(c => [normalizeDomain(c.name), c]));
489
+ return domainNames.map(name => {
490
+ const c = byName.get(normalizeDomain(name));
491
+ const availability: DomainAvailability = {
492
+ domainName: normalizeDomain(name),
493
+ available: c?.registrable ?? false,
494
+ };
495
+ if (c?.tier) availability.premium = c.tier === 'premium';
496
+ const price = toPrice(c?.pricing?.registration_cost);
497
+ if (price != null) availability.price = price;
498
+ if (c?.pricing?.currency) availability.currency = c.pricing.currency;
499
+ return availability;
500
+ });
501
+ }
502
+
503
+ // resolve a domain name to its Cloudflare zone via the Zones API (null if the
504
+ // domain is not a zone in this account)
505
+ private async findZone(domainName: string, opts?: RequestOptions): Promise<CfZone | null> {
506
+ const res = await this.http.request<CfEnvelope<CfZone[]>>({
507
+ path: '/zones',
508
+ query: { name: domainName },
509
+ ...opts,
510
+ });
511
+ if (!res.success) {
512
+ throw new Error(res.errors?.[0]?.message ?? 'Failed to look up zone');
513
+ }
514
+ return res.result?.find(z => z.name === domainName) ?? null;
515
+ }
516
+
517
+ // resolve a domain to its zone or throw a helpful error (forwarding requires
518
+ // the domain to be an active zone in the account).
519
+ private async requireZone(domainName: string, opts?: RequestOptions): Promise<CfZone> {
520
+ const zone = await this.findZone(domainName, opts);
521
+ if (!zone) {
522
+ throw new Error(
523
+ `${this.name}: ${domainName} is not a zone in this account; forwarding is managed ` +
524
+ 'through the Rules and Email Routing APIs, which require the domain to be an active zone'
525
+ );
526
+ }
527
+ return zone;
528
+ }
529
+
530
+ // --- domain (URL) forwarding: Rules/Rulesets API + a proxied placeholder DNS
531
+ // record so Cloudflare's edge can apply the redirect --------------------------
532
+
533
+ /**
534
+ * Read URL forwarding from the zone's `http_request_dynamic_redirect` phase
535
+ * ruleset. Each static redirect rule (action `redirect`, a literal target URL,
536
+ * and a single `http.host eq "..."` match) maps to one DomainForward. Rules
537
+ * with dynamic (expression) targets or multi-condition matches are skipped —
538
+ * they are richer than the generic forwarding model. Returns [] when no
539
+ * redirect ruleset exists.
540
+ */
541
+ override async getDomainForwarding(
542
+ domainName: string,
543
+ opts?: RequestOptions
544
+ ): Promise<DomainForward[]> {
545
+ const zone = await this.requireZone(domainName, opts);
546
+ const ruleset = await this.getRedirectRuleset(zone.id, opts);
547
+ const forwards: DomainForward[] = [];
548
+ for (const rule of ruleset?.rules ?? []) {
549
+ if (rule.action !== 'redirect') continue;
550
+ const url = rule.action_parameters?.from_value?.target_url?.value;
551
+ const host = hostFromExpression(rule.expression, zone.name);
552
+ if (!url || host == null) continue;
553
+ const status = rule.action_parameters?.from_value?.status_code;
554
+ forwards.push({ host, url, type: status === 301 ? 'permanent' : 'temporary' });
555
+ }
556
+ return forwards;
557
+ }
558
+
559
+ /**
560
+ * Set URL forwarding (replace-all). For each rule this (1) ensures a proxied
561
+ * placeholder DNS record exists on the source host so Cloudflare's edge handles
562
+ * the request, and (2) writes a static redirect rule to the zone's
563
+ * `http_request_dynamic_redirect` ruleset (301 for `permanent`, 302 for
564
+ * `redirect`). HTTPS works automatically via Cloudflare's Universal SSL. An
565
+ * empty list clears forwarding: the redirect rules and the placeholder records
566
+ * are removed.
567
+ */
568
+ override async setDomainForwarding(
569
+ domainName: string,
570
+ forwards: DomainForward[],
571
+ opts?: RequestOptions
572
+ ): Promise<OperationResult> {
573
+ try {
574
+ const settable = settableForwards(forwards); // reject masked before any write
575
+ const zone = await this.requireZone(domainName, opts);
576
+ const existing = await this.getRedirectRuleset(zone.id, opts);
577
+
578
+ if (settable.length === 0) {
579
+ if (existing?.id) {
580
+ await this.http.request<CfEnvelope<unknown>>({
581
+ method: 'DELETE',
582
+ path: `/zones/${encodeURIComponent(zone.id)}/rulesets/${encodeURIComponent(existing.id)}`,
583
+ ...opts,
584
+ });
585
+ }
586
+ await this.removePlaceholderRecords(zone.id, opts);
587
+ return { success: true, message: 'Domain forwarding cleared successfully' };
588
+ }
589
+
590
+ const rules = settable.map(f => {
591
+ const fqdn = toFqdn(f.host || '@', zone.name);
592
+ return {
593
+ expression: `http.host eq "${fqdn}"`,
594
+ description: `forward ${fqdn}`,
595
+ action: 'redirect',
596
+ action_parameters: {
597
+ from_value: {
598
+ target_url: { value: f.url },
599
+ status_code: f.type === 'permanent' ? 301 : 302,
600
+ preserve_query_string: false,
601
+ },
602
+ },
603
+ };
604
+ });
605
+
606
+ // ensure the edge sees traffic for each source host
607
+ for (const f of settable) {
608
+ await this.ensurePlaceholderRecord(zone.id, toFqdn(f.host || '@', zone.name), opts);
609
+ }
610
+
611
+ const body = { name: 'default', kind: 'zone', phase: PHASE_DYNAMIC_REDIRECT, rules };
612
+ const res = existing?.id
613
+ ? await this.http.request<CfEnvelope<CfRuleset>>({
614
+ method: 'PUT',
615
+ path: `/zones/${encodeURIComponent(zone.id)}/rulesets/${encodeURIComponent(existing.id)}`,
616
+ body,
617
+ ...opts,
618
+ })
619
+ : await this.http.request<CfEnvelope<CfRuleset>>({
620
+ method: 'POST',
621
+ path: `/zones/${encodeURIComponent(zone.id)}/rulesets`,
622
+ body,
623
+ ...opts,
624
+ });
625
+ return res.success
626
+ ? { success: true, message: 'Domain forwarding updated successfully' }
627
+ : {
628
+ success: false,
629
+ message: res.errors?.[0]?.message ?? 'Failed to update domain forwarding',
630
+ };
631
+ } catch (error) {
632
+ return { success: false, message: toRegistrarError(error).message };
633
+ }
634
+ }
635
+
636
+ // GET the dynamic-redirect phase entry-point ruleset, or null when none exists
637
+ // (the API returns 404 with code 10003 in that case).
638
+ private async getRedirectRuleset(
639
+ zoneId: string,
640
+ opts?: RequestOptions
641
+ ): Promise<CfRuleset | null> {
642
+ try {
643
+ const res = await this.http.request<CfEnvelope<CfRuleset>>({
644
+ path: `/zones/${encodeURIComponent(zoneId)}/rulesets/phases/${PHASE_DYNAMIC_REDIRECT}/entrypoint`,
645
+ ...opts,
646
+ });
647
+ return res.result ?? null;
648
+ } catch (error) {
649
+ if (toRegistrarError(error).status === 404) return null;
650
+ throw error;
651
+ }
652
+ }
653
+
654
+ // ensure a proxied placeholder A/AAAA/CNAME exists for `fqdn` so Cloudflare's
655
+ // edge receives the request. Creates a proxied AAAA -> 100:: (a reserved
656
+ // originless address) only when the host has no A/AAAA/CNAME record yet; an
657
+ // existing record is left untouched (it must be proxied for the redirect to
658
+ // fire).
659
+ private async ensurePlaceholderRecord(
660
+ zoneId: string,
661
+ fqdn: string,
662
+ opts?: RequestOptions
663
+ ): Promise<void> {
664
+ const list = await this.http.request<CfEnvelope<CfDnsRecord[]>>({
665
+ path: `/zones/${encodeURIComponent(zoneId)}/dns_records`,
666
+ query: { name: fqdn },
667
+ ...opts,
668
+ });
669
+ const hasAddress = (list.result ?? []).some(r =>
670
+ ['A', 'AAAA', 'CNAME'].includes((r.type ?? '').toUpperCase())
671
+ );
672
+ if (hasAddress) return;
673
+ await this.http.request<CfEnvelope<unknown>>({
674
+ method: 'POST',
675
+ path: `/zones/${encodeURIComponent(zoneId)}/dns_records`,
676
+ body: { type: 'AAAA', name: fqdn, content: PLACEHOLDER_AAAA, proxied: true, ttl: 1 },
677
+ ...opts,
678
+ });
679
+ }
680
+
681
+ // delete the proxied placeholder records this provider creates for forwarding
682
+ // (identified by their reserved sentinel content), leaving real records alone.
683
+ private async removePlaceholderRecords(zoneId: string, opts?: RequestOptions): Promise<void> {
684
+ const list = await this.http.request<CfEnvelope<CfDnsRecord[]>>({
685
+ path: `/zones/${encodeURIComponent(zoneId)}/dns_records`,
686
+ query: { per_page: 200 },
687
+ ...opts,
688
+ });
689
+ for (const r of list.result ?? []) {
690
+ if (r.id && PLACEHOLDER_CONTENT.has((r.content ?? '').toLowerCase())) {
691
+ await this.http.request<CfEnvelope<unknown>>({
692
+ method: 'DELETE',
693
+ path: `/zones/${encodeURIComponent(zoneId)}/dns_records/${encodeURIComponent(r.id)}`,
694
+ ...opts,
695
+ });
696
+ }
697
+ }
698
+ }
699
+
700
+ // --- email forwarding: Email Routing (zone rules + catch-all) ----------------
701
+
702
+ /**
703
+ * Read Email Routing forwarding rules. Each rule/catch-all with a `forward`
704
+ * action maps to one EmailForward: the local part of the matched address is the
705
+ * `alias` ("*" for the catch-all), and the first forward destination is
706
+ * `forwardTo`. Non-forward actions (drop / Worker) are skipped.
707
+ */
708
+ override async getEmailForwarding(
709
+ domainName: string,
710
+ opts?: RequestOptions
711
+ ): Promise<EmailForward[]> {
712
+ const zone = await this.requireZone(domainName, opts);
713
+ const zid = encodeURIComponent(zone.id);
714
+ const forwards: EmailForward[] = [];
715
+
716
+ const rules = await this.http.request<CfEnvelope<CfEmailRule[]>>({
717
+ path: `/zones/${zid}/email/routing/rules`,
718
+ ...opts,
719
+ });
720
+ for (const rule of rules.result ?? []) {
721
+ const dest = forwardAction(rule);
722
+ if (!dest) continue;
723
+ const matcher = rule.matchers?.[0];
724
+ if (matcher?.type === 'all') continue; // catch-all handled below
725
+ const value = matcher?.value ?? '';
726
+ const alias = value.includes('@') ? value.slice(0, value.indexOf('@')) : value;
727
+ forwards.push({ alias, forwardTo: dest });
728
+ }
729
+
730
+ const catchAll = await this.http.request<CfEnvelope<CfEmailRule>>({
731
+ path: `/zones/${zid}/email/routing/rules/catch_all`,
732
+ ...opts,
733
+ });
734
+ if (catchAll.result?.enabled) {
735
+ const dest = forwardAction(catchAll.result);
736
+ if (dest) forwards.push({ alias: '*', forwardTo: dest });
737
+ }
738
+ return forwards;
739
+ }
740
+
741
+ /**
742
+ * Set Email Routing forwarding (replace-all). Enables Email Routing if needed
743
+ * (which adds the required MX/SPF records), then rewrites the rules: each
744
+ * `alias@domain -> forwardTo` becomes a routing rule, and an `alias` of "*"/"@"
745
+ * becomes the catch-all. Destination addresses must be verified on the account
746
+ * before Cloudflare will activate a rule; where the token can manage addresses,
747
+ * any unknown destination is added (which sends its verification email) and the
748
+ * result message names the addresses still awaiting verification. If the token
749
+ * lacks the address-management scope, that pre-check is skipped.
750
+ */
751
+ override async setEmailForwarding(
752
+ domainName: string,
753
+ forwards: EmailForward[],
754
+ opts?: RequestOptions
755
+ ): Promise<OperationResult> {
756
+ try {
757
+ const zone = await this.requireZone(domainName, opts);
758
+ const zid = encodeURIComponent(zone.id);
759
+
760
+ const settings = await this.http.request<CfEnvelope<CfEmailSettings>>({
761
+ path: `/zones/${zid}/email/routing`,
762
+ ...opts,
763
+ });
764
+ if (!settings.result?.enabled) {
765
+ const enabled = await this.http.request<CfEnvelope<CfEmailSettings>>({
766
+ method: 'POST',
767
+ path: `/zones/${zid}/email/routing/enable`,
768
+ body: {},
769
+ ...opts,
770
+ });
771
+ if (!enabled.success) {
772
+ return {
773
+ success: false,
774
+ message: enabled.errors?.[0]?.message ?? 'Failed to enable Email Routing',
775
+ };
776
+ }
777
+ }
778
+
779
+ // ensure each destination is a verified account address (best-effort — the
780
+ // token may lack the address-management scope, in which case this is skipped)
781
+ const destinations = [...new Set(forwards.map(f => f.forwardTo))];
782
+ const unverified = await this.ensureDestinations(destinations, opts);
783
+
784
+ // remove existing non-catch-all rules
785
+ const existing = await this.http.request<CfEnvelope<CfEmailRule[]>>({
786
+ path: `/zones/${zid}/email/routing/rules`,
787
+ ...opts,
788
+ });
789
+ for (const rule of existing.result ?? []) {
790
+ if (rule.matchers?.[0]?.type === 'all' || !rule.id) continue;
791
+ await this.http.request<CfEnvelope<unknown>>({
792
+ method: 'DELETE',
793
+ path: `/zones/${zid}/email/routing/rules/${encodeURIComponent(rule.id)}`,
794
+ ...opts,
795
+ });
796
+ }
797
+
798
+ let catchAllDest: string | undefined;
799
+ for (const f of forwards) {
800
+ const alias = f.alias.trim();
801
+ if (alias === '*' || alias === '@' || alias === '') {
802
+ catchAllDest = f.forwardTo;
803
+ continue;
804
+ }
805
+ const create = await this.http.request<CfEnvelope<CfEmailRule>>({
806
+ method: 'POST',
807
+ path: `/zones/${zid}/email/routing/rules`,
808
+ body: {
809
+ enabled: true,
810
+ name: `forward ${alias}@${zone.name}`,
811
+ matchers: [{ type: 'literal', field: 'to', value: `${alias}@${zone.name}` }],
812
+ actions: [{ type: 'forward', value: [f.forwardTo] }],
813
+ },
814
+ ...opts,
815
+ });
816
+ if (!create.success) {
817
+ return {
818
+ success: false,
819
+ message: create.errors?.[0]?.message ?? `Failed to create rule for ${alias}`,
820
+ };
821
+ }
822
+ }
823
+
824
+ // catch-all: forward when a wildcard was given, otherwise reset to drop
825
+ const catchAllBody = catchAllDest
826
+ ? {
827
+ enabled: true,
828
+ matchers: [{ type: 'all' }],
829
+ actions: [{ type: 'forward', value: [catchAllDest] }],
830
+ }
831
+ : { enabled: false, matchers: [{ type: 'all' }], actions: [{ type: 'drop' }] };
832
+ const ca = await this.http.request<CfEnvelope<CfEmailRule>>({
833
+ method: 'PUT',
834
+ path: `/zones/${zid}/email/routing/rules/catch_all`,
835
+ body: catchAllBody,
836
+ ...opts,
837
+ });
838
+ if (!ca.success) {
839
+ return {
840
+ success: false,
841
+ message: ca.errors?.[0]?.message ?? 'Failed to set catch-all rule',
842
+ };
843
+ }
844
+ const note = unverified.length
845
+ ? ` — awaiting verification of ${unverified.join(', ')} (rules stay inactive until verified)`
846
+ : '';
847
+ return { success: true, message: `Email forwarding updated successfully${note}` };
848
+ } catch (error) {
849
+ return { success: false, message: toRegistrarError(error).message };
850
+ }
851
+ }
852
+
853
+ // ensure each destination is a verified Email Routing address on the account.
854
+ // Unknown addresses are added (which triggers Cloudflare's verification email).
855
+ // Best-effort: if the token can't manage addresses (403) the check is skipped
856
+ // and an empty list is returned. Returns the destinations not yet verified.
857
+ private async ensureDestinations(dests: string[], opts?: RequestOptions): Promise<string[]> {
858
+ if (dests.length === 0) return [];
859
+ const acct = encodeURIComponent(this.credentials.accountId);
860
+ try {
861
+ const res = await this.http.request<
862
+ CfEnvelope<{ email?: string; verified?: string | null }[]>
863
+ >({ path: `/accounts/${acct}/email/routing/addresses`, query: { per_page: 50 }, ...opts });
864
+ const byEmail = new Map(
865
+ (res.result ?? []).map(a => [(a.email ?? '').toLowerCase(), a.verified])
866
+ );
867
+ const unverified: string[] = [];
868
+ for (const dest of dests) {
869
+ const verified = byEmail.get(dest.toLowerCase());
870
+ if (verified) continue;
871
+ if (!byEmail.has(dest.toLowerCase())) {
872
+ await this.http.request<CfEnvelope<unknown>>({
873
+ method: 'POST',
874
+ path: `/accounts/${acct}/email/routing/addresses`,
875
+ body: { email: dest },
876
+ ...opts,
877
+ });
878
+ }
879
+ unverified.push(dest);
880
+ }
881
+ return unverified;
882
+ } catch (error) {
883
+ if (toRegistrarError(error).status === 403) return []; // no address scope; skip
884
+ throw error;
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Register a domain via POST /registrar/registrations (Registrar API, beta).
890
+ * Cloudflare registers onto the account's default WHOIS contact unless a
891
+ * registrant is supplied; nameservers cannot be set at registration (the domain
892
+ * joins the account on Cloudflare's nameservers). Registration is at-cost —
893
+ * this spends real money — and only standard-tier, API-supported TLDs qualify
894
+ * (premium names return registrable:false from checkAvailability). When the
895
+ * request does not finish synchronously the registration continues server-side
896
+ * and is polled to a terminal state.
897
+ */
898
+ override async registerDomain(
899
+ domainName: string,
900
+ input: RegisterDomainInput,
901
+ opts?: RequestOptions
902
+ ): Promise<OperationResult> {
903
+ try {
904
+ const name = normalizeDomain(domainName);
905
+ const body: Record<string, unknown> = { domain_name: name };
906
+ if (input.autoRenew != null) body.auto_renew = input.autoRenew;
907
+ if (input.privacy != null) body.privacy_mode = input.privacy ? 'redaction' : 'off';
908
+ const registrant = input.contacts?.registrant;
909
+ if (registrant) body.contacts = { registrant: toCfRegistrant(registrant) };
910
+
911
+ const res = await this.http.request<CfEnvelope<CfRegistration>>({
912
+ method: 'POST',
913
+ path: `${this.registrarPath}/registrations`,
914
+ body,
915
+ ...opts,
916
+ });
917
+ if (!res.success) {
918
+ return { success: false, message: res.errors?.[0]?.message ?? 'Registration failed' };
919
+ }
920
+
921
+ let reg = res.result;
922
+ for (let i = 0; reg && !reg.completed && reg.state === 'in_progress' && i < 15; i++) {
923
+ await sleep(2000, opts?.signal);
924
+ const poll = await this.http.request<CfEnvelope<CfRegistration>>({
925
+ path: `${this.registrarPath}/registrations/${encodeURIComponent(name)}/registration-status`,
926
+ ...opts,
927
+ });
928
+ if (!poll.success) break;
929
+ reg = poll.result;
930
+ }
931
+
932
+ if (reg?.completed || reg?.state === 'succeeded') {
933
+ return { success: true, message: `Domain ${domainName} registered successfully` };
934
+ }
935
+ return {
936
+ success: false,
937
+ message: `Registration did not complete (state: ${reg?.state ?? 'unknown'}${
938
+ reg?.reason ? `, ${reg.reason}` : ''
939
+ })`,
940
+ };
941
+ } catch (error) {
942
+ return { success: false, message: toRegistrarError(error).message };
943
+ }
944
+ }
945
+
946
+ // Cloudflare exposes no post-registration update endpoint for auto-renew, lock,
947
+ // or nameservers. `auto_renew`, `privacy_mode`, and `locked` can be set only at
948
+ // registration (see `registerDomain`); the legacy PUT edit endpoint refuses
949
+ // these fields (422 "not allowed to perform this action" for auto_renew/locked,
950
+ // 403 for nameservers) while still accepting `privacy` (see `setPrivacy`).
951
+ // Verified live against the account 2026-08-29 on both a gTLD (.dev) and .uk, so
952
+ // this is API-wide, not TLD-specific. Renewals are likewise not in the API yet.
953
+ // These stay NotImplementedError until Cloudflare ships update endpoints; the
954
+ // wiring (patchDomain + a `name_servers`/`auto_renew`/`locked` body) is ready.
955
+ private static readonly UPDATE_UNAVAILABLE =
956
+ "Cloudflare's API has no post-registration update endpoint for it; set it during " +
957
+ 'registration or in the Cloudflare dashboard';
958
+
959
+ override renewDomain(
960
+ _domainName: string,
961
+ _years = 1,
962
+ _opts?: RequestOptions
963
+ ): Promise<OperationResult> {
964
+ return Promise.reject(
965
+ new NotImplementedError(
966
+ `${this.name}: renewDomain is not available — renewals are not yet in the Cloudflare ` +
967
+ 'Registrar API. Enable auto-renew at registration so the domain renews at expiry'
968
+ )
969
+ );
970
+ }
971
+
972
+ override updateNameservers(
973
+ _domainName: string,
974
+ _nameservers: string[],
975
+ _opts?: RequestOptions
976
+ ): Promise<OperationResult> {
977
+ return Promise.reject(
978
+ new NotImplementedError(
979
+ `${this.name}: updateNameservers is not available — Cloudflare Registrar nameserver ` +
980
+ 'changes require contacting Cloudflare Support (the API rejects them with 403)'
981
+ )
982
+ );
983
+ }
984
+
985
+ override lockDomain(_domainName: string, _opts?: RequestOptions): Promise<OperationResult> {
986
+ return Promise.reject(
987
+ new NotImplementedError(
988
+ `${this.name}: lockDomain — ${CloudflareRegistrar.UPDATE_UNAVAILABLE}`
989
+ )
990
+ );
991
+ }
992
+
993
+ override unlockDomain(_domainName: string, _opts?: RequestOptions): Promise<OperationResult> {
994
+ return Promise.reject(
995
+ new NotImplementedError(
996
+ `${this.name}: unlockDomain — ${CloudflareRegistrar.UPDATE_UNAVAILABLE}`
997
+ )
998
+ );
999
+ }
1000
+
1001
+ override setAutoRenew(
1002
+ _domainName: string,
1003
+ _enabled: boolean,
1004
+ _opts?: RequestOptions
1005
+ ): Promise<OperationResult> {
1006
+ return Promise.reject(
1007
+ new NotImplementedError(
1008
+ `${this.name}: setAutoRenew — ${CloudflareRegistrar.UPDATE_UNAVAILABLE}`
1009
+ )
1010
+ );
1011
+ }
1012
+
1013
+ /**
1014
+ * Toggle WHOIS privacy (Cloudflare includes it free). PUT `privacy` on the
1015
+ * registrar domain. Whether the registry actually honors the change is
1016
+ * TLD-dependent, but Cloudflare accepts the update for supported TLDs.
1017
+ */
1018
+ override async setPrivacy(
1019
+ domainName: string,
1020
+ enabled: boolean,
1021
+ opts?: RequestOptions
1022
+ ): Promise<OperationResult> {
1023
+ return this.patchDomain(domainName, { privacy: enabled }, opts);
1024
+ }
1025
+
1026
+ // confirm a domain exists in the account (registrar domains are keyed by name)
1027
+ private async findDomain(domainName: string, opts?: RequestOptions): Promise<CfDomain | null> {
1028
+ const res = await this.http.request<CfEnvelope<CfDomain[]>>({
1029
+ path: this.accountPath,
1030
+ query: { name: domainName },
1031
+ ...opts,
1032
+ });
1033
+ return res.result?.find(d => d.name === domainName) ?? null;
1034
+ }
1035
+
1036
+ // confirm the domain exists then PUT the given fields (registrar API is name-keyed)
1037
+ private async patchDomain(
1038
+ domainName: string,
1039
+ body: Record<string, unknown>,
1040
+ opts?: RequestOptions
1041
+ ): Promise<OperationResult> {
1042
+ try {
1043
+ const domain = await this.findDomain(domainName, opts);
1044
+ if (!domain) return { success: false, message: 'Domain not found' };
1045
+
1046
+ const res = await this.http.request<CfEnvelope<CfDomain>>({
1047
+ method: 'PUT',
1048
+ path: `${this.accountPath}/${encodeURIComponent(domainName)}`,
1049
+ body,
1050
+ ...opts,
1051
+ });
1052
+ return {
1053
+ success: res.success,
1054
+ message: res.success ? 'Domain updated successfully' : 'Failed to update domain',
1055
+ };
1056
+ } catch (error) {
1057
+ return { success: false, message: toRegistrarError(error).message };
1058
+ }
1059
+ }
1060
+ }
1061
+
1062
+ // the Rules phase that holds Single Redirects (URL forwarding) rules.
1063
+ const PHASE_DYNAMIC_REDIRECT = 'http_request_dynamic_redirect';
1064
+ // reserved originless AAAA target for proxied placeholder records (see
1065
+ // https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/#originless-setups)
1066
+ const PLACEHOLDER_AAAA = '100::';
1067
+ // sentinel contents this provider writes for forwarding placeholder records, so
1068
+ // they can be recognized and cleaned up without touching real records.
1069
+ const PLACEHOLDER_CONTENT = new Set(['100::', '192.0.2.0', '192.0.2.1']);
1070
+
1071
+ // extract the single host from a `http.host eq "FQDN"` expression, returned
1072
+ // relative to the zone apex ("@"). Returns null when the expression is not a
1073
+ // simple single-host match (e.g. a dynamic or multi-condition rule).
1074
+ function hostFromExpression(expression: string | undefined, zoneName: string): string | null {
1075
+ if (!expression) return null;
1076
+ const m = /^\(?\s*http\.host eq "([^"]+)"\s*\)?$/.exec(expression.trim());
1077
+ if (!m) return null;
1078
+ const fqdn = m[1].replace(/\.$/, '');
1079
+ const zone = zoneName.replace(/\.$/, '');
1080
+ if (fqdn === zone) return '@';
1081
+ if (fqdn.endsWith(`.${zone}`)) return fqdn.slice(0, -(zone.length + 1));
1082
+ return fqdn;
1083
+ }
1084
+
1085
+ // the first forward destination on an Email Routing rule, or undefined when the
1086
+ // rule has no forward action (e.g. drop or Worker).
1087
+ function forwardAction(rule: CfEmailRule): string | undefined {
1088
+ return rule.actions?.find(a => a.type === 'forward')?.value?.[0];
1089
+ }
1090
+
1091
+ // map a Cloudflare DNS record to the generic DnsRecord shape. `content` holds
1092
+ // the value for every type; MX/SRV carry a `priority`, and SRV's weight/port
1093
+ // live on the nested `data` object.
1094
+ function toDnsRecord(r: CfDnsRecord): DnsRecord {
1095
+ const type = r.type.toUpperCase();
1096
+ const record: DnsRecord = { type, name: r.name, value: r.content ?? '' };
1097
+ if (r.ttl != null) record.ttl = r.ttl;
1098
+ const priority = r.priority ?? r.data?.priority;
1099
+ if ((type === 'MX' || type === 'SRV') && priority != null) record.priority = priority;
1100
+ if (type === 'SRV') {
1101
+ if (r.data?.weight != null) record.weight = r.data.weight;
1102
+ if (r.data?.port != null) record.port = r.data.port;
1103
+ }
1104
+ return record;
1105
+ }
1106
+
1107
+ // resolve a DnsRecord name (relative, "@" apex, or already a FQDN) to the
1108
+ // fully-qualified name Cloudflare's DNS API expects.
1109
+ function toFqdn(name: string, zoneName: string): string {
1110
+ const n = (name ?? '').replace(/\.$/, '');
1111
+ const zone = zoneName.replace(/\.$/, '');
1112
+ if (n === '' || n === '@' || n === zone) return zone;
1113
+ if (n.endsWith(`.${zone}`)) return n;
1114
+ return `${n}.${zone}`;
1115
+ }
1116
+
1117
+ // map a normalized DnsRecord to a Cloudflare dns_records create body. `content`
1118
+ // holds the value for every type except SRV, whose priority/weight/port/target
1119
+ // go on the nested `data` object (Cloudflare derives service/proto from `name`).
1120
+ function toCfDnsBody(record: DnsRecord, type: string, name: string): Record<string, unknown> {
1121
+ if (type === 'SRV') {
1122
+ const body: Record<string, unknown> = {
1123
+ type,
1124
+ name,
1125
+ data: {
1126
+ priority: record.priority ?? 0,
1127
+ weight: record.weight ?? 0,
1128
+ port: record.port ?? 0,
1129
+ target: record.value,
1130
+ },
1131
+ };
1132
+ if (record.ttl != null) body.ttl = record.ttl;
1133
+ return body;
1134
+ }
1135
+ const body: Record<string, unknown> = { type, name, content: record.value };
1136
+ if (record.ttl != null) body.ttl = record.ttl;
1137
+ if (type === 'MX') body.priority = record.priority ?? 0;
1138
+ return body;
1139
+ }
1140
+
1141
+ // parse a Cloudflare price string (major currency units, e.g. "11.20") to a
1142
+ // number, or undefined when absent/unparseable.
1143
+ function toPrice(value: string | undefined): number | undefined {
1144
+ if (value == null || value === '') return undefined;
1145
+ const n = Number(value);
1146
+ return Number.isFinite(n) ? n : undefined;
1147
+ }
1148
+
1149
+ // map the library's Contact to Cloudflare's registrant shape (email/phone plus a
1150
+ // nested postal_info.address). Cloudflare joins first + last into a single name.
1151
+ function toCfRegistrant(c: Contact): Record<string, unknown> {
1152
+ const name = [c.firstName, c.lastName].filter(Boolean).join(' ');
1153
+ const street = [c.address1, c.address2].filter(Boolean).join(', ');
1154
+ const postal: Record<string, unknown> = {
1155
+ name,
1156
+ address: {
1157
+ street,
1158
+ city: c.city,
1159
+ state: c.state,
1160
+ postal_code: c.postalCode,
1161
+ country_code: c.country,
1162
+ },
1163
+ };
1164
+ if (c.organization) postal.organization = c.organization;
1165
+ return { email: c.email, phone: c.phone, postal_info: postal };
1166
+ }
1167
+
1168
+ // resolve after `ms` milliseconds, rejecting early if `signal` aborts. Used to
1169
+ // space out registration status polls. Browser/edge-safe (setTimeout only).
1170
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
1171
+ return new Promise((resolve, reject) => {
1172
+ const fail = () =>
1173
+ reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'));
1174
+ if (signal?.aborted) return fail();
1175
+ const timer = setTimeout(resolve, ms);
1176
+ signal?.addEventListener(
1177
+ 'abort',
1178
+ () => {
1179
+ clearTimeout(timer);
1180
+ fail();
1181
+ },
1182
+ { once: true }
1183
+ );
1184
+ });
1185
+ }
1186
+
1187
+ // map a Cloudflare WHOIS contact to the normalized Contact shape. Optional
1188
+ // fields are set only when non-empty (Cloudflare returns "" for absent values).
1189
+ function toContact(c: CfContact): Contact {
1190
+ const contact: Contact = {
1191
+ firstName: c.first_name ?? '',
1192
+ lastName: c.last_name ?? '',
1193
+ email: c.email ?? '',
1194
+ phone: c.phone ?? '',
1195
+ address1: c.address ?? '',
1196
+ city: c.city ?? '',
1197
+ postalCode: c.zip ?? '',
1198
+ country: c.country ?? '',
1199
+ };
1200
+ if (c.organization) contact.organization = c.organization;
1201
+ if (c.fax) contact.fax = c.fax;
1202
+ if (c.address2) contact.address2 = c.address2;
1203
+ if (c.state) contact.state = c.state;
1204
+ return contact;
1205
+ }