ird-cljs
ClojureScript client for the ird public API on Node.js — .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 wrapped by a thin, documented function that returns a core.async channel, and the test suite checks the wrapped set against the OpenAPI spec one-to-one. Designed for ClojureScript rather than ported from the Clojure client: channels instead of blocking calls, errors as values, injectable transport instead of dynamic vars.
Runtime dependencies: ClojureScript and core.async. Node 18+ (global fetch).
Install
A source library, consumed as a git dependency — no registry, no build step of its own. The usual setup is shadow-cljs reading deps.edn:
;; deps.edn
{:paths ["src"]
:deps {ro.ird/ird-cljs {:git/url "https://ird.cod-sursa.ro/cljs.git" :git/sha "…"}
thheller/shadow-cljs {:mvn/version "3.5.0"}}}
;; shadow-cljs.edn
{:deps true
:builds {:app {:target :node-script :main my.app/main :output-to "out/app.js"}}}
with shadow-cljs in your package.json devDependencies at the same version. Any of shadow-cljs's Node targets works (:node-script, :node-library, :esm with :keep-native-requires), as does a vanilla ClojureScript build with :target :nodejs.
Pin :git/sha to the latest commit (or use :git/tag with its sha prefix):
git ls-remote https://ird.cod-sursa.ro/cljs.git HEAD
The landing page at https://ird.cod-sursa.ro/cljs/ 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
(require '[cljs.core.async :refer [go <!]]
'[ird.async :refer [<?]] ; <! that rethrows an error value
'[ird.client :as ird]
'[ird.domains :as domains]
'[ird.credits :as credits]
'[ird.errors :as err])
;; API key from your account panel (or an account JWT — same field)
(def c (ird/make-client {:api-key (.. js/process -env -IRD_API_KEY)}))
;; every call returns a channel that delivers ONE value:
;; the response map, or an ExceptionInfo
(go
(let [r (<! (credits/balance c))]
(if (err/error? r)
(println "failed:" (ex-message r) (ex-data r))
(println (:balance r)))))
;; => {:credits "1234.50" ...}
;; or let <? throw inside the go block and use try/catch
(go
(try
(println (:status (<? (domains/check c "example.ro"))))
(catch :default e
(println "failed:" (ex-message e)))))
;; a few endpoints work without a key:
(domains/check (ird/make-client {}) "example.ro")
Outside a go block, ird.async/->promise turns a channel into a JS Promise (rejecting on error values) — handy at the edge of a :node-library build, where JavaScript callers expect promises.
Authentication
One Bearer credential, two forms — pass either as :api-key:
- API key (
ird_...) created in the account panel.
- Account JWT from the device login flow:
(require '[ird.auth :as auth])
(go
(let [tokens (<? (auth/device-login (ird/make-client {})))]
;; prints a verification URL, polls until you approve, prompts for the code
(def c (ird/make-client {:api-key (:access_token tokens)}))))
A few account-level areas accept only an account JWT (API keys are rejected with 401): everything in ird.sharing and ird.transfers, plus the API-key management functions in ird.auth.
Errors
Nothing is thrown out of an API function; failures travel on the channel as ex-info values with a :type:
:type |
ex-data |
when |
:ird/api-error |
:code :status :response |
non-2xx response |
:ird/transport-error |
:detail |
connection failure, timeout, non-JSON body |
:ird/config-error |
:detail |
bad options or arguments |
:ird/domain-denied |
:domain :matched-pattern :op |
domain gate refusal (below) |
:ird/timeout |
:last |
a polling helper hit its deadline |
:ird/job-failed |
:job |
jobs/run: the job failed |
:ird/upload-failed |
:upload |
ipfs/add-file: the upload failed |
Predicates live in ird.errors (api-error?, domain-denied?, …, and error? for any error value). Inside a go block (<? ch) throws the error value so ordinary try/catch applies; ird.async/go-try is a go whose body may throw — the exception is delivered on the returned channel instead of crashing the process. Build your own helpers with it.
ird.client/make-client is the one function that throws synchronously, on invalid options.
Domain gate (client-side safety net)
Restrict what a client may touch, independent of account permissions — useful for agents and automation. The gate needs one request up front, so gated clients are created asynchronously:
(go
;; this client can never mutate prod.ro or anything under it,
;; even though the account can:
(def c (<? (ird/make-gated-client {:api-key key
:denied-domains ["prod.ro" "*.prod.ro"]})))
;; or allow-list: ONLY these may be mutated
(def c (<? (ird/make-gated-client {:api-key key
:allowed-domains ["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; a violation is delivered as an :ird/domain-denied error. Reads are never gated. Registration-id-based operations resolve through a domain cache built when the gate is attached; a cache miss falls back to fetching the single registration (and caches it) — ird.gate/refresh re-syncs the whole cache. Ids that still cannot be resolved fail closed under an allow-list. An empty :allowed-domains collection denies every domain mutation. Names are matched in DNS (punycode) form, so write IDN patterns in punycode.
Namespaces
| Namespace |
Endpoints |
Covers |
ird.domains |
9 |
availability check/search, utilities, .ro web directory |
ird.registrations |
27 |
register, renew, transfer, trade, hosts, DS data, registrant |
ird.dns |
18 |
hosted DNS zones, records, DNSSEC, nameserver sets, bring-your-own-domain claims |
ird.sharing |
7 |
delegated-access grants, both directions |
ird.vps |
34 |
tiers, instances, one-click apps, volumes, snapshots |
ird.transfers |
9 |
cross-account VPS resource handover |
ird.tunnels |
6 |
HTTPS tunnels on your own domain |
ird.ipfs |
18 |
pinning, direct uploads, IPNS, gateway domains and the pins they serve |
ird.pins |
5 |
standard IPFS Pinning Service API at /pins |
ird.p2p |
12 |
p2p apps, TURN and offline-mailbox credentials, custom domains |
ird.mailbox |
2* |
the offline mailbox itself — deposit/replay on the app's own node |
ird.jobs |
3 |
async jobs: LLM chat/completion, embeddings, NLP, bulk checks |
ird.credits |
3 |
balance, ledger, prices |
ird.support |
4 |
support threads |
ird.auth |
6 |
device login, API keys |
* ird.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):
- First argument is always the client map from
ird.client/make-client — except in ird.mailbox, where deposit/replay take the credentials map instead, since they address a node rather than the API.
- Path parameters are positional arguments; optional query/body maps come last.
- Every function returns a core.async promise-chan that delivers exactly one value — the parsed response map, or an error value — and keeps delivering it to later takers.
- Keys are the API's own names as keywords, unrenamed — almost always snake_case (
:auto_renew, :size_gb). Three response keys are hyphenated instead, so reach for :job-id on jobs/get-result and jobs/run, and :api-key/:api-keys on auth/create-api-key/auth/list-api-keys. The snake_case spelling silently reads as nil.
- The HTTP status is available as
(:ird/status (meta resp)) (202 means an async action was queued).
- Money is a decimal string, never a JSON number — balances, prices, costs. It carries more precision than a double survives; compare with
js/BigInt or a decimal library, not >.
- Transport is injectable:
make-client takes :http-fn (request map → channel of {:status :body}) and :sleep-fn (ms → channel) — that is how the offline tests run, and how you add proxies, retries or instrumentation. Dynamic vars are not used anywhere: a binding does not survive a parked go block.
Pagination
Cursor-paginated listings have a *-all companion that follows next_cursor and delivers the collected vector: registrations/list-all-registrations, registrations/list-all-actions, credits/ledger-all, domains/directory-all, support/list-all-threads.
Offset-paginated listings have one too, walking limit/offset until a short page: ipfs/list-all-pins, ipfs/list-all-domain-pins. Both take an optional query map (filters plus a page :limit, default 100). An error on any page is delivered in place of the vector.
Async patterns
Jobs — enqueue → poll → result, or in one call:
(require '[ird.jobs :as jobs])
(go
(<? (jobs/run c (jobs/domain-availability-bulk ["a.ro" "b.ro"])))
(<? (jobs/run c (jobs/llm-chat [{:role "user" :content "Salut!"}] {:max_tokens 256})))
(<? (jobs/run c (jobs/embedding "text to embed")))
(<? (jobs/run c (jobs/nlp-detect-language "ce limbă e asta?"))))
Builders exist for all job types: domain-availability-bulk, embedding, llm-chat, llm-raw-completion, nlp-analyze, nlp-detect-language. Multimodal content-part helpers (jobs/text-part, jobs/image-part, jobs/user-message) are included forward-compatibly; the public API currently accepts string content only. image-part reads files, so it returns a channel.
Registry / VPS actions — 202 responses return an action; poll registrations/get-action / vps/get-action until terminal (ird.transport/poll-until does the backoff).
IPFS uploads — grant → raw-byte POST → poll, or in one call; a file path, a Buffer/Uint8Array, a Blob or a ReadableStream all work:
(require '[ird.ipfs :as ipfs])
(go (<? (ipfs/add-file c "site.tar" {:name "site"})))
;; => {:cid "bafy..." :pin_id "..." :status "completed" ...}
IPFS gateway domains — serve your own pins from your own domain:
(go
(<? (ipfs/attach-domain c {:domain "cdn.example.ro" :pin_ids ["pin-1"]}))
;; => {:domain {:id "gw-1" :dns_target "1.2.3.4" :pin_count 1 ...}}
;; point cdn.example.ro's A record at :dns_target (linked rows get it for free)
(<? (ipfs/update-domain-pins c "gw-1" {:add ["pin-2"] :remove ["pin-1"]}))
(<? (ipfs/list-all-domain-pins c "gw-1")))
;; => [{:id "pin-2" :cid "bafy..." :cid_v1 "bafy..." :state "pinned" ...}]
Bring-your-own-domain DNS — host a domain registered elsewhere on ird DNS:
(require '[ird.dns :as dns])
(go
;; take the copy while the domain still resolves at its current host
(let [snapshot (<? (domains/dns-snapshot c "example.com"))
claim (:claim (<? (dns/create-claim c {:domain "example.com"})))]
(<? (dns/seed-claim c (:id claim) {:snapshot_id (:id snapshot)}))
;; set (:nameservers claim) at the registrar, then:
(<? (dns/verify-claim c (:id claim)))))
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:
(require '[ird.p2p :as p2p]
'[ird.mailbox :as mailbox])
(go
(<? (p2p/update-app c "app-1" {:mailbox_enabled true}))
;; one short-lived token, reused across calls (every mint is charged)
(let [creds (<? (mailbox/credentials c "app-1" {:room db-address :ttl_sec 3600}))]
;; => {:token "..." :endpoint "https://sueta.ap2p.ro/mailbox/v1" :ttl 3600 ...}
(<? (mailbox/deposit creds db-address ciphertext))
;; => {:seq "1874163641836634112" :id "51f2..." :expires_at ... :deduped false}
(<? (mailbox/replay-all creds db-address {:after cursor}))))
;; => {:messages [{:seq "..." :id "..." :ts ... :payload "<base64>"}]
;; :cursor "1874163641836634112" :gap? false}
Calls go to the app's own node, not api.ird.ro; credentials mints the token and hands you the :endpoint in one map. Payloads are opaque to the node, which holds no keys — encrypt before depositing. mailbox/payload-bytes and payload-string 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/history-gap? 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
mailbox/history-gap? (replay-all 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 install # shadow-cljs (dev only; the Clojure side comes from deps.edn)
npm test # unit + full-coverage inventory (offline)
npm run test:live # live read-only tests; needs IRD_API_KEY (env or .env)
npm run spec:fetch # refresh spec/openapi.json from the live API
The coverage test invokes every one of the 161 wrapped operations against a capturing transport and asserts the exact method and URL — and compares the set of wrapped operations with every operation spec/openapi.json declares, so an endpoint the API gains (or loses) fails the suite with the exact difference the moment the spec is refreshed.
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. ird.spec-check-test runs offline and pins each class of drift the detector is meant to catch, because a conformance sweep whose detector quietly stopped detecting would pass forever.
Tests inject a fake transport through :http-fn and an instant :sleep-fn; see test/ird/test_util.cljs for the helpers and deftest-async for running a test body inside a go block.
License
MIT