@dinio/sdk (0.0.2)

Published 2026-11-06 11:03:49 +01:00 by bitelex

Installation

@dinio:registry=
npm install @dinio/sdk@0.0.2
"@dinio/sdk": "0.0.2"

About this package

dinio-sdk-ts

TypeScript SDK for the Dinio platform, primarily for managing Dinio DNS: zones, records, geo bans, health checks, redirects, zone certificates, zone members, sinkhole lists and client identities.

It is meant for automating your Dinio setup from scripts, integrations and CI. It talks to the Core REST API (/api/v1) with an API key and runs anywhere a standard fetch exists: Node.js ≥ 20, Deno, Bun and edge runtimes. Zero runtime dependencies. Plain JavaScript works too; the types are a bonus.

Status: the whole non-admin Core API is covered, unit-tested against a fake Core, not yet tried against a live one. See docs/status.md.


Installation

@dinio/sdk is published to the public npm registry of the dinio-public organization on https://git.bitelex.com, not to npmjs.com. Point the @dinio scope at it in your project's .npmrc:

@dinio:registry=https://git.bitelex.com/api/packages/dinio-public/npm/

Then install as usual:

npm install @dinio/sdk

Deno can use npm:@dinio/sdk with the same .npmrc; Bun reads .npmrc too.


Quick start

import { DinioClient } from "@dinio/sdk";

const dinio = new DinioClient({
  baseUrl: "https://api-dinio.bitelex.com",
  apiKey: process.env.DINIO_API_KEY, // Deno: Deno.env.get("DINIO_API_KEY")
});

for await (const zone of dinio.zones.iterate()) {
  console.log(zone.name);
}

const zone = await dinio.zones.get("example.com"); // uuid or name
const values = await dinio.records.listAll(zone.uuid, { type: "A" }); // flat list of values

The SDK never reads environment variables or config files itself: you pass the base URL and the key. It never logs either; JSON.stringify(dinio) shows the key redacted.


Authentication

An API key issued by Core, sent as Authorization: Bearer <key>:

  • personal key (du_…): the same access as your user (zones you own or administer, your client identities and sinkhole lists, your own account);
  • zone key (dz_…): access to exactly one zone (its records, redirects and certificates). It has no access to client identities and sinkhole lists, which belong to users, not zones.

Core shows a key only once, when it issues it, and keeps just its hash; store it right away. Issuing a new key replaces the old one, which stops working immediately:

const { api_key } = await dinio.zones.createApiKey("example.com"); // dz_…
await dinio.zones.revokeApiKey("example.com");

const me = await dinio.users.get("ana@example.com");
const personal = await dinio.users.createApiKey(me.uuid); // du_…

Zone.has_api_key and User.has_api_key tell whether a key exists. A key without the dz_ / du_ prefix (e.g. one from before 2026-10-21) is rejected by new DinioClient with DinioInputError; issue a new one. JWT is not supported. dinio.health() needs no key.


What is covered

Client property What
zones list, get (with records), create, issue/revoke the zone key, delete; export, import, discover (zone files)
records record values: list (flat), get an RRset (with geo bans), create, update, TTL, delete
healthChecks one http/tcp/ping check per record value
geoBans geo bans on record values (by region, sub-region, country)
geo the catalog of regions, sub-regions and countries geo bans can name
zoneMembers the zone's Owner and Administrators: list, add, remove
users your own account: get, issue/revoke your personal key
clientIdentities clients Relay recognises (IP, DoT hostname, DoH path)
sinkholeLists, sinkholeRoles, sinkholeLinks blacklists/whitelists, their rules, and which clients they apply to
redirects HTTP(S) redirects for host names in your zones, with automatic HTTPS certificates
certificates your zones' TLS certificates: list, find by name, upload, renew, delete
auditLogs who did what on your account and zones (cursor-paginated, iterate)
stats query statistics of your zones and client identities (Relay telemetry)
freeDomains free domains to lease a subdomain of, and whether a name is free (no key needed)

Admin-only routes (user management, edge instances, platform certificates, GeoIP files) are deliberately not in the SDK.

Some records are kept by Core for something else and carry managed_by: System (the zone's SOA/NS; editable, but other NS means leaving Dinio's DNS; the SOA and the last NS cannot be deleted), Redirect and Acme (changed only through their redirect; a direct write is MANAGED_RECORD).

Every method has JSDoc with its Core route, who may call it and the errors to expect; your editor shows it on hover.


Working by uuid (direct API)

The resources on the client (dinio.records, dinio.geoBans, …) map one-to-one to Core routes, with no logic of their own. Every call returns the full object as Core sends it, uuid included. If your integration keeps its own state, store the uuids and work with them directly:

const zone = await dinio.zones.create("example.com");

