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.
Why the app’s own page cannot ask
Section titled “Why the app’s own page cannot ask”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.
The flow
Section titled “The flow”-
Request. The app calls
POST /consent/requestsinside 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": trueasks 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_RELEASESthere (403); for an upgrade, whoever the package names (SystemState::may_upgrade: a package that declaresupgrades: publisheris its publisher organization’s,409while 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./approveasks 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.mpkfiles, and freezes what the page will show. It answers{ "requestId", "url", "expiresAt", "code" };codeis the request’s confirmation code, which the app or console shows before it sends the person tourl. The request lives in that node’s memory, bound to the account, for 10 minutes (CONSENT_REQUEST_TTL), and can be decided once. -
Page. The app sends the person to
url,GET /consent/{id}on the node’s own public URL. The page is static HTML compiled intomerod: 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 readsGET /consent/{id}/modeland shows it. -
Passkey. Approve posts
POST /consent/{id}/approve. The node runs the very route a signed-in client would run (/apps/{app}/upgrade,/auth/releasesor/orgs/{org}/releases,/auth/outboundor/orgs/{org}/outbound) with the arguments frozen in the request, so the412 CONFIRM_REQUIREDchallenge commits to exactly what the page showed. The page asks the authenticator for a user-verified assertion and posts it back asconfirm. 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. -
Back. The page navigates to
returnTo?request_id=<id>&result=approved|declined. If that redirect is lost, the app asksGET /consent/{id}/result(sealed, the same account; any node answers, asking the node that holds the request):{ "requestId", "kind", "status": "pending|approved|declined", "decidedAt" }.
Where the app may be sent back
Section titled “Where the app may be sent back”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.
What the page shows
Section titled “What the page shows”The page is written for the person who approves, not for an engineer. From top to bottom:
- 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”.
- Who is asking. The app and its publisher in plain words (“Team notes
by Acme Inc.”: the signed manifest’s
metadata.nameandauthor), and the account approving (“Signed in as …”). - 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”. - 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), thenattention, 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. - 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. - Approve with passkey (Withdraw approval for a withdrawal) and Decline, right after these, with the status line below.
- 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.
The model’s plain-language fields
Section titled “The model’s plain-language fields”| 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.
The confirmation code
Section titled “The confirmation code”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:
- Take
SHA-256of the request id’s UTF-8 bytes. - Read the first 4 bytes as a big-endian unsigned 32-bit integer and keep its top 30 bits.
- Write them as six 5-bit groups, most significant first, each as a
character of Crockford’s base32 alphabet
0123456789ABCDEFGHJKMNPQRSTVWXYZ. - 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.
What the consent binds
Section titled “What the consent binds”| 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).
Limits
Section titled “Limits”| 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 |
What it does not protect
Section titled “What it does not protect”- 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-idmust 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).