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://manager.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 Cancelif (error === 'unexpected_callback') return; // a link this tab never began
// 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 handoff adds a random link_state to the callback URL and remembers it in
this browser’s localStorage for ten minutes, so the callback may return in a
different tab (including one opened with noopener).
A grant is returned only when the callback carries that state for the same account,
so a forwarded redirect URL is refused instead of spent.
Pass linkAccountWithGrant only a grant that readAccountLinkCallback returned.
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? teeRole: string | null; // 'RelayTee' | 'ReadOnlyTee' | null (sidecar too old to report one) canExecute: boolean; // can an intent be presented here now? lastSeenAt?: string | null; confirmedAt?: string | null;}canExecute is the field to act on. It is the cloud’s own can_execute — a
URL, an executor account, a fresh heartbeat, and a node that relays by role:
a RelayTee (admitted under a TEE admission policy set with mode: 'relay')
relays without needing CAN_AUTHOR_ON_BEHALF, and a ReadOnlyTee is a replica
that never relays, whatever its grant. Only a node reporting no role (a
sidecar older than the field) is decided by authorshipReady. Against a cloud
too old to send can_execute, the client derives it the way it always was:
authorshipReady plus a URL and an executor account.
authorshipReady and teeRole are reported, not decided by the cloud: both
are governance facts observable only by a node holding that group’s key, so the
cloud relays what the relay last told it.
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.
Several 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 node must relay. That last one is canExecute, never
authorshipReady on its own: a ReadOnlyTee holding the grant still refuses.
const relay = await cloud.findExecutingRelay(namespaceId);if (!relay) { // enable HA, wait for an assignment, admit TEEs as relays (mode: 'relay'), // or — for a node too old to report a role — 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? teeRole: string | null; // 'RelayTee' | 'ReadOnlyTee' | null (no role reported) canExecute: boolean; // the above + a RelayTee (or, with no role, 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, folded from what the node
reported: a RelayTee relays by role, a ReadOnlyTee never does, and only a node
reporting no role is decided by the grant. A context in a restricted subgroup is
governed by that subgroup, so a node can relay for the namespace and not for 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 + relaying somewhere namespaces: Array<{ namespaceId: string; status: string; // 'assigned' | 'active' authorshipReady: boolean; teeRole: string | null; // 'RelayTee' | 'ReadOnlyTee' | null 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.
Letting the first fleet node reach you
Section titled “Letting the first fleet node reach you”The first fleet node placed on a namespace has nobody but your node to ask for
admission. Without an address it can only broadcast its attestation and hope
your node — often a laptop behind a relay — is in the gossip mesh to hear it.
Pass your node’s addresses with enableNamespaceHa and the fleet node dials you
directly instead:
import { admitterAddrsFromNetworkStatus } from '@calimero-network/mero-js';
const status = await sdk.admin.getNetworkStatus();await cloud.enableNamespaceHa(namespaceId, { admitterAddrs: admitterAddrsFromNetworkStatus(status),});admitterAddrsFromNetworkStatus takes the node’s external addresses and makes
each end in /p2p/<its peer id>, the shape a fleet node dials. They are a dial
hint, never a grant: your node still admits by its own TEE admission policy.
Omit admitterAddrs to leave what the cloud already holds; send them again when
your relay address changes and they replace the stored ones.
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.
enableHaAsAccount(options): Promise<unknown>
Section titled “enableHaAsAccount(options): Promise<unknown>”Enable HA for a namespace an account founded (through a relay, with
foundNamespace), with no cloud session. A nodeless account holds no node
that could issue an ownership proof, and no login either: it proves instead that
it is the founder. A namespace id is derived from the founding account and the
salt foundNamespace returned, so only that account reproduces it.
import { CloudClient, AccountNotLinkedError, AccountLinkedToSeveralUsersError,} from '@calimero-network/mero-js';
const cloud = new CloudClient({ cloudBaseUrl }); // no session neededtry { await cloud.enableHaAsAccount({ namespaceId, // what foundNamespace returned salt, // ditto, hex (32 bytes) accountId, // the founding account, hex credential, // hex AccountProof<DeviceCert> deviceSecret, // or `signer` // ttlMs: 60_000 (default; at most 300_000) });} catch (err) { if (err instanceof AccountNotLinkedError) { // link this account to a cloud user in the wallet, then retry } else if (err instanceof AccountLinkedToSeveralUsersError) { // unlink it from all but one cloud user } else throw err;}It signs a claim with signAccountHaClaim and posts it, unauthenticated, to
POST /api/cloud/accounts/{accountId}/namespaces/{namespaceId}/enable-ha with
the body { ownership_proof }. The 200 body is the session route’s enable-ha
response.
The cloud bills the cloud user the account is linked to through the wallet,
so the account must be linked to exactly one. The two 409s for that are typed,
because the fix is something a person does, not a retry:
| Status | Body | Thrown |
|---|---|---|
409 |
{"error":"account_not_linked"} |
AccountNotLinkedError |
409 |
{"error":"account_linked_to_several_users"} |
AccountLinkedToSeveralUsersError |
403 |
Ownership proof failed: … |
HTTPError |
| other | quota, invites, … as the session route answers | HTTPError |
Both typed errors extend AccountHaRefusedError, which extends HTTPError and
carries the cloud’s code verbatim.
signAccountHaClaim(input) and signAccountOwnershipClaim(input)
Section titled “signAccountHaClaim(input) and signAccountOwnershipClaim(input)”The two founder’s claims. Both produce
{ kind: 'account', credential, signed_payload, signature }: signed_payload is
base64 UTF-8 JSON, and signature is base64 ed25519 by the certified device key
over "calimero.mdma.account-ownership-claim.v1\0" ‖ payload.
{ "v": 1, "audience": "mdma:enable-ha-namespace-as-account", // ACCOUNT_HA_AUDIENCE "group_id": "<namespace, hex>", "account_id": "<account, hex>", "salt": "<hex>", "nonce": "<16 random bytes, hex>", "issued_at_ms": 0, "expires_at_ms": 0 // default issued + 60 s, at most + 300 s}signAccountHaClaim |
signAccountOwnershipClaim |
|
|---|---|---|
| Route | /api/cloud/accounts/{account}/namespaces/{ns}/enable-ha (anonymous) |
/api/cloud/me/namespaces/{ns}/enable-ha (session) |
audience |
mdma:enable-ha-namespace-as-account |
mdma:enable-ha-namespace |
subject |
absent | the cloud login’s email (required) |
| Sent by | enableHaAsAccount |
enableFoundedNamespaceHa(namespaceId, proof) |
The audiences differ so a claim minted for one route can never be replayed on the other.
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, except the relay’s signing key and the release a warrant pins, which the relay itself reports before each write; 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.