npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@regfish/api-v1

CI npm

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-v1

Quick 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.listByDomain excludes NS records; zones.get includes them.
  • records.patch matches by name and type — and, except for A and AAAA, by current data — and needs a unique match. Use records.patchByRrid to change a record's name or type.
  • records.add is 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.create spends money and the API applies no rate limit or idempotency to it. A blind retry after a timeout creates a second paid order.
  • tls.list queries 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

Licence

MIT