ird-ts
TypeScript client for the ird public API — .ro domains, DNS (hosted zones, DNSSEC, bring-your-own-domain claims), VPS, tunnels, IPFS, P2P apps (including their offline mailbox), and async jobs (LLM, NLP, embeddings, bulk domain checks).
Every one of the API's 161 operations is a small typed function, with request and response types generated from the OpenAPI spec and verified one-to-one against it by the test suite. Zero runtime dependencies: Node 18+ fetch, ESM and CommonJS.
Install
There is no registry; the package is a git dependency served straight from this domain:
{
"dependencies": {
"ird-ts": "git+https://ird.cod-sursa.ro/ts.git#semver:^0.2.0"
}
}
or pinned to an exact commit:
git ls-remote https://ird.cod-sursa.ro/ts.git
npm install "git+https://ird.cod-sursa.ro/ts.git#<sha>"
The compiled dist/ is committed, so installing needs no build step and no dev dependencies.
The landing page at https://ird.cod-sursa.ro/ts/ shows the current release coordinates ready to paste, and https://ird.cod-sursa.ro/releases.json carries them for scripts and agents (head, tag, tag_sha per repo).
Quickstart
import { createClient, domains, credits } from 'ird-ts';
// API key from your account panel (or an account JWT — same field)
const client = createClient({ apiKey: process.env.IRD_API_KEY });
const { total } = await credits.balance(client);
// total: "1234.50" (money is always a decimal string)
const { status } = await domains.check(client, 'example.ro');
// status: "registered"
// a few endpoints work without a key:
await domains.check(createClient(), 'example.ro');
CommonJS works the same way:
const { createClient, domains } = require('ird-ts');
Authentication
One Bearer credential, two forms — pass either as apiKey:
- API key (
ird_...) created in the account panel.
- Account JWT from the device login flow:
import { auth, createClient } from 'ird-ts';
const tokens = await auth.deviceLogin(createClient());
// prints a verification URL, polls until you approve, prompts for the code
const client = createClient({ apiKey: tokens.access_token });
A few account-level areas accept only an account JWT (API keys are rejected with 401): everything in sharing and transfers, plus the API-key management functions in auth.
Errors
Every failure is a plain Error tagged with a kind; narrow with the type guards or on kind:
import { isApiError, isDomainDeniedError, registrations } from 'ird-ts';
try {
await registrations.register(client, { domain: 'example.ro', years: 1 });
} catch (e) {
if (isApiError(e)) console.log(e.status, e.code, e.message, e.response);
else if (isDomainDeniedError(e)) console.log(e.domain, e.op);
else throw e;
}
| kind |
thrown when |
api |
non-2xx response (code, status, response) |
transport |
connection failure, or a 2xx body that is not JSON |
config |
bad arguments or client options, before any request |
domainDenied |
the domain gate blocked a mutation |
timeout |
a polling helper ran past its deadline (last response attached) |
jobFailed / uploadFailed |
jobs.run / ipfs.addFile reached a terminal failure |
Domain gate (client-side safety net)
Restrict what a client may touch, independent of account permissions — useful for agents and automation:
import { createGatedClient } from 'ird-ts';
// this client can never mutate prod.ro or anything under it,
// even though the account can:
const client = await createGatedClient({ apiKey, deniedDomains: ['prod.ro', '*.prod.ro'] });
// or allow-list: ONLY these may be mutated
const client = await createGatedClient({ apiKey, allowedDomains: ['staging.ro', '*.dev.ro'] });
Domain-touching mutations (registrations, DNS zones and claims, sharing invites, tunnel/gateway/p2p domain attach and detach/delete, changes to the pins a gateway domain serves, VPS domain connect/disconnect, action cancellation) are checked before any request is sent; violations throw a domainDenied error. Reads are never gated. Registration-id-based operations resolve through a domain cache built at client creation (which is why the gated constructor is async); a cache miss falls back to fetching the single registration and caches it — refreshGate re-syncs the whole cache. Ids that still cannot be resolved fail closed under an allow-list. An empty allowedDomains list denies every domain mutation; deny wins over allow.
Modules
| Module |
Endpoints |
Covers |
domains |
9 |
availability check/search, utilities, .ro web directory |
registrations |
27 |
register, renew, transfer, trade, hosts, DS data, registrant |
dns |
18 |
hosted DNS zones, records, DNSSEC, nameserver sets, bring-your-own-domain claims |
sharing |
7 |
delegated-access grants, both directions |
vps |
34 |
tiers, instances, one-click apps, volumes, snapshots |
transfers |
9 |
cross-account VPS resource handover |
tunnels |
6 |
HTTPS tunnels on your own domain |
ipfs |
18 |
pinning, direct uploads, IPNS, gateway domains and the pins they serve |
pins |
5 |
standard IPFS Pinning Service API at /pins |
p2p |
12 |
p2p apps, TURN and offline-mailbox credentials, custom domains |
mailbox |
2* |
the offline mailbox itself — deposit/replay on the app's own node |
jobs |
3 |
async jobs: LLM chat/completion, embeddings, NLP, bulk checks |
credits |
3 |
balance, ledger, prices |
support |
4 |
support threads |
auth |
6 |
device login, API keys |
* mailbox talks to https://<app-host>/mailbox/v1 rather than api.ird.ro, so its two endpoints sit outside the 161 and outside the coverage table.
Conventions shared by every function:
- The first argument is always the client from
createClient — except in mailbox, where deposit/replay take the credentials instead, since they address a node rather than the API.
- Path parameters are positional; an optional query or body object comes last.
- Field names are the API's own, unrenamed — almost always snake_case (
auto_renew, size_gb). Three response keys are hyphenated: 'job-id' on jobs.getResult/jobs.run, and 'api-key'/'api-keys' on auth.createApiKey/auth.listApiKeys. The generated types spell them exactly that way.
- Every operation has exactly one success status in the spec, so functions return the parsed body directly; 202 responses (queued async actions) are documented per function.
- Money is a decimal string, never a JSON number — balances, prices, costs. It carries more precision than a double survives, so do not
Number() it.
- The client is a frozen plain value.
fetch and sleep are injectable options, which is how the tests run offline and how you add instrumentation or a proxy.
Types
Types come from spec/openapi.json via openapi-typescript (committed as src/generated/api.ts, regenerate with npm run gen:types). Because the spec has no operation ids, every type is addressed by path and method through four helpers:
import type { Res, Body, Query, Params } from 'ird-ts';
type Registration = Res<'/domains/registrations/{id}', 'get'>['registration'];
type RegisterBody = Body<'/domains/registrations', 'post'>;
type SearchQuery = Query<'/domains/search', 'get'>;
http.request(client, 'get', '/any/spec/path', { params, query, body }) is the typed escape hatch for calling an operation directly; http.requestUrl does the same against an absolute URL.
Pagination
Cursor-paginated listings have an All companion that follows next_cursor and returns the collected array: registrations.listAll, registrations.listAllActions, credits.ledgerAll, domains.directoryAll, support.listAllThreads.
Offset-paginated listings have one too, walking limit/offset until a short page: ipfs.listAllPins, ipfs.listAllDomainPins. All take an optional query (filters plus a page limit, default 100).
Async patterns
Jobs — enqueue → poll → result, or in one call:
import { jobs } from 'ird-ts';
await jobs.run(client, jobs.domainAvailabilityBulk(['a.ro', 'b.ro']));
await jobs.run(client, jobs.llmChat([{ role: 'user', content: 'Salut!' }], { max_tokens: 256 }));
await jobs.run(client, jobs.embedding('text to embed'));
await jobs.run(client, jobs.nlpDetectLanguage('ce limbă e asta?'));
Builders exist for all job types: domainAvailabilityBulk, embedding, llmChat, llmRawCompletion, nlpAnalyze, nlpDetectLanguage. Multimodal content-part helpers (jobs.textPart, jobs.imagePart, jobs.userMessage) are included forward-compatibly; the public API currently accepts string content only.
Registry / VPS actions — 202 responses return an action; poll registrations.getAction / vps.getAction until terminal.
IPFS uploads — grant → raw-byte POST → poll, or in one call:
import { ipfs } from 'ird-ts';
const upload = await ipfs.addFile(client, 'site.tar', { name: 'site' });
// { cid: "bafy...", pin_id: "...", status: "completed", ... }
addFile accepts a file path, bytes, a Blob, or a ReadableStream.
IPFS gateway domains — serve your own pins from your own domain:
const { domain } = await ipfs.attachDomain(client, { domain: 'cdn.example.ro', pin_ids: ['pin-1'] });
// point cdn.example.ro's A record at domain.dns_target (linked rows get it for free)
await ipfs.updateDomainPins(client, domain.id, { add: ['pin-2'], remove: ['pin-1'] });
await ipfs.listAllDomainPins(client, domain.id);
Bring-your-own-domain DNS — host a domain registered elsewhere on ird DNS:
import { dns, domains } from 'ird-ts';
// take the copy while the domain still resolves at its current host
const snapshot = await domains.dnsSnapshot(client, 'example.com');
const { claim } = await dns.createClaim(client, { domain: 'example.com' });
await dns.seedClaim(client, claim.id, { snapshot_id: snapshot.id });
// set claim.nameservers at the registrar, then:
await dns.verifyClaim(client, claim.id);
P2P offline mailbox — the catch-up path for peers that are never online at the same time. Turn it on per app, then deposit an encrypted payload for a room and let a peer arriving later replay it:
import { mailbox, p2p } from 'ird-ts';
await p2p.updateApp(client, 'app-1', { mailbox_enabled: true });
// one short-lived token, reused across calls (every mint is charged)
const creds = await mailbox.credentials(client, 'app-1', { room: dbAddress, ttl_sec: 3600 });
// { token, endpoint: "https://<node>/mailbox/v1", ttl: 3600, ... }
await mailbox.deposit(creds, dbAddress, ciphertext);
// { seq: "1874163641836634112", id: "51f2...", expires_at, deduped: false }
const { messages, cursor, gap } = await mailbox.replayAll(creds, dbAddress, { after: lastCursor });
Calls go to the app's own node, not api.ird.ro; credentials mints the token and hands you the endpoint in one value. Payloads are opaque to the node, which holds no keys — encrypt before depositing. mailbox.payloadBytes and payloadString decode a replayed message.
Two things the node's design asks of you:
seq is a string and must stay one. It packs a millisecond clock into its high bits and is far past what a JSON number survives; compare seqs as opaque ordered tokens. mailbox.isHistoryGap does it correctly (with BigInt).
- Storage is bounded, by TTL and by a per-app ring buffer, so history can be truncated between your visits. Check your cursor against each page with
isHistoryGap (replayAll returns it as gap) and report a gap rather than pretending the room was quiet.
Identical payloads deduplicate per room, so retries — and the same block deposited by five peers — store and bill once (deduped: true).
Tests
npm test # unit + full-coverage table (offline)
npm run test:live # live read-only tests; needs IRD_API_KEY (env or .env)
The coverage test invokes every one of the 161 wrapped operations against a recording fetch and asserts the exact method and URL, so client and spec cannot drift silently. spec/openapi.json is the spec snapshot the client was built against.
That pins the client to the spec, but says nothing about whether the spec still matches the API. The live run adds a conformance sweep that closes the gap: it calls every read endpoint and validates each response body against the schema the spec declares for it — types, required keys, enum membership, and (since every object is additionalProperties: false) fields the server returns that the schema never mentions. It is read-only, creates nothing, and skips endpoints whose resource the account does not have. The validator (spec-check.ts) is shipped and has its own offline tests pinning each class of drift it must catch.
Build
npm run build # dist/esm + dist/cjs with declarations (committed)
npm run typecheck
npm run gen:types # regenerate src/generated/api.ts from spec/openapi.json
Because consumers install from git, dist/ is committed and every tagged release points at a commit whose dist/ matches its src/ (npm run check:dist verifies that).
License
MIT