Cloud client
CloudClient talks to Calimero Cloud — the control plane that knows which
namespaces an account owns and which attested TEE nodes are assigned to each.
It is the discovery half of delegated execution: a caller who runs no node cannot know which relay serves their namespace, or which account that relay writes as, and a warrant is unusable without both. The cloud answers that.
import { CloudClient } from '@calimero-network/mero-js';
const cloud = new CloudClient(); // default host// or: new CloudClient({ cloudBaseUrl: 'https://cloud.example.com' })It is also reachable from an existing SDK instance as sdk.cloud, so an app
with both a node connection and a cloud account drives them from one object:
const sdk = createMeroJs({ baseUrl: 'http://localhost:2528', cloud: { sessionToken } });await sdk.cloud.getMyNamespaces();Constructor
Section titled “Constructor”class CloudClient { constructor(config?: { cloudBaseUrl?: string; // default 'https://cloud.calimero.network' sessionToken?: string; // a stored session, to skip re-signing in onSession?: (s: CloudSession | null) => void; // persist a new / refreshed session fetch?: typeof fetch; // for tests and non-browser runtimes timeoutMs?: number; // default 10000 routingCredential?: { // prove an account on the routing read credential: string; // AccountProof<DeviceCert>, hex deviceSecret: string; // the certified device key, hex }; });}routingCredential has nothing to do with sessionToken. A session says who is
signed in; this says which account holds the key, and a joiner has the second and
cannot have the first. See
getNamespaceRouting.
Signing in
Section titled “Signing in”The app owns the Google flow (One Tap, an auth-code popup, a native browser) and hands the resulting ID token over once. Everything after that uses the returned session token.
const session = await cloud.signInWithGoogle(googleIdToken);// { sessionToken, expiresAt /* epoch SECONDS */, user: { email, name, picture } }Sessions are 7-day JWTs, so persist sessionToken via onSession and pass it
back in the constructor next launch rather than re-running the Google flow.
The cloud rotates the token on activity and returns the replacement in a
response header; this client adopts it automatically and calls onSession
again. Ignoring that would cost nothing today and log the user out a week later.
refreshSession() forces a rotation, which is rarely needed. signOut() revokes
the session server-side and clears it locally — the local half happens even if
the server call fails, because the caller asked to be signed out.
Linking from an app that holds the root, not a session
Section titled “Linking from an app that holds the root, not a session”The bind a browser app is in: only Google issues a first cloud session, so an app that mints an account root can prove it owns that account and still never get a session — the proof says who you are, the link says what you are entitled to, and linking lives behind a session.
The way across is a redirect. The person goes to the cloud, where they are signed in, consents to linking one named account, and comes back holding a grant.
// 1. Send them, having derived the account id — never let a user type it.const { url } = CloudClient.accountLinkHandoff({ portalUrl: 'https://cloud.calimero.network', accountId: root.accountId, callbackUrl: window.location.origin + '/connected',});window.open(url, '_blank');
// 2. On the way back, read the fragment. Safe to call on every load.const { grant, error } = CloudClient.readAccountLinkCallback();if (error === 'denied') return; // they pressed Cancel
// 3. Spend it: the root signs the grant, and both halves go together.if (grant) await cloud.linkAccountWithGrant({ grant, rootSecret });A grant is not a session token, and deliberately not a Google ID token. It authorises exactly one state transition on one named account, expires in five minutes, and is spendable only by whoever can produce the account root’s signature over it — so a grant intercepted in transit links nothing. That is what makes it safe to carry back through a browser redirect, and it is why the callback puts it in the URL fragment, which browsers never send to servers.
The callback origin must be allow-listed on the cloud
(account_link_callback_origins), and the flow is off where none is configured.
An unlisted origin is refused when the grant is minted, not when it is spent.
The signature is an ordinary account-link signature with the grant as the nonce,
so signAccountLink signs this the same way it signs every other link.
Signing in as an account, with no password anywhere
Section titled “Signing in as an account, with no password anywhere”A keyholder can open a session with nothing but the account root — no Google, no
password, no server-held secret. The cloud mints a challenge, the root signs it,
and what comes back is the same session token signInWithGoogle returns and
every cloud route already accepts.
const session = await cloud.signInWithAccount(rootSecret);// { sessionToken, expiresAt, user: { email } } -- the linked login's emailDoing this once is what makes that account’s later device proofs mean something. The cloud records the ownership claim, and from then on it knows the account behind a routing proof was claimed by whoever holds its root — which a device credential can never establish, since certificates are public.
Two 403s, and they mean different things:
- the signature did not verify, or the challenge was expired or spent; or
- the account is not linked to a cloud login. Ownership says who you are; the link says what you are entitled to. Anyone can mint a root offline, so a session on the proof alone would authenticate perfectly and authorize nothing — no plan, no namespaces to scope it to. The ownership claim is recorded anyway, so a keyholder who links later does not have to come back and prove again.
getAccountLoginChallenge() and submitAccountLogin() are the split halves, for
a root held somewhere this process cannot reach. Both legs are anonymous by
necessity: obtaining a session is what the exchange does.
Linking a Calimero account
Section titled “Linking a Calimero account”A cloud login is an email; a Calimero identity is a 32-byte account. Linking the two is what lets the cloud bill a hosted node to somebody, and sign that account back in later. It is not what authorizes a write — a relay runs an intent on a warrant alone, and never asks the cloud who you are.
const link = await cloud.linkAccount(rootSecret);// { accountId, linkedAt, alreadyLinked }One call, two hops: it fetches a single-use challenge and signs it with the
account root. The secret never leaves the process — only a signature over a
nonce the cloud minted does — and the account id and root public key are derived
from it here, because the cloud refuses a proof whose account_id is not the
account the signing key names.
Root-signed rather than device-signed on purpose: the root is the credential someone still holds after losing a device, which is the case linking exists to serve.
Re-linking the same account to the same login is a no-op that reports
alreadyLinked: true, so re-running onboarding is safe. An account linked to a
different login is a 409; a replayed or expired challenge is a 403; and
exceeding the plan’s limit is a 402.
Where rootSecret comes from is the app’s choice — a freshly generated root, a
restored recovery phrase, or a desktop app or hardware key that already holds
one. See Account roots.
const { accounts, limit } = await cloud.getMyAccounts();// accounts: [{ accountId, linkedAt, hasRecoveryEnvelope }]// limit: how many the plan allows, or null for no limitgetMyNamespaces(): Promise<CloudNamespace[]>
Section titled “getMyNamespaces(): Promise<CloudNamespace[]>”The namespaces this account owns.
interface CloudNamespace { namespaceId: string; groupId: string; // always equal to namespaceId; kept for wire compatibility haStatus: string; // 'enabled' | 'disabled' | 'none' ('none' = never enabled) haEnabledAt?: string | null; contexts: string[]; fleetReplicas?: unknown;}getNamespaceRelays(namespaceId): Promise<CloudRelay[]>
Section titled “getNamespaceRelays(namespaceId): Promise<CloudRelay[]>”The relays serving one namespace: where to present an intent, and as whom.
interface CloudRelay { peerId: string; relayUrl: string | null; // null when the cloud knows no URL yet executorAccount: string | null; // the account a warrant must name as `executor` status: string; // 'assigned' | 'active' authorshipReady: boolean; // does it hold CAN_AUTHOR_ON_BEHALF here? lastSeenAt?: string | null; confirmedAt?: string | null;}authorshipReady is reported, not decided by the cloud.
CAN_AUTHOR_ON_BEHALF is a governance capability granted by an admin of the
namespace and observable only by a node holding that group’s key, so the cloud
relays what the relay last told it. A relay that is active with
authorshipReady: false is a healthy replica waiting for a grant — and that
is the normal first state, because core implies the capability from nothing.
An owned namespace with no relays comes back as an empty array, not a 404: “HA
is not enabled” and “you do not own this” must not look alike. A namespace this
account does not own is a 404 HTTPError.
findExecutingRelay(namespaceId): Promise<CloudRelay | null>
Section titled “findExecutingRelay(namespaceId): Promise<CloudRelay | null>”The first relay that can actually run an intent, or null.
Three independent conditions have to hold, and a caller who checks fewer gets a refusal it cannot explain — after the author has already spent a nonce on the warrant: the cloud must know a URL, the relay must have reported its executor account, and the grant must be in place.
const relay = await cloud.findExecutingRelay(namespaceId);if (!relay) { // enable HA, wait for an assignment, or ask an admin for the grant — // `getNamespaceRelays` tells you which}getNamespaceRouting(namespaceId): Promise<CloudNamespaceRouting>
Section titled “getNamespaceRouting(namespaceId): Promise<CloudNamespaceRouting>”Where to reach a namespace without owning it: one read, both verbs.
Unlike every method above, this one needs no session — and that is the point. A joiner is by construction not the namespace owner: it holds an invitation, a namespace id and its own keys, nothing else. Requiring a cloud account here would make the password-free join path cloud-only.
interface CloudNamespaceRouting { namespaceId: string; nodes: CloudNamespaceNode[]; // usable first, then a stable order servable: boolean; // can any listed node take a join now? writable: boolean; // can any listed node take a delegated write now?}
interface CloudNamespaceNode { peerId: string; account: string | null; // intersect against the invitation's `admitters` relayUrl: string | null; admitUrl: string | null; // ready-made, so you never rebuild the path status: string; // 'assigned' | 'active' fresh: boolean; canAdmit: boolean; // a URL + an account + a fresh heartbeat authorshipReady: boolean; // does it hold CAN_AUTHOR_ON_BEHALF here? canExecute: boolean; // the above + the grant}Admission and writability are answered together because a keyholder resolves a namespace to a node once and then admits, reads and posts intents against that same node. The per-context routing read cannot serve the first of those: it needs a context id a joiner does not have yet.
canAdmit deliberately does not require authorshipReady. Relaying a join
the joiner already signed is not authoring on anyone’s behalf — merod checks
only that the node is on the invitation’s signed admitters list — so a node
with no grant is a perfectly good admitter.
canExecute is the namespace-level answer, and like authorshipReady above
it is reported, never decided by the cloud. A context in a restricted subgroup is
governed by that subgroup, so a node can hold the grant on the namespace and not
on the group owning one context; if a write is refused despite canExecute, fall
back to the per-context routing read.
An empty nodes array is a state, not an error: a namespace with no fleet
assignment has no cloud node, and the joiner must use one of the invitation’s
other admitters — another admin’s node, or a self-hosted peer.
Proving an account for this read
Section titled “Proving an account for this read”The read takes no session, but it is not staying anonymous: a 32-byte namespace id is not guessable and not secret either, and anonymous discovery maps which nodes serve a namespace, their URLs and their liveness to anyone who learns one.
It cannot be gated on a cloud login, for the reason above. So the caller proves
possession of the credential it already holds — construct the client with
routingCredential and this method fetches a sealed, namespace-bound challenge,
signs it with the device key, and sends
X-Calimero-Credential / X-Calimero-Nonce / X-Calimero-Signature:
const cloud = new CloudClient({ routingCredential: { credential, deviceSecret }, // from signDeviceCert()});const routing = await cloud.getNamespaceRouting(namespaceId);The device signs, not the root — the opposite of
linkAccount, and deliberately. That one exists
for the device-loss case so only the root will do; this runs on every routing
read from a browser that discards the root after certifying its device.
The certificate alone would not do: it travels in the clear inside every device-link op, so anyone who has seen one can replay it. Only the challenge signature binds the presenter to the device.
What this establishes: the caller holds a device key certified by some account root, and which account that is. Not that the account was invited, nor that it is a member — the cloud cannot check either, because membership is governance state on the nodes, and anyone can mint a root offline. It buys attribution and ends anonymous bulk discovery; authorization stays at the node, on the signed op.
Omit routingCredential and the read stays anonymous, which the cloud still
answers today. A supplied proof is verified either way, so a client that builds
one wrong finds out immediately rather than when the requirement turns on.
Low-level pieces, for a caller whose key is somewhere this client cannot reach:
getRoutingChallenge(namespaceId), signRoutingChallenge(nonce, deviceSecret)
and routingProofHeaders(challenge, credential).
findAdmitter(namespaceId, admitters?): Promise<CloudNamespaceNode | null>
Section titled “findAdmitter(namespaceId, admitters?): Promise<CloudNamespaceNode | null>”The node to send a signed join to, chosen from those the invitation names.
const node = await cloud.findAdmitter(namespaceId, invitation.admitters);if (!node) { // no cloud node this invitation names can take the join — use another // admitter from the invitation, or a self-hosted peer}await fetch(node.admitUrl, { method: 'POST', body: signedJoin });Pass the invitation’s admitters. The cloud lists every node assigned to the
namespace, while admitters is a snapshot signed when the invitation was minted
— a node added afterwards is live, healthy, listed, and answers 403. Omit the
argument only for an invitation that names none, since an empty list on the wire
authorises any node to admit.
getMyMachines(): Promise<CloudMachine[]>
Section titled “getMyMachines(): Promise<CloudMachine[]>”Every attested machine serving something this account owns — the same rows as
getNamespaceRelays, keyed by machine rather than by namespace, so a UI can
answer “what is running for me?” in one call.
interface CloudMachine { peerId: string; relayUrl: string | null; executorAccount: string | null; canExecute: boolean; // a URL + an account + the grant somewhere namespaces: Array<{ namespaceId: string; status: string; // 'assigned' | 'active' authorshipReady: boolean; fresh: boolean; // heartbeat within the fleet freshness TTL confirmedAt?: string | null; lastSeenAt?: string | null; }>;}Only the caller’s own namespaces appear on each machine: a plan’s
usersPerMachine is greater than one, so a fleet node routinely serves several
accounts at once.
The view is deliberately narrow — no zone, machine type, instance name, public IP or KMS ids. Those are operator data, and a cloud user is not an operator.
fresh: false is worth surfacing rather than filtering: a replica that stopped
polling otherwise keeps looking healthy, and “my writes stopped working” has no
visible cause.
Setting a namespace up
Section titled “Setting a namespace up”These three are the owner’s side of the flow, and they need a node — see the end-to-end guide.
// Proof from a node that is a DIRECT ADMIN of the namespace; merod refuses// to issue one otherwise. Forward its response object verbatim.const proof = await sdk.admin.issueNamespaceOwnershipProof(namespaceId, { … });await cloud.claimNamespace(namespaceId, proof); // the cloud learns it existsawait cloud.enableNamespaceHa(namespaceId); // the fleet is asked to host itawait cloud.disableNamespaceHa(namespaceId); // …and to stopclaimNamespace is idempotent for the same account; a namespace already claimed
by someone else is a 409, and a replayed proof nonce a 403.
disableNamespaceHa is not merely a billing flip: each assigned node notices the
namespace is no longer assigned to it and self-leaves, which makes core evict
it and purge its local data and keys.
getMyNode(): Promise<object | null>
Section titled “getMyNode(): Promise<object | null>”Deprecated — use getMyMachines(). This reads the cloud’s retired
NodeAssignment ledger, which nothing has written since the v1 retirement, so
it answers null for every namespace-native account. That reads as “you have no
machine” when the truth is “that is no longer how a machine is assigned”.
enableHA(options): void / disableHA(options): void
Section titled “enableHA(options): void / disableHA(options): void”Open the cloud’s HA flow in a new browser tab. See the high-availability guide.
interface EnableHAOptions { groupId: string; contextId: string; redirectUrl?: string;}
cloud.enableHA({ groupId, contextId, redirectUrl: window.location.href });cloud.disableHA({ groupId, contextId });What this client never sees
Section titled “What this client never sees”Discovery goes through the cloud; the write goes straight from your client to the relay. The cloud is handed an address and a public account id, and never a warrant, a device secret, or an intent. A cloud that proxied intents would read the method and arguments of every write its users make — the plaintext is the whole point of that hop — and would put itself on the critical path of each one.