const value = await dinio.records.create({
  zone_id: zone.uuid,
  name: "www.example.com", // full owner name
  type: "A",
  data: "192.0.2.1",
  ttl: 300, // only for a new RRset
});
// store value.uuid; later:
await dinio.records.update(value.uuid, { data: "192.0.2.2" });
await dinio.geoBans.create(value.uuid, { level: "Country", code: 191 });
await dinio.healthChecks.create({
  record_id: value.uuid,
  type: "tcp",
  config: { host: "192.0.2.2", port: 443 },
  interval: 60, // seconds
  timeout: 5,
}); // switches the value's host_health to "Auto"
await dinio.records.update(value.uuid, { host_health: "Down" }); // maintenance: check paused
await dinio.records.update(value.uuid, { host_health: "Auto" }); // the check decides again
await dinio.records.delete(value.uuid); // with its geo bans and health check

await dinio.zoneMembers.add(zone.uuid, "ana@example.com"); // a registered user; list() to confirm
await dinio.zoneMembers.remove(zone.uuid, userId);
await dinio.sinkholeLinks.create(identityId, listId);

// IP clients give their address; for DoT/DoH Core generates identify.
await dinio.clientIdentities.create({ id_type: "IP", identify: "203.0.113.10", name: "office" });
const phone = await dinio.clientIdentities.create({ id_type: "Path", name: "phone" });
// phone.identify is the DoH path part; regenerateIdentify(phone.uuid) replaces it.

Where Core has no uuid, the route uses the natural key:

  • an RRset (all values of one name and type) has no uuid of its own: records.get, records.getWithGeoBans and records.setTtl take the zone uuid, the full name and the type;
  • a health check is keyed by its record value's uuid;
  • zone members and sinkhole links are keyed by their pair of uuids (a member is added by email);
  • zones accept the uuid or the name, redirects are identified by their host name, client identities by the uuid or the identify value (for get; writes take the uuid).

Working by name instead of uuid

If you do not keep uuids, the operations below look them up for you and work the way you think about DNS: zone, name, type and data. They are built on the direct API above.

import {
  addGeoBans,
  addRecordValue,
  findClientIdentity,
  findSinkholeList,
  findZoneForName,
  qualifyName,
  removeRecordValue,
  updateRecordValue,
} from "@dinio/sdk";

const zone = await dinio.zones.get("example.com");
const www = qualifyName("www", zone.name); // "www.example.com"; "@" is the apex

// Creates the RRset if needed (TTL only applies to a new one, default 3600).
await addRecordValue(dinio, zone, www, "A", { data: "192.0.2.1", ttl: 300 });

// Values are found by data in Core's canonical form, or by uuid ({ id }).
await updateRecordValue(dinio, zone, www, "A", { data: "192.0.2.1" }, { data: "192.0.2.2" });

// Areas by name or ISO code, resolved through Core's catalog (GET /geo; keep it, Core allows a
// day); already banned areas are skipped.
const catalog = await dinio.geo.catalog();
await addGeoBans(dinio, zone, www, "A", { data: "192.0.2.2" }, ["RU", "Eastern Asia"], { catalog });

await removeRecordValue(dinio, zone, www, "A", { data: "192.0.2.2" });

// The zone of a host name (the most specific one you can see), e.g. for a redirect.
const target = await findZoneForName(dinio, "go.example.com");
if (target) {
  await dinio.redirects.create({ zone_id: target.uuid, name: "go.example.com", code: 302, location: "https://example.com/" });
}

// Client identities by uuid, identify value or name; sinkhole lists by uuid or name.
const phone = await findClientIdentity(dinio, "phone");
const ads = await findSinkholeList(dinio, "ads", { includeShared: true });
await dinio.sinkholeLinks.create(phone.uuid, ads.uuid);

Also available: findRecordGroup, findRecordValue, findRecordValueWithGeoBans, removeRecordGroup, groupRecordValues, removeGeoBans, parseGeoArea, knownAreas, describeGeoArea, matchClientIdentity, matchSinkholeList, relativeName and sameData. Names of client identities and sinkhole lists are not unique: an ambiguous name is refused (DinioInputError listing the candidates), not guessed.

These operations make several requests and are not atomic. Those that write more than once (removeRecordGroup, addGeoBans, removeGeoBans) say in their JSDoc what stays done if a request fails midway.


Zone files: export, import, discovery

A zone can be exported as Core's own JSON document (ZoneDocument, with geo bans, health checks and redirects; names relative to the zone, so it works as a template for another zone), as a BIND zone file or as CSV. An import only adds what the zone lacks; the records go in all or nothing. The SDK takes and returns file content as text: reading and writing files is up to you.

import { importReport, zoneFileFormatFromName } from "@dinio/sdk";

const zoneFile = await dinio.zones.export("example.com", "bind");

