Skip to content

The consent page

Some decisions belong to the account holder alone: which mero-tee releases may hold their data, which code may read it, which addresses it may be sent to (Deployment governance). Each is approved with a fresh, user-verified passkey assertion that the node logs as consent. Apps still need to prompt for them: a new version is out, a feature needs an outbound host. The consent page is how an app does that without ever being the page that asks for the passkey.

A passkey assertion is blind. The authenticator shows the person a site name and a fingerprint prompt, never what the challenge commits to. Whatever page starts the ceremony decides what the person believes they approve.

An app’s frontend is the publisher’s code, served from the bundle and possibly closed source. If it could run an approval ceremony, it could show “update to 1.2.1” and have the passkey sign an upgrade to something else, or show “allow analytics” and approve every prefix the manifest declares. So a session opened on an app’s origin is bound to its package and refused every approval route (Accounts, passkeys & sessions). That stays true. Instead, the app asks the node to ask the person.

  1. Request. The app calls POST /consent/requests inside the sealed channel with any session of the account, a package-bound one included:

    { "kind": "upgrade", "subject": { "app": "<appId>", "toVersion": "2.0.0" },
    "returnTo": "https://<label>.<apps-domain>/2.0.0/settings" }
    { "kind": "release", "scope": "account" | { "org": "<orgId>" },
    "subject": { "version": "1.4.0", "withdraw": false }, "returnTo": "…" }
    { "kind": "outbound", "scope": "account" | { "org": "<orgId>" },
    "subject": { "package": "<package>?", "prefixes": ["https://api.example/"]? },
    "returnTo": "…" }

    A release request approves by default; "withdraw": true asks to withdraw the release’s approval instead (a console’s Withdraw). Unknown fields are refused. The node checks the caller could make this approval at all, with the route’s own rules, before any page exists: the governance holder; for an organization’s release, APPROVE_RELEASES there (403); for an upgrade, whoever the package names (SystemState::may_upgrade: a package that declares upgrades: publisher is its publisher organization’s, 409 while no organization has bound its key, and an owner-upgraded app is never moved to a publisher release, 403), and a release deadline that stops the upgrade (409 RELEASE_DEADLINE); organization membership; a package-bound session only about its own package and apps. /approve asks again, before the passkey, so a capability revoked while the page is open is refused there. It resolves the subject from its own state and the releases’ verified .mpk files, and freezes what the page will show. It answers { "requestId", "url", "expiresAt", "code" }; code is the request’s confirmation code, which the app or console shows before it sends the person to url. The request lives in that node’s memory, bound to the account, for 10 minutes (CONSENT_REQUEST_TTL), and can be decided once.

  2. Page. The app sends the person to url, GET /consent/{id} on the node’s own public URL. The page is static HTML compiled into merod: no app code, an inline script and stylesheet allowed by their hashes only, default-src 'none', connect-src 'self', frame-ancestors 'none', X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cache-Control: no-store. It reads GET /consent/{id}/model and shows it.

  3. Passkey. Approve posts POST /consent/{id}/approve. The node runs the very route a signed-in client would run (/apps/{app}/upgrade, /auth/releases or /orgs/{org}/releases, /auth/outbound or /orgs/{org}/outbound) with the arguments frozen in the request, so the 412 CONFIRM_REQUIRED challenge commits to exactly what the page showed. The page asks the authenticator for a user-verified assertion and posts it back as confirm. The node checks that the passkey is one of the request’s account, checks its authority again, writes the approval with the assertion as consent, and marks the request approved. Decline posts /consent/{id}/decline; nothing is approved.

  4. Back. The page navigates to returnTo?request_id=<id>&result=approved|declined. If that redirect is lost, the app asks GET /consent/{id}/result (sealed, the same account; any node answers, asking the node that holds the request): { "requestId", "kind", "status": "pending|approved|declined", "decidedAt" }.

returnTo is never arbitrary: a node that redirects anywhere would lend its trusted page to phishing. For a package-bound session it must be the package’s served frontend origin ({label}.{apps-domain}) or an origin the package’s newest signed manifest names (links.frontend, frontendAliases); for an unbound session, one of the cluster’s configured --origins. It must be https (http only for localhost), without credentials or a fragment, and at most 2 KiB. Otherwise 400 RETURN_TO_NOT_ALLOWED.

