Skip to content

Packages & frontends

An app instance runs one bundle: a WASM module named by hex(SHA-256(wasm)). In production a bundle always comes out of a package release: a signed .mpk (calimero-bundle) holding a calimero:app component built with calimero-app-build, its manifest signed by its publisher with mero-sign. See Packaging & signing for building one.

The .mpk manifest names the package, its version, its services (each a WASM module; a single-service package has one unnamed service), a frontend, display metadata, the minRuntimeVersion it needs, and the outbound URL prefixes it declares. The manifest is signed by the publisher’s key, and every artifact in the archive is listed with its digest.

A release travels from its publisher to a registry, and from there to every node that runs it, never from node to node: the node that installs it verifies it before the system log records its hash, and each replica fetches and verifies the same bytes again before running them. The installer is an account (or an app’s creator, since POST /apps installs a missing release first) or the operator. On a development cluster (--dev) an uploaded .mpk (POST /packages) skips the registry and is spread to a majority of nodes instead; a production node refuses uploads (403 UPLOADS_DISABLED).

There are two ways in:

  • POST /packages/install { package, version } fetches package@version from the operator’s registry (--registry-url, calimero-app-downloader’s HttpRegistry).
  • POST /packages takes an uploaded .mpk, on a development cluster only.

Before the system log records anything (InstallPackage), the node that took the request verifies the release (Node::verify_release):

  1. The publisher’s signature over the manifest and every artifact’s digest (calimero_bundle::VerifiedBundle), and that it ships WASM at all.
  2. minRuntimeVersion is not newer than this node’s release (CALIMERO_RELEASE_VERSION).
  3. The package’s pinned signer: the first release of a package pins its publisher key (did:key); a release signed by any other key is refused.
  4. Each service’s WASM compiles.
  5. A registry install got the package and version it asked for.

apply checks the signer pin again, refuses a version already installed with other contents (the same release again changes nothing but who installed it: the new installer is added to the release’s installers), and charges the .mpk’s size to the installer’s bundle quota (the operator’s installs count against no account). The log keeps, per release, the .mpk’s hash and size, where it came from (registry or upload), each service’s bundle hash, the metadata, and the declared outbound prefixes.

  • A registry release is never passed between nodes. Each node that needs to run it fetches it from the registry itself (Node::release_wasm), refuses bytes whose hash differs from the one the log recorded, and verifies the release again before using its WASM. That is the one-source rule: the registry is the only place a registry release comes from.
  • An uploaded release (development clusters only) has no registry. The .mpk is stored on a majority of nodes before the log records it; a node that lacks it fetches it from a peer, checks the recorded hash and verifies it again. Production takes no uploads: an upload’s only copies are on the nodes it reached, and a cluster that grows from one node and retires it would lose the release, while a registry release can always be fetched again.
  • Each service’s WASM is also kept in the installing node’s blob store under its bundle hash; other nodes extract it from the .mpk on first use.
  • POST /apps { package, version?, service?, … } creates an instance from a release; without a version, the newest installed one. If the release is not installed yet, it is installed from the registry first. A package with several services needs service. With deployment, the instance runs the release that deployment offers for package, and records it.
  • POST /apps/{app}/upgrade { version } moves an instance to another release of its package (Upgrades).
  • DELETE /packages/{p}[/{v}] removes a release, or all of them, and is refused while any app still runs one of its bundles. A bundle another release also ships stays, recorded under that release. Only whoever installed the package’s first release (an account, or the operator) may uninstall, and never a release a deployment offers.

Many organizations may share one cluster, and what one of them installed is not another’s to list or run. A release is visible (SystemState::sees_release) to:

  • whoever installed it: the account recorded as installedBy, or one that installed the same release again later (installers);
  • every account, for a release the operator installed (/ops/packages): the cluster’s own catalog, which the operator upgrades apps to;
  • an account that shares an organization with an account that installed it;
  • an account that reaches an app running one of its services (so a member of an app always sees the release it runs);
  • a session opened on the package’s own frontend, for every release of that package (the frontend is public by name, and it creates instances of it);
  • the operator, for the releases it installed; it sees no account’s releases.

For anyone else the release is not installed. GET /packages and GET /packages/{p} list only the releases the caller sees (a package with none answers 404), and GET /ops/packages only the operator’s. Creating or upgrading an app from it resolves as for a missing release: without a version, the newest release the caller sees; with one, the release is installed from the registry first, which makes a registry release the caller’s too, and answers what a missing release answers when the registry lacks it (an upload). Naming its WASM by hash answers as an unknown bundle, and approving outbound hosts for a package with no release the caller sees answers 404. An organization’s upgrade run moves an app only to a release its upgrader sees; the operator’s, to one it installed or the app’s owner sees.

Records from before installers existed read with none; every release has always recorded installedBy, so each is visible by the rules above from the first start of this version.

Rolling upgrade (no version gate). A node on the previous release applies an install of an already installed release as a no-op. While both releases run, a repeated install is recorded only on the upgraded nodes (and a node upgraded later keeps the record its old snapshot had). Repeat the install once every node runs this version, and it is recorded everywhere. Nodes on the previous release still list every package to everyone.

