cljs

cljs/

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:

(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):

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:

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

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/cljs.git