Agent index (llms.txt) — fetch this first, then the page it names.

TypeScript SDK

@debros/orama (version 0.3.0, MIT) is the programmatic surface of an Orama namespace. Everything a deployed application does (query its database, publish to a topic, cache a value, store a file, invoke a function) goes through one client against one gateway URL.

The SDK reaches a deliberate subset of the gateway. Deploying an application, minting a key and managing nodes are the CLI's job. Every route and its owner is on Gateway HTTP API. There is also a Go client.

Install

bash
npm install @debros/orama

ESM and CommonJS, in Node and in a browser bundle. Node 20.19 or later. Entry points:

ImportWhat
@debros/oramaThe client and everything below
@debros/orama/chainRead the chain, build and sign transactions. Optional peers: @bufbuild/protobuf, @noble/curves, @noble/hashes, @scure/base (all 2.x)
@debros/orama/relayRelayedFetch for relayed downloads. Node.js only

The vault client is a separate package, @debros/orama-vault; see Secrets.

The client

ts
import { createClient } from "@debros/orama";

const client = createClient({
  baseURL: "https://ns-myapp.cluster.example.com",
  apiKey: process.env.ORAMA_API_KEY,
});

baseURL is the namespace gateway (ns-<name>.<base-domain>) for data-plane calls, or the cluster gateway (https://<base-domain>) for the lobby and namespace creation.

MemberWhat it reaches
client.authCredentials, wallet login, devices, session renewal
client.dbThe namespace's RQLite database
client.pubsubTopics: HTTP to publish, WebSocket to subscribe
client.cacheThe Olric distributed cache
client.storageIPFS upload, pin, retrieve, fetch capabilities
client.functionsServerless function invocation
client.networkGateway health, cluster status, peers, the anonymity proxy

ClientConfig

OptionDefaultMeaning
baseURLrequiredGateway URL
apiKeyorama_<type>_<payload>_<checksum>. Exchanged for a token before the first request; the key itself never goes on the wire again
jwtAn existing session token
timeout60000Per-request timeout, ms
maxRetries3Attempts for a retryable status (408, 429, 5xx)
retryDelayMs1000Base backoff, multiplied by the attempt and jittered
debugfalsePrint diagnostics. Off, the SDK writes nothing to the console
storagememoryWhere tokens persist. LocalStorageAdapter in a browser
wsConfigWebSocket options, including reconnect
functionsConfignamespace, and an optional separate gatewayURL
fetchplatformReplace the fetch used for every request
onNetworkErrorCalled when a request does not reach the gateway; the place to fail over

Where a key belongs

An API key carries grants, and a key in a browser bundle is a key in the hands of everyone who loads the page.

  • +Server side only: an admin key. It reaches the control plane: deploys, secrets, key management, raw RQLite. It belongs in CI and on a developer's machine.
  • +Safe in a client bundle: an app-runtime key. Data-plane grants only, and the gateway additionally requires a logged-in user's wallet session for storage, WebRTC and proxy (a deployed app's own workload token also qualifies on storage and WebRTC), so an extracted runtime key on its own cannot act as anybody.
bash
orama namespace keys create --scope app-runtime --label web
orama namespace keys create --scope invoke,pubsub,cache --label worker
orama namespace keys create --scope admin --label ci

The calls below need these grants. client.db reaches the control plane, so it needs an admin key or a signed-in wallet holding the developer role or above. client.pubsub needs pubsub and client.cache needs cache, which the app-runtime profile leaves out, so name them in --scope. client.storage needs storage plus a signed-in user's session. client.functions needs invoke.

See Access control and keys.

Authentication

Every request carries Authorization: Bearer <token> and nothing else.

API key

ts
const client = createClient({ baseURL, apiKey: process.env.ORAMA_API_KEY });

The key is exchanged once, before the first request, for a 15-minute token carrying the key's grants. The SDK exchanges again before it expires. The key stays in the process it was given to: not in an access log, a proxy trace, a devtools tab or a WebSocket URL. getToken() therefore returns a token rather than the key.

A deployed app

ts
import { createWorkloadClient } from "@debros/orama";
const client = await createWorkloadClient();

It reads ORAMA_GATEWAY_URL and ORAMA_TOKEN_FILE from the environment the platform set up and renews the token before it expires. What it may reach is whatever orama app grants set <app> … gave it: nothing, until you do.