POST /bundles takes raw WASM (at most 16 MiB) with no publisher signature. It is refused unless the node allows unsigned bundles (--dev, or allow_unsigned_bundles). The node checks the WASM compiles, stores it on a majority of nodes, then records PutBundle { hash, size }. Raw bundles have no package, so they cannot be served as a frontend, cannot declare outbound prefixes, and have no pinned signer.

The node reads the ABI a component embeds (its calimero:abi section) for what it acts on: which methods are views and confirm methods, whom each method admits (accounts, API keys, other apps, the system), its timers, and the tables search indexes. A method the ABI does not name is refused (default-deny).

With --apps-domain D, each package’s frontend is served at its own origin, {label}.D, where the label is 52 lowercase base32 characters of SHA-256("calimero.app-host.v1\0" ‖ package) (app_host::label). merod local uses localhost.

  • The files are the release’s own .mpk frontend, fetched like its WASM and verified against the publisher’s signature before a byte is served.
  • GET / redirects to the newest release’s entry; /{version}/{path} serves a file, falling back to the entry for client-side routes. /.well-known/calimero.json names the package, its releases, the deployments offering it (id, name, app, version) and the cluster’s API URLs.
  • GET /app/{app}/{path} redirects to /{version}/{path} of the release that instance runs (the one it was created at or last upgraded to, never simply the newest shipping its WASM: releases may share one and differ only in their frontend), so a link handed to a customer opens the frontend matching their instance after a newer release is installed. GET /d/{deployment}/{path} redirects to /{version}/{path}?deployment={id}, the release that deployment offers for this package (the newest, if it offers several), so the page knows which deployment to create instances from. Neither names anything but a version; an instance of another package, or a deployment that does not offer this one, is a 404.
  • Only GET and HEAD of files. An unknown label under D is a 404, never a node route. Responses carry a fixed content security policy with the cluster’s API origins and the attestation mirror (https://cloud.calimero.network, where the page’s client gets the release’s signed measurements and Intel’s collateral; both are signed, so the mirror is trusted with nothing) added to connect-src.

A package may be deployed on several clusters while each customer’s account and organization live on one, and a front that routes by hostname alone (Calimero Cloud’s resolver) cannot tell them apart by the package’s host. So on each cluster the package also has the host {cluster_label}.D, where the label is 52 lowercase base32 characters (unpadded, alphabet a–z, 2–7) of SHA-256("calimero.app-host.cluster.v1\0" ‖ cluster ‖ "\0" ‖ package), cluster being the node’s --cluster (app_host::cluster_label). For example, com.calimero.kv on cluster eu-shared-1 is at zqcnu7vlgoxqnu6ar6awxsbcclqru6ry4ygt3bou7tqpimvx25va.D.

On its cluster the host is the package’s site exactly as {label}.D is (every path above, /d/{deployment}/ included), and an origin of the package: CORS admits it, and a passkey ceremony from it opens a session bound to the package. A cluster label made for another cluster answers 404.

The front does not route these yet; cluster hosts are what it routes.

Every deployment has a host of its own too, {dlabel}.D, where the dlabel is 52 lowercase base32 characters (unpadded, alphabet a–z, 2–7) of SHA-256("calimero.app-host.deployment.v1\0" ‖ id) and id is the deployment’s id (32 lowercase hex characters; app_host::deployment_label). For example, deployment 0123456789abcdef0123456789abcdef is served at lprfchrsznkwfwiu7gfovvcywyujxdza3rentyjkivnw6cxescca.D. Its own prefix keeps a dlabel apart from every package label.

The deployment’s host names the deployment, and with it the cluster that holds it.

  • It serves the frontend of the one package the deployment offers, exactly as /d/{deployment}/ does on the package’s host: GET / redirects to /{version}/?deployment={id} of the release it offers; files, client routes and /app/{app}/ as above; /d/{deployment}/ only for its own deployment.
  • /.well-known/calimero.json carries "deployment": id and lists that deployment first (null on the package’s host).
  • A deployment that offers nothing yet, or apps of several packages, has no frontend: its host answers 404.
  • It is an origin of its own, of the same package: CORS admits it, and a passkey ceremony from it opens a session bound to that package, like one from the package’s host.
  • POST /deployments answers the new deployment’s host beside its id, and GET /deployments and GET /deployments/{id} carry it (null without --apps-domain).

A package’s manager may have the cluster serve its frontend on a domain of their own (https://kv.example.com) by registering it as a served origin (POST /package-origins/{package} { origin, served: true }, with their passkey; Registered origins). A request whose Host is that name gets the package’s site exactly as {label}.D serves it (every path above, the same content security policy, /.well-known/calimero.json), before any node route. Unlike the package’s host, it is a registered origin: a passkey ceremony there runs under the domain’s own rp-id, never the cluster’s, and opens a session bound to the package that names the origin; removing it ends the site and those sessions together. TLS for the name ends inside the TD with a certificate Calimero Cloud orders for the node’s attested key; the end-to-end design is docs/specs/custom-domains.md.

Why its own origin. An app’s page is third-party code. Served from the cluster’s own origin, it could reach anything the signed-in account can. On its own origin it is a separate site to the browser, and a passkey ceremony from it opens a session bound to its package: that session reaches only instances of that package, creates only instances of it, and is refused credentials, API keys, approvals, upgrades, uninstalls and uploads (Accounts, passkeys & sessions). Its step-ups come only from its own origin.