@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,930 @@
1
+ import type {
2
+ ConfigField,
3
+ Contact,
4
+ ConnectionResult,
5
+ ContactSet,
6
+ DnsRecord,
7
+ DnssecStatus,
8
+ Domain,
9
+ DomainAvailability,
10
+ DomainForward,
11
+ DomainForwardType,
12
+ ListDomainsOptions,
13
+ OperationResult,
14
+ RegisterDomainInput,
15
+ RegistrarOptions,
16
+ RequestOptions,
17
+ TldPricing,
18
+ TransferDomainInput,
19
+ } from '../types';
20
+ import { createDomain, filterDomains, normalizeDomain, settableForwards } from '../utils';
21
+ import { NotFoundError, NotImplementedError, toRegistrarError } from '../errors';
22
+ import { BaseRegistrar, selectBaseUrl } from '../registrar';
23
+ import { Feature, type RegistrarFeature } from '../features';
24
+ import type { RegistrarCredentials } from '../types';
25
+
26
+ // Porkbun returns `{ status: "SUCCESS" | "ERROR", ... }` for every operation.
27
+ interface PbResponse {
28
+ status?: string;
29
+ message?: string;
30
+ domains?: PbDomain[];
31
+ }
32
+
33
+ interface PbDomain {
34
+ domain?: string;
35
+ status?: string;
36
+ createDate?: string;
37
+ expireDate?: string;
38
+ securityLock?: string | number;
39
+ whoisPrivacy?: string | number;
40
+ autoRenew?: string | number;
41
+ }
42
+
43
+ // domain/getNs response — `ns` is the current nameserver list
44
+ interface PbNsResponse extends PbResponse {
45
+ ns?: string[];
46
+ }
47
+
48
+ // a single DNS record from dns/retrieve. All fields arrive as strings.
49
+ interface PbDnsRecord {
50
+ id?: string;
51
+ name?: string; // fully-qualified host, e.g. "www.example.com" ("example.com" at apex)
52
+ type?: string;
53
+ content?: string; // record data (for SRV: "weight port target")
54
+ ttl?: string;
55
+ prio?: string; // priority, for MX / SRV
56
+ notes?: string;
57
+ }
58
+
59
+ interface PbDnsResponse extends PbResponse {
60
+ records?: PbDnsRecord[];
61
+ }
62
+
63
+ // one TLD's price points from pricing/get. Values are strings in major USD units.
64
+ interface PbTldPrice {
65
+ registration?: string;
66
+ renewal?: string;
67
+ transfer?: string;
68
+ }
69
+
70
+ interface PbPricingResponse extends PbResponse {
71
+ pricing?: Record<string, PbTldPrice>;
72
+ }
73
+
74
+ // bulk/per-domain response (e.g. domain/updateAutoRenew) — the per-domain
75
+ // outcome is nested under `results`, keyed by domain name.
76
+ interface PbBulkResponse extends PbResponse {
77
+ results?: Record<string, { status?: string; message?: string }>;
78
+ }
79
+
80
+ // domain/getUrlForwarding — one URL-forward rule. `type` is
81
+ // permanent/temporary/masked; `redirectType` is the matching 301/302.
82
+ interface PbUrlForward {
83
+ id?: string;
84
+ subdomain?: string; // "" at the apex
85
+ location?: string; // destination URL
86
+ type?: string;
87
+ includePath?: string;
88
+ wildcard?: string;
89
+ }
90
+ interface PbUrlForwardResponse extends PbResponse {
91
+ forwards?: PbUrlForward[];
92
+ }
93
+
94
+ // dns/getDnssecRecords — DS records keyed by key tag (or null when none)
95
+ interface PbDnssecRecord {
96
+ keyTag?: string | number;
97
+ alg?: string | number;
98
+ digestType?: string | number;
99
+ digest?: string;
100
+ maxSigLife?: string;
101
+ }
102
+ interface PbDnssecResponse extends PbResponse {
103
+ records?: Record<string, PbDnssecRecord> | null;
104
+ }
105
+
106
+ // map between our generic (settable) forward type and Porkbun's `type` value
107
+ const FORWARD_TO_PB_TYPE: Record<'temporary' | 'permanent', string> = {
108
+ temporary: 'temporary', // 302
109
+ permanent: 'permanent', // 301
110
+ };
111
+ const PB_TYPE_TO_FORWARD: Record<string, DomainForwardType> = {
112
+ temporary: 'temporary',
113
+ permanent: 'permanent',
114
+ masked: 'masked', // read-only; setDomainForwarding rejects it
115
+ };
116
+
117
+ // Porkbun's contact shape: phone is split into a national number plus a numeric
118
+ // country code, distinct from the library's single international `phone`.
119
+ interface PbContact {
120
+ firstName: string;
121
+ lastName: string;
122
+ organization?: string;
123
+ email: string;
124
+ phone: string;
125
+ phoneCountryCode: string;
126
+ address1: string;
127
+ address2?: string;
128
+ city: string;
129
+ state?: string;
130
+ postalCode: string;
131
+ country: string;
132
+ }
133
+
134
+ // domain/checkDomain response — availability sits under `response`. With
135
+ // `priceType: "renewal"`, per-operation prices appear under `additional`
136
+ // (`additional.renewal.price` is the exact renewal, premium included). The
137
+ // top-level `price` stays the first-year registration price regardless.
138
+ interface PbCheckResponse extends PbResponse {
139
+ response?: {
140
+ avail?: string; // "yes" | "no"
141
+ type?: string; // "registration" | ...
142
+ price?: string;
143
+ regularPrice?: string;
144
+ premium?: string; // "yes" | "no"
145
+ additional?: {
146
+ renewal?: { type?: string; price?: string; regularPrice?: string };
147
+ transfer?: { type?: string; price?: string; regularPrice?: string };
148
+ };
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Porkbun Registrar
154
+ * API docs: https://porkbun.com/api/json/v3/documentation
155
+ *
156
+ * Credentials: create an API key + secret under Account > API Access, then
157
+ * enable API access for each domain you want to manage (Porkbun gates most
158
+ * operations behind a per-domain "API Access" toggle in the domain's settings).
159
+ *
160
+ * Note: Porkbun uses a JSON POST API; credentials travel in the request body as
161
+ * `apikey` / `secretapikey`. Success is signalled by `status: "SUCCESS"`.
162
+ *
163
+ * Write field notes (all verified end-to-end in the sandbox): register and renew
164
+ * take a `cost` in pennies that must exactly match the current price (fetched
165
+ * from `checkDomain`/`pricing/get`), plus `agreeToTerms: "yes"` on register;
166
+ * Porkbun always registers/renews the registry-minimum term, so `years` is not
167
+ * honored. `create` carries no nameservers/auto-renew/contacts fields, so those
168
+ * are applied as follow-up calls (contacts default to the account WHOIS at
169
+ * registration — set them afterwards with `updateContacts`). Auto-renew is
170
+ * toggled with `status: "on"|"off"` and reports its result in a nested
171
+ * `results[domain]` object. Transfer-in needs `authCode` + the transfer `cost`.
172
+ * Porkbun manages the apex NS records itself (creating NS at the apex is
173
+ * rejected and its default nameservers auto-restore), so `setDnsRecords` leaves
174
+ * them untouched.
175
+ *
176
+ * API gaps (left as NotImplementedError): the transfer lock is read-only via the
177
+ * API (`securityLock` is exposed but has no toggle endpoint), WHOIS privacy has
178
+ * no post-registration toggle (settable only at registration), and there is no
179
+ * contact-read (WHOIS) endpoint.
180
+ */
181
+ export class PorkbunRegistrar extends BaseRegistrar {
182
+ readonly name = 'porkbun';
183
+
184
+ static readonly displayName = 'Porkbun';
185
+ static readonly helpText =
186
+ 'Create an API key and secret in your Porkbun account under Account > API Access, ' +
187
+ 'then enable "API Access" on each domain you want to manage (per-domain toggle in ' +
188
+ 'the domain details). For testing, use a sandbox key (prefixed "pk1_sb_") with ' +
189
+ '{ environment: "sandbox" } — it hits the same base URL but an isolated test ' +
190
+ 'environment with $1,000 of fake credit and no real charges (top up or reset via ' +
191
+ 'POST /sandbox/topup and POST /sandbox/reset).';
192
+ static readonly configFields: ConfigField[] = [
193
+ { name: 'apiKey', label: 'API Key', type: 'password', required: true },
194
+ { name: 'secretApiKey', label: 'Secret API Key', type: 'password', required: true },
195
+ ];
196
+ // Porkbun's sandbox is the SAME base URL with a swapped key: a sandbox key is
197
+ // prefixed `pk1_sb_` and runs against an isolated test environment (no real
198
+ // registry actions, no DNS changes, no charges). Each sandbox starts with
199
+ // $1,000 of fake credit and every response includes `"sandbox": true`. Top up
200
+ // or reset a sandbox with `POST /sandbox/topup` / `POST /sandbox/reset` using a
201
+ // sandbox key. Because only the key differs, the sandbox base URL equals
202
+ // production — selecting `environment: 'sandbox'` just avoids throwing.
203
+ static readonly supportsSandbox = true;
204
+ // JSON API. Beyond core: DNSSEC read/disable and URL (domain) forwarding, both
205
+ // implemented and verified in the sandbox. No auth-code retrieval or email
206
+ // forwarding via the API; transfer lock and WHOIS-privacy toggles have no write
207
+ // endpoint (privacy is set only at registration).
208
+ static readonly extendedFeatures: readonly RegistrarFeature[] = [
209
+ Feature.GetDnssec,
210
+ Feature.DisableDnssec,
211
+ Feature.GetDomainForwarding,
212
+ Feature.SetDomainForwarding,
213
+ ];
214
+
215
+ constructor(credentials: RegistrarCredentials, options?: RegistrarOptions) {
216
+ super(
217
+ credentials,
218
+ {
219
+ baseUrl: selectBaseUrl('Porkbun', options?.environment, {
220
+ production: 'https://api.porkbun.com/api/json/v3',
221
+ // sandbox shares the production URL; the `pk1_sb_` key selects the
222
+ // isolated test environment server-side.
223
+ sandbox: 'https://api.porkbun.com/api/json/v3',
224
+ }),
225
+ },
226
+ options
227
+ );
228
+ }
229
+
230
+ // POST an operation with the credentials merged into the JSON body
231
+ private call<T extends PbResponse = PbResponse>(
232
+ path: string,
233
+ extra: Record<string, unknown> = {},
234
+ opts?: RequestOptions
235
+ ): Promise<T> {
236
+ return this.http.request<T>({
237
+ method: 'POST',
238
+ path,
239
+ body: {
240
+ apikey: this.credentials.apiKey,
241
+ secretapikey: this.credentials.secretApiKey,
242
+ ...extra,
243
+ },
244
+ ...opts,
245
+ });
246
+ }
247
+
248
+ override async testConnection(opts?: RequestOptions): Promise<ConnectionResult> {
249
+ try {
250
+ const res = await this.call('/ping', {}, opts);
251
+ return isOk(res)
252
+ ? { success: true, message: 'Connection successful' }
253
+ : { success: false, message: res.message ?? 'Unknown error' };
254
+ } catch (error) {
255
+ return { success: false, message: toRegistrarError(error).message };
256
+ }
257
+ }
258
+
259
+ override async listDomains(opts?: ListDomainsOptions): Promise<Domain[]> {
260
+ // listAll has no name filter, so `search` is applied client-side. Porkbun
261
+ // returns fixed 1000-domain chunks and pages by `start` offset.
262
+ const { search, ...reqOpts } = opts ?? {};
263
+ const domains: Domain[] = [];
264
+ const chunk = 1000; // Porkbun returns up to 1000 domains per call
265
+ let start = 0;
266
+ let hasMore = true;
267
+
268
+ while (hasMore) {
269
+ const res = await this.call('/domain/listAll', { start, includeLabels: 'no' }, reqOpts);
270
+ if (!isOk(res)) {
271
+ throw new Error(res.message ?? 'API request failed');
272
+ }
273
+
274
+ const list = res.domains ?? [];
275
+ for (const d of list) {
276
+ domains.push(
277
+ createDomain({
278
+ domainName: d.domain,
279
+ registrar: this.name,
280
+ status: d.status ?? 'ok',
281
+ createdDate: d.createDate,
282
+ expirationDate: d.expireDate,
283
+ renewalDate: d.expireDate,
284
+ autoRenew: isYes(d.autoRenew),
285
+ locked: isYes(d.securityLock),
286
+ privacy: isYes(d.whoisPrivacy),
287
+ nameservers: [], // not returned by listAll (see domain/getNs)
288
+ })
289
+ );
290
+ }
291
+
292
+ hasMore = list.length === chunk;
293
+ start += chunk;
294
+ }
295
+ return filterDomains(domains, search);
296
+ }
297
+
298
+ /**
299
+ * Porkbun has no per-domain "get info" endpoint, so this lists the account and
300
+ * returns the matching record. `listDomains` already normalizes every field, so
301
+ * the result carries the same shape as any other provider's `getDomain`. Throws
302
+ * NotFoundError when the domain isn't in the account.
303
+ */
304
+ override async getDomain(domainName: string, opts?: RequestOptions): Promise<Domain> {
305
+ const target = normalizeDomain(domainName);
306
+ const domains = await this.listDomains(opts);
307
+ const match = domains.find(d => normalizeDomain(d.domainName) === target);
308
+ if (!match) {
309
+ throw new NotFoundError(`${this.name}: domain ${domainName} not found in this account`);
310
+ }
311
+ return match;
312
+ }
313
+
314
+ override async getNameservers(domainName: string, opts?: RequestOptions): Promise<string[]> {
315
+ const res = await this.call<PbNsResponse>(
316
+ `/domain/getNs/${encodeURIComponent(domainName)}`,
317
+ {},
318
+ opts
319
+ );
320
+ if (!isOk(res)) throw new Error(res.message ?? 'API request failed');
321
+ return res.ns ?? [];
322
+ }
323
+
324
+ /**
325
+ * Porkbun exposes no WHOIS/contact retrieval endpoint — contact details are
326
+ * only settable at registration and never read back through the API — so there
327
+ * is no way to fulfil this. (Mirrors Spaceship's `getPricing` gap handling.)
328
+ */
329
+ override getContacts(_domainName: string, _opts?: RequestOptions): Promise<ContactSet> {
330
+ return Promise.reject(
331
+ new NotImplementedError(
332
+ `${this.name}: getContacts is not available — Porkbun's API has no contact-read ` +
333
+ '(WHOIS) endpoint'
334
+ )
335
+ );
336
+ }
337
+
338
+ override async getDnsRecords(domainName: string, opts?: RequestOptions): Promise<DnsRecord[]> {
339
+ const res = await this.call<PbDnsResponse>(
340
+ `/dns/retrieve/${encodeURIComponent(domainName)}`,
341
+ {},
342
+ opts
343
+ );
344
+ if (!isOk(res)) throw new Error(res.message ?? 'API request failed');
345
+ return (res.records ?? []).map(r => toDnsRecord(r, domainName));
346
+ }
347
+
348
+ /**
349
+ * Renewal price for a specific name (premium included) when given a full
350
+ * domain; the standard per-TLD price table when given a bare TLD.
351
+ *
352
+ * For a full domain, `checkDomain` with `priceType: "renewal"` returns the
353
+ * exact renewal fee for that exact name under `additional.renewal.price` —
354
+ * elevated for premium names — and it works whether or not the domain is in the
355
+ * account. `pricing/get` only has the generic per-TLD rate, which misses
356
+ * premium renewals, so we deliberately do NOT fall back to it here: if the
357
+ * per-name check can't price the domain we return no renewal, leaving the
358
+ * caller to fill from its own base pricing.
359
+ *
360
+ * For a bare TLD (as the renew/transfer write flows pass), there is no specific
361
+ * name to price, so this returns the `pricing/get` table entry directly.
362
+ */
363
+ override async getPricing(tldOrDomain: string, opts?: RequestOptions): Promise<TldPricing> {
364
+ // A dot means a full domain (e.g. "example.com"); a bare label is a TLD.
365
+ if (tldOrDomain.includes('.')) {
366
+ const tld = extractTld(tldOrDomain);
367
+ const checked = await this.renewPriceCheck(tldOrDomain, opts);
368
+ return checked
369
+ ? { tld, currency: checked.currency, renewal: checked.renewal }
370
+ : { tld, currency: 'USD' };
371
+ }
372
+ return this.tldPricing(tldOrDomain, opts);
373
+ }
374
+
375
+ /**
376
+ * The standard per-TLD price points from `pricing/get` (strings in major USD
377
+ * units). Throws NotFoundError when the TLD isn't in the table. Used by the
378
+ * renew/transfer flows and by `getPricing` for a bare TLD.
379
+ */
380
+ private async tldPricing(tld: string, opts?: RequestOptions): Promise<TldPricing> {
381
+ const res = await this.call<PbPricingResponse>('/pricing/get', {}, opts);
382
+ if (!isOk(res)) throw new Error(res.message ?? 'API request failed');
383
+ const price = res.pricing?.[tld];
384
+ if (!price) {
385
+ throw new NotFoundError(`${this.name}: no pricing found for TLD .${tld}`);
386
+ }
387
+ return {
388
+ tld,
389
+ currency: 'USD', // Porkbun prices are always quoted in USD
390
+ registration: toPrice(price.registration),
391
+ renewal: toPrice(price.renewal),
392
+ transfer: toPrice(price.transfer),
393
+ };
394
+ }
395
+
396
+ /**
397
+ * The exact renewal fee for a specific name via `checkDomain` +
398
+ * `priceType: "renewal"`, read from `additional.renewal.price` (premium
399
+ * included). Returns null when the name can't be priced (error, rate limit, or
400
+ * a missing renewal figure) so `getPricing` can fall back. Note: Porkbun
401
+ * rate-limits `checkDomain` to one call per ~10s; the shared HTTP layer retries
402
+ * on the 429 (honoring Retry-After).
403
+ */
404
+ private async renewPriceCheck(
405
+ domain: string,
406
+ opts?: RequestOptions
407
+ ): Promise<{ renewal: number; currency: string } | null> {
408
+ try {
409
+ const res = await this.call<PbCheckResponse>(
410
+ `/domain/checkDomain/${encodeURIComponent(domain)}`,
411
+ { priceType: 'renewal' },
412
+ opts
413
+ );
414
+ if (!isOk(res)) return null;
415
+ const renewal = toPrice(res.response?.additional?.renewal?.price);
416
+ return renewal != null ? { renewal, currency: 'USD' } : null;
417
+ } catch {
418
+ return null;
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Porkbun's checkDomain takes a single domain per call, so this issues one
424
+ * request per name. The price it reports (major USD units) is the first-year
425
+ * registration price for both regular and premium names.
426
+ */
427
+ override async checkAvailability(
428
+ domainNames: string[],
429
+ opts?: RequestOptions
430
+ ): Promise<DomainAvailability[]> {
431
+ const results: DomainAvailability[] = [];
432
+ for (const domainName of domainNames) {
433
+ const res = await this.call<PbCheckResponse>(
434
+ `/domain/checkDomain/${encodeURIComponent(domainName)}`,
435
+ {},
436
+ opts
437
+ );
438
+ if (!isOk(res)) throw new Error(res.message ?? 'API request failed');
439
+ const info = res.response ?? {};
440
+ const price = toPrice(info.price);
441
+ results.push({
442
+ domainName,
443
+ available: info.avail === 'yes',
444
+ premium: info.premium === 'yes',
445
+ price,
446
+ currency: price != null ? 'USD' : undefined,
447
+ period: price != null ? 1 : undefined,
448
+ });
449
+ }
450
+ return results;
451
+ }
452
+
453
+ override async updateNameservers(
454
+ domainName: string,
455
+ nameservers: string[],
456
+ opts?: RequestOptions
457
+ ): Promise<OperationResult> {
458
+ if (nameservers.length < 2) {
459
+ throw new Error('Porkbun requires at least 2 nameservers');
460
+ }
461
+ const res = await this.call(
462
+ `/domain/updateNs/${encodeURIComponent(domainName)}`,
463
+ { ns: nameservers },
464
+ opts
465
+ );
466
+ return statusResult(res);
467
+ }
468
+
469
+ /**
470
+ * Registers a domain. Porkbun's `create` requires a `cost` (pennies) that
471
+ * exactly matches the domain's current price — fetched here via `checkDomain`
472
+ * — plus agreement to its terms (sent automatically; invoking this method is
473
+ * the agreement). It always registers the registry-minimum term, so `years` is
474
+ * ignored. Contacts aren't accepted at registration (Porkbun applies the
475
+ * account-default WHOIS); use `updateContacts` afterwards to set them.
476
+ * Nameservers and auto-renew have no `create` field, so they're applied as
477
+ * follow-up calls when provided.
478
+ */
479
+ override async registerDomain(
480
+ domainName: string,
481
+ input: RegisterDomainInput,
482
+ opts?: RequestOptions
483
+ ): Promise<OperationResult> {
484
+ const [availability] = await this.checkAvailability([domainName], opts);
485
+ if (!availability?.available) {
486
+ throw new Error(`${this.name}: ${domainName} is not available for registration`);
487
+ }
488
+ if (availability.price == null) {
489
+ throw new Error(`${this.name}: could not determine the registration price for ${domainName}`);
490
+ }
491
+ const body: Record<string, unknown> = {
492
+ cost: toPennies(availability.price),
493
+ agreeToTerms: 'yes',
494
+ };
495
+ // whoisPrivacy is optional; omitting it uses the account default
496
+ if (input.privacy != null) body.whoisPrivacy = input.privacy;
497
+
498
+ const res = await this.call(`/domain/create/${encodeURIComponent(domainName)}`, body, opts);
499
+ if (!isOk(res)) return statusResult(res);
500
+
501
+ // create has no nameserver / auto-renew fields — apply them separately
502
+ if (input.nameservers && input.nameservers.length > 0) {
503
+ await this.updateNameservers(domainName, input.nameservers, opts);
504
+ }
505
+ if (input.autoRenew != null) {
506
+ await this.setAutoRenew(domainName, input.autoRenew, opts);
507
+ }
508
+ return { success: true, message: `Domain ${domainName} registered successfully` };
509
+ }
510
+
511
+ /**
512
+ * Renews a domain. Porkbun renews the registry-minimum term (usually 1 year)
513
+ * and ignores any requested `years`; it requires a `cost` (pennies) matching
514
+ * the domain's current renewal price, fetched here from `pricing/get`. Premium
515
+ * renewals aren't supported via the API.
516
+ */
517
+ override async renewDomain(
518
+ domainName: string,
519
+ _years = 1,
520
+ opts?: RequestOptions
521
+ ): Promise<OperationResult> {
522
+ const pricing = await this.tldPricing(extractTld(domainName), opts);
523
+ if (pricing.renewal == null) {
524
+ throw new Error(`${this.name}: could not determine the renewal price for ${domainName}`);
525
+ }
526
+ const res = await this.call(
527
+ `/domain/renew/${encodeURIComponent(domainName)}`,
528
+ { cost: toPennies(pricing.renewal) },
529
+ opts
530
+ );
531
+ return statusResult(res);
532
+ }
533
+
534
+ override async setAutoRenew(
535
+ domainName: string,
536
+ enabled: boolean,
537
+ opts?: RequestOptions
538
+ ): Promise<OperationResult> {
539
+ const res = await this.call<PbBulkResponse>(
540
+ `/domain/updateAutoRenew/${encodeURIComponent(domainName)}`,
541
+ { status: enabled ? 'on' : 'off' },
542
+ opts
543
+ );
544
+ // updateAutoRenew reports the real outcome in a nested results[domain] entry,
545
+ // even when the top-level status is SUCCESS.
546
+ return bulkResult(
547
+ res,
548
+ domainName,
549
+ `Auto-renew ${enabled ? 'enabled' : 'disabled'} successfully`
550
+ );
551
+ }
552
+
553
+ /**
554
+ * Transfers a domain in with its EPP auth code. Porkbun's `transfer` requires a
555
+ * `cost` (pennies) matching the current transfer price, fetched here from
556
+ * `pricing/get`. The transfer body carries no contacts/privacy/auto-renew — the
557
+ * existing registration's details carry over — and premium/`.uk` transfers
558
+ * aren't supported via the API.
559
+ */
560
+ override async transferIn(
561
+ domainName: string,
562
+ input: TransferDomainInput,
563
+ opts?: RequestOptions
564
+ ): Promise<OperationResult> {
565
+ const pricing = await this.tldPricing(extractTld(domainName), opts);
566
+ if (pricing.transfer == null) {
567
+ throw new Error(`${this.name}: could not determine the transfer price for ${domainName}`);
568
+ }
569
+ const res = await this.call(
570
+ `/domain/transfer/${encodeURIComponent(domainName)}`,
571
+ { authCode: input.authCode, cost: toPennies(pricing.transfer) },
572
+ opts
573
+ );
574
+ return statusResult(res, `Domain ${domainName} transfer requested successfully`);
575
+ }
576
+
577
+ /**
578
+ * Updates contact roles. Only the roles present in `contacts` are changed;
579
+ * unspecified roles are left as-is. Porkbun splits the phone into a national
580
+ * number plus a numeric country code, and runs Google Address Validation on
581
+ * registrant changes for address-validated TLDs.
582
+ */
583
+ override async updateContacts(
584
+ domainName: string,
585
+ contacts: ContactSet,
586
+ opts?: RequestOptions
587
+ ): Promise<OperationResult> {
588
+ const roles: Record<string, PbContact> = {};
589
+ if (contacts.registrant) roles.registrant = toPorkbunContact(contacts.registrant);
590
+ if (contacts.admin) roles.admin = toPorkbunContact(contacts.admin);
591
+ if (contacts.tech) roles.tech = toPorkbunContact(contacts.tech);
592
+ if (contacts.billing) roles.billing = toPorkbunContact(contacts.billing);
593
+ if (Object.keys(roles).length === 0) {
594
+ throw new Error(`${this.name}: updateContacts requires at least one contact role`);
595
+ }
596
+ const res = await this.call(
597
+ `/domain/updateContacts/${encodeURIComponent(domainName)}`,
598
+ { contacts: roles },
599
+ opts
600
+ );
601
+ return statusResult(res, 'Contacts updated successfully');
602
+ }
603
+
604
+ /**
605
+ * Replaces the entire editable record set (full-replace / PUT semantics):
606
+ * retrieves the current records, deletes them, then creates the supplied set.
607
+ * The apex NS records are Porkbun-managed (it rejects creating NS at the apex
608
+ * and auto-restores its defaults), so they're skipped on both delete and
609
+ * create. Because Porkbun has no atomic "set all" endpoint, this issues one
610
+ * call per record.
611
+ */
612
+ override async setDnsRecords(
613
+ domainName: string,
614
+ records: DnsRecord[],
615
+ opts?: RequestOptions
616
+ ): Promise<OperationResult> {
617
+ const zone = normalizeDomain(domainName);
618
+ const existing = await this.call<PbDnsResponse>(
619
+ `/dns/retrieve/${encodeURIComponent(domainName)}`,
620
+ {},
621
+ opts
622
+ );
623
+ if (!isOk(existing)) return statusResult(existing);
624
+
625
+ for (const r of existing.records ?? []) {
626
+ if (isApexNs(r.type, r.name, zone) || r.id == null) continue;
627
+ const del = await this.call(
628
+ `/dns/delete/${encodeURIComponent(domainName)}/${r.id}`,
629
+ {},
630
+ opts
631
+ );
632
+ if (!isOk(del)) return statusResult(del);
633
+ }
634
+
635
+ for (const record of records) {
636
+ const name = relativeName(record.name, domainName);
637
+ const type = record.type.toUpperCase();
638
+ if (type === 'NS' && (name === '@' || name === '')) continue; // apex NS is managed by Porkbun
639
+ const create = await this.call(
640
+ `/dns/create/${encodeURIComponent(domainName)}`,
641
+ toPorkbunDnsRecord(record, name, type),
642
+ opts
643
+ );
644
+ if (!isOk(create)) return statusResult(create);
645
+ }
646
+ return { success: true, message: 'DNS records updated successfully' };
647
+ }
648
+
649
+ // --- extended capabilities ---------------------------------------------
650
+
651
+ /**
652
+ * DNSSEC status via dns/getDnssecRecords, which returns DS records keyed by key
653
+ * tag (or `null` when there are none). A non-empty set means DNSSEC is enabled.
654
+ */
655
+ override async getDnssec(domainName: string, opts?: RequestOptions): Promise<DnssecStatus> {
656
+ const res = await this.call<PbDnssecResponse>(
657
+ `/dns/getDnssecRecords/${encodeURIComponent(domainName)}`,
658
+ {},
659
+ opts
660
+ );
661
+ if (!isOk(res)) throw new Error(res.message ?? 'API request failed');
662
+ const dsRecords = Object.values(res.records ?? {}).map(r => ({
663
+ keyTag: Number(r.keyTag) || 0,
664
+ algorithm: Number(r.alg) || 0,
665
+ digestType: Number(r.digestType) || 0,
666
+ digest: r.digest ?? '',
667
+ }));
668
+ return { enabled: dsRecords.length > 0, dsRecords };
669
+ }
670
+
671
+ /**
672
+ * Disable DNSSEC by removing every DS record. Porkbun has no single "off" call,
673
+ * so read the current records and delete each by its key tag.
674
+ */
675
+ override async disableDnssec(
676
+ domainName: string,
677
+ opts?: RequestOptions
678
+ ): Promise<OperationResult> {
679
+ try {
680
+ const res = await this.call<PbDnssecResponse>(
681
+ `/dns/getDnssecRecords/${encodeURIComponent(domainName)}`,
682
+ {},
683
+ opts
684
+ );
685
+ if (!isOk(res)) return statusResult(res);
686
+ for (const keyTag of Object.keys(res.records ?? {})) {
687
+ const del = await this.call(
688
+ `/dns/deleteDnssecRecord/${encodeURIComponent(domainName)}/${encodeURIComponent(keyTag)}`,
689
+ {},
690
+ opts
691
+ );
692
+ if (!isOk(del)) return statusResult(del);
693
+ }
694
+ return { success: true, message: 'DNSSEC disabled successfully' };
695
+ } catch (error) {
696
+ return { success: false, message: toRegistrarError(error).message };
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Read URL forwarding via domain/getUrlForwarding. Porkbun forwards per
702
+ * subdomain ("" = apex), so each rule maps to a DomainForward with `host` set
703
+ * to the subdomain (or "@" at the apex).
704
+ */
705
+ override async getDomainForwarding(
706
+ domainName: string,
707
+ opts?: RequestOptions
708
+ ): Promise<DomainForward[]> {
709
+ const res = await this.call<PbUrlForwardResponse>(
710
+ `/domain/getUrlForwarding/${encodeURIComponent(domainName)}`,
711
+ {},
712
+ opts
713
+ );
714
+ if (!isOk(res)) throw new Error(res.message ?? 'API request failed');
715
+ return (res.forwards ?? []).map(f => ({
716
+ host: f.subdomain || '@',
717
+ url: f.location ?? '',
718
+ type: PB_TYPE_TO_FORWARD[(f.type ?? '').toLowerCase()] ?? 'permanent',
719
+ }));
720
+ }
721
+
722
+ /**
723
+ * Replace URL forwarding (full replace; an empty list clears it). Porkbun has
724
+ * no bulk set, so delete every existing forward by id, then add each desired
725
+ * one via domain/addUrlForward.
726
+ */
727
+ override async setDomainForwarding(
728
+ domainName: string,
729
+ forwards: DomainForward[],
730
+ opts?: RequestOptions
731
+ ): Promise<OperationResult> {
732
+ try {
733
+ const settable = settableForwards(forwards); // reject masked before any write
734
+ const enc = encodeURIComponent(domainName);
735
+ const existing = await this.call<PbUrlForwardResponse>(
736
+ `/domain/getUrlForwarding/${enc}`,
737
+ {},
738
+ opts
739
+ );
740
+ if (!isOk(existing)) return statusResult(existing);
741
+ for (const f of existing.forwards ?? []) {
742
+ if (f.id == null) continue;
743
+ const del = await this.call(
744
+ `/domain/deleteUrlForward/${enc}/${encodeURIComponent(f.id)}`,
745
+ {},
746
+ opts
747
+ );
748
+ if (!isOk(del)) return statusResult(del);
749
+ }
750
+ for (const f of settable) {
751
+ const add = await this.call(
752
+ `/domain/addUrlForward/${enc}`,
753
+ {
754
+ subdomain: f.host === '@' ? '' : f.host,
755
+ location: f.url,
756
+ type: FORWARD_TO_PB_TYPE[f.type],
757
+ includePath: 'no',
758
+ wildcard: 'no',
759
+ },
760
+ opts
761
+ );
762
+ if (!isOk(add)) return statusResult(add);
763
+ }
764
+ return { success: true, message: 'Domain forwarding updated successfully' };
765
+ } catch (error) {
766
+ return { success: false, message: toRegistrarError(error).message };
767
+ }
768
+ }
769
+ }
770
+
771
+ // whether the response status is "SUCCESS"
772
+ function isOk(res: PbResponse): boolean {
773
+ return res.status === 'SUCCESS';
774
+ }
775
+
776
+ // map a response's status to an OperationResult
777
+ function statusResult(res: PbResponse, successMessage = 'SUCCESS'): OperationResult {
778
+ if (isOk(res)) return { success: true, message: res.message ?? successMessage };
779
+ return { success: false, message: res.message ?? 'Unknown response' };
780
+ }
781
+
782
+ // map a bulk/per-domain response (e.g. updateAutoRenew) to an OperationResult.
783
+ // The top-level status can be SUCCESS while the per-domain entry failed, so the
784
+ // real outcome is read from results[domain].
785
+ function bulkResult(
786
+ res: PbBulkResponse,
787
+ domainName: string,
788
+ successMessage: string
789
+ ): OperationResult {
790
+ const entry = res.results?.[domainName];
791
+ if (isOk(res) && (!entry || entry.status === 'SUCCESS')) {
792
+ return { success: true, message: entry?.message ?? successMessage };
793
+ }
794
+ return { success: false, message: entry?.message ?? res.message ?? 'Unknown response' };
795
+ }
796
+
797
+ // Porkbun booleans arrive as "1"/"0" (sometimes numbers) — normalize to boolean
798
+ function isYes(value: string | number | undefined): boolean {
799
+ return value === '1' || value === 1 || value === 'yes';
800
+ }
801
+
802
+ // reduce a TLD or full domain to its bare TLD (no leading dot, lowercased). A
803
+ // multi-label TLD (e.g. "co.uk") is preserved by taking everything after the
804
+ // first label of a full domain.
805
+ function extractTld(tldOrDomain: string): string {
806
+ const value = tldOrDomain.trim().toLowerCase().replace(/^\.+/, '');
807
+ return value.includes('.') ? value.slice(value.indexOf('.') + 1) : value;
808
+ }
809
+
810
+ // convert major USD units (e.g. 11.08) to integer pennies (1108), as Porkbun's
811
+ // `cost` field requires
812
+ function toPennies(major: number): number {
813
+ return Math.round(major * 100);
814
+ }
815
+
816
+ // whether a record is an apex NS record (Porkbun-managed: can't be created and
817
+ // auto-restores, so setDnsRecords must leave it alone). `name` may be the
818
+ // fully-qualified host from a retrieve, or "@"/"" for the apex.
819
+ function isApexNs(type: string | undefined, name: string | undefined, zone: string): boolean {
820
+ if ((type ?? '').toUpperCase() !== 'NS') return false;
821
+ const n = normalizeDomain(name ?? '');
822
+ return n === zone || n === '' || name === '@';
823
+ }
824
+
825
+ // map a normalized DnsRecord to a Porkbun dns/create body. `name` is already the
826
+ // zone-relative host ("@"/"" at the apex); Porkbun wants the bare subdomain
827
+ // (empty string at the apex). SRV data is packed as "<weight> <port> <target>".
828
+ function toPorkbunDnsRecord(
829
+ record: DnsRecord,
830
+ name: string,
831
+ type: string
832
+ ): Record<string, unknown> {
833
+ const body: Record<string, unknown> = {
834
+ name: name === '@' ? '' : name,
835
+ type,
836
+ content: record.value,
837
+ };
838
+ if (record.ttl != null) body.ttl = String(record.ttl);
839
+ if (record.priority != null) body.prio = String(record.priority);
840
+ if (type === 'SRV') {
841
+ body.content = `${record.weight ?? 0} ${record.port ?? 0} ${record.value}`;
842
+ }
843
+ return body;
844
+ }
845
+
846
+ // map the library's Contact to Porkbun's shape, splitting the international
847
+ // phone ("+1.4805551234") into a national number + numeric country code
848
+ function toPorkbunContact(c: Contact): PbContact {
849
+ const { countryCode, national } = splitPhone(c.phone);
850
+ const contact: PbContact = {
851
+ firstName: c.firstName,
852
+ lastName: c.lastName,
853
+ email: c.email,
854
+ phone: national,
855
+ phoneCountryCode: countryCode,
856
+ address1: c.address1,
857
+ city: c.city,
858
+ postalCode: c.postalCode,
859
+ country: c.country,
860
+ };
861
+ if (c.organization) contact.organization = c.organization;
862
+ if (c.address2) contact.address2 = c.address2;
863
+ if (c.state) contact.state = c.state;
864
+ return contact;
865
+ }
866
+
867
+ // split an international phone ("+1.4805551234" — "+<cc>.<national>") into its
868
+ // numeric country code and national number (digits only). Falls back gracefully
869
+ // for values without the leading "+" or the "." separator.
870
+ function splitPhone(phone: string): { countryCode: string; national: string } {
871
+ const trimmed = (phone ?? '').trim();
872
+ const dot = trimmed.indexOf('.');
873
+ if (trimmed.startsWith('+') && dot > 1) {
874
+ return {
875
+ countryCode: trimmed.slice(1, dot).replace(/\D/g, ''),
876
+ national: trimmed.slice(dot + 1).replace(/\D/g, ''),
877
+ };
878
+ }
879
+ // no recognizable separator: return all digits as the national number
880
+ return { countryCode: '', national: trimmed.replace(/\D/g, '') };
881
+ }
882
+
883
+ // parse a Porkbun price string (major USD units) into a number, or undefined
884
+ function toPrice(value: string | undefined): number | undefined {
885
+ if (value == null || value === '') return undefined;
886
+ const n = Number(value);
887
+ return Number.isFinite(n) ? n : undefined;
888
+ }
889
+
890
+ // convert a Porkbun fully-qualified record name to a name relative to the zone
891
+ // apex ("@" at the apex), matching the DnsRecord contract.
892
+ function relativeName(name: string, domainName: string): string {
893
+ const fqdn = normalizeDomain(name);
894
+ const zone = normalizeDomain(domainName);
895
+ if (fqdn === zone || fqdn === '') return '@';
896
+ if (fqdn.endsWith(`.${zone}`)) return fqdn.slice(0, -(zone.length + 1));
897
+ return fqdn;
898
+ }
899
+
900
+ // map a Porkbun DNS record to the normalized DnsRecord shape. Porkbun stores SRV
901
+ // data as prio=priority and content="weight port target"; other types put their
902
+ // value in `content`.
903
+ function toDnsRecord(r: PbDnsRecord, domainName: string): DnsRecord {
904
+ const type = (r.type ?? '').toUpperCase();
905
+ const record: DnsRecord = {
906
+ type,
907
+ name: relativeName(r.name ?? '', domainName),
908
+ value: r.content ?? '',
909
+ };
910
+ if (r.ttl != null && r.ttl !== '') {
911
+ const ttl = Number(r.ttl);
912
+ if (Number.isFinite(ttl)) record.ttl = ttl;
913
+ }
914
+ const prio = r.prio != null && r.prio !== '' ? Number(r.prio) : NaN;
915
+ if ((type === 'MX' || type === 'SRV') && Number.isFinite(prio)) {
916
+ record.priority = prio;
917
+ }
918
+ if (type === 'SRV') {
919
+ // content is "<weight> <port> <target>"
920
+ const parts = (r.content ?? '').trim().split(/\s+/);
921
+ if (parts.length === 3) {
922
+ const weight = Number(parts[0]);
923
+ const port = Number(parts[1]);
924
+ if (Number.isFinite(weight)) record.weight = weight;
925
+ if (Number.isFinite(port)) record.port = port;
926
+ record.value = parts[2];
927
+ }
928
+ }
929
+ return record;
930
+ }