Wallet login

A user proves ownership of an address by signing a Sign-In with Ethereum message (EIP-4361), or the Solana counterpart for chain_type: "SOL".

ts
const challenge = await client.auth.challenge({
  wallet: "0xYourWallet",
  namespace: "acme",
  chain_type: "ETH",            // or "SOL"
});
// Sign the message verbatim. It is what the wallet shows the user.
const signature = await wallet.signMessage(challenge.message);
const session = await client.auth.verify({ message: challenge.message, signature });

The message carries the gateway's domain, the namespace, the nonce and a five-minute expiry; verify takes the message, not the fields beside it, so the wallet and namespace it acts on are read out of the bytes the user approved. verify stores the access token, the refresh token and the namespace. Challenges are single use. client.auth.getApiKey({ message, signature }) mints a long-lived key the same way from its own fresh challenge.

Sessions renew themselves

When the gateway answers 401 and a refresh token is stored, the SDK renews the session and replays the request once. Concurrent requests that expire together share one renewal.

ts
await client.auth.logout();                      // tell the gateway, clear everything
await client.auth.logout({ keepApiKey: true });  // the user signs out; the app key stays
await client.auth.logout({ server: false });     // clear local state only

client.auth.whoami() answers { authenticated: false } for a rejected credential and throws for anything else, so a gateway that is down is not reported as a signed-out user.

Device-bound sessions

See Devices for the model. SDK calls:

ts
client.auth.setDeviceSigner({
  publicJwk,                                  // {kty:"EC",crv:"P-256",x,y} or {kty:"OKP",crv:"Ed25519",x}
  sign: async (message) => base64url(await platformSign(message)),
});
const challenge = await client.auth.challenge({ wallet, namespace: "acme", device_id: deviceIdOf(publicJwk) });
const result = await client.auth.verify({
  message: challenge.message,
  signature: await wallet.signMessage(challenge.message),
  device_key: publicJwk,
  device_signature: base64url(await platformSign(challenge.message)),
  device_label: "Alice's phone",
});

deviceIdOf(publicJwk) is the RFC 7638 thumbprint. A device-bound verify returns no API key; later refreshes sign a proof with the device key automatically, and without a signer refresh() throws DEVICE_PROOF_REQUIRED. When the namespace requires an existing device's approval, verify returns {status: "pending_approval", user_code, device_code} and stores nothing: show the user code, call client.auth.approveDeviceLink(code) on a device already signed in, then client.auth.claimDeviceLink(device_code, namespace) on the new one. startDeviceLink({namespace}) starts a link with no wallet. listDevices() and revokeDevice(id) manage devices. deviceProofMessage is exported for a client that signs proofs itself.

Persisting tokens in a browser

ts
import { createClient, LocalStorageAdapter } from "@debros/orama";

const client = createClient({ baseURL, apiKey, storage: new LocalStorageAdapter() });

client.auth methods

setApiKey, setJwt, getToken, renew, refresh, whoami, logout, logoutUser, clear, challenge, verify, getApiKey, setDeviceSigner, listDevices, revokeDevice, approveDeviceLink, startDeviceLink, claimDeviceLink.

Database

ts
const rows = await client.db.query("SELECT * FROM messages WHERE room = ?", ["general"]);
await client.db.exec("INSERT INTO messages (room, body) VALUES (?, ?)", ["general", "hi"]);

const recent = await client.db.find("messages", { room: "general" }, { limit: 20 });
const one = await client.db.findOne("messages", { id: 42 });      // null when absent

const page = await client.db.createQueryBuilder("messages")
  .where("room = ?", ["general"]).orderBy("created_at DESC").limit(20).getMany();

const messages = client.db.repository<Message>("messages");
await messages.save({ room: "general", body: "hi" });

A row that is not there is null from both findOne methods, never an exception. Table, primary-key and column names are validated as plain SQL identifiers ([A-Za-z_][A-Za-z0-9_]*); anything else is rejected with INVALID_IDENTIFIER. Values are parameterised.

Methods: exec, query, find, findOne, createQueryBuilder, repository, transaction, createTable, dropTable, getSchema. See Databases.

Pub/sub

ts
await client.pubsub.publish("room:general", "hello");
await client.pubsub.publish("room:general", new Uint8Array([1, 2, 3]));

