@aoxborrow/registrar-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }