Call an external service
Task: call a service outside the cluster (a captcha check, a payment API, a model, any HTTPS API) and write what it answers into state.
A call may ask its node for one HTTPS request. The node makes it only when every gate below holds and answers with its reason otherwise. Outbound requests are off by default: an app that needs them runs on a cluster whose operator turned them on, for its hosts or for any host an app declares.
The gates
Section titled “The gates”| Gate | Who sets it |
|---|---|
[outbound] enabled = true, and the request’s host is in hosts (or hosts holds "*": any host an installed app declares) |
The cluster’s operator |
The request’s URL is under a prefix in the app’s signed release manifest (outbound): the same host, and the prefix’s path or one below it at a /, with any query string |
The app’s publisher |
| A prefix covering the URL is approved for the app’s publisher | The organization’s owner (or the account, for an app outside an organization); the operator where the cluster was deployed with --outbound-approvals operator |
The run is a call that writes: not a view, an init, a migration, or a call by a read-only member |
The node |
One request per run; GET, POST, PUT, PATCH or DELETE; https on port 443 to a DNS name that resolves only to public addresses; no fragment; no redirect followed; no proxy |
The node |
| Request body ≤ 64 KiB, URL ≤ 2 KiB, ≤ 8 headers of ≤ 1 KiB, response body ≤ 1 MiB (a longer one is refused, not cut), 10 s for the whole request | The node (calimero_primitives::outbound) |
Host, Cookie, Connection, Content-Length, Transfer-Encoding, Proxy-* and X-Forwarded-* headers are refused |
The node |
A refusal is a reason the method can handle (Err(String)). A refusal for a
missing approval starts with a stable word an app can match on:
NotApproved:nobody approved the prefix the URL is under. Nothing was ever approved for this publisher, or the owner saw the prefix and left it out or revoked it. Tell a member to ask the owner.AwaitingApproval:a later release of the app declares the prefix and the owner has not been asked about it yet. The prefixes approved before keep working. Show the owner what to approve.
OutboundRefusal::of reads the word for you:
use calimero_sdk::outbound::OutboundRefusal;
match env::http_request(&request) { Ok(response) => { /* decide, and store the decision */ } Err(reason) => match OutboundRefusal::of(&reason) { Some(OutboundRefusal::NotApproved) => { /* ask the organization's owner */ } Some(OutboundRefusal::AwaitingApproval) => { /* the owner approves the release's new prefix */ } _ => { /* anything else is a bug in the app or its deployment */ } },}A run with no outbound client at all (outbound is off, the app is not a signed release declaring a prefix, or the run is not a writing call) and a second request in one run trap the run.
Secrets
Section titled “Secrets”The app never holds a secret. A request names one as {{secret:NAME}} in a
header value or the body; the node fills it in from its operator’s
secrets, for this app only (the package and signer of the installed release,
never anything the app says), and only into a request to a host the operator
bound that secret to. Any copy of it in the response is replaced with
[redacted] before the app sees the response. So a method cannot write the
secret into state, where it would replicate to every member.
-
Declare the URL prefixes in
Cargo.toml. They are signed into the release’s manifest and shown to whoever approves them, so declare the narrowest that works:[package.metadata.calimero]outbound = ["https://challenges.cloudflare.com/turnstile/v0/siteverify","https://api.stripe.com/v1/", # every path below /v1/] -
Make the request from a method that writes, and store the decision:
use calimero_sdk::env;use calimero_sdk::outbound::{OutboundMethod, OutboundRequest};pub fn submit(&mut self, answer: String, captcha_token: String) -> app::Result<()> {let response = env::http_request(&OutboundRequest {method: OutboundMethod::Post,url: "https://challenges.cloudflare.com/turnstile/v0/siteverify".to_owned(),headers: vec![("Content-Type".to_owned(),"application/x-www-form-urlencoded".to_owned(),)],body: format!("secret={{{{secret:TURNSTILE_SECRET}}}}&response={captcha_token}").into_bytes(),}).map_err(|reason| app::err!("captcha check failed: {reason}"))?;if response.status != 200 || !String::from_utf8_lossy(&response.body).contains(r#""success":true"#) {app::bail!("captcha not solved");}self.answers.push(answer.into())?;Ok(())} -
Have the owner approve the prefixes (see approving), and the operator allow the host and supply the secret (see the operator’s side).
Approving the prefixes
Section titled “Approving the prefixes”The organization’s data is what a call can send, so its owner agrees to the hosts too: the operator allowing a host and the publisher declaring it are not enough. The owner approves with a passkey step-up over the exact change:
POST /orgs/{org}/outbound{ "package": "com.acme.forms", "prefixes": "manifest" }"manifest" (the default) approves every prefix the package’s latest release
declares; a list approves those. An app that declares no outbound needs and
asks for nothing.
- Per publisher. The approval is keyed by the package’s pinned signer, so a release signed by someone else starts with nothing approved.
- Per organization. It holds for every app of the package in the
organization. An app outside any organization uses its owner’s own approval
(
POST /auth/outbound, same body). - Releases only add. A prefix a later release declares that the owner was
never shown answers
AwaitingApprovaluntil approved; the rest keep working. - Revoke by setting the list.
"replace": truesets the publisher’s list exactly; an empty list with it revokes everything. It holds from the next call. - Operator-governed clusters. A cluster deployed with
--outbound-approvals operatortakes approvals from its operator instead, for every app:POST /ops/outboundwith the same body, over the Ops API. The owner’s route then answers that the operator holds them.
With meroctl: namespace outbound <org> --package <pkg> [--prefix <p>]… [--replace],
and account outbound --package <pkg> … for apps outside an organization.
The operator’s side
Section titled “The operator’s side”The [outbound] table of merod’s configuration, or of the file
--settings names (which replaces it). Give every node of a cluster the same
table: the call runs on the app’s leader, whichever node that is.
[outbound]enabled = truehosts = ["challenges.cloudflare.com", "api.stripe.com"] # or ["*"]
# Secrets from environment variables, read once at start: a missing one# fails the start.[outbound.env_secrets.TURNSTILE_SECRET]env = "MERO_TURNSTILE_SECRET"packages = ["com.calimero.mero-forms"]signers = ["did:key:z6Mk…"] # the package's signerhosts = ["challenges.cloudflare.com"]hosts = ["*"] lets every installed app reach the hosts its own signed
manifest declares and its owner approved, so a new app needs no config
change. Every other gate still holds, and a secret still goes only to the hosts
bound to it.
Instead of env_secrets, a node can ask a secrets service beside it
(exclusive with env_secrets):
[outbound.sidecar]url = "http://127.0.0.1:7070/v1/secret"token_env = "MERO_SECRETS_TOKEN"The node POSTs {"package", "signer", "name"} with the token as a bearer and
expects 200 {"value", "hosts"} or 404. The URL must be http on a loopback
address. A managed deployment answers there with the secrets a deployer set
for their app, so the secret lives on the nodes that serve the app and nowhere
else.
The node logs every request it makes or refuses, with the app and the URL,
never the secret, and counts the requests it sends
(calimero_app_outbound_requests_total).
A secret is matched on both the package and the release’s signer: anyone can name a package, only its publisher can sign it.
Test it
Section titled “Test it”env::http_request needs a node: under TestHost it returns
Err("outbound requests need a node"), so unit-test the logic around the
answer by passing the answer in.
End to end, build merod with --features calimero-node/outbound-testing.
Its [outbound.testing] table, which a release build refuses, answers .test
hosts in-process and takes literal secrets:
[outbound]enabled = truehosts = ["echo.outbound.test"]
[outbound.testing]echo = true # .test hosts are answered in-process
[outbound.testing.secrets.ECHO_KEY]value = "s3cr3t-e2e"packages = ["com.calimero.outbound-probe"]signers = ["did:key:z6Mk…"]hosts = ["echo.outbound.test"]The echo answers 200 with the request the node sent, as JSON: method, URL,
path, query, headers, body and the number of secrets filled in. It answers only
after every gate has passed, and its answer is redacted like any other, so a
test sees exactly what left the node. apps/outbound-probe and its
workflows/outbound.yml cover every gate this way.
Where it stops
Section titled “Where it stops”- A request never runs in a view. Read the answer back from state.
- Redaction removes the secret as sent. A service that answers with it encoded (base64, URL-escaped) defeats it, so bind a secret only to hosts that do not echo it.
- The response is not authenticated beyond TLS: a service you call can answer anything, so call only services you trust for the decision you store.