const subscription = await client.pubsub.subscribe("room:general", {
  onMessage: (msg) => {
    msg.data;       // payload decoded as UTF-8
    msg.bytes;      // payload exactly as published
    msg.timestamp;  // server-assigned
  },
  onError: (error) => report(error),
  onClose: () => teardown(),
});
subscription.close();

data is lossy for a payload that is not text; read bytes for anything binary. A dropped connection is re-established with exponential backoff (ten attempts by default) and the subscription resumes; onClose fires only when it is finished for good. Presence is opt-in:

ts
await client.pubsub.subscribe("room:general", {
  presence: { enabled: true, memberId: userId, onJoin: show, onLeave: hide },
});

onLeave fires for a close, a dropped connection, or a client that stops answering the gateway's pings (dropped after 75 seconds of silence). Methods: publish, topics, getPresence, subscribe; a subscription has onMessage, onError, onClose, getPresence, hasPresence, isConnected, close. See Pub/sub.

Cache

ts
await client.cache.put("sessions", userId, { seen: Date.now() }, "1h");
const hit = await client.cache.get("sessions", userId);    // null on a miss
await client.cache.delete("sessions", userId);

Methods: health, get, put, delete, multiGet, scan. Limits and TTLs are on Cache.

Storage

ts
const { cid } = await client.storage.upload(file, file.name);
const status = await client.storage.status(cid);
const stream = await client.storage.get(cid);              // ReadableStream
const response = await client.storage.getBinary(cid);      // Response, with headers
await client.storage.unpin(cid);

A read that closely follows a write can legitimately 404 while the pin propagates, so reads retry a 404 for about eighteen seconds, but only while the gateway marks it retryable (a CID uploaded or pinned in the last two minutes). Methods: upload, pin, status, get, getBinary, unpin, mintFetchCaps, revokeFetchCap, directTransport, fetchWith. Error codes: Storage. Downloading without the storage node learning your address: Relayed fetch below and the privacy page.

Functions

ts
const client = createClient({ baseURL, apiKey, functionsConfig: { namespace: "myapp" } });
const result = await client.functions.invoke("resize", { cid, width: 400 });

functionsConfig.gatewayURL sends invocations to a different origin with the same credentials. Writing and deploying functions: Functions.

Relayed fetch

A relayed fetch downloads a CID without the storage node learning the caller's address. The owner mints capabilities; the downloader presents one through a relay. The model and its limits are on Relayed fetch; this section is the SDK's side of it.

typescript
// Owner (device-bound session): mint one token per fetch, 3600..604800 s lifetime.
const { caps } = await client.storage.mintFetchCaps(cid, { count: 3, ttlSeconds: 86400 });
// Each capability carries `revokeKey`, the proof that you issued its id; keep it with the owner.
await client.storage.revokeFetchCap(caps[0].id, caps[0].revokeKey);

// Downloader, Node.js only:
import { RelayedFetch } from "@debros/orama/relay";

const transport = new RelayedFetch({
  relays: ["https://node-1.relays.example", "https://node-2.relays.example"],
  namespaceHost: "ns-myapp.cluster.example.com", // a plain hostname (letters, digits, hyphens); anything else throws
  jitterMs: 2000,        // optional: random 0..2 s before the request is sent
  circuit: "fresh",      // "session" reuses one Tor circuit across a batch
});
const bytes = await client.storage.fetchWith(transport, cid, caps[1], { signal });

// The non-private baseline: the client's own credential, no relay.
const direct = await client.storage.fetchWith(client.storage.directTransport(), cid, caps[2]);

For each fetch the SDK picks one relay uniformly at random (never the namespace host), opens GET /v1/proxy/relay on it with no credential, runs TLS to the namespace host inside that WebSocket's byte stream, and sends GET /v1/storage/relayed/<cid> with X-Orama-Fetch-Cap. The relay carries ciphertext.

What it hides, and what it does not:

PartySeesDoes not see
Relayyour IP address; the namespace host; the size and timing of the streamthe CID, the capability, the content
Storage nodethe CID and the capability; a Tor exit addressyour IP address
Anyone watching both endstiming and size can correlate a fetch (jitterMs blunts this, it does not remove it)

