Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
18 changes: 17 additions & 1 deletion bin/porkbun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
planRegistration,
planUnpark,
porkbunCaller,
previewRegistration,
priceCents,
registerDomain,
setRecord,
Expand Down Expand Up @@ -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}?`))) {
Expand Down
118 changes: 107 additions & 11 deletions src/porkbun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, unknown>) => Promise<Record<string, unknown>>;
export type Caller = (
path: string,
body?: Record<string, unknown>,
options?: CallOptions,
) => Promise<Record<string, unknown>>;

/**
* Turn a response body into either its data or an error.
Expand All @@ -95,21 +118,44 @@ export function unwrap(body: unknown, path: string): Record<string, unknown> {
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<string, unknown>;
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(
credentials: Credentials,
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,
});
Expand Down Expand Up @@ -623,18 +669,68 @@ export async function planRegistration(
};
}

function createBody(plan: RegistrationPlan): Record<string, unknown> {
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<RegistrationPreview> {
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.
*
* 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.
*
* 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<void> {
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<void> {
await call(`/domain/create/${plan.domain}`, createBody(plan), { idempotencyKey });
}
Loading
Loading