The page is written for the person who approves, not for an engineer. From top to bottom:

  1. Trust strip. “Secure approval page from your Calimero cluster”, with the page’s own host and “Check that your browser’s address bar shows host”.
  2. Who is asking. The app and its publisher in plain words (“Team notes by Acme Inc.”: the signed manifest’s metadata.name and author), and the account approving (“Signed in as …”).
  3. The headline, the model’s title: “Update Team notes to version 1.5.0”, “Allow a new secure server version (2.3.131) to hold Acme’s data”, “Stop server version 2.3.128 from receiving Acme’s data”, “Allow Team notes to send data to api.openai.com”.
  4. What this means: the model’s points, two to five plain sentences (what approving allows, that nothing changes until they approve, what a withdrawal stops and that nothing is deleted), then attention, the things not to miss. For an upgrade that is the “Needs your attention” list, each entry present only when it applies: sends data to new addresses (hosts only), adds a new sign-in address, lets anyone use methods without signing in (methods added or newly public), declares its stored data at another state version although an update runs no migration, removes features (methods removed), another publisher key; with none, “No new permissions.” A withdrawal of the last approved release is flagged too.
  5. How you know this is legit: checks a person can make. The page is served by their node at host, not by the app; the passkey will ask to sign in to rpId, and any other site means cancel; for an upgrade or outbound request, published and signed by the publisher (the organization its key is bound to on the cluster, when there is one), with whether the key is the one the running version (or every release of the package) was signed with; for a release, built by Calimero’s public release process and its signature verified by the node, or, on mock attestation, “Test cluster: no hardware checks”. Last, the confirmation code, large.
  6. Approve with passkey (Withdraw approval for a withdrawal) and Decline, right after these, with the status line below.
  7. Technical details, collapsed: everything below, long values cut to one line with the full value as their title and selectable, and every empty list left out.
Kind Technical details, from node-side data only
Release The version; whether it is already approved and what is approved now; which of the cluster’s nodes attest it; this node’s profile if it runs it; the release’s signed measurements per profile and their digest (on mock attestation it says there are none); links to the signed release and its Sigstore-signed published-mrtds.json, with how to verify them independently; the deadline, if any
Release withdrawal The version; whether it is approved now (if not, that withdrawing changes nothing) and what else is; whether it is the only approved release, which the node refuses to withdraw; which of the cluster’s nodes attest it; this node’s profile if it runs it. No measurements: a withdrawal lets nothing in, so it never waits on fetching them. The page says what changes: the nodes that hold the data send nodes on that release no more Raft entries or snapshots, and no app is placed or moved onto them; and what does not: nothing is deleted or moved, a replica already there keeps what it holds until the operator moves it, and one that leads an app keeps leading it until leadership moves
Upgrade The app, current and target version, the publisher’s key and author, and a capability sheet computed from the two signed manifests and the calimero:abi section each component embeds: outbound prefixes added and removed; frontend origin and aliases, with every new sign-in origin listed (a page there could act in the app as the person); interfaces used and exported; methods added, removed or changed (kind, passkey confirmation, public and its rate, accepted from the system, other apps or only the app’s actions, parameters and result); events; timers; the state version each side declares (an update is code only: no migration runs); SDK and build information; the .mpk, WASM and manifest hashes
Outbound The package and its publisher key, the release whose declarations are listed, the prefixes being approved, which of them are new, which are already approved

Each kind’s model also keeps the engineer’s wording: headline, before (what holds until the person approves), after (what changes once they do) and notes, shown under Technical details.

Field Kinds What it is
title all The headline in plain words
points all Two to five plain sentences: what approving means
attention all [{ "flag", "text", "items" }], what not to miss; items are hosts, origins or method names. Flags: sendsData, newSignIn, newPublic, stateVersion, removesFeatures, newPublisherKey (upgrade), lastApproved (withdrawal). Empty: no new permissions
app upgrade, outbound { "name", "package" }: the manifest’s display name and the package id
publisher upgrade, outbound { "name", "signer", "keyChanged", "org" }: the manifest’s author (null when it names none), the verified signing key (did:key), whether it differs from the running version’s, and the organization the key is bound to on this cluster ({ "id", "name" }, from BindPublisher; null while unbound). The page names that organization as the publisher when there is one
release.deadline release { "after", "afterSecs", "passed" } when the operator set an end-of-new-work deadline for the version (SetReleaseDeadline, GET /releases), else null; points then say it in plain words (“After Nov 15, 2026 (UTC), new apps, installs and updates for Acme’s apps pause until this version (or a newer one) is approved”)
requestedBy all { "package", "name", "publisher", "returnOrigin" }: the asking package (a package-bound session’s, else null), its newest release’s display name and author
rpId all The relying party ID the passkey prompt will name
code all The confirmation code

