Skip to content

Writing without a node, end to end

Every piece of delegated execution works. Getting a first write through it touches four systems, and the order matters, so this is the whole path in one place.

This does not let a person sign up with Google and use Calimero with nothing installed. Two of the steps below can only be done from a node that is a direct admin of the namespace:

  • claiming the namespace in the cloud requires a merod-signed ownership proof, and merod refuses to issue one unless the node is a direct admin of that group;
  • granting the relay authorship is a governance op an admin signs.

So the shape is: one person (or one machine — a desktop app, a CI runner, a server) owns the namespace and does the setup once. After that, any number of keyholders with no node at all — browser tabs, phones, agents, that person’s own other devices — write into it through the relay.

That is the feature. It is multi-device and thin-client access to a namespace somebody owns, not nodeless namespace creation.

Who Holds Does
Owner a node, admin of the namespace steps 1–3, 5–6
Cloud (MDMA) the account and the fleet directory steps 2–4
Relay (TEE fleet node) the group key, the app, an account steps 4, 7
Keyholder one ed25519 key, nothing else step 7
  1. Owner creates the namespace and a context from their node.

    const owner = createMeroJs({ baseUrl: 'http://localhost:2528' });
    await owner.authenticate({ username, password });
    const { namespaceId } = await owner.admin.createNamespace({ applicationId, name: 'my-app' });
    const { contextId } = await owner.admin.createContext({ applicationId, groupId: namespaceId });
  2. Owner claims it in the cloud, proving ownership with a node-signed proof. The cloud has no other way to know the namespace exists — there is deliberately no “create namespace” button in the cloud UI.

    const cloud = owner.cloud; // or new CloudClient({...})
    await cloud.signInWithGoogle(googleIdToken);
    const proof = await owner.admin.issueNamespaceOwnershipProof(namespaceId, {
    audience: 'mdma:enable-ha-namespace',
    subject: cloud.getSession().user.email, // must equal the cloud account
    nonce: crypto.randomUUID(), // single-use, cloud-side
    expiresAtMs: Date.now() + 60_000,
    });
    await cloud.claimNamespace(namespaceId, proof);

    The proof is minted, presented and spent within a minute; the cloud rejects a replayed nonce for any user. Forward merod’s response object as-is — the cloud accepts core’s signerPublicKey/signedPayload casing as well as snake_case, so there is no re-keying step.

  3. Owner enables HA on the namespace — the step that asks the fleet to host it. It carries no proof of its own: the claim already established ownership.

    await cloud.enableNamespaceHa(namespaceId);
  4. The fleet assigns a machine, and it reports itself. Nothing for anyone to do — the sidecar on each attested node polls once a second, is assigned the namespace, joins it by TDX attestation, and reports the two facts the cloud cannot derive: the account it writes as, and the URL to reach it.

    const relays = await cloud.getNamespaceRelays(namespaceId);
    // [{ peerId, relayUrl, executorAccount, status: 'active', authorshipReady: false }]
    // or, across every namespace this account owns:
    const machines = await cloud.getMyMachines();
    // [{ peerId, relayUrl, executorAccount, canExecute: false, namespaces: [...] }]

    authorshipReady should already be true on a namespace created by a recent merod — see step 5. If it is false, step 5 is the fix.

  5. Grant the relay authorship — usually already done.

    A namespace is created carrying CAN_AUTHOR_ON_BEHALF in its default-capability mask, and core copies that mask into a non-admin member’s capability row at admission. So a relay assigned to a namespace created by a merod with that default lands able to relay, and there is nothing to do here.

    You still land on this step in three cases:

    • the namespace predates that default;
    • the relay was admitted before the mask was set on it — the mask is copied at admission, not read live;
    • the node is the owner’s own, which never receives the default at all: core seeds it for non-admin roles only, and the capability is not implied by admin.
    Terminal window
    meroctl --node node1 group members set-capabilities <NAMESPACE_ID> <EXECUTOR_ACCOUNT> \
    --can-author-on-behalf
    // or from the SDK, on the owner's node
    await owner.admin.grantAuthorship(namespaceId, executorAccount);

    CAN_AUTHOR_ON_BEHALF is implied by nothing — not membership, not admin, and it is not propagated by the subgroup cascade. The namespace default is the only thing that grants it without an explicit op, and a Restricted subgroup does not inherit it.

    grantAuthorship reads the relay’s current mask and adds the one bit, because both the CLI flag form and setMemberCapabilities replace the mask rather than merging into it — so doing this by hand means re-passing every flag the relay already holds, and forgetting one revokes it silently. It is a no-op when the relay already has the grant, so it is safe to run on every startup.

    Within a second the relay notices and re-reports, and getNamespaceRelays flips to authorshipReady: true.

  6. Owner adds the keyholder as a member, by account.

    The keyholder generates its own identity offline first — no node, no network:

    import {
    generateAccountRoot,
    mintDeviceId,
    signDeviceCert,
    } from '@calimero-network/mero-js';
    // The account root: kept offline, and the only thing that can certify a
    // device into this account. `phrase` is 24 words — show it once and have the
    // holder write it down, because nothing else can restore this account.
    // A keyholder that already has a root (a desktop app, a hardware key) skips
    // this and uses `accountForRoot(rootSecret)` instead. See
    // [Account roots](/reference/account-roots/).
    const { secret: rootSecret, accountId: account, phrase } = await generateAccountRoot();
    const device = await mintDeviceId(account, crypto.getRandomValues(new Uint8Array(16)));
    const authorProof = await signDeviceCert({
    rootSecret,
    device,
    signPublicKey, // the device key that will sign warrants
    kemPublicKey, // X25519, where a group key would be delivered
    deviceEpoch: 1,
    });

    authorProof is the hex AccountProof<DeviceCert> that step 7 passes.

    The owner then adds that account, not a key:

    await owner.admin.addGroupMembers(namespaceId, {
    members: [{ identity: account, role: 'Member' }],
    });

    By account because the keyholder’s device joins nothing and will never appear in any group’s binding rows — which is exactly the case a device certificate covers. A key-based membership check would refuse every write below.

  7. The keyholder writes. No node, no group key, no credential on the relay.

    import { connectCloud } from '@calimero-network/mero-js';
    const connection = await connectCloud({
    googleIdToken, // or a stored sessionToken
    authorAccount: account, // from step 6
    authorProof, // the device certificate from step 6
    deviceSecret, // the DEVICE signing secret — never transmitted
    });
    await connection.execute(contextId, 'set', { key: 'greeting', value: 'hello' });

