@aoxborrow/registrar-client 0.7.0 → 0.9.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/README.md +55 -43
- package/dist/index.cjs +589 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -1
- package/dist/index.d.ts +30 -1
- package/dist/index.js +589 -47
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/http.ts +26 -2
- package/src/index.ts +1 -0
- package/src/registrars/domain101.ts +709 -0
- package/src/registrars/dynadot.ts +3 -0
- package/src/registrars/index.ts +12 -10
- package/src/registrars/namecheap.ts +11 -5
- package/src/registrars/namecom.ts +8 -12
- package/src/types.ts +6 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthorizationError,
|
|
3
|
+
ConfigurationError,
|
|
4
|
+
ConnectionError,
|
|
5
|
+
InvalidResponseError,
|
|
6
|
+
NotFoundError,
|
|
7
|
+
OutcomeUnknownError,
|
|
8
|
+
ParsingError,
|
|
9
|
+
RegistrarError,
|
|
10
|
+
toRegistrarError,
|
|
11
|
+
} from '../errors';
|
|
12
|
+
import { Feature, type RegistrarFeature } from '../features';
|
|
13
|
+
import { HttpClient, REST_SAFE_METHODS, type RequestConfig } from '../http';
|
|
14
|
+
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
15
|
+
import type {
|
|
16
|
+
ConfigField,
|
|
17
|
+
ConnectionResult,
|
|
18
|
+
DnsRecord,
|
|
19
|
+
Domain,
|
|
20
|
+
DomainAvailability,
|
|
21
|
+
DomainForward,
|
|
22
|
+
ListDomainsOptions,
|
|
23
|
+
OperationResult,
|
|
24
|
+
RegistrarCredentials,
|
|
25
|
+
RegistrarOptions,
|
|
26
|
+
RequestOptions,
|
|
27
|
+
TldPricing,
|
|
28
|
+
} from '../types';
|
|
29
|
+
import { createDomain, filterDomains, normalizeDomain } from '../utils';
|
|
30
|
+
|
|
31
|
+
// 101domain Client API 1.1.0, https://api.101domain.com/api/documentation
|
|
32
|
+
interface Envelope<T> {
|
|
33
|
+
status: string;
|
|
34
|
+
data: T;
|
|
35
|
+
invalid?: string[];
|
|
36
|
+
meta?: { pagination?: { current_page: number; total_pages: number; total: number } };
|
|
37
|
+
}
|
|
38
|
+
interface RawDomain {
|
|
39
|
+
domain_name: string;
|
|
40
|
+
status?: string;
|
|
41
|
+
registered_at?: string | null;
|
|
42
|
+
expires_at?: string | null;
|
|
43
|
+
auto_renew?: boolean;
|
|
44
|
+
registry_statuses?: string[];
|
|
45
|
+
nameservers?: string[];
|
|
46
|
+
}
|
|
47
|
+
interface RawPrice {
|
|
48
|
+
register?: string;
|
|
49
|
+
renew?: string;
|
|
50
|
+
transfer?: string;
|
|
51
|
+
currency: string;
|
|
52
|
+
premium?: boolean;
|
|
53
|
+
term_years?: number;
|
|
54
|
+
}
|
|
55
|
+
interface RawAvailability {
|
|
56
|
+
domain_name: string;
|
|
57
|
+
available: boolean;
|
|
58
|
+
// one entry per term; null when the name isn't available to register
|
|
59
|
+
pricing?: RawPrice[] | null;
|
|
60
|
+
}
|
|
61
|
+
interface RawRecord {
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
type: string;
|
|
65
|
+
value: string;
|
|
66
|
+
ttl: number;
|
|
67
|
+
proxied?: boolean;
|
|
68
|
+
}
|
|
69
|
+
interface RawForward {
|
|
70
|
+
destination: string | null;
|
|
71
|
+
type: '301' | 'cloak' | null;
|
|
72
|
+
}
|
|
73
|
+
type RecordSpec = Record<string, string | number | boolean>;
|
|
74
|
+
|
|
75
|
+
// Response bodies and fetch/parser excerpts can echo private data. Keep typed
|
|
76
|
+
// statuses and Retry-After, and of an error body only the API's own `code` and
|
|
77
|
+
// `message` fields (e.g. NAMESERVERS_NOT_LOCAL), never the raw body.
|
|
78
|
+
class Domain101HttpClient extends HttpClient {
|
|
79
|
+
override async request<T = unknown>(req: RequestConfig): Promise<T> {
|
|
80
|
+
try {
|
|
81
|
+
return await super.request<T>(req);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// A read-only key is valid but scoped: name the missing scope so the
|
|
84
|
+
// caller can say what to grant rather than "403 Forbidden".
|
|
85
|
+
if (error instanceof AuthorizationError) {
|
|
86
|
+
const scope = requiredScope(req);
|
|
87
|
+
if (!scope || error.providerCode !== 'INSUFFICIENT_PERMISSIONS') throw error;
|
|
88
|
+
const scoped = new AuthorizationError(
|
|
89
|
+
`101domain: this API key does not have the ${scope} scope. Create a key with ${scope} to ${SCOPE_PURPOSE[scope]}.`
|
|
90
|
+
);
|
|
91
|
+
scoped.providerCode = error.providerCode;
|
|
92
|
+
throw scoped;
|
|
93
|
+
}
|
|
94
|
+
if (error instanceof OutcomeUnknownError)
|
|
95
|
+
throw new OutcomeUnknownError(
|
|
96
|
+
'101domain: the registrar did not confirm this change. Re-read the domain before trying again.',
|
|
97
|
+
{ feature: error.feature }
|
|
98
|
+
);
|
|
99
|
+
if (error instanceof ParsingError) throw new ParsingError('101domain: invalid JSON response');
|
|
100
|
+
if (error instanceof ConnectionError)
|
|
101
|
+
throw new ConnectionError('101domain: network request failed', { notSent: error.notSent });
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
protected override async toStatusError(response: Response, url: string): Promise<RegistrarError> {
|
|
106
|
+
const detail = await errorDetail(response);
|
|
107
|
+
const error = await super.toStatusError(
|
|
108
|
+
new Response(null, { status: response.status, headers: response.headers }),
|
|
109
|
+
url
|
|
110
|
+
);
|
|
111
|
+
if (detail) {
|
|
112
|
+
error.message = `101domain: ${detail.message}`;
|
|
113
|
+
error.providerCode = detail.code;
|
|
114
|
+
} else if (response.status === 403) {
|
|
115
|
+
// The API host sits behind Cloudflare, whose bot challenge answers
|
|
116
|
+
// some clients with an HTML 403 before the request reaches the API.
|
|
117
|
+
error.message =
|
|
118
|
+
'101domain: the request was refused before reaching the API (403 without an API error)';
|
|
119
|
+
}
|
|
120
|
+
return error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type Scope = 'domains_read' | 'domains_write' | 'dns_read' | 'dns_write';
|
|
125
|
+
const SCOPE_PURPOSE: Record<Scope, string> = {
|
|
126
|
+
domains_read: 'read the domain portfolio',
|
|
127
|
+
domains_write: 'change URL forwarding',
|
|
128
|
+
dns_read: 'read DNS records and nameservers',
|
|
129
|
+
dns_write: 'change DNS records and nameservers',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// The scope an endpoint requires; search, bulk-search and TLD pricing need none.
|
|
133
|
+
function requiredScope(req: RequestConfig): Scope | undefined {
|
|
134
|
+
const write = !REST_SAFE_METHODS.includes((req.method ?? 'GET').toUpperCase());
|
|
135
|
+
if (req.path.startsWith('/dns/')) return write ? 'dns_write' : 'dns_read';
|
|
136
|
+
if (req.path === '/domains' || /^\/domains\/[^/]+(?:\/forwarding)?$/.test(req.path))
|
|
137
|
+
return write ? 'domains_write' : 'domains_read';
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The error envelope's code and message, which are fixed API strings. Returns
|
|
142
|
+
// null for anything else so a stray body is never surfaced.
|
|
143
|
+
async function errorDetail(response: Response): Promise<{ code: string; message: string } | null> {
|
|
144
|
+
try {
|
|
145
|
+
const body = (await response.json()) as { code?: unknown; message?: unknown };
|
|
146
|
+
if (typeof body?.code !== 'string' || typeof body.message !== 'string' || !body.message)
|
|
147
|
+
return null;
|
|
148
|
+
return { code: body.code, message: body.message.replace(/\s+/g, ' ').slice(0, 200) };
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class Domain101Registrar extends BaseRegistrar {
|
|
155
|
+
readonly name = '101domain';
|
|
156
|
+
static override readonly safeMethods = REST_SAFE_METHODS;
|
|
157
|
+
static readonly displayName = '101domain';
|
|
158
|
+
static readonly website = '101domain.com';
|
|
159
|
+
static readonly supportsSandbox = false;
|
|
160
|
+
static readonly configFields: ConfigField[] = [
|
|
161
|
+
{ name: 'apiKey', label: 'API key', type: 'password', required: true },
|
|
162
|
+
];
|
|
163
|
+
static readonly helpText =
|
|
164
|
+
'Create a Bearer API key under My Account > Developer Tools - API & MCP. ' +
|
|
165
|
+
'Use domains_read and dns_read for reads; dns_write for DNS/nameservers and domains_write for forwarding. ' +
|
|
166
|
+
'Only a primary account with 2FA or SSO can create keys. Keys expire after at most one year. ' +
|
|
167
|
+
'Registration, renewal, auto-renew, privacy, lock, contacts and transfers are not available through this API.';
|
|
168
|
+
static override readonly extendedFeatures: readonly RegistrarFeature[] = [
|
|
169
|
+
Feature.GetDomainForwarding,
|
|
170
|
+
Feature.SetDomainForwarding,
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
|
|
174
|
+
const apiKey = credentials.apiKey?.trim() ?? '';
|
|
175
|
+
if (/[\r\n]/.test(apiKey)) throw new ConfigurationError('101domain: invalid API key format');
|
|
176
|
+
const config = {
|
|
177
|
+
baseUrl: selectBaseUrl('101domain', options?.environment, {
|
|
178
|
+
production: 'https://api.101domain.com/v1',
|
|
179
|
+
}),
|
|
180
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
181
|
+
};
|
|
182
|
+
super(credentials, config, options);
|
|
183
|
+
this.http = new Domain101HttpClient({
|
|
184
|
+
...config,
|
|
185
|
+
safeMethods: REST_SAFE_METHODS,
|
|
186
|
+
options: this.options,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
|
|
191
|
+
try {
|
|
192
|
+
await this.request<RawDomain[]>({
|
|
193
|
+
path: '/domains',
|
|
194
|
+
query: { page: 1, per_page: 1 },
|
|
195
|
+
...opts,
|
|
196
|
+
});
|
|
197
|
+
return { success: true, message: 'Connection successful' };
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
|
|
204
|
+
const { search, detailed: _detailed, ...requestOpts } = opts ?? {};
|
|
205
|
+
const result: Domain[] = [];
|
|
206
|
+
const seen = new Set<string>();
|
|
207
|
+
for (let page = 1; ; page++) {
|
|
208
|
+
const response = await this.request<RawDomain[]>({
|
|
209
|
+
path: '/domains',
|
|
210
|
+
query: { page, per_page: 50 },
|
|
211
|
+
...requestOpts,
|
|
212
|
+
});
|
|
213
|
+
if (!Array.isArray(response.data)) throw invalid('domain list');
|
|
214
|
+
const pagination = response.meta?.pagination;
|
|
215
|
+
if (
|
|
216
|
+
!pagination ||
|
|
217
|
+
pagination.current_page !== page ||
|
|
218
|
+
!Number.isInteger(pagination.total_pages) ||
|
|
219
|
+
pagination.total_pages < 0 ||
|
|
220
|
+
pagination.total_pages > 100000 ||
|
|
221
|
+
!Number.isInteger(pagination.total) ||
|
|
222
|
+
pagination.total < 0
|
|
223
|
+
)
|
|
224
|
+
throw invalid('pagination');
|
|
225
|
+
for (const raw of response.data) {
|
|
226
|
+
const domain = toDomain(raw);
|
|
227
|
+
if (seen.has(domain.domainName)) throw invalid('duplicate domain across pages');
|
|
228
|
+
seen.add(domain.domainName);
|
|
229
|
+
result.push(domain);
|
|
230
|
+
}
|
|
231
|
+
if (page >= pagination.total_pages) {
|
|
232
|
+
if (result.length !== pagination.total) throw invalid('incomplete portfolio');
|
|
233
|
+
// The list keeps every order the account ever placed, so drop the
|
|
234
|
+
// names it doesn't hold (as GoDaddy's status filter does).
|
|
235
|
+
return filterDomains(
|
|
236
|
+
result.filter(d => !d.deleted),
|
|
237
|
+
search
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (!response.data.length) throw invalid('empty intermediate page');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
|
|
245
|
+
const domain = toDomain(
|
|
246
|
+
(await this.request<RawDomain>({ path: domainPath(domainName), ...opts })).data
|
|
247
|
+
);
|
|
248
|
+
if (domain.domainName !== normalizeDomain(domainName)) throw invalid('domain identity');
|
|
249
|
+
return domain;
|
|
250
|
+
}
|
|
251
|
+
override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
|
|
252
|
+
const raw = (
|
|
253
|
+
await this.request<string[]>({ path: `${dnsPath(domainName)}/nameservers`, ...opts })
|
|
254
|
+
).data;
|
|
255
|
+
if (!Array.isArray(raw) || raw.some(n => typeof n !== 'string')) throw invalid('nameservers');
|
|
256
|
+
return raw.map(n => n.toLowerCase());
|
|
257
|
+
}
|
|
258
|
+
override async updateNameservers(
|
|
259
|
+
domainName: string,
|
|
260
|
+
nameservers: string[],
|
|
261
|
+
opts?: RequestOptions
|
|
262
|
+
): Promise<OperationResult> {
|
|
263
|
+
const normalized = nameservers.map(n => n.trim().toLowerCase());
|
|
264
|
+
if (
|
|
265
|
+
normalized.length < 2 ||
|
|
266
|
+
normalized.length > 13 ||
|
|
267
|
+
new Set(normalized).size !== normalized.length ||
|
|
268
|
+
normalized.some(n => !/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9-]+$/.test(n))
|
|
269
|
+
)
|
|
270
|
+
throw new ConfigurationError('101domain: provide 2–13 distinct nameserver hostnames');
|
|
271
|
+
try {
|
|
272
|
+
const response = await this.request<{ change_status: string }>({
|
|
273
|
+
...opts,
|
|
274
|
+
method: 'PUT',
|
|
275
|
+
path: `${dnsPath(domainName)}/nameservers`,
|
|
276
|
+
body: { nameservers: normalized },
|
|
277
|
+
});
|
|
278
|
+
if (response.data?.change_status === 'pending')
|
|
279
|
+
return {
|
|
280
|
+
success: true,
|
|
281
|
+
pending: true,
|
|
282
|
+
message:
|
|
283
|
+
'Nameserver change submitted; pending registry processing. Re-read nameservers to confirm completion.',
|
|
284
|
+
};
|
|
285
|
+
if (response.data?.change_status === 'failed')
|
|
286
|
+
return { success: false, message: '101domain rejected the nameserver change' };
|
|
287
|
+
if (response.data?.change_status !== 'completed')
|
|
288
|
+
throw new OutcomeUnknownError(
|
|
289
|
+
'101domain: invalid nameserver change confirmation. Re-read nameservers before trying again.'
|
|
290
|
+
);
|
|
291
|
+
return { success: true, message: 'Nameservers already configured as requested' };
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return failed(error);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
override async checkAvailability(
|
|
298
|
+
domainNames: string[],
|
|
299
|
+
opts?: RequestOptions
|
|
300
|
+
): Promise<DomainAvailability[]> {
|
|
301
|
+
const names = [...new Set(domainNames.map(normalizeDomain))];
|
|
302
|
+
const result: DomainAvailability[] = [];
|
|
303
|
+
for (const domains of chunks(names, 50)) {
|
|
304
|
+
const response = await this.request<RawAvailability[]>({
|
|
305
|
+
...opts,
|
|
306
|
+
method: 'POST',
|
|
307
|
+
path: '/domains/bulk-search',
|
|
308
|
+
body: { domains, pricing_term: 1 },
|
|
309
|
+
});
|
|
310
|
+
if (!Array.isArray(response.data) || (response.invalid?.length ?? 0) > 0)
|
|
311
|
+
throw invalid('availability results');
|
|
312
|
+
const found = new Set<string>();
|
|
313
|
+
for (const raw of response.data) {
|
|
314
|
+
if (typeof raw.domain_name !== 'string' || typeof raw.available !== 'boolean')
|
|
315
|
+
throw invalid('availability result');
|
|
316
|
+
const domainName = normalizeDomain(raw.domain_name);
|
|
317
|
+
if (!domains.includes(domainName) || found.has(domainName))
|
|
318
|
+
throw invalid('availability identity');
|
|
319
|
+
found.add(domainName);
|
|
320
|
+
const price = oneYear(raw.pricing);
|
|
321
|
+
result.push({
|
|
322
|
+
domainName,
|
|
323
|
+
available: raw.available,
|
|
324
|
+
premium: price?.premium,
|
|
325
|
+
price: amount(price?.register),
|
|
326
|
+
renewalPrice: amount(price?.renew),
|
|
327
|
+
currency: price?.currency,
|
|
328
|
+
period: 1,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
if (found.size !== domains.length) throw invalid('incomplete availability results');
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
override async getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing> {
|
|
337
|
+
const name = normalizeDomain(tldOrDomain);
|
|
338
|
+
if (name.startsWith('.') || !name.includes('.')) {
|
|
339
|
+
const tld = name.replace(/^\./, '');
|
|
340
|
+
const response = await this.request<{ pricing: RawPrice[] }>({
|
|
341
|
+
path: `/tlds/${encodeURIComponent('.' + tld)}`,
|
|
342
|
+
query: { pricing_terms: '1' },
|
|
343
|
+
...opts,
|
|
344
|
+
});
|
|
345
|
+
const price = oneYear(response.data?.pricing);
|
|
346
|
+
if (!price) throw new NotFoundError('101domain: no one-year pricing for this TLD');
|
|
347
|
+
return {
|
|
348
|
+
tld,
|
|
349
|
+
currency: price.currency,
|
|
350
|
+
registration: amount(price.register),
|
|
351
|
+
renewal: amount(price.renew),
|
|
352
|
+
transfer: amount(price.transfer),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const response = await this.request<RawAvailability>({
|
|
356
|
+
path: '/domains/search',
|
|
357
|
+
query: { domain_name: name, pricing_terms: '1' },
|
|
358
|
+
...opts,
|
|
359
|
+
});
|
|
360
|
+
const raw = response.data;
|
|
361
|
+
if (!raw || normalizeDomain(raw.domain_name) !== name) throw invalid('pricing identity');
|
|
362
|
+
// Pricing is null for a name that's taken, the account's own included.
|
|
363
|
+
const price = oneYear(raw.pricing);
|
|
364
|
+
if (!price) throw new NotFoundError('101domain: pricing unavailable for this domain');
|
|
365
|
+
return {
|
|
366
|
+
tld: name.slice(name.indexOf('.') + 1),
|
|
367
|
+
currency: price.currency,
|
|
368
|
+
registration: amount(price.register),
|
|
369
|
+
renewal: amount(price.renew),
|
|
370
|
+
transfer: amount(price.transfer),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
|
|
375
|
+
return (await this.rawRecords(domainName, opts)).map(fromRecord);
|
|
376
|
+
}
|
|
377
|
+
// Reconcile by content-derived IDs. Preserve unchanged records, edit by ID, create additions
|
|
378
|
+
// before removing stale records, and stop on any failed batch. Never retry an
|
|
379
|
+
// uncertain write. This replaces the custom records, not the apex NS delegation.
|
|
380
|
+
override async setDnsRecords(
|
|
381
|
+
domainName: string,
|
|
382
|
+
records: DnsRecord[],
|
|
383
|
+
opts?: RequestOptions
|
|
384
|
+
): Promise<OperationResult> {
|
|
385
|
+
const desired = records.map(r => recordSpec(r, domainName));
|
|
386
|
+
const unique = desired.filter(
|
|
387
|
+
(r, i) => desired.findIndex(other => recordSignature(other) === recordSignature(r)) === i
|
|
388
|
+
);
|
|
389
|
+
// Whether an earlier batch already landed, leaving the zone part-updated.
|
|
390
|
+
let changed = false;
|
|
391
|
+
try {
|
|
392
|
+
// Existing records are only compared, never validated as writes: the
|
|
393
|
+
// zone may hold what the writer refuses (an SWA record's automatic TTL,
|
|
394
|
+
// a TTL under 300). Apex NS is the delegation, which this never replaces.
|
|
395
|
+
const stale = (await this.rawRecords(domainName, opts))
|
|
396
|
+
.map(raw => ({ raw, spec: recordSpec(fromRecord(raw), domainName, false) }))
|
|
397
|
+
.filter(({ spec }) => !(spec.type === 'NS' && spec.name === ''));
|
|
398
|
+
const additions: RecordSpec[] = [];
|
|
399
|
+
const edits: RecordSpec[] = [];
|
|
400
|
+
for (const record of unique) {
|
|
401
|
+
const index = stale.findIndex(
|
|
402
|
+
({ spec }) => recordSignature(spec) === recordSignature(record)
|
|
403
|
+
);
|
|
404
|
+
if (index >= 0) stale.splice(index, 1);
|
|
405
|
+
else {
|
|
406
|
+
// The generic DNS shape has no proxy flag. Preserve the existing
|
|
407
|
+
// host/type's SWA proxying when replacing its content or TTL.
|
|
408
|
+
const priorIndex = stale.findIndex(
|
|
409
|
+
({ spec }) => spec.type === record.type && spec.name === record.name
|
|
410
|
+
);
|
|
411
|
+
const prior = priorIndex >= 0 ? stale.splice(priorIndex, 1)[0].raw : undefined;
|
|
412
|
+
if (prior?.proxied) record.proxied = true;
|
|
413
|
+
if (record.ttl === 1 && record.proxied !== true)
|
|
414
|
+
throw new ConfigurationError(
|
|
415
|
+
'101domain: automatic TTL requires an existing proxied SWA record'
|
|
416
|
+
);
|
|
417
|
+
if (prior) {
|
|
418
|
+
const edit: RecordSpec = { ...record, id: prior.id };
|
|
419
|
+
delete edit.name;
|
|
420
|
+
delete edit.type;
|
|
421
|
+
edits.push(edit);
|
|
422
|
+
} else additions.push(record);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
for (const batch of chunks(edits, 25)) {
|
|
426
|
+
const response = await this.request<{ old_id: string; id: string }[]>({
|
|
427
|
+
...opts,
|
|
428
|
+
method: 'PATCH',
|
|
429
|
+
path: `${dnsPath(domainName)}/records`,
|
|
430
|
+
body: { records: batch },
|
|
431
|
+
});
|
|
432
|
+
// IDs change on edit. These records have been removed from the stale
|
|
433
|
+
// set, so neither their old nor new IDs may be deleted below.
|
|
434
|
+
if (
|
|
435
|
+
!Array.isArray(response.data) ||
|
|
436
|
+
response.data.length !== batch.length ||
|
|
437
|
+
new Set(response.data.map(r => r?.old_id)).size !== batch.length ||
|
|
438
|
+
response.data.some(
|
|
439
|
+
r =>
|
|
440
|
+
!r || !batch.some(edit => edit.id === r.old_id) || typeof r.id !== 'string' || !r.id
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
throw new OutcomeUnknownError(
|
|
444
|
+
'101domain: invalid DNS edit confirmation. Re-read the zone before trying again.'
|
|
445
|
+
);
|
|
446
|
+
changed = true;
|
|
447
|
+
}
|
|
448
|
+
for (const batch of chunks(additions, 25)) {
|
|
449
|
+
await this.request({
|
|
450
|
+
...opts,
|
|
451
|
+
method: 'POST',
|
|
452
|
+
path: `${dnsPath(domainName)}/records`,
|
|
453
|
+
body: { records: batch },
|
|
454
|
+
});
|
|
455
|
+
changed = true;
|
|
456
|
+
}
|
|
457
|
+
for (const batch of chunks(stale, 25)) {
|
|
458
|
+
await this.request({
|
|
459
|
+
...opts,
|
|
460
|
+
method: 'DELETE',
|
|
461
|
+
path: `${dnsPath(domainName)}/records`,
|
|
462
|
+
body: { ids: batch.map(({ raw }) => raw.id) },
|
|
463
|
+
});
|
|
464
|
+
changed = true;
|
|
465
|
+
}
|
|
466
|
+
return { success: true, message: 'DNS records updated successfully' };
|
|
467
|
+
} catch (error) {
|
|
468
|
+
const result = failed(error);
|
|
469
|
+
// Nothing was written when the first write was refused (a read-only
|
|
470
|
+
// key, say), so only warn about a partial zone once one may exist.
|
|
471
|
+
if (!changed && !result.outcome) return result;
|
|
472
|
+
return {
|
|
473
|
+
...result,
|
|
474
|
+
message: `${result.message}. DNS replacement is not atomic; read the zone before retrying.`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
override async getDomainForwarding(
|
|
480
|
+
domainName: string,
|
|
481
|
+
opts?: RequestOptions
|
|
482
|
+
): Promise<DomainForward[]> {
|
|
483
|
+
// The domain response explicitly distinguishes no forwarding from a missing
|
|
484
|
+
// domain. A 404 on the forwarding endpoint must not be mistaken for no rule.
|
|
485
|
+
const raw = (
|
|
486
|
+
await this.request<RawDomain & { web_forwarding?: RawForward | null }>({
|
|
487
|
+
path: domainPath(domainName),
|
|
488
|
+
...opts,
|
|
489
|
+
})
|
|
490
|
+
).data;
|
|
491
|
+
if (!raw || normalizeDomain(raw.domain_name) !== normalizeDomain(domainName))
|
|
492
|
+
throw invalid('domain identity');
|
|
493
|
+
const forward = raw.web_forwarding;
|
|
494
|
+
// No rule reads as all-null fields ({destination: null, type: null}).
|
|
495
|
+
if (forward === null || (forward?.destination === null && forward.type === null)) return [];
|
|
496
|
+
if (
|
|
497
|
+
!forward ||
|
|
498
|
+
typeof forward.destination !== 'string' ||
|
|
499
|
+
(forward.type !== '301' && forward.type !== 'cloak')
|
|
500
|
+
)
|
|
501
|
+
throw invalid('forwarding');
|
|
502
|
+
return [
|
|
503
|
+
{
|
|
504
|
+
host: '@',
|
|
505
|
+
url: forward.destination,
|
|
506
|
+
type: forward.type === '301' ? 'permanent' : 'masked',
|
|
507
|
+
},
|
|
508
|
+
];
|
|
509
|
+
}
|
|
510
|
+
override async setDomainForwarding(
|
|
511
|
+
domainName: string,
|
|
512
|
+
forwards: DomainForward[],
|
|
513
|
+
opts?: RequestOptions
|
|
514
|
+
): Promise<OperationResult> {
|
|
515
|
+
if (forwards.length > 1 || forwards.some(f => f.host !== '@' || f.type !== 'permanent'))
|
|
516
|
+
throw new ConfigurationError(
|
|
517
|
+
'101domain: only one permanent apex forwarding rule is supported'
|
|
518
|
+
);
|
|
519
|
+
if (forwards.length) {
|
|
520
|
+
let url: URL;
|
|
521
|
+
try {
|
|
522
|
+
url = new URL(forwards[0].url);
|
|
523
|
+
} catch {
|
|
524
|
+
throw new ConfigurationError('101domain: forwarding needs a valid HTTP(S) URL');
|
|
525
|
+
}
|
|
526
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
|
|
527
|
+
throw new ConfigurationError('101domain: forwarding needs a valid HTTP(S) URL');
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const existing = await this.getDomainForwarding(domainName, opts);
|
|
531
|
+
const path = `${domainPath(domainName)}/forwarding`;
|
|
532
|
+
if (!forwards.length) {
|
|
533
|
+
if (existing.length) await this.request({ ...opts, method: 'DELETE', path });
|
|
534
|
+
} else if (existing[0]?.url !== forwards[0].url || existing[0]?.type !== 'permanent') {
|
|
535
|
+
await this.request({
|
|
536
|
+
...opts,
|
|
537
|
+
method: existing.length ? 'PATCH' : 'POST',
|
|
538
|
+
path,
|
|
539
|
+
body: { destination: forwards[0].url, type: '301' },
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
success: true,
|
|
544
|
+
message: forwards.length ? 'URL forwarding updated' : 'URL forwarding cleared',
|
|
545
|
+
};
|
|
546
|
+
} catch (error) {
|
|
547
|
+
return failed(error);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private async request<T>(req: RequestConfig): Promise<Envelope<T>> {
|
|
552
|
+
const raw = await this.http.request<Envelope<T>>(req);
|
|
553
|
+
if (!raw || typeof raw !== 'object' || raw.status !== 'success' || !('data' in raw)) {
|
|
554
|
+
if (req.call?.intent === 'write' && !REST_SAFE_METHODS.includes(req.method ?? 'GET'))
|
|
555
|
+
throw new OutcomeUnknownError(
|
|
556
|
+
'101domain: invalid write confirmation. Re-read the domain before trying again.'
|
|
557
|
+
);
|
|
558
|
+
throw invalid('response envelope');
|
|
559
|
+
}
|
|
560
|
+
return raw;
|
|
561
|
+
}
|
|
562
|
+
private async rawRecords(domainName: string, opts?: RequestOptions): Promise<RawRecord[]> {
|
|
563
|
+
const raw = (
|
|
564
|
+
await this.request<RawRecord[]>({ path: `${dnsPath(domainName)}/records`, ...opts })
|
|
565
|
+
).data;
|
|
566
|
+
if (
|
|
567
|
+
!Array.isArray(raw) ||
|
|
568
|
+
raw.some(
|
|
569
|
+
r =>
|
|
570
|
+
!r ||
|
|
571
|
+
typeof r.id !== 'string' ||
|
|
572
|
+
!r.id ||
|
|
573
|
+
typeof r.name !== 'string' ||
|
|
574
|
+
typeof r.value !== 'string' ||
|
|
575
|
+
typeof r.type !== 'string' ||
|
|
576
|
+
!Number.isInteger(r.ttl)
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
throw invalid('DNS records');
|
|
580
|
+
if (new Set(raw.map(r => r.id)).size !== raw.length) throw invalid('duplicate DNS ids');
|
|
581
|
+
return raw;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function invalid(part: string): InvalidResponseError {
|
|
586
|
+
return new InvalidResponseError(`101domain: invalid ${part}`);
|
|
587
|
+
}
|
|
588
|
+
function failed(error: unknown): OperationResult {
|
|
589
|
+
return {
|
|
590
|
+
success: false,
|
|
591
|
+
message: toRegistrarError(error).message,
|
|
592
|
+
...(error instanceof OutcomeUnknownError ? { outcome: 'unknown' as const } : {}),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function domainPath(name: string): string {
|
|
596
|
+
return `/domains/${encodeURIComponent(normalizeDomain(name))}`;
|
|
597
|
+
}
|
|
598
|
+
function dnsPath(name: string): string {
|
|
599
|
+
return `/dns/${encodeURIComponent(normalizeDomain(name))}`;
|
|
600
|
+
}
|
|
601
|
+
function chunks<T>(items: T[], size: number): T[][] {
|
|
602
|
+
const result: T[][] = [];
|
|
603
|
+
for (let i = 0; i < items.length; i += size) result.push(items.slice(i, i + size));
|
|
604
|
+
return result;
|
|
605
|
+
}
|
|
606
|
+
// The one-year entry of a per-term price list.
|
|
607
|
+
function oneYear(pricing: RawPrice[] | null | undefined): RawPrice | undefined {
|
|
608
|
+
if (pricing == null) return undefined;
|
|
609
|
+
if (!Array.isArray(pricing)) throw invalid('pricing');
|
|
610
|
+
return pricing.find(p => p?.term_years === 1);
|
|
611
|
+
}
|
|
612
|
+
function amount(raw?: string): number | undefined {
|
|
613
|
+
if (raw == null) return undefined;
|
|
614
|
+
if (!/^\d+(?:\.\d+)?$/.test(raw)) throw invalid('price');
|
|
615
|
+
const value = Number(raw);
|
|
616
|
+
if (!Number.isFinite(value)) throw invalid('price');
|
|
617
|
+
return value;
|
|
618
|
+
}
|
|
619
|
+
function toDomain(raw: RawDomain): Domain {
|
|
620
|
+
if (!raw || typeof raw.domain_name !== 'string' || !raw.domain_name.trim())
|
|
621
|
+
throw invalid('domain');
|
|
622
|
+
return createDomain({
|
|
623
|
+
domainName: normalizeDomain(raw.domain_name),
|
|
624
|
+
registrar: '101domain',
|
|
625
|
+
status: raw.status,
|
|
626
|
+
createdDate: raw.registered_at,
|
|
627
|
+
expirationDate: raw.expires_at,
|
|
628
|
+
autoRenew: raw.auto_renew,
|
|
629
|
+
locked: raw.registry_statuses?.some(s => /^clientTransferProhibited$/i.test(s)),
|
|
630
|
+
nameservers: raw.nameservers,
|
|
631
|
+
deleted: !isHeld(raw),
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
// Statuses for a name the account no longer (or never) held: deleted,
|
|
635
|
+
// cancelled, a denied application, or transferred to another registrar.
|
|
636
|
+
const GONE = new Set(['DELETED', 'CANCELLED', 'DENIED', 'XFER_AWAY']);
|
|
637
|
+
// Whether the account holds this name. Incoming transfers and applications
|
|
638
|
+
// that never registered (no registered_at) aren't held yet either.
|
|
639
|
+
function isHeld(raw: RawDomain): boolean {
|
|
640
|
+
const status = raw.status?.toUpperCase() ?? '';
|
|
641
|
+
return !GONE.has(status) && !status.startsWith('XFER_IN_') && raw.registered_at != null;
|
|
642
|
+
}
|
|
643
|
+
function fromRecord(raw: RawRecord): DnsRecord {
|
|
644
|
+
const common = { name: raw.name || '@', type: raw.type, ttl: raw.ttl };
|
|
645
|
+
if (raw.type === 'MX') {
|
|
646
|
+
const match = /^(\d+)\s+(.+)$/.exec(raw.value);
|
|
647
|
+
if (!match) throw invalid('MX record');
|
|
648
|
+
return { ...common, value: match[2], priority: Number(match[1]) };
|
|
649
|
+
}
|
|
650
|
+
if (raw.type === 'SRV') {
|
|
651
|
+
const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/.exec(raw.value);
|
|
652
|
+
if (!match) throw invalid('SRV record');
|
|
653
|
+
return {
|
|
654
|
+
...common,
|
|
655
|
+
value: match[4],
|
|
656
|
+
priority: Number(match[1]),
|
|
657
|
+
weight: Number(match[2]),
|
|
658
|
+
port: Number(match[3]),
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
return { ...common, value: raw.value };
|
|
662
|
+
}
|
|
663
|
+
// The API's write shape for a record. `strict` applies the write rules; an
|
|
664
|
+
// existing record is read with it off, only to compare against.
|
|
665
|
+
function recordSpec(record: DnsRecord, domain: string, strict = true): RecordSpec {
|
|
666
|
+
const type = record.type.toUpperCase();
|
|
667
|
+
const zone = normalizeDomain(domain);
|
|
668
|
+
let name = record.name.trim().toLowerCase().replace(/\.$/, '');
|
|
669
|
+
if (name === zone || name === '@') name = '';
|
|
670
|
+
else if (name.endsWith('.' + zone)) name = name.slice(0, -zone.length - 1);
|
|
671
|
+
const reject = (message: string) => {
|
|
672
|
+
if (strict) throw new ConfigurationError(`101domain: ${message}`);
|
|
673
|
+
};
|
|
674
|
+
if (
|
|
675
|
+
!['A', 'AAAA', 'CNAME', 'NS', 'MX', 'TXT', 'SRV', 'CAA'].includes(type) ||
|
|
676
|
+
(type === 'NS' && !name)
|
|
677
|
+
)
|
|
678
|
+
reject('unsupported DNS type or apex NS record');
|
|
679
|
+
const ttl = record.ttl ?? 3600;
|
|
680
|
+
if (!Number.isInteger(ttl) || (ttl !== 1 && ttl < 300))
|
|
681
|
+
reject('DNS TTL must be at least 300 seconds');
|
|
682
|
+
const result: RecordSpec = { name, type, ttl };
|
|
683
|
+
if (type === 'MX' || type === 'SRV') {
|
|
684
|
+
if (!Number.isInteger(record.priority) || record.priority! < 0 || record.priority! > 65535)
|
|
685
|
+
reject('MX/SRV priority is required');
|
|
686
|
+
result.priority = record.priority!;
|
|
687
|
+
result.target = record.value;
|
|
688
|
+
if (type === 'SRV') {
|
|
689
|
+
if (![record.weight, record.port].every(n => Number.isInteger(n) && n! >= 0 && n! <= 65535))
|
|
690
|
+
reject('SRV weight and port are required');
|
|
691
|
+
result.weight = record.weight!;
|
|
692
|
+
result.port = record.port!;
|
|
693
|
+
}
|
|
694
|
+
} else if (type === 'CAA') {
|
|
695
|
+
const match = /^(\d+)\s+(\w+)\s+"([\s\S]*)"$/.exec(record.value);
|
|
696
|
+
if (!match || Number(match[1]) > 255) {
|
|
697
|
+
reject('CAA needs flags, tag and quoted value');
|
|
698
|
+
result.value = record.value;
|
|
699
|
+
} else {
|
|
700
|
+
result.flag = Number(match[1]);
|
|
701
|
+
result.tag = match[2];
|
|
702
|
+
result.value = match[3];
|
|
703
|
+
}
|
|
704
|
+
} else result.value = record.value;
|
|
705
|
+
return result;
|
|
706
|
+
}
|
|
707
|
+
function recordSignature(record: RecordSpec): string {
|
|
708
|
+
return JSON.stringify(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
|
|
709
|
+
}
|