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:
getAccountRelaysandgetNamespaceRoutingtake a challenge signed by the device key (routingCredential).CloudSignInuses 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.
Signing in
Section titled “Signing in”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), usertry await cloud.refreshSession()try await cloud.signOut() // forgets the session even if the server call failsCopies 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.
Namespaces, relays and machines
Section titled “Namespaces, relays and machines”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 nillet machines = try await cloud.getMyMachines() // [CloudMachine], your namespaces onlyCloudRelay.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.
Connecting in one call
Section titled “Connecting in one call”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 inconnection.relayInfo // what the cloud reported about the relayconnection.cloud // the signed-in CloudClienttry 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).
Hosting a namespace (HA)
Section titled “Hosting a namespace (HA)”// 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.
Linking an account to a cloud login
Section titled “Linking an account to a cloud login”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, prooflet linked = try await cloud.getMyAccounts() // accounts + the plan's limitFor 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.