Ship a frontend
Task: give your app a web page that the cluster itself serves from the signed release, that signs people in with their passkey, and that calls the app as them, without ever getting more of their account than the app needs.
The cluster serves a release’s frontend from the .mpk it verified, on an
origin of the package’s own, so the page is exactly what the publisher signed.
A passkey ceremony from that origin opens a session bound to the package:
it reaches only instances of that package. What it may not do (approve an
upgrade, create an organization, invite someone), the page asks the node’s
consent page for.
The protocol is in Packages & frontends,
Package-bound sessions and
The consent page.
Build the page for the cluster
Section titled “Build the page for the cluster”The node serves each file with a fixed content security policy:
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; img-src 'self' data: blob:;font-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none';form-action 'self'; frame-ancestors 'self'; connect-src 'self' <the cluster's API origins> <the attestation mirror>So the build must:
- ship everything it loads. No script, style, font or image from another origin; the page reaches only the cluster’s API (and the attestation mirror mero-js reads release measurements and Intel’s collateral from).
- run no inline script. Inline styles are allowed, inline
<script>andon*=handlers are not. - use relative URLs. The page is served under
/{version}/, so an absolute/assets/app.jsmisses; build with a relative base (Vitebase: './') and no<base>element. - use only these file types:
html,js,mjs,css,json,webmanifest,txt,svg,png,jpg,jpeg,gif,webp,ico,woff,woff2,ttf,wasm,webm,xml. - stay under 32 MiB for all files together (
MAX_FRONTEND_BYTES).
calimero_bundle::check_frontend_html is the lint a bundling tool can run on
the build output for the first three rules (FRONTEND_REMOTE_RESOURCE,
FRONTEND_INLINE_SCRIPT, FRONTEND_ESCAPING_URL); the node does not run it
and relies on the policy above.
Put it in the release
Section titled “Put it in the release”The manifest names the files under frontend, each with its SHA-256 and size,
so the publisher’s signature covers them; the archive carries them under
frontend/:
{ "package": "com.example.notes", "appVersion": "1.0.0", "wasm": { "path": "app.wasm", "hash": "…", "size": 48213 }, "frontend": { "entry": "frontend/index.html", "files": [ { "path": "frontend/index.html", "hash": "…", "size": 412 }, { "path": "frontend/assets/app.js", "hash": "…", "size": 90321 } ] }}meroctl app pack --frontend <dir> writes it: every file under the
build directory goes into the archive as frontend/<path> with its SHA-256 and
size, and --frontend-entry (default index.html) names the page served at
/. pack refuses, before signing, what the node would refuse at install
(the rules below), so a bad build fails on your machine, not on the cluster.
npm run build # writes dist/ with base './'meroctl app build apps/notes --out app.wasmmeroctl app pack --wasm app.wasm --frontend dist \ --package com.example.notes --app-version 1.0.0 --key my-signing-key.json \ --out dist-mpk/com.example.notes-1.0.0.mpkmeroctl app install --path dist-mpk/com.example.notes-1.0.0.mpk # a development clusterAt install the node refuses a frontend whose paths are not canonical paths
under frontend/ (FRONTEND_UNSAFE_PATH), listed twice
(FRONTEND_DUPLICATE_PATH), of a type it does not serve
(FRONTEND_UNSUPPORTED_TYPE), over 32 MiB (FRONTEND_TOO_LARGE), whose
entry is missing or not HTML (FRONTEND_NO_ENTRY, FRONTEND_ENTRY_NOT_HTML),
or whose bytes do not match the manifest (FRONTEND_SIZE_MISMATCH,
FRONTEND_HASH_MISMATCH, FRONTEND_MISSING_FILE).
A release that changes only the frontend is a release like any other: an instance moves to it with an upgrade (Ship a new version).
Develop it locally
Section titled “Develop it locally”meroctl local dev [DIR] runs the loop on a development cluster, for an app laid
out as meroctl app new makes one (the crate in DIR/logic, the frontend in
DIR/app). It starts the cluster if it is not running, builds the crate,
uploads it, writes DIR/app/.env.development.local, runs the frontend on
http://localhost:5173, and rebuilds and uploads on every save:
meroctl app new my-app # from calimero-network/apps' kv-storemeroctl local dev my-appWhere it is served
Section titled “Where it is served”Only on a cluster started with --apps-domain D (merod local uses
localhost). Each package gets a host of its own, {label}.D, where the label
is 52 base32 characters of the package’s hashed name
(calimero_node::app_host::label).
| Path | Serves |
|---|---|
/ |
redirects to /{newest version}/ |
/{version}/{path} |
that release’s file; any other path under the version is the entry, for client-side routes |
/app/{app}/{path} |
redirects to /{version}/{path} of the release that instance runs, so a link you give a customer keeps opening the frontend that matches their instance |
/d/{deployment}/{path} |
redirects to /{version}/{path}?deployment={id}, the release that deployment offers |
/.well-known/calimero.json |
{ package, versions, deployments, api }: the releases, the deployments offering the package and the cluster’s API URLs |
Every redirect keeps the query, so /?invite=… reaches the page with its token.
Only GET and HEAD are served; an unknown label is a 404, never a node
route.
The same site is also on the package’s cluster host (for Calimero Cloud’s
resolver), on each deployment’s own host, and on any custom domain you
register (below).
Sign people in from the page
Section titled “Sign people in from the page”import { CalimeroClient, browserPasskey, frontendManifest, pageDeployment, PasskeyUnknownHere,} from '@calimero-network/mero-js';
const { package: pkg, api } = await frontendManifest(); // this page's /.well-known/calimero.jsonconst client = new CalimeroClient({ baseUrl: api[0], nodes: api.slice(1), policy: { release: { minReleaseVersion: '2.3.109' } }, // { allowMock: true } on merod local});
const saved = sessionStorage.getItem('calimero');if (saved) client.useSession(JSON.parse(saved));else { try { sessionStorage.setItem('calimero', JSON.stringify(await client.signIn(browserPasskey()))); } catch (e) { if (e instanceof PasskeyUnknownHere) showSignUp(); // client.signUp(browserPasskey(), name) else throw e; }}
// Opened through /d/{deployment}/: create the person's instance from that deployment.const deployment = pageDeployment();const { appId } = deployment ? await client.apps.create({ name: 'My notes', deployment, package: pkg }) : { appId: appFromPath() };await client.apps.call({ app: appId, method: 'add', args: { text: 'hello' } });- The well-known file is fetched in the clear and only says where the API is; trust comes from the attestation the client checks before it sends anything.
- In a browser,
signInandsignUpsend the page’s origin when it is not one of the client’s nodes, and the node answers with the relying-party id to use. The session is bound to the package of that origin;me()answers itsappandorigin. - A passkey made on another cluster, or removed, is
401 PASSKEY_UNKNOWN_HERE(PasskeyUnknownHere). - Keep the session in
sessionStorageanduseSessionit after a reload, so the person is not prompted again.
What a frontend session may do
Section titled “What a frontend session may do”It acts as the person, but only within its package. Sessions and authority explains why (the session gate and the account’s authority are separate checks) and walks through a chat app creating a workspace and inviting someone.
| May | May not |
|---|---|
| call and read instances of its package, and list them | reach any other app of the account |
| create instances of its package (from a deployment, or by package) | manage passkeys, API keys, recovery or sessions |
| upload blobs and files to its apps, follow their events and presence | approve releases, outbound hosts or upgrades; install or uninstall packages |
step up with a passkey for one of its apps’ confirm methods, from its own origin |
create, rename or delete organizations, invite, redeem invites, change members |
| manage the teams below its package’s app team (create, rename, move within, members, delete when all their apps are its package’s), for apps such as a chat’s channels | change the app team itself, an app’s access outside those teams, or delete an app |
Everything in the right column is refused (403) whatever the person’s own
rights. The person still has those rights; the page sends them to the node’s
consent page to use one.
Ask for consent
Section titled “Ask for consent”For an upgrade, a release approval, outbound hosts, or an organization change, the page asks the node for a consent request and sends the person to the node’s own page, where they see what they approve (from the node’s state and the signed manifests, not from your page) and confirm with their passkey.
import { createOrgConsent, upgradeConsent } from '@calimero-network/mero-js';
const returnTo = `${location.origin}${location.pathname}`;await client.openConsentPage(upgradeConsent(appId, '1.1.0', returnTo), { confirm: async ({ code }) => code === undefined || window.confirm(`The next page shows ${code}. Continue?`),});
// Back on returnTo?request_id=…&result=…: read the decision from the node, never the URL.const decision = await client.resumeConsent();if (decision?.status === 'approved' && decision.kind === 'createOrg') useOrg(decision.outcome);openConsentPage posts POST /consent/requests { kind, scope?, subject, returnTo } (any session of the account, a bound one included) and navigates
to the answer’s url. The kinds are release, upgrade, outbound,
createOrg, createInvite, acceptInvite, leaveOrg, removeMember,
setMemberRole, setMemberTeams, removeMemberTeams, revokeInvite,
renameOrg, deleteOrg, setCapabilities and setAllApps; mero-js has a
builder for each (releaseConsent, upgradeConsent, createOrgConsent, …).
- For a package-bound session,
returnTomust be on one of the package’s own origins (its{label}.Dand other hosts, a registered or served origin) or one its newest signed manifest names (links.frontend,frontendAliases); for an unbound session, one of the cluster’s--origins. It ishttps(orhttponlocalhost), without credentials or a fragment, at most 2 KiB: else400 RETURN_TO_NOT_ALLOWED(allowed_return_toincrates/node/src/consent_page.rs). - Show the person the answer’s
codebefore you send them; the node’s page shows the same code. - An account has one undecided request per node: a new one replaces the
older, whose result then answers
404(CONSENT_REPLACEDin mero-js). A request lives 10 minutes; a page whose subject changed meanwhile is409 CONSENT_STALE. resumeConsentthrowsCONSENT_NOT_STARTED,CONSENT_REQUEST_MISMATCHorCONSENT_NOT_DECIDEDfor a return it cannot vouch for: do not act on those.
The full list of subjects and what the page shows is in The consent page; mero-js’s user approvals guide covers the client side.
Serve it on your own domain
Section titled “Serve it on your own domain”Two kinds of origin can sign people in to a package besides its {label}.D
host, both registered by the package’s manager (its publisher organization’s
org-wide MANAGE_APPLICATION holders, else the account that installed it
first) with their passkey:
- A registered origin (
https://notes.example.com): your own hosting serves the page, and passkey ceremonies there run under that domain’s relying-party id. Sessions opened there are bound to the package and name the origin; removing the origin ends them. - A served origin (
served: true): the cluster also serves the package’s frontend there from the signed bundle, the same site as{label}.D. It must behttpswith no port, not under the apps domain or the cluster’s rp-id; browsers reach it once Calimero Cloud holds its certificate and route.
meroctl app origins add com.example.notes https://notes.example.com --servedmeroctl app origins list com.example.notesmeroctl app origins remove com.example.notes https://notes.example.comimport { browserPasskey, passkeyConfirm } from '@calimero-network/mero-js';
// From a session on the cluster's own origin: a package-bound session cannot manage origins.await client.packages.originsAdd( { package: 'com.example.notes', origin: 'https://notes.example.com', served: true }, { confirm: passkeyConfirm(browserPasskey()) },);| Limit or refusal | When |
|---|---|
| 8 origins per package; 4 served per package, 64 per cluster | |
403 ORIGIN_NOT_REGISTERED |
a sign-in or sign-up from an origin the package has not registered |
400 NOT_SERVING |
--served on a cluster without --apps-domain |
409 ORIGIN_TAKEN |
the origin is held with the other served; remove it first |
409 CLUSTER_NOT_UPGRADED |
a served origin while a node runs an older release |
http origins |
only localhost names, on a --dev node |