@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,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
|
+
}
|