The author and display name are what the publisher wrote in the signed manifest; the node verifies the signature and the key, not that the name is true. The page says which key signed.

A short code that ties the page to the request the person’s own app or console made, so a link to another request (on the same node) shows a different code. The create answer and the model both carry it as code; a client can also compute it:

  1. Take SHA-256 of the request id’s UTF-8 bytes.
  2. Read the first 4 bytes as a big-endian unsigned 32-bit integer and keep its top 30 bits.
  3. Write them as six 5-bit groups, most significant first, each as a character of Crockford’s base32 alphabet 0123456789ABCDEFGHJKMNPQRSTVWXYZ.
  4. Put a - after the third: XXX-XXX.

For example, 1.AAAAAAAAAAAAAAAAAAAAAA is R02-9P2 and 2.q7Xb3kR0fLzM8nW1yVt5cA is 54E-429 (consent_page::confirmation_code). The code is not a secret and proves nothing on its own: the passkey’s challenge is what binds the approval.

The model is what the app can neither write nor alter: it is frozen when the request is made, from the node’s state and the releases’ verified manifests. App-controlled strings in it (a package’s display name, URLs) are rendered as text only.

Kind The passkey signs confirm_challenge(subject, method, args, nonce) with args =
Release { version, policyDigest }: policyDigest is SHA-256 of the release’s Sigstore-verified measurements as the node reads them when it writes the approval, so a release re-published under the same tag with other measurements is another approval. A node that cannot fetch them refuses (503); on mock attestation, where no node checks measurements, the digest is over none ({ "profiles": {}, "version" })
Upgrade { bundle, migrate, manifest }: the target WASM’s hash and the target release’s signed-manifest hash (VerifiedBundle::manifest_hash: SHA-256 of the canonical manifest, every artifact digest included; null only for a raw development bundle, which has no manifest)
Outbound { package, prefixes, replace }, with the prefixes listed on the page, never "manifest"

Before asking for the passkey and again before writing, the page checks that what it showed still holds: the app still runs the bundle it ran, the target version is still the same .mpk, the package’s newest release (whose declarations it listed) is still the same. Otherwise 409 CONSENT_STALE, and the app asks again so the person sees the new facts.

The routes bind the same arguments whoever calls them: POST /apps/{app}/upgrade, POST /auth/releases and /orgs/{org}/releases compute the manifest hash and the measurements digest themselves, and the challenge they hand out commits to them. A target or release that changes between the 412 and the assertion no longer matches the challenge, and the approval is refused. Withdrawing a release lets nothing in and binds { version } alone, through the page ("withdraw": true) as through the route; the node fetches no measurements for it, so revoking a bad release never waits on the network. The last approved release cannot be withdrawn: /approve answers 400 REJECTED (“the last approved release cannot be withdrawn”) after the passkey, and the request stays pending. A withdrawal the person confirms is reported like any consent: result=approved (they approved the withdrawal).

Limit Value
A request’s life (CONSENT_REQUEST_TTL) 10 minutes
A decided request’s result stays readable (CONSENT_RESULT_TTL) 10 minutes after the decision
Requests one node holds (MAX_CONSENT_REQUESTS) 1,000, then 429
Undecided requests per account per node (MAX_OPEN_REQUESTS_PER_ACCOUNT) 8, then 429
returnTo (MAX_RETURN_TO_BYTES) 2 KiB
Prefixes in one outbound request (MAX_REQUESTED_PREFIXES) 64
Request and page bodies (MAX_CONSENT_BODY_BYTES) 64 KiB
  • The page travels under TLS, not the sealed channel. A browser cannot open a sealed session without a client library, so the page, its model and the approve and decline posts are plain HTTPS to the node. Whoever terminates TLS in front of the node can read what is being approved and could serve another page in its place. It cannot change what is approved (the arguments were fixed inside the sealed channel when the app asked, and the passkey signs a challenge over them), and cannot approve without the person’s passkey. Terminate TLS inside the TD to close this.
  • The relying party. The passkey must be usable on the node’s origin: the cluster’s --rp-id must be a registrable suffix of the node’s host. The node accepts consent-page assertions from its own nodes’ origins and the cluster’s --origins, never from an app’s origin.
  • Requests live in one node’s memory. A restart forgets them; the app asks again. The page is on the node that issued the request (its url).