Skip to content

Calimero Cloud

CloudClient talks to the Calimero Cloud manager (MDMA, https://manager.cloud.calimero.network). It is the directory for the hosted tier: which relay serves a namespace and which account it writes as, which machines run for you, and which accounts are linked to your cloud login. It never sees an intent: writes go from the app straight to the relay as warrants.

Two halves, proven differently:

  • Device-proven reads: getAccountRelays and getNamespaceRouting take a challenge signed by the device key (routingCredential). CloudSignIn uses them; see Authentication.
  • Signed-in routes (/api/cloud/me/..., HA, linking) take an MDMA session token as a Bearer. This page covers them.

Everything here is a port of mero-js src/cloud/*, route for route.

let cloud = CloudClient(
sessionStore: CloudClient.keychainSessionStore(), // survives relaunches
onSession: { session in print("session now", session?.sessionToken ?? "none") })
// With Google: the app runs the Google flow and hands over the ID token.
try await cloud.signInWithGoogle(idToken)
// Or as the account itself, proven by its root. No Google involved.
let root = try loadAccountRootSigner(from: KeychainRootSeedVault())!
try await cloud.signInWithAccount(.signer(root.signer))
cloud.isSignedIn // a token is held (the server judges whether it is live)
cloud.getSession() // CloudLoginSession: sessionToken, expiresAt (epoch s), user
try await cloud.refreshSession()
try await cloud.signOut() // forgets the session even if the server call fails

Copies of a CloudClient share one session. MDMA rotates the token while you use it and returns the replacement in x-mdma-session-refresh; the client adopts it (from a failed response too), saves it to sessionStore, and calls onSession. A token you already hold can be passed as sessionToken:.

A call to a signed-in route with no session throws AccountError.notSignedIn before anything is sent.

signInWithAccount signs the cloud’s challenge with signAccountLogin; for a root that signs elsewhere, use getAccountLoginChallenge() and submitAccountLogin(_:). A 403 means the signature did not verify, or the account is not linked to a cloud login yet.

let namespaces = try await cloud.getMyNamespaces() // [CloudNamespace]: haStatus, contexts, …
let relays = try await cloud.getNamespaceRelays(ns) // [CloudRelay]
let relay = try await cloud.findExecutingRelay(ns) // the first that can run an intent, or nil
let machines = try await cloud.getMyMachines() // [CloudMachine], your namespaces only

CloudRelay.canExecute is the cloud’s verdict: a RelayTee relays by role, a ReadOnlyTee never does, and only a node reporting no role is decided by the CAN_AUTHOR_ON_BEHALF grant (authorshipReady). Filter on it before signing a warrant, or the relay refuses it after a nonce is spent. admission, admissionHint and join say why a relay is stuck, in the admitters’ own words.

mero-js’s deprecated getMyNode is not ported; getMyMachines replaces it.

connectCloud signs in, picks the namespace you own and the relay that can write there, and returns the same CloudConnection that CloudSignIn.connect returns, with the cloud fields set:

let connection = try await connectCloud(ConnectCloudOptions(
authorAccount: session.account, authorProof: session.credential, keys: deviceKeys,
sessionToken: storedToken)) // or googleIdToken:, or a sessionStore that holds one
connection.namespaceId // the namespace writes land in
connection.relayInfo // what the cloud reported about the relay
connection.cloud // the signed-in CloudClient
try await connection.execute(contextId: ctx, method: "set", argsJson: ["key": "k"])

connectCloudWithAccount(ConnectCloudWithAccountOptions(root: .signer(root.signer), …)) signs in with the account root instead, and writes as the account the root derives unless you name another authorAccount. The root signs the sign-in only; the device keys still sign every warrant.

When there is nothing to write through, both throw AccountError.cloudNotReady with a message that says what to do: claim a namespace, pass namespaceId (you own several), enable HA, wait for an assignment, set the TEE admission policy to mode: 'relay' (every node is a replica), or grant CAN_AUTHOR_ON_BEHALF to the named relay account.

Reads and live events through the relay need its node key: pass relayNodeKey: if you know it; otherwise the key the cloud reports is used, and failing that the key the relay attests (CloudConfig.relayKeyVerifier, a DcapRelayKeyVerifier by default: see TEE trust). Writes work either way. Pass seal: (a quote verifier) to seal every call to the relay to its attested TD (Sealed transport).

// A node-founded namespace: claim it with the node's ownership proof, then host it.
let proof = try await mero.admin.issueNamespaceOwnershipProof(ns, request: request)
try await cloud.claimNamespace(ns, proof: proof)
let addrs = admitterAddrsFromNetworkStatus(try await mero.admin.getNetworkStatus())
try await cloud.enableNamespaceHa(ns, admitterAddrs: addrs)
try await cloud.disableNamespaceHa(ns) // fleet nodes self-leave and purge
// A namespace an account founded through a relay, from a signed-in session:
let claim = try await signAccountOwnershipClaim(AccountOwnershipClaimInput(
namespaceId: ns, accountId: account, salt: salt, credential: credential,
subject: cloud.getSession()!.user.email, signer: deviceSigner))
try await cloud.enableFoundedNamespaceHa(ns, ownershipProof: claim)

With no cloud session, enableHaAsAccount signs the anonymous claim (signAccountHaClaim, audience mdma:enable-ha-namespace-as-account) and the cloud bills the user the account is linked to. Its refusals arrive as AccountError.haRefused; error.haRefusal reads them back as HaRefusal.accountNotLinked, .accountLinkedToSeveralUsers, .haRequestPending (409), .unknownRelay or .relayNotDialable (422).

The cloud’s own /enable-ha and /disable-ha pages, for a person to finish by hand, open in the system auth sheet from MeroKitUI:

import MeroKitUI
try await cloud.enableHA(
HAPageOptions(groupId: ns, contextId: ctx), callback: .scheme("myapp", path: "ha"),
authenticator: SystemWebAuthenticator())

enableHAURL(_:) / disableHAURL(_:) give the URL if you present it yourself.

A link needs a cloud session and a root signature. With both in one app:

try await cloud.linkAccount(.signer(root.signer)) // challenge, root signature, proof
let linked = try await cloud.getMyAccounts() // accounts + the plan's limit

For a root held elsewhere: getAccountLinkChallenge(), sign with signAccountLink, then submitAccountLink(_:).

An app that holds the root but no cloud session sends the person to the cloud’s consent page instead. They consent there, where they are signed in, and the cloud sends back a single-use grant in the URL fragment, spendable only with the account’s root:

import MeroKitUI
// Show the person the account id first: the consent screen is the only check
// that they meant this account.
let grant = try await CloudLink.beginCloudLink(
accountId: root.accountId, callback: .scheme("myapp", path: "link"),
authenticator: SystemWebAuthenticator())
try await CloudLink.completeCloudLink(grant, root: .signer(root.signer))

Without MeroKitUI, CloudLink.cloudLinkUrl(accountId:returnTo:) builds the consent URL and remembers the link; CloudLink.readCloudLinkCallback(_:) reads the answer. The portal handoff (CloudClient.accountLinkHandoff / readAccountLinkCallback) is the same flow against the portal’s / route, answering with AccountLinkCallback instead of throwing.

A grant is accepted only when it answers a link this app began, for the account that link named, within 10 minutes, once. Begun links live in a CloudLinkStateStore (.shared in memory; .keychain() when the callback can arrive after a relaunch). A decline throws AccountError.linkDeclined; a callback the app did not ask for throws AccountError.stateMismatch.