Skip to content

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();
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.

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 email

Doing 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.

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 limit

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

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.

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.

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 exists
await cloud.enableNamespaceHa(namespaceId); // the fleet is asked to host it
await cloud.disableNamespaceHa(namespaceId); // …and to stop

claimNamespace 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.

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 });

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.