@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,736 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigField,
|
|
3
|
+
ConnectionResult,
|
|
4
|
+
Contact,
|
|
5
|
+
ContactSet,
|
|
6
|
+
DnsRecord,
|
|
7
|
+
Domain,
|
|
8
|
+
DomainAvailability,
|
|
9
|
+
ListDomainsOptions,
|
|
10
|
+
OperationResult,
|
|
11
|
+
RegisterDomainInput,
|
|
12
|
+
RegistrarOptions,
|
|
13
|
+
RequestOptions,
|
|
14
|
+
TldPricing,
|
|
15
|
+
TransferDomainInput,
|
|
16
|
+
} from '../types';
|
|
17
|
+
import { createDomain, filterDomains, requireConsent, sleep } from '../utils';
|
|
18
|
+
import { NotImplementedError, toRegistrarError } from '../errors';
|
|
19
|
+
import { BaseRegistrar, selectBaseUrl } from '../registrar';
|
|
20
|
+
import { Feature, type RegistrarFeature } from '../features';
|
|
21
|
+
import type { RegistrarCredentials } from '../types';
|
|
22
|
+
|
|
23
|
+
interface SpaceshipNameservers {
|
|
24
|
+
provider?: string;
|
|
25
|
+
hosts?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SpaceshipPrivacy {
|
|
29
|
+
contactForm?: boolean;
|
|
30
|
+
level?: string; // "public" | "high"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SpaceshipDomainContacts {
|
|
34
|
+
registrant?: string;
|
|
35
|
+
admin?: string;
|
|
36
|
+
tech?: string;
|
|
37
|
+
billing?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SpaceshipDomain {
|
|
41
|
+
name: string;
|
|
42
|
+
unicodeName?: string;
|
|
43
|
+
isPremium?: boolean;
|
|
44
|
+
autoRenew?: boolean;
|
|
45
|
+
registrationDate?: string;
|
|
46
|
+
expirationDate?: string;
|
|
47
|
+
lifecycleStatus?: string;
|
|
48
|
+
eppStatuses?: string[];
|
|
49
|
+
privacyProtection?: SpaceshipPrivacy;
|
|
50
|
+
nameservers?: SpaceshipNameservers;
|
|
51
|
+
contacts?: SpaceshipDomainContacts;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SpaceshipPage<T> {
|
|
55
|
+
items?: T[];
|
|
56
|
+
total?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// one premium price point from an availability check
|
|
60
|
+
interface SpaceshipPremiumPrice {
|
|
61
|
+
operation?: string; // "register" | "transfer" | "renew" | "restore"
|
|
62
|
+
price?: number;
|
|
63
|
+
currency?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface SpaceshipAvailability {
|
|
67
|
+
domain?: string;
|
|
68
|
+
result?: string; // "available" | "taken" | "invalidDomainName" | ...
|
|
69
|
+
premiumPricing?: SpaceshipPremiumPrice[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// a contact record (ContactDetails)
|
|
73
|
+
interface SpaceshipContact {
|
|
74
|
+
firstName?: string;
|
|
75
|
+
lastName?: string;
|
|
76
|
+
organization?: string;
|
|
77
|
+
email?: string;
|
|
78
|
+
address1?: string;
|
|
79
|
+
address2?: string;
|
|
80
|
+
city?: string;
|
|
81
|
+
stateProvince?: string;
|
|
82
|
+
postalCode?: string;
|
|
83
|
+
country?: string;
|
|
84
|
+
phone?: string;
|
|
85
|
+
phoneExt?: string;
|
|
86
|
+
fax?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// a DNS record (ResourceRecord — a discriminated union on `type`)
|
|
90
|
+
interface SpaceshipRecord {
|
|
91
|
+
type?: string;
|
|
92
|
+
name?: string;
|
|
93
|
+
ttl?: number;
|
|
94
|
+
group?: { type?: string }; // "custom" | "product" | "personalNs" (read-only)
|
|
95
|
+
address?: string; // A / AAAA
|
|
96
|
+
cname?: string; // CNAME
|
|
97
|
+
nameserver?: string; // NS
|
|
98
|
+
value?: string; // TXT / CAA
|
|
99
|
+
exchange?: string; // MX
|
|
100
|
+
preference?: number; // MX priority
|
|
101
|
+
target?: string; // SRV / ALIAS
|
|
102
|
+
priority?: number; // SRV
|
|
103
|
+
weight?: number; // SRV
|
|
104
|
+
port?: number; // SRV
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// DNS types this provider can write from our generic DnsRecord shape. Others
|
|
108
|
+
// (SRV, CAA, HTTPS, …) need sub-fields our record doesn't carry, so writing them
|
|
109
|
+
// throws rather than sending an incomplete record. Reads handle every type.
|
|
110
|
+
const WRITABLE_DNS_TYPES = new Set(['A', 'AAAA', 'CNAME', 'NS', 'TXT', 'MX']);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Spaceship Registrar
|
|
114
|
+
* API docs: https://docs.spaceship.dev/
|
|
115
|
+
*
|
|
116
|
+
* Credentials: generate an API key + secret in the Spaceship API Manager
|
|
117
|
+
* ("New API key"). Both are sent as X-Api-Key and X-Api-Secret headers.
|
|
118
|
+
*
|
|
119
|
+
* Modern REST/JSON API (`/api/v1`). A few operations are asynchronous — register,
|
|
120
|
+
* transfer, and renew return `202` with a `spaceship-async-operationid` header
|
|
121
|
+
* and complete out of band (poll `GET /async-operations/{id}`); the rest are
|
|
122
|
+
* synchronous 2xx/204.
|
|
123
|
+
*
|
|
124
|
+
* `registerDomain`/`transferIn` are implemented (async, 202) and require
|
|
125
|
+
* per-call `consent`; they spend real money and haven't been exercised against a
|
|
126
|
+
* funded account, so treat them as documented-but-unverified.
|
|
127
|
+
*
|
|
128
|
+
* `getPricing` is overridden to throw a specific error: Spaceship exposes no
|
|
129
|
+
* pricing endpoint at all (only per-domain premium prices via an availability
|
|
130
|
+
* check), so there's no TLD price to return.
|
|
131
|
+
*/
|
|
132
|
+
export class SpaceshipRegistrar extends BaseRegistrar {
|
|
133
|
+
readonly name = 'spaceship';
|
|
134
|
+
|
|
135
|
+
static readonly displayName = 'Spaceship';
|
|
136
|
+
static readonly helpText =
|
|
137
|
+
'Generate your API key and secret in the API Manager at Spaceship using the ' +
|
|
138
|
+
'"New API key" button. The API requires both X-API-Key and X-API-Secret headers.';
|
|
139
|
+
static readonly configFields: ConfigField[] = [
|
|
140
|
+
{ name: 'apiKey', label: 'API Key', type: 'password', required: true },
|
|
141
|
+
{ name: 'apiSecret', label: 'API Secret', type: 'password', required: true },
|
|
142
|
+
];
|
|
143
|
+
static readonly supportsSandbox = false; // Spaceship has no public sandbox environment
|
|
144
|
+
// Modern REST API. Beyond core: transfer-out auth-code retrieval
|
|
145
|
+
// (GET .../transfer/auth-code). No DNSSEC or forwarding via the API; Spacemail
|
|
146
|
+
// (email) has no public API.
|
|
147
|
+
static readonly extendedFeatures: readonly RegistrarFeature[] = [Feature.GetAuthCode];
|
|
148
|
+
|
|
149
|
+
constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
|
|
150
|
+
super(
|
|
151
|
+
credentials,
|
|
152
|
+
{
|
|
153
|
+
baseUrl: selectBaseUrl('Spaceship', options?.environment, {
|
|
154
|
+
production: 'https://spaceship.dev/api',
|
|
155
|
+
}),
|
|
156
|
+
headers: {
|
|
157
|
+
'X-API-Key': credentials.apiKey,
|
|
158
|
+
'X-API-Secret': credentials.apiSecret,
|
|
159
|
+
'Content-Type': 'application/json',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
options
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
|
|
167
|
+
try {
|
|
168
|
+
await this.http.request<SpaceshipPage<SpaceshipDomain>>({
|
|
169
|
+
path: '/v1/domains',
|
|
170
|
+
query: { take: 1, skip: 0 },
|
|
171
|
+
...opts,
|
|
172
|
+
});
|
|
173
|
+
return { success: true, message: 'Connection successful' };
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
|
|
180
|
+
// No server-side name filter on this endpoint, so `search` is client-side.
|
|
181
|
+
const { search, ...reqOpts } = opts ?? {};
|
|
182
|
+
const domains: Domain[] = [];
|
|
183
|
+
const take = 100; // Spaceship API maximum page size (1-100)
|
|
184
|
+
let skip = 0;
|
|
185
|
+
let hasMore = true;
|
|
186
|
+
|
|
187
|
+
while (hasMore) {
|
|
188
|
+
const res = await this.http.request<SpaceshipPage<SpaceshipDomain>>({
|
|
189
|
+
path: '/v1/domains',
|
|
190
|
+
query: { take, skip },
|
|
191
|
+
...reqOpts,
|
|
192
|
+
});
|
|
193
|
+
const list = res.items ?? [];
|
|
194
|
+
for (const d of list) domains.push(this.toDomain(d));
|
|
195
|
+
hasMore = list.length === take;
|
|
196
|
+
skip += take;
|
|
197
|
+
if (hasMore) await sleep(200); // gentle rate limiting between pages
|
|
198
|
+
}
|
|
199
|
+
return filterDomains(domains, search);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
|
|
203
|
+
return this.toDomain(await this.getRaw(domainName, opts));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
|
|
207
|
+
const d = await this.getRaw(domainName, opts);
|
|
208
|
+
return d.nameservers?.hosts ?? [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Transfer authorization (EPP) code via GET /v1/domains/{domain}/transfer/auth-code.
|
|
213
|
+
* Synchronous; the code is returned in `authCode`. Requires the API key to
|
|
214
|
+
* carry the `domains:transfer` scope.
|
|
215
|
+
*/
|
|
216
|
+
override async getAuthCode(domainName: string, opts?: RequestOptions): Promise<string> {
|
|
217
|
+
const res = await this.http.request<{ authCode?: string }>({
|
|
218
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/transfer/auth-code`,
|
|
219
|
+
...opts,
|
|
220
|
+
});
|
|
221
|
+
return res.authCode ?? '';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Bulk availability via POST /v1/domains/available. Spaceship reports a `result`
|
|
226
|
+
* enum (not a bool) and only carries a price for *premium* names, so `price` is
|
|
227
|
+
* set from the premium "register" price when present.
|
|
228
|
+
*/
|
|
229
|
+
override async checkAvailability(
|
|
230
|
+
domainNames: string[],
|
|
231
|
+
opts?: RequestOptions
|
|
232
|
+
): Promise<DomainAvailability[]> {
|
|
233
|
+
const res = await this.http.request<{ domains?: SpaceshipAvailability[] }>({
|
|
234
|
+
method: 'POST',
|
|
235
|
+
path: '/v1/domains/available',
|
|
236
|
+
body: { domains: domainNames },
|
|
237
|
+
...opts,
|
|
238
|
+
});
|
|
239
|
+
return (res.domains ?? []).map(a => {
|
|
240
|
+
const premium = (a.premiumPricing?.length ?? 0) > 0;
|
|
241
|
+
const registerPrice = a.premiumPricing?.find(p => p.operation === 'register');
|
|
242
|
+
return {
|
|
243
|
+
domainName: a.domain ?? '',
|
|
244
|
+
available: a.result === 'available',
|
|
245
|
+
premium,
|
|
246
|
+
price: registerPrice?.price,
|
|
247
|
+
currency: registerPrice?.currency,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
override getPricing(_tldOrDomain: string, _opts?: RequestOptions): Promise<TldPricing> {
|
|
253
|
+
return Promise.reject(
|
|
254
|
+
new NotImplementedError(
|
|
255
|
+
`${this.name}: getPricing is not available — Spaceship exposes no pricing endpoint ` +
|
|
256
|
+
'(only per-domain premium prices via an availability check)'
|
|
257
|
+
)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Registers a domain. Contacts are separate resources, so this saves each
|
|
263
|
+
* supplied contact (getting an id) and references the ids on the domain;
|
|
264
|
+
* omitted roles fall back to the registrant. Registration is async (202).
|
|
265
|
+
*/
|
|
266
|
+
override async registerDomain(
|
|
267
|
+
domainName: string,
|
|
268
|
+
input: RegisterDomainInput,
|
|
269
|
+
opts?: RequestOptions
|
|
270
|
+
): Promise<OperationResult> {
|
|
271
|
+
requireConsent(this.name, input.consent);
|
|
272
|
+
if (!input.contacts.registrant) {
|
|
273
|
+
throw new Error(`${this.name}: registration requires at least a registrant contact`);
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const contacts = await this.saveContactSet(input.contacts, opts);
|
|
277
|
+
const body = {
|
|
278
|
+
autoRenew: input.autoRenew ?? false,
|
|
279
|
+
years: input.years ?? 1,
|
|
280
|
+
privacyProtection: { level: input.privacy ? 'high' : 'public', userConsent: true },
|
|
281
|
+
contacts,
|
|
282
|
+
};
|
|
283
|
+
await this.http.request({
|
|
284
|
+
method: 'POST',
|
|
285
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}`,
|
|
286
|
+
body,
|
|
287
|
+
...opts,
|
|
288
|
+
});
|
|
289
|
+
return { success: true, message: `Domain ${domainName} registration requested successfully` };
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Transfers a domain in with its auth code. Contacts are optional (Spaceship
|
|
297
|
+
* carries over the existing registration's where not supplied); when given,
|
|
298
|
+
* they're saved and referenced by id. Async (202).
|
|
299
|
+
*/
|
|
300
|
+
override async transferIn(
|
|
301
|
+
domainName: string,
|
|
302
|
+
input: TransferDomainInput,
|
|
303
|
+
opts?: RequestOptions
|
|
304
|
+
): Promise<OperationResult> {
|
|
305
|
+
requireConsent(this.name, input.consent);
|
|
306
|
+
try {
|
|
307
|
+
const body: Record<string, unknown> = {
|
|
308
|
+
authCode: input.authCode,
|
|
309
|
+
autoRenew: input.autoRenew ?? false,
|
|
310
|
+
};
|
|
311
|
+
if (input.privacy != null) {
|
|
312
|
+
body.privacyProtection = { level: input.privacy ? 'high' : 'public', userConsent: true };
|
|
313
|
+
}
|
|
314
|
+
if (input.contacts) body.contacts = await this.saveContactSet(input.contacts, opts);
|
|
315
|
+
await this.http.request({
|
|
316
|
+
method: 'POST',
|
|
317
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/transfer`,
|
|
318
|
+
body,
|
|
319
|
+
...opts,
|
|
320
|
+
});
|
|
321
|
+
return { success: true, message: `Domain ${domainName} transfer requested successfully` };
|
|
322
|
+
} catch (error) {
|
|
323
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Renewal is async (202 + operation id). Spaceship requires the current
|
|
329
|
+
* expiration date as a guard, so this fetches the domain first to supply it.
|
|
330
|
+
*/
|
|
331
|
+
override async renewDomain(
|
|
332
|
+
domainName: string,
|
|
333
|
+
years = 1,
|
|
334
|
+
opts?: RequestOptions
|
|
335
|
+
): Promise<OperationResult> {
|
|
336
|
+
const d = await this.getRaw(domainName, opts);
|
|
337
|
+
if (!d.expirationDate) {
|
|
338
|
+
throw new Error(`Spaceship: could not determine current expiration date for ${domainName}`);
|
|
339
|
+
}
|
|
340
|
+
return this.mutate(
|
|
341
|
+
{
|
|
342
|
+
method: 'POST',
|
|
343
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/renew`,
|
|
344
|
+
body: { years, currentExpirationDate: d.expirationDate },
|
|
345
|
+
},
|
|
346
|
+
'Domain renewal requested successfully',
|
|
347
|
+
opts
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
override async setAutoRenew(
|
|
352
|
+
domainName: string,
|
|
353
|
+
enabled: boolean,
|
|
354
|
+
opts?: RequestOptions
|
|
355
|
+
): Promise<OperationResult> {
|
|
356
|
+
return this.mutate(
|
|
357
|
+
{
|
|
358
|
+
method: 'PUT',
|
|
359
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/autorenew`,
|
|
360
|
+
body: { isEnabled: enabled },
|
|
361
|
+
},
|
|
362
|
+
`Auto-renew ${enabled ? 'enabled' : 'disabled'} successfully`,
|
|
363
|
+
opts
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
override async updateNameservers(
|
|
368
|
+
domainName: string,
|
|
369
|
+
nameservers: string[],
|
|
370
|
+
opts?: RequestOptions
|
|
371
|
+
): Promise<OperationResult> {
|
|
372
|
+
return this.mutate(
|
|
373
|
+
{
|
|
374
|
+
method: 'PUT',
|
|
375
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/nameservers`,
|
|
376
|
+
// custom nameservers go under a provider wrapper
|
|
377
|
+
body: { provider: 'custom', hosts: nameservers },
|
|
378
|
+
},
|
|
379
|
+
'Nameservers updated successfully',
|
|
380
|
+
opts
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
override async lockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
385
|
+
return this.mutate(
|
|
386
|
+
{
|
|
387
|
+
method: 'PUT',
|
|
388
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/transfer/lock`,
|
|
389
|
+
body: { isLocked: true },
|
|
390
|
+
},
|
|
391
|
+
'Domain transfer lock enabled successfully',
|
|
392
|
+
opts
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
override async unlockDomain(domainName: string, opts?: RequestOptions): Promise<OperationResult> {
|
|
397
|
+
return this.mutate(
|
|
398
|
+
{
|
|
399
|
+
method: 'PUT',
|
|
400
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/transfer/lock`,
|
|
401
|
+
body: { isLocked: false },
|
|
402
|
+
},
|
|
403
|
+
'Domain transfer lock disabled successfully',
|
|
404
|
+
opts
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* WHOIS privacy via the privacy preference (`public` = details visible, `high`
|
|
410
|
+
* = protected). Enabling requires consent, which calling this with
|
|
411
|
+
* `enabled = true` supplies.
|
|
412
|
+
*/
|
|
413
|
+
override async setPrivacy(
|
|
414
|
+
domainName: string,
|
|
415
|
+
enabled: boolean,
|
|
416
|
+
opts?: RequestOptions
|
|
417
|
+
): Promise<OperationResult> {
|
|
418
|
+
return this.mutate(
|
|
419
|
+
{
|
|
420
|
+
method: 'PUT',
|
|
421
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/privacy/preference`,
|
|
422
|
+
body: { privacyLevel: enabled ? 'high' : 'public', userConsent: enabled },
|
|
423
|
+
},
|
|
424
|
+
`Privacy ${enabled ? 'enabled' : 'disabled'} successfully`,
|
|
425
|
+
opts
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
override async getContacts(domainName: string, opts?: RequestOptions): Promise<ContactSet> {
|
|
430
|
+
const d = await this.getRaw(domainName, opts);
|
|
431
|
+
const refs = d.contacts ?? {};
|
|
432
|
+
// contacts are referenced by id; resolve each present role in parallel
|
|
433
|
+
const [registrant, admin, tech, billing] = await Promise.all([
|
|
434
|
+
this.getContact(refs.registrant, opts),
|
|
435
|
+
this.getContact(refs.admin, opts),
|
|
436
|
+
this.getContact(refs.tech, opts),
|
|
437
|
+
this.getContact(refs.billing, opts),
|
|
438
|
+
]);
|
|
439
|
+
return { registrant, admin, tech, billing };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Two-step: save each supplied contact (which returns a contact id), then
|
|
444
|
+
* assign the ids to the domain. Spaceship requires a registrant on assignment,
|
|
445
|
+
* so any omitted role falls back to the registrant.
|
|
446
|
+
*/
|
|
447
|
+
override async updateContacts(
|
|
448
|
+
domainName: string,
|
|
449
|
+
contacts: ContactSet,
|
|
450
|
+
opts?: RequestOptions
|
|
451
|
+
): Promise<OperationResult> {
|
|
452
|
+
if (!contacts.registrant) {
|
|
453
|
+
throw new Error(`${this.name}: updateContacts requires at least a registrant contact`);
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const contactIds = await this.saveContactSet(contacts, opts);
|
|
457
|
+
await this.http.request({
|
|
458
|
+
method: 'PUT',
|
|
459
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}/contacts`,
|
|
460
|
+
body: contactIds,
|
|
461
|
+
...opts,
|
|
462
|
+
});
|
|
463
|
+
return { success: true, message: 'Contacts updated successfully' };
|
|
464
|
+
} catch (error) {
|
|
465
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
|
|
470
|
+
const records = await this.fetchRecords(domainName, opts);
|
|
471
|
+
return records.map(toDnsRecord);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Spaceship's DNS API is upsert-based (PUT adds/updates; there is no atomic
|
|
476
|
+
* replace). To honor replace semantics, this upserts the new set, then deletes
|
|
477
|
+
* any custom records whose (type, name) is no longer present — done in that
|
|
478
|
+
* order so the zone is never momentarily empty. Only the common record types
|
|
479
|
+
* are writable from the generic shape (see `WRITABLE_DNS_TYPES`).
|
|
480
|
+
*/
|
|
481
|
+
override async setDnsRecords(
|
|
482
|
+
domainName: string,
|
|
483
|
+
records: DnsRecord[],
|
|
484
|
+
opts?: RequestOptions
|
|
485
|
+
): Promise<OperationResult> {
|
|
486
|
+
const items = records.map(toSpaceshipRecord);
|
|
487
|
+
try {
|
|
488
|
+
const existing = await this.fetchRecords(domainName, opts);
|
|
489
|
+
|
|
490
|
+
// The upsert endpoint rejects an empty `items` array (422), so skip it when
|
|
491
|
+
// there's nothing to add/replace — clearing a zone is then handled entirely
|
|
492
|
+
// by the stale-delete pass below.
|
|
493
|
+
if (items.length > 0) {
|
|
494
|
+
await this.http.request({
|
|
495
|
+
method: 'PUT',
|
|
496
|
+
path: `/v1/dns/records/${encodeURIComponent(domainName)}`,
|
|
497
|
+
body: { force: true, items },
|
|
498
|
+
...opts,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// delete custom records whose (type, name) no longer appears in the new
|
|
503
|
+
// set. Spaceship's delete endpoint matches on the full record, so it needs
|
|
504
|
+
// each record's value field (address/cname/value/exchange/…) — send the
|
|
505
|
+
// existing records with only the read-only `group` metadata stripped.
|
|
506
|
+
const keep = new Set(items.map(r => recordKey(r.type, r.name)));
|
|
507
|
+
const stale = existing
|
|
508
|
+
.filter(r => (r.group?.type ?? 'custom') === 'custom')
|
|
509
|
+
.filter(r => !keep.has(recordKey(r.type, r.name)))
|
|
510
|
+
.map(({ group: _group, ...rr }) => rr);
|
|
511
|
+
if (stale.length > 0) {
|
|
512
|
+
await this.http.request({
|
|
513
|
+
method: 'DELETE',
|
|
514
|
+
path: `/v1/dns/records/${encodeURIComponent(domainName)}`,
|
|
515
|
+
body: stale,
|
|
516
|
+
...opts,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
return { success: true, message: 'DNS records updated successfully' };
|
|
520
|
+
} catch (error) {
|
|
521
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// --- internal helpers ---
|
|
526
|
+
|
|
527
|
+
// GET a single domain's raw payload
|
|
528
|
+
private getRaw(domainName: string, opts?: RequestOptions): Promise<SpaceshipDomain> {
|
|
529
|
+
return this.http.request<SpaceshipDomain>({
|
|
530
|
+
path: `/v1/domains/${encodeURIComponent(domainName)}`,
|
|
531
|
+
...opts,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// resolve one contact id to a normalized Contact (undefined if unset)
|
|
536
|
+
private async getContact(
|
|
537
|
+
id: string | undefined,
|
|
538
|
+
opts?: RequestOptions
|
|
539
|
+
): Promise<Contact | undefined> {
|
|
540
|
+
if (!id) return undefined;
|
|
541
|
+
const c = await this.http.request<SpaceshipContact>({
|
|
542
|
+
path: `/v1/contacts/${encodeURIComponent(id)}`,
|
|
543
|
+
...opts,
|
|
544
|
+
});
|
|
545
|
+
return fromSpaceshipContact(c);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// save all four contact roles and return their ids (Spaceship requires a
|
|
549
|
+
// registrant; omitted roles fall back to it)
|
|
550
|
+
private async saveContactSet(
|
|
551
|
+
contacts: ContactSet,
|
|
552
|
+
opts?: RequestOptions
|
|
553
|
+
): Promise<SpaceshipDomainContacts> {
|
|
554
|
+
if (!contacts.registrant) {
|
|
555
|
+
throw new Error(`${this.name}: a registrant contact is required`);
|
|
556
|
+
}
|
|
557
|
+
const registrant = await this.saveContact(contacts.registrant, opts);
|
|
558
|
+
return {
|
|
559
|
+
registrant,
|
|
560
|
+
admin: contacts.admin ? await this.saveContact(contacts.admin, opts) : registrant,
|
|
561
|
+
tech: contacts.tech ? await this.saveContact(contacts.tech, opts) : registrant,
|
|
562
|
+
billing: contacts.billing ? await this.saveContact(contacts.billing, opts) : registrant,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// save a contact and return its Spaceship contact id
|
|
567
|
+
private async saveContact(contact: Contact, opts?: RequestOptions): Promise<string> {
|
|
568
|
+
const res = await this.http.request<{ contactId: string }>({
|
|
569
|
+
method: 'PUT',
|
|
570
|
+
path: '/v1/contacts',
|
|
571
|
+
body: toSpaceshipContact(contact),
|
|
572
|
+
...opts,
|
|
573
|
+
});
|
|
574
|
+
return res.contactId;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// fetch all DNS records for a domain (paginated; take/skip are required)
|
|
578
|
+
private async fetchRecords(
|
|
579
|
+
domainName: string,
|
|
580
|
+
opts?: RequestOptions
|
|
581
|
+
): Promise<SpaceshipRecord[]> {
|
|
582
|
+
const records: SpaceshipRecord[] = [];
|
|
583
|
+
const take = 100;
|
|
584
|
+
let skip = 0;
|
|
585
|
+
let hasMore = true;
|
|
586
|
+
while (hasMore) {
|
|
587
|
+
const res = await this.http.request<SpaceshipPage<SpaceshipRecord>>({
|
|
588
|
+
path: `/v1/dns/records/${encodeURIComponent(domainName)}`,
|
|
589
|
+
query: { take, skip },
|
|
590
|
+
...opts,
|
|
591
|
+
});
|
|
592
|
+
const items = res.items ?? [];
|
|
593
|
+
records.push(...items);
|
|
594
|
+
hasMore = items.length === take;
|
|
595
|
+
skip += take;
|
|
596
|
+
if (hasMore) await sleep(200);
|
|
597
|
+
}
|
|
598
|
+
return records;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// map a Spaceship domain payload to the normalized Domain shape
|
|
602
|
+
private toDomain(d: SpaceshipDomain): Domain {
|
|
603
|
+
return createDomain({
|
|
604
|
+
domainName: d.name,
|
|
605
|
+
registrar: this.name,
|
|
606
|
+
status: d.lifecycleStatus ?? '',
|
|
607
|
+
createdDate: d.registrationDate,
|
|
608
|
+
expirationDate: d.expirationDate,
|
|
609
|
+
renewalDate: d.expirationDate,
|
|
610
|
+
autoRenew: d.autoRenew ?? false,
|
|
611
|
+
// transfer lock shows up as an EPP status, not a dedicated flag
|
|
612
|
+
locked: (d.eppStatuses ?? []).includes('clientTransferProhibited'),
|
|
613
|
+
privacy: d.privacyProtection?.level === 'high',
|
|
614
|
+
// normalizeNameservers understands the { hosts } shape
|
|
615
|
+
nameservers: d.nameservers,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// run a mutating request; success is any 2xx (async ops return 202)
|
|
620
|
+
private async mutate(
|
|
621
|
+
req: { method: string; path: string; body?: unknown },
|
|
622
|
+
successMessage: string,
|
|
623
|
+
opts?: RequestOptions
|
|
624
|
+
): Promise<OperationResult> {
|
|
625
|
+
try {
|
|
626
|
+
await this.http.request({ ...req, ...opts });
|
|
627
|
+
return { success: true, message: successMessage };
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return { success: false, message: toRegistrarError(error).message };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// --- record mapping between Spaceship's per-type shape and our DnsRecord ---
|
|
635
|
+
|
|
636
|
+
// extract the primary value for a Spaceship record based on its type
|
|
637
|
+
function spaceshipRecordValue(r: SpaceshipRecord, type: string): string {
|
|
638
|
+
switch (type) {
|
|
639
|
+
case 'A':
|
|
640
|
+
case 'AAAA':
|
|
641
|
+
return r.address ?? '';
|
|
642
|
+
case 'CNAME':
|
|
643
|
+
return r.cname ?? '';
|
|
644
|
+
case 'NS':
|
|
645
|
+
return r.nameserver ?? '';
|
|
646
|
+
case 'MX':
|
|
647
|
+
return r.exchange ?? '';
|
|
648
|
+
case 'SRV':
|
|
649
|
+
case 'ALIAS':
|
|
650
|
+
return r.target ?? '';
|
|
651
|
+
default:
|
|
652
|
+
return r.value ?? '';
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function toDnsRecord(r: SpaceshipRecord): DnsRecord {
|
|
657
|
+
const type = (r.type ?? '').toUpperCase();
|
|
658
|
+
const record: DnsRecord = { type, name: r.name ?? '', value: spaceshipRecordValue(r, type) };
|
|
659
|
+
if (r.ttl != null) record.ttl = r.ttl;
|
|
660
|
+
if (type === 'MX' && r.preference != null) record.priority = r.preference;
|
|
661
|
+
if (type === 'SRV') {
|
|
662
|
+
if (r.priority != null) record.priority = r.priority;
|
|
663
|
+
if (r.weight != null) record.weight = r.weight;
|
|
664
|
+
if (r.port != null) record.port = r.port;
|
|
665
|
+
}
|
|
666
|
+
return record;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function toSpaceshipRecord(r: DnsRecord): SpaceshipRecord {
|
|
670
|
+
const type = r.type.toUpperCase();
|
|
671
|
+
if (!WRITABLE_DNS_TYPES.has(type)) {
|
|
672
|
+
throw new Error(`Spaceship: writing DNS record type '${type}' is not supported`);
|
|
673
|
+
}
|
|
674
|
+
const record: SpaceshipRecord = { type, name: r.name || '@' };
|
|
675
|
+
if (r.ttl != null) record.ttl = r.ttl;
|
|
676
|
+
switch (type) {
|
|
677
|
+
case 'A':
|
|
678
|
+
case 'AAAA':
|
|
679
|
+
record.address = r.value;
|
|
680
|
+
break;
|
|
681
|
+
case 'CNAME':
|
|
682
|
+
record.cname = r.value;
|
|
683
|
+
break;
|
|
684
|
+
case 'NS':
|
|
685
|
+
record.nameserver = r.value;
|
|
686
|
+
break;
|
|
687
|
+
case 'MX':
|
|
688
|
+
record.exchange = r.value;
|
|
689
|
+
record.preference = r.priority ?? 10;
|
|
690
|
+
break;
|
|
691
|
+
default: // TXT
|
|
692
|
+
record.value = r.value;
|
|
693
|
+
}
|
|
694
|
+
return record;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// a stable key for a (type, name) pair, for set membership
|
|
698
|
+
function recordKey(type: string | undefined, name: string | undefined): string {
|
|
699
|
+
return `${(type ?? '').toUpperCase()}\x00${name ?? '@'}`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// --- contact mapping between Spaceship's shape and the normalized Contact ---
|
|
703
|
+
|
|
704
|
+
function fromSpaceshipContact(c: SpaceshipContact): Contact {
|
|
705
|
+
return {
|
|
706
|
+
firstName: c.firstName ?? '',
|
|
707
|
+
lastName: c.lastName ?? '',
|
|
708
|
+
organization: c.organization,
|
|
709
|
+
email: c.email ?? '',
|
|
710
|
+
phone: c.phone ?? '',
|
|
711
|
+
fax: c.fax,
|
|
712
|
+
address1: c.address1 ?? '',
|
|
713
|
+
address2: c.address2,
|
|
714
|
+
city: c.city ?? '',
|
|
715
|
+
state: c.stateProvince,
|
|
716
|
+
postalCode: c.postalCode ?? '',
|
|
717
|
+
country: c.country ?? '',
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function toSpaceshipContact(c: Contact): SpaceshipContact {
|
|
722
|
+
return {
|
|
723
|
+
firstName: c.firstName,
|
|
724
|
+
lastName: c.lastName,
|
|
725
|
+
organization: c.organization,
|
|
726
|
+
email: c.email,
|
|
727
|
+
phone: c.phone,
|
|
728
|
+
fax: c.fax,
|
|
729
|
+
address1: c.address1,
|
|
730
|
+
address2: c.address2,
|
|
731
|
+
city: c.city,
|
|
732
|
+
stateProvince: c.state,
|
|
733
|
+
postalCode: c.postalCode,
|
|
734
|
+
country: c.country,
|
|
735
|
+
};
|
|
736
|
+
}
|