diff --git a/README.md b/README.md index 0af6378..a5d51ab 100644 --- a/README.md +++ b/README.md @@ -543,6 +543,18 @@ unless you pass `--no-whois-privacy`, and a TLD that cannot do privacy is refuse rather than quietly publishing your address. It pays from the account balance, topping up the card on file if that is short. +Before charging anything it runs Porkbun's **own** pre-flight (`dryRun`), which is +the only way to see the account-level gates — funds, the monthly API spend cap and +whether the account is verified at all. No read endpoint reports those, so a local +check cannot substitute. `--dry-run` stops after it. The real purchase carries an +`Idempotency-Key`, because a create that times out has probably still registered +the domain and the natural retry would buy a second year. + +Refusals keep Porkbun's structured `code`, `hint` and `next_action.url`, so +`VERIFICATION_REQUIRED` prints what would clear it, where, and that retrying will +not help — rather than one sentence that reads like a transient and invites six +more attempts. + 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` diff --git a/bin/porkbun.ts b/bin/porkbun.ts index 7a1c986..ad983cc 100755 --- a/bin/porkbun.ts +++ b/bin/porkbun.ts @@ -39,6 +39,7 @@ import { planRegistration, planUnpark, porkbunCaller, + previewRegistration, priceCents, registerDomain, setRecord, @@ -365,8 +366,23 @@ if (isMain(import.meta.url)) { process.stderr.write(' note: promotional first year — the renewal price is higher\n'); } + // Porkbun's own pre-flight, not a local one: funds, the monthly spend + // cap and account verification are account-level gates that no read + // endpoint reports, so this is the only way to see them before paying. + const preview = await previewRegistration(call, plan); + process.stderr.write( + ` balance: ${preview.balance ?? 'unknown'}` + + `${preview.sufficientFunds === false ? ' — will top up the card on file' : ''}\n`, + ); + if (preview.withinMonthlySpendLimit === false) { + throw new PorkbunError(`${plan.domain} would exceed the account's monthly API spend cap`); + } + if (!preview.wouldSucceed) { + throw new PorkbunError(`Porkbun refused the pre-flight for ${plan.domain}`); + } + if (parsed.flags.has('--dry-run')) { - process.stdout.write('--dry-run: nothing registered\n'); + process.stdout.write('--dry-run: pre-flight passed, nothing registered\n'); break; } if (!assumeYes && !(await confirm(`register ${plan.domain} for ${plan.price}?`))) { diff --git a/src/porkbun.ts b/src/porkbun.ts index 1c7f794..4597c0e 100644 --- a/src/porkbun.ts +++ b/src/porkbun.ts @@ -20,6 +20,8 @@ * body's own `status` as the verdict and surfaces `message` verbatim. */ +import { randomUUID } from 'node:crypto'; + export const API_BASE = 'https://api.porkbun.com/api/json/v3'; /** Porkbun rejects anything lower, and silently on some endpoints. */ @@ -72,14 +74,35 @@ export interface UrlForward { } export class PorkbunError extends Error { - constructor(message: string) { + /** Porkbun's machine-readable code, when it sent one: `VERIFICATION_REQUIRED`. */ + readonly code?: string; + /** Whether Porkbun says trying again could ever work. */ + readonly retryable?: boolean; + + constructor(message: string, details: { code?: string; retryable?: boolean } = {}) { super(message); this.name = 'PorkbunError'; + if (details.code !== undefined) this.code = details.code; + if (details.retryable !== undefined) this.retryable = details.retryable; } } +export interface CallOptions { + /** + * Replays the first result instead of acting twice, for 24 hours. + * + * Only meaningful on a write that costs money: without it, a retry after a + * timeout that actually succeeded buys the domain a second time. + */ + idempotencyKey?: string; +} + /** Issue one API call and return its body. Injected so tests never go to the network. */ -export type Caller = (path: string, body?: Record) => Promise>; +export type Caller = ( + path: string, + body?: Record, + options?: CallOptions, +) => Promise>; /** * Turn a response body into either its data or an error. @@ -95,7 +118,27 @@ export function unwrap(body: unknown, path: string): Record { if (record.status === 'SUCCESS') return record; const message = typeof record.message === 'string' ? record.message : JSON.stringify(record); - throw new PorkbunError(`${path}: ${message}`); + + // Newer endpoints answer a refusal with a `code`, and a `next_action` saying + // what would clear it and whether retrying can ever help. Dropping that turns + // "verify the account at this URL, retrying will not work" into a bare + // sentence that reads like a transient — which is exactly the case where + // someone retries six times instead of opening the page. + const next = (record.next_action ?? {}) as Record; + const code = typeof record.code === 'string' ? record.code : undefined; + const hint = typeof next.hint === 'string' ? next.hint : undefined; + const url = typeof next.url === 'string' ? next.url : undefined; + const retryable = typeof next.retryable === 'boolean' ? next.retryable : undefined; + + const parts = [`${path}: ${message}`]; + if (hint && hint !== message) parts.push(hint); + if (url) parts.push(url); + if (retryable === false) parts.push('retrying will not help'); + + throw new PorkbunError(parts.join('\n '), { + ...(code === undefined ? {} : { code }), + ...(retryable === undefined ? {} : { retryable }), + }); } export function porkbunCaller( @@ -103,13 +146,16 @@ export function porkbunCaller( timeoutMs: number = DEFAULT_TIMEOUT_MS, fetcher: typeof fetch = fetch, ): Caller { - return async (path, body = {}) => { + return async (path, body = {}, options = {}) => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetcher(`${API_BASE}${path}`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { + 'content-type': 'application/json', + ...(options.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}), + }, body: JSON.stringify({ ...credentials, ...body }), signal: controller.signal, }); @@ -623,6 +669,51 @@ export async function planRegistration( }; } +function createBody(plan: RegistrationPlan): Record { + return { + cost: plan.costCents, + agreeToTerms: 'yes', + whoisPrivacy: plan.whoisPrivacy ? 'yes' : 'no', + }; +} + +export interface RegistrationPreview { + wouldSucceed: boolean; + /** Porkbun's own rendering of the charge, e.g. `$11.08`. */ + costDisplay: string | null; + balance: string | null; + sufficientFunds: boolean | null; + /** Null when no monthly API spend cap is set on the account. */ + withinMonthlySpendLimit: boolean | null; +} + +/** + * Every pre-flight check Porkbun would run, without charging or creating. + * + * Worth preferring over checking things locally: this is the only way to see + * the account-level gates — funds, the monthly API spend cap, and whether the + * account is verified at all — none of which any read endpoint exposes. It + * does not consume the create rate-limit budget either. + * + * A refused pre-flight arrives as a {@link PorkbunError} carrying Porkbun's + * `code`, so `VERIFICATION_REQUIRED` is distinguishable from a price mismatch. + */ +export async function previewRegistration( + call: Caller, + plan: RegistrationPlan, +): Promise { + const body = await call(`/domain/create/${plan.domain}`, { ...createBody(plan), dryRun: true }); + const bool = (value: unknown): boolean | null => (typeof value === 'boolean' ? value : null); + + return { + wouldSucceed: body.wouldSucceed === true, + costDisplay: typeof body.costDisplay === 'string' ? body.costDisplay : null, + balance: typeof body.balance === 'string' ? body.balance : null, + sufficientFunds: bool(body.sufficientFunds), + withinMonthlySpendLimit: bool(body.withinMonthlySpendLimit), + }; +} + /** * Buy the domain the plan describes. * @@ -630,11 +721,16 @@ export async function planRegistration( * 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. + * + * The idempotency key is not optional in practice. A create that times out has + * still very likely registered the domain, and the natural response — run it + * again — buys and bills a second year. Keyed, the retry replays the first + * result for 24 hours instead. */ -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', - }); +export async function registerDomain( + call: Caller, + plan: RegistrationPlan, + idempotencyKey: string = randomUUID(), +): Promise { + await call(`/domain/create/${plan.domain}`, createBody(plan), { idempotencyKey }); } diff --git a/test/porkbun.test.ts b/test/porkbun.test.ts index b2fb19d..675e3e5 100644 --- a/test/porkbun.test.ts +++ b/test/porkbun.test.ts @@ -5,6 +5,7 @@ import { PorkbunError, type Caller, type DnsRecord, + type RegistrationPlan, type UrlForward, checkAvailability, createRecord, @@ -21,6 +22,7 @@ import { planRegistration, planUnpark, porkbunCaller, + previewRegistration, priceCents, registerDomain, setRecord, @@ -587,3 +589,165 @@ describe('registerDomain', () => { ).rejects.toThrow(/Insufficient funds/); }); }); + +/** The plan every registration test buys, so the numbers are one fact. */ +function samplePlan(): RegistrationPlan { + return { + domain: 'diskpush.com', + costCents: 1108, + price: '$11.08', + renewal: '$11.08', + premium: false, + firstYearPromo: false, + years: 1, + whoisPrivacy: true, + }; +} + +/** The message `unwrap` would throw, for asserting on its text. */ +function unwrapMessage(body: unknown): string { + try { + unwrap(body, '/x'); + return ''; + } catch (error) { + return (error as Error).message; + } +} + +describe('unwrap, structured refusals', () => { + const refusal = { + status: 'ERROR', + message: 'Your account phone number and email address must be verified.', + code: 'VERIFICATION_REQUIRED', + next_action: { + type: 'verify_account', + hint: 'Verify your account email and phone number, then retry.', + retryable: false, + url: 'https://porkbun.com/account', + }, + }; + + it('keeps the code and the retryable verdict on the error', () => { + try { + unwrap(refusal, '/domain/create/x.com'); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(PorkbunError); + expect((error as PorkbunError).code).toBe('VERIFICATION_REQUIRED'); + expect((error as PorkbunError).retryable).toBe(false); + } + }); + + // The whole point: a refusal that cannot be retried should not read like a + // transient, or it gets retried instead of acted on. + it('puts the fix, the URL and "retrying will not help" in the message', () => { + expect(() => unwrap(refusal, '/domain/create/x.com')).toThrow(/porkbun\.com\/account/); + expect(() => unwrap(refusal, '/domain/create/x.com')).toThrow(/retrying will not help/); + }); + + it('does not repeat the hint when it merely restates the message', () => { + const message = unwrapMessage({ + status: 'ERROR', + message: 'Nope.', + next_action: { hint: 'Nope.' }, + }); + expect(message.match(/Nope\./g)).toHaveLength(1); + }); + + it('still handles a plain refusal with no code at all', () => { + try { + unwrap({ status: 'ERROR', message: 'Invalid domain.' }, '/dns/retrieve/x.com'); + expect.unreachable('should have thrown'); + } catch (error) { + expect((error as PorkbunError).code).toBeUndefined(); + expect((error as PorkbunError).retryable).toBeUndefined(); + expect((error as Error).message).toMatch(/Invalid domain/); + } + }); +}); + +describe('idempotency', () => { + it('sends an Idempotency-Key on the call that spends money', async () => { + const seen: { headers: Record }[] = []; + const fetcher = (async (_url: string, init: RequestInit) => { + seen.push({ headers: init.headers as Record }); + return new Response(JSON.stringify({ status: 'SUCCESS' }), { status: 200 }); + }) as unknown as typeof fetch; + + const call = porkbunCaller({ apikey: 'pk1_x', secretapikey: 'sk1_y' }, 1000, fetcher); + await registerDomain(call, samplePlan(), 'fixed-key-123'); + + expect(seen[0]?.headers['Idempotency-Key']).toBe('fixed-key-123'); + }); + + it('generates a key when none is given, rather than sending none', async () => { + const seen: { headers: Record }[] = []; + const fetcher = (async (_url: string, init: RequestInit) => { + seen.push({ headers: init.headers as Record }); + return new Response(JSON.stringify({ status: 'SUCCESS' }), { status: 200 }); + }) as unknown as typeof fetch; + + const call = porkbunCaller({ apikey: 'pk1_x', secretapikey: 'sk1_y' }, 1000, fetcher); + await registerDomain(call, samplePlan()); + + expect(seen[0]?.headers['Idempotency-Key']).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('leaves the header off calls that do not spend', async () => { + const seen: { headers: Record }[] = []; + const fetcher = (async (_url: string, init: RequestInit) => { + seen.push({ headers: init.headers as Record }); + return new Response(JSON.stringify({ status: 'SUCCESS', records: [] }), { status: 200 }); + }) as unknown as typeof fetch; + + const call = porkbunCaller({ apikey: 'pk1_x', secretapikey: 'sk1_y' }, 1000, fetcher); + await listRecords(call, 'example.com'); + + expect(seen[0]?.headers['Idempotency-Key']).toBeUndefined(); + }); +}); + +describe('previewRegistration', () => { + it('reads the account-level gates out of the dry run', async () => { + const { call, seen } = scripted({ + '/domain/create/diskpush.com': { + status: 'SUCCESS', + wouldSucceed: true, + cost: 1108, + costDisplay: '$11.08', + balance: '$0.00', + sufficientFunds: false, + withinMonthlySpendLimit: true, + }, + }); + + const preview = await previewRegistration(call, samplePlan()); + expect(preview).toEqual({ + wouldSucceed: true, + costDisplay: '$11.08', + balance: '$0.00', + sufficientFunds: false, + withinMonthlySpendLimit: true, + }); + // The flag that makes it free to run. + expect(seen[0]?.body.dryRun).toBe(true); + }); + + it('sends the same cost and terms the real call would', async () => { + const { call, seen } = scripted({ + '/domain/create/diskpush.com': { status: 'SUCCESS', wouldSucceed: true }, + }); + await previewRegistration(call, samplePlan()); + + expect(seen[0]?.body).toMatchObject({ cost: 1108, agreeToTerms: 'yes', whoisPrivacy: 'yes' }); + }); + + it('reports a missing spend cap as null rather than false', async () => { + const { call } = scripted({ + '/domain/create/diskpush.com': { status: 'SUCCESS', wouldSucceed: true }, + }); + const preview = await previewRegistration(call, samplePlan()); + expect(preview.withinMonthlySpendLimit).toBeNull(); + expect(preview.sufficientFunds).toBeNull(); + }); +});