@aoxborrow/registrar-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/index.cjs +6798 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +768 -0
- package/dist/index.d.ts +768 -0
- package/dist/index.js +6747 -0
- package/dist/index.js.map +1 -0
- package/package.json +110 -0
- package/src/client.ts +200 -0
- package/src/constants.ts +11 -0
- package/src/errors.ts +192 -0
- package/src/features.ts +104 -0
- package/src/http.ts +230 -0
- package/src/index.ts +39 -0
- package/src/portfolio.ts +65 -0
- package/src/registrar.ts +240 -0
- package/src/registrars/cloudflare.ts +1205 -0
- package/src/registrars/dynadot.ts +1097 -0
- package/src/registrars/gandi.ts +970 -0
- package/src/registrars/godaddy.ts +1342 -0
- package/src/registrars/index.ts +41 -0
- package/src/registrars/namebright.ts +775 -0
- package/src/registrars/namecheap.ts +870 -0
- package/src/registrars/namesilo.ts +997 -0
- package/src/registrars/porkbun.ts +930 -0
- package/src/registrars/spaceship.ts +736 -0
- package/src/types.ts +446 -0
- package/src/utils.ts +135 -0
- package/src/xml.ts +39 -0
|
@@ -0,0 +1,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
|
+
}
|