A relay run by the same operator as the storage node, or in the same cluster, is not a privacy boundary: together they hold both halves. Choose relays from a different operator than the namespace.

  • +Node.js only. RelayedFetch needs node:tls and ws, so it lives in the @debros/orama/relay entry and the core import stays bundleable for React Native and browsers. Those apps need a native helper that runs TLS to the namespace host over the relay stream.
  • +No direct fallback. A relay or tunnel failure throws and is never retried as a direct request, which would send your address to the storage node. Falling back is the application's decision, made by calling fetchWith with another transport.
  • +A response must say how long it is. A 200 with neither Content-Length nor chunked encoding is refused (RELAY_PROTOCOL_ERROR): a body that ends when the connection does cannot be told from one a reset cut short.
  • +Pin propagation. fetchWith re-asks on a retryable 404 exactly as get does, through the same transport, choosing a relay afresh each time.
  • +A fetch buffers the body (default limit 64 MiB, the relay's per-stream cap; deadline 5 minutes: maxBodyBytes, timeoutMs). Pass ca to trust a private CA for the namespace host.

Errors:

ErrorcodeMeaning
FetchCapErrorFETCH_CAP_INVALID (403)Forged, expired, for another CID or namespace
FetchCapErrorFETCH_CAP_REVOKED (403)The owner revoked it
FetchCapErrorFETCH_CAP_MISSING (401)No capability header reached the storage node
ScopeErrorFETCH_CAP_REVOKE_KEY_INVALID (403)revokeFetchCap was given a revokeKey that was not issued for that id (no key at all throws VALIDATION_FAILED before the call)
RelayErrorRELAY_DESTINATION_NOT_ALLOWED (400)The relay does not serve this namespace host
RelayErrorRATE_LIMITED (429)Relay limit; retryAfterSeconds says when
RelayErrorRELAY_UNAVAILABLE (503)The relay cannot carry a stream now
RelayErrorRELAY_CONNECT_FAILEDRelay unreachable, TLS to the namespace host failed, or the stream closed early
RelayErrorRELAY_PROTOCOL_ERROR, RELAY_RESPONSE_TOO_LARGE, RELAY_NONE_CONFIGURED, RELAY_UNSUPPORTED_RUNTIMEMalformed answer; body over maxBodyBytes; no usable relay; not Node

Network

client.network has health, status, peers, connect, disconnect and proxyAnon (the Tor anonymity proxy; see Tor).

Cancelling work

Every request method takes an AbortSignal. A cancelled request rejects with a NetworkError whose code is ABORTED, is never retried and is not reported through onNetworkError.

Errors

Every failure is an SDKError.

ClassWhenExtra
AuthError401: credential missing, expired, or not enough alonerequiredScope
RevokedCredentialError401 AUTH_REVOKED: sign in again, do not retry
ScopeError403: the credential's grants do not cover the operationrequiredScope
NamespaceError403 NAMESPACE_MISMATCH: wrong gateway, or the key came from the wrong environmentnamespace, credentialNamespace
NotFoundError404
NetworkErrorNo HTTP response. httpStatus is 0code is NETWORK_ERROR, TIMEOUT or ABORTED

error.code is the gateway's own (full list in Sign-in and sessions). error.retryable is true or false when the gateway says, otherwise undefined. Scope and Role are exported types, so a grant name that does not exist is a compile error.

ts
import { NetworkError, ScopeError } from "@debros/orama";
try {
  await client.storage.upload(file, file.name);
} catch (error) {
  if (error instanceof ScopeError) console.error(`this key needs the ${error.requiredScope} grant`);
  else if (error instanceof NetworkError) console.error("the gateway was not reached");
  else throw error;
}

Grants

ts
import { SCOPES, PROFILE_SCOPES, satisfiesScope } from "@debros/orama";
PROFILE_SCOPES["app-runtime"];          // ["invoke", "storage", "push", "webrtc", "proxy"]
satisfiesScope(["admin"], "storage");   // true: admin is a wildcard

The grants are admin, cache, invoke, proxy, pubsub, push, storage and webrtc. They mirror the gateway's own list, and a test in the SDK fails if the two ever disagree.

Retries and failover

A retryable status (408, 429, 500, 502, 503, 504) is retried up to maxRetries times. A Retry-After header is honoured, capped at thirty seconds; otherwise the delay grows with the attempt and carries up to 25% jitter. Failing over to another gateway is the application's decision, made in onNetworkError:

ts
const client = createClient({
  baseURL: gateways[0],
  apiKey,
  onNetworkError: (error, context) => { if (error.httpStatus === 0) rotateGateway(context.path); },
});

Secrets

The vault client is @debros/orama-vault. It talks to guardian daemons on the WireGuard overlay, which an application outside the cluster cannot reach. An application reaches the vault through the gateway's /v1/vault/push and /v1/vault/pull, which do the Shamir split server side and authenticate each request with a per-request Ed25519 ownership signature. See Vault.

A gateway with an untrusted certificate

A test cluster on Let's Encrypt staging presents a certificate your runtime does not trust. Supply a fetch that relaxes verification for that connection only:

ts
import { Agent, fetch as undiciFetch } from "undici";
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
const client = createClient({
  baseURL, apiKey,
  fetch: (input, init) => undiciFetch(input, { ...init, dispatcher }) as unknown as Promise<Response>,
});

The SDK never changes the process's TLS settings on your behalf: that would disable verification for every other HTTPS client in the process.

The chain module

@debros/orama/chain reads the Orama chain, builds and signs its transactions, and describes them for an approval screen.

ts
import { LocalSigner, MSG, OramaChainClient, describeTx } from "@debros/orama/chain";

const chain = new OramaChainClient({
  gatewayURL: "https://cluster.example.com",   // the read-only /v1/chain/ proxy
  restURL: "http://127.0.0.1:31003",           // a node's REST API: accounts, balances, broadcast
});
const signer = new LocalSigner(privateKeyHex);  // or any OramaSigner, such as RootWallet
const msg = MSG.nodesFundHotKey.create({ operator: signer.address, nodeId: "node-1", amount: "2500000000" });
const { signed, result } = await chain.signAndBroadcast([msg], signer, {
  chainId: "orama-stagenet-1", gasLimit: 300_000, feeNorama: 300_000,
});
console.log(result.txHash, describeTx(signed.txBytes).messages[0].summary);

// Use earnings: withdraw them to your balance, then pay in the open by saying so.
const tx = { chainId: "orama-stagenet-1", gasLimit: 200_000, feeNorama: 200_000 };
await chain.withdrawEarnings(25_000_000_000n, signer, tx);                       // 25 ORAMA
const paid = await chain.transfer({ to: recipient, amount: 5_000_000_000n, public: true }, { signer, tx });
console.log(paid.warning);   // the sender, the recipient and the amount are visible on the chain
  • +Reads. The gateway proxy serves status, block, blocks, tx, validators, supply, stakingPool and the indexer's indexStatus, indexBlock, indexTx, indexAccountTxs, cnftAsset, cnftOwnerAssets; the two lists take { page, limit } with page 1 to 10, and past that { cursor }, the next_cursor of the previous page (never with page). A node's REST API adds account, balances and broadcast. Module state speaks gRPC only, so moduleQuery(service, method, request, { height }) reads any query through the gateway, with typed reads for x/nodes (nodesParams, operator, node, nodeCluster, nodeUnbondings, nodeByName, nameOfNode, nodeNames), x/storage (storageParams, deal, slot, storageAuthorization, storageChallenges, storageEpochMint, storageQueue), x/fees (feesParams, baseFee, earnings, feesDeposit), x/archive (archiveParams, archiveRange, lastArchivedHeight, retainHeight) and x/relay (relayParams, relayReporters, relay, relayEpochResult). uint64 fields come back as decimal strings; a key not on chain is an SDKError with httpStatus 404. x/houses and the other modules are reachable through moduleQuery, and a read that needs a base URL the config does not have fails and names it.
  • +A wallet with only a gateway. A wallet shows an address and sends from it through gatewayURL alone, with no node and no restURL. Balances, delegations and unbonding are wallet queries on /v1/chain/query/: bankBalance, bankAllBalances, bankSpendableBalances, authAccount, authAccountInfo, stakingDelegation, stakingDelegatorDelegations, stakingUnbondingDelegation, stakingDelegatorUnbondingDelegations, stakingValidator, stakingParams, distributionDelegationRewards, distributionDelegationTotalRewards, contractInfo and isContract. Then simulateTx(txBytes) returns the gas and fee (gasWanted is the limit the transaction declares; both gas figures are bigints), broadcastTx(txBytes) submits and answers once the node's mempool has checked the transaction, and tx(hash) polls until the transaction is in a block (a NotFoundError until then). The listings take { limit, key } (limit 1 to 100, key the previous page's pagination.next_key). isContract is false on the gateway's typed 404 for an address that is not a contract. A transaction the chain refuses, on either call, is a ChainTxRefusedError (an SDKError with code CHAIN_TX_REJECTED, httpStatus 422, and chainCode, codespace, log and, for a broadcast, txHash); the log is the gateway's sanitised copy of the reason. signAndBroadcast still uses restURL.
  • +Building. buildSignDoc makes the SIGN_MODE_DIRECT body, auth info and SignDoc for one signer; signTx has an OramaSigner sign it and checks the signature; verifyTx verifies an encoded transaction the way the chain does. MSG is the message set: 69 messages, 59 of them Orama's own, plus bank send, staking, unjail, reward withdrawal and the two CosmWasm messages a wallet signs. The fee is always norama.
  • +Sending ORAMA: private by default, public by choice. chain.transfer({ to, amount }, options) sends privately unless the request says public: true. Only the boolean true pays in the open: an absent or false flag is private, and any other value ("false", 1, null) is a TypeError, so a transfer cannot turn public by omission or by a mistyped string. amount is a positive whole number of norama (a bigint, a safe-integer number or a decimal string); a fraction or a zero is a RangeError. A private transfer needs a shielded wallet, which proves with the owner's shielded spending key and so never lives in this SDK: pass it as options.shielded ({ build({ to, amount }) } returns the signer-less transactions in broadcast order, and the SDK broadcasts them through gatewayURL). Without one the call throws PrivateTransferUnavailableError (code PRIVATE_TRANSFER_UNAVAILABLE) and sends nothing; it never falls back to a public payment. A public transfer needs options.signer and options.tx (chainId, gasLimit, feeNorama), signs a bank send, and returns { privacy: "public", txHash, warning }, where warning is PUBLIC_TRANSFER_WARNING: the sender, the recipient and the amount are visible on the chain to everyone, permanently. Show it to the person who chose to pay publicly. If a later transaction of a private transfer fails after earlier ones were broadcast, the error is PRIVATE_TRANSFER_INCOMPLETE and names the hashes already sent. Every broadcast (broadcast, broadcastTx, signAndBroadcast, transfer) computes the transaction's hash itself (txHashOf) and throws CHAIN_TX_HASH_MISMATCH when the node or gateway answers another, and the chain's reason text is stripped of control characters before it reaches an error. RootWallet does not yet build shielded bundles, so today a wallet-driven transfer is a public one.
  • +Using earnings. chain.withdrawEarnings(amount, signer, tx) builds MsgWithdrawEarnings for the signer: the chain moves that much of the signer's earnings to the signer's own bank balance, and there is no destination parameter. Withdrawing is visible on the chain. From the balance, send it publicly with transfer({ ..., public: true }), or shield it.
  • +Signers. OramaSigner is { address, publicKey, signDirect(signDoc) }. The builder hands the signer the serialized SignDoc, never a key, so a wallet can decode it and show the user what they approve. LocalSigner holds a key in memory, for tests and servers that own their key.
  • +Approval text. describeTx and describeMessage summarize each message, with notes for what a signer must see (a token's powers, a deal allowance's limits and expiry, a collection's royalty, a contract call's decoded body) and a sensitive flag. A message type the SDK does not hold is described as unknown and sensitive.

Generated code and the Go side. src/chain/gen is generated from chain/proto by pnpm gen:chain (protoc and ts-proto, pinned). A test regenerates it and fails on a difference. chain/client/tx/testdata/tx_vectors.json is written by the Go builder: every case is rebuilt in TypeScript and must match the body, auth info, SignDoc, signature and transaction bytes. wallet_msgs.json lists every message the chain registers; the SDK must decode all of them.

Testing against a gateway

bash
export GATEWAY_BASE_URL=https://ns-myapp.cluster.example.com
export GATEWAY_API_KEY=<a test key>
pnpm test:e2e

Without GATEWAY_API_KEY those tests report as skipped; the unit suite needs nothing.