diff --git a/README.md b/README.md index 05c087f..d21e681 100644 --- a/README.md +++ b/README.md @@ -461,7 +461,7 @@ parsed should not change shape when it fails. ### `porkbun` -DNS at Porkbun, without the dashboard. +Domains and DNS at Porkbun, without the dashboard. ```sh porkbun ls example.com # the zone, apex first @@ -471,6 +471,8 @@ porkbun set example.com @ ALIAS app.up.railway.app # apex, via Porkbun's A porkbun rm example.com www --type CNAME --yes porkbun unpark example.com # stop the parking page winning porkbun domains # everything on the account +porkbun check example.com # available? at what price? +porkbun register example.com --max-price 20 # buy it ``` Write the host the way you would say it. `@`, an empty value and the bare domain @@ -498,6 +500,22 @@ records pointing at `porkbun.com`. The `MX` records at `fwd1.porkbun.com` are Porkbun's *email forwarding* and the `NS` records are the zone's delegation — sweeping either up would break mail or take the domain off the internet. +`register` spends real money, so it is built to make that hard to do by accident. +It resolves one plan — the TLD's rules, then availability and price — prints it, +and asks; the number in the prompt is the number sent to the registrar, because +it is the same number. `--dry-run` prices it and stops, `--max-price` refuses +anything dearer (a premium name can be hundreds), and a promotional first year is +called out because it is not what you will pay next year. WHOIS privacy is on +unless you pass `--no-whois-privacy`, and a TLD that cannot do privacy is refused +rather than quietly publishing your address. It pays from the account balance, +topping up the card on file if that is short. + +Two things about it. Availability is **rate limited to one check per ten seconds**, +which is why the price is fetched once and carried rather than re-checked just +before buying. And prices are quoted as dollar strings while `/domain/create` +wants integer cents that match the quote exactly — `parseFloat('11.08') * 100` is +`1107.9999999999998`, so the conversion is done as text and never becomes a float. + Two Porkbun-specific traps the errors call out by name. Every call is a `POST` with the credentials in the body, and **`status` is a field rather than the HTTP code**: a bad key, an unknown domain and a malformed record all return `200 OK` diff --git a/bin/porkbun.ts b/bin/porkbun.ts index bdb099c..7a1c986 100755 --- a/bin/porkbun.ts +++ b/bin/porkbun.ts @@ -22,6 +22,7 @@ import { MIN_TTL, PorkbunError, type RecordInput, + checkAvailability, createRecord, credentialsFrom, deleteForward, @@ -35,8 +36,11 @@ import { listRecords, matchRecords, ping, + planRegistration, planUnpark, porkbunCaller, + priceCents, + registerDomain, setRecord, sortRecords, } from '../src/porkbun.ts'; @@ -50,6 +54,8 @@ const USAGE = `Usage: porkbun rm ( | --type TYPE) [--yes] porkbun forwards [--json] porkbun unpark [--dry-run] [--yes] + porkbun check [--json] + porkbun register [--max-price N] [--no-whois-privacy] [--dry-run] [--yes] Commands: ping check the credentials and show the IP Porkbun sees @@ -60,6 +66,8 @@ Commands: rm delete by record id, or by host + --type forwards list URL forwarding rules unpark remove URL forwarding and the parking records it owns + check is a domain available, and what would it cost + register buy a domain — spends real money, so it confirms first The host is written as you would say it: \`@\` or the bare domain for the apex, \`www\` or \`www.example.com\` for a subdomain. Both forms mean the same record. @@ -71,6 +79,10 @@ Options: --prio N priority, for MX and SRV --json raw JSON instead of a table --dry-run for unpark: print what would be deleted, delete nothing + for register: price it and stop, buy nothing + --max-price N for register: refuse to spend more than N dollars + --no-whois-privacy + for register: publish your contact details (privacy is on by default) --yes skip the confirmation prompt -h, --help show this help @@ -78,6 +90,10 @@ Credentials come from PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY, via \`cli-tools config pull\` or the environment. Porkbun also requires API access to be switched on per domain, in the domain's settings — a key that pings fine still gets "Invalid domain" until that is on. + +\`register\` pays from the Porkbun account balance, topping up the card on file +if it is short. It registers for the TLD's minimum term with auto-renew on and +WHOIS privacy on, using the account's default contacts. `; function fail(message: string, code = 2): never { @@ -101,8 +117,8 @@ async function confirm(question: string): Promise { if (isMain(import.meta.url)) { try { const parsed = parseArgs(process.argv.slice(2), { - boolean: ['--json', '--dry-run', '--yes', '-h', '--help'], - string: ['--type', '--name', '--ttl', '--prio'], + boolean: ['--json', '--dry-run', '--yes', '--no-whois-privacy', '-h', '--help'], + string: ['--type', '--name', '--ttl', '--prio', '--max-price'], }); if (parsed.flags.has('-h') || parsed.flags.has('--help') || parsed.positional.length === 0) { @@ -294,6 +310,82 @@ if (isMain(import.meta.url)) { break; } + case 'check': { + const domain = needDomain(); + const availability = await checkAvailability(call, domain); + + if (json) { + process.stdout.write(`${JSON.stringify(availability, null, 2)}\n`); + break; + } + if (!availability.available) { + process.stdout.write(`${domain} is taken\n`); + process.exit(1); + } + process.stdout.write( + `${domain} is available — ${availability.price}` + + `${availability.minDuration > 1 ? ` for ${availability.minDuration} years` : '/yr'}` + + `${availability.premium ? ' (premium)' : ''}\n` + + (availability.renewal && availability.renewal !== availability.price + ? `renews at ${availability.renewal}/yr\n` + : ''), + ); + break; + } + + case 'register': { + const domain = needDomain(); + + // Priced in dollars because that is how the prompt reads it back; + // `integer` would reject the cents, so the shared money parser does it. + let maxCents: number | undefined; + const maxPrice = parsed.values.get('--max-price'); + if (maxPrice !== undefined) { + try { + maxCents = priceCents(maxPrice.replace(/^\$/, '')); + } catch { + throw new UsageError(`--max-price must be an amount in dollars, got ${JSON.stringify(maxPrice)}`); + } + } + + const plan = await planRegistration(call, domain, { + whoisPrivacy: !parsed.flags.has('--no-whois-privacy'), + ...(maxCents === undefined ? {} : { maxCents }), + }); + + const term = plan.years && plan.years > 1 ? `${plan.years} years` : '1 year'; + process.stderr.write( + ` ${plan.domain} ${plan.price} for ${term}${plan.premium ? ' (premium)' : ''}\n` + + ` whois privacy: ${plan.whoisPrivacy ? 'on' : 'OFF — your contacts will be public'}\n` + + ` auto-renew: on${plan.renewal ? `, at ${plan.renewal}/yr` : ''}\n`, + ); + // A first year that renews dearer is the one surprise worth shouting + // about: the price agreed to here is not the price paid next year. + if (plan.firstYearPromo) { + process.stderr.write(' note: promotional first year — the renewal price is higher\n'); + } + + if (parsed.flags.has('--dry-run')) { + process.stdout.write('--dry-run: nothing registered\n'); + break; + } + if (!assumeYes && !(await confirm(`register ${plan.domain} for ${plan.price}?`))) { + process.stderr.write('cancelled\n'); + process.exit(1); + } + + await registerDomain(call, plan); + process.stdout.write(`registered ${plan.domain} for ${plan.price}\n`); + // API access is per-domain and off by default, so the very next thing + // anyone tries — pointing the new name somewhere — fails with "Invalid + // domain" until it is switched on. Say so before that happens. + process.stdout.write( + `turn on API access for it at https://porkbun.com/account/domainsSpeedy ` + + `before \`porkbun set ${plan.domain} ...\` will work\n`, + ); + break; + } + default: throw new UsageError(`unknown command: ${command}`); } diff --git a/src/porkbun.ts b/src/porkbun.ts index c850ea3..1c7f794 100644 --- a/src/porkbun.ts +++ b/src/porkbun.ts @@ -419,3 +419,222 @@ export async function setRecord( await editRecord(call, domain, current.id, input); return { action: 'updated', id: current.id }; } + +/* ------------------------------------------------------------------------- * + * Registration + * ------------------------------------------------------------------------- */ + +/** + * Money, as Porkbun means it. + * + * Prices are quoted as decimal *dollar* strings (`"11.08"`) and + * `/domain/create` wants an integer count of **cents** that matches the quote + * exactly. The obvious conversion is a trap: `parseFloat('11.08') * 100` is + * `1107.9999999999998`, which truncates to `1107` and buys nothing — the API + * refuses the mismatch. So the decimal is split as text and never becomes a + * float at all. + */ +export function priceCents(price: string | number): number { + const text = String(price ?? '').trim(); + if (!/^\d+(\.\d+)?$/.test(text)) { + throw new PorkbunError(`unreadable price from Porkbun: ${JSON.stringify(price)}`); + } + const [whole, fraction = ''] = text.split('.'); + const cents = Number(whole) * 100 + Number(`${fraction}00`.slice(0, 2)); + // A third decimal is not a thing Porkbun quotes, but round it like money + // rather than silently dropping half a cent if it ever appears. + return Number(fraction[2] ?? 0) >= 5 ? cents + 1 : cents; +} + +/** `$11.08`, from either form. Display only — never send this to the API. */ +export function formatPrice(price: string | number): string { + return `$${(priceCents(price) / 100).toFixed(2)}`; +} + +/** Lowercase, trimmed, no trailing dot, and actually shaped like a domain. */ +export function normalizeDomain(domain: string): string { + const zone = String(domain ?? '') + .trim() + .toLowerCase() + .replace(/\.$/, ''); + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(zone)) { + throw new PorkbunError(`${JSON.stringify(domain)} is not a valid domain name`); + } + return zone; +} + +/** + * The TLD, as `/domain/getRegistrationRequirements` spells it. + * + * Everything after the first label, so `diskpush.com` is `com` and + * `example.co.uk` is `co.uk` — which is what that endpoint keys on, rather than + * the last label alone. + */ +export function tldOf(domain: string): string { + const zone = normalizeDomain(domain); + return zone.slice(zone.indexOf('.') + 1); +} + +export interface Availability { + domain: string; + available: boolean; + premium: boolean; + /** The quote, in whole cents — exactly what `/domain/create` must be sent. */ + costCents: number; + /** The same number for people: `$11.08`. */ + price: string; + renewal: string | null; + /** A cheap first year that renews dearer, which is worth saying out loud. */ + firstYearPromo: boolean; + /** Years. `.com` is 1; some TLDs sell a 2-year minimum. */ + minDuration: number; +} + +/** + * Is it available, and at what price? + * + * **Rate limited to one call per ten seconds per account**, which shapes the + * caller: check once, keep the quote, and spend it. Checking again to + * "confirm" just before buying earns a rate-limit error instead of a second + * answer. + */ +export async function checkAvailability(call: Caller, domain: string): Promise { + const zone = normalizeDomain(domain); + const body = await call(`/domain/checkDomain/${zone}`); + const response = (body.response ?? {}) as Record; + + const price = response.price; + if (price === undefined) { + throw new PorkbunError(`/domain/checkDomain/${zone}: no price in the response`); + } + const additional = (response.additional ?? {}) as Record | undefined>; + const renewal = additional.renewal?.price; + + return { + domain: zone, + available: response.avail === 'yes', + premium: response.premium === 'yes', + costCents: priceCents(price as string), + price: formatPrice(price as string), + renewal: renewal === undefined ? null : formatPrice(renewal as string), + firstYearPromo: response.firstYearPromo === 'yes', + minDuration: Number(response.minDuration ?? 1) || 1, + }; +} + +export interface RegistrationRequirements { + tld: string; + /** Some TLDs are dashboard-only. Better to hear it here than after a charge. */ + apiRegisterable: boolean; + whoisPrivacySupported: boolean; + years: number | null; + /** Registry eligibility fields (`.us` nexus, `.ca` legal type), or null. */ + registryRequirements: unknown; +} + +export async function registrationRequirements( + call: Caller, + tld: string, +): Promise { + const body = await call(`/domain/getRegistrationRequirements/${tld}`); + return { + tld, + apiRegisterable: body.apiRegisterable === true, + whoisPrivacySupported: body.whoisPrivacySupported === true, + years: typeof body.registrationDurationYears === 'number' ? body.registrationDurationYears : null, + registryRequirements: body.registryRequirements ?? null, + }; +} + +export interface RegistrationPlan { + domain: string; + costCents: number; + price: string; + renewal: string | null; + premium: boolean; + firstYearPromo: boolean; + years: number | null; + whoisPrivacy: boolean; +} + +export interface RegistrationOptions { + whoisPrivacy?: boolean; + /** Refuse to spend more than this, in cents. A typo guard, and a premium guard. */ + maxCents?: number; +} + +/** + * Everything that has to be true before money moves, resolved once. + * + * A plan rather than a `register(domain)` that does the lot, for the same + * reason {@link planUnpark} is a plan: the number shown at the confirmation + * prompt and the number sent to the registrar are then the same number by + * construction and cannot drift between the two. It also means the + * availability check — one per ten seconds — happens exactly once. + */ +export async function planRegistration( + call: Caller, + domain: string, + options: RegistrationOptions = {}, +): Promise { + const zone = normalizeDomain(domain); + const requirements = await registrationRequirements(call, tldOf(zone)); + + if (!requirements.apiRegisterable) { + throw new PorkbunError( + `.${requirements.tld} cannot be registered through the API — buy it in the dashboard`, + ); + } + if (requirements.registryRequirements) { + throw new PorkbunError( + `.${requirements.tld} needs registry eligibility details this command does not collect ` + + '— buy it in the dashboard', + ); + } + + const availability = await checkAvailability(call, zone); + if (!availability.available) { + throw new PorkbunError(`${zone} is already registered`); + } + + const whoisPrivacy = options.whoisPrivacy ?? true; + if (whoisPrivacy && !requirements.whoisPrivacySupported) { + throw new PorkbunError( + `.${requirements.tld} does not support WHOIS privacy — pass --no-whois-privacy to ` + + 'register with your contact details public', + ); + } + if (options.maxCents !== undefined && availability.costCents > options.maxCents) { + throw new PorkbunError( + `${zone} costs ${availability.price}, over the --max-price limit` + + `${availability.premium ? ' — it is a premium name' : ''}`, + ); + } + + return { + domain: zone, + costCents: availability.costCents, + price: availability.price, + renewal: availability.renewal, + premium: availability.premium, + firstYearPromo: availability.firstYearPromo, + years: requirements.years, + whoisPrivacy, + }; +} + +/** + * Buy the domain the plan describes. + * + * Takes a {@link RegistrationPlan} rather than a domain and a price so nothing + * between the prompt and the purchase can substitute a different number. + * `cost` has to equal Porkbun's live quote or the call is refused, which is the + * safety net: a stale plan fails instead of quietly overpaying. + */ +export async function registerDomain(call: Caller, plan: RegistrationPlan): Promise { + await call(`/domain/create/${plan.domain}`, { + cost: plan.costCents, + agreeToTerms: 'yes', + whoisPrivacy: plan.whoisPrivacy ? 'yes' : 'no', + }); +} diff --git a/test/porkbun.test.ts b/test/porkbun.test.ts index 453d711..b2fb19d 100644 --- a/test/porkbun.test.ts +++ b/test/porkbun.test.ts @@ -6,6 +6,7 @@ import { type Caller, type DnsRecord, type UrlForward, + checkAvailability, createRecord, credentialsFrom, formatForwards, @@ -16,10 +17,15 @@ import { listDomains, listRecords, matchRecords, + normalizeDomain, + planRegistration, planUnpark, porkbunCaller, + priceCents, + registerDomain, setRecord, sortRecords, + tldOf, unwrap, } from '../src/porkbun.ts'; @@ -331,3 +337,253 @@ describe('porkbunCaller', () => { await expect(call('/ping')).rejects.toThrow(/HTTP 502.*not JSON/s); }); }); + +describe('priceCents', () => { + // The reason this function exists rather than `parseFloat(p) * 100`: that + // expression is 1107.9999999999998 here, and a truncated 1107 buys nothing. + it('converts a dollar string without going through a float', () => { + expect(priceCents('11.08')).toBe(1108); + expect(priceCents('0.99')).toBe(99); + expect(priceCents('1234.56')).toBe(123_456); + }); + + it('handles whole dollars and a single decimal', () => { + expect(priceCents('22')).toBe(2200); + expect(priceCents('22.5')).toBe(2250); + expect(priceCents(35)).toBe(3500); + }); + + it('rounds a third decimal like money', () => { + expect(priceCents('1.005')).toBe(101); + expect(priceCents('1.004')).toBe(100); + }); + + it('refuses anything that is not a price', () => { + expect(() => priceCents('free')).toThrow(PorkbunError); + expect(() => priceCents('-5.00')).toThrow(/unreadable price/); + expect(() => priceCents('')).toThrow(/unreadable price/); + }); +}); + +describe('normalizeDomain and tldOf', () => { + it('lowercases and drops a trailing dot', () => { + expect(normalizeDomain(' DiskPush.COM. ')).toBe('diskpush.com'); + }); + + it('rejects a bare label, a URL and a path', () => { + expect(() => normalizeDomain('diskpush')).toThrow(/not a valid domain/); + expect(() => normalizeDomain('https://diskpush.com')).toThrow(/not a valid domain/); + expect(() => normalizeDomain('diskpush.com/a')).toThrow(/not a valid domain/); + }); + + // The requirements endpoint keys on the whole suffix, not the last label. + it('takes everything after the first label as the TLD', () => { + expect(tldOf('diskpush.com')).toBe('com'); + expect(tldOf('example.co.uk')).toBe('co.uk'); + }); +}); + +describe('checkAvailability', () => { + const body = (overrides: Record = {}) => ({ + status: 'SUCCESS', + response: { + avail: 'yes', + type: 'registration', + price: '11.08', + firstYearPromo: 'no', + regularPrice: '11.08', + premium: 'no', + additional: { renewal: { type: 'renewal', price: '11.08' } }, + minDuration: 1, + ...overrides, + }, + }); + + it('reads the quote out of the nested response', async () => { + const { call, seen } = scripted({ '/domain/checkDomain/diskpush.com': body() }); + const availability = await checkAvailability(call, 'DiskPush.com'); + + expect(availability).toMatchObject({ + domain: 'diskpush.com', + available: true, + premium: false, + costCents: 1108, + price: '$11.08', + renewal: '$11.08', + }); + expect(seen[0]?.path).toBe('/domain/checkDomain/diskpush.com'); + }); + + it('reports a taken name rather than throwing', async () => { + const { call } = scripted({ + '/domain/checkDomain/taken.com': body({ avail: 'no' }), + }); + expect((await checkAvailability(call, 'taken.com')).available).toBe(false); + }); + + it('throws when there is no price to send back', async () => { + const { call } = scripted({ + '/domain/checkDomain/x.com': { status: 'SUCCESS', response: { avail: 'yes' } }, + }); + await expect(checkAvailability(call, 'x.com')).rejects.toThrow(/no price/); + }); +}); + +describe('planRegistration', () => { + const requirements = (overrides: Record = {}) => ({ + status: 'SUCCESS', + tld: 'com', + apiRegisterable: true, + registrationDurationYears: 1, + whoisPrivacySupported: true, + registryRequirements: null, + ...overrides, + }); + + const available = (overrides: Record = {}) => ({ + status: 'SUCCESS', + response: { + avail: 'yes', + price: '11.08', + premium: 'no', + firstYearPromo: 'no', + additional: { renewal: { price: '11.08' } }, + minDuration: 1, + ...overrides, + }, + }); + + const scriptFor = ( + domain: string, + reqs: Record = requirements(), + avail: Record = available(), + ) => + scripted({ + '/domain/getRegistrationRequirements/com': reqs, + [`/domain/checkDomain/${domain}`]: avail, + }); + + it('carries the quote through as the cost to send', async () => { + const { call } = scriptFor('diskpush.com'); + const plan = await planRegistration(call, 'diskpush.com'); + + expect(plan).toEqual({ + domain: 'diskpush.com', + costCents: 1108, + price: '$11.08', + renewal: '$11.08', + premium: false, + firstYearPromo: false, + years: 1, + whoisPrivacy: true, + }); + }); + + // One check per ten seconds, so a second one is an error and not an answer. + it('checks availability exactly once', async () => { + const { call, seen } = scriptFor('diskpush.com'); + await planRegistration(call, 'diskpush.com'); + expect(seen.filter((entry) => entry.path.startsWith('/domain/checkDomain'))).toHaveLength(1); + }); + + it('refuses a name that is already registered', async () => { + const { call } = scriptFor('diskpush.com', requirements(), available({ avail: 'no' })); + await expect(planRegistration(call, 'diskpush.com')).rejects.toThrow(/already registered/); + }); + + it('refuses a TLD the API cannot sell', async () => { + const { call } = scriptFor('diskpush.com', requirements({ apiRegisterable: false })); + await expect(planRegistration(call, 'diskpush.com')).rejects.toThrow(/cannot be registered/); + }); + + // .us nexus, .ca legal type: fields this command has no way to collect. + it('refuses a TLD with registry eligibility fields', async () => { + const { call } = scriptFor('diskpush.com', requirements({ registryRequirements: { nexus: {} } })); + await expect(planRegistration(call, 'diskpush.com')).rejects.toThrow(/eligibility/); + }); + + it('refuses to silently publish contacts when privacy is unsupported', async () => { + const { call } = scriptFor('diskpush.com', requirements({ whoisPrivacySupported: false })); + await expect(planRegistration(call, 'diskpush.com')).rejects.toThrow(/--no-whois-privacy/); + }); + + it('allows public contacts when asked for explicitly', async () => { + const { call } = scriptFor('diskpush.com', requirements({ whoisPrivacySupported: false })); + const plan = await planRegistration(call, 'diskpush.com', { whoisPrivacy: false }); + expect(plan.whoisPrivacy).toBe(false); + }); + + it('stops a premium name from quietly costing hundreds', async () => { + const { call } = scriptFor( + 'diskpush.com', + requirements(), + available({ price: '2999.00', premium: 'yes' }), + ); + await expect(planRegistration(call, 'diskpush.com', { maxCents: 5000 })).rejects.toThrow( + /premium/, + ); + }); + + it('allows a price at the limit', async () => { + const { call } = scriptFor('diskpush.com'); + await expect(planRegistration(call, 'diskpush.com', { maxCents: 1108 })).resolves.toMatchObject({ + costCents: 1108, + }); + }); +}); + +describe('registerDomain', () => { + it('sends the plan cost in cents, with the terms agreed', async () => { + const { call, seen } = scripted({ '/domain/create/diskpush.com': { status: 'SUCCESS' } }); + await registerDomain(call, { + domain: 'diskpush.com', + costCents: 1108, + price: '$11.08', + renewal: '$11.08', + premium: false, + firstYearPromo: false, + years: 1, + whoisPrivacy: true, + }); + + expect(seen).toHaveLength(1); + expect(seen[0]).toEqual({ + path: '/domain/create/diskpush.com', + body: { cost: 1108, agreeToTerms: 'yes', whoisPrivacy: 'yes' }, + }); + }); + + it('passes privacy off through as "no"', async () => { + const { call, seen } = scripted({ '/domain/create/x.com': { status: 'SUCCESS' } }); + await registerDomain(call, { + domain: 'x.com', + costCents: 900, + price: '$9.00', + renewal: null, + premium: false, + firstYearPromo: false, + years: null, + whoisPrivacy: false, + }); + expect(seen[0]?.body.whoisPrivacy).toBe('no'); + }); + + // The 200-with-ERROR shape again, on the one call that spends money. + it('surfaces a refusal rather than reporting success', async () => { + const call: Caller = async () => { + throw new PorkbunError('/domain/create/x.com: Insufficient funds.'); + }; + await expect( + registerDomain(call, { + domain: 'x.com', + costCents: 900, + price: '$9.00', + renewal: null, + premium: false, + firstYearPromo: false, + years: null, + whoisPrivacy: true, + }), + ).rejects.toThrow(/Insufficient funds/); + }); +});