Where it breaks, and what each failure means

Section titled “Where it breaks, and what each failure means”

Every one of these is a different action, which is why the SDK reports them apart rather than as “could not connect”.

Symptom Cause Fix
connectCloud: “owns no namespaces” step 2 not done claim the namespace from the owner’s node
connectCloud: “owns N namespaces — pass namespaceId” ambiguous name it
connectCloud: “no relays: enable HA” step 3 not done enable HA in the cloud
connectCloud: “no relay has been assigned yet” step 4 in flight wait; if it persists, no attested node has capacity or the MRTD is not allowlisted
connectCloud: “waiting for the authorship grant” (names an account) step 5 not done grant CAN_AUTHOR_ON_BEHALF to that account
IntentRefusedError, “author’s account is not a member” step 6 not done add the keyholder’s account to the group
IntentRefusedError, retryable: true, “nonce” warrant replayed re-present under a fresh warrant; if it repeats, the nonce source is restarting
relay_url: null in the cloud the deployment publishes no relay URL operator sets MDMA_TEE_RELAY_URL_TEMPLATE
401/403 from the relay host, not an IntentRefusedError the node is not serving as a relay the image needs delegated execution enabled at both merod and its ingress

What the cloud stores, and how to read it back

Section titled “What the cloud stores, and how to read it back”

Useful when debugging: these are the only rows that tie a person to a namespace and a machine.

Row Key Written by
UserNamespace (user_email, namespace_id), namespace_id globally unique the claim in step 2, after the ownership proof verifies. This is the authorization boundary for every namespace read
HaRequest group_id unique enable-HA in step 3
FleetAssignment (peer_id, group_id) the relay’s own poll and confirm in step 4 — including authorship_ready
Node name, plus peer_id the dispatcher at create time; executor_account and relay_url come from the relay’s poll

Read them back with:

  • GET /api/cloud/me/namespaces — what this account owns, with HA state, its contexts, and replica counts.
  • GET /api/cloud/me/namespaces/{ns}/relays — the relays for one namespace.
  • GET /api/cloud/me/machines — every machine serving this account, grouped by machine, with can_execute folding the three-way precondition.

Why the intent does not go through the cloud

Section titled “Why the intent does not go through the cloud”

The cloud is a directory. It is handed an address and a public account id, and the write goes straight from the client to the relay.

A cloud that proxied intents would read the method and arguments of every write its users make — the plaintext is the whole point of the relay hop, since the relay has to run the method — and would sit on the critical path of every write in the fleet. What the client sends the cloud is “which relay serves me?”; what it sends the relay is the intent and a warrant it signed itself.