// Preview first, then import for real.
const format = zoneFileFormatFromName("example.com.zone") ?? "bind";
const preview = await dinio.zones.import("example.org", format, zoneFile, { dryRun: true });
console.log(`${preview.records.created} to create, ${preview.records.errors.length} errors`);
try {
  await dinio.zones.import("example.org", format, zoneFile);
} catch (err) {
  const report = importReport(err); // IMPORT_FAILED: nothing was written
  if (!report) throw err;
  for (const issue of report.records.errors) console.log(`line ${issue.ref}: ${issue.message}`);
}

// Moving from another provider: ask its nameservers, trim the proposal, import it.
const found = await dinio.zones.discover("example.com", { nameservers: ["ns1.old-provider.net"] });
found.document.records = found.document.records.filter((r) => r.name !== "old");
await dinio.zones.import("example.com", "json", found.document);

Discovery asks for the names a zone usually has, not every name, and takes up to about a minute (the SDK waits at least 90 s for it, whatever timeoutMs is, unless timeoutMs is 0).


Audit log, statistics, free domains

// The last week of record changes in a zone, newest first, page after page.
for await (const e of dinio.auditLogs.iterate({ zoneId: zone.uuid, action: "record.*", from: new Date(Date.now() - 7 * 86_400_000) })) {
  console.log(e.ts, e.actor_label, e.action, e.resource_name, e.outcome);
}

// Queries to a zone per day, by qtype; times in responses are milliseconds.
const week = await dinio.stats.zoneQueries("example.com", { from: new Date(Date.now() - 7 * 86_400_000), step: "1d", groupBy: "qtype" });
for (const s of week.series) console.log(s.label, s.total);

// What your clients' sinkhole lists blocked most.
const blocked = await dinio.stats.clientBlocked({ limit: 10 });

// Lease a free subdomain: check, then create it as your zone.
const { domains } = await dinio.freeDomains.list();
const { available, reason } = await dinio.freeDomains.available(domains[0]!, "myhome");
if (available) await dinio.zones.create(`myhome.${domains[0]}`);
else console.log(reason);

The audit log keeps at most a 31-day range per query (default the last 24 hours); statistics up to 92 days (data is kept 90). A Core without them answers AUDIT_DISABLED / TELEMETRY_DISABLED (503).


Errors

Every error extends DinioError:

Class When
DinioApiError Core refused: status, code (NOT_FOUND, CONFLICT, VALIDATION, FORBIDDEN, SUSPENDED, MANAGED_RECORD, …), message, details
DinioInputError rejected by the SDK before sending (bad option or argument)
DinioNotFoundError a name-based operation found nothing (the message lists what exists)
DinioNetworkError Core could not be reached
DinioTimeoutError no full response within timeoutMs (default 30 s)
DinioAbortError your AbortSignal aborted the call
DinioDecodeError the response was not Core's (e.g. a proxy error page)
import { DinioApiError } from "@dinio/sdk";

try {
  await dinio.zones.create("example.com");
} catch (err) {
  if (err instanceof DinioApiError && err.code === "CONFLICT") {
    // already exists
  } else {
    throw err;
  }
}

SUSPENDED (403) means an Admin suspended the zone or client identity, or its owner. Reads still work, and so do deleting the zone or identity itself, removing a zone member and revoking the zone key; every other write is refused until the suspension is lifted. Zone and ClientIdentity carry suspended and owner_suspended.

There is no automatic retry.


Cancellation and timeouts

Every method takes { signal } as its last argument. The client-wide timeoutMs covers the whole response, not only the headers; only zones.discover waits longer (at least 90 s).

const dinio = new DinioClient({ baseUrl, apiKey, timeoutMs: 10_000 });
await dinio.zones.listAll({ signal: AbortSignal.timeout(5_000) });

Other options: fetch (your own implementation, e.g. for a proxy or tests), userAgent (prepended to the SDK's own) and headers (extra headers; they cannot override the key).


Known limitations

These are gaps in the Core API, not in the SDK. They are tracked in docs/status.md (§4, §5) and will be fixed in Core.

  • The rules of a shared sinkhole list are visible only to its owner. Others see its type and name, not what it blocks.
  • Core has no "who am I" route. To issue your personal key, look yourself up by email first: const me = await dinio.users.get(email).

Development

Requires Node.js ≥ 20 and npm.

npm install
npm run typecheck
npm test
npm run build      # → dist/ (ESM + .d.ts)

A local Core (../core, npm run dev) listens on http://localhost:3000.


Documentation

  • CLAUDE.md: rules and guidance for AI-assisted development
  • docs/architecture.md: design, module boundaries, auth, errors, build (Croatian)
  • docs/status.md: status, decisions, open questions, Core API gaps (Croatian)

License

Proprietary, see LICENSE.

Dependencies

Development Dependencies

ID Version
typescript ^5.9.3
vitest ^3.2.4
Details
npm
2026-11-06 11:03:49 +01:00
2
Bitelex
SEE LICENSE IN LICENSE
latest
126 KiB
Assets (1)
sdk-0.0.2.tgz 126 KiB
Versions (1) View all
0.0.2 2026-11-06