@regfish/api-v1
v0.1.0
Published
TypeScript client for the regfish API v1 — domains, DNS records and zones, DNSSEC, web hosting and TLS certificates.
Downloads
101
Maintainers
Readme
@regfish/api-v1
TypeScript client for the regfish API v1 — domains, DNS records and zones, DNSSEC, web hosting and TLS certificates.
Server-side only. The API sends no CORS headers, so a browser cannot call it, and an API key is a credential that must never reach a browser bundle. Node ≥ 20, Deno, Bun and edge runtimes are all fine.
Install
npm install @regfish/api-v1Quick start
import { RegfishClient } from "@regfish/api-v1";
const client = new RegfishClient({ apiKey: process.env.RF_API_KEY! });
const record = await client.records.add({
name: "www.example.com.",
type: "A",
data: "10.2.3.4",
ttl: 600,
});
console.log("created rrid", record.id);The API key is created in the regfish dash under Account → Security → API
keys and is sent as the x-api-key header.
Configuration
const client = new RegfishClient({
apiKey,
baseUrl: BASE_URL_COM, // https://api.regfish.com, same API
timeoutMs: 30_000, // default 60_000; 0 disables
userAgent: "my-app/1.0",
fetch: myFetch, // e.g. undici with a custom dispatcher
});Every method takes an optional final argument with an AbortSignal, combined
with the configured timeout:
await client.zones.list({ signal: AbortSignal.timeout(5_000) });Errors
Failures throw an ApiError carrying the HTTP status, the regfish error code
and the API's message. Use error.reason rather than reading the fields
directly — the API puts its explanation in message on some endpoints and in
error on others.
import { ApiError, ErrorCode, errorCode, isApiError } from "@regfish/api-v1";
try {
await client.records.add(record);
} catch (err) {
if (errorCode(err) === ErrorCode.ResourceRecordAlreadyExists) {
// a record with this name/type/data already exists
} else if (isApiError(err)) {
console.error(err.status, err.reason);
}
}Why a call was refused
An HTTP status is not enough to decide what to do about a refusal. denialOf
reduces the response to one value you can branch on:
import { denialOf, missingPermission, deniedOperation } from "@regfish/api-v1";
switch (denialOf(err)) {
case "permission": {
const permission = missingPermission(err);
throw new Error(
permission
? `grant ${permission} to this API key in the dash`
: `this API key is not permitted to do that`,
);
}
case "scope":
throw new Error("this is a scope-bound platform credential; use a customer key");
case "unmapped-operation":
throw new Error(`regfish has no permission mapping for ${deniedOperation(err)}`);
case "guardian":
throw new Error("the Domain-Guardian blocked this — confirm it in the dash");
case "quota":
break; // retry after the window or the UTC day rolls over
case "unauthenticated":
throw new Error("the API key was not accepted");
}missingPermission and deniedOperation read the name out of the API's own
message, so they never go stale — but they return undefined if the server
rewords it. Always handle that branch.
If you retry on 5xx, check isDomainGuardianBlocked first. The API reports
a Domain-Guardian block as HTTP 403 on the domain endpoints but as HTTP 500
on the DNS record endpoints, where nothing but the message text separates it
from a genuine, retryable fault. A naive retry loop hammers a decision that
cannot succeed until a human acts:
import { isPermanent } from "@regfish/api-v1";
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await client.records.add(record);
} catch (err) {
if (isPermanent(err) || attempt === 4) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
}
}isPermanent is one-directional: true means no retry can clear it, false only
means "not known to be permanent". Cap your retries either way.
API keys and permissions
A key is one of three kinds, and keyKind is the only safe way to tell them
apart:
| Kind | permissions on the wire | Meaning |
| --- | --- | --- |
| legacy | null | Created before RBAC — full access |
| rbac | […], possibly [] | Exactly the listed permissions; [] means none |
| capability | null, plus a scope | Platform credential (e.g. ACME DNS-01), limited to a fixed set of operations whatever its permissions |
null and [] mean opposite things, and a capability-scoped key looks like a
legacy key if you only read permissions. Check the key once at startup so an
under-scoped one fails the deploy rather than the first write:
import { Permission, describeReport } from "@regfish/api-v1";
const report = await client.meta.checkPermissions([
Permission.DnsRead,
Permission.DnsWrite,
]);
if (!report.satisfied) {
throw new Error(describeReport(report));
// regfish API key kAbC [rbac]: missing dns:write
}Gate on satisfied, not on missing.length: a capability-scoped credential has
nothing missing and still cannot do the work, so those land in undetermined.
The client never refuses a call on its own. It keeps no copy of the server's
operation-to-permission table, so it can never deny something the API would have
allowed, and it cannot go stale when regfish adds an endpoint. checkPermissions
and permits are advisory diagnostics; the API remains the only authority. A
satisfied report is not a promise either — it cannot see Domain-Guardian rules,
domain ownership, plan entitlements or quotas.
To rehydrate cached token metadata, use parseTokenInfo rather than
JSON.parse: a payload that lost its permissions member would otherwise read
as a legacy key with full access.
Testing your own code
The package ships a mock server and fixtures at @regfish/api-v1/testing, so
your tests never touch the live API:
import { createMockRegfish, ok, mockRecord } from "@regfish/api-v1/testing";
const mock = await createMockRegfish();
mock.on("POST", "/dns/rr", () => ok(mockRecord({ id: 4711 })));
const record = await mock.client.records.add({ name: "www.example.com.", type: "A", data: "192.0.2.1" });
expect(record.id).toBe(4711);
expect(mock.requestsFor("POST", "/dns/rr")).toHaveLength(1);
await mock.close();The reason it exists is the unhappy path. These refusals are painful to provoke on purpose and are exactly the ones consumers get wrong, so they are one call each:
| Builder | Reproduces |
| --- | --- |
| guardianBlocked() | A Domain-Guardian block as the record endpoints serve it — HTTP 500, error code 1 |
| transientFailure() | A genuinely retryable fault with the same status and code |
| permissionDenied("dns:write") | The RBAC gate, with the permission name intact |
| scopeDenied("ListDomains") | A scope-bound credential refused outside its scope |
| unmappedOperation("NewOp") | The fail-closed refusal for an unmapped operation |
| quotaExceeded() / unauthorized() | A daily budget, and an unusable key |
Pair guardianBlocked() with transientFailure() to prove your retry loop
stops on the one and keeps going on the other — they are indistinguishable on
status and code alone.
Fixtures: mockRecord, mockZoneSummary, mockCertificate, mockTokenInfo,
mockLegacyTokenInfo, mockAcmeTokenInfo. Each takes overrides, so a test
states only the field it cares about.
Examples
Twelve runnable programs in examples/, each a real task — an ACME
DNS-01 solver, a DynDNS updater, declarative record sync, zone backup, a
portfolio audit, certificate ordering with automatic DCV, DNSSEC rollout and
more. They compile in CI, so they cannot go stale.
Coverage
All 39 v1 operations. The Permission column is what to pass to
checkPermissions; it is documentation, never enforcement, and mirrors the
server as of API version 1.6.2. Note that read and write do not split the way
the method names suggest — tls.download needs tls:read, and every hosting
method needs hosting:read.
| Method | Endpoint | operationId | Permission |
| --- | --- | --- | --- |
| meta.getTokenInfo | GET /meta/token | GetTokenInfo | — |
| meta.checkPermissions | GET /meta/token | GetTokenInfo | — |
| meta.downloadOpenApiSpec | GET /openapi.yaml | DownloadOpenAPISpec | — |
| domains.list | GET /domains | ListDomains | domain:read |
| domains.get | GET /domains/{domain} | GetDomainByName | domain:read |
| domains.getNameservers | GET /domains/{domain}/nameservers | GetNameserversByDomain | domain:read |
| domains.setNameservers | PUT /domains/{domain}/nameservers | PutNameserversByDomain | domain:write |
| domains.requestAuthinfo | POST /domains/{domain}/authinfo | RequestAuthinfoByDomain | domain:write |
| records.listByDomain | GET /dns/{domain}/rr | GetRecordsByDomain | dns:read |
| records.get | GET /dns/rr/{rrid} | GetRecordByRRID | dns:read |
| records.add | POST /dns/rr | AddRecord | dns:write |
| records.patch | PATCH /dns/rr | PatchRecord | dns:write |
| records.patchByRrid | PATCH /dns/rr/{rrid} | PatchRecordByRRID | dns:write |
| records.delete | DELETE /dns/rr/{rrid} | DeleteRecordByRRID | dns:write |
| zones.list | GET /dns/zones | ListDNSZones | dns:read |
| zones.get | GET /dns/zones/{domain} | GetDNSZoneByDomain | dns:read |
| zones.exportBind | GET /dns/zones/{domain}/export | ExportDNSZoneBindByDomain | dns:read |
| dnssec.get | GET /dns/{domain}/dnssec | GetDNSSECByDomain | dns:read |
| dnssec.put | PUT /dns/{domain}/dnssec | PutDNSSECByDomain | dns:write |
| dnssec.cancel | POST /dns/{domain}/dnssec/cancel | CancelDNSSECByDomain | dns:write |
| dnssec.verify | POST /dns/{domain}/dnssec/verify | VerifyDNSSECByDomain | dns:write |
| dnssec.listJobs | GET /dns/{domain}/dnssec/jobs | ListDNSSECJobsByDomain | dns:read |
| hosting.list | GET /hosting/packages | ListHostingPackages | hosting:read |
| hosting.get | GET /hosting/packages/{id} | GetHostingPackage | hosting:read |
| hosting.listAliases | GET /hosting/packages/{id}/aliases | ListHostingAliases | hosting:read |
| hosting.listDatabases | GET /hosting/packages/{id}/databases | ListHostingDatabases | hosting:read |
| tls.listProducts | GET /tls/products | ListTLSProducts | tls:read |
| tls.list | GET /tls/certificate | ListCertificates | tls:read |
| tls.create | POST /tls/certificate | CreateCertificate | tls:write |
| tls.get | GET /tls/certificate/{id} | GetCertificateByID | tls:read |
| tls.complete | POST /tls/certificate/{id}/complete | CompleteCertificate | tls:write |
| tls.cancel | POST /tls/certificate/{id}/cancel | CancelCertificate | tls:write |
| tls.revoke | POST /tls/certificate/{id}/revoke | RevokeCertificate | tls:write |
| tls.cancelOrder | POST /tls/certificate/{id}/order-cancel | CancelIssuedCertificateOrder | tls:write |
| tls.reissue | POST /tls/certificate/{id}/reissue | ReissueCertificate | tls:write |
| tls.download | GET /tls/certificate/{id}/download/{format} | DownloadCertificate | tls:read |
| tls.listOrganizations | GET /tls/organization | ListOrganizations | tls:read |
| tls.createOrganization | POST /tls/organization | CreateOrganization | tls:write |
| tls.getOrganization | GET /tls/organization/{id} | GetOrganizationByID | tls:read |
| tls.patchOrganization | PATCH /tls/organization/{id} | PatchOrganizationByID | tls:write |
Behaviour worth knowing
records.listByDomainexcludesNSrecords;zones.getincludes them.records.patchmatches by name and type — and, except forAandAAAA, by current data — and needs a unique match. Userecords.patchByRridto change a record's name or type.records.addis idempotent only for a capability-scoped ACME credential, which is that integration's sole way back to a lost rrid. With an ordinary key a duplicate fails.tls.createspends money and the API applies no rate limit or idempotency to it. A blind retry after a timeout creates a second paid order.tls.listqueries the CA once per certificate, so it is slow on large accounts and is not paginated.- A 404 means "not found, or not reachable with this key" — the API hides other tenants' objects behind it deliberately.
Related
regfish/regfish-dnsapi-go— the Go client, same coverage and the same denial semantics.
Licence
MIT
